diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index fcf42da628d..62ab6740600 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -19,12 +19,11 @@ other applicable versions (`graphql-batch`, etc) **GraphQL schema** -Include relevant types and fields (in Ruby is best, in GraphQL IDL is ok). -Are you using [interpreter](https://graphql-ruby.org/queries/interpreter.html)? Any custom instrumentation, etc? +Include relevant types and fields (in Ruby is best, in GraphQL IDL is ok). Any custom extensions, etc? ```ruby class Product < GraphQL::Schema::Object - field :id, ID, null: false, hash_key: :id + field :id, ID, hash_key: :id # … end diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..5ace4600a1f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/actions-lint.yaml b/.github/workflows/actions-lint.yaml new file mode 100644 index 00000000000..589b001d545 --- /dev/null +++ b/.github/workflows/actions-lint.yaml @@ -0,0 +1,42 @@ +name: Lint GitHub Actions + +on: + pull_request: + push: + branches: + - master + +permissions: {} + +jobs: + actionlint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Run actionlint + uses: docker://rhysd/actionlint:1.7.12 + with: + args: -color + + zizmor: + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Run zizmor + uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 + with: + advanced-security: false + annotations: true + min-severity: high + version: 1.28.0 diff --git a/.github/workflows/apidocs.yaml b/.github/workflows/apidocs.yaml deleted file mode 100644 index 692a1632a13..00000000000 --- a/.github/workflows/apidocs.yaml +++ /dev/null @@ -1,47 +0,0 @@ -name: Publish API docs -on: - # For some reason, `on: release: ...` didn't work with `nektos/act` - push: - # Sequence of patterns matched against refs/tags - tags: - - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 - -jobs: - build: - name: Publish API Docs - runs-on: ubuntu-latest - steps: - - name: Checkout release tag - uses: actions/checkout@v2 - with: - ref: ${{ env.GITHUB_REF }} - - name: Checkout GitHub pages branch - uses: actions/checkout@v2 - with: - path: gh-pages - ref: gh-pages - - uses: actions/setup-ruby@v1 - with: - ruby-version: '2.7' - - name: Bundle install - run: | - gem install bundler - bundle config path vendor/bundle - bundle install --jobs 4 --retry 3 - - name: Build API docs - run: | - bundle exec rake site:fetch_latest apidocs:gen_version - - name: Commit changes as last committer - run: | - git config --global user.name rmosolgo - git config --global user.email rdmosolgo@github.com - git status - bundle exec rake site:commit_changes - git status - - name: Deploy to GitHub pages via gh-pages branch - uses: s0/git-publish-subdir-action@master - env: - REPO: self - BRANCH: gh-pages - FOLDER: gh-pages - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e048c57c9e1..146f48af86f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,36 +1,43 @@ name: CI Suite on: - - push - pull_request jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 with: - ruby-version: 2.6 + ruby-version: 3.4 bundler-cache: true - run: bundle exec rake rubocop + - run: npx @herb-tools/linter@0.8.10 system_tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: ruby/setup-ruby@v1 + - uses: shogo82148/actions-setup-redis@3e38d435ea02619c76909c929ecc501806c7145e # v1.56.0 with: - ruby-version: 2.6 + redis-version: "7.x" + - run: redis-cli ping + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: 3.4 bundler-cache: true env: - BUNDLE_GEMFILE: ./spec/dummy/Gemfile - - run: bundle exec rails test:system + BUNDLE_GEMS__GRAPHQL__PRO: ${{ secrets.BUNDLE_GEMS__GRAPHQL__PRO }} + BUNDLE_GEMFILE: gemfiles/rails_master.gemfile + - run: bin/rails test:all working-directory: ./spec/dummy + env: + BUNDLE_GEMS__GRAPHQL__PRO: ${{ secrets.BUNDLE_GEMS__GRAPHQL__PRO }} + BUNDLE_GEMFILE: ../../gemfiles/rails_master.gemfile # Some coverage goals of these tests: # - Test once without Rails at all # - Test postgres, to make sure that the ActiveRecord # stuff works on that (as well as the default sqlite) # - Test mongoid -- and several versions, since they're quite different - # - Run the tests with Rails _and_ TESTING_LEGACY=1 to test legacy codepaths # - Run the JS unit tests once # - Test each major version of Rails we support # - Test the min/max minor Ruby version we support (and others?) @@ -40,54 +47,65 @@ jobs: matrix: include: - gemfile: Gemfile - ruby: 2.6 - - gemfile: gemfiles/rails_3.2.gemfile - ruby: 2.3 - bundler: "1" - - gemfile: gemfiles/rails_4.2.gemfile - ruby: 2.4 - bundler: "1" - # Rails 5.2 is tested with Postgresql below - - gemfile: gemfiles/rails_6.1.gemfile - ruby: 2.7 - - gemfile: gemfiles/rails_master.gemfile - ruby: 3.0 + ruby: head + - gemfile: Gemfile + ruby: 2.7 # lowest supported version + - gemfile: gemfiles/rails_8.0.gemfile + ruby: 3.3 + graphql_future: 1 + - gemfile: gemfiles/rails_8.1.gemfile + ruby: 4.0 + graphql_future: 1 + redis: 1 - gemfile: gemfiles/rails_master.gemfile - ruby: truffleruby-head + ruby: 3.4 + graphql_future: 1 + isolation_level_fiber: 1 + redis: 1 runs-on: ubuntu-latest steps: - - run: echo BUNDLE_GEMFILE=${{ matrix.gemfile }} > $GITHUB_ENV - - uses: actions/checkout@v2 - - uses: ruby/setup-ruby@v1 + - run: echo BUNDLE_GEMFILE=${{ matrix.gemfile }} > "$GITHUB_ENV" + - run: echo GRAPHQL_FUTURE=1 > "$GITHUB_ENV" + if: ${{ !!matrix.graphql_future }} + - run: echo ISOLATION_LEVEL_FIBER=1 > "$GITHUB_ENV" + if: ${{ !!matrix.isolation_level_fiber }} + - uses: shogo82148/actions-setup-redis@3e38d435ea02619c76909c929ecc501806c7145e # v1.56.0 + with: + redis-version: "7.x" + if: ${{ !!matrix.redis }} + - run: redis-cli ping + if: ${{ !!matrix.redis }} + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - bundler: ${{ matrix.bundler || 'default' }} + - run: bundle exec rake compile - run: bundle exec rake test - legacy_test: - runs-on: ubuntu-latest - steps: - - run: echo BUNDLE_GEMFILE='gemfiles/rails_6.1.gemfile' > $GITHUB_ENV - - uses: actions/checkout@v2 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: 2.7 - bundler-cache: true - - run: bundle exec rake test TESTING_LEGACY=1 javascript_test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - ruby-version: 2.7 - bundler-cache: true - - run: bundle exec rake js:all + node-version: latest + - run: npm ci + working-directory: ./javascript_client + - run: npm test + working-directory: ./javascript_client postgres_test: runs-on: ubuntu-latest + strategy: + matrix: + include: + - gemfile: gemfiles/rails_master.gemfile + ruby: 3.3 + isolation_level_fiber: 1 + - gemfile: gemfiles/rails_7.2_postgresql.gemfile + ruby: 3.3 services: postgres: - image: postgres:latest + image: postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -101,33 +119,36 @@ jobs: --health-timeout 5s --health-retries 5 steps: - - run: echo BUNDLE_GEMFILE='gemfiles/rails_5.2_postgresql.gemfile' > $GITHUB_ENV - - run: echo DATABASE='POSTGRESQL' > $GITHUB_ENV - - run: echo PGPASSWORD='postgres' > $GITHUB_ENV - - uses: actions/checkout@v2 - - uses: ruby/setup-ruby@v1 + - run: echo BUNDLE_GEMFILE='' > "$GITHUB_ENV" + - run: echo DATABASE='POSTGRESQL' > "$GITHUB_ENV" + - run: echo PGPASSWORD='postgres' > "$GITHUB_ENV" + - run: echo GRAPHQL_CPARSER=1 > "$GITHUB_ENV" + - run: echo ISOLATION_LEVEL_FIBER=1 > "$GITHUB_ENV" + if: ${{ !!matrix.isolation_level_fiber }} + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 with: - ruby-version: 2.7 + ruby-version: "3.3" bundler-cache: true - - run: bundle exec rake test + - run: bundle exec rake compile test mongodb_test: strategy: fail-fast: false matrix: gemfile: - - gemfiles/mongoid_6.gemfile - - gemfiles/mongoid_7.gemfile + - gemfiles/mongoid_9.gemfile + - gemfiles/mongoid_8.gemfile runs-on: ubuntu-latest services: mongodb: - image: mongo:3.4.23 + image: mongo:8.2.12@sha256:e0ce8c35124d4a9f9785532d1f268f39e9728ffa1cb38f46fa482436424c4bd3 ports: - 27017:27017 steps: - - run: echo BUNDLE_GEMFILE=${{ matrix.gemfile }} > $GITHUB_ENV - - uses: actions/checkout@v2 - - uses: ruby/setup-ruby@v1 + - run: echo BUNDLE_GEMFILE=${{ matrix.gemfile }} > "$GITHUB_ENV" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 with: - ruby-version: 2.7 + ruby-version: 3.4 bundler-cache: true - - run: bundle exec rake test + - run: bundle exec rake compile test diff --git a/.github/workflows/pronto.yaml b/.github/workflows/pronto.yaml new file mode 100644 index 00000000000..054ea6d7439 --- /dev/null +++ b/.github/workflows/pronto.yaml @@ -0,0 +1,31 @@ +name: Pronto +on: # zizmor: ignore[dangerous-triggers] Runs trusted base-branch code to report on pull requests. + - pull_request_target + +permissions: {} + +jobs: + pronto: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - run: echo BUNDLE_GEMFILE=gemfiles/pronto.gemfile > "$GITHUB_ENV" + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.base.sha }} + - run: git fetch --no-tags --prune --unshallow origin +refs/heads/*:refs/remotes/origin/* + - name: Setup Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: 3.4 + bundler-cache: true + - name: Run Pronto + run: bundle exec pronto run -f github_pr -c "origin/${BASE_REF}" + env: + BASE_REF: ${{ github.base_ref }} + PRONTO_PULL_REQUEST_ID: ${{ github.event.pull_request.number }} + PRONTO_GITHUB_ACCESS_TOKEN: "${{ github.token }}" diff --git a/.github/workflows/website.yaml b/.github/workflows/website.yaml index 1c204b3acf0..3186c38e8df 100644 --- a/.github/workflows/website.yaml +++ b/.github/workflows/website.yaml @@ -1,28 +1,44 @@ name: Publish Website on: + # For some reason, `on: release: ...` didn't work with `nektos/act` push: - branches: [master] + # Sequence of patterns matched against refs/tags + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + workflow_dispatch: + inputs: + publish_website: + description: "Publish guides to website?" + type: boolean + required: true + default: true + publish_version: + description: "If present, pull this GraphQL-Ruby version to rebuild API docs" + required: false + type: string +permissions: {} +env: + BUNDLE_WITH: jekyll_plugins jobs: - build: + website: + if: ${{ inputs.publish_website || github.ref_name }} + permissions: + contents: write name: Publish Website runs-on: ubuntu-latest steps: - name: Checkout master - uses: actions/checkout@v2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout GitHub pages branch - uses: actions/checkout@v2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: gh-pages ref: gh-pages - - uses: actions/setup-ruby@v1 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 with: - ruby-version: '2.7' - - name: Bundle install - run: | - gem install bundler - bundle config path vendor/bundle - bundle install --jobs 4 --retry 3 + ruby-version: '3.1' + - run: bundle install - name: Build HTML, reindex env: ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }} @@ -30,13 +46,49 @@ jobs: bundle exec rake site:fetch_latest site:build_doc site:update_search_index site:clean_html site:build_html - name: Commit changes as last committer run: | - git config --global user.name "%(git log --format="%aN" -n 1)" - git config --global user.email "%(git log --format="%aE" -n 1)" + git config --global user.name "$(git log --format="%aN" -n 1)" + git config --global user.email "$(git log --format="%aE" -n 1)" bundle exec rake site:commit_changes - name: Deploy to GitHub pages via gh-pages branch - uses: s0/git-publish-subdir-action@master + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./gh-pages + api_docs: + needs: website + if: ${{ inputs.publish_version || github.ref_name }} + permissions: + contents: write + name: Publish API Docs + runs-on: ubuntu-latest + steps: + - name: Checkout release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.GITHUB_REF }} + - name: Checkout GitHub pages branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: gh-pages + ref: gh-pages + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: '3.2' + - run: bundle install + - name: Build API docs env: - REPO: self - BRANCH: gh-pages - FOLDER: gh-pages - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISH_VERSION: ${{ inputs.publish_version || env.GITHUB_REF }} + run: | + bundle exec rake site:fetch_latest "apidocs:gen_version[${PUBLISH_VERSION}]" + - name: Commit changes as rmosolgo + run: | + git config --global user.name rmosolgo + git config --global user.email rdmosolgo@gmail.com + git status + bundle exec rake site:commit_changes + git status + - name: Deploy to GitHub pages via gh-pages branch + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./gh-pages diff --git a/.gitignore b/.gitignore index c1bd24c9468..093c2629632 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,14 @@ Gemfile.lock gemfiles/*.lock # Test database *.db +*.db-shm +*.db-wal +*.sqlite3 +.byebug_history .bundle/ vendor/ .idea/ +coverage/ _site .sass-cache @@ -17,14 +22,15 @@ _site .jekyll-cache gh-pages/ tmp/* -__*.db node_modules/ yarn.lock OperationStoreClient.js +DumpPayloadExample.json spec/integration/tmp .vscode/ *.swp *.swo +.DS_Store # These are generated for distribution, but shouldn't be # versioned with the typescript source (which is in javascript_client/src): javascript_client/__tests__ @@ -32,3 +38,11 @@ javascript_client/subscriptions javascript_client/sync javascript_client/cli* javascript_client/index* +javascript_client/esm/**/*.d.ts +javascript_client/esm/**/*.js +!javascript_client/esm/package.json +# Don't commit compiled extension files: +*.bundle +*.so +# Ragel generates Ruby type hints which is great, but I'm not ready to support them +*.ri diff --git a/.herb.yml b/.herb.yml new file mode 100644 index 00000000000..026ad7ddede --- /dev/null +++ b/.herb.yml @@ -0,0 +1,88 @@ +# This file configures Herb for your project and team. +# Settings here take precedence over individual editor preferences. +# +# Herb is a suite of tools for HTML+ERB templates including: +# - Linter: Validates templates and enforces best practices +# - Formatter: Auto-formats templates with intelligent indentation +# - Language Server: Provides IDE support (VS Code, Zed, Neovim, etc.) +# +# Website: https://herb-tools.dev +# Configuration: https://herb-tools.dev/configuration +# GitHub Repo: https://github.com/marcoroth/herb +# + +version: 0.8.10 + +# files: +# # Additional patterns beyond the defaults (**.html, **.rhtml, **.html.erb, etc.) +# include: +# - '**/*.xml.erb' +# - 'custom/**/*.html' +# +# # Patterns to exclude (can exclude defaults too) +# exclude: +# - 'public/**/*' +# - 'tmp/**/*' + +linter: + enabled: true + + exclude: + - '**/*.html' + - 'vendor/**/*' + + # # Exit with error code when diagnostics of this severity or higher are present + # # Valid values: error (default), warning, info, hint + # failLevel: warning + + # # Additional patterns beyond the defaults for linting + # include: + # - '**/*.xml.erb' + # + # # Patterns to exclude from linting + # exclude: + # - 'app/views/admin/**/*' + + rules: + erb-prefer-image-tag-helper: + enabled: false + # erb-no-extra-newline: + # enabled: false + # + # # Rules can have 'include', 'only', and 'exclude' patterns + # some-rule: + # # Additional patterns to check (additive, ignored when 'only' is present) + # include: + # - 'app/components/**/*' + # # Don't apply this rule to files matching these patterns + # exclude: + # - 'app/views/admin/**/*' + # + # another-rule: + # # Only apply this rule to files matching these patterns (overrides all 'include') + # only: + # - 'app/views/**/*' + # # Exclude still applies even with 'only' + # exclude: + # - 'app/views/admin/**/*' + +formatter: + enabled: false + indentWidth: 2 + maxLineLength: 80 + + # # Additional patterns beyond the defaults for formatting + # include: + # - '**/*.xml.erb' + # + # # Patterns to exclude from formatting + # exclude: + # - 'app/views/admin/**/*' + + # # Rewriters modify templates during formatting + # rewriter: + # # Pre-format rewriters (modify AST before formatting) + # pre: + # - tailwind-class-sorter + # # Post-format rewriters (modify formatted output string) + # post: [] diff --git a/.rubocop.yml b/.rubocop.yml index c362dbcd56a..46d31f1164b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,10 +1,16 @@ require: - - ./cop/none_without_block_cop - - ./cop/no_focus_cop + - ./cop/development/none_without_block_cop + - ./cop/development/no_eval_cop + - ./cop/development/no_focus_cop + - ./lib/graphql/rubocop/graphql/default_null_true + - ./lib/graphql/rubocop/graphql/default_required_true + - ./cop/development/context_is_passed_cop + - ./cop/development/trace_methods_cop.rb AllCops: DisabledByDefault: true - TargetRubyVersion: 2.2 + SuggestExtensions: false + TargetRubyVersion: 2.7 Exclude: - 'lib/graphql/language/lexer.rb' - 'lib/graphql/language/parser.rb' @@ -12,6 +18,42 @@ AllCops: - 'tmp/**/*' - 'vendor/**/*' - 'spec/integration/tmp/**/*' + - 'spec/fixtures/cop/*.rb' + +Development/ContextIsPassedCop: + Exclude: + - 'spec/**/*' + - 'cop/**/*' + - 'lib/graphql/schema/validation.rb' + - 'lib/graphql/static_validation/literal_validator.rb' + - 'lib/graphql/static_validation/rules/**/*.rb' + # AST-related: + - 'lib/graphql/schema/build_from_definition.rb' + - 'lib/graphql/language/printer.rb' + - 'lib/graphql/language/nodes.rb' + # Build-time, not runtime: + - 'lib/graphql/schema/addition.rb' + - 'lib/graphql/schema/introspection_system.rb' + # Methods from generators + - 'lib/generators/graphql/type_generator.rb' + +Development/NoneWithoutBlockCop: + Include: + - "lib/**/*" + - "spec/**/*" + +Development/NoEvalCop: + Include: + - "lib/**/*" + +Development/NoFocusCop: + Include: + - "spec/**/*" + +Development/TraceMethodsCop: + Include: + - "lib/graphql/tracing/perfetto_trace.rb" + - "lib/graphql/tracing/notifications_trace.rb" # def ... # end @@ -39,6 +81,7 @@ Style/ClassAndModuleChildren: Layout/EmptyLineBetweenDefs: AllowAdjacentOneLineDefs: true + NumberOfEmptyLines: [0, 1, 2] Style/FrozenStringLiteralComment: Enabled: true @@ -61,3 +104,9 @@ Style/WordArray: # ->(...) { ... } Layout/SpaceInLambdaLiteral: Enabled: true # Default is "require_no_space" + +GraphQL/DefaultNullTrue: + Enabled: true + +GraphQL/DefaultRequiredTrue: + Enabled: true diff --git a/CHANGELOG-enterprise.md b/CHANGELOG-enterprise.md new file mode 100644 index 00000000000..a277a2b45f4 --- /dev/null +++ b/CHANGELOG-enterprise.md @@ -0,0 +1,218 @@ +# graphql-enterprise + +### Breaking Changes + +### Deprecations + +### New Features + +### Bug Fix + +# 1.7.0 (24 Apr 2026) + +- Support GraphQL-Ruby's new `Execution::Next` runtime + +# 1.6.0 (25 Nov 2025) + +- `RuntimeLimiter` and `ActiveOperationLimiter` now support `Redis::Cluster` via `redis_cluster: ...` options #5465 + +# 1.5.9 (21 Nov 2025) + +- RuntimeLimiter: improve compatibility with ObjectCache + +# 1.5.8 (19 Sep 2025) + +- ObjectCache: Fix deprecation regarding `Resolve.resolve_all` by using a forward-compatible approach to lazy value resolution #5437 + +# 1.5.7 (2 May 2025) + +- ObjectCache: Use Rails's `.cache_key_with_version` in `CacheableRelation` for proper cache busting + +# 1.5.6 (13 Dec 2024) + +- ObjectCache: Add `CacheableRelation` helper for top-level ActiveRecord relations + +# 1.5.5 (10 Dec 2024) + +- Changesets: Add missing `ensure_loaded` call for class-based changesets + +# 1.5.4 (31 Oct 2024) + +- ObjectCache: Add `reauthorize_cached_objects: false` + +# 1.5.3 (1 Oct 2024) + +- Limiters: Add expiration to rate limit data (to reduce Redis footprint) + +# 1.5.2 (6 Sept 2024) + +- Limiters: Add `connection_pool:` support + +# 1.5.1 (30 Aug 2024) + +- ObjectCache: Add `connection_pool:` support + +# 1.5.0 (26 Jul 2024) + +- ObjectCache: Add Dalli backend for Memcached + +# 1.4.2 (11 Jun 2024) + +- ObjectCache: Add `Schema.fingerprint` hook and `context[:refresh_object_cache]` + +# 1.4.1 (30 May 2024) + +- ObjectCache: properly handle when object fingerprints are evicted but the cached result wasn't + +# 1.4.0 (11 Apr 2024) + +- ObjectCache: add support for `redis_cluster: ...` backend + +# 1.3.4 (18 Mar 2024) + +- ObjectCache: use new `trace_with` API for instrumentation + +# 1.3.3 (30 Jan 2024) + +- ObjectCache: fix compatibility with `run_graphql_field` test helper #4816 + +# 1.3.2 (15 Jan 2024) + +### Bug Fix + +- Limiters: Migrate to new `trace_with` instrumentation API, requires GraphQL-Ruby 2.0.18+ + +# 1.3.1 (12 June 2023) + +### Bug Fix + +- Add missing `require "graphql"` #4511 + +# 1.3.0 (29 May 2023) + +### New Features + +- Changesets: Add `added_in: ...` and `removed_in: ...` for inline definition changes + +# 1.2.0 (10 February 2023) + +### New Features + +- Support the `redis-client` gem as `redis:` (requires graphql-pro 1.24.0+) + +# 1.1.14 (3 November 2022) + +### New Features + +- Limiters: Support `dashboard_charts: false` to disable built-in instrumentation +- Limiters: Support `assign_as:` to use a different accessor method for storing limiter instances on schema classes (add a corresponding `class << self; attr_accessor ...; end` to the schema class to use it) +- Limiters: Support `context_key:` to put runtime info in a different key in query context +- Runtime Limiter: Add `window_ms:` to runtime info + +# 1.1.13 (21 October 2022) + +### Bug Fix + +- Limiter: handle missing fields in MutationLimiter + +# 1.1.12 (18 October 2022) + +### New Features + +- Limiters: add MutationLimiter + +### Bug Fix + +- ObjectCache: Update Redis calls to support redis-rb 5.0 + +# 1.1.11 (25 August 2022) + +### Bug Fix + +- ObjectCache: also update `delete` to handle more than 1000 objects in Lua + +# 1.1.10 (19 August 2022) + +### Bug Fix + +- ObjectCache: read and write objects 1000-at-a-time to avoid overloading Lua scripts in Redis + +# 1.1.9 (3 August 2022) + +### New Features + +- ObjectCache: Add a message to context when a type or field causes a query to be treated as "private" + +### Bug Fix + +- ObjectCache: skip the query analyzer when `context[:skip_object_cache]` is present + +# 1.1.8 (1 August 2022) + +### New Features + +- ObjectCache: Add `ObjectType.cache_dependencies_for(object, context)` to customize dependencies for an object + +### Bug Fix + +- ObjectCache: Fix to make `context[:object_cache][:objects]` a Set +# 1.1.7 (28 July 2022) + +### Bug Fix + +- ObjectCache: remove needless `resolve_type` calls + +# 1.1.6 (28 July 2022) + +### Bug Fix + +- ObjectCache: persist the type names of cached objects, pass them to `Schema.resolve_type` when validating cached responses. + +# 1.1.5 (22 July 2022) + +### New Features + +- ObjectCache: add `cache_introspection: { ttl: ... }` for setting an expiration (in seconds) on introspection fields. + +# 1.1.4 (19 March 2022) + +### Bug Fix + +- ObjectCache: don't create a cache fingerprint if the query is found to be uncacheable during analysis. + +# 1.1.3 (3 March 2022) + +### Bug Fix + +- Changesets: Return an empty set when a schema doesn't use changesets #3972 + +# 1.1.2 (1 March 2022) + +### New Features + +- Changesets: Add introspection methods `Schema.changesets` and `Changeset.changes` + +# 1.1.1 (14 February 2021) + +### Bug Fix + +- Changesets: don't require `context.schema` for plain-Ruby calls to introspection methods #3929 + +# 1.1.0 (24 November 2021) + +### New Features + +- Changesets: Add `GraphQL::Enterprise::Changeset` + +# 1.0.1 (9 November 2021) + +### Bug Fix + +- Object Cache: properly handle invalid queries #3703 + +# 1.0.0 (13 October 2021) + +### New Features + +- Rate limiters: first release +- Object cache: first release diff --git a/CHANGELOG-pro.md b/CHANGELOG-pro.md index 5863fc6c317..239eb786325 100644 --- a/CHANGELOG-pro.md +++ b/CHANGELOG-pro.md @@ -6,8 +6,518 @@ ### New Features +# 1.30.2 (3 Aug 2025) + +- `PusherSubscriptions`: accept `extra_webhook_tokens:` to use when rolling credentials + +# 1.30.1 (30 Jun 2025) + +- `@defer`: fix memory leak in legacy execution when used without Dataloader + +# 1.30.0 (24 Apr 2025) + +- Support GraphQL-Ruby's new `Execution::Next` runtime + +# 1.29.14 (21 Nov 2025) + +- Add configuration for `ostruct` dependency + +# 1.29.13 (22 Sept 2025) + +- Stable connections: fix condition grouping with IS NULL #5435 +- `@defer`: Correctly handle fields where `@defer` is present twice #5434 +- `@defer`: Update implementation to address warnings in GraphQL-Ruby 2.5.12+ + +# 1.29.12 (12 Sept 2025) + +- `OperationStore`: also support `visibility_profile:` on lazy routes. + +# 1.29.11 (12 Sept 2025) + +- `OperationStore`: add `visibility_profile: ...` argument to `operation_store_sync` so that incoming operations are bound to the given profile. + +# 1.29.10 (3 Jun 2025) + +- `@defer`, `@stream`: Include `"data"` in the payload, if there is any, even if there are `"errors"` #5365 + +# 1.29.9 (20 May 2025) + +- Stable relation connection: fix missing records with `nil` values in Postgres #5346 + +# 1.29.8 (15 May 2025) + +- `FutureStream`: Support `GraphQL::ExecutionError` raised from lazy enumerators + +# 1.29.7 (12 May 2025) + +- `FutureStream`: Add `#to_incremental_h` + +# 1.29.6 (5 May 2025) + +- `@stream`: Add `FutureStream` for lazy enumerators + +# 1.29.5 (31 Mar 2025) + +- OperationStore: Improve Redis cleanup when deleting a single client +- Stable connections: Fix NULL handling on Rails 7.2 + Postgresql + +# 1.29.4 (18 Nov 2024) + +- OperationStore: Add forward compatibility for removing old validation code #5164 + +# 1.29.3 (15 Nov 2024) + +- OperationStore: Improve `sync` performance with `GraphQL::Schema::Visibility` + +# 1.29.2 (4 Sept 2024) + +- Subscriptions: show broadcast subscriber count in dashboard (Pusher requires "subscription count" to be turned on and `use ... show_broadcast_subscribers_count: true`) + +# 1.29.1 (29 Aug 2024) + +- OperationStore: Accept a `context:` in `#add` + +# 1.29.0 (28 Aug 2024) + +- Subscriptions: use a single Pusher or Ably channel to deliver broadcast payloads to subscribers +- Dashboard: fix crash when a topic had no active subscriptions + +# 1.28.1 (22 Aug 2024) + +- Subscriptions: Track `last_triggered_at`; add more metadata to the dashboard. + +# 1.28.0 (20 Aug 2024) + +- OperationStore: require the `ActiveRecord` backend inside an `ActiveSupport.on_load(:active_record) { ... }` block to improve Rails compatibility + +# 1.27.7 (13 Aug 2024) + +- Subscriptions: Fix _another_ Lua error in big cleanup operations + +# 1.27.6 (13 Aug 2024) + +- Subscriptions: Fix Lua error when cleaning up huge numbers of inactive subscriptions + +# 1.27.5 (9 May 2024) + +- OperationStore: remove needless call to `.metadata` #4947 + +# 1.27.4 (2 May 2024) + +- Pundit, CanCan, OperationStore: add Rails generators for getting started + +# 1.27.3 (1 May 2024) + +- OperationStore: Fix `.reindex` for many stored operations #4940 + +# 1.27.2 (30 Apr 2024) + +- Dashboard: handle missing index references gracefully #4940 + +# 1.27.1 (18 Apr 2024) + +- OperationStore: Don't call `query.query_string` if there's already a parsed document #4922 + +# 1.27.0 (11 Apr 2024) + +- RelationConnection: support Arel's `NullsFirst` and `NullsLast` nodes #4910 + +# 1.26.5 (1 Mar 2024) + +- OperationStore::AddOperationBatch: remove rescue for StatementInvalid inside transaction + +# 1.26.4 (27 Feb 2024) + +- RelationConnection: Don't quote table names that weren't quoted in original SQL, fixes #4508 (comment) + +# 1.26.3 (19 Feb 2024) + +- OperationStore: fix `sync` endpoint for Rack 3+ #4829 +- Improve error message handling on Rails 7.1 + +# 1.26.2 (30 Jan 2024) + +- `@defer` / `@stream`: Write delimiters at the end of each patch so that clients respond to payloads more quickly. (Previously, delimiters were added at the start of each patch, so clients had to wait for the _next_ patch before they knew the current one was complete.) + +# 1.26.1 (23 Jan 2024) + +- Pundit integration: improve error message when a `Scope` class is missing + +# 1.26.0 (19 Jan 2024) + +### Breaking Changes + +- Pundit integration: when the integration encounters an Array, it tries to find a configured policy class. If it can't, it raises an error. + + Previously, the integration silently permitted all items in the array; this default has been changed. See #4726 for more discussion of this change. + + If you encounter this error: + + - add `scope: false` to any fields that return arrays to get the previous behavior (no authorization applied to the array; each item authorized on its own) + - Or, apply [scoping](https://graphql-ruby.org/authorization/scoping.html) by manually configuring a `pundit_policy_class` in the field's return type, then adding a `class Scope ...` inside that policy class. See the Pundit docs for the scope class API: https://github.com/varvet/pundit#scopes. + + If you want to continue passing _all_ arrays through without scoping (for example, if you know they've already been authorized another way, or if you're OK with them being authorized one-at-a-time later), you can implement this in your base `Scope` class, for example: + + ```ruby + class BasePolicy + class Scope + def initialize(user, items) + @user = user + @items = items + end + + def resolve + if items.is_a?(Array) + items + else + raise "Implement #{self.class}#resolve to filter these items: #{items.inspect}" + end + end + end + + # Pass this scope class along to subclasses: + def self.inherited(child_class) + child_class.const_set(:Scope, Class.new(BasePolicy::Scope)) + super + end + end + ``` + + Alternatively, you could implement `def self.scope_items(items, context)` to skip arrays, for example: + + ```ruby + module SkipScopingOnArrays + def scope_items(items, context) + if items.is_a?(Array) + items # return these as-is + else + super + end + end + end + + # Then, in type definitions which should skip scoping on arrays: + extend SkipScopingOnArrays + ``` + +# 1.25.2 (29 Dec 2023) + +### New Features + +- Subscriptions: send `more: false` when the server calls `unsubscribe` + +# 1.25.1 (21 Dec 2023) + +### Bug Fix + +- Ably subscriptions: update webhook handler for `presence.message` events + +# 1.25.0 (7 Dec 2023) + ### Bug Fix +- OperationStore: `.dup` the given `context` to avoid leaking state between queries when indexing +- Subscriptions: use the schema or query logger to output debug messages + +# 1.24.15 (17 Nov 2023) + +### Bug Fix + +- OperationStore: don't sort directives when normalizing, properly retain directives on Operation and Fragment definitions #4703 + +# 1.24.14 (16 Nov 2023) + +### Bug Fix + +- OperationStore: also pass `context:` for ActiveRecord backend batches + +# 1.24.13 (13 Nov 2023) + +### New Features + +- OperationStore: accept `context:` for `AddOperationBatch.call` #4697 + +# 1.24.12 (13 Nov 2023) + +### New Features + +- OperationStore: accept `context:` to `Validate.validate` #4697 + +### Bug Fix + +- OperationStore: don't rescue application-raised `KeyError`s #4699 + +# 1.24.11 (8 Nov 2023) + +### Bug Fix + +- OperationStore: fix compatibility with 1.12.x #4696 + +# 1.24.10 (2 Nov 2023) + +### Bug Fix + +- Improve compatibility with GraphQL-Ruby 1.12.x + +# 1.24.9 (4 Oct 2023) + +### Bug Fix + +- OperationStore: Preserve variable default values of `false` when normalizing queries + +# 1.24.8 (29 Aug 2023) + +### Bug Fix + +- OperationStore: search for operation during `Query#initialize` to avoid races with other instrumentation. Add `use ... trace: true` to get the old behavior. + +# 1.24.7 (16 June 2023) + +### Bug Fix + +- Stable relation connections: quote table names and column names in `WHERE` clauses #4508 + +# 1.24.6 (24 May 2023) + +### New Features + +- Defer: Add `incremental: true` for new proposed wire format, add example for working with GraphQL-Batch #4477 + +# 1.24.5 (24 May 2023) + +### Bug Fix + +- Stable relation connection: Quote table names and column names in selects and orders #4485 + +# 1.24.4 (18 April 2023) + +### Bug Fix + +- `@defer`: update `context[:current_path]` usage to fix `path:` on deferred errors + +# 1.24.3 (14 April 2023) + +### Bug Fix + +- `OperationStore`: fix when used with Changesets (or other ways of defining arguments with the same name) #4440 + +# 1.24.2 (20 Mar 2023) + +### Bug Fix + +- Remove debug output, oops + +# 1.24.1 (20 Mar 2023) + +### Bug Fix + +- Fix `OperationStore` with new module-based execution traces (#4389) + +# 1.24.0 (10 Feb 2023) + +### New Features + +- Support the `redis-client` gem as `redis:` + +# 1.23.9 (2 Feb 2023) + +### Bug Fix + +- Dashboard: Support Ruby 3.2.0 + +# 1.23.8 (27 Jan 2023) + +### New Features + +- OperationStore: Support `Changeset-Version` header for syncing with changesets #4304 + +# 1.23.7 (25 Jan 2023) + +### Bug Fix + +- Stable Relation Connections: Fix handling of Postgres JSON accesses + +# 1.23.6 + +### New Features + +- Subscriptions: accept `connection_pool:` instead of `redis:` for use with the `connection_pool` gem + +### Bug Fix + +- Stable connections: rescue `ActiveRecord::StatementInvalid` when loading nodes and return a client-facing error instead + +# 1.23.5 (29 December 2022) + +### New Features + +- Ably subscriptions: Also listen for `presence.leave` webhooks to clean up subscriptions more quickly + +# 1.23.4 (20 December 2022) + +### Bug Fix + +- Dashboard: nicely render subscriptions that are not found or cleaned up by `read_subscription_failed_error` + +# 1.23.3 (19 December 2022) + +### New Features + +- Add `GraphQL::Pro::Subscriptions#read_subscription_failed_error` for handling errors that are raised when reloading queries from storage + +# 1.23.2 (18 October 2022) + +### New Features + +- Add dashboard component for Enterprise mutation limiter + +# 1.23.1 (25 August 2022) + +### Bug Fix + +- Redis: update redis usage to be forward-compatible with redis 5.x #4167 + +# 1.23.0 (2 August 2022) + +### New Features + +- Stable connections: support SQL queries that sort by `IS NOT NULL` #4153 + +# 1.22.3 (26 July 2022) + +### Bug Fix + +- Stable connections: handle `edges {...}` when an invalid cursor is given #4148 + +# 1.22.2 (20 April 2022) + +### Bug Fix + +- Use `deprecated_accepts_definitions` to stop warnings when loading this gem on 1.13.x + +# 1.22.1 (22 March 2022) + +### Bug Fix + +- Pusher subscriptions: don't try to send empty trigger batches to Pusher + +# 1.22.0 (19 March 2022) + +### New Features + +- Pusher subscriptions: it now sends updates in groups of 10 by default, pass `use ..., batch_size: 1` to revert to the previous behavior. +- OperationStore: when using ActiveRecord for storage, it now batches updates to `last_used_at` every 5 seconds. Pass `use ..., update_last_used_at_every: 0` to update that column synchronously, instead, as before. + +# 1.21.6 (16 March 2022) + +### Bug Fix + +- OperationStore: Fix no method error in Redis pipeline usage + +# 1.21.5 (7 March 2022) + +### Bug Fix + +- Postgres stable connection: support more complex aliased selects #3976 + +# 1.21.4 (15 February 2022) + +### Bug Fix + +- Encoders: don't extend `DeprecatedDefine` if it's not present (graphql-ruby < 1.12) + +# 1.21.3 (9 February 2022) + +### New Features + +- Future-proof for GraphQL-Ruby 2.0 + +# 1.21.2 (27 January 2022) + +### New Features + +- Dashboard, Routes: support lazy-loading the schema with `Routes::Lazy` #3868 +- OperationStore: Update deprecated usage of `@redis.pipelined` to address warning + +# 1.21.1 (20 January 2022) + +### Bug Fix + +- Stream, Defer: Include `hasNext: true|false` in patches + +# 1.21.0 (20 January 2022) + +### New Features + +- Stream: Add `@stream` directive for evaluating list items one-at-a-time + +# 1.20.4 (4 December 2021) + +### Bug Fix + +- Stable connections: Fix using startCursor / endCursor without nodes #3752 + +# 1.20.3 (27 November 2021) + +### Bug Fix + +- Stable Connections: Properly handle cursors containing invalid JSON #3735 + +# 1.20.2 (15 November 2021) + +### New Features + +- Operation Store sync: ActiveRecord backend performance improvements: when syncing operations, only validate newly-added operations, reduce allocations when normalizing incoming query strings + +# 1.20.1 (8 November 2021) + +### Bug Fix + +- Operation Store sync: fix when operations are re-synced with new aliases + +# 1.20.0 (5 November 2021) + +### New Features + +- Operation Store: Use Rails `insert_all` for better performance when adding new operations + +# 1.19.2 (26 October 2021) + +### New Features + +- Pundit and CanCan integrations: Add `ResolverIntegration` modules for plain resolvers #3392 + +### Bug Fix + +- OperationStore Redis backend: pipeline updates to last_used_at values #3672 + +# 1.19.1 (15 October 2021) + +### Bug Fix + +- OperationStore: fix a stack overflow error on GraphQL 1.9 #3653 + +# 1.19.0 (13 October 2021) + +### New Features + +- Dashboard: add a component for GraphQL-Enterprise rate limiters +# 1.18.3 (1 Sept 2021) + +### Breaking Changes + +- Stable cursors: raise an error on unrecognized orderings instead of ignoring them #3605 + +### Bug Fix + +- Stable cursors: Handle `Arel::Attributes::Attribute` and `Arel::SqlLiteral` #3605 + +# 1.18.2 (16 August 2021) + +### Bug Fix + +- Stable connections: nicely handle incoming cursors with too many sort values #3581 + # 1.18.1 (20 July 2021) ### Bug Fix @@ -388,7 +898,7 @@ ### Bug Fix -- Pundit integration: use overriden `pundit_policy_class` for scoping and mutation authorization +- Pundit integration: use overridden `pundit_policy_class` for scoping and mutation authorization ## 1.9.11 (20 Feb 2019) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e6cc63655..e3fa0706566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,1802 @@ # Changelog +[Versioning guidelines](https://graphql-ruby.org/development.html#versioning) + +### Breaking changes + +### Deprecations + +### New features + +### Bug fixes + +# 2.6.7 (28 Jul 2026) + +### Bug fixes + +- SDL: Speed up parsing interfaces #5678 +- Execution::Next: fix selection step enqueuing #5679 +- AsyncDataloader: improve pending work accounting #5675 + +# 2.6.6 (21 Jul 2026) + +- __Security__: This version includes a remediation for a security issue in `Execution::Next` (GHSA-j7xr-4g94-r9h3). + +### New features + +- Support pattern matching non-null and list type definitions #5660 + +### Bug fixes + +- SDL: properly apply directive argument default values #5659 +- Dataloader: fix Ruby version compatibility check #5662 +- Validation: apply query token limit in `Schema.validate` #5668 +- Dataloader: Use a queue for more efficient Source resolution #5666 +- Remove needless compat with Ruby 2.2 #5670 +- Schema: fix union memberships lookup #5663 +- Dataloader: unify lazy resolution code #5669 +- AsyncDataloader: improve task scheduling to fix deadlocks #5672 + +# 2.6.5 (6 Jul 2026) + +### New features + +- `Execution::Next`: improve non-null error propagation #5644 +- Dataloader: optimize memory usage in `Dataloader::Source` #5658 +- AsyncDataloader: fix concurrency errors #5656 +- Generators: don't generate fields for filtered parameters #5653 + +# 2.6.4 (22 Jun 2026) + +**Yanked** due to problems introduced to `AsyncDataloader`. + +### Bug fixes + +- AsyncDataloader: rework to avoid deadlocks with Falcon #5479 +- Execution::Next: fix tracer arguments #5650 + +# 2.6.3 (26 May 2026) + +### Breaking changes + +- `Schema::Visibility`: must be attached after root type configurations to support `preload: ...`. Move the configuration down if you encounter an error from this. #5635 + +### New features + +- Validators: accept procs for configuration options #5641 + +### Bug fixes + +- `Execution::Next`: don't crash when non-null propagation nullifies an object inside a fragment that has sibling selections #5637 +- `Execution::Next`: Implement `GraphQL::Current.field` #5640 +- Fix `required: :nullable` when combined with `as:` #5636 +- `Execution::Next`: improve execute_query_lazy compatibility #5634 +- Analysis: Fix when skip/include argument fails coercion #5633 + +# 2.6.2 (12 May 2026) + +### New features + +- Use a better error message for end-of-file parse errors #6513 + +### Bug fixes + +- `Execution::Next`: Lots of compatibility improvements and bug fixes #5625 #5618 +- Directives: handle errors raised when checking `.include?` #5612 +- Improve error message when a field definition is missing at runtime #5615 +- JRuby: fix `Dataloader#with` #5616 +- DetailedTrace: fix introspection with anonymous classes #5624 + +# 2.6.1 (27 Apr 2026) + +### Bug fixes + +- Lexer: in the Ruby lexer, count comments against a schema's `max_token_count` configuration + +# 2.6.0 (24 Apr 2026) + +### Breaking changes + +- SDL: previously, GraphQL-Ruby didn't require object types and interfaces to explicitly name all transitively implemented interfaces. For example if Interface A implements Interface B, and Object O implements Interface A, GraphQL-Ruby didn't require the SDL to include `implements ... & B`, But it should have, according to the spec. This misbehavior has been corrected, but may cause some previously-accepted SDL strings to be rejected with errors. #5602 + +### Bug fixes + +- SDL: require types to name _all_ implemented interfaces #5602 +- Execution::Next: call Resolver `#ready?` methods #5611 + +# 2.5.25 + +### Bug fixes + +- Field extensions: correctly return `false` through for fields with extensions that don't define `after_resolve` #5610 + +# 2.5.24 + +### New features + +- Continue building `Execution::Next`: #5606, #5603, #5596, #5604, #5607 + +# 2.5.23 + +### New features + +- Static Validations: several performance improvements #5584, #5585, #5586, #5585, #5587, #5591 +- Continue building `Execution::Next` #5589, #5592, #5582 + +### Bug fixes + +- Fix Dataloader state on list items #5597 + +# 2.5.22 + +- Continue building `Execution::Next`: #5575 #5581 + +# 2.5.21 + +### New features + +- Continue building `Execution::Next` #5553 #5562 #5563 #5566 #5569 #5570 #5571 + +### Bug fixes + +- `Schema.from_definition`: fix field calls when all arguments come from extras #5557 + +# 2.5.20 (23 Feb 2026) + +- `DetailedTrace`: Handle inspecting instances of anonymous classes #5530 +- `Dashboard`: move controllers to their own files, fix lazy loading #5524 +- `Dataloader`: add `dataload_all` shortcut; add shortcuts to `context` #5540 +- Add experimental new runtime code (not loaded by default) #5509 #5544 #5547 +- Fix `@defer` on inline fragments when fields return Lazy values and Dataloader isn't used #5550 + +# 2.5.19 (5 Feb 2026) + +- `DetailedTrace`: add ActiveRecord backend, generator #5525 +- `DetailedTrace`: improve ActiveRecord backend, filter hashes before logging #5527 + +# 2.5.18 (22 Jan 2026) + +- `GraphQL::Dashboard`: properly require `action_controller` before using it #5510 +- `GraphQL::Dashboard`: don't use `config.asset_host` for Dashboard assets since they're handled by the dashboard itself #5511 + +# 2.5.17 (21 Jan 2026) + +### Bug fixes + +- `GraphQL::Dashboard`: fix routes compatibility with Devise #5505 +- `GraphQL::Dashboard`: fix HTML/erb lint issues #5497 +- Parser: improve check for invalid number followed by name #5492 #5492 +- `GraphQL::Field`: optimize boot memory by removing `Field.from_options` #5495 +- `field`, `argument`: improve API documentation for DSL methods #5491 + +# 2.5.16 (10 Dec 2025) + +### Bug fixes + +- `fiber-storage`: properly include dependency on Ruby < 3.2 #5484 +- Fix typo in `legacy_invalid_empty_selections_on_union_with_type` warning #5481 + +# 2.5.15 (9 Dec 2025) + +### New features + +- `DetailedTrace`: add separate spans for debug-only `.inspect` calls, support `debug: false` config #5477 +- `required:` validator: Raise a developer error when all `one_of:` options are hidden, support `allow_all_hidden: true` to allow this case #5474 +- `GraphQL::Testing::MockActionCable`: added to support testing ActionCableSubscriptions #5482 +- `legacy_invalid_empty_selections_on_union_with_type`: new method added for better metadata about legacy behavior #5480 + +### Bug fixes + +- Fix typo in date encoding error #5447 +- Fix schema printer bug #5468 +- Ensure `data` exists for execution errors #5452 +- Improve SDL directive argument coercion #5469 +- Don't require `fiber-storage` on Ruby 3.2+ #5456 +- Visibility: default to `preload: true` when Rails.env.staging? #5409 + +# 2.5.14 (8 Oct 2025) + +### Bug fixes + +- Fix error when GraphQL-Batch is used (???) #5444 + +# 2.5.13 (22 Sep 2025) + +### New features + +- Testing helpers: support `visibility_profile: ...` #5439 + +### Bug fixes + +- Directives: correctly handle schema directive arguments which are lists of input objects #5440 + +# 2.5.12 (15 Sep 2025) + +### Breaking Changes + +- Tracing: `execute_query_lazy` will _not_ be called if there isn't any lazy resolution (eg GraphQL-Batch) to do. Migrate any finalization code to `execute_multiplex` instead. #5450 #5422 + +### New features + +- Runtime: add hooks for experimental custom runtimes #5425, #5429 +- Lazy handling and Dataloader have been merged under the hood #5422 +- Doc: mark `load_application_object_failed` as public #5426 + +# 2.5.11 (9 Jul 2025) + +### Bug fixes + +- Dataloader: improve compatibility when objects are loaded by GraphQL-Batch but `.authorized?` uses Dataloader #5400 + +# 2.5.10 (3 Jul 2025) + +### New features + +- Schema: Add `.freeze_schema` for minimal Ractor support #5370 + +### Bug fixes + +- Schema: inherit validation configurations #5382 +- Visibility: fix inheriting visibility with `preload: true` #5386 +- Improve error messages with interfaces from SDL #5372 +- Remove needless counter in execution code #5392 +- Reduce execution overhead in schemas built from SDL #5393 +- RequiredValidator: remove hidden definitions from error message #5396 +- `.possible_types`: don't return interfaces in this list #5395 +- `dataload_association`: fix loading associations with different scopes on the same object #5398 + +# 2.5.9 (6 Jun 2025) + +### New features + +- Improve metadata on Scalar coercion errors #5375 + +### Bug fixes + +- Directives: fix validation of Ruby values on definition directive arguments #5377 +- `loads:`: fix typechecking of Interface `loads:` values #5379 + +# 2.5.8 (28 May 2025) + +### New features + +- Timeout: support disabling during a query #5361 +- Query::Partial: support running a fragment in isolation #5362 + +### Bug fixes + +- Visibility: improve performance for `loadable?` #5355 +- RequiredValidator: Fix typo #5359 +- Scalar validation: remove redundant infinity handling #5358 +- Directives: fix validation of schema definition directives #5368 + +# 2.5.7 (15 May 2025) + +### Bug fixes + +- `PerfettoTrace`: Handle non-ascii strings #5351 +- `Partial`: Add `#selected_operation_name` to support `GraphQL::Current` #5353 + +# 2.5.6 (5 May 2025) + +### New features + +- Execution: Add `Query#run_partials` for running sub-trees of valid queries #5183 + +# 2.5.5 (29 Apr 2025) + +### Bug fixes + +- Visibility: fix when `::Rails` doesn't have `.env` #5339 +- Compatibility: restore default (legacy) behavior when no setting is configured #5343 +- `ActiveSupport::Notifications`: fix fiber resume without previous event #5335 +- Simplify non-null input object argument handling #5333 +- Fix compatibility with `ruby-head` #5342 + +# 2.5.4 (18 Apr 2025) + +### Bug fixes + +- `ActiveRecordSource`: Support composite primary keys #5330 +- `ActiveRecordAssociationSource`: Support has_many associations #5331 +- Remove broken `Context#path` method (use `#current_path` instead) #5332 + +# 2.5.3 (14 Apr 2025) + +### Deprecations + +- Validation: two non-spec behaviors are deprecated: + - When a query includes two scalar fields of different types which may occur in the same place in the response, the query was previously allowed. The spec says it should be rejected. This version emits a warning in this case. See `Schema.allow_legacy_invalid_return_type_conflicts` for migration support. #4351 + - When a query selects a field which returns a Union, but doesn't make any subselections on the Union, the spec says the query should be rejected as invalid but previous GraphQL-Ruby allowed it. It now emits a warning. See `Schema.allow_legacy_invalid_empty_selections_on_union` for migration support #5322 +- Complexity: several bugs about merging complexity cost across branches of a query have been fixed but require opting in. They may produce higher complexity scores. See `Schema.complexity_cost_calculation_mode` for migration support. #4843 + +### New features + +- `AlwaysVisible`: improve speed (using `Schema::Visibility`) #5326 +- Return more descriptive errors when non-nullable list elements are `null` #5301 +- Visibility: improve performance on large schemas #5325 + +# 2.5.2 (8 Apr 2025) + +### New features + +- Resolver: accept `deprecation_reason` #5320 + +### Bug fixes + +- Visibility: hide argument types whose uses are all hidden (to match Warden) #5291 +- InputObject: fix validation for nested input object with `prepare:` method configs #5321 + +# 2.5.1 (3 Apr 2025) + +### Bug fixes + +- Datadog trace: fix Dataloader source tracing method #5318 +- Sentry trace: handle `nil` current span #5313 + +# 2.5.0 (1 Apr 2025) + +### Breaking changes + +- Subscriptions: GraphQL-Ruby now implements the spec's requirement that a subscription has only one root selection #5250 +- Datadog trace: the custom `prepare_span` hook now receives an execution-related object instead of a hash of keywords. #5298 + +### New features + +- Tracers: APM tracers have been updated to reflect Dataloader's fiber stops and starts #5296 #5298 + +# 2.4.16 (1 Apr 2025) + +### New features + +- Move some more modules into GraphQL::Dashboard #5308 #5310 + +### Bug fixes + +- Parser: raise when variable definitions don't include a type name #5305 +- PerfettoTrace: Don't create zombie ActiveSupport::Notifications subscribers #5307 + +# 2.4.15 (19 Mar 2025) + +### New features + +- `Schema.from_definition`: support custom base type classes #5282 +- `Schema.from_definition`: support type extensions #5281 + +### Bug fixes + +- Handle `GraphQL::ExecutionError` from `resolve_type` #5274 +- Backtrace: handle inline fragments # 5274 +- `run_graphql_field`: fix when `.authorized?` calls Dataloader #5289 +- InputObject: run validators even when custom `def prepare` is present #5285 +- Multiplex: don't attempt to execute zero queries #5278 + +# 2.4.14 (13 Mar 2025) + +### Bug fixes + +- New Relic tracing: fix dataloaded, skipped scalars #5277 + +# 2.4.13 (12 Mar 2025) + +- Security: Fix CVE-2025-27407 + +# 2.4.12 (11 Mar 2025) + +### Breaking changes + +- Remove `InvalidNullError#value` which is always `nil` #5256 + +### New features + +- `validate_timeout` is 3 seconds by default #5258 + +### Bug fixes + +- New Relic: reimplement skipping scalars by default #5271 +- Resolver: revert inheriting overridden `graphql_name` #5260 +- Analysis: manually implement timeout to handle I/O better #5263 +- Parser: properly handle extra token at the end of the query string #5267 +- Validation: fix conflicting aliases inside fragment #5268 + +# 2.4.11 (28 Feb 2025) + +### Breaking changes + +- Enums: enum value accessor methods have been switched to opt-in. Add `value_methods(true)` to your base enum class to opt back in. #5255 + +### New features + +- `InvalidNullError`: Improve default handling to add path and locations #5257 +- `DetailedTrace`: Add a sampling profiler for creating detailed traces #5244 + +### Bug fixes + +- `InvalidNullError`: use `GraphQL::Error` as a base class #5248 +- CI: test on Mongoid 8 and 9 #5251 + +# 2.4.10 (18 Feb 2025) + +### New features + +- Dataloader: improve built-in Rails integration #5213 + +### Bug fixes + +- `NewRelicTrace`: don't double-count time waiting on Dataloader fibers +- Fix possible type memberships inherited from superclass #5236 +- `Visibility`: properly use configured contexts for visibility profiles #5235 +- `Enum`: reduce needless `value_method` warnings #5230 #5220 +- `Backtrace`: fix error handling with `rescue_from` #5227 +- Parser: return a proper error when variable type is missing #5225 + +# 2.4.9 (29 Jan 2025) + +### New features + +- Enum: Enum types now have methods to access GraphQL-ready values directly #5206 #5218 + +### Bug fixes + +- Validation: fix order dependency and mutual exclusion bug in `required: { one_of: [ ... ] }` +- Backtrace: simplify trace setup and rendering code +- Fix dependencies for Ruby 3.4 #5199 +- Resolver: inherit description from superclass #5195 +- Visibility: fix for when multiple implementations are all hidden #5191 + +# 2.4.8 (10 Dec 2024) + +### New features + +- Subscriptions: support calling `write_subscription` within `resolve` #5142 + +### Bug fixes + +- Autoloading: improve autoloading of `Tracing` classes #5190 + +# 2.4.7 (7 Dec 2024) + +### Bug fixes + +- Remove warning when code isn't eager-loaded #5187 +- Add missing `require "ostruct"` in ActionCableSubscriptions #5184 + +# 2.4.6 (5 Dec 2024) + +### Bug fixes + +- Autoloading: fix referencing built-in types #5181 +- Autoloading: use Rails `config.before_eager_load` hook for better integration #5182 +- `loads:`: Check possible types for `loads:`-only unions #5180 + +# 2.4.5 (2 Dec 2024) + +### Breaking changes + +- In non-Rails production environments, GraphQL-Ruby will emit a warning about calling `.eager_load!` for better boot performance. #5178 + +### New features + +- Loading: GraphQL-Ruby now uses Ruby's `autoload ...` for many constants. #5178 +- Input objects may be pattern matched (they implement `#deconstruct_keys`) #5170 + +### Bug fixes + +- Visibility: hide definition directives in SDL #5175 +- Internals: use `Fiber[...]` for internal state instead of `Thread.current` #5176 +- Dataloader: properly handle arrays of all falsey values #5167 #5169 +- Visibility: hide directives when their uses are all hidden #5163 +- Require object types to have fields and require input objects to have arguments (to comply with the GraphQL spec) #5137 +- Improve error message when a misplaced `-` is encountered #5115 + +# 2.4.4 (18 Nov 2024) + +- Visibility: improve performance with `sync` #5161 + +# 2.4.3 (11 Nov 2024) + +### Bug fixes + +- Lookahead: return an empty hash for `.arguments` when they raised a `GraphQL::ExecutionError` #5155 +- Visibility: fix error when Mutation is lazy-loaded #5158 +- Visibility: improve performance of `Schema.types` #5157 + +# 2.4.2 (7 Nov 2024) + +### Bug fixes + +- Validation: fix error message when selections are made on an enum #5144 #5145 +- Visibility: fix preloading when no profiles are named #5148 + +# 2.4.1 (4 Nov 2024) + +### Bug fixes + +- Visibility: support dynamically-generated `#enum_values` #5141 + +# 2.4.0 (31 Oct 2024) + +### Deprecations + +- Visibility: Implementing `visible?` now requires `use GraphQL::Schema::Visibility` or `use GraphQL::Schema::Warden` in your schema definition #5123 + +### New features + +- Validation: Add "did you mean" to error messages when `DidYouMean` is available #4966 +- Schema: types can be lazy-loaded when using `GraphQL::Schema::Visibility` #4919 + +# 2.3.20 (31 Oct 2024) + +### Bug fixes + +- Arguments: suppress warning for `objectId` arguments #5124 +- Arguments: don't require input object arguments when a default value is configured + +# 2.3.19 (24 Oct 2024) + +### New features + +- Dataloader: accept a `fiber_limit:` option #5132 + +### Bug fixes + +- Argument Validation: improve the `one_of:` error message #5130 +- Lookahead: return a null lookahead from `Query#lookahead` when no operation is selected #5129 +- Static Validation: speed up FieldsWillMerge when some fields are not defined #5125 + +# 2.3.18 (7 Oct 2024) + +### Bug fixes + +- Properly use trace options when `trace_with` is used after `trace_class` #5118 + +# 2.3.17 (4 Oct 2024) + +### Bug fixes + +- Fix `InvalidNullError#inspect` #5103 +- Add server-side tests for ActionCableSubscriptions #5108 +- RuboCop: Fix FieldTypeInBlock for list types and interface types #5107 #5112 +- Subscriptions: Fix triggering with nested input objects #5117 +- Extensions: fix extensions which add other extensions #5116 + + +# 2.3.16 (12 Sept 2024) + +### Bug fixes + +- RuboCop: fix `FieldTypeInBlock` for single-line classes #5098 +- Testing: Add `context[:current_field]` to testing helpers #5096 + +# 2.3.15 (10 Sept 2024) + +### New features + +- Type definitions accept `comment("...")` for annotating SDL #5067 +- Parser: add `tokens_count` method #5066 +- Schema: allow `validate_timeout` to be reset #5062 + +### Bug fixes + +- Optimize `Language.escape_single_quoted_newlines` #5095 +- Generators: Add `# frozen_string_literal: true` to base resolver #5092 +- Parser: Properly handle minus followed by name #5090 +- Migrate some attr_reader methods #5080 +- Handle variable definition directives #5072 +- Handle `GraphQL::ExecutionError` when loading arguments during analysis #5071 +- NotificationsTrace: properly call `super` +- Use symbols for namespaced_types generator option #5068 +- Reduce memory usage in lazy resolution #5061 +- Fix default trace inheritance #5045 + +# 2.3.14 (13 Aug 2024) + +### Bug fixes + +- Subscriptions: fix subscriptions when subscription type is added after subscription plug-in #5063 + +# 2.3.13 (12 Aug 2024) + +### New features + +- Authorization: Call `EnumValue#authorized?` during execution #5058 +- `Subset`: support lazy-loading root types and field return types (not documented yet) #5055, #5054 + +### Bug fixes + +- Validation: don't validate `nil` if null value is permitted for incoming lists #5048 +- Multiplex: fix `Mutation#ready?` dataloader cache in multiplexes #5059 + +# 2.3.12 (5 Aug 2024) + +### Bug fixes + +- Add `fiber-storage` dependency for Ruby < 3.2 support + +# 2.3.11 (2 Aug 2024) + +### New features + +- `GraphQL::Current` offers globally-available methods for runtime metadata #5034 +- Continue improving `Schema::Subset` (not production-ready yet, though) #5018 #5039 + +### Bug fixes + +- Fix `Node#line` and `Node#col` when nodes are created by manually #5047 +- Remove unused `interpreter?`, `using_ast_analysis?` and `new_connections?` flag methods #5039 +- Clean up `.compare_by_identity` usages #5037 + +# 2.3.10 (19 Jul 2024) + +### Bug fixes + +- Parser: fix parsing operation names that match keywords #5033 +- Parser: support leading pipes in Union type definitions #5027 +- Validation: remove rule that prohibits non-null variables from having default values #5030 +- Dataloader: raise fresh error instances when sources return errors #5021 +- Enum and Union: don't create nested error classes in anonymous classes (eg, when parsing SDL -- to improve bug tracker integration) #5022 + +# 2.3.9 (13 Jul 2024) + +### Bug fixes + +- Subscriptions: fix `subscriptionType` in introspection #5019 + +# 2.3.8 (12 Jul 2024) + +### New features + +- Input validation: Add `all: { ... }` validator #5013 +- Visibility: Add `Query#types` for future type filtering improvements #4998 +- Broadcast: Add `default_broadcast(true)` option for Connection and Edge types #5012 + +### Bug fixes + +- Remove unused `InvalidTypeError` #5003 +- Parser: remove unused `previous_token` and `Token` #5015 + +# 2.3.7 (27 Jun 2024) + +### Bug fixes + +- Properly merge field directives and resolver directives #5001 + +# 2.3.6 (25 Jun 2024) + +### New features + +- Analysis classes are now in `GraphQL::Analysis` (`GraphQL::Analysis::AST` still works, too) #4996 +- Resolvers and Mutations accept `directive ...` configurations #4995 + +### Bug fixes + +- `AsyncDataloader`: Copy Fiber-local variables into Async tasks #4994 +- `Dataloader`: properly batch `fetch` calls with `loads:` arguments that call Dataloader sources during `.authorized?` #4997 + +# 2.3.5 (13 Jun 2024) + +### Breaking changes + +- Remove default `load_*` implementations in arguments -- this could break calls to `super` if you have redefined this method in subclasses #4978 +- `Schema.possible_types` and `Schema.references_to` now use type classes as keys instead of type names (Strings). You can create a new Hash with the old structure using `.transform_keys(&:graphql_name)`. #4986 #4971 + +### Bug fixes + +- Enums: fix parsing enum values that match GraphQL keywords (eg `type`, `extend`) #4987 +- Consolidate runtime state #4969 +- Simplify schema type indexes #4971 #4986 +- Remove duplicate when clause #4976 +- Address many Ruby warnings #4978 +- Remove needless `ruby2_keywords` usage #4989 +- Fix some YARD docs #4984 + +# 2.3.4 (21 May 2024) + +### New features + +- Async Dataloader: document integration with Rails database connections #4944 #4964 + +### Bug fixes + +- `Query#fingerprint`: handle `nil` query strings like `""` #4963 +- `Language::Nodes`: support marshalling parsed ASTs #4959 +- Directives: fix directives in nested fragment spreads #4958 +- Tracing: fix conflicts between Sentry and Prometheus traces #4957 + +# 2.3.3 (9 May 2024) + +### New features + +- Max Complexity: add `count_introspection:` option #4939 + +### Bug fixes + +- Language: Fix regression in `Nodes#line` and `Nodes#col` #4949 +- Runtime: Simplify runtime state management #4935 + +# 2.3.2 (26 Apr 2024) + +### Bug fixes + +- Properly `.prepare` lists of input objects #4933 +- Fix deleting directives using the AST visitor #4931 + +# 2.3.1 (22 Apr 2024) + +### New features + +- `Schema.max_query_string_tokens`: support a limit on the number of tokens the lexer should identify #4929 +- Parser: add an option to reject numbers followed immediately by argument names #4924 +- Parser and CParser: reduce allocated and retained strings when parsing schemas #4899 +- `run_graphql_field`: support `:lookahead` and `:ast_node` field extras #4930 + +### Bug fixes + +- Rescue when trying to print integers that are too big for Ruby #4923 +- Mutation: clear the Dataloader cache before resolving #4903 +- Fix `FieldUsage` analyzer when InputObjects return a prepared value #4902 +- Add a minimal query string for `run_graphql_field` #4891 +- Fix PrometheusTrace with multiple tracers #4888 + +# 2.3.0 (20 Mar 2024) + +### Breaking Changes + +- `orphan_types`: Only object types are accepted here; other types may be added to the schema through `extra_types` instead. #4869 +- Parser: line terminators are no longer allowed in single-quoted strings (as per the GraphQL spec). Escape newline characters instead; see `GraphQL::Language.escape_single_quoted_newline(query_str)` if you need to transform incoming query strings #4834 + +### Deprecations + +- `.tracer(...)` is deprecated, use `.trace_with(...)` instead, using trace modules (https://graphql-ruby.org/queries/tracing.html) #4878 + +### Bug fixes + +- Parser: handle some escaped character edge cases according to the GraphQL spec #4824 +- Analyzers: fix fragment skip/include tracking #4865 +- Remove unused Context modules #4876 + +# 2.2.14 (18 Mar 2024) + +### Bug fixes + +- Parser: properly handle stray hyphens in query strings #4879 + +# 2.2.13 (11 Mar 2024) + +### Bug fixes + +- Tracing: when a new base `:default` trace class is added, merge already-configured trace modules into it #4875 + +# 2.2.12 (6 Mar 2024) + +### Deprecations + +- `Schema.{query|mutation|subscription}_execution_strategy` methods are deprecated without replacement #4867 + +### Breaking Changes + +- Connections: Revert changes to `hasNextPage` returning `false` when no `first` is given (previously changed in 2.2.6) #4866 + +### Bug fixes + +- Complexity: handle unauthorized argument errors better #4868 +- Pass `context` when fetching argument for `loads: ...` #4870 + +# 2.2.11 (27 Feb 2024) + +### New features + +- Sentry: support transaction names in tracing #4853 + +### Bug fixes + +- Tracing: handle unknown trace modes at runtime #4856 + +# 2.2.10 (20 Feb 2024) + +### New features + +- Parser: support directives on variable definitions #4847 + +### Bug fixes + +- Fix compatibility with Ruby 3.4 #4846 +- Tracing: Fix applying default options to non-default modes #4849, #4850 + +# 2.2.9 (15 Feb 2024) + +### New features + +- Complexity: Treat custom Connection fields as metadata (like `totalCount`), not as if they were evaluated for each item in the list #4842 +- Subscriptions: Serialize `ActiveRecord::Relation`s given to `.trigger` #4840 + +### Bug fixes + +- Complexity: apply configured `complexity ...` to connection fields #4841 +- Authorization: properly handle Resolver arguments that return `false` for `#authorized?` #4839 + +# 2.2.8 (7 Feb 2024) + +### New features + +- Responses have `"errors"` before `"data"`, as recommended by the GraphQL spec #4823 + +### Bug fixes + +- Sentry: fix integration with other trace modules #4830 +- Sentry: fix when child span is `nil` (test environments) #4828 +- Remove needless Base64 backport #4820 +- Fix module arrangement to support RDoc #4819 + +# 2.2.7 (29 Jan 2024) + +### Deprecations + +- Deprecate returning `.resolve` dataloader requests (use `.load` instead) #4807 +- Deprecate `error_bubbling(true)`, no replacement. Please open an issue if you need this option. #4813 + +### Bug fixes + +- Remove unused `racc` dependency #4814 +- Fix `backtrace: true` when used with `@defer` and batch-loaded lists #4815 +- Accept input objects when required arguments aren't provided but have default values #4811 + +# 2.2.6 (25 Jan 2024) + +### Deprecations + +- `instrument(:query | :multiplex, ...)` was deprecated, use a `trace_with` module instead. #4771 +- Legacy `PlatformTracing` classes are deprecated, use a `PlatformTrace` module instead #4779 + +### New features + +- `FieldUsage` analyzer: returns a `used_deprecated_enum_values: ...` array in its result Hash #4805 +- `validate_timeout` applies to query analysis as well as static validation #4800 +- `SentryTrace` is added for instrumenting with Sentry #4775 + +### Bug fixes + +- `FieldUsage` analyzer: properly find deprecated arguments in non-null input objects #4805 +- DataDog: replace usage of `span_type` setter with `span` setter #4776 +- Fix coercion error handing with given `null` values #4799 +- Raise a better error when variables are defined with non-input types #4791 +- Fix `hasNextPage` when `max_page_size` is set #4780 + +# 2.2.5 (10 Jan 2024) + +### Bug fixes + +- Parser: fix enum values named `type` #4772 +- GraphQL::Deprecation: remove this unused helper module #4769 + +# 2.2.4 (3 Jan 2024) + +### Bug fixes + +- AsyncDataloader: don't resolve fields with event loop #4757 +- Parser: properly parse some fields and args named after keywords #4759 +- Performance: use `all?` to check classes directly #4760 + +# 2.2.3 (28 Dec 2023) + +### Bug fixes + +- AsyncDataloader: avoid leftover `suspended` Fibers #4754 +- Generators: fix path and constant name of BaseResolver #4755 + +# 2.2.2 (27 Dec 2023) + +### Bug fixes + +- Dataloader: remove `Fiber#transfer` support because Ruby's control flow is unpredictable (#4748, #4752, #4743) +- Parser: fix handling of single-token document +- QueryComplexity: improve performance + +# 2.2.1 (20 Dec 2023) + +### Bug fixes + +- `AsyncDataloader`: re-raise errors from fields and sources #4736 +- Parser: fix parsing directives on interfaces in SDL #4738 + +# 2.2.0 (18 Dec 2023) + +### Breaking changes + +- `loads:` now requires a schema's `self.resolve_type` method to be implemented so that loaded objects can be verified to be of the expected type #4678 +- Tracing: the new Ruby-based parser doesn't emit a "lex" event. (`graphql/c_parser` still does.) + +### New features + +- `GraphQL::Dataloader::AsyncDataloader`: a Dataloader class that uses the `async` gem to run I/O from fields and Dataloader sources in parallel #4727 +- Parser: use a heavily-optimized lexer and a hand-written parser for better performance #4718 +- `run_graphql_field`: a helper method for running fields in tests #4732 + +# 2.1.10 (27 Dec 2023) + +- Dataloader: remove Fiber#transfer support because of unpredictable Ruby control flow #4753 + +# 2.1.9 (21 Dec 2023) + +### Bug fixes + +- Dataloader: fix some fiber scheduling bugs #4744 + +# 2.1.8 (18 Dec 2023) + +### New features + +- Rails generators: generate a base resolver class by default #4513 +- Dataloader: add some support for transfer-based Fiber schedulers, simplify algorithm #4625 #4729 +- `prepare`: check for the named method on the argument owner, too #4717 + +# 2.1.7 (4 Dec 2023) + +### New features + +- Make `NullContext` inherit from `Context`, to make typechecking easier #4709 +- Accept a custom `Schema.query_class` to use for executing queries #4679 + +### Bug fixes + +- Default `reauthorize_scoped_objects` to false #4720 +- Fix `subscriptions.trigger` with custom enum values #4713 +- Fix `backtrace: true` with GraphQL-Pro `@defer` #4708 +- Omit `to_h` from Input Object validation error message #4701 +- When trimming whitespace from block strings, remove first and last lines that contain only whitespace #4704 + +# 2.1.6 (2 Nov 2023) + +### Breaking Changes + +- The parser cache is now opt-in. Add `config.graphql.parser_cache = true` to your Rails environment setup to enable it. #4648 + +### New features + +- New `ISO8601Duration` scalar #4688 + +### Bug fixes + +- Trace: fix custom trace mode inheritance #4693 + +# 2.1.5 (25 Oct 2023) + +### Bug fixes + +- Logger: Fix `Schema.default_logger` when Rails is present but doesn't have a logger #4686 + +# 2.1.4 (24 Oct 2023) + +### New features + +- Add `Query#logger` #4674 +- Lookahead: Add `with_alias:` option #2912 +- Improve support for `load_application_object_failed` #4667 + +### Bug fixes + +- Execution: Fix runtime loop in some cases with fragments #4684 +- Fix `Connection#initialize` outside of execution #4675 +- Fix ParseError in `Subscriptions#trigger` #4673 +- Mongo: don't load all records in hasNextPage #4671 +- Interfaces: fix `definition_methods` when interfaces implement other interfaces #4670 +- Migrate `NullContext` to use the built-in Singleton module #4669 +- Speed up type lookup #4664 +- Fix `ScopeExtension#after_resolve` outside of execution #4685 +- Speed up `one_of?` checks #4680 + +# 2.1.3 (12 Oct 2023) + +### Bug fixes + +- Tracing: fix legacy tracers added to `GraphQL::Schema` #4663 +- Add `racc` as a dependency because it's not included by default in Ruby 3.3 #4661 +- Connections: don't add automatic connection behaviors for types named "Connection" #4668 + +# 2.1.2 (11 Oct 2023) + +### New features + +- Depth: accept `count_introspection_fields: false` #4658 +- Dataloader: add `get_fiber_variables` and `set_fiber_variables` #4593 +- Trace: Add `Schema.default_trace_mode` #4642 + +### Bug fixes + +- Fix merging results after calling directives #4639 #4660 +- Visibility: don't reveal implementers of hidden abstract types #4589 +- Bump required Ruby version to 2.7 since numbered block arguments are used #4659 +- `hash_key:`: use the configured hash key when the underlying Hash has a default value Proc #4656 + +# 2.1.1 (2 Oct 2023) + +### New features + +- Mutations: `HasSingleInput` provides Relay Classic-like `input: ...` argument behavior #4581 +- Add `@specifiedBy` default directive #4633 +- Analysis: support `visit?` hook to skip visit but still return a value +- Add `context.scoped` for a long-lived reference to the current scoped context #4605 + +### Bug fixes + +- Sanitized printer: Correctly print enum variable defaults #4652 +- Schema printer: use `extend schema` when the schema has directives #4647 +- Performance: pass runtime state through interpreter code #4621 +- Performance: add `StaticVisitor` for faster AST visits #4645 +- Performance: faster field lookup #4626 +- Improve generator templates #4627 +- Dataloader: clear cache between root mutation fields #4617 +- Performance: Improve argument checks #4622 +- Remove unused legacy connection code #4606 + +# 2.1.0 (30 Aug 2023) + +### Breaking changes + +- Visitor: legacy-style proc-based visitors are no longer supported #4577 #4583 +- Deprecated `GraphQL::Filter` is removed #4325 +- Language::Printer has been re-written to append to a buffer; custom printers will need to be updated #4394 + +### New features + +- Authorization: Items in a list can skip object-level `.authorized?` checks if the type is configured with `reauthorize_scoped_objects(false)` #3994 +- Subscriptions: `unsubscribe(...)` accepts a value to be used to return a result along with unsubscribing #4283 +- Language::Printer is much faster #4394 + +# 2.0.27 (30 Aug 2023) + +### New features + +- Validators: Support `%{value}` in custom messages #4601 + +### Bug fixes + +- Resolvers: Support `return false, nil` from `ready?` and `authorized?` #4585 +- Enums: properly load directives from Schema IDL #4596 +- Language: faster scanner #4576 +- Language: support fields and arguments named `"null"` #4586 +- Language: fix block string quote unescaping #4580 +- Generator: use generated node type in Relay-related fields #4598 + +# 2.0.26 (8 Aug 2023) + +### Bug fixes + +- Datadog Tracing: fix LocalJumpError #4579 + +# 2.0.25 (7 Aug 2023) + +### New features + +- Tracing: add trace modes #4571 +- Dataloader: add `Source#result_key_for` for customizing cache keys in sources #4569 + +### Bug fixes + +- Tracing: Support multiple tracing platforms at once #4543 + +# 2.0.24 (27 Jun 2023) + +### New features + +- `Schema::Object.wrap` can be used to customize how objects are (or aren't) wrapped by `GraphQL::Schema::Object` instances at runtime #4524 +- `Query`: accept a `static_validator:` option in `#initialize` to use instead of the default validation configuration. + +### Bug fixes + +- Performance: Reduce memory usage when adding types to a schema #4533 +- Performance, `Dataloader`: when loading specific keys, only run dataloader until those specific keys are resolved #4519 + +# 2.0.23 (19 Jun 2023) + +### New features + +- Printer: print extensions in SDL #4516 +- Trace: accept trace instances during query execution #4497 +- AlwaysVisible: Make a way to bypass type visibility #4442, #4491 + +### Bug fixes + +- Tests: fix assertion for Ruby 3.3.0-dev #4515 +- Performance: improve fragment possible type lookup #4506 +- Docs: document Timeout can handle floats #4505 +- Performance: use a dedicated object for field extension state #4401 +- Backtrace: fix `backtrace: true` with other trace modules #4505 +- Handle `context.warden` being nil #4503 +- Dev: disable Minitest::Reporters for RubyMin #4494 +- Trace: fix compatibility with inheritance #4487 +- Context: fix NullContext compatibility with fetch, dig and key? #4483 + +# 2.0.22 (17 May 2023) + +### New features + +- Warden: manually instantiating doesn't require a `filter` instance #4462 + +### Bug fixes + +- Enum: fix procs for enum values #4474 +- Lexer: force UTF-8 encoding #4467 +- Trace: inherit superclass `trace_options` #4470 +- Dataloader: properly run mutations in sequence #4461 +- NotificationsTrace: Add `execute_multiplex.graphql` event #4460 +- Fix `Context#dig` when called with one key #4458 +- Performance: Use a plain hash for selection sets at runtime #4453 +- Performance: Memoize current trace #4450, #4452 +- Performance: Pass is_non_null to runtime check #4449 +- Performance: Use `compare_by_identity` on some runtime caches +- Properly support nested queries (fix `Thread.current` clash) #4445 + +# 2.0.21 (11 April 2023) + +### Deprecations + +- Deprecate `GraphQL::Filter` (use `visible?` methods instead) #4424 + +### New features + +- PrometheusTracing: support histograms #4418 + +### Bug fixes + +- Backtrace: improve compatibility with `trace_with` #4437 +- Consolidate internally-used empty value constants #4434 +- Fix some warnings #4422 +- Performance: improve runtime speed #4436 #4433 #4428 #4430 #4427 #4399 +- Validation: fix inline fragment selection on scalar #4429 +- `@oneOf`: print definition in the SDL when it's used +- SDL: load schema directives when they're used +- Appsignal tracing: Fix `resolve_type` definition + +# 2.0.20 (30 March 2023) + +### Bug fixes + +- `.resolve_type`: fix returning `[Type, false]` from resolve_type #4412 +- Parsing: improve usage of `GraphQL.default_parser` #4411 +- AppsignalTrace: implement missing methods #4390 +- Runtime: Fix `current_depth` method in some lazy lists #4386 +- Performance: improve `Object` object shape #4365 +- Tracing: return execution errors raised from field resolution to `execute_field` hooks #4398 + +# 2.0.19 (14 March 2023) + +### Bug fixes + +- Scoped context: fix `context.scoped_context.current_path` #4376 +- Tracing: fix `tracer` inheritance in Schema classes #4379 +- Timeout: fix `Timeout` plugin when other tracers are used #4383 +- Performance: use Arrays instead of `GraphQL::Language::Token`s when scanning #4366 + +# 2.0.18 (9 March 2023) + +### Breaking Changes + +- Tracing: `"execute_field"` events on fields defined on interface types will now receive the _interface_ type as `data[:owner]` instead of the current object type. To get the old behavior, use `data[:object].class` instead. #4292 + +### New features + +- Add `TypeKind#leaf?` #4352 + +### Bug fixes + +- Tracing: use the interface type as `data[:owner]` instead of the object type #4292 +- Performance: improve Shape compatibility of `GraphQL::Schema::Field` #4360 +- Performance: improve Shape compatibility of `GraphQL::Schema::Warden` #4361 +- Performance: rewrite the token scanner in plain Ruby #4369 +- Performance: make `deprecation_reason` faster #4356 +- Performance: improve lazy value resolution in execution #4333 +- Performance: create `current_path` only when the application needs it #4342 +- Performance: add `GraphQL::Tracing::Trace` as a lower-overhead tracing API #4344 +- Connections: fix `hasNextPage` for already-loaded ActiveRecord Relations #4349 + + +# 2.0.17.2 (29 March 2023) + +### Bug fixes + +- Unions and Interfaces: support returning `[type_module, false]` from `resolve_type` #4413 + +# 2.0.17.1 (27 March 2023) + +### Bug fixes + +- Tracing: restore behavior returning execution errors raised during field resolution #4402 + +# 2.0.17 (14 February 2023) + +### Breaking changes + +- Enums: require at least one value in a definition #4278 + +### New features + +- Enums: support `nil` as a Ruby value #4311 + +### Bug fixes + +- Don't re-encode ASCII strings as UTF-8 #4319, #4343 +- Fix `handle_or_reraise` with arguments validation #4341 +- Performance: Remove error handling from `Lazy#value` (unused) #4335 +- Performance: Use codegen instead of dynamic dispatch in `Language::Visitor` and `Analysis::AST::Visitor` #4338 +- Performance: reduce indirection in `#introspection?` and `#graphql_name` #4327 +- Clean up thread-based state after running queries #4329 +- JSON types: don't pass raw NullValue AST nodes to `coerce_input` #4324, #4320 +- Performance: reduce `.is_a?` calls at runtime #4318 +- Performance: cache interface type memberships #4311 +- Performance: eagerly define some type instance variables for Shape friendliness #4300 #4295 #4297 +- Performance: reduce argument overhead, don't scope introspection by default, reduce duplicate call to Field#type #4317 +- Fix anonymous `eval` usage #4288 +- Authorization: fix field auth fail call after lazy #4289 +- Subscriptions: fix `loads:`/`as:` + +# 2.0.16 (19 December 2022) + +### Breaking changes + +- `Union`: Only accept Object types in `possible_types` (previously, other types were also accepted, but this was against the spec) #4269 + +### New features + +- Rake: support introspection query options in the `RakeTask` #4247 +- Subscriptions: Merge `.trigger(... context: { ... })` into the query context when running updates #4242 + +### Bug fixes + +- Make BaseEdge and subclasses return true for `.default_relay?` #4272 +- Validation: return a proper error for duplicate-named fragments when used indirectly #4268 +- Don't re-apply `scope_items` to `nodes { ... }` or `edges { ... }` arrays #4263 +- Fix `Concurrent::Map` initialization to prevent race conditions +- Speed up scoped context lookup #4245 +- Support overriding built-in context keys #4239 +- Context: properly `dig` into `:current_arguments` #4249 + +# 2.0.15 (22 October 2022) + +### New features + +- SDL: support extensions on the schema itself #4203 +- SDL: recognize `.graphqls` files in `.from_definition` #4204 +- Schema: add a reader method of `TypeMembership#options` #4209 + +### Bug fixes + +- Node Behaviors: call the id-from-object hook with the type definition, not the type instance #4233 +- RelayClassicMutation: add a period to the generated description of the payload type #4229 +- Dataloader: make scoped context work with Dataloader #4220 +- SDL: fix parsing repeatable directives #4218 +- Lookahead: reduce more allocations in `.selects?` #4212 +- Introspection Query: strip blank lines from generated query strings #4208 +- Enums: Add error handling to result coercion #4206 +- Lookahead: add `selected_type:` to `.selects?` #4194 +- Lookahead: fix `.selects?` on unions #4193 +- Fields: use field-local `connection:` config over resolver config #4191 + +# 2.0.14 (8 September 2022) + +### New features + +- Input Objects: support `one_of` for input objects that allow exactly one argument #4184 +- Dataloader: add `source.merge({ ... })` for adding objects to dataloader source caches #4186 +- Validation: generate new schemas with a suggested `validate_max_errors` of 100 #4179 + +### Bug fixes + +- Lookahead: improve performance when field names are given as symbols #4189 +- Runtime: simplify some internal code #4183 +- Datadog tracing: remove deprecated options #4159 + +# 2.0.13 (12 August 2022) + +### New features + +- Fields: add configuration methods for `default_value` and `prepare` #4156 +- Static validation: merge directive errors when they're on the same location or directive + +### Bug fixes + +- Subscriptions: properly use the given `.trigger(... context: )` for determining subscription root field visibility #4160 +- Fix fields that use `hash_key:` and have a falsy value for that key #4132 +- Variable validation: respect `validate_max_error` limit +- Performance: use `Array#+` to add objects during execution #4142 + +# 2.0.12 (19 July 2022) + +### New features + +- Support returning `[Type, nil]` from `resolve_type` #4130 + +### Bug fixes + +- SDL: Don't print empty braces for input objects with no arguments #4138 +- Arguments: always call `prepare` before loading objects based on ID (`loads:`) #4128 +- Don't support re-assigning `Query#validate=` after validation has run #4127 + +# 2.0.11 (20 June 2022) + +### New features + +- Support full unicode range #4090 + +### Bug fixes + +- Subscriptions: support overriding subscriptions in subclasses #4108 +- Schema: support types with duplicate names and cyclical references #4107 +- Connections: don't exceed application-applied `LIMIT` with `max_page_size` #4104 +- Field: add `Field#relay_nodes_field` config reader #4103 +- Remove partial `opentelementry` implementation, oops #4086 +- Remove unused method `Lazy.resolve` + +# 2.0.10 (20 June 2022) + +Oops, this version was accidentally released to RubyGems as "2.10.0". I yanked it. See 2.0.11 instead. + +# 2.0.9 (31 May 2022) + +### New features + +- Connections: use `Schema.default_page_size`, `Field#default_page_size`, or `Resolver.default_page_size` when one of them is available and no `first` or `last` is given #4081 +- Tracing: Add `OpenTelementryTracing` #4077 + +### Bug fixes + +- Field usage analyzer: don't crash on null input objects #4078 +- Complexity: properly handle `ExecutionError`s raised in `prepare:` hooks #4079 + +# 2.0.8 (24 May 2022) + +### New Features + +- Fields: return `fallback_value:` when method or hash key field resolution fails #4069 +- Support `hash_key:` lookups on Hash-like objects #4072 +- Datadog tracing: support `prepare_span` hook for adding custom tags #4067 + +### Bug fixes + +- Fields: When `hash_key:` is given, populate `#method_str` based on it #4072 +- Errors: rescue errors raised when calling `.each` on list values #4052 +- Date type: continue accepting dates without hyphens #4061 +- Parser: properly parse empty type definitions #4046 + +# 2.0.7 (25 April 2022) + +### New Features + +- Subscriptions: support `validate_update: false` to disable validation when running subscription updates #4039 +- Expose duplicated name on `DuplicateNamesError` #4022 + +### Bug Fixes + +- Datadog: improve tracer #4038 +- `hash_key:` try stringified hash key when resolving fields (this restores previous behavior) #4043 +- Printer: Don't print empty field set when types have no fields (`{\n}`) #4042 +- Dataloader: improve handoff between lazy resolution and dataloader resolution #4036 +- Remove unused `Lazy::Resolve` module from legacy execution code #4035 + +# 2.0.6 (14 April 2022) + +### Bug fixes + +- Dataloader: make multiplexes use custom dataloaders #4026 +- ISO8601Date: properly accept `nil` as input #4025 +- Mutation: fix error message when `ready?` returns an invalid result #4029 +- ISO8601 scalars: add `specified_by_url` configs #4014 +- Array connection: don't return all items when `before` is the first cursor #4012 +- Introspection: fix typo `specifiedByUrl` -> `specifiedByURL` +- Fields: fix `hash_key` to take priority over method lookup #4015 + +# 2.0.5 (28 March 2022) + +### Bug Fixes + +- Resolvers: fix inheriting arguments when parent classes aren't hooked up directly to the schema #4006 + +# 2.0.4 (21 March 2022) + +### Bug fixes + +- Fields: make sure `null:` config overrides a default from a resolver #4000 + +# 2.0.3 (21 March 2022) + +### Bug fixes + +- Fields: make sure field configs override resolver defaults #3975 +- Fix `Field#scoped?` when the field uses a resolver #3990 +- Allow schema members to have multiple of `repeatable` directives #3986 +- Remove some legacy code #3979 #9995 +- SDL: fix indirect interface implementation when loading a schema #3982 +- Datadog tracing: Support ddtrace 1.0 #3978 +- Fix `Node` implementation when connection types include built-in behavior modules #3967 +- Small stack trace size reduction #3957 + +# 2.0.2 (1 March 2022) + +### New features + +- Reduce schema memory footprint #3959 + +### Bug fixes + +- Mutation: Correctly use a configured `type(...)` #3965 +- Interfaces: De-duplicate indirectly implemented interfaces #3932 +- Remove an unnecessary require #3961 + +# 2.0.1 (21 February 2022) + +### Breaking changes + +- Resolvers: refactored so that, instead of _copying_ configurations to `field ...` instances, `GraphQL::Schema::Field`s reference their provided `resolver: ...`, `mutation: ...`, or `subscription: ...` classes for many properties. This _shouldn't_ break anything -- all of graphql-ruby's own tests passed just fine -- but it's mentioned here in case you notice anything out-of-sorts in your own application #3916 +- Remove deprecated field options `field:`, `function:`, and `resolve:` (these were already no-ops, but they were overlooked in 2.0.0) #3917 + +### Bug fixes + +- Scoped context: fix usage with dataloader #3950 +- Subscriptions: support multiple definitions for subscription root fields with `.trigger` #3897 #3935 +- Improve some error messages #3920 #3923 +- Clean up scalar validation code #3982 + +# 2.0.0 (9 February 2022) + +### Breaking Changes + +- __None, ideally.__ If you have an application that ran without warnings on v1.13, you should be able to update to 2.0.0 without a hitch. If this isn't the case, please [open an issue](https://github.com/rmosolgo/graphql-ruby/issues/new?template=bug_report.md&title=[2.0%20update]%20describe%20your%20problem) and let me know what happened! I plan to maintain 1.13 for a while in order to ensure a smooth transition. +- But, many legacy code components were removed, so if there are any more references to those, there will be name errors! See #3729 for a list of removed components. + +# 1.13.19 (2 February 2023) + +### Bug fixes + +- Performance: don't re-encode schema member names #4323 +- Performance: fix a duplicate field.type call #4316 +- Performance: use `scope: false` for introspection types #4315 +- Performance: improve argument coercion and validation #4312 +- Performance: improve interface type membership lookup #4309 + +# 1.13.18 (10 January 2023) + +### New Features + +- `hash_key:`: perform `[...]` lookups even when the underlying object isn't a Hash #4286 + +# 1.13.17 (17 November 2022) + +### Bug fixes + +- Handle ExecutionErrors from prepare hooks when calculating complexity #4248 + +# 1.13.16 (31 August 2022) + +### New Features + +- Make variable validation respect `validate_max_errors` #4178 + +# 1.13.15 (30 June 2022) + +### Bug fixes + +- Remove partial OpenTelementry tracing #4086 +- Properly use `Query#validate` to skip static validation #3881 + +# 1.13.14 (20 June 2022) + +### New Features + +- Add `Field#relay_nodes_field` reader #4103 +- Datadog: detect tracing module #4100 + +# 1.13.13 (31 May 2022) + +### New features + +- Datadog: update tracer for ddtrace 1.0 #4038 +- Datadog: Add `#prepare_span` hook for custom tags #4067 +- Tracing: Add `OpenTelementry` tracing #4077 + +# 1.13.12 (14 April 2022) + +- Pass `context[:dataloader]` to multiplex context #4026 +- Add a deprecation warning to `.accepts_definitions` #4002 + +# 1.13.11 (21 March 2022) + +### Deprecations + +- `RangeAdd` warns when `context:` isn't provided (it's required in GraphQL-Ruby 2.0) #3996 + +# 1.13.10 + +### Breaking changes + +- `id` fields: #3914 Previously, when a field was created with `global_id_field`, it would pass a _legacy-style_ type definition (an instance of `GraphQL::ObjectType`) to `Schema.id_from_object(...)`. Now, it passes a class-based definition instead. If your `id_from_object(...)` method was using any methods from those legacy definitions, they should be migrated. (Most notably, uses of `type.name` should be migrated to `type.graphql_name`.) + +### Deprecations + +- Connections: deprecation warnings were added to configuration methods `.bidirectional_pagination = ...` and `.default_nodes_field = ...`. These two configurations don't apply to the new pagination implementation, so they can be removed. #3918 + +# 1.13.9 (9 February 2022) + ### Breaking changes +- Authorization: #3903 In graphql-ruby v1.12.17-1.13.8, when input objects used `prepare: -> { ... }` , the returned values were not authorized at all. However, this release goes back to the behavior from 1.12.16 and before, where a returned `Hash` is validated just like an input object that didn't have a `prepare:` hook. To get the previous behavior, you can implement `def self.authorized?` in the input object you want to skip authorization in: + + ```ruby + class Types::BaseInputObject < GraphQL::Schema::InputObject + def self.authorized?(obj, value, ctx) + if value.is_a?(self) + super + else + true # graphql-ruby skipped auth in this case for v1.12.17-v1.13.8 + end + end + end + ``` + +### Bug fixes + +- Support re-setting `query.validate = ...` after a query is initialized #3881 +- Handle validation errors in connection complexity calculations #3906 +- Input Objects: try to authorize values when `prepare:` returns a Hash (this was default < v1.12.16) #3903 +- SDL: fix when a type has two directives + +# 1.13.8 (1 February 2022) + +### Bug fixes + +- Introspection query: hide newly-supported fields behind arguments, maintain backwards-compatible INTROSPECTION_QUERY #3877 + +# 1.13.7 (28 January 2022) + +### New Features + +- Arguments: `replace_null_with_default: true` replaces incoming `null`s with the configured `default_value:` #3871 +- Arguments: support `dig: [key1, key2, ...]` for nested hash key access #3856 +- Generators: support more Postgresql field types #3577 +- Generators: support downcased generator argument types #3577 +- Generators: add an input type generator #3577 +- Generators: support namespaces in generators #3577 + +### Bug Fixes + +- Field: better error for nil `owner` #3870 +- ISO8601DateTime: don't accept inputs with partial time parts #3862 +- SDL: fix for base connection classes that implement interfaces #3859 +- Cops: find `required: true` on `f.argument` calls (with explicit receiver) #3858 +- Analysis: handle undefined or hidden fields with `nil` in `visitor.field_definition` #3857 + +# 1.13.6 (20 January 2022) + +### New features + +- Introspection: support `__Schema.description`, `__Directive.isRepeatable`, `__Type.specifiedByUrl`, and `__DirectiveLocation.VARIABLE_DEFINITION` #3854 +- Directives: Call `Directive.resolve_each` for list items #3853 +- Dataloader: Run each list item in its own fiber (to support batching across list items) #3841 + +### Bug fixes + +- RelationConnection: Preserve `OFFSET` when it's already set on the relation #3846 +- `Types::ISO8601Date`: Accept default values as Ruby date objects #3563 + +# 1.13.5 (13 January 2022) + +### New features + +- Directives: support `repeatable` directives #3837 +- Tracing: use `context[:fallback_transaction_name]` when operations aren't named #3778 + +### Bug fixes + +- Performance: improve performance of queries with directives #3835 +- Fix crash on undefined constant `NodeField` #3832 +- Fix crash on partially-required `ActiveSupport` #3829 + +# 1.13.4 (7 January 2022) + +### Bug fixes + +- Connections: Fix regression in 1.13.3 on unbounded Relation connections #3822 + +# 1.13.3 (6 January 2022) + +### Deprecations + +- `GraphQL::Relay::NodeField` and `GraphQL::Relay::NodesField` are deprecated; use `GraphQL::Types::Relay::HasNodesField` or `GraphQL::Types::Relay::HasNodeField` instead. (The underlying field instances require a reference to their owner type, but `NodeField` and `NodesField` can't do that, since they're shared instances) #3791 + +### New features + +- Arguments: support `required: :nullable` to make an argument required to be _present_, even if it's `null` #3784 +- Connections: When paginating an AR::Relation, use already-loaded results if possible #3790 +- Tracing: Support DRY::Notifications #3776 +- Improve the error when a Ruby method doesn't support the defined GraphQL arguments #3785 +- Input Objects: call `.authorized?` on them at runtime #3786 +- Field extensions: add `extras(...)` for extension-related extras with automatic cleanup #3787 + +### Bug fixes + +- Validation: accept nullable variable types for arguments with default values #3819 +- Validation: raise a better error when a schema receives a `query { ... }` but has no query root #3815 +- Improve the error message when `Schema.get_field` can't make sense of the arguments #3815 +- Subscriptions: losslessly serialize Rails 7 TimeWithZone #3774 +- Field Usage analyzer: handle errors from `prepare:` hooks #3794 +- Schema from definition: fix default values with camelized arguments #3780 + +# 1.13.2 (15 December 2021) + +### Bug fixes + +- Authorization: only authorize arguments _once_, after they've been loaded with `loads:` #3782 +- Execution: always provide an `Interpreter::Arguments` instance as `context[:current_arguments]` #3783 + +# 1.13.1 (13 December 2021) + ### Deprecations +- `.to_graphql` and `.graphql_definition` are deprecated and will be removed in GraphQL-Ruby 2.0. All features using those legacy definitions are already removed and all behaviors should have been ported to class-based definitions. So, you should be able to remove those calls entirely. Please open an issue if you have trouble with it! #3750 #3765 + +### New features + +- `context.response_extensions[...] = ...` adds key-value pairs to the `"extensions" => {...}` hash in the final response #3770 +- Connections: `node_type` and `edge_type` accept `field_options:` to pass custom options to generated fields #3756 +- Field extensions: Support `default_argument ...` configuration for adding arguments if the field doesn't already have them #3751 + +### Bug fixes + +- fix `rails destroy graphql:install` #3739 +- ActionCable subscriptions: close channel when unsubscribing from server #3737 +- Mutations: call `.authorized?` on arguments from `input_object_class`, `input_type`, too #3738 +- Prevent blank strings with `validates: { length: ... }, allow_blank: false` #3747 +- Lexer: return mutable strings when strings are empty #3741 +- Errors: don't send execution errors to schema-defined handlers from inside lazies #3749 +- Complexity: don't multiple `edges` and `nodes` fields by page size #3758 +- Performance: fix validation performance degradation from 1.12.20 #3762 + +# 1.13.0 (24 November 2021) + +Since this version, GraphQL-Ruby is tested on Ruby 2.4+ and Rails 4+ only. + +### Breaking changes + +- ActionCable Subscriptions: No update is delivered if all subscriptions return `NO_UPDATE` #3713 +- Subscription classes: If a subscription has a `scope ...` configuration, then a `scope:` option is required in `.trigger(...)`. Use `scope ..., optional: true` to get the old behavior. #3692 +- Arguments whose default values are used aren't checked for authorization #3665 +- Complexity: Connection fields have a default complexity implementation based on `first`/`last`/`max_page_size` #3609 +- Arguments: if arguments are configured to return `false` for `.visible?(context)`, their default values won't be applied + +### New features + +- Visibility: A schema may contain multiple members with the same name. For each name, GraphQL-Ruby will use the one that returns true for `.visible?(context)` for each query (and raise an error if multiple objects with the same name are visible). #3651 #3716 #3725 +- Dataloader: `nonblocking: true` will make GraphQL::Dataloader use `Fiber.scheduler` to run fields and load data with sources, supporting non-blocking IO. #3482 +- `null: true` and `required: true` are now default. GraphQL-Ruby includes some RuboCop cops, `GraphQL/DefaultNullTrue` and `GraphQL/DefaultRequiredTrue`, which identify and remove those needless configurations. #3612 +- Interfaces may `implement ...` other interfaces #3613 + +### Bug fixes + +- Enum `value(...)` and Input Object `argument(...)` methods return the defined object #3727 +- When a field returns an array of mixed errors and values, the result will contain `nil` where there were errors in the list #3656 + +# 1.12.24 (4 February 2022) + +### Bug fixes + +- SDL: fix parsing schemas where types have multiple directives #3886 + +# 1.12.23 (20 December 2021) + +### Bug fixes + +- FieldUsage analyzer: handle arguments that raise an error during `prepare:` #3795 + +# 1.12.22 (8 December 2021) + +### Bug fixes + +- Static validation: fix regression and improve performance of fields_will_merge validation #3761 + +# 1.12.21 (23 November 2021) + +### Bug fixes + +- Validators: Fix `format:`/`allow_blank: true` to correctly accept a blank string #3726 +- Generators: generate a correct `Schema.type_error` hook #3722 + +# 1.12.20 (17 November 2021) + +### New Features + +- Static validation: improve error messages when fields won't merge #3698 +- Generators: improve id_from_object and type_error suggested implementations #3710 +- Connections: make the new connections module fall back to old connections #3704 + +### Bug fixes + +- Dataloader: re-enqueue sources when one call to `yield` didn't satisfy their pending requests #3707 +- Subscriptions: Fix when JSON-typed arguments are used #3705 + +# 1.12.19 (5 November 2021) + +### New Features + +- Argument validation: Make `allow_null` and `allow_blank` work standalone #3671 +- Add field and path info to Encoding errors #3697 +- Add `Resolver#unauthorized_object` for handling loaded but unauthorized objects #3689 + +### Bug fixes + +- Properly hook up `Schema.validate_max_errors` at runtime #3691 + +# 1.12.18 (2 November 2021) + +### New features + +- Subscriptions: Add `NO_UPDATE` constant for skipping subscription updates #3664 +- Validation: Add `Schema.validate_max_errors(integer)` for halting validation when it reaches a certain number #3683 +- Call `self.load_...` methods on Input objects for loading arguments #3682 +- Use `import_methods` in Refinements when available #3674 +- `AppsignalTracing`: Add `set_action_name` #3659 + +### Bug fixes + +- Authorize objects returned from custom `def load_...` methods #3682 +- Fix `context[:current_field]` when argument `prepare:` hooks raise an error #3666 +- Raise a helpful error when a Resolver doesn't have a configured `type(...)` #3679 +- Better error message when subscription clients are using ActionCable #3668 +- Dataloader: Fix dataloading of input object arguments #3666 +- Subscriptions: Fix parsing time zones #3667 +- Subscriptions: Fix parsing with non-null arguments #3620 +- Authorization: Call `schema.unauthorized_field` for unauthorized resolvers +- Fix when literal `null` is used as a value for a list argument #3660 + +# 1.12.17 (15 October 2021) + +### New features + +- Support `extras: [:parent]` #3645 +- Support ranges in `NumericalityValidator` #3635 +- Add some Dataloader methods for testing #3335 + +### Bug fixes + +- Support input object arguments called `context` #3654 +- Support single-item default values for list arguments #3652 +- Ensure query strings are strings before running a query #3628 +- Fix empty hash kwargs for Ruby 3 #3610 +- Fix wrongly detecting Ipnut objects in authorization #3606 + +# 1.12.16 (31 August 2021) + ### New features +- Connections: automatically support Mongoid 7.3 #3599 +- Support `def self.topic_for` in Subscription classes for server-filtered streams #3597 +- When a list item or object field has an invalid null, stop executing that list or + +### Bug fixes + +- Perf: don't refine String when unnecessary #3593 +- BigInt: always parse as base 10 #3586 +- Errors: only return one error when a node in a non-null connection has an invalid null #3601 + +# 1.12.15 (23 August 2021) + +### New Features + +- Subscriptions: add support for multi-tenant setups when deserializing context #3574 +- Analyzers: also track deprecated arguments #3549 + +# 1.12.14 (22 July 2021) + ### Bug fixes +- SDL: support directive arguments referencing overridden built-in scalars #3564 +- Use `"_"` as the name for `field :_, ...` fields #3560 +- Support `sanitized_printer(...)` in the schema definition for `Query#sanitized_query_string` +- `GraphQL::Backtrace`: fix multiplex support + # 1.12.13 (20 June 2021) ### Breaking changes -- Add a trailing newline to the `Schema.to_definition` output sstring #3541 +- Add a trailing newline to the `Schema.to_definition` output string #3541 ### Bug fixes @@ -192,6 +1976,24 @@ ### Bug fixes +# 1.11.10 (5 Nov 2021) + +### Bug fixes + +- Properly hook up `Schema.max_validation_errors` at query runtime #3690 + +# 1.11.9 (1 Nov 2021) + +### New Features + +- `Schema.max_validation_errors(val)` limits the number of errors that can be added during static validation #3675 + +# 1.11.8 (12 Feb 2021) + +### Bug fixes + +- Improve performance of `Schema.possible_types(t)` for object types #3172 + # 1.11.7 (18 January 2021) ### Breaking changes @@ -461,7 +2263,7 @@ FieldExtension: pass extended values instead of originals to `after_resolve` #31 ### New features -- Add options to `implements(...)` and inteface type visibility #2791 +- Add options to `implements(...)` and interface type visibility #2791 - Add `Query#fingerprint` for logging #2859 - Add `--playground` option to install generator #2839 - Support lazy-loaded objects from input object `loads:` #2834 @@ -636,7 +2438,7 @@ FieldExtension: pass extended values instead of originals to `after_resolve` #31 ### Breaking changes -- `GraphQL::Schema::Resolver#initialize` accepts a new keyword argument, `field:`. If you have overriden this method, you'll have to add that keyword to your argument list (and pass it along to `super`.) #2605 +- `GraphQL::Schema::Resolver#initialize` accepts a new keyword argument, `field:`. If you have overridden this method, you'll have to add that keyword to your argument list (and pass it along to `super`.) #2605 ### Deprecations @@ -1036,7 +2838,7 @@ FieldExtension: pass extended values instead of originals to `after_resolve` #31 ### Bug fixes - Argument default values include nested default values #1728 -- Clean up duplciate method defs #1739 +- Clean up duplicate method defs #1739 ### New features @@ -1560,7 +3362,7 @@ FieldExtension: pass extended values instead of originals to `after_resolve` #31 - `GraphQL::Argument.define` builds re-usable arguments #948 - `GraphQL::Subscriptions` provides hooks for subscription platforms #672 - `GraphQL::Subscriptions::ActionCableSubscriptions` implements subscriptions over ActionCable #672 -- More runtime values are accessble from a `ctx` object #923 : +- More runtime values are accessible from a `ctx` object #923 : - `ctx.parent` returns the `ctx` from the parent field - `ctx.object` returns the current `obj` for that field - `ctx.value` returns the resolved GraphQL value for that field @@ -2079,7 +3881,7 @@ FieldExtension: pass extended values instead of originals to `after_resolve` #31 - Absent variables aren't present in `args` #479 - Fix grouped ActiveRecord relation with `last` only #476 -- `Schema#default_mask` & query `only:`/`except:` are combined, not overriden #485 +- `Schema#default_mask` & query `only:`/`except:` are combined, not overridden #485 - Root types can be hidden with dynamic filters #480 ## 1.4.0 (8 Jan 2017) @@ -2261,7 +4063,7 @@ FieldExtension: pass extended values instead of originals to `after_resolve` #31 ### Deprecations -- `InternalRepresentation::Node#children` and `InternalRepresentation::Node#definitions` are deprecated due to the bug described below and the breaking change described above. Instead, use `InternalRepresentation::Node#typed_children` and `InternalRepresentation::Node#defininition`. #373 +- `InternalRepresentation::Node#children` and `InternalRepresentation::Node#definitions` are deprecated due to the bug described below and the breaking change described above. Instead, use `InternalRepresentation::Node#typed_children` and `InternalRepresentation::Node#definition`. #373 ### New features diff --git a/Gemfile b/Gemfile index 078e8cda0dd..0a321a06a3c 100644 --- a/Gemfile +++ b/Gemfile @@ -7,15 +7,16 @@ gem 'bootsnap' # required by the Rails apps generated in tests gem 'stackprof', platform: :ruby gem 'pry' gem 'pry-stack_explorer', platform: :ruby -gem 'graphql-batch' -if RUBY_VERSION >= "2.4" - gem 'pry-byebug' + +if RUBY_VERSION >= "3.2.0" + gem "async", "~>2.0" + gem "minitest-mock" end -# Required for running `jekyll algolia ...` (via `rake site:update_search_index`) -group :jekyll_plugins do - if RUBY_VERSION >= "2.3" - gem 'jekyll-algolia', '~> 1.0' - end +# Website tasks opt in to these dependencies via BUNDLE_WITH=jekyll_plugins. +group :jekyll_plugins, optional: true do + gem 'jekyll' + gem 'jekyll-sass-converter', '~> 2.2' + gem 'jekyll-algolia', '~> 1.0' gem 'jekyll-redirect-from' end diff --git a/Rakefile b/Rakefile index b4ce2045d11..4a48d826c40 100644 --- a/Rakefile +++ b/Rakefile @@ -1,35 +1,32 @@ # frozen_string_literal: true -require "bundler/setup" -Bundler.require +require "bundler/gem_helper" Bundler::GemHelper.install_tasks require "rake/testtask" require_relative "guides/_tasks/site" require_relative "lib/graphql/rake_task/validate" - +require 'rake/extensiontask' Rake::TestTask.new do |t| - t.libs << "spec" << "lib" + t.libs << "spec" << "lib" << "graphql-c_parser/lib" exclude_integrations = [] - ['Mongoid', 'Rails'].each do |integration| + ['mongoid', 'rails'].each do |integration| begin - Object.const_get(integration) - rescue NameError - exclude_integrations << integration.downcase + require integration + rescue LoadError + exclude_integrations << integration end end - t.test_files = Dir['spec/**/*_spec.rb'].reject do |f| - next unless f.start_with?("spec/integration/") - excluded = exclude_integrations.any? do |integration| - f.start_with?("spec/integration/#{integration}/") - end - puts "+ #{f}" unless excluded - excluded + t.test_files = FileList.new("spec/**/*_spec.rb") do |fl| + fl.exclude(*exclude_integrations.map { |int| "spec/integration/#{int}/**/*" }) end - t.warning = false + # After 2.7, there were not warnings for uninitialized ivars anymore + if RUBY_VERSION < "3" + t.warning = false + end end require 'rubocop/rake_task' @@ -42,31 +39,21 @@ else task(default: default_tasks) end -desc "Use Racc & Ragel to regenerate parser.rb & lexer.rb from configuration files" -task :build_parser do - def assert_dependency_version(dep_name, required_version, check_script) - version = `#{check_script}` - if !version.include?(required_version) - raise <<-ERR +def assert_dependency_version(dep_name, required_version, check_script) + version = `#{check_script}` + if !version.include?(required_version) + raise <<-ERR build_parser requires #{dep_name} version "#{required_version}", but found: - $ #{check_script} - > #{version} + $ #{check_script} + > #{version} To fix this issue: - Update #{dep_name} to the required version - Update the assertion in `Rakefile` to match the current version ERR - end end - - assert_dependency_version("Ragel", "7.0.0.9", "ragel -v") - assert_dependency_version("Racc", "1.4.16", %|ruby -e "require 'racc'; puts Racc::VERSION"|) - - `rm -f lib/graphql/language/parser.rb lib/graphql/language/lexer.rb ` - `racc lib/graphql/language/parser.y -o lib/graphql/language/parser.rb` - `ragel -R -F1 lib/graphql/language/lexer.rl` end namespace :bench do @@ -75,6 +62,18 @@ namespace :bench do require_relative("./benchmark/run.rb") end + desc "Benchmark parsing" + task :parse do + prepare_benchmark + GraphQLBenchmark.run("parse") + end + + desc "Benchmark lexical analysis" + task :scan do + prepare_benchmark + GraphQLBenchmark.run("scan") + end + desc "Benchmark the introspection query" task :query do prepare_benchmark @@ -87,6 +86,12 @@ namespace :bench do GraphQLBenchmark.run("validate") end + desc "Profile a validation" + task :validate_memory do + prepare_benchmark + GraphQLBenchmark.validate_memory + end + desc "Generate a profile of the introspection query" task :profile do prepare_benchmark @@ -99,11 +104,76 @@ namespace :bench do GraphQLBenchmark.profile_large_result end + desc "Run benchmarks on a small result" + task :profile_small_result do + prepare_benchmark + GraphQLBenchmark.profile_small_result + end + + desc "Run introspection on a small schema" + task :profile_small_introspection do + prepare_benchmark + GraphQLBenchmark.profile_small_introspection + end + + desc "Dump schema to SDL" + task :profile_to_definition do + prepare_benchmark + GraphQLBenchmark.profile_to_definition + end + + desc "Load schema from SDL" + task :profile_from_definition do + prepare_benchmark + GraphQLBenchmark.profile_from_definition + end + desc "Compare GraphQL-Batch and GraphQL-Dataloader" task :profile_batch_loaders do prepare_benchmark GraphQLBenchmark.profile_batch_loaders end + + desc "Run benchmarks on schema creation" + task :profile_boot do + prepare_benchmark + GraphQLBenchmark.profile_boot + end + + desc "Check the memory footprint of a large schema" + task :profile_schema_memory_footprint do + prepare_benchmark + GraphQLBenchmark.profile_schema_memory_footprint + end + + desc "Check the depth of the stacktrace during execution" + task :profile_stack_depth do + prepare_benchmark + GraphQLBenchmark.profile_stack_depth + end + + desc "Run a very big introspection query" + task :profile_large_introspection do + prepare_benchmark + GraphQLBenchmark.profile_large_introspection + end + + task :profile_small_query_on_large_schema do + prepare_benchmark + GraphQLBenchmark.profile_small_query_on_large_schema + end + + desc "Run analysis on a big query" + task :profile_large_analysis do + prepare_benchmark + GraphQLBenchmark.profile_large_analysis + end + + desc "Run analysis on parsing" + task :profile_parse do + prepare_benchmark + GraphQLBenchmark.profile_parse + end end namespace :test do @@ -145,3 +215,26 @@ namespace :js do end task all: [:install, :build, :test] end + +task :build_c_lexer do + assert_dependency_version("Ragel", "7.0.4", "ragel -v") + `ragel -F1 graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl` +end + +Rake::ExtensionTask.new("graphql_c_parser_ext") do |t| + t.ext_dir = 'graphql-c_parser/ext/graphql_c_parser_ext' + t.lib_dir = "graphql-c_parser/lib/graphql" +end + +task :build_yacc_parser do + assert_dependency_version("Bison", "3.8", "yacc --version") + `yacc graphql-c_parser/ext/graphql_c_parser_ext/parser.y -o graphql-c_parser/ext/graphql_c_parser_ext/parser.c -Wyacc` +end + +task :move_binary do + # For some reason my local env doesn't respect the `lib_dir` configured above + `mv graphql-c_parser/lib/*.bundle graphql-c_parser/lib/graphql` +end + +desc "Build the C Extension" +task build_ext: [:build_c_lexer, :build_yacc_parser, "compile:graphql_c_parser_ext", :move_binary] diff --git a/benchmark/batch_loading.rb b/benchmark/batch_loading.rb index 9eb4378c9d6..b2d3f2d22c8 100644 --- a/benchmark/batch_loading.rb +++ b/benchmark/batch_loading.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true module BatchLoading class GraphQLBatchSchema < GraphQL::Schema DATA = [ @@ -41,8 +42,8 @@ def team end class Query < GraphQL::Schema::Object - field :team, Team, null: true do - argument :name, String, required: true + field :team, Team do + argument :name, String end def team(name:) @@ -88,8 +89,8 @@ def team end class Query < GraphQL::Schema::Object - field :team, Team, null: true do - argument :name, String, required: true + field :team, Team do + argument :name, String end def team(name:) @@ -123,8 +124,8 @@ def team end class Query < GraphQL::Schema::Object - field :team, Team, null: true do - argument :name, String, required: true + field :team, Team do + argument :name, String end def team(name:) diff --git a/benchmark/run.rb b/benchmark/run.rb index 763216233b3..8f3b6d062fc 100644 --- a/benchmark/run.rb +++ b/benchmark/run.rb @@ -1,11 +1,14 @@ # frozen_string_literal: true -TESTING_INTERPRETER = true require "graphql" +ADD_WARDEN = false +TESTING_EXEC_NEXT = !!ENV["GRAPHQL_FUTURE"] +TESTING_METHOD = !!ENV["TEST_METHOD"] require "jazz" require "benchmark/ips" require "stackprof" require "memory_profiler" require "graphql/batch" +require "securerandom" module GraphQLBenchmark QUERY_STRING = GraphQL::Introspection::INTROSPECTION_QUERY @@ -15,10 +18,12 @@ module GraphQLBenchmark BENCHMARK_PATH = File.expand_path("../", __FILE__) CARD_SCHEMA = GraphQL::Schema.from_definition(File.read(File.join(BENCHMARK_PATH, "schema.graphql"))) ABSTRACT_FRAGMENTS = GraphQL.parse(File.read(File.join(BENCHMARK_PATH, "abstract_fragments.graphql"))) - ABSTRACT_FRAGMENTS_2 = GraphQL.parse(File.read(File.join(BENCHMARK_PATH, "abstract_fragments_2.graphql"))) + ABSTRACT_FRAGMENTS_2_QUERY_STRING = File.read(File.join(BENCHMARK_PATH, "abstract_fragments_2.graphql")) + ABSTRACT_FRAGMENTS_2 = GraphQL.parse(ABSTRACT_FRAGMENTS_2_QUERY_STRING) BIG_SCHEMA = GraphQL::Schema.from_definition(File.join(BENCHMARK_PATH, "big_schema.graphql")) - BIG_QUERY = GraphQL.parse(File.read(File.join(BENCHMARK_PATH, "big_query.graphql"))) + BIG_QUERY_STRING = File.read(File.join(BENCHMARK_PATH, "big_query.graphql")) + BIG_QUERY = GraphQL.parse(BIG_QUERY_STRING) FIELDS_WILL_MERGE_SCHEMA = GraphQL::Schema.from_definition("type Query { hello: String }") FIELDS_WILL_MERGE_QUERY = GraphQL.parse("{ #{Array.new(5000, "hello").join(" ")} }") @@ -35,12 +40,62 @@ def self.run(task) x.report("validate - abstract fragments 2") { CARD_SCHEMA.validate(ABSTRACT_FRAGMENTS_2) } x.report("validate - big query") { BIG_SCHEMA.validate(BIG_QUERY) } x.report("validate - fields will merge") { FIELDS_WILL_MERGE_SCHEMA.validate(FIELDS_WILL_MERGE_QUERY) } + when "validate_profile" + profile_schemas = [CARD_SCHEMA, BIG_SCHEMA, FIELDS_WILL_MERGE_SCHEMA].map do |s| + ps = Class.new(s) + ps.use(GraphQL::Schema::Visibility, profiles: { default: {} }) + ps.validate(GraphQL.parse("{ __typename }"), context: { visibility_profile: :default }) + ps + end + ctx = { visibility_profile: :default } + x.report("validate (visibility profile) - introspection ") { profile_schemas[0].validate(DOCUMENT, context: ctx) } + x.report("validate (visibility profile) - abstract fragments") { profile_schemas[0].validate(ABSTRACT_FRAGMENTS, context: ctx) } + x.report("validate (visibility profile) - abstract fragments 2") { profile_schemas[0].validate(ABSTRACT_FRAGMENTS_2, context: ctx) } + x.report("validate (visibility profile) - big query") { profile_schemas[1].validate(BIG_QUERY, context: ctx) } + x.report("validate (visibility profile) - fields will merge") { profile_schemas[2].validate(FIELDS_WILL_MERGE_QUERY, context: ctx) } + when "scan" + require "graphql/c_parser" + x.report("scan c - introspection") { GraphQL.scan_with_c(QUERY_STRING) } + x.report("scan - introspection") { GraphQL.scan_with_ruby(QUERY_STRING) } + x.report("scan c - fragments") { GraphQL.scan_with_c(ABSTRACT_FRAGMENTS_2_QUERY_STRING) } + x.report("scan - fragments") { GraphQL.scan_with_ruby(ABSTRACT_FRAGMENTS_2_QUERY_STRING) } + x.report("scan c - big query") { GraphQL.scan_with_c(BIG_QUERY_STRING) } + x.report("scan - big query") { GraphQL.scan_with_ruby(BIG_QUERY_STRING) } + when "parse" + # Uncomment this to use the C parser: + # require "graphql/c_parser" + x.report("parse - introspection") { GraphQL.parse(QUERY_STRING) } + x.report("parse - fragments") { GraphQL.parse(ABSTRACT_FRAGMENTS_2_QUERY_STRING) } + x.report("parse - big query") { GraphQL.parse(BIG_QUERY_STRING) } else raise("Unexpected task #{task}") end end end + def self.profile_parse + # To profile the C parser instead: + # require "graphql/c_parser" + + report = MemoryProfiler.report do + GraphQL.parse(BIG_QUERY_STRING) + GraphQL.parse(QUERY_STRING) + GraphQL.parse(ABSTRACT_FRAGMENTS_2_QUERY_STRING) + end + report.pretty_print + end + + def self.validate_memory + FIELDS_WILL_MERGE_SCHEMA.validate(FIELDS_WILL_MERGE_QUERY) + + report = MemoryProfiler.report do + FIELDS_WILL_MERGE_SCHEMA.validate(FIELDS_WILL_MERGE_QUERY) + nil + end + + report.pretty_print + end + def self.profile # Warm up any caches: SCHEMA.execute(document: DOCUMENT) @@ -53,17 +108,265 @@ def self.profile StackProf::Report.new(result).print_text end + def self.build_large_schema + Class.new(GraphQL::Schema) do + query_t = Class.new(GraphQL::Schema::Object) do + graphql_name("Query") + int_ts = 5.times.map do |i| + int_t = Module.new do + include GraphQL::Schema::Interface + graphql_name "Interface#{i}" + 5.times do |n2| + field :"field#{n2}", String do + argument :arg, String + end + end + end + field :"int_field_#{i}", int_t + int_t + end + + obj_ts = 100.times.map do |n| + input_obj_t = Class.new(GraphQL::Schema::InputObject) do + graphql_name("Input#{n}") + argument :arg, String + end + obj_t = Class.new(GraphQL::Schema::Object) do + graphql_name("Object#{n}") + implements(*int_ts) + 20.times do |n2| + field :"field#{n2}", String do + argument :input, input_obj_t + end + + end + field :self_field, self + field :int_0_field, int_ts[0] + end + + field :"rootfield#{n}", obj_t + obj_t + end + + 10.times do |n| + union_t = Class.new(GraphQL::Schema::Union) do + graphql_name "Union#{n}" + possible_types(*obj_ts.sample(10)) + end + field :"unionfield#{n}", union_t + end + end + query(query_t) + end + end + + def self.profile_boot + Benchmark.ips do |x| + x.config(time: 10) + x.report("Booting large schema") { + build_large_schema + } + end + + result = StackProf.run(mode: :wall, interval: 1) do + build_large_schema + end + StackProf::Report.new(result).print_text + + retained_schema = nil + report = MemoryProfiler.report do + retained_schema = build_large_schema + end + + report.pretty_print + end + + SILLY_LARGE_SCHEMA = build_large_schema + + def self.profile_small_query_on_large_schema + schema = Class.new(SILLY_LARGE_SCHEMA) + Benchmark.ips do |x| + x.report("Run small query") { + schema.execute("{ __typename }") + } + end + + result = StackProf.run(mode: :wall, interval: 1) do + schema.execute("{ __typename }") + end + StackProf::Report.new(result).print_text + + StackProf.run(mode: :wall, out: "tmp/small_query.dump", interval: 1) do + schema.execute("{ __typename }") + end + + report = MemoryProfiler.report do + schema.execute("{ __typename }") + end + puts "\n\n" + report.pretty_print + end + + def self.profile_large_introspection + schema = SILLY_LARGE_SCHEMA + Benchmark.ips do |x| + x.config(time: 10) + x.report("Run large introspection") { + schema.to_json + } + end + + result = StackProf.run(mode: :wall) do + schema.to_json + end + StackProf::Report.new(result).print_text + + report = MemoryProfiler.report do + schema.to_json + end + puts "\n\n" + report.pretty_print + end + + def self.profile_large_analysis + query_str = "query {\n".dup + 5.times do |n| + query_str << " intField#{n} { " + 20.times do |o| + query_str << "...Obj#{o}Fields " + end + query_str << "}\n" + end + query_str << "}" + + 20.times do |o| + query_str << "fragment Obj#{o}Fields on Object#{o} { " + 20.times do |f| + query_str << " field#{f}(arg: \"a\")\n" + end + query_str << " selfField { selfField { selfField { __typename } } }\n" + # query_str << " int0Field { ...Int0Fields }" + query_str << "}\n" + end + # query_str << "fragment Int0Fields on Interface0 { __typename }" + query = GraphQL::Query.new(SILLY_LARGE_SCHEMA, query_str) + analyzers = [ + GraphQL::Analysis::AST::FieldUsage, + GraphQL::Analysis::AST::QueryDepth, + GraphQL::Analysis::AST::QueryComplexity + ] + Benchmark.ips do |x| + x.report("Running introspection") { + GraphQL::Analysis::AST.analyze_query(query, analyzers) + } + end + + StackProf.run(mode: :wall, out: "last-stackprof.dump", interval: 1) do + GraphQL::Analysis::AST.analyze_query(query, analyzers) + end + + result = StackProf.run(mode: :wall, interval: 1) do + GraphQL::Analysis::AST.analyze_query(query, analyzers) + end + + StackProf::Report.new(result).print_text + + report = MemoryProfiler.report do + GraphQL::Analysis::AST.analyze_query(query, analyzers) + end + puts "\n\n" + report.pretty_print + end + # Adapted from https://github.com/rmosolgo/graphql-ruby/issues/861 def self.profile_large_result schema = ProfileLargeResult::Schema + schema.use(GraphQL::Dataloader) document = ProfileLargeResult::ALL_FIELDS + method_document = ProfileLargeResult::ALL_METHOD_FIELDS + + r1 = schema.execute_next(document: document) + r2 = schema.execute(document: document) + if r1 != r2 + raise "Legacy vs next mismatch" + end + + r3 = schema.execute_next(document: method_document) + if r1 != r3 + raise "Method vs non-method mismatch" + end + Benchmark.ips do |x| + x.config(time: 5) + x.report("exec ") { + schema.execute(document: document) + } + x.report("exec method") { + schema.execute(document: method_document) + } + x.report("exec_next") { + schema.execute_next(document: document) + } + x.report("exec_next method") { + schema.execute_next(document: method_document) + } + x.compare! + end + + + exec_method = TESTING_EXEC_NEXT ? :execute_next : :execute + exec_doc = TESTING_METHOD ? method_document : document + result = StackProf.run(mode: :wall, interval: 1) do + schema.public_send(exec_method, document: exec_doc) + end + StackProf::Report.new(result).print_text + + StackProf.run(mode: :wall, interval: 1, out: "tmp/stackprof.dump") do + schema.public_send(exec_method, document: exec_doc) + end + + report = MemoryProfiler.report do + schema.public_send(exec_method, document: exec_doc) + end + + report.pretty_print + end + + def self.profile_small_result + schema = ProfileLargeResult::Schema + document = GraphQL.parse <<-GRAPHQL + query { + foos(first: 5) { + __typename + id + int1 + int2 + string1 + string2 + foos(first: 5) { + __typename + string1 + string2 + foo { + __typename + int1 + } + } + } + } + GRAPHQL + Benchmark.ips do |x| + x.config(time: 10) x.report("Querying for #{ProfileLargeResult::DATA.size} objects") { schema.execute(document: document) } end - result = StackProf.run(mode: :wall) do + StackProf.run(mode: :wall, interval: 1, out: "tmp/small.dump") do + schema.execute(document: document) + end + + result = StackProf.run(mode: :wall, interval: 1) do schema.execute(document: document) end StackProf::Report.new(result).print_text @@ -75,53 +378,194 @@ def self.profile_large_result report.pretty_print end - module ProfileLargeResult - DATA = 1000.times.map { - { - id: SecureRandom.uuid, - int1: SecureRandom.random_number(100000), - int2: SecureRandom.random_number(100000), - string1: SecureRandom.base64, - string2: SecureRandom.base64, - boolean1: SecureRandom.random_number(1) == 0, - boolean2: SecureRandom.random_number(1) == 0, - int_array: 10.times.map { SecureRandom.random_number(100000) }, - string_array: 10.times.map { SecureRandom.base64 }, - boolean_array: 10.times.map { SecureRandom.random_number(1) == 0 }, + def self.profile_small_introspection + schema = ProfileLargeResult::Schema + document = GraphQL.parse(GraphQL::Introspection::INTROSPECTION_QUERY) + + Benchmark.ips do |x| + x.config(time: 5) + x.report("Introspection") { + schema.execute(document: document) } + end + + result = StackProf.run(mode: :wall, interval: 1) do + schema.execute(document: document) + end + + StackProf::Report.new(result).print_text + + report = MemoryProfiler.report do + schema.execute(document: document) + end + + report.pretty_print + end + + module ProfileLargeResult + def self.eager_or_proc(value) + ENV["EAGER"] ? value : -> { value } + end + DATA_SIZE = 1000 + DATA = DATA_SIZE.times.map { + eager_or_proc({ + id: SecureRandom.uuid, + int1: SecureRandom.random_number(100000), + int2: SecureRandom.random_number(100000), + string1: eager_or_proc(SecureRandom.base64), + string2: SecureRandom.base64, + boolean1: SecureRandom.random_number(1) == 0, + boolean2: SecureRandom.random_number(1) == 0, + int_array: eager_or_proc(10.times.map { eager_or_proc(SecureRandom.random_number(100000)) } ), + string_array: 10.times.map { SecureRandom.base64 }, + boolean_array: 10.times.map { SecureRandom.random_number(1) == 0 }, + }) } + module Bar + include GraphQL::Schema::Interface + field :string_array, [String], null: false, hash_key: :string_array + end + + module Baz + include GraphQL::Schema::Interface + implements Bar + field :int_array, [Integer], null: false, hash_key: :int_array + field :boolean_array, [Boolean], null: false, hash_key: :boolean_array + end + + + class ExampleExtension < GraphQL::Schema::FieldExtension + end + + def self.generate_foo_type(name, config_key) + Class.new(GraphQL::Schema::Object) do + graphql_name(name) + implements Baz + field :id, GraphQL::Types::ID, null: false, extensions: [ExampleExtension], config_key => :id + def id + object[:id] + end + + field :int1, Integer, null: false, extensions: [ExampleExtension], config_key => :int1 + + def int1 + object[:int1] + end + + field :int2, Integer, null: false, extensions: [ExampleExtension], config_key => :int2 + + def int2 + object[:int2] + end + + field :string1, String, null: false, config_key => :string1 do + argument :arg1, String, required: false + argument :arg2, String, required: false + argument :arg3, String, required: false + argument :arg4, String, required: false + end + + def string1(...) + object[:string1] + end + + field :string2, String, null: false, config_key => :string2 do + argument :arg1, String, required: false + argument :arg2, String, required: false + argument :arg3, String, required: false + argument :arg4, String, required: false + end + + def string2(...) + object[:string2] + end - class FooType < GraphQL::Schema::Object - field :id, ID, null: false - field :int1, Integer, null: false - field :int2, Integer, null: false - field :string1, String, null: false - field :string2, String, null: false - field :boolean1, Boolean, null: false - field :boolean2, Boolean, null: false - field :string_array, [String], null: false - field :int_array, [Integer], null: false - field :boolean_array, [Boolean], null: false + field :boolean1, GraphQL::Types::Boolean, null: false, config_key => :boolean1 do + argument :arg1, String, required: false + argument :arg2, String, required: false + argument :arg3, String, required: false + argument :arg4, String, required: false + end + + def boolean1(...) + object[:boolean1] + end + + field :boolean2, GraphQL::Types::Boolean, null: false, config_key => :boolean2 do + argument :arg1, String, required: false + argument :arg2, String, required: false + argument :arg3, String, required: false + argument :arg4, String, required: false + end + + def boolean2(...) + object[:boolean2] + end + + field :foos, [self], null: false, description: "Return a list of Foo objects", resolve_legacy_instance_method: true do + argument :first, Integer, default_value: DATA_SIZE + end + + def foos(first:) + DATA.first(first) + end + + field :foo, self, resolve_legacy_instance_method: true + def foo + DATA.sample + end + end end + FooType = generate_foo_type("Foo", :hash_key) + FooMethodType = generate_foo_type("MethodFoo", :resolve_legacy_instance_method) + class QueryType < GraphQL::Schema::Object description "Query root of the system" - field :foos, [FooType], null: false, description: "Return a list of Foo objects" - def foos - DATA + field :foos, [FooType], null: false, description: "Return a list of Foo objects", resolve_legacy_instance_method: true do + argument :first, Integer, default_value: DATA_SIZE + end + + def foos(first:) + DATA.first(first) + end + + field :method_foos, [FooMethodType], null: false, resolver_method: :foos, resolve_legacy_instance_method: :foos do + argument :first, Integer, default_value: DATA_SIZE end end class Schema < GraphQL::Schema query QueryType - use GraphQL::Dataloader + use GraphQL::Execution::Next + # use GraphQL::Dataloader + if !ENV["EAGER"] + lazy_resolve Proc, :call + end end ALL_FIELDS = GraphQL.parse <<-GRAPHQL - { + query($skip: Boolean = false) { foos { - id + id @skip(if: $skip) + int1 + int2 + string1 + string2 + boolean1 + boolean2 + stringArray + intArray + booleanArray + } + } + GRAPHQL + + ALL_METHOD_FIELDS = GraphQL.parse <<-GRAPHQL + query($skip: Boolean = false) { + foos: methodFoos { + id @skip(if: $skip) int1 int2 string1 @@ -136,6 +580,47 @@ class Schema < GraphQL::Schema GRAPHQL end + def self.profile_to_definition + require_relative "./batch_loading" + schema = ProfileLargeResult::Schema + schema.to_definition + + Benchmark.ips do |x| + x.report("to_definition") { schema.to_definition } + end + + result = StackProf.run(mode: :wall, interval: 1) do + schema.to_definition + end + StackProf::Report.new(result).print_text + + report = MemoryProfiler.report do + schema.to_definition + end + + report.pretty_print + end + + def self.profile_from_definition + # require "graphql/c_parser" + schema_str = SILLY_LARGE_SCHEMA.to_definition + + Benchmark.ips do |x| + x.report("from_definition") { GraphQL::Schema.from_definition(schema_str) } + end + + result = StackProf.run(mode: :wall, interval: 1) do + GraphQL::Schema.from_definition(schema_str) + end + StackProf::Report.new(result).print_text + + report = MemoryProfiler.report do + GraphQL::Schema.from_definition(schema_str) + end + + report.pretty_print + end + def self.profile_batch_loaders require_relative "./batch_loading" include BatchLoading @@ -202,4 +687,120 @@ def self.profile_batch_loaders report.pretty_print end + + def self.profile_schema_memory_footprint + schema = nil + report = MemoryProfiler.report do + query_type = Class.new(GraphQL::Schema::Object) do + graphql_name "Query" + 100.times do |i| + type = Class.new(GraphQL::Schema::Object) do + graphql_name "Object#{i}" + field :f, Integer + end + field "f#{i}", type + end + end + + thing_type = Class.new(GraphQL::Schema::Object) do + graphql_name "Thing" + field :name, String + end + + mutation_type = Class.new(GraphQL::Schema::Object) do + graphql_name "Mutation" + 100.times do |i| + mutation_class = Class.new(GraphQL::Schema::RelayClassicMutation) do + graphql_name "Do#{i}" + argument :id, "ID" + field :thing, thing_type + field :things, thing_type.connection_type + end + field "f#{i}", mutation: mutation_class + end + end + + schema = Class.new(GraphQL::Schema) do + query(query_type) + mutation(mutation_type) + end + end + + report.pretty_print + end + + class StackDepthSchema < GraphQL::Schema + class Thing < GraphQL::Schema::Object + field :thing, self do + argument :lazy, Boolean, default_value: false + end + + def thing(lazy:) + if lazy + -> { :something } + else + :something + end + end + + field :stack_trace_depth, Integer do + argument :lazy, Boolean, default_value: false + end + + def stack_trace_depth(lazy:) + get_depth = -> { + graphql_caller = caller.select { |c| c.include?("graphql") } + graphql_caller.size + } + + if lazy + get_depth + else + get_depth.call + end + end + end + + class Query < GraphQL::Schema::Object + field :thing, Thing + + def thing + :something + end + end + + query(Query) + lazy_resolve(Proc, :call) + end + + def self.profile_stack_depth + query_str = <<-GRAPHQL + query($lazyThing: Boolean!, $lazyStackTrace: Boolean!) { + thing { + thing(lazy: $lazyThing) { + thing(lazy: $lazyThing) { + thing(lazy: $lazyThing) { + thing(lazy: $lazyThing) { + stackTraceDepth(lazy: $lazyStackTrace) + } + } + } + } + } + } + GRAPHQL + + eager_res = StackDepthSchema.execute(query_str, variables: { lazyThing: false, lazyStackTrace: false }) + lazy_res = StackDepthSchema.execute(query_str, variables: { lazyThing: true, lazyStackTrace: false }) + very_lazy_res = StackDepthSchema.execute(query_str, variables: { lazyThing: true, lazyStackTrace: true }) + get_depth = ->(result) { result["data"]["thing"]["thing"]["thing"]["thing"]["thing"]["stackTraceDepth"] } + + puts <<~RESULT + Result Depth + --------------------- + Eager #{get_depth.call(eager_res)} + Lazy #{get_depth.call(lazy_res)} + Very Lazy #{get_depth.call(very_lazy_res)} + RESULT + end end diff --git a/cop/development/context_is_passed_cop.rb b/cop/development/context_is_passed_cop.rb new file mode 100644 index 00000000000..1ad52c5ce21 --- /dev/null +++ b/cop/development/context_is_passed_cop.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +require 'rubocop' + +module Cop + module Development + class ContextIsPassedCop < RuboCop::Cop::Base + MSG = <<-MSG +This method also accepts `context` as an argument. Pass it so that the returned value will reflect the current query, or use another method that isn't context-dependent. +MSG + + # These are already context-aware or else not query-related + def_node_matcher :likely_query_specific_receiver?, " + { + (send _ {:ast_node :query :context :warden :ctx :query_ctx :query_context}) + (lvar {:ast_node :query :context :warden :ctx :query_ctx :query_context}) + (ivar {:@query :@context :@warden}) + (send _ {:introspection_system}) + } + " + + def_node_matcher :method_doesnt_receive_second_context_argument?, <<-MATCHER + (send _ {:get_field :get_argument :get_type} _) + MATCHER + + def_node_matcher :method_doesnt_receive_first_context_argument?, <<-MATCHER + (send _ {:fields :arguments :types :enum_values}) + MATCHER + + def_node_matcher :is_enum_values_call_without_arguments?, " + (send (send _ {:enum :enum_type (ivar {:@enum :@enum_type})}) {:values}) + " + + def on_send(node) + if ( + method_doesnt_receive_second_context_argument?(node) || + method_doesnt_receive_first_context_argument?(node) || + is_enum_values_call_without_arguments?(node) + ) && !likely_query_specific_receiver?(node.to_a[0]) + add_offense(node) + end + end + end + end +end diff --git a/cop/development/no_eval_cop.rb b/cop/development/no_eval_cop.rb new file mode 100644 index 00000000000..fd3ce237688 --- /dev/null +++ b/cop/development/no_eval_cop.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +require 'rubocop' + +module Cop + module Development + class NoEvalCop < RuboCop::Cop::Base + MSG_TEMPLATE = "Don't use `%{eval_method_name}` which accepts strings and may result evaluating unexpected code. Use `%{exec_method_name}` instead, and pass a block." + + def on_send(node) + case node.method_name + when :module_eval, :class_eval, :instance_eval + message = MSG_TEMPLATE % { eval_method_name: node.method_name, exec_method_name: node.method_name.to_s.sub("eval", "exec").to_sym } + add_offense node, message: message + end + end + end + end +end diff --git a/cop/development/no_focus_cop.rb b/cop/development/no_focus_cop.rb new file mode 100644 index 00000000000..86228234298 --- /dev/null +++ b/cop/development/no_focus_cop.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +require 'rubocop' + +module Cop + module Development + # Make sure no tests are focused, from https://github.com/rubocop-hq/rubocop/issues/3773#issuecomment-420662102 + class NoFocusCop < RuboCop::Cop::Base + MSG = 'Remove `focus` from tests.' + + def_node_matcher :focused?, <<-MATCHER + (send nil? :focus) + MATCHER + + def on_send(node) + return unless focused?(node) + + add_offense node + end + end + end +end diff --git a/cop/development/none_without_block_cop.rb b/cop/development/none_without_block_cop.rb new file mode 100644 index 00000000000..73b428c700d --- /dev/null +++ b/cop/development/none_without_block_cop.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +require 'rubocop' + +module Cop + module Development + # A custom Rubocop rule to catch uses of `.none?` without a block. + # + # @see https://github.com/rmosolgo/graphql-ruby/pull/2090 + class NoneWithoutBlockCop < RuboCop::Cop::Base + MSG = <<-MD +Instead of `.none?` or `.any?` without a block: + +- Use `.empty?` to check for an empty collection (faster) +- Add a block to explicitly check for `false` (more clear) + +Run `-a` to replace this with `%{bang}.empty?`. + MD + def on_block(node) + # Since this method was called with a block, it can't be + # a case of `.none?` without a block + ignore_node(node.send_node) + end + + def on_send(node) + if !ignored_node?(node) && (node.method_name == :none? || node.method_name == :any?) && node.arguments.size == 0 + add_offense(node, message: MSG % { bang: node.method_name == :none? ? "" : "!.." } ) + end + end + + def autocorrect(node) + lambda do |corrector| + if node.method_name == :none? + corrector.replace(node.location.selector, "empty?") + else + # Backtrack to any chained method calls so we can insert `!` before them + full_exp = node + while node.parent.send_type? + full_exp = node.parent + end + new_source = "!" + full_exp.source_range.source.sub("any?", "empty?") + corrector.replace(full_exp, new_source) + end + end + end + end + end +end diff --git a/cop/development/trace_methods_cop.rb b/cop/development/trace_methods_cop.rb new file mode 100644 index 00000000000..308033d8849 --- /dev/null +++ b/cop/development/trace_methods_cop.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true +require 'rubocop' + +module Cop + module Development + class TraceMethodsCop < RuboCop::Cop::Base + extend RuboCop::Cop::AutoCorrector + + TRACE_HOOKS = [ + :analyze_multiplex, + :analyze_query, + :authorized, + :authorized_lazy, + :begin_analyze_multiplex, + :begin_authorized, + :begin_dataloader, + :begin_dataloader_source, + :begin_execute_field, + :begin_resolve_type, + :begin_validate, + :dataloader_fiber_exit, + :dataloader_fiber_resume, + :dataloader_fiber_yield, + :dataloader_spawn_execution_fiber, + :dataloader_spawn_source_fiber, + :end_analyze_multiplex, + :end_authorized, + :end_dataloader, + :end_dataloader_source, + :end_execute_field, + :end_resolve_type, + :end_validate, + :execute_field, + :execute_field_lazy, + :execute_multiplex, + :execute_query, + :execute_query_lazy, + :lex, + :object_loaded, + :objects, + :parse, + :resolve_type, + :resolve_type_lazy, + :validate, + ] + + MSG = "Trace methods should call `super` to pass control to other traces" + + def on_def(node) + if TRACE_HOOKS.include?(node.method_name) && !node.each_descendant(:super, :zsuper).any? + add_offense(node) do |corrector| + if node.body + offset = node.loc.column + 2 + corrector.insert_after(node.body.loc.expression, "\n#{' ' * offset}super") + end + end + end + end + + def on_module(node) + if node.defined_module_name.to_s.end_with?("Trace") + all_defs = [] + node.body.each_child_node do |body_node| + if body_node.def_type? + all_defs << body_node.method_name + end + end + + missing_defs = TRACE_HOOKS - all_defs + redundant_defs = [ + # Not really necessary for making a good trace: + :lex, :analyze_query, :execute_query, :execute_query_lazy, + # Only useful for isolated event tracking: + :begin_dataloader, :end_dataloader, + :dataloader_fiber_exit, :dataloader_spawn_execution_fiber, :dataloader_spawn_source_fiber, + # Tracks object references, but not durations: + :objects, :object_loaded + ] + missing_defs.each do |missing_def| + if all_defs.include?(:"begin_#{missing_def}") && all_defs.include?(:"end_#{missing_def}") + redundant_defs << missing_def + redundant_defs << :"#{missing_def}_lazy" + end + missing_name = missing_def.to_s + if missing_name.start_with?("begin") && all_defs.include?(:"#{missing_name.sub("begin_", "")}") + redundant_defs << missing_def + elsif missing_name.start_with?("end") && all_defs.include?(:"#{missing_name.sub("end_", "")}") + redundant_defs << missing_def + end + end + + missing_defs -= redundant_defs + if missing_defs.any? + add_offense(node, message: "Missing some trace hook methods:\n\n- #{missing_defs.join("\n- ")}") + end + end + end + end + end +end diff --git a/cop/no_focus_cop.rb b/cop/no_focus_cop.rb deleted file mode 100644 index 766cb10ce8d..00000000000 --- a/cop/no_focus_cop.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true -require 'rubocop' - -module Cop - # Make sure no tests are focused, from https://github.com/rubocop-hq/rubocop/issues/3773#issuecomment-420662102 - class NoFocusCop < RuboCop::Cop::Cop - MSG = 'Remove `focus` from tests.' - - def_node_matcher :focused?, <<-MATCHER - (send nil? :focus) - MATCHER - - def on_send(node) - return unless focused?(node) - - add_offense node - end - end -end diff --git a/cop/none_without_block_cop.rb b/cop/none_without_block_cop.rb deleted file mode 100644 index 93e5ebdf550..00000000000 --- a/cop/none_without_block_cop.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true -require 'rubocop' - -module Cop - # A custom Rubocop rule to catch uses of `.none?` without a block. - # - # @see https://github.com/rmosolgo/graphql-ruby/pull/2090 - class NoneWithoutBlockCop < RuboCop::Cop::Cop - MSG = <<-MD -Instead of `.none?` without a block: - -- Use `.empty?` to check for an empty collection (faster) -- Add a block to explicitly check for `false` (more clear) - -Run `-a` to replace this with `.empty?`. - MD - def on_block(node) - # Since this method was called with a block, it can't be - # a case of `.none?` without a block - ignore_node(node.send_node) - end - - def on_send(node) - if !ignored_node?(node) && node.method_name == :none? && node.arguments.size == 0 - add_offense(node) - end - end - - def autocorrect(node) - lambda do |corrector| - corrector.replace(node.location.selector, "empty?") - end - end - end -end diff --git a/gemfiles/mongoid_6.gemfile b/gemfiles/mongoid_8.gemfile similarity index 65% rename from gemfiles/mongoid_6.gemfile rename to gemfiles/mongoid_8.gemfile index 1d002e9ca7a..a81487c21d6 100644 --- a/gemfiles/mongoid_6.gemfile +++ b/gemfiles/mongoid_8.gemfile @@ -2,10 +2,14 @@ source "https://rubygems.org" +gem 'logger' gem "bootsnap" gem "ruby-prof", platform: :ruby gem "pry" gem "pry-stack_explorer", platform: :ruby -gem "mongoid", "~> 6.4.1" +gem "mongoid", "~> 8.0" +gem "async" +gem "concurrent-ruby", "1.3.4" +gem "minitest-mock" gemspec path: "../" diff --git a/gemfiles/mongoid_7.gemfile b/gemfiles/mongoid_9.gemfile similarity index 77% rename from gemfiles/mongoid_7.gemfile rename to gemfiles/mongoid_9.gemfile index bbaa85ecd67..27dccb7532a 100644 --- a/gemfiles/mongoid_7.gemfile +++ b/gemfiles/mongoid_9.gemfile @@ -6,6 +6,8 @@ gem "bootsnap" gem "ruby-prof", platform: :ruby gem "pry" gem "pry-stack_explorer", platform: :ruby -gem "mongoid", "~> 7.0.1" +gem "mongoid", "~> 9.0" +gem "async" +gem "minitest-mock" gemspec path: "../" diff --git a/gemfiles/pronto.gemfile b/gemfiles/pronto.gemfile new file mode 100644 index 00000000000..dd073f9d866 --- /dev/null +++ b/gemfiles/pronto.gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "pronto" +gem "pronto-rubocop" +gem "pronto-undercover" +gem "base64" diff --git a/gemfiles/rails_3.2.gemfile b/gemfiles/rails_3.2.gemfile deleted file mode 100644 index 270590668cf..00000000000 --- a/gemfiles/rails_3.2.gemfile +++ /dev/null @@ -1,17 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "bootsnap" -gem "ruby-prof", platform: :ruby -gem "pry" -gem "pry-stack_explorer", platform: :ruby -gem "rails", "3.2.22.5", require: "rails/all" -gem "activerecord", "~> 3.2.21" -gem "actionpack", "~> 3.2.21" -gem "test-unit" -gem "sqlite3", "~> 1.3.6", platform: :ruby -gem "activerecord-jdbcsqlite3-adapter", platform: :jruby -gem "sequel" - -gemspec path: "../" diff --git a/gemfiles/rails_4.2.gemfile b/gemfiles/rails_4.2.gemfile deleted file mode 100644 index 2d1b212c6ec..00000000000 --- a/gemfiles/rails_4.2.gemfile +++ /dev/null @@ -1,17 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "bootsnap" -gem "ruby-prof", platform: :ruby -gem "pry" -gem "pry-stack_explorer", platform: :ruby -gem "rails", "~> 4.2", require: "rails/all" -gem "activerecord", "~> 4.2.4" -gem "actionpack", "~> 4.2.4" -gem "concurrent-ruby", "~> 1.0" -gem "sqlite3", "~> 1.3.6", platform: :ruby -gem "activerecord-jdbcsqlite3-adapter", platform: :jruby -gem "sequel" - -gemspec path: "../" diff --git a/gemfiles/rails_5.2_postgresql.gemfile b/gemfiles/rails_7.2_postgresql.gemfile similarity index 74% rename from gemfiles/rails_5.2_postgresql.gemfile rename to gemfiles/rails_7.2_postgresql.gemfile index cb69bffef26..7fa4d6cb2e8 100644 --- a/gemfiles/rails_5.2_postgresql.gemfile +++ b/gemfiles/rails_7.2_postgresql.gemfile @@ -6,8 +6,10 @@ gem "bootsnap" gem "ruby-prof", platform: :ruby gem "pry" gem "pry-stack_explorer", platform: :ruby -gem "rails", "~> 5.2.0", require: "rails/all" +gem "rails", "~> 7.2.0", require: "rails/all" gem "pg", platform: :ruby gem "sequel" +gem "async" +gem "google-protobuf" gemspec path: "../" diff --git a/gemfiles/rails_6.1.gemfile b/gemfiles/rails_8.0.gemfile similarity index 58% rename from gemfiles/rails_6.1.gemfile rename to gemfiles/rails_8.0.gemfile index 723ef73458e..5a7e7f97905 100644 --- a/gemfiles/rails_6.1.gemfile +++ b/gemfiles/rails_8.0.gemfile @@ -6,9 +6,12 @@ gem "bootsnap" gem "ruby-prof", platform: :ruby gem "pry" gem "pry-stack_explorer", platform: :ruby -gem "rails", "~> 6.1.0", require: "rails/all" -gem "sqlite3", "~> 1.4", platform: :ruby -gem "activerecord-jdbcsqlite3-adapter", platform: :jruby +gem "rails", "~> 8.0.0", require: "rails/all" +gem "sqlite3" +gem "pg", platform: :ruby gem "sequel" +gem "async" +gem "google-protobuf" +gem "minitest-mock" gemspec path: "../" diff --git a/gemfiles/rails_8.1.gemfile b/gemfiles/rails_8.1.gemfile new file mode 100644 index 00000000000..d493839a87a --- /dev/null +++ b/gemfiles/rails_8.1.gemfile @@ -0,0 +1,17 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "bootsnap" +gem "ruby-prof", platform: :ruby +gem "pry" +gem "pry-stack_explorer", platform: :ruby +gem "rails", "~> 8.1.0", require: "rails/all" +gem "sqlite3" +gem "pg", platform: :ruby +gem "sequel" +gem "async" +gem "google-protobuf" +gem "minitest-mock" + +gemspec path: "../" diff --git a/gemfiles/rails_master.gemfile b/gemfiles/rails_master.gemfile index 8f481417e0b..fd13b14f5ba 100644 --- a/gemfiles/rails_master.gemfile +++ b/gemfiles/rails_master.gemfile @@ -7,8 +7,22 @@ gem "ruby-prof", platform: :ruby gem "pry" gem "pry-stack_explorer", platform: :ruby gem "rails", github: "rails/rails", require: "rails/all", ref: "main" -gem 'sqlite3', "~> 1.4", platform: :ruby -gem "activerecord-jdbcsqlite3-adapter", platform: :jruby +gem 'sqlite3' +gem 'pg' gem "sequel" +gem "async" +gem "google-protobuf" +gem "redis" + +gem 'puma' +gem 'sprockets-rails' +gem 'capybara' +gem 'selenium-webdriver' +gem "minitest-mock" gemspec path: "../" + +if (cred = Bundler.settings["GEMS__GRAPHQL__PRO"]) && !cred.empty? + gem "graphql-pro", source: "https://gems.graphql.pro" + gem "graphql-enterprise", source: "https://gems.graphql.pro" +end diff --git a/graphql-c_parser/CHANGELOG.md b/graphql-c_parser/CHANGELOG.md new file mode 100644 index 00000000000..79439bd4a45 --- /dev/null +++ b/graphql-c_parser/CHANGELOG.md @@ -0,0 +1,51 @@ +# GraphQL::CParser + +## 1.1.4 + +- Set `YYSTACK_USE_ALLOCA 1` to avoid use-after-free (GHSA-52mm-32rv-3rpg) + +## 1.1.3 + +- Fix to disallow non-null sign (`!`) in fragment conditions #5347 + +## 1.1.2 + +- Fix to handle strings with null bytes #5193 + +## 1.1.1 + +- Add support for `Schema.max_query_string_tokens` #4929 + +## 1.1.0 + +- Drop support for Ruby 2.7 #4899 +- Reduce allocation of repeated strings for identifiers when parsing schemas #4899 + +## 1.0.8 + +- Support directives on variable definitions, requires `graphql` 2.2.10+ #4847 + +## 1.0.5 + +- Properly parse integers with leading zeros as Integers, not Floats #4556 + +## 1.0.4 + +- Use UTF-8 encoding for static strings #4526 + +## 1.0.3 + +- Raise a `ParseError` on bad Unicode escapes (like the Ruby parser) #4514 +- Force UTF-8 encoding (like the Ruby parser) #4467 + +## 1.0.2 + +- Remove `.y` and `.rl` files to avoid triggering build tasks during install + +## 1.0.1 + +- Fix gem files (to include `ext`) + +## 1.0.0 + +- Release GraphQL::CParser diff --git a/graphql-c_parser/Rakefile b/graphql-c_parser/Rakefile new file mode 100644 index 00000000000..35cb636a9e2 --- /dev/null +++ b/graphql-c_parser/Rakefile @@ -0,0 +1,10 @@ +# frozen_string_literal: true +require "bundler/gem_helper" + +# use a custom tag to avoid conflicting with GraphQL-Ruby tags in the same git repo +class CustomGemHelper < Bundler::GemHelper + def version_tag + "graphql-c_parser-v#{version}" + end +end +CustomGemHelper.install_tasks diff --git a/graphql-c_parser/ext/graphql_c_parser_ext/extconf.rb b/graphql-c_parser/ext/graphql_c_parser_ext/extconf.rb new file mode 100644 index 00000000000..4deacc9eec3 --- /dev/null +++ b/graphql-c_parser/ext/graphql_c_parser_ext/extconf.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +require 'mkmf' + +create_makefile 'graphql/graphql_c_parser_ext' diff --git a/graphql-c_parser/ext/graphql_c_parser_ext/graphql_c_parser_ext.c b/graphql-c_parser/ext/graphql_c_parser_ext/graphql_c_parser_ext.c new file mode 100644 index 00000000000..c8a67aede9b --- /dev/null +++ b/graphql-c_parser/ext/graphql_c_parser_ext/graphql_c_parser_ext.c @@ -0,0 +1,22 @@ +#include "graphql_c_parser_ext.h" + +VALUE GraphQL_CParser_Lexer_tokenize_with_c_internal(VALUE self, VALUE query_string, VALUE fstring_identifiers, VALUE reject_numbers_followed_by_names, VALUE max_tokens) { + return tokenize(query_string, RTEST(fstring_identifiers), RTEST(reject_numbers_followed_by_names), FIX2INT(max_tokens)); +} + +VALUE GraphQL_CParser_Parser_c_parse(VALUE self) { + yyparse(self, rb_ivar_get(self, rb_intern("@filename"))); + return Qnil; +} + +void Init_graphql_c_parser_ext() { + VALUE GraphQL = rb_define_module("GraphQL"); + VALUE CParser = rb_define_module_under(GraphQL, "CParser"); + VALUE Lexer = rb_define_module_under(CParser, "Lexer"); + rb_define_singleton_method(Lexer, "tokenize_with_c_internal", GraphQL_CParser_Lexer_tokenize_with_c_internal, 4); + setup_static_token_variables(); + + VALUE Parser = rb_define_class_under(CParser, "Parser", rb_cObject); + rb_define_method(Parser, "c_parse", GraphQL_CParser_Parser_c_parse, 0); + initialize_node_class_variables(); +} diff --git a/graphql-c_parser/ext/graphql_c_parser_ext/graphql_c_parser_ext.h b/graphql-c_parser/ext/graphql_c_parser_ext/graphql_c_parser_ext.h new file mode 100644 index 00000000000..412b395569f --- /dev/null +++ b/graphql-c_parser/ext/graphql_c_parser_ext/graphql_c_parser_ext.h @@ -0,0 +1,8 @@ +#ifndef Graphql_ext_h +#define Graphql_ext_h +#include +#include +#include "lexer.h" +#include "parser.h" +void Init_graphql_c_parser_ext(); +#endif diff --git a/graphql-c_parser/ext/graphql_c_parser_ext/lexer.c b/graphql-c_parser/ext/graphql_c_parser_ext/lexer.c new file mode 100644 index 00000000000..ec29aba07ed --- /dev/null +++ b/graphql-c_parser/ext/graphql_c_parser_ext/lexer.c @@ -0,0 +1,2040 @@ +#line 1 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + +#line 106 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + + + +#line 8 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" +static const char _graphql_c_lexer_trans_keys[] = { + 1, 22, 4, 43, 14, 47, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 49, 4, 22, + 4, 4, 4, 4, 4, 22, 4, 4, + 4, 4, 14, 15, 14, 15, 10, 15, + 12, 12, 0, 49, 0, 0, 1, 22, + 4, 4, 4, 4, 4, 4, 4, 22, + 4, 4, 4, 4, 1, 1, 14, 15, + 12, 12, 10, 29, 14, 15, 12, 15, + 12, 12, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 14, 46, 14, 46, 14, 46, 14, 46, + 0 +}; + +static const signed char _graphql_c_lexer_char_class[] = { + 0, 1, 2, 2, 1, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 0, + 3, 4, 5, 6, 2, 7, 2, 8, + 9, 2, 10, 0, 11, 12, 13, 14, + 15, 15, 15, 15, 15, 15, 15, 15, + 15, 16, 2, 2, 17, 2, 2, 18, + 19, 19, 19, 19, 20, 19, 19, 19, + 19, 19, 19, 19, 19, 19, 19, 19, + 19, 19, 19, 19, 19, 19, 19, 19, + 19, 19, 21, 22, 23, 2, 24, 2, + 25, 26, 27, 28, 29, 30, 31, 32, + 33, 19, 19, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 19, 45, + 46, 19, 47, 48, 49, 0 +}; + +static const short _graphql_c_lexer_index_offsets[] = { + 0, 22, 62, 96, 129, 162, 195, 228, + 261, 294, 327, 363, 382, 383, 384, 403, + 404, 405, 407, 409, 415, 416, 466, 467, + 489, 490, 491, 492, 511, 512, 513, 514, + 516, 517, 537, 539, 543, 544, 577, 610, + 643, 676, 709, 742, 775, 808, 841, 874, + 907, 940, 973, 1006, 1039, 1072, 1105, 1138, + 1171, 1204, 1237, 1270, 1303, 1336, 1369, 1402, + 1435, 1468, 1501, 1534, 1567, 1600, 1633, 1666, + 1699, 1732, 1765, 1798, 1831, 1864, 1897, 1930, + 1963, 1996, 2029, 2062, 2095, 2128, 2161, 2194, + 2227, 2260, 2293, 2326, 2359, 2392, 2425, 2458, + 2491, 2524, 2557, 2590, 2623, 2656, 2689, 2722, + 2755, 2788, 2821, 2854, 2887, 2920, 2953, 2986, + 3019, 3052, 3085, 3118, 3151, 3184, 3217, 3250, + 3283, 3316, 3349, 3382, 3415, 3448, 3481, 3514, + 3547, 3580, 3613, 3646, 0 +}; + +static const short _graphql_c_lexer_indices[] = { + 0, 1, 1, 2, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 3, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 1, + 0, 0, 0, 0, 0, 0, 0, 0, + 1, 0, 0, 0, 1, 0, 0, 0, + 1, 0, 0, 0, 0, 0, 1, 0, + 0, 0, 1, 0, 1, 4, 5, 5, + 0, 0, 0, 5, 5, 0, 0, 0, + 0, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 6, + 7, 7, 0, 0, 0, 7, 7, 0, + 0, 0, 0, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 8, 8, 0, 0, 0, 8, 8, + 0, 0, 0, 0, 8, 8, 8, 8, + 8, 8, 8, 8, 8, 8, 8, 8, + 8, 8, 8, 8, 8, 8, 8, 8, + 8, 8, 1, 1, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 9, 9, 0, 0, 0, + 9, 9, 0, 0, 0, 0, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 9, + 9, 9, 9, 9, 10, 10, 0, 0, + 0, 10, 10, 0, 0, 0, 0, 10, + 10, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 11, 11, 0, + 0, 0, 11, 11, 0, 0, 0, 0, + 11, 11, 11, 11, 11, 11, 11, 11, + 11, 11, 11, 11, 11, 11, 11, 11, + 11, 11, 11, 11, 11, 11, 12, 12, + 0, 0, 0, 12, 12, 0, 0, 0, + 0, 12, 12, 12, 12, 12, 12, 12, + 12, 12, 12, 12, 12, 12, 12, 12, + 12, 12, 12, 12, 12, 12, 12, 12, + 12, 0, 0, 0, 12, 12, 0, 0, + 0, 0, 12, 12, 12, 12, 12, 12, + 12, 12, 12, 12, 12, 12, 12, 12, + 12, 12, 12, 12, 12, 12, 12, 12, + 0, 0, 1, 15, 14, 14, 14, 14, + 14, 14, 14, 14, 14, 14, 14, 14, + 14, 14, 14, 14, 14, 16, 17, 18, + 19, 14, 14, 14, 14, 14, 14, 14, + 14, 14, 14, 14, 14, 14, 14, 14, + 14, 14, 16, 20, 21, 23, 23, 25, + 25, 26, 26, 24, 24, 25, 25, 27, + 30, 31, 29, 32, 33, 34, 35, 36, + 37, 38, 29, 39, 40, 29, 41, 42, + 43, 44, 45, 46, 46, 47, 29, 48, + 46, 46, 46, 46, 49, 50, 51, 46, + 46, 52, 46, 53, 54, 55, 46, 56, + 57, 58, 59, 60, 46, 46, 46, 61, + 62, 63, 30, 65, 1, 1, 66, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 3, 14, 69, 70, 71, 14, 14, 14, + 14, 14, 14, 14, 14, 14, 14, 14, + 14, 14, 14, 14, 14, 14, 16, 72, + 18, 73, 41, 42, 75, 26, 26, 76, + 76, 23, 23, 76, 76, 76, 76, 77, + 76, 76, 76, 76, 76, 76, 76, 76, + 77, 25, 25, 75, 74, 42, 42, 78, + 46, 46, 13, 13, 13, 46, 46, 13, + 13, 13, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 79, 79, 79, 46, 46, + 79, 79, 79, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 80, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 79, 79, 79, 46, + 46, 79, 79, 79, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 81, 46, 46, 46, + 46, 46, 46, 46, 46, 79, 79, 79, + 46, 46, 79, 79, 79, 46, 46, 46, + 46, 46, 82, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 79, 79, + 79, 46, 46, 79, 79, 79, 46, 46, + 46, 83, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 79, + 79, 79, 46, 46, 79, 79, 79, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 84, 46, 46, 46, 46, 46, 46, + 79, 79, 79, 46, 46, 79, 79, 79, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 85, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 79, 79, 79, 46, 46, 79, 79, + 79, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 86, 46, 46, + 46, 46, 79, 79, 79, 46, 46, 79, + 79, 79, 46, 46, 46, 46, 46, 87, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 79, 79, 79, 46, 46, + 79, 79, 79, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 88, + 46, 46, 46, 46, 46, 46, 46, 46, + 89, 46, 46, 46, 79, 79, 79, 46, + 46, 79, 79, 79, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 90, + 46, 46, 46, 46, 46, 79, 79, 79, + 46, 46, 79, 79, 79, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 91, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 79, 79, + 79, 46, 46, 79, 79, 79, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 92, 46, 46, 46, 46, 46, 46, 79, + 79, 79, 46, 46, 79, 79, 79, 46, + 46, 46, 46, 46, 93, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 79, 79, 79, 46, 46, 79, 79, 79, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 94, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 79, 79, 79, 46, 46, 79, 79, + 79, 46, 46, 46, 46, 95, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 79, 79, 79, 46, 46, 79, + 79, 79, 46, 96, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 97, 46, 46, 46, 46, 46, + 46, 46, 46, 79, 79, 79, 46, 46, + 79, 79, 79, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 98, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 79, 79, 79, 46, + 46, 79, 79, 79, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 99, 46, 46, + 46, 46, 46, 46, 46, 79, 79, 79, + 46, 46, 79, 79, 79, 46, 46, 46, + 46, 46, 100, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 79, 79, + 79, 46, 46, 79, 79, 79, 46, 101, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 79, + 79, 79, 46, 46, 79, 79, 79, 46, + 46, 46, 46, 46, 46, 46, 102, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 79, 79, 79, 46, 46, 79, 79, 79, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 103, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 79, 79, 79, 46, 46, 79, 79, + 79, 46, 46, 46, 46, 46, 104, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 79, 79, 79, 46, 46, 79, + 79, 79, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 105, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 79, 79, 79, 46, 46, + 79, 79, 79, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 106, 46, 46, + 46, 46, 46, 46, 79, 79, 79, 46, + 46, 79, 79, 79, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 107, + 108, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 79, 79, 79, + 46, 46, 79, 79, 79, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 109, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 79, 79, + 79, 46, 46, 79, 79, 79, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 110, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 79, + 79, 79, 46, 46, 79, 79, 79, 46, + 46, 46, 46, 46, 111, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 79, 79, 79, 46, 46, 79, 79, 79, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 112, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 79, 79, 79, 46, 46, 79, 79, + 79, 46, 46, 46, 46, 46, 113, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 79, 79, 79, 46, 46, 79, + 79, 79, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 114, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 79, 79, 79, 46, 46, + 79, 79, 79, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 115, 46, 46, + 46, 46, 46, 46, 79, 79, 79, 46, + 46, 79, 79, 79, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 116, 46, 46, + 46, 46, 46, 46, 46, 79, 79, 79, + 46, 46, 79, 79, 79, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 117, 46, 46, 46, 118, + 46, 46, 46, 46, 46, 46, 79, 79, + 79, 46, 46, 79, 79, 79, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 119, 46, 46, 46, 46, 46, 79, + 79, 79, 46, 46, 79, 79, 79, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 120, 46, 46, 46, 46, 46, 46, + 79, 79, 79, 46, 46, 79, 79, 79, + 46, 46, 46, 46, 46, 121, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 79, 79, 79, 46, 46, 79, 79, + 79, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 122, 46, 46, 46, 46, 46, 46, + 46, 46, 79, 79, 79, 46, 46, 79, + 79, 79, 46, 46, 46, 46, 46, 46, + 123, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 79, 79, 79, 46, 46, + 79, 79, 79, 46, 124, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 79, 79, 79, 46, + 46, 79, 79, 79, 46, 46, 46, 125, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 79, 79, 79, + 46, 46, 79, 79, 79, 46, 46, 46, + 46, 46, 126, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 79, 79, + 79, 46, 46, 79, 79, 79, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 127, 46, 46, 46, 46, 46, 79, + 79, 79, 46, 46, 79, 79, 79, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 128, 46, 46, 46, 46, 46, 46, + 79, 79, 79, 46, 46, 79, 79, 79, + 46, 129, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 79, 79, 79, 46, 46, 79, 79, + 79, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 130, 46, 46, 46, 46, + 46, 46, 79, 79, 79, 46, 46, 79, + 79, 79, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 131, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 79, 79, 79, 46, 46, + 79, 79, 79, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 132, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 79, 79, 79, 46, + 46, 79, 79, 79, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 133, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 79, 79, 79, + 46, 46, 79, 79, 79, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 134, 46, 46, 46, 46, 46, 79, 79, + 79, 46, 46, 79, 79, 79, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 135, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 79, + 79, 79, 46, 46, 79, 79, 79, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 136, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 79, 79, 79, 46, 46, 79, 79, 79, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 137, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 79, 79, 79, 46, 46, 79, 79, + 79, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 138, 46, 46, 46, + 46, 46, 79, 79, 79, 46, 46, 79, + 79, 79, 46, 46, 46, 46, 46, 139, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 79, 79, 79, 46, 46, + 79, 79, 79, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 140, 46, 46, 46, 46, + 46, 46, 46, 46, 79, 79, 79, 46, + 46, 79, 79, 79, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 141, 46, 46, 79, 79, 79, + 46, 46, 79, 79, 79, 46, 46, 46, + 46, 46, 142, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 79, 79, + 79, 46, 46, 79, 79, 79, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 143, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 79, + 79, 79, 46, 46, 79, 79, 79, 46, + 46, 46, 46, 46, 144, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 79, 79, 79, 46, 46, 79, 79, 79, + 46, 145, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 79, 79, 79, 46, 46, 79, 79, + 79, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 146, 46, 46, 46, 46, + 46, 46, 79, 79, 79, 46, 46, 79, + 79, 79, 46, 147, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 79, 79, 79, 46, 46, + 79, 79, 79, 46, 46, 148, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 79, 79, 79, 46, + 46, 79, 79, 79, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 149, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 79, 79, 79, + 46, 46, 79, 79, 79, 46, 46, 46, + 46, 46, 150, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 79, 79, + 79, 46, 46, 79, 79, 79, 46, 46, + 46, 151, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 152, 46, 46, 46, 46, 46, 79, + 79, 79, 46, 46, 79, 79, 79, 46, + 153, 46, 46, 46, 46, 46, 46, 154, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 79, 79, 79, 46, 46, 79, 79, 79, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 155, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 79, 79, 79, 46, 46, 79, 79, + 79, 46, 156, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 79, 79, 79, 46, 46, 79, + 79, 79, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 157, 46, 46, 46, 46, 46, + 46, 46, 46, 79, 79, 79, 46, 46, + 79, 79, 79, 46, 46, 46, 46, 46, + 158, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 79, 79, 79, 46, + 46, 79, 79, 79, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 159, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 79, 79, 79, + 46, 46, 79, 79, 79, 46, 160, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 79, 79, + 79, 46, 46, 79, 79, 79, 46, 46, + 161, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 79, + 79, 79, 46, 46, 79, 79, 79, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 162, 46, 46, 46, 46, 46, 46, 46, + 79, 79, 79, 46, 46, 79, 79, 79, + 46, 46, 46, 163, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 79, 79, 79, 46, 46, 79, 79, + 79, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 164, 46, 46, 46, 46, 46, 46, + 46, 46, 79, 79, 79, 46, 46, 79, + 79, 79, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 165, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 79, 79, 79, 46, 46, + 79, 79, 79, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 166, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 79, 79, 79, 46, + 46, 79, 79, 79, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 167, 46, + 46, 46, 46, 46, 46, 79, 79, 79, + 46, 46, 79, 79, 79, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 168, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 79, 79, + 79, 46, 46, 79, 79, 79, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 169, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 79, + 79, 79, 46, 46, 79, 79, 79, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 170, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 79, 79, 79, 46, 46, 79, 79, 79, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 171, 46, 46, 46, 46, 46, 172, 46, + 46, 79, 79, 79, 46, 46, 79, 79, + 79, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 173, 46, 46, 46, + 46, 46, 79, 79, 79, 46, 46, 79, + 79, 79, 46, 46, 46, 46, 46, 174, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 79, 79, 79, 46, 46, + 79, 79, 79, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 175, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 79, 79, 79, 46, + 46, 79, 79, 79, 46, 46, 46, 46, + 46, 176, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 79, 79, 79, + 46, 46, 79, 79, 79, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 177, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 79, 79, + 79, 46, 46, 79, 79, 79, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 178, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 79, + 79, 79, 46, 46, 79, 79, 79, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 179, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 46, + 79, 79, 79, 46, 46, 79, 79, 79, + 46, 46, 46, 46, 46, 46, 46, 46, + 46, 46, 46, 46, 180, 46, 46, 46, + 46, 46, 46, 46, 46, 46, 46, 0 +}; + +static const signed char _graphql_c_lexer_index_defaults[] = { + 1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 14, 14, 14, 14, 14, + 14, 22, 24, 24, 0, 29, 64, 1, + 67, 68, 68, 14, 14, 14, 34, 65, + 74, 76, 76, 74, 65, 13, 79, 79, + 79, 79, 79, 79, 79, 79, 79, 79, + 79, 79, 79, 79, 79, 79, 79, 79, + 79, 79, 79, 79, 79, 79, 79, 79, + 79, 79, 79, 79, 79, 79, 79, 79, + 79, 79, 79, 79, 79, 79, 79, 79, + 79, 79, 79, 79, 79, 79, 79, 79, + 79, 79, 79, 79, 79, 79, 79, 79, + 79, 79, 79, 79, 79, 79, 79, 79, + 79, 79, 79, 79, 79, 79, 79, 79, + 79, 79, 79, 79, 79, 79, 79, 79, + 79, 79, 79, 79, 79, 79, 79, 79, + 79, 79, 79, 79, 0 +}; + +static const short _graphql_c_lexer_cond_targs[] = { + 21, 0, 21, 1, 2, 3, 6, 4, + 5, 7, 8, 9, 10, 21, 11, 12, + 14, 13, 25, 15, 16, 27, 21, 33, + 21, 34, 18, 21, 21, 21, 22, 21, + 21, 23, 30, 21, 21, 21, 21, 31, + 36, 32, 35, 21, 21, 21, 37, 21, + 21, 38, 46, 53, 63, 81, 88, 91, + 92, 96, 105, 123, 128, 21, 21, 21, + 21, 21, 24, 21, 21, 26, 21, 28, + 29, 21, 21, 17, 21, 19, 20, 21, + 39, 40, 41, 42, 43, 44, 45, 37, + 47, 49, 48, 37, 50, 51, 52, 37, + 54, 57, 55, 56, 37, 58, 59, 60, + 61, 62, 37, 64, 72, 65, 66, 67, + 68, 69, 70, 71, 37, 73, 75, 74, + 37, 76, 77, 78, 79, 80, 37, 82, + 83, 84, 85, 86, 87, 37, 89, 90, + 37, 37, 93, 94, 95, 37, 97, 98, + 99, 100, 101, 102, 103, 104, 37, 106, + 113, 107, 110, 108, 109, 37, 111, 112, + 37, 114, 115, 116, 117, 118, 119, 120, + 121, 122, 37, 124, 126, 125, 37, 127, + 37, 129, 130, 131, 37, 0 +}; + +static const signed char _graphql_c_lexer_cond_actions[] = { + 1, 0, 2, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 3, 0, 0, + 0, 0, 0, 0, 0, 4, 5, 6, + 7, 0, 0, 8, 0, 11, 0, 12, + 13, 6, 0, 14, 15, 16, 17, 0, + 6, 6, 6, 18, 19, 20, 21, 22, + 23, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 24, 25, 26, + 27, 28, 29, 30, 31, 0, 32, 4, + 4, 33, 34, 0, 35, 0, 0, 36, + 0, 0, 0, 0, 0, 0, 0, 37, + 0, 0, 0, 38, 0, 0, 0, 39, + 0, 0, 0, 0, 40, 0, 0, 0, + 0, 0, 41, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 42, 0, 0, 0, + 43, 0, 0, 0, 0, 0, 44, 0, + 0, 0, 0, 0, 0, 45, 0, 0, + 46, 47, 0, 0, 0, 48, 0, 0, + 0, 0, 0, 0, 0, 0, 49, 0, + 0, 0, 0, 0, 0, 50, 0, 0, + 51, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 52, 0, 0, 0, 53, 0, + 54, 0, 0, 0, 55, 0 +}; + +static const signed char _graphql_c_lexer_to_state_actions[] = { + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 9, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0 +}; + +static const signed char _graphql_c_lexer_from_state_actions[] = { + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 10, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0 +}; + +static const signed char _graphql_c_lexer_eof_trans[] = { + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 14, 14, 14, 14, 14, + 14, 23, 25, 25, 1, 29, 65, 66, + 68, 69, 69, 69, 69, 69, 74, 66, + 75, 77, 77, 75, 66, 14, 80, 80, + 80, 80, 80, 80, 80, 80, 80, 80, + 80, 80, 80, 80, 80, 80, 80, 80, + 80, 80, 80, 80, 80, 80, 80, 80, + 80, 80, 80, 80, 80, 80, 80, 80, + 80, 80, 80, 80, 80, 80, 80, 80, + 80, 80, 80, 80, 80, 80, 80, 80, + 80, 80, 80, 80, 80, 80, 80, 80, + 80, 80, 80, 80, 80, 80, 80, 80, + 80, 80, 80, 80, 80, 80, 80, 80, + 80, 80, 80, 80, 80, 80, 80, 80, + 80, 80, 80, 80, 80, 80, 80, 80, + 80, 80, 80, 80, 0 +}; + +static const int graphql_c_lexer_start = 21; +static const int graphql_c_lexer_first_final = 21; +static const int graphql_c_lexer_error = -1; + +static const int graphql_c_lexer_en_main = 21; + + +#line 108 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + + +#include +#include + +#define INIT_STATIC_TOKEN_VARIABLE(token_name) \ +static VALUE GraphQLTokenString##token_name; + +INIT_STATIC_TOKEN_VARIABLE(ON) +INIT_STATIC_TOKEN_VARIABLE(FRAGMENT) +INIT_STATIC_TOKEN_VARIABLE(QUERY) +INIT_STATIC_TOKEN_VARIABLE(MUTATION) +INIT_STATIC_TOKEN_VARIABLE(SUBSCRIPTION) +INIT_STATIC_TOKEN_VARIABLE(REPEATABLE) +INIT_STATIC_TOKEN_VARIABLE(RCURLY) +INIT_STATIC_TOKEN_VARIABLE(LCURLY) +INIT_STATIC_TOKEN_VARIABLE(RBRACKET) +INIT_STATIC_TOKEN_VARIABLE(LBRACKET) +INIT_STATIC_TOKEN_VARIABLE(RPAREN) +INIT_STATIC_TOKEN_VARIABLE(LPAREN) +INIT_STATIC_TOKEN_VARIABLE(COLON) +INIT_STATIC_TOKEN_VARIABLE(VAR_SIGN) +INIT_STATIC_TOKEN_VARIABLE(DIR_SIGN) +INIT_STATIC_TOKEN_VARIABLE(ELLIPSIS) +INIT_STATIC_TOKEN_VARIABLE(EQUALS) +INIT_STATIC_TOKEN_VARIABLE(BANG) +INIT_STATIC_TOKEN_VARIABLE(PIPE) +INIT_STATIC_TOKEN_VARIABLE(AMP) +INIT_STATIC_TOKEN_VARIABLE(SCHEMA) +INIT_STATIC_TOKEN_VARIABLE(SCALAR) +INIT_STATIC_TOKEN_VARIABLE(EXTEND) +INIT_STATIC_TOKEN_VARIABLE(IMPLEMENTS) +INIT_STATIC_TOKEN_VARIABLE(INTERFACE) +INIT_STATIC_TOKEN_VARIABLE(UNION) +INIT_STATIC_TOKEN_VARIABLE(ENUM) +INIT_STATIC_TOKEN_VARIABLE(DIRECTIVE) +INIT_STATIC_TOKEN_VARIABLE(INPUT) + +static VALUE GraphQL_type_str; +static VALUE GraphQL_true_str; +static VALUE GraphQL_false_str; +static VALUE GraphQL_null_str; +typedef enum TokenType { + AMP, + BANG, + COLON, + DIRECTIVE, + DIR_SIGN, + ENUM, + ELLIPSIS, + EQUALS, + EXTEND, + FALSE_LITERAL, + FLOAT, + FRAGMENT, + IDENTIFIER, + INPUT, + IMPLEMENTS, + INT, + INTERFACE, + LBRACKET, + LCURLY, + LPAREN, + MUTATION, + NULL_LITERAL, + ON, + PIPE, + QUERY, + RBRACKET, + RCURLY, + REPEATABLE, + RPAREN, + SCALAR, + SCHEMA, + STRING, + SUBSCRIPTION, + TRUE_LITERAL, + TYPE_LITERAL, + UNION, + VAR_SIGN, + BLOCK_STRING, + QUOTED_STRING, + UNKNOWN_CHAR, + COMMENT, + BAD_UNICODE_ESCAPE +} TokenType; + +typedef struct Meta { + int line; + int col; + char *query_cstr; + char *pe; + VALUE tokens; + int dedup_identifiers; + int reject_numbers_followed_by_names; + int preceeded_by_number; + int max_tokens; + int tokens_count; +} Meta; + +#define STATIC_VALUE_TOKEN(token_type, content_str) \ +case token_type: \ +token_sym = ID2SYM(rb_intern(#token_type)); \ +token_content = GraphQLTokenString##token_type; \ +break; + +#define DYNAMIC_VALUE_TOKEN(token_type) \ +case token_type: \ +token_sym = ID2SYM(rb_intern(#token_type)); \ +token_content = rb_utf8_str_new(ts, te - ts); \ +break; + +void emit(TokenType tt, char *ts, char *te, Meta *meta) { + meta->tokens_count++; + // -1 indicates that there is no limit: + if (meta->max_tokens > 0 && meta->tokens_count > meta->max_tokens) { + VALUE mGraphQL = rb_const_get_at(rb_cObject, rb_intern("GraphQL")); + VALUE cParseError = rb_const_get_at(mGraphQL, rb_intern("ParseError")); + VALUE exception = rb_funcall( + cParseError, rb_intern("new"), 4, + rb_str_new_cstr("This query is too large to execute."), + LONG2NUM(meta->line), + LONG2NUM(meta->col), + rb_str_new_cstr(meta->query_cstr) + ); + rb_exc_raise(exception); + } + int quotes_length = 0; // set by string tokens below + int line_incr = 0; + VALUE token_sym = Qnil; + VALUE token_content = Qnil; + int this_token_is_number = 0; + switch(tt) { + STATIC_VALUE_TOKEN(ON, "on") + STATIC_VALUE_TOKEN(FRAGMENT, "fragment") + STATIC_VALUE_TOKEN(QUERY, "query") + STATIC_VALUE_TOKEN(MUTATION, "mutation") + STATIC_VALUE_TOKEN(SUBSCRIPTION, "subscription") + STATIC_VALUE_TOKEN(REPEATABLE, "repeatable") + STATIC_VALUE_TOKEN(RCURLY, "}") + STATIC_VALUE_TOKEN(LCURLY, "{") + STATIC_VALUE_TOKEN(RBRACKET, "]") + STATIC_VALUE_TOKEN(LBRACKET, "[") + STATIC_VALUE_TOKEN(RPAREN, ")") + STATIC_VALUE_TOKEN(LPAREN, "(") + STATIC_VALUE_TOKEN(COLON, ":") + STATIC_VALUE_TOKEN(VAR_SIGN, "$") + STATIC_VALUE_TOKEN(DIR_SIGN, "@") + STATIC_VALUE_TOKEN(ELLIPSIS, "...") + STATIC_VALUE_TOKEN(EQUALS, "=") + STATIC_VALUE_TOKEN(BANG, "!") + STATIC_VALUE_TOKEN(PIPE, "|") + STATIC_VALUE_TOKEN(AMP, "&") + STATIC_VALUE_TOKEN(SCHEMA, "schema") + STATIC_VALUE_TOKEN(SCALAR, "scalar") + STATIC_VALUE_TOKEN(EXTEND, "extend") + STATIC_VALUE_TOKEN(IMPLEMENTS, "implements") + STATIC_VALUE_TOKEN(INTERFACE, "interface") + STATIC_VALUE_TOKEN(UNION, "union") + STATIC_VALUE_TOKEN(ENUM, "enum") + STATIC_VALUE_TOKEN(DIRECTIVE, "directive") + STATIC_VALUE_TOKEN(INPUT, "input") + // For these, the enum name doesn't match the symbol name: + case TYPE_LITERAL: + token_sym = ID2SYM(rb_intern("TYPE")); + token_content = GraphQL_type_str; + break; + case TRUE_LITERAL: + token_sym = ID2SYM(rb_intern("TRUE")); + token_content = GraphQL_true_str; + break; + case FALSE_LITERAL: + token_sym = ID2SYM(rb_intern("FALSE")); + token_content = GraphQL_false_str; + break; + case NULL_LITERAL: + token_sym = ID2SYM(rb_intern("NULL")); + token_content = GraphQL_null_str; + break; + case IDENTIFIER: + if (meta->reject_numbers_followed_by_names && meta->preceeded_by_number) { + VALUE mGraphQL = rb_const_get_at(rb_cObject, rb_intern("GraphQL")); + VALUE mCParser = rb_const_get_at(mGraphQL, rb_intern("CParser")); + VALUE prev_token = rb_ary_entry(meta->tokens, -1); + VALUE exception = rb_funcall( + mCParser, rb_intern("prepare_number_name_parse_error"), 5, + LONG2NUM(meta->line), + LONG2NUM(meta->col), + rb_str_new_cstr(meta->query_cstr), + rb_ary_entry(prev_token, 3), + rb_utf8_str_new(ts, te - ts) + ); + rb_exc_raise(exception); + } + token_sym = ID2SYM(rb_intern("IDENTIFIER")); + if (meta->dedup_identifiers) { + token_content = rb_enc_interned_str(ts, te - ts, rb_utf8_encoding()); + } else { + token_content = rb_utf8_str_new(ts, te - ts); + } + break; + // Can't use these while we're in backwards-compat mode: + // DYNAMIC_VALUE_TOKEN(INT) + // DYNAMIC_VALUE_TOKEN(FLOAT) + case INT: + token_sym = ID2SYM(rb_intern("INT")); + token_content = rb_utf8_str_new(ts, te - ts); + this_token_is_number = 1; + break; + case FLOAT: + token_sym = ID2SYM(rb_intern("FLOAT")); + token_content = rb_utf8_str_new(ts, te - ts); + this_token_is_number = 1; + break; + DYNAMIC_VALUE_TOKEN(COMMENT) + case UNKNOWN_CHAR: + if (ts[0] == '\0') { + return; + } else { + token_content = rb_utf8_str_new(ts, te - ts); + token_sym = ID2SYM(rb_intern("UNKNOWN_CHAR")); + break; + } + case QUOTED_STRING: + quotes_length = 1; + token_content = rb_utf8_str_new(ts + quotes_length, (te - ts - (2 * quotes_length))); + token_sym = ID2SYM(rb_intern("STRING")); + break; + case BLOCK_STRING: + token_sym = ID2SYM(rb_intern("STRING")); + quotes_length = 3; + token_content = rb_utf8_str_new(ts + quotes_length, (te - ts - (2 * quotes_length))); + line_incr = FIX2INT(rb_funcall(token_content, rb_intern("count"), 1, rb_utf8_str_new_cstr("\n"))); + break; + // These are used only by the parser, this is never reached + case STRING: + case BAD_UNICODE_ESCAPE: + break; + } + + if (token_sym != Qnil) { + if (tt == BLOCK_STRING || tt == QUOTED_STRING) { + VALUE mGraphQL = rb_const_get_at(rb_cObject, rb_intern("GraphQL")); + VALUE mGraphQLLanguage = rb_const_get_at(mGraphQL, rb_intern("Language")); + VALUE mGraphQLLanguageLexer = rb_const_get_at(mGraphQLLanguage, rb_intern("Lexer")); + VALUE valid_string_pattern = rb_const_get_at(mGraphQLLanguageLexer, rb_intern("VALID_STRING")); + if (tt == BLOCK_STRING) { + VALUE mGraphQLLanguageBlockString = rb_const_get_at(mGraphQLLanguage, rb_intern("BlockString")); + token_content = rb_funcall(mGraphQLLanguageBlockString, rb_intern("trim_whitespace"), 1, token_content); + tt = STRING; + } else { + tt = STRING; + if ( + RB_TEST(rb_funcall(token_content, rb_intern("valid_encoding?"), 0)) && + RB_TEST(rb_funcall(token_content, rb_intern("match?"), 1, valid_string_pattern)) + ) { + rb_funcall(mGraphQLLanguageLexer, rb_intern("replace_escaped_characters_in_place"), 1, token_content); + if (!RB_TEST(rb_funcall(token_content, rb_intern("valid_encoding?"), 0))) { + token_sym = ID2SYM(rb_intern("BAD_UNICODE_ESCAPE")); + tt = BAD_UNICODE_ESCAPE; + } + } else { + token_sym = ID2SYM(rb_intern("BAD_UNICODE_ESCAPE")); + tt = BAD_UNICODE_ESCAPE; + } + } + } + + VALUE token = rb_ary_new_from_args(5, + token_sym, + rb_int2inum(meta->line), + rb_int2inum(meta->col), + token_content, + INT2FIX(200 + (int)tt) + ); + + if (tt != COMMENT) { + rb_ary_push(meta->tokens, token); + } + meta->preceeded_by_number = this_token_is_number; + } + // Bump the column counter for the next token + meta->col += te - ts; + meta->line += line_incr; +} + +VALUE tokenize(VALUE query_rbstr, int fstring_identifiers, int reject_numbers_followed_by_names, int max_tokens) { + int cs = 0; + int act = 0; + char *p = StringValuePtr(query_rbstr); + long query_len = RSTRING_LEN(query_rbstr); + char *pe = p + query_len; + char *eof = pe; + char *ts = 0; + char *te = 0; + VALUE tokens = rb_ary_new(); + struct Meta meta_s = {1, 1, p, pe, tokens, fstring_identifiers, reject_numbers_followed_by_names, 0, max_tokens, 0}; + Meta *meta = &meta_s; + + +#line 987 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + { + cs = (int)graphql_c_lexer_start; + ts = 0; + te = 0; + act = 0; + } + +#line 407 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + + +#line 998 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + { + unsigned int _trans = 0; + const char * _keys; + const short * _inds; + int _ic; + _resume: {} + if ( p == pe && p != eof ) + goto _out; + switch ( _graphql_c_lexer_from_state_actions[cs] ) { + case 10: { + { +#line 1 "NONE" + {ts = p;}} + +#line 1013 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + } + + if ( p == eof ) { + if ( _graphql_c_lexer_eof_trans[cs] > 0 ) { + _trans = (unsigned int)_graphql_c_lexer_eof_trans[cs] - 1; + } + } + else { + _keys = ( _graphql_c_lexer_trans_keys + ((cs<<1))); + _inds = ( _graphql_c_lexer_indices + (_graphql_c_lexer_index_offsets[cs])); + + if ( ( (*( p))) <= 125 && ( (*( p))) >= 9 ) { + _ic = (int)_graphql_c_lexer_char_class[(int)( (*( p))) - 9]; + if ( _ic <= (int)(*( _keys+1)) && _ic >= (int)(*( _keys)) ) + _trans = (unsigned int)(*( _inds + (int)( _ic - (int)(*( _keys)) ) )); + else + _trans = (unsigned int)_graphql_c_lexer_index_defaults[cs]; + } + else { + _trans = (unsigned int)_graphql_c_lexer_index_defaults[cs]; + } + + } + cs = (int)_graphql_c_lexer_cond_targs[_trans]; + + if ( _graphql_c_lexer_cond_actions[_trans] != 0 ) { + + switch ( _graphql_c_lexer_cond_actions[_trans] ) { + case 6: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1051 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 26: { + { +#line 75 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 75 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(RCURLY, ts, te, meta); } + }} + +#line 1064 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 24: { + { +#line 76 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 76 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(LCURLY, ts, te, meta); } + }} + +#line 1077 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 17: { + { +#line 77 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 77 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(RPAREN, ts, te, meta); } + }} + +#line 1090 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 16: { + { +#line 78 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 78 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(LPAREN, ts, te, meta); } + }} + +#line 1103 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 23: { + { +#line 79 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 79 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(RBRACKET, ts, te, meta); } + }} + +#line 1116 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 22: { + { +#line 80 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 80 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(LBRACKET, ts, te, meta); } + }} + +#line 1129 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 18: { + { +#line 81 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 81 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(COLON, ts, te, meta); } + }} + +#line 1142 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 32: { + { +#line 82 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 82 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(BLOCK_STRING, ts, te, meta); } + }} + +#line 1155 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 2: { + { +#line 83 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 83 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(QUOTED_STRING, ts, te, meta); } + }} + +#line 1168 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 14: { + { +#line 84 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 84 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(VAR_SIGN, ts, te, meta); } + }} + +#line 1181 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 20: { + { +#line 85 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 85 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(DIR_SIGN, ts, te, meta); } + }} + +#line 1194 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 8: { + { +#line 86 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 86 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(ELLIPSIS, ts, te, meta); } + }} + +#line 1207 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 19: { + { +#line 87 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 87 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(EQUALS, ts, te, meta); } + }} + +#line 1220 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 13: { + { +#line 88 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 88 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(BANG, ts, te, meta); } + }} + +#line 1233 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 25: { + { +#line 89 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 89 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(PIPE, ts, te, meta); } + }} + +#line 1246 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 15: { + { +#line 90 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 90 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(AMP, ts, te, meta); } + }} + +#line 1259 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 12: { + { +#line 93 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 93 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + + meta->line += 1; + meta->col = 1; + meta->preceeded_by_number = 0; + } + }} + +#line 1276 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 11: { + { +#line 104 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p+1;{ +#line 104 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(UNKNOWN_CHAR, ts, te, meta); } + }} + +#line 1289 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 34: { + { +#line 54 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p;p = p - 1;{ +#line 54 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(INT, ts, te, meta); } + }} + +#line 1302 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 35: { + { +#line 55 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p;p = p - 1;{ +#line 55 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(FLOAT, ts, te, meta); } + }} + +#line 1315 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 31: { + { +#line 82 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p;p = p - 1;{ +#line 82 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(BLOCK_STRING, ts, te, meta); } + }} + +#line 1328 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 30: { + { +#line 83 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p;p = p - 1;{ +#line 83 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(QUOTED_STRING, ts, te, meta); } + }} + +#line 1341 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 36: { + { +#line 91 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p;p = p - 1;{ +#line 91 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(IDENTIFIER, ts, te, meta); } + }} + +#line 1354 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 33: { + { +#line 92 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p;p = p - 1;{ +#line 92 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(COMMENT, ts, te, meta); } + }} + +#line 1367 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 27: { + { +#line 99 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p;p = p - 1;{ +#line 99 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + + meta->col += te - ts; + meta->preceeded_by_number = 0; + } + }} + +#line 1383 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 28: { + { +#line 104 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {te = p;p = p - 1;{ +#line 104 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(UNKNOWN_CHAR, ts, te, meta); } + }} + +#line 1396 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 5: { + { +#line 54 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {p = ((te))-1; + { +#line 54 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(INT, ts, te, meta); } + }} + +#line 1410 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 7: { + { +#line 55 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {p = ((te))-1; + { +#line 55 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(FLOAT, ts, te, meta); } + }} + +#line 1424 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 1: { + { +#line 104 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {p = ((te))-1; + { +#line 104 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(UNKNOWN_CHAR, ts, te, meta); } + }} + +#line 1438 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 3: { + { +#line 1 "NONE" + {switch( act ) { + case 3: { + p = ((te))-1; + { +#line 56 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(ON, ts, te, meta); } + break; + } + case 4: { + p = ((te))-1; + { +#line 57 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(FRAGMENT, ts, te, meta); } + break; + } + case 5: { + p = ((te))-1; + { +#line 58 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(TRUE_LITERAL, ts, te, meta); } + break; + } + case 6: { + p = ((te))-1; + { +#line 59 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(FALSE_LITERAL, ts, te, meta); } + break; + } + case 7: { + p = ((te))-1; + { +#line 60 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(NULL_LITERAL, ts, te, meta); } + break; + } + case 8: { + p = ((te))-1; + { +#line 61 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(QUERY, ts, te, meta); } + break; + } + case 9: { + p = ((te))-1; + { +#line 62 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(MUTATION, ts, te, meta); } + break; + } + case 10: { + p = ((te))-1; + { +#line 63 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(SUBSCRIPTION, ts, te, meta); } + break; + } + case 11: { + p = ((te))-1; + { +#line 64 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(SCHEMA, ts, te, meta); } + break; + } + case 12: { + p = ((te))-1; + { +#line 65 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(SCALAR, ts, te, meta); } + break; + } + case 13: { + p = ((te))-1; + { +#line 66 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(TYPE_LITERAL, ts, te, meta); } + break; + } + case 14: { + p = ((te))-1; + { +#line 67 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(EXTEND, ts, te, meta); } + break; + } + case 15: { + p = ((te))-1; + { +#line 68 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(IMPLEMENTS, ts, te, meta); } + break; + } + case 16: { + p = ((te))-1; + { +#line 69 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(INTERFACE, ts, te, meta); } + break; + } + case 17: { + p = ((te))-1; + { +#line 70 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(UNION, ts, te, meta); } + break; + } + case 18: { + p = ((te))-1; + { +#line 71 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(ENUM, ts, te, meta); } + break; + } + case 19: { + p = ((te))-1; + { +#line 72 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(INPUT, ts, te, meta); } + break; + } + case 20: { + p = ((te))-1; + { +#line 73 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(DIRECTIVE, ts, te, meta); } + break; + } + case 21: { + p = ((te))-1; + { +#line 74 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(REPEATABLE, ts, te, meta); } + break; + } + case 29: { + p = ((te))-1; + { +#line 82 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(BLOCK_STRING, ts, te, meta); } + break; + } + case 30: { + p = ((te))-1; + { +#line 83 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(QUOTED_STRING, ts, te, meta); } + break; + } + case 38: { + p = ((te))-1; + { +#line 91 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + emit(IDENTIFIER, ts, te, meta); } + break; + } + }} + } + +#line 1604 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 47: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1614 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 56 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 3;}} + +#line 1620 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 41: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1630 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 57 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 4;}} + +#line 1636 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 53: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1646 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 58 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 5;}} + +#line 1652 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 40: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1662 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 59 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 6;}} + +#line 1668 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 46: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1678 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 60 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 7;}} + +#line 1684 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 48: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1694 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 61 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 8;}} + +#line 1700 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 45: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1710 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 62 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 9;}} + +#line 1716 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 52: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1726 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 63 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 10;}} + +#line 1732 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 51: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1742 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 64 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 11;}} + +#line 1748 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 50: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1758 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 65 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 12;}} + +#line 1764 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 54: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1774 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 66 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 13;}} + +#line 1780 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 39: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1790 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 67 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 14;}} + +#line 1796 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 42: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1806 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 68 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 15;}} + +#line 1812 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 44: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1822 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 69 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 16;}} + +#line 1828 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 55: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1838 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 70 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 17;}} + +#line 1844 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 38: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1854 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 71 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 18;}} + +#line 1860 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 43: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1870 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 72 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 19;}} + +#line 1876 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 37: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1886 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 73 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 20;}} + +#line 1892 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 49: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1902 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 74 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 21;}} + +#line 1908 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 4: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1918 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 82 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 29;}} + +#line 1924 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 29: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1934 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 83 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 30;}} + +#line 1940 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + case 21: { + { +#line 1 "NONE" + {te = p+1;}} + +#line 1950 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + { +#line 91 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + {act = 38;}} + +#line 1956 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + } + + } + + if ( p == eof ) { + if ( cs >= 21 ) + goto _out; + } + else { + switch ( _graphql_c_lexer_to_state_actions[cs] ) { + case 9: { + { +#line 1 "NONE" + {ts = 0;}} + +#line 1976 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.c" + + + break; + } + } + + p += 1; + goto _resume; + } + _out: {} + } + +#line 408 "graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl" + + + return tokens; +} + + +#define SETUP_STATIC_TOKEN_VARIABLE(token_name, token_content) \ +GraphQLTokenString##token_name = rb_utf8_str_new_cstr(token_content); \ +rb_funcall(GraphQLTokenString##token_name, rb_intern("-@"), 0); \ +rb_global_variable(&GraphQLTokenString##token_name); \ + +#define SETUP_STATIC_STRING(var_name, str_content) \ +var_name = rb_utf8_str_new_cstr(str_content); \ +rb_global_variable(&var_name); \ +rb_str_freeze(var_name); \ + +void setup_static_token_variables() { + SETUP_STATIC_TOKEN_VARIABLE(ON, "on") + SETUP_STATIC_TOKEN_VARIABLE(FRAGMENT, "fragment") + SETUP_STATIC_TOKEN_VARIABLE(QUERY, "query") + SETUP_STATIC_TOKEN_VARIABLE(MUTATION, "mutation") + SETUP_STATIC_TOKEN_VARIABLE(SUBSCRIPTION, "subscription") + SETUP_STATIC_TOKEN_VARIABLE(REPEATABLE, "repeatable") + SETUP_STATIC_TOKEN_VARIABLE(RCURLY, "}") +SETUP_STATIC_TOKEN_VARIABLE(LCURLY, "{") + SETUP_STATIC_TOKEN_VARIABLE(RBRACKET, "]") + SETUP_STATIC_TOKEN_VARIABLE(LBRACKET, "[") + SETUP_STATIC_TOKEN_VARIABLE(RPAREN, ")") + SETUP_STATIC_TOKEN_VARIABLE(LPAREN, "(") + SETUP_STATIC_TOKEN_VARIABLE(COLON, ":") + SETUP_STATIC_TOKEN_VARIABLE(VAR_SIGN, "$") + SETUP_STATIC_TOKEN_VARIABLE(DIR_SIGN, "@") + SETUP_STATIC_TOKEN_VARIABLE(ELLIPSIS, "...") + SETUP_STATIC_TOKEN_VARIABLE(EQUALS, "=") + SETUP_STATIC_TOKEN_VARIABLE(BANG, "!") + SETUP_STATIC_TOKEN_VARIABLE(PIPE, "|") + SETUP_STATIC_TOKEN_VARIABLE(AMP, "&") + SETUP_STATIC_TOKEN_VARIABLE(SCHEMA, "schema") + SETUP_STATIC_TOKEN_VARIABLE(SCALAR, "scalar") + SETUP_STATIC_TOKEN_VARIABLE(EXTEND, "extend") + SETUP_STATIC_TOKEN_VARIABLE(IMPLEMENTS, "implements") + SETUP_STATIC_TOKEN_VARIABLE(INTERFACE, "interface") + SETUP_STATIC_TOKEN_VARIABLE(UNION, "union") + SETUP_STATIC_TOKEN_VARIABLE(ENUM, "enum") + SETUP_STATIC_TOKEN_VARIABLE(DIRECTIVE, "directive") + SETUP_STATIC_TOKEN_VARIABLE(INPUT, "input") + + SETUP_STATIC_STRING(GraphQL_type_str, "type") + SETUP_STATIC_STRING(GraphQL_true_str, "true") + SETUP_STATIC_STRING(GraphQL_false_str, "false") + SETUP_STATIC_STRING(GraphQL_null_str, "null") +} diff --git a/graphql-c_parser/ext/graphql_c_parser_ext/lexer.h b/graphql-c_parser/ext/graphql_c_parser_ext/lexer.h new file mode 100644 index 00000000000..199628266b4 --- /dev/null +++ b/graphql-c_parser/ext/graphql_c_parser_ext/lexer.h @@ -0,0 +1,6 @@ +#ifndef Graphql_lexer_h +#define Graphql_lexer_h +#include +VALUE tokenize(VALUE query_rbstr, int fstring_identifiers, int reject_numbers_followed_by_names, int max_tokens); +void setup_static_token_variables(); +#endif diff --git a/graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl b/graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl new file mode 100644 index 00000000000..51d886d58a7 --- /dev/null +++ b/graphql-c_parser/ext/graphql_c_parser_ext/lexer.rl @@ -0,0 +1,459 @@ +%%{ + machine graphql_c_lexer; + + IDENTIFIER = [_A-Za-z][_0-9A-Za-z]*; + NEWLINE = [\c\r\n]; + BLANK = [, \t]+; + COMMENT = '#' [^\n\r]*; + INT = '-'? ('0'|[1-9][0-9]*); + FLOAT = INT ('.'[0-9]+) (('e' | 'E')?('+' | '-')?[0-9]+)?; + ON = 'on'; + FRAGMENT = 'fragment'; + TRUE_LITERAL = 'true'; + FALSE_LITERAL = 'false'; + NULL_LITERAL = 'null'; + QUERY = 'query'; + MUTATION = 'mutation'; + SUBSCRIPTION = 'subscription'; + SCHEMA = 'schema'; + SCALAR = 'scalar'; + TYPE_LITERAL = 'type'; + EXTEND = 'extend'; + IMPLEMENTS = 'implements'; + INTERFACE = 'interface'; + UNION = 'union'; + ENUM = 'enum'; + INPUT = 'input'; + DIRECTIVE = 'directive'; + REPEATABLE = 'repeatable'; + LCURLY = '{'; + RCURLY = '}'; + LPAREN = '('; + RPAREN = ')'; + LBRACKET = '['; + RBRACKET = ']'; + COLON = ':'; + # Could limit to hex here, but “bad unicode escape” on 0XXF is probably a + # more helpful error than “unknown char” + UNICODE_ESCAPE = "\\u" ([0-9A-Za-z]{4} | LCURLY [0-9A-Za-z]{4,} RCURLY); + VAR_SIGN = '$'; + DIR_SIGN = '@'; + ELLIPSIS = '...'; + EQUALS = '='; + BANG = '!'; + PIPE = '|'; + AMP = '&'; + + QUOTED_STRING = ('"' ((('\\"' | ^'"') - "\\" - "\n" - "\r") | UNICODE_ESCAPE | '\\' [\\/bfnrt])* '"'); + # catch-all for anything else. must be at the bottom for precedence. + UNKNOWN_CHAR = /./; + + BLOCK_STRING = ('"""' ('\\"""' | ^'"' | '"'{1,2} ^'"')* '"'{0,2} '"""'); + + main := |* + INT => { emit(INT, ts, te, meta); }; + FLOAT => { emit(FLOAT, ts, te, meta); }; + ON => { emit(ON, ts, te, meta); }; + FRAGMENT => { emit(FRAGMENT, ts, te, meta); }; + TRUE_LITERAL => { emit(TRUE_LITERAL, ts, te, meta); }; + FALSE_LITERAL => { emit(FALSE_LITERAL, ts, te, meta); }; + NULL_LITERAL => { emit(NULL_LITERAL, ts, te, meta); }; + QUERY => { emit(QUERY, ts, te, meta); }; + MUTATION => { emit(MUTATION, ts, te, meta); }; + SUBSCRIPTION => { emit(SUBSCRIPTION, ts, te, meta); }; + SCHEMA => { emit(SCHEMA, ts, te, meta); }; + SCALAR => { emit(SCALAR, ts, te, meta); }; + TYPE_LITERAL => { emit(TYPE_LITERAL, ts, te, meta); }; + EXTEND => { emit(EXTEND, ts, te, meta); }; + IMPLEMENTS => { emit(IMPLEMENTS, ts, te, meta); }; + INTERFACE => { emit(INTERFACE, ts, te, meta); }; + UNION => { emit(UNION, ts, te, meta); }; + ENUM => { emit(ENUM, ts, te, meta); }; + INPUT => { emit(INPUT, ts, te, meta); }; + DIRECTIVE => { emit(DIRECTIVE, ts, te, meta); }; + REPEATABLE => { emit(REPEATABLE, ts, te, meta); }; + RCURLY => { emit(RCURLY, ts, te, meta); }; + LCURLY => { emit(LCURLY, ts, te, meta); }; + RPAREN => { emit(RPAREN, ts, te, meta); }; + LPAREN => { emit(LPAREN, ts, te, meta); }; + RBRACKET => { emit(RBRACKET, ts, te, meta); }; + LBRACKET => { emit(LBRACKET, ts, te, meta); }; + COLON => { emit(COLON, ts, te, meta); }; + BLOCK_STRING => { emit(BLOCK_STRING, ts, te, meta); }; + QUOTED_STRING => { emit(QUOTED_STRING, ts, te, meta); }; + VAR_SIGN => { emit(VAR_SIGN, ts, te, meta); }; + DIR_SIGN => { emit(DIR_SIGN, ts, te, meta); }; + ELLIPSIS => { emit(ELLIPSIS, ts, te, meta); }; + EQUALS => { emit(EQUALS, ts, te, meta); }; + BANG => { emit(BANG, ts, te, meta); }; + PIPE => { emit(PIPE, ts, te, meta); }; + AMP => { emit(AMP, ts, te, meta); }; + IDENTIFIER => { emit(IDENTIFIER, ts, te, meta); }; + COMMENT => { emit(COMMENT, ts, te, meta); }; + NEWLINE => { + meta->line += 1; + meta->col = 1; + meta->preceeded_by_number = 0; + }; + + BLANK => { + meta->col += te - ts; + meta->preceeded_by_number = 0; + }; + + UNKNOWN_CHAR => { emit(UNKNOWN_CHAR, ts, te, meta); }; + *|; +}%% + +%% write data; + +#include +#include + +#define INIT_STATIC_TOKEN_VARIABLE(token_name) \ + static VALUE GraphQLTokenString##token_name; + +INIT_STATIC_TOKEN_VARIABLE(ON) +INIT_STATIC_TOKEN_VARIABLE(FRAGMENT) +INIT_STATIC_TOKEN_VARIABLE(QUERY) +INIT_STATIC_TOKEN_VARIABLE(MUTATION) +INIT_STATIC_TOKEN_VARIABLE(SUBSCRIPTION) +INIT_STATIC_TOKEN_VARIABLE(REPEATABLE) +INIT_STATIC_TOKEN_VARIABLE(RCURLY) +INIT_STATIC_TOKEN_VARIABLE(LCURLY) +INIT_STATIC_TOKEN_VARIABLE(RBRACKET) +INIT_STATIC_TOKEN_VARIABLE(LBRACKET) +INIT_STATIC_TOKEN_VARIABLE(RPAREN) +INIT_STATIC_TOKEN_VARIABLE(LPAREN) +INIT_STATIC_TOKEN_VARIABLE(COLON) +INIT_STATIC_TOKEN_VARIABLE(VAR_SIGN) +INIT_STATIC_TOKEN_VARIABLE(DIR_SIGN) +INIT_STATIC_TOKEN_VARIABLE(ELLIPSIS) +INIT_STATIC_TOKEN_VARIABLE(EQUALS) +INIT_STATIC_TOKEN_VARIABLE(BANG) +INIT_STATIC_TOKEN_VARIABLE(PIPE) +INIT_STATIC_TOKEN_VARIABLE(AMP) +INIT_STATIC_TOKEN_VARIABLE(SCHEMA) +INIT_STATIC_TOKEN_VARIABLE(SCALAR) +INIT_STATIC_TOKEN_VARIABLE(EXTEND) +INIT_STATIC_TOKEN_VARIABLE(IMPLEMENTS) +INIT_STATIC_TOKEN_VARIABLE(INTERFACE) +INIT_STATIC_TOKEN_VARIABLE(UNION) +INIT_STATIC_TOKEN_VARIABLE(ENUM) +INIT_STATIC_TOKEN_VARIABLE(DIRECTIVE) +INIT_STATIC_TOKEN_VARIABLE(INPUT) + +static VALUE GraphQL_type_str; +static VALUE GraphQL_true_str; +static VALUE GraphQL_false_str; +static VALUE GraphQL_null_str; +typedef enum TokenType { + AMP, + BANG, + COLON, + DIRECTIVE, + DIR_SIGN, + ENUM, + ELLIPSIS, + EQUALS, + EXTEND, + FALSE_LITERAL, + FLOAT, + FRAGMENT, + IDENTIFIER, + INPUT, + IMPLEMENTS, + INT, + INTERFACE, + LBRACKET, + LCURLY, + LPAREN, + MUTATION, + NULL_LITERAL, + ON, + PIPE, + QUERY, + RBRACKET, + RCURLY, + REPEATABLE, + RPAREN, + SCALAR, + SCHEMA, + STRING, + SUBSCRIPTION, + TRUE_LITERAL, + TYPE_LITERAL, + UNION, + VAR_SIGN, + BLOCK_STRING, + QUOTED_STRING, + UNKNOWN_CHAR, + COMMENT, + BAD_UNICODE_ESCAPE +} TokenType; + +typedef struct Meta { + int line; + int col; + char *query_cstr; + char *pe; + VALUE tokens; + int dedup_identifiers; + int reject_numbers_followed_by_names; + int preceeded_by_number; + int max_tokens; + int tokens_count; +} Meta; + +#define STATIC_VALUE_TOKEN(token_type, content_str) \ + case token_type: \ + token_sym = ID2SYM(rb_intern(#token_type)); \ + token_content = GraphQLTokenString##token_type; \ + break; + +#define DYNAMIC_VALUE_TOKEN(token_type) \ + case token_type: \ + token_sym = ID2SYM(rb_intern(#token_type)); \ + token_content = rb_utf8_str_new(ts, te - ts); \ + break; + +void emit(TokenType tt, char *ts, char *te, Meta *meta) { + meta->tokens_count++; + // -1 indicates that there is no limit: + if (meta->max_tokens > 0 && meta->tokens_count > meta->max_tokens) { + VALUE mGraphQL = rb_const_get_at(rb_cObject, rb_intern("GraphQL")); + VALUE cParseError = rb_const_get_at(mGraphQL, rb_intern("ParseError")); + VALUE exception = rb_funcall( + cParseError, rb_intern("new"), 4, + rb_str_new_cstr("This query is too large to execute."), + LONG2NUM(meta->line), + LONG2NUM(meta->col), + rb_str_new_cstr(meta->query_cstr) + ); + rb_exc_raise(exception); + } + int quotes_length = 0; // set by string tokens below + int line_incr = 0; + VALUE token_sym = Qnil; + VALUE token_content = Qnil; + int this_token_is_number = 0; + switch(tt) { + STATIC_VALUE_TOKEN(ON, "on") + STATIC_VALUE_TOKEN(FRAGMENT, "fragment") + STATIC_VALUE_TOKEN(QUERY, "query") + STATIC_VALUE_TOKEN(MUTATION, "mutation") + STATIC_VALUE_TOKEN(SUBSCRIPTION, "subscription") + STATIC_VALUE_TOKEN(REPEATABLE, "repeatable") + STATIC_VALUE_TOKEN(RCURLY, "}") + STATIC_VALUE_TOKEN(LCURLY, "{") + STATIC_VALUE_TOKEN(RBRACKET, "]") + STATIC_VALUE_TOKEN(LBRACKET, "[") + STATIC_VALUE_TOKEN(RPAREN, ")") + STATIC_VALUE_TOKEN(LPAREN, "(") + STATIC_VALUE_TOKEN(COLON, ":") + STATIC_VALUE_TOKEN(VAR_SIGN, "$") + STATIC_VALUE_TOKEN(DIR_SIGN, "@") + STATIC_VALUE_TOKEN(ELLIPSIS, "...") + STATIC_VALUE_TOKEN(EQUALS, "=") + STATIC_VALUE_TOKEN(BANG, "!") + STATIC_VALUE_TOKEN(PIPE, "|") + STATIC_VALUE_TOKEN(AMP, "&") + STATIC_VALUE_TOKEN(SCHEMA, "schema") + STATIC_VALUE_TOKEN(SCALAR, "scalar") + STATIC_VALUE_TOKEN(EXTEND, "extend") + STATIC_VALUE_TOKEN(IMPLEMENTS, "implements") + STATIC_VALUE_TOKEN(INTERFACE, "interface") + STATIC_VALUE_TOKEN(UNION, "union") + STATIC_VALUE_TOKEN(ENUM, "enum") + STATIC_VALUE_TOKEN(DIRECTIVE, "directive") + STATIC_VALUE_TOKEN(INPUT, "input") + // For these, the enum name doesn't match the symbol name: + case TYPE_LITERAL: + token_sym = ID2SYM(rb_intern("TYPE")); + token_content = GraphQL_type_str; + break; + case TRUE_LITERAL: + token_sym = ID2SYM(rb_intern("TRUE")); + token_content = GraphQL_true_str; + break; + case FALSE_LITERAL: + token_sym = ID2SYM(rb_intern("FALSE")); + token_content = GraphQL_false_str; + break; + case NULL_LITERAL: + token_sym = ID2SYM(rb_intern("NULL")); + token_content = GraphQL_null_str; + break; + case IDENTIFIER: + if (meta->reject_numbers_followed_by_names && meta->preceeded_by_number) { + VALUE mGraphQL = rb_const_get_at(rb_cObject, rb_intern("GraphQL")); + VALUE mCParser = rb_const_get_at(mGraphQL, rb_intern("CParser")); + VALUE prev_token = rb_ary_entry(meta->tokens, -1); + VALUE exception = rb_funcall( + mCParser, rb_intern("prepare_number_name_parse_error"), 5, + LONG2NUM(meta->line), + LONG2NUM(meta->col), + rb_str_new_cstr(meta->query_cstr), + rb_ary_entry(prev_token, 3), + rb_utf8_str_new(ts, te - ts) + ); + rb_exc_raise(exception); + } + token_sym = ID2SYM(rb_intern("IDENTIFIER")); + if (meta->dedup_identifiers) { + token_content = rb_enc_interned_str(ts, te - ts, rb_utf8_encoding()); + } else { + token_content = rb_utf8_str_new(ts, te - ts); + } + break; + // Can't use these while we're in backwards-compat mode: + // DYNAMIC_VALUE_TOKEN(INT) + // DYNAMIC_VALUE_TOKEN(FLOAT) + case INT: + token_sym = ID2SYM(rb_intern("INT")); + token_content = rb_utf8_str_new(ts, te - ts); + this_token_is_number = 1; + break; + case FLOAT: + token_sym = ID2SYM(rb_intern("FLOAT")); + token_content = rb_utf8_str_new(ts, te - ts); + this_token_is_number = 1; + break; + DYNAMIC_VALUE_TOKEN(COMMENT) + case UNKNOWN_CHAR: + if (ts[0] == '\0') { + return; + } else { + token_content = rb_utf8_str_new(ts, te - ts); + token_sym = ID2SYM(rb_intern("UNKNOWN_CHAR")); + break; + } + case QUOTED_STRING: + quotes_length = 1; + token_content = rb_utf8_str_new(ts + quotes_length, (te - ts - (2 * quotes_length))); + token_sym = ID2SYM(rb_intern("STRING")); + break; + case BLOCK_STRING: + token_sym = ID2SYM(rb_intern("STRING")); + quotes_length = 3; + token_content = rb_utf8_str_new(ts + quotes_length, (te - ts - (2 * quotes_length))); + line_incr = FIX2INT(rb_funcall(token_content, rb_intern("count"), 1, rb_utf8_str_new_cstr("\n"))); + break; + // These are used only by the parser, this is never reached + case STRING: + case BAD_UNICODE_ESCAPE: + break; + } + + if (token_sym != Qnil) { + if (tt == BLOCK_STRING || tt == QUOTED_STRING) { + VALUE mGraphQL = rb_const_get_at(rb_cObject, rb_intern("GraphQL")); + VALUE mGraphQLLanguage = rb_const_get_at(mGraphQL, rb_intern("Language")); + VALUE mGraphQLLanguageLexer = rb_const_get_at(mGraphQLLanguage, rb_intern("Lexer")); + VALUE valid_string_pattern = rb_const_get_at(mGraphQLLanguageLexer, rb_intern("VALID_STRING")); + if (tt == BLOCK_STRING) { + VALUE mGraphQLLanguageBlockString = rb_const_get_at(mGraphQLLanguage, rb_intern("BlockString")); + token_content = rb_funcall(mGraphQLLanguageBlockString, rb_intern("trim_whitespace"), 1, token_content); + tt = STRING; + } else { + tt = STRING; + if ( + RB_TEST(rb_funcall(token_content, rb_intern("valid_encoding?"), 0)) && + RB_TEST(rb_funcall(token_content, rb_intern("match?"), 1, valid_string_pattern)) + ) { + rb_funcall(mGraphQLLanguageLexer, rb_intern("replace_escaped_characters_in_place"), 1, token_content); + if (!RB_TEST(rb_funcall(token_content, rb_intern("valid_encoding?"), 0))) { + token_sym = ID2SYM(rb_intern("BAD_UNICODE_ESCAPE")); + tt = BAD_UNICODE_ESCAPE; + } + } else { + token_sym = ID2SYM(rb_intern("BAD_UNICODE_ESCAPE")); + tt = BAD_UNICODE_ESCAPE; + } + } + } + + VALUE token = rb_ary_new_from_args(5, + token_sym, + rb_int2inum(meta->line), + rb_int2inum(meta->col), + token_content, + INT2FIX(200 + (int)tt) + ); + + if (tt != COMMENT) { + rb_ary_push(meta->tokens, token); + } + meta->preceeded_by_number = this_token_is_number; + } + // Bump the column counter for the next token + meta->col += te - ts; + meta->line += line_incr; +} + +VALUE tokenize(VALUE query_rbstr, int fstring_identifiers, int reject_numbers_followed_by_names, int max_tokens) { + int cs = 0; + int act = 0; + char *p = StringValuePtr(query_rbstr); + long query_len = RSTRING_LEN(query_rbstr); + char *pe = p + query_len; + char *eof = pe; + char *ts = 0; + char *te = 0; + VALUE tokens = rb_ary_new(); + struct Meta meta_s = {1, 1, p, pe, tokens, fstring_identifiers, reject_numbers_followed_by_names, 0, max_tokens, 0}; + Meta *meta = &meta_s; + + %% write init; + %% write exec; + + return tokens; +} + + +#define SETUP_STATIC_TOKEN_VARIABLE(token_name, token_content) \ + GraphQLTokenString##token_name = rb_utf8_str_new_cstr(token_content); \ + rb_funcall(GraphQLTokenString##token_name, rb_intern("-@"), 0); \ + rb_global_variable(&GraphQLTokenString##token_name); \ + +#define SETUP_STATIC_STRING(var_name, str_content) \ + var_name = rb_utf8_str_new_cstr(str_content); \ + rb_global_variable(&var_name); \ + rb_str_freeze(var_name); \ + +void setup_static_token_variables() { + SETUP_STATIC_TOKEN_VARIABLE(ON, "on") + SETUP_STATIC_TOKEN_VARIABLE(FRAGMENT, "fragment") + SETUP_STATIC_TOKEN_VARIABLE(QUERY, "query") + SETUP_STATIC_TOKEN_VARIABLE(MUTATION, "mutation") + SETUP_STATIC_TOKEN_VARIABLE(SUBSCRIPTION, "subscription") + SETUP_STATIC_TOKEN_VARIABLE(REPEATABLE, "repeatable") + SETUP_STATIC_TOKEN_VARIABLE(RCURLY, "}") + SETUP_STATIC_TOKEN_VARIABLE(LCURLY, "{") + SETUP_STATIC_TOKEN_VARIABLE(RBRACKET, "]") + SETUP_STATIC_TOKEN_VARIABLE(LBRACKET, "[") + SETUP_STATIC_TOKEN_VARIABLE(RPAREN, ")") + SETUP_STATIC_TOKEN_VARIABLE(LPAREN, "(") + SETUP_STATIC_TOKEN_VARIABLE(COLON, ":") + SETUP_STATIC_TOKEN_VARIABLE(VAR_SIGN, "$") + SETUP_STATIC_TOKEN_VARIABLE(DIR_SIGN, "@") + SETUP_STATIC_TOKEN_VARIABLE(ELLIPSIS, "...") + SETUP_STATIC_TOKEN_VARIABLE(EQUALS, "=") + SETUP_STATIC_TOKEN_VARIABLE(BANG, "!") + SETUP_STATIC_TOKEN_VARIABLE(PIPE, "|") + SETUP_STATIC_TOKEN_VARIABLE(AMP, "&") + SETUP_STATIC_TOKEN_VARIABLE(SCHEMA, "schema") + SETUP_STATIC_TOKEN_VARIABLE(SCALAR, "scalar") + SETUP_STATIC_TOKEN_VARIABLE(EXTEND, "extend") + SETUP_STATIC_TOKEN_VARIABLE(IMPLEMENTS, "implements") + SETUP_STATIC_TOKEN_VARIABLE(INTERFACE, "interface") + SETUP_STATIC_TOKEN_VARIABLE(UNION, "union") + SETUP_STATIC_TOKEN_VARIABLE(ENUM, "enum") + SETUP_STATIC_TOKEN_VARIABLE(DIRECTIVE, "directive") + SETUP_STATIC_TOKEN_VARIABLE(INPUT, "input") + + SETUP_STATIC_STRING(GraphQL_type_str, "type") + SETUP_STATIC_STRING(GraphQL_true_str, "true") + SETUP_STATIC_STRING(GraphQL_false_str, "false") + SETUP_STATIC_STRING(GraphQL_null_str, "null") +} diff --git a/graphql-c_parser/ext/graphql_c_parser_ext/parser.c b/graphql-c_parser/ext/graphql_c_parser_ext/parser.c new file mode 100644 index 00000000000..ac40bb36b34 --- /dev/null +++ b/graphql-c_parser/ext/graphql_c_parser_ext/parser.c @@ -0,0 +1,3371 @@ +/* A Bison parser, made by GNU Bison 3.8.2. */ + +/* Bison implementation for Yacc-like parsers in C + + Copyright (C) 1984, 1989-1990, 2000-2015, 2018-2021 Free Software Foundation, + Inc. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . */ + +/* As a special exception, you may create a larger work that contains + part or all of the Bison parser skeleton and distribute that work + under terms of your choice, so long as that work isn't itself a + parser generator using the skeleton or a modified version thereof + as a parser skeleton. Alternatively, if you modify or redistribute + the parser skeleton itself, you may (at your option) remove this + special exception, which will cause the skeleton and the resulting + Bison output files to be licensed under the GNU General Public + License without this special exception. + + This special exception was added by the Free Software Foundation in + version 2.2 of Bison. */ + +/* C LALR(1) parser skeleton written by Richard Stallman, by + simplifying the original so-called "semantic" parser. */ + +/* DO NOT RELY ON FEATURES THAT ARE NOT DOCUMENTED in the manual, + especially those whose name start with YY_ or yy_. They are + private implementation details that can be changed or removed. */ + +/* All symbols defined below should begin with yy or YY, to avoid + infringing on user name space. This should be done even for local + variables, as they might otherwise be expanded by user macros. + There are some unavoidable exceptions within include files to + define necessary library symbols; they are noted "INFRINGES ON + USER NAME SPACE" below. */ + +/* Identify Bison output, and Bison version. */ +#define YYBISON 30802 + +/* Bison version string. */ +#define YYBISON_VERSION "3.8.2" + +/* Skeleton name. */ +#define YYSKELETON_NAME "yacc.c" + +/* Pure parsers. */ +#define YYPURE 2 + +/* Push parsers. */ +#define YYPUSH 0 + +/* Pull parsers. */ +#define YYPULL 1 + + + + +/* First part of user prologue. */ +#line 5 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + +// C Declarations +#include +#define YYSTYPE VALUE +#define YYSTACK_USE_ALLOCA 1 + +int yylex(YYSTYPE *, VALUE, VALUE); +void yyerror(VALUE, VALUE, const char*); + +static VALUE GraphQL_Language_Nodes_NONE; +static VALUE r_string_query; + +#define MAKE_AST_NODE(node_class_name, nargs, ...) rb_funcall(GraphQL_Language_Nodes_##node_class_name, rb_intern("from_a"), nargs + 1, filename,__VA_ARGS__) + +#define SETUP_NODE_CLASS_VARIABLE(node_class_name) static VALUE GraphQL_Language_Nodes_##node_class_name; + +SETUP_NODE_CLASS_VARIABLE(Argument) +SETUP_NODE_CLASS_VARIABLE(Directive) +SETUP_NODE_CLASS_VARIABLE(Document) +SETUP_NODE_CLASS_VARIABLE(Enum) +SETUP_NODE_CLASS_VARIABLE(Field) +SETUP_NODE_CLASS_VARIABLE(FragmentDefinition) +SETUP_NODE_CLASS_VARIABLE(FragmentSpread) +SETUP_NODE_CLASS_VARIABLE(InlineFragment) +SETUP_NODE_CLASS_VARIABLE(InputObject) +SETUP_NODE_CLASS_VARIABLE(ListType) +SETUP_NODE_CLASS_VARIABLE(NonNullType) +SETUP_NODE_CLASS_VARIABLE(NullValue) +SETUP_NODE_CLASS_VARIABLE(OperationDefinition) +SETUP_NODE_CLASS_VARIABLE(TypeName) +SETUP_NODE_CLASS_VARIABLE(VariableDefinition) +SETUP_NODE_CLASS_VARIABLE(VariableIdentifier) + +SETUP_NODE_CLASS_VARIABLE(ScalarTypeDefinition) +SETUP_NODE_CLASS_VARIABLE(ObjectTypeDefinition) +SETUP_NODE_CLASS_VARIABLE(InterfaceTypeDefinition) +SETUP_NODE_CLASS_VARIABLE(UnionTypeDefinition) +SETUP_NODE_CLASS_VARIABLE(EnumTypeDefinition) +SETUP_NODE_CLASS_VARIABLE(InputObjectTypeDefinition) +SETUP_NODE_CLASS_VARIABLE(EnumValueDefinition) +SETUP_NODE_CLASS_VARIABLE(DirectiveDefinition) +SETUP_NODE_CLASS_VARIABLE(DirectiveLocation) +SETUP_NODE_CLASS_VARIABLE(FieldDefinition) +SETUP_NODE_CLASS_VARIABLE(InputValueDefinition) +SETUP_NODE_CLASS_VARIABLE(SchemaDefinition) + +SETUP_NODE_CLASS_VARIABLE(ScalarTypeExtension) +SETUP_NODE_CLASS_VARIABLE(ObjectTypeExtension) +SETUP_NODE_CLASS_VARIABLE(InterfaceTypeExtension) +SETUP_NODE_CLASS_VARIABLE(UnionTypeExtension) +SETUP_NODE_CLASS_VARIABLE(EnumTypeExtension) +SETUP_NODE_CLASS_VARIABLE(InputObjectTypeExtension) +SETUP_NODE_CLASS_VARIABLE(SchemaExtension) + +#line 126 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + +# ifndef YY_CAST +# ifdef __cplusplus +# define YY_CAST(Type, Val) static_cast (Val) +# define YY_REINTERPRET_CAST(Type, Val) reinterpret_cast (Val) +# else +# define YY_CAST(Type, Val) ((Type) (Val)) +# define YY_REINTERPRET_CAST(Type, Val) ((Type) (Val)) +# endif +# endif +# ifndef YY_NULLPTR +# if defined __cplusplus +# if 201103L <= __cplusplus +# define YY_NULLPTR nullptr +# else +# define YY_NULLPTR 0 +# endif +# else +# define YY_NULLPTR ((void*)0) +# endif +# endif + + +/* Debug traces. */ +#ifndef YYDEBUG +# define YYDEBUG 0 +#endif +#if YYDEBUG +extern int yydebug; +#endif + +/* Token kinds. */ +#ifndef YYTOKENTYPE +# define YYTOKENTYPE + enum yytokentype + { + YYEMPTY = -2, + YYEOF = 0, /* "end of file" */ + YYerror = 256, /* error */ + YYUNDEF = 257, /* "invalid token" */ + AMP = 200, /* AMP */ + BANG = 201, /* BANG */ + COLON = 202, /* COLON */ + DIRECTIVE = 203, /* DIRECTIVE */ + DIR_SIGN = 204, /* DIR_SIGN */ + ENUM = 205, /* ENUM */ + ELLIPSIS = 206, /* ELLIPSIS */ + EQUALS = 207, /* EQUALS */ + EXTEND = 208, /* EXTEND */ + FALSE_LITERAL = 209, /* FALSE_LITERAL */ + FLOAT = 210, /* FLOAT */ + FRAGMENT = 211, /* FRAGMENT */ + IDENTIFIER = 212, /* IDENTIFIER */ + INPUT = 213, /* INPUT */ + IMPLEMENTS = 214, /* IMPLEMENTS */ + INT = 215, /* INT */ + INTERFACE = 216, /* INTERFACE */ + LBRACKET = 217, /* LBRACKET */ + LCURLY = 218, /* LCURLY */ + LPAREN = 219, /* LPAREN */ + MUTATION = 220, /* MUTATION */ + NULL_LITERAL = 221, /* NULL_LITERAL */ + ON = 222, /* ON */ + PIPE = 223, /* PIPE */ + QUERY = 224, /* QUERY */ + RBRACKET = 225, /* RBRACKET */ + RCURLY = 226, /* RCURLY */ + REPEATABLE = 227, /* REPEATABLE */ + RPAREN = 228, /* RPAREN */ + SCALAR = 229, /* SCALAR */ + SCHEMA = 230, /* SCHEMA */ + STRING = 231, /* STRING */ + SUBSCRIPTION = 232, /* SUBSCRIPTION */ + TRUE_LITERAL = 233, /* TRUE_LITERAL */ + TYPE_LITERAL = 234, /* TYPE_LITERAL */ + UNION = 235, /* UNION */ + VAR_SIGN = 236 /* VAR_SIGN */ + }; + typedef enum yytokentype yytoken_kind_t; +#endif +/* Token kinds. */ +#define YYEMPTY -2 +#define YYEOF 0 +#define YYerror 256 +#define YYUNDEF 257 +#define AMP 200 +#define BANG 201 +#define COLON 202 +#define DIRECTIVE 203 +#define DIR_SIGN 204 +#define ENUM 205 +#define ELLIPSIS 206 +#define EQUALS 207 +#define EXTEND 208 +#define FALSE_LITERAL 209 +#define FLOAT 210 +#define FRAGMENT 211 +#define IDENTIFIER 212 +#define INPUT 213 +#define IMPLEMENTS 214 +#define INT 215 +#define INTERFACE 216 +#define LBRACKET 217 +#define LCURLY 218 +#define LPAREN 219 +#define MUTATION 220 +#define NULL_LITERAL 221 +#define ON 222 +#define PIPE 223 +#define QUERY 224 +#define RBRACKET 225 +#define RCURLY 226 +#define REPEATABLE 227 +#define RPAREN 228 +#define SCALAR 229 +#define SCHEMA 230 +#define STRING 231 +#define SUBSCRIPTION 232 +#define TRUE_LITERAL 233 +#define TYPE_LITERAL 234 +#define UNION 235 +#define VAR_SIGN 236 + +/* Value type. */ +#if ! defined YYSTYPE && ! defined YYSTYPE_IS_DECLARED +typedef int YYSTYPE; +# define YYSTYPE_IS_TRIVIAL 1 +# define YYSTYPE_IS_DECLARED 1 +#endif + + + + +int yyparse (VALUE parser, VALUE filename); + + + +/* Symbol kind. */ +enum yysymbol_kind_t +{ + YYSYMBOL_YYEMPTY = -2, + YYSYMBOL_YYEOF = 0, /* "end of file" */ + YYSYMBOL_YYerror = 1, /* error */ + YYSYMBOL_YYUNDEF = 2, /* "invalid token" */ + YYSYMBOL_AMP = 3, /* AMP */ + YYSYMBOL_BANG = 4, /* BANG */ + YYSYMBOL_COLON = 5, /* COLON */ + YYSYMBOL_DIRECTIVE = 6, /* DIRECTIVE */ + YYSYMBOL_DIR_SIGN = 7, /* DIR_SIGN */ + YYSYMBOL_ENUM = 8, /* ENUM */ + YYSYMBOL_ELLIPSIS = 9, /* ELLIPSIS */ + YYSYMBOL_EQUALS = 10, /* EQUALS */ + YYSYMBOL_EXTEND = 11, /* EXTEND */ + YYSYMBOL_FALSE_LITERAL = 12, /* FALSE_LITERAL */ + YYSYMBOL_FLOAT = 13, /* FLOAT */ + YYSYMBOL_FRAGMENT = 14, /* FRAGMENT */ + YYSYMBOL_IDENTIFIER = 15, /* IDENTIFIER */ + YYSYMBOL_INPUT = 16, /* INPUT */ + YYSYMBOL_IMPLEMENTS = 17, /* IMPLEMENTS */ + YYSYMBOL_INT = 18, /* INT */ + YYSYMBOL_INTERFACE = 19, /* INTERFACE */ + YYSYMBOL_LBRACKET = 20, /* LBRACKET */ + YYSYMBOL_LCURLY = 21, /* LCURLY */ + YYSYMBOL_LPAREN = 22, /* LPAREN */ + YYSYMBOL_MUTATION = 23, /* MUTATION */ + YYSYMBOL_NULL_LITERAL = 24, /* NULL_LITERAL */ + YYSYMBOL_ON = 25, /* ON */ + YYSYMBOL_PIPE = 26, /* PIPE */ + YYSYMBOL_QUERY = 27, /* QUERY */ + YYSYMBOL_RBRACKET = 28, /* RBRACKET */ + YYSYMBOL_RCURLY = 29, /* RCURLY */ + YYSYMBOL_REPEATABLE = 30, /* REPEATABLE */ + YYSYMBOL_RPAREN = 31, /* RPAREN */ + YYSYMBOL_SCALAR = 32, /* SCALAR */ + YYSYMBOL_SCHEMA = 33, /* SCHEMA */ + YYSYMBOL_STRING = 34, /* STRING */ + YYSYMBOL_SUBSCRIPTION = 35, /* SUBSCRIPTION */ + YYSYMBOL_TRUE_LITERAL = 36, /* TRUE_LITERAL */ + YYSYMBOL_TYPE_LITERAL = 37, /* TYPE_LITERAL */ + YYSYMBOL_UNION = 38, /* UNION */ + YYSYMBOL_VAR_SIGN = 39, /* VAR_SIGN */ + YYSYMBOL_YYACCEPT = 40, /* $accept */ + YYSYMBOL_start = 41, /* start */ + YYSYMBOL_document = 42, /* document */ + YYSYMBOL_definitions_list = 43, /* definitions_list */ + YYSYMBOL_definition = 44, /* definition */ + YYSYMBOL_executable_definition = 45, /* executable_definition */ + YYSYMBOL_operation_definition = 46, /* operation_definition */ + YYSYMBOL_operation_type = 47, /* operation_type */ + YYSYMBOL_operation_name_opt = 48, /* operation_name_opt */ + YYSYMBOL_variable_definitions_opt = 49, /* variable_definitions_opt */ + YYSYMBOL_variable_definitions_list = 50, /* variable_definitions_list */ + YYSYMBOL_variable_definition = 51, /* variable_definition */ + YYSYMBOL_default_value_opt = 52, /* default_value_opt */ + YYSYMBOL_selection_list = 53, /* selection_list */ + YYSYMBOL_selection = 54, /* selection */ + YYSYMBOL_selection_set = 55, /* selection_set */ + YYSYMBOL_selection_set_opt = 56, /* selection_set_opt */ + YYSYMBOL_field = 57, /* field */ + YYSYMBOL_arguments_opt = 58, /* arguments_opt */ + YYSYMBOL_arguments_list = 59, /* arguments_list */ + YYSYMBOL_argument = 60, /* argument */ + YYSYMBOL_literal_value = 61, /* literal_value */ + YYSYMBOL_input_value = 62, /* input_value */ + YYSYMBOL_null_value = 63, /* null_value */ + YYSYMBOL_variable = 64, /* variable */ + YYSYMBOL_list_value = 65, /* list_value */ + YYSYMBOL_list_value_list = 66, /* list_value_list */ + YYSYMBOL_enum_name = 67, /* enum_name */ + YYSYMBOL_enum_value = 68, /* enum_value */ + YYSYMBOL_object_value = 69, /* object_value */ + YYSYMBOL_object_value_list_opt = 70, /* object_value_list_opt */ + YYSYMBOL_object_value_list = 71, /* object_value_list */ + YYSYMBOL_object_value_field = 72, /* object_value_field */ + YYSYMBOL_object_literal_value = 73, /* object_literal_value */ + YYSYMBOL_object_literal_value_list_opt = 74, /* object_literal_value_list_opt */ + YYSYMBOL_object_literal_value_list = 75, /* object_literal_value_list */ + YYSYMBOL_object_literal_value_field = 76, /* object_literal_value_field */ + YYSYMBOL_directives_list_opt = 77, /* directives_list_opt */ + YYSYMBOL_directives_list = 78, /* directives_list */ + YYSYMBOL_directive = 79, /* directive */ + YYSYMBOL_name = 80, /* name */ + YYSYMBOL_schema_keyword = 81, /* schema_keyword */ + YYSYMBOL_name_without_on = 82, /* name_without_on */ + YYSYMBOL_fragment_spread = 83, /* fragment_spread */ + YYSYMBOL_inline_fragment = 84, /* inline_fragment */ + YYSYMBOL_fragment_definition = 85, /* fragment_definition */ + YYSYMBOL_fragment_name_opt = 86, /* fragment_name_opt */ + YYSYMBOL_type = 87, /* type */ + YYSYMBOL_nullable_type = 88, /* nullable_type */ + YYSYMBOL_type_system_definition = 89, /* type_system_definition */ + YYSYMBOL_schema_definition = 90, /* schema_definition */ + YYSYMBOL_operation_type_definition_list_opt = 91, /* operation_type_definition_list_opt */ + YYSYMBOL_operation_type_definition_list = 92, /* operation_type_definition_list */ + YYSYMBOL_operation_type_definition = 93, /* operation_type_definition */ + YYSYMBOL_type_definition = 94, /* type_definition */ + YYSYMBOL_description = 95, /* description */ + YYSYMBOL_description_opt = 96, /* description_opt */ + YYSYMBOL_scalar_type_definition = 97, /* scalar_type_definition */ + YYSYMBOL_object_type_definition = 98, /* object_type_definition */ + YYSYMBOL_implements_opt = 99, /* implements_opt */ + YYSYMBOL_interfaces_list = 100, /* interfaces_list */ + YYSYMBOL_legacy_interfaces_list = 101, /* legacy_interfaces_list */ + YYSYMBOL_input_value_definition = 102, /* input_value_definition */ + YYSYMBOL_input_value_definition_list = 103, /* input_value_definition_list */ + YYSYMBOL_arguments_definitions_opt = 104, /* arguments_definitions_opt */ + YYSYMBOL_field_definition = 105, /* field_definition */ + YYSYMBOL_field_definition_list_opt = 106, /* field_definition_list_opt */ + YYSYMBOL_field_definition_list = 107, /* field_definition_list */ + YYSYMBOL_interface_type_definition = 108, /* interface_type_definition */ + YYSYMBOL_pipe_opt = 109, /* pipe_opt */ + YYSYMBOL_union_members = 110, /* union_members */ + YYSYMBOL_union_type_definition = 111, /* union_type_definition */ + YYSYMBOL_enum_type_definition = 112, /* enum_type_definition */ + YYSYMBOL_enum_value_definition = 113, /* enum_value_definition */ + YYSYMBOL_enum_value_definitions = 114, /* enum_value_definitions */ + YYSYMBOL_input_object_type_definition = 115, /* input_object_type_definition */ + YYSYMBOL_directive_definition = 116, /* directive_definition */ + YYSYMBOL_directive_repeatable_opt = 117, /* directive_repeatable_opt */ + YYSYMBOL_directive_locations = 118, /* directive_locations */ + YYSYMBOL_type_system_extension = 119, /* type_system_extension */ + YYSYMBOL_schema_extension = 120, /* schema_extension */ + YYSYMBOL_type_extension = 121, /* type_extension */ + YYSYMBOL_scalar_type_extension = 122, /* scalar_type_extension */ + YYSYMBOL_object_type_extension = 123, /* object_type_extension */ + YYSYMBOL_interface_type_extension = 124, /* interface_type_extension */ + YYSYMBOL_union_type_extension = 125, /* union_type_extension */ + YYSYMBOL_enum_type_extension = 126, /* enum_type_extension */ + YYSYMBOL_input_object_type_extension = 127, /* input_object_type_extension */ + YYSYMBOL_NamedTypeForCondition = 128 /* NamedTypeForCondition */ +}; +typedef enum yysymbol_kind_t yysymbol_kind_t; + + + + +#ifdef short +# undef short +#endif + +/* On compilers that do not define __PTRDIFF_MAX__ etc., make sure + and (if available) are included + so that the code can choose integer types of a good width. */ + +#ifndef __PTRDIFF_MAX__ +# include /* INFRINGES ON USER NAME SPACE */ +# if defined __STDC_VERSION__ && 199901 <= __STDC_VERSION__ +# include /* INFRINGES ON USER NAME SPACE */ +# define YY_STDINT_H +# endif +#endif + +/* Narrow types that promote to a signed type and that can represent a + signed or unsigned integer of at least N bits. In tables they can + save space and decrease cache pressure. Promoting to a signed type + helps avoid bugs in integer arithmetic. */ + +#ifdef __INT_LEAST8_MAX__ +typedef __INT_LEAST8_TYPE__ yytype_int8; +#elif defined YY_STDINT_H +typedef int_least8_t yytype_int8; +#else +typedef signed char yytype_int8; +#endif + +#ifdef __INT_LEAST16_MAX__ +typedef __INT_LEAST16_TYPE__ yytype_int16; +#elif defined YY_STDINT_H +typedef int_least16_t yytype_int16; +#else +typedef short yytype_int16; +#endif + +/* Work around bug in HP-UX 11.23, which defines these macros + incorrectly for preprocessor constants. This workaround can likely + be removed in 2023, as HPE has promised support for HP-UX 11.23 + (aka HP-UX 11i v2) only through the end of 2022; see Table 2 of + . */ +#ifdef __hpux +# undef UINT_LEAST8_MAX +# undef UINT_LEAST16_MAX +# define UINT_LEAST8_MAX 255 +# define UINT_LEAST16_MAX 65535 +#endif + +#if defined __UINT_LEAST8_MAX__ && __UINT_LEAST8_MAX__ <= __INT_MAX__ +typedef __UINT_LEAST8_TYPE__ yytype_uint8; +#elif (!defined __UINT_LEAST8_MAX__ && defined YY_STDINT_H \ + && UINT_LEAST8_MAX <= INT_MAX) +typedef uint_least8_t yytype_uint8; +#elif !defined __UINT_LEAST8_MAX__ && UCHAR_MAX <= INT_MAX +typedef unsigned char yytype_uint8; +#else +typedef short yytype_uint8; +#endif + +#if defined __UINT_LEAST16_MAX__ && __UINT_LEAST16_MAX__ <= __INT_MAX__ +typedef __UINT_LEAST16_TYPE__ yytype_uint16; +#elif (!defined __UINT_LEAST16_MAX__ && defined YY_STDINT_H \ + && UINT_LEAST16_MAX <= INT_MAX) +typedef uint_least16_t yytype_uint16; +#elif !defined __UINT_LEAST16_MAX__ && USHRT_MAX <= INT_MAX +typedef unsigned short yytype_uint16; +#else +typedef int yytype_uint16; +#endif + +#ifndef YYPTRDIFF_T +# if defined __PTRDIFF_TYPE__ && defined __PTRDIFF_MAX__ +# define YYPTRDIFF_T __PTRDIFF_TYPE__ +# define YYPTRDIFF_MAXIMUM __PTRDIFF_MAX__ +# elif defined PTRDIFF_MAX +# ifndef ptrdiff_t +# include /* INFRINGES ON USER NAME SPACE */ +# endif +# define YYPTRDIFF_T ptrdiff_t +# define YYPTRDIFF_MAXIMUM PTRDIFF_MAX +# else +# define YYPTRDIFF_T long +# define YYPTRDIFF_MAXIMUM LONG_MAX +# endif +#endif + +#ifndef YYSIZE_T +# ifdef __SIZE_TYPE__ +# define YYSIZE_T __SIZE_TYPE__ +# elif defined size_t +# define YYSIZE_T size_t +# elif defined __STDC_VERSION__ && 199901 <= __STDC_VERSION__ +# include /* INFRINGES ON USER NAME SPACE */ +# define YYSIZE_T size_t +# else +# define YYSIZE_T unsigned +# endif +#endif + +#define YYSIZE_MAXIMUM \ + YY_CAST (YYPTRDIFF_T, \ + (YYPTRDIFF_MAXIMUM < YY_CAST (YYSIZE_T, -1) \ + ? YYPTRDIFF_MAXIMUM \ + : YY_CAST (YYSIZE_T, -1))) + +#define YYSIZEOF(X) YY_CAST (YYPTRDIFF_T, sizeof (X)) + + +/* Stored state numbers (used for stacks). */ +typedef yytype_int16 yy_state_t; + +/* State numbers in computations. */ +typedef int yy_state_fast_t; + +#ifndef YY_ +# if defined YYENABLE_NLS && YYENABLE_NLS +# if ENABLE_NLS +# include /* INFRINGES ON USER NAME SPACE */ +# define YY_(Msgid) dgettext ("bison-runtime", Msgid) +# endif +# endif +# ifndef YY_ +# define YY_(Msgid) Msgid +# endif +#endif + + +#ifndef YY_ATTRIBUTE_PURE +# if defined __GNUC__ && 2 < __GNUC__ + (96 <= __GNUC_MINOR__) +# define YY_ATTRIBUTE_PURE __attribute__ ((__pure__)) +# else +# define YY_ATTRIBUTE_PURE +# endif +#endif + +#ifndef YY_ATTRIBUTE_UNUSED +# if defined __GNUC__ && 2 < __GNUC__ + (7 <= __GNUC_MINOR__) +# define YY_ATTRIBUTE_UNUSED __attribute__ ((__unused__)) +# else +# define YY_ATTRIBUTE_UNUSED +# endif +#endif + +/* Suppress unused-variable warnings by "using" E. */ +#if ! defined lint || defined __GNUC__ +# define YY_USE(E) ((void) (E)) +#else +# define YY_USE(E) /* empty */ +#endif + +/* Suppress an incorrect diagnostic about yylval being uninitialized. */ +#if defined __GNUC__ && ! defined __ICC && 406 <= __GNUC__ * 100 + __GNUC_MINOR__ +# if __GNUC__ * 100 + __GNUC_MINOR__ < 407 +# define YY_IGNORE_MAYBE_UNINITIALIZED_BEGIN \ + _Pragma ("GCC diagnostic push") \ + _Pragma ("GCC diagnostic ignored \"-Wuninitialized\"") +# else +# define YY_IGNORE_MAYBE_UNINITIALIZED_BEGIN \ + _Pragma ("GCC diagnostic push") \ + _Pragma ("GCC diagnostic ignored \"-Wuninitialized\"") \ + _Pragma ("GCC diagnostic ignored \"-Wmaybe-uninitialized\"") +# endif +# define YY_IGNORE_MAYBE_UNINITIALIZED_END \ + _Pragma ("GCC diagnostic pop") +#else +# define YY_INITIAL_VALUE(Value) Value +#endif +#ifndef YY_IGNORE_MAYBE_UNINITIALIZED_BEGIN +# define YY_IGNORE_MAYBE_UNINITIALIZED_BEGIN +# define YY_IGNORE_MAYBE_UNINITIALIZED_END +#endif +#ifndef YY_INITIAL_VALUE +# define YY_INITIAL_VALUE(Value) /* Nothing. */ +#endif + +#if defined __cplusplus && defined __GNUC__ && ! defined __ICC && 6 <= __GNUC__ +# define YY_IGNORE_USELESS_CAST_BEGIN \ + _Pragma ("GCC diagnostic push") \ + _Pragma ("GCC diagnostic ignored \"-Wuseless-cast\"") +# define YY_IGNORE_USELESS_CAST_END \ + _Pragma ("GCC diagnostic pop") +#endif +#ifndef YY_IGNORE_USELESS_CAST_BEGIN +# define YY_IGNORE_USELESS_CAST_BEGIN +# define YY_IGNORE_USELESS_CAST_END +#endif + + +#define YY_ASSERT(E) ((void) (0 && (E))) + +#if 1 + +/* The parser invokes alloca or malloc; define the necessary symbols. */ + +# ifdef YYSTACK_USE_ALLOCA +# if YYSTACK_USE_ALLOCA +# ifdef __GNUC__ +# define YYSTACK_ALLOC __builtin_alloca +# elif defined __BUILTIN_VA_ARG_INCR +# include /* INFRINGES ON USER NAME SPACE */ +# elif defined _AIX +# define YYSTACK_ALLOC __alloca +# elif defined _MSC_VER +# include /* INFRINGES ON USER NAME SPACE */ +# define alloca _alloca +# else +# define YYSTACK_ALLOC alloca +# if ! defined _ALLOCA_H && ! defined EXIT_SUCCESS +# include /* INFRINGES ON USER NAME SPACE */ + /* Use EXIT_SUCCESS as a witness for stdlib.h. */ +# ifndef EXIT_SUCCESS +# define EXIT_SUCCESS 0 +# endif +# endif +# endif +# endif +# endif + +# ifdef YYSTACK_ALLOC + /* Pacify GCC's 'empty if-body' warning. */ +# define YYSTACK_FREE(Ptr) do { /* empty */; } while (0) +# ifndef YYSTACK_ALLOC_MAXIMUM + /* The OS might guarantee only one guard page at the bottom of the stack, + and a page size can be as small as 4096 bytes. So we cannot safely + invoke alloca (N) if N exceeds 4096. Use a slightly smaller number + to allow for a few compiler-allocated temporary stack slots. */ +# define YYSTACK_ALLOC_MAXIMUM 4032 /* reasonable circa 2006 */ +# endif +# else +# define YYSTACK_ALLOC YYMALLOC +# define YYSTACK_FREE YYFREE +# ifndef YYSTACK_ALLOC_MAXIMUM +# define YYSTACK_ALLOC_MAXIMUM YYSIZE_MAXIMUM +# endif +# if (defined __cplusplus && ! defined EXIT_SUCCESS \ + && ! ((defined YYMALLOC || defined malloc) \ + && (defined YYFREE || defined free))) +# include /* INFRINGES ON USER NAME SPACE */ +# ifndef EXIT_SUCCESS +# define EXIT_SUCCESS 0 +# endif +# endif +# ifndef YYMALLOC +# define YYMALLOC malloc +# if ! defined malloc && ! defined EXIT_SUCCESS +void *malloc (YYSIZE_T); /* INFRINGES ON USER NAME SPACE */ +# endif +# endif +# ifndef YYFREE +# define YYFREE free +# if ! defined free && ! defined EXIT_SUCCESS +void free (void *); /* INFRINGES ON USER NAME SPACE */ +# endif +# endif +# endif +#endif /* 1 */ + +#if (! defined yyoverflow \ + && (! defined __cplusplus \ + || (defined YYSTYPE_IS_TRIVIAL && YYSTYPE_IS_TRIVIAL))) + +/* A type that is properly aligned for any stack member. */ +union yyalloc +{ + yy_state_t yyss_alloc; + YYSTYPE yyvs_alloc; +}; + +/* The size of the maximum gap between one aligned stack and the next. */ +# define YYSTACK_GAP_MAXIMUM (YYSIZEOF (union yyalloc) - 1) + +/* The size of an array large to enough to hold all stacks, each with + N elements. */ +# define YYSTACK_BYTES(N) \ + ((N) * (YYSIZEOF (yy_state_t) + YYSIZEOF (YYSTYPE)) \ + + YYSTACK_GAP_MAXIMUM) + +# define YYCOPY_NEEDED 1 + +/* Relocate STACK from its old location to the new one. The + local variables YYSIZE and YYSTACKSIZE give the old and new number of + elements in the stack, and YYPTR gives the new location of the + stack. Advance YYPTR to a properly aligned location for the next + stack. */ +# define YYSTACK_RELOCATE(Stack_alloc, Stack) \ + do \ + { \ + YYPTRDIFF_T yynewbytes; \ + YYCOPY (&yyptr->Stack_alloc, Stack, yysize); \ + Stack = &yyptr->Stack_alloc; \ + yynewbytes = yystacksize * YYSIZEOF (*Stack) + YYSTACK_GAP_MAXIMUM; \ + yyptr += yynewbytes / YYSIZEOF (*yyptr); \ + } \ + while (0) + +#endif + +#if defined YYCOPY_NEEDED && YYCOPY_NEEDED +/* Copy COUNT objects from SRC to DST. The source and destination do + not overlap. */ +# ifndef YYCOPY +# if defined __GNUC__ && 1 < __GNUC__ +# define YYCOPY(Dst, Src, Count) \ + __builtin_memcpy (Dst, Src, YY_CAST (YYSIZE_T, (Count)) * sizeof (*(Src))) +# else +# define YYCOPY(Dst, Src, Count) \ + do \ + { \ + YYPTRDIFF_T yyi; \ + for (yyi = 0; yyi < (Count); yyi++) \ + (Dst)[yyi] = (Src)[yyi]; \ + } \ + while (0) +# endif +# endif +#endif /* !YYCOPY_NEEDED */ + +/* YYFINAL -- State number of the termination state. */ +#define YYFINAL 79 +/* YYLAST -- Last index in YYTABLE. */ +#define YYLAST 820 + +/* YYNTOKENS -- Number of terminals. */ +#define YYNTOKENS 40 +/* YYNNTS -- Number of nonterminals. */ +#define YYNNTS 89 +/* YYNRULES -- Number of rules. */ +#define YYNRULES 185 +/* YYNSTATES -- Number of states. */ +#define YYNSTATES 314 + +/* YYMAXUTOK -- Last valid token kind. */ +#define YYMAXUTOK 257 + + +/* YYTRANSLATE(TOKEN-NUM) -- Symbol number corresponding to TOKEN-NUM + as returned by yylex, with out-of-bounds checking. */ +#define YYTRANSLATE(YYX) \ + (0 <= (YYX) && (YYX) <= YYMAXUTOK \ + ? YY_CAST (yysymbol_kind_t, yytranslate[YYX]) \ + : YYSYMBOL_YYUNDEF) + +/* YYTRANSLATE[TOKEN-NUM] -- Symbol number corresponding to TOKEN-NUM + as returned by yylex. */ +static const yytype_int8 yytranslate[] = +{ + 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, + 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + 33, 34, 35, 36, 37, 38, 39, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 1, 2 +}; + +#if YYDEBUG +/* YYRLINE[YYN] -- Source line where rule number YYN was defined. */ +static const yytype_int16 yyrline[] = +{ + 0, 107, 107, 109, 123, 124, 127, 128, 129, 132, + 133, 136, 147, 158, 171, 172, 173, 176, 177, 180, + 181, 184, 185, 188, 200, 201, 204, 205, 208, 209, + 210, 213, 216, 217, 220, 231, 244, 245, 248, 249, + 252, 262, 263, 264, 265, 266, 267, 268, 269, 270, + 273, 274, 275, 277, 285, 294, 295, 298, 299, 302, + 303, 304, 305, 307, 316, 325, 326, 329, 330, 333, + 344, 353, 354, 357, 358, 361, 372, 373, 376, 377, + 379, 389, 390, 393, 394, 395, 396, 397, 398, 399, + 400, 401, 402, 403, 404, 407, 408, 409, 410, 411, + 412, 416, 426, 435, 446, 458, 459, 462, 463, 466, + 473, 482, 483, 484, 487, 500, 501, 504, 508, 513, + 518, 519, 520, 521, 522, 523, 525, 528, 529, 532, + 544, 558, 559, 560, 561, 564, 572, 578, 586, 591, + 605, 606, 609, 610, 613, 627, 628, 631, 632, 633, + 636, 650, 651, 654, 662, 667, 680, 693, 705, 706, + 709, 722, 736, 737, 740, 741, 745, 746, 749, 760, + 772, 773, 774, 775, 776, 777, 779, 789, 801, 813, + 822, 833, 842, 853, 862, 873 +}; +#endif + +/** Accessing symbol of state STATE. */ +#define YY_ACCESSING_SYMBOL(State) YY_CAST (yysymbol_kind_t, yystos[State]) + +#if 1 +/* The user-facing name of the symbol whose (internal) number is + YYSYMBOL. No bounds checking. */ +static const char *yysymbol_name (yysymbol_kind_t yysymbol) YY_ATTRIBUTE_UNUSED; + +static const char * +yysymbol_name (yysymbol_kind_t yysymbol) +{ + static const char *const yy_sname[] = + { + "end of file", "error", "invalid token", "AMP", "BANG", "COLON", + "DIRECTIVE", "DIR_SIGN", "ENUM", "ELLIPSIS", "EQUALS", "EXTEND", + "FALSE_LITERAL", "FLOAT", "FRAGMENT", "IDENTIFIER", "INPUT", + "IMPLEMENTS", "INT", "INTERFACE", "LBRACKET", "LCURLY", "LPAREN", + "MUTATION", "NULL_LITERAL", "ON", "PIPE", "QUERY", "RBRACKET", "RCURLY", + "REPEATABLE", "RPAREN", "SCALAR", "SCHEMA", "STRING", "SUBSCRIPTION", + "TRUE_LITERAL", "TYPE_LITERAL", "UNION", "VAR_SIGN", "$accept", "start", + "document", "definitions_list", "definition", "executable_definition", + "operation_definition", "operation_type", "operation_name_opt", + "variable_definitions_opt", "variable_definitions_list", + "variable_definition", "default_value_opt", "selection_list", + "selection", "selection_set", "selection_set_opt", "field", + "arguments_opt", "arguments_list", "argument", "literal_value", + "input_value", "null_value", "variable", "list_value", "list_value_list", + "enum_name", "enum_value", "object_value", "object_value_list_opt", + "object_value_list", "object_value_field", "object_literal_value", + "object_literal_value_list_opt", "object_literal_value_list", + "object_literal_value_field", "directives_list_opt", "directives_list", + "directive", "name", "schema_keyword", "name_without_on", + "fragment_spread", "inline_fragment", "fragment_definition", + "fragment_name_opt", "type", "nullable_type", "type_system_definition", + "schema_definition", "operation_type_definition_list_opt", + "operation_type_definition_list", "operation_type_definition", + "type_definition", "description", "description_opt", + "scalar_type_definition", "object_type_definition", "implements_opt", + "interfaces_list", "legacy_interfaces_list", "input_value_definition", + "input_value_definition_list", "arguments_definitions_opt", + "field_definition", "field_definition_list_opt", "field_definition_list", + "interface_type_definition", "pipe_opt", "union_members", + "union_type_definition", "enum_type_definition", "enum_value_definition", + "enum_value_definitions", "input_object_type_definition", + "directive_definition", "directive_repeatable_opt", + "directive_locations", "type_system_extension", "schema_extension", + "type_extension", "scalar_type_extension", "object_type_extension", + "interface_type_extension", "union_type_extension", + "enum_type_extension", "input_object_type_extension", + "NamedTypeForCondition", YY_NULLPTR + }; + return yy_sname[yysymbol]; +} +#endif + +#define YYPACT_NINF (-249) + +#define yypact_value_is_default(Yyn) \ + ((Yyn) == YYPACT_NINF) + +#define YYTABLE_NINF (-148) + +#define yytable_value_is_error(Yyn) \ + 0 + +/* YYPACT[STATE-NUM] -- Index in YYTABLE of the portion describing + STATE-NUM. */ +static const yytype_int16 yypact[] = +{ + 148, 179, 749, 485, -249, -249, 14, -249, -249, 45, + -249, 83, -249, -249, -249, 716, -249, -249, -249, -249, + -249, 120, -249, -249, -249, -249, -249, -249, -249, -249, + -249, -249, -249, -249, -249, -249, -249, -249, 716, 716, + 716, 716, 14, 716, 716, -249, -249, -249, -249, -249, + -249, -249, -249, -249, -249, -249, -249, -249, -249, -249, + -249, -249, -249, -249, 21, 584, -249, -249, 518, -249, + -249, 11, -249, -249, -249, 716, 47, 14, -249, -249, + -249, 58, -249, 74, 716, 716, 716, 716, 716, 716, + 14, 14, 65, 14, 75, 27, 65, 14, 716, 716, + 84, 14, -249, -249, 716, 716, 14, 93, 174, -249, + -249, 95, 14, 716, 14, 14, 65, 14, 65, 14, + 111, 27, 122, 27, 317, 14, 14, 174, 14, 140, + 102, -249, 14, 14, 617, -249, -249, 93, 650, -249, + 149, 84, -249, 155, 72, -249, 716, 48, -249, 84, + 144, 146, 151, 14, -249, 14, 160, 143, 143, 716, + 208, 171, 716, 158, 118, 158, 162, 84, 84, 551, + 14, -249, -249, 418, -249, -249, 716, -249, -249, 181, + -249, -249, -249, 143, 154, 143, 143, 158, 158, 162, + 782, -249, -11, 716, -249, -10, -249, 171, 716, -249, + 15, -249, -249, -249, -249, 716, 165, -249, -249, -249, + 84, -249, -249, -249, -249, 350, 716, -249, -249, -249, + -249, 716, -249, -249, -249, -249, -249, -249, -249, -249, + -249, -249, -249, -249, 683, 88, -249, 169, 35, 55, + -249, -249, 165, 14, -249, -249, 194, -249, -249, -249, + 716, -249, 69, -249, 716, -249, -249, -249, 384, 173, + 716, -249, 176, 716, -249, 195, -249, 683, -249, 196, + 203, -249, 716, -249, -249, -249, 683, 144, -249, -249, + -249, -249, -249, -249, -249, 205, -249, -249, 209, 418, + 185, 452, 14, -249, -249, 192, 196, 214, 418, 452, + -249, -249, -249, 716, -249, -249, 716, 14, 683, -249, + -249, -249, 14, -249 +}; + +/* YYDEFACT[STATE-NUM] -- Default reduction number in state STATE-NUM. + Performed when YYTABLE does not specify something else to do. Zero + means the default is an error. */ +static const yytype_uint8 yydefact[] = +{ + 127, 0, 105, 0, 15, 14, 76, 126, 16, 0, + 2, 127, 4, 6, 9, 17, 10, 7, 111, 112, + 128, 0, 120, 121, 122, 123, 124, 125, 113, 8, + 166, 167, 170, 171, 172, 173, 174, 175, 0, 0, + 0, 0, 76, 0, 0, 91, 89, 92, 97, 93, + 95, 90, 86, 87, 98, 94, 84, 83, 96, 85, + 88, 99, 100, 106, 0, 76, 82, 13, 0, 26, + 28, 36, 81, 29, 30, 0, 115, 77, 78, 1, + 5, 19, 18, 0, 0, 0, 0, 0, 0, 0, + 76, 76, 131, 0, 0, 169, 131, 76, 0, 0, + 0, 76, 12, 27, 0, 0, 76, 36, 0, 114, + 79, 0, 76, 0, 76, 76, 131, 76, 131, 76, + 0, 182, 0, 184, 0, 76, 176, 0, 76, 0, + 180, 185, 76, 76, 0, 103, 101, 36, 0, 38, + 0, 32, 80, 0, 0, 117, 0, 0, 21, 0, + 142, 0, 0, 76, 129, 76, 0, 127, 127, 0, + 135, 133, 134, 145, 0, 145, 151, 0, 0, 0, + 76, 37, 39, 0, 33, 35, 0, 116, 118, 0, + 20, 22, 11, 127, 162, 127, 127, 145, 145, 151, + 0, 158, 127, 0, 140, 127, 135, 132, 0, 138, + 127, 178, 168, 177, 152, 0, 179, 104, 102, 31, + 32, 45, 41, 59, 42, 0, 65, 53, 60, 43, + 44, 0, 61, 50, 40, 46, 51, 48, 63, 47, + 52, 49, 62, 119, 0, 127, 163, 0, 127, 127, + 150, 130, 155, 76, 181, 159, 0, 183, 141, 136, + 0, 148, 127, 153, 0, 34, 55, 57, 0, 0, + 66, 67, 0, 72, 73, 0, 54, 0, 109, 24, + 107, 143, 0, 156, 160, 157, 0, 142, 146, 149, + 154, 56, 58, 64, 68, 0, 70, 74, 0, 0, + 0, 0, 76, 108, 164, 161, 24, 0, 0, 0, + 50, 69, 110, 71, 25, 23, 0, 76, 0, 75, + 165, 139, 76, 144 +}; + +/* YYPGOTO[NTERM-NUM]. */ +static const yytype_int16 yypgoto[] = +{ + -249, -249, -249, -249, 211, -249, -249, 0, -249, -249, + -249, 77, -70, 94, -67, -90, 17, -249, -98, -249, + 91, -248, -165, -249, -249, -249, -249, 40, -249, -249, + -249, -249, -29, -249, -249, -249, -28, 23, -36, -63, + -13, -168, 1, -249, -249, -249, -249, -238, -249, -249, + -249, -249, 107, -127, -249, -249, 4, -249, -249, -76, + 80, -249, -183, -18, -41, -12, -152, -249, -249, -249, + 54, -249, -249, -185, 60, -249, -249, -249, -249, -249, + -249, -249, -249, -249, -249, -249, -249, -249, 147 +}; + +/* YYDEFGOTO[NTERM-NUM]. */ +static const yytype_int16 yydefgoto[] = +{ + 0, 9, 10, 11, 12, 13, 14, 61, 81, 112, + 147, 148, 292, 68, 69, 174, 175, 70, 106, 138, + 139, 223, 301, 225, 226, 227, 258, 228, 229, 230, + 259, 260, 261, 231, 262, 263, 264, 76, 77, 78, + 71, 62, 72, 73, 74, 16, 64, 269, 270, 17, + 18, 109, 144, 145, 19, 20, 193, 22, 23, 125, + 161, 162, 194, 195, 184, 251, 201, 252, 24, 205, + 206, 25, 26, 191, 192, 27, 28, 237, 295, 29, + 30, 31, 32, 33, 34, 35, 36, 37, 132 +}; + +/* YYTABLE[YYPACT[STATE-NUM]] -- What to do in state STATE-NUM. If + positive, shift that token. If negative, reduce the rule whose + number is the opposite. If YYTABLE_NINF, syntax error. */ +static const yytype_int16 yytable[] = +{ + 15, 103, 82, 63, 21, 232, 95, 245, 224, 142, + 135, 15, 248, 203, 110, 21, 104, 178, 244, 247, + 128, 75, 232, 7, 7, 90, 91, 92, 93, 290, + 96, 97, 110, 105, 75, 240, 241, 178, 296, 170, + 153, 300, 155, 304, -147, 79, 98, 232, -77, 7, + 257, 309, 248, 245, 121, 123, 248, 126, 110, 182, + 110, 130, 107, 110, 273, 94, 101, 110, 108, 7, + 312, 114, 115, 116, 117, 118, 119, 207, 208, 180, + 111, 113, 124, -3, 274, 131, 131, 146, 100, 7, + 232, 137, 140, 282, 1, 4, 127, 2, 278, 5, + 150, 177, 103, 7, 3, 134, 4, 8, 143, 75, + 5, 160, -77, 120, 122, 105, 6, 7, 8, 271, + 129, 232, 7, 232, 136, 140, 83, 143, 84, 141, + 232, 232, 157, 179, 146, 149, 85, 151, 152, 86, + 154, 4, 156, 158, 143, 5, 196, 202, 163, 199, + 166, 165, 87, 8, 173, 167, 168, 88, 89, 1, + 176, 190, 2, 233, 143, 235, 183, 185, 239, 3, + 189, 4, 186, 222, 198, 5, 187, 7, 188, 200, + 246, 6, 7, 8, 236, 249, 234, 38, 204, 190, + 222, 254, 253, 210, 272, 39, 190, 4, 40, 276, + 289, 5, 283, 265, 250, 286, 291, 293, 266, 8, + 298, 41, 42, 302, 299, 222, 43, 44, 306, 308, + -137, 268, 80, -137, 181, -137, 307, 255, 169, 172, + 243, 284, -137, -137, 164, 287, 297, 277, -137, 197, + 279, 280, 190, 242, -137, 238, 133, 285, 0, 0, + 288, 0, 0, 0, 268, 0, 250, 0, 222, 294, + 0, 0, 0, 268, 0, 0, 275, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 222, + 288, 222, 0, 310, 0, 268, 0, 0, 222, 222, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 305, 0, 0, 0, 0, + 159, 0, 0, 45, 0, 46, 0, 0, 47, 48, + 311, 49, 50, 51, 52, 313, 53, 0, 0, 0, + 4, 54, 66, 0, 5, 0, 0, 55, 0, 56, + 57, 0, 8, 58, 59, 60, 45, 0, 46, 0, + 0, 47, 211, 212, 49, 213, 51, 52, 214, 53, + 215, 216, 0, 4, 217, 218, 0, 5, 256, 0, + 55, 0, 56, 57, 219, 8, 220, 59, 60, 221, + 45, 0, 46, 0, 0, 47, 211, 212, 49, 213, + 51, 52, 214, 53, 215, 216, 0, 4, 217, 218, + 0, 5, 281, 0, 55, 0, 56, 57, 219, 8, + 220, 59, 60, 221, 45, 0, 46, 0, 0, 47, + 211, 212, 49, 213, 51, 52, 214, 53, 215, 216, + 0, 4, 217, 218, 0, 5, 0, 0, 55, 0, + 56, 57, 219, 8, 220, 59, 60, 221, 45, 0, + 46, 0, 0, 47, 211, 212, 49, 213, 51, 52, + 214, 53, 215, 303, 0, 4, 217, 218, 0, 5, + 0, 0, 55, 0, 56, 57, 219, 8, 220, 59, + 60, 45, 0, 46, 65, 0, 47, 48, 0, 49, + 50, 51, 52, 0, 53, 0, 0, 0, 4, 54, + 66, 0, 5, 0, 67, 55, 0, 56, 57, 0, + 8, 58, 59, 60, 45, 0, 46, 65, 0, 47, + 48, 0, 49, 50, 51, 52, 0, 53, 0, 0, + 0, 4, 54, 66, 0, 5, 0, 102, 55, 0, + 56, 57, 0, 8, 58, 59, 60, 45, 0, 46, + 65, 0, 47, 48, 0, 49, 50, 51, 52, 0, + 53, 0, 0, 0, 4, 54, 66, 0, 5, 0, + 209, 55, 0, 56, 57, 0, 8, 58, 59, 60, + 45, 75, 46, 0, 0, 47, 48, 0, 49, 50, + 51, 52, 0, 53, 0, 0, 0, 4, 54, 99, + 0, 5, 0, 0, 55, 0, 56, 57, 0, 8, + 58, 59, 60, 45, 0, 46, 65, 0, 47, 48, + 0, 49, 50, 51, 52, 0, 53, 0, 0, 0, + 4, 54, 66, 0, 5, 0, 0, 55, 0, 56, + 57, 0, 8, 58, 59, 60, 45, 0, 46, 0, + 0, 47, 48, 0, 49, 50, 51, 52, 0, 53, + 0, 0, 0, 4, 54, 66, 0, 5, 0, 0, + 55, 171, 56, 57, 0, 8, 58, 59, 60, 45, + 0, 46, 0, 0, 47, 48, 0, 49, 50, 51, + 52, 0, 53, 267, 0, 0, 4, 54, 66, 0, + 5, 0, 0, 55, 0, 56, 57, 0, 8, 58, + 59, 60, 45, 0, 46, 0, 0, 47, 48, 0, + 49, 50, 51, 52, 0, 53, 0, 0, 0, 4, + 54, 66, 0, 5, 0, 0, 55, 0, 56, 57, + 0, 8, 58, 59, 60, 45, 0, 46, 0, 0, + 47, 48, 0, 49, 50, 51, 52, 0, 53, 0, + 0, 0, 4, 54, 0, 0, 5, 0, 0, 55, + 0, 56, 57, 0, 8, 58, 59, 60, 45, 0, + 46, 0, 0, 47, 0, 0, 49, 213, 51, 52, + 0, 53, 0, 0, 0, 4, 0, 218, 0, 5, + 0, 0, 55, 0, 56, 57, 0, 8, 0, 59, + 60 +}; + +static const yytype_int16 yycheck[] = +{ + 0, 68, 15, 2, 0, 173, 42, 192, 173, 107, + 100, 11, 195, 165, 77, 11, 5, 144, 29, 29, + 96, 7, 190, 34, 34, 38, 39, 40, 41, 267, + 43, 44, 95, 22, 7, 187, 188, 164, 276, 137, + 116, 289, 118, 291, 29, 0, 25, 215, 21, 34, + 215, 299, 235, 238, 90, 91, 239, 93, 121, 149, + 123, 97, 75, 126, 29, 42, 65, 130, 21, 34, + 308, 84, 85, 86, 87, 88, 89, 167, 168, 31, + 22, 7, 17, 0, 29, 98, 99, 39, 65, 34, + 258, 104, 105, 258, 11, 23, 21, 14, 29, 27, + 113, 29, 169, 34, 21, 21, 23, 35, 108, 7, + 27, 124, 10, 90, 91, 22, 33, 34, 35, 31, + 97, 289, 34, 291, 101, 138, 6, 127, 8, 106, + 298, 299, 21, 146, 39, 112, 16, 114, 115, 19, + 117, 23, 119, 21, 144, 27, 159, 29, 125, 162, + 10, 128, 32, 35, 5, 132, 133, 37, 38, 11, + 5, 157, 14, 176, 164, 183, 22, 21, 186, 21, + 10, 23, 21, 173, 3, 27, 153, 34, 155, 21, + 193, 33, 34, 35, 30, 198, 5, 8, 26, 185, + 190, 26, 205, 170, 25, 16, 192, 23, 19, 5, + 5, 27, 29, 216, 200, 29, 10, 4, 221, 35, + 5, 32, 33, 28, 5, 215, 37, 38, 26, 5, + 12, 234, 11, 15, 147, 17, 296, 210, 134, 138, + 190, 260, 24, 25, 127, 263, 277, 250, 30, 159, + 252, 254, 238, 189, 36, 185, 99, 260, -1, -1, + 263, -1, -1, -1, 267, -1, 252, -1, 258, 272, + -1, -1, -1, 276, -1, -1, 243, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, 289, + 303, 291, -1, 306, -1, 308, -1, -1, 298, 299, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, 292, -1, -1, -1, -1, + 3, -1, -1, 6, -1, 8, -1, -1, 11, 12, + 307, 14, 15, 16, 17, 312, 19, -1, -1, -1, + 23, 24, 25, -1, 27, -1, -1, 30, -1, 32, + 33, -1, 35, 36, 37, 38, 6, -1, 8, -1, + -1, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, -1, 23, 24, 25, -1, 27, 28, -1, + 30, -1, 32, 33, 34, 35, 36, 37, 38, 39, + 6, -1, 8, -1, -1, 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, 21, -1, 23, 24, 25, + -1, 27, 28, -1, 30, -1, 32, 33, 34, 35, + 36, 37, 38, 39, 6, -1, 8, -1, -1, 11, + 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, + -1, 23, 24, 25, -1, 27, -1, -1, 30, -1, + 32, 33, 34, 35, 36, 37, 38, 39, 6, -1, + 8, -1, -1, 11, 12, 13, 14, 15, 16, 17, + 18, 19, 20, 21, -1, 23, 24, 25, -1, 27, + -1, -1, 30, -1, 32, 33, 34, 35, 36, 37, + 38, 6, -1, 8, 9, -1, 11, 12, -1, 14, + 15, 16, 17, -1, 19, -1, -1, -1, 23, 24, + 25, -1, 27, -1, 29, 30, -1, 32, 33, -1, + 35, 36, 37, 38, 6, -1, 8, 9, -1, 11, + 12, -1, 14, 15, 16, 17, -1, 19, -1, -1, + -1, 23, 24, 25, -1, 27, -1, 29, 30, -1, + 32, 33, -1, 35, 36, 37, 38, 6, -1, 8, + 9, -1, 11, 12, -1, 14, 15, 16, 17, -1, + 19, -1, -1, -1, 23, 24, 25, -1, 27, -1, + 29, 30, -1, 32, 33, -1, 35, 36, 37, 38, + 6, 7, 8, -1, -1, 11, 12, -1, 14, 15, + 16, 17, -1, 19, -1, -1, -1, 23, 24, 25, + -1, 27, -1, -1, 30, -1, 32, 33, -1, 35, + 36, 37, 38, 6, -1, 8, 9, -1, 11, 12, + -1, 14, 15, 16, 17, -1, 19, -1, -1, -1, + 23, 24, 25, -1, 27, -1, -1, 30, -1, 32, + 33, -1, 35, 36, 37, 38, 6, -1, 8, -1, + -1, 11, 12, -1, 14, 15, 16, 17, -1, 19, + -1, -1, -1, 23, 24, 25, -1, 27, -1, -1, + 30, 31, 32, 33, -1, 35, 36, 37, 38, 6, + -1, 8, -1, -1, 11, 12, -1, 14, 15, 16, + 17, -1, 19, 20, -1, -1, 23, 24, 25, -1, + 27, -1, -1, 30, -1, 32, 33, -1, 35, 36, + 37, 38, 6, -1, 8, -1, -1, 11, 12, -1, + 14, 15, 16, 17, -1, 19, -1, -1, -1, 23, + 24, 25, -1, 27, -1, -1, 30, -1, 32, 33, + -1, 35, 36, 37, 38, 6, -1, 8, -1, -1, + 11, 12, -1, 14, 15, 16, 17, -1, 19, -1, + -1, -1, 23, 24, -1, -1, 27, -1, -1, 30, + -1, 32, 33, -1, 35, 36, 37, 38, 6, -1, + 8, -1, -1, 11, -1, -1, 14, 15, 16, 17, + -1, 19, -1, -1, -1, 23, -1, 25, -1, 27, + -1, -1, 30, -1, 32, 33, -1, 35, -1, 37, + 38 +}; + +/* YYSTOS[STATE-NUM] -- The symbol kind of the accessing symbol of + state STATE-NUM. */ +static const yytype_uint8 yystos[] = +{ + 0, 11, 14, 21, 23, 27, 33, 34, 35, 41, + 42, 43, 44, 45, 46, 47, 85, 89, 90, 94, + 95, 96, 97, 98, 108, 111, 112, 115, 116, 119, + 120, 121, 122, 123, 124, 125, 126, 127, 8, 16, + 19, 32, 33, 37, 38, 6, 8, 11, 12, 14, + 15, 16, 17, 19, 24, 30, 32, 33, 36, 37, + 38, 47, 81, 82, 86, 9, 25, 29, 53, 54, + 57, 80, 82, 83, 84, 7, 77, 78, 79, 0, + 44, 48, 80, 6, 8, 16, 19, 32, 37, 38, + 80, 80, 80, 80, 77, 78, 80, 80, 25, 25, + 77, 82, 29, 54, 5, 22, 58, 80, 21, 91, + 79, 22, 49, 7, 80, 80, 80, 80, 80, 80, + 77, 78, 77, 78, 17, 99, 78, 21, 99, 77, + 78, 80, 128, 128, 21, 55, 77, 80, 59, 60, + 80, 77, 58, 47, 92, 93, 39, 50, 51, 77, + 80, 77, 77, 99, 77, 99, 77, 21, 21, 3, + 80, 100, 101, 77, 92, 77, 10, 77, 77, 53, + 58, 31, 60, 5, 55, 56, 5, 29, 93, 80, + 31, 51, 55, 22, 104, 21, 21, 77, 77, 10, + 96, 113, 114, 96, 102, 103, 80, 100, 3, 80, + 21, 106, 29, 106, 26, 109, 110, 55, 55, 29, + 77, 12, 13, 15, 18, 20, 21, 24, 25, 34, + 36, 39, 47, 61, 62, 63, 64, 65, 67, 68, + 69, 73, 81, 80, 5, 103, 30, 117, 114, 103, + 106, 106, 110, 67, 29, 113, 80, 29, 102, 80, + 96, 105, 107, 80, 26, 56, 28, 62, 66, 70, + 71, 72, 74, 75, 76, 80, 80, 20, 80, 87, + 88, 31, 25, 29, 29, 77, 5, 80, 29, 105, + 80, 28, 62, 29, 72, 80, 29, 76, 80, 5, + 87, 10, 52, 4, 80, 118, 87, 104, 5, 5, + 61, 62, 28, 21, 61, 77, 26, 52, 5, 61, + 80, 77, 87, 77 +}; + +/* YYR1[RULE-NUM] -- Symbol kind of the left-hand side of rule RULE-NUM. */ +static const yytype_uint8 yyr1[] = +{ + 0, 40, 41, 42, 43, 43, 44, 44, 44, 45, + 45, 46, 46, 46, 47, 47, 47, 48, 48, 49, + 49, 50, 50, 51, 52, 52, 53, 53, 54, 54, + 54, 55, 56, 56, 57, 57, 58, 58, 59, 59, + 60, 61, 61, 61, 61, 61, 61, 61, 61, 61, + 62, 62, 62, 63, 64, 65, 65, 66, 66, 67, + 67, 67, 67, 68, 69, 70, 70, 71, 71, 72, + 73, 74, 74, 75, 75, 76, 77, 77, 78, 78, + 79, 80, 80, 81, 81, 81, 81, 81, 81, 81, + 81, 81, 81, 81, 81, 82, 82, 82, 82, 82, + 82, 83, 84, 84, 85, 86, 86, 87, 87, 88, + 88, 89, 89, 89, 90, 91, 91, 92, 92, 93, + 94, 94, 94, 94, 94, 94, 95, 96, 96, 97, + 98, 99, 99, 99, 99, 100, 100, 101, 101, 102, + 103, 103, 104, 104, 105, 106, 106, 107, 107, 107, + 108, 109, 109, 110, 110, 111, 112, 113, 114, 114, + 115, 116, 117, 117, 118, 118, 119, 119, 120, 120, + 121, 121, 121, 121, 121, 121, 122, 123, 124, 125, + 125, 126, 126, 127, 127, 128 +}; + +/* YYR2[RULE-NUM] -- Number of symbols on the right-hand side of rule RULE-NUM. */ +static const yytype_int8 yyr2[] = +{ + 0, 2, 1, 1, 1, 2, 1, 1, 1, 1, + 1, 5, 3, 2, 1, 1, 1, 0, 1, 0, + 3, 1, 2, 6, 0, 2, 1, 2, 1, 1, + 1, 3, 0, 1, 6, 4, 0, 3, 1, 2, + 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 2, 2, 3, 1, 2, 1, + 1, 1, 1, 1, 3, 0, 1, 1, 2, 3, + 3, 0, 1, 1, 2, 3, 0, 1, 1, 2, + 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 3, 5, 3, 6, 0, 1, 1, 2, 1, + 3, 1, 1, 1, 3, 0, 3, 1, 2, 3, + 1, 1, 1, 1, 1, 1, 1, 0, 1, 4, + 6, 0, 3, 2, 2, 1, 3, 1, 2, 6, + 1, 2, 0, 3, 6, 0, 3, 0, 1, 2, + 6, 0, 1, 2, 3, 6, 7, 3, 1, 2, + 7, 8, 0, 1, 1, 3, 1, 1, 6, 3, + 1, 1, 1, 1, 1, 1, 4, 6, 6, 6, + 4, 7, 4, 7, 4, 1 +}; + + +enum { YYENOMEM = -2 }; + +#define yyerrok (yyerrstatus = 0) +#define yyclearin (yychar = YYEMPTY) + +#define YYACCEPT goto yyacceptlab +#define YYABORT goto yyabortlab +#define YYERROR goto yyerrorlab +#define YYNOMEM goto yyexhaustedlab + + +#define YYRECOVERING() (!!yyerrstatus) + +#define YYBACKUP(Token, Value) \ + do \ + if (yychar == YYEMPTY) \ + { \ + yychar = (Token); \ + yylval = (Value); \ + YYPOPSTACK (yylen); \ + yystate = *yyssp; \ + goto yybackup; \ + } \ + else \ + { \ + yyerror (parser, filename, YY_("syntax error: cannot back up")); \ + YYERROR; \ + } \ + while (0) + +/* Backward compatibility with an undocumented macro. + Use YYerror or YYUNDEF. */ +#define YYERRCODE YYUNDEF + + +/* Enable debugging if requested. */ +#if YYDEBUG + +# ifndef YYFPRINTF +# include /* INFRINGES ON USER NAME SPACE */ +# define YYFPRINTF fprintf +# endif + +# define YYDPRINTF(Args) \ +do { \ + if (yydebug) \ + YYFPRINTF Args; \ +} while (0) + + + + +# define YY_SYMBOL_PRINT(Title, Kind, Value, Location) \ +do { \ + if (yydebug) \ + { \ + YYFPRINTF (stderr, "%s ", Title); \ + yy_symbol_print (stderr, \ + Kind, Value, parser, filename); \ + YYFPRINTF (stderr, "\n"); \ + } \ +} while (0) + + +/*-----------------------------------. +| Print this symbol's value on YYO. | +`-----------------------------------*/ + +static void +yy_symbol_value_print (FILE *yyo, + yysymbol_kind_t yykind, YYSTYPE const * const yyvaluep, VALUE parser, VALUE filename) +{ + FILE *yyoutput = yyo; + YY_USE (yyoutput); + YY_USE (parser); + YY_USE (filename); + if (!yyvaluep) + return; + YY_IGNORE_MAYBE_UNINITIALIZED_BEGIN + YY_USE (yykind); + YY_IGNORE_MAYBE_UNINITIALIZED_END +} + + +/*---------------------------. +| Print this symbol on YYO. | +`---------------------------*/ + +static void +yy_symbol_print (FILE *yyo, + yysymbol_kind_t yykind, YYSTYPE const * const yyvaluep, VALUE parser, VALUE filename) +{ + YYFPRINTF (yyo, "%s %s (", + yykind < YYNTOKENS ? "token" : "nterm", yysymbol_name (yykind)); + + yy_symbol_value_print (yyo, yykind, yyvaluep, parser, filename); + YYFPRINTF (yyo, ")"); +} + +/*------------------------------------------------------------------. +| yy_stack_print -- Print the state stack from its BOTTOM up to its | +| TOP (included). | +`------------------------------------------------------------------*/ + +static void +yy_stack_print (yy_state_t *yybottom, yy_state_t *yytop) +{ + YYFPRINTF (stderr, "Stack now"); + for (; yybottom <= yytop; yybottom++) + { + int yybot = *yybottom; + YYFPRINTF (stderr, " %d", yybot); + } + YYFPRINTF (stderr, "\n"); +} + +# define YY_STACK_PRINT(Bottom, Top) \ +do { \ + if (yydebug) \ + yy_stack_print ((Bottom), (Top)); \ +} while (0) + + +/*------------------------------------------------. +| Report that the YYRULE is going to be reduced. | +`------------------------------------------------*/ + +static void +yy_reduce_print (yy_state_t *yyssp, YYSTYPE *yyvsp, + int yyrule, VALUE parser, VALUE filename) +{ + int yylno = yyrline[yyrule]; + int yynrhs = yyr2[yyrule]; + int yyi; + YYFPRINTF (stderr, "Reducing stack by rule %d (line %d):\n", + yyrule - 1, yylno); + /* The symbols being reduced. */ + for (yyi = 0; yyi < yynrhs; yyi++) + { + YYFPRINTF (stderr, " $%d = ", yyi + 1); + yy_symbol_print (stderr, + YY_ACCESSING_SYMBOL (+yyssp[yyi + 1 - yynrhs]), + &yyvsp[(yyi + 1) - (yynrhs)], parser, filename); + YYFPRINTF (stderr, "\n"); + } +} + +# define YY_REDUCE_PRINT(Rule) \ +do { \ + if (yydebug) \ + yy_reduce_print (yyssp, yyvsp, Rule, parser, filename); \ +} while (0) + +/* Nonzero means print parse trace. It is left uninitialized so that + multiple parsers can coexist. */ +int yydebug; +#else /* !YYDEBUG */ +# define YYDPRINTF(Args) ((void) 0) +# define YY_SYMBOL_PRINT(Title, Kind, Value, Location) +# define YY_STACK_PRINT(Bottom, Top) +# define YY_REDUCE_PRINT(Rule) +#endif /* !YYDEBUG */ + + +/* YYINITDEPTH -- initial size of the parser's stacks. */ +#ifndef YYINITDEPTH +# define YYINITDEPTH 200 +#endif + +/* YYMAXDEPTH -- maximum size the stacks can grow to (effective only + if the built-in stack extension method is used). + + Do not make this value too large; the results are undefined if + YYSTACK_ALLOC_MAXIMUM < YYSTACK_BYTES (YYMAXDEPTH) + evaluated with infinite-precision integer arithmetic. */ + +#ifndef YYMAXDEPTH +# define YYMAXDEPTH 10000 +#endif + + +/* Context of a parse error. */ +typedef struct +{ + yy_state_t *yyssp; + yysymbol_kind_t yytoken; +} yypcontext_t; + +/* Put in YYARG at most YYARGN of the expected tokens given the + current YYCTX, and return the number of tokens stored in YYARG. If + YYARG is null, return the number of expected tokens (guaranteed to + be less than YYNTOKENS). Return YYENOMEM on memory exhaustion. + Return 0 if there are more than YYARGN expected tokens, yet fill + YYARG up to YYARGN. */ +static int +yypcontext_expected_tokens (const yypcontext_t *yyctx, + yysymbol_kind_t yyarg[], int yyargn) +{ + /* Actual size of YYARG. */ + int yycount = 0; + int yyn = yypact[+*yyctx->yyssp]; + if (!yypact_value_is_default (yyn)) + { + /* Start YYX at -YYN if negative to avoid negative indexes in + YYCHECK. In other words, skip the first -YYN actions for + this state because they are default actions. */ + int yyxbegin = yyn < 0 ? -yyn : 0; + /* Stay within bounds of both yycheck and yytname. */ + int yychecklim = YYLAST - yyn + 1; + int yyxend = yychecklim < YYNTOKENS ? yychecklim : YYNTOKENS; + int yyx; + for (yyx = yyxbegin; yyx < yyxend; ++yyx) + if (yycheck[yyx + yyn] == yyx && yyx != YYSYMBOL_YYerror + && !yytable_value_is_error (yytable[yyx + yyn])) + { + if (!yyarg) + ++yycount; + else if (yycount == yyargn) + return 0; + else + yyarg[yycount++] = YY_CAST (yysymbol_kind_t, yyx); + } + } + if (yyarg && yycount == 0 && 0 < yyargn) + yyarg[0] = YYSYMBOL_YYEMPTY; + return yycount; +} + + + + +#ifndef yystrlen +# if defined __GLIBC__ && defined _STRING_H +# define yystrlen(S) (YY_CAST (YYPTRDIFF_T, strlen (S))) +# else +/* Return the length of YYSTR. */ +static YYPTRDIFF_T +yystrlen (const char *yystr) +{ + YYPTRDIFF_T yylen; + for (yylen = 0; yystr[yylen]; yylen++) + continue; + return yylen; +} +# endif +#endif + +#ifndef yystpcpy +# if defined __GLIBC__ && defined _STRING_H && defined _GNU_SOURCE +# define yystpcpy stpcpy +# else +/* Copy YYSRC to YYDEST, returning the address of the terminating '\0' in + YYDEST. */ +static char * +yystpcpy (char *yydest, const char *yysrc) +{ + char *yyd = yydest; + const char *yys = yysrc; + + while ((*yyd++ = *yys++) != '\0') + continue; + + return yyd - 1; +} +# endif +#endif + + + +static int +yy_syntax_error_arguments (const yypcontext_t *yyctx, + yysymbol_kind_t yyarg[], int yyargn) +{ + /* Actual size of YYARG. */ + int yycount = 0; + /* There are many possibilities here to consider: + - If this state is a consistent state with a default action, then + the only way this function was invoked is if the default action + is an error action. In that case, don't check for expected + tokens because there are none. + - The only way there can be no lookahead present (in yychar) is if + this state is a consistent state with a default action. Thus, + detecting the absence of a lookahead is sufficient to determine + that there is no unexpected or expected token to report. In that + case, just report a simple "syntax error". + - Don't assume there isn't a lookahead just because this state is a + consistent state with a default action. There might have been a + previous inconsistent state, consistent state with a non-default + action, or user semantic action that manipulated yychar. + - Of course, the expected token list depends on states to have + correct lookahead information, and it depends on the parser not + to perform extra reductions after fetching a lookahead from the + scanner and before detecting a syntax error. Thus, state merging + (from LALR or IELR) and default reductions corrupt the expected + token list. However, the list is correct for canonical LR with + one exception: it will still contain any token that will not be + accepted due to an error action in a later state. + */ + if (yyctx->yytoken != YYSYMBOL_YYEMPTY) + { + int yyn; + if (yyarg) + yyarg[yycount] = yyctx->yytoken; + ++yycount; + yyn = yypcontext_expected_tokens (yyctx, + yyarg ? yyarg + 1 : yyarg, yyargn - 1); + if (yyn == YYENOMEM) + return YYENOMEM; + else + yycount += yyn; + } + return yycount; +} + +/* Copy into *YYMSG, which is of size *YYMSG_ALLOC, an error message + about the unexpected token YYTOKEN for the state stack whose top is + YYSSP. + + Return 0 if *YYMSG was successfully written. Return -1 if *YYMSG is + not large enough to hold the message. In that case, also set + *YYMSG_ALLOC to the required number of bytes. Return YYENOMEM if the + required number of bytes is too large to store. */ +static int +yysyntax_error (YYPTRDIFF_T *yymsg_alloc, char **yymsg, + const yypcontext_t *yyctx) +{ + enum { YYARGS_MAX = 5 }; + /* Internationalized format string. */ + const char *yyformat = YY_NULLPTR; + /* Arguments of yyformat: reported tokens (one for the "unexpected", + one per "expected"). */ + yysymbol_kind_t yyarg[YYARGS_MAX]; + /* Cumulated lengths of YYARG. */ + YYPTRDIFF_T yysize = 0; + + /* Actual size of YYARG. */ + int yycount = yy_syntax_error_arguments (yyctx, yyarg, YYARGS_MAX); + if (yycount == YYENOMEM) + return YYENOMEM; + + switch (yycount) + { +#define YYCASE_(N, S) \ + case N: \ + yyformat = S; \ + break + default: /* Avoid compiler warnings. */ + YYCASE_(0, YY_("syntax error")); + YYCASE_(1, YY_("syntax error, unexpected %s")); + YYCASE_(2, YY_("syntax error, unexpected %s, expecting %s")); + YYCASE_(3, YY_("syntax error, unexpected %s, expecting %s or %s")); + YYCASE_(4, YY_("syntax error, unexpected %s, expecting %s or %s or %s")); + YYCASE_(5, YY_("syntax error, unexpected %s, expecting %s or %s or %s or %s")); +#undef YYCASE_ + } + + /* Compute error message size. Don't count the "%s"s, but reserve + room for the terminator. */ + yysize = yystrlen (yyformat) - 2 * yycount + 1; + { + int yyi; + for (yyi = 0; yyi < yycount; ++yyi) + { + YYPTRDIFF_T yysize1 + = yysize + yystrlen (yysymbol_name (yyarg[yyi])); + if (yysize <= yysize1 && yysize1 <= YYSTACK_ALLOC_MAXIMUM) + yysize = yysize1; + else + return YYENOMEM; + } + } + + if (*yymsg_alloc < yysize) + { + *yymsg_alloc = 2 * yysize; + if (! (yysize <= *yymsg_alloc + && *yymsg_alloc <= YYSTACK_ALLOC_MAXIMUM)) + *yymsg_alloc = YYSTACK_ALLOC_MAXIMUM; + return -1; + } + + /* Avoid sprintf, as that infringes on the user's name space. + Don't have undefined behavior even if the translation + produced a string with the wrong number of "%s"s. */ + { + char *yyp = *yymsg; + int yyi = 0; + while ((*yyp = *yyformat) != '\0') + if (*yyp == '%' && yyformat[1] == 's' && yyi < yycount) + { + yyp = yystpcpy (yyp, yysymbol_name (yyarg[yyi++])); + yyformat += 2; + } + else + { + ++yyp; + ++yyformat; + } + } + return 0; +} + + +/*-----------------------------------------------. +| Release the memory associated to this symbol. | +`-----------------------------------------------*/ + +static void +yydestruct (const char *yymsg, + yysymbol_kind_t yykind, YYSTYPE *yyvaluep, VALUE parser, VALUE filename) +{ + YY_USE (yyvaluep); + YY_USE (parser); + YY_USE (filename); + if (!yymsg) + yymsg = "Deleting"; + YY_SYMBOL_PRINT (yymsg, yykind, yyvaluep, yylocationp); + + YY_IGNORE_MAYBE_UNINITIALIZED_BEGIN + YY_USE (yykind); + YY_IGNORE_MAYBE_UNINITIALIZED_END +} + + + + + + +/*----------. +| yyparse. | +`----------*/ + +int +yyparse (VALUE parser, VALUE filename) +{ +/* Lookahead token kind. */ +int yychar; + + +/* The semantic value of the lookahead symbol. */ +/* Default value used for initialization, for pacifying older GCCs + or non-GCC compilers. */ +YY_INITIAL_VALUE (static YYSTYPE yyval_default;) +YYSTYPE yylval YY_INITIAL_VALUE (= yyval_default); + + /* Number of syntax errors so far. */ + int yynerrs = 0; + + yy_state_fast_t yystate = 0; + /* Number of tokens to shift before error messages enabled. */ + int yyerrstatus = 0; + + /* Refer to the stacks through separate pointers, to allow yyoverflow + to reallocate them elsewhere. */ + + /* Their size. */ + YYPTRDIFF_T yystacksize = YYINITDEPTH; + + /* The state stack: array, bottom, top. */ + yy_state_t yyssa[YYINITDEPTH]; + yy_state_t *yyss = yyssa; + yy_state_t *yyssp = yyss; + + /* The semantic value stack: array, bottom, top. */ + YYSTYPE yyvsa[YYINITDEPTH]; + YYSTYPE *yyvs = yyvsa; + YYSTYPE *yyvsp = yyvs; + + int yyn; + /* The return value of yyparse. */ + int yyresult; + /* Lookahead symbol kind. */ + yysymbol_kind_t yytoken = YYSYMBOL_YYEMPTY; + /* The variables used to return semantic value and location from the + action routines. */ + YYSTYPE yyval; + + /* Buffer for error messages, and its allocated size. */ + char yymsgbuf[128]; + char *yymsg = yymsgbuf; + YYPTRDIFF_T yymsg_alloc = sizeof yymsgbuf; + +#define YYPOPSTACK(N) (yyvsp -= (N), yyssp -= (N)) + + /* The number of symbols on the RHS of the reduced rule. + Keep to zero when no symbol should be popped. */ + int yylen = 0; + + YYDPRINTF ((stderr, "Starting parse\n")); + + yychar = YYEMPTY; /* Cause a token to be read. */ + + goto yysetstate; + + +/*------------------------------------------------------------. +| yynewstate -- push a new state, which is found in yystate. | +`------------------------------------------------------------*/ +yynewstate: + /* In all cases, when you get here, the value and location stacks + have just been pushed. So pushing a state here evens the stacks. */ + yyssp++; + + +/*--------------------------------------------------------------------. +| yysetstate -- set current state (the top of the stack) to yystate. | +`--------------------------------------------------------------------*/ +yysetstate: + YYDPRINTF ((stderr, "Entering state %d\n", yystate)); + YY_ASSERT (0 <= yystate && yystate < YYNSTATES); + YY_IGNORE_USELESS_CAST_BEGIN + *yyssp = YY_CAST (yy_state_t, yystate); + YY_IGNORE_USELESS_CAST_END + YY_STACK_PRINT (yyss, yyssp); + + if (yyss + yystacksize - 1 <= yyssp) +#if !defined yyoverflow && !defined YYSTACK_RELOCATE + YYNOMEM; +#else + { + /* Get the current used size of the three stacks, in elements. */ + YYPTRDIFF_T yysize = yyssp - yyss + 1; + +# if defined yyoverflow + { + /* Give user a chance to reallocate the stack. Use copies of + these so that the &'s don't force the real ones into + memory. */ + yy_state_t *yyss1 = yyss; + YYSTYPE *yyvs1 = yyvs; + + /* Each stack pointer address is followed by the size of the + data in use in that stack, in bytes. This used to be a + conditional around just the two extra args, but that might + be undefined if yyoverflow is a macro. */ + yyoverflow (YY_("memory exhausted"), + &yyss1, yysize * YYSIZEOF (*yyssp), + &yyvs1, yysize * YYSIZEOF (*yyvsp), + &yystacksize); + yyss = yyss1; + yyvs = yyvs1; + } +# else /* defined YYSTACK_RELOCATE */ + /* Extend the stack our own way. */ + if (YYMAXDEPTH <= yystacksize) + YYNOMEM; + yystacksize *= 2; + if (YYMAXDEPTH < yystacksize) + yystacksize = YYMAXDEPTH; + + { + yy_state_t *yyss1 = yyss; + union yyalloc *yyptr = + YY_CAST (union yyalloc *, + YYSTACK_ALLOC (YY_CAST (YYSIZE_T, YYSTACK_BYTES (yystacksize)))); + if (! yyptr) + YYNOMEM; + YYSTACK_RELOCATE (yyss_alloc, yyss); + YYSTACK_RELOCATE (yyvs_alloc, yyvs); +# undef YYSTACK_RELOCATE + if (yyss1 != yyssa) + YYSTACK_FREE (yyss1); + } +# endif + + yyssp = yyss + yysize - 1; + yyvsp = yyvs + yysize - 1; + + YY_IGNORE_USELESS_CAST_BEGIN + YYDPRINTF ((stderr, "Stack size increased to %ld\n", + YY_CAST (long, yystacksize))); + YY_IGNORE_USELESS_CAST_END + + if (yyss + yystacksize - 1 <= yyssp) + YYABORT; + } +#endif /* !defined yyoverflow && !defined YYSTACK_RELOCATE */ + + + if (yystate == YYFINAL) + YYACCEPT; + + goto yybackup; + + +/*-----------. +| yybackup. | +`-----------*/ +yybackup: + /* Do appropriate processing given the current state. Read a + lookahead token if we need one and don't already have one. */ + + /* First try to decide what to do without reference to lookahead token. */ + yyn = yypact[yystate]; + if (yypact_value_is_default (yyn)) + goto yydefault; + + /* Not known => get a lookahead token if don't already have one. */ + + /* YYCHAR is either empty, or end-of-input, or a valid lookahead. */ + if (yychar == YYEMPTY) + { + YYDPRINTF ((stderr, "Reading a token\n")); + yychar = yylex (&yylval, parser, filename); + } + + if (yychar <= YYEOF) + { + yychar = YYEOF; + yytoken = YYSYMBOL_YYEOF; + YYDPRINTF ((stderr, "Now at end of input.\n")); + } + else if (yychar == YYerror) + { + /* The scanner already issued an error message, process directly + to error recovery. But do not keep the error token as + lookahead, it is too special and may lead us to an endless + loop in error recovery. */ + yychar = YYUNDEF; + yytoken = YYSYMBOL_YYerror; + goto yyerrlab1; + } + else + { + yytoken = YYTRANSLATE (yychar); + YY_SYMBOL_PRINT ("Next token is", yytoken, &yylval, &yylloc); + } + + /* If the proper action on seeing token YYTOKEN is to reduce or to + detect an error, take that action. */ + yyn += yytoken; + if (yyn < 0 || YYLAST < yyn || yycheck[yyn] != yytoken) + goto yydefault; + yyn = yytable[yyn]; + if (yyn <= 0) + { + if (yytable_value_is_error (yyn)) + goto yyerrlab; + yyn = -yyn; + goto yyreduce; + } + + /* Count tokens shifted since error; after three, turn off error + status. */ + if (yyerrstatus) + yyerrstatus--; + + /* Shift the lookahead token. */ + YY_SYMBOL_PRINT ("Shifting", yytoken, &yylval, &yylloc); + yystate = yyn; + YY_IGNORE_MAYBE_UNINITIALIZED_BEGIN + *++yyvsp = yylval; + YY_IGNORE_MAYBE_UNINITIALIZED_END + + /* Discard the shifted token. */ + yychar = YYEMPTY; + goto yynewstate; + + +/*-----------------------------------------------------------. +| yydefault -- do the default action for the current state. | +`-----------------------------------------------------------*/ +yydefault: + yyn = yydefact[yystate]; + if (yyn == 0) + goto yyerrlab; + goto yyreduce; + + +/*-----------------------------. +| yyreduce -- do a reduction. | +`-----------------------------*/ +yyreduce: + /* yyn is the number of a rule to reduce with. */ + yylen = yyr2[yyn]; + + /* If YYLEN is nonzero, implement the default value of the action: + '$$ = $1'. + + Otherwise, the following line sets YYVAL to garbage. + This behavior is undocumented and Bison + users should not rely upon it. Assigning to YYVAL + unconditionally makes the parser a bit smaller, and it avoids a + GCC warning that YYVAL may be used uninitialized. */ + yyval = yyvsp[1-yylen]; + + + YY_REDUCE_PRINT (yyn); + switch (yyn) + { + case 2: /* start: document */ +#line 107 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { rb_ivar_set(parser, rb_intern("@result"), yyvsp[0]); } +#line 1931 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 3: /* document: definitions_list */ +#line 109 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + VALUE position_source = rb_ary_entry(yyvsp[0], 0); + VALUE line, col; + if (RB_TEST(position_source)) { + line = rb_funcall(position_source, rb_intern("line"), 0); + col = rb_funcall(position_source, rb_intern("col"), 0); + } else { + line = INT2FIX(1); + col = INT2FIX(1); + } + yyval = MAKE_AST_NODE(Document, 3, line, col, yyvsp[0]); + } +#line 1948 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 4: /* definitions_list: definition */ +#line 123 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_new_from_args(1, yyvsp[0]); } +#line 1954 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 5: /* definitions_list: definitions_list definition */ +#line 124 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { rb_ary_push(yyval, yyvsp[0]); } +#line 1960 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 11: /* operation_definition: operation_type operation_name_opt variable_definitions_opt directives_list_opt selection_set */ +#line 136 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(OperationDefinition, 7, + rb_ary_entry(yyvsp[-4], 1), + rb_ary_entry(yyvsp[-4], 2), + rb_ary_entry(yyvsp[-4], 3), + (RB_TEST(yyvsp[-3]) ? rb_ary_entry(yyvsp[-3], 3) : Qnil), + yyvsp[-2], + yyvsp[-1], + yyvsp[0] + ); + } +#line 1976 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 12: /* operation_definition: LCURLY selection_list RCURLY */ +#line 147 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(OperationDefinition, 7, + rb_ary_entry(yyvsp[-2], 1), + rb_ary_entry(yyvsp[-2], 2), + r_string_query, + Qnil, + GraphQL_Language_Nodes_NONE, + GraphQL_Language_Nodes_NONE, + yyvsp[-1] + ); + } +#line 1992 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 13: /* operation_definition: LCURLY RCURLY */ +#line 158 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(OperationDefinition, 7, + rb_ary_entry(yyvsp[-1], 1), + rb_ary_entry(yyvsp[-1], 2), + r_string_query, + Qnil, + GraphQL_Language_Nodes_NONE, + GraphQL_Language_Nodes_NONE, + GraphQL_Language_Nodes_NONE + ); + } +#line 2008 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 17: /* operation_name_opt: %empty */ +#line 176 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = Qnil; } +#line 2014 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 19: /* variable_definitions_opt: %empty */ +#line 180 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = GraphQL_Language_Nodes_NONE; } +#line 2020 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 20: /* variable_definitions_opt: LPAREN variable_definitions_list RPAREN */ +#line 181 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = yyvsp[-1]; } +#line 2026 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 21: /* variable_definitions_list: variable_definition */ +#line 184 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_new_from_args(1, yyvsp[0]); } +#line 2032 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 22: /* variable_definitions_list: variable_definitions_list variable_definition */ +#line 185 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { rb_ary_push(yyval, yyvsp[0]); } +#line 2038 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 23: /* variable_definition: VAR_SIGN name COLON type default_value_opt directives_list_opt */ +#line 188 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(VariableDefinition, 6, + rb_ary_entry(yyvsp[-5], 1), + rb_ary_entry(yyvsp[-5], 2), + rb_ary_entry(yyvsp[-4], 3), + yyvsp[-2], + yyvsp[-1], + yyvsp[0] + ); + } +#line 2053 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 24: /* default_value_opt: %empty */ +#line 200 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = Qnil; } +#line 2059 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 25: /* default_value_opt: EQUALS literal_value */ +#line 201 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = yyvsp[0]; } +#line 2065 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 26: /* selection_list: selection */ +#line 204 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_new_from_args(1, yyvsp[0]); } +#line 2071 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 27: /* selection_list: selection_list selection */ +#line 205 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { rb_ary_push(yyval, yyvsp[0]); } +#line 2077 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 31: /* selection_set: LCURLY selection_list RCURLY */ +#line 213 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = yyvsp[-1]; } +#line 2083 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 32: /* selection_set_opt: %empty */ +#line 216 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_new(); } +#line 2089 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 34: /* field: name COLON name arguments_opt directives_list_opt selection_set_opt */ +#line 220 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(Field, 7, + rb_ary_entry(yyvsp[-5], 1), + rb_ary_entry(yyvsp[-5], 2), + rb_ary_entry(yyvsp[-5], 3), // alias + rb_ary_entry(yyvsp[-3], 3), // name + yyvsp[-2], // args + yyvsp[-1], // directives + yyvsp[0] // subselections + ); + } +#line 2105 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 35: /* field: name arguments_opt directives_list_opt selection_set_opt */ +#line 231 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(Field, 7, + rb_ary_entry(yyvsp[-3], 1), + rb_ary_entry(yyvsp[-3], 2), + Qnil, // alias + rb_ary_entry(yyvsp[-3], 3), // name + yyvsp[-2], // args + yyvsp[-1], // directives + yyvsp[0] // subselections + ); + } +#line 2121 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 36: /* arguments_opt: %empty */ +#line 244 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = GraphQL_Language_Nodes_NONE; } +#line 2127 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 37: /* arguments_opt: LPAREN arguments_list RPAREN */ +#line 245 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = yyvsp[-1]; } +#line 2133 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 38: /* arguments_list: argument */ +#line 248 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_new_from_args(1, yyvsp[0]); } +#line 2139 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 39: /* arguments_list: arguments_list argument */ +#line 249 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { rb_ary_push(yyval, yyvsp[0]); } +#line 2145 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 40: /* argument: name COLON input_value */ +#line 252 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(Argument, 4, + rb_ary_entry(yyvsp[-2], 1), + rb_ary_entry(yyvsp[-2], 2), + rb_ary_entry(yyvsp[-2], 3), + yyvsp[0] + ); + } +#line 2158 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 41: /* literal_value: FLOAT */ +#line 262 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_funcall(rb_ary_entry(yyvsp[0], 3), rb_intern("to_f"), 0); } +#line 2164 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 42: /* literal_value: INT */ +#line 263 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_funcall(rb_ary_entry(yyvsp[0], 3), rb_intern("to_i"), 0); } +#line 2170 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 43: /* literal_value: STRING */ +#line 264 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_entry(yyvsp[0], 3); } +#line 2176 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 44: /* literal_value: TRUE_LITERAL */ +#line 265 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = Qtrue; } +#line 2182 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 45: /* literal_value: FALSE_LITERAL */ +#line 266 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = Qfalse; } +#line 2188 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 53: /* null_value: NULL_LITERAL */ +#line 277 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(NullValue, 3, + rb_ary_entry(yyvsp[0], 1), + rb_ary_entry(yyvsp[0], 2), + rb_ary_entry(yyvsp[0], 3) + ); + } +#line 2200 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 54: /* variable: VAR_SIGN name */ +#line 285 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(VariableIdentifier, 3, + rb_ary_entry(yyvsp[-1], 1), + rb_ary_entry(yyvsp[-1], 2), + rb_ary_entry(yyvsp[0], 3) + ); + } +#line 2212 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 55: /* list_value: LBRACKET RBRACKET */ +#line 294 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = GraphQL_Language_Nodes_NONE; } +#line 2218 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 56: /* list_value: LBRACKET list_value_list RBRACKET */ +#line 295 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = yyvsp[-1]; } +#line 2224 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 57: /* list_value_list: input_value */ +#line 298 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_new_from_args(1, yyvsp[0]); } +#line 2230 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 58: /* list_value_list: list_value_list input_value */ +#line 299 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { rb_ary_push(yyval, yyvsp[0]); } +#line 2236 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 63: /* enum_value: enum_name */ +#line 307 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(Enum, 3, + rb_ary_entry(yyvsp[0], 1), + rb_ary_entry(yyvsp[0], 2), + rb_ary_entry(yyvsp[0], 3) + ); + } +#line 2248 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 64: /* object_value: LCURLY object_value_list_opt RCURLY */ +#line 316 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(InputObject, 3, + rb_ary_entry(yyvsp[-2], 1), + rb_ary_entry(yyvsp[-2], 2), + yyvsp[-1] + ); + } +#line 2260 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 65: /* object_value_list_opt: %empty */ +#line 325 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = GraphQL_Language_Nodes_NONE; } +#line 2266 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 67: /* object_value_list: object_value_field */ +#line 329 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_new_from_args(1, yyvsp[0]); } +#line 2272 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 68: /* object_value_list: object_value_list object_value_field */ +#line 330 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { rb_ary_push(yyval, yyvsp[0]); } +#line 2278 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 69: /* object_value_field: name COLON input_value */ +#line 333 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(Argument, 4, + rb_ary_entry(yyvsp[-2], 1), + rb_ary_entry(yyvsp[-2], 2), + rb_ary_entry(yyvsp[-2], 3), + yyvsp[0] + ); + } +#line 2291 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 70: /* object_literal_value: LCURLY object_literal_value_list_opt RCURLY */ +#line 344 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(InputObject, 3, + rb_ary_entry(yyvsp[-2], 1), + rb_ary_entry(yyvsp[-2], 2), + yyvsp[-1] + ); + } +#line 2303 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 71: /* object_literal_value_list_opt: %empty */ +#line 353 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = GraphQL_Language_Nodes_NONE; } +#line 2309 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 73: /* object_literal_value_list: object_literal_value_field */ +#line 357 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_new_from_args(1, yyvsp[0]); } +#line 2315 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 74: /* object_literal_value_list: object_literal_value_list object_literal_value_field */ +#line 358 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { rb_ary_push(yyval, yyvsp[0]); } +#line 2321 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 75: /* object_literal_value_field: name COLON literal_value */ +#line 361 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(Argument, 4, + rb_ary_entry(yyvsp[-2], 1), + rb_ary_entry(yyvsp[-2], 2), + rb_ary_entry(yyvsp[-2], 3), + yyvsp[0] + ); + } +#line 2334 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 76: /* directives_list_opt: %empty */ +#line 372 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = GraphQL_Language_Nodes_NONE; } +#line 2340 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 78: /* directives_list: directive */ +#line 376 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_new_from_args(1, yyvsp[0]); } +#line 2346 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 79: /* directives_list: directives_list directive */ +#line 377 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { rb_ary_push(yyval, yyvsp[0]); } +#line 2352 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 80: /* directive: DIR_SIGN name arguments_opt */ +#line 379 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(Directive, 4, + rb_ary_entry(yyvsp[-2], 1), + rb_ary_entry(yyvsp[-2], 2), + rb_ary_entry(yyvsp[-1], 3), + yyvsp[0] + ); + } +#line 2365 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 101: /* fragment_spread: ELLIPSIS name_without_on directives_list_opt */ +#line 416 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(FragmentSpread, 4, + rb_ary_entry(yyvsp[-2], 1), + rb_ary_entry(yyvsp[-2], 2), + rb_ary_entry(yyvsp[-1], 3), + yyvsp[0] + ); + } +#line 2378 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 102: /* inline_fragment: ELLIPSIS ON NamedTypeForCondition directives_list_opt selection_set */ +#line 426 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(InlineFragment, 5, + rb_ary_entry(yyvsp[-4], 1), + rb_ary_entry(yyvsp[-4], 2), + yyvsp[-2], + yyvsp[-1], + yyvsp[0] + ); + } +#line 2392 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 103: /* inline_fragment: ELLIPSIS directives_list_opt selection_set */ +#line 435 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(InlineFragment, 5, + rb_ary_entry(yyvsp[-2], 1), + rb_ary_entry(yyvsp[-2], 2), + Qnil, + yyvsp[-1], + yyvsp[0] + ); + } +#line 2406 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 104: /* fragment_definition: FRAGMENT fragment_name_opt ON NamedTypeForCondition directives_list_opt selection_set */ +#line 446 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(FragmentDefinition, 6, + rb_ary_entry(yyvsp[-5], 1), + rb_ary_entry(yyvsp[-5], 2), + yyvsp[-4], + yyvsp[-2], + yyvsp[-1], + yyvsp[0] + ); + } +#line 2421 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 105: /* fragment_name_opt: %empty */ +#line 458 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = Qnil; } +#line 2427 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 106: /* fragment_name_opt: name_without_on */ +#line 459 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_entry(yyvsp[0], 3); } +#line 2433 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 108: /* type: nullable_type BANG */ +#line 463 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = MAKE_AST_NODE(NonNullType, 3, rb_funcall(yyvsp[-1], rb_intern("line"), 0), rb_funcall(yyvsp[-1], rb_intern("col"), 0), yyvsp[-1]); } +#line 2439 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 109: /* nullable_type: name */ +#line 466 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(TypeName, 3, + rb_ary_entry(yyvsp[0], 1), + rb_ary_entry(yyvsp[0], 2), + rb_ary_entry(yyvsp[0], 3) + ); + } +#line 2451 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 110: /* nullable_type: LBRACKET type RBRACKET */ +#line 473 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(ListType, 3, + rb_funcall(yyvsp[-1], rb_intern("line"), 0), + rb_funcall(yyvsp[-1], rb_intern("col"), 0), + yyvsp[-1] + ); + } +#line 2463 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 114: /* schema_definition: SCHEMA directives_list_opt operation_type_definition_list_opt */ +#line 487 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(SchemaDefinition, 6, + rb_ary_entry(yyvsp[-2], 1), + rb_ary_entry(yyvsp[-2], 2), + // TODO use static strings: + rb_hash_aref(yyvsp[0], rb_str_new_cstr("query")), + rb_hash_aref(yyvsp[0], rb_str_new_cstr("mutation")), + rb_hash_aref(yyvsp[0], rb_str_new_cstr("subscription")), + yyvsp[-1] + ); + } +#line 2479 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 115: /* operation_type_definition_list_opt: %empty */ +#line 500 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_hash_new(); } +#line 2485 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 116: /* operation_type_definition_list_opt: LCURLY operation_type_definition_list RCURLY */ +#line 501 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = yyvsp[-1]; } +#line 2491 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 117: /* operation_type_definition_list: operation_type_definition */ +#line 504 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = rb_hash_new(); + rb_hash_aset(yyval, rb_ary_entry(yyvsp[0], 0), rb_ary_entry(yyvsp[0], 1)); + } +#line 2500 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 118: /* operation_type_definition_list: operation_type_definition_list operation_type_definition */ +#line 508 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + rb_hash_aset(yyval, rb_ary_entry(yyvsp[0], 0), rb_ary_entry(yyvsp[0], 1)); + } +#line 2508 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 119: /* operation_type_definition: operation_type COLON name */ +#line 513 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = rb_ary_new_from_args(2, rb_ary_entry(yyvsp[-2], 3), rb_ary_entry(yyvsp[0], 3)); + } +#line 2516 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 127: /* description_opt: %empty */ +#line 528 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = Qnil; } +#line 2522 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 129: /* scalar_type_definition: description_opt SCALAR name directives_list_opt */ +#line 532 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(ScalarTypeDefinition, 5, + rb_ary_entry(yyvsp[-2], 1), + rb_ary_entry(yyvsp[-2], 2), + rb_ary_entry(yyvsp[-1], 3), + // TODO see get_description for reading a description from comments + (RB_TEST(yyvsp[-3]) ? rb_ary_entry(yyvsp[-3], 3) : Qnil), + yyvsp[0] + ); + } +#line 2537 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 130: /* object_type_definition: description_opt TYPE_LITERAL name implements_opt directives_list_opt field_definition_list_opt */ +#line 544 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(ObjectTypeDefinition, 7, + rb_ary_entry(yyvsp[-4], 1), + rb_ary_entry(yyvsp[-4], 2), + rb_ary_entry(yyvsp[-3], 3), + yyvsp[-2], // implements + // TODO see get_description for reading a description from comments + (RB_TEST(yyvsp[-5]) ? rb_ary_entry(yyvsp[-5], 3) : Qnil), + yyvsp[-1], + yyvsp[0] + ); + } +#line 2554 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 131: /* implements_opt: %empty */ +#line 558 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = GraphQL_Language_Nodes_NONE; } +#line 2560 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 132: /* implements_opt: IMPLEMENTS AMP interfaces_list */ +#line 559 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = yyvsp[0]; } +#line 2566 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 133: /* implements_opt: IMPLEMENTS interfaces_list */ +#line 560 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = yyvsp[0]; } +#line 2572 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 134: /* implements_opt: IMPLEMENTS legacy_interfaces_list */ +#line 561 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = yyvsp[0]; } +#line 2578 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 135: /* interfaces_list: name */ +#line 564 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + VALUE new_name = MAKE_AST_NODE(TypeName, 3, + rb_ary_entry(yyvsp[0], 1), + rb_ary_entry(yyvsp[0], 2), + rb_ary_entry(yyvsp[0], 3) + ); + yyval = rb_ary_new_from_args(1, new_name); + } +#line 2591 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 136: /* interfaces_list: interfaces_list AMP name */ +#line 572 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + VALUE new_name = MAKE_AST_NODE(TypeName, 3, rb_ary_entry(yyvsp[0], 1), rb_ary_entry(yyvsp[0], 2), rb_ary_entry(yyvsp[0], 3)); + rb_ary_push(yyval, new_name); + } +#line 2600 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 137: /* legacy_interfaces_list: name */ +#line 578 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + VALUE new_name = MAKE_AST_NODE(TypeName, 3, + rb_ary_entry(yyvsp[0], 1), + rb_ary_entry(yyvsp[0], 2), + rb_ary_entry(yyvsp[0], 3) + ); + yyval = rb_ary_new_from_args(1, new_name); + } +#line 2613 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 138: /* legacy_interfaces_list: legacy_interfaces_list name */ +#line 586 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + rb_ary_push(yyval, MAKE_AST_NODE(TypeName, 3, rb_ary_entry(yyvsp[0], 1), rb_ary_entry(yyvsp[0], 2), rb_ary_entry(yyvsp[0], 3))); + } +#line 2621 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 139: /* input_value_definition: description_opt name COLON type default_value_opt directives_list_opt */ +#line 591 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(InputValueDefinition, 7, + rb_ary_entry(yyvsp[-4], 1), + rb_ary_entry(yyvsp[-4], 2), + rb_ary_entry(yyvsp[-4], 3), + yyvsp[-2], + yyvsp[-1], + // TODO see get_description for reading a description from comments + (RB_TEST(yyvsp[-5]) ? rb_ary_entry(yyvsp[-5], 3) : Qnil), + yyvsp[0] + ); + } +#line 2638 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 140: /* input_value_definition_list: input_value_definition */ +#line 605 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_new_from_args(1, yyvsp[0]); } +#line 2644 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 141: /* input_value_definition_list: input_value_definition_list input_value_definition */ +#line 606 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { rb_ary_push(yyval, yyvsp[0]); } +#line 2650 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 142: /* arguments_definitions_opt: %empty */ +#line 609 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = GraphQL_Language_Nodes_NONE; } +#line 2656 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 143: /* arguments_definitions_opt: LPAREN input_value_definition_list RPAREN */ +#line 610 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = yyvsp[-1]; } +#line 2662 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 144: /* field_definition: description_opt name arguments_definitions_opt COLON type directives_list_opt */ +#line 613 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(FieldDefinition, 7, + rb_ary_entry(yyvsp[-4], 1), + rb_ary_entry(yyvsp[-4], 2), + rb_ary_entry(yyvsp[-4], 3), + yyvsp[-1], + // TODO see get_description for reading a description from comments + (RB_TEST(yyvsp[-5]) ? rb_ary_entry(yyvsp[-5], 3) : Qnil), + yyvsp[-3], + yyvsp[0] + ); + } +#line 2679 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 145: /* field_definition_list_opt: %empty */ +#line 627 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = GraphQL_Language_Nodes_NONE; } +#line 2685 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 146: /* field_definition_list_opt: LCURLY field_definition_list RCURLY */ +#line 628 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = yyvsp[-1]; } +#line 2691 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 147: /* field_definition_list: %empty */ +#line 631 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = GraphQL_Language_Nodes_NONE; } +#line 2697 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 148: /* field_definition_list: field_definition */ +#line 632 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_new_from_args(1, yyvsp[0]); } +#line 2703 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 149: /* field_definition_list: field_definition_list field_definition */ +#line 633 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { rb_ary_push(yyval, yyvsp[0]); } +#line 2709 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 150: /* interface_type_definition: description_opt INTERFACE name implements_opt directives_list_opt field_definition_list_opt */ +#line 636 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(InterfaceTypeDefinition, 7, + rb_ary_entry(yyvsp[-4], 1), + rb_ary_entry(yyvsp[-4], 2), + rb_ary_entry(yyvsp[-3], 3), + // TODO see get_description for reading a description from comments + (RB_TEST(yyvsp[-5]) ? rb_ary_entry(yyvsp[-5], 3) : Qnil), + yyvsp[-2], + yyvsp[-1], + yyvsp[0] + ); + } +#line 2726 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 151: /* pipe_opt: %empty */ +#line 650 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = GraphQL_Language_Nodes_NONE; } +#line 2732 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 152: /* pipe_opt: PIPE */ +#line 651 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = GraphQL_Language_Nodes_NONE; } +#line 2738 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 153: /* union_members: pipe_opt name */ +#line 654 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + VALUE new_member = MAKE_AST_NODE(TypeName, 3, + rb_ary_entry(yyvsp[0], 1), + rb_ary_entry(yyvsp[0], 2), + rb_ary_entry(yyvsp[0], 3) + ); + yyval = rb_ary_new_from_args(1, new_member); + } +#line 2751 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 154: /* union_members: union_members PIPE name */ +#line 662 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + rb_ary_push(yyval, MAKE_AST_NODE(TypeName, 3, rb_ary_entry(yyvsp[0], 1), rb_ary_entry(yyvsp[0], 2), rb_ary_entry(yyvsp[0], 3))); + } +#line 2759 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 155: /* union_type_definition: description_opt UNION name directives_list_opt EQUALS union_members */ +#line 667 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(UnionTypeDefinition, 6, + rb_ary_entry(yyvsp[-4], 1), + rb_ary_entry(yyvsp[-4], 2), + rb_ary_entry(yyvsp[-3], 3), + yyvsp[0], // types + // TODO see get_description for reading a description from comments + (RB_TEST(yyvsp[-5]) ? rb_ary_entry(yyvsp[-5], 3) : Qnil), + yyvsp[-2] + ); + } +#line 2775 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 156: /* enum_type_definition: description_opt ENUM name directives_list_opt LCURLY enum_value_definitions RCURLY */ +#line 680 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(EnumTypeDefinition, 6, + rb_ary_entry(yyvsp[-5], 1), + rb_ary_entry(yyvsp[-5], 2), + rb_ary_entry(yyvsp[-4], 3), + // TODO see get_description for reading a description from comments + (RB_TEST(yyvsp[-6]) ? rb_ary_entry(yyvsp[-6], 3) : Qnil), + yyvsp[-3], + yyvsp[-1] + ); + } +#line 2791 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 157: /* enum_value_definition: description_opt enum_name directives_list_opt */ +#line 693 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(EnumValueDefinition, 5, + rb_ary_entry(yyvsp[-1], 1), + rb_ary_entry(yyvsp[-1], 2), + rb_ary_entry(yyvsp[-1], 3), + // TODO see get_description for reading a description from comments + (RB_TEST(yyvsp[-2]) ? rb_ary_entry(yyvsp[-2], 3) : Qnil), + yyvsp[0] + ); + } +#line 2806 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 158: /* enum_value_definitions: enum_value_definition */ +#line 705 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_new_from_args(1, yyvsp[0]); } +#line 2812 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 159: /* enum_value_definitions: enum_value_definitions enum_value_definition */ +#line 706 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { rb_ary_push(yyval, yyvsp[0]); } +#line 2818 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 160: /* input_object_type_definition: description_opt INPUT name directives_list_opt LCURLY input_value_definition_list RCURLY */ +#line 709 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(InputObjectTypeDefinition, 6, + rb_ary_entry(yyvsp[-5], 1), + rb_ary_entry(yyvsp[-5], 2), + rb_ary_entry(yyvsp[-4], 3), + // TODO see get_description for reading a description from comments + (RB_TEST(yyvsp[-6]) ? rb_ary_entry(yyvsp[-6], 3) : Qnil), + yyvsp[-3], + yyvsp[-1] + ); + } +#line 2834 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 161: /* directive_definition: description_opt DIRECTIVE DIR_SIGN name arguments_definitions_opt directive_repeatable_opt ON directive_locations */ +#line 722 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(DirectiveDefinition, 7, + rb_ary_entry(yyvsp[-6], 1), + rb_ary_entry(yyvsp[-6], 2), + rb_ary_entry(yyvsp[-4], 3), + (RB_TEST(yyvsp[-2]) ? Qtrue : Qfalse), // repeatable + // TODO see get_description for reading a description from comments + (RB_TEST(yyvsp[-7]) ? rb_ary_entry(yyvsp[-7], 3) : Qnil), + yyvsp[-3], + yyvsp[0] + ); + } +#line 2851 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 162: /* directive_repeatable_opt: %empty */ +#line 736 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = Qnil; } +#line 2857 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 163: /* directive_repeatable_opt: REPEATABLE */ +#line 737 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = Qtrue; } +#line 2863 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 164: /* directive_locations: name */ +#line 740 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { yyval = rb_ary_new_from_args(1, MAKE_AST_NODE(DirectiveLocation, 3, rb_ary_entry(yyvsp[0], 1), rb_ary_entry(yyvsp[0], 2), rb_ary_entry(yyvsp[0], 3))); } +#line 2869 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 165: /* directive_locations: directive_locations PIPE name */ +#line 741 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { rb_ary_push(yyval, MAKE_AST_NODE(DirectiveLocation, 3, rb_ary_entry(yyvsp[0], 1), rb_ary_entry(yyvsp[0], 2), rb_ary_entry(yyvsp[0], 3))); } +#line 2875 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 168: /* schema_extension: EXTEND SCHEMA directives_list_opt LCURLY operation_type_definition_list RCURLY */ +#line 749 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(SchemaExtension, 6, + rb_ary_entry(yyvsp[-5], 1), + rb_ary_entry(yyvsp[-5], 2), + // TODO use static strings: + rb_hash_aref(yyvsp[-1], rb_str_new_cstr("query")), + rb_hash_aref(yyvsp[-1], rb_str_new_cstr("mutation")), + rb_hash_aref(yyvsp[-1], rb_str_new_cstr("subscription")), + yyvsp[-3] + ); + } +#line 2891 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 169: /* schema_extension: EXTEND SCHEMA directives_list */ +#line 760 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(SchemaExtension, 6, + rb_ary_entry(yyvsp[-2], 1), + rb_ary_entry(yyvsp[-2], 2), + Qnil, + Qnil, + Qnil, + yyvsp[0] + ); + } +#line 2906 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 176: /* scalar_type_extension: EXTEND SCALAR name directives_list */ +#line 779 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(ScalarTypeExtension, 4, + rb_ary_entry(yyvsp[-3], 1), + rb_ary_entry(yyvsp[-3], 2), + rb_ary_entry(yyvsp[-1], 3), + yyvsp[0] + ); + } +#line 2919 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 177: /* object_type_extension: EXTEND TYPE_LITERAL name implements_opt directives_list_opt field_definition_list_opt */ +#line 789 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(ObjectTypeExtension, 6, + rb_ary_entry(yyvsp[-5], 1), + rb_ary_entry(yyvsp[-5], 2), + rb_ary_entry(yyvsp[-3], 3), + yyvsp[-2], // implements + yyvsp[-1], + yyvsp[0] + ); + } +#line 2934 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 178: /* interface_type_extension: EXTEND INTERFACE name implements_opt directives_list_opt field_definition_list_opt */ +#line 801 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(InterfaceTypeExtension, 6, + rb_ary_entry(yyvsp[-5], 1), + rb_ary_entry(yyvsp[-5], 2), + rb_ary_entry(yyvsp[-3], 3), + yyvsp[-2], + yyvsp[-1], + yyvsp[0] + ); + } +#line 2949 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 179: /* union_type_extension: EXTEND UNION name directives_list_opt EQUALS union_members */ +#line 813 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(UnionTypeExtension, 5, + rb_ary_entry(yyvsp[-5], 1), + rb_ary_entry(yyvsp[-5], 2), + rb_ary_entry(yyvsp[-3], 3), + yyvsp[0], // types + yyvsp[-2] + ); + } +#line 2963 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 180: /* union_type_extension: EXTEND UNION name directives_list */ +#line 822 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(UnionTypeExtension, 5, + rb_ary_entry(yyvsp[-3], 1), + rb_ary_entry(yyvsp[-3], 2), + rb_ary_entry(yyvsp[-1], 3), + GraphQL_Language_Nodes_NONE, // types + yyvsp[0] + ); + } +#line 2977 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 181: /* enum_type_extension: EXTEND ENUM name directives_list_opt LCURLY enum_value_definitions RCURLY */ +#line 833 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(EnumTypeExtension, 5, + rb_ary_entry(yyvsp[-6], 1), + rb_ary_entry(yyvsp[-6], 2), + rb_ary_entry(yyvsp[-4], 3), + yyvsp[-3], + yyvsp[-1] + ); + } +#line 2991 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 182: /* enum_type_extension: EXTEND ENUM name directives_list */ +#line 842 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(EnumTypeExtension, 5, + rb_ary_entry(yyvsp[-3], 1), + rb_ary_entry(yyvsp[-3], 2), + rb_ary_entry(yyvsp[-1], 3), + yyvsp[0], + GraphQL_Language_Nodes_NONE + ); + } +#line 3005 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 183: /* input_object_type_extension: EXTEND INPUT name directives_list_opt LCURLY input_value_definition_list RCURLY */ +#line 853 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(InputObjectTypeExtension, 5, + rb_ary_entry(yyvsp[-6], 1), + rb_ary_entry(yyvsp[-6], 2), + rb_ary_entry(yyvsp[-4], 3), + yyvsp[-3], + yyvsp[-1] + ); + } +#line 3019 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 184: /* input_object_type_extension: EXTEND INPUT name directives_list */ +#line 862 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + yyval = MAKE_AST_NODE(InputObjectTypeExtension, 5, + rb_ary_entry(yyvsp[-3], 1), + rb_ary_entry(yyvsp[-3], 2), + rb_ary_entry(yyvsp[-1], 3), + yyvsp[0], + GraphQL_Language_Nodes_NONE + ); + } +#line 3033 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + case 185: /* NamedTypeForCondition: name */ +#line 874 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + { + /* This action creates a TypeName AST node. + $1 (yyvsp[0] in C) refers to the semantic value of 'name'. + The MAKE_AST_NODE macro is used, consistent with other rules. + 'name' (represented by $1) provides an array: [filename, line, col, name_string] */ + yyval = MAKE_AST_NODE(TypeName, 3, + rb_ary_entry(yyvsp[0], 1), /* line from name token */ + rb_ary_entry(yyvsp[0], 2), /* col from name token */ + rb_ary_entry(yyvsp[0], 3) /* name string itself */ + ); + } +#line 3049 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + break; + + +#line 3053 "graphql-c_parser/ext/graphql_c_parser_ext/parser.c" + + default: break; + } + /* User semantic actions sometimes alter yychar, and that requires + that yytoken be updated with the new translation. We take the + approach of translating immediately before every use of yytoken. + One alternative is translating here after every semantic action, + but that translation would be missed if the semantic action invokes + YYABORT, YYACCEPT, or YYERROR immediately after altering yychar or + if it invokes YYBACKUP. In the case of YYABORT or YYACCEPT, an + incorrect destructor might then be invoked immediately. In the + case of YYERROR or YYBACKUP, subsequent parser actions might lead + to an incorrect destructor call or verbose syntax error message + before the lookahead is translated. */ + YY_SYMBOL_PRINT ("-> $$ =", YY_CAST (yysymbol_kind_t, yyr1[yyn]), &yyval, &yyloc); + + YYPOPSTACK (yylen); + yylen = 0; + + *++yyvsp = yyval; + + /* Now 'shift' the result of the reduction. Determine what state + that goes to, based on the state we popped back to and the rule + number reduced by. */ + { + const int yylhs = yyr1[yyn] - YYNTOKENS; + const int yyi = yypgoto[yylhs] + *yyssp; + yystate = (0 <= yyi && yyi <= YYLAST && yycheck[yyi] == *yyssp + ? yytable[yyi] + : yydefgoto[yylhs]); + } + + goto yynewstate; + + +/*--------------------------------------. +| yyerrlab -- here on detecting error. | +`--------------------------------------*/ +yyerrlab: + /* Make sure we have latest lookahead translation. See comments at + user semantic actions for why this is necessary. */ + yytoken = yychar == YYEMPTY ? YYSYMBOL_YYEMPTY : YYTRANSLATE (yychar); + /* If not already recovering from an error, report this error. */ + if (!yyerrstatus) + { + ++yynerrs; + { + yypcontext_t yyctx + = {yyssp, yytoken}; + char const *yymsgp = YY_("syntax error"); + int yysyntax_error_status; + yysyntax_error_status = yysyntax_error (&yymsg_alloc, &yymsg, &yyctx); + if (yysyntax_error_status == 0) + yymsgp = yymsg; + else if (yysyntax_error_status == -1) + { + if (yymsg != yymsgbuf) + YYSTACK_FREE (yymsg); + yymsg = YY_CAST (char *, + YYSTACK_ALLOC (YY_CAST (YYSIZE_T, yymsg_alloc))); + if (yymsg) + { + yysyntax_error_status + = yysyntax_error (&yymsg_alloc, &yymsg, &yyctx); + yymsgp = yymsg; + } + else + { + yymsg = yymsgbuf; + yymsg_alloc = sizeof yymsgbuf; + yysyntax_error_status = YYENOMEM; + } + } + yyerror (parser, filename, yymsgp); + if (yysyntax_error_status == YYENOMEM) + YYNOMEM; + } + } + + if (yyerrstatus == 3) + { + /* If just tried and failed to reuse lookahead token after an + error, discard it. */ + + if (yychar <= YYEOF) + { + /* Return failure if at end of input. */ + if (yychar == YYEOF) + YYABORT; + } + else + { + yydestruct ("Error: discarding", + yytoken, &yylval, parser, filename); + yychar = YYEMPTY; + } + } + + /* Else will try to reuse lookahead token after shifting the error + token. */ + goto yyerrlab1; + + +/*---------------------------------------------------. +| yyerrorlab -- error raised explicitly by YYERROR. | +`---------------------------------------------------*/ +yyerrorlab: + /* Pacify compilers when the user code never invokes YYERROR and the + label yyerrorlab therefore never appears in user code. */ + if (0) + YYERROR; + ++yynerrs; + + /* Do not reclaim the symbols of the rule whose action triggered + this YYERROR. */ + YYPOPSTACK (yylen); + yylen = 0; + YY_STACK_PRINT (yyss, yyssp); + yystate = *yyssp; + goto yyerrlab1; + + +/*-------------------------------------------------------------. +| yyerrlab1 -- common code for both syntax error and YYERROR. | +`-------------------------------------------------------------*/ +yyerrlab1: + yyerrstatus = 3; /* Each real token shifted decrements this. */ + + /* Pop stack until we find a state that shifts the error token. */ + for (;;) + { + yyn = yypact[yystate]; + if (!yypact_value_is_default (yyn)) + { + yyn += YYSYMBOL_YYerror; + if (0 <= yyn && yyn <= YYLAST && yycheck[yyn] == YYSYMBOL_YYerror) + { + yyn = yytable[yyn]; + if (0 < yyn) + break; + } + } + + /* Pop the current state because it cannot handle the error token. */ + if (yyssp == yyss) + YYABORT; + + + yydestruct ("Error: popping", + YY_ACCESSING_SYMBOL (yystate), yyvsp, parser, filename); + YYPOPSTACK (1); + yystate = *yyssp; + YY_STACK_PRINT (yyss, yyssp); + } + + YY_IGNORE_MAYBE_UNINITIALIZED_BEGIN + *++yyvsp = yylval; + YY_IGNORE_MAYBE_UNINITIALIZED_END + + + /* Shift the error token. */ + YY_SYMBOL_PRINT ("Shifting", YY_ACCESSING_SYMBOL (yyn), yyvsp, yylsp); + + yystate = yyn; + goto yynewstate; + + +/*-------------------------------------. +| yyacceptlab -- YYACCEPT comes here. | +`-------------------------------------*/ +yyacceptlab: + yyresult = 0; + goto yyreturnlab; + + +/*-----------------------------------. +| yyabortlab -- YYABORT comes here. | +`-----------------------------------*/ +yyabortlab: + yyresult = 1; + goto yyreturnlab; + + +/*-----------------------------------------------------------. +| yyexhaustedlab -- YYNOMEM (memory exhaustion) comes here. | +`-----------------------------------------------------------*/ +yyexhaustedlab: + yyerror (parser, filename, YY_("memory exhausted")); + yyresult = 2; + goto yyreturnlab; + + +/*----------------------------------------------------------. +| yyreturnlab -- parsing is finished, clean up and return. | +`----------------------------------------------------------*/ +yyreturnlab: + if (yychar != YYEMPTY) + { + /* Make sure we have latest lookahead translation. See comments at + user semantic actions for why this is necessary. */ + yytoken = YYTRANSLATE (yychar); + yydestruct ("Cleanup: discarding lookahead", + yytoken, &yylval, parser, filename); + } + /* Do not reclaim the symbols of the rule whose action triggered + this YYABORT or YYACCEPT. */ + YYPOPSTACK (yylen); + YY_STACK_PRINT (yyss, yyssp); + while (yyssp != yyss) + { + yydestruct ("Cleanup: popping", + YY_ACCESSING_SYMBOL (+*yyssp), yyvsp, parser, filename); + YYPOPSTACK (1); + } +#ifndef yyoverflow + if (yyss != yyssa) + YYSTACK_FREE (yyss); +#endif + if (yymsg != yymsgbuf) + YYSTACK_FREE (yymsg); + return yyresult; +} + +#line 887 "graphql-c_parser/ext/graphql_c_parser_ext/parser.y" + + +// Custom functions +int yylex (YYSTYPE *lvalp, VALUE parser, VALUE filename) { + VALUE next_token_idx_rb_int = rb_ivar_get(parser, rb_intern("@next_token_index")); + int next_token_idx = FIX2INT(next_token_idx_rb_int); + VALUE tokens = rb_ivar_get(parser, rb_intern("@tokens")); + VALUE next_token = rb_ary_entry(tokens, next_token_idx); + + if (!RB_TEST(next_token)) { + return YYEOF; + } + rb_ivar_set(parser, rb_intern("@next_token_index"), INT2FIX(next_token_idx + 1)); + VALUE token_type_rb_int = rb_ary_entry(next_token, 4); + int next_token_type = FIX2INT(token_type_rb_int); + if (next_token_type == 241) { // BAD_UNICODE_ESCAPE + VALUE mGraphQL = rb_const_get_at(rb_cObject, rb_intern("GraphQL")); + VALUE mCParser = rb_const_get_at(mGraphQL, rb_intern("CParser")); + VALUE bad_unicode_error = rb_funcall( + mCParser, rb_intern("prepare_bad_unicode_error"), 1, + parser + ); + rb_exc_raise(bad_unicode_error); + } + *lvalp = next_token; + return next_token_type; +} + +void yyerror(VALUE parser, VALUE filename, const char *msg) { + VALUE mGraphQL = rb_const_get_at(rb_cObject, rb_intern("GraphQL")); + VALUE mCParser = rb_const_get_at(mGraphQL, rb_intern("CParser")); + VALUE rb_message = rb_str_new_cstr(msg); + VALUE exception = rb_funcall( + mCParser, rb_intern("prepare_parse_error"), 2, + rb_message, + parser + ); + rb_exc_raise(exception); +} + +#define INITIALIZE_NODE_CLASS_VARIABLE(node_class_name) \ + rb_global_variable(&GraphQL_Language_Nodes_##node_class_name); \ + GraphQL_Language_Nodes_##node_class_name = rb_const_get_at(mGraphQLLanguageNodes, rb_intern(#node_class_name)); + +void initialize_node_class_variables() { + VALUE mGraphQL = rb_const_get_at(rb_cObject, rb_intern("GraphQL")); + VALUE mGraphQLLanguage = rb_const_get_at(mGraphQL, rb_intern("Language")); + VALUE mGraphQLLanguageNodes = rb_const_get_at(mGraphQLLanguage, rb_intern("Nodes")); + + rb_global_variable(&GraphQL_Language_Nodes_NONE); + GraphQL_Language_Nodes_NONE = rb_ary_new(); + rb_ary_freeze(GraphQL_Language_Nodes_NONE); + + rb_global_variable(&r_string_query); + r_string_query = rb_str_new_cstr("query"); + rb_str_freeze(r_string_query); + + INITIALIZE_NODE_CLASS_VARIABLE(Argument) + INITIALIZE_NODE_CLASS_VARIABLE(Directive) + INITIALIZE_NODE_CLASS_VARIABLE(Document) + INITIALIZE_NODE_CLASS_VARIABLE(Enum) + INITIALIZE_NODE_CLASS_VARIABLE(Field) + INITIALIZE_NODE_CLASS_VARIABLE(FragmentDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(FragmentSpread) + INITIALIZE_NODE_CLASS_VARIABLE(InlineFragment) + INITIALIZE_NODE_CLASS_VARIABLE(InputObject) + INITIALIZE_NODE_CLASS_VARIABLE(ListType) + INITIALIZE_NODE_CLASS_VARIABLE(NonNullType) + INITIALIZE_NODE_CLASS_VARIABLE(NullValue) + INITIALIZE_NODE_CLASS_VARIABLE(OperationDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(TypeName) + INITIALIZE_NODE_CLASS_VARIABLE(VariableDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(VariableIdentifier) + + INITIALIZE_NODE_CLASS_VARIABLE(ScalarTypeDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(ObjectTypeDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(InterfaceTypeDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(UnionTypeDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(EnumTypeDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(InputObjectTypeDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(EnumValueDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(DirectiveDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(DirectiveLocation) + INITIALIZE_NODE_CLASS_VARIABLE(FieldDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(InputValueDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(SchemaDefinition) + + INITIALIZE_NODE_CLASS_VARIABLE(ScalarTypeExtension) + INITIALIZE_NODE_CLASS_VARIABLE(ObjectTypeExtension) + INITIALIZE_NODE_CLASS_VARIABLE(InterfaceTypeExtension) + INITIALIZE_NODE_CLASS_VARIABLE(UnionTypeExtension) + INITIALIZE_NODE_CLASS_VARIABLE(EnumTypeExtension) + INITIALIZE_NODE_CLASS_VARIABLE(InputObjectTypeExtension) + INITIALIZE_NODE_CLASS_VARIABLE(SchemaExtension) +} diff --git a/graphql-c_parser/ext/graphql_c_parser_ext/parser.h b/graphql-c_parser/ext/graphql_c_parser_ext/parser.h new file mode 100644 index 00000000000..1e76a505b9a --- /dev/null +++ b/graphql-c_parser/ext/graphql_c_parser_ext/parser.h @@ -0,0 +1,5 @@ +#ifndef Graphql_parser_h +#define Graphql_parser_h +int yyparse(VALUE parser, VALUE filename); +void initialize_node_class_variables(); +#endif diff --git a/graphql-c_parser/ext/graphql_c_parser_ext/parser.y b/graphql-c_parser/ext/graphql_c_parser_ext/parser.y new file mode 100644 index 00000000000..83a87685c40 --- /dev/null +++ b/graphql-c_parser/ext/graphql_c_parser_ext/parser.y @@ -0,0 +1,981 @@ +%require "3.8" +%define api.pure full +%define parse.error detailed + +%{ +// C Declarations +#include +#define YYSTYPE VALUE +#define YYSTACK_USE_ALLOCA 1 + +int yylex(YYSTYPE *, VALUE, VALUE); +void yyerror(VALUE, VALUE, const char*); + +static VALUE GraphQL_Language_Nodes_NONE; +static VALUE r_string_query; + +#define MAKE_AST_NODE(node_class_name, nargs, ...) rb_funcall(GraphQL_Language_Nodes_##node_class_name, rb_intern("from_a"), nargs + 1, filename,__VA_ARGS__) + +#define SETUP_NODE_CLASS_VARIABLE(node_class_name) static VALUE GraphQL_Language_Nodes_##node_class_name; + +SETUP_NODE_CLASS_VARIABLE(Argument) +SETUP_NODE_CLASS_VARIABLE(Directive) +SETUP_NODE_CLASS_VARIABLE(Document) +SETUP_NODE_CLASS_VARIABLE(Enum) +SETUP_NODE_CLASS_VARIABLE(Field) +SETUP_NODE_CLASS_VARIABLE(FragmentDefinition) +SETUP_NODE_CLASS_VARIABLE(FragmentSpread) +SETUP_NODE_CLASS_VARIABLE(InlineFragment) +SETUP_NODE_CLASS_VARIABLE(InputObject) +SETUP_NODE_CLASS_VARIABLE(ListType) +SETUP_NODE_CLASS_VARIABLE(NonNullType) +SETUP_NODE_CLASS_VARIABLE(NullValue) +SETUP_NODE_CLASS_VARIABLE(OperationDefinition) +SETUP_NODE_CLASS_VARIABLE(TypeName) +SETUP_NODE_CLASS_VARIABLE(VariableDefinition) +SETUP_NODE_CLASS_VARIABLE(VariableIdentifier) + +SETUP_NODE_CLASS_VARIABLE(ScalarTypeDefinition) +SETUP_NODE_CLASS_VARIABLE(ObjectTypeDefinition) +SETUP_NODE_CLASS_VARIABLE(InterfaceTypeDefinition) +SETUP_NODE_CLASS_VARIABLE(UnionTypeDefinition) +SETUP_NODE_CLASS_VARIABLE(EnumTypeDefinition) +SETUP_NODE_CLASS_VARIABLE(InputObjectTypeDefinition) +SETUP_NODE_CLASS_VARIABLE(EnumValueDefinition) +SETUP_NODE_CLASS_VARIABLE(DirectiveDefinition) +SETUP_NODE_CLASS_VARIABLE(DirectiveLocation) +SETUP_NODE_CLASS_VARIABLE(FieldDefinition) +SETUP_NODE_CLASS_VARIABLE(InputValueDefinition) +SETUP_NODE_CLASS_VARIABLE(SchemaDefinition) + +SETUP_NODE_CLASS_VARIABLE(ScalarTypeExtension) +SETUP_NODE_CLASS_VARIABLE(ObjectTypeExtension) +SETUP_NODE_CLASS_VARIABLE(InterfaceTypeExtension) +SETUP_NODE_CLASS_VARIABLE(UnionTypeExtension) +SETUP_NODE_CLASS_VARIABLE(EnumTypeExtension) +SETUP_NODE_CLASS_VARIABLE(InputObjectTypeExtension) +SETUP_NODE_CLASS_VARIABLE(SchemaExtension) +%} + +%param {VALUE parser} +%param {VALUE filename} + +// YACC Declarations +%token AMP 200 +%token BANG 201 +%token COLON 202 +%token DIRECTIVE 203 +%token DIR_SIGN 204 +%token ENUM 205 +%token ELLIPSIS 206 +%token EQUALS 207 +%token EXTEND 208 +%token FALSE_LITERAL 209 +%token FLOAT 210 +%token FRAGMENT 211 +%token IDENTIFIER 212 +%token INPUT 213 +%token IMPLEMENTS 214 +%token INT 215 +%token INTERFACE 216 +%token LBRACKET 217 +%token LCURLY 218 +%token LPAREN 219 +%token MUTATION 220 +%token NULL_LITERAL 221 +%token ON 222 +%token PIPE 223 +%token QUERY 224 +%token RBRACKET 225 +%token RCURLY 226 +%token REPEATABLE 227 +%token RPAREN 228 +%token SCALAR 229 +%token SCHEMA 230 +%token STRING 231 +%token SUBSCRIPTION 232 +%token TRUE_LITERAL 233 +%token TYPE_LITERAL 234 +%token UNION 235 +%token VAR_SIGN 236 + +%type NamedTypeForCondition + +%% + + // YACC Rules + start: document { rb_ivar_set(parser, rb_intern("@result"), $1); } + + document: definitions_list { + VALUE position_source = rb_ary_entry($1, 0); + VALUE line, col; + if (RB_TEST(position_source)) { + line = rb_funcall(position_source, rb_intern("line"), 0); + col = rb_funcall(position_source, rb_intern("col"), 0); + } else { + line = INT2FIX(1); + col = INT2FIX(1); + } + $$ = MAKE_AST_NODE(Document, 3, line, col, $1); + } + + definitions_list: + definition { $$ = rb_ary_new_from_args(1, $1); } + | definitions_list definition { rb_ary_push($$, $2); } + + definition: + executable_definition + | type_system_definition + | type_system_extension + + executable_definition: + operation_definition + | fragment_definition + + operation_definition: + operation_type operation_name_opt variable_definitions_opt directives_list_opt selection_set { + $$ = MAKE_AST_NODE(OperationDefinition, 7, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($1, 3), + (RB_TEST($2) ? rb_ary_entry($2, 3) : Qnil), + $3, + $4, + $5 + ); + } + | LCURLY selection_list RCURLY { + $$ = MAKE_AST_NODE(OperationDefinition, 7, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + r_string_query, + Qnil, + GraphQL_Language_Nodes_NONE, + GraphQL_Language_Nodes_NONE, + $2 + ); + } + | LCURLY RCURLY { + $$ = MAKE_AST_NODE(OperationDefinition, 7, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + r_string_query, + Qnil, + GraphQL_Language_Nodes_NONE, + GraphQL_Language_Nodes_NONE, + GraphQL_Language_Nodes_NONE + ); + } + + operation_type: + QUERY + | MUTATION + | SUBSCRIPTION + + operation_name_opt: + /* none */ { $$ = Qnil; } + | name + + variable_definitions_opt: + /* none */ { $$ = GraphQL_Language_Nodes_NONE; } + | LPAREN variable_definitions_list RPAREN { $$ = $2; } + + variable_definitions_list: + variable_definition { $$ = rb_ary_new_from_args(1, $1); } + | variable_definitions_list variable_definition { rb_ary_push($$, $2); } + + variable_definition: + VAR_SIGN name COLON type default_value_opt directives_list_opt { + $$ = MAKE_AST_NODE(VariableDefinition, 6, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($2, 3), + $4, + $5, + $6 + ); + } + + default_value_opt: + /* none */ { $$ = Qnil; } + | EQUALS literal_value { $$ = $2; } + + selection_list: + selection { $$ = rb_ary_new_from_args(1, $1); } + | selection_list selection { rb_ary_push($$, $2); } + + selection: + field + | fragment_spread + | inline_fragment + + selection_set: + LCURLY selection_list RCURLY { $$ = $2; } + + selection_set_opt: + /* none */ { $$ = rb_ary_new(); } + | selection_set + + field: + name COLON name arguments_opt directives_list_opt selection_set_opt { + $$ = MAKE_AST_NODE(Field, 7, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($1, 3), // alias + rb_ary_entry($3, 3), // name + $4, // args + $5, // directives + $6 // subselections + ); + } + | name arguments_opt directives_list_opt selection_set_opt { + $$ = MAKE_AST_NODE(Field, 7, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + Qnil, // alias + rb_ary_entry($1, 3), // name + $2, // args + $3, // directives + $4 // subselections + ); + } + + arguments_opt: + /* none */ { $$ = GraphQL_Language_Nodes_NONE; } + | LPAREN arguments_list RPAREN { $$ = $2; } + + arguments_list: + argument { $$ = rb_ary_new_from_args(1, $1); } + | arguments_list argument { rb_ary_push($$, $2); } + + argument: + name COLON input_value { + $$ = MAKE_AST_NODE(Argument, 4, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($1, 3), + $3 + ); + } + + literal_value: + FLOAT { $$ = rb_funcall(rb_ary_entry($1, 3), rb_intern("to_f"), 0); } + | INT { $$ = rb_funcall(rb_ary_entry($1, 3), rb_intern("to_i"), 0); } + | STRING { $$ = rb_ary_entry($1, 3); } + | TRUE_LITERAL { $$ = Qtrue; } + | FALSE_LITERAL { $$ = Qfalse; } + | null_value + | enum_value + | list_value + | object_literal_value + + input_value: + literal_value + | variable + | object_value + + null_value: NULL_LITERAL { + $$ = MAKE_AST_NODE(NullValue, 3, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($1, 3) + ); + } + + variable: VAR_SIGN name { + $$ = MAKE_AST_NODE(VariableIdentifier, 3, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($2, 3) + ); + } + + list_value: + LBRACKET RBRACKET { $$ = GraphQL_Language_Nodes_NONE; } + | LBRACKET list_value_list RBRACKET { $$ = $2; } + + list_value_list: + input_value { $$ = rb_ary_new_from_args(1, $1); } + | list_value_list input_value { rb_ary_push($$, $2); } + + enum_name: /* any identifier, but not "true", "false" or "null" */ + IDENTIFIER + | ON + | operation_type + | schema_keyword + + enum_value: enum_name { + $$ = MAKE_AST_NODE(Enum, 3, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($1, 3) + ); + } + + object_value: + LCURLY object_value_list_opt RCURLY { + $$ = MAKE_AST_NODE(InputObject, 3, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + $2 + ); + } + + object_value_list_opt: + /* nothing */ { $$ = GraphQL_Language_Nodes_NONE; } + | object_value_list + + object_value_list: + object_value_field { $$ = rb_ary_new_from_args(1, $1); } + | object_value_list object_value_field { rb_ary_push($$, $2); } + + object_value_field: + name COLON input_value { + $$ = MAKE_AST_NODE(Argument, 4, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($1, 3), + $3 + ); + } + + /* like the previous, but with literals only: */ + object_literal_value: + LCURLY object_literal_value_list_opt RCURLY { + $$ = MAKE_AST_NODE(InputObject, 3, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + $2 + ); + } + + object_literal_value_list_opt: + /* nothing */ { $$ = GraphQL_Language_Nodes_NONE; } + | object_literal_value_list + + object_literal_value_list: + object_literal_value_field { $$ = rb_ary_new_from_args(1, $1); } + | object_literal_value_list object_literal_value_field { rb_ary_push($$, $2); } + + object_literal_value_field: + name COLON literal_value { + $$ = MAKE_AST_NODE(Argument, 4, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($1, 3), + $3 + ); + } + + + directives_list_opt: + /* none */ { $$ = GraphQL_Language_Nodes_NONE; } + | directives_list + + directives_list: + directive { $$ = rb_ary_new_from_args(1, $1); } + | directives_list directive { rb_ary_push($$, $2); } + + directive: DIR_SIGN name arguments_opt { + $$ = MAKE_AST_NODE(Directive, 4, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($2, 3), + $3 + ); + } + + name: + name_without_on + | ON + + schema_keyword: + SCHEMA + | SCALAR + | TYPE_LITERAL + | IMPLEMENTS + | INTERFACE + | UNION + | ENUM + | INPUT + | DIRECTIVE + | EXTEND + | FRAGMENT + | REPEATABLE + + name_without_on: + IDENTIFIER + | TRUE_LITERAL + | FALSE_LITERAL + | NULL_LITERAL + | operation_type + | schema_keyword + + + fragment_spread: + ELLIPSIS name_without_on directives_list_opt { + $$ = MAKE_AST_NODE(FragmentSpread, 4, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($2, 3), + $3 + ); + } + + inline_fragment: + ELLIPSIS ON NamedTypeForCondition directives_list_opt selection_set { + $$ = MAKE_AST_NODE(InlineFragment, 5, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + $3, + $4, + $5 + ); + } + | ELLIPSIS directives_list_opt selection_set { + $$ = MAKE_AST_NODE(InlineFragment, 5, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + Qnil, + $2, + $3 + ); + } + + fragment_definition: + FRAGMENT fragment_name_opt ON NamedTypeForCondition directives_list_opt selection_set { + $$ = MAKE_AST_NODE(FragmentDefinition, 6, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + $2, + $4, + $5, + $6 + ); + } + + fragment_name_opt: + /* none */ { $$ = Qnil; } + | name_without_on { $$ = rb_ary_entry($1, 3); } + + type: + nullable_type + | nullable_type BANG { $$ = MAKE_AST_NODE(NonNullType, 3, rb_funcall($1, rb_intern("line"), 0), rb_funcall($1, rb_intern("col"), 0), $1); } + + nullable_type: + name { + $$ = MAKE_AST_NODE(TypeName, 3, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($1, 3) + ); + } + | LBRACKET type RBRACKET { + $$ = MAKE_AST_NODE(ListType, 3, + rb_funcall($2, rb_intern("line"), 0), + rb_funcall($2, rb_intern("col"), 0), + $2 + ); + } + +type_system_definition: + schema_definition + | type_definition + | directive_definition + + schema_definition: + SCHEMA directives_list_opt operation_type_definition_list_opt { + $$ = MAKE_AST_NODE(SchemaDefinition, 6, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + // TODO use static strings: + rb_hash_aref($3, rb_str_new_cstr("query")), + rb_hash_aref($3, rb_str_new_cstr("mutation")), + rb_hash_aref($3, rb_str_new_cstr("subscription")), + $2 + ); + } + + operation_type_definition_list_opt: + /* none */ { $$ = rb_hash_new(); } + | LCURLY operation_type_definition_list RCURLY { $$ = $2; } + + operation_type_definition_list: + operation_type_definition { + $$ = rb_hash_new(); + rb_hash_aset($$, rb_ary_entry($1, 0), rb_ary_entry($1, 1)); + } + | operation_type_definition_list operation_type_definition { + rb_hash_aset($$, rb_ary_entry($2, 0), rb_ary_entry($2, 1)); + } + + operation_type_definition: + operation_type COLON name { + $$ = rb_ary_new_from_args(2, rb_ary_entry($1, 3), rb_ary_entry($3, 3)); + } + + type_definition: + scalar_type_definition + | object_type_definition + | interface_type_definition + | union_type_definition + | enum_type_definition + | input_object_type_definition + + description: STRING + + description_opt: + /* none */ { $$ = Qnil; } + | description + + scalar_type_definition: + description_opt SCALAR name directives_list_opt { + $$ = MAKE_AST_NODE(ScalarTypeDefinition, 5, + rb_ary_entry($2, 1), + rb_ary_entry($2, 2), + rb_ary_entry($3, 3), + // TODO see get_description for reading a description from comments + (RB_TEST($1) ? rb_ary_entry($1, 3) : Qnil), + $4 + ); + } + + object_type_definition: + description_opt TYPE_LITERAL name implements_opt directives_list_opt field_definition_list_opt { + $$ = MAKE_AST_NODE(ObjectTypeDefinition, 7, + rb_ary_entry($2, 1), + rb_ary_entry($2, 2), + rb_ary_entry($3, 3), + $4, // implements + // TODO see get_description for reading a description from comments + (RB_TEST($1) ? rb_ary_entry($1, 3) : Qnil), + $5, + $6 + ); + } + + implements_opt: + /* none */ { $$ = GraphQL_Language_Nodes_NONE; } + | IMPLEMENTS AMP interfaces_list { $$ = $3; } + | IMPLEMENTS interfaces_list { $$ = $2; } + | IMPLEMENTS legacy_interfaces_list { $$ = $2; } + + interfaces_list: + name { + VALUE new_name = MAKE_AST_NODE(TypeName, 3, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($1, 3) + ); + $$ = rb_ary_new_from_args(1, new_name); + } + | interfaces_list AMP name { + VALUE new_name = MAKE_AST_NODE(TypeName, 3, rb_ary_entry($3, 1), rb_ary_entry($3, 2), rb_ary_entry($3, 3)); + rb_ary_push($$, new_name); + } + + legacy_interfaces_list: + name { + VALUE new_name = MAKE_AST_NODE(TypeName, 3, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($1, 3) + ); + $$ = rb_ary_new_from_args(1, new_name); + } + | legacy_interfaces_list name { + rb_ary_push($$, MAKE_AST_NODE(TypeName, 3, rb_ary_entry($2, 1), rb_ary_entry($2, 2), rb_ary_entry($2, 3))); + } + + input_value_definition: + description_opt name COLON type default_value_opt directives_list_opt { + $$ = MAKE_AST_NODE(InputValueDefinition, 7, + rb_ary_entry($2, 1), + rb_ary_entry($2, 2), + rb_ary_entry($2, 3), + $4, + $5, + // TODO see get_description for reading a description from comments + (RB_TEST($1) ? rb_ary_entry($1, 3) : Qnil), + $6 + ); + } + + input_value_definition_list: + input_value_definition { $$ = rb_ary_new_from_args(1, $1); } + | input_value_definition_list input_value_definition { rb_ary_push($$, $2); } + + arguments_definitions_opt: + /* none */ { $$ = GraphQL_Language_Nodes_NONE; } + | LPAREN input_value_definition_list RPAREN { $$ = $2; } + + field_definition: + description_opt name arguments_definitions_opt COLON type directives_list_opt { + $$ = MAKE_AST_NODE(FieldDefinition, 7, + rb_ary_entry($2, 1), + rb_ary_entry($2, 2), + rb_ary_entry($2, 3), + $5, + // TODO see get_description for reading a description from comments + (RB_TEST($1) ? rb_ary_entry($1, 3) : Qnil), + $3, + $6 + ); + } + + field_definition_list_opt: + /* none */ { $$ = GraphQL_Language_Nodes_NONE; } + | LCURLY field_definition_list RCURLY { $$ = $2; } + + field_definition_list: + /* none - this is not actually valid but graphql-ruby used to print this */ { $$ = GraphQL_Language_Nodes_NONE; } + | field_definition { $$ = rb_ary_new_from_args(1, $1); } + | field_definition_list field_definition { rb_ary_push($$, $2); } + + interface_type_definition: + description_opt INTERFACE name implements_opt directives_list_opt field_definition_list_opt { + $$ = MAKE_AST_NODE(InterfaceTypeDefinition, 7, + rb_ary_entry($2, 1), + rb_ary_entry($2, 2), + rb_ary_entry($3, 3), + // TODO see get_description for reading a description from comments + (RB_TEST($1) ? rb_ary_entry($1, 3) : Qnil), + $4, + $5, + $6 + ); + } + + pipe_opt: + /* none */ { $$ = GraphQL_Language_Nodes_NONE; } + | PIPE { $$ = GraphQL_Language_Nodes_NONE; } + + union_members: + pipe_opt name { + VALUE new_member = MAKE_AST_NODE(TypeName, 3, + rb_ary_entry($2, 1), + rb_ary_entry($2, 2), + rb_ary_entry($2, 3) + ); + $$ = rb_ary_new_from_args(1, new_member); + } + | union_members PIPE name { + rb_ary_push($$, MAKE_AST_NODE(TypeName, 3, rb_ary_entry($3, 1), rb_ary_entry($3, 2), rb_ary_entry($3, 3))); + } + + union_type_definition: + description_opt UNION name directives_list_opt EQUALS union_members { + $$ = MAKE_AST_NODE(UnionTypeDefinition, 6, + rb_ary_entry($2, 1), + rb_ary_entry($2, 2), + rb_ary_entry($3, 3), + $6, // types + // TODO see get_description for reading a description from comments + (RB_TEST($1) ? rb_ary_entry($1, 3) : Qnil), + $4 + ); + } + + enum_type_definition: + description_opt ENUM name directives_list_opt LCURLY enum_value_definitions RCURLY { + $$ = MAKE_AST_NODE(EnumTypeDefinition, 6, + rb_ary_entry($2, 1), + rb_ary_entry($2, 2), + rb_ary_entry($3, 3), + // TODO see get_description for reading a description from comments + (RB_TEST($1) ? rb_ary_entry($1, 3) : Qnil), + $4, + $6 + ); + } + + enum_value_definition: + description_opt enum_name directives_list_opt { + $$ = MAKE_AST_NODE(EnumValueDefinition, 5, + rb_ary_entry($2, 1), + rb_ary_entry($2, 2), + rb_ary_entry($2, 3), + // TODO see get_description for reading a description from comments + (RB_TEST($1) ? rb_ary_entry($1, 3) : Qnil), + $3 + ); + } + + enum_value_definitions: + enum_value_definition { $$ = rb_ary_new_from_args(1, $1); } + | enum_value_definitions enum_value_definition { rb_ary_push($$, $2); } + + input_object_type_definition: + description_opt INPUT name directives_list_opt LCURLY input_value_definition_list RCURLY { + $$ = MAKE_AST_NODE(InputObjectTypeDefinition, 6, + rb_ary_entry($2, 1), + rb_ary_entry($2, 2), + rb_ary_entry($3, 3), + // TODO see get_description for reading a description from comments + (RB_TEST($1) ? rb_ary_entry($1, 3) : Qnil), + $4, + $6 + ); + } + + directive_definition: + description_opt DIRECTIVE DIR_SIGN name arguments_definitions_opt directive_repeatable_opt ON directive_locations { + $$ = MAKE_AST_NODE(DirectiveDefinition, 7, + rb_ary_entry($2, 1), + rb_ary_entry($2, 2), + rb_ary_entry($4, 3), + (RB_TEST($6) ? Qtrue : Qfalse), // repeatable + // TODO see get_description for reading a description from comments + (RB_TEST($1) ? rb_ary_entry($1, 3) : Qnil), + $5, + $8 + ); + } + + directive_repeatable_opt: + /* nothing */ { $$ = Qnil; } + | REPEATABLE { $$ = Qtrue; } + + directive_locations: + name { $$ = rb_ary_new_from_args(1, MAKE_AST_NODE(DirectiveLocation, 3, rb_ary_entry($1, 1), rb_ary_entry($1, 2), rb_ary_entry($1, 3))); } + | directive_locations PIPE name { rb_ary_push($$, MAKE_AST_NODE(DirectiveLocation, 3, rb_ary_entry($3, 1), rb_ary_entry($3, 2), rb_ary_entry($3, 3))); } + + + type_system_extension: + schema_extension + | type_extension + + schema_extension: + EXTEND SCHEMA directives_list_opt LCURLY operation_type_definition_list RCURLY { + $$ = MAKE_AST_NODE(SchemaExtension, 6, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + // TODO use static strings: + rb_hash_aref($5, rb_str_new_cstr("query")), + rb_hash_aref($5, rb_str_new_cstr("mutation")), + rb_hash_aref($5, rb_str_new_cstr("subscription")), + $3 + ); + } + | EXTEND SCHEMA directives_list { + $$ = MAKE_AST_NODE(SchemaExtension, 6, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + Qnil, + Qnil, + Qnil, + $3 + ); + } + + type_extension: + scalar_type_extension + | object_type_extension + | interface_type_extension + | union_type_extension + | enum_type_extension + | input_object_type_extension + + scalar_type_extension: EXTEND SCALAR name directives_list { + $$ = MAKE_AST_NODE(ScalarTypeExtension, 4, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($3, 3), + $4 + ); + } + + object_type_extension: + EXTEND TYPE_LITERAL name implements_opt directives_list_opt field_definition_list_opt { + $$ = MAKE_AST_NODE(ObjectTypeExtension, 6, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($3, 3), + $4, // implements + $5, + $6 + ); + } + + interface_type_extension: + EXTEND INTERFACE name implements_opt directives_list_opt field_definition_list_opt { + $$ = MAKE_AST_NODE(InterfaceTypeExtension, 6, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($3, 3), + $4, + $5, + $6 + ); + } + + union_type_extension: + EXTEND UNION name directives_list_opt EQUALS union_members { + $$ = MAKE_AST_NODE(UnionTypeExtension, 5, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($3, 3), + $6, // types + $4 + ); + } + | EXTEND UNION name directives_list { + $$ = MAKE_AST_NODE(UnionTypeExtension, 5, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($3, 3), + GraphQL_Language_Nodes_NONE, // types + $4 + ); + } + + enum_type_extension: + EXTEND ENUM name directives_list_opt LCURLY enum_value_definitions RCURLY { + $$ = MAKE_AST_NODE(EnumTypeExtension, 5, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($3, 3), + $4, + $6 + ); + } + | EXTEND ENUM name directives_list { + $$ = MAKE_AST_NODE(EnumTypeExtension, 5, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($3, 3), + $4, + GraphQL_Language_Nodes_NONE + ); + } + + input_object_type_extension: + EXTEND INPUT name directives_list_opt LCURLY input_value_definition_list RCURLY { + $$ = MAKE_AST_NODE(InputObjectTypeExtension, 5, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($3, 3), + $4, + $6 + ); + } + | EXTEND INPUT name directives_list { + $$ = MAKE_AST_NODE(InputObjectTypeExtension, 5, + rb_ary_entry($1, 1), + rb_ary_entry($1, 2), + rb_ary_entry($3, 3), + $4, + GraphQL_Language_Nodes_NONE + ); + } + + NamedTypeForCondition: + name + { + /* This action creates a TypeName AST node. + $1 (yyvsp[0] in C) refers to the semantic value of 'name'. + The MAKE_AST_NODE macro is used, consistent with other rules. + 'name' (represented by $1) provides an array: [filename, line, col, name_string] */ + $$ = MAKE_AST_NODE(TypeName, 3, + rb_ary_entry($1, 1), /* line from name token */ + rb_ary_entry($1, 2), /* col from name token */ + rb_ary_entry($1, 3) /* name string itself */ + ); + } + ; + +%% + +// Custom functions +int yylex (YYSTYPE *lvalp, VALUE parser, VALUE filename) { + VALUE next_token_idx_rb_int = rb_ivar_get(parser, rb_intern("@next_token_index")); + int next_token_idx = FIX2INT(next_token_idx_rb_int); + VALUE tokens = rb_ivar_get(parser, rb_intern("@tokens")); + VALUE next_token = rb_ary_entry(tokens, next_token_idx); + + if (!RB_TEST(next_token)) { + return YYEOF; + } + rb_ivar_set(parser, rb_intern("@next_token_index"), INT2FIX(next_token_idx + 1)); + VALUE token_type_rb_int = rb_ary_entry(next_token, 4); + int next_token_type = FIX2INT(token_type_rb_int); + if (next_token_type == 241) { // BAD_UNICODE_ESCAPE + VALUE mGraphQL = rb_const_get_at(rb_cObject, rb_intern("GraphQL")); + VALUE mCParser = rb_const_get_at(mGraphQL, rb_intern("CParser")); + VALUE bad_unicode_error = rb_funcall( + mCParser, rb_intern("prepare_bad_unicode_error"), 1, + parser + ); + rb_exc_raise(bad_unicode_error); + } + *lvalp = next_token; + return next_token_type; +} + +void yyerror(VALUE parser, VALUE filename, const char *msg) { + VALUE mGraphQL = rb_const_get_at(rb_cObject, rb_intern("GraphQL")); + VALUE mCParser = rb_const_get_at(mGraphQL, rb_intern("CParser")); + VALUE rb_message = rb_str_new_cstr(msg); + VALUE exception = rb_funcall( + mCParser, rb_intern("prepare_parse_error"), 2, + rb_message, + parser + ); + rb_exc_raise(exception); +} + +#define INITIALIZE_NODE_CLASS_VARIABLE(node_class_name) \ + rb_global_variable(&GraphQL_Language_Nodes_##node_class_name); \ + GraphQL_Language_Nodes_##node_class_name = rb_const_get_at(mGraphQLLanguageNodes, rb_intern(#node_class_name)); + +void initialize_node_class_variables() { + VALUE mGraphQL = rb_const_get_at(rb_cObject, rb_intern("GraphQL")); + VALUE mGraphQLLanguage = rb_const_get_at(mGraphQL, rb_intern("Language")); + VALUE mGraphQLLanguageNodes = rb_const_get_at(mGraphQLLanguage, rb_intern("Nodes")); + + rb_global_variable(&GraphQL_Language_Nodes_NONE); + GraphQL_Language_Nodes_NONE = rb_ary_new(); + rb_ary_freeze(GraphQL_Language_Nodes_NONE); + + rb_global_variable(&r_string_query); + r_string_query = rb_str_new_cstr("query"); + rb_str_freeze(r_string_query); + + INITIALIZE_NODE_CLASS_VARIABLE(Argument) + INITIALIZE_NODE_CLASS_VARIABLE(Directive) + INITIALIZE_NODE_CLASS_VARIABLE(Document) + INITIALIZE_NODE_CLASS_VARIABLE(Enum) + INITIALIZE_NODE_CLASS_VARIABLE(Field) + INITIALIZE_NODE_CLASS_VARIABLE(FragmentDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(FragmentSpread) + INITIALIZE_NODE_CLASS_VARIABLE(InlineFragment) + INITIALIZE_NODE_CLASS_VARIABLE(InputObject) + INITIALIZE_NODE_CLASS_VARIABLE(ListType) + INITIALIZE_NODE_CLASS_VARIABLE(NonNullType) + INITIALIZE_NODE_CLASS_VARIABLE(NullValue) + INITIALIZE_NODE_CLASS_VARIABLE(OperationDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(TypeName) + INITIALIZE_NODE_CLASS_VARIABLE(VariableDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(VariableIdentifier) + + INITIALIZE_NODE_CLASS_VARIABLE(ScalarTypeDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(ObjectTypeDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(InterfaceTypeDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(UnionTypeDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(EnumTypeDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(InputObjectTypeDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(EnumValueDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(DirectiveDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(DirectiveLocation) + INITIALIZE_NODE_CLASS_VARIABLE(FieldDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(InputValueDefinition) + INITIALIZE_NODE_CLASS_VARIABLE(SchemaDefinition) + + INITIALIZE_NODE_CLASS_VARIABLE(ScalarTypeExtension) + INITIALIZE_NODE_CLASS_VARIABLE(ObjectTypeExtension) + INITIALIZE_NODE_CLASS_VARIABLE(InterfaceTypeExtension) + INITIALIZE_NODE_CLASS_VARIABLE(UnionTypeExtension) + INITIALIZE_NODE_CLASS_VARIABLE(EnumTypeExtension) + INITIALIZE_NODE_CLASS_VARIABLE(InputObjectTypeExtension) + INITIALIZE_NODE_CLASS_VARIABLE(SchemaExtension) +} diff --git a/graphql-c_parser/graphql-c_parser.gemspec b/graphql-c_parser/graphql-c_parser.gemspec new file mode 100644 index 00000000000..bd9667e9089 --- /dev/null +++ b/graphql-c_parser/graphql-c_parser.gemspec @@ -0,0 +1,27 @@ +# frozen_string_literal: true +$LOAD_PATH.push File.expand_path("../lib", __FILE__) +require "graphql/c_parser/version" +require "date" + +Gem::Specification.new do |s| + s.name = "graphql-c_parser" + s.version = GraphQL::CParser::VERSION + s.date = Date.today.to_s + s.summary = "A parser for GraphQL, implemented as a C extension" + s.homepage = "https://github.com/rmosolgo/graphql-ruby" + s.authors = ["Robert Mosolgo"] + s.email = ["rdmosolgo@gmail.com"] + s.license = "MIT" + s.required_ruby_version = ">= 3.0.0" + s.metadata = { + "homepage_uri" => "https://graphql-ruby.org", + "changelog_uri" => "https://github.com/rmosolgo/graphql-ruby/blob/master/graphql-c_parser/CHANGELOG.md", + "source_code_uri" => "https://github.com/rmosolgo/graphql-ruby", + "bug_tracker_uri" => "https://github.com/rmosolgo/graphql-ruby/issues", + "mailing_list_uri" => "https://buttondown.email/graphql-ruby", + } + + s.files = Dir["{lib,ext}/**/*.{rb,h,c}"] + s.extensions << "ext/graphql_c_parser_ext/extconf.rb" + s.add_dependency "graphql", ">= 2.2.10" +end diff --git a/graphql-c_parser/lib/graphql-c_parser.rb b/graphql-c_parser/lib/graphql-c_parser.rb new file mode 100644 index 00000000000..791c0bb19a4 --- /dev/null +++ b/graphql-c_parser/lib/graphql-c_parser.rb @@ -0,0 +1,2 @@ +# frozen_string_literal: true +require "graphql/c_parser" diff --git a/graphql-c_parser/lib/graphql/c_parser.rb b/graphql-c_parser/lib/graphql/c_parser.rb new file mode 100644 index 00000000000..3deec946f18 --- /dev/null +++ b/graphql-c_parser/lib/graphql/c_parser.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require "graphql" +require "graphql/c_parser/version" +require "graphql/graphql_c_parser_ext" + +module GraphQL + module CParser + def self.parse(query_str, filename: nil, trace: GraphQL::Tracing::NullTrace, max_tokens: nil) + Parser.parse(query_str, filename: filename, trace: trace, max_tokens: max_tokens) + end + + def self.parse_file(filename) + contents = File.read(filename) + parse(contents, filename: filename) + end + + def self.tokenize_with_c(str) + reject_numbers_followed_by_names = GraphQL.respond_to?(:reject_numbers_followed_by_names) && GraphQL.reject_numbers_followed_by_names + tokenize_with_c_internal(str, false, reject_numbers_followed_by_names) + end + + def self.prepare_parse_error(message, parser) + query_str = parser.query_string + filename = parser.filename + if message.start_with?("memory exhausted") + return GraphQL::ParseError.new("This query is too large to execute.", nil, nil, query_str, filename: filename) + end + token = parser.tokens[parser.next_token_index - 1] + if token + # There might not be a token if it's a comments-only string + line = token[1] + col = token[2] + if line && col + location_str = " at [#{line}, #{col}]" + if !message.include?(location_str) + message += location_str + end + end + + if !message.include?("end of file") + message.sub!(/, unexpected ([a-zA-Z ]+)(,| at)/, ", unexpected \\1 (#{token[3].inspect})\\2") + end + end + + GraphQL::ParseError.new(message, line, col, query_str, filename: filename) + end + + def self.prepare_number_name_parse_error(line, col, query_str, number_part, name_part) + raise GraphQL::ParseError.new("Name after number is not allowed (in `#{number_part}#{name_part}`)", line, col, query_str) + end + + def self.prepare_bad_unicode_error(parser) + token = parser.tokens[parser.next_token_index - 1] + line = token[1] + col = token[2] + GraphQL::ParseError.new( + "Parse error on bad Unicode escape sequence: #{token[3].inspect} (error) at [#{line}, #{col}]", + line, + col, + parser.query_string, + filename: parser.filename + ) + end + + module Lexer + def self.tokenize(graphql_string, intern_identifiers: false, max_tokens: nil) + if !(graphql_string.encoding == Encoding::UTF_8 || graphql_string.ascii_only?) + graphql_string = graphql_string.dup.force_encoding(Encoding::UTF_8) + end + if !graphql_string.valid_encoding? + return [ + [ + :BAD_UNICODE_ESCAPE, + 1, + 1, + graphql_string, + 241 # BAD_UNICODE_ESCAPE in lexer.rl + ] + ] + end + reject_numbers_followed_by_names = GraphQL.respond_to?(:reject_numbers_followed_by_names) && GraphQL.reject_numbers_followed_by_names + # -1 indicates that there is no limit + lexer_max_tokens = max_tokens.nil? ? -1 : max_tokens + tokenize_with_c_internal(graphql_string, intern_identifiers, reject_numbers_followed_by_names, lexer_max_tokens) + end + end + + class Parser + def self.parse(query_str, filename: nil, trace: GraphQL::Tracing::NullTrace, max_tokens: nil) + self.new(query_str, filename, trace, max_tokens).result + end + + def self.parse_file(filename) + contents = File.read(filename) + parse(contents, filename: filename) + end + + def initialize(query_string, filename, trace, max_tokens) + if query_string.nil? + raise GraphQL::ParseError.new("No query string was present", nil, nil, query_string) + end + @query_string = query_string + @filename = filename + @tokens = nil + @next_token_index = 0 + @result = nil + @trace = trace + @intern_identifiers = false + @max_tokens = max_tokens + end + + def result + if @result.nil? + @tokens = @trace.lex(query_string: @query_string) do + GraphQL::CParser::Lexer.tokenize(@query_string, intern_identifiers: @intern_identifiers, max_tokens: @max_tokens) + end + @trace.parse(query_string: @query_string) do + c_parse + @result + end + end + @result + end + + def tokens_count + result + @tokens.length + end + + attr_reader :tokens, :next_token_index, :query_string, :filename + end + + class SchemaParser < Parser + def initialize(*args) + super + @intern_identifiers = true + end + end + end + + def self.scan_with_c(graphql_string) + GraphQL::CParser::Lexer.tokenize(graphql_string) + end + + def self.parse_with_c(string, filename: nil, trace: GraphQL::Tracing::NullTrace) + if string.nil? + raise GraphQL::ParseError.new("No query string was present", nil, nil, string) + end + document = GraphQL::CParser.parse(string, filename: filename, trace: trace) + if document.definitions.size == 0 + raise GraphQL::ParseError.new("Unexpected end of document", 1, 1, string) + end + document + end + + self.default_parser = GraphQL::CParser +end diff --git a/graphql-c_parser/lib/graphql/c_parser/version.rb b/graphql-c_parser/lib/graphql/c_parser/version.rb new file mode 100644 index 00000000000..39f0ae5d262 --- /dev/null +++ b/graphql-c_parser/lib/graphql/c_parser/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module GraphQL + module CParser + VERSION = "1.1.4" + end +end diff --git a/graphql.gemspec b/graphql.gemspec index ff013e8f66e..3ea1c4ebc8a 100644 --- a/graphql.gemspec +++ b/graphql.gemspec @@ -13,35 +13,40 @@ Gem::Specification.new do |s| s.authors = ["Robert Mosolgo"] s.email = ["rdmosolgo@gmail.com"] s.license = "MIT" - s.required_ruby_version = ">= 2.2.0" # bc `.to_sym` used on user input + s.required_ruby_version = ">= 2.7.0" s.metadata = { "homepage_uri" => "https://graphql-ruby.org", "changelog_uri" => "https://github.com/rmosolgo/graphql-ruby/blob/master/CHANGELOG.md", "source_code_uri" => "https://github.com/rmosolgo/graphql-ruby", "bug_tracker_uri" => "https://github.com/rmosolgo/graphql-ruby/issues", - "mailing_list_uri" => "https://tinyletter.com/graphql-ruby", + "mailing_list_uri" => "https://buttondown.email/graphql-ruby", + "rubygems_mfa_required" => "true", } s.files = Dir["{lib}/**/*", "MIT-LICENSE", "readme.md", ".yardopts"] + s.add_runtime_dependency "base64" + s.add_runtime_dependency "fiber-storage" + s.add_runtime_dependency "logger" + s.add_development_dependency "benchmark-ips" s.add_development_dependency "concurrent-ruby", "~>1.0" + s.add_development_dependency "google-protobuf" + s.add_development_dependency "graphql-batch" s.add_development_dependency "memory_profiler" - # Remove this limit when minitest-reports is compatible - # https://github.com/kern/minitest-reporters/pull/220 - s.add_development_dependency "minitest", "~> 5.9.0" - s.add_development_dependency "minitest-focus", "~> 1.1" - s.add_development_dependency "minitest-reporters", "~>1.0" - s.add_development_dependency "racc", "~> 1.4" - s.add_development_dependency "rake", "~> 12" - s.add_development_dependency "rubocop", "0.68" # for Ruby 2.2 enforcement - # required for upgrader - s.add_development_dependency "parser" - # website stuff - s.add_development_dependency "jekyll" + + s.add_development_dependency "minitest" + s.add_development_dependency "minitest-focus" + s.add_development_dependency "minitest-reporters" + s.add_development_dependency "ostruct" + s.add_development_dependency "rake" + s.add_development_dependency 'rake-compiler' + s.add_development_dependency "rubocop" + s.add_development_dependency "simplecov" + s.add_development_dependency "simplecov-lcov" + s.add_development_dependency "undercover" s.add_development_dependency "yard" - s.add_development_dependency "jekyll-algolia" if RUBY_VERSION >= '2.4.0' - s.add_development_dependency "jekyll-redirect-from" if RUBY_VERSION >= '2.4.0' s.add_development_dependency "m", "~> 1.5.0" + s.add_development_dependency "mutex_m" s.add_development_dependency "webrick" end diff --git a/guides/_config.yml b/guides/_config.yml index 0412390c430..33a879e261c 100644 --- a/guides/_config.yml +++ b/guides/_config.yml @@ -21,10 +21,21 @@ defaults: path: "" values: layout: "default" + fullwidth: true algolia: application_id: '8VO8708WUV' index_name: 'prod_graphql_ruby' + settings: + searchableAttributes: + - section + - title + - headings + - content + customRanking: + - desc(title) + - desc(headings) + - desc(content) plugins: - jekyll-algolia diff --git a/guides/_layouts/default.html b/guides/_layouts/default.html index 16ed5c08000..68adcfdc46e 100644 --- a/guides/_layouts/default.html +++ b/guides/_layouts/default.html @@ -8,29 +8,67 @@ {% else %} GraphQL - {{ page.title }} {% endif %} - + + -
+
{{ content }}
+ diff --git a/guides/_layouts/doc_stub.html b/guides/_layouts/doc_stub.html index 1391559ea8a..c29ec196f8d 100644 --- a/guides/_layouts/doc_stub.html +++ b/guides/_layouts/doc_stub.html @@ -1,7 +1,7 @@ - + {{ content }} diff --git a/guides/_layouts/guide.html b/guides/_layouts/guide.html index 77f1d05297d..0870ccff60e 100644 --- a/guides/_layouts/guide.html +++ b/guides/_layouts/guide.html @@ -1,12 +1,23 @@ --- layout: default --- - + {% if page.experimental %}

@@ -14,7 +25,8 @@

This feature may get big changes in future releases. - Check the changelog for update notes. + Check the changelog or + subscribe to the newsletter for updates.

{% endif %} @@ -28,7 +40,18 @@

{% endif %} +{% if page.enterprise %} +
+

+ 🌟 Enterprise Feature 🌟 + + This feature is bundled with GraphQL-Enterprise. + +

+
+{% endif %}

{{ page.title }}

+
{% table_of_contents %}
{{ content }}
@@ -53,4 +76,8 @@

{{ page.title }}

header.appendChild(headerLink); } } + function navigateToSelected(selectElement) { + var nextPage = selectElement.selectedOptions[0].dataset["target"] + document.location = nextPage + } diff --git a/guides/_plugins/api_doc.rb b/guides/_plugins/api_doc.rb index e4cac9b26e1..8a665479568 100644 --- a/guides/_plugins/api_doc.rb +++ b/guides/_plugins/api_doc.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true require_relative "../../lib/graphql/version" +require "kramdown" module GraphQLSite API_DOC_ROOT = "/api-doc/#{GraphQL::VERSION}/" @@ -36,6 +37,29 @@ def render(context) end end + class CalloutBlock < Liquid::Block + def initialize(tag_name, callout_class, tokens) + super + @callout_class = callout_class.strip + end + + def render(context) + raw_text = super + + site = context.registers[:site] + converter = site.find_converter_instance(::Jekyll::Converters::Markdown) + rendered_text = converter.convert(raw_text) + + heading = case @callout_class + when "warning" + "⚠ Heads up!" + else + raise ArgumentError, "Unhandled callout class: #{@callout_class.inspect}" + end + %|

#{heading}

#{rendered_text}
| + end + end + class OpenAnIssue < Liquid::Tag def initialize(tag_name, issue_info, tokens) title, body = issue_info.split(",") @@ -92,6 +116,91 @@ def exist?(path) POSSIBLE_EXTENSIONS.any? { |ext| File.exist?(filepath + ext) } end end + + class TableOfContents < Liquid::Tag + def render(context) + headers = context["page"]["content"].scan(/^##+[^\n]+$/m) + section_count = 0 + current_table = header_table = [nil] + prev_depth = nil + headers.each do |h| + header_hashes = h.match(/^#+/)[0] + depth = header_hashes.size + if depth == 2 + section_count += 1 + end + text = h.gsub(/^#+ /, "") + target = text.downcase + .gsub("🟡", "00emoji00") + .gsub("❌", "00emoji00") + .gsub(/[^a-z0-9_]+/, "-") + .sub(/-$/, "") + .sub(/^-/, "") + .gsub("-00emoji00", "-") + + rendered_text = Kramdown::Document.new(text, auto_ids: false) + .to_html + .sub("

", "") + .sub("

", "") # remove wrapping added by kramdown + + if prev_depth + if prev_depth > depth + # outdent + current_table = current_table[0] + elsif prev_depth < depth + # indent + new_table = [current_table] + current_table[-1][-1] = new_table + current_table = new_table + else + # same depth + end + end + + current_table << [rendered_text, target, []] + prev_depth = depth + end + + table_html = "".dup + render_table_into_html(table_html, header_table) + + html = <<~HTML +
+

Contents

+ #{table_html} +
+ HTML + + if section_count == 0 + if headers.any? + full_path = "guides/#{context["page"]["path"]}" + warn("No sections identified for #{full_path} -- make sure it's using `## ...` for section headings.") + end + "" + else + html + end + end + + private + + def render_table_into_html(html_str, table) + html_str << "
    " + table.each_with_index do |entry, idx| + if idx == 0 + next # parent reference + end + rendered_text, target, child_table = *entry + html_str << "
  1. " + html_str << "#{rendered_text}" + if child_table.any? + render_table_into_html(html_str, child_table) + end + html_str << "
  2. " + end + html_str << "
" + end + end end @@ -100,3 +209,44 @@ def exist?(path) Liquid::Template.register_tag("api_doc_root", GraphQLSite::APIDocRoot) Liquid::Template.register_tag("open_an_issue", GraphQLSite::OpenAnIssue) Liquid::Template.register_tag("internal_link", GraphQLSite::InternalLink) +Liquid::Template.register_tag("table_of_contents", GraphQLSite::TableOfContents) +Liquid::Template.register_tag('callout', GraphQLSite::CalloutBlock) +Jekyll::Hooks.register :site, :pre_render do |site| + section_pages = Hash.new { |h, k| h[k] = [] } + section_names = [] + site.pages.each do |page| + this_section = page.data["section"] + if this_section + this_section_pages = section_pages[this_section] + this_section_pages << page + this_section_pages.sort_by! { |page| page.data["index"] || 100 } + page.data["section_pages"] = this_section_pages + section_names << this_section + end + end + section_names.compact! + section_names.uniq! + all_sections = [] + section_names.each do |section_name| + all_sections << { + "name" => section_name, + "overview_page" => section_pages[section_name].first, + } + end + + sorted_section_names = site.pages.find { |p| p.data["title"] == "Guides Index" }.data["sections"].map { |s| s["name"] } + all_sections.sort_by! { |s| sorted_section_names.index(s["name"]) } + site.data["all_sections"] = all_sections +end + +module Jekyll + module Algolia + module Hooks + def self.before_indexing_each(record, node, context) + record = record.dup + record.delete(:section_pages) + record + end + end + end +end diff --git a/guides/_tasks/site.rb b/guides/_tasks/site.rb index 70645a1b574..3c6cfbabdf6 100644 --- a/guides/_tasks/site.rb +++ b/guides/_tasks/site.rb @@ -7,6 +7,7 @@ task :gen_version, [:version] do |t, args| # GITHUB_REF comes from GitHub Actions version = args[:version] || ENV["GITHUB_REF"] || raise("A version is required") + puts "Building docs for #{version}" # GitHub Actions gives the full tag name if version.start_with?("refs/tags/") version = version[10..-1] @@ -21,13 +22,14 @@ system("rm graphql-#{version}.gem") Dir.chdir("graphql-#{version}") do - system("yardoc") # Copy it into gh-pages for publishing # and locally for previewing push_dest = File.expand_path("../gh-pages/api-doc/#{version}") local_dest = File.expand_path("../guides/_site/api-doc/#{version}") - mkdir_p push_dest - mkdir_p local_dest + puts "Creating directories: #{push_dest.inspect}, #{local_dest.inspect}" + FileUtils.mkdir_p(push_dest) + FileUtils.mkdir_p(local_dest) + system("yardoc") puts "Copying from #{Dir.pwd}/doc to #{push_dest}" copy_entry "doc", push_dest puts "Copying from #{Dir.pwd}/doc to #{local_dest}" @@ -40,7 +42,7 @@ namespace :site do desc "View the documentation site locally" - task serve: [:build_doc] do + task serve: [] do # if you need api docs, add `:build_doc` to the list of dependencies require "jekyll" options = { "source" => File.expand_path("guides"), diff --git a/guides/authorization/authorization.md b/guides/authorization/authorization.md index b7517218f21..474b544b9d7 100644 --- a/guides/authorization/authorization.md +++ b/guides/authorization/authorization.md @@ -16,6 +16,8 @@ Schema members have `authorized?` methods which will be called during execution: - Type classes have `.authorized?(object, context)` class methods - Fields have `#authorized?(object, args, context)` instance methods - Arguments have `#authorized?(object, arg_value, context)` instance methods +- Mutations and Resolvers have `.authorized?(object, context)` class methods and `#authorized?(args)` instance methods +- Enum values have `#authorized?(context)` instance methods These methods are called with: @@ -46,9 +48,9 @@ Now, whenever an object of type `Friendship` is going to be returned to the clie Field `#authorized?` methods are called before resolving a field, for example: ```ruby -class Types::BaseField < GraphQL::Field +class Types::BaseField < GraphQL::Schema::Field # Pass `field ..., require_admin: true` to reject non-admin users from a given field - def initialize(*args, **kwargs, require_admin: false, &block) + def initialize(*args, require_admin: false, **kwargs, &block) @require_admin = require_admin super(*args, **kwargs, &block) end @@ -67,8 +69,8 @@ For this to work, the base field class must be {% internal_link "configured with Argument `#authorized?` hooks are called before resolving the field that the argument belongs to. For example: ```ruby -class Types::BaseArgument < GraphQL::Field - def initialize(*args, **kwargs, require_logged_in: false, &block) +class Types::BaseArgument < GraphQL::Schema::Argument + def initialize(*args, require_logged_in: false, **kwargs, &block) @require_logged_in = require_logged_in super(*args, **kwargs, &block) end @@ -85,6 +87,18 @@ end For this to work, the base argument class must be {% internal_link "configured with other GraphQL types", "/type_definitions/extensions.html#customizing-arguments" %}. +## Mutation Authorization + +See {% internal_link "Mutation Authorization", "/mutations/mutation_authorization.html#can-this-user-perform-this-action" %} in the Mutation Guides. + +## Enum Value Authorization + +{{ "GraphQL::Schema::EnumValue#authorized?" | api_doc }} is called when client input is received and when the schema returns values to the client. + +For authorizing input, if a value's `#authorized?` method returns false, then a {{ "GraphQL::UnauthorizedEnumValueError" | api_doc }} is raised. It passed to your schema's `.unauthorized_object` hook, where you can handle it another way if you want. + +For authorizing return values, if an outgoing value's `#authorized?` method returns false, then a {{ "GraphQL::Schema::Enum::UnresolvedValueError" | api_doc }} is raised, which crashes the query. In this case, you should modify your field or resolver to _not_ return this value to an unauthorized viewer. (In this case, the error isn't returned to the viewer because the viewer can't do anything about it -- it's a developer-facing issue instead.) + ## Handling Unauthorized Objects By default, GraphQL-Ruby silently replaces unauthorized objects with `nil`, as if they didn't exist. You can customize this behavior by implementing {{ "Schema.unauthorized_object" | api_doc }} in your schema class, for example: diff --git a/guides/authorization/can_can_integration.md b/guides/authorization/can_can_integration.md index c80152159b8..09c3113eb82 100644 --- a/guides/authorization/can_can_integration.md +++ b/guides/authorization/can_can_integration.md @@ -39,14 +39,15 @@ context = { MySchema.execute(..., context: context) ``` -And read on about the different features of the integration: +### Rails Generator -- [Authorizing Objects](#authorizing-objects) -- [Scoping Lists and Connections](#scopes) -- [Authorizing Fields](#authorizing-fields) -- [Authorizing Arguments](#authorizing-arguments) -- [Authorizing Mutations](#authorizing-mutations) -- [Custom Abilities Class](#custom-abilities-class) +If your schema files follow the same convention as `rails generate graphql:install`, then you can install the CanCan integration with a Rails generator: + +```bash +$ rails generate graphql:cancan:install +``` + +This will insert all the necessary `include ...`s described below. Alternatively, check the docs below to mix in `CanCanIntegration`'s modules. ## Authorizing Objects @@ -158,7 +159,7 @@ Then, you can add `can_can_action:` options to your fields: class Types::JobPosting < Types::BaseObject # Only allow `can :review_applications, JobPosting` users # to see who has applied - field :applicants, [Types::User], null: true, + field :applicants, [Types::User], can_can_action: :review_applicants end ``` @@ -171,7 +172,7 @@ CanCan 3.0 added attribute-level authorization ([pull request](https://github.co ```ruby # This will call `.can?(:read, user, :email_address)` -field :email_address, String, null: true, +field :email_address, String, can_can_action: :read, can_can_attribute: :email_address ``` @@ -203,7 +204,6 @@ field :users, Types::User.connection_type, null: false, can_can_action: :manage, # `:all` will be used instead of `object` (which is `nil`) can_can_subject: :all -end ``` The configuration above will call `can?(:manage, :all)` whenever that field is requested. @@ -241,7 +241,7 @@ Now, arguments accept a `can_can_action:` option, for example: ```ruby class Types::Company < Types::BaseObject - field :employees, Types::Employee.connection_type, null: true do + field :employees, Types::Employee.connection_type do # Only admins can filter employees by email: argument :email, String, required: false, can_can_action: :admin end @@ -311,7 +311,7 @@ Beyond the normal [object reading permissions](#authorizing-objects), you can ad ```ruby class Mutations::FireEmployee < Mutations::BaseMutation - argument :employee_id, ID, required: true, + argument :employee_id, ID, loads: Types::Employee, can_can_action: :supervise, end @@ -343,7 +343,7 @@ Whatever that method returns will be treated as an early return value for the mu ```ruby class Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation - field :errors, [String], null: true + field :errors, [String] def unauthorized_by_can_can(owner, value) # Return errors as data: @@ -352,6 +352,21 @@ class Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation end ``` +## Authorizing Resolvers + +Resolvers are authorized just like [mutations](#authorizing-mutations), and require similar setup: + +```ruby +# app/graphql/resolvers/base_resolver.rb +class Resolvers::BaseResolver < GraphQL::Schema::Resolver + include GraphQL::Pro::CanCanIntegration::ResolverIntegration + argument_class BaseArgument + # can_can_action(nil) # to disable authorization by default +end +``` + +Beyond that, see [Authorizing Mutations](#authorizing-mutations) above for further details. + ## Custom Abilities Class By default, the integration will look for a top-level `::Ability` class. diff --git a/guides/authorization/pundit_integration.md b/guides/authorization/pundit_integration.md index c23800ae39b..0900df6611c 100644 --- a/guides/authorization/pundit_integration.md +++ b/guides/authorization/pundit_integration.md @@ -38,15 +38,15 @@ context = { MySchema.execute(..., context: context) ``` -And read on about the different features of the integration: +### Rails Generator -- [Authorizing Objects](#authorizing-objects) -- [Scoping Lists and Connections](#scopes) -- [Authorizing Fields](#authorizing-fields) -- [Authorizing Arguments](#authorizing-arguments) -- [Authorizing Mutations](#authorizing-mutations) -- [Custom Policy Lookup](#custom-policy-lookup) -- [Custom User Lookup](#custom-user-lookup) +If your schema files follow the same convention as `rails generate graphql:install`, then you can install the Pundit integration with a Rails generator: + +```bash +$ rails generate graphql:pundit:install +``` + +This will insert all the necessary `include ...`s described below. Alternatively, check the docs below to mix in `PunditIntegration`'s modules. ## Authorizing Objects @@ -140,10 +140,6 @@ module BaseInterface end ``` -Pundit scopes [don't play well](https://github.com/rmosolgo/graphql-ruby/issues/2008) with `Array`s, so the integration _skips_ scopes on Arrays. You can also opt out on a field-by-field basis as described below. - -You can also customize how the scopes are looked up and applied, see below. - #### Bypassing scopes To allow an unscoped relation to be returned from a field, disable scoping with `scope: false`, for example: @@ -195,7 +191,7 @@ class Types::JobPosting < Types::BaseObject # But, only allow `JobPostingPolicy#staff?` users to see # who has applied - field :applicants, [Types::User], null: true, + field :applicants, [Types::User], pundit_role: :staff end ``` @@ -210,7 +206,7 @@ You can override the policy class for a field using `pundit_policy_class:`, for class Types::JobPosting < Types::BaseObject # Only allow `ApplicantsPolicy#staff?` users to see # who has applied - field :applicants, [Types::User], null: true, + field :applicants, [Types::User], pundit_role: :staff, pundit_policy_class: ApplicantsPolicy # Or with a string: @@ -255,7 +251,7 @@ Now, arguments accept a `pundit_role:` option, for example: ```ruby class Types::Company < Types::BaseObject - field :employees, Types::Employee.connection_type, null: true do + field :employees, Types::Employee.connection_type do # Only admins can filter employees by email: argument :email, String, required: false, pundit_role: :admin end @@ -340,7 +336,7 @@ Beyond the normal [object reading permissions](#authorizing-objects), you can ad ```ruby class Mutations::FireEmployee < Mutations::BaseMutation - argument :employee_id, ID, required: true, + argument :employee_id, ID, loads: Types::Employee, pundit_role: :supervisor, end @@ -372,7 +368,7 @@ Whatever that method returns will be treated as an early return value for the mu ```ruby class Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation - field :errors, [String], null: true + field :errors, [String] def unauthorized_by_pundit(owner, value) # Return errors as data: @@ -381,6 +377,21 @@ class Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation end ``` +## Authorizing Resolvers + +Resolvers are authorized just like [mutations](#authorizing-mutations), and require similar setup: + +```ruby +# app/graphql/resolvers/base_resolver.rb +class Resolvers::BaseResolver < GraphQL::Schema::Resolver + include GraphQL::Pro::PunditIntegration::ResolverIntegration + argument_class BaseArgument + # pundit_role nil # to disable authorization by default +end +``` + +Beyond that, see [Authorizing Mutations](#authorizing-mutations) above for further details. + ## Custom Policy Lookup By default, the integration uses `Pundit`'s top-level methods to interact with policies: @@ -406,11 +417,11 @@ Here's an example of how the custom hooks can be installed: ```ruby module CustomPolicyLookup # Lookup policies in the `SystemAdmin::` namespace for system_admin users + # @return [Class] def pundit_policy_class_for(object, context) current_user = context[:current_user] if current_user.system_admin? - policy_class = SystemAdmin.const_get("#{object.class.name}Policy") - policy_class.new(current_user, object) + SystemAdmin.const_get("#{object.class.name}Policy") else super end diff --git a/guides/authorization/scoping.md b/guides/authorization/scoping.md index fb9c829ba9d..acdfbcaaaf3 100644 --- a/guides/authorization/scoping.md +++ b/guides/authorization/scoping.md @@ -8,9 +8,9 @@ index: 4 --- -_Scoping_ is a complementary consideration to authorization. Rather than checking "can this user see this thing?", scoping takes a list of items filters it to the subset which is appropriate for the current viewer and context. The resulting subset is authorized as normal, and, assuming that it was properly scoped, each item should pass authorization checks. +_Scoping_ is a complementary consideration to authorization. Rather than checking "can this user see this thing?", scoping takes a list of items filters it to the subset which is appropriate for the current viewer and context. -For similar features, see [Pundit scopes](https://github.com/varvet/pundit#scopes) and [Cancan's `.accessible_by`](https://github.com/cancancommunity/cancancan/wiki/Fetching-Records). +For similar features, see [Pundit scopes](https://github.com/varvet/pundit#scopes) and [Cancan's `.accessible_by`](https://github.com/CanCanCommunity/cancancan/blob/develop/docs/fetching_records.md). ## `scope:` option @@ -43,3 +43,26 @@ end ``` The method should return a new list with only the appropriate items for the current `context`. + +## Bypassing object-level authorization + +If you know that any items returned from `.scope_items` should be visible to the current client, you can skip the normal `.authorized?(obj, ctx)` checks by configuring `reauthorize_scoped_objects(false)` in your type definition. For example: + +```ruby +class Types::Product < Types::BaseObject + # Check that singly-loaded objects are visible to the current viewer + def self.authorized?(object, context) + super && object.visible_to?(context[:viewer]) + end + + # Filter any list to only include objects that are visible to the current viewer + def self.scope_items(items, context) + items = super(items, context) + items.visible_for(context[:viewer]) + end + + # If an object of this type was returned from `.scope_items`, + # don't call `.authorized?` with it. + reauthorize_scoped_objects(false) +end +``` diff --git a/guides/authorization/visibility.md b/guides/authorization/visibility.md index 82ab44117e8..2127b298daf 100644 --- a/guides/authorization/visibility.md +++ b/guides/authorization/visibility.md @@ -3,7 +3,7 @@ layout: guide search: true section: Authorization title: Visibility -desc: Programatically hide parts of the GraphQL schema from some users. +desc: Programmatically hide parts of the GraphQL schema from some users. index: 1 redirect_from: - /schema/limiting_visibility @@ -18,7 +18,16 @@ Here are some reasons you might want to hide parts of your schema: ## Hiding Parts of the Schema -You can customize the visibility of parts of your schema by reimplementing various `visible?` methods: +To start limiting visibility of your schema, add the plugin: + +```ruby +class MySchema < GraphQL::Schema + # ... + use GraphQL::Schema::Visibility # see below for options +end +``` + +Then, you can customize the visibility of parts of your schema by reimplementing various `visible?` methods: - Type classes have a `.visible?(context)` class method - Fields and arguments have a `#visible?(context)` instance method @@ -30,6 +39,33 @@ These methods are called with the query context, based on the hash you pass as ` - In introspection, the member will _not_ be included in the result - In normal queries, if a query references that member, it will return a validation error, since that member doesn't exist +## Visibility Profiles + +You can use named profiles to cache your schema's visibility modes. For example: + +```ruby +use GraphQL::Schema::Visibility, profiles: { + # mode_name => example_context_hash + public: { public: true }, + beta: { public: true, beta: true }, + internal_admin: { internal_admin: true } +} +``` + +Then, you can run queries with `context[:visibility_profile]` equal to one of the pre-defined profiles. When you do, GraphQL-Ruby will create a cached set of types for named profile. `.visible?` will only be called with the context hash passed to `profiles: ...`. + +The profile contexts passed to `profiles` will have `visibility_profile: ...` added to them, then they're frozen by GraphQL-Ruby. + +### Preloading profiles + +By default, GraphQL-Ruby will preload all named visibility profiles when `Rails.env.production?` is present and true. You can manually set this option by passing `use ... preload: true` (or `false`). Enable preloading in production to reduce latency of the first request to each visibility profile. Disable preloading in development to speed up application boot. + +### Dynamic profiles + +When you provide named visibility profiles, `context[:visibility_profile]` is required for query execution. You can also permit dynamic visibility for queries which _don't_ have that key set by passing `use ..., dynamic: true`. You could use this to support backwards compatibility or when visibility calculations are too complex to predefine. + +When no named profiles are defined, all queries use dynamic visibility. + ## Object Visibility Let's say you're working on a new feature which should remain secret for a while. You can implement `.visible?` in a type: @@ -60,9 +96,9 @@ And in introspection: ## Field Visibility ```ruby -class Types::BaseField < GraphQL::Field +class Types::BaseField < GraphQL::Schema::Field # Pass `field ..., require_admin: true` to hide this field from non-admin users - def initialize(*args, **kwargs, require_admin: false, &block) + def initialize(*args, require_admin: false, **kwargs, &block) @require_admin = require_admin super(*args, **kwargs, &block) end @@ -79,17 +115,55 @@ For this to work, the base field class must be {% internal_link "configured with ## Argument Visibility ```ruby -class Types::BaseArgument < GraphQL::Field +class Types::BaseArgument < GraphQL::Schema::Argument # If `require_logged_in: true` is given, then this argument will be hidden from logged-out viewers - def initialize(*args, **kwargs, require_logged_in: false, &block) + def initialize(*args, require_logged_in: false, **kwargs, &block) @require_logged_in = require_logged_in super(*args, **kwargs, &block) end - def authorized?(ctx) + def visible?(ctx) super && (@require_logged_in ? ctx[:viewer].present? : true) end end ``` -For this to work, the base argument class must be {% internal_link "configured with other GraphQL types", "/type_definitions/extensions.html#customizing-arguments" %}. \ No newline at end of file +For this to work, the base argument class must be {% internal_link "configured with other GraphQL types", "/type_definitions/extensions.html#customizing-arguments" %}. + +## Opting Out + +By default, GraphQL-Ruby always runs visibility checks. You can opt out of this by adding to your schema class: + +```ruby +class MySchema < GraphQL::Schema + # ... + # Opt out of GraphQL-Ruby's visibility feature: + use GraphQL::Schema::AlwaysVisible +end +``` + +For big schemas, this can be a worthwhile speed-up. + +## Migration Notes + +{{ "GraphQL::Schema::Visibility" | api_doc }} is a _new_ implementation of visibility in GraphQL-Ruby. It has some slight differences from the previous implementation ({{ "GraphQL::Schema::Warden" | api_doc }}): + +- `Visibility` speeds up Rails app boot because it doesn't require all types to be loaded during boot and only loads types as they are used by queries. +- `Visibility` supports predefined, reusable visibility profiles which speeds up queries using complicated `visible?` checks. +- `Visibility` hides types differently in a few edge cases: + - Previously, `Warden` hid interface and union types which had no possible types. `Visibility` doesn't check possible types (in order to support performance improvements), so those types must return `false` for `visible?` in the same cases where all possible types were hidden. Otherwise, that interface or union type will be visible but have no possible types. + - When an object type is connected to the schema as a field return type or a union member, and also implements and interface, if the object type's _other_ connection(s) to the schema are hidden, then it won't appear as an implementer of that interface unless it's registered with `orphan_types` (either by the schema or interface). `Warden` used a "global" map of types so it could discover object types in this case, but `Visibility` doesn't have that global map. (Since time of writing, `Visibility` _does_ have some global type tracking, so maybe this could be fixed.) +- When `Visibility` is used, several (Ruby-level) Schema introspection methods don't work because the caches they draw on haven't been calculated (`Schema.references_to`, `Schema.union_memberships`). If you're using these, please get in touch so that we can find a way forward. +- If you programmatically generate your GraphQL schema (instead of using plain-Ruby `.rb` files), you must ensure that generation is threadsafe. Otherwise, definitions may be malformed in Rails development mode when multithreaded. (Rails eager-loads all files in production mode, so this concern doesn't exist there.) + +### Migration Mode + +You can use `use GraphQL::Schema::Visibility, ... migration_errors: true` to enable migration mode. In this mode, GraphQL-Ruby will make visibility checks with _both_ `Visibility` and `Warden` and compare the result, raising a descriptive error when the two systems return different results. As you migrate to `Visibility`, enable this mode in test to find any unexpected discrepancies. + +Sometimes, there's a discrepancy that is hard to resolve but doesn't make any _real_ difference in application behavior. To address these cases, you can use these flags in `context`: + +- `context[:visibility_migration_running] = true` is set in the main query context. +- `context[:visibility_migration_warden_running] = true` is set in the _duplicate_ context which is passed to a `Warden` instance. +- If you set `context[:skip_migration_error] = true`, then no migration error will be raised for that query. + +You can use these flags to conditionally handle edge cases that should be ignored in testing. diff --git a/guides/changesets/definition.md b/guides/changesets/definition.md new file mode 100644 index 00000000000..f46c5eda054 --- /dev/null +++ b/guides/changesets/definition.md @@ -0,0 +1,300 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Changesets +title: Defining Changesets +desc: Creating a set of modifications to release in an API version +index: 2 +--- + +After {% internal_link "installing Changeset integrations", "/changesets/installation" %} in your schema, you can create Changesets which modify parts of the schema. Changesets extend `GraphQL::Enterprise::Changeset` and include a `release` string. Once a Changeset class is defined, it can be referenced with `added_in: ...` or `removed_in: ...` configurations in the schema. + +__Note:__ Before GraphQL-Enterprise 1.3.0, Changesets were configured with `modifies ...` blocks. These blocks are still supported and you can find the documentation for that API [on GitHub](https://github.com/rmosolgo/graphql-ruby/blob/v2.0.22/guides/changesets/definition.md). + + +## Changeset Classes + +This Changeset will be available to any client whose `context[:changeset_version]` is on or after `2020-12-01`: + +```ruby +# app/graphql/changesets/deprecate_recipe_flag.rb +class Changesets::DeprecateRecipeTags < GraphQL::Enterprise::Changeset + release "2020-12-01" +end +``` + +Additionally, Changesets must be {% internal_link "released", "/changesets/releases" %} for their changes to be published. + +## Publishing with `added_in:` + +New things can be published in a changeset by adding `added_in: SomeChangeset` to their configuration. For example, to add a new argument to a field: + +```ruby +field :search_recipes, [Types::Recipe] do + argument :query, String + argument :tags, [Types::RecipeTag], required: false, added_in: Changesets::AddRecipeTags +end +``` + +You can also provide a _replacement_ implementation by using `added_in:`. When a new definition has the same name as an existing definition, it implicitly replaces the previous definition in new versions of the API. For example: + +```ruby +field :rating, Integer, "A 1-5 score for this recipe" # This definition will be superseded by the following one +field :rating, Float, "A 1.0-5.0 score for this recipe", added_in: Changesets::FloatingPointRatings +``` + +Here, a new implementation for `rating` will be used when clients requests an API version that includes `Changesets::FloatingPointRatings`. (If the client requests a version _before_ that changeset, then the preceding implementation would be used instead.) + +## Removing with `removed_in:` + +A `removed_in:` configuration removes something in the named changeset. For example, these enum values are replaced with more clearly-named ones: + +```ruby +class Types::RecipeTag < Types::BaseEnum + # These are replaced by *_HEAT below: + value :SPICY, removed_in: Changesets::ClarifyHeatTags + value :MEDIUM, removed_in: Changesets::ClarifyHeatTags + value :MILD, removed_in: Changesets::ClarifyHeatTags + # These new tags are more clear: + value :SPICY_HEAT, added_in: Changesets::ClarifyHeatTags + value :MEDIUM_HEAT, added_in: Changesets::ClarifyHeatTags + value :MILD_HEAT, added_in: Changesets::ClarifyHeatTags +end +``` + +If something has been defined several times, a `removed_in:` configuration removes _all_ definitions: + +```ruby +class Mutations::SubmitRecipeRating < Mutations::BaseMutation + # This is replaced in future API versions by the following argument + argument :rating, Integer + # This replaces the previous, but in another future version, + # it is removed completely (and so is the previous one) + argument :rating, Float, added_in: Changesets::FloatingPointRatings, removed_in: Changesets::RemoveRatingsCompletely +end +``` + +## Examples + +See below for the different kind of modifications you can make in a changeset: + +- [Fields](#fields): adding, modifying, and removing fields +- [Arguments](#arguments): adding, modifying, and removing arguments +- [Enum values](#enum-values): adding, modifying, and removing arguments +- [Unions](#unions): adding or removing object types from a union +- [Interfaces](#interfaces): adding or removing interface implementations from object types +- [Types](#types): changing one type definition for another +- [Runtime](#runtime): choosing a behavior at runtime based on the current request and changeset + +### Fields + +To add or redefine a field, use `field(..., added_in: ...)`, including all configuration values for the new implementation (see {{ "GraphQL::Schema::Field#initialize" | api_doc }}). The definition given here will override the previous definition (if there was one) whenever this Changeset applies. + +```ruby +class Types::Recipe < Types::BaseObject + # This new field is available when `context[:changeset_version]` + # is on or after the release date of `AddRecipeTags` + field :tags, [Types::RecipeTag], added_in: Changeset::AddRecipeTags +end +``` + +To remove a field, add a `removed_in: ...` configuration to the last definition of the field: + +```ruby +class Types::Recipe < Types::BaseObject + # Even after migrating to floating point values, + # the "rating" feature never took off, + # so we removed it entirely eventually. + field :rating, Integer + field :rating, Float, added_in: Changeset::FloatingPointRatings, + removed_in: Changeset::RemoveRatings +end +``` + +When a field is removed, queries that request that field will be invalid, unless the client has requested a previous API version where the field is still available. + +### Arguments + +You can add, redefine, or remove arguments that belong to fields, input objects, or resolvers. Use `added_in: ...` to provide a new (or updated) definition for an argument, for example: + +```ruby +class Types::RecipesFilter < Types::BaseInputObject + argument :rating, Integer + # This new definition is available when + # the client's `context[:changeset_version]` includes `FloatingPointRatings` + argument :rating, Float, added_in: Changesets::FloatingPointRatings +end +``` + +To remove an argument entirely, add a `removed_in: ...` configuration to the last definition. It will remove _all_ implementations for that argument. For example: + +```ruby +class Mutations::SubmitRating < Mutations::BaseMutation + # Remove this because it's irrelevant: + argument :phone_number, String, removed_in: Changesets::StopCollectingPersonalInformation +end +``` + +When arguments are removed, the schema will reject any queries which use them unless the client has requested a previous API version where the argument is still allowed. + +### Enum Values + +With Changesets, you can add, redefine, or remove enum values. To add a new value (or provide a new implementation for a value), include `added_in:` in the `value(...)` configuration: + +```ruby +class Types::RecipeTag < Types::BaseEnum + # This enum will accept and return `KETO` only when the client's API version + # includes `AddKetoDietSupport`'s release date. + value :KETO, added_in: Changesets::AddKetoDietSupport +end +``` + +Values can be removed with `removed_in:`, for example: + +```ruby +class Types::RecipeTag < Types::BaseEnum + # Old API versions will serve this value; + # new versions won't accept it or return it. + value :GRAPEFRUIT_DIET, removed_in: Changesets::RemoveLegacyDiets +end +``` + +When enum values are removed, they won't be accepted as input and they won't be allowed as return values from fields unless the client has requested a previous API version where those values are still allowed. + +### Unions + +You can add to or remove from a union's possible types. To release a new union member, include `added_in:` in the `possible_types` configuration: + +```ruby +class Types::Cookable < Types::BaseUnion + possible_types Types::Recipe, Types::Ingredient + # Add this to the union when clients opt in to our new feature: + possible_types Types::Cuisine, added_in: Changeset::ReleaseCuisines +``` + +To remove a member from a union, move it to a `possible_types` call with `removed_in: ...`: + +```ruby +# Stop including this in the union in new API versions: +possible_types Types::Chef, removed_in: Changeset::LessChefHype +``` + +When a possible type is removed, it will not be associated with the union type in introspection queries or schema dumps. + +### Interfaces + +You can add to or remove from an object type's interface definitions. To add one or more interface implementations, use `implements(..., added_in:)`. This will add the interface and its fields to the object whenever this Changeset is active, for example: + +```ruby +class Types::Recipe < Types::BaseObject + # Add this new implementation in new API versions only: + implements Types::RssSubject, added_in: Changesets::AddRssSupport +end +``` + + +To remove one or more more interface implementations, add `removed_in:` to the `implements ...` configuration, for example: + +```ruby + implements Types::RssSubject, + added_in: Changesets::AddRssSupport, + # Sadly, nobody seems to want to use this, + # so we removed it all: + removed_in: Changesets::RemoveRssSupport +``` + +When an interface implementation is removed, then the interface will not be associated with the object in introspection queries or schema dumps. Also, any fields inherited from the interface will be hidden from clients. (If the object defines the field itself, it will still be visible.) + +### Types + +Using Changesets, it's possible to define a new type using the same name as an old type. (Only one type per name is allowed for each query, but different queries can use different types for the same name.) + +First, to define two types with the same name, make two different type definitions. One of them will have to use `graphql_name(...)` to specify the conflicting type name. For example, to migrate an enum type to an object type, define two types: + +```ruby +# app/graphql/types/legacy_recipe_flag.rb + +# In the old version of the schema, "recipe tags" were limited to defined set of values. +# This enum was renamed from `Types::RecipeTag`, then `graphql_name("RecipeTag")` +# was added for GraphQL. +class Types::LegacyRecipeTag < Types::BaseEnum + graphql_name "RecipeTag" + # ... +end +``` + +```ruby +# app/graphql/types/recipe_flag.rb + +# But in the new schema, each tag is a full-fledged object with fields of its own +class Types::RecipeTag < Types::BaseObject + field :name, String, null: false + field :is_vegetarian, Boolean, null: false + # ... +end +``` + +Then, add or update fields or arguments to use the _new_ type instead of the old one. For example: + +```diff + class Types::Recipe < Types::BaseObject + +# Change this definition to point at the newly-renamed _legacy_ type +# (It's the same type definition, but the Ruby class has a new name) +- field :tags, [Types::RecipeTag] ++ field :tags, [Types::LegacyRecipeTag] + +# And add a new field for the new type: ++ field :tags, [Types::RecipeTag], added_in: Changesets::MigrateRecipeTagToObject + end +``` + +With that Changeset, `Recipe.tags` will return an object type instead of an enum type. Clients requesting older versions will still receive enum values from that field. + +The resolver will probably need an update, too, for example: + +```ruby +class Types::Recipe < Types::BaseObject + # Here's the original definition which returns enum values: + field :tags, [Types::LegacyRecipeTag], null: false + # Here's the new definition which replaces the previous one on new API versions: + field :tags, [Types::RecipeTag], null: false, added_in: Changesets::MigrateRecipeTagToObject + + def flags + all_flag_objects = object.flag_objects + if Changesets::MigrateRecipeTagToObject.active?(context) + # Here's the new behavior, returning full objects: + all_flag_objects + else + # Convert this to enum values, for legacy behavior: + all_flag_objects.map { |f| f.name.upcase } + end + end +end +``` + +That way, legacy clients will continue to receive enum values while new clients will receive objects. + +## Runtime + +While a query is running, you can check if a changeset applies by using its `.active?(context)` method. For example: + +```ruby +class Types::Recipe + field :flag, Types::RecipeFlag, null: true + + def flag + # Check if this changeset applies to the current request: + if Changesets::DeprecateRecipeFlag.active?(context) + Stats.count(:deprecated_recipe_flag, context[:viewer]) + end + # ... + end +end +``` + +Besides observability, you can use a runtime check when a resolver needs to pick a different behavior depending on the API version. + +After defining a changeset, add it to the schema to {% internal_link "release it", "/changesets/releases" %}. diff --git a/guides/changesets/installation.md b/guides/changesets/installation.md new file mode 100644 index 00000000000..b4e004211c9 --- /dev/null +++ b/guides/changesets/installation.md @@ -0,0 +1,86 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Changesets +title: Installing Changesets +desc: Adding Changesets to your schema +index: 1 +--- + +Changesets require some updates to the schema (to define changesets) and some updates to your controller (to receive version headers from clients). + +## Schema Setup + +To get started with [GraphQL-Enterprise](https://graphql.pro/enterprise) Changesets, you have to add them to your schema. They're added in several places: + +- To support versioning arguments, add the `ArgumentIntegration` to your base argument: + + ```ruby + # app/graphql/types/base_argument.rb + class Types::BaseArgument < GraphQL::Schema::Argument + include GraphQL::Enterprise::Changeset::ArgumentIntegration + end + ``` + + Also, make sure that your `BaseField`, `BaseInputObject`, `BaseResolver`, and `BaseMutation` have `argument_class(Types::BaseArgument)` configured in them. + +- To support versioning fields, add the `FieldIntegration` to your base field: + + ```ruby + # app/graphql/types/base_field.rb + class Types::BaseField < GraphQL::Schema::Field + include GraphQL::Enterprise::Changeset::FieldIntegration + argument_class(Types::BaseArgument) + end + ``` + + Also, make sure that your `BaseObject`, `BaseInterface`, and `BaseMutation` have `field_class(Types::BaseField)` configured in them. + +- To support versioning enum values, add the `EnumValueIntegration` to your base enum value: + + ```ruby + # app/graphql/types/base_enum_value.rb + class Types::BaseEnumValue < GraphQL::Schema::EnumValue + include GraphQL::Enterprise::Changeset::EnumValueIntegration + end + ``` + + Also, make sure that your `BaseEnum` has `enum_value_class(Types::BaseEnumValue)` configured in it. + +- To support versioning union memberships and interface implementations, add the `TypeMembershipIntegration` to your base type membership: + + ```ruby + # app/graphql/types/base_type_membership.rb + class Types::BaseTypeMembership < GraphQL::Schema::TypeMembership + include GraphQL::Enterprise::Changeset::TypeMembershipIntegration + end + ``` + + Also, make sure that your `BaseUnion` and `BaseInterface` have `type_membership_class(Types::BaseTypeMembership)` configured in it. (`TypeMembership`s are used by GraphQL-Ruby to link object types to the union types they belong to and the interfaces they implement. By using a custom type membership class, you can make objects belong (or _not_ belong) to unions or interfaces, depending on the API version.) + +Once those integrations are set up, you're ready to {% internal_link "write a changeset", "/changesets/definition" %} and start {% internal_link "releasing API versions", "/changesets/releases" %}! + +## Controller Setup + +Additionally, your controller must pass `context[:changeset_version]` when running queries. To provide this, update your controller: + +```ruby +class GraphqlController < ApplicationController + def execute + context = { + # ... + changeset_version: request.headers["API-Version"], # <- Your header here. Choose something for API clients to pass. + } + result = MyAppSchema.execute(..., context: context) + # ... + end +end +``` + +In the example above, `API-Version: ...` will be parsed from the incoming request and used as `context[:changeset_version]`. + +If `context[:changeset_version]` is `nil`, then _no_ changesets will apply to that request. + +Now that Changesets are installed, read on to {% internal_link "define some changesets", "/changesets/definition" %}. diff --git a/guides/changesets/overview.md b/guides/changesets/overview.md new file mode 100644 index 00000000000..b391429f465 --- /dev/null +++ b/guides/changesets/overview.md @@ -0,0 +1,52 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Changesets +title: API Versioning for GraphQL-Ruby +desc: Evolve your schema over time, feature-by-feature +index: 0 +--- + + +Out-of-the-box, GraphQL is [versionless by design](https://graphql.org/learn/schema-design/). GraphQL's openness to extension paves the way for continuously expanding and improving an API. You can _always_ add new fields, new arguments, and new types to implement new features and customize existing behavior. + +However, sometimes a business case may call for a different versioning scheme. [GraphQL-Enterprise](https://graphql.pro/enterprise)'s "Changesets" enable schemas to release _any_ change -- even breaking changes -- to clients, depending on what version of the schema they're using. With Changesets, you can redefine existing fields, define new types using old names, add or remove enum values -- anything, really -- while maintaining compatibility for existing clients. + +## Why Changesets? + +Changesets are a _complementary_ evolution technique to continuous additions. In general, additive changes (new fields, new arguments, new types) are best added right to the existing schema. But if you need to _remove_ something from the schema or redefine existing parts of the schema in non-backwards-compatible ways, Changesets provide a handy way of doing so. + +For example, if you add a values to an Enum, you can just add it to the existing schema: + +```diff + class Types::RecipeTag < Types::BaseEnum + value "LOW_FAT" + value "LOW_CARB" ++ value "VEGAN" ++ value "KETO" ++ value "GRAPEFRUIT_DIET" + end +``` + +However, if you want to change the schema in ways that would _break_ previous queries, you can do that with a Changeset: + +```ruby +class Types::RecipeTag < Types::BaseEnum + # Turns out this makes you sick: + value "GRAPEFRUIT_DIET", removed_in: Changesets::RemoveLegacyDiets +end +``` + +Then, only clients requesting API versions _before_ this changeset would be able to use `GRAPEFRUIT_DIET`; clients requesting newer versions could not send it as input and would not receive it in responses. + +(Changesets _also_ support additive changes, if you prefer to make them that way.) + +## Getting Started + +To start using Changesets, read on: + +- {% internal_link "Installing Changesets", "/changesets/installation" %} +- {% internal_link "Writing Changesets", "/changesets/definition" %} +- {% internal_link "Releasing Changesets", "/changesets/releases" %} diff --git a/guides/changesets/releases.md b/guides/changesets/releases.md new file mode 100644 index 00000000000..cba13722310 --- /dev/null +++ b/guides/changesets/releases.md @@ -0,0 +1,85 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Changesets +title: Releasing Changesets +desc: Associating changes to version numbers +index: 3 +--- + +To be available to clients, Changesets added to the schema with `use GraphQL::Enterprise::Changeset::Release changesets_dir: "..."`: + +```ruby +class MyAppSchema < GraphQL::Schema + # Add this before root types so that newly-added types are also added to the schema + use GraphQL::Enterprise::Changeset::Release, changesets_dir: "app/graphql/changesets" + + query(...) + mutation(...) + subscription(...) +end +``` + +This attaches each Changeset defined in `app/graphql/changesets/*.rb` to the schema. (It assumes Rails conventions, where an underscored file like `app/graphql/changesets/add_some_feature.rb` contains a class like `Changesets::AddSomeFeature`.) + +{% callout warning %} + +Add `GraphQL::Enterprise::Changeset::Release` _before_ hooking up your root `query(...)`, `mutation(...)`, and `subscription(...)` types. Otherwise, the schema may not find links to types in new schema versions. + +{% endcallout %} + +Alternatively, Changesets can be explicitly attached using `changesets: [...]`, for example: + +```ruby +class MyAppSchema < GraphQL::Schema + use GraphQL::Enterprise::Changeset::Release, changesets: [ + Changesets::DeprecateRecipeFlag, + Changesets::RemoveRecipeFlag, + ] + # ... +end +``` + +Only changesets in the directory (or in the array) will be shown to clients. The `release ...` configuration in the changeset will be compared to `context[:changeset_version]` to determine if the changeset applies to the current request. + +## Inspecting Releases + +To preview releases, you can create schema dumps by passing `context: { changeset_version: ... }` to {{ "Schema.to_definition" | api_doc }}. + +For example, to see how the schema looks with `API-Version: 2021-06-01`: + +```ruby +schema_sdl = MyAppSchema.to_definition(context: { changeset_version: "2021-06-01"}) +# The GraphQL schema definition for the schema at version "2021-06-01": +puts schema_sdl +``` + +To make sure schema versions don't change unexpectedly, use the techniques described in the {% internal_link "Schema structure guide", "/testing/schema_structure" %}. + +### Introspection Methods + +You can also inspect a schema's changesets programmatically. `GraphQL::Enterprise` adds a `Schema.changesets` method which returns a `Set` of changeset classes: + +```ruby +MySchema.changesets +# # +``` + +Additionally, each changeset has a `.changes` method describing its modifications: + +```ruby +AddNewFeature.changes +# [ +# #, +# #, +# #, +# ... +# ] +``` + +Each `Change` object responds to: + +- `.member`, the part of the schema that was modified +- `.type`, the kind of modification (`:addition` when something new is added, `:removal` when a member is removed or replaced with a new definition) diff --git a/guides/css/main.scss b/guides/css/main.scss index 9ca4eac5cb2..a4d980d0416 100644 --- a/guides/css/main.scss +++ b/guides/css/main.scss @@ -1,31 +1,62 @@ --- --- -@import "reset"; +@use "reset"; $brand-color: #a5152a; +$dark-theme-brand-color: #e5534b; $brand-color-light: #ed8090; $brand-color-extralight: #f9e8ee; -$experimental-background: #fff7cf; -$experimental-color: #655400; -$class-based-background: #c1f6ff; -$class-based-color: #003a58; +$dark-theme-brand-color-extralight: #262324; + +$experimental-color: #91812f; +$experimental-background: hsla(50, 100%, 32%, 0.15); + $pro-color: #406db5; -$pro-background: #f1f4f9; -$faint-color: #f0f0f0; +$pro-background: hsla(217, 100%, 29%, 0.15); + +$enterprise-color: #238c44; +$enterprise-background: hsla(135, 97%, 25%, 0.15); + +$dark-theme-code-border: #aaaaaa; $code-border: #d6d6d6; +$dark-theme-code-background: #1e1b1b; $code-background: #fafafa; +$dark-theme-code-color: #b5b5b5; $code-color: #777777; $code-border-radius: 2px; + $muted-color: #777777; $subtle-color: #aaaaaa; $font: 'Rubik', sans-serif; -$font-color: black; $code-font: 'Monaco', monospace; +$faint-color: #f0f0f0; +$dark-theme-faint-color: #6a6969; + +$font-color: black; +$dark-theme-font-color: #dbdbdb; + +$background-color: #fafafa; +$dark-theme-background-color: #422e2e; + +$container-color: white; +$dark-theme-container-color: #424242; + body { font-family: $font; - background: #fafafa; + background: $background-color; + .dark-theme-button::after { + content: "☀" + } +} + +body.dark-theme { + background: $dark-theme-code-background; + color: $dark-theme-font-color; + .dark-theme-button::after { + content: "☽" + } } strong, b { @@ -38,11 +69,21 @@ strong, b { font-weight: bold; } +.dark-theme { + .header { + background: $dark-theme-container-color; + box-shadow: 0px 0px 10px 0px black; + .nav a:hover { + color: $font-color; + background-color: $dark-theme-brand-color; + } + } +} .header { box-shadow: 0px 0px 10px 0px #d6d6d6; z-index: 1; position: relative; - background: white; + background: $container-color; .nav { $height: 30px; $margin: 10px; @@ -51,6 +92,11 @@ strong, b { align-items: center; $fade-time: 0.2s; + .nav-links { + margin-left: auto; + display: flex; + } + .img-link { transition: background $fade-time; @@ -77,25 +123,56 @@ strong, b { a, span { transition: background-color $fade-time; + transition: color $fade-time; padding: $margin; height: $height; display: flex; align-items: center; text-decoration: none; + &:hover { + background-color: $brand-color; + color: white; + } } } } .header-container { - max-width: 1040px; - margin: 0px auto; + margin: 0px 20px 0px 20px; } .container { - max-width: 1000px; + max-width: 1200px; margin: 0px auto; padding: 10px 20px; - background: white; + background: $container-color; + &.fullwidth { + max-width: 100%; + margin: 0px 20px 0px 20px; + } +} + +.dark-theme { + .container { + background: $dark-theme-container-color; + } +} + +.callout { + padding: 20px 20px 10px 20px; + margin: 20px; + border: 2px; + border-radius: 10px; + .heading { + font-size: 20px; + font-weight: bold; + margin-bottom: 20px; + } + + &.callout-warning { + background-color: rgba(255, 217, 0, 0.2); + border-color: rgba(255, 217, 0, 0.5); + } } pre { @@ -109,6 +186,13 @@ pre { line-height: 1.4rem; } +.dark-theme { + pre { + background-color: $dark-theme-code-background; + border: 1px solid $dark-theme-code-border; + } +} + p, li { line-height: 1.3rem; } @@ -127,23 +211,43 @@ ul { list-style-position: outside; } +ol { + list-style: decimal; + margin-left: 5px; +} + code { font-family: $code-font; color: $code-color; font-weight: 400; } +.dark-theme code { + color: $dark-theme-code-color; +} .code .line-numbers { display: none; } +.dark-theme a { + color: $dark-theme-brand-color; + border-color: $dark-theme-brand-color; + code { + color: $dark-theme-brand-color; + } +} + a { color: $brand-color; border-color: $brand-color; + text-decoration: none; + code { + color: $brand-color; + } } + a:hover, a:hover code { - color: white; - background-color: $brand-color; + text-decoration: underline; } #readme img { @@ -172,16 +276,6 @@ a:hover, a:hover code { overflow-x: scroll; } -.monitoring-img-group { - display: flex; - flex-direction: row; - margin-bottom: 20px; - flex-wrap: wrap; - justify-content: space-around; - align-items: center; -} - - .guides-toc { ul { list-style: none; @@ -195,9 +289,6 @@ a:hover, a:hover code { } } -.breadcrumb { - color: $muted-color; -} .guides { .guide-desc { @@ -224,6 +315,7 @@ a:hover, a:hover code { } a { color: $color; + text-decoration: underline; &:hover { background-color: $color; color: $background-color; @@ -235,14 +327,18 @@ a:hover, a:hover code { @include doc-header($experimental-color, $experimental-background); } +.pro-header { + @include doc-header($pro-color, $pro-background); +} -.class-based-header { - @include doc-header($class-based-color, $class-based-background); +.enterprise-header { + @include doc-header($enterprise-color, $enterprise-background); } -.pro-header { - @include doc-header($pro-color, $pro-background); +.dark-theme .guide-footer { + background-color: $dark-theme-brand-color-extralight; } + .guide-footer { background: $brand-color-extralight; margin: 25px 0px 0px 0px; @@ -250,6 +346,22 @@ a:hover, a:hover code { border-radius: $code-border-radius; } + +.dark-theme { + .hero { + .hero-part { + &.shaded { + background: $dark-theme-faint-color; + } + + h2 { + color: $dark-theme-brand-color; + text-shadow: $dark-theme-background-color 1px 1px 1px; + } + } + } +} + .hero { display: flex; flex-direction: column; @@ -264,8 +376,18 @@ a:hover, a:hover code { } } + .hero-subtitle { + padding: 10px 0px; + p { + margin: 5px auto; + text-align: center; + } + } + .hero-part { - padding: 10px; + display: flex; + justify-content: space-between; + flex-wrap: wrap; &.shaded { background: $faint-color; @@ -276,24 +398,11 @@ a:hover, a:hover code { text-shadow: #cccccc 1px 1px 1px; font-size: 1.4em; } - } - .hero-feature { - display: flex; - flex-basis: 50%; - flex-grow: 0; - flex-shrink: 0; - justify-content: space-between; - - .teaser p { - font-size: 1.2em; - line-height: 2em; - } - .teaser:first-child { - margin-right: 10px; - } - .teaser:last-child { - margin-left: 10px; + .hero-feature { + padding: 15px; + flex-basis: calc(50% - 60px); + flex-grow: 1; } } } @@ -328,14 +437,36 @@ table { } } +.dark-theme { + .search-input { + background: $dark-theme-code-background; + color: $dark-theme-font-color; + } + .search-results-container { + background-color: $dark-theme-background-color; + #search-results { + .search-result { + &:focus, &:hover { + background-color: $dark-theme-container-color; + border-bottom-color: $dark-theme-brand-color; + .search-title { + color: $dark-theme-brand-color; + } + } + .search-category { + border: 1px solid $dark-theme-brand-color; + color: $dark-theme-brand-color; + } + } + } + } +} .search-input { font-size: 1em; padding: 5px; margin: 10px; border: 1px solid $subtle-color; border-radius: 3px; - // make it float right in a flex container - margin-left: auto; } .search-results-container { @@ -379,18 +510,109 @@ table { &:focus, &:hover { outline: none; + background-color: $bg-highlight; + border-bottom-color: $brand-color; .search-title { color: $brand-color; } - background-color: $bg-highlight; } + } + } +} - &:hover { - border-bottom-color: $brand-color; +.dark-theme ul.breadcrumb .jump-to-select { + color: $dark-theme-brand-color; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='292.4' height='292.4'%3E%3Cpath fill='%23e5534b' d='M287 69.4a17.6 17.6 0 0 0-13-5.4H18.4c-5 0-9.3 1.8-12.9 5.4A17.6 17.6 0 0 0 0 82.2c0 5 1.8 9.3 5.4 12.9l128 127.9c3.6 3.6 7.8 5.4 12.8 5.4s9.2-1.8 12.8-5.4L287 95c3.5-3.5 5.4-7.8 5.4-12.8 0-5-1.9-9.2-5.5-12.8z'/%3E%3C/svg%3E"); +} + +ul.breadcrumb { + color: $muted-color; + + li { + display: inline; + list-style: none; + margin: 0; + } + li:before { + content: "»"; + margin: 0px 4px 0px 2px; + } + li:first-child:before { + content: ""; + margin: 0; + } + + .jump-to-select { + box-sizing: border-box; + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + padding: 5px 20px 5px 5px; + border: 1px solid $code-border; + border-radius: 5px; + background-color: transparent; + color: $brand-color; + font-size: 16px; + font-weight: 500; + line-height: 1.3; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='292.4' height='292.4'%3E%3Cpath fill='%23a5152a' d='M287 69.4a17.6 17.6 0 0 0-13-5.4H18.4c-5 0-9.3 1.8-12.9 5.4A17.6 17.6 0 0 0 0 82.2c0 5 1.8 9.3 5.4 12.9l128 127.9c3.6 3.6 7.8 5.4 12.8 5.4s9.2-1.8 12.8-5.4L287 95c3.5-3.5 5.4-7.8 5.4-12.8 0-5-1.9-9.2-5.5-12.8z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 9px; + + cursor: default; + + &:hover { + border-color: #777; + } + + &:focus { + border-color: #999; + box-shadow: 0 0 1px 2px #6db4ff; + outline: none; + } + + + option { + color: black; + } + } +} + + +.dark-theme { + .table-of-contents { + background: $dark-theme-code-background; + } +} + +.table-of-contents { + float: right; + border: 1px solid $subtle-color; + border-radius: 3px; + padding: 15px; + margin: 0 10px 10px 10px; + width: 300px; + background: $code-background; + .contents-header { + margin: 0 0 5px 20px; + } + .contents-list { + margin: 0; + list-style: decimal; + padding-left: 5px; + .contents-entry { + &::marker { + color: $muted-color; + } + + .contents-entry { + list-style: none; } } } } + /* pygments CSS, github theme */ .highlight .hll { background-color: #ffffcc } .highlight .c { color: #999988; font-style: italic } /* Comment */ @@ -453,3 +675,73 @@ table { .highlight .vg { color: #008080 } /* Name.Variable.Global */ .highlight .vi { color: #008080 } /* Name.Variable.Instance */ .highlight .il { color: #009999 } /* Literal.Number.Integer.Long */ + + +.dark-theme { + .highlight .hll { background-color: #49483e } + pre.highlight { background: #272822; color: #f8f8f2 } + .highlight .c { color: #75715e } /* Comment */ + .highlight .err { color: #960050; background-color: #1e0010 } /* Error */ + .highlight .k { color: #66d9ef } /* Keyword */ + .highlight .l { color: #ae81ff } /* Literal */ + .highlight .n { color: #f8f8f2 } /* Name */ + .highlight .o { color: #f92672 } /* Operator */ + .highlight .p { color: #f8f8f2 } /* Punctuation */ + .highlight .ch { color: #75715e } /* Comment.Hashbang */ + .highlight .cm { color: #75715e } /* Comment.Multiline */ + .highlight .cp { color: #75715e } /* Comment.Preproc */ + .highlight .cpf { color: #75715e } /* Comment.PreprocFile */ + .highlight .c1 { color: #75715e } /* Comment.Single */ + .highlight .cs { color: #75715e } /* Comment.Special */ + .highlight .gd { color: #f92672; background-color: #5e4343; } /* Generic.Deleted */ + .highlight .ge { font-style: italic } /* Generic.Emph */ + .highlight .gi { color: #a6e22e; background-color: #475547; } /* Generic.Inserted */ + .highlight .gs { font-weight: bold } /* Generic.Strong */ + .highlight .gu { color: #75715e } /* Generic.Subheading */ + .highlight .kc { color: #66d9ef } /* Keyword.Constant */ + .highlight .kd { color: #66d9ef } /* Keyword.Declaration */ + .highlight .kn { color: #f92672 } /* Keyword.Namespace */ + .highlight .kp { color: #66d9ef } /* Keyword.Pseudo */ + .highlight .kr { color: #66d9ef } /* Keyword.Reserved */ + .highlight .kt { color: #66d9ef } /* Keyword.Type */ + .highlight .ld { color: #e6db74 } /* Literal.Date */ + .highlight .m { color: #ae81ff } /* Literal.Number */ + .highlight .s { color: #e6db74 } /* Literal.String */ + .highlight .na { color: #a6e22e } /* Name.Attribute */ + .highlight .nb { color: #f8f8f2 } /* Name.Builtin */ + .highlight .nc { color: #a6e22e } /* Name.Class */ + .highlight .no { color: #66d9ef } /* Name.Constant */ + .highlight .nd { color: #a6e22e } /* Name.Decorator */ + .highlight .ni { color: #f8f8f2 } /* Name.Entity */ + .highlight .ne { color: #a6e22e } /* Name.Exception */ + .highlight .nf { color: #a6e22e } /* Name.Function */ + .highlight .nl { color: #f8f8f2 } /* Name.Label */ + .highlight .nn { color: #f8f8f2 } /* Name.Namespace */ + .highlight .nx { color: #a6e22e } /* Name.Other */ + .highlight .py { color: #f8f8f2 } /* Name.Property */ + .highlight .nt { color: #f92672 } /* Name.Tag */ + .highlight .nv { color: #f8f8f2 } /* Name.Variable */ + .highlight .ow { color: #f92672 } /* Operator.Word */ + .highlight .w { color: #f8f8f2 } /* Text.Whitespace */ + .highlight .mb { color: #ae81ff } /* Literal.Number.Bin */ + .highlight .mf { color: #ae81ff } /* Literal.Number.Float */ + .highlight .mh { color: #ae81ff } /* Literal.Number.Hex */ + .highlight .mi { color: #ae81ff } /* Literal.Number.Integer */ + .highlight .mo { color: #ae81ff } /* Literal.Number.Oct */ + .highlight .sb { color: #e6db74 } /* Literal.String.Backtick */ + .highlight .sc { color: #e6db74 } /* Literal.String.Char */ + .highlight .sd { color: #e6db74 } /* Literal.String.Doc */ + .highlight .s2 { color: #e6db74 } /* Literal.String.Double */ + .highlight .se { color: #ae81ff } /* Literal.String.Escape */ + .highlight .sh { color: #e6db74 } /* Literal.String.Heredoc */ + .highlight .si { color: #e6db74 } /* Literal.String.Interpol */ + .highlight .sx { color: #e6db74 } /* Literal.String.Other */ + .highlight .sr { color: #e6db74 } /* Literal.String.Regex */ + .highlight .s1 { color: #e6db74 } /* Literal.String.Single */ + .highlight .ss { color: #e6db74 } /* Literal.String.Symbol */ + .highlight .bp { color: #f8f8f2 } /* Name.Builtin.Pseudo */ + .highlight .vc { color: #f8f8f2 } /* Name.Variable.Class */ + .highlight .vg { color: #f8f8f2 } /* Name.Variable.Global */ + .highlight .vi { color: #f8f8f2 } /* Name.Variable.Instance */ + .highlight .il { color: #ae81ff } /* Literal.Number.Integer.Long */ +} diff --git a/guides/dataloader/adopting.md b/guides/dataloader/adopting.md index 3481602b911..c7f78d0ec24 100644 --- a/guides/dataloader/adopting.md +++ b/guides/dataloader/adopting.md @@ -5,7 +5,6 @@ section: Dataloader title: Dataloader vs. GraphQL-Batch desc: Comparing and Contrasting Batch Loading Options index: 3 -experimental: true --- {{ "GraphQL::Dataloader" | api_doc }} solves the same problem as [`GraphQL::Batch`](https://github.com/shopify/graphql-batch). There are a few major differences between the modules: @@ -15,7 +14,7 @@ experimental: true - __Maturity:__ Frankly, GraphQL-Batch is about as old as GraphQL-Ruby, and it's been in production at Shopify, GitHub, and others for many years. GraphQL::Dataloader is new, and although Ruby has supported `Fiber`s since 1.9, they still aren't widely used. - __Scope:__ It's not currently possible to use `GraphQL::Dataloader` _outside_ GraphQL. -The incentive in writing `GraphQL::Dataloader` was to leverage `Fiber`'s ability to _transparently_ pause and resume work, which removes the need for `Promise`s (and removes the resulting complexity in the code). Additionally, `GraphQL::Dataloader` shoulde _eventually_ support Ruby 3.0's `Fiber.scheduler` API, which runs I/O in the background by default. +The incentive in writing `GraphQL::Dataloader` was to leverage `Fiber`'s ability to _transparently_ pause and resume work, which removes the need for `Promise`s (and removes the resulting complexity in the code). Additionally, `GraphQL::Dataloader` should _eventually_ support Ruby 3.0's `Fiber.scheduler` API, which runs I/O in the background by default. ## Comparison: Fetching a single object @@ -27,7 +26,7 @@ In this example, a single object is batch-loaded to satisfy a GraphQL field. record_promise = Loaders::Record.load(1) ``` - Then, under the hood, GraphQL-Ruby manages the promise (using its `lazy_resolve` feature, upstreamed from GraphQL-Batch many years ago). GraphQL-Ruby will call `.sync` on it when no futher execution is possible; `promise.rb` implements `Promise#sync` to execute the pending work. + Then, under the hood, GraphQL-Ruby manages the promise (using its `lazy_resolve` feature, upstreamed from GraphQL-Batch many years ago). GraphQL-Ruby will call `.sync` on it when no further execution is possible; `promise.rb` implements `Promise#sync` to execute the pending work. - With __GraphQL::Dataloader__, you get a source, then call `.load` on it, which may pause the current Fiber, but it returns the requested object. @@ -51,7 +50,7 @@ In this example, one object is loaded, then another object is loaded _based on_ That call returns a `Promise`, which is stored by GraphQL-Ruby, and finally `.sync`ed. -- With __GraphQL-Dataloader__, `.load(...)` returns the requested object (after a potential `Fiber` pause), so other method calls are necessary: +- With __GraphQL-Dataloader__, `.load(...)` returns the requested object (after a potential `Fiber` pause), so no other method calls are necessary: ```ruby record = dataloader.with(Sources::Record).load(1) @@ -60,7 +59,7 @@ In this example, one object is loaded, then another object is loaded _based on_ ## Comparison: Fetching objects concurrently (independent) -Sometimes, you need multiple _independent_ records to perform a calcuation. Each record is loaded, then they're combined in some bit of work. +Sometimes, you need multiple _independent_ records to perform a calculation. Each record is loaded, then they're combined in some bit of work. - With __GraphQL-Batch__, `Promise.all(...)` is used to to wait for several pending loads: diff --git a/guides/dataloader/async_dataloader.md b/guides/dataloader/async_dataloader.md new file mode 100644 index 00000000000..f725602c9a2 --- /dev/null +++ b/guides/dataloader/async_dataloader.md @@ -0,0 +1,80 @@ +--- +layout: guide +search: true +section: Dataloader +title: Async Source Execution +desc: Using AsyncDataloader to fetch external data in parallel +index: 5 +--- + +`AsyncDataloader` will run {{ "GraphQL::Dataloader::Source#fetch" | api_doc }} calls in parallel, so that external service calls (like database queries or network calls) don't have to wait in a queue. + +To use `AsyncDataloader`, hook it up in your schema _instead of_ `GraphQL::Dataloader`: + +```diff +- use GraphQL::Dataloader ++ use GraphQL::Dataloader::AsyncDataloader +``` + +__Also__, add [the `async` gem](https://github.com/socketry/async) to your project, for example: + +``` +bundle add async +``` + +Now, {{ "GraphQL::Dataloader::AsyncDataloader" | api_doc }} will create `Async::Task` instances instead of plain `Fiber`s and the `async` gem will manage parallelism. + +For a demonstration of this behavior, see: [https://github.com/rmosolgo/rails-graphql-async-demo](https://github.com/rmosolgo/rails-graphql-async-demo) + +_You can also implement {% internal_link "manual parallelism", "/dataloader/parallelism" %} using `dataloader.yield`._ + +## Rails + +For Rails, you'll need **Rails 7.1**, which properly supports fiber-based concurrency, and you'll also want to configure Rails to use Fibers for isolation: + +```ruby +class Application < Rails::Application + # ... + config.active_support.isolation_level = :fiber +end +``` +### ActiveRecord Connections + +You can use Dataloader's {% internal_link "Fiber lifecycle hooks", "/dataloader/dataloader#fiber-lifecycle-hooks" %} to improve ActiveRecord connection handling: + +- In Rails < 7.2, connections are not reused when a Fiber exits; instead, they're only reused when a request or background job finishes. You can add manual `release_connection` calls to improve this. +- With `isolation_level = :fiber`, new Fibers don't inherit `connected_to ...` settings from their parent fibers. + +Altogether, it can be improved like this: + +```ruby +def get_fiber_variables + vars = super + # Collect the current connection config to pass on: + vars[:connected_to] = { + role: ActiveRecord::Base.current_role, + shard: ActiveRecord::Base.current_shard, + prevent_writes: ActiveRecord::Base.current_preventing_writes + } + vars +end + +def set_fiber_variables(vars) + connection_config = vars.delete(:connected_to) + # Reset connection config from the parent fiber: + ActiveRecord::Base.connecting_to(**connection_config) + super(vars) +end + +def cleanup_fiber + super + # Release the current connection + ActiveRecord::Base.connection_pool.release_connection +end +``` + +Modify the example according to your database configuration and abstract class hierarchy. + +## Other Options + +You can also manually implement parallelism with Dataloader. See the {% internal_link "Dataloader Parallelism", "/dataloader/parallelism" %} guide for details. diff --git a/guides/dataloader/dataloader.md b/guides/dataloader/dataloader.md index b4a343beac6..bde06aba0f4 100644 --- a/guides/dataloader/dataloader.md +++ b/guides/dataloader/dataloader.md @@ -5,7 +5,6 @@ section: Dataloader title: Dataloader desc: The Dataloader orchestrates Fibers and Sources index: 2 -experimental: true --- {{ "GraphQL::Dataloader" | api_doc }} instances are created for each query (or multiplex) and they: @@ -18,3 +17,26 @@ During a query, you can access the dataloader instance with: - {{ "GraphQL::Query::Context#dataloader" | api_doc }} (`context.dataloader`, anywhere that query context is available) - {{ "GraphQL::Schema::Object#dataloader" | api_doc }} (`dataloader` inside a resolver method) - {{ "GraphQL::Schema::Resolver#dataloader" | api_doc }} (`dataloader` inside `def resolve` of a Resolver, Mutation, or Subscription class.) + +## Fiber Lifecycle Hooks + +Under the hood, `Dataloader` creates Fibers as-needed and uses them to run GraphQL and load data from `Source` classes. You can hook into these Fibers through several lifecycle hooks. To implement these hooks, create a custom subclass and provide new implementation for these methods: + +```ruby +class MyDataloader < GraphQL::Dataloader # or GraphQL::Dataloader::AsyncDataloader + # ... +end +``` + +Then, use your customized dataloader instead of the built-in one: + +```diff + class MySchema < GraphQL::Schema +- use GraphQL::Dataloader ++ use MyDataloader + end +``` + +- __{{ "GraphQL::Dataloader#get_fiber_variables" | api_doc }}__ is called before creating a Fiber. By default, it returns a hash containing the parent Fiber's variables (from `Thread.current[...]`). You can add to this hash in your own implementation of this method. +- __{{ "GraphQL::Dataloader#set_fiber_variables" | api_doc }}__ is called inside the new Fiber. It's passed the hash returned from `get_fiber_variables`. You can use this method to initialize "global" state inside the new Fiber. +- __{{ "GraphQL::Dataloader#cleanup_fiber" | api_doc }}__ is called just before a Dataloader Fiber exits. You can use this methods to teardown any state that you prepared in `set_fiber_variables`. diff --git a/guides/dataloader/overview.md b/guides/dataloader/overview.md index d08ecdcca50..e842ae94220 100644 --- a/guides/dataloader/overview.md +++ b/guides/dataloader/overview.md @@ -5,10 +5,9 @@ section: Dataloader title: Overview desc: Getting started with the Fiber-based Dataloader index: 0 -experimental: true --- -GraphQL-Ruby 1.12 includes {{ "GraphQL::Dataloader" | api_doc }}, a module for managing efficient database access in a way that's transparent to application code, backed by Ruby's `Fiber` concurrency primitive. + {{ "GraphQL::Dataloader" | api_doc }} provides efficient, batched access to external services, backed by Ruby's `Fiber` concurrency primitive. It has a per-query result cache and {% internal_link "AsyncDataloader", "/dataloader/async_dataloader" %} supports truly parallel execution out-of-the-box. `GraphQL::Dataloader` is inspired by [`@bessey`'s proof-of-concept](https://github.com/bessey/graphql-fiber-test/tree/no-gem-changes) and [shopify/graphql-batch](https://github.com/shopify/graphql-batch). @@ -35,6 +34,8 @@ At a high level, `GraphQL::Dataloader`'s usage of `Fiber` looks like this: Whenever `GraphQL::Dataloader` creates a new `Fiber`, it copies each pair from `Thread.current[...]` and reassigns them inside the new `Fiber`. +`AsyncDataloader`, built on top of the [`async` gem](https://github.com/socketry/async), supports parallel I/O operations (like network and database communication) via Ruby's non-blocking `Fiber.schedule` API. {% internal_link "Learn more →", "/dataloader/async_dataloader" %}. + ## Getting Started To install {{ "GraphQL::Dataloader" | api_doc }}, add it to your schema with `use ...`, for example: @@ -49,8 +50,8 @@ end Then, inside your schema, you can request batch-loaded objects by their lookup key with `dataloader.with(...).load(...)`: ```ruby -field :user, Types::User, null: true do - argument :handle, String, required: true +field :user, Types::User do + argument :handle, String end def user(handle:) @@ -62,8 +63,8 @@ Or, load several objects by passing an array of lookup keys to `.load_all(...)`: ```ruby field :is_following, Boolean, null: false do - argument :follower_handle, String, required: true - argument :followed_handle, String, required: true + argument :follower_handle, String + argument :followed_handle, String end def is_following(follower_handle:, followed_handle:) @@ -79,10 +80,10 @@ To prepare requests from several sources, use `.request(...)`, then call `.load` ```ruby class AddToList < GraphQL::Schema::Mutation - argument :handle, String, required: true - argument :list, String, required: true, as: :list_name + argument :handle, String + argument :list, String, as: :list_name - field :list, Types::UserList, null: true + field :list, Types::UserList def resolve(handle:, list_name:) # first, register the requests: @@ -115,9 +116,9 @@ Then, any arguments with `loads:` will use that method to fetch objects. For exa ```ruby class FollowUser < GraphQL::Schema::Mutation - argument :follow_id, ID, required: true, loads: Types::User + argument :follow_id, ID, loads: Types::User - field :followed, Types::User, null: true + field :followed, Types::User def resolve(follow:) # `follow` was fetched using the Schema's `object_from_id` hook @@ -130,3 +131,10 @@ end ## Data Sources To implement batch-loading data sources, see the {% internal_link "Sources guide", "/dataloader/sources" %}. + +## Parallelism + +You can run I/O operations in parallel with GraphQL::Dataloader. There are two approaches: + +- `AsyncDataloader` uses the `async` gem to automatically background I/O from `Dataloader::Source#fetch` calls. {% internal_link "Read More", "/dataloader/async_dataloader" %} +- You can manually call `dataloader.yield` after starting work in the background. {% internal_link "Read More", "/dataloader/parallelism" %} diff --git a/guides/dataloader/parallelism.md b/guides/dataloader/parallelism.md new file mode 100644 index 00000000000..a0634bb7bf4 --- /dev/null +++ b/guides/dataloader/parallelism.md @@ -0,0 +1,105 @@ +--- +layout: guide +search: true +section: Dataloader +title: Manual Parallelism +desc: Yield to Dataloader after starting work +index: 7 +--- + +You can coordinate with {{ "GraphQL::Dataloader" | api_doc }} to run tasks in the background. To do this, call `dataloader.yield` inside `Source#fetch` after kicking off your task. For example: + +```ruby +def fetch(ids) + # somehow queue up a background query, + # see examples below + future_result = async_query_for(ids) + # return control to the dataloader + dataloader.yield + # dataloader will come back here + # after calling other sources, + # now wait for the value + future_result.value +end +``` + +_Alternatively, you can use {% internal_link "AsyncDataloader", "/dataloader/async_dataloader" %} to automatically background I/O inside `Source#fetch` calls._ + +## Example: Rails load_async + +You can use Rails's `load_async` method to load `ActiveRecord::Relation`s in the background. For example: + +```ruby +class Sources::AsyncRelationSource < GraphQL::Dataloader::Source + def fetch(relations) + relations.each(&:load_async) # start loading them in the background + dataloader.yield # hand back to GraphQL::Dataloader + relations.each(&:load) # now, wait for the result, returning the now-loaded relation + end +end +``` + +You could call that source from a GraphQL field method: + +```ruby +field :direct_reports, [Person] + +def direct_reports + # prepare an ActiveRecord::Relation: + direct_reports = Person.where(manager: object) + # pass it off to the source: + dataloader + .with(Sources::AsyncRelationSource) + .load(direct_reports) +end +``` + +## Example: Rails async calculations + +In a Dataloader source, you can run Rails async calculations in the background while other work continues. For example: + +```ruby +class Sources::DirectReportsCount < GraphQL::Dataloader::Source + def fetch(users) + # Start the queries in the background: + promises = users.map { |u| u.direct_reports.async_count } + # Return to GraphQL::Dataloader: + dataloader.yield + # Now return the results, waiting if necessary: + promises.map(&:value) + end +end +``` + +Which could be used in a GraphQL field: + +```ruby +field :direct_reports_count, Int + +def direct_reports_count + dataloader.with(Sources::DirectReportsCount).load(object) +end +``` + +## Example: Concurrent::Future + +You could use `concurrent-ruby` to put work in a background thread. For example, using `Concurrent::Future`: + +```ruby +class Sources::ExternalDataSource < GraphQL::Dataloader::Source + def fetch(urls) + # Start some I/O-intensive work: + futures = urls.map do |url| + Concurrent::Future.execute { + # Somehow fetch and parse data: + get_remote_json(url) + } + end + # Yield back to GraphQL::Dataloader: + dataloader.yield + # Dataloader has done what it can, + # so now return the value, waiting if necessary: + futures.map(&:value) + end +end +``` diff --git a/guides/dataloader/sources.md b/guides/dataloader/sources.md index 183adfed3c1..0dc4b03ddb8 100644 --- a/guides/dataloader/sources.md +++ b/guides/dataloader/sources.md @@ -5,14 +5,13 @@ section: Dataloader title: Sources desc: Batch-loading objects for GraphQL::Dataloader index: 1 -experimental: true --- _Sources_ are what {{ "GraphQL::Dataloader" | api_doc }} uses to fetch data from external services. ## Source Concepts -Sources are classes that inherit from `GraphQL::Dataloader::Source`. A Source _must_ implement `def fetch(keys)` to return a list of objects, one for each of the given keys. A source _may_ implement `def initialize(dataloader, ...)` to accept other batching parameters. +Sources are classes that inherit from `GraphQL::Dataloader::Source`. A Source _must_ implement `def fetch(keys)` to return a list of objects, one for each of the given keys. A source _may_ implement `def initialize(...)` to accept other batching parameters. Sources will receive two kinds of inputs from `GraphQL::Dataloader`: @@ -22,12 +21,14 @@ Sources will receive two kinds of inputs from `GraphQL::Dataloader`: Under the hood, each Source instance maintains a `key => object` cache. -- _batch parameters_, which are the basis of batched groups. For example, if you're loading records from different database tables, the the table name would be a batch parameter. +- _batch parameters_, which are the basis of batched groups. For example, if you're loading records from different database tables, the table name would be a batch parameter. - Batch parameters are given to `dataloader.with(source_class, *batch_parameters)`, and the default is _no batch parameters_. When you define a source, you should add the batch parameters to `def initialize(dataloader, ...)` and store them in instance variables. + Batch parameters are given to `dataloader.with(source_class, *batch_parameters)`, and the default is _no batch parameters_. When you define a source, you should add the batch parameters to `def initialize(...)` and store them in instance variables. (`dataloader.with(source_class, *batch_parameters)` returns an instance of `source_class` with the given batch parameters -- but it might be an instance which was cached by `dataloader`.) + Additionally, batch parameters are used to de-duplicate Source initializations during a query run. `.with(...)` calls that have the same batch parameters will use the same Source instance under the hood. To customize how Sources are de-duplicated, see {{ "GraphQL::Dataloader::Source.batch_key_for" | api_doc }}. + ## Example: Loading Strings from Redis by Key The simplest source might fetch values based on their keys. For example: @@ -131,6 +132,45 @@ def fetch(keys) end ``` -For a more robust asynchronous task primitive, check out [`Concurrent::Future`](http://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/Future.html). +See the {% internal_link "parallelism guide", "/dataloader/parallelism" %} for details about this approach. + +## Filling the Dataloader Cache + +If you load records from the database, you can use them to populate a source's cache by using {{ "Dataloader::Source#merge" | api_doc }}. For example: + +```ruby +# Build a `{ key => value }` map to populate the cache +comments_by_id = post.comments.each_with_object({}) { |comment, hash| hash[comment.id] = comment } +# Merge the map into the source's cache +dataloader.with(Sources::ActiveRecordObject, Comment).merge(comments_by_id) +``` -Ruby 3.0 added built-in support for yielding Fibers that make I/O calls -- hopefully a future GraphQL-Ruby version will work with that! +After that, any calls to `.load(id)` will use those already-loaded records if they're available. + +## De-duplicating equivalent objects + +Sometimes, _different_ objects in the application should load the same object from `fetch`. You can customize this behavior by implementing `def result_key_for(key)` in your application. For example, to map records from your ORM to their database ID: + +```ruby +# Load the `created_by` person for a record from our database +class CreatedBySource < GraphQL::Dataloader::Source + def result_key_for(key) + key.id # Use the record's ID to deduplicate different `.load` calls + end + + # Fetch a `person` for each of `records`, based on their created_by_id + def fetch(records) + PersonService.find_each(records.map(&:created_by_id)) + end +end +``` + +In this case, `records` will include the _first_ object for each unique `record.id` -- subsequent records with the same `.id` will be assumed to be duplicates. Under the hood, the `Source` will cache the result based on the record's `id`. + +Alternatively, you could use this to make the `Source` retain each incoming object, even when they would _otherwise_ be treated as duplicates. (This would come in handy when you need `def fetch` to mutate each object). For example, to treat _every_ incoming object as distinct: + +```ruby +def result_key_for(record) + record.object_id # even if the records are equivalent, handle each distinct Ruby object separately +end +``` diff --git a/guides/dataloader/testing.md b/guides/dataloader/testing.md new file mode 100644 index 00000000000..5742c6b897b --- /dev/null +++ b/guides/dataloader/testing.md @@ -0,0 +1,87 @@ +--- +layout: guide +search: true +section: Dataloader +title: Testing +desc: Tips for testing Dataloader implementation +index: 4 +--- + +There are a few techniques for testing your {{ "GraphQL::Dataloader" | api_doc }} setup. + +## Integration Tests + +One important feature of `Dataloader` is how it manages database access while GraphQL runs queries. You can test that by listening for database queries while running queries, for example, with ActiveRecord: + + +```ruby +def test_active_record_queries_are_batched_and_cached + # set up a listener function + database_queries = 0 + callback = lambda {|_name, _started, _finished, _unique_id, _payload| database_queries += 1 } + + query_str = <<-GRAPHQL + { + a1: author(id: 1) { name } + a2: author(id: 2) { name } + b1: book(id: 1) { author { name } } + b2: book(id: 2) { author { name } } + } + GRAPHQL + + # Run the query with the listener + ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + MySchema.execute(query_str) + end + + # One query for authors, one query for books + assert_equal 2, database_queries +end +``` + +You could also make specific assertions on the queries that are run (see the [`sql.active_record` docs](https://edgeguides.rubyonrails.org/active_support_instrumentation.html#active-record)). For other frameworks and databases, check your ORM or library for instrumentation options. + +## Testing Dataloader Sources + +You can also test `Dataloader` behavior outside of GraphQL using {{ "GraphQL::Dataloader.with_dataloading" | api_doc }}. For example, let's say you have a `Sources::ActiveRecord` source defined like so: + +```ruby + +module Sources + class User < GraphQL::Dataloader::Source + def fetch(ids) + records = User.where(id: ids) + # return a list with `nil` for any ID that wasn't found, so the shape matches + ids.map { |id| records.find { |r| r.id == id.to_i } } + end + end +end +``` + +You can test it like so: + +```ruby +def test_it_fetches_objects_by_id + user_1, user_2, user_3 = 3.times.map { User.create! } + + database_queries = 0 + callback = lambda {|_name, _started, _finished, _unique_id, _payload| database_queries += 1 } + + ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + GraphQL::Dataloader.with_dataloading do |dataloader| + req1 = dataloader.with(Sources::ActiveRecord).request(user_1.id) + req2 = dataloader.with(Sources::ActiveRecord).request(user_2.id) + req3 = dataloader.with(Sources::ActiveRecord).request(user_3.id) + req4 = dataloader.with(Sources::ActiveRecord).request(-1) + + # Validate source's matching up of records + expect(req1.load).to eq(user_1) + expect(req2.load).to eq(user_2) + expect(req3.load).to eq(user_3) + expect(req4.load).to be_nil + end + end + + assert_equal 1, database_queries, "All users were looked up at once" +end +``` diff --git a/guides/defer/defer-graphiql-gif.gif b/guides/defer/defer-graphiql-gif.gif new file mode 100644 index 00000000000..db542e19dba Binary files /dev/null and b/guides/defer/defer-graphiql-gif.gif differ diff --git a/guides/defer/graphiql.md b/guides/defer/graphiql.md new file mode 100644 index 00000000000..661abeeabfa --- /dev/null +++ b/guides/defer/graphiql.md @@ -0,0 +1,72 @@ +--- +layout: guide +doc_stub: false +search: true +section: GraphQL Pro - Defer +title: Use with GraphiQL +desc: Using @defer with the GraphiQL IDE +index: 4 +pro: true +--- + +You can use `@defer` and `@stream` with [GraphiQL](https://github.com/graphql/graphiql/blob/main/packages/graphiql/README.md), an in-browser IDE. + +Using @defer with GraphiQL + +## Incremental responses + +If you're using the proposed `incremental: ...` response syntax ([proposal](https://github.com/graphql/graphql-spec/pull/742), [Ruby support](/defer/setup.html#example-rails-with-apollo-client)), you'll need a custom "fetcher" function to handle the `incremental: ...` part of the response. For example: + +```js +import { meros } from "meros"; // for handling multipart responses + +const customFetcher = async function* (graphqlParams, fetcherOpts) { + // Make the initial fetch + var result = await fetch("/graphql", { + method: "POST", + body: JSON.stringify(graphqlParams), + headers: { + 'content-type': 'application/json', + } + }).then((r) => { + // Use meros to turn multipart responses into streams + return meros(r, { multiple: true }) + }) + + if (!isAsyncIterable(result)) { + // Return plain responses as promises + return result.json() + } else { + // Handle multipart responses one chunk at a time + for await (const chunk of result) { + yield chunk.map(part => { + // Move the incremental part of the response into top-level + // This assumes there's only one `incremental` entry + // which is currently true for GraphQL-Pro's @defer implementation + var newJson = {...part.body} + if (newJson.incremental) { + newJson.data = newJson.incremental[0].data + newJson.path = newJson.incremental[0].path + delete newJson.incremental + } + return newJson + }); + } + } +} + +// Helper for checking for a multipart response: +function isAsyncIterable(input) { + return ( + typeof input === "object" && + input !== null && + ( + input[Symbol.toStringTag] === "AsyncGenerator" || + (Symbol.asyncIterator && Symbol.asyncIterator in input) + ) + ); +} + +``` + +Hopefully a new GraphiQL version will support this out of the box; follow the [issue on GitHub](https://github.com/graphql/graphiql/issues/3470). diff --git a/guides/defer/overview.md b/guides/defer/overview.md index 8d35990d7cc..0b58e7b915d 100644 --- a/guides/defer/overview.md +++ b/guides/defer/overview.md @@ -15,7 +15,7 @@ By streaming the response, the server can send the most critical (or most availa `@defer` was first described by [Lee Byron at React Europe 2015](https://youtu.be/ViXL0YQnioU?t=768) and got experimental support in [Apollo in 2018](https://blog.apollographql.com/introducing-defer-in-apollo-server-f6797c4e9d6e). -`@defer` requires the new {% internal_link "interpreter runtime", "/queries/interpreter" %} which ships with GraphQL-Ruby 1.9+. +`@stream` is like `@defer`, but it returns list items one at a time. Find details in the {% internal_link "Stream guide", "/defer/stream" %}. ## Example diff --git a/guides/defer/setup.md b/guides/defer/setup.md index bf92070016b..fc8895c3bf7 100644 --- a/guides/defer/setup.md +++ b/guides/defer/setup.md @@ -14,18 +14,17 @@ Before using `@defer` in queries, you have to: - Update `graphql` and `graphql-pro` gems - Add `@defer` to your GraphQL schema - Update your HTTP handlers (eg, Rails controllers) to send streaming responses +- Optionally, customize `@defer` to work with GraphQL-Batch You can also see a [full Rails & Apollo-Client demo](https://github.com/rmosolgo/graphql_defer_example). ## Updating the gems -`GraphQL::Pro::Defer` is included in `graphql-pro 1.10+`, and it requires the new {% internal_link "Interpreter runtime", "/queries/interpreter" %} in `graphql 1.9+`, so update your gemfile: +GraphQL-Ruby 1.9+ and GraphQL-Pro 1.10+ are required: ```ruby -# 1.9+ for Interpreter -gem "graphql", "~>1.9.0" -# 1.10+ for `@defer` -gem "graphql-pro", "~>1.10.0" +gem "graphql", "~>1.9" +gem "graphql-pro", "~>1.10" ``` And then install them: @@ -89,7 +88,7 @@ The initial result is _also_ present in the deferrals, so you can treat it just Each deferred patch has a few methods for building a response: - `.to_h` returns a hash with `path:`, `data:`, and/or `errors:`. (There is no `path:` for the root result.) -- `.to_http_multipart` returns a string which works with Apollo client's `@defer` support. +- `.to_http_multipart(incremental: true)` returns a string which works with Apollo client's `@defer` support. (Use `incremental: true` to format patches for the forthcoming spec.) - `.path` returns the path to this patch in the response - `.data` returns successfully-resolved results of the patch - `.errors` returns an array of errors, if there were any @@ -111,8 +110,10 @@ class GraphqlController < ApplicationController # Check if this is a deferred query: if (deferred = result.context[:defer]) + # Required for Rack 2.2+, see https://github.com/rack/rack/issues/1619 + response.headers['Last-Modified'] = Time.now.httpdate # Use built-in `stream_http_multipart` with Apollo-Client & ActionController::Live - deferred.stream_http_multipart(response) + deferred.stream_http_multipart(response, incremental: true) else # Return a plain, non-deferred result render json: result @@ -126,6 +127,43 @@ end You can also investigate a [full Rails & Apollo-Client demo](https://github.com/rmosolgo/graphql_defer_example) +## With GraphQL-Batch + +`GraphQL-Batch` is a third-party data loading library that wraps GraphQL-Ruby execution. Deferred resolution happens outside the normal execution flow, so to work with GraphQL-Batch, you have to customize `GraphQL::Pro::Defer` a bit. Also, you'll need GraphQL-Pro `v1.24.6` or later. Here's a custom `Defer` implementation: + +```ruby +# app/graphql/directives/defer.rb +module Directives + # Modify the library's `@defer` implementation to work with GraphQL-Batch + class Defer < GraphQL::Pro::Defer + def self.resolve(obj, arguments, context, &block) + # While the query is running, store the batch executor to re-use later + context[:graphql_batch_executor] ||= GraphQL::Batch::Executor.current + super + end + + class Deferral < GraphQL::Pro::Defer::Deferral + def resolve + # Before calling the deferred execution, + # set GraphQL-Batch back up: + prev_executor = GraphQL::Batch::Executor.current + GraphQL::Batch::Executor.current ||= @context[:graphql_batch_executor] + super + ensure + # Clean up afterward: + GraphQL::Batch::Executor.current = prev_executor + end + end + end +end +``` + +And update your schema to use your custom defer implementation: + +```ruby +# Use our GraphQL-Batch-compatible defer: +use Directives::Defer +``` ## Next Steps Read about {% internal_link "client usage", "/defer/usage" %} of `@defer`. diff --git a/guides/defer/stream.md b/guides/defer/stream.md new file mode 100644 index 00000000000..6dd3d1ac150 --- /dev/null +++ b/guides/defer/stream.md @@ -0,0 +1,49 @@ +--- +layout: guide +doc_stub: false +search: true +section: GraphQL Pro - Defer +title: Stream +desc: Using @stream to receive list items one at a time +index: 3 +pro: true +--- + +`@stream` works very much like `@defer`, except it only applies to list fields. When a field has `@stream` and it returns a list, then each item in the list is returned to the client as a patch. `@stream` is described in a [proposal to the GraphQL specification](https://github.com/graphql/graphql-wg/blob/main/rfcs/DeferStream.md). + +__Note:__ `@stream` was added in GraphQL-Pro 1.21.0 and requires GraphQL-Ruby 1.13.6+. + +## Installation + +To support `@stream` in your schema, add it with `use GraphQL::Pro::Stream`: + +```ruby +class MySchema < GraphQL::Schema + # ... + use GraphQL::Pro::Stream +end +``` + +Additionally, you should update your controller to handle deferred parts of the response. See the {% internal_link "@defer setup guide", "defer/setup#sending-streaming-responses" %} for details. (`@stream` uses the same deferral pipeline as `@defer`, so the same setup instructions apply.) + +## Usage + +After that, you can include `@stream` in your queries, for example: + +```ruby +{ + # Send each movie in its own patch: + nowPlaying @stream { + title + director { name } + } +} +``` + +If `@stream` is applied to non-list fields, it's ignored. + +`@stream` supports several arguments: + +- `if: Boolean = true`: when `false`, the list is _not_ streamed. Instead, all items are returned synchronously. +- `label: String`: if present, the given string is returned in patches as `"label": "..."` +- `initialCount: Int = 0`: this number of list items are returned synchronously. (If the list is shorter than `initialCount`, then the whole list is returned synchronously.) diff --git a/guides/defer/usage.md b/guides/defer/usage.md index 497e46c239a..92925da3fec 100644 --- a/guides/defer/usage.md +++ b/guides/defer/usage.md @@ -24,8 +24,7 @@ query GetPlayerInfo($handle: String!){ The directives `@skip` and `@include` are built into any GraphQL server and client, but `@defer` requires special attention. -Apollo-Client has [experimental support](https://www.apollographql.com/docs/react/features/defer-support.html) -but it may [have some issues](https://github.com/apollographql/apollo-client/issues/4484), so you can try [this updated fork](https://github.com/rmosolgo/apollo-client) while they're worked out. +Apollo-Client [currently supports the @defer directive](https://www.apollographql.com/docs/react/data/defer/). `@defer` also accepts a `label:` option which will be included in outgoing patches when it's present in the query (eg, `@defer(label: "patch1")`). diff --git a/guides/development.md b/guides/development.md index 2f35fa5a96a..935ab067800 100644 --- a/guides/development.md +++ b/guides/development.md @@ -14,12 +14,12 @@ So, you want to hack on GraphQL Ruby! Here are some tips for getting started. - [Debug](#debugging-with-pry) with pry - [Run the benchmarks](#running-the-benchmarks) to test performance in your environment - [Coding guidelines](#coding-guidelines) for working on your contribution -- Special tools for building the [lexer and parser](#lexer-and-parser) +- Special tools for building the lexer and parser - Building and publishing the [GraphQL Ruby website](#website) - [Versioning](#versioning) describes how changes are managed and released - [Releasing](#releasing) Gem versions -### Setup +## Setup Get your own copy of graphql-ruby by forking [`rmosolgo/graphql-ruby` on GitHub](https://github.com/rmosolgo/graphql-ruby) and cloning your fork. @@ -27,11 +27,12 @@ Then, install the dependencies: - Install SQLite3 and MongoDB (eg, `brew install sqlite && brew tap mongodb/brew && brew install mongodb-community`) - `bundle install` +- `rake compile # If you get warnings at this step, you can ignore them.` - Optional: [Ragel](https://www.colm.net/open-source/ragel/) is required to build the lexer -### Running the Tests +## Running the Tests -#### Unit tests +### Unit tests You can run the tests with @@ -65,16 +66,33 @@ bundle exec rake test (This is provided by `minitest-focus`.) -#### Integration tests +### Integration tests You need to pick a specific gemfile from gemfiles/ to run integration tests. For example: ``` -BUNDLE_GEMFILE=gemfiles/rails_5.1.gemfile bundle install -BUNDLE_GEMFILE=gemfiles/rails_5.1.gemfile bundle exec rake test TEST=spec/integration/rails/graphql/relay/array_connection_spec.rb +BUNDLE_GEMFILE=gemfiles/rails_6.1.gemfile bundle install +BUNDLE_GEMFILE=gemfiles/rails_6.1.gemfile bundle exec rake test TEST=spec/integration/rails/graphql/relay/array_connection_spec.rb ``` -#### Other tests +### GraphQL-CParser tests + +To test the `graphql_cparser` gem, you have to build the binary first: + +``` +bundle exec rake build_ext +``` + +Then, run the test suite with `GRAPHQL_CPARSER=1`: + +``` +GRAPHQL_CPARSER=1 bundle exec rake test +``` + +(Add `TEST=` to pick a certain file.) + + +### Other tests There are system tests for checking ActionCable behavior, use: @@ -88,7 +106,7 @@ And JavaScript tests: bundle exec rake test:js ``` -### Gemfiles, Gemfiles, Gemfiles +## Gemfiles, Gemfiles, Gemfiles `graphql-ruby` has several gemfiles to ensure support for various Rails versions. You can specify a gemfile with `BUNDLE_GEMFILE`, eg: @@ -96,9 +114,9 @@ bundle exec rake test:js BUNDLE_GEMFILE=gemfiles/rails_5.gemfile bundle exec rake test ``` -### Debugging with Pry +## Debugging with Pry -[`pry`](https://pryrepl.org/) is included with GraphQL-Ruby's development setup to help with debugging. +[`pry`](https://pry.github.io/) is included with GraphQL-Ruby's development setup to help with debugging. To pause execution in Ruby code, add: @@ -108,7 +126,7 @@ binding.pry Then, the program will pause and your terminal will become a Ruby REPL. Feel free to use `pry` in your development process! -### Running the Benchmarks +## Running the Benchmarks This project includes some Rake tasks to record benchmarks: @@ -135,7 +153,7 @@ Keep these points in mind when using benchmarks: - The results are hardware-specific: computers with different hardware will have different results. So don't compare your results to results from other computers. - The results are environment-specific: CPU and memory availability are affected by other processes on your computer. So try to create similar environments for your before-and-after testing. -### Coding Guidelines +## Coding Guidelines GraphQL-Ruby uses a thorough test suite to make sure things work reliably day-after-day. Please include tests that describe your changes, for example: @@ -145,43 +163,7 @@ GraphQL-Ruby uses a thorough test suite to make sure things work reliably day-af Don't fret about coding style or organization. There's a minimal Rubocop config in `.rubocop.yml` which runs during CI. You can run it manually with `bundle exec rake rubocop`. -### Lexer and Parser - -The lexer and parser use a multistep build process: - -- Write the definition (`lexer.rl` or `parser.y`) -- Run the generator (Ragel or Racc) to create `.rb` files (`lexer.rb` or `parser.rb`) -- `require` those `.rb` files in GraphQL-Ruby - -To update the lexer or parser, you should update their corresponding _definitions_ (`lexer.rl` or `parser.y`). Then, you can run `bundle exec rake build_parser` to re-generate the `.rb` files. - -You will need Ragel to build the lexer (see above). - -#### Install Ragel and Colm on a Mac - -GraphQL Ruby requires Ragel 7.0.0.9 which is not available on Homebrew. To install it, you might have to download it from source. - -This is not meant to be a step by step guide and will likely not work as the documentation ages. - -Download colm from [http://www.colm.net/files/colm/colm-0.13.0.4.tar.gz](http://www.colm.net/files/colm/colm-0.13.0.4.tar.gz) - -Download ragel from [http://www.colm.net/files/ragel/ragel-7.0.0.9.tar.gz](http://www.colm.net/files/ragel/ragel-7.0.0.9.tar.gz) - -```sh -# In colm directory -cat README # for install instructions -# The author who added this documentation succeeded with these steps -./configure -make -make install - -# After installing colm, in ragel directory -./configure -make -make install -``` - -### Website +## Website To update the website, update the `.md` files in `guides/`. @@ -199,7 +181,7 @@ To publish the website with GitHub pages, run the Rake task: bundle exec rake site:publish ``` -#### Search Index +### Search Index GraphQL-Ruby's search index is powered by Algolia. To update the index, you need the API key in an environment variable: @@ -209,7 +191,7 @@ $ export ALGOLIA_API_KEY=... Without this key, the search index will fall out-of-sync with the website. Contact @rmosolgo to gain access to this key. -#### API Docs +### API Docs The GraphQL-Ruby website has its own rendered version of the gem's API docs. They're pushed to GitHub pages with a special process. @@ -234,7 +216,7 @@ $ bundle exec rake site:publish Finally, check your work by visiting the docs on the website. -### Versioning +## Versioning GraphQL-Ruby does _not_ attempt to deliver "semantic versioning" for the reasons described in `jashkenas`' s post, ["Why Semantic Versioning Isn't"](https://gist.github.com/jashkenas/cbd2b088e20279ae2c8e). Instead, the following scheme is used as a guideline: @@ -250,7 +232,7 @@ Pull requests and issues may be tagged with a [GitHub milestone](https://github. The [changelog](https://github.com/rmosolgo/graphql-ruby/blob/master/CHANGELOG.md) should always contain accurate and thorough information so that users can upgrade. If you have trouble upgrading based on the changelog, please open an issue on GitHub. -### Releasing +## Releasing GraphQL-Ruby doesn't have a strict release schedule. If you think it should, consider opening an issue to share your thoughts. @@ -260,14 +242,10 @@ To cut a release: - Add a new heading for the new version, and paste the four categories of changes into the new section - Open the GitHub milestone corresponding to the new version - Check each pull request and put it in the category (or categories) that it belongs in - - If a change affects the default behavior of GraphQL-Ruby in a disruptive way, add it to `### Breaking Changes` and include migration notes if possible + - If a change affects the default behavior of GraphQL-Ruby in a disruptive way, add it to `## Breaking Changes` and include migration notes if possible - Include the PR number beside the change description for future reference - Update `lib/graphql/version.rb` with the new version number - Commit changes to master -- Release to RubyGems - - Without 2FA 😢: `bundle exec rake release` - - With 2FA 😎: `bundle exec rake build` then `gem push pkg/graphql-.gem`, `git tag v && git push v` -- Update the website: - - Generate new API docs with `bundle exec rake apidocs:gen_version[]` - - Push them to the website with `bundle exec rake site:publish` +- Push changes to GitHub: `git push origin master`. GitHub Actions will update the website. +- Release to RubyGems: `bundle exec rake release`. This will also push the tag to GitHub which will kick off a GitHub Actions job to update the API docs. - Celebrate 🎊 ! diff --git a/guides/errors/overview.md b/guides/errors/overview.md index 2a1cc00ee6d..c15fbc93f19 100644 --- a/guides/errors/overview.md +++ b/guides/errors/overview.md @@ -27,6 +27,8 @@ Each error has a message, line, column and path. The validation rules are part of the GraphQL specification and built into GraphQL-Ruby, so there's not really a way to customize this behavior, except to pass `validate: false` when executing a query, which skips validation altogether. +You can configure your schema to stop validating after a certain number of errors by setting {{ "Schema.validate_max_errors" | api_doc }}. Also, you can add a timeout to this step with {{ "Schema.validate_timeout" | api_doc }}. + ## Analysis Errors GraphQL-Ruby supports pre-execution analysis, which may return `"errors"` instead of running a query. You can find details in the {% internal_link "Analysis guide", "queries/ast_analysis" %}. @@ -60,4 +62,4 @@ For example, Rails will probably return a generic `500` page. When you want end users (human beings) to read error messages, you can express errors _in the schema_, using normal GraphQL fields and types. In this approach, errors are strongly-typed data, queryable in the schema, like any other application data. -For more about this approach, see {% internal_link "Mutation Errors", "/mutations/mutation_errors" %} +For more about this approach, see {% internal_link "Mutation Errors", "/mutations/mutation_errors.html#errors-as-data" %} diff --git a/guides/execution/migration.md b/guides/execution/migration.md new file mode 100644 index 00000000000..3c7dea561d5 --- /dev/null +++ b/guides/execution/migration.md @@ -0,0 +1,370 @@ +--- +layout: guide +doc_stub: false +search: true +section: Execution +title: Migrating to Execution::Next +desc: Guidelines for migrating to the new execution engine +index: 2 +--- + +This guide includes tips for migrating your schema configuration and production traffic to the new engine. + +## Migration Philosophy + +`Execution::Next` is designed to run alongside the previous engine so that the same schema can run queries _both_ ways. This supports an incremental migration and live toggling in production. + +First, update your schema to include the necessary {% internal_link "field configurations", "/execution/next#field-configurations" %}. If you implement new class methods in your Object type classes, you can also migrate instance methods to call "up" to those class methods, preserving a single source of truth: + +```ruby +field :unpublished_posts, [Types::Post], resolve_each: true + +# Support batching: +def self.unpublished_posts(object, context) + object.posts.where(published: false).order("created_at DESC") +end + +# Support legacy in a DRY way by calling the class method: +def unpublished_posts + self.class.unpublished_posts(object, context) +end +``` + +Test your new configurations in CI by running a new build which calls `execution_next` instead of `execution`, for example: + +```ruby +# test_helpers.rb +def run_graphql(...) + if ENV["GRAPHQL_EXECUTION_NEXT"] + MyAppSchema.execute_next(...) + else + MyAppSchema.execute(...) + end +end +``` + +Adopting a feature flag system (described below) can also make this easier. + +When all tests pass on `.execute_next`, you're ready to try it out in production. + +## Migration and Clean-Up Script + +`graphql_migrate_execution` is a command-line development tool that can automate many common GraphQL-Ruby field resolver patterns. + +Check out its docs and try out: + +## Production Considerations + +There are two categories of problems when migrating: + +- Some schema misconfigurations may only be detected at runtime. +- The engine may have bugs. (It's brand-new code trying to emulate 10 years of incremental development!) + +When migrating, these possibilities should be considered from three different angles. Using the new engine may... + +- ...raise errors in new ways. +- ...return a different result than the old engine. +- ...perform worse than the old engine, especially because of different database access patterns. + +To mitigate these possibilities, use dynamic release tools in production like feature flags and experiments. + +### Feature Flags + +You should use a feature flagging system so that you can shift traffic between old and new runtime engines without redeploying. A good feature-flagging system supports percentage-based flags, so that you can send 1% of traffic to new code while the other 99% uses existing code. After it runs without issues, you can increase the percentage. Or, if you discover issues in production (errors or performance), you can turn it back to 0% while you troubleshoot the problem. + +For example: + +```ruby +# app/controllers/graphql_controller.rb +exec_method = use_graphql_next? ? :execute_next : :execute +result = MyAppSchema.public_send(exec_method, query_string, context: { ... }, variables: { ... }) +render json: result +``` + +[Flipper](https://github.com/flippercloud/flipper) is a great gem for feature flags. You could also roll your own or pick a third-party service. + +__Before__ using `.execute_next` to produce results for production traffic, you might want to run an experiment as described below. + +### Experiments + +While the two runtime engines _should_ return identical responses, it's possible that `.execute_next` will return a different result than `.execute` due to gem bugs or schema misconfigurations. You can check for this using an "experiment" system in your application which runs _both_ execution engines and compares the result (for __queries only__!). + +You'll want to use feature flagging to run the experiment on a subset of traffic, since it comes with performance overhead. + +Here's some example code for a setup like this: + +```ruby +# app/controllers/graphql_controller.rb +result = MySchema.execute(...) + +# Use a dynamic flag, eg Flipper. This should always be true in development and test. +if use_graphql_next_experiment? + if !query_string.include?("mutation") && !query_string.include?("subscription") # easy way of checking for queries, could possibly have false negatives + batched_result = MySchema.execute_next(...) + if batched_result.to_h != result.to_h + # Log this mismatch somehow here, avoiding potential PII/passwords: + BugTracker.report <<~TXT + A GraphQL query returned a non-identical response. Sanitized query string: + + #{result.query.sanitized_query_string} + + User: #{current_user.id} + # Other context info here... + TXT + end + end +end +``` + +See [Scientist](https://github.com/github/scientist) for a full-blown production experimentation system. + +## Combining feature flags and experiments + +A fully-managed rollout would include two flags: + +- `use_graphql_next_experiment?`: when true, build an `.execute_next` response and compare it to the `.execute` response. But _always_ return the `.execute` response. +- `use_graphql_next?`: when true, use `.execute_next` and don't call `.execute` at all + +This gives you full control over how production traffic is executed without needing to redeploy. You can always turn them down to 0% to get the current behavior. + +Here's some example code: + +```ruby +if use_graphql_next? # again, use a dynamic feature flag + result = MySchema.execute_next(...) +else + result = MySchema.execute(...) + if use_graphql_next_experiment? + # Continue running the comparison experiment + end +end + +render json: result.to_h +``` + +## Compatibility Notes + +`Execution::Next`'s new structure means that some GraphQL-Ruby features behave differently (or aren't supported at all, at least not yet). They are discussed one-by-one below. + +### Implicit Field Resolution + +The _default_, _implicit_ field resolution behavior has changed. Previously, when a field didn't have a specified method or hash key, GraphQL-Ruby would try a combination of `object.public_send(...)` and `object[...]` to resolve it. In `Execution::Next`, GraphQL-Ruby tries `object.public_send(field_sym)` unless another configuration is provided. This removes a lot of overhead from field execution. + +Consider a field like this: + +```ruby +field :title, String +``` + +Previously, GraphQL-Ruby would check `type_object.respond_to?(:title)`, `object.respond_to?(:title)`, `object.is_a?(Hash)`. `object.key?(:title)` and `object.key?("title")`. + +Now, GraphQL-Ruby simply calls `object.title` and allows the `NoMethodError` to bubble up if one is raised. + +### Interface Resolver Methods + +Resolver methods are now class methods instead of instance methods. In order to make this work in interface modules, they must be defined in a `resolver_methods do ... end` block, for example: + + +```ruby +module Node + include BaseInterface + + field :id, ID, resolve_each: true + + resolver_methods do + # This will define `def self.id` on Object types that implement this interface + def id(object, context) + GlobalId.new(object).to_s + end + end + + # Backwards compat instance method: + def id + self.class.id(object, context) + end +end +``` + +Methods defined in `resolver_methods { ... }` will be copied into Object type classes as _class methods_, so they'll be available for `resolve_{each|static|batch}` fields. + +### Query Analyzers, including complexity 🟡 + +Support is identical; this runs before execution using the exact same code. + +TODO: accessing loaded arguments inside analyzers may turn out to be slightly different; it still calls legacy code. + +### Authorization, Scoping + +`def (self.)authorized?` and `def self.scope_items` will be called as needed during execution. + +One incompatibility: + +- Argument `#authorized?` _will_ be called if the argument wasn't present in the query but a default value is used. `Execution::Next` doesn't create the metadata necessary to skip authorization in that case. A work-around might be to check if the value is equal to the default value in `def authorized?` and permit it if it is. If this is a blocker for you, please open an issue on GitHub and we can check it out. + +### Visibility, including Changesets + +Visibility works exactly as before; both runtime modules call the same methods to get type information from the schema. + +### Dataloader + +Dataloader runs with new execution, but when migrating from instance methods to batch-level class methods, you may need to use {{ "Schema::Member::HasDataloader#dataload_all" | api_doc }} instead of `.dataload`. + +### Tracing + +Fully supported, but some legacy hooks are _not_ called. Implement the new hooks instead (existing runtime already calls these new hooks). Not called are: + +- `execute_field`, `execute_field_lazy`: use `begin_execute_field`, `end_execute_field` instead. (These may be called multiple times when Dataloader pauses or a GraphQL-Batch promise is returned) +- `execute_query`, `execute_query_lazy`: use `execute_multiplex` for a top-level hook instead. (Single queries are always executed in a multiplex of size = 1.) +- `resolve_type`, `authorized`: use `{begin,end}_resolve_type` and `{begin,end}_authorized` instead. (May be called multiple times for Dataloader etc.) + +Additionally, `object` parameters to those methods will receive an _Array_ of `objects` instead. + +### Lazy resolution (GraphQL-Batch) + +Lazy resolution runs in the new execution (GraphQL-Batch is supported). When migrating to class methods, you may need to update your library method calls to work on a set of inputs rather than a single input. + +### `current_path` ❌ + +This is not supported because the new runtime doesn't actually produce `current_path`. + +It is theoretically possible to support this but it will be a ton of work. If you use this for core runtime functions, please share your use case in a GitHub issue and we can investigate future options. + +### Scoped context ❌ + +This is currently implemented with `current_path`. Another implementation is probably possible but not implemented yet. Please open an issue to discuss. + +### `@defer` 🟡 + +`@defer` is supported with an implementation difference that _probably_ doesn't affect your application: previously, `@defer` worked by pausing and resuming the _same `GraphQL::Query` instance_. However, with `Execution::Next`, `@defer` takes a different approach. Instead, when a `GraphQL::Query` encounters `@defer`, it notes the location in the document and stops executing that branch. Later, when you request the deferred result, that branch of the query is resumed using a new instance of `GraphQL::Query::Partial`. + +This might matter if you're modifying `context` at runtime because those new instances _also_ have fresh `Query::Context` instances. The original query context _will_ get copied into the `@defer` branches using `Query::Context.new(**original_query.context.to_h)`, so any custom values will be available. But if you _assign new keys_ after the context is copied, those keys won't appear when running later `@defer`ed branches. + +To handle this, you can refactor how you accumulate data during execution. Instead of `||=`'ing into `context[...]` during execution, assign a new accumulator object _before_ starting the query, then call methods on that object to make any necessary state changes. That new object _will_ be copied into `@defer` partials, and since the object is shared between the different branches, any necessary state changes will still be "seen" everywhere. + +If this gives you trouble, please feel free to email me or open an issue on GitHub to discuss a migration strategy. + +##### GraphQL-Batch support + +When using `Execution::Next`, no custom code is required to support `graphql-batch` -- support is built-in. + +### `@stream` + +`@stream` is supported. + +See the not above about how `@defer` no longer _resumes_ the original, top-level query. The same thing applies to `@stream`. + +`GraphQL::Pro::Stream` now lazily streams Enumerators. If you were using the (undocumented) `GraphQL::Pro::FutureStream`, you can switch to `GraphQL::Pro::Stream` _after_ migrating to `Execution::Next`. (Once all your traffic uses the new execution module, you'll get the same runtime behavior from `GraphQL::Pro::Stream`.) + +### ObjectCache + +Supported completely. + +### Custom Directives ❌ + +There is some implementation in the code right now but it's not stable. Please open an issue to discuss. + +Query-level directives are not implemented yet, but will be. Please open an issue if you have a use case for this. + +### `as:` + +`as:` is applied: arguments are passed into Ruby methods by their `as:` names instead of their GraphQL names. + +### `loads:` 🟡 + +`loads:` is handled as previously, __except__ that custom `def load_...` methods are _not_ called. + +### `prepare:` 🟡 + +Procs are called as before. + +Methods that depend on a runtime `object` (such as a type instance or Mutation class) are _not_ called, because arguments are prepared before objects are ready. + +### `validates:` 🟡 + +Built-in validators are supported. Custom validators will always receive `nil` as the `object`. (`object` is no longer available; this API will probably change before this is fully released.) + +### Field Extensions 🟡 + +Field extension methods are called with new arguments: + +- `objects:` instead of `object:`, with an Array +- `values:` instead of `value:`, with an Array + +You can support both types of calls in your methods by changing the signature to `object: nil, objects: nil` (and `value: nil, values: nil`), then checking which argument was passed. + +### Resolver classes (including Mutations and Subscriptions) 🟡 + +Resolver classes are called, but with slightly different semantics: + +- `#ready?` is still called, but after arguments are loaded. It's now a useless method and will probably be deprecated. +- `def load_...` methods are not called; instead, arguments are passed to the top-level `Schema.object_from_id` hook. + +### Field `extras:`, including `lookahead` + +`:ast_node` and `:lookahead` are already implemented. Others are possible -- please raise an issue if you need one. `extras: [:current_path]` is not possible. + +### `raw_value` 🟡 + +Supported, but the `raw_value` call must be made on `context`, for example: + +```ruby +field :values, SomeObjectType, resolve_static: true + +def self.values(context) + context.raw_value(...) +end +``` + +### Errors and `rescue_from` 🟡 + +Raising `GraphQL::ExecutionError` and adding `rescue_from` handlers are supported + +Returning an array of `GraphQL::ExecutionError` instances is not supported anymore. + +`extras: [:execution_errors]` and `context.add_error` are not supported anymore. + +### Connection fields + +Connection arguments are automatically handled and connection wrapper objects are automatically applied to arrays and relations. + +### Custom Introspection + +This _works_ but if you want custom authorization or any lazy values, see notes about that compatibility. + +If you're reimplementing default values, you'll need to add the corresponding `resolve_static: true` or `resolve_each: true` configurations. See the built-in type definitions under `GraphQL::Introspection` to get these configurations. + +### Multiplex + +To use the new engine to run a multiplex, use `MyAppSchema.multiplex_next(...)` with the same arguments. + +### GraphQL::Current 🟡 + +`current_field` doesn't work; `dataloader_source` works. `current_operation_name` doesn't work. + +This will be fixed soon but may require opt-in to avoid needless overhead. + +### `fallback_value:` ❌ + +`fallback_value:` is not supported in Execution::Next. It's not implemented because of the overhead it adds to resultion. You'll have to implement it by hand in resolvers. + +`graphql_migrate_execution` creates a resolver that _always_ returns the `fallback_value`. This might be right in some cases, but you'll probably have to implement your own method, like: + +```ruby +field :name, String, fallback_value: "Anonymous", resolve_each: :resolve_name + +def resolve_name(object, context) + if object.respond_to?(:name) + object.name + elsif (is_h = object.is_a?(Hash)) && object.key?(:name) + object[:name] + elsif is_h && object.key?("name") + object["name"] + else + "Anonymous" + end +end +``` + +## GraphQL::Backtrace + +Doesn't support Execution::Next, but it's probably not necessary. `Execution::Next` includes the field name in error messages and doesn't generate crazy-long stack traces because of its design. diff --git a/guides/execution/next.md b/guides/execution/next.md new file mode 100644 index 00000000000..28dcc86fd0c --- /dev/null +++ b/guides/execution/next.md @@ -0,0 +1,251 @@ +--- +layout: guide +doc_stub: false +search: true +section: Execution +title: New Execution Module +desc: Background on GraphQL-Ruby's new execution approach +index: 1 +--- + +GraphQL-Ruby has a new execution engine, {{ "GraphQL::Execution::Next" | api_doc }}. It's much faster and less memory-consuming than the existing execution engine, but requires some care in migrating. + +This feature is in heavy development, so if you give it a try and run into any problems, please open an issue on GitHub! + +## Background + +Breadth-first GraphQL execution (or, "execution batching") is an algorithmic paradigm developed by Shopify to address problems of scale when resolving large lists and nested sets. Rather than paying field-level overhead costs (resolver calls, instrumentation, lazy promises, etc) for every field _of every resolved object_, the pattern instead incurs these costs only once per field selection and runs the corresponding breadth of objects with no additional overhead. + +The original proof-of-concept of Shopify's core algorithm and white paper notes can be found in [graphql-breadth-exec](https://github.com/gmac/graphql-breadth-exec). That prototype matured into Shopify's proprietary _GraphQL Cardinal_ execution engine that now runs much of their core traffic. + +GraphQL-Ruby brings these breadth-first design principles to the open-source community with several novel techniques for implementing GraphQL: + +- Fields are resolved breadth-first using implicitly batched resolvers. These run longer and hotter on application logic with no execution overhead. +- Batched resolvers may bind entire load sets to a single lazy promise to dramatically reduce promise bloat. +- Error handling is optimized into a second pass that only runs when errors actually occur. +- Stack profiling becomes much more organized with a linear flow and aggregate field spans, rather than fields getting split up across subtree repetitions. +- The engine is driven by enqueuing rather than recursion, which shrinks stack traces and reduces memory usage. + +Breadth-first patterns can produce dramatic results in responses with a high degree of repetition: it's not uncommon to see breadth batching run __15x__ faster and use __75% less__ memory than classic GraphQL Ruby execution. However – gains are relative. A flat tree with no lists will see little difference. A list of 2 resolving one field each will see a small gain, while a list of 100 resolving ten fields each will likely see dramatic results. + +The downside is that many of GraphQL-Ruby's "bonus features" -- those that go beyond the behavior described in the GraphQL Specification -- add non-trivial overhead when used. So, the task ahead is to "lift the ceiling" of performance in GraphQL-Ruby while retaining as much compatibility as possible and supporting a gradual transition to this new runtime engine. + +## Enabling Execution::Next + +The new execution engine is enabled with two steps: + +- Add the plugin to your schema with `use GraphQL::Execution::Next` +- Call `MySchema.execute_next(...)` instead of `MySchema.execute(...)`. It takes the same arguments. + +See {% internal_link "compatibility notes", "/execution/migration#compatibility-notes" %} for updating your schema to run queries with the new engine. + +You can also add `..., as_default: true` to use `execute_next` by default. In that case, call `execute_legacy` if you need the old runtime. + +## Field configurations + +The new runtime engine supports several field resolution configurations out of the box. + +### Method calls (default, `method:`) + +These fields call `object.#{field_name}`. This is the default, and the method name can be overridden with `method: ...`: + +```ruby +field :title, String # calls object.title +field :title, String, method: :get_title_somehow # calls object.get_title_somehow +``` + +### Hash keys (`hash_key:`) + +These fields call `object[hash_key]`, configured with `hash_key: ...`. + +```ruby +field :title, String, hash_key: :title # calls object[:title] +field :title, String, hash_key: "title" # calls object["title"] +``` + +**Note:** new execution doesn't "fall back" to hash key lookups, and it doesn't try strings when Symbols are given. The existing runtime engine does that, but it has been excluded for performance reasons. To get the old resolution behavior, you can code it like: + +```ruby +field :title, String, resolve_each: true + +def self.title(object, context) + # For example, try a symbol key first, then a string: + object[:title] || object["title"] +end +``` + +### Per-object (`resolve_each:`) + +These fields use a _class method_ to produce a result for each object, configured with `resolve_each:`. + +```ruby +field :title, String, resolve_each: true do # calls `self.title(...)` below + argument :language, Types::Language, required: false, default_value: "EN" +end + +def self.title(object, context, language:) + # Assuming this makes no database lookups or other external service calls: + object.localization.get(:title, language:) +end +``` + +The default method is the same as the field name symbol. You can also provide a custom method: + +```ruby +# Avoid a conflict with Ruby's built-in `Class#name`: +field :name, String, resolve_each: :get_name + +def self.get_name(object, context) + # ... +end +``` + + +Under the hood, GraphQL-Ruby calls `objects.map { ... }`, calling this class method. + + +‼️ __Don't use this__ if your logic calls external services or databases (including with Dataloader). If you do, your I/O will be sequential instead of batched. Use `resolve_batch:` or `resolve_static:` instead, see below. + +### Global (`resolve_static:`) + +Fields that use a _class method_ to produce a single result shared by all objects, configured with `resolve_static:`. The method does _not_ receive any `object`, only `context`: + +```ruby +field :posts_count, Integer, resolve_static: :count_all_posts do + argument :include_unpublished, Boolean, required: false, default_value: false +end + +def self.count_all_posts(context, include_unpublished:) + posts = Post.all + if !include_unpublished + posts = posts.published + end + posts.count +end +``` + +Under the hood, GraphQL-Ruby calls `Array.new(objects.size, static_result)`. + +### Batch resolvers (`resolve_batch:`) + +This is a high-performance option for when you need to do I/O to generate results. By working with a batch of objects, you can greatly reduce the framework overhead in preparing a result. + +These fields use a _class method_ to map parent objects to field results, configured with `resolve_batch:`: + +```ruby +field :title, String, resolve_batch: true do # calls self.title below + argument :language, Types::Language, required: false, default_value: "EN" +end + +def self.title(objects, context, language:) + # This is equivalent to plain `field :title, ...`, but for example: + objects.map { |obj| obj.title(language:) } +end +``` + +This is especially useful when batching Dataloader calls: + +```ruby +class Types::Comment < BaseObject + field :author_rating, Integer, resolve_batch: true + + def self.author_rating(objects, context) + authors = context.dataload_all_records(objects, :author) + context.dataload_all(Sources::AuthorRating, authors) + end +end +``` + +By default, it calls a class method matching the field name. You can customize this configuration, too: + +```ruby +field :author_rating, Integer, resolve_batch: :calculate_rating # calls `self.calculate_rating(objects, context)` + +def self.calculate_rating(objects, context) + # ... +end +``` + +### Dataloader + +`Execution::Next` supports field configuration shorthands for common dataloader usage. Under the hood, these make sure data fetching is batched and cached. + +#### Sources + +Use a custom dataloader source from your application: + +```ruby +class Types::CommentType + # Equivalent to `dataload(Sources::CommentRating, object)` + field :rating, Integer, dataload: Sources::CommentRating + + # `using:`: A method to call to get a value to pass to dataloader + # `by: [...]`: An array of arguments to pass on to dataloader + # + # Equivalent to `dataload(Sources::ReadingDuration, :comment, object.body) + field :reading_duration, Integer, dataload: { with: Sources::ReadingDuration, using: :body, by: [:comment] } +``` + +#### Rails Associations + +Load ActiveRecord associations using {{ "GraphQL::Dataloader::ActiveRecordAssociationSource" | api_doc }}: + +```ruby +class Types::CommentType < Types::BaseObject + # Equivalent to `dataload_association(:post)` + field :post, Types::Post, dataload: { association: true } + # Equivalent to `dataload_association(:user) + field :author, Types::Post, dataload: { association: :user } +end +``` + +#### Rails Records + +Load ActiveRecord associations using {{ "GraphQL::Dataloader::ActiveRecordSource" | api_doc }}. + +```ruby +class Types::SearchResult < Types::BaseObject + # Equivalent to `dataload_record(Post, object.post_id)` + field :post, Types::Post, dataload: { model: Post, using: :post_id } + # Equivalent to `dataload_record(User, object.created_by_handle, find_by: :handle)` + field :author, Types::User, dataload: { model: User, using: :created_by_handle, find_by: :handle } +end +``` + +### Legacy instance methods + +`resolve_legacy_instance_method:` + +There is _partial_ support for instance methods on Object type classes, for now. It will be deprecated and removed soon. + +‼️ Don't use legacy instance methods with Dataloader. It will be sequential, not batched. ‼️ + +```ruby +field :title, String, resolve_legacy_instance_method: true do + argument :language, Types::Language, required: false, default_value: "EN" +end + +def title(language:) + # Assuming this makes no database lookups or other external service calls: + object.localization.get(:title, language:) +end +``` + +Under the hood, GraphQL-Ruby calls `objects.map { ... }`, calling this instance method. It adds significant overhead because GraphQL-Ruby initializes the object type class. + + +### `true` shorthand + +There is also a `true` shorthand: when one of the `resolve_...:` configurations is passed as `true` (ie, `resolve_batch: true`, `resolve_each: true`, `resolve_static: true`, or `resolve_legacy_instance_method: true`), then the Symbol field name is used as the class method. For example: + +```ruby +field :posts_count, Integer, resolve_static: true + +def self.posts_count(context) + Post.all.count +end +``` + +## Migration + +Read about migrating in the {% internal_link "Migration Doc", "/execution/migration" %}. diff --git a/guides/faq.md b/guides/faq.md index 875c2d3e2c1..eab9a195531 100644 --- a/guides/faq.md +++ b/guides/faq.md @@ -10,25 +10,62 @@ desc: How to do common tasks Returning Route URLs ==================== -With GraphQL there is less of a need to include resource URLs to other REST resources, however sometimes you want to use Rails routing to include a URL as one of your fields. A common use case would be to build HTML format URLs to render a link in your React UI. In that case you can add the Rails route helpers to the execution context as shown below. +With GraphQL there is less of a need to include resource URLs to other REST resources, however sometimes you want to use Rails routing to include a URL as one of your fields. A common use case would be to build HTML format URLs to render a link in your React UI. In that case you can pass the request to your context, so that the helpers are able to build full URLs based on the incoming host, port and protocol. Example ------- ```ruby class Types::UserType < Types::BaseObject + include ActionController::UrlFor + include Rails.application.routes.url_helpers + # Needed by ActionController::UrlFor to extract the host, port, protocol etc. from the current request + def request + context[:request] + end + # Needed by Rails.application.routes.url_helpers, it will then use the url_options defined by ActionController::UrlFor + def default_url_options + {} + end + field :profile_url, String, null: false def profile_url - context[:routes].user_url(object) + user_url(object) end end -# Add the url helpers to `context`: +# In your GraphQL controller, add the request to `context`: MySchema.execute( params[:query], variables: params[:variables], context: { - routes: Rails.application.routes.url_helpers, - # ... + request: request }, ) ``` + +Returning ActiveStorage blob URLs +================================= +If you are using ActiveStorage and need to return a URL to an attachment blob, you will find that using `Rails.application.routes.url_helpers.rails_blob_url` alone will throw an exception since Rails won't know what host, port or protocol to use in it. +You can include `ActiveStorage::SetCurrent` in your GraphQL controller to pass on this information into your resolvers. + +Example +======= + +```ruby +class GraphqlController < ApplicationController + include ActiveStorage::SetCurrent + ... +end + +class Types::UserType < Types::BaseObject + field :picture_url, String, null: false + def picture_url + Rails.application.routes.url_helpers.rails_blob_url( + object.picture, + protocol: ActiveStorage::Current.url_options[:protocol], + host: ActiveStorage::Current.url_options[:host], + port: ActiveStorage::Current.url_options[:port] + ) + end +end +``` diff --git a/guides/fields/arguments.md b/guides/fields/arguments.md index d00c8ea62ae..a6f38a7c191 100644 --- a/guides/fields/arguments.md +++ b/guides/fields/arguments.md @@ -14,7 +14,7 @@ Arguments are defined with the `argument` helper. These arguments are passed as ```ruby field :search_posts, [PostType], null: false do - argument :category, String, required: true + argument :category, String end def search_posts(category:) @@ -22,6 +22,8 @@ def search_posts(category:) end ``` +## Nullability + To make an argument optional, set `required: false`, and set default values for the corresponding keyword arguments: ```ruby @@ -50,6 +52,8 @@ def search_posts(**args) end ``` +### Default Values + Another approach is to use `default_value: value` to provide a default value for the argument if it is not supplied in the query. ```ruby @@ -62,6 +66,23 @@ def search_posts(category:) end ``` +Arguments with `required: false` _do_ accept `null` as inputs from clients. This can be surprising in resolver code, for example, an argument with `Integer, required: false` can sometimes be `nil`. In this case, you can use `replace_null_with_default: true` to apply the given `default_value: ...` when clients provide `null`. For example: + +```ruby +# Even if clients send `query: null`, the resolver will receive `"*"` for this argument: +argument :query, String, required: false, default_value: "*", replace_null_with_default: true +``` + +Finally, `required: :nullable` will require clients to pass the argument, although it will accept `null` as a valid input. For example: + +```ruby +# This argument _must_ be given -- send `null` if there's no other appropriate value: +argument :email_address, String, required: :nullable +``` + + +## Deprecation + **Experimental:** __Deprecated__ arguments can be marked by adding a `deprecation_reason:` keyword argument: ```ruby @@ -70,14 +91,15 @@ field :search_posts, [PostType], null: false do argument :query, String, required: false end ``` -Note argument deprecation is a stage 2 GraphQL [proposal](https://github.com/graphql/graphql-spec/pull/525) so not all clients will leverage this information. + +## Aliasing Use `as: :alternate_name` to use a different key from within your resolvers while exposing another key to clients. ```ruby field :post, PostType, null: false do - argument :post_id, ID, required: true, as: :id + argument :post_id, ID, as: :id end def post(id:) @@ -85,11 +107,13 @@ def post(id:) end ``` +## Preprocessing + Provide a `prepare` function to modify or validate the value of an argument before the field's resolver method is executed: ```ruby field :posts, [PostType], null: false do - argument :start_date, String, required: true, prepare: ->(startDate, ctx) { + argument :start_date, String, prepare: ->(startDate, ctx) { # return the prepared argument. # raise a GraphQL::ExecutionError to halt the execution of the field and # add the exception's message to the `errors` key. @@ -101,11 +125,13 @@ def posts(start_date:) end ``` +## Automatic camelization + Arguments that are snake_cased will be camelized in the GraphQL schema. Using the example of: ```ruby field :posts, [PostType], null: false do - argument :start_year, Int, required: true + argument :start_year, Int end ``` @@ -123,7 +149,7 @@ To disable auto-camelization, pass `camelize: false` to the `argument` method. ```ruby field :posts, [PostType], null: false do - argument :start_year, Int, required: true, camelize: false + argument :start_year, Int, camelize: false end ``` @@ -131,7 +157,7 @@ Furthermore, if your argument is already camelCased, then it will remain cameliz ```ruby field :posts, [PostType], null: false do - argument :startYear, Int, required: true + argument :startYear, Int end def posts(start_year:) @@ -139,10 +165,12 @@ def posts(start_year:) end ``` +## Valid Argument Types + Only certain types are valid for arguments: -- {{ "GraphQL::ScalarType" | api_doc }}, including built-in scalars (string, int, float, boolean, ID) -- {{ "GraphQL::EnumType" | api_doc }} -- {{ "GraphQL::InputObjectType" | api_doc }}, which allows key-value pairs as input -- {{ "GraphQL::ListType" | api_doc }}s of a valid input type -- {{ "GraphQL::NonNullType" | api_doc }}s of a valid input type +- {{ "GraphQL::Schema::Scalar" | api_doc }}, including built-in scalars (string, int, float, boolean, ID) +- {{ "GraphQL::Schema::Enum" | api_doc }} +- {{ "GraphQL::Schema::InputObject" | api_doc }}, which allows key-value pairs as input +- {{ "GraphQL::Schema::List" | api_doc }}s of a valid input type, configured using `[...]` +- {{ "GraphQL::Schema::NonNull" | api_doc }}s of a valid input type (arguments are non-null by default; use `required: false` to make optional arguments) diff --git a/guides/fields/introduction.md b/guides/fields/introduction.md index acd8ce4cb64..c84fd8d3d9e 100644 --- a/guides/fields/introduction.md +++ b/guides/fields/introduction.md @@ -19,14 +19,29 @@ field :name, String, "The unique name of this list", null: false The different elements of field definition are addressed below: +- [Names](#field-names) identify the field in GraphQL - [Return types](#field-return-type) say what kind of data this field returns -- [Documentation](#field-documentation) includes description and deprecation notes +- [Documentation](#field-documentation) includes description, comments and deprecation notes - [Resolution behavior](#field-resolution) hooks up Ruby code to the GraphQL field - [Arguments](#field-arguments) allow fields to take input when they're queried - [Extra field metadata](#extra-field-metadata) for low-level access to the GraphQL-Ruby runtime - [Add default values for field parameters](#field-parameter-default-values) -### Field Return Type +## Field Names + +A field's name is provided as the first argument or as the `name:` option: + +```ruby +field :team_captain, ... +# or: +field ..., name: :team_captain +``` + +Under the hood, GraphQL-Ruby **camelizes** field names, so `field :team_captain, ...` would be `{ teamCaptain }` in GraphQL. You can disable this behavior by adding `camelize: false` to your field definition or to the [default field options](#field-parameter-default-values). + +The field's name is also used as the basis of [field resolution](#field-resolution). + +## Field Return Type The second argument to `field(...)` is the return type. This can be: @@ -34,9 +49,9 @@ The second argument to `field(...)` is the return type. This can be: - A GraphQL type from your application - An _array_ of any of the above, which denotes a {% internal_link "list type", "/type_definitions/lists" %}. -{% internal_link "Nullability", "/type_definitions/non_nulls" %} is expressed with the required `null:` keyword: +{% internal_link "Nullability", "/type_definitions/non_nulls" %} is expressed with the `null:` keyword: -- `null: true` means that the field _may_ return `nil` +- `null: true` (default) means that the field _may_ return `nil` - `null: false` means the field is non-nullable; it may not return `nil`. If the implementation returns `nil`, GraphQL-Ruby will return an error to the client. Additionally, list types maybe nullable by adding `[..., null: true]` to the definition. @@ -44,15 +59,15 @@ Additionally, list types maybe nullable by adding `[..., null: true]` to the def Here are some examples: ```ruby -field :name, String, null: true # `String`, may return a `String` or `nil` +field :name, String # `String`, may return a `String` or `nil` field :id, ID, null: false # `ID!`, always returns an `ID`, never `nil` field :teammates, [Types::User], null: false # `[User!]!`, always returns a list containing `User`s -field :scores, [Integer, null: true], null: true # `[Int]`, may return a list or `nil`, the list may contain a mix of `Integer`s and `nil`s +field :scores, [Integer, null: true] # `[Int]`, may return a list or `nil`, the list may contain a mix of `Integer`s and `nil`s ``` -### Field Documentation +## Field Documentation -Fields may be documented with a __description__ and may be __deprecated__. +Fields may be documented with a __description__, __comment__ and may be __deprecated__. __Descriptions__ can be added with the `field(...)` method as a positional argument, a keyword argument, or inside the block: @@ -70,16 +85,36 @@ field :name, String, null: false do end ``` +__Comments__ can be added with the `field(...)` method as a keyword argument, or inside the block: +```ruby +# `comment:` keyword +field :name, String, null: false, comment: "Rename to full name" + +# inside the block +field :name, String, null: false do + comment "Rename to full name" +end +``` + +Generates field name with comment above "Rename to full name" above. + +```graphql +type Foo { + # Rename to full name + name: String! +} +``` + __Deprecated__ fields can be marked by adding a `deprecation_reason:` keyword argument: ```ruby -field :email, String, null: true, +field :email, String, deprecation_reason: "Users may have multiple emails, use `User.emails` instead." ``` Fields with a `deprecation_reason:` will appear as "deprecated" in GraphiQL. -### Field Resolution +## Field Resolution In general, fields return Ruby values corresponding to their GraphQL return types. For example, a field with the return type `String` should return a Ruby string, and a field with the return type `[User!]!` should return a Ruby array with zero or more `User` objects in it. @@ -87,6 +122,7 @@ By default, fields return values by: - Trying to call a method on the underlying object; _OR_ - If the underlying object is a `Hash`, lookup a key in that hash. +- An optional `:fallback_value` can be supplied that will be used if the above fail. The method name or hash key corresponds to the field name, so in this example: @@ -96,7 +132,7 @@ field :top_score, Integer, null: false The default behavior is to look for a `#top_score` method, or lookup a `Hash` key, `:top_score` (symbol) or `"top_score"` (string). -You can override the method name with the `method:` keyword, or override the hash key with the `hash_key:` keyword, for example: +You can override the method name with the `method:` keyword, or override the hash key(s) with the `hash_key:` or `dig:` keyword, for example: ```ruby # Use the `#best_score` method to resolve this field @@ -106,6 +142,10 @@ field :top_score, Integer, null: false, # Lookup `hash["allPlayers"]` to resolve this field field :players, [User], null: false, hash_key: "allPlayers" + +# Use the `#dig` method on the hash with `:nested` and `:movies` keys +field :movies, [Movie], null: false, + dig: [:nested, :movies] ``` To pass-through the underlying object without calling a method on it, you can use `method: :itself`: @@ -172,7 +212,7 @@ end Note that `resolver_method` _cannot_ be used in combination with `method` or `hash_key`. -### Field Arguments +## Field Arguments _Arguments_ allow fields to take input to their resolution. For example: @@ -182,7 +222,7 @@ _Arguments_ allow fields to take input to their resolution. For example: Read more in the {% internal_link "Arguments guide", "/fields/arguments" %} -### Extra Field Metadata +## Extra Field Metadata Inside a field method, you can access some low-level objects from the GraphQL-Ruby runtime. Be warned, these APIs are subject to change, so check the changelog when updating. @@ -193,7 +233,8 @@ A few `extras` are available: - `owner` (the type that this field belongs to) - `lookahead` (see {% internal_link "Lookahead", "/queries/lookahead" %}) - `execution_errors`, whose `#add(err_or_msg)` method should be used for adding errors -- `argument_details` (Intepreter only), an instance of {{ "GraphQL::Execution::Interpreter::Arguments" | api_doc }} with argument metadata +- `argument_details` (Interpreter only), an instance of {{ "GraphQL::Execution::Interpreter::Arguments" | api_doc }} with argument metadata +- `parent` (the previous `object` in the query) - Custom extras, see below To inject them into your field method, first, add the `extras:` option to the field definition: @@ -214,7 +255,7 @@ At runtime, the requested runtime object will be passed to the field. __Custom extras__ are also possible. Any method on your field class can be passed to `extras: [...]`, and the value will be injected into the method. For example, `extras: [:owner]` will inject the object type who owns the field. Any new methods on your custom field class may be used, too. -### Field Parameter Default Values +## Field Parameter Default Values The field method requires you to pass `null:` keyword argument to determine whether the field is nullable or not. For another field you may want to override `camelize`, which is `true` by default. You can override this behavior by adding a custom field with overwritten `camelize` option, which is `true` by default. diff --git a/guides/fields/resolvers.md b/guides/fields/resolvers.md index 4278bef42fb..4ba1320b984 100644 --- a/guides/fields/resolvers.md +++ b/guides/fields/resolvers.md @@ -42,16 +42,16 @@ end ```ruby # Generate a field which returns a filtered, sorted list of items -def self.items_field(name, override_options) +def self.items_field(name, override_options, &block) # Prepare options default_field_options = { type: [Types::Item], null: false } field_options = default_field_options.merge(override_options) # Create the field - field(name, field_options) do + field(name, **field_options) do argument :order_by, Types::ItemOrder, required: false argument :category, Types::ItemCategory, required: false # Allow an override block to add more arguments - yield self if block_given? + instance_eval(&block) if block_given? end end @@ -99,24 +99,13 @@ So, if there are other, better options, why does `Resolver` exist? Here are a fe ## Using `resolver` -To add resolvers to your project, make a base class: +Use the base resolver class: ```ruby -# app/graphql/resolvers/base.rb module Resolvers - class Base < GraphQL::Schema::Resolver - # if you have a custom argument class, you can attach it: - argument_class Arguments::Base - end -end -``` - -Then, extend it as needed: - -```ruby -module Resolvers - class RecommendedItems < Resolvers::Base + class RecommendedItems < BaseResolver type [Types::Item], null: false + description "Items this user might like" argument :order_by, Types::ItemOrder, required: false argument :category, Types::ItemCategory, required: false @@ -140,9 +129,7 @@ And attach it to your field: ```ruby class Types::User < Types::BaseObject - field :recommended_items, - resolver: Resolvers::RecommendedItems, - description: "Items this user might like" + field :recommended_items, resolver: Resolvers::RecommendedItems end ``` @@ -173,7 +160,7 @@ end # app/graphql/resolvers/tasks_resolver.rb module Resolvers - class TasksResolver < GraphQL::Schema::Resolver + class TasksResolver < BaseResolver type [Types::TaskType], null: false def resolve @@ -189,7 +176,7 @@ A simple solution is to express the type as a string in the resolver: ```ruby module Resolvers - class TasksResolver < GraphQL::Schema::Resolver + class TasksResolver < BaseResolver type "[Types::TaskType]", null: false def resolve diff --git a/guides/fields/validation.md b/guides/fields/validation.md index ed00d09b1dc..3eb860c661e 100644 --- a/guides/fields/validation.md +++ b/guides/fields/validation.md @@ -4,24 +4,24 @@ doc_stub: false search: true section: Fields title: Validation -desc: Rails-like validations for arguments and fields +desc: Rails-like validations for arguments index: 3 --- -Fields (and their arguments, and input object arguments) can be validated at runtime using built-in or custom validators. +Arguments can be validated at runtime using built-in or custom validators. -Validations are configured in `field(...)` or `argument(...)` calls: +Validations are configured in `argument(...)` calls on fields or input objects: ```ruby -argument :home_phone, String, required: true, +argument :home_phone, String, description: "A US phone number", validates: { format: { with: /\d{3}-\d{3}-\d{4}/ } } ``` -or: +or, `validates required: { ... }` inside a `field ... do ... end` block: ```ruby -field :comments, [Comment], null: true, +field :comments, [Comment], description: "Find comments by author ID or author name" do argument :author_id, ID, required: false argument :author_name, String, required: false @@ -34,27 +34,6 @@ Validations can be provided with a keyword (`validates: { ... }`) or with a meth ## Built-In Validations -All the validators below accept the following options: - -- `allow_blank: true` will permit any input that responds to `.blank?` and returns true for it. -- `allow_null: true` will permit `null` (from JS) and/or `nil` (from Ruby) (bypassing the validation) -- `message: "..."` customizes the error message when the validation fails - -For example: - -```ruby -field :comments, [Comment], null: true, - description: "Find comments by author ID or author name" do - argument :author_id, ID, required: false - argument :author_name, String, required: false - # Include a message for the end user if the validation fails: - validates required: { - one_of: [:author_id, :author_name], - message: "May use either author_id or author_name, but not both." - } -end -``` - See each validator's API docs for details: - `length: { maximum: ..., minimum: ..., is: ..., within: ... }` {{ "Schema::Validator::LengthValidator" | api_doc }} @@ -63,16 +42,40 @@ See each validator's API docs for details: - `inclusion: { in: [...] }` {{ "Schema::Validator::InclusionValidator" | api_doc }} - `exclusion: { in: [...] }` {{ "Schema::Validator::ExclusionValidator" | api_doc }} - `required: { one_of: [...] }` {{ "Schema::Validator::RequiredValidator" | api_doc }} - +- `allow_blank: true|false` {{ "Schema::Validator::AllowBlankValidator" | api_doc }} +- `allow_null: true|false` {{ "Schema::Validator::AllowNullValidator" | api_doc }} +- `all: { ... }` {{ "Schema::Validator::AllValidator" | api_doc }} Some of the validators accept customizable messages for certain validation failures; see the API docs for examples. +`allow_blank:` and `allow_null:` may affect other validations, for example: + +```ruby +validates: { format: { with: /\A\d{4}\Z/ }, allow_blank: true } +``` + +Will permit any String containing four digits, or the empty string (`""`) if Rails is loaded. (GraphQL-Ruby checks for `.blank?`, which is usually defined by Rails.) + +Alternatively, they can be used alone, for example: + +```ruby +argument :id, ID, required: false, validates: { allow_null: true } +``` + +Will permit any query that passes `id: null`. + +Validation options may also be passed as procs that accept no parameters, for example: + +```ruby +validates :title, String, validates: { format: { with: -> { Flipper.enabled?(:new_title_format) ? NEW_TITLE_FORMAT : /.*/ }} +``` + ## Custom Validators You can write custom validators, too. A validator is a class that extends `GraphQL::Schema::Validator`. It should implement: - `def initialize(..., **default_options)` to accept any validator-specific options and pass along the defaults to `super(**default_options)` -- `def validate(object, context, value)` which is called at runtime to validate `value`. It may return a String error message or an Array of Strings. GraphQL-Ruby will add those messages to the top-level `"errors"` array along with runtime context information. +- `def validate(_empty, context, value)` which is called at runtime to validate `value`. It may return a String error message or an Array of Strings. GraphQL-Ruby will add those messages to the top-level `"errors"` array along with runtime context information. Then, custom validators can be attached either: @@ -80,5 +83,3 @@ Then, custom validators can be attached either: - by keyword, if the keyword is registered with `GraphQL::Schema::Validator.install(:custom, MyCustomValidator)`. (That would support `validates: { custom: { some: :options }})`.) Validators are initialized when the schema is constructed (at application boot), and `validate(...)` is called while executing the query. There's one `Validator` instance for each configuration on each field, argument, or input object. (`Validator` instances aren't shared.) - - diff --git a/guides/getting_started.md b/guides/getting_started.md index 9aafe533357..99ce10049bd 100644 --- a/guides/getting_started.md +++ b/guides/getting_started.md @@ -55,7 +55,7 @@ module Types # fields should be queried in camel-case (this will be `truncatedPreview`) field :truncated_preview, String, null: false # Fields can return lists of other objects: - field :comments, [Types::CommentType], null: true, + field :comments, [Types::CommentType], # And fields can have their own descriptions: description: "This post's comments, or null if this post has comments disabled." end @@ -72,21 +72,28 @@ end ### Build a Schema -Before building a schema, you have to define an [entry point to your system, the "query root"](https://graphql.org/learn/schema/#the-query-and-mutation-types): +Before building a schema, you have to define an [entry point to your system, the "query root"](https://graphql.org/learn/schema/#the-query-mutation-and-subscription-types): ```ruby class QueryType < GraphQL::Schema::Object description "The query root of this schema" - # First describe the field signature: - field :post, PostType, null: true do - description "Find a post by ID" - argument :id, ID, required: true - end + field :post, resolver: Resolvers::PostResolver +end +``` - # Then provide an implementation: - def post(id:) - Post.find(id) +Define how this field is resolved by creating a resolver class: + +```ruby +# app/graphql/resolvers/post_resolver.rb +module Resolvers + class PostResolver < BaseResolver + type Types::PostType, null: false + argument :id, ID + + def resolve(id:) + ::Post.find(id) + end end end ``` @@ -95,7 +102,7 @@ Then, build a schema with `QueryType` as the query entry point: ```ruby class Schema < GraphQL::Schema - query QueryType + query Types::QueryType end ``` @@ -133,7 +140,7 @@ See {% internal_link "Executing Queries","/queries/executing_queries" %} for mor If you're building a backend for [Relay](https://facebook.github.io/relay/), you'll need: - A JSON dump of the schema, which you can get by sending [`GraphQL::Introspection::INTROSPECTION_QUERY`](https://github.com/rmosolgo/graphql-ruby/blob/master/lib/graphql/introspection/introspection_query.rb) -- Relay-specific helpers for GraphQL, see the `GraphQL::Relay` guides. +- Relay-specific helpers for GraphQL, see the {% internal_link "Connection guide", "/pagination/connection_concepts" %}, {% internal_link "Mutation guide", "mutations/mutation_classes" %}, and {% internal_link "Object Identification guide", "/schema/object_identification" %}. ## Use with Apollo Client diff --git a/guides/graphql-ruby-dark.png b/guides/graphql-ruby-dark.png new file mode 100644 index 00000000000..4e3241b4971 Binary files /dev/null and b/guides/graphql-ruby-dark.png differ diff --git a/guides/guides.html b/guides/guides.html index 451c1f3b78a..f221039a739 100644 --- a/guides/guides.html +++ b/guides/guides.html @@ -3,6 +3,7 @@ sections: - name: Schema - name: Queries + - name: Execution - name: Type Definitions - name: Authorization - name: Fields @@ -15,6 +16,9 @@ - name: GraphQL Pro - name: GraphQL Pro - OperationStore - name: GraphQL Pro - Defer + - name: GraphQL Enterprise - Rate Limiters + - name: GraphQL Enterprise - Object Cache + - name: GraphQL Enterprise - Changesets - name: JavaScript Client - name: Language Tools - name: Testing diff --git a/guides/index.html b/guides/index.html index 13b31a1282c..39d666de7c4 100644 --- a/guides/index.html +++ b/guides/index.html @@ -1,56 +1,57 @@ --- title: Welcome +fullwidth: false ---
- GraphQL Ruby Logo + GraphQL Ruby Logo

GraphQL Ruby

+
+

The graphql gem implements the GraphQL Server Specification in Ruby.

+

Use it to add a GraphQL API to your Ruby or Rails app.

+
-

Install the Gem

-
+

Install the Gem

+

+ Get going fast with the graphql gem, + battle-tested and trusted by GitHub, Shopify, Flexport, Chime, and Kickstarter. +

{% highlight bash %} # Download the gem: -gem install graphql +bundle add graphql # Setup with Rails: rails generate graphql:install {% endhighlight %} -
-
-

- Get going fast with the graphql gem, - battle-tested and trusted by GitHub, Shopify and Kickstarter. -

-
-
-
-

Define Your Schema

-
-

- Describe your application with the - GraphQL schema - to create a self-documenting, strongly-typed API. -

-
-
+

Define Your Schema

+

+ Describe your application with a + GraphQL schema + to create a self-documenting, strongly-typed API. +

{% highlight ruby %} # app/graphql/types/profile_type.rb class Types::ProfileType < Types::BaseObject field :id, ID, null: false field :name, String, null: false - field :avatar, Types::PhotoType, null: true + field :avatar, Types::PhotoType end {% endhighlight %} -
-
-

Run Queries

+
-
+

Serve Queries

+

+ Provide custom data to clients and extend your API with + {% internal_link "mutations", "/mutations/mutation_root" %}, + {% internal_link "subscriptions", "/subscriptions/overview" %}, + {% internal_link "streaming responses", "/defer/overview" %}, + and {% internal_link "multiplexing", "/queries/multiplex" %}. +

{% highlight ruby %} # app/controllers/graphql_controller.rb result = MySchema.execute( @@ -60,17 +61,49 @@

Run Queries

) render json: result {% endhighlight %} -
-
-

- Serve queries to build a great UI or webservice. -

-
+
+
+

Harden Your API

+

+ Confidently deploy GraphQL with GraphQL-Ruby: +

    +
  • {% internal_link "Testing helpers", "/testing/overview" %} to validate your system
  • +
  • {% internal_link "Authorization", "/authorization/overview" %} integrates with your app's permission system
  • +
  • {% internal_link "GraphQL::Dataloader", "/dataloader/overview" %} optimizes access to data sources
  • +
  • {% internal_link "Complexity limits", "/queries/complexity_and_depth" %}, {% internal_link "timeouts", "/queries/timeout" %}, and {% internal_link "rate limits", "/limiters/overview" %} to protect your server resources
  • +
  • {% internal_link "Tracing", "/queries/tracing" %} for integration with your APM or custom usage
  • +
  • {% internal_link "API versioning", "/changesets/overview" %} to roll out changes while preserving client experience
  • +
  • {% internal_link "Persisted queries", "/operation_store/overview" %} to guarantee approved API usage
  • +
  • {% internal_link "Caching", "/object_cache/overview" %} to serve repeated data requests
  • +
+

+
+
+
+
+

Integrate with Client Libraries

+

+ {% internal_link "graphql-ruby-client", "/javascript_client/overview" %} provides integration with + {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %}, + {% internal_link "Relay", "/javascript_client/relay_subscriptions" %}, + {% internal_link "GraphiQL", "/javascript_client/graphiql_subscriptions" %}, + {% internal_link "urql", "/javascript_client/urql_subscriptions" %}, or custom JavaScript. +

+
+
+

Going Beyond

+

+ Customize your GraphQL API: +

    +
  • {% internal_link "Language tooling", "/language_tools/visitor/ %} for manipulating GraphQL documents
  • +
  • {% internal_link "Type system extensions", "/type_definitions/extensions/ %} for customizing your schema definition
  • +
  • {% internal_link "Query analysis", "/queries/ast_analysis" %} for ahead-of-time query inspection
  • +
+

-
-

- Add GraphQL to your Ruby app. Get Started! +

+ Add GraphQL to your Ruby app. Get Started!

diff --git a/guides/javascript_client/apollo_subscriptions.md b/guides/javascript_client/apollo_subscriptions.md index fbc268cb8b2..3b09d6f7b70 100644 --- a/guides/javascript_client/apollo_subscriptions.md +++ b/guides/javascript_client/apollo_subscriptions.md @@ -8,23 +8,23 @@ desc: GraphQL subscriptions with GraphQL-Ruby and Apollo Client index: 2 --- -GraphQL-Ruby's JavaScript client includes four kinds of support for Apollo Client: +GraphQL-Ruby's JavaScript client includes several kinds of support for Apollo Client: -- Apollo 2.x: - - [Overview](#apollo-2) - - [Pusher](#apollo-2--pusher) - - [Ably](#apollo-2--ably) - - [ActionCable](#apollo-2--actioncable) +- Apollo Link (2.x, 3.x): + - [Overview](#apollo-link) + - [Pusher](#apollo-link--pusher) + - [Ably](#apollo-link--ably) + - [ActionCable](#apollo-link--actioncable) - Apollo 1.x: - [Overview](#apollo-1) - [Pusher](#apollo-1--pusher) - [ActionCable](#apollo-1--actioncable) -## Apollo 2 +## Apollo Link -Apollo 2 is supported by implementing Apollo Links. +Apollo Links are used by Apollo client 2.x and 3.x. -## Apollo 2 -- Pusher +## Apollo Link -- Pusher `graphql-ruby-client` includes support for subscriptions with Pusher and ApolloLink. @@ -34,11 +34,7 @@ For example: ```js // Load Apollo stuff -import { ApolloLink } from 'apollo-link'; -import { ApolloClient } from 'apollo-client'; -import { HttpLink } from 'apollo-link-http'; -import { InMemoryCache } from 'apollo-cache-inmemory'; - +import { ApolloClient, HttpLink, ApolloLink, InMemoryCache } from "@apollo/client"; // Load PusherLink from graphql-ruby-client import PusherLink from 'graphql-ruby-client/subscriptions/PusherLink'; @@ -67,7 +63,7 @@ const client = new ApolloClient({ This link will check responses for the `X-Subscription-ID` header, and if it's present, it will use that value to subscribe to Pusher for future updates. -If you're using {% internal_link "compressed payloads", "/subscriptions/pusher_implementation#compressed-payloads" %}, configure a `decompress:` function, too: +If you're using {% internal_link "compressed payloads", "/subscriptions/pusher_implementation#payload-compression" %}, configure a `decompress:` function, too: ```javascript // Add `pako` to the project for gunzipping @@ -88,7 +84,7 @@ const pusherLink = new PusherLink({ }) ``` -## Apollo 2 -- Ably +## Apollo Link -- Ably `graphql-ruby-client` includes support for subscriptions with Ably and ApolloLink. @@ -98,10 +94,7 @@ For example: ```js // Load Apollo stuff -import { ApolloLink } from 'apollo-link'; -import { ApolloClient } from 'apollo-client'; -import { HttpLink } from 'apollo-link-http'; -import { InMemoryCache } from 'apollo-cache-inmemory'; +import { ApolloClient, HttpLink, ApolloLink, InMemoryCache } from '@apollo/client'; // Load Ably subscriptions link import AblyLink from 'graphql-ruby-client/subscriptions/AblyLink' // Load Ably and create a client @@ -133,7 +126,7 @@ For your __app key__, make a key with "Subscribe" and "Presence" privileges and {{ "/javascript_client/ably_key.png" | link_to_img:"Ably Subscription Key Privileges" }} -## Apollo 2 -- ActionCable +## Apollo Link -- ActionCable `graphql-ruby-client` includes support for subscriptions with ActionCable and ApolloLink. @@ -145,10 +138,7 @@ To use it, construct a split link that routes: For example: ```js -import { ApolloLink } from 'apollo-link'; -import { ApolloClient } from 'apollo-client'; -import { HttpLink } from 'apollo-link-http'; -import { InMemoryCache } from 'apollo-cache-inmemory'; +import { ApolloClient, HttpLink, ApolloLink, InMemoryCache } from '@apollo/client'; import { createConsumer } from '@rails/actioncable'; import ActionCableLink from 'graphql-ruby-client/subscriptions/ActionCableLink'; @@ -205,7 +195,7 @@ var OperationStoreClient = require("./OperationStoreClient") RailsNetworkInterface.use([OperationStoreClient.apolloMiddleware]) ``` -If you're using {% internal_link "compressed payloads", "/subscriptions/pusher_implementation#compressed-payloads" %}, configure a `decompress:` function, too: +If you're using {% internal_link "compressed payloads", "/subscriptions/pusher_implementation#payload-compression" %}, configure a `decompress:` function, too: ```javascript // Add `pako` to the project for gunzipping diff --git a/guides/javascript_client/graphiql_subscriptions.md b/guides/javascript_client/graphiql_subscriptions.md new file mode 100644 index 00000000000..6b1279d61e5 --- /dev/null +++ b/guides/javascript_client/graphiql_subscriptions.md @@ -0,0 +1,94 @@ +--- +layout: guide +doc_stub: false +search: true +section: JavaScript Client +title: GraphiQL Subscriptions +desc: Testing GraphQL subscriptions in the GraphiQL IDE +index: 5 +--- + +After setting up your server, you can integrate subscriptions into [GraphiQL](https://github.com/graphql/graphiql/tree/main/packages/graphiql#readme), the in-browser GraphQL IDE. + +## Adding GraphiQL to your app + +To get started, make a page for rendering GraphiQL, for example: + +```html + +
+``` + +Then, install GraphiQL (eg, `yarn add graphiql`) and add JavaScript code to import GraphiQL and render it on your page: + +```js +import { GraphiQL } from 'graphiql' +import React from 'react' +import { createRoot } from 'react-dom/client' +import 'graphiql/graphiql.css' +import { createGraphiQLFetcher } from '@graphiql/toolkit' + +const fetcher = createGraphiQLFetcher({ url: '/graphql' }) +const root = createRoot(document.getElementById('root')) +root.render() +``` + +After that, you should be able to load the page in your app and see the GraphiQL editor. + +## Ably + +To integrate {% internal_link "Ably subscriptions", "subscriptions/ably_implementation" %}, use `createAblyFetcher`, for example: + +```js +import Ably from "ably" +import createAblyFetcher from 'graphql-ruby-client/subscriptions/createAblyFetcher' + +// Initialize a client +// the key must have "subscribe" and "presence" permissions +const ably = new Ably.Realtime({ key: "your.application.key" }) + +// Initialize a new fetcher and pass it to GraphiQL below +var fetcher = createAblyFetcher({ ably: ably, url: "/graphql" }) +const root = createRoot(document.getElementById('root')) +root.render() +``` + +Under the hood, it will use `window.fetch` to send GraphQL operations to the server, then listen for `X-Subscription-ID` headers in responses. To customize its HTTP requests, you can pass a `fetchOptions:` object or a custom `fetch:` function to `createAblyFetcher({ ... })`. + +## Pusher + +To integrate {% internal_link "Pusher subscriptions", "subscriptions/pusher_implementation" %}, use `createPusherFetcher`, for example: + +```js +import Pusher from "pusher-js" +import createPusherFetcher from 'graphql-ruby-client/subscriptions/createPusherFetcher' + +// Initialize a client +const pusher = new Pusher("your-app-key", { cluster: "your-cluster" }) + +// Initialize a new fetcher and pass it to GraphiQL below +var fetcher = createPusherFetcher({ pusher: pusher, url: "/graphql" }) +const root = createRoot(document.getElementById('root')) +root.render() +``` + +Under the hood, it will use `window.fetch` to send GraphQL operations to the server, then listen for `X-Subscription-ID` headers in responses. To customize its HTTP requests, you can pass a `fetchOptions:` object or a custom `fetch:` function to `createPusherFetcher({ ... })`. + +## ActionCable + +To integrate {% internal_link "ActionCable subscriptions", "subscriptions/action_cable_implementation" %}, use `createActionCableFetcher`, for example: + +```js +import { createConsumer } from "@rails/actioncable" +import createActionCableFetcher from 'graphql-ruby-client/subscriptions/createActionCableFetcher'; + +// Initialize a client +const actionCable = createConsumer() + +// Initialize a new fetcher and pass it to GraphiQL below +var fetcher = createActionCableFetcher({ consumer: actionCable, url: "/graphql" }) +const root = createRoot(document.getElementById('root')) +root.render() +``` + +Under the hood, it will split traffic: it will send `subscription { ... }` operations via ActionCable and send queries and mutations via HTTP `POST` using `window.fetch`. To customize its HTTP requests, you can pass a `fetchOptions:` object or a custom `fetch:` function to `createActionCableFetcher({ ... })`. diff --git a/guides/javascript_client/overview.md b/guides/javascript_client/overview.md index e3c293c7149..6e9b4f41d81 100644 --- a/guides/javascript_client/overview.md +++ b/guides/javascript_client/overview.md @@ -26,3 +26,5 @@ See detailed guides for more info about its features: - Subscription support: - {% internal_link "Apollo integration", "/javascript_client/apollo_subscriptions" %} - {% internal_link "Relay integration", "/javascript_client/relay_subscriptions" %} + - {% internal_link "urql integration", "/javascript_client/urql_subscriptions" %} + - {% internal_link "GraphiQL integration", "/javascript_client/graphiql_subscriptions" %} diff --git a/guides/javascript_client/relay_subscriptions.md b/guides/javascript_client/relay_subscriptions.md index 0522b2eb42b..c641ac74c27 100644 --- a/guides/javascript_client/relay_subscriptions.md +++ b/guides/javascript_client/relay_subscriptions.md @@ -14,7 +14,10 @@ index: 3 - [Ably](#ably) - [ActionCable](#actioncable) -To use it, require `subscriptions/createHandler` and call the function with your client and optionally, your OperationStoreClient. +To use it, require `graphql-ruby-client/subscriptions/createRelaySubscriptionHandler` and call the function with your client and optionally, your OperationStoreClient. + +__Note:__ For Relay <11, use `import { createLegacyRelaySubscriptionHandler } from "graphql-ruby-client/subscriptions/createRelaySubscriptionHandler"` instead; the signature changed in Relay 11. + See the {% internal_link "Subscriptions guide", "/subscriptions/overview" %} for information about server-side setup. @@ -54,7 +57,7 @@ var network = Network.create(fetchQuery, subscriptionHandler) ### Compressed Payloads -If you're using {% internal_link "compressed payloads", "/subscriptions/pusher_implementation#compressed-payloads" %}, configure a `decompress:` function, too: +If you're using {% internal_link "compressed payloads", "/subscriptions/pusher_implementation#payload-compression" %}, configure a `decompress:` function, too: ```javascript // Add `pako` to the project for gunzipping @@ -122,7 +125,7 @@ var OperationStoreClient = require("./OperationStoreClient") // Create a Relay Modern-compatible handler var subscriptionHandler = createRelaySubscriptionHandler({ - cable: cable, + cable: createConsumer(...), operations: OperationStoreClient, }) @@ -130,6 +133,22 @@ var subscriptionHandler = createRelaySubscriptionHandler({ var network = Network.create(fetchQuery, subscriptionHandler) ``` +## With Relay Persisted Queries + +If you're using Relay's built-in [persisted query support](https://relay.dev/docs/guides/persisted-queries/), you can pass `clientName:` to the handler in order to build IDs that work with the {% internal_link "OperationStore", "/operation_store/overview.html" %}. For example: + +```js +var subscriptionHandler = createRelaySubscriptionHandler({ + cable: createConsumer(...), + clientName: "web-frontend", // This should match the one you use for `sync` +}) + +// Create a Relay Modern network with the handler +var network = Network.create(fetchQuery, subscriptionHandler) +``` + +Then, the ActionCable handler will use Relay's provided operation IDs to interact with the OperationStore. + ## fetchOperation function The `fetchOperation` function can be extracted from your `fetchQuery` function. Its signature is: diff --git a/guides/javascript_client/sync.md b/guides/javascript_client/sync.md index ae30035a356..4d818b017ae 100644 --- a/guides/javascript_client/sync.md +++ b/guides/javascript_client/sync.md @@ -15,7 +15,9 @@ JavaScript support for GraphQL projects using [graphql-pro](https://graphql.pro) - [Relay 2+ support](#use-with-relay-persisted-output) - [Apollo Client support](#use-with-apollo-client) - [Apollo Link support](#use-with-apollo-link) +- [Apollo Codegen Support](#use-with-apollo-codegen) - [Apollo Android support](#use-with-apollo-android) +- [Apollo Persisted Queries Support](#use-with-apollo-persisted-queries) - [Plain JS support](#use-with-plain-javascript) - [Authorization](#authorization) @@ -43,13 +45,17 @@ option | description `--url` | {% internal_link "Sync API", "/operation_store/getting_started.html#add-routes" %} url `--path` | Local directory to search for `.graphql` / `.graphql.js` files `--relay-persisted-output` | Path to a `.json` file from `relay-compiler ... --persist-output` +`--apollo-codegen-json-output` | Path to a `.json` file from `apollo client:codegen ... --target json` `--apollo-android-operation-output` | Path to an `OperationOutput.json` file from Apollo Android `--client` | Client ID ({% internal_link "created on server", "/operation_store/client_workflow" %}) `--secret` | Client Secret ({% internal_link "created on server", "/operation_store/client_workflow" %}) `--outfile` | Destination for generated code `--outfile-type` | What kind of code to generate (`js` or `json`) +`--header={key}:{value}` | Add a header to the outgoing HTTP request (may be repeated) `--add-typename` | Add `__typename` to all selection sets (for use with Apollo Client) `--verbose` | Output some debug information +`--changeset-version` | Set a {% internal_link "Changeset Version", "/changesets/installation#controller-setup" %} when syncing these queries. (`context[:changeset_version]` will also be required at runtime, when running these stored operations.) +`--dump-payload` | A file to write the HTTP Post payload into, or if no filename is passed, then the payload will be written to stdout. You can see these and a few others with `graphql-ruby-client sync --help`. @@ -64,7 +70,7 @@ To sync your queries with the server, use the `--path` option to point to your ` $ graphql-ruby-client sync --path=src/__generated__ --outfile=src/OperationStoreClient.js --url=... ``` -Then, the generated code may be integrated with Relay's [Network Layer](https://facebook.github.io/relay/docs/network-layer.html): +Then, the generated code may be integrated with Relay's [Network Layer](https://relay.dev/docs/guides/network-layer/): ```js // ... @@ -100,12 +106,15 @@ function fetchQuery(operation, variables, cacheConfig, uploadables) { ## Use With Relay Persisted Output -Relay 2.0+ includes a `--persist-output` option for `relay-compiler` which works perfectly with GraphQL-Ruby. (Relay's own docs, for reference: https://relay.dev/docs/en/persisted-queries.) +To use Relay's persisted output, add a `"file": ...` to your project's [`persistConfig` object](https://relay.dev/docs/guides/persisted-queries/). For example: -When generating queries for Relay, include `--persist-output`: - -``` -$ relay-compiler ... --persist-output path/to/persisted-queries.json +```json + "relay": { + ... + "persistConfig": { + "file": "./persisted-queries.json" + } + }, ``` Then, push Relay's generated queries to your OperationStore server with `--relay-persisted-output`: @@ -137,7 +146,7 @@ function fetchQuery(operation, variables,) { } ``` -(Inspired by https://relay.dev/docs/en/persisted-queries#network-layer-changes.) +(Inspired by https://relay.dev/docs/guides/persisted-queries/#network-layer-changes.) Now, your Relay app will only send operation IDs over the wire to the server. @@ -205,6 +214,25 @@ context = { Now, `context[:operation_id]` will be used to fetch a query from the database. +## Use with Apollo Codegen + +Use `apollo client:codegen ... --target json` to build a JSON artifact containing your app's queries. Then, pass the path to that artifact to `graphql-ruby-client sync --apollo-codegen-json-output path/to/output.json ...`. `sync` will use Apollo-generated `operationId`s to populate the `OperationStore`. + +Then, to use Apollo-style persisted query IDs, hook up the __Persisted Queries Link__ as described in [Apollo's documentation](https://www.apollographql.com/docs/react/api/link/persisted-queries/) + +Finally, __update the controller__ to pass the Apollo-style persisted query ID as the operation ID: + +```ruby +# app/controllers/graphql_controller.rb +context = { + # ... + # Support already-synced Apollo Persisted Queries: + operation_id: params[:extensions][:operationId] +} +``` + +Now, Apollo-style persisted query IDs will be used to fetch operations from the server's `OperationStore`. + ## Use with Apollo Android Apollo Android's [generateOperationOutput option](https://www.apollographql.com/docs/android/advanced/persisted-queries/#operationoutputjson) builds an `OperationOutput.json` file which works with the OperationStore. To sync those queries, __use the `--apollo-android-operation-output` option__: @@ -237,6 +265,29 @@ end You may also have to __update your app__ to send an identifier, so that the server can determine the "client name" used with the operation store. (Apollo Android sends a query hash, but the operation store expects IDs in the form `#{client_name}/#{query_hash}`.) +## Use with Apollo Persisted Queries + +Apollo client has a [Persisted Queries Link](https://www.apollographql.com/docs/react/api/link/persisted-queries/). You can use that link with GraphQL-Pro's {% internal_link "OperationStore", "/operation_store/overview" %}. First, create a manifest with [`generate-persisted-query-manifest`](https://www.apollographql.com/docs/react/api/link/persisted-queries/#1-generate-operation-manifests), then, pass the path to that file to `sync`: + +```sh +$ graphql-ruby-client sync --apollo-persisted-query-manifest=path/to/manifest.json ... +``` + +Then, configure Apollo Client to [use your persisted query manifest](https://www.apollographql.com/docs/react/api/link/persisted-queries/#persisted-queries-implementation). + +Finally, update your controller to receive the operation ID and pass it as `context[:operation_id]`: + +```ruby +client_name = "..." # TODO: send the client name as a query param or header +persisted_query_hash = params[:extensions][:persistedQuery][:sha256Hash] +context = { + # ... + operation_id: "#{client_name}/#{persisted_query_hash}" +} +``` + +The `operation_id` will also need your client name. Using Apollo Client, you could send this as a [custom header](https://www.apollographql.com/docs/react/networking/basic-http-networking/#customizing-request-headers) or another way that works for your application (eg, session or user agent). + ## Use with plain JavaScript `OperationStoreClient.getOperationId` takes an operation name as input and returns the server-side alias for that operation: diff --git a/guides/javascript_client/urql_subscriptions.md b/guides/javascript_client/urql_subscriptions.md new file mode 100644 index 00000000000..25983705666 --- /dev/null +++ b/guides/javascript_client/urql_subscriptions.md @@ -0,0 +1,54 @@ +--- +layout: guide +doc_stub: false +search: true +section: JavaScript Client +title: urql Subscriptions +desc: GraphQL subscriptions with GraphQL-Ruby and urql +index: 4 +--- + +GraphQL-Ruby currently supports using `urql` with the {% internal_link "ActionCable", "/subscriptions/action_cable_implementation" %} and {% internal_link "Pusher implementation", "/subscriptions/pusher_implementation" %}. + +## Pusher + +```js +import SubscriptionExchange from "graphql-ruby-client/subscriptions/SubscriptionExchange" +import Pusher from "pusher" +import { Client, defaultExchanges, subscriptionExchange } from 'urql' + +const pusherClient = new Pusher("your-app-key", { cluster: "us2" }) +const forwardToPusher = SubscriptionExchange.create({ pusher: pusherClient }) + +const client = new Client({ + url: '/graphql', + exchanges: [ + ...defaultExchanges, + subscriptionExchange({ + forwardSubscription: forwardToPusher + }), + ], +}); +``` + +## ActionCable + +```js +import { createConsumer } from "@rails/actioncable"; +import SubscriptionExchange from "graphql-ruby-client/subscriptions/SubscriptionExchange" + +const actionCable = createConsumer('ws://127.0.0.1:3000/cable'); +const forwardToActionCable = SubscriptionExchange.create({ consumer: actionCable }) + +const client = new Client({ + url: '/graphql', + exchanges: [ + ...defaultExchanges, + subscriptionExchange({ + forwardSubscription: forwardToActionCable + }), + ], +}); +``` + +Want to use `urql` with another subscription backend? Please {% open_an_issue "Using urql with ..." %}. diff --git a/guides/language_tools/c_parser.md b/guides/language_tools/c_parser.md new file mode 100644 index 00000000000..820f3e59e64 --- /dev/null +++ b/guides/language_tools/c_parser.md @@ -0,0 +1,25 @@ +--- +layout: guide +doc_stub: false +search: true +section: Language Tools +title: C-based Parser +desc: The GraphQL::CParser gem is a drop-in replacement for the built-in parser +index: 1 +--- + +GraphQL-Ruby includes a plain-Ruby parser, but a faster parser is available as a C extension. To use it, add the [`graphql-c_parser` gem](https://rubygems.org/gems/graphql-c_parser) to your project, for example: + +```ruby +bundle add graphql-c_parser +``` + +When `graphql-c_parser` is `require`d by your app, the C-based parser is installed as the default parser (as {{ "GraphQL.default_parser" | api_doc }}). Bundler requires the library automatically, but you can also require it manually: + +```ruby +require "graphql/c_parser" +``` + +This alternative parser is faster and uses less memory. + +The library also adds `GraphQL.scan_with_c` and `GraphQL.parse_with_c` for calling the C-based parser directly. diff --git a/guides/limiters/active_operation_limiter_dashboard.png b/guides/limiters/active_operation_limiter_dashboard.png new file mode 100644 index 00000000000..96140ed17c6 Binary files /dev/null and b/guides/limiters/active_operation_limiter_dashboard.png differ diff --git a/guides/limiters/active_operations.md b/guides/limiters/active_operations.md new file mode 100644 index 00000000000..c104523478b --- /dev/null +++ b/guides/limiters/active_operations.md @@ -0,0 +1,103 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Rate Limiters +title: Active Operation Limiter +desc: Limit the number of concurrent GraphQL operations +index: 2 +--- + +`GraphQL::Enterprise::ActiveOperationLimiter` prevents clients from running too many GraphQL operations at the same time. It uses {% internal_link "Redis", "limiters/redis" %} to track currently-running operations. + +## Why? + +Some clients may suddently swamp a server with tons of requests, occupying all available Ruby processes and therefore interrupting service for other clients. This limiter aims to prevent that at the GraphQL level by halting queries when a client already has lots of queries running. That way, server processes will remain available for other clients' requests. + +## Setup + +To use this limiter, update the schema configuration and include `context[:limiter_key]` in your queries. + +#### Schema Setup + +To setup the schema, add `use GraphQL::Enterprise::ActiveOperationLimiter` with a default `limit:` value: + +```ruby +class MySchema < GraphQL::Schema + # ... + use GraphQL::Enterprise::ActiveOperationLimiter, + redis: Redis.new(...), + # Or: + # connection_pool: ... + # redis_cluster: ... + limit: 5 +end +``` + +`limit: false` may also be given, which defaults to _no limit_ for this limiter. + +It also accepts a `stale_request_seconds:` option. The limiter uses that value to clean up request data in case of a crash or other unexpected scenario. + +Before requests will actually be halted, {% internal_link "soft mode", "/limiters/deployment#soft-limits" %} must be disabled. + +#### Query Setup + +In order to limit clients, the limiter needs a client identifier for each GraphQL operation. By default, it checks `context[:limiter_key]` to find it: + +```ruby +context = { + viewer: current_user, + # for example: + limiter_key: logged_in? ? "user:#{current_user.id}" : "anon-ip:#{request.remote_ip}", + # ... +} + +result = MySchema.execute(query_str, context: context) +``` + +Operations with the same `context[:limiter_key]` will rate limited in the same buckets. A limiter key is required; if a query is run without one, the limiter will raise an error. + +To provide a client identifier another way, see [Customization](#customization). + +## Customization + +`GraphQL::Enterprise::ActiveOperationLimiter` provides several hooks for customizing its behavior. To use these, make a subclass of the limiter and override methods as described: + +```ruby +# app/graphql/limiters/active_operations.rb +class Limiters::ActiveOperations < GraphQL::Enterprise::ActiveOperationsLimiter + # override methods here +end +``` + +The hooks are: + +- `def limiter_key(query)` should return a string which identifies the current client for `query`. +- `def limit_for(key, query)` should return an integer or `nil`. If an integer is returned, that limit is applied for the current query. If `nil` is returned, no limit is applied to the current query. +- `def soft_limit?(key, query)` can be implemented to customize the application of "soft mode". By default, it checks a setting in redis. +- `def handle_redis_error(err)` is called when the limit rescues an error from Redis. By default, it's passed to `warn` and the query is _not_ halted. + +## Instrumentation + +While the limiter is installed, it adds some information to the query context about its operation. It can be accessed at `context[:active_operation_limiter]`: + +```ruby +result = MySchema.execute(...) + +pp result.context[:active_operation_limiter] +# {:key=>"user:123", :limit=>2, :soft=>false, :limited=>true} +``` + +It returns a Hash containing: + +- `key: [String]`, the limiter key used for this query +- `limit: [Integer, nil]`, the limit applied to this query +- `soft: [Boolean]`, `true` if the query was run in "soft mode" +- `limited: [Boolean]`, `true` if the query exceeded the rate limit (but if `soft:` was also `true`, then the query was _not_ halted) + +You could use this to add detailed metrics to your application monitoring system, for example: + +```ruby +MyMetrics.increment("graphql.active_operation_limiter", tags: result.context[:active_operation_limiter]) +``` diff --git a/guides/limiters/deployment.md b/guides/limiters/deployment.md new file mode 100644 index 00000000000..0b2d21c7be7 --- /dev/null +++ b/guides/limiters/deployment.md @@ -0,0 +1,94 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Rate Limiters +title: Deploying Rate Limiters +desc: Tips for releasing limiters smoothly +index: 4 +--- + +Here are a few options for deploying GraphQL-Enterprise's rate limiters: + + +- The [Dashboard](#dashboard) shows some basic metrics about the limiter. +- [Soft limits](#soft-limits) start logging over-limit requests to the dashboard but don't actually halt traffic. +- [Subscriptions](#subscriptions) need extra consideration + + +## Dashboard + +Once installed, your {% internal_link "GraphQL-Pro dashboard", "/pro/dashboard" %} will include a simple metrics view: + +{{ "/limiters/active_operation_limiter_dashboard.png" | link_to_img:"GraphQL Active Operation Limiter Dashboard" }} + +To disable dashboard charts, add `use(... dashboard_charts: false)` to your configuration. + +Also, the dashboard includes a link to enable or disable "soft mode": + +{{ "/limiters/soft_button.png" | link_to_img:"GraphQL Rate Limiter Soft Mode Button" }} + +When "soft mode" is enabled, limited requests are _not_ actually halted (although they are _counted_). When "soft mode" is disabled, any over-limit requests are halted. + +For more detailed metrics, see the "Instrumentation" section of the documentation for each limiter. + +## Soft Limits + +By default, limiters don't actually halt queries; instead, they start out in "soft mode". In this mode: + +- limited/unlimited requests are counted in the [Dashboard](#dashboard) +- but, no requests are actually halted + +This mode is for assessing the impact of the limiter before it's applied to production traffic. Additionally, if you release the limiter but find that it's affecting production traffic adversely, you can re-enable "soft mode" to stop blocking traffic. + +To disable "soft mode" and start limiting, use the [Dashboard](#dashboard) or re-implement some of the customization methods of the limiter. + +You can also disable "soft mode" in Ruby: + +```ruby +# Turn "soft mode" off for the ActiveOperationLimiter +MySchema.enterprise_active_operation_limiter.set_soft_limit(false) +# or, for RuntimeLimiter +MySchema.enterprise_runtime_limiter.set_soft_limit(false) +``` + + +## Subscriptions + +If you're using {% internal_link "PusherSubscriptions", "/subscriptions/pusher_implementation" %} or {% internal_link "AblySubscriptions", "/subscriptions/ably_implementation" %}, then you'll need to accomodate subscriptions that were created _before_ you deployed the rate limiter. Those subscriptions are already stored in Redis and their contexts _don't_ include the required `limiter_key:` value. + +To address this, you can customize the limiter(s) you're using to provide a default value in this case. For example: + +```ruby +class CustomRuntimeLimiter < GraphQL::Enterprise::RuntimeLimiter + def limiter_key(query) + if query.subscription_update? && query.context[:limiter_key].nil? + # This subscription was created before limiter_key was required, + # so provide a value for it. + # If `context` includes enough information to create a + # "real" limiter key, you could also do that here. + # In this case, we're providing a default flag: + "legacy-subscription-update" + else + super + end + end + + def limit_for(key, query) + if key == "legacy-subscription-update" + nil # no limit in this case + else + super + end + end +end +``` + +With methods like that, any subscriptions created _before_ `limiter_key:` was required will not be subject to rate limits. Adjust those methods as needed for your application. Finally, be sure to attach your custom limiter in your schema, for example: + + +```ruby +# Use a custom subclass of GraphQL::Enterprise::RuntimeLimiter: +use CustomRuntimeLimiter, ... +``` diff --git a/guides/limiters/overview.md b/guides/limiters/overview.md new file mode 100644 index 00000000000..260560ed63f --- /dev/null +++ b/guides/limiters/overview.md @@ -0,0 +1,26 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Rate Limiters +title: Rate Limiters for GraphQL +desc: Manage access to your GraphQL API +index: 0 +--- + + +`GraphQL::Enterprise` includes rate limiters built especially for GraphQL. + +For REST APIs, rate limiters often count _requests_ and block clients when they exceed their limit over a certain period of time. However, this paradigm doesn't translate well to GraphQL because the cost of serving a request may vary dramatically depending on the GraphQL query contained in the request. Instead, `GraphQL::Enterprise` implements two other kinds of limiters: + +- An __active operation limiter__ which allows clients to run a certain number of operations _at a time_. For example, if the limit is five concurrent operations, and a client sends six requests simultaneously, then only five of those incoming operations will be executed; the sixth will be returned with an error and it may be retried when one of the five others finishes. +- A __runtime limiter__ which limits the amount of processing time a client may consume during a given window. For example, a limit of 120 seconds per minute would allow two concurrent requests on average -- although in practice, it might be spiky: perhaps five concurrent, 20-second-long requests, followed by 40 seconds of no requests. + +There's some overlap in these limiters; both of them constrain the amount of _time_ a client may force the server to spend in handling requests. The active operation limiter puts an upper bound on how _many_ processes a client may occupy while the runtime limiter puts a bound on total processing time (regardless of the number of concurrent operations at a given moment). + +To get started, read on: + +- {% internal_link "Configure Redis", "limiters/redis" %} for the limiters' backend +- {% internal_link "Active Operation Limiter", "limiters/active_operations" %} +- {% internal_link "Runtime Limiter", "limiters/runtime" %} diff --git a/guides/limiters/redis.md b/guides/limiters/redis.md new file mode 100644 index 00000000000..43b8df00f26 --- /dev/null +++ b/guides/limiters/redis.md @@ -0,0 +1,38 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Rate Limiters +title: Configuring Redis +desc: Preparing the rate limiter backend +index: 1 +--- + +Rate limiting requires a persistent Redis instance, just like [Sidekiq](https://github.com/mperham/sidekiq/wiki/Using-Redis) or the {% internal_link "Operation Store", "/operation_store/redis_backend" %}. Set `maxmemory-policy noeviction` in `redis.conf` to ensure that Redis doesn't silently drop keys when it reaches its memory limit. + +## Memory Usage + +Estimating memory usage depends on the string used to identify clients, since those are used in the Redis keys. Using 100-character client keys, the runtime limiter uses 400 bytes per client (two keys). Memory usage by the active operation limiter depends on the limit because each concurrent operation uses some memory; a higher limit permits more concurrent operations. With 10 active operations and a 100-character client key, the active operation limiter uses 350 bytes per client. Additionally, the limiters use up to 35kb for dashboards (2 limiters, for each one: 2x 60 per-minute keys, 24 hourly keys, and 30 daily keys @ 72 bytes per key). + +By those estimates, 1 gigabyte of memory would support both rate limiters for over 1.4 million active clients. + +## Connection Pool + +`ActiveOperationsLimiter` and `RuntimeLimiter` support [ConnectionPool](https://github.com/mperham/connection_pool). To use it, pass `connection_pool:`: + +```ruby +use GraphQL::Enterprise::RuntimeLimiter, # or ActiveOperationLimiter + connection_pool: ConnectionPool.new(...) { ... } + # ... +``` + +## Redis Cluster + +`ActiveOperationsLimiter` and `RuntimeLimiter` support [`redis-cluster`](https://github.com/redis/redis-rb/tree/master/cluster). To use it, pass `redis_cluster:`: + +```ruby +use GraphQL::Enterprise::RuntimeLimiter, # or ActiveOperationLimiter + redis_cluster: Redis::Cluster.new(...) + # ... +``` diff --git a/guides/limiters/runtime.md b/guides/limiters/runtime.md new file mode 100644 index 00000000000..0f2084ccf74 --- /dev/null +++ b/guides/limiters/runtime.md @@ -0,0 +1,117 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Rate Limiters +title: Runtime Limiter +desc: Limit the total runtime of a client's GraphQL Operations +index: 3 +--- + +`GraphQL::Enterprise::RuntimeLimiter` applies an upper bound to processing time consumed by a single client. It uses {% internal_link "Redis", "limiters/redis" %} track time with a [token bucket](https://en.wikipedia.org/wiki/Token_bucket) algorithm. + +## Why? + +This limiter prevents a single client from consuming too much processing time, regardless of whether it comes a burst of short-lived queries (which the {% internal_link "Active Operation Limiter", "/limiters/active_operations" %} can prevent) or a small number of long-running queries. Unlike request counters or complexity calculations, the runtime limiter pays no attention to the structure of the incoming request. Instead, it simply measures the time spent on the request _as a whole_ and halts queries when a client consumes more than the limit. + +## Setup + +To use this limiter, update the schema configuration and include `context[:limiter_key]` in your queries. + +### Schema Setup + +To setup the schema, add `use GraphQL::Enterprise::RuntimeLimiter` with a default `limit_ms:` value: + +```ruby +class MySchema < GraphQL::Schema + # ... + use GraphQL::Enterprise::RuntimeLimiter, + redis: Redis.new(...), + # Or: + # connection_pool: ... + # redis_cluster: ... + limit_ms: 90 * 1000 # 90 seconds per minute +end +``` + +`limit_ms: false` may also be given, which defaults to _no limit_ for this limiter. + +It also accepts a `window_ms:` option, which is the duration over which `limit_ms:` is added to a client's bucket. It defaults to `60_000` (one minute). + +Before requests will actually be halted, {% internal_link "soft mode", "/limiters/deployment#soft-limits" %} must be disabled. + +### Query Setup + +In order to limit clients, the limiter needs a client identifier for each GraphQL operation. By default, it checks `context[:limiter_key]` to find it: + +```ruby +context = { + viewer: current_user, + # for example: + limiter_key: logged_in? ? "user:#{current_user.id}" : "anon-ip:#{request.remote_ip}", + # ... +} + +result = MySchema.execute(query_str, context: context) +``` + +Operations with the same `context[:limiter_key]` will rate limited in the same buckets. A limiter key is required; if a query is run without one, the limiter will raise an error. + +To provide a client identifier another way, see [Customization](#customization). + +## Customization + +`GraphQL::Enterprise::RuntimeLimiter` provides several hooks for customizing its behavior. To use these, make a subclass of the limiter and override methods as described: + +```ruby +# app/graphql/limiters/runtime.rb +class Limiters::Runtime < GraphQL::Enterprise::RuntimeLimiter + # override methods here +end +``` + +The hooks are: + +- `def limiter_key(query)` should return a string which identifies the current client for `query`. +- `def limit_for(key, query)` should return an integer or `nil`. If an integer is returned, that limit is applied for the current query. If `nil` is returned, no limit is applied to the current query. +- `def soft_limit?(key, query)` can be implemented to customize the application of "soft mode". By default, it checks a setting in redis. +- `def handle_redis_error(err)` is called when the limit rescues an error from Redis. By default, it's passed to `warn` and the query is _not_ halted. + +## Instrumentation + +While the limiter is installed, it adds some information to the query context about its operation. It can be accessed at `context[:runtime_limiter]`: + + +```ruby +result = MySchema.execute(...) + +pp result.context[:runtime_limiter] +# {:key=>"custom-key-9", +# :limit_ms=>800, +# :remaining_ms=>0, +# :soft=>true, +# :limited=>true, +# :window_ms=>60_000} +``` + +It returns a Hash containing: + +- `key: [String]`, the limiter key used for this query +- `limit_ms: [Integer, nil]`, the limit applied to this query +- `remaining_ms: [Integer, nil]`, the amount of time remaining in this client's bucket +- `soft: [Boolean]`, `true` if the query was run in "soft mode" +- `limited: [Boolean]`, `true` if the query exceeded the rate limit (but if `soft:` was also `true`, then the query was _not_ halted) +- `window_ms: [Integer]` the configured `window_ms:` for the limiter + +You could use this to add detailed metrics to your application monitoring system, for example: + +```ruby +MyMetrics.increment("graphql.runtime_limiter", tags: result.context[:runtime_limiter]) +``` + +## Some Caveats + +The limiter will not _interrupt_ a long-running field. Instead, it stops executing new fields after a client exceeds its allowed processing time. This is because interrupting arbitrary code may have unintended consequences for I/O operations, see ["Timeout: Ruby's most dangerous API"](https://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/). + +Also, the limiter only checks remaining time at the _start_ of a query and it only decreases the remaining time at the _end_ of a query. This means that simulaneous queries may consume the remainder at the same time. Use the {% internal_link "Active Operation Limiter", "/limiters/active_operations" %} to limit behavior in this regard. This implementation is basically a trade-off: more granular updates would require more communication with Redis which would add overhead to each request. diff --git a/guides/limiters/runtime_limiter_dashboard.png b/guides/limiters/runtime_limiter_dashboard.png new file mode 100644 index 00000000000..d79626e7a37 Binary files /dev/null and b/guides/limiters/runtime_limiter_dashboard.png differ diff --git a/guides/limiters/soft_button.png b/guides/limiters/soft_button.png new file mode 100644 index 00000000000..3c162fd563e Binary files /dev/null and b/guides/limiters/soft_button.png differ diff --git a/guides/mutations/mutation_authorization.md b/guides/mutations/mutation_authorization.md index 06910fc10f2..f5b08c0f9f5 100644 --- a/guides/mutations/mutation_authorization.md +++ b/guides/mutations/mutation_authorization.md @@ -16,6 +16,22 @@ Before running a mutation, you probably want to do a few things: This guide describes how to accomplish that workflow with GraphQL-Ruby. +## Checking conditions before instantiating the mutation + +```ruby +class UpdateUserMutation < BaseMutation + # ... + + def resolve(update_user_input:, user:) + # ... + end + + def self.authorized?(obj, ctx) + super && ctx[:viewer].present? + end +end +``` + ## Checking the user permissions Before loading any data from the database, you might want to see if the user has a certain permission level. For example, maybe only `.admin?` users can run `Mutation.promoteEmployee`. @@ -41,7 +57,7 @@ end Now, when any non-`admin` user tries to run the mutation, it won't run. Instead, they'll get an error in the response. -Additionally, `#ready?` may return `false, { ... }` to return {% internal_link "errors as data", "/mutations/mutation_errors" %}: +Additionally, `#ready?` may return `false, { ... }` to return {% internal_link "errors as data", "/mutations/mutation_errors.html#errors-as-data" %}: ```ruby def ready? @@ -63,7 +79,7 @@ In short, here's an example: ```ruby class Mutations::PromoteEmployee < Mutations::BaseMutation # `employeeId` is an ID, Types::Employee is an _Object_ type - argument :employee_id, ID, required: true, loads: Types::Employee + argument :employee_id, ID, loads: Types::Employee # Behind the scenes, `:employee_id` is used to fetch an object from the database, # then the object is authorized with `Employee.authorized?`, then @@ -87,7 +103,7 @@ In this case, if the argument value is provided by `object_from_id` doesn't retu Alternatively if your `ID` doesn't specify both class _and_ id, resolvers have a `load_#{argument}` method that can be overridden. ```ruby -argument :employee_id, ID, required: true, loads: Types::Employee +argument :employee_id, ID, loads: Types::Employee def load_employee(id) ::Employee.find(id) @@ -98,7 +114,7 @@ If you don't want this behavior, don't use it. Instead, create arguments with ty ```ruby # No special loading behavior: -argument :employee_id, ID, required: true +argument :employee_id, ID ``` ## Can _this user_ perform _this action_? @@ -109,7 +125,7 @@ You can add this check by implementing a `#authorized?` method, for example: ```ruby def authorized?(employee:) - context[:current_user].manager_of?(employee) + super && context[:current_user].manager_of?(employee) end ``` @@ -117,11 +133,11 @@ When `#authorized?` returns `false` (or something falsey), the mutation will be #### Adding errors -To add errors as data (as described in {% internal_link "Mutation errors", "/mutations/mutation_errors" %}), return a value _along with_ `false`, for example: +To add errors as data (as described in {% internal_link "Mutation errors", "/mutations/mutation_errors.html#errors-as-data" %}), return a value _along with_ `false`, for example: ```ruby def authorized?(employee:) - if context[:current_user].manager_of?(employee) + super && if context[:current_user].manager_of?(employee) true else return false, { errors: ["Can't promote an employee you don't manage"] } @@ -133,8 +149,11 @@ Alternatively, you can add top-level errors by raising `GraphQL::ExecutionError` ```ruby def authorized?(employee:) - return true if context[:current_user].manager_of?(employee) - raise GraphQL::ExecutionError, "You can only promote your _own_ employees" + super && if context[:current_user].manager_of?(employee) + true + else + raise GraphQL::ExecutionError, "You can only promote your _own_ employees" + end end ``` diff --git a/guides/mutations/mutation_classes.md b/guides/mutations/mutation_classes.md index 06338086754..513ce24c968 100644 --- a/guides/mutations/mutation_classes.md +++ b/guides/mutations/mutation_classes.md @@ -33,8 +33,6 @@ GraphQL-Ruby includes two classes to help you write mutations: Besides those, you can also use the plain {% internal_link "field API", "/type_definitions/objects#fields" %} to write mutation fields. -An additional `null` helper method is provided on classes inheriting from `GraphQL::Schema::Mutation` to allow setting the nullability of the mutation. This is not required and defaults to `true`. - ## Example mutation class If you used the {% internal_link "install generator", "/schema/generators#graphqlinstall" %}, a base mutation class will already have been generated for you. If that's not the case, you should add a base class to your application, for example: @@ -56,11 +54,10 @@ Then extend it for your mutations: ```ruby class Mutations::CreateComment < Mutations::BaseMutation null true + argument :body, String + argument :post_id, ID - argument :body, String, required: true - argument :post_id, ID, required: true - - field :comment, Types::Comment, null: true + field :comment, Types::Comment field :errors, [String], null: false def resolve(body:, post_id:) @@ -87,6 +84,8 @@ The `#resolve` method should return a hash whose symbols match the `field` names (See {% internal_link "Mutation Errors", "/mutations/mutation_errors" %} for more information about returning errors.) +Also, you can configure `null(false)` in your mutation class to make the generated payload class non-null. + ## Hooking up mutations Mutations must be attached to the mutation root using the `mutation:` keyword, for example: @@ -105,9 +104,9 @@ An alternative approach is to use the `loads:` argument when defining the argume ```ruby class Mutations::AddStar < Mutations::BaseMutation - argument :post_id, ID, required: true, loads: Types::Post + argument :post_id, ID, loads: Types::Post - field :post, Types::Post, null: true + field :post, Types::Post def resolve(post:) post.star @@ -119,7 +118,7 @@ class Mutations::AddStar < Mutations::BaseMutation end ``` -By specifying that the `post_id` argument loads a `Types::Post` object type, a `Post` object will be loaded via {% internal_link "`Schema#object_from_id`", "/schema/definition.html#object-identification-hooks" %} with the provided `post_id`. +By specifying that the `post_id` argument loads a `Types::Post` object type, a `Post` object will be loaded via {% internal_link "`Schema.object_from_id`", "/schema/definition.html#object-identification" %} with the provided `post_id`. All arguments that end in `_id` and use the `loads:` method will have their `_id` suffix removed. For example, the mutation resolver above receives a `post` argument which contains the loaded object, instead of a `post_id` argument. @@ -127,9 +126,9 @@ The `loads:` option also works with list of IDs, for example: ```ruby class Mutations::AddStars < Mutations::BaseMutation - argument :post_ids, [ID], required: true, loads: Types::Post + argument :post_ids, [ID], loads: Types::Post - field :posts, [Types::Post], null: true + field :posts, [Types::Post] def resolve(posts:) posts.map(&:star) @@ -147,9 +146,9 @@ In some cases, you may want to control the resulting argument name. This can be ```ruby class Mutations::AddStar < Mutations::BaseMutation - argument :post_id, ID, required: true, loads: Types::Post, as: :something + argument :post_id, ID, loads: Types::Post, as: :something - field :post, Types::Post, null: true + field :post, Types::Post def resolve(something:) something.star @@ -162,3 +161,32 @@ end ``` In the above examples, `loads:` is provided a concrete type, but it also supports abstract types (i.e. interfaces and unions). + +### Resolving the type of loaded objects + +When `loads:` gets an object from {{ "Schema.object_from_id" | api_doc }}, it passes that object to {{ "Schema.resolve_type" | api_doc }} to confirm that it resolves to the same type originally configured with `loads:`. + +### Handling failed loads + +If `loads:` fails to find an object or if the loaded object isn't resolved to the specified `loads:` type (using {{ "Schema.resolve_type" | api_doc }}), a {{ "GraphQL::LoadApplicationObjectFailedError" | api_doc }} is raised and returned to the client. + +You can customize this behavior by implementing `def load_application_object_failed` in your mutation class, for example: + +```ruby +def load_application_object_failed(error) + raise GraphQL::ExecutionError, "Couldn't find an object for ID: `#{error.id}`" +end +``` + +Or, if `load_application_object_failed` returns a new object, that object will be used as the `loads:` result. + +### Handling unauthorized loaded objects + +When an object is _loaded_ but fails its {% internal_link "`.authorized?` check", "/authorization/authorization#object-authorization" %}, a {{ "GraphQL::UnauthorizedError" | api_doc }} is raised. By default, it's passed to {{ "Schema.unauthorized_object" | api_doc }} (see {% internal_link "Handling Unauthorized Objects", "/authorization/authorization.html#handling-unauthorized-objects" %}). You can customize this behavior by implementing `def unauthorized_object(err)` in your mutation, for example: + +```ruby +def unauthorized_object(error) + # Raise a nice user-facing error instead + raise GraphQL::ExecutionError, "You don't have permission to modify the loaded #{error.type.graphql_name}." +end +``` diff --git a/guides/mutations/mutation_errors.md b/guides/mutations/mutation_errors.md index de17cdd74af..6d1f74cd363 100644 --- a/guides/mutations/mutation_errors.md +++ b/guides/mutations/mutation_errors.md @@ -46,7 +46,7 @@ class Types::UserError < Types::BaseObject field :message, String, null: false, description: "A description of the error" - field :path, [String], null: true, + field :path, [String], description: "Which input value this error came from" end ``` @@ -72,12 +72,12 @@ def resolve(id:, attributes:) } else # Convert Rails model errors into GraphQL-ready error hashes - user_errors = post.errors.map do |attribute, message| + user_errors = post.errors.map do |error| # This is the GraphQL argument which corresponds to the validation error: - path = ["attributes", attribute.to_s.camelize(:lower)] + path = ["attributes", error.attribute.to_s.camelize(:lower)] { path: path, - message: message, + message: error.message, } end { @@ -129,20 +129,20 @@ Then, client apps can show the error messages to end users, so they might correc ## Nullable Mutation Payload Fields -To benefit from "Errors as Data" described above, mutation fields must have `null: true`. Why? +To benefit from "Errors as Data" described above, mutation fields must not have `null: false`. Why? Well, for _non-null_ fields (which have `null: false`), if they return `nil`, then GraphQL aborts the query and removes those fields from the response altogether. In mutations, when errors happen, the other fields may return `nil`. So, if those other fields have `null: false`, but they return `nil`, the GraphQL will panic and remove the whole mutation from the response, _including_ the errors! -In order to have the rich error data, even when other fields are `nil`, those fields must have `null: true` so that the type system can be obeyed when errors happen. +In order to have the rich error data, even when other fields are `nil`, those fields must have `null: true` (which is the default) so that the type system can be obeyed when errors happen. Here's an example of a nullable field (good!): ```ruby class Mutations::UpdatePost < Mutations::BaseMutation - # Use `null: true` to support rich errors: - field :post, Types::Post, null: true + # Use the default `null: true` to support rich errors: + field :post, Types::Post # ... end ``` diff --git a/guides/object_cache/caching.md b/guides/object_cache/caching.md new file mode 100644 index 00000000000..1d39b94b14e --- /dev/null +++ b/guides/object_cache/caching.md @@ -0,0 +1,202 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Object Cache +title: Caching Results +desc: Configuration options for caching objects and fields +index: 2 +--- + +`GraphQL::Enterprise::ObjectCache` supports several different caching configurations for objects and fields. To get started, include the extension in your base object class and base field class and use `cacheable(...)` to set up the default cache behavior: + +```ruby +# app/graphql/types/base_object.rb +class Types::BaseObject < GraphQL::Schema::Object + include GraphQL::Enterprise::ObjectCache::ObjectIntegration + field_class Types::BaseField + cacheable(...) # see below + # ... +end +``` + +```ruby +# app/graphql/types/base_field.rb +class Types::BaseField < GraphQL::Schema::Field + include GraphQL::Enterprise::ObjectCache::FieldIntegration + cacheable(...) # see below + # ... +end +``` + +Also, make sure your base interface module is using your field class: + +```ruby +# app/graphql/types/base_interface.md +module Types::BaseInterface + field_class Types::BaseField +end +``` + +Field caching can be configured per-field, too, for example: + +```ruby +field :latest_update, Types::Update, null: false, cacheable: { ttl: 60 } + +field :random_number, Int, null: false, cacheable: false +``` + +Only _queries_ are cached. `ObjectCache` skips mutations and subscriptions altogether. + +## `cacheable(true|false)` + +`cacheable(true)` means that the configured type or field may be stored in the cache until its cache fingerprint changes. It also defaults to `public: false`, meaning that clients will _not_ share cached responses. See [`public:`](#public) below for more about this option. + +`cacheable(false)` disables caching for the configured type or field. Any query that includes this type or field will neither check for an already-cached value nor update the cache with its result. + +## `public:` + +`cacheable(public: false)` means that a type or field may be _cached_, but {% internal_link "`Schema.private_context_fingerprint_for(ctx)`", "/object_cache/schema_setup#context-fingerprint" %} should be included in its cache key. In practice, this means that each client can have its own cached responses. Any query that contains a `cacheable(public: false)` type or field will use a private cache key. + +`cacheable(public: true)` means that cached values from this type or field may be shared by _all_ clients. Use this for public-facing data which is the same for all viewers. Queries that include _only_ `public: true` types and fields will not include `Schema.private_context_fingerprint_for(ctx)` in their cache keys. That way their responses will be shared by all clients who request them. + +## `ttl:` + +`cacheable(ttl: seconds)` expires any cached value after the given number of seconds, regardless of cache fingerprint. `ttl:` shines in a few cases: + +- Objects that can't reliably generate a fingerprint value (for example, they have no `.updated_at` timestamp). In this case, a conservative `ttl` may be the only option for cache expiration. +- Or, root-level fields that should be expired after a certain amount of time. The root-level `Query` often has _no_ backing object, so it won't have a cache fingerprint, either. Adding `cacheable: { ttl: ... }` to root level fields will provide some caching along with a guarantee about when they'll be expired. +- Or, list responses that may be difficult to invalidate properly (see below). + +Under the hood, `ttl:` is implemented with Redis's `EXPIRE`. + +## Caching lists and connections + +Lists and connections require a little extra consideration. By default, each _item_ in a list is registered with the cache, but when new items are created, they are unknown to the cache and therefore don't invalidate the cached result. There are two main approaches to address this. + +### `has_many` lists + +In order to effectively bust the cache, items that belong to the list of "parent" object should __update the parent__ (eg, Rails `.touch`) whenever they're created, destroyed, or updated. For example, if there's a list of players on a team: + +```graphql +{ + team { players { totalCount } } +} +``` + +None of the _specific_ `Player`s will be part of the cached response, but the `Team` will be. To properly invalidate the cache, the `Team`'s `updated_at` (or other cache key) should be updated whenever a `Player` is added or removed from the `Team`. + +If a list may be sorted, then updates to `Player`s should also update the `Team` so that any sorted results in the cache are invalidated, too. Alternatively (or additionally), you could use a `ttl:` to expire cached results after a certain duration, just to be sure that results are eventually expired. + +With Rails, you can accomplish this with: + +```ruby + # update the team whenever a player is saved or destroyed: + belongs_to :team, touch: true +``` + +### Top-level lists + +For `ActiveRecord::Relation`s _without_ a "parent" object, you can use `GraphQL::Enterprise::ObjectCache::CacheableRelation` to make a synthetic cache entry for the _whole_ relation. To use this class, make a subclass and implement `def items`, for example: + +```ruby +class AllTeams < GraphQL::Enterprise::ObjectCache::CacheableRelation + def items(division: nil) + teams = Team.all + if division + teams = teams.where(division: division) + end + teams + end +end +``` + +Then, in your resolver, use your new class to retrieve the items: + +```ruby +class Query < GraphQL::Schema::Object + field :teams, Team.connection_type do + argument :division, Division, required: false + end + + def teams(division: nil) + AllTeams.items_for(self, division: division) + end +end +``` + +If you're using {{ "GraphQL::Schema::Resolver" | api_doc }}, you'd call `.items_for` slightly differently: + +```ruby +def resolve(division: nil) + # use `context[:current_object]` to get the GraphQL::Schema::Object instance whose field is being resolved + AllTeams.items_for(context[:current_object], division: division) +end +``` + +Finally, you'll need to handle `CacheableRelation`s in your object identification methods, for example: + +```ruby +class MySchema < GraphQL::Schema + # ... + def self.id_from_object(object, type, ctx) + if object.is_a?(GraphQL::Enterprise::ObjectCache::CacheableRelation) + object.id + else + # The rest of your id_from_object logic here... + end + end + + def self.object_from_id(id, ctx) + if (cacheable_rel = GraphQL::Enterprise::ObjectCache::CacheableRelation.find?(id)) + cacheable_rel + else + # The rest of your object_from_id logic here... + end + end +end +``` + +In this example, `AllTeams` implements several methods to support caching: + +- `#id` creates a cache-friendly, stable global ID +- `#to_param` creates a cache fingerprint (using Rails's `#cache_key` under the hood) +- `.find?` retrieves the list based on its ID + +This way, if a `Team` is created, the cached result will be invalidated and a fresh result will be created. + +Alternatively (or additionally), you could use a `ttl:` to expire cached results after a certain duration, just to be sure that results are eventually expired. + +### Connections + +By default, connection-related objects (like `*Connection` and `*Edge` types) "inherit" cacheability from their node types. You can override this in your base classes as long as `GraphQL::Enterprise::ObjectCache::ObjectIntegration` is included in the inheritance chain somewhere. + +## Caching Introspection + +By default, introspection fields are considered _public_ for all queries. This means that they are considered cacheable and their results will be reused for any clients who request them. When {% internal_link "adding the ObjectCache to your schema", "/object_cache/schema_setup#add-the-cache", %}, you can provide some options to customize this behavior: + +- `cache_introspection: { public: false, ... }` to use [`public: false`](#public) for all introspection fields. Use this if you hide schema members for some clients. +- `cache_introspection: false` to completely disable caching on introspection fields. +- `cache_introspection: { ttl: ..., ... }` to set a [ttl](#ttl) (in seconds) for introspection fields. + +## Object Dependencies + +By default, the `object` of a GraphQL Object type is used for caching the fields selected on that object. But, you can specify what object (or objects) should be used to check the cache by implementing `def self.cache_dependencies_for(object, context)` in your type definition. For example: + +```ruby +class Types::Player + def self.cache_dependencies_for(player, context) + # we update the team's timestamp whenever player details change, + # so ignore the `player` for caching purposes + player.team + end +end +``` + +Use this to: + +- improve performance when caching lists of children that belong to a parent object +- register other objects with the ObjectCache when running a query. (`cacheable_object(obj)` or `def self.object_fingerprint_for` can also be used in this case.) + +If this method returns an `Array`, each object in the array will be registered with the cache. diff --git a/guides/object_cache/memcached.md b/guides/object_cache/memcached.md new file mode 100644 index 00000000000..7ab0b9af266 --- /dev/null +++ b/guides/object_cache/memcached.md @@ -0,0 +1,18 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Object Cache +title: Dalli Configuration +desc: Setting up the Memcached backend +index: 3 +--- + +`GraphQL::Enterprise::ObjectCache` can also run with a Memcached backend via the [Dalli](https://github.com/petergoldstein/dalli) client gem. + +Set it up by passing a `Dalli::Client` instance as `dalli: ...`, for example: + +```ruby +use GraphQL::Enterprise::OperationStore, dalli: Dalli::Client.new(...) +``` diff --git a/guides/object_cache/overview.md b/guides/object_cache/overview.md new file mode 100644 index 00000000000..d04d93e648f --- /dev/null +++ b/guides/object_cache/overview.md @@ -0,0 +1,45 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Object Cache +title: GraphQL ObjectCache +desc: A server-side cache for GraphQL-Ruby +index: 0 +--- + +`GraphQL::Enterprise::ObjectCache` is an application-level cache for GraphQL-Ruby servers. It works by storing a {% internal_link "_cache fingerprint_ for each object", "/object_cache/schema_setup#object-fingerprint" %} in a query, then serving a cached response as long as those fingerprints don't change. The cache can also be customized with {% internal_link "TTLs", "/object_cache/caching#ttl" %}. + +## Why? + +`ObjectCache` can greatly reduce GraphQL response times by serving cached responses when the underlying data for a query hasn't changed. + +Usually, a GraphQL query alternates between data fetching and calling application logic: + + +{{ "/object_cache/query-without-cache.png" | link_to_img:"GraphQL-Ruby profile, without caching" }} + + +But with `ObjectCache`, it checks the cache first, returning a cached response if possible: + +{{ "/object_cache/query-with-cache.png" | link_to_img:"GraphQL-Ruby profile, with ObjectCache" }} + +This reduces latency for clients and reduces the load on your database and application server. + +## How + +Before running a query, `ObjectCache` creates a fingerprint for the query using {{ "GraphQL::Query#fingerprint" | api_doc }} and {% internal_link "`Schema.context_fingerprint_for(ctx)`", "/object_cache/schema_setup#context-fingerprint" %}. Then, it checks the backend for a cached response which matches the fingerprint. + +If a match is found, the `ObjectCache` fetches the objects previously visited by this query. Then, it compares the current fingerprint of each object ot the one in the cache and checks `.authorized?` for that object. If the fingerprints all match and all objects pass authorization checks, then the cached response returned. (Authorization checks can be {% internal_link "disabled", "/object_cache/schema_setup#disabling-reauthorization" %}.) + +If there is no cached response or if the fingerprints don't match, then the incoming query is re-evaluated. While it's executed, `ObjectCache` gathers the IDs and fingerprints of each object it encounters. When the query is done, the result and the new object fingerprints are written to the cache. + +## Setup + +To get started with the object cache: + +- {% internal_link "Prepare the schema", "/object_cache/schema_setup" %} +- Set up a {% internal_link "Redis backend", "/object_cache/redis" %} or {% internal_link "Memcached backend", "/object_cache/memcached" %} +- {% internal_link "Configure types and fields for caching", "/object_cache/caching" %} +- Check out the {% internal_link "runtime considerations", "/object_cache/runtime_considerations" %} diff --git a/guides/object_cache/query-with-cache.png b/guides/object_cache/query-with-cache.png new file mode 100644 index 00000000000..1fc156866c7 Binary files /dev/null and b/guides/object_cache/query-with-cache.png differ diff --git a/guides/object_cache/query-without-cache.png b/guides/object_cache/query-without-cache.png new file mode 100644 index 00000000000..5a070097e3d Binary files /dev/null and b/guides/object_cache/query-without-cache.png differ diff --git a/guides/object_cache/redis.md b/guides/object_cache/redis.md new file mode 100644 index 00000000000..9d352ed84a1 --- /dev/null +++ b/guides/object_cache/redis.md @@ -0,0 +1,64 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Object Cache +title: Redis Configuration +desc: Setting up the Redis backend +index: 3 +--- + +`GraphQL::Enterprise::ObjectCache` requires a Redis connection to store cached responses. Unlike `OperationStore` or rate limiters, this Redis instance should be configured to evict keys as needed. + +## Memory Management + +Memory consumption is hard to estimate since it depends on how many queries the cache receives, how many objects those queries reference, how big the response is for those queries, and how long the fingerprints are for each object and query. To manage memory, configure the Redis instance with a `maxmemory` and `maxmemory-policy` directive, for example: + + +``` +maxmemory 1gb +maxmemory-policy allkeys-lfu +``` + +Additionally, consider conditionally skipping the cache to prioritize your most critical GraphQL traffic. + +## Redis Cluster + +`ObjectCache` also supports Redis Cluster. To use, pass `redis_cluster:`: + +```ruby +use GraphQL::Enterprise::ObjectCache, redis_cluster: Redis::Cluster.new(...) +``` + +Under the hood, it uses query fingerprints as [hash tags](https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/#hash-tags) and each cached result has its own set of object metadata. + +## Connection Pool + +`ObjectCache` also supports [ConnectionPool](https://github.com/mperham/connection_pool). To use it, pass `connection_pool:`: + +```ruby +use GraphQL::Enterprise::ObjectCache, connection_pool: ConnectionPool.new(...) { ... } +``` + +## Data Structure + +Under the hood, `ObjectCache` stores a mapping of queries and objects. Additionally, there are back-references from objects to queries that reference them. In general, like this: + +``` +"query1:result" => '{"data":{...}}' +"query1:objects" => ["obj1:v1", "obj2:v2"] + +"query2:result" => '{"data":{...}}' +"query2:objects" => ["obj2:v2", "obj3:v1"] + +"obj1:v1" => { "fingerprint" => "...", "id" => "...", "type_name" => "..." } +"obj2:v2" => { "fingerprint" => "...", "id" => "...", "type_name" => "..." } +"obj3:v1" => { "fingerprint" => "...", "id" => "...", "type_name" => "..." } + +"obj1:v1:queries" => ["query1"] +"obj2:v2:queries" => ["query1", "query2"] +"obj3:v1:queries" => ["query2"] +``` + +These mappings enable proper clean-up when queries or objects are expired from the cache. Additionally, whenever `ObjectCache` finds incomplete data in storage (for example, a necessary key was evicted), then it invalidates the whole query and re-runs it. diff --git a/guides/object_cache/runtime_considerations.md b/guides/object_cache/runtime_considerations.md new file mode 100644 index 00000000000..4eb959a8ae0 --- /dev/null +++ b/guides/object_cache/runtime_considerations.md @@ -0,0 +1,67 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Object Cache +title: Runtime Considerations +desc: Settings and observability per-query +index: 4 +--- + +With caching configured, here are a few more things to keep in mind while queries are running. + +## Skipping the cache + +You can set `skip_object_cache: true` in your query `context: { ... }` to disable `ObjectCache` for a given query. + +## Manually adding an object to caching + +By default, `ObjectCache` gathers the objects "behind" each GraphQL object in the result, then uses their fingerprints as cache keys. To manually register another object with the cache while a query is running, call `Schema::Object.cacheable_object(...)`, passing the object and `context`. For example: + +```ruby +field :team_member_count, Integer, null: true do + argument :name, String, required: true +end + +def team_member_count(name:) + team = Team.find_by(name: name) + if team + # Register this object so that the cached result + # will be invalidated when the team is updated: + Types::Team.cacheable_object(team, context) + team.members.count + else + nil + end +end +``` + +(When the cache is disabled, `cacheable_object(...)` is a no-op.) + +## Measuring the cache + +While the cache is running, it logs some data in a Hash as `context[:object_cache]`. For example: + +```ruby +result = MySchema.execute(...) +pp result.context[:object_cache] +{ + key: "...", # the cache key used for this query + write: true, # if this query caused an update to the cache + ttl: 15, # the smallest `ttl:` value encountered in this query (used for this query's result) + hit: true, # if this query returned a cached result + public: false, # true or false, whether this query used a public cache key or a private one + messages: ["...", "..."], # status messages about the cache's behavior + objects: Set(...), # application objects encountered during the query + uncacheable: true, # if ObjectCache found a reason that this query couldn't be cached (see `messages: ...` for reason) + reauthorized_cached_objects: true, + # if `.authorized?` was checked for cached objects, see "Disabling Reauthorization" +} +``` + +## Manually refreshing the cache + +If you need to manually clear the cache for a query, pass `context: { refresh_object_cache: true, ... }`. This will cause the `ObjectCache` to remove the already-cached result (if there was one), reassess the query for cache validity, and return a freshly-executed result. + +Usually, this shouldn't be necessary; making sure objects update their {% internal_link "cache fingerprints", "/object_cache/schema_setup.html#object-fingerprint" %} will cause entries to expire when they should be re-executed. See also {% internal_link "Schema fingerprint", "/object_cache/schema_setup.html#schema-fingerprint" %} for expiring _all_ results in the cache. diff --git a/guides/object_cache/schema_setup.md b/guides/object_cache/schema_setup.md new file mode 100644 index 00000000000..9d28e46c1b6 --- /dev/null +++ b/guides/object_cache/schema_setup.md @@ -0,0 +1,107 @@ +--- +layout: guide +doc_stub: false +search: true +enterprise: true +section: GraphQL Enterprise - Object Cache +title: Schema Setup +desc: Prepare your schema to serve cached responses +index: 1 +--- + +To prepare the schema to serve cached responses, you have to add `GraphQL::Enterprise::ObjectCache` and implement a few hooks. + +## Add the Cache + +In your schema, add `use GraphQL::Enterprise::ObjectCache, redis: ...`: + +```ruby +class MySchema < GraphQL::Schema + use GraphQL::Enterprise::ObjectCache, redis: CACHE_REDIS +end +``` + +See the {% internal_link "Redis guide", "/object_cache/redis" %} or {% internal_link "Memcached guide", "/object_cache/memcached" %} for details about configuring cache storage. + +Additionally, it accepts some options for customizing how introspection is cached, see {% internal_link "Caching Introspection", "/object_cache/caching#caching-introspection" %} + +## Context Fingerprint + +Additionally, you should implement `def self.private_context_fingerprint_for(context)` to return a string identifying the private scope of the given context. This method will be called whenever a query includes a {% internal_link "`public: false` type or field", "/object_cache/caching#public" %}. For example: + +```ruby +class MySchema < GraphQL::Schema + # ... + def self.private_context_fingerprint_for(context) + viewer = context[:viewer] + if viewer.nil? + # This should never happen, but just in case: + raise("Invariant: No viewer in context! Can't create a private context fingerprint" ) + end + + # include permissions in the fingerprint so that if the viewer's permissions change, the cache will be invalidated + permission_fingerprint = viewer.team_memberships.map { |tm| "#{tm.team_id}/#{tm.permission}" }.join(":") + + "user:#{viewer.id}:#{permission_fingerprint}" + end +end +``` + +Whenever queries including `public: false` are cached, the private context fingerprint will be part of the cache key, preventing responses from being shared between different viewers. + +The returned String should reflect any aspects of `context` that, if changed, should invalidate the cache. For example, if a user's permission level or team memberships change, then any previously-cached responses should be ignored. + +## Object Fingerprint + +In order to determine whether cached results should be returned or invalidated, GraphQL needs a way to determine the "version" of each object in the query. It uses `Schema.object_fingerprint_for(object)` to do this. By default, it checks `.cache_key_with_version` (implemented by Rails), then `.to_param`, then it returns `nil`. Returning `nil` tells the cache not to use the cache _at all_. To customize this behavior, you can implement `def self.object_fingerprint_for(object)` in your schema: + +```ruby +class MySchema < GraphQL::Schema + # ... + + # For example, if you defined `.custom_cache_key` and `.uncacheable?` + # on objects in your application: + def self.object_fingerprint_for(object) + if object.respond_to?(:custom_cache_key) + object.custom_cache_key + elsif object.respond_to?(:uncacheable?) && object.uncacheable? + nil # don't cache queries containing this object + else + super + end + end +end +``` + +The returned strings are used as cache keys in the database -- whenever they change, stale data is left to be {% internal_link "cleaned up by Redis", "/object_cache/redis#memory-management" %}. + +## Object Identification + +`ObjectCache` depends on object identification hooks used elsewhere in GraphQL-Ruby: + +- `def self.id_from_object(object, type, context)` which returns a globally-unique String id for `object` +- `def self.object_from_id(id, context)` which returns the application object for the given globally-unique `id` +- `def self.resolve_type(abstract_type, object, context)` which returns a GraphQL object type definition to use for `object` + +After your schema is setup, you can {% internal_link "configure caching on your types and fields", "/object_cache/caching", %}. + +## Schema Fingerprint + + `ObjectCache` will also call `.fingerprint` on your Schema class. You can implement this method to return a new string if you make breaking changes to your schema, for example: + + ```ruby +class MySchema < GraphQL::Schema + def self.fingerprint + "v2" # increment this if there are breaking changes to the schema + end +end +``` + +By returning a new `MySchema.fingerprint`, _all_ previously-cached results will be expired. + +## Disabling Reauthorization + +By default, `ObjectCache` checks `.authorized?` on each object before returning a cached result. However, if all authorization-related considerations are present in the object's cache fingerprint, then you can disable this check in two ways: + +- __per-query__, by passing `context: { reauthorize_cached_objects: false }` +- __globally__, by configuring `use GraphQL::Enterprise::ObjectCache, ... reauthorize_cached_objects: false` diff --git a/guides/operation_store/active_record_backend.md b/guides/operation_store/active_record_backend.md index d69861f4183..8b36454343b 100644 --- a/guides/operation_store/active_record_backend.md +++ b/guides/operation_store/active_record_backend.md @@ -13,7 +13,24 @@ GraphQL-Pro's `OperationStore` can use ActiveRecord to store persisted queries. ## Database Setup -To use ActiveRecord, `GraphQL::Pro::OperationStore` requires some database tables. You can add these with a migration: +To use ActiveRecord, `GraphQL::Pro::OperationStore` requires some database tables. + +### Rails Generator + +With Rails, you can generate the required migration then run it: + +```bash +$ rails generate graphql:operation_store:create +$ rails db:migrate +``` + +(You'll have to run that migration on any staging or production servers, too.) + +Now, `OperationStore` has what it needs to save queries using ActiveRecord! + +### Manual Setup + +You can also create the required migration by manually by generating an empty migration: ```bash $ rails generate migration SetupOperationStore @@ -33,25 +50,25 @@ def change add_index :graphql_clients, :name, unique: true add_index :graphql_clients, :secret, unique: true + create_table :graphql_operations, primary_key: :id do |t| + t.column :digest, :string, null: false + t.column :body, :text, null: false + t.column :name, :string, null: false + t.timestamps + end + add_index :graphql_operations, :digest, unique: true + create_table :graphql_client_operations, primary_key: :id do |t| t.references :graphql_client, null: false t.references :graphql_operation, null: false t.column :alias, :string, null: false t.column :last_used_at, :datetime - t.column :is_archived, :boolean + t.column :is_archived, :boolean, default: false t.timestamps end add_index :graphql_client_operations, [:graphql_client_id, :alias], unique: true, name: "graphql_client_operations_pairs" add_index :graphql_client_operations, :is_archived - create_table :graphql_operations, primary_key: :id do |t| - t.column :digest, :string, null: false - t.column :body, :text, null: false - t.column :name, :string, null: false - t.timestamps - end - add_index :graphql_operations, :digest, unique: true - create_table :graphql_index_entries, primary_key: :id do |t| t.column :name, :string, null: false end @@ -83,3 +100,20 @@ GraphQL-Pro 1.15.0 introduced new features for the OperationStore. To enable the add_column :graphql_client_operations, :is_archived, :boolean, default: false add_column :graphql_client_operations, :last_used_at, :datetime ``` + +## Updating `last_used_at` + +By default, GraphQL-Pro updates `last_used_at` values in a background thread every 5 seconds. You can customize this by passing a number of seconds to `update_last_used_at_every:` when installing the OperationStore: + +```ruby +use GraphQL::Pro::OperationStore, update_last_used_at_every: 1 # seconds +``` + +To update that column inline each time an operation is accessed, pass `0`. + +**Note:** It is recommended to set this to `0` in test environments, to avoid delayed updates in another thread that can cause intermittent test hangs and failures. For example: + +```ruby +# Update immediately in Test, wait 5 seconds in other environments: +use GraphQL::Pro::OperationStore, update_last_used_at_every: Rails.env.test? ? 0 : 5 +``` diff --git a/guides/operation_store/client_workflow.md b/guides/operation_store/client_workflow.md index 7cb1cc18bcd..422375ec52f 100644 --- a/guides/operation_store/client_workflow.md +++ b/guides/operation_store/client_workflow.md @@ -18,7 +18,7 @@ To use persisted queries with your client application, you must: This documentation also touches on {% internal_link "graphql-ruby-client sync", "/javascript_client/sync" %}, a JavaScript client library for using `OperationStore`. -### Add a Client +## Add a Client Clients are registered via {% internal_link "the dashboard","/operation_store/getting_started#add-routes" %}: @@ -28,7 +28,7 @@ A default `secret` is provided for you, but you can also enter your own. The `se (Are you interested in a Ruby API for this? Please {% open_an_issue "OperationStore Ruby API" %} or email `support@graphql.pro`.) -### Syncing +## Syncing Once a client is registered, it can push queries to the server via {% internal_link "the Sync API","/operation_store/getting_started#add-routes" %}. @@ -47,7 +47,7 @@ For example: For help syncing in another language, you can take inspiration from the [JavaScript implementation](https://github.com/rmosolgo/graphql-ruby/tree/master/javascript_client), {% open_an_issue "Implementing operation sync in another language" %}, or email `support@graphql.pro`. -### Client Usage +## Client Usage See the {% internal_link "Sync Guide", "/javascript_client/sync" %} for using OperationStore with Relay Modern, Apollo 1.x, Apollo Link, or plain JavaScript. @@ -64,6 +64,6 @@ To run stored operations from another client, send a param called `operationId` The server will use those values to fetch an operation from the database. -#### Next Steps +### Next Steps Learn more about `OperationStore`'s {% internal_link "authentication", "/operation_store/access_control" %} or read some tips for {% internal_link "server management","/operation_store/server_management" %}. diff --git a/guides/operation_store/getting_started.md b/guides/operation_store/getting_started.md index 25ed52ec785..9303cac183f 100644 --- a/guides/operation_store/getting_started.md +++ b/guides/operation_store/getting_started.md @@ -18,7 +18,7 @@ To use `GraphQL::Pro::OperationStore` with your app, follow these steps: - [Update your controller](#update-the-controller) to support persisted queries - {% internal_link "Add a client","/operation_store/client_workflow" %} to start syncing queries -#### Dependencies +## Dependencies `OperationStore` requires two gems in your application environment: @@ -27,27 +27,33 @@ To use `GraphQL::Pro::OperationStore` with your app, follow these steps: These are bundled with Rails by default. -#### Prepare the Database +## Prepare the Database If you're going to store data with ActiveRecord, {% internal_link "migrate the database", "/operation_store/active_record_backend" %} to prepare tables for it. -#### Add `OperationStore` +## Add `OperationStore` To hook up the storage to your schema, add the plugin: ```ruby class MySchema < GraphQL::Schema + # Add it _after_ other tracing-related features, for example: + # use GraphQL::Tracing::DataDogTracing # ... use GraphQL::Pro::OperationStore end ``` +Make sure to add this feature _after_ other {% internal_link "Tracing", "/queries/tracing" %}-based features so that those other features will have access to the loaded query string. Otherwise, you may get `"No query string was present"` errors. + By default, it uses `ActiveRecord`. It also accepts: - `redis:`, for using a {% internal_link "Redis backend", "/operation_store/redis_backend" %}; OR - `backend_class:`, for implementing custom persistence. -#### Add Routes +Also, you can disable updates to "last used at" with `default_touch_last_used_at: false`. (This can also be configured per-query with `context[:operation_store_touch_last_used_at] = true|false`.) + +## Add Routes To use `OperationStore`, add two routes to your app: @@ -75,7 +81,28 @@ end `operation_store_sync` and `dashboard` are both Rack apps, so you can mount them in Rails, Sinatra, or any other Rack app. -#### Update the Controller +__Alternatively__, you can configure the routes to load your schema lazily, during the first request: + +```ruby +# Provide the fully-qualified class name of your schema: +lazy_routes = GraphQL::Pro::Routes::Lazy.new("MySchema") +mount lazy_routes.dashboard, at: "/graphql/dashboard" +mount lazy_routes.operation_store_sync, at: "/graphql/sync" +``` + +### With Visibility Profiles + +You can apply a {% internal_link "visibility profile", "/authorization/visibility#visibility-profiles" %} to incoming operations by passing the profile name to `operation_store_sync`, for example: + +```ruby +mount MySchema.operation_store_sync(visibility_profile: :public_api), at: "/graphql/sync" +# or: +mount lazy_routes.operation_store_sync(visibility_profile: :public_api), at: "/graphql/sync" +``` + +This will apply that profile to all newly-synced operations. (It doesn't affect operations that were already synced.) + +## Update the Controller Add `operation_id:` to your GraphQL context: @@ -98,6 +125,6 @@ MySchema.execute( See {% internal_link "Server Management","/operation_store/server_management" %} for details about rejecting GraphQL from `params[:query]`. -#### Next Steps +## Next Steps Sync your operations with the {% internal_link "Client Workflow","/operation_store/client_workflow" %}. diff --git a/guides/operation_store/overview.md b/guides/operation_store/overview.md index 23ecbd098e9..0b8cc5995d2 100644 --- a/guides/operation_store/overview.md +++ b/guides/operation_store/overview.md @@ -9,7 +9,7 @@ index: 0 pro: true --- -`GraphQL::Pro::OperationStore` uses `Rack` and a storage backend ({% internal_link "ActiveRecord", "/operation_store/active_record_backend" %} or {% internal_link "Redis", "/operation_store/active_record_backend" %}) to maintain a normalized, deduplicated database of _persisted queries_ for your GraphQL system. +`GraphQL::Pro::OperationStore` uses `Rack` and a storage backend ({% internal_link "ActiveRecord", "/operation_store/active_record_backend" %} or {% internal_link "Redis", "/operation_store/redis_backend" %}) to maintain a normalized, deduplicated database of _persisted queries_ for your GraphQL system. In this guide, you'll find: @@ -26,7 +26,7 @@ In other guides, you can read more about: Also, you can find a [demo app on GitHub](https://github.com/rmosolgo/graphql-pro-operation-store-example). -### What are Persisted Queries? +## What are Persisted Queries? _Persisted queries_ are GraphQL queries (`query`, `mutation`, or `subscription`) that are saved on the server and invoked by clients by _reference_. In this arrangement, clients don't send GraphQL queries over the network. Instead, clients send: @@ -61,12 +61,12 @@ MyGraphQLEndpoint.post({ }) ``` -### Why Persisted Queries? +## Why Persisted Queries? Using persisted queries improves the _security_, _efficiency_ and _visibility_ of your GraphQL system. -#### Security +### Security Persisted queries improve security because you can reject arbitrary GraphQL queries, removing an attack vector from your system. The query database serves a whitelist, so you can be sure that no unexpected queries will hit your system. @@ -82,7 +82,7 @@ else end ``` -#### Efficiency +### Efficiency Persisted queries improve the _efficiency_ of your system by reducing HTTP traffic. Instead of repeatedly sending GraphQL over the wire, queries are fetched from the database, so your requests require less bandwidth. @@ -94,13 +94,13 @@ But _after_ using persisted queries, only the query identification info is sent {{ "/operation_store/request_after.png" | link_to_img:"GraphQL request with persisted queries" }} -#### Visibility +### Visibility Persisted queries improve _visibility_ because you can track GraphQL usage from a single location. `OperationStore` maintains an index of type, field and argument usage so that you can analyze your traffic. {{ "/operation_store/operation_index.png" | link_to_img:"Index of GraphQL usage with persisted queries" }} -### How it Works +## How it Works `OperationStore` uses tables in your database to store normalized, deduplicated GraphQL strings. The database is immutable: new operations may be added, but operations are never modified or removed. @@ -114,6 +114,6 @@ params[:operationId] # => "relay-app-v1/810c97f6631001..." `OperationStore` uses this to fetch the matching operation from the database. From there, the query is evaluated normally. -### Getting Started +## Getting Started See the {% internal_link "getting started guide","/operation_store/getting_started" %} to add `OperationStore` to your app. diff --git a/guides/operation_store/redis_backend.md b/guides/operation_store/redis_backend.md index ee46f9e9819..adc2fa590c5 100644 --- a/guides/operation_store/redis_backend.md +++ b/guides/operation_store/redis_backend.md @@ -20,4 +20,3 @@ end (You can initialize `Redis` with any options you need.) __Note:__ Be sure that this Redis instance is configured as a _persistent database_, not as a cache. You don't want it to throw away old keys! - diff --git a/guides/operation_store/server_management.md b/guides/operation_store/server_management.md index 7d8088c891b..7fe37854bc5 100644 --- a/guides/operation_store/server_management.md +++ b/guides/operation_store/server_management.md @@ -11,7 +11,7 @@ pro: true After {% internal_link "getting started","/operation_store/getting_started" %}, here some things to keep in mind. -### Rejecting Arbitrary Queries +## Rejecting Arbitrary Queries With persisted queries, you can stop accepting arbitrary GraphQL input. This way, malicious users can't run large or inappropriate queries on your server. @@ -53,7 +53,7 @@ MySchema.execute( ) ``` -### Archiving and Deleting Data +## Archiving and Deleting Data Clients can only _add_ to the database, but as an administrator, you can also archive or delete entries from the database. (Make sure you {% internal_link "authorize access to the Dashboard","/pro/dashboard" %}.) This is a dangerous operation: by archiving or deleting something, any clients who depend on that data will crash. @@ -66,7 +66,7 @@ If this is true, you can use "Archive" or "Delete" buttons to remove things from When an operation is archived, it's no longer available to clients, but it's still in the database. It can be unarchived later, so this is lower-risk than full deletion. -### Integration with Your Application +## Integration with Your Application It's on the road map to add a Ruby API to `OperationStore` so that you can integrate it with your application. For example, you might: diff --git a/guides/pagination/cursors.md b/guides/pagination/cursors.md index 26cb1330cb2..820ac0fd340 100644 --- a/guides/pagination/cursors.md +++ b/guides/pagination/cursors.md @@ -10,7 +10,7 @@ index: 4 Connections use _cursors_ to advance through paginated lists. A cursor is an opaque string that indicates a specific point in this. -Here, _opaque_ means that the string has no meaning except its value. cursors shouldn't be decoded, reverse-engineered, or generated ad-hoc. The only guarantee of a cursor is that, after you retrieve one, you can use it to request subsequent or preceeding items in the list. +Here, _opaque_ means that the string has no meaning except its value. cursors shouldn't be decoded, reverse-engineered, or generated ad-hoc. The only guarantee of a cursor is that, after you retrieve one, you can use it to request subsequent or preceding items in the list. Although cursors can be tricky, they were chosen for Relay-style connections because they can be implemented in stable and high-performing ways. @@ -39,4 +39,4 @@ end Now, all connections will use URL-safe base-64 encoding. -From a connection instance, the `cursor_encoders` methods are available via {{ "GraphQL::Pagination::Connection#encode" | api_doc }} and {{ "GraphQL::Pagination::Connection#decode" | api_doc }} +From a connection instance, the `cursor_encoder` methods are available via [GraphQL::Pagination::Connection](https://github.com/rmosolgo/graphql-ruby/blob/master/lib/graphql/pagination/connection.rb) `#encode` and `#decode` diff --git a/guides/pagination/custom_connections.md b/guides/pagination/custom_connections.md index 9697a56a02a..d10058b07c5 100644 --- a/guides/pagination/custom_connections.md +++ b/guides/pagination/custom_connections.md @@ -77,7 +77,7 @@ Alternatively, you can apply a connection wrapper on a case-by-case basis by app ```ruby field :search, Types::SearchResult.connection_type, null: false do - argument :query, String, required: true + argument :query, String end def search(query:) diff --git a/guides/pagination/stable_relation_connections.md b/guides/pagination/stable_relation_connections.md index dc519b740a7..3c20dafa285 100644 --- a/guides/pagination/stable_relation_connections.md +++ b/guides/pagination/stable_relation_connections.md @@ -13,8 +13,6 @@ pro: true These connection implementations are database-specific so that they can build proper queries with regard to `NULL` handling. (Postgres treats nulls as _larger_ than other values while MySQL and SQLite treat them as _smaller_ than other values.) -__Note:__ In GraphQL-Pro 1.12.x, the {% internal_link "previous stable connection implementation", "/pro/cursors" %} is still enabled by default. See [Opting Out](/pro/cursors#opting-out) to disable that feature, then enable this new one. - ## What's the difference? The default {{ "GraphQL::Pagination::ActiveRecordRelationConnection" | api_doc }} (which turns an `ActiveRecord::Relation` into a GraphQL-ready connection) uses _offset_ as a cursor. This naive approach is sufficient for many cases, but it's subject to a specific set of bugs. diff --git a/guides/pagination/using_connections.md b/guides/pagination/using_connections.md index 537f139ae5b..9bd9e722833 100644 --- a/guides/pagination/using_connections.md +++ b/guides/pagination/using_connections.md @@ -10,7 +10,7 @@ index: 2 GraphQL-Ruby ships with a few implementations of the {% internal_link "connection pattern", "pagination/connection_concepts" %} that you can use out of the box. They support Ruby Arrays, Mongoid, Sequel, and ActiveRecord. -Additionally, connections allow you to limit the number of items returned with [`max_page_size`](#max-page-size). +Additionally, connections allow you to limit the number of items returned with [`max_page_size`](#max-page-size) and set the default number of items returned with [`default_page_size`](#default-page-size). ## Make Connection Fields @@ -29,6 +29,17 @@ field :items, Types::ItemConnectionPage, null: false, connection: true The field will be given some arguments by default: `first`, `last`, `after`, and `before`. +### Opting out of default connection handling + +To opt out of GraphQL-Ruby's default connection handling, add `connection: false` to the field definition: + +```diff +- field :items, Types::ItemType.connection_type, null: false ++ field :items, Types::ItemType.connection_type, null: false, connection: false +``` + +Then, add any arguments you want (`first`, `last`, `after`, `before`) and make sure that your resolver returns an object that can fulfill the fields of the configured return type. + ## Return Collections With connection fields, you can return collection objects from fields or resolvers: @@ -62,7 +73,7 @@ This way, you can handle this _particular_ `relation` with custom code. ## Max Page Size -You can apply `max_page_size` to limit the number of items returned, regardless of what the client requests. +You can apply `max_page_size` to limit the number of items returned and queried from the database, regardless of what the client requests. - __For the whole schema__, you can add it to your schema definition: @@ -91,3 +102,35 @@ end ``` To _remove_ a `max_page_size` setting, you can pass `nil`. That will allow unbounded collections to be returned to clients. + +## Default Page Size + +You can apply `default_page_size` to limit the number of items returned and queried from the database when no `first` or `last` is provided. + +- __For the whole schema__, you can add it to your schema definition: + +```ruby +class MyAppSchema < GraphQL::Schema + default_page_size 50 +end +``` + + At runtime, that value will be applied to _every_ connection, unless an override is provided as described below. + +- __For a given field__, add it to the field definition with a keyword: + +```ruby +field :items, Item.connection_type, null: false, + default_page_size: 25 +``` + +- __Dynamically__, you can add `default_page_size:` when you apply custom connection wrappers: + +```ruby +def items + relation = object.items + Connections::ItemsConnection.new(relation, default_page_size: 10) +end +``` + +If `max_page_size` is set and `default_page_size` is higher than it, the `default_page_size` will be clamped down to match `max_page_size`. If both `default_page_size` and `max_page_size` are set to `nil`, unbounded collections will be returned. diff --git a/guides/pro/checksums/graphql-enterprise-1.0.0.txt b/guides/pro/checksums/graphql-enterprise-1.0.0.txt new file mode 100644 index 00000000000..44e71f5782f --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.0.0.txt @@ -0,0 +1 @@ +960ec05f3ef88fff70bef68ca02fa3d37b512f9ad668345a903766776aaca08fba52795a9625cde39f9c2080ae13ec6c76b8757f05c26ccdfb27edcc69112ca8 diff --git a/guides/pro/checksums/graphql-enterprise-1.0.1.txt b/guides/pro/checksums/graphql-enterprise-1.0.1.txt new file mode 100644 index 00000000000..ebfd63f26e5 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.0.1.txt @@ -0,0 +1 @@ +37364b0f6dbb5e97203e30172d3ed21fdd3c90f78de3e93479d15008d00b170d4e1656dc86b686de69fe060f4573d743f341a1ee55eeb793426694619ffc3cb0 diff --git a/guides/pro/checksums/graphql-enterprise-1.1.0.txt b/guides/pro/checksums/graphql-enterprise-1.1.0.txt new file mode 100644 index 00000000000..d4dde131a6f --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.0.txt @@ -0,0 +1 @@ +9de179b3c8a2f374c112146cbad6436cc72b553f2a7fb289171af50201137609545636aa5fa972c6fd19f371f8414c9339a708394169dc1582ebef04c68f9ecc diff --git a/guides/pro/checksums/graphql-enterprise-1.1.1.txt b/guides/pro/checksums/graphql-enterprise-1.1.1.txt new file mode 100644 index 00000000000..80661dc017d --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.1.txt @@ -0,0 +1 @@ +2438c01b8bfd1ad23ff41f3dffd68b2a3cf150ca12f2dd45e2ba06fb063e6344dfca0d38e3d5f55693e050d9b44738e563abb484f24799fec80c6b78662075cf diff --git a/guides/pro/checksums/graphql-enterprise-1.1.10.txt b/guides/pro/checksums/graphql-enterprise-1.1.10.txt new file mode 100644 index 00000000000..923dafa0b36 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.10.txt @@ -0,0 +1 @@ +4fd53a85c197db33fdf2a73593a016dd687bd9e8a5e9eb01e8e09594bbfae2cd7f5a27a17a7555603e2ff14b779f26362091e478b37737b3ed8a1a7cb8b0fce2 diff --git a/guides/pro/checksums/graphql-enterprise-1.1.11.txt b/guides/pro/checksums/graphql-enterprise-1.1.11.txt new file mode 100644 index 00000000000..281bb84c3a8 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.11.txt @@ -0,0 +1 @@ +af885c3e261efe1ce3d14861d46d2150de3195d71debbfd0ac0de866eb7de45b6f5080a3686d8750522cd8f0eb499c6b1abb2e54bc9d449c62d9efe678440127 diff --git a/guides/pro/checksums/graphql-enterprise-1.1.12.txt b/guides/pro/checksums/graphql-enterprise-1.1.12.txt new file mode 100644 index 00000000000..71c345d8204 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.12.txt @@ -0,0 +1 @@ +dd4d30115510d1767dcbce8a64e91c241d48a4f6b4c9d57df29e334143431958783a17ed20cf7601675a1957b000c200dca4b9a649b1c433dbf879e5344d1c67 diff --git a/guides/pro/checksums/graphql-enterprise-1.1.13.txt b/guides/pro/checksums/graphql-enterprise-1.1.13.txt new file mode 100644 index 00000000000..b254a248f6d --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.13.txt @@ -0,0 +1 @@ +545dc5c0ec289b6bec7c2ab59bf41708885f1e3a746dfd6b070d3d87eb82e4107ee154110b9340896144875384dca6ee7a4a5676b3301ed76884308d389fd96e diff --git a/guides/pro/checksums/graphql-enterprise-1.1.14.txt b/guides/pro/checksums/graphql-enterprise-1.1.14.txt new file mode 100644 index 00000000000..1bf28d8d196 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.14.txt @@ -0,0 +1 @@ +90f5193f787414b85ae649881d252dc0b0c1b6a880920ed117cea40aefa21350c0d0fc91e7a5a980c4958a5fdd7932874db5df966db81365a3882631fbae4eb5 diff --git a/guides/pro/checksums/graphql-enterprise-1.1.2.txt b/guides/pro/checksums/graphql-enterprise-1.1.2.txt new file mode 100644 index 00000000000..487a2e22fb5 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.2.txt @@ -0,0 +1 @@ +c512384b9d0482a44925a0e47026b6cd48f48282c3e22f9bb90fad6452dd5209f394752b0ecfe1e5ea41cd5f848325fd4fb9c68c54d63c36774fa568cfc93c01 diff --git a/guides/pro/checksums/graphql-enterprise-1.1.3.txt b/guides/pro/checksums/graphql-enterprise-1.1.3.txt new file mode 100644 index 00000000000..af8efb645b7 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.3.txt @@ -0,0 +1 @@ +4c46c862544ad3bd836949a57e7dc272a4bd5e6a71757879b6de8729d063c866d8ff2f2f6959001fc1120c4f748e25379039fa855fd9b1b717ae6ad5c43ed02f diff --git a/guides/pro/checksums/graphql-enterprise-1.1.4.txt b/guides/pro/checksums/graphql-enterprise-1.1.4.txt new file mode 100644 index 00000000000..920cffc4170 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.4.txt @@ -0,0 +1 @@ +8320d4f89f1986c23e92cfe1dae0fad37c7ca6d7b39e5f05d110e7167aa3d0ce6ae995d514986941caa90f76aa3fa96679810e7fe3fe5232500c954c5d3645a3 diff --git a/guides/pro/checksums/graphql-enterprise-1.1.5.txt b/guides/pro/checksums/graphql-enterprise-1.1.5.txt new file mode 100644 index 00000000000..2936a79b4c2 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.5.txt @@ -0,0 +1 @@ +6c6248cf76ea0ca250f9e0ff587e6475a51c3fa3b4cdb6a517a35baf9226d87be2b009f073a952ac33a19bbe46cdf44889466b160dfa20fc24d8a365bab4238b diff --git a/guides/pro/checksums/graphql-enterprise-1.1.6.txt b/guides/pro/checksums/graphql-enterprise-1.1.6.txt new file mode 100644 index 00000000000..198b76c1596 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.6.txt @@ -0,0 +1 @@ +875b3f09b699e006733a2ffee31379db3f775b79408b35726a24c8a00386b1a7fcfafc56c57163a71dadafa22fab24aa7dec5608feae1cce8c59bb652e60e439 diff --git a/guides/pro/checksums/graphql-enterprise-1.1.7.txt b/guides/pro/checksums/graphql-enterprise-1.1.7.txt new file mode 100644 index 00000000000..908fe3aca76 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.7.txt @@ -0,0 +1 @@ +cc841f4c0365ddfd65b40b1af2c93217f6c5ab531331fa0e5e84e70dfbd7f997f20db2a5d741da7652c3efcb5dde42987668ed29387a378db22234b2aa18153a diff --git a/guides/pro/checksums/graphql-enterprise-1.1.8.txt b/guides/pro/checksums/graphql-enterprise-1.1.8.txt new file mode 100644 index 00000000000..cc2b312e954 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.8.txt @@ -0,0 +1 @@ +66710fed7209fef5223602a3d8722c70c83f79540dff9752b42caad6ab53be7d74cd304dfc7e789ade3972d590ed3198aa42ea8e89dcb46c929fa1b9d3a997fa diff --git a/guides/pro/checksums/graphql-enterprise-1.1.9.txt b/guides/pro/checksums/graphql-enterprise-1.1.9.txt new file mode 100644 index 00000000000..e959c61a0ff --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.1.9.txt @@ -0,0 +1 @@ +0bfb112eaca92cb98adb4beffed43f22a68993dacf67bf3631bec05f30b29954181629b5ab11d429619d75ec76c7295ae74f7eeba9940aadec2cdef7e5d96a95 diff --git a/guides/pro/checksums/graphql-enterprise-1.2.0.txt b/guides/pro/checksums/graphql-enterprise-1.2.0.txt new file mode 100644 index 00000000000..6bf73f68b35 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.2.0.txt @@ -0,0 +1 @@ +a4c9d7118ab03cb27bf49c97b0fe95e5695ace646bf9f459ec327bc18190dc9bcdcc772438873a7a7531213ba4d8d10af151e195a4b8971342230e5ad2d92346 diff --git a/guides/pro/checksums/graphql-enterprise-1.3.0.txt b/guides/pro/checksums/graphql-enterprise-1.3.0.txt new file mode 100644 index 00000000000..ae841586d41 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.3.0.txt @@ -0,0 +1 @@ +b1a909bf8582e6c253bdb901f9865634dd970519529eb34021c138b6cbfe76c1573756899d7966c16f32878dd11272d6f6c8be7abde3654c9a5969e93af62b0a diff --git a/guides/pro/checksums/graphql-enterprise-1.3.1.txt b/guides/pro/checksums/graphql-enterprise-1.3.1.txt new file mode 100644 index 00000000000..25911b14389 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.3.1.txt @@ -0,0 +1 @@ +6b23b11331e345a757ba31edb3af77e3174996603162dd1fb698971886545ff8344dfc39f21c6096d2206f7be1d7f529efbddc6f86cbe15ddf81232e4b631603 diff --git a/guides/pro/checksums/graphql-enterprise-1.3.2.txt b/guides/pro/checksums/graphql-enterprise-1.3.2.txt new file mode 100644 index 00000000000..221cda64d42 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.3.2.txt @@ -0,0 +1 @@ +cc215bc8fee73b02c40ce29070244a31072f42e8a68a3b7038ef449572eab977180d13bc61fa3cbd02cc6855fadaa05bcfc6db033b517e8b8136960a188bf01a diff --git a/guides/pro/checksums/graphql-enterprise-1.3.3.txt b/guides/pro/checksums/graphql-enterprise-1.3.3.txt new file mode 100644 index 00000000000..252d760ae44 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.3.3.txt @@ -0,0 +1 @@ +019e92e06ffab9cbecde167c72321136e0826db4bd4ae7a74c33b0d915e6b253c847f688f3efdc4a7f27428f0cd36b2ffa34fa02769ff3a431e101b88fcd7533 diff --git a/guides/pro/checksums/graphql-enterprise-1.3.4.txt b/guides/pro/checksums/graphql-enterprise-1.3.4.txt new file mode 100644 index 00000000000..4e6db32eda8 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.3.4.txt @@ -0,0 +1 @@ +e08061b7eae9ac0deb15a4d7361deddc9cd881d89931652806fb4a2a544c5b29e58245f850322c228dbbd3000920ce94d2de2bc2882cec29dc0c46698943058f diff --git a/guides/pro/checksums/graphql-enterprise-1.4.0.txt b/guides/pro/checksums/graphql-enterprise-1.4.0.txt new file mode 100644 index 00000000000..923811547b3 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.4.0.txt @@ -0,0 +1 @@ +d2152d80aee8cd1699c4e64e28329c51ddee38ea6ea318f89c6e9bc996fbd68d4db28831fcedb5583ba92d499852dd7fc8ca99583376eacd28f00c908fec8f28 diff --git a/guides/pro/checksums/graphql-enterprise-1.4.1.txt b/guides/pro/checksums/graphql-enterprise-1.4.1.txt new file mode 100644 index 00000000000..8bfb1504aea --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.4.1.txt @@ -0,0 +1 @@ +e0a1bfe31eebec9523faf1710ef1887cf15bac93a9476778804958b646f9664bdbf304000cc568ccf87ca7d450aba646081bee295c998fd81491156890a1d092 diff --git a/guides/pro/checksums/graphql-enterprise-1.4.2.txt b/guides/pro/checksums/graphql-enterprise-1.4.2.txt new file mode 100644 index 00000000000..4cdc2d1267e --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.4.2.txt @@ -0,0 +1 @@ +cce6aca47058577462d4bf51c02b578c6aaf7bb47335e3435129efc9662e37e13756542d40ce728702a373d4e93416bb72202dfa9781bcc3de61c81b29471a5a diff --git a/guides/pro/checksums/graphql-enterprise-1.5.0.txt b/guides/pro/checksums/graphql-enterprise-1.5.0.txt new file mode 100644 index 00000000000..d286673b9fe --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.5.0.txt @@ -0,0 +1 @@ +753347081830d3007f568b04007cb401353130f3964ed5d75a65898b1751f88b47d571e43357e09173b6ba10ec954c946fe74adcf64b29a5fa799c50ec704a7a diff --git a/guides/pro/checksums/graphql-enterprise-1.5.1.txt b/guides/pro/checksums/graphql-enterprise-1.5.1.txt new file mode 100644 index 00000000000..d56f308b5a6 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.5.1.txt @@ -0,0 +1 @@ +f83113767fb51f584f0d27b34f44fccc8ca05fab61e150bd6dd9098c9b3aef41751c4337ca994e2b78486fdd71c623a6d24da985d3bcee983a33c7a798a25e2d diff --git a/guides/pro/checksums/graphql-enterprise-1.5.2.txt b/guides/pro/checksums/graphql-enterprise-1.5.2.txt new file mode 100644 index 00000000000..ed1ad1ee061 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.5.2.txt @@ -0,0 +1 @@ +e56b5290ed8798c67ca8e2fb1d6af8895570893e4c71e7958f3051dfa24617ec7772d325e44f54dc320f2963e802b28ba8036f26a4932971c5b9a2181bd98c6b diff --git a/guides/pro/checksums/graphql-enterprise-1.5.3.txt b/guides/pro/checksums/graphql-enterprise-1.5.3.txt new file mode 100644 index 00000000000..dee10ad5e17 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.5.3.txt @@ -0,0 +1 @@ +92d3392668d3365d61d31d1906f19e54b4cc330383a2876997ed3cefa787cbcd51f009b6a30181f0631ee6a80a3402549f44d4905145aadeb7abe421167daa6c diff --git a/guides/pro/checksums/graphql-enterprise-1.5.4.txt b/guides/pro/checksums/graphql-enterprise-1.5.4.txt new file mode 100644 index 00000000000..a437383dc37 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.5.4.txt @@ -0,0 +1 @@ +77201981e1495aa32181b36ca444b020860f83e6a29d8a749beca11cb02d4ca8e25fdfad8dea1c0365b8616661d39452de72d861f0460828db03151a627fb7ee diff --git a/guides/pro/checksums/graphql-enterprise-1.5.5.txt b/guides/pro/checksums/graphql-enterprise-1.5.5.txt new file mode 100644 index 00000000000..df90dc1de44 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.5.5.txt @@ -0,0 +1 @@ +93269e41b9069a070584b77722d412c3a43251deabe013090874a657a07f1180aec5d5a90091fa8cac7885147d83e9be0701f6e3bb52d3bc17c4759069882bb4 diff --git a/guides/pro/checksums/graphql-enterprise-1.5.6.txt b/guides/pro/checksums/graphql-enterprise-1.5.6.txt new file mode 100644 index 00000000000..cda1481101a --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.5.6.txt @@ -0,0 +1 @@ +4e7776668f4e8f7897e41a2d8844c1684e914c477f1eb8f4aa204db9dca56c219f0a6d631e253b23d951d15a8e8bf106f786925655491a1f562ac270561f8f81 diff --git a/guides/pro/checksums/graphql-enterprise-1.5.7.txt b/guides/pro/checksums/graphql-enterprise-1.5.7.txt new file mode 100644 index 00000000000..27b064e6cee --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.5.7.txt @@ -0,0 +1 @@ +9ad14f1225195a537bb83daee54d2b35e1c29139177d3f85cf64e1bc7b8b052a476ea654577e816c8cc8aacb1b8f857f2328e895465bf6ad47da38ab35c33c7a diff --git a/guides/pro/checksums/graphql-enterprise-1.5.8.txt b/guides/pro/checksums/graphql-enterprise-1.5.8.txt new file mode 100644 index 00000000000..eda89fb4d94 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.5.8.txt @@ -0,0 +1 @@ +0540ceba5c6b2379d803105458a91e5144c332440eb3f71b57442ae6d5dae18deff01fbf07cba8483081bb824c9870e6d63c39be4d27f078318781eb4533926e diff --git a/guides/pro/checksums/graphql-enterprise-1.5.9.txt b/guides/pro/checksums/graphql-enterprise-1.5.9.txt new file mode 100644 index 00000000000..0a2f8316811 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.5.9.txt @@ -0,0 +1 @@ +73a2208665ee81b635863dbfe03eb291fafc29ef756d9e6a4172e9ad680b1fb63ba04e5ae8cd848480513e4300e61dcf0f6ac961fa4003ac2a30ec51d7a26060 diff --git a/guides/pro/checksums/graphql-enterprise-1.6.0.txt b/guides/pro/checksums/graphql-enterprise-1.6.0.txt new file mode 100644 index 00000000000..37e4e86c00d --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.6.0.txt @@ -0,0 +1 @@ +e135d50d7cd9c8e08ebaac02cfe6ddbbb31dea7c4c752c7acd3d327600ae8af0278a6534b3504b8186799286609051b1c38e7611b1659de23022b661763dd3c4 diff --git a/guides/pro/checksums/graphql-enterprise-1.7.0.txt b/guides/pro/checksums/graphql-enterprise-1.7.0.txt new file mode 100644 index 00000000000..5e0a5895d84 --- /dev/null +++ b/guides/pro/checksums/graphql-enterprise-1.7.0.txt @@ -0,0 +1 @@ +147e0f0dca20e88c15b542390cf97388cff8679316b31c73212bb0e0138ab60e909795b9afbd551a6da6e3f1a3102d69241fe110b82668bb802a3efa5a37bd74 diff --git a/guides/pro/checksums/graphql-pro-1.18.2.txt b/guides/pro/checksums/graphql-pro-1.18.2.txt new file mode 100644 index 00000000000..9bd5120229c --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.18.2.txt @@ -0,0 +1 @@ +c863ba11770864257cb232fc1cc25a2386c3387bcab8aa95a309e4ee67bb6e184f5e6281b45820cc1383cd80e3cdee8a5c011951e7ac6d3397c3925879017777 diff --git a/guides/pro/checksums/graphql-pro-1.18.3.txt b/guides/pro/checksums/graphql-pro-1.18.3.txt new file mode 100644 index 00000000000..a5dd18b2098 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.18.3.txt @@ -0,0 +1 @@ +c0c009deab18661ad98d30c44f1cbb33a44b6a21c806428bfc6b50f28e0fbe67c7b2fd4696b8e4992b9d1cee3d4c71d7b7b4de91dbf0c103715f1542ba6a39c8 diff --git a/guides/pro/checksums/graphql-pro-1.19.0.txt b/guides/pro/checksums/graphql-pro-1.19.0.txt new file mode 100644 index 00000000000..e6bd6b99d4b --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.19.0.txt @@ -0,0 +1 @@ +70775118ede3e9600778bb92cc5b8a56f731ae133f820a7dd2e5d00b279a79e4a1e0be9e400b0fd092f1a8295d5cf10fa4b2931f834542998ede4327d25d9d12 diff --git a/guides/pro/checksums/graphql-pro-1.19.1.txt b/guides/pro/checksums/graphql-pro-1.19.1.txt new file mode 100644 index 00000000000..6ea65b088c2 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.19.1.txt @@ -0,0 +1 @@ +aeff5df888036b4ccd8d7523673c077b2819015a0e1f27091b72de091a7d0cf609940ffba7cd73f3f48e0195c61c5e627fc86886775313f31beb31cb2d21e196 diff --git a/guides/pro/checksums/graphql-pro-1.19.2.txt b/guides/pro/checksums/graphql-pro-1.19.2.txt new file mode 100644 index 00000000000..ca36afa2456 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.19.2.txt @@ -0,0 +1 @@ +385d1311d4c5e41cf0c508b1ba6ee1edf16f29f010d913311b87d3b9f3bdc844dce970258fca3d606fc7d472f2024dbbd4d797c775c3bee455bfd158cb1e90fd diff --git a/guides/pro/checksums/graphql-pro-1.20.0.txt b/guides/pro/checksums/graphql-pro-1.20.0.txt new file mode 100644 index 00000000000..1529b94c970 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.20.0.txt @@ -0,0 +1 @@ +813aa497534c608234e188a4272266cf2f2e14691c626c68c0ef1b05291dc305db1c274c57cf2d5bd83568fb7064bb121f8fecb617014496d23a98df3660486d diff --git a/guides/pro/checksums/graphql-pro-1.20.1.txt b/guides/pro/checksums/graphql-pro-1.20.1.txt new file mode 100644 index 00000000000..0016fc8a1fd --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.20.1.txt @@ -0,0 +1 @@ +bb055c688d9c427a9f0535e22041bc7c414c74a9c40bd626a7ccc59595303afc7e3361c644edda5041350f814a59dfc64f403030a32b948e707e6a1dbc72872e diff --git a/guides/pro/checksums/graphql-pro-1.20.2.txt b/guides/pro/checksums/graphql-pro-1.20.2.txt new file mode 100644 index 00000000000..5f0825458d6 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.20.2.txt @@ -0,0 +1 @@ +002710975d063bb3301eec6a2b52b068dfed50349bf44e7d7926709ce357cc70108bc05df1352929c8989b0425988e11ee2bc187967a0966211f30be5a0963b8 diff --git a/guides/pro/checksums/graphql-pro-1.20.3.txt b/guides/pro/checksums/graphql-pro-1.20.3.txt new file mode 100644 index 00000000000..6d1a9d97b12 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.20.3.txt @@ -0,0 +1 @@ +d75f4a170d3763ed3f01c241bdb01e21bfb5fabf607c13a45ed4815ac7d4f98ff44e4e6bc2ab7b91d380d5a510a21c4224d95ca61ee52e3d10992fe022605570 diff --git a/guides/pro/checksums/graphql-pro-1.20.4.txt b/guides/pro/checksums/graphql-pro-1.20.4.txt new file mode 100644 index 00000000000..9917aa9fb67 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.20.4.txt @@ -0,0 +1 @@ +ce98318463a74c268ca618f46cf441859c2a374abd5908f1f25c788f934d87a9ab97377c3422882e8ad86da4d926cfbc30356856f6d2312c793a168abea8069f diff --git a/guides/pro/checksums/graphql-pro-1.21.0.txt b/guides/pro/checksums/graphql-pro-1.21.0.txt new file mode 100644 index 00000000000..650897d0c55 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.21.0.txt @@ -0,0 +1 @@ +3ba6e99c5d9ff61a771f8e775027470b066ca8fd80b45926cfc5b9a89f09ea44bf616bc07099dc39c2f096bb70e0d331aa648de0779012be9fae6abd1e81fa5f diff --git a/guides/pro/checksums/graphql-pro-1.21.1.txt b/guides/pro/checksums/graphql-pro-1.21.1.txt new file mode 100644 index 00000000000..37f5ec63e22 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.21.1.txt @@ -0,0 +1 @@ +d3cb24e0212471783cb116ffd3aeac56007692d9107fd9f3d5b50b65057865b321ca52c049ec87b196129140d044282ae807aa1e14f36623b72c6c88f2b90956 diff --git a/guides/pro/checksums/graphql-pro-1.21.2.txt b/guides/pro/checksums/graphql-pro-1.21.2.txt new file mode 100644 index 00000000000..cb9cb0e3e76 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.21.2.txt @@ -0,0 +1 @@ +61049f9ecb5e4124486dd881ef942503988413f24256048a7ef93ce7ccae2ab9f16a8915e5ace0f04c84d4e75732acac93e36af23e893eba7d3959cc4787fc9f diff --git a/guides/pro/checksums/graphql-pro-1.21.3.txt b/guides/pro/checksums/graphql-pro-1.21.3.txt new file mode 100644 index 00000000000..f3b20dc40a7 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.21.3.txt @@ -0,0 +1 @@ +40342ee99d7aafd5de610927de407a3d736cf37afa3ecd9ecfb35abade00675d0f4156ad47f81871d63658a67ff59a3fb46660dfc28a10d21223ac40cdd13bdb diff --git a/guides/pro/checksums/graphql-pro-1.21.4.txt b/guides/pro/checksums/graphql-pro-1.21.4.txt new file mode 100644 index 00000000000..f9bd5d67a52 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.21.4.txt @@ -0,0 +1 @@ +7f47e41ca607a330a0dc988a9706fe8fa4065ee9045512ef0d5e9ebe12f53040e761cb9162448c1625af7d230baa2d24cdd3efbba0e4852bf855a075c2857bf6 diff --git a/guides/pro/checksums/graphql-pro-1.21.5.txt b/guides/pro/checksums/graphql-pro-1.21.5.txt new file mode 100644 index 00000000000..c649a915bff --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.21.5.txt @@ -0,0 +1 @@ +d128ce5bcdf09b67de33cc8a32f3e8e6f3efb602f7c61bce7001ad7f616299923b90ada691d50a120a73478673cc7db49736822013556de86dd902209a5c5b0d diff --git a/guides/pro/checksums/graphql-pro-1.21.6.txt b/guides/pro/checksums/graphql-pro-1.21.6.txt new file mode 100644 index 00000000000..2ee04ae6a00 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.21.6.txt @@ -0,0 +1 @@ +3cfa5ee53774a042aa9cbdc2a64747265b359048db0087b24d084611c2c1a97a7f3f20547612d5681a338c32fd45c496c21a511872d098bde50ba8e66579c456 diff --git a/guides/pro/checksums/graphql-pro-1.22.0.txt b/guides/pro/checksums/graphql-pro-1.22.0.txt new file mode 100644 index 00000000000..a88228b9727 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.22.0.txt @@ -0,0 +1 @@ +f35ee054e97240c089f1f56d233875e6038ac264facfeddc0aeb2abd5d1c7c0844e92f2ab3087c45c2999f4fd7bec8720bcfd4794af19dc5e565afa884e1be85 diff --git a/guides/pro/checksums/graphql-pro-1.22.1.txt b/guides/pro/checksums/graphql-pro-1.22.1.txt new file mode 100644 index 00000000000..7ea8b9bfb18 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.22.1.txt @@ -0,0 +1 @@ +fb7fc671dec8dfc0528b42177b380ff7997340029746c367523aee8dd5bb2b335cd61b6663f5b7a198df5473fe61b31b2eadfb61fe93ca13bfe18d8294e80329 diff --git a/guides/pro/checksums/graphql-pro-1.22.2.txt b/guides/pro/checksums/graphql-pro-1.22.2.txt new file mode 100644 index 00000000000..6f317661ec6 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.22.2.txt @@ -0,0 +1 @@ +3e7421186fdcc7ef7ab87f3a294b84d3478f47dbe54a5cf68ad739625c4cb9b58b3ac83c900039ee65b2e789b37d5faf9a67586c29045fc099bbd3e6f09eff85 diff --git a/guides/pro/checksums/graphql-pro-1.22.3.txt b/guides/pro/checksums/graphql-pro-1.22.3.txt new file mode 100644 index 00000000000..0c7577b25a7 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.22.3.txt @@ -0,0 +1 @@ +300b9bb439118289cba69db994e9402697114d1b262b7c2879e49e19266f7b7b97d0aab45f54921c5449847579043867fbaf451165d616da78d945927415fdb9 diff --git a/guides/pro/checksums/graphql-pro-1.23.0.txt b/guides/pro/checksums/graphql-pro-1.23.0.txt new file mode 100644 index 00000000000..580ecb3c7d2 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.23.0.txt @@ -0,0 +1 @@ +c87ffc9ad12f452c013e996f2c4a792869c4a6b333c01091cb535ee62ccdc8f8546ae09738d08bd284bbfc27719b6cf22730c06678434e807407ce3b601dc090 diff --git a/guides/pro/checksums/graphql-pro-1.23.1.txt b/guides/pro/checksums/graphql-pro-1.23.1.txt new file mode 100644 index 00000000000..804c9bf0504 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.23.1.txt @@ -0,0 +1 @@ +cbcbceda448c0b31acfbb5fc5ac18dc1bd90787009bb9b17758629b3fc1b1fb697c64666bb864d2a3cf968ee14f0c7aaf5e9887c4eefded223689b414c0a3b71 diff --git a/guides/pro/checksums/graphql-pro-1.23.2.txt b/guides/pro/checksums/graphql-pro-1.23.2.txt new file mode 100644 index 00000000000..00f7bafcfa3 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.23.2.txt @@ -0,0 +1 @@ +8006a4ce467f9fe0842757075fbbfd6dbeddafe5dc1e3de640a8fb3406126155cd782b16128fdf056d79d6681eff83c5f2b1e86b49460a7a0b23b2b231ae781a diff --git a/guides/pro/checksums/graphql-pro-1.23.3.txt b/guides/pro/checksums/graphql-pro-1.23.3.txt new file mode 100644 index 00000000000..ba0b4cb27f1 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.23.3.txt @@ -0,0 +1 @@ +8bbab870128ce2e9283c41ef84959f99b15f003f7b0d499c95b2e40c0f1b706e65b3d9046749a56ba241046d2cdb5c9411c1a414efd87b7a9961595074c39983 diff --git a/guides/pro/checksums/graphql-pro-1.23.4.txt b/guides/pro/checksums/graphql-pro-1.23.4.txt new file mode 100644 index 00000000000..811daad2749 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.23.4.txt @@ -0,0 +1 @@ +84f8cf2aa279a63440dc412b7ccf6338df48b4b3f5aa21af99ad15febe667d277b725bb924e1ac48766ba72d02dcc0170f4d5cdb2c280615ec60a98839391d89 diff --git a/guides/pro/checksums/graphql-pro-1.23.5.txt b/guides/pro/checksums/graphql-pro-1.23.5.txt new file mode 100644 index 00000000000..580110b6e85 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.23.5.txt @@ -0,0 +1 @@ +6adfe5c8e153b43bd4fdb2f0dc79c2e5d035d3fe9e571c2f97255edd87d2419c91f8495bedaab3c3761d4e6b7e7b460c091d5189a184e6e2ed785ea06f204597 diff --git a/guides/pro/checksums/graphql-pro-1.23.6.txt b/guides/pro/checksums/graphql-pro-1.23.6.txt new file mode 100644 index 00000000000..7a944d0d774 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.23.6.txt @@ -0,0 +1 @@ +f910abcf08a2ad51e46f68f492b2d651fa5d5debf80761464ce1809d2672b1db1064db304f6f5bd19c2587792b160c9bb5fa526b371fd3a4060e8a0841f5452d diff --git a/guides/pro/checksums/graphql-pro-1.23.7.txt b/guides/pro/checksums/graphql-pro-1.23.7.txt new file mode 100644 index 00000000000..d0c5f4b4297 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.23.7.txt @@ -0,0 +1 @@ +461c5c8d68892208c8fe457a51a6c18b253ec32cd37aface4fa2eae81fca94874c654a95a16f21571134476970a66304b261063c7fe2cd8a19daaba9bb90389b diff --git a/guides/pro/checksums/graphql-pro-1.23.8.txt b/guides/pro/checksums/graphql-pro-1.23.8.txt new file mode 100644 index 00000000000..30466800130 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.23.8.txt @@ -0,0 +1 @@ +e6f77862bf426401c4b86bfc92eae792da5ae3cc830c03ae6f90e80efa8ff1e324cfd4a49474832b0f82d32c2e568018b757a5efcbd896ed4de7ee5dc736e9e5 diff --git a/guides/pro/checksums/graphql-pro-1.23.9.txt b/guides/pro/checksums/graphql-pro-1.23.9.txt new file mode 100644 index 00000000000..febdaa6cf0e --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.23.9.txt @@ -0,0 +1 @@ +3b93d5b119ca2b21784fb6b151f5e9f9b9630c8cd4f26995d87669097e2d19048588d4055dc230f2f926aa248f88bfbb4a4ec547c2f5ad6b801ff43b05c5c391 diff --git a/guides/pro/checksums/graphql-pro-1.24.0.txt b/guides/pro/checksums/graphql-pro-1.24.0.txt new file mode 100644 index 00000000000..606ecf7dbf1 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.0.txt @@ -0,0 +1 @@ +325cc56c6cb2773eb9341963371f1c80167168a76601a96a097e7d60f77bed0715ef8b21e5132555308295775d061674cf87f47ce390155496099fa1e49bb441 diff --git a/guides/pro/checksums/graphql-pro-1.24.1.txt b/guides/pro/checksums/graphql-pro-1.24.1.txt new file mode 100644 index 00000000000..b86cee5a9b7 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.1.txt @@ -0,0 +1 @@ +b6934de96b0d4f0758d0e84671d5e9d82e5e5790c01d3dbb53db1793aa4836e216d0962386542126f3b2dc1a27b35bec1ae802e644794a6029ce38314269d5f5 diff --git a/guides/pro/checksums/graphql-pro-1.24.10.txt b/guides/pro/checksums/graphql-pro-1.24.10.txt new file mode 100644 index 00000000000..7640af11d0a --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.10.txt @@ -0,0 +1 @@ +e53374f59ccb74dae6d99f2d9fffff6a932f442d31646aa4dd7a6725f434dd08925dba18e4c049432cebb48b0cdabdb72a0653b0c8eeb32399fb3a8cecb9380b diff --git a/guides/pro/checksums/graphql-pro-1.24.11.txt b/guides/pro/checksums/graphql-pro-1.24.11.txt new file mode 100644 index 00000000000..ceba77d4c9f --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.11.txt @@ -0,0 +1 @@ +26b5a3d30b0866b87dbcc800f5860c92f982aa04fdf8a855674ffec3bc8ce1fbe041672df9d00c4318040c44239b9c3ab829c5f99f6568d0d37f3cea19ecdb81 diff --git a/guides/pro/checksums/graphql-pro-1.24.12.txt b/guides/pro/checksums/graphql-pro-1.24.12.txt new file mode 100644 index 00000000000..18aba842de3 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.12.txt @@ -0,0 +1 @@ +019ea12ce24507d58afd8ffbcd4a8de990f8cdf354c7c273480dd09eca211a709dcb7e41cedb99813cdfc680bae8eab1f25826246ea85c5860898f60c34b3af8 diff --git a/guides/pro/checksums/graphql-pro-1.24.13.txt b/guides/pro/checksums/graphql-pro-1.24.13.txt new file mode 100644 index 00000000000..3126e350805 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.13.txt @@ -0,0 +1 @@ +566d9dfda5e0c39310f5c54e5c3cf44d3f7118224ac8a1aab2ccd2c9a46f34acaf29bf99a5baf2e2454a83c0b894cedff316a5b1e85d577d3c0721297afad78e diff --git a/guides/pro/checksums/graphql-pro-1.24.14.txt b/guides/pro/checksums/graphql-pro-1.24.14.txt new file mode 100644 index 00000000000..178771ae90f --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.14.txt @@ -0,0 +1 @@ +d3e3134b7e49c1f0c7c368c0fb5cdf0c27448b7527aac4cc7df012137b52919962b41ed0c3c503298232ccd7ea03a781be798b310af654906150b446a5043671 diff --git a/guides/pro/checksums/graphql-pro-1.24.15.txt b/guides/pro/checksums/graphql-pro-1.24.15.txt new file mode 100644 index 00000000000..0b7932b8a49 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.15.txt @@ -0,0 +1 @@ +f2b2eb456344075b733c1fac38c258ec02b0fa492ecc124d6d5a090b7b949eed1166da008b90182db9d29f106236251b3a2cc61fd2e99fe7dba268e20c3a0ddd diff --git a/guides/pro/checksums/graphql-pro-1.24.2.txt b/guides/pro/checksums/graphql-pro-1.24.2.txt new file mode 100644 index 00000000000..df070b6a60a --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.2.txt @@ -0,0 +1 @@ +4b1fb57d91cd1ee1f94ecfdacd0b67585c3638c8cc1b06bfcdd7c8e050f15bba37c573fb1a394451ffcf5cc2077f39c24a77b2f130943538a3c01098d487228e diff --git a/guides/pro/checksums/graphql-pro-1.24.3.txt b/guides/pro/checksums/graphql-pro-1.24.3.txt new file mode 100644 index 00000000000..6acb6e6cb34 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.3.txt @@ -0,0 +1 @@ +c0dc667c78c347daecfaca74da7e7cd230cd1c3992dc4c0b30014aa6b8add65ef7b110eca420495b93ee4f90a472297bde81e0758b77675389e3d9dd7edc0faf diff --git a/guides/pro/checksums/graphql-pro-1.24.4.txt b/guides/pro/checksums/graphql-pro-1.24.4.txt new file mode 100644 index 00000000000..47b76e81009 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.4.txt @@ -0,0 +1 @@ +2a89e5dd34183dcbaf9474a26d51c51b7f37ef39aa09fe9e28de310910b00b81751a9d03e40b0c70dee7422d6b10bc92e6adae7fc480d8e0c943ee6ecce05761 diff --git a/guides/pro/checksums/graphql-pro-1.24.5.txt b/guides/pro/checksums/graphql-pro-1.24.5.txt new file mode 100644 index 00000000000..291b6aadd79 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.5.txt @@ -0,0 +1 @@ +a179aa5876ac3dc84d516ed73c08fdae2879b7ed8a725beb762860f02f671061d9e4f6569828bce0754da49ed3a1f46d134444a71ffd1a60d71078ee16260391 diff --git a/guides/pro/checksums/graphql-pro-1.24.6.txt b/guides/pro/checksums/graphql-pro-1.24.6.txt new file mode 100644 index 00000000000..782979b4f37 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.6.txt @@ -0,0 +1 @@ +60c791368edeecb40481e573b129851090fc11f3b5f2b6a6a05ae1f7a64ff53e113addc456a4df469b9bcdf84199bc52381df6fc5cf03d65028023f5e3520be9 diff --git a/guides/pro/checksums/graphql-pro-1.24.7.txt b/guides/pro/checksums/graphql-pro-1.24.7.txt new file mode 100644 index 00000000000..5448e2f11e1 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.7.txt @@ -0,0 +1 @@ +a6cfd98894639e5fbf7c687b60efcb66c7c0c7a9cc8a810083a2f9cc7d87a3ac51df3c3b6b4d49e4ea95cc7bae2e839177d83550cba992eaa99bfffcd85168a7 diff --git a/guides/pro/checksums/graphql-pro-1.24.8.txt b/guides/pro/checksums/graphql-pro-1.24.8.txt new file mode 100644 index 00000000000..5d766b61466 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.8.txt @@ -0,0 +1 @@ +f684ab732ae69ead5c4726c0c7bf2ec72127061b499cfc7f144348fb7b584b3cfdd02f8382158f849b6d0a109bcf1d0597a9eaa6dd427e254a784337b57aec7f diff --git a/guides/pro/checksums/graphql-pro-1.24.9.txt b/guides/pro/checksums/graphql-pro-1.24.9.txt new file mode 100644 index 00000000000..d0cc22e278e --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.24.9.txt @@ -0,0 +1 @@ +06f556766da9333ddcf1d75e04707501e9f36420870da5df72e4c3e20d757fb8e157d3ea5fae5650090efea305bd7fce790cc5829ec08d322022c8f3c0c4ebff diff --git a/guides/pro/checksums/graphql-pro-1.25.0.txt b/guides/pro/checksums/graphql-pro-1.25.0.txt new file mode 100644 index 00000000000..e822d5a3ed9 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.25.0.txt @@ -0,0 +1 @@ +5cefd636eae11fec8127d2245d9cc2d630492311ab55ccae464058ea83920f24342d64d22afff325a1132f5a55f4c0721cc77077311b2ad906a39d4a7a6ec425 diff --git a/guides/pro/checksums/graphql-pro-1.25.1.txt b/guides/pro/checksums/graphql-pro-1.25.1.txt new file mode 100644 index 00000000000..2b37ff364dc --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.25.1.txt @@ -0,0 +1 @@ +2ce7d2337ba0ce219cb8c8cd80f39b6db3a523175ff1ef09410b01a4f6f19139b19af3b54174d8cd09ce6fdb0e7eb526825fc50433d64c3db36d2ddee916fb2c diff --git a/guides/pro/checksums/graphql-pro-1.25.2.txt b/guides/pro/checksums/graphql-pro-1.25.2.txt new file mode 100644 index 00000000000..eb6ca1b2ff0 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.25.2.txt @@ -0,0 +1 @@ +39a4910d324054894d3b6695b9651040f7fdbb418450827409c6b8361a628a16a5a7ae6f1bb2d57b9b3784218862f7428572a0baf9d18ac3b9c0e5267018e479 diff --git a/guides/pro/checksums/graphql-pro-1.26.0.txt b/guides/pro/checksums/graphql-pro-1.26.0.txt new file mode 100644 index 00000000000..edaaa693fcd --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.26.0.txt @@ -0,0 +1 @@ +c5e6955c2878ed82c168a6f73abee8e6ed5a79a6f57bc25131a44603e88c4bab17726f1f6c1c856ad3aeae96f8a19d38fae864019f6d2b64306f9c4e5433a6ce diff --git a/guides/pro/checksums/graphql-pro-1.26.1.txt b/guides/pro/checksums/graphql-pro-1.26.1.txt new file mode 100644 index 00000000000..33f59a79862 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.26.1.txt @@ -0,0 +1 @@ +33537b3b1a5afef64ea25817d607afae983de531902c18dc5c99bd4b52538b5b03cb3f7032dc3a53b5690ef85a7abd7a0422cdead9a871a187b3bc755ba4062c diff --git a/guides/pro/checksums/graphql-pro-1.26.2.txt b/guides/pro/checksums/graphql-pro-1.26.2.txt new file mode 100644 index 00000000000..79b23d76d94 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.26.2.txt @@ -0,0 +1 @@ +c274b2993b36849ef08083eed7b9712d88eada464a893bdc123bbb7dd46e90417e3e3206e377d00a23a730a04170440375c6f2198418043d2fdc28baa0463e28 diff --git a/guides/pro/checksums/graphql-pro-1.26.3.txt b/guides/pro/checksums/graphql-pro-1.26.3.txt new file mode 100644 index 00000000000..083a5d11b69 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.26.3.txt @@ -0,0 +1 @@ +1913e81ac6a1f6055b023b4e33d0188f801c722e4f6f18a410f2776169e1fa1dc87fad1e89416305ff4e08e352f9b7107a61fe9edf65d215ec2547cc4a78eb81 diff --git a/guides/pro/checksums/graphql-pro-1.26.4.txt b/guides/pro/checksums/graphql-pro-1.26.4.txt new file mode 100644 index 00000000000..9caa82b00e6 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.26.4.txt @@ -0,0 +1 @@ +d1099e2f64e9ad256d86a624904a6a5fdd28241bbfa478fcd25bc32af53e5200892ce8e4808be0bcdd8b42a74e5463565002eb8810928748d3390c5c7f4eb736 diff --git a/guides/pro/checksums/graphql-pro-1.26.5.txt b/guides/pro/checksums/graphql-pro-1.26.5.txt new file mode 100644 index 00000000000..d37841db695 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.26.5.txt @@ -0,0 +1 @@ +34218544d143f790a5424f12ec792f04b2d4e0e4244d549cb9baa6c3eacc1b68d2f0960a678479507c893050804d5cad916ba3ad9f8caec9c9958c7c02fd0dd5 diff --git a/guides/pro/checksums/graphql-pro-1.27.0.txt b/guides/pro/checksums/graphql-pro-1.27.0.txt new file mode 100644 index 00000000000..df2cec8543a --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.27.0.txt @@ -0,0 +1 @@ +0f796160fcacd74386d1e955cafa7b51815593efcaa72d5f1583f024a4732abd8e97e6c2a9d83ff6a5f3f6c9906f367a002e11455db16fbbd71f4538c2552411 diff --git a/guides/pro/checksums/graphql-pro-1.27.1.txt b/guides/pro/checksums/graphql-pro-1.27.1.txt new file mode 100644 index 00000000000..8fa03b871d4 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.27.1.txt @@ -0,0 +1 @@ +c0ea2ec42b02b4f03f6499dfaa153f53814f0b91638146e4d3b3f9021da2df1fd3bf8dbac150091d4ca988e2271addcd263624999700d16c53b77ee4dd430038 diff --git a/guides/pro/checksums/graphql-pro-1.27.2.txt b/guides/pro/checksums/graphql-pro-1.27.2.txt new file mode 100644 index 00000000000..356af9cd323 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.27.2.txt @@ -0,0 +1 @@ +ffdaed66f84e10c2118ab0b4db4f2d958412380851edfc84c4b3c87f1b6c108399a0655912d750fdcc17393df7d803a345d02569846b8ef6fdd6a3f5d3ac8f83 diff --git a/guides/pro/checksums/graphql-pro-1.27.3.txt b/guides/pro/checksums/graphql-pro-1.27.3.txt new file mode 100644 index 00000000000..61e0c3af45f --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.27.3.txt @@ -0,0 +1 @@ +eaecab7fe1956b08d6bd11009bc984c4876acc93ba1977b2da2af3dc1c0dfd6974d1d98a25fd8005aea6890a4cb01bea0d5d9621dfa97f549e7b9c9d3afd944d diff --git a/guides/pro/checksums/graphql-pro-1.27.4.txt b/guides/pro/checksums/graphql-pro-1.27.4.txt new file mode 100644 index 00000000000..a9975e1cee7 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.27.4.txt @@ -0,0 +1 @@ +aef6b82f70e6f264f8a762d3a2fce3d4b2f6779b07d91f15102a11dca76f0c1fcbfdaa46c436fe3407683a3f194a1bb39578934cdb8cbb10589c8c7526508228 diff --git a/guides/pro/checksums/graphql-pro-1.27.5.txt b/guides/pro/checksums/graphql-pro-1.27.5.txt new file mode 100644 index 00000000000..6c3037ca5b4 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.27.5.txt @@ -0,0 +1 @@ +5107cca81a11674de03cc65f0c3a13389c4538225b10a8bcc0c6fc4aae2ec08e7c01e18ad5f86fa5db5fde01608a695fbdada4ea98fb4041dd4a3a7ed5019262 diff --git a/guides/pro/checksums/graphql-pro-1.27.6.txt b/guides/pro/checksums/graphql-pro-1.27.6.txt new file mode 100644 index 00000000000..9df116f4b8d --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.27.6.txt @@ -0,0 +1 @@ +ecf249015ae4da921afc1291cbc7c8519804d47895d7de771760fb8c76a9bc19f3eba7e0d7cd07beb5a47c27974340d94b9dba1f1bec17428728f802cf157df2 diff --git a/guides/pro/checksums/graphql-pro-1.27.7.txt b/guides/pro/checksums/graphql-pro-1.27.7.txt new file mode 100644 index 00000000000..fa59cc80fb6 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.27.7.txt @@ -0,0 +1 @@ +3cee596b97b156879fce33e22a5e6ccea3bc2a4f44d27451d4a32d910cb108bce0f83a2121fd7b247ade237040a86b824b37f63dc2cff7e26b73398fb4468d27 diff --git a/guides/pro/checksums/graphql-pro-1.28.0.txt b/guides/pro/checksums/graphql-pro-1.28.0.txt new file mode 100644 index 00000000000..184bae98e88 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.28.0.txt @@ -0,0 +1 @@ +46190abe8d74973ecc67c365029c861ef0b06ab06adad979cacf8dafa87df0b8a1b297ebc58867a62674e06afd9663467641e60da6a3e7c4ab60859fae409a36 diff --git a/guides/pro/checksums/graphql-pro-1.28.1.txt b/guides/pro/checksums/graphql-pro-1.28.1.txt new file mode 100644 index 00000000000..dc02c5325f7 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.28.1.txt @@ -0,0 +1 @@ +1393056efd26d98854bb54e345e9bb3dc5032648724540f5aa759c92e1a3076f7317a64e9a4275658b150b81e53e06185025848f467ea6d92af805197a6c9660 diff --git a/guides/pro/checksums/graphql-pro-1.29.0.txt b/guides/pro/checksums/graphql-pro-1.29.0.txt new file mode 100644 index 00000000000..fc98027ece0 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.0.txt @@ -0,0 +1 @@ +8b7c83c448d7cadf1223c75f495b1342809f703374433a54cd184d5d4c2e934bf5f1ab3cebea1c901de42780e7a73a6f1fdea575400b32376efd2ef1befd0d63 diff --git a/guides/pro/checksums/graphql-pro-1.29.1.txt b/guides/pro/checksums/graphql-pro-1.29.1.txt new file mode 100644 index 00000000000..7ed66d4fec3 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.1.txt @@ -0,0 +1 @@ +2f6aad9879bdf10f3089102c0f37b79233d3b6dd76267a3c52b286340f723723cc4aa0642eda555ce640e615c8af1b7c815db83160f00ef389aef949ebc62bb6 diff --git a/guides/pro/checksums/graphql-pro-1.29.10.txt b/guides/pro/checksums/graphql-pro-1.29.10.txt new file mode 100644 index 00000000000..a22545aeb5a --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.10.txt @@ -0,0 +1 @@ +2b45187577bcea131660cc2da1a7c846e61e6b5e3d6d4cd23890ce769b510feac4961c63412f4b8031dff232ae91ed62a97ea6824705eb08d76348e1680c907f diff --git a/guides/pro/checksums/graphql-pro-1.29.11.txt b/guides/pro/checksums/graphql-pro-1.29.11.txt new file mode 100644 index 00000000000..0cc38ece6d9 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.11.txt @@ -0,0 +1 @@ +ea5f544ddad2a88d1813629567dbaa3c6e0a11408ddc0c31e0b2129bc599aa1af0b791b0307944d2d26689f58f611b8c0aaac14f85b7b5c9cd5c1b5938499a90 diff --git a/guides/pro/checksums/graphql-pro-1.29.12.txt b/guides/pro/checksums/graphql-pro-1.29.12.txt new file mode 100644 index 00000000000..89837e6657f --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.12.txt @@ -0,0 +1 @@ +fea66a1684747ef85ea4d086f292cbb605ca2f28b0a705b47ae68b6701bd97c9ef462451767cab04b70516ac38b8a55069775803abc5c9289633ed1b73e19144 diff --git a/guides/pro/checksums/graphql-pro-1.29.13.txt b/guides/pro/checksums/graphql-pro-1.29.13.txt new file mode 100644 index 00000000000..66675329fd4 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.13.txt @@ -0,0 +1 @@ +b87205dd28a9282ecd60724ca79b4e7e278e49388b6f3129a750fc0fc0da5ab7eaad8b58316d4a17227e05df65c9b673f8f49744509c4edaba49bfe8ce4d1d1b diff --git a/guides/pro/checksums/graphql-pro-1.29.14.txt b/guides/pro/checksums/graphql-pro-1.29.14.txt new file mode 100644 index 00000000000..a531cbb3792 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.14.txt @@ -0,0 +1 @@ +6267bbd7206fa50e69e96bfd7143ac118bcc51cc3a91b667499324f856d1894dec2a276603230a0661538989d4153f50e323b70bcf37982fd6bce8ed28ec7715 diff --git a/guides/pro/checksums/graphql-pro-1.29.2.txt b/guides/pro/checksums/graphql-pro-1.29.2.txt new file mode 100644 index 00000000000..9b06209c9f5 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.2.txt @@ -0,0 +1 @@ +60d43612eaedfb0130e4e4c27f6f4239d6f5a1febca6eecd799b14923da2684830c75b5807d0c8894fa5194efa65b2800093147a4222090e60e532858315ba32 diff --git a/guides/pro/checksums/graphql-pro-1.29.3.txt b/guides/pro/checksums/graphql-pro-1.29.3.txt new file mode 100644 index 00000000000..fcd79bac1d2 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.3.txt @@ -0,0 +1 @@ +40aa14f24d3a3151de2015c5b3cd58a98ea7a2c0fccaa4663700bd0c804d68a129b9ceb63fc07b288ea814c74eeb4f20c82768781c99b8e80908531fb114f0d4 diff --git a/guides/pro/checksums/graphql-pro-1.29.4.txt b/guides/pro/checksums/graphql-pro-1.29.4.txt new file mode 100644 index 00000000000..e04b4c74cf0 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.4.txt @@ -0,0 +1 @@ +bfbc862b5f4f3e5a06517713e3bb618dce6b3e3f6feded200634ce97344651a98e408094cf7c02ee711008ded100049b3e743526f374f6ca7f9d38bccd08d004 diff --git a/guides/pro/checksums/graphql-pro-1.29.5.txt b/guides/pro/checksums/graphql-pro-1.29.5.txt new file mode 100644 index 00000000000..468e7f82aa5 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.5.txt @@ -0,0 +1 @@ +9c351ce7366d67f085125834bc82a0b4696c99a2398846b2536db06eff17aa041661ef4abe4adb98157724223a59853ce8d8c3fc4cf5b4447e1e9ec468f7eb22 diff --git a/guides/pro/checksums/graphql-pro-1.29.6.txt b/guides/pro/checksums/graphql-pro-1.29.6.txt new file mode 100644 index 00000000000..cc64eff5c6a --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.6.txt @@ -0,0 +1 @@ +4c58ee6ebe6c1ceffd78e5802a965f54b7ed278c0e5069c6b2bac589ca59e543030456ac52827d3b89ddfe8019944336a519de6b529aaa982cec30e62326fe05 diff --git a/guides/pro/checksums/graphql-pro-1.29.7.txt b/guides/pro/checksums/graphql-pro-1.29.7.txt new file mode 100644 index 00000000000..f2892e2cba2 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.7.txt @@ -0,0 +1 @@ +8e1846cdd9f5d62fc2d72fe44b9270a1e9ab30a325430b7b1874c537e1df37d8f5d64db4ad26b96413ff7ccaf9d9a5d1f05da77c28dd6f61ffa04ec7eb39a2a3 diff --git a/guides/pro/checksums/graphql-pro-1.29.8.txt b/guides/pro/checksums/graphql-pro-1.29.8.txt new file mode 100644 index 00000000000..c84cce3a127 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.8.txt @@ -0,0 +1 @@ +42b3c1ff9c885cc981ac12e313bcc8a977d90a6baab2224e941ae59a435ee6e6c63af0d93f4c08879209222a9fbf83d93befa8d676c3ae73c56daf6c7024af91 diff --git a/guides/pro/checksums/graphql-pro-1.29.9.txt b/guides/pro/checksums/graphql-pro-1.29.9.txt new file mode 100644 index 00000000000..42d74351f7c --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.29.9.txt @@ -0,0 +1 @@ +a9b6a9d80900aefe10c6d88081a0f9245455517785b3a71e9ebb940f00f1c805e3d212ed2dba06989089151f2db676394454db3bd8e9dccf73b5ef294ffe3bdf diff --git a/guides/pro/checksums/graphql-pro-1.30.0.txt b/guides/pro/checksums/graphql-pro-1.30.0.txt new file mode 100644 index 00000000000..dbf0a2309d5 --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.30.0.txt @@ -0,0 +1 @@ +3775d4c2465db911755bb980afcd04755a2903707c69b9b544b053cdbd9cabb62ff582ae1d8faaf3883c11deae051b96dd205510296dc61650092f2334fa8697 diff --git a/guides/pro/checksums/graphql-pro-1.30.1.txt b/guides/pro/checksums/graphql-pro-1.30.1.txt new file mode 100644 index 00000000000..6f8f15af61b --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.30.1.txt @@ -0,0 +1 @@ +15580879e4170084cbf12a1071de9bf5a619f9e87453dffb50b6ab657d14b6cee2d1376358c34291589412ba0768d0d8fd0f5f22809dc9cc2b416edf98edbe1e diff --git a/guides/pro/checksums/graphql-pro-1.30.2.txt b/guides/pro/checksums/graphql-pro-1.30.2.txt new file mode 100644 index 00000000000..aff310a207c --- /dev/null +++ b/guides/pro/checksums/graphql-pro-1.30.2.txt @@ -0,0 +1 @@ +be14488856900a8ec99d61cbb2ab2313ed038a30e5583379c1d127da68e248e0bc72bf17979933af00875302a90354aafeed3309e86ae0a23eb35e7688883b31 diff --git a/guides/pro/cursors.md b/guides/pro/cursors.md deleted file mode 100644 index a5932f4fa1e..00000000000 --- a/guides/pro/cursors.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -layout: guide -doc_stub: false -search: true -section: GraphQL Pro -title: Stable Cursors for ActiveRecord -desc: Value-based cursors for stable pagination over ActiveRecord::Relations -index: 5 -pro: true ---- - -__Note:__ See the new {% internal_link "stable relation connection", "/pagination/stable_relation_connections" %} guide for a more robust and flexible implementation of this feature. - ------ - -`GraphQL::Pro` includes a mechanism for serving _stable_ cursors for `ActiveRecord::Relation`s based on column values. If objects are created or destroyed during pagination, the list of items won't be disrupted. - -A new `RelationConnection` is applied by default. It is backwards-compatible with existing offset-based cursors. See ["Opting Out"](#opting-out) below if you wish to continue using offset-based pagination. - -To enforce the opacity of your cursors, consider an {% internal_link "encrypted encoder","/pro/encoders" %}. - -## What's the difference? - -The default `RelationConnection` (which turns an `ActiveRecord::Relation` into a Relay-compatible connection) uses _offset_ as a cursor. This naive approach is sufficient for many cases, but it's subject to a specific set of bugs. - -Let's say you're looking at the second page of 10 items (`LIMIT 10 OFFSET 10`). During that time, one of the items on page 1 is deleted. When you navigate to page 3 (`LIMIT 10 OFFSET 20`), you'll actually _miss_ one item. The entire list shifted "up" one position when a previous item was deleted. - -To solve this bug, we should use a _value_ to page through items (instead of _offset_). For example, if items are ordered by `id`, use the `id` for pagination: - -```sql -LIMIT 10 -- page 1 -WHERE id > :last_id LIMIT 10 -- page 2 -``` - -This way, even when items are added or removed, pagination will continue without interruption. - -For more information about this issue, see ["Pagination: You're (Probably) Doing It Wrong"](https://coderwall.com/p/lkcaag/pagination-you-re-probably-doing-it-wrong). - -## Implementation Notes - -Keep these points in mind when using value-based cursors: - -- For a given `ActiveRecord::Relation`, only columns of that specific model can be used in pagination. (This is because column names are turned into `WHERE` conditions.) -- `RelationConnection` may add an additional `primary_key` ordering to ensure that the cursor value is unique. This behavior is inspired by `Relation#reverse_order` which also assumes that `primary_key` is the default sort. - -## Grouped Relations - -When using a grouped `ActiveRecord::Relation`, include a unique ID in your sort to ensure that each row in the result has a unique cursor. For example: - -```ruby -# Bad: If two results have the same `max(price)`, -# they will be identical from a pagination perspective: -Products.select("max(price) as price").group("category_id").order("price") - -# Good: `category_id` is used to disambiguate any results with the same price: -Products.select("max(price) as price").group("category_id").order("price, category_id") -``` - -For ungrouped relations, this issue is handled automatically by adding the model's `primary_key` to the order values. - -If you provide an unordered, grouped relation, `GraphQL::Pro::RelationConnection::InvalidRelationError` will be raised because an unordered relation _cannot_ be paginated in a stable way. - -## Backwards Compatibility - -`GraphQL::Pro`'s `RelationConnection` is backwards-compatible. If it receives an offset-based cursor, it uses that cursor for the next resolution, then returns value-based cursors in the next result. - -If you're also switching to {% internal_link "encrypted cursors","/pro/encoders" %}, you'll need a {% internal_link "versioned encoder","/pro/encoders#versioning" %}, too. This way, _both_ unencrypted _and_ encrypted cursors will be accepted! For example: - -```ruby -# Define an encrypted encoder for use with cursors: -EncryptedCursorEncoder = MyEncoder = GraphQL::Pro::Encoder.define do - key("f411f30...") -end - -# Make a versioned encoder combining new & old -VersionedCursorEncoder = GraphQL::Pro::Encoder.versioned( - # New encrypted encoder: - EncryptedCursorEncoder - # Old plaintext encoder (this is the default): - GraphQL::Schema::Base64Encoder -) - -MySchema = GraphQL::Schema.define do - # Apply the versioned encoder: - cursor_encoder(VersionedCursorEncoder) -end -``` - -Now, _both_ unencrypted and encrypted cursors will be accepted. - -## Opting Out - -If you don't want `GraphQL::Pro`'s new cursor behavior, re-register the offset-based `RelationConnection`: - -```ruby -MySchema = GraphQL::Schema.define { ... } -# Always use the offset-based connection, override `GraphQL::Pro::RelationConnection` -GraphQL::Relay::BaseConnection.register_connection_implementation( - ActiveRecord::Relation, GraphQL::Relay::RelationConnection -) -``` - -## ActiveRecord Versions - -`GraphQL::Pro::RelationConnection` supports ActiveRecord `>= 4.1.0`. diff --git a/guides/pro/dashboard.md b/guides/pro/dashboard.md index 638ebcb14cb..0b3fbf22bc5 100644 --- a/guides/pro/dashboard.md +++ b/guides/pro/dashboard.md @@ -36,6 +36,22 @@ With this configuration, it will be available at `/graphql/dashboard`. The dashboard is a Rack app, so you can mount it in Sinatra or any other Rack app. +#### Lazy-loading the schema + +Alternatively, you can set up the dashboard to load the schema during the first request. To do that, initialize `GraphQL::Pro::Routes::Lazy` with a string that gives the fully-qualified name of your schema class, for example: + +```ruby +Rails.application.routes.draw do + # ... + # Add the GraphQL::Pro Dashboard + # TODO: authorize, see below + lazy_routes = GraphQL::Pro::Routes::Lazy.new("MySchema") + mount lazy_routes.dashboard, at: "/graphql/dashboard" +end +``` + +With this setup, `MySchema` will be loaded when the dashboard serves its first request. This can speed up your application's boot in development since it doesn't load the whole GraphQL schema when building the routes. + ## Authorizing the Dashboard You should only allow admin users to see `/graphql/dashboard` because it allows viewers to delete stored operations. diff --git a/guides/pro/encoders.md b/guides/pro/encoders.md index 36263e537ce..d2b7284b838 100644 --- a/guides/pro/encoders.md +++ b/guides/pro/encoders.md @@ -22,10 +22,10 @@ pro: true ## Defining an Encoder -Encoders can be created with `Encoder.define { ... }`: +Encoders can be created by subclassing `GraphQL::Pro::Encoder`: ```ruby -MyEncoder = GraphQL::Pro::Encoder.define do +class MyEncoder < GraphQL::Pro::Encoder key("f411f30...") # optional: tag("81ce51c307") @@ -40,32 +40,32 @@ end Encrypt cursors by attaching an encrypted encoder to `Schema#cursor_encoder`: ```ruby -MySchema = GraphQL::Schema.define do +class MySchema GraphQL::Schema cursor_encoder(MyCursorEncoder) end ``` Now, built-in connection implementations will use that encoder for cursors. -If you implement your own connections, you can access the encoder's encryption methods via {{ "GraphQL::Relay::BaseConnection#encode" | api_doc }} and {{ "GraphQL::Relay::BaseConnection#decode" | api_doc }}. +If you implement your own connections, you can access the encoder's encryption methods via {{ "GraphQL::Pagination::Connection#encode" | api_doc }} and {{ "GraphQL::Pagination::Connection#decode" | api_doc }}. ## Encrypting IDs -Encrypt IDs by using encoders in `Schema#id_from_object` and `Schema#object_from_id`: +Encrypt IDs by using encoders in `Schema.id_from_object` and `Schema.object_from_id`: ```ruby -MySchema = GraphQL::Schema.define do - id_from_object ->(object, type, ctx) { +class MySchema < GraphQL::Schema + def self.id_from_object(object, type, ctx) id_data = "#{object.class.name}/#{object.id}" MyIDEncoder.encode(id_data) - } + end - object_from_id ->(id, ctx) { + def self.object_from_id(id, ctx) id_data = MyIDEncoder.decode(id) class_name, id = id_data.split("/") class_name.constantize.find(id) - } + end end ``` @@ -77,9 +77,17 @@ You can combine several encoders into a single chain of versioned encoders. Pass ```ruby # Define some encoders ... -NewSecureEncoder = GraphQL::Pro::Encoder.define { ... } -OldSecureEncoder = GraphQL::Pro::Encoder.define { ... } -LegacyInsecureEncoder = GraphQL::Pro::Encoder.define { ... } +class NewSecureEncoder < GraphQL::Pro::Encoder + # ... +end + +class OldSecureEncoder < GraphQL::Pro::Encoder + # ... +end + +class LegacyInsecureEncoder < GraphQL::Pro::Encoder + # ... +end # Then order them by priority: VersionedEncoder = GraphQL::Pro::Encoder.versioned( @@ -117,7 +125,6 @@ module URLSafeEncoder def self.encode(str) Base64.urlsafe_encode64(str) end - def self.decode(str) Base64.urlsafe_decode64(str) end @@ -127,7 +134,7 @@ end Then attach it to your encoder: ```ruby -MyURLSafeEncoder = GraphQL::Pro::Encoder.define do +class MyURLSafeEncoder < GraphQL::Pro::Encoder encoder URLSafeEncoder end ``` diff --git a/guides/pro/privacy.md b/guides/pro/privacy.md index ff65d4a9725..c56f8d83657 100644 --- a/guides/pro/privacy.md +++ b/guides/pro/privacy.md @@ -10,12 +10,7 @@ index: 7 The following statement describes what data GraphQL::Pro collects during normal operation and how that data is used. -- [What Data We Collect And How It's Used](#what-data-we-collect-and-how-its-used) -- [Third-Party Services](#third-party-services) -- [Data Security](#data-security) -- [More Information](#more-information) - -### What Data We Collect And How It's Used +## What Data We Collect And How It's Used GraphQL::Pro collects the following kinds of data: @@ -30,7 +25,7 @@ The `graphql-pro` Ruby gem collects no data and never "phones home" for any purp GraphQL::Pro is not directed at children under the age of 13. If you are under age 13, please do not use GraphQL::Pro. -### Third-Party Services +## Third-Party Services Use of GraphQL::Pro includes the following third-party services: @@ -43,16 +38,16 @@ Bugsnag | Application Monitoring New Relic APM | Application Monitoring Papertrail | Application Monitoring Stripe | Payment Processing -MailChimp | Newsletter Management +Buttondown | Newsletter Management Google Apps | Company Infrastructure -### Data Security +## Data Security GraphQL::Pro does not collect ["Sensitive Personal Information"](https://gdpr-info.eu/art-9-gdpr/). GraphQL::Pro's systems are secured with strong, unique passwords and two-factor authentication is enabled wherever possible. You are responsible for using a strong, unique password to log into https://billing.graphql.pro. -### More Information +## More Information This document is managed [on GitHub](https://github.com/rmosolgo/graphql-ruby/blob/master/guides/pro/privacy.md). You can use a GitHub account to watch for changes or subscribe to a [public RSS feed](https://github.com/rmosolgo/graphql-ruby/commits/master.atom). diff --git a/guides/queries/appoptics_example.png b/guides/queries/appoptics_example.png deleted file mode 100644 index 07bd21ffbba..00000000000 Binary files a/guides/queries/appoptics_example.png and /dev/null differ diff --git a/guides/queries/appsignal_example.png b/guides/queries/appsignal_example.png deleted file mode 100644 index 3eee1366ad5..00000000000 Binary files a/guides/queries/appsignal_example.png and /dev/null differ diff --git a/guides/queries/ast_analysis.md b/guides/queries/ast_analysis.md index b29491cf748..2025774f1e2 100644 --- a/guides/queries/ast_analysis.md +++ b/guides/queries/ast_analysis.md @@ -12,9 +12,9 @@ redirect_from: You can do ahead-of-time analysis for your queries. -The primitive for analysis is {{ "GraphQL::Analysis::AST::Analyzer" | api_doc }}. Analyzers must inherit from this base class and implement the desired methods for analysis. +The primitive for analysis is {{ "GraphQL::Analysis::Analyzer" | api_doc }}. Analyzers must inherit from this base class and implement the desired methods for analysis. -### Using Analyzers +## Using Analyzers Query analyzers are added to the schema with `query_analyzer`, for example: @@ -31,20 +31,20 @@ Pass the **class** (and not an _instance_) of your analyzer. The analysis engine Analyzers respond to methods similar to AST visitors. They're named like `on_enter_#{ast_node}` and `on_leave_#{ast_node}`. Methods are called with three arguments: - `node`: The current AST node (being entered or left) -- `parent`: The AST node which preceeds this one in the tree -- `visitor`: A {{ "GraphQL::Analysis::AST::Visitor" | api_doc }} which is managing this analysis run +- `parent`: The AST node which precedes this one in the tree +- `visitor`: A {{ "GraphQL::Analysis::Visitor" | api_doc }} which is managing this analysis run For example: ```ruby -class BasicCounterAnalyzer < GraphQL::Analysis::AST::Analyzer +class BasicCounterAnalyzer < GraphQL::Analysis::Analyzer def initialize(query_or_multiplex) super @fields = Set.new @arguments = Set.new end - # Visitors are all defined on the AST::Analyzer base class + # Visitors are all defined on the Analyzer base class # We override them for custom analyzers. def on_leave_field(node, _parent, _visitor) @fields.add(node.name) @@ -62,13 +62,13 @@ or if it was skipped by directives. If we want to detect those contexts, we can methods: ```ruby -class BasicFieldAnalyzer < GraphQL::Analysis::AST::Analyzer +class BasicFieldAnalyzer < GraphQL::Analysis::Analyzer def initialize(query_or_multiplex) super @fields = Set.new end - # Visitors are all defined on the AST::Analyzer base class + # Visitors are all defined on the Analyzer base class # We override them for custom analyzers. def on_leave_field(node, _parent, visitor) if visitor.skipping? || visitor.visiting_fragment_definition? @@ -85,7 +85,7 @@ class BasicFieldAnalyzer < GraphQL::Analysis::AST::Analyzer end ``` -See {{ "GraphQL::Analysis::AST::Visitor" | api_doc }} for more information about the `visitor` object. +See {{ "GraphQL::Analysis::Visitor" | api_doc }} for more information about the `visitor` object. ### Field Arguments @@ -96,7 +96,7 @@ Usually, analyzers will use `on_enter_field` and `on_leave_field` to process que It is still possible to return errors from an analyzer. To reject a query and halt its execution, you may return {{ "GraphQL::AnalysisError" | api_doc }} in the `result` method: ```ruby -class NoFieldsCalledHello < GraphQL::Analysis::AST::Analyzer +class NoFieldsCalledHello < GraphQL::Analysis::Analyzer def on_leave_field(node, _parent, visitor) if node.name == "hello" @field_called_hello = true @@ -114,7 +114,7 @@ end Some analyzers might only make sense in certain context, or some might be too expensive to run for every query. To handle these scenarios, your analyzers may answer to an `analyze?` method: ```ruby -class BasicFieldAnalyzer < GraphQL::Analysis::AST::Analyzer +class BasicFieldAnalyzer < GraphQL::Analysis::Analyzer # Use the analyze? method to enable or disable a certain analyzer # at query time. def analyze? diff --git a/guides/queries/complexity_and_depth.md b/guides/queries/complexity_and_depth.md index 757ab683564..b3294be8e82 100644 --- a/guides/queries/complexity_and_depth.md +++ b/guides/queries/complexity_and_depth.md @@ -10,6 +10,46 @@ index: 4 GraphQL-Ruby ships with some validations based on {% internal_link "query analysis", "/queries/ast_analysis" %}. You can customize them as-needed, too. +## Prevent deeply-nested queries + +You can also reject queries based on the depth of their nesting. You can define `max_depth` at schema-level or query-level: + +```ruby +# Schema-level: +class MySchema < GraphQL::Schema + # ... + max_depth 15 +end + +# Query-level, which overrides the schema-level setting: +MySchema.execute(query_string, max_depth: 20) +``` + +By default, **introspection fields are counted**. The default introspection query requires at least `max_depth 13`. You can also configure your schema not to count introspection fields with `max_depth ..., count_introspection_fields: false`. + +You can use `nil` to disable the validation: + +```ruby +# This query won't be validated: +MySchema.execute(query_string, max_depth: nil) +``` + +To get a feeling for depth of queries in your system, you can extend {{ "GraphQL::Analysis::QueryDepth" | api_doc }}. Hook it up to log out values from each query: + +```ruby +class LogQueryDepth < GraphQL::Analysis::QueryDepth + def result + query_depth = super + message = "[GraphQL Query Depth] #{query_depth} || staff? #{query.context[:current_user].staff?}" + Rails.logger.info(message) + end +end + +class MySchema < GraphQL::Schema + query_analyzer(LogQueryDepth) +end +``` + ## Prevent complex queries Fields have a "complexity" value which can be configured in their definition. It can be a constant (numeric) value, or a proc. If no `complexity` is defined for a field, it will default to a value of `1`. It can be defined as a keyword _or_ inside the configuration block. For example: @@ -60,10 +100,10 @@ Using `nil` will disable the validation: MySchema.execute(query_string, max_complexity: nil) ``` -To get a feeling for complexity of queries in your system, you can extend {{ "GraphQL::Analysis::AST::QueryComplexity" | api_doc }}. Hook it up to log out values from each query: +To get a feeling for complexity of queries in your system, you can extend {{ "GraphQL::Analysis::QueryComplexity" | api_doc }}. Hook it up to log out values from each query: ```ruby -class LogQueryComplexityAnalyzer < GraphQL::Analysis::AST::QueryComplexity +class LogQueryComplexityAnalyzer < GraphQL::Analysis::QueryComplexity # Override this method to _do something_ with the calculated complexity value def result complexity = super @@ -77,40 +117,205 @@ class MySchema < GraphQL::Schema end ``` -## Prevent deeply-nested queries +By default, **introspection fields are counted**. You can also configure your schema not to count introspection fields with `max_complexity ..., count_introspection_fields: false`. -You can also reject queries based on the depth of their nesting. You can define `max_depth` at schema-level or query-level: +#### Connection fields + +By default, GraphQL-Ruby calculates a complexity value for connection fields by: + +- adding `1` for `pageInfo` and each of its subselections +- adding `1` for `count`, `totalCount`, or `total` +- adding `1` for the connection field itself +- multiplying the complexity of other fields by the largest possible page size, which is the greater of `first:` or `last:`, or if neither of those are given it will go through each of `default_page_size`, the schema's `default_page_size`, `max_page_size`, and then the schema's `default_max_page_size`. + + (If no default page size or max page size can be determined, then the analysis crashes with an internal error -- set `default_page_size` or `default_max_page_size` in your schema to prevent this.) + +For example, this query has complexity `26`: + +```graphql +query { + author { # +1 + name # +1 + books(first: 10) { # +1 + nodes { # +10 (+1, multiplied by `first:` above) + title # +10 (ditto) + } + pageInfo { # +1 + endCursor # +1 + } + totalCount # +1 + } + } +} +``` + +To customize this behavior, implement `def calculate_complexity(query:, nodes:, child_complexity:)` in your base field class, handling the case where `self.connection?` is `true`: ```ruby -# Schema-level: -class MySchema < GraphQL::Schema - # ... - max_depth 10 +class Types::BaseField < GraphQL::Schema::Field + def calculate_complexity(query:, nodes:, child_complexity:) + if connection? + # Custom connection calculation goes here + else + super + end + end end +``` -# Query-level, which overrides the schema-level setting: -MySchema.execute(query_string, max_depth: 10) +## How complexity scoring works + +GraphQL Ruby's complexity scoring algorithm is biased towards selection fairness. While highly accurate, its results are not always intuitive. Here's an example query performed on the [Shopify Admin API](https://shopify.dev/docs/api/admin-graphql): + +```graphql +query { + node(id: "123") { # interface Node + id + ...on HasMetafields { # interface HasMetafields + metafield(key: "a") { + value + } + metafields(first: 10) { + nodes { + value + } + } + } + ...on Product { # implements HasMetafields + title + metafield(key: "a") { + definition { + description + } + } + } + ...on PriceList { + name + catalog { + id + } + } + } +} ``` -You can use `nil` to disable the validation: +First, GraphQL Ruby allows field definitions to specify a `complexity` attribute that provides a complexity score (or a proc that computes a score) for each field. Let's say that this schema defines a system where: -```ruby -# This query won't be validated: -MySchema.execute(query_string, max_depth: nil) +- Leaf fields cost `0` +- Composite fields cost `1` +- Connection fields cost `children * input size` + +Given these parameters, we get an itemized scoring distribution of: + +```graphql +query { + node(id: "123") { # 1, composite + id # 0, leaf + ...on HasMetafields { + metafield(key: "a") { # 1, composite + value # 0, leaf + } + metafields(first: 10) { # 1 * 10, connection + nodes { # 1, composite + value # 0, leaf + } + } + } + ...on Product { + title # 0, leaf + metafield(key: "a") { # 1, composite + definition { # 1, composite + description # 0, leaf + } + } + } + ...on PriceList { + name # 0, leaf + catalog { # 1, composite + id # 0, leaf + } + } + } +} ``` -To get a feeling for depth of queries in your system, you can extend {{ "GraphQL::Analysis::AST::QueryDepth" | api_doc }}. Hook it up to log out values from each query: +However, we cannot naively tally these itemized scores without over-costing the query. Consider: + +- The `node` scope makes many _possible_ selections on an abstract type, so we need the maximum among concrete possibilities for a fair representation. +- A `node.metafield` selection path is duplicated across the `HasMetafields` and `Product` selection scopes. This path will only resolve once, so should also only cost once. + +To reconcile these possibilities, the [complexity algorithm](https://github.com/rmosolgo/graphql-ruby/blob/master/lib/graphql/analysis/query_complexity.rb) breaks the selection down into a tree of types mapped to possible selections, across which lexical selections can be coalesced and deduplicated (pseudocode): ```ruby -class LogQueryDepth < GraphQL::Analysis::AST::QueryDepth - def result - query_depth = super - message = "[GraphQL Query Depth] #{query_depth} || staff? #{query.context[:current_user].staff?}" - Rails.logger.info(message) - end -end +{ + Schema::Query => { + "node" => { + Schema::Node => { + "id" => nil, + }, + Schema::HasMetafields => { + "metafield" => { + Schema::Metafield => { + "value" => nil, + }, + }, + "metafields" => { + Schema::Metafield => { + "nodes" => { ... }, + }, + }, + }, + Schema::Product => { + "title" => nil, + "metafield" => { + Schema::Metafield => { + "definition" => { ... }, + }, + }, + }, + Schema::PriceList => { + "name" => nil, + "catalog" => { + Schema::Catalog => { + "id" => nil, + }, + }, + }, + }, + }, +} +``` -class MySchema < GraphQL::Schema - query_analyzer(LogQueryDepth) -end +This aggregation provides a new perspective on the scoring where _possible typed selections_ have costs rather than individual fields. In this normalized view, `Product` acquires the `HasMetafields` interface costs, and ignores a duplicated path. Ultimately the maximum of possible typed costs is used, making this query cost `12`: + +```graphql +query { + node(id: "123") { # max(11, 12, 1) = 12 + id + ...on HasMetafields { # 1 + 10 = 11 + metafield(key: "a") { # 1 + value + } + metafields(first: 10) { # 10 + nodes { + value + } + } + } + ...on Product { # 1 + 11 from HasMetafields = 12 + title + metafield(key: "a") { # duplicated in HasMetafields + definition { # 1 + description + } + } + } + ...on PriceList { # 1 = 1 + name + catalog { # 1 + id + } + } + } +} ``` diff --git a/guides/queries/executing_queries.md b/guides/queries/executing_queries.md index ba3fcc16e05..5694afbb9dd 100644 --- a/guides/queries/executing_queries.md +++ b/guides/queries/executing_queries.md @@ -39,7 +39,7 @@ There are also several options you can use: - `variables:` provides values for `$`-named [query variables](https://graphql.org/learn/queries/#variables) - `context:` accepts application-specific data to pass to `resolve` functions - `root_value:` will be provided to root-level `resolve` functions as `obj` -- `operation_name:` picks a [named operation](https://graphql.org/learn/queries/#operation-name) from the incoming string to execute +- `operation_name:` picks a [named operation](https://graphql.org/learn/queries/#operation-type-and-name) from the incoming string to execute - `document:` accepts an already-parsed query (instead of a string), see {{ "GraphQL.parse" | api_doc }} - `validate:` may be `false` to skip static validation for this query - `max_depth:` and `max_complexity:` may override schema-level values @@ -65,7 +65,7 @@ variables = { "postId" => "1" } MySchema.execute(query_string, variables: variables) ``` -If the variable is a {{ "GraphQL::InputObjectType" | api_doc }}, you can provide a nested hash, for example: +If the variable is a {{ "GraphQL::Schema::InputObject" | api_doc }}, you can provide a nested hash, for example: ```ruby query_string = " @@ -111,8 +111,8 @@ MySchema.execute(query_string, context: context) Then, you can access those values during execution: ```ruby -field :post, Post, null: true do - argument :id, ID, required: true +field :post, Post do + argument :id, ID end def post(id:) @@ -123,6 +123,75 @@ end Note that `context` is _not_ the hash that you passed it. It's an instance of {{ "GraphQL::Query::Context" | api_doc }}, but it delegates `#[]`, `#[]=`, and a few other methods to the hash you provide. +### Scoped Context + +`context` is shared by the whole query. Anything you add to `context` will be accessible by any other field in the query (although GraphQL-Ruby's order of execution can vary). + +However, "scoped context" can be used to assign values into `context` that are only available in the current field and the _children_ of the current field. For example, in this query: + +```graphql +{ + posts { + comments { + author { + isOriginalPoster + } + } + } +} +``` + +You could use "scoped context" to implement `isOriginalPoster`, based on the parent `comments` field. + +{% callout warning %} + +Using scoped context may result in a violation of [the GraphQL specification](https://spec.graphql.org/draft/#sel-EABDLDFAACHAo3V) and +break normalized client stores, which assume that a given object always +has the same values for its fields. + +See ["Referencing ancestors breaks normalized stores"](https://benjie.dev/graphql/ancestors#breaks-normalized-stores) +for details about this pitfall and alternative approaches which avoid it. + +{% endcallout %} + +In `def comments`, add `:current_post` to scoped context using `context.scoped_set!`: + +```ruby +class Types::Post < Types::BaseObject + # ... + def comments + context.scoped_set!(:current_post, object) + object.comments + end +end +``` + +Then, inside `User` (assuming `author` resolves to `Types::User`), you can check `context[:current_post]`: + +```ruby +class Types::User < Types::BaseObject + # ... + def is_original_poster + current_post = context[:current_post] + current_post && current_post.author == object + end +end +``` + +`context[:current_post]` will be present if an "upstream" field assigned it with `scoped_set!`. + +`context.scoped_merge!({ ... })` is also available for setting multiple keys at once. + +**Note**: With batched data loading (eg, GraphQL-Batch), scoped context might not work because of GraphQL-Ruby's control flow jumps from one field to the next. In that case, use `scoped_ctx = context.scoped` to grab a scoped context reference _before_ calling a loader, then used `scoped_ctx.set!` or `scoped_ctx.merge!` to modify scoped context inside the promise body. For example: + +```ruby +# For use with GraphQL-Batch promises: +scoped_ctx = context.scoped +SomethingLoader.load(:something).then do |thing| + scoped_ctx.set!(:thing_name, thing.name) +end +``` + ## Root Value You can provide a root `object` value with `root_value:`. For example, to base the query off of the current organization: @@ -136,7 +205,7 @@ That value will be provided to root-level fields, such as mutation fields. For e ```ruby class Types::MutationType < GraphQL::Schema::Object - field :create_post, Post, null: true + field :create_post, Post def create_post(**args) object # => # @@ -145,4 +214,4 @@ class Types::MutationType < GraphQL::Schema::Object end ``` -{{ "GraphQL::Relay::Mutation" | api_doc }} fields will also receive `root_value:` as `obj` (assuming they're attached directly to your `MutationType`). +{{ "GraphQL::Schema::Mutation" | api_doc }} fields will also receive `root_value:` as `obj` (assuming they're attached directly to your `MutationType`). diff --git a/guides/queries/instrumentation.md b/guides/queries/instrumentation.md deleted file mode 100644 index cd8a316d243..00000000000 --- a/guides/queries/instrumentation.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: Instrumentation -layout: guide -doc_stub: false -search: true -section: Queries -desc: Wrap query execution with custom logic ---- - -You can call hooks _before_ and _after_ each query. Query instrumentation can be attached during schema definition: - -```ruby -class MySchema < GraphQL::Schema - instrument(:query, QueryTimerInstrumentation) -end -``` - -The instrumenter must implement `#before_query(query)` and `#after_query(query)`. The return values of these methods are not used. They receive the {{ "GraphQL::Query" | api_doc }} instance. - -```ruby -module QueryTimerInstrumentation - module_function - - # Log the time of the query - def before_query(query) - Rails.logger.info("Query begin: #{Time.now.to_i}") - end - - def after_query(query) - Rails.logger.info("Query end: #{Time.now.to_i}") - end -end -``` diff --git a/guides/queries/interpreter.md b/guides/queries/interpreter.md deleted file mode 100644 index 051813e74d3..00000000000 --- a/guides/queries/interpreter.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -title: Interpreter -layout: guide -doc_stub: false -search: true -section: Queries -desc: A New Runtime for GraphQL-Ruby -index: 11 ---- - -GraphQL-Ruby 1.9.0 includes a new runtime module which you may use for your schema. It is the default runtime since 1.12.0. - -It's called `GraphQL::Execution::Interpreter`, read on to learn more! - -## Rationale - -The new runtime was added to address a few specific concerns: - -- __Validation Performance__: The previous runtime depended on a preparation step (`GraphQL::InternalRepresentation::Rewrite`) which could be very slow in some cases. In many cases, the overhead of that step provided no value. -- __Runtime Performance__: For very large results, the previous runtime was slow because it allocated a new `ctx` object for every field, even very simple fields that didn't need any special tracking. -- __Extensibility__: Although the GraphQL specification supports custom directives, GraphQL-Ruby didn't have a good way to build them. - -## Installation - -In GraphQL-Ruby 1.12, the interpreter is installed __by default__. In older versions, you can opt in to the interpreter in your schema class: - -```ruby -class MySchema < GraphQL::Schema - # These are default in 1.12+: - use GraphQL::Execution::Interpreter - use GraphQL::Analysis::AST -end -``` - -Some Relay configurations must be updated too. For example: - -```diff -- field :node, field: GraphQL::Relay::Node.field -+ include GraphQL::Types::Relay::HasNodeField -``` - -(Alternatively, consider implementing `Query.node` in your own app, using `NodeField` as inspiration.) - -## Compatibility - -The new runtime works with class-based schemas only. Several features are no longer supported: - -- Proc-dependent field features: - - - Field Instrumentation - - Middleware - - Resolve procs - - `GraphQL::Function` - - All these depend on the memory- and time-hungry per-field `ctx` object. To improve performance, only method-based resolves are supported. If need something from `ctx`, you can get it with the `extras: [...]` configuration option. To wrap resolve behaviors, try {% internal_link "Field Extensions", "/type_definitions/field_extensions" %}, {% internal_link "Tracing", "/queries/tracing" %}, or {% internal_link "GraphQL::Schema::Resolver", "/fields/resolvers" %}. - -- Query analyzers and `irep_node`s - - These depend on the now-removed `Rewrite` step, which wasted a lot of time making often-unneeded preparation. Most of the attributes you might need from an `irep_node` are available with `extras: [...]`. Query analyzers can be refactored to be static checks (custom validation rules) or dynamic checks, made at runtime. The built-in analyzers have been refactored to run as validators. - - For a replacement, check out: - - - {{ "GraphQL::Execution::Lookahead" | api_doc }} for field-level info about child selections - - {{ "GraphQL::Analysis::AST" | api_doc }} for query analysis which is compatible with the new interpreter - -- `rescue_from` - - This was built on middleware, which is not supported anymore. For a replacement, see {% internal_link "Error Handling", "/errors/error_handling" %}. - -- `.graphql_definition` and `def to_graphql` - - The interpreter uses class-based schema definitions only, and never converts them to legacy GraphQL definition objects. Any custom definitions to GraphQL objects should be re-implemented on custom base classes. - -- `GraphQL::Schema::Field#resolve_field` - - If you customized your base field's resolution method, it needs an update. The interpreter calls a different method: `#resolve(obj, args, ctx)`. There are two differences with the new method: - - - `args` is plain ol' Ruby Hash, with symbol keys, instead of a `GraphQL::Query::Arguments` - - `ctx` is a `GraphQL::Query::Context` instead of a `GraphQL::Query::Context::FieldResolutionContext` - - But besides that, it's largely the same. - -Maybe this section should have been called _incompatibility_ 🤔. - -## Extending the Runtime - -See {% internal_link "Directives", "/type_definitions/directives" %}. - -## Analyzers - -GraphQL-Ruby has "analyzers" that run _before_ execution and may reject a query. With the interpreter, you can use {% internal_link "AST Analyzers", "/queries/ast_analysis" %} to get better performance. - -To make the migration, convert your previous analyzers to extend {{ "GraphQL::Analysis::AST::Analyzer" | api_doc }} as described in the guide, then add to your schema: - -```ruby -use GraphQL::Analysis::AST -``` - -When you use _both_ `Interpreter` and `Analysis::AST`, GraphQL-Ruby will skip the slow process of building `irep_nodes`. - -All analyzers must be migrated at once; running _some_ legacy analyzers and _some_ AST analyzers is not supported. - -In GraphQL-Ruby 1.9, you can migrate to `Interpreter` before migrating to `Analysis::AST`. In that case, the `irep_node` tree will still be constructed and used for analysis, even though it will not be used for execution. - -In GraphQL-Ruby 1.10+, `Interpreter` _requires_ `Analysis::AST` and will not work without it. (Soon, these will be the default runtime modules.) - -## Implementation Notes - -Instead of a tree of `irep_nodes`, the interpreter consumes the AST directly. This removes a complicated concept from GraphQL-Ruby (`irep_node`s) and simplifies the query lifecycle. The main difference relates to how fragment spreads are resolved. In the previous runtime, the possible combinations of fields for a given object were calculated ahead of time, then some of those combinations were used during runtime, but many of them may not have been. In the new runtime, no precalculation is made; instead each object is checked against each fragment at runtime. - -Instead of creating a `GraphQL::Query::Context::FieldResolutionContext` for _every_ field in the response, the interpreter uses long-lived, mutable objects for execution bookkeeping. This is more complicated to manage, since the changes to those objects can be hard to predict, but it's worth it for the performance gain. When needed, those bookkeeping objects can be "forked", so that two parts of an operation can be resolved independently. - -Instead of calling `.to_graphql` internally to convert class-based definitions to `.define`-based definitions, the interpreter operates on class-based definitions directly. This simplifies the workflow for creating custom configurations and using them at runtime. diff --git a/guides/queries/logging.md b/guides/queries/logging.md new file mode 100644 index 00000000000..f50fb61cfcb --- /dev/null +++ b/guides/queries/logging.md @@ -0,0 +1,22 @@ +--- +layout: guide +doc_stub: false +search: true +section: Queries +title: Logging +desc: Development output from GraphQL-Ruby +index: 12 +--- + +At runtime, GraphQL-Ruby will output debug information using {{ "GraphQL::Query#logger" | api_doc }}. By default, this uses `Rails.logger`. To see output, make sure `config.log_level = :debug` is set. (This information isn't meant for production logs.) + +You can configure a custom logger with {{ "GraphQL::Schema.default_logger" | api_doc }}, for example: + +```ruby +class MySchema < GraphQL::Schema + # This logger will be used by queries during execution: + default_logger MyCustomLogger.new +end +``` + +You can also pass `context[:logger]` to provide a logger during execution. diff --git a/guides/queries/lookahead.md b/guides/queries/lookahead.md index 2ea1b913320..8a5541d4851 100644 --- a/guides/queries/lookahead.md +++ b/guides/queries/lookahead.md @@ -12,7 +12,7 @@ GraphQL-Ruby 1.9+ includes {{ "GraphQL::Execution::Lookahead" | api_doc }} for c ## Getting a Lookahead -Add `extras: [:lookahead]` to your field configuration to recieve an injected lookahead: +Add `extras: [:lookahead]` to your field configuration to receive an injected lookahead: ```ruby field :files, [Types::File], null: false, extras: [:lookahead] @@ -94,3 +94,32 @@ end ``` That way, you can check for specific selections on the nodes in a connection. + +## Lookaheads with aliases + +If you want to find selection by its [alias](https://spec.graphql.org/June2018/#sec-Field-Alias), you can use `#alias_selection(...)` or check if it exists with `#selects_alias?`. In this case, the lookahead will check if there is a field with the provided alias. + + +For example, this query can find a bird species by its name: + +```graphql +query { + gull: findBirdSpecies(byName: "Laughing Gull") { + name + } + + tanager: findBirdSpecies(byName: "Scarlet Tanager") { + name + } +} +``` + +You can get the lookahead for each selection in a following way: + +```ruby +def find_bird_species(by_name:, lookahead:) + if lookahead.selects_alias?("gull") + lookahead.alias_selection("gull") + end +end +``` diff --git a/guides/queries/multiplex.md b/guides/queries/multiplex.md index f209799f9af..9e3d1301485 100644 --- a/guides/queries/multiplex.md +++ b/guides/queries/multiplex.md @@ -8,11 +8,11 @@ desc: Run multiple queries concurrently index: 10 --- -Some clients may send _several_ queries to the server at once (for example, [Apollo Client's query batching](https://www.apollographql.com/docs/react/api/link/apollo-link-batch-http/)). You can execute them concurrently with {{ "Schema#multiplex" | api_doc }}. +Some clients may send _several_ queries to the server at once (for example, [Apollo Client's query batching](https://www.apollographql.com/docs/react/api/link/apollo-link-batch-http/)). You can execute them concurrently with {{ "Schema.multiplex" | api_doc }}. Multiplex runs have their own context, analyzers and instrumentation. -__NOTE:__ As an implementation detail, _all_ queries are run inside multiplexes. That is, a stand-alone query is executed as a "multiplex of one", so instrumentation and multiplex analyzers and instrumentation _will_ apply to standalone queries run with `MySchema.execute(...)`. +__NOTE:__ As an implementation detail, _all_ queries run inside multiplexes. That is, a stand-alone query is executed as a "multiplex of one", so instrumentation and multiplex analyzers and tracers _will_ apply to standalone queries run with `MySchema.execute(...)`. ## Concurrent Execution @@ -41,7 +41,7 @@ queries = [ ] ``` -Then, pass them to `Schema#multiplex`: +Then, pass them to `Schema.multiplex`: ```ruby results = MySchema.multiplex(queries) @@ -84,7 +84,7 @@ end ## Validation and Error Handling -Each query is validated and {% internal_link "analyzed","/queries/ast_analysis" %} independently. The `results` array may include a mix of successful results and failed results +Each query is validated and {% internal_link "analyzed","/queries/ast_analysis" %} independently. The `results` array may include a mix of successful results and failed results. ## Multiplex-Level Context @@ -111,22 +111,20 @@ The API is the same as {% internal_link "query analyzers","/queries/ast_analysis Multiplex analyzers may return {{ "AnalysisError" | api_doc }} to halt execution of the whole multiplex. -## Multiplex Instrumentation +## Multiplex Tracing -You can add hooks for each multiplex run with multiplex instrumentation. +You can add hooks for each multiplex run with {% internal_link "trace modules", "/queries/tracing" %}. -An instrumenter must implement `.before_multiplex(multiplex)` and `.after_multiplex(multiplex)`. Then, it can be mounted with `instrument(:multiplex, MyMultiplexAnalyzer)`. See {{ "Execution::Multiplex" | api_doc }} for available methods. +The trace module may implement `def execute_multiplex(multiplex:)` which calls `super` to allow the multiplex to execute. See {{ "Execution::Multiplex" | api_doc }} for available methods. For example: ```ruby # Count how many queries are in the multiplex run: module MultiplexCounter - def self.before_multiplex(multiplex) + def execute_multiplex(multiplex:) Rails.logger.info("Multiplex size: #{multiplex.queries.length}") - end - - def self.after_multiplex(multiplex) + super end end @@ -134,8 +132,8 @@ end class MySchema < GraphQL::Schema # ... - instrument(:multiplex, MultiplexCounter) + trace_with(MultiplexCounter ) end ``` -Now, `MultiplexCounter.before_multiplex` will be called before each multiplex and `.after_multiplex` will run after each multiplex. +Now, `MultiplexCounter#execute_multiplex` will be called for each execution, logging the size of each multiplex. diff --git a/guides/queries/new_relic_example.png b/guides/queries/new_relic_example.png deleted file mode 100644 index 0d23e74c1d4..00000000000 Binary files a/guides/queries/new_relic_example.png and /dev/null differ diff --git a/guides/queries/perfetto_example.png b/guides/queries/perfetto_example.png new file mode 100644 index 00000000000..eba506ce4e4 Binary files /dev/null and b/guides/queries/perfetto_example.png differ diff --git a/guides/queries/phases_of_execution.md b/guides/queries/phases_of_execution.md index c6ad6a5f371..e75989a3ea0 100644 --- a/guides/queries/phases_of_execution.md +++ b/guides/queries/phases_of_execution.md @@ -15,4 +15,4 @@ When GraphQL receives a query string, it goes through these steps: - Validate: {{ "GraphQL::StaticValidation::Validator" | api_doc }} validates the incoming AST as a valid query for the schema - Analyze: If there are any query analyzers, they are run with {{ "GraphQL::Analysis.analyze_query" | api_doc }} - Execute: The query is traversed, `resolve` functions are called and the response is built -- Respond: The response is returned as a Hash +- Respond: The response is returned as a {{ "GraphQL::Query::Result" | api_doc }} diff --git a/guides/queries/response_extensions.md b/guides/queries/response_extensions.md new file mode 100644 index 00000000000..5602de450fe --- /dev/null +++ b/guides/queries/response_extensions.md @@ -0,0 +1,37 @@ +--- +title: Response Extensions +layout: guide +doc_stub: false +search: true +section: Queries +desc: Adding "extensions" to the response hash +index: 12 +--- + +During query execution, you can add to the response's `"extensions" => { ... }` Hash. By default, no `"extensions"` key is present in the result, but if you call the method below, it will be present with the given values. + +To add to `"extensions"`, call `context.response_extensions[key] = value` during execution. For example: + +```ruby +field :to_dos, [ToDo] + +def to_dos + warnings = context.response_extensions["warnings"] ||= [] + warnings << "To-Dos will be disabled on Jan. 31, 2022." + context[:current_user].deprecated_to_dos +end +``` + + +That would add to the final query response: + +```ruby +{ + "data" => { ... }, + "extensions" => { + "warnings" => ["To-Dos will be disabled on Jan. 31, 2022"], + }, +} +``` + +Values written to `context.response_extensions` are added to the GraphQL response verbatim. diff --git a/guides/queries/scout_example.png b/guides/queries/scout_example.png deleted file mode 100644 index 94be73a5064..00000000000 Binary files a/guides/queries/scout_example.png and /dev/null differ diff --git a/guides/queries/skylight_example.png b/guides/queries/skylight_example.png deleted file mode 100755 index 94505b5218b..00000000000 Binary files a/guides/queries/skylight_example.png and /dev/null differ diff --git a/guides/queries/timeout.md b/guides/queries/timeout.md index 1d3353dc84e..d72c9a7d890 100644 --- a/guides/queries/timeout.md +++ b/guides/queries/timeout.md @@ -20,7 +20,7 @@ After `max_seconds`, no new fields will be resolved. Instead, errors will be add __Note__ that this _does not interrupt_ field execution (doing so is [buggy](https://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/)). If you're making external calls (eg, HTTP requests or database queries), make sure to use a library-specific timeout for that operation (eg, [Redis timeout](https://github.com/redis/redis-rb#timeouts), [Net::HTTP](https://ruby-doc.org/stdlib-2.4.1/libdoc/net/http/rdoc/Net/HTTP.html)'s `ssl_timeout`, `open_timeout`, and `read_timeout`). -### Custom Error Handling +## Custom Error Handling To log the error, provide a subclass of `GraphQL::Schema::Timeout` with an overridden `handle_timeout` method: @@ -36,7 +36,7 @@ class MySchema < GraphQL::Schema end ``` -### Customizing the Timeout Window +## Customizing the Timeout Window To dynamically pick a timeout duration (or bypass it), override {{ "GraphQL::Schema::Timeout#max_seconds" | api_doc }} in your subclass. To bypass the timeout altogether, `max_seconds` can return `false`. @@ -63,18 +63,25 @@ class MySchema < GraphQL::Schema end ``` -### Validation +## Validation and Analysis Queries can originate from a user, and may be crafted in a manner to take a long time to validate against the schema. -It is possible to limit how many seconds the static validation rules are allowed to run before returning a validation timeout error. The default is no timeout. +It is possible to limit how many seconds the static validation rules and analysers are allowed to run before returning a validation timeout error. By default, validation and query analysis have a 3-second timeout. You can customize this timeout or disable it completely: For example: ```ruby +# Customize timeout (in seconds) class MySchema < GraphQL::Schema + # Applies to static validation and query analysis validate_timeout 10 end + +# OR disable timeout completely +class MySchema < GraphQL::Schema + validate_timeout nil +end ``` **Note:** This configuration uses Ruby's built-in `Timeout` API, which can interrupt IO calls mid-flight, resulting in [very weird bugs](https://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/). None of GraphQL-Ruby's validators make IO calls but if you want to use this configuration and you have custom static validators that make IO calls, open an issue to discuss implementing this in an IO-safe way. diff --git a/guides/queries/tracing.md b/guides/queries/tracing.md index e82205e6aad..25612676bc9 100644 --- a/guides/queries/tracing.md +++ b/guides/queries/tracing.md @@ -6,203 +6,55 @@ search: true section: Queries desc: Observation hooks for execution index: 11 +redirect_from: + - /queries/instrumentation --- -{{ "GraphQL::Tracing" | api_doc }} provides a `.trace` hook to observe events from the GraphQL runtime. - -A tracer must implement `.trace`, for example: +{{ "GraphQL::Tracing::Trace" | api_doc }} provides hooks to observe and modify events during runtime. Tracing hooks are methods, defined in modules and mixed in with {{ "Schema.trace_with" | api_doc }}. ```ruby -class MyCustomTracer - def self.trace(key, data) - # do stuff with key & data - yield +module CustomTrace + def parse(query_string:) + # measure, log, etc + super end -end -``` - -`.trace` is called with: - -- `key`: the event happening in the runtime -- `data`: a hash of metadata about the event -- `&block`: the event itself, it must be `yield`ed and the value must be returned - -To run a tracer for __every query__, add it to the schema with `tracer`: - -```ruby -# Run `MyCustomTracer` for all queries -class MySchema < GraphQL::Schema - tracer(MyCustomTracer) -end -``` - -Or, to run a tracer for __one query only__, add it to `context:` as `tracers: [...]`, for example: - -```ruby -# Run `MyCustomTracer` for this query -MySchema.execute(..., context: { tracers: [MyCustomTracer]}) -``` - -For a full list of events, see the {{ "GraphQL::Tracing" | api_doc }} API docs. - -## ActiveSupport::Notifications - -You can emit events to `ActiveSupport::Notifications` with an experimental tracer, `ActiveSupportNotificationsTracing`. - -To enable it, install the tracer: - -```ruby -# Send execution events to ActiveSupport::Notifications -class MySchema < GraphQL::Schema - tracer(GraphQL::Tracing::ActiveSupportNotificationsTracing) -end -``` - -## Monitoring - -Several monitoring platforms are supported out-of-the box by GraphQL-Ruby (see platforms below). - -Leaf fields are _not_ monitored (to avoid high cardinality in the metrics service). - -Implementations are based on {{ "Tracing::PlatformTracing" | api_doc }}. - -## AppOptics - -[AppOptics](https://appoptics.com/) instrumentation will be automatic starting -with appoptics_apm-4.11.0.gem. For earlier gem versions please add appoptics_apm -tracing as follows: - -```ruby -require 'appoptics_apm' - -class MySchema < GraphQL::Schema - use(GraphQL::Tracing::AppOpticsTracing) -end -``` -
- {{ "/queries/appoptics_example.png" | link_to_img:"appoptics monitoring" }} -
- -## Appsignal - -To add [AppSignal](https://appsignal.com/) instrumentation: - -```ruby -class MySchema < GraphQL::Schema - use(GraphQL::Tracing::AppsignalTracing) -end -``` -
- {{ "/queries/appsignal_example.png" | link_to_img:"appsignal monitoring" }} -
- -## New Relic - -To add [New Relic](https://newrelic.com/) instrumentation: - -```ruby -class MySchema < GraphQL::Schema - use(GraphQL::Tracing::NewRelicTracing) - # Optional, use the operation name to set the new relic transaction name: - # use(GraphQL::Tracing::NewRelicTracing, set_transaction_name: true) + # ... end ``` - -
- {{ "/queries/new_relic_example.png" | link_to_img:"new relic monitoring" }} -
- -## Scout - -To add [Scout APM](https://scoutapp.com/) instrumentation: +To include a trace module when running queries, add it to the schema with `trace_with`: ```ruby +# Run `MyCustomTrace` for all queries class MySchema < GraphQL::Schema - use(GraphQL::Tracing::ScoutTracing) + trace_with(MyCustomTrace) end ``` -
- {{ "/queries/scout_example.png" | link_to_img:"scout monitoring" }} -
- -## Skylight - -To add [Skylight](https://www.skylight.io) instrumentation, you may either enable the [GraphQL probe](https://www.skylight.io/support/getting-more-from-skylight#graphql) or use [ActiveSupportNotificationsTracing](/queries/tracing.html#activesupportnotifications). - -```ruby -# config/application.rb -config.skylight.probes << "graphql" -``` - -
- {{ "/queries/skylight_example.png" | link_to_img:"skylight monitoring" }} -
+For a full list of methods and their arguments, see {{ "GraphQL::Tracing::Trace" | api_doc }}. -GraphQL instrumentation for Skylight is available in versions >= 4.2.0. +By default, GraphQL-Ruby makes a new trace instance when it runs a query. You can pass an existing instance as `context: { trace: ... }`. Also, `GraphQL.parse( ..., trace: ...)` accepts a trace instance. -## Datadog +## Detailed Traces -To add [Datadog](https://www.datadoghq.com) instrumentation: +You can capture detailed traces of query execution with {{ "Tracing::DetailedTrace" | api_doc }}. They can be viewed in Google's [Perfetto Trace Viewer](https://ui.perfetto.dev). They include a per-Fiber breakdown with links between fields and Dataloader sources. -```ruby -class MySchema < GraphQL::Schema - use(GraphQL::Tracing::DataDogTracing, options) -end -``` - -You may provide `options` as a `Hash` with the following values: - -| Key | Description | Default | -| --- | ----------- | ------- | -| `analytics_enabled` | Enable analytics for spans. `true` for on, `nil` to defer to Datadog global setting, `false` for off. | `false` | -| `analytics_sample_rate` | Rate which tracing data should be sampled for Datadog analytics. Must be a float between `0` and `1.0`. | `1.0` | -| `service` | Service name used for `graphql` instrumentation | `'ruby-graphql'` | -| `tracer` | `Datadog::Tracer` used to perform instrumentation. Usually you don't need to set this. | `Datadog.tracer` | +{{ "/queries/perfetto_example.png" | link_to_img:"GraphQL-Ruby Dataloader Perfetto Trace" }} -For more details about Datadog's tracing API, check out the [Ruby documentation](https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md) or the [APM documentation](https://docs.datadoghq.com/tracing/) for more product information. +Learn how to set it up in the {{ "Tracing::DetailedTrace" | api_doc }} docs. -## Prometheus +## External Monitoring Platforms -To add [Prometheus](https://prometheus.io) instrumentation: - -```ruby -require 'prometheus_exporter/client' - -class MySchema < GraphQL::Schema - use(GraphQL::Tracing::PrometheusTracing) -end -``` - -The PrometheusExporter server must be run with a custom type collector that extends -`GraphQL::Tracing::PrometheusTracing::GraphQLCollector`: - -```ruby -# lib/graphql_collector.rb - -require 'graphql/tracing' - -class GraphQLCollector < GraphQL::Tracing::PrometheusTracing::GraphQLCollector -end -``` - -```sh -bundle exec prometheus_exporter -a lib/graphql_collector.rb -``` - -## Statsd - -You can add Statsd instrumentation by initializing a statsd client and passing it to {{ "GraphQL::Tracing::StatsdTracing" | api_doc }}: - -```ruby -$statsd = Statsd.new 'localhost', 9125 -# ... - -class MySchema < GraphQL::Schema - use GraphQL::Tracing::StatsdTracing, statsd: $statsd -end -``` +There integrations for GraphQL-Ruby with several other monitoring systems: -Any Statsd client that implements `.time(name) { ... }` will work. +- `ActiveSupport::Notifications`: See {{ "Tracing::ActiveSupportNotificationsTrace" | api_doc }}. +- [AppOptics](https://appoptics.com/) instrumentation is automatic in `appoptics_apm` v4.11.0+. +- [AppSignal](https://appsignal.com/): See {{ "Tracing::AppsignalTrace" | api_doc }}. +- [Datadog](https://www.datadoghq.com): See {{ "Tracing::DataDogTrace" | api_doc }}. +- [NewRelic](https://newrelic.com/): See {{ "Tracing::NewRelicTrace" | api_doc }}. +- [Prometheus](https://prometheus.io): See {{ "Tracing::PrometheusTrace" | api_doc }}. +- [Scout APM](https://www.scoutapm.com/): See {{ "Tracing::ScoutTrace" | api_doc }}. +- [Sentry](https://sentry.io): See {{ "Tracing::SentryTrace" | api_doc }}. +- [Skylight](https://www.skylight.io): either enable the [GraphQL probe](https://www.skylight.io/support/getting-more-from-skylight#graphql) or use {{ "Tracing::ActiveSupportNotificationsTrace" | api_doc }}. +- Statsd: See {{ "Tracing::StatsdTrace" | api_doc }}. diff --git a/guides/related_projects.md b/guides/related_projects.md index 7f3637a32f8..dff3e99c646 100644 --- a/guides/related_projects.md +++ b/guides/related_projects.md @@ -15,10 +15,10 @@ Want to add something? Please open a pull request [on GitHub](https://github.com - `graphql-ruby` + Sinatra demo ([src](https://github.com/robinjmurphy/ruby-graphql-server-example) / [heroku](https://ruby-graphql-server-example.herokuapp.com/)) - [`graphql-batch`](https://github.com/shopify/graphql-batch), a batched query execution strategy - [`graphql-cache`](https://github.com/stackshareio/graphql-cache), a resolver-level caching solution -- [`graphql-libgraphqlparser`](https://github.com/rmosolgo/graphql-libgraphqlparser-ruby), bindings to [libgraphqlparser](https://github.com/graphql/libgraphqlparser), a C-level parser. - [`graphql-devise`](https://github.com/graphql-devise/graphql_devise), a gql interface to handle authentication with Devise - [`graphql-docs`](https://github.com/gjtorikian/graphql-docs), a tool to automatically generate static HTML documentation from your GraphQL implementation - [`graphql-metrics`](https://github.com/Shopify/graphql-metrics), a plugin to extract fine-grain metrics of GraphQL queries received by your server +- [`graphql-stitching`](https://github.com/gmac/graphql-stitching-ruby), tools to combine multiple local and remote schemas into a single graph that queries as one - [`graphql-groups`](https://github.com/hschne/graphql-groups), a DSL to define group- and aggregation queries with graphql-ruby - Rails Helpers: - [`graphql-activerecord`](https://github.com/goco-inc/graphql-activerecord) @@ -26,6 +26,8 @@ Want to add something? Please open a pull request [on GitHub](https://github.com - [`graphql-query-resolver`](https://github.com/nettofarah/graphql-query-resolver), a graphql-ruby add-on to minimize N+1 queries. - [`graphql-rails_logger`](https://github.com/jetruby/graphql-rails_logger), a logger which allows you to inspect GraphQL queries in a more readable format. - [`apollo_upload_server-ruby`](https://github.com/jetruby/apollo_upload_server-ruby), a middleware which allows you to upload files with GraphQL and multipart/form-data using [`apollo-upload-client`](https://github.com/jaydenseric/apollo-upload-client) library on front-end. + - [`graphql-sources`](https://github.com/ksylvest/graphql-sources) a collection of common GraphQL [sources](https://graphql-ruby.org/dataloader/sources.html) to simplify using `ActiveRecord`, `ActiveStorage`, `Rails.cache`, and more. + - [`graphql-filters`](https://github.com/moku-io/graphql-filters), a DSL to define fully typed filters for list fields. - [`search_object_graphql`](https://github.com/rstankov/SearchObjectGraphQL), a DSL for defining search resolvers for GraphQL. - [`action_policy-graphql`](https://github.com/palkan/action_policy-graphql), an integration for using [`action_policy`](https://github.com/palkan/action_policy) as an authorization framework for GraphQL applications. - [`graphql_rails`](https://github.com/samesystem/graphql_rails), Rails way GraphQL build tool @@ -33,16 +35,13 @@ Want to add something? Please open a pull request [on GitHub](https://github.com - [`graphql-ruby-fragment_cache`](https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache), a tool for caching response fragments. - [`graphql-ruby-persisted_queries`](https://github.com/DmitryTsepelev/graphql-ruby-persisted_queries), the implementation of [Apollo persisted queries](https://github.com/apollographql/apollo-link-persisted-queries). - [`rubocop-graphql`](https://github.com/DmitryTsepelev/rubocop-graphql), [rubocop](https://github.com/rubocop-hq/rubocop) extension for enforcing best practices. -- [`apollo-federation-ruby`](https://github.com/Gusto/apollo-federation-ruby), a Ruby implementation of Apollo Federation. +- [`apollo-federation-ruby`](https://github.com/Gusto/apollo-federation-ruby), a Ruby implementation of the Apollo Federation [subgraph spec](https://www.apollographql.com/docs/federation/subgraph-spec/). ## Blog Posts - Building a blog in GraphQL and Relay on Rails [Introduction](https://medium.com/@gauravtiwari/graphql-and-relay-on-rails-getting-started-955a49d251de), [Part 1]( https://medium.com/@gauravtiwari/graphql-and-relay-on-rails-creating-types-and-schema-b3f9b232ccfc), [Part 2](https://medium.com/@gauravtiwari/graphql-and-relay-on-rails-first-relay-powered-react-component-cb3f9ee95eca) - https://medium.com/@khor/relay-facebook-on-rails-8b4af2057152 - https://blog.jacobwgillespie.com/from-rest-to-graphql-b4e95e94c26b#.4cjtklrwt -- http://mgiroux.me/2015/getting-started-with-rails-graphql-relay/ -- http://mgiroux.me/2015/uploading-files-using-relay-with-rails/ -- http://mgiroux.me/2016/journey-into-graphql-ruby-query-execution/ - https://jonsimpson.ca/parallel-graphql-resolvers-with-futures/ - Active Storage meets GraphQL: [Direct uploads](https://evilmartians.com/chronicles/active-storage-meets-graphql-direct-uploads) and [Exposing attachment URLs](https://evilmartians.com/chronicles/active-storage-meets-graphql-pt-2-exposing-attachment-urls) - [Exposing permissions in GraphQL APIs with Action Policy](https://evilmartians.com/chronicles/exposing-permissions-in-graphql-apis-with-action-policy) diff --git a/guides/schema/class_based_api.md b/guides/schema/class_based_api.md deleted file mode 100644 index a4c8bb162f5..00000000000 --- a/guides/schema/class_based_api.md +++ /dev/null @@ -1,310 +0,0 @@ ---- -layout: guide -doc_stub: false -search: true -section: Schema -title: Class-based API Migration -desc: Migrate from legacy .define DSL to Ruby classes. -index: 6 ---- - -In GraphQL `1.8`+, you can use Ruby classes to build your schema. You can __mix__ class-style and `.define`-style type definitions in a schema. - -The `.define` DSL is deprecated and will be removed at version 2.0. - -You can get an overview of this new feature: - -- [Rationale & Goals](#rationale--goals) -- [Compatibility & Migration Overview](#compatibility--migration-overview) -- [Using the upgrader](#upgrader) -- [Roadmap](#roadmap) - -And learn about the APIs: - -- {% internal_link "Schema class", "/schema/definition" %} -- [Common type configurations](#common-type-configurations) (shared by all the following types) -- {% internal_link "Object classes", "/type_definitions/objects" %} -- {% internal_link "Interface classes", "/type_definitions/interfaces" %} -- {% internal_link "Union classes", "/type_definitions/unions" %} -- {% internal_link "Enum classes", "/type_definitions/enums" %} -- {% internal_link "Input Object classes", "/type_definitions/input_objects" %} -- {% internal_link "Scalar classes", "/type_definitions/scalars" %} -- {% internal_link "Customizing definitions", "/type_definitions/extensions" %} -- {% internal_link "Custom introspection", "/schema/introspection" %} - -## Rationale & Goals - -This new API aims to improve the "getting started" experience and the schema customization experience by replacing GraphQL-Ruby-specific DSLs with familiar Ruby semantics (classes and methods). - -Additionally, this new API must be cross-compatible with the current schema definition API so that it can be adopted bit-by-bit. - -## Compatibility & Migration overview - -Parts of your schema can be converted one-by-one, so you can convert definitions gradually. - -### Classes - -In general, each `.define { ... }` block will be converted to a class. - -- Instead of a `GraphQL::{X}Type`, classes inherit from `GraphQL::Schema::{X}`. For example, instead of `GraphQL::ObjectType.define { ... }`, a definition is made by extending `GraphQL::Schema::Object` -- Any class hierarchy is supported; It's recommended to create a base class for your application, then extend the base class for each of your types (like `ApplicationController` in Rails, see [Customizing Definitions](#customizing-defintions)). - -See sections below for specific information about each schema definition class. - -### Note: Finding legacy members -As of https://github.com/DmitryTsepelev/rubocop-graphql/pull/39, [rubocop-graphql](https://github.com/DmitryTsepelev/rubocop-graphql) supports a rule to find legacy DSL classes! You can install the gem and require it in your `.rubocop.yml` file. - -An example command to find violating files would then be: - -```$ rubocop --format files --only GraphQL/LegacyDsl app >> violating_files.txt``` - -### ⚠️ Heads up ⚠️ - -Keep in mind that class based Schemas will be initialized at execution time instead of application boot, depending on the size of your schema, this could result in request timeouts for your users after your application restarts. For a workaround please check https://github.com/rmosolgo/graphql-ruby/issues/2034 - -### Type Instances - -The previous `GraphQL::{X}Type` objects are still used under the hood. Each of the new `GraphQL::Schema::{X}` classes implements a few methods: - -- `.to_graphql`: creates a new instance of `GraphQL::{X}Type` -- `.graphql_definition`: returns a cached instance of `GraphQL::{X}Type` - -If you have custom code which breaks on new-style definitions, try calling `.graphql_definition` to get the underlying type object. - -As described below, `.to_graphql` can be overridden to customize the type system. - -### List Types and Non-Null Types - -Previously, list types were expressed with `types[T]` and non-null types were expressed with `!T`. Now: - -- List types are expressed with Ruby Arrays, `[T]`, for example, `field :owners, [Types::UserType]` - - By default, list members are _non-null_, for example, `[Types::UserType]` becomes `[User!]` - - If your list members may be null, add `, null: true` to the array: `[Types::UserType, null: true]` becomes `[User]` (the list may include `nil`) -- Non-null types are expressed with keyword arguments `null:` or `required:` - - `field` takes a keyword `null:`. `null: true` means the field is nullable, `null: false` means the field is non-null (equivalent to `!`) - - `argument` takes a keyword `required:`. `required: true` means the argument is non-null (equivalent to `!`), `required: false` means that the argument is nullable - -In legacy-style classes, you may also use plain Ruby methods to create list and non-null types: - -- `#to_non_null_type` converts a type to a non-null variant (ie, `T.to_non_null_type` is equivalent to `!T`) -- `#to_list_type` converts a type to a list variant (ie, `T.to_list_type` is equivalent to `types[T]`) - -The `!` method has been removed to avoid ambiguity with the built-in logical operator and related foot-gunning. - -For compatibility, you may wish to backport `!` to class-based type definitions. You have two options: - -__A refinement__, activated in [file scope or class/module scope](https://docs.ruby-lang.org/en/2.4.0/syntax/refinements_rdoc.html#label-Scope): - -```ruby -# Enable `!` method in this scope -using GraphQL::DeprecatedDSL -``` - -__A monkeypatch__, activated in global scope: - -```ruby -# Enable `!` everywhere -GraphQL::DeprecatedDSL.activate -``` - -### Connection fields & types - -There is no `connection(...)` method. Instead, connection fields are inferred from the type name. - -If the type name ends in `Connection`, the field is treated as a connection field. - -This default may be overridden by passing a `connection: true` or `connection: false` keyword. - -For example: - -```ruby -# This will be treated as a connection, since the type name ends in "Connection" -field :projects, Types::ProjectType.connection_type -``` - -### Resolve function compatibility - -If you define a type with a class, you can use existing GraphQL-Ruby resolve functions with that class, for example: - -```ruby -# Using a Proc literal or #call-able -field :something, ... resolve: ->(obj, args, ctx) { ... } -# Using a predefined field -field :do_something, field: Mutations::DoSomething.field -# Using a GraphQL::Function -field :something, function: Functions::Something.new -``` - -When using these resolution implementations, they will be called with the same `(obj, args, ctx)` parameters as before. - -## Upgrader - -`1.8` includes an _auto-upgrader_ for transforming Ruby files from the `.define`-based syntax to `class`-based syntax. The upgrader is a pipeline of sequential transform operations. It ships with default pipelines, but you may customize the upgrade process by replacing the built-in pipelines with a custom ones. - -The upgrader has an additional dependency, `parser`, which you must add to your project manually (for example, by adding to your `Gemfile`). - -Remember that your project may be transformed one file at a time because the two syntaxes are compatible. This way, you can convert a few files and run your tests to identify outstanding issues, and continue working incrementally. - -This transformation may not be perfect, but it should cover the most common cases. If you want to ask a question or report a bug, please {% open_an_issue "Upgrader question/bug report","Please share: the source code you're trying to transform, the output you got from the transformer, and the output you want to get from the transformer." %}. - -### Using the Default Upgrade Task - -The upgrader ships with rake tasks, included as a railtie ([source](https://github.com/rmosolgo/graphql-ruby/blob/v1.8.0/lib/graphql/railtie.rb)). The railtie will be automatically installed by your Rails app, and it provides the following tasks: - -- `graphql:upgrade:schema[path/to/schema.rb]`: upgrade the Schema file -- `graphql:upgrade:member[path/to/some/type.rb]`: upgrade a type definition (object, interface, union, etc) -- `graphql:upgrade[app/graphql/**/*]`: run the `member` upgrade on files which have a suffix of `_(type|interface|enum|union).rb` -- `graphql:upgrade:create_base_objects[path/to/graphql/]`: add base classes to your project - -### Writing a Custom Upgrade Task - -You might write a custom task because: - -- You want to customize the transformation pipeline -- You're not using Rails, so a railtie won't work - -To write a custom task, you can write a rake task (or Ruby script) which uses the upgrader's API directly. - -Here's the code to upgrade a type definition with the default transform pipeline: - -```ruby -# Read the original source code into a string -original_source = File.read("path/to/type.rb") -# Initialize an upgrader with the default transforms -upgrader = GraphQL::Upgrader::Member.new(original_source) -# Perform the transformation, get the transformed source code -transformed_source = upgrader.upgrade -# Update the source file with the new code -File.write("path/to/type.rb", transformed_source) -``` - -In this custom code, you can pass some keywords to {{ "GraphQL::Upgrader::Member.new" | api_doc }}: - -- `type_transforms:` Applied to the source code as a whole, applied first -- `field_transforms:` Applied to each field/connection/argument definition (extracted from the source, transformed independently, then re-inserted) -- `clean_up_transforms:` Applied to the source code as a whole, _after_ the type and field transforms - -Keep in mind that these transforms are performed in sequence, so the text changes over time. If you want to transform the source text, use `.unshift()` to add transforms to the _beginning_ of the pipeline instead of the end. - -For example, in `script/graphql-upgrade`: - -```ruby -#!/usr/bin/env ruby - -# @example Upgrade app/graphql/types/user_type.rb: -# script/graphql-upgrade app/graphql/types/user_type.rb - -# Replace the default define-to-class transform with a custom one: -type_transforms = GraphQL::Upgrader::Member::DEFAULT_TYPE_TRANSFORMS.map { |t| - if t == GraphQL::Upgrader::TypeDefineToClassTransform - GraphQL::Upgrader::TypeDefineToClassTransform.new(base_class_pattern: "Platform::\\2s::Base") - else - t - end -} - -# Add this transformer at the beginning of the list: -type_transforms.unshift(GraphQL::Upgrader::ConfigurationToKwargTransform.new(kwarg: "visibility")) - -# run the upgrader -original_text = File.read(ARGV[0]) -upgrader = GraphQL::Upgrader::Member.new(original_text, type_transforms: type_transforms) -transformed_text = upgrader.upgrade -File.write(filename, transformed_text) -``` - -### Writing a custom transformer - -Objects in the transform pipeline may be: - -- A class which responds to `.new.apply(input_text)` and returns the transformed code -- An object which responds to `.apply(input_text)` and returns the transformed code - -The library provides a {{ "GraphQL::Upgrader::Transform" | api_doc }} base class with a few convenience methods. You can also customize the built-in transformers listed below. - -For example, here's a transform which rewrites type definitions from a `model_type(model) do ... end` factory method to the class-based syntax: - -```ruby -# Create a custom transform for our `model_type` factory: -class ModelTypeToClassTransform < GraphQL::Upgrader::Transform - def initialize - # Find calls to the factory method, which have a type class inside - @find_pattern = /^( +)([a-zA-Z_0-9:]*) = model_type\(-> ?\{ ?:{0,2}([a-zA-Z_0-9:]*) ?\} ?\) do/ - # Replace them with a class definition and a `model_name("...")` call: - @replace_pattern = "\\1class \\2 < Platform::Objects::Base\n\\1 model_name \"\\3\"" - end - - def apply(input_text) - # Run the substitution on the input text: - input_text.sub(@find_pattern, @replace_pattern) - end -end -# Add the class to the beginning of the pipeline -type_transforms.unshift(ModelTypeToClassTransform) -``` - -### Built-in transformers - -Follow links to the API doc to read the source of each transform: - -Type transforms ({{ "GraphQL::Upgrader::Member::DEFAULT_TYPE_TRANSFORMS" | api_doc }}): - -- {{ "GraphQL::Upgrader::Transform" | api_doc }} base class, provides a `normalize_type_expression` helper -- {{ "GraphQL::Upgrader::TypeDefineToClassTransform" | api_doc }} turns `.define` into `class ...` with a regexp substitution -- {{ "GraphQL::Upgrader::NameTransform" | api_doc }} takes `name "..."` and removes it if it's redundant, or converts it to `graphql_name "..."` -- {{ "GraphQL::Upgrader::InterfacesToImplementsTransform" | api_doc }} turns `interfaces [A, B...]` into `implements(A)\nimplements(B)...` - -Field transforms ({{ "GraphQL::Upgrader::Member::DEFAULT_FIELD_TRANSFORMS" | api_doc }}): - -- {{ "GraphQL::Upgrader::RemoveNewlinesTransform" | api_doc }} removes newlines from field definitions to normalize them -- {{ "GraphQL::Upgrader::PositionalTypeArgTransform" | api_doc }} moves `type X` from the `do ... end` block into a positional argument, to normalize the definition -- {{ "GraphQL::Upgrader::ConfigurationToKwargTransform" | api_doc }} moves a `do ... end` configuration to a keyword argument. By default, this is used for `property` and `description`. You can add new instances of this transform to convert your custom DSL. -- {{ "GraphQL::Upgrader::PropertyToMethodTransform" | api_doc }} turns `property:` to `method:` -- {{ "GraphQL::Upgrader::UnderscoreizeFieldNameTransform" | api_doc }} converts field names to underscore-case. __NOTE__ that this conversion may be _wrong_ in the case of `bodyHTML => body_html`. When you find it is wrong, manually revert it and preserve the camel-case field name. -- {{ "GraphQL::Upgrader::ResolveProcToMethodTransform" | api_doc }} converts `resolve -> { ... }` to `def {field_name} ... ` method definitions -- {{ "GraphQL::Upgrader::UpdateMethodSignatureTransform" | api_doc }} converts the type name to the new syntax, and adds `null:`/`required:` to the method signature - -Clean-up transforms ({{ "GraphQL::Upgrader::Member::DEFAULT_CLEAN_UP_TRANSFORMS" | api_doc }}): - -- {{ "GraphQL::Upgrader::RemoveExcessWhitespaceTransform" | api_doc }} removes redundant newlines -- {{ "GraphQL::Upgrader::RemoveEmptyBlocksTransform" | api_doc }} removes `do end` with nothing inside them - -## Roadmap - -Here is a working plan for rolling out this feature: - -- ongoing: - - ☐ Receive feedback from GraphQL schema owners about the new API (usability & goals) -- graphql 1.8: - - ☑ Build a schema definition API based on classes instead of singletons - - ☑ Migrate a few components of GitHub's GraphQL schema to this new API - - ☑ Build advanced class-based features: - - ☑ Custom `Context` classes - - ☑ Custom introspection types - - ☐ ~~Custom directives~~ Probably will mess with execution soon, not worth the investment now - - ☐ ~~Custom `Schema#execute` method~~ not necessary - - ☑ Migrate all of GitHub's GraphQL schema to this new API -- graphql 1.9: - - ☑ Update all GraphQL-Ruby docs to reflect this new API -- graphql 1.10: - - ☑ Begin sunsetting `.define` -- graphql 2.0: - - ☐ Remove `.define` - -## Common Type Configurations - -Some configurations are used for _all_ types described below: - -- `graphql_name` overrides the type name. (The default value is the Ruby constant name, without any namespaces) -- `description` provides a description for GraphQL introspection. - -For example: - -```ruby -class Types::TodoList < GraphQL::Schema::Object # or Scalar, Enum, Union, whatever - graphql_name "List" # Overrides the default of "TodoList" - description "Things to do (may have already been done)" -end -``` - -(Implemented in {{ "GraphQL::Schema::Member" | api_doc }}). diff --git a/guides/schema/definition.md b/guides/schema/definition.md index 7c36f0710d8..ebcd6c82913 100644 --- a/guides/schema/definition.md +++ b/guides/schema/definition.md @@ -8,6 +8,7 @@ desc: Defining your schema index: 1 --- + A GraphQL system is called a _schema_. The schema contains all the types and fields in the system. The schema executes queries and publishes an {% internal_link "introspection system","/schema/introspection" %}. Your GraphQL schema is a class that extends {{ "GraphQL::Schema" | api_doc }}, for example: @@ -16,7 +17,7 @@ Your GraphQL schema is a class that extends {{ "GraphQL::Schema" | api_doc }}, f class MyAppSchema < GraphQL::Schema max_complexity 400 query Types::Query - use GraphQL::Batch + use GraphQL::Dataloader # Define hooks as class methods: def self.resolve_type(type, obj, ctx) @@ -33,176 +34,98 @@ class MyAppSchema < GraphQL::Schema end ``` -There are lots of schema configuration options: - -- [root objects, introspection and orphan types](#root-objects-introspection-and-orphan-types) -- [object identification hooks](#object-identification-hooks) -- [execution configuration](#execution-configuration) -- [context class](#context-class) -- [default limits](#default-limits) -- [plugins](#plugins) +There are lots of schema configuration methods. For defining GraphQL types, see the guides for those types: {% internal_link "object types", "/type_definitions/objects" %}, {% internal_link "interface types", "/type_definitions/interfaces" %}, {% internal_link "union types", "/type_definitions/unions" %}, {% internal_link "input object types", "/type_definitions/input_objects" %}, {% internal_link "enum types", "/type_definitions/enums" %}, and {% internal_link "scalar types", "/type_definitions/scalars" %}. -## Root Objects, Introspection and Orphan Types - -A GraphQL schema is a web of interconnected types, and it has a few starting points for discovering the elements of that web: - -__Root types__ (`query`, `mutation`, and `subscription`) are the [entry points for queries to the system](https://graphql.org/learn/schema/#the-query-and-mutation-types). Each one is an object type which can be connected to the schema by a method with the same name: - -```ruby -class MySchema < GraphQL::Schema - # Required: - query Types::Query - # Optional: - mutation Types::Mutation - subscription Types::Subscription -end -``` - -__Introspection__ is a built-in part of the schema. Every schema has a default introspection system, but you can {% internal_link "customize it","/schema/introspection" %} and hook it up with `introspection`: - -```ruby -class MySchema < GraphQL::Schema - introspection CustomIntrospection -end -``` - -__Orphan Types__ are types which should be in the schema, but can't be discovered by traversing the types and fields from `query`, `mutation` or `subscription`. This has one very specific use case, see {% internal_link "Orphan Types", "/type_definitions/interfaces#orphan-types" %}. - -```ruby -class MySchema < GraphQL::Schema - orphan_types [Types::Comment, ...] -end -``` - -## Object Identification Hooks +## Types in the Schema -A GraphQL schema needs a handful of hooks for finding and disambiguating objects while queries are executed. +- {{ "Schema.query" | api_doc }}, {{ "Schema.mutation" | api_doc }}, and {{ "Schema.subscription" | api_doc}} declare the [entry-point types](https://graphql.org/learn/schema/#the-query-mutation-and-subscription-types) of the schema. +- {{ "Schema.orphan_types" | api_doc }} declares object types which implement {% internal_link "Interfaces", "/type_definitions/interfaces" %} but aren't used as field return types in the schema. For more about this specific scenario, see {% internal_link "Orphan Types", "/type_definitions/interfaces#orphan-types" %} -__`resolve_type`__ is used when a specific object's corresponding GraphQL type must be determined. This happens for fields that return {% internal_link "interface", "/type_definitions/interfaces" %} or {% internal_link "union", "/type_definitions/unions" %} types. The class method `def self.resolve_type` is used: +### Lazy-loading types -```ruby -class MySchema < GraphQL::Schema - def self.resolve_type(abstract_type, object, context) - # Disambiguate `object`, from among `abstract_type`'s members - # (`abstract_type` is an interface or union type.) - end -end -``` +In development, GraphQL-Ruby can defer loading your type definitions until they're needed. This requires some configuration to opt in: -__`object_from_id`__ is used by Relay's `node(id: ID!): Node` field. It receives a unique ID and must return the object for that ID, or `nil` if the object isn't found (or if it should be hidden from the current user). +- Add `use GraphQL::Schema::Visibility` to your schema. ({{ "GraphQL::Schema::Visibility" | api_doc }} supports lazy loading and will be the default in a future GraphQL-Ruby version. See {% internal_link "Migration Notes", "/authorization/visibility#migration-notes" %} if you have an existing visibility implementation.) +- Move your entry-point type definitions into a block, for example: -```ruby -class MySchema < GraphQL::Schema - def self.object_from_id(unique_id, context) - # Find and return the object for `unique_id` - # or `nil` - end -end -``` + ```diff + - query Types::Query + + query { Types::Query } + ``` -__`id_from_object`__ is used to implement Relay's `Node.id` field. It should return a unique ID for the given object. This ID will later be sent to `object_from_id` to refetch the object. +- Optionally, move field types into blocks, too: -```ruby -class MySchema < GraphQL::Schema - def self.id_from_object(object, type, context) - # Return a unique ID for `object`, whose GraphQL type is `type` - end -end -``` + ```diff + - field :posts, [Types::Post] # Loads `types/post.rb` immediately + + field :posts do + + type([Types::Post]) # Loads `types/post.rb` when this field is used in a query + + end + ``` -## Execution Configuration +To enforce these patterns, you can enable two Rubocop rules that ship with GraphQL-Ruby: -__`instrument`__ attaches instrumenters to the schema, see {% internal_link "Instrumentation", "/queries/instrumentation" %} for more information. +- `GraphQL/RootTypesInBlock` will make sure that `query`, `mutation`, and `subscription` are all defined in a block. +- `GraphQL/FieldTypeInBlock` will make sure that non-built-in field return types are defined in blocks. -```ruby -class MySchema < GraphQL::Schema - instrument :field, ResolveTimerInstrumentation -end -``` +## Object Identification -__`tracer`__ is another way to hook into execution, see {% internal_link "Tracing", "/queries/tracing" %} for more. +Some GraphQL features use unique IDs to load objects: -```ruby -class MySchema < GraphQL::Schema - tracer MetricTracer -end -``` +- the `node(id:)` field looks up objects by ID (See {% internal_link "Object Identification", "/schema/object_identification" %} for more about Relay-style object identification.) +- any arguments with `loads:` configurations look up objects by ID +- the {% internal_link "ObjectCache", "/object_cache/overview" %} uses IDs in its caching scheme -__`query_analyzer`__ and __`multiplex_analyzer`__ accept processors for ahead-of-type query analysis, see {% internal_link "Analysis", "/queries/ast_analysis" %} for more. +To use these features, you must provide some methods for generating UUIDs and fetching objects with them: -```ruby -class MySchema < GraphQL::Schema - query_analyzer MyQueryAnalyzer -end -``` +{{ "Schema.object_from_id" | api_doc }} is called by GraphQL-Ruby to load objects directly from the database. It's usually used by the `node(id: ID!): Node` field (see {{ "GraphQL::Types::Relay::Node" | api_doc }}), Argument {% internal_link "loads:", "/mutations/mutation_classes#auto-loading-arguments" %}, or the {% internal_link "ObjectCache", "/object_cache/overview" %}. It receives a unique ID and must return the object for that ID, or `nil` if the object isn't found (or if it should be hidden from the current user). -__`lazy_resolve`__ registers classes with {% internal_link "lazy execution", "/schema/lazy_execution" %}: +{{ "Schema.id_from_object" | api_doc }} is used to implement `Node.id`. It should return a unique ID for the given object. This ID will later be sent to `object_from_id` to refetch the object. -```ruby -class MySchema < GraphQL::Schema - lazy_resolve Promise, :sync -end -``` +Additionally, {{ "Schema.resolve_type" | api_doc }} is called by GraphQL-Ruby to get the runtime Object type for fields that return return {% internal_link "interface", "/type_definitions/interfaces" %} or {% internal_link "union", "/type_definitions/unions" %} types. -__`type_error`__ handles type errors at runtime, read more in the {% internal_link "Invariants guide", "/errors/type_errors" %}. +## Error Handling -```ruby -class MySchema < GraphQL::Schema - def self.type_error(type_err, context) - # Handle `type_err` in some way - end -end -``` +- {{ "Schema.type_error" | api_doc }} handles type errors at runtime, read more in the {% internal_link "Type errors guide", "/errors/type_errors" %}. +- {{ "Schema.rescue_from" | api_doc }} defines error handlers for application errors. See the {% internal_link "error handling guide", "/errors/error_handling" %} for more. +- {{ "Schema.parse_error" | api_doc }} and {{ "Schema.query_stack_error" | api_doc }} provide hooks for reporting errors to your bug tracker. -__`rescue_from`__ accepts error handlers for application errors, for example: +## Default Limits -```ruby -class MySchema < GraphQL::Schema - rescue_from(ActiveRecord::RecordNotFound) { "Not found" } -end -``` +- {{ "Schema.max_depth" | api_doc }} and {{ "Schema.max_complexity" | api_doc }} apply some limits to incoming queries. See {% internal_link "Complexity and Depth", "/queries/complexity_and_depth" %} for more. +- {{ "Schema.default_max_page_size" | api_doc }} applies limits to {% internal_link "connection fields", "/pagination/overview" %}. +- {{ "Schema.validate_timeout" | api_doc }}, {{ "Schema.validate_max_errors" | api_doc }} and {{ "Schema.max_query_string_tokens" | api_doc }} all apply limits to query execution. See {% internal_link "Timeout", "/queries/timeout" %} for more. -## Context Class +## Introspection -Usually, `context` is an instance of {{ "GraphQL::Query::Context" | api_doc }}, but you can create a custom subclass and attach it with `.context_class`, for example: +- {{ "Schema.extra_types" | api_doc }} declares types which should be printed in the SDL and returned in introspection queries, but aren't otherwise used in the schema. +- {{ "Schema.introspection" | api_doc }} can attach a {% internal_link "custom introspection system", "/schema/introspection" %} to the schema. -```ruby -class CustomContext < GraphQL::Query::Context - # Shorthand to get the current user - def viewer - self[:viewer] - end -end +## Authorization -class MySchema < GraphQL::Schema - context_class CustomContext -end -``` +- {{ "Schema.unauthorized_object" | api_doc }} and {{ "Schema.unauthorized_field" | api_doc }} are called when {% internal_link "authorization hooks", "/authorization/authorization" %} return `false` during query execution. -Then, during execution, `context` will be an instance of `CustomContext`. +## Execution Configuration -## Default Limits +- {{ "Schema.trace_with" | api_doc }} attaches tracer modules. See {% internal_link "Tracing", "/queries/tracing" %} for more. +- {{ "Schema.query_analyzer" | api_doc }} and {{ "Schema.multiplex_analyzer" }} accept processors for ahead-of-time query analysis, see {% internal_link "Analysis", "/queries/ast_analysis" %} for more. +- {{ "Schema.default_logger" | api_doc }} configures a logger for runtime. See {% internal_link "Logging", "/queries/logging" %}. +- {{ "Schema.context_class" | api_doc }} and {{ "Schema.query_class" | api_doc }} attach custom subclasses to your schema to use during execution. +- {{ "Schema.lazy_resolve" | api_doc }} registers classes with {% internal_link "lazy execution", "/schema/lazy_execution" %}. -`max_depth` and `max_complexity` apply some limits to incoming queries. See {% internal_link "Complexity and Depth", "/queries/complexity_and_depth" %} for more. +## Plugins -`default_max_page_size` applies limits to `Connection` fields. +- {{ "Schema.use" | api_doc }} adds plugins to your schema. For example, {{ "GraphQL::Dataloader" | api_doc }} and {{ "GraphQL::Schema::Visibility" | api_doc }} are installed this way. -```ruby -class MySchema < GraphQL::Schema - max_depth 10 - max_complexity 300 - default_max_page_size 20 -end -``` +## Production Considerations -## Plugins +- __Parser caching__: if your application parses GraphQL _files_ (queries or schema definition), it may benefit from enabling {{ "GraphQL::Language::Cache" | api_doc }}. +- __Eager loading the library__: by default, GraphQL-Ruby autoloads its constants as-needed. In production, they should be eager loaded instead, using `GraphQL.eager_load!`. -A plugin is an object that responds to `#use`. Plugins are used to attach new behavior to a schema without a lot of API overhead. For example, the gem's {% internal_link "monitoring tools", "/queries/tracing#monitoring" %} are plugins: + - Rails: enabled automatically. (ActiveSupport calls `.eager_load!`.) + - Sinatra: add `configure(:production) { GraphQL.eager_load! }` to your application file. + - Hanami: add `environment(:production) { GraphQL.eager_load! }` to your application file. + - Other frameworks: call `GraphQL.eager_load!` when your application is booting in production mode. -```ruby -class MySchema < GraphQL::Schema - use(GraphQL::Tracing::NewRelicTracing) -end -``` + See {{"GraphQL::Autoload#eager_load!" | api_doc }} for more details. diff --git a/guides/schema/dynamic_types.md b/guides/schema/dynamic_types.md new file mode 100644 index 00000000000..9497b5d2e86 --- /dev/null +++ b/guides/schema/dynamic_types.md @@ -0,0 +1,314 @@ +--- +layout: guide +doc_stub: false +search: true +section: Schema +title: Dynamic types and fields +desc: Using different schema members for each request +index: 8 +--- + +You can use different versions of your GraphQL schema for each operation. To do this, add `use GraphQL::Schema::Visibility` and implement `visible?(context)` on the parts of your schema that will be conditionally accessible. Additionally, many schema elements have definition methods which are called at runtime by GraphQL-Ruby. You can re-implement those to return any valid schema objects. + + +GraphQL-Ruby caches schema elements for the duration of the operation, but if you're making external service calls to implement the methods below, consider adding a cache layer to improve the client experience and reduce load on your backend. + +At runtime, ensure that only one object is visible per name (type name, field name, etc.). (If `.visible?(context)` returns `false`, then that part of the schema will be hidden for the current operation.) + +When using dynamic schema members, be sure to include the relevant `context: ...` when [generating schema definition files](#schema-dumps). + +## Different fields + +You can customize which field definitions are used for each operation. + +### Using `#visible?(context)` + +To serve different fields to different clients, implement `def visible?(context)` in your {% internal_link "base field class", "/type_definitions/extensions#customizing-fields" %}: + +```ruby +class Types::BaseField < GraphQL::Schema::Field + def initialize(*args, for_staff: false, **kwargs, &block) + super(*args, **kwargs, &block) + @for_staff = for_staff + end + + def visible?(context) + super && case @for_staff + when true + !!context[:current_user]&.staff? + when false + !context[:current_user]&.staff? + else + true + end + end +end +``` + +Then, you can configure fields with `for_staff: true|false`: + +```ruby +field :comments, Types::Comment.connection_type, null: false, + description: "Comments on this blog post", + resolver_method: :moderated_comments, + for_staff: false + +field :comments, Types::Comment.connection_type, null: false, + description: "Comments on this blog post, including unmoderated comments", + resolver_method: :all_comments, + for_staff: true +``` + +With that configuration, `post { comments { ... } }` will use `def moderated_comments` when `context[:current_user]` is `nil` or is not `.staff?`, but when `context[:current_user].staff?` is `true`, it will use `def all_comments` instead. + +### Using `.fields(context)` and `.get_field(name, context)` + +To customize the set of fields used at runtime, you can implement `def self.fields(context)` in your type classes. It should return a Hash of `{ String => GraphQL::Schema::Field }`. + +Along with this, you should implement `.get_field(name, context)` to return a field for `name`, if it should exist. For example: + +```ruby +class Types::User < Types::BaseObject + def self.fields(context) + all_fields = super + if !context[:current_user]&.staff? + all_fields.delete("isSpammy") # this is staff-only + end + all_fields + end + + def self.get_field(name, context) + field = super + if field.graphql_name == "isSpammy" && !context[:current_user]&.staff? + nil # don't show this field to non-staff + else + field + end + end +end +``` + +### Hidden Return Types + +Besides field visibility described above, if an field's return type is hidden (that is, it implements `self.visible?(context)` to return `false`), then the field will be hidden too. + +## Different arguments + +As with fields, you can use different sets of argument definitions for different GraphQL operations. + +### Using `#visible?(context)` + +To serve different arguments to different clients, implement `def visible?(context)` in your {% internal_link "base argument class", "/type_definitions/extensions#customizing-arguments" %}: + +```ruby +class Types::BaseArgument < GraphQL::Schema::Argument + def initialize(*args, for_staff: false, **kwargs, &block) + super(*args, **kwargs, &block) + @for_staff = for_staff + end + + def visible?(context) + super && case @for_staff + when true + !!context[:current_user]&.staff? + when false + !context[:current_user]&.staff? + else + true + end + end +end +``` + +Then, you can configure arguments with `for_staff: true|false`: + +```ruby +field :user, Types::User, null: true, description: "Look up a user" do + # Require a UUID-style ID from non-staff clients: + argument :id, ID, required: true, for_staff: false + # Support database primary key lookups for staff clients: + argument :id, ID, required: false, for_staff: true + argument :database_id, Int, required: false, for_staff: true +end + +def user(id: nil, database_id: nil) + # ... +end +``` + +That way, any staff client will have the option of `id` or `databaseId` while non-staff clients must use `id`. + +### Using `def arguments(context)` and `def get_argument(name, context)` + +Also, you can implement `def arguments(context)` on your base field class to return a Hash of `{ String => GraphQL::Schema::Argument }` and `def get_argument(name, context)` to return a {{ "GraphQL::Schema::Argument" | api_doc }} or `nil`. . If you take this approach, you might want some custom field classes for any types or resolvers that use these methods. That way, you don't have to reimplement the method for _all_ the fields in the schema. + +### Hidden Input Types + +Besides argument visibility described above, if an argument's input type is hidden (that is, it implements `self.visible?(context)` to return `false`), then the argument will be hidden too. + +## Different enum values + +### Using `#visible?(context)` + +You can implement `def visible?(context)` in your {% internal_link "base enum value class", "/type_definitions/extensions#customizing-enum-values" %} to hide some enum values from some clients. For example: + +```ruby +class BaseEnumValue < GraphQL::Schema::EnumValue + def initialize(*args, for_staff: false, **kwargs, &block) + super(*args, **kwargs, &block) + @for_staff = for_staff + end + + def visible?(context) + super && case @for_staff + when true + !!context[:current_user]&.staff? + when false + !context[:current_user]&.staff? + else + true + end + end +end +``` + +With this base class, you can configure some enum values to be _just_ for staff or non-staff viewers: + +```ruby +class AccountStatus < Types::BaseEnum + value "ACTIVE" + value "INACTIVE" + # Use this for sensitive account statuses when the viewer is public: + value "OTHER", for_staff: false + # Staff-only sensitive account statuses: + value "BANNED", for_staff: true + value "PAYMENT_FAILED", for_staff: true + value "PENDING_VERIFICATION", for_staff: true +end +``` + +### Using `.enum_values(context)` + +Alternatively, you can implement `def self.enum_values(context)` in your enum types to return an Array of {{ "GraphQL::Schema::EnumValue" | api_doc }}s. For example, to return a dynamic set of enum values: + +```ruby +class ProjectStatus < Types::BaseEnum + def self.enum_values(context = {}) + # Fetch the values from the database + status_names = context[:tenant].project_statuses.pluck("name") + + # Then build an Array of Enum values + status_names.map do |name| + # Be sure to include `owner: self`, the back-reference from the EnumValue to its parent Enum + GraphQL::Schema::EnumValue.new(name, owner: self) + end + end +end +``` + +## Different types + +You can also use different types for each query. A few behaviors depend on the methods defined above: + +- If a type is not used as a return type, an argument type, or as a member of a union or implementer of an interface, it will be hidden +- If an interface or union has members, it will be hidden +- If a field's return type is hidden, the field will be hidden +- If an argument's input type is hidden, the argument will be hidden + +As you can imagine, these different hiding behaviors influence one another and they can cause some real head-scratchers when used simultaneously. + +### Using `.visible?(context)` + +Type classes can implement `def self.visible?(context)` to hide themselves at runtime: + +```ruby +class Types::BanReason < Types::BaseEnum + # Hide any arguments or fields that use this enum + # unless the current user is staff + def self.visible?(context) + super && !!context[:current_user]&.staff? + end + + # ... +end +``` + +### Different definitions for the same type + +You can provide different implementations of the same type by: + +- Implementing `def self.visible?(context)` to return `true` and `false` in complementary contexts. (They should never both be `.visible? => true`). +- Hooking the types up to the schema with different field or argument definitions, as described above + +For example, to migrate your `Money` scalar to a `Money` object type: + +```ruby +# Previously, we used a simple string to describe money: +class Types::LegacyMoney < Types::BaseScalar + # This graphql name will conflict with `Types::Money`, + # so we have to be careful not to use them at the same time. + # (GraphQL-Ruby will raise an error if it finds two definitions with the same name at runtime.) + graphql_name "Money" + describe "A string describing an amount of money." + + # Use this type definition if the current request + # explicitly opted in to the legacy money representation: + def self.visible?(context) + !!context[:requests_legacy_money] + end +end + +# But we want to improve the client experience with a dedicated object type: +class Types::Money < Types::BaseObject + field :amount, Integer, null: false + field :currency, Types::Currency, null: false + + # Use this new definition if the client + # didn't explicitly ask for the legacy definition: + def self.visible?(context) + !context[:requests_legacy_money] + end +end +``` + +Then, hook the definitions up to the schema using field definitions: + +```ruby +class Types::BaseField < GraphQL::Schema::Field + def initialize(*args, legacy_money: false, **kwargs, &block) + super(*args, **kwargs, &block) + @legacy_money = legacy_money + end + + def visible?(context) + super && (@legacy_money ? !!context[:requests_legacy_money] : !context[:requests_legacy_money]) + end +end + +class Types::Invoice < Types::BaseObject + # Add one definition for each possible return type + # (one definition will be hidden at runtime) + field :amount, Types::LegacyMoney, null: false, legacy_money: true + field :amount, Types::Money, null: false, legacy_money: false +end +``` + +Input types (like input objects, scalars, and enums) work the same way with argument definitions. + +## Schema Dumps + +To dump a certain _version_ of the schema, provide the applicable `context: ...` to {{ "Schema.to_definition" | api_doc }}. For example: + +```ruby +# Legacy money schema: +MySchema.to_definition(context: { requests_legacy_money: true }) +``` + +or + +```ruby +# Staff-only schema: +MySchema.to_definition(context: { current_user: OpenStruct.new(staff?: true) }) +``` + +That way, the given `context` will be passed to `visible?(context)` calls and other relevant methods. diff --git a/guides/schema/generators.md b/guides/schema/generators.md index 274e49491bf..433d8ca3bad 100644 --- a/guides/schema/generators.md +++ b/guides/schema/generators.md @@ -13,6 +13,7 @@ If you're using GraphQL with Ruby on Rails, you can use generators to: - [setup GraphQL](#graphqlinstall), including [GraphiQL](https://github.com/graphql/graphiql), [GraphQL::Batch](https://github.com/Shopify/graphql-batch), and [Relay](https://facebook.github.io/relay/) - [scaffold types](#scaffolding-types) - [scaffold Relay mutations](#scaffolding-mutations) +- [scaffold ActiveRecord create/update/delete mutations](#scaffolding-activerecord-mutations) - [scaffold GraphQL::Batch loaders](#scaffolding-loaders) ## graphql:install @@ -32,6 +33,7 @@ This will: - Add a `Mutation` type definition with a base mutation class - Add a route and controller for executing queries - Install [`graphiql-rails`](https://github.com/rmosolgo/graphiql-rails) +- Enable [`ActiveRecord::QueryLogs`](https://api.rubyonrails.org/classes/ActiveRecord/QueryLogs.html) and add GraphQL-related metadata (using {{ "GraphQL::Current" | api_doc }}) After installing you can see your new schema by: @@ -41,22 +43,35 @@ After installing you can see your new schema by: ### Options +- `--directory=DIRECTORY` will specify the directory where generated files should be saved (default is `app/graphql`) +- `--schema=MySchemaName` will be used for naming the schema (default is `#{app_name}Schema`) +- `--skip-graphiql` will exclude `graphiql-rails` from the setup +- `--skip-mutation-root-type` will not create of the mutation root type +- `--skip-query-logs` will skip the QueryLogs setup - `--relay` will add [Relay](https://facebook.github.io/relay/)-specific code to your schema - `--batch` will add [GraphQL::Batch](https://github.com/Shopify/graphql-batch) to your gemfile and include the setup in your schema - `--playground` will include `graphql_playground-rails` in the setup (mounted at `/playground`) -- `--no-graphiql` will exclude `graphiql-rails` from the setup -- `--schema=MySchemaName` will be used for naming the schema (default is `#{app_name}Schema`) +- `--api` will create smaller stack for API only apps ## Scaffolding Types Several generators will add GraphQL types to your project. Run them with `-h` to see the options: - `rails g graphql:object` +- `rails g graphql:input` - `rails g graphql:interface` - `rails g graphql:union` - `rails g graphql:enum` - `rails g graphql:scalar` +### ActiveRecord columns auto-extraction + +The `graphql:object` and `graphql:input` generators can detect the existence of an ActiveRecord class with the same name, and scaffold all database columns as fields/arguments using appropriate GraphQL types and nullability detection + +### Options + +- `--namespaced-types` will generate each one of the `object`/`input`/`interface`/... types under separate `Types::Objects::*`/`Types::Inputs::*`/`Types::Interfaces::*`/... namespaces and folders + ## Scaffolding Mutations You can prepare a Relay Classic mutation with @@ -65,6 +80,19 @@ You can prepare a Relay Classic mutation with rails g graphql:mutation #{mutation_name} ``` +## Scaffolding ActiveRecord Mutations + +You can generate a Relay Classic create, update or delete mutation for a given model with + +``` +rails g graphql:mutation_create #{model_class_name} +rails g graphql:mutation_update #{model_class_name} +rails g graphql:mutation_delete #{model_class_name} +``` + +`model_class_name` accepts both `namespace/class_type` and `Namespace::ClassType` formats. +This mutation also accepts the `--namespaced-types` flag, to keep it consistent with the scaffolded Object and Input classes from the type generators + ## Scaffolding Loaders You can prepare a GraphQL::Batch loader with diff --git a/guides/schema/introspection.md b/guides/schema/introspection.md index 0af7dcc6c12..688086823ae 100644 --- a/guides/schema/introspection.md +++ b/guides/schema/introspection.md @@ -168,13 +168,13 @@ module Introspection end ``` -This class an object type definition, so you can override fields or add new ones here. They'll be available on the root `query` object, but ignored in introspection (just like `__schema` and `__type`). +This class is an object type definition, so you can override existing fields or add new ones here. They'll be available on the root `query` object, but ignored in introspection (just like `__schema` and `__type`). ### Dynamic Fields The GraphQL spec describes a field which may be added to _any_ selection: `__typename`. It returns the name of the current GraphQL type. -You can add fields like this (or override `__typename`) by creating a custom `DynamicFields` defintion: +You can add fields like this (or override `__typename`) by creating a custom `DynamicFields` definition: ```ruby module Introspection diff --git a/guides/schema/lazy_execution.md b/guides/schema/lazy_execution.md index aa958d26ed6..2e5cdaad421 100644 --- a/guides/schema/lazy_execution.md +++ b/guides/schema/lazy_execution.md @@ -4,15 +4,15 @@ doc_stub: false search: true title: Lazy Execution section: Schema -desc: Resolve functions can return "unfinished" results that are deferred for batch resolution. +desc: Resolvers can return "unfinished" results that are deferred for batch resolution. index: 4 --- With lazy execution, you can optimize access to external services (such as databases) by making batched calls. Building a lazy loader has three steps: - Define a lazy-loading class with _one_ method for loading & returning a value -- Connect it to your schema with {{ "GraphQL::Schema#lazy_resolve" | api_doc }} -- In `resolve` functions, return instances of the lazy-loading class +- Connect it to your schema with {{ "GraphQL::Schema.lazy_resolve" | api_doc }} +- In `resolve` methods, return instances of the lazy-loading class ## Example: Batched Find @@ -67,7 +67,7 @@ end 3. Return lazy objects from `resolve` ```ruby -field :author, PersonType, null: true +field :author, PersonType def author LazyFindPerson.new(context, object.author_id) @@ -90,6 +90,7 @@ Will only make one query to load the `author` values. The example above is simple and has some shortcomings. Consider the following gems for a robust solution to batched resolution: +* {{ "GraphQL::Dataloader" | api_doc }} is a built-in, Fiber-based approach to batching. See the {% internal_link "Dataloader guide", "/dataloader/overview" %} for more information. * [`graphql-batch`](https://github.com/shopify/graphql-batch) provides a powerful, flexible toolkit for lazy resolution with GraphQL. * [`dataloader`](https://github.com/sheerun/dataloader) is more general promise-based utility for batching queries within the same thread. * [`batch-loader`](https://github.com/exAspArk/batch-loader) works with any Ruby code including GraphQL, no extra dependencies or primitives. diff --git a/guides/schema/object_identification.md b/guides/schema/object_identification.md index ba0abf10b0b..9ae80c4d8d1 100644 --- a/guides/schema/object_identification.md +++ b/guides/schema/object_identification.md @@ -4,54 +4,17 @@ doc_stub: false search: true title: Object Identification section: Schema -desc: Working with Relay-style global IDs +desc: Working with unique global IDs index: 8 --- -Relay uses [global object identification](https://facebook.github.io/relay/graphql/objectidentification.htm) to support some of its features: +GraphQL-Ruby ships with some helpers to implement [Relay-style object identification](https://relay.dev/graphql/objectidentification.htm). -- __Caching__: Unique IDs are used as primary keys in Relay's client-side cache. -- __Refetching__: Relay uses unique IDs to refetch objects when it determines that its cache is stale. (It uses the `Query.node` field to refetch objects.) +## Schema methods -### Defining UUIDs +See {% internal_link "the Schema definition guide", "/schema/definition#object-identification" %} for required top-level hooks. -You must provide a function for generating UUIDs and fetching objects with them. In your schema, define `self.id_from_object` and `self.object_from_id`: - -```ruby -class MySchema < GraphQL::Schema - def self.id_from_object(object, type_definition, query_ctx) - # Call your application's UUID method here - # It should return a string - MyApp::GlobalId.encrypt(object.class.name, object.id) - end - - def self.object_from_id(id, query_ctx) - class_name, item_id = MyApp::GlobalId.decrypt(id) - # "Post" => Post.find(item_id) - Object.const_get(class_name).find(item_id) - end -end -``` - -An unencrypted ID generator is provided in the gem. It uses `Base64` to encode values. You can use it like this: - -```ruby -class MySchema < GraphQL::Schema - # Create UUIDs by joining the type name & ID, then base64-encoding it - def self.id_from_object(object, type_definition, query_ctx) - GraphQL::Schema::UniqueWithinType.encode(type_definition.graphql_name, object.id) - end - - def self.object_from_id(id, query_ctx) - type_name, item_id = GraphQL::Schema::UniqueWithinType.decode(id) - # Now, based on `type_name` and `item_id` - # find an object in your application - # .... - end -end -``` - -### Node interface +## Node interface One requirement for Relay's object management is implementing the `"Node"` interface. @@ -84,23 +47,21 @@ class MySchema < GraphQL::Schema end ``` -### UUID fields +## UUID fields -Relay Nodes must have a field named `"id"` which returns a globally unique ID. +Nodes must have a field named `"id"` which returns a globally unique ID. -To add a UUID field named `"id"`, use the `global_id_field` helper: +To add a UUID field named `"id"`, implement the {{ "GraphQL::Types::Relay::Node" | api_doc }} interface:: ```ruby class Types::PostType < GraphQL::Schema::Object - # `id` exposes the UUID - global_id_field :id - # ... + implements GraphQL::Types::Relay::Node end ``` This field will call the previously-defined `id_from_object` class method. -### `node` field (find-by-UUID) +## `node` field (find-by-UUID) You should also provide a root-level `node` field so that Relay can refetch objects from your schema. You can attach it like this: @@ -113,7 +74,7 @@ class Types::QueryType < GraphQL::Schema::Object end ``` -### `nodes` field +## `nodes` field You can also provide a root-level `nodes` field so that Relay can refetch objects by IDs: diff --git a/guides/schema/root_types.md b/guides/schema/root_types.md index 7a588a4f825..c0bb1df703e 100644 --- a/guides/schema/root_types.md +++ b/guides/schema/root_types.md @@ -8,7 +8,7 @@ desc: Root types are the entry points for queries, mutations and subscriptions. index: 2 --- -GraphQL queries begin from [root types](https://graphql.org/learn/schema/#the-query-and-mutation-types): `query`, `mutation`, and `subscription`. +GraphQL queries begin from [root types](https://graphql.org/learn/schema/#the-query-mutation-and-subscription-types): `query`, `mutation`, and `subscription`. Attach these to your schema using methods with the same name: diff --git a/guides/subscriptions/ably_implementation.md b/guides/subscriptions/ably_implementation.md index d5fe8906059..faeeea0e153 100644 --- a/guides/subscriptions/ably_implementation.md +++ b/guides/subscriptions/ably_implementation.md @@ -13,19 +13,6 @@ pro: true After creating an app on Ably, you can hook it up to your GraphQL schema. -- [How it Works](#how-it-works) -- [Ably setup](#ably-setup) -- [Database setup](#database-setup) -- [Schema configuration](#schema-configuration) -- [Execution configuration](#execution-configuration) -- [Webhook configuration](#webhook-configuration) -- [Authorization](#authorization) -- [End-to-end encryption](#encryption) -- [Serializing context](#serializing-context) -- [Dashboard](#dashboard) -- [Development tips](#development-tips) -- [Client configuration](#client-configuration) - ## How it Works This subscription implementation uses a hybrid approach: @@ -37,7 +24,7 @@ This subscription implementation uses a hybrid approach: So, the lifecycle goes like this: - A `subscription` query is sent by HTTP Post to your server (just like a `query` or `mutation`) -- The response contains a Ably channel ID (as an HTTP header) which the client may subscribe to +- The response contains an Ably channel ID (as an HTTP header) which the client may subscribe to - The client opens that Ably channel - When the server triggers updates, they're delivered over the Ably channel - When the client unsubscribes, the server receives a webhook and responds by removing its subscription data @@ -95,7 +82,7 @@ maxmemory-policy noeviction appendonly yes ``` -Otherwise, Redis will drop data that doesn't fit in memory (read more in ["Redis persistence"](https://redis.io/topics/persistence)). +Otherwise, Redis will drop data that doesn't fit in memory (read more in ["Redis persistence"](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)). If you're already using Redis in your application, see ["Storing Data in Redis"](https://www.mikeperham.com/2015/09/24/storing-data-with-redis/) for options to isolate data and tune your configuration. @@ -131,6 +118,22 @@ There are also two configurations for managing persistence: - `stale_ttl_s:` expires subscription data after the given number of seconds without any update. After `stale_ttl_s` has passed, the data will expire from Redis. Each time a subscription receives an update, its TTL is refreshed. (Generally, this isn't required because the backend is built to clean itself up. But, if you find that Redis is collecting stale queries, you can set them to expire after some very long time as a safeguard.) - `cleanup_delay_s:` (default: `5`) prevents deleting a subscription during those first seconds after it's created. Usually, a longer delay isn't necessary, but if you observe latency between the subscription's initial response and the client's subscription to the delivery channel, you can set this configuration to account for it. +### Connection Pool + +For better performance reading and writing to Redis, you can pass a `connection_pool:` instead of `redis:`, using the [`connection_pool` gem](https://github.com/mperham/connection_pool): + +```ruby + use GraphQL::Pro::AblySubscriptions, + connection_pool: ConnectionPool.new(size: 5, timeout: 5) { Redis.new }, + ably: Ably::Rest.new(key: ABLY_API_KEY) +``` + +### Broadcasts + +If you set up {% internal_link "Broadcasts", "/subscriptions/broadcast" %}, then you can update many clients over a single Ably channel. + +Broadcast channels have stable, predictable IDs. To prevent unauthorized clients from "listening in," use [token authorization](#authorization) for transport. Broadcasts channels use the namespace `gqlbdcst:`, so you can provide capabilities to receive them using `"gqlbdcst:*" => [ ... ]` in your authorization code. (If you're using [encryption](#encryption), the prefix will be `ablyencr-gqlbdcst:` instead.) + ## Execution configuration During execution, GraphQL will assign a `subscription_id` to the `context` hash. The client will use that ID to listen for updates, so you must return the `subscription_id` in the response headers. @@ -158,7 +161,7 @@ if result.subscription? end ``` -Read more here: ["Using CORS"](https://www.html5rocks.com/en/tutorials/cors/). +Read more here: ["Using CORS"](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS). ## Webhook configuration @@ -166,7 +169,7 @@ Your server needs to receive webhooks from Ably when clients disconnect. This ke ### Server -*Note: if you're setting up in a development environment you should follow the [Developing with webhooks](#Developing-with-webhooks) section first* +*Note: if you're setting up in a development environment you should follow the [Developing with webhooks](#developing-with-webhooks) section first* Mount the Rack app for handling webhooks from Ably. For example, on Rails: @@ -183,19 +186,27 @@ Rails.application.routes.draw do end ``` +__Alternatively__, you can configure the routes to load your schema lazily, during the first request: + +```ruby +# Provide the fully-qualified class name of your schema: +lazy_routes = GraphQL::Pro::Routes::Lazy.new("MySchema") +mount lazy_routes.ably_webhooks_client, at: "/ably_webhooks" +``` + ### Ably 1. Go to the Ably dashboard -2. Click on your application. -3. Select the "Reactor" tab -4. Click on the "+ New Reactor Rule" button -5. Click on the "Choose" button for "Reactor Event" -6. Click on the "Choose" button for "WebHooks" -7. Enter your url (including the webhooks path from above) in the URL field. -8. Select "Batch request" for "Request Mode" -9. Under "Source" select "Channel Lifecycle" -10. Under "Sign with key" select the API Key prefix that matches the prefix of the ABLY_API_KEY you provided. -11. Click "Create" +2. Click on your application +3. Select the **"Integrations"** tab +4. Click on the **"+ New Integration Rule"** button +5. Click on the "Choose" button for **"Webhook"** +6. Click on the "Choose" button for **"Webhook"** (again) +7. Enter **your URL (including the webhooks path from above)** in the URL field. +8. Select **"Batch request"** for "Request Mode" +9. Under "Source", select **"Presence"** +10. Under "Sign with key", select the API Key prefix that matches the prefix of the `ABLY_API_KEY` you provided +11. Click **"Create"** ## Authorization @@ -308,4 +319,8 @@ To receive webhooks in development, you can [use ngrok](https://www.ably.io/tuto ## Client configuration -Install the [Ably JS client](https://github.com/ably/ably-js) then see docs for {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %}. +Install the [Ably JS client](https://github.com/ably/ably-js) then see docs for: + +- {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %} +- {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %}. +- {% internal_link "GraphiQL", "/javascript_client/graphiql_subscriptions" %} diff --git a/guides/subscriptions/action_cable_implementation.md b/guides/subscriptions/action_cable_implementation.md index 03e663d5235..6969c45c4bc 100644 --- a/guides/subscriptions/action_cable_implementation.md +++ b/guides/subscriptions/action_cable_implementation.md @@ -10,6 +10,10 @@ index: 4 [ActionCable](https://guides.rubyonrails.org/action_cable_overview.html) is a great platform for delivering GraphQL subscriptions on Rails 5+. It handles message passing (via `broadcast`) and transport (via `transmit` over a websocket). -To get started, see examples in the API docs: {{ "GraphQL::Subscriptions::ActionCableSubscriptions" | api_doc }}. +To get started, see examples in the API docs: {{ "GraphQL::Subscriptions::ActionCableSubscriptions" | api_doc }}. GraphQL-Ruby also includes a mock ActionCable implementation for testing: {{ "GraphQL::Testing::MockActionCable" | api_doc }}. -See client usage for {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %} or {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %}. +See client usage for: + +- {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %} +- {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %}. +- {% internal_link "GraphiQL", "/javascript_client/graphiql_subscriptions" %} diff --git a/guides/subscriptions/broadcast.md b/guides/subscriptions/broadcast.md index 5edffb7b798..9614fe8e590 100644 --- a/guides/subscriptions/broadcast.md +++ b/guides/subscriptions/broadcast.md @@ -8,7 +8,7 @@ desc: Delivering the same GraphQL result to multiple subscribers index: 3 --- -GraphQL-Ruby 1.11+ introduced a new algorithm for tracking subscriptions and delivering updates, _broadcasts_. +GraphQL subscription updates may _broadcast_ data to multiple subscribers. A broadcast is a subscription update which is executed _once_, then delivered to _any number_ of subscribers. This reduces the time your server spends running GraphQL queries, since it doesn't have to re-run the query for every subscriber. @@ -82,3 +82,37 @@ GraphQL-Ruby determines which subscribers can receive a broadcast by inspecting: So, take care to {% internal_link "set subscription_scope", "subscriptions/subscription_classes#scope" %} whenever a subscription should be implicitly scoped! (See {{ "GraphQL::Subscriptions::Event#fingerprint" | api_doc }} for the implementation of broadcast fingerprints.) + +## Checking for Broadcastable + +For testing purposes, you can confirm that a GraphQL query string is broadcastable by using {{ "Subscriptions#broadcastable?" | api_doc }}: + +```ruby +subscription_string = "subscription { ... }" +MySchema.subscriptions.broadcastable?(subscription_string) +# => true or false +``` + +Use this in your application's tests to make sure that broadcastable fields aren't accidentally made non-broadcastable. + +## Connections and Edges + +You can configure your generated `Connection` and `Edge` types to be broadcastable by setting `default_broadcastable(true)` in their definition: + +```ruby +# app/types/base_connection.rb +class Types::BaseConnection < Types::BaseObject + include GraphQL::Types::Relay::ConnectionBehaviors + default_broadcastable(true) +end + +# app/types/base_edge.rb +class Types::BaseEdge < Types::BaseObject + include GraphQL::Types::Relay::EdgeBehaviors + default_broadcastable(true) +end +``` + +(In your `BaseObject`, you should also have `connection_type_class(Types::BaseConnection)` and `edge_type_class(Types::BaseEdge)`.) + +`PageInfo` is broadcastable by default. diff --git a/guides/subscriptions/multi_tenant.md b/guides/subscriptions/multi_tenant.md new file mode 100644 index 00000000000..55ccde8b405 --- /dev/null +++ b/guides/subscriptions/multi_tenant.md @@ -0,0 +1,128 @@ +--- +layout: guide +doc_stub: false +search: true +section: Subscriptions +title: Multi-Tenant +desc: Switching tenants in GraphQL Subscription execution +index: 8 +--- + +In a multi-tenant system, data from many different accounts is stored on the same server. (An account might be an organization, a customer, a namespace, a domain, etc -- these are all _tenants_.) Gems like [Apartment](https://github.com/influitive/apartment) assist with this arrangement, but it can also be implemented in the application. Here are a few considerations for this architecture when using GraphQL subscriptions. + +## Add Tenant to `context` + +All the approaches below will use `context[:tenant]` to identify the tenant during GraphQL execution, so make sure to assign it before executing a query: + +```ruby +context = { + viewer: current_user, + tenant: current_user.tenant, + # ... +} + +MySchema.execute(query_str, context: context, ...) +``` + +## Tenant-based `subscription_scope` + +When subscriptions are delivered, {% internal_link "`subscription_scope`", "subscriptions/subscription_classes#scope" %} is one element used to route data to the right subscriber. In short, it's the _implicit_ identifier for the receiver. In a multi-tenant architecture, `subscription_scope` should reference the context key that names the tenant, for example: + +```ruby +class BudgetWasApproved < GraphQL::Schema::Subscription + subscription_scope :tenant # This would work with `context[:tenant] => "acme-corp"` + # ... +end + +# Include the scope when `.trigger`ing: +BudgetSchema.subscriptions.trigger(:budget_was_approved, {}, { ... }, scope: "acme-corp") +``` + + +Alternatively, `subscription_scope` might name something that _belongs_ to the tenant: + +```ruby +class BudgetWasApproved < GraphQL::Schema::Subscription + subscription_scope :project_id # This would work with `context[:project_id] = 1234` +end + +# Include the scope when `.trigger`ing: +BudgetSchema.subscriptions.trigger(:budget_was_approved, {}, { ... }, scope: 1234) +``` + +As long as `project_id` is unique among _all_ tenants, that would work fine too. But _some_ scope is required so that subscriptions can be disambiguated between tenants. + +## Choosing a tenant for execution + +There are a few places where subscriptions might need to load data: + +- When building the payload for the subscription (fetching data to prepare the result) +- `ActionCableSubscriptions`: when deserializing the JSON string broadcasted by `ActionCable` +- `PusherSubscriptions` and `AblySubscriptions`: when deserializing query context + +Each of these operations will need to select the right tenant in order to load data properly. + +For __building the payload__, use a {% internal_link "Trace module", "queries/tracing" %}: + +```ruby +module TenantSelectionTrace + def execute_multiplex(multiplex:) # this is the top-level, umbrella event + context = data[:multiplex].queries.first.context # This assumes that all queries in a multiplex have the same tenant + MultiTenancy.select_tenant(context[:tenant]) do + # ^^ your multi-tenancy implementation here + super # Call through to the rest of execution + end + end +end + +# ... +class MySchema < GraphQL::Schema + trace_with(TenantSelectionTrace) +end +``` + +The tracer above will use `context[:tenant]` to select a tenant for the duration of execution for _all_ queries, mutations, and subscriptions. + +For __deserializing ActionCable messages__, provide a `serializer:` object that implements `.dump(obj)` and `.load(string, context)`: + +```ruby +class MultiTenantSerializer + def self.dump(obj) + GraphQL::Subscriptions::Serialize.dump(obj) + end + + def self.load(string, context) + MultiTenancy.select_tenant(context[:tenant]) do + GraphQL::Subscriptions::Serialize.load(string) + end + end +end + +# ... +class MySchema < GraphQL::Schema + # ... + use GraphQL::Subscriptions::ActionCableSubscriptions, serializer: MultiTenantSerializer +end +``` + +The implementation above will use the built-in serialization algorithms, but it will do so _in the context of_ the selected tenant. + +For __loading query context in Pusher and Ably__, add tenant selection to your `load_context` method, if required: + +```ruby +class CustomSubscriptions < GraphQL::Pro::PusherSubscriptions # or `GraphQL::Pro::AblySubscriptions` + def dump_context(ctx) + JSON.dump(ctx.to_h) + end + + def load_context(ctx_string) + ctx_data = JSON.parse(ctx_string) + MultiTenancy.select_tenant(ctx_data["tenant"]) do + # Build a symbol-keyed hash, loading objects from the database if necessary + # to use a `context: ...` + end + end +end +``` + +With that approach, the selected tenant will be active when building the context hash, in case any objects need to be loaded from the database. diff --git a/guides/subscriptions/overview.md b/guides/subscriptions/overview.md index 5a5870b7d7d..29a1241f27f 100644 --- a/guides/subscriptions/overview.md +++ b/guides/subscriptions/overview.md @@ -16,25 +16,25 @@ _Subscriptions_ allow GraphQL clients to observe specific events and receive upd - The __Implementation__ provides application-specific methods for executing & delivering updates. - __Broadcasts__ can send the same GraphQL result to any number of subscribers. -### Subscription Type +## Subscription Type `subscription` is an entry point to your GraphQL schema, like `query` or `mutation`. It is defined by your `SubscriptionType`, a root-level `GraphQL::Schema::Object`. Read more in the {% internal_link "Subscription Type guide", "subscriptions/subscription_type" %}. -### Subscription Classes +## Subscription Classes {{ "GraphQL::Schema::Subscription" | api_doc }} is a resolver class with subscription-specific behaviors. Each subscription field should be implemented by a subscription class. Read more in the {% internal_link "Subscription Classes guide", "subscriptions/subscription_classes" %} -### Triggers +## Triggers After an event occurs in our application, _triggers_ begin the update process by sending a name and payload to GraphQL. Read more in the {% internal_link "Triggers guide","subscriptions/triggers" %}. -### Implementation +## Implementation Besides the GraphQL component, your application must provide some subscription-related plumbing, for example: @@ -44,6 +44,10 @@ Besides the GraphQL component, your application must provide some subscription-r Read more in the {% internal_link "Implementation guide", "subscriptions/implementation" %} or check out the {% internal_link "ActionCable implementation", "subscriptions/action_cable_implementation" %}, {% internal_link "Pusher implementation", "subscriptions/pusher_implementation" %} or {% internal_link "Ably implementation", "subscriptions/ably_implementation" %}. -### Broadcasts +## Broadcasts By default, the subscription implementations listed above handle each subscription in total isolation. However, this behavior can be optimized by setting up broadcasts. Read more in the {% internal_link "Broadcast guide", "subscriptions/broadcast" %}. + +## Multi-Tenant + +See the {% internal_link "Multi-tenant guide", "subscriptions/multi_tenant" %} for supporting multi-tenancy in GraphQL subscriptions. diff --git a/guides/subscriptions/pusher_implementation.md b/guides/subscriptions/pusher_implementation.md index ef2d523f3af..6813ffa8b84 100644 --- a/guides/subscriptions/pusher_implementation.md +++ b/guides/subscriptions/pusher_implementation.md @@ -13,17 +13,6 @@ pro: true After creating an app on Pusher and [configuring the Ruby gem](https://github.com/pusher/pusher-http-ruby#global), you can hook it up to your GraphQL schema. -- [How it Works](#how-it-works) -- [Database setup](#database-setup) -- [Schema configuration](#schema-configuration) -- [Execution configuration](#execution-configuration) -- [Webhook configuration](#webhook-configuration) -- [Authorization](#authorization) -- [Serializing context](#serializing-context) -- [Dashboard](#dashboard) -- [Development tips](#development-tips) -- [Client configuration](#client-configuration) - ## How it Works This subscription implementation uses a hybrid approach: @@ -84,7 +73,7 @@ maxmemory-policy noeviction appendonly yes ``` -Otherwise, Redis will drop data that doesn't fit in memory (read more in ["Redis persistence"](https://redis.io/topics/persistence)). +Otherwise, Redis will drop data that doesn't fit in memory (read more in ["Redis persistence"](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)). If you're already using Redis in your application, see ["Storing Data in Redis"](https://www.mikeperham.com/2015/09/24/storing-data-with-redis/) for options to isolate data and tune your configuration. @@ -113,11 +102,35 @@ end That connection will be used for managing subscription state. All writes to Redis are prefixed with `graphql:sub:`. -There are also two configurations for managing persistence: +There are two configurations for managing persistence: - `stale_ttl_s:` expires subscription data after the given number of seconds without any update. After `stale_ttl_s` has passed, the data will expire from Redis. Each time a subscription receives an update, its TTL is refreshed. (Generally, this isn't required because the backend is built to clean itself up. But, if you find that Redis is collecting stale queries, you can set them to expire after some very long time as a safeguard.) - `cleanup_delay_s:` (default: `5`) prevents deleting a subscription during those first seconds after it's created. Usually, a longer delay isn't necessary, but if you observe latency between the subscription's initial response and the client's subscription to the delivery channel, you can set this configuration to account for it. +Also, you can use `extra_webhook_tokens: [{ key: "...", secret: "..." }, ...]` when you need to roll Pusher keys. [Pusher uses the oldest active token](https://pusher.com/docs/channels/server_api/webhooks/#authentication), so you can pass the old credentials there while you're rolling over to the new one. + +### Connection Pool + +For better performance reading and writing to Redis, you can pass a `connection_pool:` instead of `redis:`, using the [`connection_pool` gem](https://github.com/mperham/connection_pool): + +```ruby + use GraphQL::Pro::PusherSubscriptions, + connection_pool: ConnectionPool.new(size: 5, timeout: 5) { Redis.new }, +``` + +### Broadcasts + +If you set up {% internal_link "Broadcasts", "/subscriptions/broadcast" %}, then you can update many clients over a single Pusher channel. + +Broadcast channels have stable, predictable IDs. To prevent unauthorized clients from "listening in," use an [authorized Pusher channel](#authorization) for transport. In your authorization code, you can check for a broadcast using `.broadcast_subscription_id?`: + +```ruby +# In your Pusher authorization endpoint: +channel_name = params[:channel_name] +MySchema.subscriptions.broadcast_subscription_id?(channel_name) +# => true | false +``` + ## Execution configuration During execution, GraphQL will assign a `subscription_id` to the `context` hash. The client will use that ID to listen for updates, so you must return the `subscription_id` in the response headers. @@ -145,11 +158,11 @@ if result.subscription? end ``` -Read more here: ["Using CORS"](https://www.html5rocks.com/en/tutorials/cors/). +Read more here: ["Using CORS"](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS). -#### Payload Compression +### Payload Compression -To mitigate problems with [Pusher's 10kb message limit](https://support.pusher.com/hc/en-us/articles/360019115473-What-is-the-message-size-limit-when-publishing-a-message-in-Channels-), you can specify `compress_pusher_payload: true` in the `context` of your subscription. For example: +To mitigate problems with [Pusher's 10kb message limit](https://support.pusher.com/hc/en-us/articles/4412243423761-What-Is-The-Message-Size-Limit-When-Publishing-an-Event-in-Channels-), you can specify `compress_pusher_payload: true` in the `context` of your subscription. For example: ```ruby # app/controllers/graphql_controller.rb @@ -168,6 +181,16 @@ This will cause subscription payloads to include `compressed_result: "..."` inst By configuring `compress_pusher_payload: true` on a query-by-query basis, the subscription backend can continue to support clients running _old_ client code (by not compressing) while upgrading new clients to compressed payloads. +### Batched Deliveries + +By default, `PusherSubscriptions` sends updates in batches of up to 10 at a time, using [batch triggers](https://github.com/pusher/pusher-http-ruby#batches). You can customize the batch size by passing `batch_size:` when installing it, for example: + +```ruby +use GraphQL::Pro::PusherSubscriptions, batch_size: 1, ... +``` + +`batch_size: 1` will make `PusherSubscriptions` use the single trigger API instead of batch triggers. + ## Webhook configuration Your server needs to receive webhooks from Pusher when clients disconnect. This keeps your local subscription database in sync with Pusher. @@ -193,6 +216,14 @@ end This way, we'll be kept up-to-date with Pusher's unsubscribe events. +__Alternatively__, you can configure the routes to load your schema lazily, during the first request: + +```ruby +# Provide the fully-qualified class name of your schema: +lazy_routes = GraphQL::Pro::Routes::Lazy.new("MySchema") +mount lazy_routes.pusher_webhooks_client, at: "/pusher_webhooks" +``` + ## Authorization To ensure the privacy of subscription updates, you should use a [private channel](https://pusher.com/docs/client_api_guide/client_private_channels) for transport. @@ -280,4 +311,9 @@ To receive Pusher's webhooks in development, Pusher [suggests using ngrok](https ## Client configuration -Install the [Pusher JS client](https://github.com/pusher/pusher-js) then see docs for {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %} or {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %}. +Install the [Pusher JS client](https://github.com/pusher/pusher-js) then see docs for: + +- {% internal_link "Apollo Client", "/javascript_client/apollo_subscriptions" %} +- {% internal_link "Relay Modern", "/javascript_client/relay_subscriptions" %} +- {% internal_link "GraphiQL", "/javascript_client/graphiql_subscriptions" %} +- {% internal_link "urql", "/javascript_client/urql_subscriptions" %} diff --git a/guides/subscriptions/subscription_classes.md b/guides/subscriptions/subscription_classes.md index 8d6d1da0ac3..af0914dd719 100644 --- a/guides/subscriptions/subscription_classes.md +++ b/guides/subscriptions/subscription_classes.md @@ -71,7 +71,7 @@ Subscription fields take {% internal_link "arguments", "/fields/arguments" %} ju ```ruby class Subscriptions::MessageWasPosted < Subscriptions::BaseSubscription # `room_id` loads a `room` - argument :room_id, ID, required: true, loads: Types::RoomType + argument :room_id, ID, loads: Types::RoomType # It's passed to other methods as `room` def subscribe(room:) @@ -135,7 +135,7 @@ subscription($roomId: ID!) { } ``` -If you configure fields with `null: true`, then you can return different data in the initial subscription and the subsequent updates. (See lifecycle methods below.) +If you remove `null: false`, then you can return different data in the initial subscription and the subsequent updates. (See lifecycle methods below.) Instead of a generated type, you can provide an already-configured type with `payload_type`: @@ -213,7 +213,7 @@ You can implement `#authorized?` to check that the user has permission to subscr ```ruby def authorized?(room:) - context[:viewer].can_read_messages?(room) + super && context[:viewer].can_read_messages?(room) end ``` @@ -236,14 +236,12 @@ You can define this method to add initial responses or perform other logic befor ### Adding an Initial Response -(__Note__: only supported when using the new {% internal_link "Interpreter runtime", "/queries/interpreter#installation" %}) - By default, GraphQL-Ruby returns _nothing_ (`:no_response`) on an initial subscription. But, you may choose to override this and return a value in `def subscribe`. For example: ```ruby class Subscriptions::MessageWasPosted < Subscriptions::BaseSubscription # ... - field :room, Types::RoomType, null: true + field :room, Types::RoomType def subscribe(room:) # authorize, etc ... @@ -272,26 +270,22 @@ subscription($roomId: ID!) { ## Subsequent Updates with #update -(__Note__: only supported when using the new {% internal_link "Interpreter runtime", "/queries/interpreter#installation" %}) - After a client has registered a subscription, the application may trigger subscription updates with `MySchema.subscriptions.trigger(...)` (see the {% internal_link "Triggers guide", "/subscriptions/triggers" %} for more). Then, `def update` will be called for each client's subscription. In this method you can: - Unsubscribe the client with `unsubscribe` - Return a value with `super` (which returns `object`) or by returning a different value. -- Return `:no_update` to skip this update +- Return `NO_UPDATE` to skip this update ### Skipping subscription updates -(__Note__: only supported when using the new {% internal_link "Interpreter runtime", "/queries/interpreter#installation" %}) - -Perhaps you don't want to send updates to a certain subscriber. For example, if someone leaves a comment, you might want to push the new comment to _other_ subscribers, but not the commenter, who already has that comment data. You can accomplish this by returning `:no_update`. +Perhaps you don't want to send updates to a certain subscriber. For example, if someone leaves a comment, you might want to push the new comment to _other_ subscribers, but not the commenter, who already has that comment data. You can accomplish this by returning `NO_UPDATE`. ```ruby class Subscriptions::CommentWasAdded < Subscriptions::BaseSubscription def update(post_id:) comment = object # # if comment.author == context[:viewer] - :no_update + NO_UPDATE else # Continue updating this client, since it's not the commenter super @@ -302,8 +296,6 @@ end ### Returning a different object for subscription updates -(__Note__: only supported when using the new {% internal_link "Interpreter runtime", "/queries/interpreter#installation" %}) - By default, whatever object you pass to `.trigger(event_name, args, object)` will be used for responding to subscription fields. But, you can return a different object from `#update` to override this: ```ruby @@ -342,6 +334,38 @@ end - The subscription is unregistered from the backend (this is backend-specific) - The client is told to unsubscribe (this is transport-specific) -`#unsubscribe` does _not_ halt the current update. +Arguments with `loads:` configurations will call `unsubscribe` if they are `required: true` (which is the default) and their ID doesn't return a value. (It's assumed that the subscribed object was deleted.) + +You can provide a final update value with `unsubscribe` by passing a value to the method: + +```ruby +def update(room:) + if room.archived? + # Don't let anyone subscribe to messages on an archived room + unsubscribe({message: "This room has been archived"}) + else + super + end +end +``` + +## Extras + +Subscription methods can access query-related metadata by configuring `extras [...]` in the class definition. For example, to use a `lookahead` and the `ast_node`: + +```ruby +class Subscriptions::JobFinished < GraphQL::Schema::Subscription + # ... + extras [:lookahead, :ast_node] + + def subscribe(lookahead:, ast_node:) + # ... + end + + def update(lookahead:, ast_node:) + # ... + end +end +``` -Arguments with `loads:` configurations will call `unsubscribe` if they are `required: true` and their ID doesn't return a value. (It's assumed that the subscribed object was deleted.) +See the {% internal_link "Extra Field Metadata", "/fields/introduction#extra-field-metadata" %} for more information about available metadata. diff --git a/guides/subscriptions/subscription_type.md b/guides/subscriptions/subscription_type.md index fb2282f9013..e8b1c5290e9 100644 --- a/guides/subscriptions/subscription_type.md +++ b/guides/subscriptions/subscription_type.md @@ -40,9 +40,6 @@ To add subscriptions to your system, define an `ObjectType` named `Subscription` ```ruby # app/graphql/types/subscription_type.rb class Types::SubscriptionType < GraphQL::Schema::Object - # If you're using the interpreter, also add: - extend GraphQL::Subscriptions::SubscriptionRoot - field :post_was_published, subscription: Subscriptions::PostWasPublished # ... end diff --git a/guides/subscriptions/triggers.md b/guides/subscriptions/triggers.md index b6dc0e08288..c55cf7e5a53 100644 --- a/guides/subscriptions/triggers.md +++ b/guides/subscriptions/triggers.md @@ -62,3 +62,13 @@ MySchema.subscriptions.trigger(:comment_added, {}, comment, scope: author_id) ``` Since this trigger has a `scope:`, only subscribers with a matching scope value will be updated. + +## Validation + +By default, subscriptions are re-validated when a trigger causes them to send updates. To disable this, you can pass `validate_update: false` when hooking up subscriptions to your schema. For example: + +```ruby +use SomeSubscriptions, validate_update: false +``` + +If you're sure you won't be releasing breaking changes to your schema, this setting can reduce overhead in evaluating updates. diff --git a/guides/testing/helpers.md b/guides/testing/helpers.md new file mode 100644 index 00000000000..f7e248f8ba2 --- /dev/null +++ b/guides/testing/helpers.md @@ -0,0 +1,63 @@ +--- +layout: guide +doc_stub: false +search: true +section: Testing +title: Helpers +desc: Running GraphQL fields in isolation +index: 3 +--- + +GraphQL-Ruby ships with a test helper method, `run_graphql_field`, that can execute a GraphQL field in isolation. To use it in your test suite, include the module with your schema class: + +```ruby +# Mix in `run_graphql_field(...)` to run on `MySchema` +include GraphQL::Testing::Helpers.for(MySchema) +``` + +Then, you can run fields using {{ "Testing::Helpers#run_graphql_field" | api_doc }}: + +```ruby +post = Post.first +graphql_post_title = run_graphql_field("Post.title", post) +assert_equal "100 Great Ideas", graphql_post_title +``` + +`run_graphql_field` accepts two required arguments: + +- Field _path_, in `Type.field` format +- Runtime object: some non-`nil` object to resolve the field on. + +Additionally, it accepts some keyword arguments: + +- `arguments:`, GraphQL arguments to the field, in Ruby-style (underscore, symbol) or GraphQL-style (camel-case, string) +- `context:`, the GraphQL context to use for this query + +`run_graphql_field` performs several GraphQL-related steps: + +- Checks `.visible?` on the named Object Type, raising an error if it isn't visible +- Wraps the given runtime object in the GraphQL Object Type +- Checks `.authorized?` on the type, calling {{ "Schema.unauthorized_object" | api_doc }} if authorization fails +- Prepares arguments for field resolution +- Checks `#visible?` on the field, raising an error if the field isn't visible +- Checks `#authorized?` on the field, calling {{ "Schema.unauthorized_field" | api_doc }} if it fails +- Calls any {% internal_link "field extensions", "/type_definitions/field_extensions" %} +- Runs {% internal_link "Dataloader", "/dataloader/overview" %} and/or GraphQL-Batch, as needed + +## Resolving fields on the same object + +You can use {{ "Testing::Helpers#with_resolution_context" | api_doc }} to use the same type, runtime object, and GraphQL context for multiple field resolutions. For example: + +```ruby +# Assuming `include GraphQL::Testing::Helpers.for(MySchema)` +# was used above ... +with_resolution_context(type: "Post", object: example_post, context: { current_user: author }) do |rc| + assert_equal "100 Great Ideas", rc.run_graphql_field("title") + assert_equal true, rc.run_graphql_field("viewerIsAuthor") + assert_equal 5, rc.run_graphql_field("commentsCount") + # Optionally, pass `arguments:` for the field: + assert_equal 9, rc.run_graphql_field("commentsCount", arguments: { include_unmoderated: true }) +end +``` + +The method yields a resolution context (`rc`, above) which responds to `run_graphql_field`. diff --git a/guides/testing/integration_tests.md b/guides/testing/integration_tests.md index 9f2168894e5..d21b6acac80 100644 --- a/guides/testing/integration_tests.md +++ b/guides/testing/integration_tests.md @@ -82,7 +82,7 @@ it "doesn't show draft posts to anyone except their author" do } GRAPHQL - post_id = MySchema.id_from_object(post, Types::Post, {}) + post_id = MySchema.id_from_object(draft_post, Types::Post, {}) # Authors can see their drafts: author_result = MySchema.execute(query_string, context: { viewer: author }, variables: { id: post_id }) @@ -104,7 +104,7 @@ GraphQL is usually served over HTTP. You probably want tests that make sure that - Authentication headers are used to load a `context[:viewer]` -In Rails, you might use a [functional test](https://guides.rubyonrails.org/testing.html#functional-tests-for-your-controllers) for this, for example: +In Rails, you might use a [functional test](https://guides.rubyonrails.org/testing.html#functional-testing-for-controllers) for this, for example: ```ruby it "loads user token into the viewer" do @@ -120,6 +120,6 @@ it "loads user token into the viewer" do headers: { "Authorization" => "Bearer #{user.auth_token}" } json_response = JSON.parse(@response.body) - assert_equal user.username, json_response["data"]["viewer"], "Authenticated requests load the viewer" + assert_equal user.username, json_response["data"]["viewer"]["username"], "Authenticated requests load the viewer" end ``` diff --git a/guides/testing/overview.md b/guides/testing/overview.md index 8b1798352a9..6c12f827be0 100644 --- a/guides/testing/overview.md +++ b/guides/testing/overview.md @@ -14,4 +14,5 @@ redirect_from: So, you've spiked a GraphQL API, and now you're ready to tighten things up and add some proper tests. These guides will help you think about how to ensure stability and compatibility for your GraphQL system. - {% internal_link "Structure testing", "/testing/schema_structure" %} verifies that schema changes are backwards-compatible. This way, you don't break existing clients. -- {% internal_link "Runtime testing", "/testing/integration_tests" %} exercises the various behaviors of the GraphQL system, making sure that it returns the right data to the right clients. +- {% internal_link "Integration testing", "/testing/integration_tests" %} exercises the various behaviors of the GraphQL system, making sure that it returns the right data to the right clients. +- {% internal_link "Testing helpers", "/testing/helpers" %} for running GraphQL fields without writing a whole query diff --git a/guides/testing/profiling.md b/guides/testing/profiling.md new file mode 100644 index 00000000000..68c577a05f3 --- /dev/null +++ b/guides/testing/profiling.md @@ -0,0 +1,102 @@ +--- +layout: guide +doc_stub: false +search: true +section: Testing +title: Profiling +desc: Profiling the performance of GraphQL-Ruby +index: 4 +--- + +If you want to know more about how time is spent during GraphQL queries, including GraphQL-Ruby internals, you can use Ruby profiling tools to take a closer look. + +If you want to investigate GraphQL-Ruby performance together, prepare a runtime profile and memory profile as described below and {% open_an_issue "Performance investigation" %} on GitHub, including those files. + +## StackProf + +[StackProf](https://github.com/tmm1/stackprof) is a Ruby library for figuring out where an operation's time is spent. To capture a profile, surround a block with `StackProf.run { ... }`. + +```ruby +require "stackprof" + +# Prepare any GraphQL-related data or context: +query_string = "{ someGraphQL ... }" +context = { ... } + +# This will dump a profile in `tmp/graphql-prof.dump` +StackProf.run(mode: :wall, interval: 10, out: "tmp/graphql-prof.dump") do + # Execute the query inside the block: + MySchema.execute(query_string, context: context) +end +``` + +The `out:` option tells StackProf to create a "dump" at the given location. Then, anyone who has that file can investigate the profile using the `stackprof` command, for example: + +``` +$ stackprof tmp/graphql-prof.dump +================================== + Mode: wall(1) + Samples: 2492 (58.06% miss rate) + GC: 0 (0.00%) +================================== + TOTAL (pct) SAMPLES (pct) FRAME + 902 (36.2%) 94 (3.8%) GraphQL::Execution::Interpreter::Runtime#evaluate_selection_with_resolved_keyword_args + 1283 (51.5%) 87 (3.5%) GraphQL::Execution::Interpreter::Runtime#continue_field + 274 (11.0%) 78 (3.1%) GraphQL::Schema::Field#resolve + 1068 (42.9%) 73 (2.9%) GraphQL::Execution::Interpreter::Runtime#evaluate_selection + # ... +``` + +Additionally, `stackprof` accepts a `--method` argument which provides details about the performance and usage of a specific method, for example: + +``` +$ stackprof tmp/small.dump --method #gather_selections +GraphQL::Execution::Interpreter::Runtime#gather_selections (/Users/rmosolgo/code/graphql-ruby/lib/graphql/execution/interpreter/runtime.rb:305) + samples: 17 self (0.7%) / 17 total (0.7%) + callers: + 16 ( 94.1%) GraphQL::Execution::Interpreter::Runtime#continue_field + 6 ( 35.3%) Array#each + 1 ( 5.9%) GraphQL::Execution::Interpreter::Runtime#run_eager + callees (0 total): + 6 ( Inf%) Array#each + code: + 1 (0.0%) / 1 (0.0%) | 305 | when :lookahead + 6 (0.2%) / 6 (0.2%) | 306 | if !field_ast_nodes + 3 (0.1%) / 3 (0.1%) | 307 | field_ast_nodes = [ast_node] + | 308 | end +``` + +Anyone with the `.dump` file can perform this analysis -- it's a really useful file! If you want to investigate GraphQL-Ruby performance together, please share a runtime profile. + + +## MemoryProfiler + +[MemoryProfiler](https://github.com/SamSaffron/memory_profiler) provides insight into where an operation interacts with system memory and the Ruby heap. This is helpful because memory usage problems cause code to run slowly; fixing them can make code run fast. + +To produce a report, wrap a block in `MemoryProfiler.report { ... }` and then call `.pretty_print` on the result. For example, to create a report on a GraphQL query: + +```ruby +require 'memory_profiler' + +# Prepare any GraphQL-related data or context: +query_string = "{ someGraphQL ... }" +context = { ... } + +report = MemoryProfiler.report do + # Execute the query inside the block: + MySchema.execute(query_string, context: context) +end + +# Write the result to a file +report.pretty_print(to_file: "tmp/graphql-memory.txt") +``` + +The report will include many interesting sections including: + +- Total memory and objects allocated +- Objects allocated by location and by class +- String allocations, including the number of times a string with the same value was allocated + +All of these can indicate "hot spots" in the code and inform refactors to reduce memory use. In turn, this reduces time spent in Ruby GC. + +If you want to investigate GraphQL-Ruby performance together, please share a memory profile. diff --git a/guides/testing/schema_structure.md b/guides/testing/schema_structure.md index b2e301b31ca..e2e1e85fc45 100644 --- a/guides/testing/schema_structure.md +++ b/guides/testing/schema_structure.md @@ -44,10 +44,7 @@ namespace :graphql do puts "\n" results = queries.map { |query| schema.validate(query) } - errors = results - .select { |result| result[:errors].present? } - .map { |result| result[:errors] } - .flatten + errors = results.flatten if errors.empty? puts '✅ All queries are valid' diff --git a/guides/type_definitions/directives.md b/guides/type_definitions/directives.md index 23611961869..21182f0c454 100644 --- a/guides/type_definitions/directives.md +++ b/guides/type_definitions/directives.md @@ -6,7 +6,6 @@ section: Type Definitions title: Directives desc: Special instructions for the GraphQL runtime index: 10 -experimental: true --- @@ -37,8 +36,6 @@ Here's how the two built-in directives work: - `@skip(if: ...)` skips the selection if the `if: ...` value is truthy ({{ "GraphQL::Schema::Directive::Skip" | api_doc }}) - `@include(if: ...)` includes the selection if the `if: ...` value is truthy ({{ "GraphQL::Schema::Directive::Include" | api_doc }}) -GraphQL-Ruby also supports custom runtime directives for use with the {% internal_link "interpreter runtime", "/queries/interpreter" %}. - ### Custom Runtime Directives Custom directives extend {{ "GraphQL::Schema::Directive" | api_doc }}: @@ -102,7 +99,7 @@ To make a custom schema directive, extend {{ "GraphQL::Schema::Directive" | api_ ```ruby # app/graphql/directives/permission.rb class Directives::Permission < GraphQL::Schema::Directive - argument :level, String, required: true + argument :level, String locations FIELD_DEFINITION, OBJECT end ``` @@ -149,6 +146,6 @@ end Like fields, directives may have {% internal_link "arguments", "/fields/arguments" %} : ```ruby -argument :if, Boolean, required: true, +argument :if, Boolean, description: "Skips the selection if this condition is true" ``` diff --git a/guides/type_definitions/enums.md b/guides/type_definitions/enums.md index 00886e6486b..7575b811062 100644 --- a/guides/type_definitions/enums.md +++ b/guides/type_definitions/enums.md @@ -8,7 +8,7 @@ desc: Enums are sets of discrete values index: 2 --- -Enum types are sets of discrete values. An enum field must return one of the possible values of the enum. In the [GraphQL Schema Definition Language](https://graphql.org/learn/schema/#type-language) (SDL), enums are described like this: +Enum types are sets of discrete values. An enum field must return one of the possible values of the enum. In the [GraphQL Schema Definition Language](https://graphql.org/learn/schema/#enum-types) (SDL), enums are described like this: ```ruby enum MediaCategory { @@ -43,7 +43,7 @@ In your application, enums extend {{ "GraphQL::Schema::Enum" | api_doc }} and de ```ruby # First, a base class -# app/graphql/types/base_enum +# app/graphql/types/base_enum.rb class Types::BaseEnum < GraphQL::Schema::Enum end @@ -59,6 +59,7 @@ end Each value may have: - A description (as the second argument or `description:` keyword) +- A comment (as a `comment:` keyword) - A deprecation reason (as `deprecation_reason:`), marking this value as deprecated - A corresponding Ruby value (as `value:`), see below @@ -71,3 +72,25 @@ value "AUDIO", value: :audio Then, GraphQL inputs of `AUDIO` will be converted to `:audio` and Ruby values of `:audio` will be converted to `"AUDIO"` in GraphQL responses. Enum classes are never instantiated and their methods are never called. + +You can get the GraphQL name of the enum value using the method matching its downcased name: + +```ruby +Types::MediaCategory.audio # => "AUDIO" +``` + +You can pass a `value_method:` to override the value of the generated method: + +```ruby +value "AUDIO", value: :audio, value_method: :lo_fi_audio + +# ... + +Types::MediaCategory.lo_fi_audio # => "AUDIO" +``` + +Also, you can completely skip the method generation by setting `value_method` to `false` + +```ruby +value "AUDIO", value: :audio, value_method: false +``` diff --git a/guides/type_definitions/extensions.md b/guides/type_definitions/extensions.md index 56d3b845405..214570d734b 100644 --- a/guides/type_definitions/extensions.md +++ b/guides/type_definitions/extensions.md @@ -80,7 +80,7 @@ So, you can customize this process by: For example, you can create a custom class which accepts a new parameter to `initialize`: ```ruby -class Types::AuthorizedField < GraphQL::Schema::Field +class Types::BaseField < GraphQL::Schema::Field # Override #initialize to take a new argument: def initialize(*args, required_permission: nil, **kwargs, &block) @required_permission = required_permission @@ -97,20 +97,20 @@ Then, pass the field class as `field_class(...)` wherever it should be used: ```ruby class Types::BaseObject < GraphQL::Schema::Object # Use this class for defining fields - field_class AuthorizedField + field_class BaseField end # And.... class Types::BaseInterface < GraphQL::Schema::Interface - field_class AuthorizedField + field_class BaseField end class Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation - field_class AuthorizedField -end + field_class BaseField +end ``` -Now, `AuthorizedField.new(*args, &block)` will be used to create `GraphQL::Schema::Field`s on those types. At runtime `field.required_permission` will return the configured value. +Now, `BaseField.new(*args, &block)` will be used to create `GraphQL::Schema::Field`s on those types. At runtime `field.required_permission` will return the configured value. ### Customizing Connections @@ -175,4 +175,4 @@ Enum values may be customized in a similar way to Fields. - Create a new class extending `GraphQL::Schema::EnumValue` - Assign it to your base `Enum` class with `enum_value_class(MyEnumValueClass)` -Then, in your custom argument class, you can use `#initialize(name, desc = nil, **kwargs)` to take input from the DSL. +Then, in your custom enum class, you can use `#initialize(name, desc = nil, **kwargs)` to take input from the DSL. diff --git a/guides/type_definitions/field_extensions.md b/guides/type_definitions/field_extensions.md index 7322d385af5..45a06faba74 100644 --- a/guides/type_definitions/field_extensions.md +++ b/guides/type_definitions/field_extensions.md @@ -10,7 +10,7 @@ index: 10 {{ "GraphQL::Schema::FieldExtension" | api_doc }} provides a way to modify user-defined fields in a programmatic way. For example, Relay connections are implemented as a field extension ({{ "GraphQL::Schema::Field::ConnectionExtension" | api_doc }}). -### Making a new extension +## Making a new extension Field extensions are subclasses of {{ "GraphQL::Schema::FieldExtension" | api_doc }}: @@ -19,7 +19,7 @@ class MyExtension < GraphQL::Schema::FieldExtension end ``` -### Using an extension +## Using an extension Defined extensions can be added to fields using the `extensions: [...]` option or the `extension(...)` method: @@ -33,7 +33,7 @@ end See below for how extensions may modify fields. -### Modifying field configuration +## Modifying field configuration When extensions are attached, they are initialized with a `field:` and `options:`. Then, `#apply` is called, when they may extend the field they're attached to. For example: @@ -48,7 +48,21 @@ end This way, an extension can encapsulate a behavior requiring several configuration options. -### Modifying field execution +## Adding default argument configurations + +Extensions may provide _default_ argument configurations which are applied if the field doesn't define the argument for itself. The configuration is passed to {{ "Schema::FieldExtension.default_argument" | api_doc }}. For example, to define a `:query` argument if the field doesn't already have one: + +```ruby +class SearchableExtension < GraphQL::Schema::FieldExtension + # Any field which uses this extension and _doesn't_ define + # its own `:query` argument will get an argument configured with this: + default_argument(:query, String, required: false, description: "A search query") +end +``` + +Additionally, extensions may implement `def after_define` which is called _after_ the field's `do .. . end` block. This is helpful when an extension should provide _default_ configurations without overriding anything in the field definition. (When extensions are added by calling `field.extension(...)` on an already-defined field `def after_define` is called immediately.) + +## Modifying field execution Extensions have two hooks that wrap field resolution. Since GraphQL-Ruby supports deferred execution, these hooks _might not_ be called back-to-back. @@ -58,7 +72,7 @@ After resolution and _after_ syncing lazy values (like `Promise`s from `graphql- See the linked API docs for the parameters of those methods. -#### Execution "memo" +### Execution "memo" One parameter to `after_resolve` deserves special attention: `memo:`. `resolve` _may_ yield a third value. For example: @@ -83,7 +97,7 @@ This allows the `resolve` hook to pass data to `after_resolve`. Instance variables may not be used because, in a given GraphQL query, the same field may be resolved several times concurrently, and that would result in overriding the instance variable in an unpredictable way. (In fact, extensions are frozen to prevent instance variable writes.) -### Extension options +## Extension options The `extension(...)` method takes an optional second argument, for example: @@ -101,3 +115,52 @@ def after_resolve(value:, **rest) value.limit(options[:limit]) end ``` + +If you use the `extensions: [...]` option, you can pass options using a hash: + +```ruby +field :name, String, null: false, extensions: [LimitExtension => { limit: 20 }] +``` + +## Using `extras` + +Extensions can have the same `extras` as fields (see {% internal_link "Extra Field Metadata", "fields/introduction#extra-field-metadata" %}). Add them by calling `extras` in the class definition: + +```ruby +class MyExtension < GraphQL::Schema::FieldExtension + extras [:ast_node, :errors, ...] +end +``` + +Any configured `extras` will be present in the given `arguments`, but removed before the field is resolved. (However, `extras` from _any_ extension will be present in `arguments` for _all_ extensions.) + +## Adding an extension by default + +If you want to apply an extension to _all_ your fields, you can do this in your {% internal_link "BaseField", "/type_definitions/extensions.html#customizing-fields" %}'s `def initialize`, for example: + +```ruby +class Types::BaseField < GraphQL::Schema::Field + def initialize(*args, **kwargs, &block) + super + # Add this to all fields based on this class: + extension(MyDefaultExtension) + end +end +``` + +You can also _conditionally_ apply extensions in `def initialize` by adding keywords to the method definition, for example: + +```ruby +class Types::BaseField < GraphQL::Schema::Field + # @param custom_extension [Boolean] if false, `MyCustomExtension` won't be added + # @example skipping `MyCustomExtension` + # field :no_extension, String, custom_extension: false + def initialize(*args, custom_extension: true, **kwargs, &block) + super(*args, **kwargs, &block) + # Don't apply this extension if the field is configured with `custom_extension: false`: + if custom_extension + extension(MyCustomExtensions) + end + end +end +``` diff --git a/guides/type_definitions/input_objects.md b/guides/type_definitions/input_objects.md index 7f8aef9e2ed..3cfadd00775 100644 --- a/guides/type_definitions/input_objects.md +++ b/guides/type_definitions/input_objects.md @@ -18,7 +18,7 @@ mutation { } ``` -Like a Ruby `Hash`, an input object consists of keys and values. Unlike a Hash, its keys and value types must be defined statically, as part of the GraphQL system. For example, here's an input object, expressed in the [GraphQL Schema Definition Language](https://graphql.org/learn/schema/#type-language) (SDL): +Like a Ruby `Hash`, an input object consists of keys and values. Unlike a Hash, its keys and value types must be defined statically, as part of the GraphQL system. For example, here's an input object, expressed in the [GraphQL Schema Definition Language](https://graphql.org/learn/schema/#input-object-types) (SDL): ```ruby input PostAttributes { @@ -46,13 +46,13 @@ end class Types::PostAttributes < Types::BaseInputObject description "Attributes for creating or updating a blog post" - argument :title, String, "Header for the post", required: true - argument :full_text, String, "Full body of the post", required: true + argument :title, String, "Header for the post" + argument :full_text, String, "Full body of the post" argument :categories, [Types::PostCategory], required: false end ``` -For a full description of the `argument(...)` method, see the {% internal_link "argument section of the Objects guide","/type_definitions/objects#field-arguments" %}. +For a full description of the `argument(...)` method, see the {% internal_link "argument section of the Objects guide","/fields/arguments.html" %}. ## Using Input Objects @@ -62,44 +62,46 @@ Input objects are passed to field methods as an instance of their definition cla - calling `#[]` with the _camel-cased_ name of the argument (this is for compatibility with previous GraphQL-Ruby versions) ```ruby -# This field takes an argument called `attributes` -# which will be an instance of `PostAttributes` -field :create_post, Types::Post, null: false do - argument :attributes, Types::PostAttributes, required: true -end +class Types::MutationType < GraphQL::Schema::Object + # This field takes an argument called `attributes` + # which will be an instance of `PostAttributes` + field :create_post, Types::Post, null: false do + argument :attributes, Types::PostAttributes + end -def create_post(attributes:) - puts attributes.class.name - # => "Types::PostAttributes" - # Access a value by method (underscore-cased): - puts attributes.full_text - # => "This is my first post" - # Or by hash-style lookup (camel-cased, for compatibility): - puts attributes[:fullText] - # => "This is my first post" + def create_post(attributes:) + puts attributes.class.name + # => "Types::PostAttributes" + # Access a value by method (underscore-cased): + puts attributes.full_text + # => "This is my first post" + # Or by hash-style lookup (camel-cased, for compatibility): + puts attributes[:fullText] + # => "This is my first post" + end end ``` ## Customizing Input Objects -You can customize the `GraphQL::Schema::Argument` class which is used for input objects: +You can customize the `GraphQL::Schema::Argument` class which is used for input objects: -```ruby -class Types::BaseArgument < GraphQL::Schema::Argument - # your customization here ... -end +```ruby +class Types::BaseArgument < GraphQL::Schema::Argument + # your customization here ... +end -class Types::BaseInputObject < GraphQL::Schema::InputObject - # Hook up the customized argument class - argument_class(Types::BaseArgument) -end +class Types::BaseInputObject < GraphQL::Schema::InputObject + # Hook up the customized argument class + argument_class(Types::BaseArgument) +end ``` You can also add or override methods on input object classes to customize them. They have two instance variables by default: -- `@arguments`: A {{ "GraphQL::Query::Arguments" | api_doc }} instance +- `@arguments`: A {{ "GraphQL::Execution::Interpreter::Arguments" | api_doc }} instance - `@context`: The current {{ "GraphQL::Query::Context" | api_doc }} Any extra methods you define on the class can be used for field resolution, as demonstrated above. @@ -111,8 +113,8 @@ Your input objects can be automatically converted to other Ruby types before the ```ruby class Types::DateRangeInput < Types::BaseInputObject description "Range of dates" - argument :min, Types::Date, "Minimum value of the range", required: true - argument :max, Types::Date, "Maximum value of the range", required: true + argument :min, Types::Date, "Minimum value of the range" + argument :max, Types::Date, "Maximum value of the range" def prepare min..max @@ -121,7 +123,7 @@ end class Types::CalendarType < Types::BaseObject field :appointments, [Types::Appointment], "Appointments on your calendar", null: false do - argument :during, Types::DateRangeInput, "Only show appointments within this range", required: true + argument :during, Types::DateRangeInput, "Only show appointments within this range" end def appointments(during:) @@ -130,3 +132,23 @@ class Types::CalendarType < Types::BaseObject end end ``` + +## `@oneOf` + +You can make input objects that require _exactly one_ field to be provided using `one_of`: + +```ruby +class FindUserInput < Types::BaseInput + one_of + # Either `{ id: ... }` or `{ username: ... }` may be given, + # but not both -- and one of them _must_ be given. + argument :id, ID, required: false + argument :username, String, required: false +end +``` + +An input object with `one_of` will require exactly one given argument and it will require that the given argument's value is not `nil`. With `one_of`, arguments must have `required: false`, since any _individual_ argument is not required. + +When you use `one_of`, it will appear in schema print-outs with `input ... @oneOf` and you can query it using `{ __type(name: $typename) { isOneOf } }`. + +This behavior was adopted to the September 2025 GraphQL specification. diff --git a/guides/type_definitions/interfaces.md b/guides/type_definitions/interfaces.md index 267ded7d962..84738ff4ede 100644 --- a/guides/type_definitions/interfaces.md +++ b/guides/type_definitions/interfaces.md @@ -14,7 +14,7 @@ Interfaces are lists of fields which may be implemented by object types. An interface has fields, but it's never actually instantiated. Instead, objects may _implement_ interfaces, which makes them a _member_ of that interface. Also, fields may _return_ interface types. When this happens, the returned object may be any member of that interface. -For example, let's say a `Customer` (interface) may be either an `Individual` (object) or a `Company` (object). Here's the structure in the [GraphQL Schema Definition Language](https://graphql.org/learn/schema/#type-language) (SDL): +For example, let's say a `Customer` (interface) may be either an `Individual` (object) or a `Company` (object). Here's the structure in the [GraphQL Schema Definition Language](https://graphql.org/learn/schema/#interface-types) (SDL): ```graphql interface Customer { @@ -80,6 +80,7 @@ Then, include that into each interface: ```ruby module Types::RetailItem include Types::BaseInterface + comment "TODO comment in the RetailItem interface" description "Something that can be bought" field :price, Types::Price, "How much this item costs", null: false @@ -177,6 +178,8 @@ end The type definition DSL uses this mechanism, too, so you can override those methods here also. +Note: Under the hood, `definition_methods` causes a module to be `extend`ed by the interface. Any calls to `extend` or `implement` may override methods from `definition_methods`. + ### Resolve Type When a field's return type is an interface, GraphQL has to figure out what _specific_ object type to use for the return value. In the example above, each `customer` must be categorized as an `Individual` or `Company`. You can do this by: @@ -237,7 +240,7 @@ If you add an object type which implements an interface, but that object type do module Types::RetailItem include Types::BaseInterface # ... - orphan_types Types::Comment + orphan_types Types::Car end ``` @@ -245,7 +248,7 @@ Alternatively you can add the object types to the schema's `orphan_types`: ```ruby class MySchema < GraphQL::Schema - orphan_types Types::Comment + orphan_types Types::Car end ``` diff --git a/guides/type_definitions/lists.md b/guides/type_definitions/lists.md index 83f475cdb92..aff9a4db2b2 100644 --- a/guides/type_definitions/lists.md +++ b/guides/type_definitions/lists.md @@ -8,7 +8,7 @@ desc: Ordered lists containing other types index: 6 --- -GraphQL has _list types_ which are ordered lists containing items of other types. The following examples use the [GraphQL Schema Definition Language](https://graphql.org/learn/schema/#type-language) (SDL). +GraphQL has _list types_ which are ordered lists containing items of other types. The following examples use the [GraphQL Schema Definition Language](https://graphql.org/learn/schema/#list) (SDL). Fields may return a single scalar value (eg `String`), or a _list_ of scalar values (eg, `[String]`, a list of strings): @@ -56,7 +56,7 @@ To define a list type in Ruby use `[...]` (a Ruby array with one member, the inn ```ruby # A field returning a list type: # Equivalent to `aliases: [String!]` above -field :aliases, [String], null: true +field :aliases, [String] # An argument which accepts a list type: argument :categories, [Types::PostCategory], required: false @@ -83,8 +83,9 @@ Combining list types and non-null types can be a bit tricky. There are four poss Here's how those combinations play out:   | nullable field | non-null field + ------|------|------ nullable items | [Integer, null: true], null: true
# => [Int] | [Integer, null: true], null: false
# => [Int]! -non-null items | [Integer], null: true
# => [Int!] | [Integer], null: false
# => [Int!]! +non-null items | [Integer]
# => [Int!] | [Integer], null: false
# => [Int!]! (The first line is GraphQL-Ruby code. The second line, beginning with `# =>`, is the corresponding GraphQL SDL code.) @@ -105,11 +106,11 @@ In this example, `scores` may not return `null`. It must _always_ return a list. Here are values the field may return: -Valid | Invalid -------|------ -`[]` | `null` -`[1, 2, ...]`| `[null]` -| | `[1, null, 2, ...]` +| Valid | Invalid | +| ------ | ------ | +| `[]` | `null` | +| `[1, 2, ...]` | `[null]` | +| | `[1, null, 2, ...]` | ### Non-null lists with nullable items @@ -137,7 +138,7 @@ Valid | Invalid Here's an example field: ```ruby -field :scores, [Integer, null: true], null: true +field :scores, [Integer, null: true] # In GraphQL, # scores: [Int] ``` @@ -160,7 +161,7 @@ Valid | Invalid Here's an example field: ```ruby -field :scores, [Integer], null: true +field :scores, [Integer] # In GraphQL, # scores: [Int!] ``` diff --git a/guides/type_definitions/non_nulls.md b/guides/type_definitions/non_nulls.md index a6fc46512bc..330d5aa8e7e 100644 --- a/guides/type_definitions/non_nulls.md +++ b/guides/type_definitions/non_nulls.md @@ -8,7 +8,7 @@ desc: Values which must be present index: 7 --- -GraphQL's concept of _non-null_ is expressed in the [Schema Definition Language](https://graphql.org/learn/schema/#type-language) (SDL) with `!`, for example: +GraphQL's concept of _non-null_ is expressed in the [Schema Definition Language](https://graphql.org/learn/schema/#non-null) (SDL) with `!`, for example: ```graphql type User { @@ -42,11 +42,11 @@ This means that the field will _never_ be `nil` (and if it is, it will be remove When `!` is used for arguments (like `followers(since: DateTime!)` above), it means that the argument is _required_ for the query to execute. Any query which doesn't have a value for that argument will be rejected immediately. -To make an argument non-null in Ruby, use `required: true`, for example: +Arguments are non-null by default. You can use `required: false` to mark arguments as optional: ```ruby -# equivalent to `since: DateTime!` above -argument :since, Types::DateTime, required: true +# This will be `since: DateTime` instead of `since: DateTime!` +argument :since, Types::DateTime, required: false ``` -This means that any query _without_ a value for `since:` will be rejected. +Without `required: false`, any query _without_ a value for `since:` will be rejected. diff --git a/guides/type_definitions/objects.md b/guides/type_definitions/objects.md index 78bae7ad69c..1718c2810b4 100644 --- a/guides/type_definitions/objects.md +++ b/guides/type_definitions/objects.md @@ -48,7 +48,7 @@ The same object can be defined using Ruby: ```ruby class Types::User < GraphQL::Schema::Object - field :email, String, null: true + field :email, String field :handle, String, null: false field :friends, [User], null: false end @@ -71,6 +71,7 @@ end # then... class Types::TodoList < Types::BaseObject + comment "Comment of the TodoList type" description "A list of items which may be completed" field :name, String, "The unique name of this list", null: false diff --git a/guides/type_definitions/scalars.md b/guides/type_definitions/scalars.md index 39b546f1809..b5d15647106 100644 --- a/guides/type_definitions/scalars.md +++ b/guides/type_definitions/scalars.md @@ -17,6 +17,7 @@ Scalars are "leaf" values in GraphQL. There are several built-in scalars, and yo - `ID`, which a specialized `String` for representing unique object identifiers - `ISO8601DateTime`, an ISO 8601-encoded datetime - `ISO8601Date`, an ISO 8601-encoded date +- `ISO8601Duration`, an ISO 8601-encoded duration. ⚠ This requires `ActiveSupport::Duration` to be loaded and will raise {{ "GraphQL::Error" | api_doc }} if it's `.coerce_*` methods are called when it is not defined. - `JSON`, ⚠ This returns arbitrary JSON (Ruby hashes, arrays, strings, integers, floats, booleans and nils). Take care: by using this type, you completely lose all GraphQL type safety. Consider building object types for your data instead. - `BigInt`, a numeric value which may exceed the size of a 32-bit integer @@ -39,6 +40,8 @@ field :id, ID, null: false field :created_at, GraphQL::Types::ISO8601DateTime, null: false # ISO8601Date field field :birthday, GraphQL::Types::ISO8601Date, null: false +# ISO8601Duration field +field :age, GraphQL::Types::ISO8601Duration, null: false # JSON field ⚠ field :parameters, GraphQL::Types::JSON, null: false # BigInt field @@ -49,10 +52,10 @@ Custom scalars (see below) can also be used by name: ```ruby # `homepage: Url` -field :homepage, Types::Url, null: true +field :homepage, Types::Url ``` -In the [Schema Definition Language](https://graphql.org/learn/schema/#type-language) (SDL), scalars are simply named: +In the [Schema Definition Language](https://graphql.org/learn/schema/#scalar-types) (SDL), scalars are simply named: ```ruby scalar DateTime @@ -70,6 +73,7 @@ end # app/graphql/types/url.rb class Types::Url < Types::BaseScalar + comment "TODO comment of the scalar" description "A valid URL, transported as a string" def self.coerce_input(input_value, context) @@ -97,5 +101,4 @@ Your class must define two class methods: When incoming data is incorrect, the method may raise {{ "GraphQL::CoercionError" | api_doc }}, which will be returned to the client in the `"errors"` key. - Scalar classes are never initialized; only their `.coerce_*` methods are called at runtime. diff --git a/guides/type_definitions/unions.md b/guides/type_definitions/unions.md index d9ec1277096..4b99f55f6c4 100644 --- a/guides/type_definitions/unions.md +++ b/guides/type_definitions/unions.md @@ -8,7 +8,7 @@ desc: Unions are sets of types which may appear in the same place (but don't sha index: 5 --- -A union type is a set of object types which may appear in the same spot. Here's a union, expressed in [GraphQL Schema Definition Language](https://graphql.org/learn/schema/#type-language) (SDL): +A union type is a set of object types which may appear in the same spot. Here's a union, expressed in [GraphQL Schema Definition Language](https://graphql.org/learn/schema/#union-types) (SDL): ```ruby union MediaItem = AudioClip | VideoClip | Image | TextSnippet @@ -54,6 +54,7 @@ Then, extend that one for each union in your schema: ```ruby class Types::CommentSubject < Types::BaseUnion + comment "TODO comment on the union" description "Objects which may be commented on" possible_types Types::Post, Types::Image diff --git a/javascript_client/CHANGELOG.md b/javascript_client/CHANGELOG.md index 3489d958b9a..faaa0977db4 100644 --- a/javascript_client/CHANGELOG.md +++ b/javascript_client/CHANGELOG.md @@ -1,5 +1,163 @@ # graphql-ruby-client +# 1.15.2 (23 Jul 2026) + +- Correctly forward errors with `createRelaySubscriptionHandler` #5674 + +# 1.15.1 (12 Jun 2026) + +- ActionCable: fix for when `crypto.randomUUID` isn't present #5649 + +# 1.15.0 (26 May 2026) + +- Include ESM artifacts in the published library #5621 +- Use `crypto` to generate channel IDs when using ActionCable. (A `createChannelId: () => string` option is available if you need to customize this.) #5642 #5643 + +# 1.14.9 (12 Dec 2025) + +- Update `glob` #5472 + +# 1.14.8 (17 Jun 2025) + +- Update some dependencies #5387 + +# 1.14.7 (6 Jun 2025) + +- `AblyLink`: Support calling `.unsubscribe()` from application code #5374 + +# 1.14.6 (25 Mar 2025) + +- `ActionCableLink`: accept ActionCable subscription callbacks #5288 + +# 1.14.5 (8 Nov 2024) + +- `sync`: Fix `--dump-payload` with `--outfile` #5152 + +# 1.14.4 (8 Nov 2024) + +- ActionCable: prevent unsubscribe being called twice with Relay and Urql #5150 + +# 1.14.3 (5 Nov 2024) + +- `createActionCableHandler`: Make sure `unsubscribe` is only called once #5109 + +# 1.14.2 (4 Nov 2024) + +- `sync`: Add a `--dump-payload` option for printing out the HTTP Post data #5143 + +# 1.14.1 (30 Sept 2024) + +- `AblyLink`: don't set up an Ably subscription when no Subscription header is present #5113 + +# 1.14.0 (3 Jul 2024) + +- Subscriptions: with Relay and ActionCable, don't send an empty query string (`""`) when using persisted operations #5008 + +# 1.13.3 (20 Mar 2024) + +- Subscriptions: Support `urql` + ActionCable #4886 + +# 1.13.2 (28 Feb 2024) + +- Update `glob` to v10+ to eliminate dependency on `inflight` #4859 + +# 1.13.1 (23 Feb 2024) + +- createAblyHandler: add typing for `onError` handler #4845 + +# 1.13.0 (23 Jan 2024) + +- Sync: add support for `generate-persisted-query-manifest` files #4798 +- createActionCableHandler: remove needless `perform("send", ...)` call #4793 + +# 1.12.1 (29 Dec 2023) + +- GraphiQL: support custom `channelName` and `url` in ActionCable fetcher #4756 + +# 1.12.0 (7 Dec 2023) + +- Add GraphiQL support for subscriptions #4724 + +# 1.11.10 (17 Nov 2023) + +- `createRelaySubscriptionHandler`: Support Relay persisted queries with ActionCable #4705 + +# 1.11.9 (1 Sept 2023) + +- `createRelaySubscriptionHandler`: fix error handling in handler functions #4603 + +# 1.11.8 (9 May 2023) + +- ActionCable: accept a custom `channelName` for `createActionCableHandler` and `addGraphQLSubscriptions` #4463 + +# 1.11.7 (24 February 2023) + +- ActionCableLink: fix race condition #4359 + +# 1.11.6 (14 February 2023) + +- Sync: fix `--changeset-version` #4328 +- Improve verbose logging #4328 + +# 1.11.5 (27 January 2023) + +- Sync: add a `--changeset-version` for use with Changesets #4304 +- Sync: fix handling of `--header` with a single header + +# 1.11.4 (4 January 2023) + +- PusherLink: pass initial response along to the client #4282 + +# 1.11.3 (13 October 2022) + +- `createAblySubscriptions`: don't use `Error.captureStackTrace` which isn't supported in all JS runtimes #4223 +- `createAblySubscriptions`: properly handle empty initial response from the interpreter (`{}`) #4226 + +# 1.11.2 (26 August 2022) + +- Sync: Add a `--header` option for custom headers #4171 + +# 1.11.1 (19 July 2022) + +- Subscriptions: ActionCableLink: only forward the result if `data` or `errors` is present #4114 + +# 1.11.0 (4 July 2022) + +- Subscriptions: Add `urql` support for Pusher #4129 + +# 1.10.7 (29 Mar 2022) + +- Dependencies: loosen apollo client and graphql version requirements to accept newer versions #4008 + +# 1.10.6 (10 Jan 2022) + +- Pusher Link: Don't pass along the `complete` handler because Apollo unsubscribes if you do #3830 + +# 1.10.5 (17 Dec 2021) + +- Dependencies: replace `actioncable` with `@rails/actioncable` #3773 + +# 1.10.4 (19 Nov 2021) + +- Sync: Also make sure documents are valid after removing `@client` fields #3715 + +# 1.10.3 (18 Nov 2021) + +- Sync: Remove any fields with `@client` before sending operations to the server #3712 + +# 1.10.2 (25 Oct 2021) + +- Pusher Link: Properly forward network errors to subscribers #3638 + +# 1.10.1 (22 Sept 2021) + +- Sync: Add `--apollo-codegen-json-output=...` option #3616 + +# 1.10.0 (25 Aug 2021) + +- Remove direct dependency on `request` #3594 +- Update `createRelaySubscriptionHandler` to support Relay 11. Use `createLegacyRelaySubscriptionHandler` to get the old behavior. #3594 + # 1.9.3 (31 Mar 2021) - Move `graphql` and `@apollo/client` to `peerDeps` for more flexible versions #3395 diff --git a/javascript_client/esm/package.json b/javascript_client/esm/package.json new file mode 100644 index 00000000000..3dbc1ca591c --- /dev/null +++ b/javascript_client/esm/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/javascript_client/jest.config.js b/javascript_client/jest.config.js index 0b4183c3296..c4a8c395165 100644 --- a/javascript_client/jest.config.js +++ b/javascript_client/jest.config.js @@ -4,7 +4,7 @@ module.exports = { ], verbose: true, testMatch: [ - "**/__tests__/**/[^.]+.ts", + "**/__tests__/**/[^.]+Test.ts", ], transform: { "^.+\\.ts$": "ts-jest" diff --git a/javascript_client/package-lock.json b/javascript_client/package-lock.json new file mode 100644 index 00000000000..b1c3c1e723e --- /dev/null +++ b/javascript_client/package-lock.json @@ -0,0 +1,5216 @@ +{ + "name": "graphql-ruby-client", + "version": "1.15.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "graphql-ruby-client", + "version": "1.15.2", + "license": "LGPL-3.0", + "dependencies": { + "glob": "^13.0.6", + "minimist": "^1.2.0" + }, + "bin": { + "graphql-ruby-client": "cli.js" + }, + "devDependencies": { + "@apollo/client": ">=3.3.13", + "@rails/actioncable": "^7.0.0", + "@types/glob": "^7.1.1", + "@types/jest": "^25.1.2", + "@types/minimist": "^1.2.0", + "@types/node": "^18.0.0", + "@types/pako": "^1.0.1", + "@types/pusher-js": "^4.2.2", + "@types/rails__actioncable": "^6.1.6", + "@types/react": "^17.0.0", + "@types/relay-runtime": "^14.0.0", + "@types/zen-observable": "^0.8.2", + "ably": "1.2.50", + "graphql": ">=15.0.0", + "jest": "^29.0.0", + "nock": "^11.0.0", + "pako": "^2.0.3", + "prettier": "^1.19.1", + "pusher-js": "^7.0.3", + "relay-runtime": "11.0.2", + "ts-jest": "^29.0.0", + "typescript": "5.3.3", + "urql": "^2.2.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@apollo/client": ">=3.3.6", + "graphql": ">=14.3.1" + } + }, + "node_modules/@ably/msgpack-js": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@ably/msgpack-js/-/msgpack-js-0.4.0.tgz", + "integrity": "sha512-IPt/BoiQwCWubqoNik1aw/6M/DleMdrxJOUpSja6xmMRbT2p1TA8oqKWgfZabqzrq8emRNeSl/+4XABPNnW5pQ==", + "dev": true, + "dependencies": { + "bops": "^1.0.1" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@apollo/client": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.8.1.tgz", + "integrity": "sha512-JGGj/9bdoLEqzatRikDeN8etseY5qeFAY0vSAx/Pd0ePNsaflKzHx6V2NZ0NsGkInq+9IXXX3RLVDf0EotizMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/context": "^0.7.3", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.4.3", + "graphql-tag": "^2.12.6", + "hoist-non-react-statics": "^3.3.2", + "optimism": "^0.17.5", + "prop-types": "^15.7.2", + "response-iterator": "^0.2.6", + "symbol-observable": "^4.0.0", + "ts-invariant": "^0.10.3", + "tslib": "^2.3.0", + "zen-observable-ts": "^1.2.5" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0", + "graphql-ws": "^5.5.5", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.22.9", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.9.tgz", + "integrity": "sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.22.11", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.11.tgz", + "integrity": "sha512-lh7RJrtPdhibbxndr6/xx0w8+CVlY5FJZiaSz908Fpy+G0xkBFTvwLcKJFF4PJxVfGhVWNebikpWGnOoC71juQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.22.10", + "@babel/generator": "^7.22.10", + "@babel/helper-compilation-targets": "^7.22.10", + "@babel/helper-module-transforms": "^7.22.9", + "@babel/helpers": "^7.22.11", + "@babel/parser": "^7.22.11", + "@babel/template": "^7.22.5", + "@babel/traverse": "^7.22.11", + "@babel/types": "^7.22.11", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.5.tgz", + "integrity": "sha512-x32i4hEXvr+iI0NEoEfDKzlemF8AmtOP8CcrRaEcpzysWuoEb1KknpcvMsHKPONoKZiDuItklgWhB18xEhr9PA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.5", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.10.tgz", + "integrity": "sha512-JMSwHD4J7SLod0idLq5PKgI+6g/hLD/iuWBq08ZX49xE14VpVEojJ5rHWptpirV2j020MvypRLAXAO50igCJ5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.5", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz", + "integrity": "sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.22.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz", + "integrity": "sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-module-imports": "^7.22.5", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz", + "integrity": "sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz", + "integrity": "sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.22.5.tgz", + "integrity": "sha512-gvyP4hZrgrs/wWMaocvxZ44Hw0b3W8Pe+cMxc8V1ULQ07oh8VNbIRaoD1LRZVTvD+0nieDKjfgKg89sD7rrKrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.22.5.tgz", + "integrity": "sha512-1mS2o03i7t1c6VzH6fdQ3OA8tcEIxwG18zIPRp+UY1Ihv6W+XZzBCVxExF9upussPXJ0xE9XRHwMoNs1ep/nRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.5.tgz", + "integrity": "sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.2", + "@babel/generator": "^7.24.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.24.5", + "@babel/parser": "^7.24.5", + "@babel/types": "^7.24.5", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", + "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.6.4.tgz", + "integrity": "sha512-wNK6gC0Ha9QeEPSkeJedQuTQqxZYnDPuDcDhVuVatRvMkL4D0VTvFVZj+Yuh6caG2aOfzkUZ36KtCmLNtR02hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.6.3", + "jest-util": "^29.6.3", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/console/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/core": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.6.4.tgz", + "integrity": "sha512-U/vq5ccNTSVgYH7mHnodHmCffGWHJnz/E1BEWlLuK5pM4FZmGfBn/nrJGLjUsSmyx3otCeqc1T31F4y08AMDLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.6.4", + "@jest/reporters": "^29.6.4", + "@jest/test-result": "^29.6.4", + "@jest/transform": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.6.3", + "jest-config": "^29.6.4", + "jest-haste-map": "^29.6.4", + "jest-message-util": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.6.4", + "jest-resolve-dependencies": "^29.6.4", + "jest-runner": "^29.6.4", + "jest-runtime": "^29.6.4", + "jest-snapshot": "^29.6.4", + "jest-util": "^29.6.3", + "jest-validate": "^29.6.3", + "jest-watcher": "^29.6.4", + "micromatch": "^4.0.4", + "pretty-format": "^29.6.3", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/environment": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", + "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/expect": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.6.4.tgz", + "integrity": "sha512-Warhsa7d23+3X5bLbrbYvaehcgX5TLYhI03JKoedTiI8uJU4IhqYBWF7OSSgUyz4IgLpUYPkK0AehA5/fRclAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.6.4", + "jest-snapshot": "^29.6.4" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.6.4.tgz", + "integrity": "sha512-FEhkJhqtvBwgSpiTrocquJCdXPsyvNKcl/n7A3u7X4pVoF4bswm11c9d4AV+kfq2Gpv/mM8x7E7DsRvH+djkrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.4.tgz", + "integrity": "sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.6.3", + "jest-mock": "^29.6.3", + "jest-util": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/globals": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.6.4.tgz", + "integrity": "sha512-wVIn5bdtjlChhXAzVXavcY/3PEjf4VqM174BM3eGL5kMxLiZD5CLnbmkEyA1Dwh9q8XjP6E8RwjBsY/iCWrWsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.6.4", + "@jest/expect": "^29.6.4", + "@jest/types": "^29.6.3", + "jest-mock": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.6.4.tgz", + "integrity": "sha512-sxUjWxm7QdchdrD3NfWKrL8FBsortZeibSJv4XLjESOOjSUOkjQcb0ZHJwfhEGIvBvTluTzfG2yZWZhkrXJu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.6.4", + "@jest/test-result": "^29.6.4", + "@jest/transform": "^29.6.4", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.6.3", + "jest-util": "^29.6.3", + "jest-worker": "^29.6.4", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.6.4.tgz", + "integrity": "sha512-uQ1C0AUEN90/dsyEirgMLlouROgSY+Wc/JanVVk0OiUKa5UFh7sJpMEM3aoUBAz2BRNvUJ8j3d294WFuRxSyOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.6.4.tgz", + "integrity": "sha512-E84M6LbpcRq3fT4ckfKs9ryVanwkaIB0Ws9bw3/yP4seRLg/VaCZ/LgW0MCq5wwk4/iP/qnilD41aj2fsw2RMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.6.4", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.6.4", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.6.4.tgz", + "integrity": "sha512-8thgRSiXUqtr/pPGY/OsyHuMjGyhVnWrFAwoxmIemlBuiMyU1WFs0tXoNxzcr4A4uErs/ABre76SGmrr5ab/AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.6.4", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.6.3", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rails/actioncable": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.2.tgz", + "integrity": "sha512-KGziTZfbmGm8/fHOpj515xupbYU+49hsp4etfdpoDJ/CEY2bRZR0cyFcJkpK6n0t/sxOHNWY6bo9vSgXZvT7Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.1", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", + "integrity": "sha512-aACu/U/omhdk15O4Nfb+fHgH/z3QsfQzpnvRZhYhThms83ZnAOZz7zZAWO7mn2yyNQaA4xTO8GLK3uqFU4bYYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", + "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", + "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.1", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.1.tgz", + "integrity": "sha512-MitHFXnhtgwsGZWtT68URpOvLN4EREih1u3QtQiN4VdAxWKRVvGCSvw/Qth0M0Qq3pJpnGOu5JaM/ydK7OGbqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.28", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", + "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "node_modules/@types/express-serve-static-core/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "node_modules/@types/glob/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz", + "integrity": "sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/graceful-fs/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", + "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", + "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "25.2.3", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-25.2.3.tgz", + "integrity": "sha512-JXc1nK/tXHiDhV55dvfzqtmP4S3sy3T3ouV2tkViZgxY/zeUkcpQcQPGRlgF4KmWzWW5oiWYSZwtCB+2RsE4Fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-diff": "^25.2.1", + "pretty-format": "^25.2.1" + } + }, + "node_modules/@types/jest/node_modules/@jest/types": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-25.5.0.tgz", + "integrity": "sha512-OXD0RgQ86Tu3MazKo8bnrkDRaDXXMGUqd+kTtLtK1Zb7CRzQcaSRPPPV37SvYTdevXEBVxe0HXylEjs8ibkmCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^1.1.1", + "@types/yargs": "^15.0.0", + "chalk": "^3.0.0" + }, + "engines": { + "node": ">= 8.3" + } + }, + "node_modules/@types/jest/node_modules/@types/istanbul-reports": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.2.tgz", + "integrity": "sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*", + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest/node_modules/@types/yargs": { + "version": "15.0.15", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.15.tgz", + "integrity": "sha512-IziEYMU9XoVj8hWg7k+UJrXALkGFjWJhn5QFEv9q4p+v40oZhSuC135M38st8XPjICL7Ey4TV64ferBGUoJhBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/jest/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@types/jest/node_modules/diff-sequences": { + "version": "25.2.6", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz", + "integrity": "sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.3" + } + }, + "node_modules/@types/jest/node_modules/jest-diff": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz", + "integrity": "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^3.0.0", + "diff-sequences": "^25.2.6", + "jest-get-type": "^25.2.6", + "pretty-format": "^25.5.0" + }, + "engines": { + "node": ">= 8.3" + } + }, + "node_modules/@types/jest/node_modules/jest-get-type": { + "version": "25.2.6", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz", + "integrity": "sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.3" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-25.5.0.tgz", + "integrity": "sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^25.5.0", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^16.12.0" + }, + "engines": { + "node": ">= 8.3" + } + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimist": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", + "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/pako": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.4.tgz", + "integrity": "sha512-Z+5bJSm28EXBSUJEgx29ioWeEEHUh6TiMkZHDhLwjc9wVFH+ressbkmX6waUZc5R3Gobn4Qu5llGxaoflZ+yhA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.5", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/pusher-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/pusher-js/-/pusher-js-4.2.2.tgz", + "integrity": "sha512-LP9isBRAFlNzQohQtySJxJjzmy4zQCcv5xGZD2G3rsDnTWfpEkFKyLw3x9711pFAXwwUl9ZivxKkcnFr8umSAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/rails__actioncable": { + "version": "6.1.10", + "resolved": "https://registry.npmjs.org/@types/rails__actioncable/-/rails__actioncable-6.1.10.tgz", + "integrity": "sha512-Dr6A/+OTsoTgvnj2ynysjuMvmk2XDI4J71ljPANcODZjMafKeSau2/IJ+B4IuJ6x9bsG/DQpBxNxH6RqmyFzpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "17.0.65", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.65.tgz", + "integrity": "sha512-oxur785xZYHvnI7TRS61dXbkIhDPnGfsXKv0cNXR/0ml4SipRIFpSMzA7HMEfOywFwJ5AOnPrXYTEiTRUQeGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/relay-runtime": { + "version": "14.1.23", + "resolved": "https://registry.npmjs.org/@types/relay-runtime/-/relay-runtime-14.1.23.tgz", + "integrity": "sha512-tP2l6YLI2HJ11UzEB7j4IWeADyiPIKTehdeyHsyOzNBu7WvKsyf4kAZDmsB2NPaXp9Lud+KEJbRi/VW+jEDYCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", + "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/stack-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", + "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.24", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", + "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", + "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/zen-observable": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz", + "integrity": "sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@urql/core": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@urql/core/-/core-2.6.1.tgz", + "integrity": "sha512-gYrEHy3tViJhwIhauK6MIf2Qp09QTsgNHZRd0n71rS+hF6gdwjspf1oKljl4m25+272cJF7fPjBUGmjaiEr7Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "wonka": "^4.0.14" + }, + "peerDependencies": { + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@wry/context": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.3.tgz", + "integrity": "sha512-Nl8WTesHp89RF803Se9X3IiHjdmLBrIvPMaJkl+rKVJAYyPsz1TEUbu89943HpvujtSJgDUx9W4vZw3K1Mr3sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.6.tgz", + "integrity": "sha512-D46sfMTngaYlrH+OspKf8mIJETntFnf6Hsjb0V41jAXJ7Bx2kB8Rv8RCUujuVWYttFtHkUNp7g+FwxNQAr6mXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.4.3.tgz", + "integrity": "sha512-I6bHwH0fSf6RqQcnnXLJKhkSXG45MFral3GxPaY4uAl0LYDZM+YDVDAiU9bYwjTuysy1S0IeecWtmq1SZA3M1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ably": { + "version": "1.2.50", + "resolved": "https://registry.npmjs.org/ably/-/ably-1.2.50.tgz", + "integrity": "sha512-9uC5lE7wFBR7nOJdltArHU1UDaBu6CTMbMuie+brUuH884fM1mQuFiMzZetxVacygyUG38dp5MFOyOPF9h0RsQ==", + "dev": true, + "dependencies": { + "@ably/msgpack-js": "^0.4.0", + "got": "^11.8.5", + "ws": "^8.14.2" + }, + "engines": { + "node": ">=5.10.x" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.6.4.tgz", + "integrity": "sha512-meLj23UlSLddj6PC+YTOFRgDAtjnZom8w/ACsrx0gtPtv5cJZk0A5Unk5bV4wixD7XaPCN1fQvpww8czkZURmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.6.4", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", + "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-top-level-await": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.0.2.tgz", + "integrity": "sha512-ZXBDPMt/v/8fsIqn+Z5VwrhdR6jVka0bYobHdGia0Nxi7BJ9i/Uvml3AocHIBtIIBhZjBw5MR0aR4ROs/8+SNg==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bops": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bops/-/bops-1.0.1.tgz", + "integrity": "sha512-qCMBuZKP36tELrrgXpAfM+gHzqa0nLsWZ+L37ncsb8txYlnAoxOPpVp+g7fK0sGkMXfA0wl8uQkESqw3v4HNag==", + "dev": true, + "dependencies": { + "base64-js": "1.0.2", + "to-utf8": "0.0.1" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.21.10", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz", + "integrity": "sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001517", + "electron-to-chromium": "^1.4.477", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.11" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001524", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001524.tgz", + "integrity": "sha512-Jj917pJtYg9HSJBF95HVX3Cdr89JUyLT4IZ8SvM5aDRni95swKgYi3TgYLH5hnGfPE/U1dg6IfZ50UsIlLkwSA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", + "integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.4.503", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.503.tgz", + "integrity": "sha512-LF2IQit4B0VrUHFeQkWhZm97KuJSGF2WJqq1InpY+ECpFRkXd8yTIaTtJxsO0OKDmiBYwWqcrNaXOurn2T2wiA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.6.4.tgz", + "integrity": "sha512-F2W2UyQ8XYyftHT57dtfg8Ue3X5qLgm2sSug0ivvLRH/VKNRL/pDxg/TH7zVzbQB0tu80clNFy6LU7OS/VSEKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.6.4", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.6.4", + "jest-message-util": "^29.6.3", + "jest-util": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphql": { + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", + "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.6", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", + "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", + "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.0.tgz", + "integrity": "sha512-x58orMzEVfzPUKqlbLd1hXCnySCxKdDKa6Rjg97CwuLLRI4g3FHTdnExu1OqffVFay6zeMW+T6/DowFLndWnIw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.6.4.tgz", + "integrity": "sha512-tEFhVQFF/bzoYV1YuGyzLPZ6vlPrdfvDmmAxudA1dLEuiztqg2Rkx20vkKY32xiDROcD2KXlgZ7Cu8RPeEHRKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.6.4", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.6.4" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.6.3.tgz", + "integrity": "sha512-G5wDnElqLa4/c66ma5PG9eRjE342lIbF6SUnTJi26C3J28Fv2TVY2rOyKB9YGbSA5ogwevgmxc4j4aVjrEK6Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.6.3", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.6.4.tgz", + "integrity": "sha512-YXNrRyntVUgDfZbjXWBMPslX1mQ8MrSG0oM/Y06j9EYubODIyHWP8hMUbjbZ19M3M+zamqEur7O80HODwACoJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.6.4", + "@jest/expect": "^29.6.4", + "@jest/test-result": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.6.3", + "jest-matcher-utils": "^29.6.4", + "jest-message-util": "^29.6.3", + "jest-runtime": "^29.6.4", + "jest-snapshot": "^29.6.4", + "jest-util": "^29.6.3", + "p-limit": "^3.1.0", + "pretty-format": "^29.6.3", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-cli": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.6.4.tgz", + "integrity": "sha512-+uMCQ7oizMmh8ZwRfZzKIEszFY9ksjjEQnTEMTaL7fYiL3Kw4XhqT9bYh+A4DQKUb67hZn2KbtEnDuHvcgK4pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.6.4", + "@jest/test-result": "^29.6.4", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "import-local": "^3.0.2", + "jest-config": "^29.6.4", + "jest-util": "^29.6.3", + "jest-validate": "^29.6.3", + "prompts": "^2.0.1", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.6.4.tgz", + "integrity": "sha512-JWohr3i9m2cVpBumQFv2akMEnFEPVOh+9L2xIBJhJ0zOaci2ZXuKJj0tgMKQCBZAKA09H049IR4HVS/43Qb19A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.6.4", + "@jest/types": "^29.6.3", + "babel-jest": "^29.6.4", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.6.4", + "jest-environment-node": "^29.6.4", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.6.4", + "jest-runner": "^29.6.4", + "jest-util": "^29.6.3", + "jest-validate": "^29.6.3", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.6.3", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-diff": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.6.4.tgz", + "integrity": "sha512-9F48UxR9e4XOEZvoUXEHSWY4qC4zERJaOfrbBg9JpbJOO43R1vN76REt/aMGZoY6GD5g84nnJiBIVlscegefpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.6.3.tgz", + "integrity": "sha512-2+H+GOTQBEm2+qFSQ7Ma+BvyV+waiIFxmZF5LdpBsAEjWX8QYjSCa4FrkIYtbfXUJJJnFCYrOtt6TZ+IAiTjBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.6.3.tgz", + "integrity": "sha512-KoXfJ42k8cqbkfshW7sSHcdfnv5agDdHCPA87ZBdmHP+zJstTJc0ttQaJ/x7zK6noAL76hOuTIJ6ZkQRS5dcyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.6.3", + "pretty-format": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.6.4.tgz", + "integrity": "sha512-i7SbpH2dEIFGNmxGCpSc2w9cA4qVD+wfvg2ZnfQ7XVrKL0NA5uDVBIiGH8SR4F0dKEv/0qI5r+aDomDf04DpEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.6.4", + "@jest/fake-timers": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.6.3", + "jest-util": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.6.4.tgz", + "integrity": "sha512-12Ad+VNTDHxKf7k+M65sviyynRoZYuL1/GTuhEVb8RYsNSNln71nANRb/faSyWvx0j+gHcivChXHIoMJrGYjog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.6.3", + "jest-worker": "^29.6.4", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-haste-map/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-leak-detector": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.6.3.tgz", + "integrity": "sha512-0kfbESIHXYdhAdpLsW7xdwmYhLf1BRu4AA118/OxFm0Ho1b2RcTmO4oF6aAMaxpxdxnJ3zve2rgwzNBD4Zbm7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.6.4.tgz", + "integrity": "sha512-KSzwyzGvK4HcfnserYqJHYi7sZVqdREJ9DMPAKVbS98JsIAvumihaNUbjrWw0St7p9IY7A9UskCW5MYlGmBQFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.6.4", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.6.3.tgz", + "integrity": "sha512-FtzaEEHzjDpQp51HX4UMkPZjy46ati4T5pEMyM6Ik48ztu4T9LQplZ6OsimHx7EuM9dfEh5HJa6D3trEftu3dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.6.3", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", + "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.6.4.tgz", + "integrity": "sha512-fPRq+0vcxsuGlG0O3gyoqGTAxasagOxEuyoxHeyxaZbc9QNek0AmJWSkhjlMG+mTsj+8knc/mWb3fXlRNVih7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.6.4", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.6.3", + "jest-validate": "^29.6.3", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.6.4.tgz", + "integrity": "sha512-7+6eAmr1ZBF3vOAJVsfLj1QdqeXG+WYhidfLHBRZqGN24MFRIiKG20ItpLw2qRAsW/D2ZUUmCNf6irUr/v6KHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.6.4" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.6.4.tgz", + "integrity": "sha512-SDaLrMmtVlQYDuG0iSPYLycG8P9jLI+fRm8AF/xPKhYDB2g6xDWjXBrR5M8gEWsK6KVFlebpZ4QsrxdyIX1Jaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.6.4", + "@jest/environment": "^29.6.4", + "@jest/test-result": "^29.6.4", + "@jest/transform": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.6.3", + "jest-environment-node": "^29.6.4", + "jest-haste-map": "^29.6.4", + "jest-leak-detector": "^29.6.3", + "jest-message-util": "^29.6.3", + "jest-resolve": "^29.6.4", + "jest-runtime": "^29.6.4", + "jest-util": "^29.6.3", + "jest-watcher": "^29.6.4", + "jest-worker": "^29.6.4", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-runtime": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.6.4.tgz", + "integrity": "sha512-s/QxMBLvmwLdchKEjcLfwzP7h+jsHvNEtxGP5P+Fl1FMaJX2jMiIqe4rJw4tFprzCwuSvVUo9bn0uj4gNRXsbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.6.4", + "@jest/fake-timers": "^29.6.4", + "@jest/globals": "^29.6.4", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.6.4", + "@jest/transform": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.6.4", + "jest-message-util": "^29.6.3", + "jest-mock": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.6.4", + "jest-snapshot": "^29.6.4", + "jest-util": "^29.6.3", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-snapshot": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.6.4.tgz", + "integrity": "sha512-VC1N8ED7+4uboUKGIDsbvNAZb6LakgIPgAF4RSpF13dN6YaMokfRqO+BaqK4zIh6X3JffgwbzuGqDEjHm/MrvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.6.4", + "@jest/transform": "^29.6.4", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.6.4", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.6.4", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.6.4", + "jest-message-util": "^29.6.3", + "jest-util": "^29.6.3", + "natural-compare": "^1.4.0", + "pretty-format": "^29.6.3", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.6.3.tgz", + "integrity": "sha512-QUjna/xSy4B32fzcKTSz1w7YYzgiHrjjJjevdRf61HYk998R5vVMMNmrHESYZVDS5DSWs+1srPLPKxXPkeSDOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-validate": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.6.3.tgz", + "integrity": "sha512-e7KWZcAIX+2W1o3cHfnqpGajdCs1jSM3DkXjGeLSNmCazv1EeI1ggTeK5wdZhF+7N+g44JI2Od3veojoaumlfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-watcher": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.6.4.tgz", + "integrity": "sha512-oqUWvx6+On04ShsT00Ir9T4/FvBeEh2M9PTubgITPxDa739p4hoQweWPRGyYeaojgT0xTpZKF0Y/rSY1UgMxvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.6.3", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-watcher/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-worker": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.6.4.tgz", + "integrity": "sha512-6dpvFV4WjcWbDVGgHTWo/aupl8/LbBx2NSKfiwqf79xC/yeJjKHT1+StcKy/2KTmW16hE68ccKVOtXf+WZGz7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.6.3", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/@types/node": { + "version": "20.5.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", + "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nock": { + "version": "11.9.1", + "resolved": "https://registry.npmjs.org/nock/-/nock-11.9.1.tgz", + "integrity": "sha512-U5wPctaY4/ar2JJ5Jg4wJxlbBfayxgKbiAeGh+a1kk6Pwnc2ZEuKviLyDSG6t0uXl56q7AALIxoM6FJrBSsVXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.13", + "mkdirp": "^0.5.0", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optimism": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.17.5.tgz", + "integrity": "sha512-TEcp8ZwK1RczmvMnvktxHSF2tKgMWjJ71xEFGX5ApLh67VsMSTy1ZUlipJw8W+KaqgOmQ+4pqwkeivY89j+4Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@wry/context": "^0.7.0", + "@wry/trie": "^0.4.3", + "tslib": "^2.3.0" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/prettier": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", + "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pretty-format": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.6.3.tgz", + "integrity": "sha512-ZsBgjVhFAj5KeK+nHfF1305/By3lechHQSMWCTl8iHSbfOm2TN5nHEtFc/+W7fAyUeCs2n5iow72gld4gW0xDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pure-rand": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.2.tgz", + "integrity": "sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/pusher-js": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-7.6.0.tgz", + "integrity": "sha512-5CJ7YN5ZdC24E0ETraCU5VYFv0IY5ziXhrS0gS5+9Qrro1E4M1lcZhtr9H1H+6jNSLj1LKKAgcLeE1EH9GxMlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "4.17.28", + "@types/node": "^14.14.31", + "tweetnacl": "^1.0.3" + } + }, + "node_modules/pusher-js/node_modules/@types/node": { + "version": "14.18.56", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.56.tgz", + "integrity": "sha512-+k+57NVS9opgrEn5l9c0gvD1r6C+PtyhVE4BTnMMRwiEA8ZO8uFcs6Yy2sXIy0eC95ZurBtRSvhZiHXBysbl6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pusher-js/node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/relay-runtime": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/relay-runtime/-/relay-runtime-11.0.2.tgz", + "integrity": "sha512-xxZkIRnL8kNE1cxmwDXX8P+wSeWLR+0ACFyAiAhvfWWAyjXb+bhjJ2FSsRGlNYfkqaTNEuDqpnodQV1/fF7Idw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "fbjs": "^3.0.0", + "invariant": "^2.2.4" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz", + "integrity": "sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", + "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/response-iterator": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/response-iterator/-/response-iterator-0.2.6.tgz", + "integrity": "sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/to-utf8": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/to-utf8/-/to-utf8-0.0.1.tgz", + "integrity": "sha512-zks18/TWT1iHO3v0vFp5qLKOG27m67ycq/Y7a7cTiRuUNlc4gf3HGnkRgMv0NyhnfTamtkYBJl+YeD1/j07gBQ==", + "dev": true + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-invariant": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.3.tgz", + "integrity": "sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-jest": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", + "integrity": "sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^29.0.0", + "json5": "^2.2.3", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "^7.5.3", + "yargs-parser": "^21.0.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ua-parser-js": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", + "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", + "integrity": "sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/urql": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/urql/-/urql-2.2.3.tgz", + "integrity": "sha512-XMkSYJKW9s4ZlbSuxcUz3fTBIykOn0sGileRXQeyZpaRBXJPVz5saSY05k7jdefNxShZtTI+/nr7PYUWQertfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@urql/core": "^2.6.1", + "wonka": "^4.0.14" + }, + "peerDependencies": { + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "react": ">= 16.8.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", + "integrity": "sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wonka": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/wonka/-/wonka-4.0.15.tgz", + "integrity": "sha512-U0IUQHKXXn6PFo9nqsHphVCE5m3IntqZNB9Jjn7EB1lrR7YTDY3YWgFvEvwniTzXSvOH/XMzAZaIfJF/LvHYXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zen-observable": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", + "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zen-observable-ts": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz", + "integrity": "sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "zen-observable": "0.8.15" + } + } + } +} diff --git a/javascript_client/package.json b/javascript_client/package.json index f73caaf42f2..f700f81a50d 100644 --- a/javascript_client/package.json +++ b/javascript_client/package.json @@ -1,46 +1,109 @@ { "name": "graphql-ruby-client", - "version": "1.9.4", + "version": "1.15.2", "description": "JavaScript client for graphql-ruby", "main": "index.js", + "module": "esm/index.js", "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./esm/index.js", + "require": "./index.js", + "default": "./index.js" + }, + "./index.js": { + "types": "./index.d.ts", + "import": "./esm/index.js", + "require": "./index.js", + "default": "./index.js" + }, + "./cli.js": "./cli.js", + "./sync": { + "types": "./sync/index.d.ts", + "import": "./esm/sync/index.js", + "require": "./sync/index.js", + "default": "./sync/index.js" + }, + "./sync/*": { + "types": "./sync/*", + "import": "./esm/sync/*.js", + "require": "./sync/*.js", + "default": "./sync/*.js" + }, + "./sync/*.js": { + "types": "./sync/*.d.ts", + "import": "./esm/sync/*.js", + "require": "./sync/*.js", + "default": "./sync/*.js" + }, + "./subscriptions/*": { + "types": "./subscriptions/*", + "import": "./esm/subscriptions/*.js", + "require": "./subscriptions/*.js", + "default": "./subscriptions/*.js" + }, + "./subscriptions/*.js": { + "types": "./subscriptions/*.d.ts", + "import": "./esm/subscriptions/*.js", + "require": "./subscriptions/*.js", + "default": "./subscriptions/*.js" + } + }, + "typesVersions": { + "*": { + "sync": [ + "sync/index.d.ts" + ], + "sync/*": [ + "sync/*" + ], + "subscriptions/*": [ + "subscriptions/*" + ] + } + }, "repository": "https://github.com/rmosolgo/graphql-ruby", "author": "Robert Mosolgo", "license": "LGPL-3.0", "bin": "./cli.js", "devDependencies": { - "@apollo/client": "^3.3.13", - "@types/actioncable": "^5.2.3", + "@apollo/client": ">=3.3.13", + "@rails/actioncable": "^7.0.0", "@types/glob": "^7.1.1", "@types/jest": "^25.1.2", "@types/minimist": "^1.2.0", - "@types/node": "^13.7.1", + "@types/node": "^18.0.0", "@types/pako": "^1.0.1", "@types/pusher-js": "^4.2.2", + "@types/rails__actioncable": "^6.1.6", "@types/react": "^17.0.0", + "@types/relay-runtime": "^14.0.0", "@types/zen-observable": "^0.8.2", - "ably": "1.2.6", - "graphql": "^15.0.0", - "jest": "^25.0.0", + "ably": "1.2.50", + "graphql": ">=15.0.0", + "jest": "^29.0.0", "nock": "^11.0.0", "pako": "^2.0.3", "prettier": "^1.19.1", "pusher-js": "^7.0.3", - "ts-jest": "^25.2.0", - "typescript": "^3.7.5" + "relay-runtime": "11.0.2", + "ts-jest": "^29.0.0", + "typescript": "5.3.3", + "urql": "^2.2.2" }, "scripts": { - "test": "tsc && jest", - "prepublishOnly": "tsc" + "build": "tsc && node scripts/clean-esm.js && tsc -p tsconfig.esm.json && node scripts/prepare-esm.js", + "prepack": "npm run build", + "test": "tsc && jest" }, "dependencies": { - "glob": "^7.1.4", - "minimist": "^1.2.0", - "request": "^2.88.2" + "glob": "^13.0.6", + "minimist": "^1.2.0" }, "peerDependencies": { - "@apollo/client": "^3.3.6", - "graphql": "^14.3.1 || ^15.0.0" + "@apollo/client": ">=3.3.6", + "graphql": ">=14.3.1" }, "prettier": { "semi": false, diff --git a/javascript_client/scripts/clean-esm.js b/javascript_client/scripts/clean-esm.js new file mode 100644 index 00000000000..09345e4e395 --- /dev/null +++ b/javascript_client/scripts/clean-esm.js @@ -0,0 +1,7 @@ +const fs = require("fs") +const path = require("path") + +const esmRoot = path.join(__dirname, "..", "esm") + +fs.rmSync(esmRoot, { recursive: true, force: true }) +fs.mkdirSync(esmRoot, { recursive: true }) diff --git a/javascript_client/scripts/prepare-esm.js b/javascript_client/scripts/prepare-esm.js new file mode 100644 index 00000000000..7ab80ba7a59 --- /dev/null +++ b/javascript_client/scripts/prepare-esm.js @@ -0,0 +1,57 @@ +const fs = require("fs") +const path = require("path") + +const esmRoot = path.join(__dirname, "..", "esm") + +fs.writeFileSync( + path.join(esmRoot, "package.json"), + JSON.stringify({ type: "module" }, null, 2) + "\n" +) + +for (const filePath of findJavaScriptFiles(esmRoot)) { + const source = fs.readFileSync(filePath, "utf8") + const nextSource = source + .replaceAll("@apollo/client/core\"", "@apollo/client/core/index.js\"") + .replaceAll("@apollo/client/core'", "@apollo/client/core/index.js'") + .replaceAll("graphql/language/printer\"", "graphql/language/printer.js\"") + .replaceAll("graphql/language/printer'", "graphql/language/printer.js'") + .replace(/(from\s+["'])(\.[^"']+)(["'])/g, function(match, prefix, specifier, suffix) { + return prefix + resolveRelativeSpecifier(filePath, specifier) + suffix + }) + + if (nextSource !== source) { + fs.writeFileSync(filePath, nextSource) + } +} + +function findJavaScriptFiles(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap(function(entry) { + const entryPath = path.join(directory, entry.name) + + if (entry.isDirectory()) { + return findJavaScriptFiles(entryPath) + } + + return entry.isFile() && entry.name.endsWith(".js") ? [entryPath] : [] + }) +} + +function resolveRelativeSpecifier(fromPath, specifier) { + if (path.extname(specifier)) { + return specifier + } + + const fromDirectory = path.dirname(fromPath) + const filePath = path.resolve(fromDirectory, specifier + ".js") + const indexPath = path.resolve(fromDirectory, specifier, "index.js") + + if (fs.existsSync(filePath)) { + return specifier + ".js" + } + + if (fs.existsSync(indexPath)) { + return specifier + "/index.js" + } + + return specifier +} diff --git a/javascript_client/src/__tests__/__snapshots__/syncTest.ts.snap b/javascript_client/src/__tests__/__snapshots__/syncTest.ts.snap index 7afd603fb08..baefca5d212 100644 --- a/javascript_client/src/__tests__/__snapshots__/syncTest.ts.snap +++ b/javascript_client/src/__tests__/__snapshots__/syncTest.ts.snap @@ -1,21 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`sync operations Input files Merges fragments and operations across files 1`] = ` -Array [ - Object { - "alias": "5f0da489cf508a7c65ff5fa144e50545", +[ + { + "alias": "4568c28d403794e011363caf815ec827", "body": "fragment Frag1 on Query { moreStuff } query GetStuff { ...Frag1 -} -", +}", "name": "GetStuff", }, - Object { - "alias": "c944b08d15eb94cf93dd124b7d664b62", + { + "alias": "faf462be033e16dd2a56130d56a9192f", "body": "fragment Frag1 on Query { moreStuff } @@ -32,12 +31,11 @@ query GetStuff2 { stuff ...Frag1 ...Frag2 -} -", +}", "name": "GetStuff2", }, - Object { - "alias": "fc215a466a3ea309fe493e8f7cb206f2", + { + "alias": "aab385a1685772ad520fc70d468030fa", "body": "fragment Frag2 on Query { ...Frag3 } @@ -58,12 +56,11 @@ query GetStuff3 { } ...Frag2 ...Frag4 -} -", +}", "name": "GetStuff3", }, - Object { - "alias": "919af8f80d8848a9b9b6176e457f8e45", + { + "alias": "b2cb0b317d071f9f38905fba21d73258", "body": "query GetStuffIsolated { ...FragIsolated things { @@ -75,91 +72,82 @@ fragment FragIsolated on Query { evenMoreStuff { stuffInside } -} -", +}", "name": "GetStuffIsolated", }, - Object { - "alias": "d5dfe825034aeda06e2639935d8b107d", + { + "alias": "6cdae165fd6dc5dc5900e5a2bba90cc2", "body": "query GetStuffIsolated2 { things { existHere } -} -", +}", "name": "GetStuffIsolated2", }, ] `; exports[`sync operations Input files Uses mode: file to process each file separately 1`] = ` -Array [ - Object { - "alias": "26c44bfe42872860da112b6177355bfa", +[ + { + "alias": "664225b943e29ea8c6aae40bbde8923a", "body": "fragment Frag1 on Query { moreStuff -} -", +}", "name": "", }, - Object { - "alias": "7de9e7bf1d6ea1f527de07a25983086c", + { + "alias": "269bbe8bbe7f6a0b9dae7f98b45a9675", "body": "fragment Frag2 on Query { ...Frag3 -} -", +}", "name": "", }, - Object { - "alias": "8bc9b9922a7fbb66f4ac6c58d5d5c357", + { + "alias": "d12578840c6518c746b125ae2e7a8ab1", "body": "fragment Frag3 on Query { evenMoreStuff -} -", +}", "name": "", }, - Object { - "alias": "bfc521a5ade5ff6f17bdf9352c86c850", + { + "alias": "1a1b6154fb1db8bc6652edfbd7d9ac8a", "body": "fragment Frag4 on Query { evenMoreStuff { stuffInside } -} -", +}", "name": "", }, - Object { - "alias": "0a6add7303775e2487f2c2235ecb1c80", + { + "alias": "8ab1711fcbb7befc98d06ef7d155fd81", "body": "query GetStuff { ...Frag1 -} -", +}", "name": "GetStuff", }, - Object { - "alias": "2f26b770ded2a04279bc4bf824ca54ac", + { + "alias": "cf517696fbd9ec204cd402f48c831090", "body": "query GetStuff2 { stuff ...Frag1 ...Frag2 -} -", +}", "name": "GetStuff2", }, - Object { - "alias": "1b8da77aabef67ee54df7e5acfd75893", + { + "alias": "1e5290206d87a4da749118d84f7e2c65", "body": "query GetStuff3 { stuff { withStuffInside } ...Frag2 ...Frag4 -} -", +}", "name": "GetStuff3", }, - Object { - "alias": "919af8f80d8848a9b9b6176e457f8e45", + { + "alias": "b2cb0b317d071f9f38905fba21d73258", "body": "query GetStuffIsolated { ...FragIsolated things { @@ -171,107 +159,145 @@ fragment FragIsolated on Query { evenMoreStuff { stuffInside } -} -", +}", "name": "GetStuffIsolated", }, - Object { - "alias": "d5dfe825034aeda06e2639935d8b107d", + { + "alias": "6cdae165fd6dc5dc5900e5a2bba90cc2", "body": "query GetStuffIsolated2 { things { existHere } -} -", +}", "name": "GetStuffIsolated2", }, ] `; -exports[`sync operations Logging Can be quieted with quiet: true 1`] = `Array []`; +exports[`sync operations Logging Can be quieted with quiet: true 1`] = `[]`; exports[`sync operations Logging Logs progress 1`] = ` -Array [ - Array [ +[ + [ "Syncing 5 operations to bogus...", ], - Array [ + [ "Generating client module in src/OperationStoreClient.js...", ], - Array [ + [ "✓ Done!", ], ] `; exports[`sync operations Printing the result prints failure and sends the message to the promise 1`] = ` -Array [ - Array [ +[ + [ "Syncing 5 operations to http://example.com/stored_operations/sync...", ], - Array [ + [ + "[Sync] 2 Headers:", + ], + [ + "[Sync] Content-Type: application/json", + ], + [ + "[Sync] Content-Length: 1132", + ], + [ + "[Sync] Data:", + "{"operations":[{"name":"GetStuff","body":"fragment Frag1 on Query {\\n moreStuff\\n}\\n\\nquery GetStuff {\\n ...Frag1\\n}","alias":"4568c28d403794e011363caf815ec827"},{"name":"GetStuff2","body":"fragment Frag1 on Query {\\n moreStuff\\n}\\n\\nfragment Frag2 on Query {\\n ...Frag3\\n}\\n\\nfragment Frag3 on Query {\\n evenMoreStuff\\n}\\n\\nquery GetStuff2 {\\n stuff\\n ...Frag1\\n ...Frag2\\n}","alias":"faf462be033e16dd2a56130d56a9192f"},{"name":"GetStuff3","body":"fragment Frag2 on Query {\\n ...Frag3\\n}\\n\\nfragment Frag3 on Query {\\n evenMoreStuff\\n}\\n\\nfragment Frag4 on Query {\\n evenMoreStuff {\\n stuffInside\\n }\\n}\\n\\nquery GetStuff3 {\\n stuff {\\n withStuffInside\\n }\\n ...Frag2\\n ...Frag4\\n}","alias":"aab385a1685772ad520fc70d468030fa"},{"name":"GetStuffIsolated","body":"query GetStuffIsolated {\\n ...FragIsolated\\n things {\\n existHere\\n }\\n}\\n\\nfragment FragIsolated on Query {\\n evenMoreStuff {\\n stuffInside\\n }\\n}","alias":"b2cb0b317d071f9f38905fba21d73258"},{"name":"GetStuffIsolated2","body":"query GetStuffIsolated2 {\\n things {\\n existHere\\n }\\n}","alias":"6cdae165fd6dc5dc5900e5a2bba90cc2"}]}", + ], + [ + "[Sync] Response Headers: ", + "{"content-type":"application/json"}", + ], + [ + "[Sync] Response Body: ", + "{"errors":{"4568c28d403794e011363caf815ec827":["something"]},"failed":["4568c28d403794e011363caf815ec827"],"added":["defg"],"not_modified":[]}", + ], + [ " 0 added", ], - Array [ + [ " 0 not modified", ], - Array [ + [ " 1 failed", ], ] `; exports[`sync operations Printing the result prints failure and sends the message to the promise 2`] = ` -Array [ - Array [ +[ + [ "Sync failed, errors:", ], - Array [ + [ " GetStuff:", ], - Array [ + [ " ✘ something", ], ] `; exports[`sync operations Printing the result prints success 1`] = ` -Array [ - Array [ +[ + [ "Syncing 5 operations to http://example.com/stored_operations/sync...", ], + [ + "[Sync] 2 Headers:", + ], + [ + "[Sync] Content-Type: application/json", + ], + [ + "[Sync] Content-Length: 1132", + ], + [ + "[Sync] Data:", + "{"operations":[{"name":"GetStuff","body":"fragment Frag1 on Query {\\n moreStuff\\n}\\n\\nquery GetStuff {\\n ...Frag1\\n}","alias":"4568c28d403794e011363caf815ec827"},{"name":"GetStuff2","body":"fragment Frag1 on Query {\\n moreStuff\\n}\\n\\nfragment Frag2 on Query {\\n ...Frag3\\n}\\n\\nfragment Frag3 on Query {\\n evenMoreStuff\\n}\\n\\nquery GetStuff2 {\\n stuff\\n ...Frag1\\n ...Frag2\\n}","alias":"faf462be033e16dd2a56130d56a9192f"},{"name":"GetStuff3","body":"fragment Frag2 on Query {\\n ...Frag3\\n}\\n\\nfragment Frag3 on Query {\\n evenMoreStuff\\n}\\n\\nfragment Frag4 on Query {\\n evenMoreStuff {\\n stuffInside\\n }\\n}\\n\\nquery GetStuff3 {\\n stuff {\\n withStuffInside\\n }\\n ...Frag2\\n ...Frag4\\n}","alias":"aab385a1685772ad520fc70d468030fa"},{"name":"GetStuffIsolated","body":"query GetStuffIsolated {\\n ...FragIsolated\\n things {\\n existHere\\n }\\n}\\n\\nfragment FragIsolated on Query {\\n evenMoreStuff {\\n stuffInside\\n }\\n}","alias":"b2cb0b317d071f9f38905fba21d73258"},{"name":"GetStuffIsolated2","body":"query GetStuffIsolated2 {\\n things {\\n existHere\\n }\\n}","alias":"6cdae165fd6dc5dc5900e5a2bba90cc2"}]}", + ], ] `; exports[`sync operations Printing the result prints success 2`] = ` -Array [ - Array [ +[ + [ + "[Sync] Response Headers: ", + "{"content-type":"application/json"}", + ], + [ + "[Sync] Response Body: ", + "{"errors":{},"failed":[],"added":["defg"],"not_modified":["xyz","123"]}", + ], + [ " 1 added", ], - Array [ + [ " 2 not modified", ], - Array [ + [ " 0 failed", ], - Array [ + [ "Generating client module in src/OperationStoreClient.js...", ], - Array [ + [ "✓ Done!", ], ] `; -exports[`sync operations Printing the result prints success 3`] = `Array []`; +exports[`sync operations Printing the result prints success 3`] = `[]`; exports[`sync operations Relay support Uses Apollo Android OperationOutput JSON files 1`] = ` -Array [ - Object { +[ + { "alias": "aba626ea9bdf465954e89e5590eb2c1a", - "body": "mutation RemoveTodoMutation( - $input: RemoveTodoInput! -) { + "body": "mutation RemoveTodoMutation($input: RemoveTodoInput!) { removeTodo(input: $input) { deletedTodoId user { @@ -280,10 +306,9 @@ Array [ id } } -} -", +}", }, - Object { + { "alias": "67c2bc8aa3185a209d6651b4feb63c04", "body": "query appQuery( $userId: String @@ -360,7 +385,7 @@ fragment Todo_user on User { } ", }, - Object { + { "alias": "db9904c31d91416f21d45fe3d153884c", "body": "mutation MarkAllTodosMutation( $input: MarkAllTodosInput! @@ -378,7 +403,7 @@ fragment Todo_user on User { } ", }, - Object { + { "alias": "2eb8c9941fdb3117fdbc08d15fab62d0", "body": "mutation AddTodoMutation( $input: AddTodoInput! @@ -401,7 +426,7 @@ fragment Todo_user on User { } ", }, - Object { + { "alias": "d970fd7dbf118794415dec7324d463e3", "body": "mutation RenameTodoMutation( $input: RenameTodoInput! @@ -415,7 +440,7 @@ fragment Todo_user on User { } ", }, - Object { + { "alias": "a49217db31a8be3f4107763b957d5fca", "body": "mutation RemoveCompletedTodosMutation( $input: RemoveCompletedTodosInput! @@ -431,7 +456,7 @@ fragment Todo_user on User { } ", }, - Object { + { "alias": "d7dda774dcfa32fe0d9661e01cac9a4a", "body": "mutation ChangeTodoStatusMutation( $input: ChangeTodoStatusInput! @@ -452,9 +477,35 @@ fragment Todo_user on User { ] `; +exports[`sync operations Relay support Uses Apollo Codegen JSON files 1`] = ` +[ + { + "alias": "22cc98c61c1402c92b230b7c515e07eb793a5152c388b015e86df4652ec58156", + "body": "mutation UpdateSomething($name: String!) { + updateSomething(name: $name) { + __typename + name + } +}", + "name": "UpdateSomething", + }, + { + "alias": "688df2ea182541c70a34c55ca056dc249014bf9f33c64eee527120c714e936fc", + "body": "query getHelloWorld { + helloWorld + ...MoreFields +} +fragment MoreFields on Query { + __typename +}", + "name": "getHelloWorld", + }, +] +`; + exports[`sync operations Relay support Uses Relay generated .js files 1`] = ` -Array [ - Object { +[ + { "alias": "353e010cb78d082b29cb63ee7e9027b3", "body": "query AppFeedQuery { feed(type: NEW, limit: 5) { @@ -491,8 +542,8 @@ fragment FeedEntry on Entry { `; exports[`sync operations Relay support Uses relay --persist-output JSON files 1`] = ` -Array [ - Object { +[ + { "alias": "aba626ea9bdf465954e89e5590eb2c1a", "body": "mutation RemoveTodoMutation( $input: RemoveTodoInput! @@ -508,7 +559,7 @@ Array [ } ", }, - Object { + { "alias": "67c2bc8aa3185a209d6651b4feb63c04", "body": "query appQuery( $userId: String @@ -585,7 +636,7 @@ fragment Todo_user on User { } ", }, - Object { + { "alias": "db9904c31d91416f21d45fe3d153884c", "body": "mutation MarkAllTodosMutation( $input: MarkAllTodosInput! @@ -603,7 +654,7 @@ fragment Todo_user on User { } ", }, - Object { + { "alias": "2eb8c9941fdb3117fdbc08d15fab62d0", "body": "mutation AddTodoMutation( $input: AddTodoInput! @@ -626,7 +677,7 @@ fragment Todo_user on User { } ", }, - Object { + { "alias": "d970fd7dbf118794415dec7324d463e3", "body": "mutation RenameTodoMutation( $input: RenameTodoInput! @@ -640,7 +691,7 @@ fragment Todo_user on User { } ", }, - Object { + { "alias": "a49217db31a8be3f4107763b957d5fca", "body": "mutation RemoveCompletedTodosMutation( $input: RemoveCompletedTodosInput! @@ -656,7 +707,7 @@ fragment Todo_user on User { } ", }, - Object { + { "alias": "d7dda774dcfa32fe0d9661e01cac9a4a", "body": "mutation ChangeTodoStatusMutation( $input: ChangeTodoStatusInput! @@ -677,45 +728,176 @@ fragment Todo_user on User { ] `; +exports[`sync operations Sync output Can dump payload and outfile at the same time 1`] = ` +"{ + "operations": [ + { + "name": "GetStuff", + "body": "fragment Frag1 on Query {\\n moreStuff\\n}\\n\\nquery GetStuff {\\n ...Frag1\\n}", + "alias": "4568c28d403794e011363caf815ec827" + }, + { + "name": "GetStuff2", + "body": "fragment Frag1 on Query {\\n moreStuff\\n}\\n\\nfragment Frag2 on Query {\\n ...Frag3\\n}\\n\\nfragment Frag3 on Query {\\n evenMoreStuff\\n}\\n\\nquery GetStuff2 {\\n stuff\\n ...Frag1\\n ...Frag2\\n}", + "alias": "faf462be033e16dd2a56130d56a9192f" + }, + { + "name": "GetStuff3", + "body": "fragment Frag2 on Query {\\n ...Frag3\\n}\\n\\nfragment Frag3 on Query {\\n evenMoreStuff\\n}\\n\\nfragment Frag4 on Query {\\n evenMoreStuff {\\n stuffInside\\n }\\n}\\n\\nquery GetStuff3 {\\n stuff {\\n withStuffInside\\n }\\n ...Frag2\\n ...Frag4\\n}", + "alias": "aab385a1685772ad520fc70d468030fa" + }, + { + "name": "GetStuffIsolated", + "body": "query GetStuffIsolated {\\n ...FragIsolated\\n things {\\n existHere\\n }\\n}\\n\\nfragment FragIsolated on Query {\\n evenMoreStuff {\\n stuffInside\\n }\\n}", + "alias": "b2cb0b317d071f9f38905fba21d73258" + }, + { + "name": "GetStuffIsolated2", + "body": "query GetStuffIsolated2 {\\n things {\\n existHere\\n }\\n}", + "alias": "6cdae165fd6dc5dc5900e5a2bba90cc2" + } + ] +} +" +`; + exports[`sync operations custom file processing options Adds .graphql to the glob if needed 1`] = ` -Array [ - Object { - "alias": "f7f65309043352183e905e1396e51078", +[ + { + "alias": "b8086942c2fbb6ac69b97cbade848033", "body": "query GetStuff { stuff -} -", +}", "name": "GetStuff", }, ] `; exports[`sync operations custom file processing options Adds .graphql to the glob if needed 2`] = ` -Array [ - Object { - "alias": "f7f65309043352183e905e1396e51078", +[ + { + "alias": "b8086942c2fbb6ac69b97cbade848033", "body": "query GetStuff { stuff -} -", +}", "name": "GetStuff", }, ] `; exports[`sync operations custom file processing options Uses a custom hash function if provided 1`] = ` -Array [ - Object { +[ + { "alias": "GETSTUFF", "body": "query GetStuff { stuff -} -", +}", "name": "GetStuff", }, ] `; +exports[`sync operations generating artifacts without syncing works with persisted query manifest 1`] = ` +" + /** + * Generated by graphql-ruby-client + * + */ + + /** + * Map local operation names to persisted keys on the server + * @return {Object} + * @private + */ + var _aliases = { + "TestQuery1": "4a29162b05ee4d82ad02e8f50af4bf112f47181ec558a7100a", + "TestQuery2": "xyz-123" +} + + /** + * The client who synced these operations with the server + * @return {String} + * @private + */ + var _client = "test-1" + + var OperationStoreClient = { + /** + * Build a string for \`params[:operationId]\` + * @param {String} operationName + * @return {String} stored operation ID + */ + getOperationId: function(operationName) { + return _client + "/" + OperationStoreClient.getPersistedQueryAlias(operationName) + }, + + /** + * Fetch a persisted alias from a local operation name + * @param {String} operationName + * @return {String} persisted alias + */ + getPersistedQueryAlias: function(operationName) { + var persistedAlias = _aliases[operationName] + if (!persistedAlias) { + throw new Error("Failed to find persisted alias for operation name: " + operationName) + } else { + return persistedAlias + } + }, + + /** + * Satisfy the Apollo Link API. + * This link checks for an operation name, and if it's present, + * sets the HTTP context to _not_ include the query, + * and instead, include \`extensions.operationId\`. + * (This is inspired by apollo-link-persisted-queries.) + */ + apolloLink: function(operation, forward) { + if (operation.operationName) { + const operationId = OperationStoreClient.getOperationId(operation.operationName) + operation.setContext({ + http: { + includeQuery: false, + includeExtensions: true, + } + }) + operation.extensions.operationId = operationId + } + return forward(operation) + }, + /** + * Satisfy the Apollo middleware API. + * Replace the query with an operationId + */ + apolloMiddleware: { + applyBatchMiddleware: function(options, next) { + options.requests.forEach(function(req) { + // Fetch the persisted alias for this operation + req.operationId = OperationStoreClient.getOperationId(req.operationName) + // Remove the now-unused query string + delete req.query + return req + }) + // Continue the request + next() + }, + + applyMiddleware: function(options, next) { + var req = options.request + // Fetch the persisted alias for this operation + req.operationId = OperationStoreClient.getOperationId(req.operationName) + // Remove the now-unused query string + delete req.query + // Continue the request + next() + } + } + } + + module.exports = OperationStoreClient + " +`; + exports[`sync operations generating artifacts without syncing works without a URL 1`] = ` " /** @@ -729,7 +911,7 @@ exports[`sync operations generating artifacts without syncing works without a UR * @private */ var _aliases = { - \\"GetStuff\\": \\"f7f65309043352183e905e1396e51078\\" + "GetStuff": "b8086942c2fbb6ac69b97cbade848033" } /** @@ -737,7 +919,7 @@ exports[`sync operations generating artifacts without syncing works without a UR * @return {String} * @private */ - var _client = \\"test-1\\" + var _client = "test-1" var OperationStoreClient = { /** @@ -746,7 +928,7 @@ exports[`sync operations generating artifacts without syncing works without a UR * @return {String} stored operation ID */ getOperationId: function(operationName) { - return _client + \\"/\\" + OperationStoreClient.getPersistedQueryAlias(operationName) + return _client + "/" + OperationStoreClient.getPersistedQueryAlias(operationName) }, /** @@ -757,7 +939,7 @@ exports[`sync operations generating artifacts without syncing works without a UR getPersistedQueryAlias: function(operationName) { var persistedAlias = _aliases[operationName] if (!persistedAlias) { - throw new Error(\\"Failed to find persisted alias for operation name: \\" + operationName) + throw new Error("Failed to find persisted alias for operation name: " + operationName) } else { return persistedAlias } @@ -817,27 +999,27 @@ exports[`sync operations generating artifacts without syncing works without a UR `; exports[`sync operations verbose Adds debug output 1`] = ` -Array [ - Array [ +[ + [ "[Sync] glob: ", "./src/__tests__/documents**/*.graphql*", ], - Array [ + [ "[Sync] 1 files:", ], - Array [ - "[Sync] - ./src/__tests__/documents/doc1.graphql", + [ + "[Sync] - src/__tests__/documents/doc1.graphql", ], - Array [ + [ "Syncing 1 operations to bogus...", ], - Array [ + [ "Verbose!", ], - Array [ + [ "Generating client module in src/OperationStoreClient.js...", ], - Array [ + [ "✓ Done!", ], ] diff --git a/javascript_client/src/__tests__/apolloExample/apollo.config.js b/javascript_client/src/__tests__/apolloExample/apollo.config.js new file mode 100644 index 00000000000..aa00f7be6d8 --- /dev/null +++ b/javascript_client/src/__tests__/apolloExample/apollo.config.js @@ -0,0 +1,11 @@ +// apollo client:codegen gen/output.json --target json +module.exports = { + client: { + service: { + name: "testSchema", + localSchemaFile: "./schema.graphql", + }, + includes: ["./*.ts"], + mergeInFieldsFromFragmentSpreads: true, + } +} diff --git a/javascript_client/src/__tests__/apolloExample/fragment.ts b/javascript_client/src/__tests__/apolloExample/fragment.ts new file mode 100644 index 00000000000..2b2e04b705e --- /dev/null +++ b/javascript_client/src/__tests__/apolloExample/fragment.ts @@ -0,0 +1,5 @@ +import { gql } from '@apollo/client'; + +export const MORE_FIELDS = gql` +fragment MoreFields on Query { __typename } +` diff --git a/javascript_client/src/__tests__/apolloExample/gen/output.json b/javascript_client/src/__tests__/apolloExample/gen/output.json new file mode 100644 index 00000000000..e94a91b7fb4 --- /dev/null +++ b/javascript_client/src/__tests__/apolloExample/gen/output.json @@ -0,0 +1,108 @@ +{ + "operations": [ + { + "filePath": "file:///Users/rmosolgo/code/graphql-ruby/javascript_client/src/sync/__tests__/apolloExample/mutation.ts", + "operationName": "UpdateSomething", + "operationType": "mutation", + "rootType": "Mutation", + "variables": [ + { + "name": "name", + "type": "String!" + } + ], + "source": "mutation UpdateSomething($name: String!) {\n updateSomething(name: $name) {\n __typename\n name\n somethingElse @client\n }\n}", + "fields": [ + { + "responseName": "updateSomething", + "fieldName": "updateSomething", + "type": "UpdateSomethingPayload", + "args": [ + { + "name": "name", + "value": { + "kind": "Variable", + "variableName": "name" + }, + "type": "String!" + } + ], + "isConditional": false, + "isDeprecated": false, + "fields": [ + { + "responseName": "__typename", + "fieldName": "__typename", + "type": "String!", + "isConditional": false + }, + { + "responseName": "name", + "fieldName": "name", + "type": "String!", + "isConditional": false, + "isDeprecated": false + } + ], + "fragmentSpreads": [], + "inlineFragments": [] + } + ], + "fragmentSpreads": [], + "inlineFragments": [], + "fragmentsReferenced": [], + "sourceWithFragments": "mutation UpdateSomething($name: String!) {\n updateSomething(name: $name) {\n __typename\n name\n }\n}", + "operationId": "22cc98c61c1402c92b230b7c515e07eb793a5152c388b015e86df4652ec58156" + }, + { + "filePath": "file:///Users/rmosolgo/code/graphql-ruby/javascript_client/src/sync/__tests__/apolloExample/query.ts", + "operationName": "getHelloWorld", + "operationType": "query", + "rootType": "Query", + "variables": [], + "source": "query getHelloWorld {\n helloWorld\n ...MoreFields\n}", + "fields": [ + { + "responseName": "helloWorld", + "fieldName": "helloWorld", + "type": "String!", + "isConditional": false, + "isDeprecated": false + } + ], + "fragmentSpreads": [ + "MoreFields" + ], + "inlineFragments": [], + "fragmentsReferenced": [ + "MoreFields" + ], + "sourceWithFragments": "query getHelloWorld {\n helloWorld\n ...MoreFields\n}\nfragment MoreFields on Query {\n __typename\n}", + "operationId": "688df2ea182541c70a34c55ca056dc249014bf9f33c64eee527120c714e936fc" + } + ], + "fragments": [ + { + "typeCondition": "Query", + "possibleTypes": [ + "Query" + ], + "fragmentName": "MoreFields", + "filePath": "file:///Users/rmosolgo/code/graphql-ruby/javascript_client/src/sync/__tests__/apolloExample/fragment.ts", + "source": "fragment MoreFields on Query {\n __typename\n}", + "fields": [ + { + "responseName": "__typename", + "fieldName": "__typename", + "type": "String!", + "isConditional": false + } + ], + "fragmentSpreads": [], + "inlineFragments": [] + } + ], + "typesUsed": [], + "unionTypes": [], + "interfaceTypes": [] +} diff --git a/javascript_client/src/__tests__/apolloExample/mutation.ts b/javascript_client/src/__tests__/apolloExample/mutation.ts new file mode 100644 index 00000000000..02d7b7d749f --- /dev/null +++ b/javascript_client/src/__tests__/apolloExample/mutation.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_SOMETHING = gql` +mutation UpdateSomething($name: String!) { + updateSomething(name: $name) { name } +} +` diff --git a/javascript_client/src/__tests__/apolloExample/query.ts b/javascript_client/src/__tests__/apolloExample/query.ts new file mode 100644 index 00000000000..7a13a7deed3 --- /dev/null +++ b/javascript_client/src/__tests__/apolloExample/query.ts @@ -0,0 +1,10 @@ +import { gql } from '@apollo/client'; +import { MORE_FIELDS } from './fragment'; + +export const GET_HELLO_WORLD = gql` +query getHelloWorld { + helloWorld + ... MoreFields +} +${MORE_FIELDS} +` diff --git a/javascript_client/src/__tests__/apolloExample/schema.graphql b/javascript_client/src/__tests__/apolloExample/schema.graphql new file mode 100644 index 00000000000..c1b8f5b1e40 --- /dev/null +++ b/javascript_client/src/__tests__/apolloExample/schema.graphql @@ -0,0 +1,11 @@ +type Query { + helloWorld: String! +} + +type Mutation { + updateSomething(name: String!): UpdateSomethingPayload +} + +type UpdateSomethingPayload { + name: String! +} diff --git a/javascript_client/src/__tests__/cliTest.ts b/javascript_client/src/__tests__/cliTest.ts index 2b2981f9b9f..b476cdb73b5 100644 --- a/javascript_client/src/__tests__/cliTest.ts +++ b/javascript_client/src/__tests__/cliTest.ts @@ -1,4 +1,5 @@ var childProcess = require("child_process") +let fs = require('fs') describe("CLI", () => { it("exits 1 on error", () => { @@ -10,4 +11,47 @@ describe("CLI", () => { it("exits 0 on OK", () => { childProcess.execSync("node ./cli.js sync -h", {stdio: "pipe"}) }) + + it("runs with some options", () => { + var buffer = childProcess.execSync("node ./cli.js sync --client=something --header=Abcd:efgh --header=\"Abc: 123 45\" --changeset-version=2023-01-01 --mode=file --path=\"**/doc1.graphql\" --verbose", {stdio: "pipe"}) + var response = buffer.toString().replace(/\033\[[0-9;]*m/g, "") + expect(response).toEqual("No URL; Generating artifacts without syncing them\n[Sync] glob: **/doc1.graphql\n[Sync] 1 files:\n[Sync] - src/__tests__/documents/doc1.graphql\nGenerating client module in src/OperationStoreClient.js...\n✓ Done!\n") + }) + + it("runs with just one header", () => { + var buffer = childProcess.execSync("node ./cli.js sync --client=something --header=Ab-cd:ef-gh --mode=file --path=\"**/doc1.graphql\"", {stdio: "pipe"}) + var response = buffer.toString().replace(/\033\[[0-9;]*m/g, "") + expect(response).toEqual("No URL; Generating artifacts without syncing them\nGenerating client module in src/OperationStoreClient.js...\n✓ Done!\n") + }) + + it("writes to a dump file", () => { + let buffer = childProcess.execSync("node ./cli.js sync --client=something --header=Ab-cd:ef-gh --dump-payload=./DumpPayloadExample.json --path=\"**/doc1.graphql\"", {stdio: "pipe"}) + console.log(buffer.toString()) + let dumpedJSON = fs.readFileSync("./DumpPayloadExample.json", 'utf8') + expect(dumpedJSON).toEqual(`{ + "operations": [ + { + "name": "GetStuff", + "body": "query GetStuff {\\n stuff\\n}", + "alias": "b8086942c2fbb6ac69b97cbade848033" + } + ] +} +`) + }) + + it("writes to stdout", () => { + let buffer = childProcess.execSync("node ./cli.js sync --client=something --header=Ab-cd:ef-gh --dump-payload --path=\"**/doc1.graphql\"", {stdio: "pipe"}) + let dumpedJSON = buffer.toString().replace(/\033\[[0-9;]*m/g, "") + expect(dumpedJSON).toEqual(`{ + "operations": [ + { + "name": "GetStuff", + "body": "query GetStuff {\\n stuff\\n}", + "alias": "b8086942c2fbb6ac69b97cbade848033" + } + ] +} +`) + }) }) diff --git a/javascript_client/src/__tests__/esmTest.ts b/javascript_client/src/__tests__/esmTest.ts new file mode 100644 index 00000000000..a6b0254cb9a --- /dev/null +++ b/javascript_client/src/__tests__/esmTest.ts @@ -0,0 +1,26 @@ +var childProcess = require("child_process") + +function runCommand(commandStr: string) { + var buffer = childProcess.execSync(commandStr, {stdio: "pipe"}) + return buffer.toString().replace(/\033\[[0-9;]*m/g, "") +} +describe("ESM build", () => { + beforeAll(() => { + runCommand("npm pack --dry-run") + }) + + it("Can import ESM modules", () => { + const importResult = runCommand("node --input-type=module -e 'import { sync, ActionCableLink } from \"graphql-ruby-client\"; console.log(typeof sync, typeof ActionCableLink)'") + expect(importResult).toEqual("function function\n") + + const importResult2 = runCommand("node --input-type=module -e 'import ActionCableLink from \"graphql-ruby-client/subscriptions/ActionCableLink.js\"; console.log(typeof ActionCableLink)'") + expect(importResult2).toEqual("function\n") + }) + + it("Can require", () => { + const requireResult = runCommand("node -e 'const ActionCableLink = require(\"graphql-ruby-client/subscriptions/ActionCableLink.js\"); console.log(typeof ActionCableLink.default || typeof ActionCableLink)'") + expect(requireResult).toEqual("function\n") + const requireResult2 = runCommand("node -e 'require.resolve(\"graphql-ruby-client/cli.js\"); console.log(\"ok\")'") + expect(requireResult2).toEqual("ok\n") + }) +}) diff --git a/javascript_client/src/__tests__/example-apollo-android-operation-output.json b/javascript_client/src/__tests__/example-apollo-android-operation-output.json index 3664bc41593..4f93955f2f6 100644 --- a/javascript_client/src/__tests__/example-apollo-android-operation-output.json +++ b/javascript_client/src/__tests__/example-apollo-android-operation-output.json @@ -1,7 +1,7 @@ { "aba626ea9bdf465954e89e5590eb2c1a": { "name": "RemoveTodoMutation", - "source": "mutation RemoveTodoMutation(\n $input: RemoveTodoInput!\n) {\n removeTodo(input: $input) {\n deletedTodoId\n user {\n completedCount\n totalCount\n id\n }\n }\n}\n" + "source": "mutation RemoveTodoMutation(\n $input: RemoveTodoInput!\n) {\n removeTodo(input: $input) {\n deletedTodoId\n user {\n completedCount\n totalCount\n thing @client\n id\n }\n }\n}\n" }, "67c2bc8aa3185a209d6651b4feb63c04": { "name": "appQuery", diff --git a/javascript_client/src/__tests__/syncTest.ts b/javascript_client/src/__tests__/syncTest.ts index 9f6b6795c83..e7497605533 100644 --- a/javascript_client/src/__tests__/syncTest.ts +++ b/javascript_client/src/__tests__/syncTest.ts @@ -1,4 +1,5 @@ import sync from "../sync" +import Logger from "../sync/logger" var fs = require("fs") var nock = require("nock") @@ -19,6 +20,9 @@ describe("sync operations", () => { beforeEach(() => { global.console.error = jest.fn() global.console.log = jest.fn() + if (fs.existsSync("./src/OperationStoreClient.js")) { + fs.unlinkSync("./src/OperationStoreClient.js") + } }) afterEach(() => { @@ -34,7 +38,21 @@ describe("sync operations", () => { return sync(options).then(function() { var generatedCode = fs.readFileSync("./src/OperationStoreClient.js", "utf8") - expect(generatedCode).toMatch('"GetStuff": "f7f65309043352183e905e1396e51078"') + expect(generatedCode).toMatch('"GetStuff": "b8086942c2fbb6ac69b97cbade848033"') + expect(generatedCode).toMatchSnapshot() + }) + }) + + it("works with persisted query manifest", () => { + var options = { + client: "test-1", + outfile: "./src/OperationStoreClient.js", + apolloPersistedQueryManifest: "./src/sync/__tests__/generate-persisted-query-manifest.json", + } + + return sync(options).then(function() { + var generatedCode = fs.readFileSync("./src/OperationStoreClient.js", "utf8") + expect(generatedCode).toMatch('"TestQuery2": "xyz-123"') expect(generatedCode).toMatchSnapshot() }) }) @@ -47,13 +65,21 @@ describe("sync operations", () => { client: "test-1", path: "./src/__tests__/documents", url: "bogus", + headers: { + "X-Something-Special": "🎂", + }, + changesetVersion: "2023-05-05", quiet: true, - send: (_sendPayload: object, options: { url: string }) => { + send: (_sendPayload: object, options: { url: string, headers: {[key: string]: string}, changesetVersion: string }) => { url = options.url + Object.keys(options.headers).forEach((h) => { + url += "?" + h + "=" + options.headers[h] + }) + url += "&changesetVersion=" + options.changesetVersion }, } return sync(options).then(function() { - expect(url).toEqual("bogus") + expect(url).toEqual("bogus?X-Something-Special=🎂&changesetVersion=2023-05-05") }) }) }) @@ -66,10 +92,8 @@ describe("sync operations", () => { path: "./src/__tests__/documents", url: "bogus", verbose: true, - send: (_sendPayload: string, opts: { verbose: boolean }) => { - if (opts.verbose) { - console.log("Verbose!") - } + send: (_sendPayload: string, opts: { logger: Logger }) => { + opts.logger.log("Verbose!") }, } return sync(options).then(function() { @@ -166,6 +190,23 @@ describe("sync operations", () => { return expect(payload.operations).toMatchSnapshot() }) }) + + it("Uses Apollo Codegen JSON files", () => { + var payload: MockPayload + var options = { + client: "test-1", + quiet: true, + apolloCodegenJsonOutput: "./src/__tests__/apolloExample/gen/output.json", + url: "bogus", + send: (sendPayload: MockPayload, _opts: object) => { + payload = sendPayload + }, + } + return sync(options).then(function () { + expect(payload.operations[0].alias).toEqual("22cc98c61c1402c92b230b7c515e07eb793a5152c388b015e86df4652ec58156") + return expect(payload.operations).toMatchSnapshot() + }) + }) }) describe("Input files", () => { @@ -230,7 +271,7 @@ describe("sync operations", () => { } return sync(options).then(function() { var generatedCode = fs.readFileSync("./src/OperationStoreClient.js", "utf8") - expect(generatedCode).toMatch('"GetStuff": "5f0da489cf508a7c65ff5fa144e50545"') + expect(generatedCode).toMatch('"GetStuff": "4568c28d403794e011363caf815ec827"') expect(generatedCode).toMatch('module.exports = OperationStoreClient') expect(generatedCode).toMatch('var _client = "test-1"') fs.unlinkSync("./src/OperationStoreClient.js") @@ -248,13 +289,35 @@ describe("sync operations", () => { } return sync(options).then(function() { var generatedCode = fs.readFileSync("./__crazy_outfile.js", "utf8") - expect(generatedCode).toMatch('"GetStuff": "5f0da489cf508a7c65ff5fa144e50545"') + expect(generatedCode).toMatch('"GetStuff": "4568c28d403794e011363caf815ec827"') expect(generatedCode).toMatch('module.exports = OperationStoreClient') expect(generatedCode).toMatch('var _client = "test-2"') fs.unlinkSync("./__crazy_outfile.js") }) }) + it("Can dump payload and outfile at the same time", () => { + var options = { + client: "test-2", + path: "./src/__tests__/project", + quiet: true, + outfile: "customOutfile.js", + dumpPayload: "customDumpPayload.js" + } + + return sync(options).then(function() { + var generatedCode = fs.readFileSync("./customOutfile.js", "utf8") + expect(generatedCode).toMatch('"GetStuff": "4568c28d403794e011363caf815ec827"') + expect(generatedCode).toMatch('module.exports = OperationStoreClient') + expect(generatedCode).toMatch('var _client = "test-2"') + + var generatedPayload = fs.readFileSync("./customDumpPayload.js", "utf8") + expect(generatedPayload).toMatchSnapshot() + fs.unlinkSync("./customOutfile.js") + fs.unlinkSync("./customDumpPayload.js") + }) + }) + it("Skips outfile generation when using --persist-output artifact", () => { var options = { client: "test-2", @@ -327,8 +390,8 @@ describe("sync operations", () => { var spyConsoleError = (console.error as unknown) as MockedObject buildMockRespondingWith(422, { - errors: { "5f0da489cf508a7c65ff5fa144e50545": ["something"] }, - failed: ["5f0da489cf508a7c65ff5fa144e50545"], + errors: { "4568c28d403794e011363caf815ec827": ["something"] }, + failed: ["4568c28d403794e011363caf815ec827"], added: ["defg"], not_modified: [], }) @@ -342,7 +405,7 @@ describe("sync operations", () => { var syncPromise = sync(options) - return syncPromise.catch((errmsg) => { + return syncPromise.catch((errmsg: string) => { expect(errmsg).toEqual("Sync failed: GetStuff: something") expect(spyConsoleLog.mock.calls).toMatchSnapshot() expect(spyConsoleError.mock.calls).toMatchSnapshot() diff --git a/javascript_client/src/cli.ts b/javascript_client/src/cli.ts index 87aa1ff0db8..069457bcd3a 100755 --- a/javascript_client/src/cli.ts +++ b/javascript_client/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import parseArgs from "minimist" -import sync from "./sync/index" +import sync, { SyncOptions } from "./sync/index" var argv = parseArgs(process.argv.slice(2)) if (argv.help || argv.h) { @@ -17,11 +17,15 @@ optional arguments: --path= Path to .graphql files (default is "./**/*.graphql") --outfile= Target file for generated code --outfile-type= Target type for generated code (default is "js") - --key= HMAC authentication key + --secret= HMAC authentication key --relay-persisted-output= Path to a .json file from "relay-compiler ... --persist-output" (Outfile generation is skipped by default.) + --apollo-codegen-json-output= Path to a .json file from "apollo client:codegen ... --target json" + (Outfile generation is skipped by default.) --apollo-android-operation-output= Path to a .json file from Apollo-Android's "generateOperationOutput" feature. (Outfile generation is skipped by default.) + --apollo-persisted-query-manifest= Path to a .json file from Apollo's "generate-persisted-query-manifest" tool. + (Outfile generation is skipped by default.) --mode= Treat files like a certain kind of project: relay: treat files like relay-compiler output project: treat files like a cohesive project (fragments are shared, names must be unique) @@ -30,7 +34,11 @@ optional arguments: By default, this flag is set to: - "relay" if "__generated__" in the path - otherwise, "project" + --header=
: Add a header to the outgoing HTTP request + (may be repeated) + --changeset-version= Populates \`context[:changeset_version]\` for this sync (for the GraphQL-Enterprise "Changesets" feature) --add-typename Automatically adds the "__typename" field to your queries + --dump-payload= Print the HTTP Post data to this file, or to stdout if no filename is given --quiet Suppress status logging --verbose Print debug output --help Print this message @@ -41,20 +49,42 @@ optional arguments: if (commandName !== "sync") { console.log("Only `graphql-ruby-client sync` is supported") } else { - var result = sync({ + var parsedHeaders: {[key: string]: string} = {} + if (argv.header) { + if (typeof(argv.header) === "string") { + var headerParts = argv.header.split(":") + parsedHeaders[headerParts[0]] = headerParts[1] + } else { + argv.header.forEach((h: string) => { + var headerParts = h.split(":") + parsedHeaders[headerParts[0]] = headerParts[1] + }) + } + } + let syncOptions: SyncOptions = { path: argv.path, relayPersistedOutput: argv["relay-persisted-output"], + apolloCodegenJsonOutput: argv["apollo-codegen-json-output"], apolloAndroidOperationOutput: argv["apollo-android-operation-output"], + apolloPersistedQueryManifest: argv["apollo-persisted-query-manifest"], url: argv.url, client: argv.client, outfile: argv.outfile, outfileType: argv["outfile-type"], secret: argv.secret, mode: argv.mode, + headers: parsedHeaders, addTypename: argv["add-typename"], quiet: argv.hasOwnProperty("quiet"), verbose: argv.hasOwnProperty("verbose"), - }) + changesetVersion: argv["changeset-version"], + } + + if ("dump-payload" in argv) { + syncOptions.dumpPayload = argv["dump-payload"] + } + + var result = sync(syncOptions) result.then(function() { process.exit(0) diff --git a/javascript_client/src/subscriptions/AblyLink.ts b/javascript_client/src/subscriptions/AblyLink.ts index 263d41f64cc..6b2a790be72 100644 --- a/javascript_client/src/subscriptions/AblyLink.ts +++ b/javascript_client/src/subscriptions/AblyLink.ts @@ -36,10 +36,26 @@ // // Do something with `data` and/or `errors` // }}) // -import { ApolloLink, Observable, FetchResult, NextLink, Operation } from "@apollo/client/core" -import { Realtime } from "ably" +import { + ApolloLink, + Observable, + FetchResult, + NextLink, + Operation, + Observer +} from "@apollo/client/core" +import { Realtime, Types } from "ably" -type RequestResult = Observable, Record>> +type RequestResult = FetchResult< + { [key: string]: any }, + Record, + Record +> + +type Subscription = { + closed: boolean + unsubscribe(): void +} class AblyLink extends ApolloLink { ably: Realtime @@ -50,24 +66,82 @@ class AblyLink extends ApolloLink { this.ably = options.ably } - request(operation: Operation, forward: NextLink): RequestResult { - return new Observable((observer) => { + request(operation: Operation, forward: NextLink): Observable { + const subscribeObservable = new Observable(_observer => {}) + + // Capture the super method + const prevSubscribe = subscribeObservable.subscribe.bind( + subscribeObservable + ) + + // Override subscribe to return an `unsubscribe` object, see + // https://github.com/apollographql/subscriptions-transport-ws/blob/master/src/client.ts#L182-L212 + subscribeObservable.subscribe = ( + observerOrNext: + | Observer + | ((value: RequestResult) => void), + onError?: (error: any) => void, + onComplete?: () => void + ): Subscription => { + // Call super + if (typeof observerOrNext == "function") { + prevSubscribe(observerOrNext, onError, onComplete) + } else { + prevSubscribe(observerOrNext) + } + + const observer = getObserver(observerOrNext, onError, onComplete) + let ablyChannel: Types.RealtimeChannelCallbacks | null = null + let subscriptionChannelId: string | null = null + // Check the result of the operation - forward(operation).subscribe({ next: (data) => { - // If the operation has the subscription header, it's a subscription - const subscriptionChannelConfig = this._getSubscriptionChannel(operation) - if (subscriptionChannelConfig) { - // This will keep pushing to `.next` - this._createSubscription(subscriptionChannelConfig, observer) - } - else { - // This isn't a subscription, - // So pass the data along and close the observer. - observer.next(data) - observer.complete() + const resultObservable = forward(operation) + // When the operation is done, try to get the subscription ID from the server + const resultSubscription = resultObservable.subscribe({ + next: (data: any) => { + // If the operation has the subscription header, it's a subscription + const subscriptionChannelConfig = this._getSubscriptionChannel( + operation + ) + if (subscriptionChannelConfig.channel) { + subscriptionChannelId = subscriptionChannelConfig.channel + // This will keep pushing to `.next` + ablyChannel = this._createSubscription( + subscriptionChannelConfig, + observer + ) + } else { + // This isn't a subscription, + // So pass the data along and close the observer. + if (data) { + observer.next(data) + } + observer.complete() + } + }, + error: observer.error + // complete: observer.complete Don't pass this because Apollo unsubscribes if you do + }) + + // Return an object that will unsubscribe _if_ the query was a subscription. + return { + closed: false, + unsubscribe: () => { + if (ablyChannel && subscriptionChannelId) { + const ablyClientId = this.ably.auth.clientId + if (ablyClientId) { + ablyChannel.presence.leave() + } else { + ablyChannel.presence.leaveClient("graphql-subscriber") + } + ablyChannel.unsubscribe() + resultSubscription.unsubscribe() + } } - }}) - }) + } + } + + return subscribeObservable } _getSubscriptionChannel(operation: Operation) { @@ -79,10 +153,15 @@ class AblyLink extends ApolloLink { return { channel: subscriptionChannel, key: cipherKey } } - _createSubscription(subscriptionChannelConfig: { channel: string, key: string }, observer: { next: Function, complete: Function}) { + _createSubscription( + subscriptionChannelConfig: { channel: string; key: string }, + observer: { next: Function; complete: Function } + ) { const subscriptionChannel = subscriptionChannelConfig["channel"] const subscriptionKey = subscriptionChannelConfig["key"] - const ablyOptions = subscriptionKey ? { cipher: { key: subscriptionKey } } : {} + const ablyOptions = subscriptionKey + ? { cipher: { key: subscriptionKey } } + : {} const ablyChannel = this.ably.channels.get(subscriptionChannel, ablyOptions) const ablyClientId = this.ably.auth.clientId // Register presence, so that we can detect empty channels and clean them up server-side @@ -94,6 +173,11 @@ class AblyLink extends ApolloLink { // Subscribe for more update ablyChannel.subscribe("update", function(message) { var payload = message.data + const result = payload.result + if (result) { + // Send the new response to listeners + observer.next(result) + } if (!payload.more) { // This is the end, the server says to unsubscribe if (ablyClientId) { @@ -104,12 +188,32 @@ class AblyLink extends ApolloLink { ablyChannel.unsubscribe() observer.complete() } - const result = payload.result - if (result) { - // Send the new response to listeners - observer.next(result) - } }) + return ablyChannel + } +} + +// Turn `subscribe` arguments into an observer-like thing, see getObserver +// https://github.com/apollographql/subscriptions-transport-ws/blob/master/src/client.ts#L347-L361 +function getObserver( + observerOrNext: Function | Observer, + onError?: (e: Error) => void, + onComplete?: () => void +) { + if (typeof observerOrNext === "function") { + // Duck-type an observer + return { + next: (v: T) => observerOrNext(v), + error: (e: Error) => onError && onError(e), + complete: () => onComplete && onComplete() + } + } else { + // Make an object that calls to the given object, with safety checks + return { + next: (v: T) => observerOrNext.next && observerOrNext.next(v), + error: (e: Error) => observerOrNext.error && observerOrNext.error(e), + complete: () => observerOrNext.complete && observerOrNext.complete() + } } } diff --git a/javascript_client/src/subscriptions/ActionCableLink.ts b/javascript_client/src/subscriptions/ActionCableLink.ts index b608068ade6..ff267bd396c 100644 --- a/javascript_client/src/subscriptions/ActionCableLink.ts +++ b/javascript_client/src/subscriptions/ActionCableLink.ts @@ -1,40 +1,57 @@ import { ApolloLink, Observable, FetchResult, Operation, NextLink } from "@apollo/client/core" -import { Cable } from "actioncable" +import type { Consumer } from "@rails/actioncable" import { print } from "graphql" +import defaultChannelId from "./defaultChannelId" type RequestResult = FetchResult<{ [key: string]: any; }, Record, Record> type ConnectionParams = object | ((operation: Operation) => object) +type SubscriptionCallbacks = { + connected?: (args?: { reconnected: boolean }) => void; + disconnected?: () => void; + received?: (payload: any) => void; +}; +type CreateChannelId = () => string class ActionCableLink extends ApolloLink { - cable: Cable + cable: Consumer channelName: string actionName: string connectionParams: ConnectionParams + callbacks: SubscriptionCallbacks + createChannelId: CreateChannelId constructor(options: { - cable: Cable, channelName?: string, actionName?: string, connectionParams?: ConnectionParams + cable: Consumer, + createChannelId?: CreateChannelId, + channelName?: string, + actionName?: string, + connectionParams?: ConnectionParams, + callbacks?: SubscriptionCallbacks, }) { super() this.cable = options.cable this.channelName = options.channelName || "GraphqlChannel" this.actionName = options.actionName || "execute" this.connectionParams = options.connectionParams || {} + this.callbacks = options.callbacks || {} + this.createChannelId = options.createChannelId || defaultChannelId } // Interestingly, this link does _not_ call through to `next` because // instead, it sends the request to ActionCable. request(operation: Operation, _next: NextLink): Observable { return new Observable((observer) => { - var channelId = Math.round(Date.now() + Math.random() * 100000).toString(16) + var channelId = this.createChannelId() var actionName = this.actionName var connectionParams = (typeof this.connectionParams === "function") ? this.connectionParams(operation) : this.connectionParams + var callbacks = this.callbacks var channel = this.cable.subscriptions.create(Object.assign({},{ channel: this.channelName, channelId: channelId }, connectionParams), { - connected: function() { - channel.perform( + connected: function(args?: any) { + this.perform( actionName, { query: operation.query ? print(operation.query) : null, @@ -44,15 +61,20 @@ class ActionCableLink extends ApolloLink { operationName: operation.operationName } ) + callbacks.connected?.(args) }, received: function(payload) { - if (payload.result.data || payload.result.errors) { + if (payload?.result?.data || payload?.result?.errors) { observer.next(payload.result) } if (!payload.more) { observer.complete() } + callbacks.received?.(payload) + }, + disconnected: function() { + callbacks.disconnected?.() } }) // Make the ActionCable subscription behave like an Apollo subscription diff --git a/javascript_client/src/subscriptions/ActionCableSubscriber.ts b/javascript_client/src/subscriptions/ActionCableSubscriber.ts index 6e72afdc945..37035df1e60 100644 --- a/javascript_client/src/subscriptions/ActionCableSubscriber.ts +++ b/javascript_client/src/subscriptions/ActionCableSubscriber.ts @@ -1,6 +1,7 @@ import printer from "graphql/language/printer" import registry from "./registry" -import { Cable } from "actioncable" +import defaultChannelId from "./defaultChannelId" +import type { Consumer } from "@rails/actioncable" interface ApolloNetworkInterface { applyMiddlewares: Function @@ -8,13 +9,19 @@ interface ApolloNetworkInterface { _opts: any } +type CreateChannelId = () => string + class ActionCableSubscriber { - _cable: Cable + _cable: Consumer _networkInterface: ApolloNetworkInterface + _channelName: string + _createChannelId: CreateChannelId - constructor(cable: Cable, networkInterface: ApolloNetworkInterface) { + constructor(cable: Consumer, networkInterface: ApolloNetworkInterface, channelName?: string, createChannelId?: CreateChannelId) { this._cable = cable this._networkInterface = networkInterface + this._channelName = channelName || "GraphqlChannel" + this._createChannelId = createChannelId || defaultChannelId } /** @@ -29,9 +36,9 @@ class ActionCableSubscriber { subscribe(request: any, handler: any) { var networkInterface = this._networkInterface // unique-ish - var channelId = Math.round(Date.now() + Math.random() * 100000).toString(16) + var channelId = this._createChannelId() var channel = this._cable.subscriptions.create({ - channel: "GraphqlChannel", + channel: this._channelName, channelId: channelId, }, { // After connecting, send the data over ActionCable @@ -58,13 +65,13 @@ class ActionCableSubscriber { // - more: true if this channel should stay open // - result: the GraphQL response for this result received: function(payload) { - if (!payload.more) { - registry.unsubscribe(id) - } var result = payload.result if (result) { handler(result.errors, result.data) } + if (!payload.more) { + registry.unsubscribe(id) + } }, }) var id = registry.add(channel) diff --git a/javascript_client/src/subscriptions/PusherLink.ts b/javascript_client/src/subscriptions/PusherLink.ts index 2143a52dd93..049bae1b174 100644 --- a/javascript_client/src/subscriptions/PusherLink.ts +++ b/javascript_client/src/subscriptions/PusherLink.ts @@ -91,6 +91,10 @@ class PusherLink extends ApolloLink { if (subscriptionChannel) { // Set up the pusher subscription for updates from the server const pusherChannel = this.pusher.subscribe(subscriptionChannel) + // Pass along the initial payload: + if (data.data && Object.keys(data.data).length > 0) { + observer.next(data) + } // Subscribe for more update pusherChannel.bind("update", (payload: any) => { this._onUpdate(subscriptionChannel, observer, payload) @@ -101,7 +105,11 @@ class PusherLink extends ApolloLink { observer.next(data) observer.complete() } - }}) + }, + error: observer.error, + // complete: observer.complete Don't pass this because Apollo unsubscribes if you do + }) + // Return an object that will unsubscribe _if_ the query was a subscription. return { closed: false, diff --git a/javascript_client/src/subscriptions/PusherSubscriber.ts b/javascript_client/src/subscriptions/PusherSubscriber.ts index a4210169785..6be33e97f0f 100644 --- a/javascript_client/src/subscriptions/PusherSubscriber.ts +++ b/javascript_client/src/subscriptions/PusherSubscriber.ts @@ -64,13 +64,13 @@ class PusherSubscriber { var pusherChannel = pusher.subscribe(subscriptionChannel) // When you get an update form Pusher, send it to Apollo pusherChannel.bind("update", function(payload: any) { - if (!payload.more) { - registry.unsubscribe(id) - } var result = payload.compressed_result ? decompress(payload.compressed_result) : payload.result if (result) { handler(result.errors, result.data) } + if (!payload.more) { + registry.unsubscribe(id) + } }) }) return id diff --git a/javascript_client/src/subscriptions/SubscriptionExchange.ts b/javascript_client/src/subscriptions/SubscriptionExchange.ts new file mode 100644 index 00000000000..cbf0418daa3 --- /dev/null +++ b/javascript_client/src/subscriptions/SubscriptionExchange.ts @@ -0,0 +1,143 @@ +import Pusher from "pusher-js" +import Urql from "urql" +import { Consumer, Subscription } from "@rails/actioncable" + +type ForwardCallback = (...args: any[]) => void + +const SubscriptionExchange = { + create(options: { pusher?: Pusher, consumer?: Consumer, channelName?: string }) { + if (options.pusher) { + return createPusherSubscription(options.pusher) + } else if (options.consumer) { + return createUrqlActionCableSubscription(options.consumer, options?.channelName) + } else { + throw new Error("Either `pusher: ...` or `consumer: ...` is required.") + } + } +} + + +function createPusherSubscription(pusher: Pusher) { + return function(operation: Urql.Operation) { + // urql will call `.subscribed` on the returned object: + // https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L68-L73 + // https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L82-L97 + return { + subscribe: ({next, error, complete}: { next: ForwardCallback, error: ForwardCallback, complete: ForwardCallback}) => { + // Somehow forward the operation to be POSTed to the server, + // I don't see an option for passing this on to the `fetchExchange` + const fetchBody = JSON.stringify({ + query: operation.query, + variables: operation.variables, + }) + var pusherChannelName: string + const subscriptionId = "" + operation.key + var fetchOptions = operation.context.fetchOptions + if (typeof fetchOptions === "function") { + fetchOptions = fetchOptions() + } else if (fetchOptions == null) { + fetchOptions = {} + } + + const headers = { + ...(fetchOptions.headers), + ...{ + 'Content-Type': 'application/json', + 'X-Subscription-ID': subscriptionId + } + } + + const defaultFetchOptions = { method: "POST" } + const mergedFetchOptions = { + ...defaultFetchOptions, + ...fetchOptions, + body: fetchBody, + headers: headers, + } + const fetchFn = operation.context.fetch || fetch + fetchFn(operation.context.url, mergedFetchOptions) + .then((fetchResult) => { + // Get the server-provided subscription ID + pusherChannelName = fetchResult.headers.get("X-Subscription-ID") as string + // Set up a subscription to Pusher, forwarding updates to + // the `next` function provided by urql + const pusherChannel = pusher.subscribe(pusherChannelName) + pusherChannel.bind("update", (payload: {result: object, more: boolean}) => { + // Here's an update to this subscription, + // pass it on: + if (payload.result) { + next(payload.result) + } + // If the server signals that this is the end, + // then unsubscribe the client: + if (!payload.more) { + complete() + } + }) + // Continue processing the initial result for the subscription + return fetchResult.json() + }) + .then((jsonResult) => { + // forward the initial result to urql + next(jsonResult) + }) + .catch(error) + + // urql will call `.unsubscribe()` if it's returned here: + // https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L102 + return { + unsubscribe: () => { + // When requested by urql, disconnect from this channel + pusherChannelName && pusher.unsubscribe(pusherChannelName) + } + } + } + } + } +} + + + +function createUrqlActionCableSubscription(consumer: Consumer, channelName: string = "GraphqlChannel") { + return function (operation: Urql.Operation) { + const subscribe = ({ next, error, complete }: { next: ForwardCallback, error: ForwardCallback, complete: ForwardCallback }) => { + let subscribed = false; + + const subscription: Subscription = consumer.subscriptions.create(channelName, { + connected() { + subscription.perform("execute", { query: operation.query, variables: operation.variables }); + subscribed = true; + }, + received(data: any) { + if (data?.result?.errors) { + error(data.errors); + } + if (data?.result?.data) { + next(data.result); + } + if (!data.more && subscribed) { + complete(); + } + } + }); + + // urql will call `.unsubscribe()` if it's returned here: + // https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L102 + const unsubscribe = () => { + if (subscribed) { + subscribed = false; + subscription?.unsubscribe(); + } + }; + + return { unsubscribe }; + }; + + // urql will call `.subscribed` on the returned object: + // https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L68-L73 + // https://github.com/FormidableLabs/urql/blob/f89cfd06d9f14ae9cb3be10b21bd5cbd12ca275c/packages/core/src/exchanges/subscription.ts#L82-L97 + return { subscribe }; + }; +} + +export default SubscriptionExchange diff --git a/javascript_client/src/subscriptions/__tests__/AblyLinkTest.ts b/javascript_client/src/subscriptions/__tests__/AblyLinkTest.ts new file mode 100644 index 00000000000..dfc57db398c --- /dev/null +++ b/javascript_client/src/subscriptions/__tests__/AblyLinkTest.ts @@ -0,0 +1,166 @@ +import AblyLink from "../AblyLink" +import { Realtime } from "ably" +import { Operation } from "@apollo/client/core" +import { parse } from "graphql" +function createAbly() { + const _channels: {[key: string]: any } = {} + const log: any[] = [] + + const ably = { + _channels: _channels, + log: log, + auth: { + clientId: null, + }, + channels: { + get(channelName: string) { + return _channels[channelName] ||= { + _listeners: [] as [string, Function][], + name: channelName, + presence: { + enterClient(_clientName: string, _status: string) {}, + leaveClient(_clientName: string) {}, + }, + detach(callback: Function) { + callback() + }, + subscribe(eventName: string, callback: Function) { + log.push(["subscribe", channelName, eventName]) + this._listeners.push([eventName, callback]) + }, + unsubscribe(){ + log.push(["unsubscribe", channelName]) + this._listeners.splice(0, this._listeners.length) + } + } + }, + release(channelName: string) { + delete _channels[channelName] + } + }, + __testTrigger(channelName: string, eventName: string, data: any) { + const channel = this.channels.get(channelName) + const handler = channel._listeners.find((l: any) => l[0] == eventName) + if (handler) { + handler[1](data) + } + } + } + + return (ably as unknown) as Realtime +} + +function createOperation(options: { subscriptionId: string | null }) { + return ({ + query: parse("subscription { foo { bar } }"), + variables: { a: 1 }, + operationId: "operationId", + operationName: "operationName", + getContext: () => { + return { + response: { + headers: { + get: (key: string) => { + if (key == "X-Subscription-ID") { + return options.subscriptionId + } else { + return null + } + } + } + } + } + } + } as unknown) as Operation +} + +function createNextLink(log: any[]) { + return (operation: any) => { + log.push(["forward", operation.operationName]) + return { + subscribe(info: any) { + info.next() + return { + unsubscribe() { + log.push(["request unsubscribed"]) + } + } + } + } as any + } +} + +describe("AblyLink", () => { + test("delegates to Ably", () => { + var mockAbly = createAbly() + var log = (mockAbly as any).log + var operation = createOperation({subscriptionId: "sub-1234"}) + var nextLink = createNextLink(log) + + var observable = new AblyLink({ ably: mockAbly}).request(operation, nextLink) + + observable.subscribe(function(result: any) { + log.push(["received", result]) + }); + + (mockAbly as any).__testTrigger("sub-1234", "update", { data: { result: { data: null }, more: true} }); + (mockAbly as any).__testTrigger("sub-1234", "update", { data: { result: { data: "data 1" }, more: true} }); + (mockAbly as any).__testTrigger("sub-1234", "update", { data: { result: { data: "data 2" }, more: false} }); + + expect(log).toEqual([ + ["forward", "operationName"], + ["subscribe", "sub-1234", "update"], + ["received", { data: null }], + ["received", { data: "data 1" }], + ["received", { data: "data 2" }], + ["unsubscribe", "sub-1234"] + ]) + }) + + test("it doesn't call ably when the subscription header isn't present", () => { + var mockAbly = createAbly() + var log = (mockAbly as any).log + var operation = createOperation({subscriptionId: null}) + var nextLink = createNextLink(log) + + var observable = new AblyLink({ ably: mockAbly}).request(operation, nextLink) + + observable.subscribe(function(result: any) { + log.push(["received", result]) + }); + + (mockAbly as any).__testTrigger("sub-1234", "update", { data: { result: { data: null }, more: true} }); + (mockAbly as any).__testTrigger("sub-1234", "update", { data: { result: { data: "data 1" }, more: true} }); + (mockAbly as any).__testTrigger("sub-1234", "update", { data: { result: { data: "data 2" }, more: false} }); + + expect(log).toEqual([["forward", "operationName"]]) + }) + + test("it can unsubscribe", () => { + var mockAbly = createAbly() + var log = (mockAbly as any).log + var operation = createOperation({subscriptionId: "sub-1234"}) + var nextLink = createNextLink(log) + + var observable = new AblyLink({ ably: mockAbly}).request(operation, nextLink) + + var subscription = observable.subscribe(function(result: any) { + log.push(["received", result]) + }); + + (mockAbly as any).__testTrigger("sub-1234", "update", { data: { result: { data: "data1" }, more: true} }); + subscription.unsubscribe(); + // This is not received: + (mockAbly as any).__testTrigger("sub-1234", "update", { data: { result: { data: "data2" }, more: true} }); + + expect(log).toEqual([ + ["forward", "operationName"], + ["subscribe", "sub-1234", "update"], + ["received", { data: "data1" }], + ["unsubscribe", "sub-1234"], + ["request unsubscribed"] + ]) + }) + + +}) diff --git a/javascript_client/src/subscriptions/__tests__/ActionCableLinkTest.ts b/javascript_client/src/subscriptions/__tests__/ActionCableLinkTest.ts index 8346428dd83..ccc6f8bfdde 100644 --- a/javascript_client/src/subscriptions/__tests__/ActionCableLinkTest.ts +++ b/javascript_client/src/subscriptions/__tests__/ActionCableLinkTest.ts @@ -1,6 +1,6 @@ import ActionCableLink from "../ActionCableLink" import { parse } from "graphql" -import { Cable } from "actioncable" +import type { Consumer } from "@rails/actioncable" import { Operation } from "@apollo/client/core" describe("ActionCableLink", () => { @@ -46,7 +46,7 @@ describe("ActionCableLink", () => { } } options = { - cable: (cable as unknown) as Cable + cable: (cable as unknown) as Consumer } query = parse("subscription { foo { bar } }") @@ -93,7 +93,7 @@ describe("ActionCableLink", () => { "perform", { actionName: "execute", options: { - query: "subscription {\n foo {\n bar\n }\n}\n", + query: "subscription {\n foo {\n bar\n }\n}", variables: { a: 1 }, operationId: "operationId", operationName: "operationName" @@ -135,7 +135,7 @@ describe("ActionCableLink", () => { "perform", { actionName: "execute", options: { - query: "subscription {\n foo {\n bar\n }\n}\n", + query: "subscription {\n foo {\n bar\n }\n}", variables: { a: 1 }, operationId: "operationId", operationName: "operationName" @@ -171,4 +171,51 @@ describe("ActionCableLink", () => { expect(subscription.params["test"]).toEqual(1) }) + + it("generates a unique channelId for each subscription", () => { + var link = new ActionCableLink(options) + var channelIds = new Set() + var subscriptions: any[] = [] + + for (var i = 0; i < 1000; i++) { + var observable = link.request(operation, null as any) + var subscription: any = (observable.subscribe(() => null) as any)._cleanup + channelIds.add(subscription.params.channelId) + subscriptions.push(subscription) + } + + expect(channelIds.size).toBe(1000) + + subscriptions.forEach(function(s) { s.unsubscribe() }) + }) + + it("accepts an injected channel ID function", () => { + var link = new ActionCableLink({...options, createChannelId: () => "Channel-ID" }) + var observable = link.request(operation, null as any) + var subscription: any = (observable.subscribe(() => null) as any)._cleanup + expect(subscription.params.channelId).toEqual("Channel-ID") + subscription.unsubscribe() + }) + + it('allows passing custom callbacks', () => { + var connected = jest.fn() + var received = jest.fn() + var disconnected = jest.fn() + + var observable = new ActionCableLink( + Object.assign(options, { callbacks: { connected, received, disconnected } }) + ).request(operation, null as any) + + // unpack the underlying subscription + var subscription: any = (observable.subscribe(() => null) as any)._cleanup + + subscription.received({ result: { data: "data 1" }, more: true }) + subscription.received({ result: { data: "data 2" }, more: false }) + subscription.disconnected() + + expect(connected).toHaveBeenCalledTimes(1) + expect(received).toHaveBeenCalledWith({ result: { data: "data 1" }, more: true }) + expect(received).toHaveBeenCalledWith({ result: { data: "data 2" }, more: false }) + expect(disconnected).toHaveBeenCalledTimes(1) + }) }) diff --git a/javascript_client/src/subscriptions/__tests__/PusherLinkTest.ts b/javascript_client/src/subscriptions/__tests__/PusherLinkTest.ts index ae9bf8cbd6e..f515370e2a7 100644 --- a/javascript_client/src/subscriptions/__tests__/PusherLinkTest.ts +++ b/javascript_client/src/subscriptions/__tests__/PusherLinkTest.ts @@ -8,7 +8,7 @@ type MockChannel = { bind: (action: string, handler: Function) => void, } -describe("ActionCableLink", () => { +describe("PusherLink", () => { var channelName = "abcd-efgh" var log: any[] var pusher: any @@ -79,6 +79,54 @@ describe("ActionCableLink", () => { } as unknown) as Operation }) + it("forwards errors to error handlers", () => { + let passedErrorHandler: Function = () => {} + + var observable = link.request(operation, function(_operation: Operation): any { + return { + subscribe: (options: { next: Function, error: Function, complete: Function }): void => { + passedErrorHandler = options.error + {} + } + } + }) + + let errorHandlerWasCalled = false + function createdErrorHandler(_err: Error) { + errorHandlerWasCalled = true + } + + observable.subscribe(function(result: any) { + log.push(["received", result]) + }, createdErrorHandler) + + if (passedErrorHandler) { + passedErrorHandler(new Error) + } + + expect(errorHandlerWasCalled).toBe(true) + }) + + it("doesn't call the link request's `complete` handler because otherwise Apollo would clean up subscriptions", () => { + let passedComplete: Function = () => {} + + var observable = link.request(operation, function(_operation: Operation): any { + return { + subscribe: (options: { next: Function, error: Function, complete: Function }): void => { + passedComplete = options.complete + {} + } + } + }) + + observable.subscribe(function(result: any) { + log.push(["received", result]) + }, null, function() { log.push(["completed"])}) + + expect(log).toEqual([]) + expect(passedComplete).toBeUndefined() + }) + it("delegates to pusher", () => { var requestFinished: Function = () => {} @@ -96,7 +144,7 @@ describe("ActionCableLink", () => { }) // Pretend the HTTP link finished - requestFinished({}) + requestFinished({ data: "initial payload" }) pusher.trigger(channelName, "update", { result: { @@ -114,13 +162,50 @@ describe("ActionCableLink", () => { expect(log).toEqual([ ["subscribe", "abcd-efgh"], + ["received", { data: "initial payload"}], ["received", { data: "data 1" }], ["received", { data: "data 2" }], ["unsubscribe", "abcd-efgh"] ]) }) - it("delegates a manual unsubscribe to the cable", () => { + it("delegates a manual unsubscribe to pusher", () => { + var requestFinished: Function = () => {} + + var observable = link.request(operation, function(_operation: Operation): any { + return { + subscribe: (options: { next: Function }): void => { + requestFinished = options.next + } + } + }) + + // unpack the underlying subscription + var subscription = observable.subscribe(function(result: any) { + log.push(["received", result]) + }) + + // Pretend the HTTP link finished + requestFinished({ data: "initial payload" }) + + pusher.trigger(channelName, "update", { + result: { + data: "data 1" + }, + more: true + }) + + subscription.unsubscribe() + + expect(log).toEqual([ + ["subscribe", "abcd-efgh"], + ["received", { data: "initial payload"}], + ["received", { data: "data 1" }], + ["unsubscribe", "abcd-efgh"] + ]) + }) + + it("doesn't send empty initial responses", () => { var requestFinished: Function = () => {} var observable = link.request(operation, function(_operation: Operation): any { @@ -137,7 +222,7 @@ describe("ActionCableLink", () => { }) // Pretend the HTTP link finished - requestFinished({}) + requestFinished({ data: null }) pusher.trigger(channelName, "update", { result: { @@ -155,6 +240,7 @@ describe("ActionCableLink", () => { ]) }) + it("throws an error when no `decompress:` is configured", () => { const link = new PusherLink({ pusher: new Pusher("123"), diff --git a/javascript_client/src/subscriptions/__tests__/SubscriptionExchangeTest.ts b/javascript_client/src/subscriptions/__tests__/SubscriptionExchangeTest.ts new file mode 100644 index 00000000000..b68068c4753 --- /dev/null +++ b/javascript_client/src/subscriptions/__tests__/SubscriptionExchangeTest.ts @@ -0,0 +1,167 @@ +import SubscriptionExchange from "../SubscriptionExchange" +import Pusher from "pusher-js" +import Urql from "urql" +import {parse} from "graphql" +import { nextTick } from "process" +import { Consumer } from "@rails/actioncable" + +type MockChannel = { + bind: (action: string, handler: Function) => void, +} + +describe("SubscriptionExchange with Pusher", () => { + var channelName = "1234" + var log: any[] + var pusher: any + var options: any + var pusherExchange: any + var operation: any + + beforeEach(() => { + log = [] + pusher = { + _channels: {}, + trigger: function(channel: string, event: string, data: any) { + var handlers = this._channels[channel] + if (handlers) { + handlers.forEach(function(handler: [string, Function]) { + if (handler[0] == event) { + handler[1](data) + } + }) + } + }, + subscribe: function(channel: string): MockChannel { + log.push(["subscribe", channel]) + var handlers = this._channels[channel] + if (!handlers) { + handlers = this._channels[channel] = [] + } + + return { + bind: (action: string, handler: Function): void => { + handlers.push([action, handler]) + } + } + }, + unsubscribe: (channel: string): void => { + delete pusher._channels[channel] + log.push(["unsubscribe", channel]) + }, + } + + options = { + pusher: (pusher as unknown) as Pusher + } + pusherExchange = SubscriptionExchange.create(options) + + operation = { + query: parse("{ foo { bar } }"), + variables: {}, + key: Number(channelName), + context: { + url: "/graphql", + requestPolicy: "network-only", + fetch: () => { + var headers = new Headers + headers.append("X-Subscription-ID", channelName) + const jsonData = { data: { foo: "bar" }} + return Promise.resolve(({ + headers: headers, + json: () => { return jsonData } + } as unknown) as Response) + } + }, + kind: "subscription", + } as Urql.Operation + }) + + it("calls through to handlers and can be unsubscribed", () => { + const subscriber = pusherExchange(operation) + const next = (data: any) => { log.push(["next", data]) } + const error = (err: any) => { log.push(["error", err]) } + const complete = (data: any) => { log.push(["complete", data]) } + const subscription = subscriber.subscribe({ next, error, complete }) + return new Promise((resolve, _reject) => { + nextTick(() => { + pusher.trigger(channelName, { result: {}, more: true }) + expect(Object.keys(pusher._channels)).toEqual([channelName]) + subscription.unsubscribe() + expect(Object.keys(pusher._channels)).toEqual([]) + const expectedLog = [ + ["subscribe", "1234"], + ["next", { data: { foo: "bar" } }], + ["unsubscribe", "1234"] + ] + expect(log).toEqual(expectedLog) + resolve(true) + }) + }) + + }) +}) + +describe("SubscriptionExchange with ActionCable", () => { + it("calls through to handlers", () => { + var handlers: any + var log: [string, any][]= [] + + var dummyActionCableConsumer = { + subscriptions: { + create: (channelName: string, newHandlers: any) => { + log.push(["create", channelName]) + handlers = newHandlers + return { + perform: (evt: string, data: any) => { + log.push([evt, data]) + }, + unsubscribe: () => { + log.push(["unsubscribed", null]) + } + } + } + } + } + + var options = { + consumer: (dummyActionCableConsumer as unknown) as Consumer, + channelName: "CustomChannel" + } + + var exchange = SubscriptionExchange.create(options); + var parsedQuery = parse("{ foo { bar } }") + var operation = { + query: parsedQuery, + variables: {}, + context: { + url: "/graphql", + requestPolicy: "network-only", + }, + kind: "subscription", + } as Urql.Operation + + var subscriber = exchange(operation) + const next = (data: any) => { log.push(["next", data]) } + const error = (err: any) => { log.push(["error", err]) } + const complete = (data: any) => { log.push(["complete", data]) } + const subscription = subscriber.subscribe({ next, error, complete }) + + + return new Promise((resolve, _reject) => { + nextTick(() => { + handlers.connected() // trigger the GraphQL send + handlers.received({ result: { data: { a: "1" } }, more: false }) + subscription.unsubscribe() + const expectedLog = [ + ["create", "CustomChannel"], + ["execute", { query: parsedQuery, variables: {} }], + ["next", { data: { a: "1" } }], + ["complete", undefined], + ["unsubscribed", null], + ] + expect(log).toEqual(expectedLog) + resolve(true) + }) + }) + }) +}) diff --git a/javascript_client/src/subscriptions/__tests__/createAblyFetcherTest.ts b/javascript_client/src/subscriptions/__tests__/createAblyFetcherTest.ts new file mode 100644 index 00000000000..f256ec53a9f --- /dev/null +++ b/javascript_client/src/subscriptions/__tests__/createAblyFetcherTest.ts @@ -0,0 +1,105 @@ +import createAblyFetcher from "../createAblyFetcher" +import { Realtime } from "ably" + +function createAbly() { + const _channels: {[key: string]: any } = {} + + const ably = { + _channels: _channels, + channels: { + get(channelName: string) { + return _channels[channelName] ||= { + _listeners: [] as [string, Function][], + name: channelName, + presence: { + enterClient(_clientName: string, _status: string) {}, + leaveClient(_clientName: string) {}, + }, + detach(callback: Function) { + callback() + }, + subscribe(eventName: string, callback: Function) { + this._listeners.push([eventName, callback]) + }, + unsubscribe(){} + } + }, + release(channelName: string) { + delete _channels[channelName] + } + }, + __testTrigger(channelName: string, eventName: string, data: any) { + const channel = this.channels.get(channelName) + const handler = channel._listeners.find((l: any) => l[0] == eventName) + if (handler) { + handler[1](data) + } + } + } + + return ably +} + + +describe("createAblyFetcher", () => { + it("yields updates for subscriptions", () => { + const ably = createAbly() + + const fetchLog: any[] = [] + const dummyFetch = function(url: string, fetchArgs: any) { + fetchLog.push([url, fetchArgs.customOpt]) + const dummyResponse = { + json: () => { + return { + data: { + hi: "First response" + } + } + }, + headers: { + get() { + return fetchArgs.body.includes("subscription") ? "abcd" : null + } + } + } + return Promise.resolve(dummyResponse) + } + + const fetcher = createAblyFetcher({ + ably: (ably as unknown) as Realtime, + url: "/graphql", + fetch: ((dummyFetch as unknown) as typeof fetch), + fetchOptions: {customOpt: true} + }) + + const result = fetcher({ + variables: {}, + operationName: "hello", + body: "subscription hello { hi }" + }, {}) + + return result.next().then((res) => { + expect(res.value.data.hi).toEqual("First response") + expect(fetchLog).toEqual([["/graphql", true]]) + }).then(() => { + const promise = result.next().then((res2) => { + expect(res2).toEqual({ value: { data: { hi: "Bonjour" } }, done: false }) + }) + + ably.__testTrigger("abcd", "update", { data: { result: { data: { hi: "Bonjour" } } } }) + + return promise.then(() => { + // Test non-subscriptions too: + expect(Object.keys(ably._channels)).toEqual(["abcd"]) + const queryResult = fetcher({ variables: {}, operationName: null, body: "{ __typename }"}, {}) + return queryResult.next().then((res) => { + expect(res.value.data).toEqual({ hi: "First response"}) + return queryResult.next().then((res2) => { + expect(res2.done).toEqual(true) + expect(ably._channels).toEqual({}) + }) + }) + }) + }) + }) +}) diff --git a/javascript_client/src/subscriptions/__tests__/createAblyHandlerTest.ts b/javascript_client/src/subscriptions/__tests__/createAblyHandlerTest.ts index 7849567f1cd..946350d4dfc 100644 --- a/javascript_client/src/subscriptions/__tests__/createAblyHandlerTest.ts +++ b/javascript_client/src/subscriptions/__tests__/createAblyHandlerTest.ts @@ -1,4 +1,4 @@ -import { createAblyHandler } from "../createAblyHandler" +import { OnErrorData, createAblyHandler } from "../createAblyHandler" import { Realtime, Types } from "ably" const dummyOperation = { text: "", name: "" } @@ -185,6 +185,41 @@ describe("createAblyHandler", () => { expect(nextInvokedWith).toBeUndefined() }) + it("doesn't dispatch anything for an empty data object", async () => { + let errorInvokedWith = undefined + let nextInvokedWith = undefined + + const producer = createAblyHandler({ + fetchOperation: () => + new Promise(resolve => + resolve({ + headers: new Map([["X-Subscription-ID", "foo"]]), + body: { data: {} } + }) + ), + ably: createDummyConsumer() + }) + + producer( + dummyOperation, + {}, + {}, + { + onError: (errors: any) => { + errorInvokedWith = errors + }, + onNext: (response: any) => { + nextInvokedWith = response + }, + onCompleted: () => {} + } + ) + + await nextTick() + expect(errorInvokedWith).toBeUndefined() + expect(nextInvokedWith).toBeUndefined() + }) + it("dispatches caught errors", async () => { let errorInvokedWith = undefined let nextInvokedWith = undefined @@ -262,7 +297,7 @@ describe("createAblyHandler", () => { key: "integration-test:invalid", log: { level: 0 } }) - await new Promise(resolve => { + await new Promise(resolve => { const fetchOperation = async () => ({ headers: new Map([["X-Subscription-ID", "foo"]]) }) @@ -272,7 +307,7 @@ describe("createAblyHandler", () => { const variables = {} const cacheConfig = {} const onError = (error: any) => { - expect(error.message).toMatch(/Invalid key in request/) + expect(error.message).toEqual("unable to handle request; no application id found in request") resolve() } const onNext = () => console.log("onNext") @@ -293,7 +328,7 @@ describe("createAblyHandler", () => { "onError is called for too many subscriptions", async () => { const ably = new Realtime({ key, log: { level: 0 } }) - await new Promise(resolve => { + await new Promise(resolve => { let subscriptionCounter = 0 const fetchOperation = async () => { subscriptionCounter += 1 @@ -348,7 +383,7 @@ describe("createAblyHandler", () => { const operation = {} const variables = {} const cacheConfig = {} - const onError = (error: Error) => { + const onError = (error: OnErrorData) => { caughtError = error } const onNext = () => {} @@ -373,7 +408,7 @@ describe("createAblyHandler", () => { } await Promise.all(disposals) - // 201st subscription - should work now that previous 200 subscriptions have been diposed + // 201st subscription - should work now that previous 200 subscriptions have been disposed const { dispose } = ablyHandler( operation, variables, @@ -406,7 +441,7 @@ describe("createAblyHandler", () => { const operation = {} const variables = {} const cacheConfig = {} - const onError = (error: Error) => { + const onError = (error: OnErrorData) => { caughtError = error } const messages: any[] = [] @@ -421,7 +456,7 @@ describe("createAblyHandler", () => { } // Publish before subscribe - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const ablyPublisher = new Realtime({ key, log: { level: 0 } }) const publishChannel = ablyPublisher.channels.get(subscriptionId) publishChannel.publish( diff --git a/javascript_client/src/subscriptions/__tests__/createActionCableFetcherTest.ts b/javascript_client/src/subscriptions/__tests__/createActionCableFetcherTest.ts new file mode 100644 index 00000000000..86b6519c687 --- /dev/null +++ b/javascript_client/src/subscriptions/__tests__/createActionCableFetcherTest.ts @@ -0,0 +1,66 @@ +import createActionCableFetcher from "../createActionCableFetcher" +import type { Consumer } from "@rails/actioncable" +import { parse } from "graphql" + +describe("createActionCableFetcherTest", () => { + it("yields updates for subscriptions", () => { + var handlers: any + var log: [string, any][]= [] + + var dummyActionCableConsumer = { + subscriptions: { + create: (_conn: any, newHandlers: any) => { + handlers = newHandlers + return { + perform: (evt: string, data: any) => { + log.push([evt, data]) + } + } + } + } + } + + const fetchLog: any[] = [] + const dummyFetch = function(url: string, fetchArgs: any) { + fetchLog.push([url, fetchArgs.custom]) + return Promise.resolve({ json: () => { {} } }) + } + + var options = { + consumer: (dummyActionCableConsumer as unknown) as Consumer, + url: "/some_graphql_endpoint", + fetch: dummyFetch as typeof fetch, + fetchOptions: { + custom: true, + } + } + + var fetcher = createActionCableFetcher(options) + + + const queryStr = "subscription listen { update { message } }" + const doc = parse(queryStr) + + const res = fetcher({ operationName: "listen", query: queryStr, variables: {}}, { documentAST: doc }) + const promise = res.next().then((result) => { + + handlers.connected() // trigger the GraphQL send + + expect(result).toEqual({ value: { data: "hello" } , done: false }) + expect(fetchLog).toEqual([]) + expect(log).toEqual([ + ["execute", { operationName: "listen", query: queryStr, variables: {} }], + ]) + }) + + handlers.received({ result: { data: "hello" } }) // simulate an update + + return promise.then(() => { + let res2 = fetcher({ operationName: null, query: "{ __typename } ", variables: {}}, {}) + const promise2 = res2.next().then(() => { + expect(fetchLog).toEqual([["/some_graphql_endpoint", true]]) + }) + return promise2 + }) + }) +}) diff --git a/javascript_client/src/subscriptions/__tests__/createActionCableHandlerTest.ts b/javascript_client/src/subscriptions/__tests__/createActionCableHandlerTest.ts index 995125a85f7..5d298c089e2 100644 --- a/javascript_client/src/subscriptions/__tests__/createActionCableHandlerTest.ts +++ b/javascript_client/src/subscriptions/__tests__/createActionCableHandlerTest.ts @@ -1,11 +1,12 @@ import { createActionCableHandler } from "../createActionCableHandler" -import { Cable } from "actioncable" +import type { Consumer } from "@rails/actioncable" + describe("createActionCableHandler", () => { it("returns a function producing a disposable subscription", () => { - var wasDisposed = false + var wasDisposedCount = 0 var subscription = { - unsubscribe: () => (wasDisposed = true) + unsubscribe: () => (wasDisposedCount += 1) } var dummyActionCableConsumer = { subscriptions: { @@ -14,11 +15,55 @@ describe("createActionCableHandler", () => { } var options = { - cable: (dummyActionCableConsumer as unknown) as Cable + cable: (dummyActionCableConsumer as unknown) as Consumer } var producer = createActionCableHandler(options) - producer({text: "", name: ""}, {}, {}, { onError: () => {}, onNext: () => {}, onCompleted: () => {} }).dispose() + var relaySubscription = producer({text: "", name: ""}, {}, {}, { onError: () => {}, onNext: () => {}, onCompleted: () => {} }) + + relaySubscription.dispose() + relaySubscription.dispose() + + expect(wasDisposedCount).toEqual(1) + }) + + it("uses a provided clientName and operation.id", () => { + var handlers: any + var log: [string, any][]= [] + + var dummyActionCableConsumer = { + subscriptions: { + create: (_conn: any, newHandlers: any) => { + handlers = newHandlers + return { + perform: (evt: string, data: any) => { + log.push([evt, data]) + } + } + } + } + } + + var options = { + cable: (dummyActionCableConsumer as unknown) as Consumer, + clientName: "client-1", + } + + var producer = createActionCableHandler(options); + + producer( + {text: "", name: "", id: "abcdef"}, + {}, + {}, + { onError: () => {}, onNext: (result: any) => { log.push(["onNext", result])}, onCompleted: () => { log.push(["onCompleted", null])} } + ) + + handlers.connected() // trigger the GraphQL send + handlers.received({ result: { data: { a: "1" } }, more: false }) - expect(wasDisposed).toEqual(true) + expect(log).toEqual([ + ["execute", { operationId: "client-1/abcdef", operationName: "", query: "", variables: {} }], + ["onNext", { data: { a: "1" } }], + ["onCompleted", null], + ]) }) }) diff --git a/javascript_client/src/subscriptions/__tests__/createPusherFetcherTest.ts b/javascript_client/src/subscriptions/__tests__/createPusherFetcherTest.ts new file mode 100644 index 00000000000..595baac353d --- /dev/null +++ b/javascript_client/src/subscriptions/__tests__/createPusherFetcherTest.ts @@ -0,0 +1,99 @@ +import createPusherFetcher from "../createPusherFetcher" +import type Pusher from "pusher-js" + +type MockChannel = { + bind: (action: string, handler: Function) => void, + unsubscribe: () => void, +} + +describe("createPusherFetcher", () => { + it("yields updates for subscriptions", () => { + const pusher = { + _channels: {} as {[key: string]: [string, Function][]}, + + trigger: function(channel: string, event: string, data: any) { + var handlers = this._channels[channel] + if (handlers) { + handlers.forEach(function(handler: [string, Function]) { + if (handler[0] == event) { + handler[1](data) + } + }) + } + }, + subscribe: function(channel: string): MockChannel { + var handlers = this._channels[channel] + if (!handlers) { + handlers = this._channels[channel] = [] + } + + return { + bind: (action: string, handler: Function): void => { + handlers.push([action, handler]) + }, + unsubscribe: () => { + delete this._channels[channel] + } + } + }, + unsubscribe: (_channel: string): void => { + }, + } + + const fetchLog: any[] = [] + const dummyFetch = function(url: string, fetchArgs: any) { + fetchLog.push([url, fetchArgs.customOpt]) + const dummyResponse = { + json: () => { + return { + data: { + hi: "First response" + } + } + }, + headers: { + get() { + return fetchArgs.body.includes("subscription") ? "abcd" : null + } + } + } + return Promise.resolve(dummyResponse) + } + + const fetcher = createPusherFetcher({ + pusher: (pusher as unknown) as Pusher, + url: "/graphql", + fetch: ((dummyFetch as unknown) as typeof fetch), + fetchOptions: {customOpt: true} + }) + + const result = fetcher({ + variables: {}, + operationName: "hello", + body: "subscription hello { hi }" + }, {}) + + return result.next().then((res) => { + expect(res.value.data.hi).toEqual("First response") + expect(fetchLog).toEqual([["/graphql", true]]) + }).then(() => { + const promise = result.next().then((res2) => { + expect(res2).toEqual({ value: { data: { hi: "Bonjour" } }, done: false }) + }) + pusher.trigger("abcd", "update", { result: { data: { hi: "Bonjour" } } }) + + return promise.then(() => { + // Test non-subscriptions too: + expect(Object.keys(pusher._channels)).toEqual(["abcd"]) + const queryResult = fetcher({ variables: {}, operationName: null, body: "{ __typename }"}, {}) + return queryResult.next().then((res) => { + expect(res.value.data).toEqual({ hi: "First response"}) + return queryResult.next().then((res2) => { + expect(res2.done).toEqual(true) + expect(pusher._channels).toEqual({}) + }) + }) + }) + }) + }) +}) diff --git a/javascript_client/src/subscriptions/__tests__/createRelaySubscriptionHandlerTest.ts b/javascript_client/src/subscriptions/__tests__/createRelaySubscriptionHandlerTest.ts new file mode 100644 index 00000000000..c9a33301aef --- /dev/null +++ b/javascript_client/src/subscriptions/__tests__/createRelaySubscriptionHandlerTest.ts @@ -0,0 +1,112 @@ +import createRelaySubscriptionHandler from "../createRelaySubscriptionHandler" +import { createLegacyRelaySubscriptionHandler } from "../createRelaySubscriptionHandler" +import type { Consumer } from "@rails/actioncable" +import { Network } from 'relay-runtime' + +describe("createRelaySubscriptionHandler", () => { + it("returns a function producing a observable subscription", () => { + var dummyActionCableConsumer = { + subscriptions: { + create: () => ({ unsubscribe: () => ( true) }) + }, + } + + var options = { + cable: (dummyActionCableConsumer as unknown) as Consumer + } + + var handler = createRelaySubscriptionHandler(options) + var fetchQuery: any + // basically, make sure this doesn't blow up during type-checking or runtime + expect(Network.create(fetchQuery, handler)).toBeTruthy() + }) + + it("doesn't send an empty string when no string is given", () => { + var channel: any; + var performLog: any[] = []; + var dummyActionCableConsumer = { + subscriptions: { + create: (opts1: any, opts2: any) => { + channel = Object.assign( + opts1, + opts2, + { + unsubscribe: () => true, + perform: (event: string, payload: object) => performLog.push([event, payload]), + + } + ) + return channel + } + }, + } + + var options = { + cable: (dummyActionCableConsumer as unknown) as Consumer + } + + var handler = createRelaySubscriptionHandler(options) + var observable = handler({id: "abc", text: null, name: "def", operationKind: "subscription", metadata: {}}, { abc: true}); + observable.subscribe({}) + channel.connected() + var expectedLog = [ + [ + 'execute', + { + variables: { abc: true }, + operationName: 'def', + query: null, + operationId: null + } + ] + ] + expect(performLog).toEqual(expectedLog) + }) + + it("forwards transport errors to the Relay observer", () => { + var channel: any + var dummyActionCableConsumer = { + subscriptions: { + create: (_params: any, handlers: any) => { + channel = handlers + return { unsubscribe: () => true } + } + } + } + + var handler = createRelaySubscriptionHandler({ + cable: (dummyActionCableConsumer as unknown) as Consumer + }) + var observable = handler( + { id: "abc", text: null, name: "def", operationKind: "subscription", metadata: {} }, + {} + ) + var receivedError: Error | undefined + observable.subscribe({ + error: (error: Error) => { + receivedError = error + } + }) + + var error = new Error("Subscription failed") + channel.received({ result: { errors: error }, more: true }) + + expect(receivedError).toBe(error) + }) +}) + +describe("createLegacyRelaySubscriptionHandler", () => { + it("still works", () => { + var dummyActionCableConsumer = { + subscriptions: { + create: () => ({ unsubscribe: () => ( true) }) + }, + } + + var options = { + cable: (dummyActionCableConsumer as unknown) as Consumer + } + + expect(createLegacyRelaySubscriptionHandler(options)).toBeInstanceOf(Function) + }) +}) diff --git a/javascript_client/src/subscriptions/__tests__/defaultChannelIdTest.ts b/javascript_client/src/subscriptions/__tests__/defaultChannelIdTest.ts new file mode 100644 index 00000000000..fec4f357808 --- /dev/null +++ b/javascript_client/src/subscriptions/__tests__/defaultChannelIdTest.ts @@ -0,0 +1,43 @@ +import defaultChannelId from "../defaultChannelId" + +describe("defaultChannelId", () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(crypto, "randomUUID") + + afterEach(() => { + if (originalDescriptor) { + Object.defineProperty(crypto, "randomUUID", originalDescriptor) + } + }) + + it("uses crypto.randomUUID when available", () => { + Object.defineProperty(crypto, "randomUUID", { + value: () => "11111111-2222-3333-4444-555555555555", + configurable: true, + writable: true, + }) + + expect(defaultChannelId()).toEqual("11111111-2222-3333-4444-555555555555") + }) + + it("falls back to crypto.getRandomValues on insecure contexts, where crypto.randomUUID is not exposed", () => { + Object.defineProperty(crypto, "randomUUID", { + value: undefined, + configurable: true, + writable: true, + }) + + expect(defaultChannelId()).toMatch(/^[0-9a-f]{32}$/) + }) + + it("generates unique ids without crypto.randomUUID", () => { + Object.defineProperty(crypto, "randomUUID", { + value: undefined, + configurable: true, + writable: true, + }) + + const ids = new Set(Array.from({ length: 1000 }, () => defaultChannelId())) + + expect(ids.size).toEqual(1000) + }) +}) diff --git a/javascript_client/src/subscriptions/addGraphQLSubscriptions.ts b/javascript_client/src/subscriptions/addGraphQLSubscriptions.ts index 8f4a91394de..fbce178f31c 100644 --- a/javascript_client/src/subscriptions/addGraphQLSubscriptions.ts +++ b/javascript_client/src/subscriptions/addGraphQLSubscriptions.ts @@ -1,7 +1,7 @@ import ActionCableSubscriber from "./ActionCableSubscriber" import PusherSubscriber from "./PusherSubscriber" import Pusher from "pusher-js" -import { Cable } from "actioncable" +import type { Consumer } from "@rails/actioncable" interface Subscriber { subscribe: Function @@ -16,7 +16,7 @@ interface Subscriber { * to the provided networkInterface. * @example Adding ActionCable subscriptions to a HTTP network interface * // Load ActionCable and create a consumer - * var ActionCable = require('actioncable') + * var ActionCable = require('@rails/actioncable') * var cable = ActionCable.createConsumer() * window.cable = cable * @@ -48,7 +48,7 @@ interface Subscriber { * @param {ActionCable.Consumer} options.cable - A cable for subscribing with * @param {Pusher} options.pusher - A pusher client for subscribing with */ -function addGraphQLSubscriptions(networkInterface: any, options: { pusher?: Pusher, cable?: Cable, subscriber?: Subscriber, decompress?: (compressed: string) => any}) { +function addGraphQLSubscriptions(networkInterface: any, options: { pusher?: Pusher, cable?: Consumer, subscriber?: Subscriber, decompress?: (compressed: string) => any, channelName?: string }) { if (!options) { options = {} } @@ -58,7 +58,7 @@ function addGraphQLSubscriptions(networkInterface: any, options: { pusher?: Push // Right now this is just for testing subscriber = options.subscriber } else if (options.cable) { - subscriber = new ActionCableSubscriber(options.cable, networkInterface) + subscriber = new ActionCableSubscriber(options.cable, networkInterface, options.channelName) } else if (options.pusher) { subscriber = new PusherSubscriber(options.pusher, networkInterface, options.decompress) } else { diff --git a/javascript_client/src/subscriptions/createAblyFetcher.ts b/javascript_client/src/subscriptions/createAblyFetcher.ts new file mode 100644 index 00000000000..dc360f4c081 --- /dev/null +++ b/javascript_client/src/subscriptions/createAblyFetcher.ts @@ -0,0 +1,92 @@ +import type Types from "ably" + +type AblyFetcherOptions = { + ably: Types.Realtime, + url: String, + fetch?: typeof fetch, + fetchOptions?: any, +} + +type SubscriptionIteratorPayload = { + value: any, + done: Boolean +} + +const clientName = "graphiql-subscriber" + +export default function createAblyFetcher(options: AblyFetcherOptions) { + var currentChannel: Types.Types.RealtimeChannelCallbacks | null = null + + return async function*(graphqlParams: any, _fetcherParams: any) { + var nextPromiseResolve: Function | null = null + var shouldBreak = false + + var iterator = { + [Symbol.asyncIterator]() { + return { + next(): Promise { + return new Promise((resolve, _reject) => { + nextPromiseResolve = resolve + }) + }, + return(): Promise { + if (currentChannel) { + currentChannel.presence.leaveClient(clientName) + currentChannel.unsubscribe() + const channelName = currentChannel.name + currentChannel.detach(() => { + options.ably.channels.release(channelName) + }) + currentChannel = null + nextPromiseResolve = null + } + return Promise.resolve({ value: null, done: true }) + } + } + } + } + + const fetchFn = options.fetch || window.fetch + fetchFn("/graphql", { + method: "POST", + body: JSON.stringify(graphqlParams), + headers: { + 'content-type': 'application/json', + }, + ... options.fetchOptions + }).then((r) => { + const subId = r.headers.get("X-Subscription-ID") + if (subId) { + currentChannel && currentChannel.unsubscribe() + currentChannel = options.ably.channels.get(subId, { modes: ["SUBSCRIBE", "PRESENCE"] }) + currentChannel.presence.enterClient(clientName, "subscribed", (err) => { + if (err) { + console.error(err) + } + }) + currentChannel.subscribe("update", (message: Types.Types.Message) => { + console.log("update", message) + if (nextPromiseResolve) { + nextPromiseResolve({ value: message.data.result, done: false }) + } + }) + + if (nextPromiseResolve) { + nextPromiseResolve({ value: r.json(), done: false }) + } + } else { + shouldBreak = true + if (nextPromiseResolve) { + nextPromiseResolve({ value: r.json(), done: false}) + } + } + }) + + for await (const payload of iterator) { + yield payload + if (shouldBreak) { + break + } + } + } +} diff --git a/javascript_client/src/subscriptions/createAblyHandler.ts b/javascript_client/src/subscriptions/createAblyHandler.ts index 938797d0881..1e7a9e44463 100644 --- a/javascript_client/src/subscriptions/createAblyHandler.ts +++ b/javascript_client/src/subscriptions/createAblyHandler.ts @@ -8,8 +8,17 @@ interface AblyHandlerOptions { fetchOperation: Function } +interface GraphQLError { + message: string + path: (string | number)[] + locations: number[][] + extensions?: object +} + +type OnErrorData = AblyError | Error| GraphQLError[] + interface ApolloObserver { - onError: Function + onError: (err: OnErrorData) => void onNext: Function onCompleted: Function } @@ -22,19 +31,17 @@ const anonymousClientId = "graphql-subscriber" // Note that using a higher value emits a warning. const maxNumRewindMessages = 100 -class AblyError { - constructor(reason: Types.ErrorInfo) { - const error = Error(reason.message) - const attributes: (keyof Types.ErrorInfo)[] = ["code", "statusCode"] - attributes.forEach(attr => { - Object.defineProperty(error, attr, { - get() { - return reason[attr] - } - }) - }) - Error.captureStackTrace(error, AblyError) - return error +class AblyError extends Error { + constructor(public reason: Types.ErrorInfo) { + super(reason.message) + } + + get code() { + return this.reason.code + } + + get statusCode() { + return this.reason.statusCode } } @@ -52,12 +59,12 @@ function createAblyHandler(options: AblyHandlerOptions) { ) => { let channel: Types.RealtimeChannelCallbacks | null = null - const dispatchResult = (result: { errors: any; data: any }) => { + const dispatchResult = (result: { errors?: GraphQLError[]; data: any }) => { if (result) { if (result.errors) { // What kind of error stuff belongs here? observer.onError(result.errors) - } else if (result.data) { + } else if (result.data && Object.keys(result.data).length > 0) { observer.onNext({ data: result.data }) } } @@ -119,7 +126,7 @@ function createAblyHandler(options: AblyHandlerOptions) { }) // Register presence, so that we can detect empty channels and clean them up server-side - const enterCallback = (errorInfo: Types.ErrorInfo | undefined) => { + const enterCallback = (errorInfo: Types.ErrorInfo | null | undefined) => { if (errorInfo && channel) { observer.onError(new AblyError(errorInfo)) } @@ -142,7 +149,7 @@ function createAblyHandler(options: AblyHandlerOptions) { // (In that case, we want to make sure the channel is cleaned up properly.) dispatchResult(response.body) } catch (error) { - observer.onError(error) + observer.onError(error as Error) } })() @@ -157,7 +164,7 @@ function createAblyHandler(options: AblyHandlerOptions) { // Ensure channel is no longer attaching, as otherwise detach does // nothing if (disposedChannel.state === "attaching") { - await new Promise((resolve, _reject) => { + await new Promise((resolve, _reject) => { const onStateChange = ( stateChange: Types.ChannelStateChange ) => { @@ -171,7 +178,7 @@ function createAblyHandler(options: AblyHandlerOptions) { } await new Promise((resolve, reject) => { - disposedChannel.detach((err) => { + disposedChannel.detach(err => { if (err) { reject(new AblyError(err)) } else { @@ -183,11 +190,11 @@ function createAblyHandler(options: AblyHandlerOptions) { ably.channels.release(disposedChannel.name) } } catch (error) { - observer.onError(error) + observer.onError(error as Error) } } } } } -export { createAblyHandler, AblyHandlerOptions } +export { createAblyHandler, AblyHandlerOptions, OnErrorData } diff --git a/javascript_client/src/subscriptions/createActionCableFetcher.ts b/javascript_client/src/subscriptions/createActionCableFetcher.ts new file mode 100644 index 00000000000..da7063f08b7 --- /dev/null +++ b/javascript_client/src/subscriptions/createActionCableFetcher.ts @@ -0,0 +1,98 @@ + +import { visit } from "graphql"; +import type { Consumer, Subscription } from "@rails/actioncable" + +type ActionCableFetcherOptions = { + consumer: Consumer, + url: string, + channelName?: string, + fetch?: typeof fetch, + fetchOptions?: any, +} + +type SubscriptionIteratorPayload = { + value: any, + done: Boolean +} + +export default function createActionCableFetcher(options: ActionCableFetcherOptions) { + let currentChannel: Subscription | null = null + const consumer = options.consumer + const url = options.url || "/graphql" + const channelName = options.channelName || "GraphqlChannel" + + const subscriptionFetcher = async function*(graphqlParams: any, fetcherOpts: any) { + let isSubscription = false; + let nextPromiseResolve: Function | null = null; + + fetcherOpts.documentAST && visit(fetcherOpts.documentAST, { + OperationDefinition(node) { + if (graphqlParams.operationName === node.name?.value && node.operation === 'subscription') { + isSubscription = true; + } + }, + }); + + if (isSubscription) { + currentChannel?.unsubscribe() + currentChannel = consumer.subscriptions.create(channelName, + { + connected: function() { + currentChannel?.perform("execute", { + query: graphqlParams.query, + operationName: graphqlParams.operationName, + variables: graphqlParams.variables, + }) + }, + + received: function(data: any) { + if (nextPromiseResolve) { + nextPromiseResolve({ value: data.result, done: false }) + } + } + } as any + ) + + var iterator = { + [Symbol.asyncIterator]() { + return { + next(): Promise { + return new Promise((resolve, _reject) => { + nextPromiseResolve = resolve + }) + }, + return(): Promise { + if (currentChannel) { + currentChannel.unsubscribe() + currentChannel = null + } + return Promise.resolve({ value: null, done: true }) + } + } + } + } + + for await (const payload of iterator) { + yield payload + } + } else { + const fetchFn = options.fetch || window.fetch + // Not a subscription fetcher, post to the given URL + yield fetchFn(url, { + method: "POST", + body: JSON.stringify({ + query: graphqlParams.query, + operationName: graphqlParams.operationName, + variables: graphqlParams.variables, + }), + headers: { + 'content-type': 'application/json', + }, + ... options.fetchOptions + }).then((r) => r.json()) + return + } + } + + return subscriptionFetcher +} diff --git a/javascript_client/src/subscriptions/createActionCableHandler.ts b/javascript_client/src/subscriptions/createActionCableHandler.ts index f8ef47ab58a..70ebee444e1 100644 --- a/javascript_client/src/subscriptions/createActionCableHandler.ts +++ b/javascript_client/src/subscriptions/createActionCableHandler.ts @@ -1,4 +1,5 @@ -import { Cable } from "actioncable" +import type { Consumer } from "@rails/actioncable" +import defaultChannelId from "./defaultChannelId" /** * Create a Relay Modern-compatible subscription handler. @@ -8,20 +9,24 @@ import { Cable } from "actioncable" * @return {Function} */ interface ActionCableHandlerOptions { - cable: Cable + cable: Consumer operations?: { getOperationId: Function} + channelName?: string + clientName?: string + createChannelId?: () => string } function createActionCableHandler(options: ActionCableHandlerOptions) { - return function (operation: { text: string, name: string}, variables: object, _cacheConfig: object, observer: {onError: Function, onNext: Function, onCompleted: Function}) { - // unique-ish - var channelId = Math.round(Date.now() + Math.random() * 100000).toString(16) + const createChannelId = options.createChannelId || defaultChannelId + return function (operation: { text: string, name: string, id?: string }, variables: object, _cacheConfig: object, observer: {onError: Function, onNext: Function, onCompleted: Function}) { + var channelId = createChannelId() var cable = options.cable var operations = options.operations + var subscribed = true // Register the subscription by subscribing to the channel const channel = cable.subscriptions.create({ - channel: "GraphqlChannel", + channel: options.channelName || "GraphqlChannel", channelId: channelId, }, { connected: function() { @@ -38,10 +43,10 @@ function createActionCableHandler(options: ActionCableHandlerOptions) { channelParams = { variables: variables, operationName: operation.name, - query: operation.text + query: operation.text, + operationId: (operation.id && options.clientName ? (options.clientName + "/" + operation.id) : null), } } - channel.perform('send', channelParams) channel.perform("execute", channelParams) }, // This result is sent back from ActionCable. @@ -54,7 +59,7 @@ function createActionCableHandler(options: ActionCableHandlerOptions) { } else if (result) { observer.onNext({data: result.data}) } - if (!payload.more) { + if (!payload.more && subscribed) { // Subscription is finished observer.onCompleted() } @@ -64,7 +69,10 @@ function createActionCableHandler(options: ActionCableHandlerOptions) { // Return an object for Relay to unsubscribe with return { dispose: function() { - channel.unsubscribe() + if (subscribed) { + subscribed = false + channel.unsubscribe() + } } } } diff --git a/javascript_client/src/subscriptions/createPusherFetcher.ts b/javascript_client/src/subscriptions/createPusherFetcher.ts new file mode 100644 index 00000000000..f400ba6ab63 --- /dev/null +++ b/javascript_client/src/subscriptions/createPusherFetcher.ts @@ -0,0 +1,79 @@ +import type Pusher from "pusher-js" +import type { Channel } from "pusher-js" + +type PusherFetcherOptions = { + pusher: Pusher, + url: String, + fetch?: typeof fetch, + fetchOptions: any, +} + +type SubscriptionIteratorPayload = { + value: any, + done: Boolean +} + +export default function createPusherFetcher(options: PusherFetcherOptions) { + var currentChannel: Channel | null = null + + return async function*(graphqlParams: any, _fetcherParams: any) { + var nextPromiseResolve: Function | null = null + var shouldBreak = false + + var iterator = { + [Symbol.asyncIterator]() { + return { + next(): Promise { + return new Promise((resolve, _reject) => { + nextPromiseResolve = resolve + }) + }, + return(): Promise { + if (currentChannel) { + currentChannel.unsubscribe() + currentChannel = null + } + return Promise.resolve({ value: null, done: true }) + } + } + } + } + + const fetchFn = options.fetch || window.fetch + fetchFn("/graphql", { + method: "POST", + body: JSON.stringify(graphqlParams), + headers: { + 'content-type': 'application/json', + }, + ...options.fetchOptions + }).then((r) => { + const subId = r.headers.get("X-Subscription-ID") + if (subId) { + currentChannel && currentChannel.unsubscribe() + currentChannel = options.pusher.subscribe(subId) + currentChannel.bind("update", (payload: any) => { + if (nextPromiseResolve) { + nextPromiseResolve({ value: payload.result, done: false }) + } + }) + + if (nextPromiseResolve) { + nextPromiseResolve({ value: r.json(), done: false }) + } + } else { + shouldBreak = true + if (nextPromiseResolve) { + nextPromiseResolve({ value: r.json(), done: false}) + } + } + }) + + for await (const payload of iterator) { + yield payload + if (shouldBreak) { + break + } + } + } +} diff --git a/javascript_client/src/subscriptions/createRelaySubscriptionHandler.ts b/javascript_client/src/subscriptions/createRelaySubscriptionHandler.ts index eae20816e98..29bdb38582f 100644 --- a/javascript_client/src/subscriptions/createRelaySubscriptionHandler.ts +++ b/javascript_client/src/subscriptions/createRelaySubscriptionHandler.ts @@ -1,6 +1,22 @@ import { createActionCableHandler, ActionCableHandlerOptions } from "./createActionCableHandler" import { createPusherHandler, PusherHandlerOptions } from "./createPusherHandler" import { createAblyHandler, AblyHandlerOptions } from "./createAblyHandler" +import { RequestParameters, Variables, Observable } from "relay-runtime" + +function createLegacyRelaySubscriptionHandler(options: ActionCableHandlerOptions | PusherHandlerOptions | AblyHandlerOptions) { + var handler: any + if ((options as ActionCableHandlerOptions).cable) { + handler = createActionCableHandler(options as ActionCableHandlerOptions) + } else if ((options as PusherHandlerOptions).pusher) { + handler = createPusherHandler(options as PusherHandlerOptions) + } else if ((options as AblyHandlerOptions).ably) { + handler = createAblyHandler(options as AblyHandlerOptions) + } else { + throw new Error("Missing options for subscription handler") + } + return handler +} + /** * Transport-agnostic wrapper for Relay Modern subscription handlers. * @example Add ActionCable subscriptions @@ -15,19 +31,49 @@ import { createAblyHandler, AblyHandlerOptions } from "./createAblyHandler" * @param {OperationStoreClient} options.operations - A generated `OperationStoreClient` for graphql-pro's OperationStore * @return {Function} A handler for a Relay Modern network */ + function createRelaySubscriptionHandler(options: ActionCableHandlerOptions | PusherHandlerOptions | AblyHandlerOptions) { - if (!options) { - return null - } - var handler - if ((options as ActionCableHandlerOptions).cable) { - handler = createActionCableHandler(options as ActionCableHandlerOptions) - } else if ((options as PusherHandlerOptions).pusher) { - handler = createPusherHandler(options as PusherHandlerOptions) - } else if ((options as AblyHandlerOptions).ably) { - handler = createAblyHandler(options as AblyHandlerOptions) - } - return handler + const handler = createLegacyRelaySubscriptionHandler(options) + + // Turn the handler into a relay-ready subscribe function + return (request: RequestParameters, variables: Variables): any => { + return Observable.from({ + subscribe: (observer: { + next: any | ((v: any) => void); + complete: () => void; + error: (error: Error) => void; + }) => { + const client = handler( + { + text: request.text, + name: request.name, + id: request.id, + }, + variables, + {}, + { + onError: (error: Error) => { + observer.error(error); + }, + onNext: (res: any) => { + if (!res || !res.data) { + return; + } + observer.next(res); + }, + onCompleted: observer.complete, + } + ); + + return { + unsubscribe: () => { + client.dispose(); + }, + }; + }, + }); + }; } +export { createLegacyRelaySubscriptionHandler } export default createRelaySubscriptionHandler diff --git a/javascript_client/src/subscriptions/defaultChannelId.ts b/javascript_client/src/subscriptions/defaultChannelId.ts new file mode 100644 index 00000000000..c5bef321d86 --- /dev/null +++ b/javascript_client/src/subscriptions/defaultChannelId.ts @@ -0,0 +1,16 @@ +// `crypto.randomUUID` is only exposed in secure contexts (HTTPS or localhost), +// so fall back to `crypto.getRandomValues` — available in all contexts — to +// keep subscriptions working on plain-HTTP origins with the same 128 bits of +// entropy. (https://github.com/rmosolgo/graphql-ruby/issues/5648) +function defaultChannelId(): string { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID() + } + + return Array.from( + crypto.getRandomValues(new Uint8Array(16)), + (byte) => byte.toString(16).padStart(2, "0") + ).join("") +} + +export default defaultChannelId diff --git a/javascript_client/src/subscriptions/registry.ts b/javascript_client/src/subscriptions/registry.ts index a9bb0047c49..20f83dce5b9 100644 --- a/javascript_client/src/subscriptions/registry.ts +++ b/javascript_client/src/subscriptions/registry.ts @@ -3,7 +3,7 @@ interface ApolloSubscription { } // State management for subscriptions. -// Used to add subscriptions to an Apollo network intrface. +// Used to add subscriptions to an Apollo network interface. class ApolloSubscriptionRegistry { // Apollo expects unique ids to reference each subscription, // here's a simple incrementing ID generator which starts at 1 diff --git a/javascript_client/src/sync/__tests__/__snapshots__/dumpPayloadTest.ts.snap b/javascript_client/src/sync/__tests__/__snapshots__/dumpPayloadTest.ts.snap new file mode 100644 index 00000000000..c830f85a4b1 --- /dev/null +++ b/javascript_client/src/sync/__tests__/__snapshots__/dumpPayloadTest.ts.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`printing out the HTTP Post payload prints the result to stdout 1`] = ` +[ + [ + "{ + "ok": { + "1": true + } +} +", + ], +] +`; diff --git a/javascript_client/src/sync/__tests__/__snapshots__/generateClientTest.ts.snap b/javascript_client/src/sync/__tests__/__snapshots__/generateClientTest.ts.snap index bbccc64117a..98e9bb1d6fd 100644 --- a/javascript_client/src/sync/__tests__/__snapshots__/generateClientTest.ts.snap +++ b/javascript_client/src/sync/__tests__/__snapshots__/generateClientTest.ts.snap @@ -13,7 +13,7 @@ exports[`returns generated code 1`] = ` * @private */ var _aliases = { - \\"GetStuff\\": \\"f7f65309043352183e905e1396e51078\\" + "GetStuff": "b8086942c2fbb6ac69b97cbade848033" } /** @@ -21,7 +21,7 @@ exports[`returns generated code 1`] = ` * @return {String} * @private */ - var _client = \\"test-client\\" + var _client = "test-client" var OperationStoreClient = { /** @@ -30,7 +30,7 @@ exports[`returns generated code 1`] = ` * @return {String} stored operation ID */ getOperationId: function(operationName) { - return _client + \\"/\\" + OperationStoreClient.getPersistedQueryAlias(operationName) + return _client + "/" + OperationStoreClient.getPersistedQueryAlias(operationName) }, /** @@ -41,7 +41,7 @@ exports[`returns generated code 1`] = ` getPersistedQueryAlias: function(operationName) { var persistedAlias = _aliases[operationName] if (!persistedAlias) { - throw new Error(\\"Failed to find persisted alias for operation name: \\" + operationName) + throw new Error("Failed to find persisted alias for operation name: " + operationName) } else { return persistedAlias } diff --git a/javascript_client/src/sync/__tests__/__snapshots__/generateJsonClientTest.ts.snap b/javascript_client/src/sync/__tests__/__snapshots__/generateJsonClientTest.ts.snap index 2a2f2b1ef6a..d6081acfe9e 100644 --- a/javascript_client/src/sync/__tests__/__snapshots__/generateJsonClientTest.ts.snap +++ b/javascript_client/src/sync/__tests__/__snapshots__/generateJsonClientTest.ts.snap @@ -2,13 +2,13 @@ exports[`generates a valid json object string that maps names to operations 1`] = ` "{ - \\"a\\": \\"b\\", - \\"c-d\\": \\"e-f\\" + "a": "b", + "c-d": "e-f" }" `; exports[`generates a valid json object string that maps names to operations 2`] = ` -Object { +{ "a": "b", "c-d": "e-f", } diff --git a/javascript_client/src/sync/__tests__/__snapshots__/prepareIsolatedFilesTest.ts.snap b/javascript_client/src/sync/__tests__/__snapshots__/prepareIsolatedFilesTest.ts.snap index fab58926f9e..79aaa4e7241 100644 --- a/javascript_client/src/sync/__tests__/__snapshots__/prepareIsolatedFilesTest.ts.snap +++ b/javascript_client/src/sync/__tests__/__snapshots__/prepareIsolatedFilesTest.ts.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`builds out single operations 1`] = ` -Array [ - Object { +[ + { "alias": "", "body": "query GetStuffIsolated { ...FragIsolated @@ -15,26 +15,24 @@ fragment FragIsolated on Query { evenMoreStuff { stuffInside } -} -", +}", "name": "GetStuffIsolated", }, - Object { + { "alias": "", "body": "query GetStuffIsolated2 { things { existHere } -} -", +}", "name": "GetStuffIsolated2", }, ] `; exports[`with --add-typename builds out single operations with __typename fields 1`] = ` -Array [ - Object { +[ + { "alias": "", "body": "query GetStuffIsolated { ...FragIsolated @@ -49,19 +47,17 @@ fragment FragIsolated on Query { stuffInside __typename } -} -", +}", "name": "GetStuffIsolated", }, - Object { + { "alias": "", "body": "query GetStuffIsolated2 { things { existHere __typename } -} -", +}", "name": "GetStuffIsolated2", }, ] diff --git a/javascript_client/src/sync/__tests__/__snapshots__/preparePersistedQueryListTest.ts.snap b/javascript_client/src/sync/__tests__/__snapshots__/preparePersistedQueryListTest.ts.snap new file mode 100644 index 00000000000..e4abbe39de5 --- /dev/null +++ b/javascript_client/src/sync/__tests__/__snapshots__/preparePersistedQueryListTest.ts.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`reads generate-persisted-query-manifest output 1`] = ` +[ + { + "alias": "4a29162b05ee4d82ad02e8f50af4bf112f47181ec558a7100a", + "body": "query TestQuery1 { + testing { + id + label + description + __typename +} }", + "name": "TestQuery1", + }, + { + "alias": "xyz-123", + "body": "query TestQuery2 { + testing2 { + id2 + label + description2 + __typename +} }", + "name": "TestQuery2", + }, +] +`; diff --git a/javascript_client/src/sync/__tests__/__snapshots__/prepareProjectTest.ts.snap b/javascript_client/src/sync/__tests__/__snapshots__/prepareProjectTest.ts.snap index 9ac6690d065..e3155d78897 100644 --- a/javascript_client/src/sync/__tests__/__snapshots__/prepareProjectTest.ts.snap +++ b/javascript_client/src/sync/__tests__/__snapshots__/prepareProjectTest.ts.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`merging a project builds out separate operations 1`] = ` -Array [ - Object { +[ + { "alias": "", "body": "query GetStuff2 { stuff @@ -20,11 +20,10 @@ fragment Frag2 on Query { fragment Frag3 on Query { evenMoreStuff -} -", +}", "name": "GetStuff2", }, - Object { + { "alias": "", "body": "query GetStuff { ...Frag1 @@ -32,16 +31,15 @@ fragment Frag3 on Query { fragment Frag1 on Query { moreStuff -} -", +}", "name": "GetStuff", }, ] `; exports[`merging a project with --add-typename builds out operation with __typename fields 1`] = ` -Array [ - Object { +[ + { "alias": "", "body": "query GetStuff3 { stuff { @@ -65,8 +63,7 @@ fragment Frag4 on Query { stuffInside __typename } -} -", +}", "name": "GetStuff3", }, ] diff --git a/javascript_client/src/sync/__tests__/dumpPayloadTest.ts b/javascript_client/src/sync/__tests__/dumpPayloadTest.ts new file mode 100644 index 00000000000..22867a6cab3 --- /dev/null +++ b/javascript_client/src/sync/__tests__/dumpPayloadTest.ts @@ -0,0 +1,33 @@ +import dumpPayload from "../dumpPayload" +import fs from 'fs' +interface MockedObject { + mock: { calls: object } +} + +describe("printing out the HTTP Post payload", () => { + beforeEach(() => { + process.stdout.write = jest.fn() + }) + + afterEach(() => { + jest.clearAllMocks(); + }) + + + it("prints the result to stdout", () => { + var spy = (process.stdout.write as unknown) as MockedObject + dumpPayload({"ok": { "1": true}}, { dumpPayload: true }) + expect(spy.mock.calls).toMatchSnapshot() + }) + + it("writes the result to a file", () => { + dumpPayload({"ok": { "1": true}}, {dumpPayload: "./DumpPayloadExample.json"}) + let writtenContents = fs.readFileSync("./DumpPayloadExample.json", 'utf8') + expect(writtenContents).toEqual(`{ + "ok": { + "1": true + } +} +`) + }) +}) diff --git a/javascript_client/src/sync/__tests__/generate-persisted-query-manifest.json b/javascript_client/src/sync/__tests__/generate-persisted-query-manifest.json new file mode 100644 index 00000000000..b469ab6783f --- /dev/null +++ b/javascript_client/src/sync/__tests__/generate-persisted-query-manifest.json @@ -0,0 +1,18 @@ +{ + "format": "apollo-persisted-query-manifest", + "version": 1, + "operations": [ + { + "id": "4a29162b05ee4d82ad02e8f50af4bf112f47181ec558a7100a", + "name": "TestQuery1", + "type": "query", + "body": "query TestQuery1 {\n testing {\n id\n label\n description\n __typename\n} }" + }, + { + "id": "xyz-123", + "name": "TestQuery2", + "type": "mutation", + "body": "query TestQuery2 {\n testing2 {\n id2\n label\n description2\n __typename\n} }" + } + ] +} diff --git a/javascript_client/src/sync/__tests__/preparePersistedQueryListTest.ts b/javascript_client/src/sync/__tests__/preparePersistedQueryListTest.ts new file mode 100644 index 00000000000..306a80c348c --- /dev/null +++ b/javascript_client/src/sync/__tests__/preparePersistedQueryListTest.ts @@ -0,0 +1,7 @@ +import preparePersistedQueryList from "../preparePersistedQueryList" + +it("reads generate-persisted-query-manifest output", () => { + const manifestPath = "./src/sync/__tests__/generate-persisted-query-manifest.json" + var ops = preparePersistedQueryList(manifestPath) + expect(ops).toMatchSnapshot() +}) diff --git a/javascript_client/src/sync/__tests__/removeClientFieldsTest.ts b/javascript_client/src/sync/__tests__/removeClientFieldsTest.ts new file mode 100644 index 00000000000..369b05b3b53 --- /dev/null +++ b/javascript_client/src/sync/__tests__/removeClientFieldsTest.ts @@ -0,0 +1,102 @@ +import { removeClientFieldsFromString } from "../removeClientFields" + + +describe("removing @client fields", () => { + function normalizeString(str: string) { + return str.replace(/\s+/g, " ").trim() + } + + it("returns a string without any fields with @client", () => { + var newString = removeClientFieldsFromString("{ f1 f2 @client { a b } f3 { a b @client } }") + var expectedString = "{ f1 f3 { a } }" + expect(normalizeString(newString)).toEqual(expectedString) + }) + + it("leaves other strings unchanged", () => { + var originalString = "{ f1 f2 @other { a b } f3 { a b @notClient } }" + var newString = removeClientFieldsFromString(originalString) + expect(normalizeString(newString)).toEqual(originalString) + }) + + it("removes references to fragments that contain all client fields", () => { + var originalString = ` + { + f1 + ...Fragment1 + ... on Query { + f3 + ...Fragment2 + } + ...Fragment3 + } + + fragment Fragment1 on Query { + f2 @client + f3 + ...Fragment2 + } + + fragment Fragment2 on Query { + f4 @client + f5 @client + f6 @client { + f7 + f8 + } + } + + fragment Fragment3 on Query { + ...Fragment2 + } + ` + + var expectedString = ` + { + f1 + ...Fragment1 + ... on Query { + f3 + } + } + + fragment Fragment1 on Query { + f3 + } + ` + + var newString = removeClientFieldsFromString(originalString) + expect(normalizeString(newString)).toEqual(normalizeString(expectedString)) + }) + + it("removes now-unused variables", () => { + var newString = removeClientFieldsFromString("query($thing: ID!){ f1 f2(thing: $thing) @client }") + var expectedString = "{ f1 }" + expect(normalizeString(newString)).toEqual(expectedString) + }) + + it("removes fragments that are spread inside client fields", () => { + // from https://github.com/apollographql/apollo-client/pull/6892/ + var originalString = ` + query Simple { + networkField + field @client { + ...ClientFragment + } + } + fragment ClientFragment on Thing { + ...NestedFragment + } + fragment NestedFragment on Thing { + otherField + bar + }` + var expectedString = ` + query Simple { + networkField + } + ` + + var newString = removeClientFieldsFromString(originalString) + expect(normalizeString(newString)).toEqual(normalizeString(expectedString)) + }) +}) diff --git a/javascript_client/src/sync/__tests__/sendPayloadTest.ts b/javascript_client/src/sync/__tests__/sendPayloadTest.ts index c009cbbe3d2..7892406973c 100644 --- a/javascript_client/src/sync/__tests__/sendPayloadTest.ts +++ b/javascript_client/src/sync/__tests__/sendPayloadTest.ts @@ -1,14 +1,25 @@ jest.dontMock('nock'); import nock from "nock" +import Logger from "../logger"; import sendPayload from "../sendPayload" +var fakeLogger = { + log: function() {}, + bright: function(str: string) { return str }, + colorize: function(str: string) { return str }, + red: function(str: string) { return str }, + green: function(str: string) { return str }, + error: function() {}, + isQuiet: true, +} as Logger + describe("Posting GraphQL to OperationStore Endpoint", () => { it("Posts to the specified URL", () => { var mock = nock("http://example.com") .post("/stored_operations/sync") .reply(200, { "ok" : "ok" }) - return sendPayload("payload", { url: "http://example.com/stored_operations/sync" }).then(function() { + return sendPayload("payload", { url: "http://example.com/stored_operations/sync", logger: fakeLogger }).then(function() { expect(mock.isDone()).toEqual(true) }) }) @@ -18,7 +29,7 @@ describe("Posting GraphQL to OperationStore Endpoint", () => { .post("/stored_operations/sync") .reply(200, { "ok" : "ok" }) - return sendPayload("payload", { url: "https://example2.com/stored_operations/sync" }).then(function() { + return sendPayload("payload", { url: "https://example2.com/stored_operations/sync", logger: fakeLogger }).then(function() { expect(mock.isDone()).toEqual(true) }) }) @@ -29,7 +40,7 @@ describe("Posting GraphQL to OperationStore Endpoint", () => { .basicAuth({ user: "username", pass: "pass" }) .reply(200, { "ok" : "ok" }) - return sendPayload("payload", { url: "https://username:pass@example2.com:229/stored_operations/sync?q=1" }).then(function() { + return sendPayload("payload", { url: "https://username:pass@example2.com:229/stored_operations/sync?q=1", logger: fakeLogger }).then(function() { expect(mock.isDone()).toEqual(true) }) }) @@ -39,11 +50,26 @@ describe("Posting GraphQL to OperationStore Endpoint", () => { .post("/stored_operations/sync") .reply(200, { result: "ok" }) - return sendPayload("payload", { url: "http://example.com/stored_operations/sync" }).then(function(response) { + return sendPayload("payload", { url: "http://example.com/stored_operations/sync", logger: fakeLogger }).then(function(response) { expect(response).toEqual('{"result":"ok"}') }) }) + it("Sends headers and changeset version", () => { + var mock = nock("http://example.com", { + reqheaders: { + thing: "Stuff", + "Changeset-Version": "2023-01-01", + } + }) + .post("/stored_operations/sync") + .reply(200, { result: "ok" }) + + return sendPayload("payload", { url: "http://example.com/stored_operations/sync", logger: fakeLogger, headers: { thing: "Stuff" }, changesetVersion: "2023-01-01" }).then(function(_response) { + expect(mock.isDone()).toEqual(true) + }) + }) + it("Adds an hmac-sha256 header if key is present", () => { var payload = { "payload": [1,2,3] } var key = "2f26b770ded2a04279bc4bf824ca54ac" @@ -57,7 +83,7 @@ describe("Posting GraphQL to OperationStore Endpoint", () => { .post("/stored_operations/sync") .reply(200, { result: "ok" }) - var opts = {secret: key, client: "Abc", url: "http://example.com/stored_operations/sync"} + var opts = {secret: key, client: "Abc", url: "http://example.com/stored_operations/sync", logger: fakeLogger } return sendPayload(payload, opts).then(function(response) { expect(response).toEqual('{"result":"ok"}') expect(mock.isDone()).toEqual(true) diff --git a/javascript_client/src/sync/addTypenameToSelectionSet.ts b/javascript_client/src/sync/addTypenameToSelectionSet.ts index 3e314ec431a..cd843be8f64 100644 --- a/javascript_client/src/sync/addTypenameToSelectionSet.ts +++ b/javascript_client/src/sync/addTypenameToSelectionSet.ts @@ -1,13 +1,13 @@ -import { visit, ASTNode, FieldNode, InlineFragmentNode } from "graphql" +import { visit, ASTNode, FieldNode, InlineFragmentNode, Kind } from "graphql" const TYPENAME_FIELD: FieldNode = { - kind: "Field", + kind: Kind.FIELD, name: { - kind: "Name", + kind: Kind.NAME, value: "__typename", }, selectionSet: { - kind: "SelectionSet", + kind: Kind.SELECTION_SET, selections: [] } } @@ -53,4 +53,3 @@ export { addTypenameToSelectionSet, addTypenameIfAbsent } - diff --git a/javascript_client/src/sync/dumpPayload.ts b/javascript_client/src/sync/dumpPayload.ts new file mode 100644 index 00000000000..c49714ae55e --- /dev/null +++ b/javascript_client/src/sync/dumpPayload.ts @@ -0,0 +1,14 @@ +import fs from 'fs'; + +interface DumpPayloadOptions { + dumpPayload: string | true, +} + +export default function dumpPayload(payload: Object, options: DumpPayloadOptions) { + let payloadStr = JSON.stringify(payload, null, 2) + "\n" + if (options.dumpPayload == true) { + process.stdout.write(payloadStr) + } else { + fs.writeFileSync(options.dumpPayload, payloadStr, 'utf8') + } +} diff --git a/javascript_client/src/sync/generateClient.ts b/javascript_client/src/sync/generateClient.ts index e989b74b8f6..bcd370e664a 100644 --- a/javascript_client/src/sync/generateClient.ts +++ b/javascript_client/src/sync/generateClient.ts @@ -1,4 +1,4 @@ -import glob from "glob" +import { globSync } from "glob" import prepareRelay from "./prepareRelay" import prepareIsolatedFiles from './prepareIsolatedFiles' import prepareProject from "./prepareProject" @@ -66,7 +66,7 @@ function gatherOperations(options: GenerateClientCodeOptions) { var operations: ClientOperation[] = [] - var filenames: string[] = glob.sync(graphqlGlob, {}) + var filenames: string[] = globSync(graphqlGlob, {}).sort() if (verbose) { console.log("[Sync] glob: ", graphqlGlob) console.log("[Sync] " + filenames.length + " files:") @@ -85,6 +85,7 @@ function gatherOperations(options: GenerateClientCodeOptions) { // Update the operations with the hash of the body operations.forEach(function(op) { op.alias = hashFunc(op.body) + // console.log("operation", op.alias, op.body) }) } return { operations: operations } diff --git a/javascript_client/src/sync/index.ts b/javascript_client/src/sync/index.ts index b058ec73493..ee5a8a8e2dd 100644 --- a/javascript_client/src/sync/index.ts +++ b/javascript_client/src/sync/index.ts @@ -1,15 +1,21 @@ import sendPayload from "./sendPayload" +import dumpPayload from "./dumpPayload" import { generateClientCode, gatherOperations, ClientOperation } from "./generateClient" import Logger from "./logger" import fs from "fs" +import { removeClientFieldsFromString } from "./removeClientFields" +import preparePersistedQueryList from "./preparePersistedQueryList" -interface SyncOptions { +export interface SyncOptions { path?: string, relayPersistedOutput?: string, apolloAndroidOperationOutput?: string, + apolloCodegenJsonOutput?: string, + apolloPersistedQueryManifest?: string, secret?: string url?: string, mode?: string, + dumpPayload?: string | true, outfile?: string, outfileType?: string, client: string, @@ -18,6 +24,8 @@ interface SyncOptions { verbose?: boolean, quiet?: boolean, addTypename?: boolean, + changesetVersion?: string, + headers?: {[key: string]: string}, } /** * Find `.graphql` files in `path`, @@ -26,6 +34,8 @@ interface SyncOptions { * @param {Object} options * @param {String} options.path - A glob to recursively search for `.graphql` files (Default is `./`) * @param {String} options.relayPersistedOutput - A path to a `.json` file from `relay-compiler`'s `--persist-output` option + * @param {String} options.apolloCodegenJsonOutput - A path to a `.json` file from `apollo client:codegen ... --type json` + * @param {String} options.apolloPersistedQueryManifest - A path to a `.json` file from `generate-persisted-query-manifest` * @param {String} options.secret - HMAC-SHA256 key which must match the server secret (default is no encryption) * @param {String} options.url - Target URL for sending prepared queries. If omitted, then an outfile is generated without sending operations to the server. * @param {String} options.mode - If `"file"`, treat each file separately. If `"project"`, concatenate all files and extract each operation. If `"relay"`, treat it as relay-compiler output @@ -36,13 +46,18 @@ interface SyncOptions { * @param {Function} options.send - A function for sending the payload to the server, with the signature `options.send(payload)`. (Default is an HTTP `POST` request) * @param {Function} options.hash - A custom hash function for query strings with the signature `options.hash(string) => digest` (Default is `md5(string) => digest`) * @param {Boolean} options.verbose - If true, log debug output + * @param {Object} options.headers - If present, extra headers to add to the HTTP request + * @param {String|true} options.dumpPayload - If a filename is given, write the HTTP Post data to that file. If present without a filename, print it to stdout. + * @param {String} options.changesetVersion - If present, sent to populate `context[:changeset_version]` on the server * @return {Promise} Rejects with an Error or String if something goes wrong. Resolves with the operation payload if successful. */ function sync(options: SyncOptions) { var logger = new Logger(!!options.quiet) var verbose = !!options.verbose var url = options.url - if (!url) { + var dumpingPayload = "dumpPayload" in options + var dumpingToStdout = options.dumpPayload == true + if (!url && !dumpingPayload) { logger.log("No URL; Generating artifacts without syncing them") } var clientName = options.client @@ -50,13 +65,13 @@ function sync(options: SyncOptions) { throw new Error("Client name must be provided for sync") } var encryptionKey = options.secret - if (encryptionKey) { + if (encryptionKey && options.dumpPayload != null) { logger.log("Authenticating with HMAC") } var graphqlGlob = options.path var hashFunc = options.hash - var sendFunc = options.send || sendPayload + var sendFunc = options.send || (dumpingPayload ? dumpPayload : sendPayload) var gatherMode = options.mode var clientType = options.outfileType if (options.relayPersistedOutput) { @@ -81,12 +96,28 @@ function sync(options: SyncOptions) { // Structure is { operationId => { "name" => "...", "source" => "query { ... } " } } for (var operationId in apolloAndroidOutput) { operationData = apolloAndroidOutput[operationId] + let bodyWithoutClientFields = removeClientFieldsFromString(operationData.source) payload.operations.push({ - body: operationData.source, + body: bodyWithoutClientFields, alias: operationId, }) } - + } else if (options.apolloCodegenJsonOutput) { + var payload: { operations: ClientOperation[] } = { operations: [] } + const jsonText = fs.readFileSync(options.apolloCodegenJsonOutput).toString() + const jsonData = JSON.parse(jsonText) + jsonData.operations.map(function(operation: {operationId: string, operationName: string, sourceWithFragments: string}) { + const bodyWithoutClientFields = removeClientFieldsFromString(operation.sourceWithFragments) + payload.operations.push({ + alias: operation.operationId, + name: operation.operationName, + body: bodyWithoutClientFields, + }) + }) + } else if (options.apolloPersistedQueryManifest) { + var payload: { operations: ClientOperation[] } = { + operations: preparePersistedQueryList(options.apolloPersistedQueryManifest) + } } else { var payload = gatherOperations({ path: graphqlGlob, @@ -102,7 +133,7 @@ function sync(options: SyncOptions) { var outfile: string | null if (options.outfile) { outfile = options.outfile - } else if (options.relayPersistedOutput || options.apolloAndroidOperationOutput) { + } else if (options.relayPersistedOutput || options.apolloAndroidOperationOutput || options.apolloCodegenJsonOutput || options.apolloPersistedQueryManifest) { // These artifacts have embedded IDs in its generated files, // no need to generate an outfile. outfile = null @@ -117,17 +148,15 @@ function sync(options: SyncOptions) { logger.log("No operations found in " + options.path + ", not syncing anything") resolve(null) return - } else if(!url) { - // This is a local-only run to generate an artifact - resolve(payload) - return - } else { + } else if (url) { logger.log("Syncing " + payload.operations.length + " operations to " + logger.bright(url) + "...") var sendOpts = { url: url, client: clientName, secret: encryptionKey, - verbose: verbose, + headers: options.headers, + changesetVersion: options.changesetVersion, + logger: logger, } var sendPromise = Promise.resolve(sendFunc(payload, sendOpts)) return sendPromise.then(function(response) { @@ -142,7 +171,7 @@ function sync(options: SyncOptions) { }) var failed = responseData.failed.length - // These might get overriden for status output + // These might get overridden for status output var notModified = responseData.not_modified.length var added = responseData.added.length if (failed) { @@ -178,7 +207,7 @@ function sync(options: SyncOptions) { return } } catch (err) { - logger.log("Failed to print sync result:", err) + logger.log("Failed to print sync result:", err as string) reject(err) return } @@ -191,6 +220,14 @@ function sync(options: SyncOptions) { reject(err) return }) + } else if (dumpingPayload) { + sendFunc(payload, { dumpPayload: options.dumpPayload }) + resolve(payload) + return + } else { + // This is a local-only run to generate an artifact + resolve(payload) + return } }) @@ -198,19 +235,25 @@ function sync(options: SyncOptions) { // The payload is yielded when sync was successful, but typescript had // trouble using it from ^^ here. So instead, just use its presence as a signal to continue. - // Don't generate a new file when we're using relay-comipler's --persist-output + // Don't generate a new file when we're using relay-compiler's --persist-output if (_payload && outfile) { var generatedCode = generateClientCode(clientName, payload.operations, clientType) var finishedPayload = { operations: payload.operations, generatedCode, } - logger.log("Generating client module in " + logger.colorize("bright", outfile) + "...") + if (!dumpingToStdout) { + logger.log("Generating client module in " + logger.colorize("bright", outfile) + "...") + } fs.writeFileSync(outfile, generatedCode, "utf8") - logger.log(logger.green("✓ Done!")) + if (!dumpingToStdout) { + logger.log(logger.green("✓ Done!")) + } return finishedPayload } else { - logger.log(logger.green("✓ Done!")) + if (!dumpingToStdout) { + logger.log(logger.green("✓ Done!")) + } return payload } }) diff --git a/javascript_client/src/sync/prepareIsolatedFiles.ts b/javascript_client/src/sync/prepareIsolatedFiles.ts index ac2bc529dd9..7d580da0a4e 100644 --- a/javascript_client/src/sync/prepareIsolatedFiles.ts +++ b/javascript_client/src/sync/prepareIsolatedFiles.ts @@ -1,7 +1,7 @@ import fs from "fs" import {parse, visit, print, OperationDefinitionNode} from "graphql" import {addTypenameIfAbsent} from "./addTypenameToSelectionSet" - +import { removeClientFields } from "./removeClientFields" /** * Read a bunch of GraphQL files and treat them as islands. @@ -33,6 +33,7 @@ function prepareIsolatedFiles(filenames: string[], addTypename: boolean) { } } ast = visit(ast, visitor) + ast = removeClientFields(ast) return { // populate alias later, when hashFunc is available diff --git a/javascript_client/src/sync/preparePersistedQueryList.ts b/javascript_client/src/sync/preparePersistedQueryList.ts new file mode 100644 index 00000000000..68d53ff7b04 --- /dev/null +++ b/javascript_client/src/sync/preparePersistedQueryList.ts @@ -0,0 +1,15 @@ +import fs from "fs" + +// Transform the output from generate-persisted-query-manifest +// to something that OperationStore `sync` can use. +export default function preparePersistedQueryList(pqlPath: string) { + const pqlString = fs.readFileSync(pqlPath, "utf8") + const pqlJson = JSON.parse(pqlString) + return pqlJson.operations.map(function(persistedQueryConfig: { body: string, id: string, name: string, type: string }) { + return { + body: persistedQueryConfig.body, + alias: persistedQueryConfig.id, + name: persistedQueryConfig.name + } + }) +} diff --git a/javascript_client/src/sync/prepareProject.ts b/javascript_client/src/sync/prepareProject.ts index fded0918638..567312610ff 100644 --- a/javascript_client/src/sync/prepareProject.ts +++ b/javascript_client/src/sync/prepareProject.ts @@ -1,6 +1,7 @@ import { addTypenameIfAbsent } from "./addTypenameToSelectionSet"; import fs from "fs" import {parse, visit, print, OperationDefinitionNode, FragmentDefinitionNode, FragmentSpreadNode, DocumentNode} from "graphql" +import { removeClientFields } from "./removeClientFields"; /** * Take a whole bunch of GraphQL in one big string @@ -68,7 +69,7 @@ function prepareProject(filenames: string[], addTypename: boolean) { // Find the dependencies, build the accumulator ast = visit(ast, visitor) - + ast = removeClientFields(ast) // For each operation, build a separate document of that operation and its deps // then print the new document to a string var operations = allOperationNames.map(function(operationName) { diff --git a/javascript_client/src/sync/removeClientFields.ts b/javascript_client/src/sync/removeClientFields.ts new file mode 100644 index 00000000000..bad65bc518a --- /dev/null +++ b/javascript_client/src/sync/removeClientFields.ts @@ -0,0 +1,123 @@ +import { parse, DocumentNode, VariableDefinitionNode, print, visit } from "graphql" + +function removeClientFields(node: DocumentNode) { + // Deleting fields can create invalid documents: + // - If variables were used by those fields (or their subfields), then their definitions are invalid + // - If a fragment contained only deleted fields, it is now empty and therefore invalid and should be deleted + // - If a fragment spread names a deleted fragment, it is now invalid + // - If a client field contained a fragment spread and it's deleted, then a fragment may be left unspread + + let anythingWasRemoved = false + const usedVariables: string[] = [] + let definedFragments: string[] = [] + let spreadFragments: string[] = [] + + // First pass: remove as much as possible, even if the document is left invalid. + // - remove fields that have @client + // - remove fragment definitions that become empty + let newDoc = visit(node, { + Field: { + enter: (node) => { + if (node.directives && node.directives.some((d) => { return d.name.value === "client" })) { + anythingWasRemoved = true + // Delete this node + return null + } else { + return undefined + } + } + }, + // FragmentSpread: ... Don't do this now, because we might find some in Fragment Definitions that are deleted later. + FragmentDefinition: { + leave: (node) => { + if (node.selectionSet.selections.length == 0) { + // All the fields in this fragment were removed + return null + } else { + definedFragments.push(node.name.value) + return undefined + } + } + }, + FragmentSpread: { + enter: (node) => { + spreadFragments.push(node.name.value) + } + }, + Variable: { + enter: (node, _key, parent) => { + if ((parent as VariableDefinitionNode).kind !== 'VariableDefinition') { + // This will only find variables that are used _after_ `@client` fields are deleted. + // (If `@client` fields are deleted, then their arguments aren't visited) + usedVariables.push(node.name.value) + } + }, + }, + }) + + if (anythingWasRemoved) { + // At this point, we can remove variables that aren't used. + newDoc = visit(newDoc, { + VariableDefinition: { + enter: (node) => { + if (!usedVariables.includes(node.variable.name.value)) { + return null + } else { + return undefined + } + } + }, + }) + + // Then, remove spreads of empty fragment definitions as long as we keep finding them + // Also remove definitions of fragments that aren't spread anymore + while (anythingWasRemoved) { + let previouslyDefinedFragments = definedFragments + let previouslySpreadFragments = spreadFragments + definedFragments = [] + spreadFragments = [] + anythingWasRemoved = false + newDoc = visit(newDoc, { + FragmentSpread: { + enter: (node) => { + if (!previouslyDefinedFragments.includes(node.name.value)) { + anythingWasRemoved = true + return null + } else { + spreadFragments.push(node.name.value) + return undefined + } + } + }, + FragmentDefinition: { + enter: (node) => { + if (node.selectionSet.selections.length == 0 || !previouslySpreadFragments.includes(node.name.value)) { + anythingWasRemoved = true + return null + } else { + definedFragments.push(node.name.value) + return undefined + } + } + } + }) + } + } + + return newDoc +} + +function removeClientFieldsFromString(body: string): string { + if (body.includes("@client")) { + const ast = parse(body) + const newAst = removeClientFields(ast) + return print(newAst) + } else { + return body + } +} + +export { + removeClientFields, + removeClientFieldsFromString +} diff --git a/javascript_client/src/sync/sendPayload.ts b/javascript_client/src/sync/sendPayload.ts index 643f03cdb38..6c082f6406a 100644 --- a/javascript_client/src/sync/sendPayload.ts +++ b/javascript_client/src/sync/sendPayload.ts @@ -2,12 +2,15 @@ import http from "http" import https from "https" import url from "url" import crypto from 'crypto' +import Logger from './logger' interface SendPayloadOptions { url: string, + logger: Logger, secret?: string, client?: string, - verbose?: boolean + headers?: { [key: string]: string }, + changesetVersion?: string, } /** * Use HTTP POST to send this payload to the endpoint. @@ -19,14 +22,15 @@ interface SendPayloadOptions { * @param {String} options.url - Target URL * @param {String} options.secret - (optional) used for HMAC header if provided * @param {String} options.client - (optional) used for HMAC header if provided - * @param {Boolean} options.verbose - (optional) if true, print extra info for debugging + * @param {Logger} options.logger - A logger for when `verbose` is true + * @param {Object} options.headers - (optional) extra headers for the request * @return {Promise} */ function sendPayload(payload: any, options: SendPayloadOptions) { var syncUrl = options.url var key = options.secret var clientName = options.client - var verbose = options.verbose + var logger = options.logger // Prepare JS object as form data var postData = JSON.stringify(payload) @@ -39,6 +43,12 @@ function sendPayload(payload: any, options: SendPayloadOptions) { 'Content-Length': Buffer.byteLength(postData).toString() } + if (options.changesetVersion) { + logger.log("Changeset Version: ", logger.bright(options.changesetVersion)) + defaultHeaders["Changeset-Version"] = options.changesetVersion + } + var allHeaders = Object.assign({}, options.headers, defaultHeaders) + var httpOptions = { protocol: parsedURL.protocol, hostname: parsedURL.hostname, @@ -46,7 +56,7 @@ function sendPayload(payload: any, options: SendPayloadOptions) { path: parsedURL.path, auth: parsedURL.auth, method: 'POST', - headers: defaultHeaders, + headers: allHeaders, }; // If an auth key was provided, add a HMAC header @@ -56,13 +66,16 @@ function sendPayload(payload: any, options: SendPayloadOptions) { .update(postData) .digest('hex') var header = "GraphQL::Pro " + clientName + " " + authDigest - if (verbose) { - console.log("[Sync] Header: ", header) - console.log("[Sync] Data:", postData) - } httpOptions.headers["Authorization"] = header } + var headerNames = Object.keys(httpOptions.headers) + logger.log("[Sync] " + headerNames.length + " Headers:") + headerNames.forEach((headerName) => { + logger.log("[Sync] " + headerName + ": " + httpOptions.headers[headerName]) + }) + logger.log("[Sync] Data:", postData) + var httpClient = parsedURL.protocol === "https:" ? https : http var promise = new Promise(function(resolve, reject) { // Make the request, @@ -76,10 +89,8 @@ function sendPayload(payload: any, options: SendPayloadOptions) { }); res.on("end", () => { - if (verbose) { - console.log("[Sync] Response Headers: ", res.headers) - console.log("[Sync] Response Body: ", body) - } + logger.log("[Sync] Response Headers: ", JSON.stringify(res.headers)) + logger.log("[Sync] Response Body: ", body) var status = res.statusCode // 422 gets special treatment because diff --git a/javascript_client/tsconfig.esm.json b/javascript_client/tsconfig.esm.json new file mode 100644 index 00000000000..6187a1e50bc --- /dev/null +++ b/javascript_client/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.js", "src/**/__tests__/**"], + "compilerOptions": { + "module": "ES2020", + "moduleResolution": "node", + "outDir": "./esm" + } +} diff --git a/javascript_client/tsconfig.json b/javascript_client/tsconfig.json index 807b147413c..88634cf02f9 100644 --- a/javascript_client/tsconfig.json +++ b/javascript_client/tsconfig.json @@ -4,7 +4,7 @@ "compilerOptions": { /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ - "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ + "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ // "lib": [], /* Specify library files to be included in the compilation. */ // "allowJs": true, /* Allow javascript files to be compiled. */ diff --git a/lib/generators/graphql/core.rb b/lib/generators/graphql/core.rb index 42c12e57297..09628a59567 100644 --- a/lib/generators/graphql/core.rb +++ b/lib/generators/graphql/core.rb @@ -19,17 +19,12 @@ def insert_root_type(type, name) sentinel = /< GraphQL::Schema\s*\n/m in_root do - inject_into_file schema_file_path, " #{type}(Types::#{name})\n", after: sentinel, verbose: false, force: false + if File.exist?(schema_file_path) + inject_into_file schema_file_path, " #{type}(Types::#{name})\n", after: sentinel, verbose: false, force: false + end end end - def create_mutation_root_type - create_dir("#{options[:directory]}/mutations") - template("base_mutation.erb", "#{options[:directory]}/mutations/base_mutation.rb", { skip: true }) - template("mutation_type.erb", "#{options[:directory]}/types/mutation_type.rb", { skip: true }) - insert_root_type('mutation', 'MutationType') - end - def schema_file_path "#{options[:directory]}/#{schema_name.underscore}.rb" end diff --git a/lib/generators/graphql/detailed_trace_generator.rb b/lib/generators/graphql/detailed_trace_generator.rb new file mode 100644 index 00000000000..10139da42c2 --- /dev/null +++ b/lib/generators/graphql/detailed_trace_generator.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true +require 'rails/generators/active_record' + +module Graphql + module Generators + class DetailedTraceGenerator < ::Rails::Generators::Base + include ::Rails::Generators::Migration + desc "Install GraphQL::Tracing::DetailedTrace for your schema" + source_root File.expand_path('../templates', __FILE__) + + class_option :redis, + type: :boolean, + default: false, + desc: "Use Redis for persistence instead of ActiveRecord" + + def self.next_migration_number(dirname) + ::ActiveRecord::Generators::Base.next_migration_number(dirname) + end + + def install_detailed_traces + + schema_glob = File.expand_path("app/graphql/*_schema.rb", destination_root) + schema_file = Dir.glob(schema_glob).first + if !schema_file + raise ArgumentError, "Failed to find schema definition file (checked: #{schema_glob.inspect})" + end + schema_file_match = /( *)class ([A-Za-z:]+) < GraphQL::Schema/.match(File.read(schema_file)) + schema_name = schema_file_match[2] + indent = schema_file_match[1] + " " + + if !options.redis? + migration_template 'create_graphql_detailed_traces.erb', 'db/migrate/create_graphql_detailed_traces.rb' + end + + log :add_detailed_traces_plugin + sentinel = /< GraphQL::Schema\s*\n/m + code = <<-RUBY +#{indent}use GraphQL::Tracing::DetailedTrace#{options.redis? ? ", redis: raise(\"TODO: pass a connection to a persistent redis database\")" : ""}, limit: 50 + +#{indent}# When this returns true, DetailedTrace will trace the query +#{indent}# Could use `query.context`, `query.selected_operation_name`, `query.query_string` here +#{indent}# Could call out to Flipper, etc +#{indent}def self.detailed_trace?(query) +#{indent} rand <= 0.000_1 # one in ten thousand +#{indent}end + + RUBY + + in_root do + inject_into_file schema_file, code, after: sentinel, force: false + end + + routes_source = File.read(File.expand_path("config/routes.rb", destination_root)) + already_has_dashboard = routes_source.include?("GraphQL::Dashboard") || + routes_source.include?("Schema.dashboard") || + routes_source.include?("GraphQL::Pro::Routes::Lazy") + + if (!already_has_dashboard || behavior == :revoke) + log :route, "GraphQL::Dashboard" + shell.mute do + route <<~RUBY + # TODO: add authorization to this route and expose it in production + # See https://graphql-ruby.org/pro/dashboard.html#authorizing-the-dashboard + if Rails.env.development? + mount GraphQL::Dashboard, at: "/graphql/dashboard", schema: #{schema_name.inspect} + end + + RUBY + end + + gem("google-protobuf") + + end + end + end + end +end diff --git a/lib/generators/graphql/enum_generator.rb b/lib/generators/graphql/enum_generator.rb index 547822ac0cf..50685b36e5f 100644 --- a/lib/generators/graphql/enum_generator.rb +++ b/lib/generators/graphql/enum_generator.rb @@ -13,20 +13,14 @@ class EnumGenerator < TypeGeneratorBase desc "Create a GraphQL::EnumType with the given name and values" source_root File.expand_path('../templates', __FILE__) - argument :values, - type: :array, - default: [], - banner: "value{:ruby_value} value{:ruby_value} ...", - desc: "Values for this enum (if present, ruby_value will be inserted verbatim)" + private - def create_type_file - template "enum.erb", "#{options[:directory]}/types/#{type_file_name}.rb" + def graphql_type + "enum" end - private - def prepared_values - values.map { |v| v.split(":", 2) } + custom_fields.map { |v| v.split(":", 2) } end end end diff --git a/lib/generators/graphql/field_extractor.rb b/lib/generators/graphql/field_extractor.rb new file mode 100644 index 00000000000..d2cfb614a37 --- /dev/null +++ b/lib/generators/graphql/field_extractor.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +require 'rails/generators/base' + +module Graphql + module Generators + module FieldExtractor + def fields + columns = [] + if (model_columns = klass&.columns) + filter = if defined?(ActiveSupport::ParameterFilter) + fp = if defined?(Rails) && Rails.application && (app_config = Rails.application.config.filter_parameters).present? && !app_config.empty? + app_config + elsif ActiveSupport.respond_to?(:filter_parameters) + ActiveSupport.filter_parameters + else + [] + end + ActiveSupport::ParameterFilter.new(fp, mask: nil) + else + nil + end + columns += model_columns + .select { |c| filter ? filter.filter_param(c.name, c.name) : true } + .map { |c| generate_column_string(c) } + end + columns + custom_fields + end + + def generate_column_string(column) + name = column.name + required = column.null ? "" : "!" + type = column_type_string(column) + "#{name}:#{required}#{type}" + end + + def column_type_string(column) + column.name == "id" ? "ID" : column.type.to_s.camelize + end + + def klass + @klass ||= Module.const_get(name.camelize) + rescue NameError + @klass = nil + end + end + end +end diff --git a/lib/generators/graphql/input_generator.rb b/lib/generators/graphql/input_generator.rb new file mode 100644 index 00000000000..ac6d3674bf3 --- /dev/null +++ b/lib/generators/graphql/input_generator.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +require 'generators/graphql/type_generator' +require 'generators/graphql/field_extractor' + +module Graphql + module Generators + # Generate an input type by name, + # with the specified fields. + # + # ``` + # rails g graphql:object PostType name:string! + # ``` + class InputGenerator < TypeGeneratorBase + desc "Create a GraphQL::InputObjectType with the given name and fields" + source_root File.expand_path('../templates', __FILE__) + include FieldExtractor + + def self.normalize_type_expression(type_expression, mode:, null: true) + case type_expression.camelize + when "Text", "Citext" + ["String", null] + when "Decimal" + ["Float", null] + when "DateTime", "Datetime" + ["GraphQL::Types::ISO8601DateTime", null] + when "Date" + ["GraphQL::Types::ISO8601Date", null] + when "Json", "Jsonb", "Hstore" + ["GraphQL::Types::JSON", null] + else + super + end + end + + private + + def graphql_type + "input" + end + + def type_ruby_name + super.gsub(/Type\z/, "InputType") + end + + def type_file_name + super.gsub(/_type\z/, "_input_type") + end + end + end +end diff --git a/lib/generators/graphql/install/mutation_root_generator.rb b/lib/generators/graphql/install/mutation_root_generator.rb new file mode 100644 index 00000000000..5b506bafc15 --- /dev/null +++ b/lib/generators/graphql/install/mutation_root_generator.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails/generators/base" +require_relative "../core" + +module Graphql + module Generators + module Install + class MutationRootGenerator < Rails::Generators::Base + include Core + + desc "Create mutation base type, mutation root type, and adds the latter to the schema" + source_root File.expand_path('../templates', __FILE__) + + class_option :schema, + type: :string, + default: nil, + desc: "Name for the schema constant (default: {app_name}Schema)" + + class_option :skip_keeps, + type: :boolean, + default: false, + desc: "Skip .keep files for source control" + + def generate + create_dir("#{options[:directory]}/mutations") + template("base_mutation.erb", "#{options[:directory]}/mutations/base_mutation.rb", { skip: true }) + template("mutation_type.erb", "#{options[:directory]}/types/mutation_type.rb", { skip: true }) + insert_root_type('mutation', 'MutationType') + end + end + end + end +end diff --git a/lib/generators/graphql/templates/base_mutation.erb b/lib/generators/graphql/install/templates/base_mutation.erb similarity index 90% rename from lib/generators/graphql/templates/base_mutation.erb rename to lib/generators/graphql/install/templates/base_mutation.erb index 36b4c2d5f66..4bb26d978ca 100644 --- a/lib/generators/graphql/templates/base_mutation.erb +++ b/lib/generators/graphql/install/templates/base_mutation.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Mutations class BaseMutation < GraphQL::Schema::RelayClassicMutation diff --git a/lib/generators/graphql/templates/mutation_type.erb b/lib/generators/graphql/install/templates/mutation_type.erb similarity index 90% rename from lib/generators/graphql/templates/mutation_type.erb rename to lib/generators/graphql/install/templates/mutation_type.erb index 6377b91a1d2..84fb4d3ea30 100644 --- a/lib/generators/graphql/templates/mutation_type.erb +++ b/lib/generators/graphql/install/templates/mutation_type.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types class MutationType < Types::BaseObject diff --git a/lib/generators/graphql/install_generator.rb b/lib/generators/graphql/install_generator.rb index 46e939c8113..e93a15f9901 100644 --- a/lib/generators/graphql/install_generator.rb +++ b/lib/generators/graphql/install_generator.rb @@ -45,9 +45,16 @@ module Generators # post "/graphql", to: "graphql#execute" # ``` # + # Add ActiveRecord::QueryLogs metadata: + # ```ruby + # current_graphql_operation: -> { GraphQL::Current.operation_name }, + # current_graphql_field: -> { GraphQL::Current.field&.path }, + # current_dataloader_source: -> { GraphQL::Current.dataloader_source_class }, + # ``` + # # Accept a `--batch` option which adds `GraphQL::Batch` setup. # - # Use `--no-graphiql` to skip `graphiql-rails` installation. + # Use `--skip-graphiql` to skip `graphiql-rails` installation. # # TODO: also add base classes class InstallGenerator < Rails::Generators::Base @@ -92,6 +99,11 @@ class InstallGenerator < Rails::Generators::Base default: false, desc: "Use GraphQL Playground over Graphiql as IDE" + class_option :skip_query_logs, + type: :boolean, + default: false, + desc: "Skip ActiveRecord::QueryLogs hooks in config/application.rb" + # These two options are taken from Rails' own generators' class_option :api, type: :boolean, @@ -105,11 +117,14 @@ def create_folder_structure template("#{base_type}.erb", "#{options[:directory]}/types/#{base_type}.rb") end + # All resolvers are defined as living in their own module, including this class. + template("base_resolver.erb", "#{options[:directory]}/resolvers/base_resolver.rb") + # Note: You can't have a schema without the query type, otherwise introspection breaks template("query_type.erb", "#{options[:directory]}/types/query_type.rb") insert_root_type('query', 'QueryType') - create_mutation_root_type unless options.skip_mutation_root_type? + invoke "graphql:install:mutation_root" unless options.skip_mutation_root_type? template("graphql_controller.erb", "app/controllers/graphql_controller.rb") route('post "/graphql", to: "graphql#execute"') @@ -122,8 +137,15 @@ def create_folder_structure if options.api? say("Skipped graphiql, as this rails project is API only") say(" You may wish to use GraphiQL.app for development: https://github.com/skevy/graphiql-app") - elsif !options[:skip_graphiql] && !File.read(Rails.root.join("Gemfile")).include?("graphiql-rails") - gem("graphiql-rails", group: :development) + elsif !options[:skip_graphiql] + # `gem(...)` uses `gsub_file(...)` under the hood, which is a no-op for `rails destroy...` (when `behavior == :revoke`). + # So handle that case by calling `gsub_file` with `force: true`. + if behavior == :invoke && !File.read(Rails.root.join("Gemfile")).include?("graphiql-rails") + gem("graphiql-rails", group: :development) + elsif behavior == :revoke + gemfile_pattern = /\n\s*gem ('|")graphiql-rails('|"), :?group(:| =>) :development/ + gsub_file Rails.root.join("Gemfile"), gemfile_pattern, "", { force: true } + end # This is a little cheat just to get cleaner shell output: log :route, 'graphiql-rails' @@ -170,6 +192,40 @@ def create_folder_structure install_relay end + if !options[:skip_query_logs] + config_file = "config/application.rb" + current_app_rb = File.read(Rails.root.join(config_file)) + existing_log_tags_pattern = /config.active_record.query_log_tags = \[\n?(\s*:[a-z_]+,?\s*\n?|\s*#[^\]]*\n)*/m + existing_log_tags = existing_log_tags_pattern.match(current_app_rb) + if existing_log_tags && behavior == :invoke + code = <<-RUBY + # GraphQL-Ruby query log tags: + current_graphql_operation: -> { GraphQL::Current.operation_name }, + current_graphql_field: -> { GraphQL::Current.field&.path }, + current_dataloader_source: -> { GraphQL::Current.dataloader_source_class }, +RUBY + if !existing_log_tags.to_s.end_with?(",") + code = ",\n#{code} " + end + # Try to insert this code _after_ any plain symbol entries in the array of query log tags: + after_code = existing_log_tags_pattern + else + code = <<-RUBY + config.active_record.query_log_tags_enabled = true + config.active_record.query_log_tags = [ + # Rails query log tags: + :application, :controller, :action, :job, + # GraphQL-Ruby query log tags: + current_graphql_operation: -> { GraphQL::Current.operation_name }, + current_graphql_field: -> { GraphQL::Current.field&.path }, + current_dataloader_source: -> { GraphQL::Current.dataloader_source_class }, + ] +RUBY + after_code = "class Application < Rails::Application\n" + end + insert_into_file(config_file, code, after: after_code) + end + if gemfile_modified? say "Gemfile has been modified, make sure you `bundle install`" end diff --git a/lib/generators/graphql/interface_generator.rb b/lib/generators/graphql/interface_generator.rb index 4d0611eb87c..9828740017c 100644 --- a/lib/generators/graphql/interface_generator.rb +++ b/lib/generators/graphql/interface_generator.rb @@ -13,14 +13,14 @@ class InterfaceGenerator < TypeGeneratorBase desc "Create a GraphQL::InterfaceType with the given name and fields" source_root File.expand_path('../templates', __FILE__) - argument :fields, - type: :array, - default: [], - banner: "name:type name:type ...", - desc: "Fields for this interface (type may be expressed as Ruby or GraphQL)" + private - def create_type_file - template "interface.erb", "#{options[:directory]}/types/#{type_file_name}.rb" + def graphql_type + "interface" + end + + def fields + custom_fields end end end diff --git a/lib/generators/graphql/mutation_create_generator.rb b/lib/generators/graphql/mutation_create_generator.rb new file mode 100644 index 00000000000..049cb7284d2 --- /dev/null +++ b/lib/generators/graphql/mutation_create_generator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require_relative 'orm_mutations_base' + +module Graphql + module Generators + # TODO: What other options should be supported? + # + # @example Generate a `GraphQL::Schema::RelayClassicMutation` by name + # rails g graphql:mutation CreatePostMutation + class MutationCreateGenerator < OrmMutationsBase + + desc "Scaffold a Relay Classic ORM create mutation for the given model class" + source_root File.expand_path('../templates', __FILE__) + + private + + def operation_type + "create" + end + end + end +end diff --git a/lib/generators/graphql/mutation_delete_generator.rb b/lib/generators/graphql/mutation_delete_generator.rb new file mode 100644 index 00000000000..bf33a7606b6 --- /dev/null +++ b/lib/generators/graphql/mutation_delete_generator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require_relative 'orm_mutations_base' + +module Graphql + module Generators + # TODO: What other options should be supported? + # + # @example Generate a `GraphQL::Schema::RelayClassicMutation` by name + # rails g graphql:mutation DeletePostMutation + class MutationDeleteGenerator < OrmMutationsBase + + desc "Scaffold a Relay Classic ORM delete mutation for the given model class" + source_root File.expand_path('../templates', __FILE__) + + private + + def operation_type + "delete" + end + end + end +end diff --git a/lib/generators/graphql/mutation_generator.rb b/lib/generators/graphql/mutation_generator.rb index fb7e49fcbbe..5eebd2adaa1 100644 --- a/lib/generators/graphql/mutation_generator.rb +++ b/lib/generators/graphql/mutation_generator.rb @@ -9,47 +9,22 @@ module Generators # # @example Generate a `GraphQL::Schema::RelayClassicMutation` by name # rails g graphql:mutation CreatePostMutation - class MutationGenerator < Rails::Generators::Base + class MutationGenerator < Rails::Generators::NamedBase include Core desc "Create a Relay Classic mutation by name" source_root File.expand_path('../templates', __FILE__) - argument :name, type: :string - - def initialize(args, *options) #:nodoc: - # Unfreeze name in case it's given as a frozen string - args[0] = args[0].dup if args[0].is_a?(String) && args[0].frozen? - super - - assign_names!(name) - end - - attr_reader :file_name, :mutation_name, :field_name - def create_mutation_file - unless @behavior == :revoke - create_mutation_root_type - else - log :gsub, "#{options[:directory]}/types/mutation_type.rb" - end - - template "mutation.erb", "#{options[:directory]}/mutations/#{file_name}.rb" + template "mutation.erb", File.join(options[:directory], "/mutations/", class_path, "#{file_name}.rb") sentinel = /class .*MutationType\s*<\s*[^\s]+?\n/m in_root do - gsub_file "#{options[:directory]}/types/mutation_type.rb", / \# TODO\: Add Mutations as fields\s*\n/m, "" - inject_into_file "#{options[:directory]}/types/mutation_type.rb", " field :#{field_name}, mutation: Mutations::#{mutation_name}\n", after: sentinel, verbose: false, force: false + path = "#{options[:directory]}/types/mutation_type.rb" + invoke "graphql:install:mutation_root" unless File.exist?(path) + inject_into_file "#{options[:directory]}/types/mutation_type.rb", " field :#{file_name}, mutation: Mutations::#{class_name}\n", after: sentinel, verbose: false, force: false end end - - private - - def assign_names!(name) - @field_name = name.camelize.underscore - @mutation_name = name.camelize(:upper) - @file_name = name.camelize.underscore - end end end end diff --git a/lib/generators/graphql/mutation_update_generator.rb b/lib/generators/graphql/mutation_update_generator.rb new file mode 100644 index 00000000000..6200dcc570a --- /dev/null +++ b/lib/generators/graphql/mutation_update_generator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require_relative 'orm_mutations_base' + +module Graphql + module Generators + # TODO: What other options should be supported? + # + # @example Generate a `GraphQL::Schema::RelayClassicMutation` by name + # rails g graphql:mutation UpdatePostMutation + class MutationUpdateGenerator < OrmMutationsBase + + desc "Scaffold a Relay Classic ORM update mutation for the given model class" + source_root File.expand_path('../templates', __FILE__) + + private + + def operation_type + "update" + end + end + end +end diff --git a/lib/generators/graphql/object_generator.rb b/lib/generators/graphql/object_generator.rb index 45b1aefaf9f..3cebb232670 100644 --- a/lib/generators/graphql/object_generator.rb +++ b/lib/generators/graphql/object_generator.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true require 'generators/graphql/type_generator' +require 'generators/graphql/field_extractor' module Graphql module Generators @@ -12,33 +13,19 @@ module Generators # # Add the Node interface with `--node`. class ObjectGenerator < TypeGeneratorBase - desc "Create a GraphQL::ObjectType with the given name and fields" + desc "Create a GraphQL::ObjectType with the given name and fields." \ + "If the given type name matches an existing ActiveRecord model, the generated type will automatically include fields for the models database columns." source_root File.expand_path('../templates', __FILE__) - - argument :custom_fields, - type: :array, - default: [], - banner: "name:type name:type ...", - desc: "Fields for this object (type may be expressed as Ruby or GraphQL)" + include FieldExtractor class_option :node, type: :boolean, default: false, desc: "Include the Relay Node interface" - def create_type_file - template "object.erb", "#{options[:directory]}/types/#{type_file_name}.rb" - end - - def fields - columns = [] - columns += klass.columns.map { |c| generate_column_string(c) } if class_exists? - columns + custom_fields - end - def self.normalize_type_expression(type_expression, mode:, null: true) - case type_expression - when "Text" + case type_expression.camelize + when "Text", "Citext" ["String", null] when "Decimal" ["Float", null] @@ -46,6 +33,8 @@ def self.normalize_type_expression(type_expression, mode:, null: true) ["GraphQL::Types::ISO8601DateTime", null] when "Date" ["GraphQL::Types::ISO8601Date", null] + when "Json", "Jsonb", "Hstore" + ["GraphQL::Types::JSON", null] else super end @@ -53,25 +42,8 @@ def self.normalize_type_expression(type_expression, mode:, null: true) private - def generate_column_string(column) - name = column.name - required = column.null ? "" : "!" - type = column_type_string(column) - "#{name}:#{required}#{type}" - end - - def column_type_string(column) - column.name == "id" ? "ID" : column.type.to_s.camelize - end - - def class_exists? - klass.is_a?(Class) && klass.ancestors.include?(ActiveRecord::Base) - rescue NameError - return false - end - - def klass - @klass ||= Module.const_get(type_name.camelize) + def graphql_type + "object" end end end diff --git a/lib/generators/graphql/orm_mutations_base.rb b/lib/generators/graphql/orm_mutations_base.rb new file mode 100644 index 00000000000..74a35d163fb --- /dev/null +++ b/lib/generators/graphql/orm_mutations_base.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +require 'rails/generators' +require 'rails/generators/named_base' +require_relative 'core' + +module Graphql + module Generators + # TODO: What other options should be supported? + # + # @example Generate a `GraphQL::Schema::RelayClassicMutation` by name + # rails g graphql:mutation CreatePostMutation + class OrmMutationsBase < Rails::Generators::NamedBase + include Core + include Rails::Generators::ResourceHelpers + + desc "Create a Relay Classic mutation by name" + + class_option :orm, banner: "NAME", type: :string, required: true, + desc: "ORM to generate the controller for" + + class_option :namespaced_types, + type: :boolean, + required: false, + default: false, + banner: "Namespaced", + desc: "If the generated types will be namespaced" + + def create_mutation_file + template "mutation_#{operation_type}.erb", File.join(options[:directory], "/mutations/", class_path, "#{file_name}_#{operation_type}.rb") + + sentinel = /class .*MutationType\s*<\s*[^\s]+?\n/m + in_root do + path = "#{options[:directory]}/types/mutation_type.rb" + invoke "graphql:install:mutation_root" unless File.exist?(path) + inject_into_file "#{options[:directory]}/types/mutation_type.rb", " field :#{file_name}_#{operation_type}, mutation: Mutations::#{class_name}#{operation_type.classify}\n", after: sentinel, verbose: false, force: false + end + end + end + end +end diff --git a/lib/generators/graphql/relay.rb b/lib/generators/graphql/relay.rb index 0f135fd1e8e..86ab567fc68 100644 --- a/lib/generators/graphql/relay.rb +++ b/lib/generators/graphql/relay.rb @@ -6,7 +6,24 @@ def install_relay # Add Node, `node(id:)`, and `nodes(ids:)` template("node_type.erb", "#{options[:directory]}/types/node_type.rb") in_root do - fields = " # Add `node(id: ID!) and `nodes(ids: [ID!]!)`\n include GraphQL::Types::Relay::HasNodeField\n include GraphQL::Types::Relay::HasNodesField\n\n" + fields = <<-RUBY + field :node, Types::NodeType, null: true, description: "Fetches an object given its ID." do + argument :id, ID, required: true, description: "ID of the object." + end + + def node(id:) + context.schema.object_from_id(id, context) + end + + field :nodes, [Types::NodeType, null: true], null: true, description: "Fetches a list of objects given a list of IDs." do + argument :ids, [ID], required: true, description: "IDs of the objects." + end + + def nodes(ids:) + ids.map { |id| context.schema.object_from_id(id, context) } + end + + RUBY inject_into_file "#{options[:directory]}/types/query_type.rb", fields, after: /class .*QueryType\s*<\s*[^\s]+?\n/m, force: false end @@ -32,20 +49,14 @@ def install_relay # Return a string UUID for `object` def self.id_from_object(object, type_definition, query_ctx) - # Here's a simple implementation which: - # - joins the type name & object.id - # - encodes it with base64: - # GraphQL::Schema::UniqueWithinType.encode(type_definition.name, object.id) + # For example, use Rails' GlobalID library (https://github.com/rails/globalid): + object.to_gid_param end # Given a string UUID, find the object - def self.object_from_id(id, query_ctx) - # For example, to decode the UUIDs generated above: - # type_name, item_id = GraphQL::Schema::UniqueWithinType.decode(id) - # - # Then, based on `type_name` and `id` - # find an object in your application - # ... + def self.object_from_id(global_id, query_ctx) + # For example, use Rails' GlobalID library (https://github.com/rails/globalid): + GlobalID.find(global_id) end RUBY inject_into_file schema_file_path, schema_code, before: /^end\n/m, force: false diff --git a/lib/generators/graphql/scalar_generator.rb b/lib/generators/graphql/scalar_generator.rb index b3e3b46f2d4..0107dc803e8 100644 --- a/lib/generators/graphql/scalar_generator.rb +++ b/lib/generators/graphql/scalar_generator.rb @@ -12,8 +12,10 @@ class ScalarGenerator < TypeGeneratorBase desc "Create a GraphQL::ScalarType with the given name" source_root File.expand_path('../templates', __FILE__) - def create_type_file - template "scalar.erb", "#{options[:directory]}/types/#{type_file_name}.rb" + private + + def graphql_type + "scalar" end end end diff --git a/lib/generators/graphql/templates/base_argument.erb b/lib/generators/graphql/templates/base_argument.erb index 40d6a386533..e8519cd0c71 100644 --- a/lib/generators/graphql/templates/base_argument.erb +++ b/lib/generators/graphql/templates/base_argument.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types class BaseArgument < GraphQL::Schema::Argument diff --git a/lib/generators/graphql/templates/base_connection.erb b/lib/generators/graphql/templates/base_connection.erb index 9a0b4156220..c98c60dc471 100644 --- a/lib/generators/graphql/templates/base_connection.erb +++ b/lib/generators/graphql/templates/base_connection.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types class BaseConnection < Types::BaseObject diff --git a/lib/generators/graphql/templates/base_edge.erb b/lib/generators/graphql/templates/base_edge.erb index d6e24ae5a8a..a8ae98d27cd 100644 --- a/lib/generators/graphql/templates/base_edge.erb +++ b/lib/generators/graphql/templates/base_edge.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types class BaseEdge < Types::BaseObject diff --git a/lib/generators/graphql/templates/base_enum.erb b/lib/generators/graphql/templates/base_enum.erb index a0a81e3e01d..22804640ef0 100644 --- a/lib/generators/graphql/templates/base_enum.erb +++ b/lib/generators/graphql/templates/base_enum.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types class BaseEnum < GraphQL::Schema::Enum diff --git a/lib/generators/graphql/templates/base_field.erb b/lib/generators/graphql/templates/base_field.erb index 041620e9101..aad5e3aefc5 100644 --- a/lib/generators/graphql/templates/base_field.erb +++ b/lib/generators/graphql/templates/base_field.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types class BaseField < GraphQL::Schema::Field diff --git a/lib/generators/graphql/templates/base_input_object.erb b/lib/generators/graphql/templates/base_input_object.erb index 10cdea07ac3..1aeb7011ca4 100644 --- a/lib/generators/graphql/templates/base_input_object.erb +++ b/lib/generators/graphql/templates/base_input_object.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types class BaseInputObject < GraphQL::Schema::InputObject diff --git a/lib/generators/graphql/templates/base_interface.erb b/lib/generators/graphql/templates/base_interface.erb index 18e8153abd6..c096abe3bdd 100644 --- a/lib/generators/graphql/templates/base_interface.erb +++ b/lib/generators/graphql/templates/base_interface.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types module BaseInterface diff --git a/lib/generators/graphql/templates/base_object.erb b/lib/generators/graphql/templates/base_object.erb index e4c43990bf6..cb68e9b43a7 100644 --- a/lib/generators/graphql/templates/base_object.erb +++ b/lib/generators/graphql/templates/base_object.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types class BaseObject < GraphQL::Schema::Object diff --git a/lib/generators/graphql/templates/base_resolver.erb b/lib/generators/graphql/templates/base_resolver.erb new file mode 100644 index 00000000000..770c24cac7e --- /dev/null +++ b/lib/generators/graphql/templates/base_resolver.erb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +<% module_namespacing_when_supported do -%> +module Resolvers + class BaseResolver < GraphQL::Schema::Resolver + end +end +<% end -%> diff --git a/lib/generators/graphql/templates/base_scalar.erb b/lib/generators/graphql/templates/base_scalar.erb index f960da6eba3..ac291e2c118 100644 --- a/lib/generators/graphql/templates/base_scalar.erb +++ b/lib/generators/graphql/templates/base_scalar.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types class BaseScalar < GraphQL::Schema::Scalar diff --git a/lib/generators/graphql/templates/base_union.erb b/lib/generators/graphql/templates/base_union.erb index 0c65a2e6699..2793219c46c 100644 --- a/lib/generators/graphql/templates/base_union.erb +++ b/lib/generators/graphql/templates/base_union.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types class BaseUnion < GraphQL::Schema::Union diff --git a/lib/generators/graphql/templates/create_graphql_detailed_traces.erb b/lib/generators/graphql/templates/create_graphql_detailed_traces.erb new file mode 100644 index 00000000000..65cb16e304f --- /dev/null +++ b/lib/generators/graphql/templates/create_graphql_detailed_traces.erb @@ -0,0 +1,10 @@ +class CreateGraphqlDetailedTraces < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] + def change + create_table :graphql_detailed_traces, force: true do |t| + t.bigint :begin_ms, null: false + t.float :duration_ms, null: false + t.binary :trace_data, null: false + t.string :operation_name, null: false + end + end +end diff --git a/lib/generators/graphql/templates/enum.erb b/lib/generators/graphql/templates/enum.erb index a0f813aee26..8f607433afa 100644 --- a/lib/generators/graphql/templates/enum.erb +++ b/lib/generators/graphql/templates/enum.erb @@ -1,6 +1,10 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types - class <%= type_ruby_name.split('::')[-1] %> < Types::BaseEnum + class <%= ruby_class_name %> < Types::BaseEnum + description "<%= human_name %> enum" + <% prepared_values.each do |v| %> value "<%= v[0] %>"<%= v.length > 1 ? ", value: #{v[1]}" : "" %> <% end %> end end diff --git a/lib/generators/graphql/templates/graphql_controller.erb b/lib/generators/graphql/templates/graphql_controller.erb index d174356e0f6..379f0436873 100644 --- a/lib/generators/graphql/templates/graphql_controller.erb +++ b/lib/generators/graphql/templates/graphql_controller.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> class GraphqlController < ApplicationController # If accessing from outside this domain, nullify the session diff --git a/lib/generators/graphql/templates/input.erb b/lib/generators/graphql/templates/input.erb new file mode 100644 index 00000000000..371735f1155 --- /dev/null +++ b/lib/generators/graphql/templates/input.erb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +<% module_namespacing_when_supported do -%> +module Types + class <%= ruby_class_name %> < Types::BaseInputObject +<% normalized_fields.each do |f| %> <%= f.to_input_argument %> +<% end %> end +end +<% end -%> diff --git a/lib/generators/graphql/templates/interface.erb b/lib/generators/graphql/templates/interface.erb index 50b6e4c90f9..4c269d1290e 100644 --- a/lib/generators/graphql/templates/interface.erb +++ b/lib/generators/graphql/templates/interface.erb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types - module <%= type_ruby_name.split('::')[-1] %> + module <%= ruby_class_name %> include Types::BaseInterface -<% normalized_fields.each do |f| %> <%= f.to_ruby %> +<% normalized_fields.each do |f| %> <%= f.to_object_field %> <% end %> end end <% end -%> diff --git a/lib/generators/graphql/templates/loader.erb b/lib/generators/graphql/templates/loader.erb index 640427e8bee..face85eb773 100644 --- a/lib/generators/graphql/templates/loader.erb +++ b/lib/generators/graphql/templates/loader.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Loaders class <%= class_name %> < GraphQL::Batch::Loader diff --git a/lib/generators/graphql/templates/mutation.erb b/lib/generators/graphql/templates/mutation.erb index 7af6a236436..adf53ed3820 100644 --- a/lib/generators/graphql/templates/mutation.erb +++ b/lib/generators/graphql/templates/mutation.erb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Mutations - class <%= mutation_name %> < BaseMutation + class <%= class_name %> < BaseMutation # TODO: define return fields # field :post, Types::PostType, null: false diff --git a/lib/generators/graphql/templates/mutation_create.erb b/lib/generators/graphql/templates/mutation_create.erb new file mode 100644 index 00000000000..cb7cd5615bb --- /dev/null +++ b/lib/generators/graphql/templates/mutation_create.erb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +<% module_namespacing_when_supported do -%> +module Mutations + class <%= class_name %>Create < BaseMutation + description "Creates a new <%= file_name %>" + + field :<%= file_name %>, Types::<%= options[:namespaced_types] ? 'Objects::' : '' %><%= class_name %>Type, null: false + + argument :<%= file_name %>_input, Types::<%= options[:namespaced_types] ? 'Inputs::' : '' %><%= class_name %>InputType, required: true + + def resolve(<%= file_name %>_input:) + <%= singular_table_name %> = ::<%= orm_class.build(class_name, "**#{file_name}_input") %> + raise GraphQL::ExecutionError.new "Error creating <%= file_name %>", extensions: <%= singular_table_name %>.errors.to_hash unless <%= orm_instance.save %> + + { <%= file_name %>: <%= singular_table_name %> } + end + end +end +<% end -%> diff --git a/lib/generators/graphql/templates/mutation_delete.erb b/lib/generators/graphql/templates/mutation_delete.erb new file mode 100644 index 00000000000..53761e92410 --- /dev/null +++ b/lib/generators/graphql/templates/mutation_delete.erb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +<% module_namespacing_when_supported do -%> +module Mutations + class <%= class_name %>Delete < BaseMutation + description "Deletes a <%= file_name %> by ID" + + field :<%= file_name %>, Types::<%= options[:namespaced_types] ? 'Objects::' : '' %><%= class_name %>Type, null: false + + argument :id, ID, required: true + + def resolve(id:) + <%= singular_table_name %> = ::<%= orm_class.find(class_name, "id") %> + raise GraphQL::ExecutionError.new "Error deleting <%= file_name %>", extensions: <%= singular_table_name %>.errors.to_hash unless <%= orm_instance.destroy %> + + { <%= file_name %>: <%= singular_table_name %> } + end + end +end +<% end -%> diff --git a/lib/generators/graphql/templates/mutation_update.erb b/lib/generators/graphql/templates/mutation_update.erb new file mode 100644 index 00000000000..cf4469b05f3 --- /dev/null +++ b/lib/generators/graphql/templates/mutation_update.erb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +<% module_namespacing_when_supported do -%> +module Mutations + class <%= class_name %>Update < BaseMutation + description "Updates a <%= file_name %> by id" + + field :<%= file_name %>, Types::<%= options[:namespaced_types] ? 'Objects::' : '' %><%= class_name %>Type, null: false + + argument :id, ID, required: true + argument :<%= file_name %>_input, Types::<%= options[:namespaced_types] ? 'Inputs::' : '' %><%= class_name %>InputType, required: true + + def resolve(id:, <%= file_name %>_input:) + <%= singular_table_name %> = ::<%= orm_class.find(class_name, "id") %> + raise GraphQL::ExecutionError.new "Error updating <%= file_name %>", extensions: <%= singular_table_name %>.errors.to_hash unless <%= orm_instance.update("**#{file_name}_input") %> + + { <%= file_name %>: <%= singular_table_name %> } + end + end +end +<% end -%> diff --git a/lib/generators/graphql/templates/node_type.erb b/lib/generators/graphql/templates/node_type.erb index 1e17dd8a828..91e4d07d88a 100644 --- a/lib/generators/graphql/templates/node_type.erb +++ b/lib/generators/graphql/templates/node_type.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types module NodeType diff --git a/lib/generators/graphql/templates/object.erb b/lib/generators/graphql/templates/object.erb index 71d24eca178..8927419f387 100644 --- a/lib/generators/graphql/templates/object.erb +++ b/lib/generators/graphql/templates/object.erb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types - class <%= type_ruby_name.split('::')[-1] %> < Types::BaseObject + class <%= ruby_class_name %> < Types::BaseObject <% if options.node %> implements GraphQL::Types::Relay::Node -<% end %><% normalized_fields.each do |f| %> <%= f.to_ruby %> +<% end %><% normalized_fields.each do |f| %> <%= f.to_object_field %> <% end %> end end <% end -%> diff --git a/lib/generators/graphql/templates/query_type.erb b/lib/generators/graphql/templates/query_type.erb index 928a064cbc7..842a635ed59 100644 --- a/lib/generators/graphql/templates/query_type.erb +++ b/lib/generators/graphql/templates/query_type.erb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types class QueryType < Types::BaseObject diff --git a/lib/generators/graphql/templates/scalar.erb b/lib/generators/graphql/templates/scalar.erb index c4d82dcc5e8..f2ffc48b173 100644 --- a/lib/generators/graphql/templates/scalar.erb +++ b/lib/generators/graphql/templates/scalar.erb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types - class <%= type_ruby_name.split('::')[-1] %> < Types::BaseScalar + class <%= ruby_class_name %> < Types::BaseScalar def self.coerce_input(input_value, context) # Override this to prepare a client-provided GraphQL value for your Ruby code input_value diff --git a/lib/generators/graphql/templates/schema.erb b/lib/generators/graphql/templates/schema.erb index 38dfb3ce55b..a8e35154455 100644 --- a/lib/generators/graphql/templates/schema.erb +++ b/lib/generators/graphql/templates/schema.erb @@ -1,15 +1,36 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> class <%= schema_name %> < GraphQL::Schema query(Types::QueryType) <% if options[:batch] %> # GraphQL::Batch setup: use GraphQL::Batch +<% else %> + # For batch-loading (see https://graphql-ruby.org/dataloader/overview.html) + use GraphQL::Dataloader <% end %> + # GraphQL-Ruby calls this when something goes wrong while running a query: + def self.type_error(err, context) + # if err.is_a?(GraphQL::InvalidNullError) + # # report to your bug tracker here + # return nil + # end + super + end + # Union and Interface Resolution def self.resolve_type(abstract_type, obj, ctx) - # TODO: Implement this function - # to return the correct object type for `obj` + # TODO: Implement this method + # to return the correct GraphQL object type for `obj` raise(GraphQL::RequiredImplementationMissingError) end + + # Limit the depth and size of incoming queries: + max_depth(15) + max_query_string_tokens(5000) + + # Stop validating when it encounters this many errors: + validate_max_errors(100) end <% end -%> diff --git a/lib/generators/graphql/templates/union.erb b/lib/generators/graphql/templates/union.erb index f726859c93e..4b142ce9c7c 100644 --- a/lib/generators/graphql/templates/union.erb +++ b/lib/generators/graphql/templates/union.erb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + <% module_namespacing_when_supported do -%> module Types - class <%= type_ruby_name.split('::')[-1] %> < Types::BaseUnion -<% if possible_types.any? %> possible_types <%= normalized_possible_types.join(", ") %> + class <%= ruby_class_name %> < Types::BaseUnion +<% if custom_fields.any? %> possible_types <%= normalized_possible_types.join(", ") %> <% end %> end end <% end -%> diff --git a/lib/generators/graphql/type_generator.rb b/lib/generators/graphql/type_generator.rb index fc46e339bf9..55f207c532f 100644 --- a/lib/generators/graphql/type_generator.rb +++ b/lib/generators/graphql/type_generator.rb @@ -8,14 +8,28 @@ module Graphql module Generators - class TypeGeneratorBase < Rails::Generators::Base + class TypeGeneratorBase < Rails::Generators::NamedBase include Core - argument :type_name, - type: :string, - required: true, - banner: "TypeName", - desc: "Name of this object type (expressed as Ruby or GraphQL)" + class_option :namespaced_types, + type: :boolean, + required: false, + default: false, + banner: "Namespaced", + desc: "If the generated types will be namespaced" + + argument :custom_fields, + type: :array, + default: [], + banner: "name:type name:type ...", + desc: "Fields for this object (type may be expressed as Ruby or GraphQL)" + + + attr_accessor :graphql_type + + def create_type_file + template "#{graphql_type}.erb", "#{options[:directory]}/types#{subdirectory}/#{type_file_name}.rb" + end # Take a type expression in any combination of GraphQL or Ruby styles # and return it in a specified output style @@ -61,12 +75,12 @@ def self.normalize_type_expression(type_expression, mode:, null: true) # @return [String] The user-provided type name, normalized to Ruby code def type_ruby_name - @type_ruby_name ||= self.class.normalize_type_expression(type_name, mode: :ruby)[0] + @type_ruby_name ||= self.class.normalize_type_expression(name, mode: :ruby)[0] end # @return [String] The user-provided type name, as a GraphQL name def type_graphql_name - @type_graphql_name ||= self.class.normalize_type_expression(type_name, mode: :graphql)[0] + @type_graphql_name ||= self.class.normalize_type_expression(name, mode: :graphql)[0] end # @return [String] The user-provided type name, as a file name (without extension) @@ -83,6 +97,24 @@ def normalized_fields } end + def ruby_class_name + class_prefix = + if options[:namespaced_types] + "#{graphql_type.pluralize.camelize}::" + else + "" + end + @ruby_class_name || class_prefix + type_ruby_name.sub(/^Types::/, "") + end + + def subdirectory + if options[:namespaced_types] + "/#{graphql_type.pluralize}" + else + "" + end + end + class NormalizedField def initialize(name, type_expr, null) @name = name @@ -90,8 +122,12 @@ def initialize(name, type_expr, null) @null = null end - def to_ruby - "field :#{@name}, #{@type_expr}, null: #{@null}" + def to_object_field + "field :#{@name}, #{@type_expr}#{@null ? '' : ', null: false'}" + end + + def to_input_argument + "argument :#{@name}, #{@type_expr}, required: false" end end end diff --git a/lib/generators/graphql/union_generator.rb b/lib/generators/graphql/union_generator.rb index 9a66cd02113..03bb24fa129 100644 --- a/lib/generators/graphql/union_generator.rb +++ b/lib/generators/graphql/union_generator.rb @@ -19,14 +19,14 @@ class UnionGenerator < TypeGeneratorBase banner: "type type ...", desc: "Possible types for this union (expressed as Ruby or GraphQL)" - def create_type_file - template "union.erb", "#{options[:directory]}/types/#{type_file_name}.rb" - end - private + def graphql_type + "union" + end + def normalized_possible_types - possible_types.map { |t| self.class.normalize_type_expression(t, mode: :ruby)[0] } + custom_fields.map { |t| self.class.normalize_type_expression(t, mode: :ruby)[0] } end end end diff --git a/lib/graphql.rb b/lib/graphql.rb index d5326baefdf..110a40a54d7 100644 --- a/lib/graphql.rb +++ b/lib/graphql.rb @@ -4,19 +4,34 @@ require "set" require "singleton" require "forwardable" +require "fiber/storage" if RUBY_VERSION < "3.2.0" +require "graphql/autoload" module GraphQL - # forwards-compat for argument handling - module Ruby2Keywords - if RUBY_VERSION < "2.7" - def ruby2_keywords(*) - end - end + extend Autoload + + # Load all `autoload`-configured classes, and also eager-load dependents who have autoloads of their own. + def self.eager_load! + super + Query.eager_load! + Types.eager_load! + Schema.eager_load! end class Error < StandardError end + # This error is raised when GraphQL-Ruby encounters a situation + # that it *thought* would never happen. Please report this bug! + class InvariantError < Error + def initialize(message) + message += " + +This is probably a bug in GraphQL-Ruby, please report this error on GitHub: https://github.com/rmosolgo/graphql-ruby/issues/new?template=bug_report.md" + super(message) + end + end + class RequiredImplementationMissingError < Error end @@ -31,8 +46,8 @@ def default_parser # Turn a query string or schema definition into an AST # @param graphql_string [String] a GraphQL query string or schema definition # @return [GraphQL::Language::Nodes::Document] - def self.parse(graphql_string, tracer: GraphQL::Tracing::NullTracer) - parse_with_racc(graphql_string, tracer: tracer) + def self.parse(graphql_string, trace: GraphQL::Tracing::NullTrace, filename: nil, max_tokens: nil) + default_parser.parse(graphql_string, trace: trace, filename: filename, max_tokens: max_tokens) end # Read the contents of `filename` and parse them as GraphQL @@ -40,147 +55,84 @@ def self.parse(graphql_string, tracer: GraphQL::Tracing::NullTracer) # @return [GraphQL::Language::Nodes::Document] def self.parse_file(filename) content = File.read(filename) - parse_with_racc(content, filename: filename) + default_parser.parse(content, filename: filename) end - def self.parse_with_racc(string, filename: nil, tracer: GraphQL::Tracing::NullTracer) - GraphQL::Language::Parser.parse(string, filename: filename, tracer: tracer) + # @return [Array] + def self.scan(graphql_string) + default_parser.scan(graphql_string) end - # @return [Array] - def self.scan(graphql_string) - scan_with_ragel(graphql_string) + def self.parse_with_racc(string, filename: nil, trace: GraphQL::Tracing::NullTrace) + warn "`GraphQL.parse_with_racc` is deprecated; GraphQL-Ruby no longer uses racc for parsing. Call `GraphQL.parse` or `GraphQL::Language::Parser.parse` instead." + GraphQL::Language::Parser.parse(string, filename: filename, trace: trace) end - def self.scan_with_ragel(graphql_string) + def self.scan_with_ruby(graphql_string) GraphQL::Language::Lexer.tokenize(graphql_string) end - # Support Ruby 2.2 by implementing `-"str"`. If we drop 2.2 support, we can remove this backport. - module StringDedupBackport - refine String do - def -@ - if frozen? - self - else - self.dup.freeze - end - end - end + NOT_CONFIGURED = Object.new.freeze + private_constant :NOT_CONFIGURED + module EmptyObjects + EMPTY_HASH = {}.freeze + EMPTY_ARRAY = [].freeze end - module StringMatchBackport - refine String do - def match?(pattern) - self =~ pattern - end - end + class << self + # If true, the parser should raise when an integer or float is followed immediately by an identifier (instead of a space or punctuation) + attr_accessor :reject_numbers_followed_by_names end -end - -# Order matters for these: - -require "graphql/execution_error" -require "graphql/runtime_type_error" -require "graphql/unresolved_type_error" -require "graphql/invalid_null_error" -require "graphql/analysis_error" -require "graphql/coercion_error" -require "graphql/invalid_name_error" -require "graphql/integer_decoding_error" -require "graphql/integer_encoding_error" -require "graphql/string_encoding_error" - -require "graphql/define" -require "graphql/base_type" -require "graphql/object_type" -require "graphql/enum_type" -require "graphql/input_object_type" -require "graphql/interface_type" -require "graphql/list_type" -require "graphql/non_null_type" -require "graphql/union_type" - -require "graphql/argument" -require "graphql/field" -require "graphql/type_kinds" - -require "graphql/backwards_compatibility" -require "graphql/scalar_type" - -require "graphql/name_validator" - -require "graphql/language" - -require_relative "./graphql/railtie" if defined? Rails::Railtie - -require "graphql/analysis" -require "graphql/tracing" -require "graphql/dig" -require "graphql/execution" -require "graphql/pagination" -require "graphql/schema" -require "graphql/query" -require "graphql/directive" -require "graphql/execution" -require "graphql/types" -require "graphql/relay" -require "graphql/boolean_type" -require "graphql/float_type" -require "graphql/id_type" -require "graphql/int_type" -require "graphql/string_type" -require "graphql/schema/built_in_types" -require "graphql/schema/loader" -require "graphql/schema/printer" -require "graphql/filter" -require "graphql/internal_representation" -require "graphql/static_validation" -require "graphql/dataloader" -require "graphql/introspection" - -require "graphql/version" -require "graphql/compatibility" -require "graphql/function" -require "graphql/subscriptions" -require "graphql/parse_error" -require "graphql/backtrace" - -require "graphql/deprecated_dsl" -require "graphql/authorization" -require "graphql/unauthorized_error" -require "graphql/unauthorized_field_error" -require "graphql/load_application_object_failed_error" -require "graphql/deprecation" -module GraphQL - # Ruby has `deprecate_constant`, - # but I don't see a way to give a nice error message in that case, - # so I'm doing this instead. - DEPRECATED_INT_TYPE = INT_TYPE - DEPRECATED_FLOAT_TYPE = FLOAT_TYPE - DEPRECATED_STRING_TYPE = STRING_TYPE - DEPRECATED_BOOLEAN_TYPE = BOOLEAN_TYPE - DEPRECATED_ID_TYPE = ID_TYPE - - remove_const :INT_TYPE - remove_const :FLOAT_TYPE - remove_const :STRING_TYPE - remove_const :BOOLEAN_TYPE - remove_const :ID_TYPE - - def self.const_missing(const_name) - deprecated_const_name = :"DEPRECATED_#{const_name}" - if const_defined?(deprecated_const_name) - deprecated_type = const_get(deprecated_const_name) - deprecated_caller = caller(1, 1).first - # Don't warn about internal uses, like `types.Int` - if !deprecated_caller.include?("lib/graphql") - warn "GraphQL::#{const_name} is deprecated and will be removed in GraphQL-Ruby 2.0, use GraphQL::Types::#{deprecated_type.graphql_name} instead. (from #{deprecated_caller})" - end - deprecated_type - else - super - end + self.reject_numbers_followed_by_names = false + + autoload :ExecutionError, "graphql/execution_error" + autoload :RuntimeTypeError, "graphql/runtime_type_error" + autoload :UnresolvedTypeError, "graphql/unresolved_type_error" + autoload :InvalidNullError, "graphql/invalid_null_error" + autoload :AnalysisError, "graphql/analysis_error" + autoload :CoercionError, "graphql/coercion_error" + autoload :InvalidNameError, "graphql/invalid_name_error" + autoload :IntegerDecodingError, "graphql/integer_decoding_error" + autoload :IntegerEncodingError, "graphql/integer_encoding_error" + autoload :StringEncodingError, "graphql/string_encoding_error" + autoload :DateEncodingError, "graphql/date_encoding_error" + autoload :DurationEncodingError, "graphql/duration_encoding_error" + autoload :TypeKinds, "graphql/type_kinds" + autoload :NameValidator, "graphql/name_validator" + autoload :Language, "graphql/language" + + autoload :Analysis, "graphql/analysis" + autoload :Tracing, "graphql/tracing" + autoload :Dig, "graphql/dig" + autoload :Execution, "graphql/execution" + autoload :Pagination, "graphql/pagination" + autoload :Schema, "graphql/schema" + autoload :Query, "graphql/query" + autoload :Dataloader, "graphql/dataloader" + autoload :Types, "graphql/types" + autoload :StaticValidation, "graphql/static_validation" + autoload :Execution, "graphql/execution" + autoload :Introspection, "graphql/introspection" + autoload :Relay, "graphql/relay" + autoload :Subscriptions, "graphql/subscriptions" + autoload :ParseError, "graphql/parse_error" + autoload :Backtrace, "graphql/backtrace" + + autoload :RuntimeError, "graphql/runtime_error" + autoload :UnauthorizedError, "graphql/unauthorized_error" + autoload :UnauthorizedEnumValueError, "graphql/unauthorized_enum_value_error" + autoload :UnauthorizedFieldError, "graphql/unauthorized_field_error" + autoload :LoadApplicationObjectFailedError, "graphql/load_application_object_failed_error" + autoload :Testing, "graphql/testing" + autoload :Current, "graphql/current" + if defined?(::Rails::Engine) + # This needs to be defined before Rails runs `add_routing_paths`, + # otherwise GraphQL::Dashboard's routes won't have been gathered for loading + # when that initializer runs. + require 'graphql/dashboard' end end + +require "graphql/version" +require "graphql/railtie" if defined? Rails::Railtie diff --git a/lib/graphql/analysis.rb b/lib/graphql/analysis.rb index 17f426862f3..36f14019f8f 100644 --- a/lib/graphql/analysis.rb +++ b/lib/graphql/analysis.rb @@ -1,9 +1,103 @@ # frozen_string_literal: true -require "graphql/analysis/ast" -require "graphql/analysis/max_query_complexity" -require "graphql/analysis/max_query_depth" +require "graphql/analysis/visitor" +require "graphql/analysis/analyzer" +require "graphql/analysis/field_usage" require "graphql/analysis/query_complexity" +require "graphql/analysis/max_query_complexity" require "graphql/analysis/query_depth" -require "graphql/analysis/reducer_state" -require "graphql/analysis/analyze_query" -require "graphql/analysis/field_usage" +require "graphql/analysis/max_query_depth" +module GraphQL + module Analysis + AST = self + + class TimeoutError < AnalysisError + def initialize(...) + super("Timeout on validation of query") + end + end + + module_function + # Analyze a multiplex, and all queries within. + # Multiplex analyzers are ran for all queries, keeping state. + # Query analyzers are ran per query, without carrying state between queries. + # + # @param multiplex [GraphQL::Execution::Multiplex] + # @param analyzers [Array] + # @return [Array] Results from multiplex analyzers + def analyze_multiplex(multiplex, analyzers) + multiplex_analyzers = analyzers.map { |analyzer| analyzer.new(multiplex) } + + multiplex.current_trace.analyze_multiplex(multiplex: multiplex) do + query_results = multiplex.queries.map do |query| + if query.valid? + analyze_query( + query, + query.analyzers, + multiplex_analyzers: multiplex_analyzers + ) + else + [] + end + end + + + multiplex_analyzers.map!(&:result) + multiplex_errors = analysis_errors(EmptyObjects::EMPTY_ARRAY, multiplex_analyzers) + multiplex.queries.each_with_index do |query, idx| + query.analysis_errors = analysis_errors(multiplex_errors, query_results[idx]) + end + multiplex_analyzers + end + end + + # @param query [GraphQL::Query] + # @param analyzers [Array] + # @return [Array] Results from those analyzers + def analyze_query(query, analyzers, multiplex_analyzers: []) + query.current_trace.analyze_query(query: query) do + query_analyzers = analyzers.map { |analyzer| analyzer.new(query) } + query_analyzers.select!(&:analyze?) + analyzers_to_run = query_analyzers + multiplex_analyzers + + if !analyzers_to_run.empty? + analyzers_to_run.select!(&:visit?) + if !analyzers_to_run.empty? + visitor = GraphQL::Analysis::Visitor.new( + query: query, + analyzers: analyzers_to_run, + timeout: query.validate_timeout_remaining, + ) + + visitor.visit + + if !visitor.rescued_errors.empty? + return visitor.rescued_errors + end + end + + query_analyzers.map(&:result) + else + EmptyObjects::EMPTY_ARRAY + end + end + rescue TimeoutError => err + [err] + rescue GraphQL::UnauthorizedError, GraphQL::ExecutionError + # This error was raised during analysis and will be returned the client before execution + EmptyObjects::EMPTY_ARRAY + end + + def analysis_errors(parent_errors, results) + if !results.empty? + results = results.flatten + results.select! { |r| r.is_a?(GraphQL::AnalysisError) } + end + + if parent_errors.empty? + results + else + parent_errors + results + end + end + end +end diff --git a/lib/graphql/analysis/analyze_query.rb b/lib/graphql/analysis/analyze_query.rb deleted file mode 100644 index a7ad32e4075..00000000000 --- a/lib/graphql/analysis/analyze_query.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Analysis - module_function - - def use(schema_class) - schema = schema_class.is_a?(Class) ? schema_class : schema_class.target - schema.analysis_engine = self - end - - # @return [void] - def analyze_multiplex(multiplex, analyzers) - multiplex.trace("analyze_multiplex", { multiplex: multiplex }) do - reducer_states = analyzers.map { |r| ReducerState.new(r, multiplex) } - query_results = multiplex.queries.map do |query| - if query.valid? - analyze_query(query, query.analyzers, multiplex_states: reducer_states) - else - [] - end - end - - multiplex_results = reducer_states.map(&:finalize_reducer) - multiplex_errors = analysis_errors(multiplex_results) - - multiplex.queries.each_with_index do |query, idx| - query.analysis_errors = multiplex_errors + analysis_errors(query_results[idx]) - end - end - nil - end - - # Visit `query`'s internal representation, calling `analyzers` along the way. - # - # - First, query analyzers are filtered down by calling `.analyze?(query)`, if they respond to that method - # - Then, query analyzers are initialized by calling `.initial_value(query)`, if they respond to that method. - # - Then, they receive `.call(memo, visit_type, irep_node)`, where visit type is `:enter` or `:leave`. - # - Last, they receive `.final_value(memo)`, if they respond to that method. - # - # It returns an array of final `memo` values in the order that `analyzers` were passed in. - # - # @param query [GraphQL::Query] - # @param analyzers [Array<#call>] Objects that respond to `#call(memo, visit_type, irep_node)` - # @return [Array] Results from those analyzers - def analyze_query(query, analyzers, multiplex_states: []) - GraphQL::Deprecation.warn "Legacy analysis will be removed in GraphQL-Ruby 2.0, please upgrade to AST Analysis: https://graphql-ruby.org/queries/ast_analysis.html (schema: #{query.schema})" - - query.trace("analyze_query", { query: query }) do - analyzers_to_run = analyzers.select do |analyzer| - if analyzer.respond_to?(:analyze?) - analyzer.analyze?(query) - else - true - end - end - - reducer_states = analyzers_to_run.map { |r| ReducerState.new(r, query) } + multiplex_states - - irep = query.internal_representation - - irep.operation_definitions.each do |name, op_node| - reduce_node(op_node, reducer_states) - end - - reducer_states.map(&:finalize_reducer) - end - end - - private - - module_function - - # Enter the node, visit its children, then leave the node. - def reduce_node(irep_node, reducer_states) - visit_analyzers(:enter, irep_node, reducer_states) - - irep_node.typed_children.each do |type_defn, children| - children.each do |name, child_irep_node| - reduce_node(child_irep_node, reducer_states) - end - end - - visit_analyzers(:leave, irep_node, reducer_states) - end - - def visit_analyzers(visit_type, irep_node, reducer_states) - reducer_states.each do |reducer_state| - next_memo = reducer_state.call(visit_type, irep_node) - - reducer_state.memo = next_memo - end - end - - def analysis_errors(results) - results.flatten.select { |r| r.is_a?(GraphQL::AnalysisError) } - end - end -end diff --git a/lib/graphql/analysis/analyzer.rb b/lib/graphql/analysis/analyzer.rb new file mode 100644 index 00000000000..ab9943d51d4 --- /dev/null +++ b/lib/graphql/analysis/analyzer.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true +module GraphQL + module Analysis + # Query analyzer for query ASTs. Query analyzers respond to visitor style methods + # but are prefixed by `enter` and `leave`. + # + # When an analyzer is initialized with a Multiplex, you can always get the current query from + # `visitor.query` in the visit methods. + # + # @param [GraphQL::Query, GraphQL::Execution::Multiplex] The query or multiplex to analyze + class Analyzer + def initialize(subject) + @subject = subject + + if subject.is_a?(GraphQL::Query) + @query = subject + @multiplex = nil + else + @multiplex = subject + @query = nil + end + end + + # Analyzer hook to decide at analysis time whether a query should + # be analyzed or not. + # @return [Boolean] If the query should be analyzed or not + def analyze? + true + end + + # Analyzer hook to decide at analysis time whether analysis + # requires a visitor pass; can be disabled for precomputed results. + # @return [Boolean] If analysis requires visitation or not + def visit? + true + end + + # The result for this analyzer. Returning {GraphQL::AnalysisError} results + # in a query error. + # @return [Any] The analyzer result + def result + raise GraphQL::RequiredImplementationMissingError + end + + # rubocop:disable Development/NoEvalCop This eval takes static inputs at load-time + class << self + private + + def build_visitor_hooks(member_name) + class_eval(<<-EOS, __FILE__, __LINE__ + 1) + def on_enter_#{member_name}(node, parent, visitor) + end + + def on_leave_#{member_name}(node, parent, visitor) + end + EOS + end + end + + build_visitor_hooks :argument + build_visitor_hooks :directive + build_visitor_hooks :document + build_visitor_hooks :enum + build_visitor_hooks :field + build_visitor_hooks :fragment_spread + build_visitor_hooks :inline_fragment + build_visitor_hooks :input_object + build_visitor_hooks :list_type + build_visitor_hooks :non_null_type + build_visitor_hooks :null_value + build_visitor_hooks :operation_definition + build_visitor_hooks :type_name + build_visitor_hooks :variable_definition + build_visitor_hooks :variable_identifier + build_visitor_hooks :abstract_node + # rubocop:enable Development/NoEvalCop + protected + + # @return [GraphQL::Query, GraphQL::Execution::Multiplex] Whatever this analyzer is analyzing + attr_reader :subject + + # @return [GraphQL::Query, nil] `nil` if this analyzer is visiting a multiplex + # (When this is `nil`, use `visitor.query` inside visit methods to get the current query) + attr_reader :query + + # @return [GraphQL::Execution::Multiplex, nil] `nil` if this analyzer is visiting a query + attr_reader :multiplex + end + end +end diff --git a/lib/graphql/analysis/ast.rb b/lib/graphql/analysis/ast.rb deleted file mode 100644 index 2346c1b01f4..00000000000 --- a/lib/graphql/analysis/ast.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true -require "graphql/analysis/ast/visitor" -require "graphql/analysis/ast/analyzer" -require "graphql/analysis/ast/field_usage" -require "graphql/analysis/ast/query_complexity" -require "graphql/analysis/ast/max_query_complexity" -require "graphql/analysis/ast/query_depth" -require "graphql/analysis/ast/max_query_depth" - -module GraphQL - module Analysis - module AST - module_function - - def use(schema_class) - if schema_class.analysis_engine == self - definition_line = caller(2, 1).first - GraphQL::Deprecation.warn("GraphQL::Analysis::AST is now the default; remove `use GraphQL::Analysis::AST` from the schema definition (#{definition_line})") - else - schema_class.analysis_engine = self - end - end - - # Analyze a multiplex, and all queries within. - # Multiplex analyzers are ran for all queries, keeping state. - # Query analyzers are ran per query, without carrying state between queries. - # - # @param multiplex [GraphQL::Execution::Multiplex] - # @param analyzers [Array] - # @return [Array] Results from multiplex analyzers - def analyze_multiplex(multiplex, analyzers) - multiplex_analyzers = analyzers.map { |analyzer| analyzer.new(multiplex) } - - multiplex.trace("analyze_multiplex", { multiplex: multiplex }) do - query_results = multiplex.queries.map do |query| - if query.valid? - analyze_query( - query, - query.analyzers, - multiplex_analyzers: multiplex_analyzers - ) - else - [] - end - end - - multiplex_results = multiplex_analyzers.map(&:result) - multiplex_errors = analysis_errors(multiplex_results) - - multiplex.queries.each_with_index do |query, idx| - query.analysis_errors = multiplex_errors + analysis_errors(query_results[idx]) - end - multiplex_results - end - end - - # @param query [GraphQL::Query] - # @param analyzers [Array] - # @return [Array] Results from those analyzers - def analyze_query(query, analyzers, multiplex_analyzers: []) - query.trace("analyze_query", { query: query }) do - query_analyzers = analyzers - .map { |analyzer| analyzer.new(query) } - .select { |analyzer| analyzer.analyze? } - - analyzers_to_run = query_analyzers + multiplex_analyzers - if analyzers_to_run.any? - visitor = GraphQL::Analysis::AST::Visitor.new( - query: query, - analyzers: analyzers_to_run - ) - - visitor.visit - - if visitor.rescued_errors.any? - visitor.rescued_errors - else - query_analyzers.map(&:result) - end - else - [] - end - end - end - - def analysis_errors(results) - results.flatten.select { |r| r.is_a?(GraphQL::AnalysisError) } - end - end - end -end diff --git a/lib/graphql/analysis/ast/analyzer.rb b/lib/graphql/analysis/ast/analyzer.rb deleted file mode 100644 index db6dfbd57f8..00000000000 --- a/lib/graphql/analysis/ast/analyzer.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Analysis - module AST - # Query analyzer for query ASTs. Query analyzers respond to visitor style methods - # but are prefixed by `enter` and `leave`. - # - # When an analyzer is initialized with a Multiplex, you can always get the current query from - # `visitor.query` in the visit methods. - # - # @param [GraphQL::Query, GraphQL::Execution::Multiplex] The query or multiplex to analyze - class Analyzer - def initialize(subject) - @subject = subject - - if subject.is_a?(GraphQL::Query) - @query = subject - @multiplex = nil - else - @multiplex = subject - @query = nil - end - end - - # Analyzer hook to decide at analysis time whether a query should - # be analyzed or not. - # @return [Boolean] If the query should be analyzed or not - def analyze? - true - end - - # The result for this analyzer. Returning {GraphQL::AnalysisError} results - # in a query error. - # @return [Any] The analyzer result - def result - raise GraphQL::RequiredImplementationMissingError - end - - class << self - private - - def build_visitor_hooks(member_name) - class_eval(<<-EOS, __FILE__, __LINE__ + 1) - def on_enter_#{member_name}(node, parent, visitor) - end - - def on_leave_#{member_name}(node, parent, visitor) - end - EOS - end - end - - build_visitor_hooks :argument - build_visitor_hooks :directive - build_visitor_hooks :document - build_visitor_hooks :enum - build_visitor_hooks :field - build_visitor_hooks :fragment_spread - build_visitor_hooks :inline_fragment - build_visitor_hooks :input_object - build_visitor_hooks :list_type - build_visitor_hooks :non_null_type - build_visitor_hooks :null_value - build_visitor_hooks :operation_definition - build_visitor_hooks :type_name - build_visitor_hooks :variable_definition - build_visitor_hooks :variable_identifier - build_visitor_hooks :abstract_node - - protected - - # @return [GraphQL::Query, GraphQL::Execution::Multiplex] Whatever this analyzer is analyzing - attr_reader :subject - - # @return [GraphQL::Query, nil] `nil` if this analyzer is visiting a multiplex - # (When this is `nil`, use `visitor.query` inside visit methods to get the current query) - attr_reader :query - - # @return [GraphQL::Execution::Multiplex, nil] `nil` if this analyzer is visiting a query - attr_reader :multiplex - end - end - end -end diff --git a/lib/graphql/analysis/ast/field_usage.rb b/lib/graphql/analysis/ast/field_usage.rb deleted file mode 100644 index ffe56f29b7e..00000000000 --- a/lib/graphql/analysis/ast/field_usage.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Analysis - module AST - class FieldUsage < Analyzer - def initialize(query) - super - @used_fields = Set.new - @used_deprecated_fields = Set.new - end - - def on_leave_field(node, parent, visitor) - field_defn = visitor.field_definition - field = "#{visitor.parent_type_definition.graphql_name}.#{field_defn.graphql_name}" - @used_fields << field - @used_deprecated_fields << field if field_defn.deprecation_reason - end - - def result - { - used_fields: @used_fields.to_a, - used_deprecated_fields: @used_deprecated_fields.to_a - } - end - end - end - end -end diff --git a/lib/graphql/analysis/ast/max_query_complexity.rb b/lib/graphql/analysis/ast/max_query_complexity.rb deleted file mode 100644 index befa3ba0a09..00000000000 --- a/lib/graphql/analysis/ast/max_query_complexity.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true -require_relative "./query_complexity" -module GraphQL - module Analysis - module AST - # Used under the hood to implement complexity validation, - # see {Schema#max_complexity} and {Query#max_complexity} - class MaxQueryComplexity < QueryComplexity - def result - return if subject.max_complexity.nil? - - total_complexity = max_possible_complexity - - if total_complexity > subject.max_complexity - GraphQL::AnalysisError.new("Query has complexity of #{total_complexity}, which exceeds max complexity of #{subject.max_complexity}") - else - nil - end - end - end - end - end -end diff --git a/lib/graphql/analysis/ast/max_query_depth.rb b/lib/graphql/analysis/ast/max_query_depth.rb deleted file mode 100644 index 395389a4499..00000000000 --- a/lib/graphql/analysis/ast/max_query_depth.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Analysis - module AST - class MaxQueryDepth < QueryDepth - def result - configured_max_depth = if query - query.max_depth - else - multiplex.schema.max_depth - end - - if configured_max_depth && @max_depth > configured_max_depth - GraphQL::AnalysisError.new("Query has depth of #{@max_depth}, which exceeds max depth of #{configured_max_depth}") - else - nil - end - end - end - end - end -end diff --git a/lib/graphql/analysis/ast/query_complexity.rb b/lib/graphql/analysis/ast/query_complexity.rb deleted file mode 100644 index b73880b8dde..00000000000 --- a/lib/graphql/analysis/ast/query_complexity.rb +++ /dev/null @@ -1,234 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Analysis - # Calculate the complexity of a query, using {Field#complexity} values. - module AST - class QueryComplexity < Analyzer - # State for the query complexity calculation: - # - `complexities_on_type` holds complexity scores for each type in an IRep node - def initialize(query) - super - @complexities_on_type_by_query = {} - end - - # Overide this method to use the complexity result - def result - max_possible_complexity - end - - class ScopedTypeComplexity - # A single proc for {#scoped_children} hashes. Use this to avoid repeated allocations, - # since the lexical binding isn't important. - HASH_CHILDREN = ->(h, k) { h[k] = {} } - - attr_reader :field_definition, :response_path, :query - - # @param node [Language::Nodes::Field] The AST node; used for providing argument values when necessary - # @param field_definition [GraphQL::Field, GraphQL::Schema::Field] Used for getting the `.complexity` configuration - # @param query [GraphQL::Query] Used for `query.possible_types` - # @param response_path [Array] The path to the response key for the field - def initialize(node, field_definition, query, response_path) - @node = node - @field_definition = field_definition - @query = query - @response_path = response_path - @scoped_children = nil - end - - # Returns true if this field has no selections, ie, it's a scalar. - # We need a quick way to check whether we should continue traversing. - def terminal? - @scoped_children.nil? - end - - # This value is only calculated when asked for to avoid needless hash allocations. - # Also, if it's never asked for, we determine that this scope complexity - # is a scalar field ({#terminal?}). - # @return [Hash ScopedTypeComplexity>] - def scoped_children - @scoped_children ||= Hash.new(&HASH_CHILDREN) - end - - def own_complexity(child_complexity) - defined_complexity = @field_definition.complexity - case defined_complexity - when Proc - arguments = @query.arguments_for(@node, @field_definition) - defined_complexity.call(@query.context, arguments.keyword_arguments, child_complexity) - when Numeric - defined_complexity + child_complexity - else - raise("Invalid complexity: #{defined_complexity.inspect} on #{@field_definition.name}") - end - end - end - - def on_enter_field(node, parent, visitor) - # We don't want to visit fragment definitions, - # we'll visit them when we hit the spreads instead - return if visitor.visiting_fragment_definition? - return if visitor.skipping? - parent_type = visitor.parent_type_definition - field_key = node.alias || node.name - # Find the complexity calculation for this field -- - # if we're re-entering a selection, we'll already have one. - # Otherwise, make a new one and store it. - # - # `node` and `visitor.field_definition` may appear from a cache, - # but I think that's ok. If the arguments _didn't_ match, - # then the query would have been rejected as invalid. - complexities_on_type = @complexities_on_type_by_query[visitor.query] ||= [ScopedTypeComplexity.new(nil, nil, query, visitor.response_path)] - - complexity = complexities_on_type.last.scoped_children[parent_type][field_key] ||= ScopedTypeComplexity.new(node, visitor.field_definition, visitor.query, visitor.response_path) - # Push it on the stack. - complexities_on_type.push(complexity) - end - - def on_leave_field(node, parent, visitor) - # We don't want to visit fragment definitions, - # we'll visit them when we hit the spreads instead - return if visitor.visiting_fragment_definition? - return if visitor.skipping? - complexities_on_type = @complexities_on_type_by_query[visitor.query] - complexities_on_type.pop - end - - private - - # @return [Integer] - def max_possible_complexity - @complexities_on_type_by_query.reduce(0) do |total, (query, complexities_on_type)| - root_complexity = complexities_on_type.last - # Use this entry point to calculate the total complexity - total_complexity_for_query = merged_max_complexity_for_scopes(query, [root_complexity.scoped_children]) - total + total_complexity_for_query - end - end - - # @param query [GraphQL::Query] Used for `query.possible_types` - # @param scoped_children_hashes [Array] Array of scoped children hashes - # @return [Integer] - def merged_max_complexity_for_scopes(query, scoped_children_hashes) - # Figure out what scopes are possible here. - # Use a hash, but ignore the values; it's just a fast way to work with the keys. - all_scopes = {} - scoped_children_hashes.each do |h| - all_scopes.merge!(h) - end - - # If an abstract scope is present, but _all_ of its concrete types - # are also in the list, remove it from the list of scopes to check, - # because every possible type is covered by a concrete type. - # (That is, there are no remainder types to check.) - prev_keys = all_scopes.keys - prev_keys.each do |scope| - next unless scope.kind.abstract? - - missing_concrete_types = query.possible_types(scope).select { |t| !all_scopes.key?(t) } - # This concrete type is possible _only_ as a member of the abstract type. - # So, attribute to it the complexity which belongs to the abstract type. - missing_concrete_types.each do |concrete_scope| - all_scopes[concrete_scope] = all_scopes[scope] - end - all_scopes.delete(scope) - end - - # This will hold `{ type => int }` pairs, one for each possible branch - complexity_by_scope = {} - - # For each scope, - # find the lexical selections that might apply to it, - # and gather them together into an array. - # Then, treat the set of selection hashes - # as a set and calculate the complexity for them as a unit - all_scopes.each do |scope, _| - # These will be the selections on `scope` - children_for_scope = [] - scoped_children_hashes.each do |sc_h| - sc_h.each do |inner_scope, children_hash| - if applies_to?(query, scope, inner_scope) - children_for_scope << children_hash - end - end - end - - # Calculate the complexity for `scope`, merging all - # possible lexical branches. - complexity_value = merged_max_complexity(query, children_for_scope) - complexity_by_scope[scope] = complexity_value - end - - # Return the max complexity among all scopes - complexity_by_scope.each_value.max - end - - def applies_to?(query, left_scope, right_scope) - if left_scope == right_scope - # This can happen when several branches are being analyzed together - true - else - # Check if these two scopes have _any_ types in common. - possible_right_types = query.possible_types(right_scope) - possible_left_types = query.possible_types(left_scope) - !(possible_right_types & possible_left_types).empty? - end - end - - # A hook which is called whenever a field's max complexity is calculated. - # Override this method to capture individual field complexity details. - # - # @param scoped_type_complexity [ScopedTypeComplexity] - # @param max_complexity [Numeric] Field's maximum complexity including child complexity - # @param child_complexity [Numeric, nil] Field's child complexity - def field_complexity(scoped_type_complexity, max_complexity:, child_complexity: nil) - end - - # @param children_for_scope [Array] An array of `scoped_children[scope]` hashes - # (`{field_key => complexity}`) - # @return [Integer] Complexity value for all these selections in the current scope - def merged_max_complexity(query, children_for_scope) - all_keys = [] - children_for_scope.each do |c| - all_keys.concat(c.keys) - end - all_keys.uniq! - complexity_for_keys = {} - - all_keys.each do |child_key| - scoped_children_for_key = nil - complexity_for_key = nil - children_for_scope.each do |children_hash| - next unless children_hash.key?(child_key) - - complexity_for_key = children_hash[child_key] - if complexity_for_key.terminal? - # Assume that all terminals would return the same complexity - # Since it's a terminal, its child complexity is zero. - complexity = complexity_for_key.own_complexity(0) - complexity_for_keys[child_key] = complexity - - field_complexity(complexity_for_key, max_complexity: complexity, child_complexity: nil) - else - scoped_children_for_key ||= [] - scoped_children_for_key << complexity_for_key.scoped_children - end - end - - next unless scoped_children_for_key - - child_complexity = merged_max_complexity_for_scopes(query, scoped_children_for_key) - # This is the _last_ one we visited; assume it's representative. - max_complexity = complexity_for_key.own_complexity(child_complexity) - - field_complexity(complexity_for_key, max_complexity: max_complexity, child_complexity: child_complexity) - - complexity_for_keys[child_key] = max_complexity - end - - # Calculate the child complexity by summing the complexity of all selections - complexity_for_keys.each_value.inject(0, &:+) - end - end - end - end -end diff --git a/lib/graphql/analysis/ast/query_depth.rb b/lib/graphql/analysis/ast/query_depth.rb deleted file mode 100644 index 299e7617951..00000000000 --- a/lib/graphql/analysis/ast/query_depth.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Analysis - # A query reducer for measuring the depth of a given query. - # - # See https://graphql-ruby.org/queries/ast_analysis.html for more examples. - # - # @example Logging the depth of a query - # class LogQueryDepth < GraphQL::Analysis::QueryDepth - # def result - # log("GraphQL query depth: #{@max_depth}") - # end - # end - # - # # In your Schema file: - # - # class MySchema < GraphQL::Schema - # use GraphQL::Analysis::AST - # query_analyzer LogQueryDepth - # end - # - # # When you run the query, the depth will get logged: - # - # Schema.execute(query_str) - # # GraphQL query depth: 8 - # - module AST - class QueryDepth < Analyzer - def initialize(query) - @max_depth = 0 - @current_depth = 0 - super - end - - def on_enter_field(node, parent, visitor) - return if visitor.skipping? || visitor.visiting_fragment_definition? - - @current_depth += 1 - end - - def on_leave_field(node, parent, visitor) - return if visitor.skipping? || visitor.visiting_fragment_definition? - - if @max_depth < @current_depth - @max_depth = @current_depth - end - @current_depth -= 1 - end - - def result - @max_depth - end - end - end - end -end diff --git a/lib/graphql/analysis/ast/visitor.rb b/lib/graphql/analysis/ast/visitor.rb deleted file mode 100644 index c00b68273b6..00000000000 --- a/lib/graphql/analysis/ast/visitor.rb +++ /dev/null @@ -1,268 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Analysis - module AST - # Depth first traversal through a query AST, calling AST analyzers - # along the way. - # - # The visitor is a special case of GraphQL::Language::Visitor, visiting - # only the selected operation, providing helpers for common use cases such - # as skipped fields and visiting fragment spreads. - # - # @see {GraphQL::Analysis::AST::Analyzer} AST Analyzers for queries - class Visitor < GraphQL::Language::Visitor - def initialize(query:, analyzers:) - @analyzers = analyzers - @path = [] - @object_types = [] - @directives = [] - @field_definitions = [] - @argument_definitions = [] - @directive_definitions = [] - @rescued_errors = [] - @query = query - @schema = query.schema - @response_path = [] - @skip_stack = [false] - super(query.selected_operation) - end - - # @return [GraphQL::Query] the query being visited - attr_reader :query - - # @return [Array] Types whose scope we've entered - attr_reader :object_types - - # @return [Array] The path to the response key for the current field - def response_path - @response_path.dup - end - - # Visitor Hooks - - def on_operation_definition(node, parent) - object_type = @schema.root_type_for_operation(node.operation_type) - @object_types.push(object_type) - @path.push("#{node.operation_type}#{node.name ? " #{node.name}" : ""}") - call_analyzers(:on_enter_operation_definition, node, parent) - super - call_analyzers(:on_leave_operation_definition, node, parent) - @object_types.pop - @path.pop - end - - def on_fragment_definition(node, parent) - on_fragment_with_type(node) do - @path.push("fragment #{node.name}") - @in_fragment_def = false - call_analyzers(:on_enter_fragment_definition, node, parent) - super - @in_fragment_def = false - call_analyzers(:on_leave_fragment_definition, node, parent) - end - end - - def on_inline_fragment(node, parent) - on_fragment_with_type(node) do - @path.push("...#{node.type ? " on #{node.type.name}" : ""}") - call_analyzers(:on_enter_inline_fragment, node, parent) - super - call_analyzers(:on_leave_inline_fragment, node, parent) - end - end - - def on_field(node, parent) - @response_path.push(node.alias || node.name) - parent_type = @object_types.last - field_definition = @schema.get_field(parent_type, node.name) - @field_definitions.push(field_definition) - if !field_definition.nil? - next_object_type = field_definition.type.unwrap - @object_types.push(next_object_type) - else - @object_types.push(nil) - end - @path.push(node.alias || node.name) - - @skipping = @skip_stack.last || skip?(node) - @skip_stack << @skipping - - call_analyzers(:on_enter_field, node, parent) - super - - @skipping = @skip_stack.pop - - call_analyzers(:on_leave_field, node, parent) - @response_path.pop - @field_definitions.pop - @object_types.pop - @path.pop - end - - def on_directive(node, parent) - directive_defn = @schema.directives[node.name] - @directive_definitions.push(directive_defn) - call_analyzers(:on_enter_directive, node, parent) - super - call_analyzers(:on_leave_directive, node, parent) - @directive_definitions.pop - end - - def on_argument(node, parent) - argument_defn = if (arg = @argument_definitions.last) - arg_type = arg.type.unwrap - if arg_type.kind.input_object? - arg_type.arguments[node.name] - else - nil - end - elsif (directive_defn = @directive_definitions.last) - directive_defn.arguments[node.name] - elsif (field_defn = @field_definitions.last) - field_defn.arguments[node.name] - else - nil - end - - @argument_definitions.push(argument_defn) - @path.push(node.name) - call_analyzers(:on_enter_argument, node, parent) - super - call_analyzers(:on_leave_argument, node, parent) - @argument_definitions.pop - @path.pop - end - - def on_fragment_spread(node, parent) - @path.push("... #{node.name}") - call_analyzers(:on_enter_fragment_spread, node, parent) - enter_fragment_spread_inline(node) - super - leave_fragment_spread_inline(node) - call_analyzers(:on_leave_fragment_spread, node, parent) - @path.pop - end - - def on_abstract_node(node, parent) - call_analyzers(:on_enter_abstract_node, node, parent) - super - call_analyzers(:on_leave_abstract_node, node, parent) - end - - # @return [GraphQL::BaseType] The current object type - def type_definition - @object_types.last - end - - # @return [GraphQL::BaseType] The type which the current type came from - def parent_type_definition - @object_types[-2] - end - - # @return [GraphQL::Field, nil] The most-recently-entered GraphQL::Field, if currently inside one - def field_definition - @field_definitions.last - end - - # @return [GraphQL::Field, nil] The GraphQL field which returned the object that the current field belongs to - def previous_field_definition - @field_definitions[-2] - end - - # @return [GraphQL::Directive, nil] The most-recently-entered GraphQL::Directive, if currently inside one - def directive_definition - @directive_definitions.last - end - - # @return [GraphQL::Argument, nil] The most-recently-entered GraphQL::Argument, if currently inside one - def argument_definition - @argument_definitions.last - end - - # @return [GraphQL::Argument, nil] The previous GraphQL argument - def previous_argument_definition - @argument_definitions[-2] - end - - private - - # Visit a fragment spread inline instead of visiting the definition - # by itself. - def enter_fragment_spread_inline(fragment_spread) - fragment_def = query.fragments[fragment_spread.name] - - object_type = if fragment_def.type - @query.warden.get_type(fragment_def.type.name) - else - object_types.last - end - - object_types << object_type - - fragment_def.selections.each do |selection| - visit_node(selection, fragment_def) - end - end - - # Visit a fragment spread inline instead of visiting the definition - # by itself. - def leave_fragment_spread_inline(_fragment_spread) - object_types.pop - end - - def skip?(ast_node) - dir = ast_node.directives - dir.any? && !GraphQL::Execution::DirectiveChecks.include?(dir, query) - end - - def call_analyzers(method, node, parent) - @analyzers.each do |analyzer| - begin - analyzer.public_send(method, node, parent, self) - rescue AnalysisError => err - @rescued_errors << err - end - end - end - - def on_fragment_with_type(node) - object_type = if node.type - @query.warden.get_type(node.type.name) - else - @object_types.last - end - @object_types.push(object_type) - yield(node) - @object_types.pop - @path.pop - end - end - end - end -end diff --git a/lib/graphql/analysis/field_usage.rb b/lib/graphql/analysis/field_usage.rb index b10a38f9050..a54767dd782 100644 --- a/lib/graphql/analysis/field_usage.rb +++ b/lib/graphql/analysis/field_usage.rb @@ -1,44 +1,81 @@ # frozen_string_literal: true module GraphQL module Analysis - # A query reducer for tracking both field usage and deprecated field usage. - # - # @example Logging field usage and deprecated field usage - # Schema.query_analyzers << GraphQL::Analysis::FieldUsage.new { |query, used_fields, used_deprecated_fields| - # puts "Used GraphQL fields: #{used_fields.join(', ')}" - # puts "Used deprecated GraphQL fields: #{used_deprecated_fields.join(', ')}" - # } - # Schema.execute(query_str) - # # Used GraphQL fields: Cheese.id, Cheese.fatContent, Query.cheese - # # Used deprecated GraphQL fields: Cheese.fatContent - # - class FieldUsage - def initialize(&block) - @field_usage_handler = block + class FieldUsage < Analyzer + def initialize(query) + super + @used_fields = Set.new + @used_deprecated_fields = Set.new + @used_deprecated_arguments = Set.new + @used_deprecated_enum_values = Set.new end - def initial_value(query) + def on_leave_field(node, parent, visitor) + field_defn = visitor.field_definition + field = "#{visitor.parent_type_definition.graphql_name}.#{field_defn.graphql_name}" + @used_fields << field + @used_deprecated_fields << field if field_defn.deprecation_reason + arguments = visitor.query.arguments_for(node, field_defn) + # If there was an error when preparing this argument object, + # then this might be an error or something: + if arguments.respond_to?(:argument_values) + extract_deprecated_arguments(arguments.argument_values) + end + end + + def result { - query: query, - used_fields: Set.new, - used_deprecated_fields: Set.new + used_fields: @used_fields.to_a, + used_deprecated_fields: @used_deprecated_fields.to_a, + used_deprecated_arguments: @used_deprecated_arguments.to_a, + used_deprecated_enum_values: @used_deprecated_enum_values.to_a, } end - def call(memo, visit_type, irep_node) - if irep_node.ast_node.is_a?(GraphQL::Language::Nodes::Field) && visit_type == :leave - field = "#{irep_node.owner_type.name}.#{irep_node.definition.name}" - memo[:used_fields] << field - if irep_node.definition.deprecation_reason - memo[:used_deprecated_fields] << field + private + + def extract_deprecated_arguments(argument_values) + argument_values.each_pair do |_argument_name, argument| + if argument.definition.deprecation_reason + @used_deprecated_arguments << argument.definition.path end - end - memo + arg_val = argument.value + + next if arg_val.nil? + + argument_type = argument.definition.type + if argument_type.non_null? + argument_type = argument_type.of_type + end + + if argument_type.kind.input_object? + extract_deprecated_arguments(argument.original_value.arguments.argument_values) # rubocop:disable Development/ContextIsPassedCop -- runtime args instance + elsif argument_type.kind.enum? + extract_deprecated_enum_value(argument_type, arg_val) + elsif argument_type.list? + inner_type = argument_type.unwrap + case inner_type.kind + when TypeKinds::INPUT_OBJECT + argument.original_value.each do |value| + extract_deprecated_arguments(value.arguments.argument_values) # rubocop:disable Development/ContextIsPassedCop -- runtime args instance + end + when TypeKinds::ENUM + arg_val.each do |value| + extract_deprecated_enum_value(inner_type, value) + end + else + # Not a kind of input that we track + end + end + end end - def final_value(memo) - @field_usage_handler.call(memo[:query], memo[:used_fields].to_a, memo[:used_deprecated_fields].to_a) + def extract_deprecated_enum_value(enum_type, value) + enum_value = @query.types.enum_values(enum_type).find { |ev| ev.value == value } + if enum_value&.deprecation_reason + @used_deprecated_enum_values << enum_value.path + end end end end diff --git a/lib/graphql/analysis/max_query_complexity.rb b/lib/graphql/analysis/max_query_complexity.rb index 737237a48c5..53235b64039 100644 --- a/lib/graphql/analysis/max_query_complexity.rb +++ b/lib/graphql/analysis/max_query_complexity.rb @@ -1,25 +1,19 @@ # frozen_string_literal: true -require_relative "./query_complexity" module GraphQL module Analysis # Used under the hood to implement complexity validation, # see {Schema#max_complexity} and {Query#max_complexity} - # - # @example Assert max complexity of 10 - # # DON'T actually do this, graphql-ruby - # # Does this for you based on your `max_complexity` setting - # MySchema.query_analyzers << GraphQL::Analysis::MaxQueryComplexity.new(10) - # - class MaxQueryComplexity < GraphQL::Analysis::QueryComplexity - def initialize(max_complexity) - disallow_excessive_complexity = ->(query, complexity) { - if complexity > max_complexity - GraphQL::AnalysisError.new("Query has complexity of #{complexity}, which exceeds max complexity of #{max_complexity}") - else - nil - end - } - super(&disallow_excessive_complexity) + class MaxQueryComplexity < QueryComplexity + def result + return if subject.max_complexity.nil? + + total_complexity = max_possible_complexity + + if total_complexity > subject.max_complexity + GraphQL::AnalysisError.new("Query has complexity of #{total_complexity}, which exceeds max complexity of #{subject.max_complexity}") + else + nil + end end end end diff --git a/lib/graphql/analysis/max_query_depth.rb b/lib/graphql/analysis/max_query_depth.rb index 853126f4632..b5a1ef4fca6 100644 --- a/lib/graphql/analysis/max_query_depth.rb +++ b/lib/graphql/analysis/max_query_depth.rb @@ -1,25 +1,19 @@ # frozen_string_literal: true -require_relative "./query_depth" module GraphQL module Analysis - # Used under the hood to implement depth validation, - # see {Schema#max_depth} and {Query#max_depth} - # - # @example Assert max depth of 10 - # # DON'T actually do this, graphql-ruby - # # Does this for you based on your `max_depth` setting - # MySchema.query_analyzers << GraphQL::Analysis::MaxQueryDepth.new(10) - # - class MaxQueryDepth < GraphQL::Analysis::QueryDepth - def initialize(max_depth) - disallow_excessive_depth = ->(query, depth) { - if depth > max_depth - GraphQL::AnalysisError.new("Query has depth of #{depth}, which exceeds max depth of #{max_depth}") - else - nil - end - } - super(&disallow_excessive_depth) + class MaxQueryDepth < QueryDepth + def result + configured_max_depth = if query + query.max_depth + else + multiplex.schema.max_depth + end + + if configured_max_depth && @max_depth > configured_max_depth + GraphQL::AnalysisError.new("Query has depth of #{@max_depth}, which exceeds max depth of #{configured_max_depth}") + else + nil + end end end end diff --git a/lib/graphql/analysis/query_complexity.rb b/lib/graphql/analysis/query_complexity.rb index 53d47fe1bb7..9afd19f728e 100644 --- a/lib/graphql/analysis/query_complexity.rb +++ b/lib/graphql/analysis/query_complexity.rb @@ -2,85 +2,276 @@ module GraphQL module Analysis # Calculate the complexity of a query, using {Field#complexity} values. - # - # @example Log the complexity of incoming queries - # MySchema.query_analyzers << GraphQL::Analysis::QueryComplexity.new do |query, complexity| - # Rails.logger.info("Complexity: #{complexity}") - # end - # - class QueryComplexity - # @yield [query, complexity] Called for each query analyzed by the schema, before executing it - # @yieldparam query [GraphQL::Query] The query that was analyzed - # @yieldparam complexity [Numeric] The complexity for this query - def initialize(&block) - @complexity_handler = block + class QueryComplexity < Analyzer + # State for the query complexity calculation: + # - `complexities_on_type` holds complexity scores for each type + def initialize(query) + super + @skip_introspection_fields = !query.schema.max_complexity_count_introspection_fields + @complexities_on_type_by_query = {} + @intersect_cache = Hash.new { |h, k| h[k] = {}.compare_by_identity }.compare_by_identity + @possible_types_cache = {}.compare_by_identity end - # State for the query complexity calcuation: - # - `target` is passed to handler - # - `complexities_on_type` holds complexity scores for each type in an IRep node - def initial_value(target) - { - target: target, - complexities_on_type: [TypeComplexity.new], - } - end - - # Implement the query analyzer API - def call(memo, visit_type, irep_node) - if irep_node.ast_node.is_a?(GraphQL::Language::Nodes::Field) - if visit_type == :enter - memo[:complexities_on_type].push(TypeComplexity.new) + # Override this method to use the complexity result + def result + case subject.schema.complexity_cost_calculation_mode_for(subject.context) + when :future + max_possible_complexity + when :legacy + max_possible_complexity(mode: :legacy) + when :compare + future_complexity = max_possible_complexity + legacy_complexity = max_possible_complexity(mode: :legacy) + if future_complexity != legacy_complexity + subject.schema.legacy_complexity_cost_calculation_mismatch(subject, future_complexity, legacy_complexity) else - type_complexities = memo[:complexities_on_type].pop - child_complexity = type_complexities.max_possible_complexity - own_complexity = get_complexity(irep_node, child_complexity) - memo[:complexities_on_type].last.merge(irep_node.owner_type, own_complexity) + future_complexity end + when nil + subject.logger.warn <<~MESSAGE + GraphQL-Ruby's complexity cost system is getting some "breaking fixes" in a future version. See the migration notes at https://graphql-ruby.org/api-doc/#{GraphQL::VERSION}/GraphQL/Schema.html#complexity_cost_calculation_mode_for-class_method + + To opt into the future behavior, configure your schema (#{subject.schema.name ? subject.schema.name : subject.schema.ancestors}) with: + + complexity_cost_calculation_mode(:future) # or `:legacy`, `:compare` + + MESSAGE + max_possible_complexity(mode: :legacy) + else + raise ArgumentError, "Expected `:future`, `:legacy`, `:compare`, or `nil` from `#{query.schema}.complexity_cost_calculation_mode_for` but got: #{query.schema.complexity_cost_calculation_mode.inspect}" + end + end + + # ScopedTypeComplexity models a tree of GraphQL types mapped to inner selections, ie: + # Hash> + class ScopedTypeComplexity < Hash + # A proc for defaulting empty namespace requests as a new scope hash. + DEFAULT_PROC = ->(h, k) { h[k] = {} } + + attr_reader :field_definition, :response_path, :query + + # @param parent_type [Class] The owner of `field_definition` + # @param field_definition [GraphQL::Field, GraphQL::Schema::Field] Used for getting the `.complexity` configuration + # @param query [GraphQL::Query] Used for `query.possible_types` + # @param response_path [Array] The path to the response key for the field + # @return [Hash>] + def initialize(parent_type, field_definition, query, response_path) + super(&DEFAULT_PROC) + @parent_type = parent_type + @field_definition = field_definition + @query = query + @response_path = response_path + @nodes = [] + end + + # @return [Array] + attr_reader :nodes + + def own_complexity(child_complexity) + @field_definition.calculate_complexity(query: @query, nodes: @nodes, child_complexity: child_complexity) + end + + def composite? + !empty? end - memo end - # Send the query and complexity to the block - # @return [Object, GraphQL::AnalysisError] Whatever the handler returns - def final_value(reduced_value) - total_complexity = reduced_value[:complexities_on_type].last.max_possible_complexity - @complexity_handler.call(reduced_value[:target], total_complexity) + def on_enter_field(node, parent, visitor) + # We don't want to visit fragment definitions, + # we'll visit them when we hit the spreads instead + return if visitor.visiting_fragment_definition? + return if visitor.skipping? + return if @skip_introspection_fields && visitor.field_definition.introspection? + parent_type = visitor.parent_type_definition + field_key = node.alias || node.name + + # Find or create a complexity scope stack for this query. + scopes_stack = @complexities_on_type_by_query[visitor.query] ||= [ScopedTypeComplexity.new(nil, nil, query, visitor.response_path)] + + # Find or create the complexity costing node for this field. + scope = scopes_stack.last[parent_type][field_key] ||= ScopedTypeComplexity.new(parent_type, visitor.field_definition, visitor.query, visitor.response_path) + scope.nodes.push(node) + scopes_stack.push(scope) + end + + def on_leave_field(node, parent, visitor) + # We don't want to visit fragment definitions, + # we'll visit them when we hit the spreads instead + return if visitor.visiting_fragment_definition? + return if visitor.skipping? + return if @skip_introspection_fields && visitor.field_definition.introspection? + scopes_stack = @complexities_on_type_by_query[visitor.query] + scopes_stack.pop end private - # Get a complexity value for a field, - # by getting the number or calling its proc - def get_complexity(irep_node, child_complexity) - field_defn = irep_node.definition - defined_complexity = field_defn.complexity - case defined_complexity - when Proc - defined_complexity.call(irep_node.query.context, irep_node.arguments, child_complexity) - when Numeric - defined_complexity + (child_complexity || 0) + # @return [Integer] + def max_possible_complexity(mode: :future) + @complexities_on_type_by_query.reduce(0) do |total, (query, scopes_stack)| + total + merged_max_complexity_for_scopes(query, [scopes_stack.first], mode) + end + end + + # @param query [GraphQL::Query] Used for `query.possible_types` + # @param scopes [Array] Array of scoped type complexities + # @param mode [:future, :legacy] + # @return [Integer] + def merged_max_complexity_for_scopes(query, scopes, mode) + # Aggregate a set of all possible scope types encountered (scope keys). + # Use a hash, but ignore the values; it's just a fast way to work with the keys. + possible_scope_types = scopes.each_with_object({}) do |scope, memo| + memo.merge!(scope) + end + + # Expand abstract scope types into their concrete implementations; + # overlapping abstracts coalesce through their intersecting types. + possible_scope_types.keys.each do |possible_scope_type| + next unless possible_scope_type.kind.abstract? + + query.types.possible_types(possible_scope_type).each do |impl_type| + possible_scope_types[impl_type] ||= true + end + possible_scope_types.delete(possible_scope_type) + end + + # Aggregate the lexical selections that may apply to each possible type, + # and then return the maximum cost among possible typed selections. + possible_scope_types.each_key.reduce(0) do |max, possible_scope_type| + # Collect inner selections from all scopes that intersect with this possible type. + all_inner_selections = scopes.each_with_object([]) do |scope, memo| + scope.each do |scope_type, inner_selections| + memo << inner_selections if types_intersect?(query, scope_type, possible_scope_type) + end + end + + # Find the maximum complexity for the scope type among possible lexical branches. + complexity = case mode + when :legacy + legacy_merged_max_complexity(query, all_inner_selections) + when :future + merged_max_complexity(query, all_inner_selections) + else + raise ArgumentError, "Expected :legacy or :future, not: #{mode.inspect}" + end + complexity > max ? complexity : max + end + end + + def types_intersect?(query, a, b) + return true if a == b + + if a.object_id < b.object_id + first_cache = @intersect_cache[a] + second_key = b else - raise("Invalid complexity: #{defined_complexity.inspect} on #{field_defn.name}") + first_cache = @intersect_cache[b] + second_key = a end + + if first_cache.key?(second_key) + first_cache[second_key] + else + a_types = @possible_types_cache[a] ||= query.types.possible_types(a).to_set + b_types = @possible_types_cache[b] ||= query.types.possible_types(b).to_set + first_cache[second_key] = a_types.intersect?(b_types) + end + end + + # A hook which is called whenever a field's max complexity is calculated. + # Override this method to capture individual field complexity details. + # + # @param scoped_type_complexity [ScopedTypeComplexity] + # @param max_complexity [Numeric] Field's maximum complexity including child complexity + # @param child_complexity [Numeric, nil] Field's child complexity + def field_complexity(scoped_type_complexity, max_complexity:, child_complexity: nil) end - # Selections on an object may apply differently depending on what is _actually_ returned by the resolve function. - # Find the maximum possible complexity among those combinations. - class TypeComplexity - def initialize - @types = Hash.new(0) + # @param inner_selections [Array>] Field selections for a scope + # @return [Integer] Total complexity value for all these selections in the parent scope + def merged_max_complexity(query, inner_selections) + child_scopes_by_key = {} + inner_selections.each do |inner_selection| + inner_selection.each do |k, v| + scopes = child_scopes_by_key[k] ||= [] + scopes << v + end end + # Add up the total cost for each unique field name's coalesced selections + total = 0 + child_scopes_by_key.each do |field_key, child_scopes| + # Compute maximum possible cost of child selections; + # composites merge their maximums, while leaf scopes are always zero. + # FieldsWillMerge validation assures all scopes are uniformly composite or leaf. + maximum_children_cost = if child_scopes.any?(&:composite?) + merged_max_complexity_for_scopes(query, child_scopes, :future) + else + 0 + end - # Return the max possible complexity for types in this selection - def max_possible_complexity - @types.each_value.max || 0 + # Identify the maximum cost and scope among possibilities + maximum_cost = 0 + maximum_scope = child_scopes.reduce(child_scopes.last) do |max_scope, possible_scope| + scope_cost = possible_scope.own_complexity(maximum_children_cost) + if scope_cost > maximum_cost + maximum_cost = scope_cost + possible_scope + else + max_scope + end + end + + field_complexity( + maximum_scope, + max_complexity: maximum_cost, + child_complexity: maximum_children_cost, + ) + + total += maximum_cost + end + + total + end + + def legacy_merged_max_complexity(query, inner_selections) + # Aggregate a set of all unique field selection keys across all scopes. + # Use a hash, but ignore the values; it's just a fast way to work with the keys. + unique_field_keys = inner_selections.each_with_object({}) do |inner_selection, memo| + memo.merge!(inner_selection) end - # Store the complexity for the branch on `type_defn`. - # Later we will see if this is the max complexity among branches. - def merge(type_defn, complexity) - @types[type_defn] += complexity + # Add up the total cost for each unique field name's coalesced selections + unique_field_keys.each_key.reduce(0) do |total, field_key| + composite_scopes = nil + field_cost = 0 + + # Collect composite selection scopes for further aggregation, + # leaf selections report their costs directly. + inner_selections.each do |inner_selection| + child_scope = inner_selection[field_key] + next unless child_scope + + # Empty child scopes are leaf nodes with zero child complexity. + if child_scope.empty? + field_cost = child_scope.own_complexity(0) + field_complexity(child_scope, max_complexity: field_cost, child_complexity: nil) + else + composite_scopes ||= [] + composite_scopes << child_scope + end + end + + if composite_scopes + child_complexity = merged_max_complexity_for_scopes(query, composite_scopes, :legacy) + + # This is the last composite scope visited; assume it's representative (for backwards compatibility). + # Note: it would be more correct to score each composite scope and use the maximum possibility. + field_cost = composite_scopes.last.own_complexity(child_complexity) + field_complexity(composite_scopes.last, max_complexity: field_cost, child_complexity: child_complexity) + end + + total + field_cost end end end diff --git a/lib/graphql/analysis/query_depth.rb b/lib/graphql/analysis/query_depth.rb index 845326e81ee..b6859bb119e 100644 --- a/lib/graphql/analysis/query_depth.rb +++ b/lib/graphql/analysis/query_depth.rb @@ -3,40 +3,55 @@ module GraphQL module Analysis # A query reducer for measuring the depth of a given query. # + # See https://graphql-ruby.org/queries/ast_analysis.html for more examples. + # # @example Logging the depth of a query - # Schema.query_analyzers << GraphQL::Analysis::QueryDepth.new { |query, depth| puts "GraphQL query depth: #{depth}" } + # class LogQueryDepth < GraphQL::Analysis::QueryDepth + # def result + # log("GraphQL query depth: #{@max_depth}") + # end + # end + # + # # In your Schema file: + # + # class MySchema < GraphQL::Schema + # query_analyzer LogQueryDepth + # end + # + # # When you run the query, the depth will get logged: + # # Schema.execute(query_str) # # GraphQL query depth: 8 # - class QueryDepth - def initialize(&block) - @depth_handler = block + class QueryDepth < Analyzer + def initialize(query) + @max_depth = 0 + @current_depth = 0 + @count_introspection_fields = query.schema.count_introspection_fields + super end - def initial_value(query) - { - max_depth: 0, - current_depth: 0, - query: query, - } + def on_enter_field(node, parent, visitor) + return if visitor.skipping? || + visitor.visiting_fragment_definition? || + (@count_introspection_fields == false && visitor.field_definition.introspection?) + + @current_depth += 1 end - def call(memo, visit_type, irep_node) - if irep_node.ast_node.is_a?(GraphQL::Language::Nodes::Field) - if visit_type == :enter - memo[:current_depth] += 1 - else - if memo[:max_depth] < memo[:current_depth] - memo[:max_depth] = memo[:current_depth] - end - memo[:current_depth] -= 1 - end + def on_leave_field(node, parent, visitor) + return if visitor.skipping? || + visitor.visiting_fragment_definition? || + (@count_introspection_fields == false && visitor.field_definition.introspection?) + + if @max_depth < @current_depth + @max_depth = @current_depth end - memo + @current_depth -= 1 end - def final_value(memo) - @depth_handler.call(memo[:query], memo[:max_depth]) + def result + @max_depth end end end diff --git a/lib/graphql/analysis/reducer_state.rb b/lib/graphql/analysis/reducer_state.rb deleted file mode 100644 index 1988fdbe1c2..00000000000 --- a/lib/graphql/analysis/reducer_state.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Analysis - class ReducerState - attr_reader :reducer - attr_accessor :memo, :errors - - def initialize(reducer, query) - @reducer = reducer - @memo = initialize_reducer(reducer, query) - @errors = [] - end - - def call(visit_type, irep_node) - @memo = @reducer.call(@memo, visit_type, irep_node) - rescue AnalysisError => err - @errors << err - end - - # Respond with any errors, if found. Otherwise, if the reducer accepts - # `final_value`, send it the last memo value. - # Otherwise, use the last value from the traversal. - # @return [Any] final memo value - def finalize_reducer - if @errors.any? - @errors - elsif reducer.respond_to?(:final_value) - reducer.final_value(@memo) - else - @memo - end - end - - private - - # If the reducer has an `initial_value` method, call it and store - # the result as `memo`. Otherwise, use `nil` as memo. - # @return [Any] initial memo value - def initialize_reducer(reducer, query) - if reducer.respond_to?(:initial_value) - reducer.initial_value(query) - else - nil - end - end - end - end -end diff --git a/lib/graphql/analysis/visitor.rb b/lib/graphql/analysis/visitor.rb new file mode 100644 index 00000000000..4e1d96bcdc2 --- /dev/null +++ b/lib/graphql/analysis/visitor.rb @@ -0,0 +1,280 @@ +# frozen_string_literal: true +module GraphQL + module Analysis + # Depth first traversal through a query AST, calling AST analyzers + # along the way. + # + # The visitor is a special case of GraphQL::Language::StaticVisitor, visiting + # only the selected operation, providing helpers for common use cases such + # as skipped fields and visiting fragment spreads. + # + # @see {GraphQL::Analysis::Analyzer} AST Analyzers for queries + class Visitor < GraphQL::Language::StaticVisitor + def initialize(query:, analyzers:, timeout:) + @analyzers = analyzers + @path = [] + @object_types = [] + @directives = [] + @field_definitions = [] + @argument_definitions = [] + @directive_definitions = [] + @rescued_errors = [] + @query = query + @schema = query.schema + @types = query.types + @response_path = [] + @skip_stack = [false] + @timeout_time = if timeout + Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) + timeout + else + Float::INFINITY + end + super(query.selected_operation) + end + + # @return [GraphQL::Query] the query being visited + attr_reader :query + + # @return [Array] Types whose scope we've entered + attr_reader :object_types + + # @return [Array] The path to the response key for the current field + def response_path + @response_path.dup + end + + # rubocop:disable Development/NoEvalCop This eval takes static inputs at load-time + # Visitor Hooks + [ + :operation_definition, :fragment_definition, + :inline_fragment, :field, :directive, :argument, :fragment_spread + ].each do |node_type| + module_eval <<-RUBY, __FILE__, __LINE__ + def call_on_enter_#{node_type}(node, parent) + @analyzers.each do |a| + a.on_enter_#{node_type}(node, parent, self) + rescue AnalysisError => err + @rescued_errors << err + end + end + + def call_on_leave_#{node_type}(node, parent) + @analyzers.each do |a| + a.on_leave_#{node_type}(node, parent, self) + rescue AnalysisError => err + @rescued_errors << err + end + end + + RUBY + end + # rubocop:enable Development/NoEvalCop + + def on_operation_definition(node, parent) + check_timeout + object_type = @schema.root_type_for_operation(node.operation_type) + @object_types.push(object_type) + @path.push("#{node.operation_type}#{node.name ? " #{node.name}" : ""}") + call_on_enter_operation_definition(node, parent) + super + call_on_leave_operation_definition(node, parent) + @object_types.pop + @path.pop + end + + def on_inline_fragment(node, parent) + check_timeout + object_type = if node.type + @types.type(node.type.name) + else + @object_types.last + end + @object_types.push(object_type) + @path.push("...#{node.type ? " on #{node.type.name}" : ""}") + @skipping = @skip_stack.last || skip?(node) + @skip_stack << @skipping + call_on_enter_inline_fragment(node, parent) + super + @skipping = @skip_stack.pop + call_on_leave_inline_fragment(node, parent) + @object_types.pop + @path.pop + end + + def on_field(node, parent) + check_timeout + @response_path.push(node.alias || node.name) + parent_type = @object_types.last + # This could be nil if the previous field wasn't found: + field_definition = parent_type && @types.field(parent_type, node.name) + @field_definitions.push(field_definition) + if !field_definition.nil? + next_object_type = field_definition.type.unwrap + @object_types.push(next_object_type) + else + @object_types.push(nil) + end + @path.push(node.alias || node.name) + + @skipping = @skip_stack.last || skip?(node) + @skip_stack << @skipping + + call_on_enter_field(node, parent) + super + @skipping = @skip_stack.pop + call_on_leave_field(node, parent) + @response_path.pop + @field_definitions.pop + @object_types.pop + @path.pop + end + + def on_directive(node, parent) + check_timeout + directive_defn = @schema.directives[node.name] + @directive_definitions.push(directive_defn) + call_on_enter_directive(node, parent) + super + call_on_leave_directive(node, parent) + @directive_definitions.pop + end + + def on_argument(node, parent) + check_timeout + argument_defn = if (arg = @argument_definitions.last) + arg_type = arg.type.unwrap + if arg_type.kind.input_object? + @types.argument(arg_type, node.name) + else + nil + end + elsif (directive_defn = @directive_definitions.last) + @types.argument(directive_defn, node.name) + elsif (field_defn = @field_definitions.last) + @types.argument(field_defn, node.name) + else + nil + end + + @argument_definitions.push(argument_defn) + @path.push(node.name) + call_on_enter_argument(node, parent) + super + call_on_leave_argument(node, parent) + @argument_definitions.pop + @path.pop + end + + def on_fragment_spread(node, parent) + check_timeout + @path.push("... #{node.name}") + @skipping = @skip_stack.last || skip?(node) + @skip_stack << @skipping + + call_on_enter_fragment_spread(node, parent) + enter_fragment_spread_inline(node) + super + @skipping = @skip_stack.pop + leave_fragment_spread_inline(node) + call_on_leave_fragment_spread(node, parent) + @path.pop + end + + # @return [GraphQL::BaseType] The current object type + def type_definition + @object_types.last + end + + # @return [GraphQL::BaseType] The type which the current type came from + def parent_type_definition + @object_types[-2] + end + + # @return [GraphQL::Field, nil] The most-recently-entered GraphQL::Field, if currently inside one + def field_definition + @field_definitions.last + end + + # @return [GraphQL::Field, nil] The GraphQL field which returned the object that the current field belongs to + def previous_field_definition + @field_definitions[-2] + end + + # @return [GraphQL::Directive, nil] The most-recently-entered GraphQL::Directive, if currently inside one + def directive_definition + @directive_definitions.last + end + + # @return [GraphQL::Argument, nil] The most-recently-entered GraphQL::Argument, if currently inside one + def argument_definition + @argument_definitions.last + end + + # @return [GraphQL::Argument, nil] The previous GraphQL argument + def previous_argument_definition + @argument_definitions[-2] + end + + private + + # Visit a fragment spread inline instead of visiting the definition + # by itself. + def enter_fragment_spread_inline(fragment_spread) + fragment_def = query.fragments[fragment_spread.name] + + object_type = if fragment_def.type + @types.type(fragment_def.type.name) + else + object_types.last + end + + object_types << object_type + + on_fragment_definition_children(fragment_def) + end + + # Visit a fragment spread inline instead of visiting the definition + # by itself. + def leave_fragment_spread_inline(_fragment_spread) + object_types.pop + end + + def skip?(ast_node) + dir = ast_node.directives + !dir.empty? && !GraphQL::Execution::DirectiveChecks.include?(dir, query) + end + + def check_timeout + if Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) > @timeout_time + raise GraphQL::Analysis::TimeoutError + end + end + end + end +end diff --git a/lib/graphql/argument.rb b/lib/graphql/argument.rb deleted file mode 100644 index 526b5b07ed8..00000000000 --- a/lib/graphql/argument.rb +++ /dev/null @@ -1,131 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # @api deprecated - class Argument - include GraphQL::Define::InstanceDefinable - accepts_definitions :name, :type, :description, :default_value, :as, :prepare, :method_access, :deprecation_reason - attr_reader :default_value - attr_accessor :description, :name, :as, :deprecation_reason - attr_accessor :ast_node - attr_accessor :method_access - alias :graphql_name :name - - ensure_defined(:name, :description, :default_value, :type=, :type, :as, :expose_as, :prepare, :method_access, :deprecation_reason) - - # @api private - module DefaultPrepare - def self.call(value, ctx); value; end - end - - def initialize - @prepare_proc = DefaultPrepare - end - - def initialize_copy(other) - @expose_as = nil - end - - def default_value? - !!@has_default_value - end - - def method_access? - # Treat unset as true -- only `false` should override - @method_access != false - end - - def default_value=(new_default_value) - if new_default_value == NO_DEFAULT_VALUE - @has_default_value = false - @default_value = nil - else - @has_default_value = true - @default_value = GraphQL::Argument.deep_stringify(new_default_value) - end - end - - # @!attribute name - # @return [String] The name of this argument on its {GraphQL::Field} or {GraphQL::InputObjectType} - - # @param new_input_type [GraphQL::BaseType, Proc] Assign a new input type for this argument (if it's a proc, it will be called after schema initialization) - def type=(new_input_type) - @clean_type = nil - @dirty_type = new_input_type - end - - # @return [GraphQL::BaseType] the input type for this argument - def type - @clean_type ||= GraphQL::BaseType.resolve_related_type(@dirty_type) - end - - # @return [String] The name of this argument inside `resolve` functions - def expose_as - @expose_as ||= (@as || @name).to_s - end - - # Backport this to support legacy-style directives - def keyword - @keyword ||= GraphQL::Schema::Member::BuildType.underscore(expose_as).to_sym - end - - # @param value [Object] The incoming value from variables or query string literal - # @param ctx [GraphQL::Query::Context] - # @return [Object] The prepared `value` for this argument or `value` itself if no `prepare` function exists. - def prepare(value, ctx) - @prepare_proc.call(value, ctx) - end - - # Assign a `prepare` function to prepare this argument's value before `resolve` functions are called. - # @param prepare_proc [#] - def prepare=(prepare_proc) - @prepare_proc = BackwardsCompatibility.wrap_arity(prepare_proc, from: 1, to: 2, name: "Argument#prepare(value, ctx)") - end - - def type_class - metadata[:type_class] - end - - NO_DEFAULT_VALUE = Object.new - # @api private - def self.from_dsl(name, type_or_argument = nil, description = nil, default_value: NO_DEFAULT_VALUE, as: nil, prepare: DefaultPrepare, **kwargs, &block) - name_s = name.to_s - - # Move some positional args into keywords if they're present - description && kwargs[:description] ||= description - kwargs[:name] ||= name_s - kwargs[:default_value] ||= default_value - kwargs[:as] ||= as - - unless prepare == DefaultPrepare - kwargs[:prepare] ||= prepare - end - - if !type_or_argument.nil? && !type_or_argument.is_a?(GraphQL::Argument) - # Maybe a string, proc or BaseType - kwargs[:type] = type_or_argument - end - - if type_or_argument.is_a?(GraphQL::Argument) - type_or_argument.redefine(**kwargs, &block) - else - GraphQL::Argument.define(**kwargs, &block) - end - end - - # @api private - def self.deep_stringify(val) - case val - when Array - val.map { |v| deep_stringify(v) } - when Hash - new_val = {} - val.each do |k, v| - new_val[k.to_s] = deep_stringify(v) - end - new_val - else - val - end - end - end -end diff --git a/lib/graphql/authorization.rb b/lib/graphql/authorization.rb deleted file mode 100644 index 3a408a1e249..00000000000 --- a/lib/graphql/authorization.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Authorization - class InaccessibleFieldsError < GraphQL::AnalysisError - # @return [Array] Fields that failed `.accessible?` checks - attr_reader :fields - - # @return [GraphQL::Query::Context] The current query's context - attr_reader :context - - # @return [Array] The visited nodes that failed `.accessible?` checks - # @see {#fields} for the Field definitions - attr_reader :irep_nodes - - def initialize(fields:, irep_nodes:, context:) - @fields = fields - @irep_nodes = irep_nodes - @context = context - super("Some fields in this query are not accessible: #{fields.map(&:graphql_name).join(", ")}") - end - end - - # @deprecated authorization at query runtime is generally a better idea. - module Analyzer - module_function - def initial_value(query) - { - schema: query.schema, - context: query.context, - inaccessible_nodes: [], - } - end - - def call(memo, visit_type, irep_node) - if visit_type == :enter - field = irep_node.definition - if field - schema = memo[:schema] - ctx = memo[:context] - next_field_accessible = schema.accessible?(field, ctx) - if !next_field_accessible - memo[:inaccessible_nodes] << irep_node - else - arg_accessible = true - irep_node.arguments.argument_values.each do |name, arg_value| - arg_accessible = schema.accessible?(arg_value.definition, ctx) - if !arg_accessible - memo[:inaccessible_nodes] << irep_node - break - end - end - if arg_accessible - return_type = field.type.unwrap - next_type_accessible = schema.accessible?(return_type, ctx) - if !next_type_accessible - memo[:inaccessible_nodes] << irep_node - end - end - end - end - end - memo - end - - def final_value(memo) - nodes = memo[:inaccessible_nodes] - if nodes.any? - fields = nodes.map do |node| - field_inst = node.definition - # Get the "source of truth" for this field - field_inst.metadata[:type_class] || field_inst - end - context = memo[:context] - err = InaccessibleFieldsError.new(fields: fields, irep_nodes: nodes, context: context) - context.schema.inaccessible_fields(err) - else - nil - end - end - end - end -end diff --git a/lib/graphql/autoload.rb b/lib/graphql/autoload.rb new file mode 100644 index 00000000000..82aa538cfc4 --- /dev/null +++ b/lib/graphql/autoload.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module GraphQL + # @see GraphQL::Railtie for automatic Rails integration + module Autoload + # Register a constant named `const_name` to be loaded from `path`. + # This is like `Kernel#autoload` but it tracks the constants so they can be eager-loaded with {#eager_load!} + # @param const_name [Symbol] + # @param path [String] + # @return [void] + def autoload(const_name, path) + @_eagerloaded_constants ||= [] + @_eagerloaded_constants << const_name + + super const_name, path + end + + # Call this to load this constant's `autoload` dependents and continue calling recursively + # @return [void] + def eager_load! + @_eager_loading = true + if @_eagerloaded_constants + @_eagerloaded_constants.each { |const_name| const_get(const_name) } + @_eagerloaded_constants = nil + end + nil + ensure + @_eager_loading = false + end + + private + + # @return [Boolean] `true` if GraphQL-Ruby is currently eager-loading its constants + def eager_loading? + @_eager_loading ||= false + end + end +end diff --git a/lib/graphql/backtrace.rb b/lib/graphql/backtrace.rb index 5086d5c3f28..c97543d1fe0 100644 --- a/lib/graphql/backtrace.rb +++ b/lib/graphql/backtrace.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require "graphql/backtrace/inspect_result" -require "graphql/backtrace/legacy_tracer" require "graphql/backtrace/table" require "graphql/backtrace/traced_error" -require "graphql/backtrace/tracer" module GraphQL # Wrap unhandled errors with {TracedError}. # @@ -23,13 +20,8 @@ class Backtrace def_delegators :to_a, :each, :[] - def self.use(schema_defn, legacy: false) - tracer = if legacy - self::LegacyTracer - else - self::Tracer - end - schema_defn.tracer(tracer) + def self.use(schema_defn) + schema_defn.using_backtrace = true end def initialize(context, value: nil) @@ -45,20 +37,5 @@ def inspect def to_a @table.to_backtrace end - - # Used for internal bookkeeping - # @api private - class Frame - attr_reader :path, :query, :ast_node, :object, :field, :arguments, :parent_frame - def initialize(path:, query:, ast_node:, object:, field:, arguments:, parent_frame:) - @path = path - @query = query - @ast_node = ast_node - @field = field - @object = object - @arguments = arguments - @parent_frame = parent_frame - end - end end end diff --git a/lib/graphql/backtrace/inspect_result.rb b/lib/graphql/backtrace/inspect_result.rb deleted file mode 100644 index f02cf9b9624..00000000000 --- a/lib/graphql/backtrace/inspect_result.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Backtrace - module InspectResult - module_function - - def inspect_result(obj) - case obj - when Hash - "{" + - obj.map do |key, val| - "#{key}: #{inspect_truncated(val)}" - end.join(", ") + - "}" - when Array - "[" + - obj.map { |v| inspect_truncated(v) }.join(", ") + - "]" - when Query::Context::SharedMethods - if obj.invalid_null? - "nil" - else - inspect_truncated(obj.value) - end - else - inspect_truncated(obj) - end - end - - def inspect_truncated(obj) - case obj - when Hash - "{...}" - when Array - "[...]" - when Query::Context::SharedMethods - if obj.invalid_null? - "nil" - else - inspect_truncated(obj.value) - end - when GraphQL::Execution::Lazy - "(unresolved)" - else - "#{obj.inspect}" - end - end - end - end -end diff --git a/lib/graphql/backtrace/legacy_tracer.rb b/lib/graphql/backtrace/legacy_tracer.rb deleted file mode 100644 index 06a88883033..00000000000 --- a/lib/graphql/backtrace/legacy_tracer.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Backtrace - module LegacyTracer - module_function - - # Implement the {GraphQL::Tracing} API. - def trace(key, metadata) - case key - when "lex", "parse" - # No context here, don't have a query yet - nil - when "execute_multiplex", "analyze_multiplex" - # No query context yet - nil - when "validate", "analyze_query", "execute_query", "execute_query_lazy" - query = metadata[:query] || metadata[:queries].first - push_data = query - multiplex = query.multiplex - when "execute_field", "execute_field_lazy" - # The interpreter passes `query:`, legacy passes `context:` - context = metadata[:context] || ((q = metadata[:query]) && q.context) - push_data = context - multiplex = context.query.multiplex - else - # Custom key, no backtrace data for this - nil - end - - if push_data - multiplex.context[:last_graphql_backtrace_context] = push_data - end - - if key == "execute_multiplex" - begin - yield - rescue StandardError => err - # This is an unhandled error from execution, - # Re-raise it with a GraphQL trace. - potential_context = metadata[:multiplex].context[:last_graphql_backtrace_context] - - if potential_context.is_a?(GraphQL::Query::Context) || potential_context.is_a?(GraphQL::Query::Context::FieldResolutionContext) - raise TracedError.new(err, potential_context) - else - raise - end - ensure - metadata[:multiplex].context.delete(:last_graphql_backtrace_context) - end - else - yield - end - end - end - end -end diff --git a/lib/graphql/backtrace/table.rb b/lib/graphql/backtrace/table.rb index a2086db8bff..04a60769d59 100644 --- a/lib/graphql/backtrace/table.rb +++ b/lib/graphql/backtrace/table.rb @@ -36,7 +36,102 @@ def to_backtrace private def rows - @rows ||= build_rows(@context, rows: [HEADERS], top: true) + @rows ||= begin + query = @context.query + query_ctx = @context + runtime_inst = query_ctx.namespace(:interpreter_runtime)[:runtime] + result = runtime_inst.instance_variable_get(:@response) + rows = [] + result_path = [] + last_part = nil + path = @context.current_path + path.each do |path_part| + value = value_at(runtime_inst, result_path) + + if result_path.empty? + name = query.selected_operation.operation_type || "query" + if (n = query.selected_operation_name) + name += " #{n}" + end + args = query.variables + else + name = result.graphql_field.path + args = result.graphql_arguments + end + + object = result.graphql_parent ? result.graphql_parent.graphql_application_value : result.graphql_application_value + object = object.object.inspect + + rows << [ + result.ast_node.position.join(":"), + name, + "#{object}", + args.to_h.inspect, + inspect_result(value), + ] + + result_path << path_part + if path_part == path.last + last_part = path_part + else + result = result[path_part] + end + end + + object = result.graphql_application_value.object.inspect + ast_node = nil + result.graphql_selections.each do |s| + found_ast_node = find_ast_node(s, last_part) + if found_ast_node + ast_node = found_ast_node + break + end + end + + if ast_node + field_defn = query.get_field(result.graphql_result_type, ast_node.name) + args = begin + if (cached_args = query.arguments_cache.cached_arguments_for(ast_node, field_defn)) + cached_args.to_h + else + EmptyObjects::EMPTY_HASH + end + rescue StandardError => err + "Failed to load arguments, #{err.class}: #{err.message}" + end + + field_path = field_defn.path + if ast_node.alias + field_path += " as #{ast_node.alias}" + end + + rows << [ + ast_node.position.join(":"), + field_path, + "#{object}", + args.inspect, + inspect_result(@override_value) + ] + end + + rows << HEADERS + rows.reverse! + rows + end + end + + def find_ast_node(node, last_part) + return nil unless node + return node if node.respond_to?(:alias) && node.respond_to?(:name) && (node.alias == last_part || node.name == last_part) + return nil unless node.respond_to?(:selections) + return nil if node.selections.nil? || node.selections.empty? + + node.selections.each do |child| + child_ast_node = find_ast_node(child, last_part) + return child_ast_node if child_ast_node + end + + nil end # @return [String] @@ -75,85 +170,44 @@ def render_table(rows) table end - # @return [Array] 5 items for a backtrace table (not `key`) - def build_rows(context_entry, rows:, top: false) - case context_entry - when Backtrace::Frame - field_alias = context_entry.ast_node.respond_to?(:alias) && context_entry.ast_node.alias - value = if top && @override_value - @override_value - else - value_at(@context.query.context.namespace(:interpreter)[:runtime], context_entry.path) - end - rows << [ - "#{context_entry.ast_node ? context_entry.ast_node.position.join(":") : ""}", - "#{context_entry.field.path}#{field_alias ? " as #{field_alias}" : ""}", - "#{context_entry.object.object.inspect}", - context_entry.arguments.to_h.inspect, - Backtrace::InspectResult.inspect_result(value), - ] - if (parent = context_entry.parent_frame) - build_rows(parent, rows: rows) - else - rows - end - when GraphQL::Query::Context::FieldResolutionContext - ctx = context_entry - field_name = "#{ctx.irep_node.owner_type.name}.#{ctx.field.name}" - position = "#{ctx.ast_node.line}:#{ctx.ast_node.col}" - field_alias = ctx.ast_node.alias - object = ctx.object - if object.is_a?(GraphQL::Schema::Object) - object = object.object - end - rows << [ - "#{position}", - "#{field_name}#{field_alias ? " as #{field_alias}" : ""}", - "#{object.inspect}", - ctx.irep_node.arguments.to_h.inspect, - Backtrace::InspectResult.inspect_result(top && @override_value ? @override_value : ctx.value), - ] - - build_rows(ctx.parent, rows: rows) - when GraphQL::Query::Context - query = context_entry.query - op = query.selected_operation - if op - op_type = op.operation_type - position = "#{op.line}:#{op.col}" - else - op_type = "query" - position = "?:?" - end - op_name = query.selected_operation_name - object = query.root_value - if object.is_a?(GraphQL::Schema::Object) - object = object.object - end - value = value_at(context_entry.namespace(:interpreter)[:runtime], []) - rows << [ - "#{position}", - "#{op_type}#{op_name ? " #{op_name}" : ""}", - "#{object.inspect}", - query.variables.to_h.inspect, - Backtrace::InspectResult.inspect_result(value), - ] - else - raise "Unexpected get_rows subject #{context_entry.class} (#{context_entry.inspect})" - end - end def value_at(runtime, path) response = runtime.final_result path.each do |key| - if response && (response = response[key]) - next - else - break - end + response && (response = response[key]) end response end + + def inspect_result(obj) + case obj + when Hash + "{" + + obj.map do |key, val| + "#{key}: #{inspect_truncated(val)}" + end.join(", ") + + "}" + when Array + "[" + + obj.map { |v| inspect_truncated(v) }.join(", ") + + "]" + else + inspect_truncated(obj) + end + end + + def inspect_truncated(obj) + case obj + when Hash + "{...}" + when Array + "[...]" + when GraphQL::Execution::Lazy + "(unresolved)" + else + "#{obj.inspect}" + end + end end end end diff --git a/lib/graphql/backtrace/tracer.rb b/lib/graphql/backtrace/tracer.rb deleted file mode 100644 index b38ede380f7..00000000000 --- a/lib/graphql/backtrace/tracer.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Backtrace - # TODO this is not fiber-friendly - module Tracer - module_function - - # Implement the {GraphQL::Tracing} API. - def trace(key, metadata) - case key - when "lex", "parse" - # No context here, don't have a query yet - nil - when "execute_multiplex", "analyze_multiplex" - # No query context yet - nil - when "validate", "analyze_query", "execute_query", "execute_query_lazy" - push_key = [] - if (query = metadata[:query]) || ((queries = metadata[:queries]) && (query = queries.first)) - push_data = query - multiplex = query.multiplex - elsif (multiplex = metadata[:multiplex]) - push_data = multiplex.queries.first - end - when "execute_field", "execute_field_lazy" - query = metadata[:query] || raise(ArgumentError, "Add `legacy: true` to use GraphQL::Backtrace without the interpreter runtime.") - multiplex = query.multiplex - push_key = metadata[:path] - parent_frame = multiplex.context[:graphql_backtrace_contexts][push_key[0..-2]] - - if parent_frame.is_a?(GraphQL::Query) - parent_frame = parent_frame.context - end - - push_data = Frame.new( - query: query, - path: push_key, - ast_node: metadata[:ast_node], - field: metadata[:field], - object: metadata[:object], - arguments: metadata[:arguments], - parent_frame: parent_frame, - ) - else - # Custom key, no backtrace data for this - nil - end - - if push_data && multiplex - push_storage = multiplex.context[:graphql_backtrace_contexts] ||= {} - push_storage[push_key] = push_data - multiplex.context[:last_graphql_backtrace_context] = push_data - end - - if key == "execute_multiplex" - multiplex_context = metadata[:multiplex].context - begin - yield - rescue StandardError => err - # This is an unhandled error from execution, - # Re-raise it with a GraphQL trace. - potential_context = multiplex_context[:last_graphql_backtrace_context] - - if potential_context.is_a?(GraphQL::Query::Context) || - potential_context.is_a?(GraphQL::Query::Context::FieldResolutionContext) || - potential_context.is_a?(Backtrace::Frame) - raise TracedError.new(err, potential_context) - else - raise - end - ensure - multiplex_context.delete(:graphql_backtrace_contexts) - multiplex_context.delete(:last_graphql_backtrace_context) - end - else - yield - end - end - end - end -end diff --git a/lib/graphql/backwards_compatibility.rb b/lib/graphql/backwards_compatibility.rb deleted file mode 100644 index 831b5a66003..00000000000 --- a/lib/graphql/backwards_compatibility.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # Helpers for migrating in a backwards-compatible way - # Remove this in GraphQL-Ruby 2.0, when all users of it will be gone. - # @api private - module BackwardsCompatibility - module_function - # Given a callable whose API used to take `from` arguments, - # check its arity, and if needed, apply a wrapper so that - # it can be called with `to` arguments. - # If a wrapper is applied, warn the application with `name`. - # - # If `last`, then use the last arguments to call the function. - def wrap_arity(callable, from:, to:, name:, last: false) - arity = get_arity(callable) - if arity == to || arity < 0 - # It already matches, return it as is - callable - elsif arity == from - # It has the old arity, so wrap it with an arity converter - message ="#{name} with #{from} arguments is deprecated, it now accepts #{to} arguments, see:" - backtrace = caller(0, 20) - # Find the first line in the trace that isn't library internals: - user_line = backtrace.find {|l| l !~ /lib\/graphql/ } - GraphQL::Deprecation.warn(message + "\n" + user_line + "\n") - wrapper = last ? LastArgumentsWrapper : FirstArgumentsWrapper - wrapper.new(callable, from) - else - raise "Can't wrap #{callable} (arity: #{arity}) to have arity #{to}" - end - end - - def get_arity(callable) - case callable - when Method, Proc - callable.arity - else - callable.method(:call).arity - end - end - - class FirstArgumentsWrapper - def initialize(callable, old_arity) - @callable = callable - @old_arity = old_arity - end - - def call(*args) - backwards_compat_args = args.first(@old_arity) - @callable.call(*backwards_compat_args) - end - end - - class LastArgumentsWrapper < FirstArgumentsWrapper - def call(*args) - backwards_compat_args = args.last(@old_arity) - @callable.call(*backwards_compat_args) - end - end - end -end diff --git a/lib/graphql/base_type.rb b/lib/graphql/base_type.rb deleted file mode 100644 index 5c4151434b2..00000000000 --- a/lib/graphql/base_type.rb +++ /dev/null @@ -1,230 +0,0 @@ -# frozen_string_literal: true -require "graphql/relay/type_extensions" - -module GraphQL - # The parent for all type classes. - class BaseType - include GraphQL::Define::NonNullWithBang - include GraphQL::Define::InstanceDefinable - include GraphQL::Relay::TypeExtensions - - accepts_definitions :name, :description, - :introspection, - :default_scalar, - :default_relay, - { - connection: GraphQL::Define::AssignConnection, - global_id_field: GraphQL::Define::AssignGlobalIdField, - } - - ensure_defined(:graphql_name, :name, :description, :introspection?, :default_scalar?) - - attr_accessor :ast_node - - def initialize - @introspection = false - @default_scalar = false - @default_relay = false - end - - def initialize_copy(other) - super - # Reset these derived defaults - @connection_type = nil - @edge_type = nil - end - - # @return [String] the name of this type, must be unique within a Schema - attr_reader :name - # Future-compatible alias - # @see {GraphQL::SchemaMember} - alias :graphql_name :name - # Future-compatible alias - # @see {GraphQL::SchemaMember} - alias :graphql_definition :itself - - def type_class - metadata[:type_class] - end - - def name=(name) - GraphQL::NameValidator.validate!(name) - @name = name - end - - # @return [String, nil] a description for this type - attr_accessor :description - - # @return [Boolean] Is this type a predefined introspection type? - def introspection? - @introspection - end - - # @return [Boolean] Is this type a built-in scalar type? (eg, `String`, `Int`) - def default_scalar? - @default_scalar - end - - # @return [Boolean] Is this type a built-in Relay type? (`Node`, `PageInfo`) - def default_relay? - @default_relay - end - - # @api private - attr_writer :introspection, :default_scalar, :default_relay - - # @param other [GraphQL::BaseType] compare to this object - # @return [Boolean] are these types equivalent? (incl. non-null, list) - # @see {ModifiesAnotherType#==} for override on List & NonNull types - def ==(other) - other.is_a?(GraphQL::BaseType) && self.name == other.name - end - - # If this type is modifying an underlying type, - # return the underlying type. (Otherwise, return `self`.) - def unwrap - self - end - - # @return [GraphQL::NonNullType] a non-null version of this type - def to_non_null_type - GraphQL::NonNullType.new(of_type: self) - end - - # @return [GraphQL::ListType] a list version of this type - def to_list_type - GraphQL::ListType.new(of_type: self) - end - - module ModifiesAnotherType - def unwrap - self.of_type.unwrap - end - - def ==(other) - other.is_a?(ModifiesAnotherType) && other.of_type == of_type - end - end - - # Find out which possible type to use for `value`. - # Returns self if there are no possible types (ie, not Union or Interface) - def resolve_type(value, ctx) - self - end - - # Print the human-readable name of this type using the query-string naming pattern - def to_s - name - end - - alias :inspect :to_s - alias :to_type_signature :to_s - - def valid_isolated_input?(value) - valid_input?(value, GraphQL::Query::NullContext) - end - - def validate_isolated_input(value) - validate_input(value, GraphQL::Query::NullContext) - end - - def coerce_isolated_input(value) - coerce_input(value, GraphQL::Query::NullContext) - end - - def coerce_isolated_result(value) - coerce_result(value, GraphQL::Query::NullContext) - end - - def valid_input?(value, ctx = nil) - if ctx.nil? - warn_deprecated_coerce("valid_isolated_input?") - ctx = GraphQL::Query::NullContext - end - - validate_input(value, ctx).valid? - end - - def validate_input(value, ctx = nil) - if ctx.nil? - warn_deprecated_coerce("validate_isolated_input") - ctx = GraphQL::Query::NullContext - end - - if value.nil? - GraphQL::Query::InputValidationResult.new - else - validate_non_null_input(value, ctx) - end - end - - def coerce_input(value, ctx = nil) - if value.nil? - nil - else - if ctx.nil? - warn_deprecated_coerce("coerce_isolated_input") - ctx = GraphQL::Query::NullContext - end - coerce_non_null_input(value, ctx) - end - end - - def coerce_result(value, ctx) - raise GraphQL::RequiredImplementationMissingError - end - - # Types with fields may override this - # @param name [String] field name to lookup for this type - # @return [GraphQL::Field, nil] - def get_field(name) - nil - end - - # During schema definition, types can be defined inside procs or as strings. - # This function converts it to a type instance - # @return [GraphQL::BaseType] - def self.resolve_related_type(type_arg) - case type_arg - when Proc - # lazy-eval it, then try again - resolve_related_type(type_arg.call) - when String - # Get a constant by this name - resolve_related_type(Object.const_get(type_arg)) - else - if type_arg.respond_to?(:graphql_definition) - type_arg.graphql_definition - else - type_arg - end - end - end - - # Return a GraphQL string for the type definition - # @param schema [GraphQL::Schema] - # @param printer [GraphQL::Schema::Printer] - # @see {GraphQL::Schema::Printer#initialize for additional options} - # @return [String] type definition - def to_definition(schema, printer: nil, **args) - printer ||= GraphQL::Schema::Printer.new(schema, **args) - printer.print_type(self) - end - - # Returns true if this is a non-nullable type. A nullable list of non-nullables is considered nullable. - def non_null? - false - end - - # Returns true if this is a list type. A non-nullable list is considered a list. - def list? - false - end - - private - - def warn_deprecated_coerce(alt_method_name) - GraphQL::Deprecation.warn("Coercing without a context is deprecated; use `#{alt_method_name}` if you don't want context-awareness") - end - end -end diff --git a/lib/graphql/boolean_type.rb b/lib/graphql/boolean_type.rb deleted file mode 100644 index f13b29397b6..00000000000 --- a/lib/graphql/boolean_type.rb +++ /dev/null @@ -1,2 +0,0 @@ -# frozen_string_literal: true -GraphQL::BOOLEAN_TYPE = GraphQL::Types::Boolean.graphql_definition diff --git a/lib/graphql/coercion_error.rb b/lib/graphql/coercion_error.rb index e94ef685f68..8980d6c0abf 100644 --- a/lib/graphql/coercion_error.rb +++ b/lib/graphql/coercion_error.rb @@ -1,13 +1,5 @@ # frozen_string_literal: true module GraphQL - class CoercionError < GraphQL::Error - # @return [Hash] Optional custom data for error objects which will be added - # under the `extensions` key. - attr_accessor :extensions - - def initialize(message, extensions: nil) - @extensions = extensions - super(message) - end + class CoercionError < GraphQL::ExecutionError end end diff --git a/lib/graphql/compatibility.rb b/lib/graphql/compatibility.rb deleted file mode 100644 index f9169e7b00f..00000000000 --- a/lib/graphql/compatibility.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true -require "graphql/compatibility/execution_specification" -require "graphql/compatibility/lazy_execution_specification" -require "graphql/compatibility/query_parser_specification" -require "graphql/compatibility/schema_parser_specification" diff --git a/lib/graphql/compatibility/execution_specification.rb b/lib/graphql/compatibility/execution_specification.rb deleted file mode 100644 index a08a0035f34..00000000000 --- a/lib/graphql/compatibility/execution_specification.rb +++ /dev/null @@ -1,436 +0,0 @@ -# frozen_string_literal: true -require "graphql/compatibility/execution_specification/counter_schema" -require "graphql/compatibility/execution_specification/specification_schema" - -module GraphQL - module Compatibility - # Test an execution strategy. This spec is not meant as a development aid. - # Rather, when the strategy _works_, run it here to see if it has any differences - # from the built-in strategy. - # - # - Custom scalar input / output - # - Null propagation - # - Query-level masking - # - Directive support - # - Typecasting - # - Error handling (raise / return GraphQL::ExecutionError) - # - Provides Irep & AST node to resolve fn - # - Skipping fields - # - # Some things are explicitly _not_ tested here, because they're handled - # by other parts of the system: - # - # - Schema definition (including types and fields) - # - Parsing & parse errors - # - AST -> IRep transformation (eg, fragment merging) - # - Query validation and analysis - # - Relay features - # - module ExecutionSpecification - # Make a minitest suite for this execution strategy, making sure it - # fulfills all the requirements of this library. - # @param execution_strategy [<#new, #execute>] An execution strategy class - # @return [Class] A test suite for this execution strategy - def self.build_suite(execution_strategy) - GraphQL::Deprecation.warn "#{self} will be removed from GraphQL-Ruby 2.0. There is no replacement, please open an issue on GitHub if you need support." - Class.new(Minitest::Test) do - class << self - attr_accessor :counter_schema, :specification_schema - end - - self.specification_schema = SpecificationSchema.build(execution_strategy) - self.counter_schema = CounterSchema.build(execution_strategy) - - def execute_query(query_string, **kwargs) - kwargs[:root_value] = SpecificationSchema::DATA - self.class.specification_schema.execute(query_string, **kwargs) - end - - def test_it_fetches_data - query_string = %| - query getData($nodeId: ID = "1001") { - flh: node(id: $nodeId) { - __typename - ... on Person { - name @include(if: true) - skippedName: name @skip(if: true) - birthdate - age(on: 1477660133) - } - - ... on NamedEntity { - ne_tn: __typename - ne_n: name - } - - ... on Organization { - org_n: name - } - } - } - | - res = execute_query(query_string) - - assert_equal nil, res["errors"], "It doesn't have an errors key" - - flh = res["data"]["flh"] - assert_equal "Fannie Lou Hamer", flh["name"], "It returns values" - assert_equal Time.new(1917, 10, 6).to_i, flh["birthdate"], "It returns custom scalars" - assert_equal 99, flh["age"], "It runs resolve functions" - assert_equal "Person", flh["__typename"], "It serves __typename" - assert_equal "Person", flh["ne_tn"], "It serves __typename on interfaces" - assert_equal "Fannie Lou Hamer", flh["ne_n"], "It serves interface fields" - assert_equal false, flh.key?("skippedName"), "It obeys @skip" - assert_equal false, flh.key?("org_n"), "It doesn't apply other type fields" - end - - def test_it_iterates_over_each - query_string = %| - query getData($nodeId: ID = "1002") { - node(id: $nodeId) { - ... on Person { - organizations { name } - } - } - } - | - - res = execute_query(query_string) - assert_equal ["SNCC"], res["data"]["node"]["organizations"].map { |o| o["name"] } - end - - def test_it_skips_skipped_fields - query_str = <<-GRAPHQL - { - o3001: organization(id: "3001") { name } - o2001: organization(id: "2001") { name } - } - GRAPHQL - - res = execute_query(query_str) - assert_equal ["o2001"], res["data"].keys - assert_equal false, res.key?("errors") - end - - def test_it_propagates_nulls_to_field - query_string = %| - query getOrg($id: ID = "2001"){ - failure: node(id: $id) { - ... on Organization { - name - leader { name } - } - } - success: node(id: $id) { - ... on Organization { - name - } - } - } - | - res = execute_query(query_string) - - failure = res["data"]["failure"] - success = res["data"]["success"] - - assert_equal nil, failure, "It propagates nulls to the next nullable field" - assert_equal({"name" => "SNCC"}, success, "It serves the same object if no invalid null is encountered") - assert_equal 1, res["errors"].length , "It returns an error for the invalid null" - end - - def test_it_propages_nulls_to_operation - query_string = %| - { - foundOrg: organization(id: "2001") { - name - } - organization(id: "2999") { - name - } - } - | - - res = execute_query(query_string) - assert_equal nil, res["data"] - assert_equal 1, res["errors"].length - end - - def test_it_exposes_raised_and_returned_user_execution_errors - query_string = %| - { - organization(id: "2001") { - name - returnedError - raisedError - } - organizations { - returnedError - raisedError - } - } - | - - res = execute_query(query_string) - - assert_equal "SNCC", res["data"]["organization"]["name"], "It runs the rest of the query" - - expected_errors = [ - { - "message"=>"This error was returned", - "locations"=>[{"line"=>5, "column"=>19}], - "path"=>["organization", "returnedError"] - }, - { - "message"=>"This error was raised", - "locations"=>[{"line"=>6, "column"=>19}], - "path"=>["organization", "raisedError"] - }, - { - "message"=>"This error was raised", - "locations"=>[{"line"=>10, "column"=>19}], - "path"=>["organizations", 0, "raisedError"] - }, - { - "message"=>"This error was raised", - "locations"=>[{"line"=>10, "column"=>19}], - "path"=>["organizations", 1, "raisedError"] - }, - { - "message"=>"This error was returned", - "locations"=>[{"line"=>9, "column"=>19}], - "path"=>["organizations", 0, "returnedError"] - }, - { - "message"=>"This error was returned", - "locations"=>[{"line"=>9, "column"=>19}], - "path"=>["organizations", 1, "returnedError"] - }, - ] - - expected_errors.each do |expected_err| - assert_includes res["errors"], expected_err - end - end - - def test_it_applies_masking - no_org = ->(member, ctx) { member.name == "Organization" } - query_string = %| - { - node(id: "2001") { - __typename - } - }| - - err = assert_raises(GraphQL::UnresolvedTypeError) { - execute_query(query_string, except: no_org) - } - - query_string = %| - { - organization(id: "2001") { name } - }| - - res = execute_query(query_string, except: no_org) - - assert_equal nil, res["data"] - assert_equal 1, res["errors"].length - assert_equal "SNCC", err.value.name - assert_equal GraphQL::Relay::Node.interface, err.field.type - assert_equal 1, err.possible_types.length - assert_equal "Organization", err.resolved_type.name - assert_equal "Query", err.parent_type.name - - query_string = %| - { - __type(name: "Organization") { name } - }| - - res = execute_query(query_string, except: no_org) - - assert_equal nil, res["data"]["__type"] - assert_equal nil, res["errors"] - end - - def test_it_provides_nodes_to_resolve - query_string = %| - { - organization(id: "2001") { - name - nodePresence - } - }| - - res = execute_query(query_string) - assert_equal "SNCC", res["data"]["organization"]["name"] - assert_equal [true, true, false], res["data"]["organization"]["nodePresence"] - end - - def test_it_runs_the_introspection_query - execute_query(GraphQL::Introspection::INTROSPECTION_QUERY) - end - - def test_it_propagates_deeply_nested_nulls - query_string = %| - { - node(id: "1001") { - ... on Person { - name - first_organization { - leader { - name - } - } - } - } - } - | - res = execute_query(query_string) - assert_equal nil, res["data"]["node"] - assert_equal 1, res["errors"].length - end - - def test_it_doesnt_add_errors_for_invalid_nulls_from_execution_errors - query_string = %| - query getOrg($id: ID = "2001"){ - failure: node(id: $id) { - ... on Organization { - name - leader { name } - } - } - } - | - res = execute_query(query_string, context: {return_error: true}) - error_messages = res["errors"].map { |e| e["message"] } - assert_equal ["Error on Nullable"], error_messages - end - - def test_it_only_resolves_fields_once_on_typed_fragments - res = self.class.counter_schema.execute(" - { - counter { count } - ... on HasCounter { - counter { count } - } - } - ") - - expected_data = { - "counter" => { "count" => 1 } - } - assert_equal expected_data, res["data"] - assert_equal 1, self.class.counter_schema.metadata[:count] - - # Deep typed children are correctly distinguished: - res = self.class.counter_schema.execute(" - { - counter { - ... on Counter { - counter { count } - } - ... on AltCounter { - counter { count, t: __typename } - } - } - } - ") - - expected_data = { - "counter" => { "counter" => { "count" => 2 } } - } - assert_equal expected_data, res["data"] - end - - def test_it_runs_middleware - log = [] - query_string = %| - { - node(id: "2001") { - __typename - } - }| - execute_query(query_string, context: {middleware_log: log}) - assert_equal ["node", "__typename"], log - end - - def test_it_uses_type_error_hooks_for_invalid_nulls - log = [] - query_string = %| - { - node(id: "1001") { - ... on Person { - name - first_organization { - leader { - name - } - } - } - } - }| - - res = execute_query(query_string, context: { type_errors: log }) - assert_equal nil, res["data"]["node"] - assert_equal [nil], log - end - - def test_it_uses_type_error_hooks_for_failed_type_resolution - log = [] - query_string = %| - { - node(id: "2003") { - __typename - } - }| - - assert_raises(GraphQL::UnresolvedTypeError) { - execute_query(query_string, context: { type_errors: log }) - } - - assert_equal [SpecificationSchema::BOGUS_NODE], log - end - - def test_it_treats_failed_type_resolution_like_nil - log = [] - ctx = { type_errors: log, gobble: true } - query_string = %| - { - node(id: "2003") { - __typename - } - }| - - res = execute_query(query_string, context: ctx) - - assert_equal nil, res["data"]["node"] - assert_equal false, res.key?("errors") - assert_equal [SpecificationSchema::BOGUS_NODE], log - - query_string_2 = %| - { - requiredNode(id: "2003") { - __typename - } - }| - - res = execute_query(query_string_2, context: ctx) - - assert_equal nil, res["data"] - assert_equal false, res.key?("errors") - assert_equal [SpecificationSchema::BOGUS_NODE, SpecificationSchema::BOGUS_NODE], log - end - - def test_it_skips_connections - query_type = GraphQL::ObjectType.define do - name "Query" - connection :skipped, types[query_type], resolve: ->(o,a,c) { c.skip } - end - schema = GraphQL::Schema.define(query: query_type) - res = schema.execute("{ skipped { __typename } }") - assert_equal({"data" => nil}, res) - end - end - end - end - end -end diff --git a/lib/graphql/compatibility/execution_specification/counter_schema.rb b/lib/graphql/compatibility/execution_specification/counter_schema.rb deleted file mode 100644 index a4c9c5d2100..00000000000 --- a/lib/graphql/compatibility/execution_specification/counter_schema.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Compatibility - module ExecutionSpecification - module CounterSchema - def self.build(execution_strategy) - counter_type = nil - schema = nil - - has_count_interface = GraphQL::InterfaceType.define do - name "HasCount" - field :count, types.Int - field :counter, ->{ has_count_interface } - end - - counter_type = GraphQL::ObjectType.define do - name "Counter" - interfaces [has_count_interface] - field :count, types.Int, resolve: ->(o,a,c) { schema.metadata[:count] += 1 } - field :counter, has_count_interface, resolve: ->(o,a,c) { :counter } - end - - alt_counter_type = GraphQL::ObjectType.define do - name "AltCounter" - interfaces [has_count_interface] - field :count, types.Int, resolve: ->(o,a,c) { schema.metadata[:count] += 1 } - field :counter, has_count_interface, resolve: ->(o,a,c) { :counter } - end - - has_counter_interface = GraphQL::InterfaceType.define do - name "HasCounter" - field :counter, has_count_interface - end - - query_type = GraphQL::ObjectType.define do - name "Query" - interfaces [has_counter_interface] - field :counter, has_count_interface, resolve: ->(o,a,c) { :counter } - end - - schema = GraphQL::Schema.define( - query: query_type, - resolve_type: ->(t, o, c) { o == :counter ? counter_type : nil }, - orphan_types: [alt_counter_type, counter_type], - query_execution_strategy: execution_strategy, - ) - schema.metadata[:count] = 0 - schema - end - end - end - end -end diff --git a/lib/graphql/compatibility/execution_specification/specification_schema.rb b/lib/graphql/compatibility/execution_specification/specification_schema.rb deleted file mode 100644 index 9953a62337b..00000000000 --- a/lib/graphql/compatibility/execution_specification/specification_schema.rb +++ /dev/null @@ -1,200 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Compatibility - module ExecutionSpecification - module SpecificationSchema - BOGUS_NODE = OpenStruct.new({ bogus: true }) - - DATA = { - "1001" => OpenStruct.new({ - name: "Fannie Lou Hamer", - birthdate: Time.new(1917, 10, 6), - organization_ids: [], - }), - "1002" => OpenStruct.new({ - name: "John Lewis", - birthdate: Time.new(1940, 2, 21), - organization_ids: ["2001"], - }), - "1003" => OpenStruct.new({ - name: "Diane Nash", - birthdate: Time.new(1938, 5, 15), - organization_ids: ["2001", "2002"], - }), - "1004" => OpenStruct.new({ - name: "Ralph Abernathy", - birthdate: Time.new(1926, 3, 11), - organization_ids: ["2002"], - }), - "2001" => OpenStruct.new({ - name: "SNCC", - leader_id: nil, # fail on purpose - }), - "2002" => OpenStruct.new({ - name: "SCLC", - leader_id: "1004", - }), - "2003" => BOGUS_NODE, - } - - # A list object must implement #each - class CustomCollection - def initialize(storage) - @storage = storage - end - - def each(&block) - @storage.each(&block) - end - end - - module TestMiddleware - def self.call(parent_type, parent_object, field_definition, field_args, query_context, &next_middleware) - query_context[:middleware_log] && query_context[:middleware_log] << field_definition.name - next_middleware.call - end - end - - def self.build(execution_strategy) - organization_type = nil - - timestamp_type = GraphQL::ScalarType.define do - name "Timestamp" - coerce_input ->(value, _ctx) { Time.at(value.to_i) } - coerce_result ->(value, _ctx) { value.to_i } - end - - named_entity_interface_type = GraphQL::InterfaceType.define do - name "NamedEntity" - field :name, !types.String - end - - person_type = GraphQL::ObjectType.define do - name "Person" - interfaces [named_entity_interface_type] - field :name, !types.String - field :birthdate, timestamp_type - field :age, types.Int do - argument :on, !timestamp_type - resolve ->(obj, args, ctx) { - if obj.birthdate.nil? - nil - else - age_on = args[:on] - age_years = age_on.year - obj.birthdate.year - this_year_birthday = Time.new(age_on.year, obj.birthdate.month, obj.birthdate.day) - if this_year_birthday > age_on - age_years -= 1 - end - end - age_years - } - end - field :organizations, types[organization_type] do - resolve ->(obj, args, ctx) { - CustomCollection.new(obj.organization_ids.map { |id| DATA[id] }) - } - end - field :first_organization, !organization_type do - resolve ->(obj, args, ctx) { - DATA[obj.organization_ids.first] - } - end - end - - organization_type = GraphQL::ObjectType.define do - name "Organization" - interfaces [named_entity_interface_type] - field :name, !types.String - field :leader, !person_type do - resolve ->(obj, args, ctx) { - DATA[obj.leader_id] || (ctx[:return_error] ? ExecutionError.new("Error on Nullable") : nil) - } - end - field :returnedError, types.String do - resolve ->(o, a, c) { - GraphQL::ExecutionError.new("This error was returned") - } - end - field :raisedError, types.String do - resolve ->(o, a, c) { - raise GraphQL::ExecutionError.new("This error was raised") - } - end - - field :nodePresence, !types[!types.Boolean] do - resolve ->(o, a, ctx) { - [ - ctx.irep_node.is_a?(GraphQL::InternalRepresentation::Node), - ctx.ast_node.is_a?(GraphQL::Language::Nodes::AbstractNode), - false, # just testing - ] - } - end - end - - node_union_type = GraphQL::UnionType.define do - name "Node" - possible_types [person_type, organization_type] - end - - query_type = GraphQL::ObjectType.define do - name "Query" - field :node, node_union_type do - argument :id, !types.ID - resolve ->(obj, args, ctx) { - obj[args[:id]] - } - end - - field :requiredNode, node_union_type.to_non_null_type do - argument :id, !types.ID - resolve ->(obj, args, ctx) { - obj[args[:id]] - } - end - - field :organization, !organization_type do - argument :id, !types.ID - resolve ->(obj, args, ctx) { - if args[:id].start_with?("2") - obj[args[:id]] - else - # test context.skip - ctx.skip - end - } - end - - field :organizations, types[organization_type] do - resolve ->(obj, args, ctx) { - [obj["2001"], obj["2002"]] - } - end - end - - GraphQL::Schema.define do - query_execution_strategy execution_strategy - query query_type - - resolve_type ->(type, obj, ctx) { - if obj.respond_to?(:birthdate) - person_type - elsif obj.respond_to?(:leader_id) - organization_type - else - nil - end - } - - type_error ->(err, ctx) { - ctx[:type_errors] && (ctx[:type_errors] << err.value) - ctx[:gobble] || GraphQL::Schema::DefaultTypeError.call(err, ctx) - } - middleware(TestMiddleware) - end - end - end - end - end -end diff --git a/lib/graphql/compatibility/lazy_execution_specification.rb b/lib/graphql/compatibility/lazy_execution_specification.rb deleted file mode 100644 index f6b47d7fc8a..00000000000 --- a/lib/graphql/compatibility/lazy_execution_specification.rb +++ /dev/null @@ -1,215 +0,0 @@ -# frozen_string_literal: true -require "graphql/compatibility/lazy_execution_specification/lazy_schema" - -module GraphQL - module Compatibility - module LazyExecutionSpecification - # @param execution_strategy [<#new, #execute>] An execution strategy class - # @return [Class] A test suite for this execution strategy - def self.build_suite(execution_strategy) - GraphQL::Deprecation.warn "#{self} will be removed from GraphQL-Ruby 2.0. There is no replacement, please open an issue on GitHub if you need support." - - Class.new(Minitest::Test) do - class << self - attr_accessor :lazy_schema - end - - self.lazy_schema = LazySchema.build(execution_strategy) - - def test_it_resolves_lazy_values - pushes = [] - query_str = %| - { - p1: push(value: 1) { - value - } - p2: push(value: 2) { - push(value: 3) { - value - push(value: 21) { - value - } - } - } - p3: push(value: 4) { - push(value: 5) { - value - push(value: 22) { - value - } - } - } - } - | - res = self.class.lazy_schema.execute(query_str, context: {pushes: pushes}) - - expected_data = { - "p1"=>{"value"=>1}, - "p2"=>{"push"=>{"value"=>3, "push"=>{"value"=>21}}}, - "p3"=>{"push"=>{"value"=>5, "push"=>{"value"=>22}}}, - } - assert_equal expected_data, res["data"] - - expected_pushes = [ - [1,2,4], # first level - [3,5], # second level - [21, 22], - ] - assert_equal expected_pushes, pushes - end - - def test_it_maintains_path - query_str = %| - { - push(value: 2) { - push(value: 3) { - fail1: push(value: 14) { - value - } - fail2: push(value: 14) { - value - } - } - } - } - | - res = self.class.lazy_schema.execute(query_str, context: {pushes: []}) - assert_equal nil, res["data"] - # The first fail causes the second field to never resolve - assert_equal 1, res["errors"].length - assert_equal ["push", "push", "fail1", "value"], res["errors"][0]["path"] - end - - def test_it_resolves_mutation_values_eagerly - pushes = [] - query_str = %| - mutation { - p1: push(value: 1) { - value - } - p2: push(value: 2) { - push(value: 3) { - value - } - } - p3: push(value: 4) { - p5: push(value: 5) { - value - } - p6: push(value: 6) { - value - } - } - } - | - res = self.class.lazy_schema.execute(query_str, context: {pushes: pushes}) - - expected_data = { - "p1"=>{"value"=>1}, - "p2"=>{"push"=>{"value"=>3}}, - "p3"=>{"p5"=>{"value"=>5},"p6"=>{"value"=>6}}, - } - assert_equal expected_data, res["data"] - - expected_pushes = [ - [1], # first operation - [2], [3], # second operation - [4], [5, 6], # third operation - ] - assert_equal expected_pushes, pushes - end - - def test_it_resolves_lazy_connections - pushes = [] - query_str = %| - { - pushes(values: [1,2,3]) { - edges { - node { - value - push(value: 4) { - value - } - } - } - } - } - | - res = self.class.lazy_schema.execute(query_str, context: {pushes: pushes}) - - expected_edges = [ - {"node"=>{"value"=>1, "push"=>{"value"=>4}}}, - {"node"=>{"value"=>2, "push"=>{"value"=>4}}}, - {"node"=>{"value"=>3, "push"=>{"value"=>4}}}, - ] - assert_equal expected_edges, res["data"]["pushes"]["edges"] - assert_equal [[1, 2, 3], [4, 4, 4]], pushes - end - - def test_it_calls_lazy_resolve_instrumentation - query_str = %| - { - p1: push(value: 1) { - value - } - p2: push(value: 2) { - push(value: 3) { - value - } - } - pushes(values: [1,2,3]) { - edges { - node { - value - push(value: 4) { - value - } - } - } - } - } - | - - log = [] - self.class.lazy_schema.execute(query_str, context: {lazy_instrumentation: log, pushes: []}) - expected_log = [ - "PUSH", - "Query.push: 1", - "Query.push: 2", - "Query.pushes: [1, 2, 3]", - "PUSH", - "LazyPush.push: 3", - "LazyPushEdge.node: 1", - "LazyPushEdge.node: 2", - "LazyPushEdge.node: 3", - "PUSH", - "LazyPush.push: 4", - "LazyPush.push: 4", - "LazyPush.push: 4", - ] - assert_equal expected_log, log - end - - def test_it_skips_ctx_skip - query_string = <<-GRAPHQL - { - p0: push(value: 15) { value } - p1: push(value: 1) { value } - p2: push(value: 2) { - value - p3: push(value: 15) { - value - } - } - } - GRAPHQL - pushes = [] - res = self.class.lazy_schema.execute(query_string, context: {pushes: pushes}) - assert_equal [[1,2]], pushes - assert_equal({"data"=>{"p1"=>{"value"=>1}, "p2"=>{"value"=>2}}}, res) - end - end - end - end - end -end diff --git a/lib/graphql/compatibility/lazy_execution_specification/lazy_schema.rb b/lib/graphql/compatibility/lazy_execution_specification/lazy_schema.rb deleted file mode 100644 index 5274b87500e..00000000000 --- a/lib/graphql/compatibility/lazy_execution_specification/lazy_schema.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Compatibility - module LazyExecutionSpecification - module LazySchema - class LazyPush - attr_reader :value - def initialize(ctx, value) - if value == 13 - @value = nil - elsif value == 14 - @value = GraphQL::ExecutionError.new("oops!") - elsif value == 15 - @skipped = true - @value = ctx.skip - else - @value = value - end - @context = ctx - pushes = @context[:lazy_pushes] ||= [] - if !@skipped - pushes << @value - end - end - - def push - if @skipped - @value - else - if @context[:lazy_pushes].include?(@value) - @context[:lazy_instrumentation] && @context[:lazy_instrumentation] << "PUSH" - @context[:pushes] << @context[:lazy_pushes] - @context[:lazy_pushes] = [] - end - # Something that _behaves_ like this object, but isn't registered lazy - OpenStruct.new(value: @value) - end - end - end - - class LazyPushCollection - def initialize(ctx, values) - @ctx = ctx - @values = values - end - - def push - @values.map { |v| LazyPush.new(@ctx, v) } - end - - def value - @values - end - end - - module LazyInstrumentation - def self.instrument(type, field) - prev_lazy_resolve = field.lazy_resolve_proc - field.redefine { - lazy_resolve ->(o, a, c) { - result = prev_lazy_resolve.call(o, a, c) - c[:lazy_instrumentation] && c[:lazy_instrumentation].push("#{type.name}.#{field.name}: #{o.value}") - result - } - } - end - end - - def self.build(execution_strategy) - lazy_push_type = GraphQL::ObjectType.define do - name "LazyPush" - field :value, !types.Int - field :push, !lazy_push_type do - argument :value, types.Int - resolve ->(o, a, c) { - LazyPush.new(c, a[:value]) - } - end - end - - query_type = GraphQL::ObjectType.define do - name "Query" - field :push, !lazy_push_type do - argument :value, types.Int - resolve ->(o, a, c) { - LazyPush.new(c, a[:value]) - } - end - - connection :pushes, lazy_push_type.connection_type do - argument :values, types[types.Int], method_access: false - resolve ->(o, a, c) { - LazyPushCollection.new(c, a[:values]) - } - end - end - - GraphQL::Schema.define do - query(query_type) - mutation(query_type) - query_execution_strategy(execution_strategy) - mutation_execution_strategy(execution_strategy) - lazy_resolve(LazyPush, :push) - lazy_resolve(LazyPushCollection, :push) - instrument(:field, LazyInstrumentation) - end - end - end - end - end -end diff --git a/lib/graphql/compatibility/query_parser_specification.rb b/lib/graphql/compatibility/query_parser_specification.rb deleted file mode 100644 index 922dd7e04a1..00000000000 --- a/lib/graphql/compatibility/query_parser_specification.rb +++ /dev/null @@ -1,266 +0,0 @@ -# frozen_string_literal: true -require "graphql/compatibility/query_parser_specification/query_assertions" -require "graphql/compatibility/query_parser_specification/parse_error_specification" - -module GraphQL - module Compatibility - # This asserts that a given parse function turns a string into - # the proper tree of {{GraphQL::Language::Nodes}}. - module QueryParserSpecification - # @yieldparam query_string [String] A query string to parse - # @yieldreturn [GraphQL::Language::Nodes::Document] - # @return [Class] A test suite for this parse function - def self.build_suite(&block) - GraphQL::Deprecation.warn "#{self} will be removed from GraphQL-Ruby 2.0. There is no replacement, please open an issue on GitHub if you need support." - - Class.new(Minitest::Test) do - include QueryAssertions - include ParseErrorSpecification - - @@parse_fn = block - - def parse(query_string) - @@parse_fn.call(query_string) - end - - def test_it_parses_queries - document = parse(QUERY_STRING) - query = document.definitions.first - assert_valid_query(query) - assert_valid_fragment(document.definitions.last) - assert_valid_variable(query.variables.first) - field = query.selections.first - assert_valid_field(field) - assert_valid_variable_argument(field.arguments.first) - assert_valid_literal_argument(field.arguments.last) - assert_valid_directive(field.directives.first) - fragment_spread = query.selections[1].selections.last - assert_valid_fragment_spread(fragment_spread) - assert_valid_typed_inline_fragment(query.selections[2]) - assert_valid_typeless_inline_fragment(query.selections[3]) - end - - def test_it_parses_unnamed_queries - document = parse("{ name, age, height }") - operation = document.definitions.first - assert_equal 1, document.definitions.length - assert_equal "query", operation.operation_type - assert_equal nil, operation.name - assert_equal 3, operation.selections.length - end - - def test_it_parses_the_introspection_query - parse(GraphQL::Introspection::INTROSPECTION_QUERY) - end - - def test_it_parses_inputs - query_string = %| - { - field( - int: 3, - float: 4.7e-24, - bool: false, - string: "☀︎🏆 \\b \\f \\n \\r \\t \\" \u00b6 \\u00b6 / \\/", - enum: ENUM_NAME, - array: [7, 8, 9] - object: {a: [1,2,3], b: {c: "4"}} - unicode_bom: "\xef\xbb\xbfquery" - keywordEnum: on - nullValue: null - nullValueInObject: {a: null, b: "b"} - nullValueInArray: ["a", null, "b"] - blockString: """ - Hello, - World - """ - ) - } - | - document = parse(query_string) - inputs = document.definitions.first.selections.first.arguments - assert_equal 3, inputs[0].value, "Integers" - assert_equal 0.47e-23, inputs[1].value, "Floats" - assert_equal false, inputs[2].value, "Booleans" - assert_equal %|☀︎🏆 \b \f \n \r \t " ¶ ¶ / /|, inputs[3].value, "Strings" - assert_instance_of GraphQL::Language::Nodes::Enum, inputs[4].value - assert_equal "ENUM_NAME", inputs[4].value.name, "Enums" - assert_equal [7,8,9], inputs[5].value, "Lists" - - obj = inputs[6].value - assert_equal "a", obj.arguments[0].name - assert_equal [1,2,3], obj.arguments[0].value - assert_equal "b", obj.arguments[1].name - assert_equal "c", obj.arguments[1].value.arguments[0].name - assert_equal "4", obj.arguments[1].value.arguments[0].value - - assert_equal %|\xef\xbb\xbfquery|, inputs[7].value, "Unicode BOM" - assert_equal "on", inputs[8].value.name, "Enum value 'on'" - - assert_instance_of GraphQL::Language::Nodes::NullValue, inputs[9].value - - args = inputs[10].value.arguments - assert_instance_of GraphQL::Language::Nodes::NullValue, args.find{ |arg| arg.name == 'a' }.value - assert_equal 'b', args.find{ |arg| arg.name == 'b' }.value - - values = inputs[11].value - assert_equal 'a', values[0] - assert_instance_of GraphQL::Language::Nodes::NullValue, values[1] - assert_equal 'b', values[2] - - block_str_value = inputs[12].value - assert_equal "Hello,\n World", block_str_value - end - - def test_it_doesnt_parse_nonsense_variables - query_string_1 = "query Vars($var1) { cheese(id: $var1) { flavor } }" - query_string_2 = "query Vars2($var1: Int = $var1) { cheese(id: $var1) { flavor } }" - - err_1 = assert_raises(GraphQL::ParseError) do - parse(query_string_1) - end - assert_equal [1,17], [err_1.line, err_1.col] - - err_2 = assert_raises(GraphQL::ParseError) do - parse(query_string_2) - end - assert_equal [1,26], [err_2.line, err_2.col] - end - - def test_enum_value_definitions_have_a_position - document = parse(""" - enum Enum { - VALUE - } - """) - - assert_equal [3, 17], document.definitions[0].values[0].position - end - - def test_field_definitions_have_a_position - document = parse(""" - type A { - field: String - } - """) - - assert_equal [3, 17], document.definitions[0].fields[0].position - end - - def test_input_value_definitions_have_a_position - document = parse(""" - input A { - field: String - } - """) - - assert_equal [3, 17], document.definitions[0].fields[0].position - end - - def test_parses_when_there_are_no_interfaces - schema = " - type A { - a: String - } - " - - document = parse(schema) - - assert_equal [], document.definitions[0].interfaces.map(&:name) - end - - def test_parses_implements_with_leading_ampersand - schema = " - type A implements & B { - a: String - } - " - - document = parse(schema) - - assert_equal ["B"], document.definitions[0].interfaces.map(&:name) - assert_equal [2, 35], document.definitions[0].interfaces[0].position - end - - def test_parses_implements_with_leading_ampersand_and_multiple_interfaces - schema = " - type A implements & B & C { - a: String - } - " - - document = parse(schema) - - assert_equal ["B", "C"], document.definitions[0].interfaces.map(&:name) - assert_equal [2, 35], document.definitions[0].interfaces[0].position - assert_equal [2, 39], document.definitions[0].interfaces[1].position - end - - def test_parses_implements_without_leading_ampersand - schema = " - type A implements B { - a: String - } - " - - document = parse(schema) - - assert_equal ["B"], document.definitions[0].interfaces.map(&:name) - assert_equal [2, 33], document.definitions[0].interfaces[0].position - end - - def test_parses_implements_without_leading_ampersand_and_multiple_interfaces - schema = " - type A implements B & C { - a: String - } - " - - document = parse(schema) - - assert_equal ["B", "C"], document.definitions[0].interfaces.map(&:name) - assert_equal [2, 33], document.definitions[0].interfaces[0].position - assert_equal [2, 37], document.definitions[0].interfaces[1].position - end - - def test_supports_old_syntax_for_parsing_multiple_interfaces - schema = " - type A implements B, C { - a: String - } - " - - document = parse(schema) - - assert_equal ["B", "C"], document.definitions[0].interfaces.map(&:name) - assert_equal [2, 33], document.definitions[0].interfaces[0].position - assert_equal [2, 36], document.definitions[0].interfaces[1].position - end - end - end - - QUERY_STRING = %| - query getStuff($someVar: Int = 1, $anotherVar: [String!] ) @skip(if: false) { - myField: someField(someArg: $someVar, ok: 1.4) @skip(if: $anotherVar) @thing(or: "Whatever") - - anotherField(someArg: [1,2,3]) { - nestedField - ... moreNestedFields @skip(if: true) - } - - ... on OtherType @include(unless: false){ - field(arg: [{key: "value", anotherKey: 0.9, anotherAnotherKey: WHATEVER}]) - anotherField - } - - ... { - id - } - } - - fragment moreNestedFields on NestedType @or(something: "ok") { - anotherNestedField @enum(directive: true) - } - | - end - end -end diff --git a/lib/graphql/compatibility/query_parser_specification/parse_error_specification.rb b/lib/graphql/compatibility/query_parser_specification/parse_error_specification.rb deleted file mode 100644 index 3866483293e..00000000000 --- a/lib/graphql/compatibility/query_parser_specification/parse_error_specification.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Compatibility - module QueryParserSpecification - module ParseErrorSpecification - def assert_raises_parse_error(query_string) - assert_raises(GraphQL::ParseError) { - parse(query_string) - } - end - - def test_it_includes_line_and_column - err = assert_raises_parse_error(" - query getCoupons { - allCoupons: {data{id}} - } - ") - - assert_includes(err.message, '{') - assert_equal(3, err.line) - assert_equal(27, err.col) - end - - def test_it_rejects_unterminated_strings - assert_raises_parse_error('{ " }') - assert_raises_parse_error(%|{ "\n" }|) - end - - def test_it_rejects_unexpected_ends - assert_raises_parse_error("query { stuff { thing }") - end - - def assert_rejects_character(char) - err = assert_raises_parse_error("{ field#{char} }") - expected_char = char.inspect.gsub('"', '').downcase - msg_downcase = err.message.downcase - # Case-insensitive for UTF-8 printing - assert_includes(msg_downcase, expected_char, "The message includes the invalid character") - end - - def test_it_rejects_invalid_characters - assert_rejects_character(";") - assert_rejects_character("\a") - assert_rejects_character("\xef") - assert_rejects_character("\v") - assert_rejects_character("\f") - assert_rejects_character("\xa0") - end - - def test_it_rejects_bad_unicode - assert_raises_parse_error(%|{ field(arg:"\\x") }|) - assert_raises_parse_error(%|{ field(arg:"\\u1") }|) - assert_raises_parse_error(%|{ field(arg:"\\u0XX1") }|) - assert_raises_parse_error(%|{ field(arg:"\\uXXXX") }|) - assert_raises_parse_error(%|{ field(arg:"\\uFXXX") }|) - assert_raises_parse_error(%|{ field(arg:"\\uXXXF") }|) - end - - def test_it_rejects_empty_inline_fragments - assert_raises_parse_error(" - query { - viewer { - login { - ... on String { - - } - } - } - } - ") - end - - def test_it_rejects_blank_queries - assert_raises_parse_error("") - assert_raises_parse_error(" ") - assert_raises_parse_error("\t \t") - assert_raises_parse_error(" # comment ") - end - - def test_it_restricts_on - assert_raises_parse_error("{ ...on }") - assert_raises_parse_error("fragment on on Type { field }") - end - end - end - end -end diff --git a/lib/graphql/compatibility/query_parser_specification/query_assertions.rb b/lib/graphql/compatibility/query_parser_specification/query_assertions.rb deleted file mode 100644 index eef257f833a..00000000000 --- a/lib/graphql/compatibility/query_parser_specification/query_assertions.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Compatibility - module QueryParserSpecification - module QueryAssertions - def assert_valid_query(query) - assert query.is_a?(GraphQL::Language::Nodes::OperationDefinition) - assert_equal "getStuff", query.name - assert_equal "query", query.operation_type - assert_equal 2, query.variables.length - assert_equal 4, query.selections.length - assert_equal 1, query.directives.length - assert_equal [2, 13], [query.line, query.col] - end - - def assert_valid_fragment(fragment_def) - assert fragment_def.is_a?(GraphQL::Language::Nodes::FragmentDefinition) - assert_equal "moreNestedFields", fragment_def.name - assert_equal 1, fragment_def.selections.length - assert_equal "NestedType", fragment_def.type.name - assert_equal 1, fragment_def.directives.length - assert_equal [20, 13], fragment_def.position - end - - def assert_valid_variable(variable) - assert_equal "someVar", variable.name - assert_equal "Int", variable.type.name - assert_equal 1, variable.default_value - assert_equal [2, 28], variable.position - end - - def assert_valid_field(field) - assert_equal "someField", field.name - assert_equal "myField", field.alias - assert_equal 2, field.directives.length - assert_equal 2, field.arguments.length - assert_equal 0, field.selections.length - assert_equal [3, 15], field.position - end - - def assert_valid_literal_argument(argument) - assert_equal "ok", argument.name - assert_equal 1.4, argument.value - end - - def assert_valid_variable_argument(argument) - assert_equal "someArg", argument.name - assert_equal "someVar", argument.value.name - end - - def assert_valid_fragment_spread(fragment_spread) - assert_equal "moreNestedFields", fragment_spread.name - assert_equal 1, fragment_spread.directives.length - assert_equal [7, 17], fragment_spread.position - end - - def assert_valid_directive(directive) - assert_equal "skip", directive.name - assert_equal "if", directive.arguments.first.name - assert_equal 1, directive.arguments.length - assert_equal [3, 62], directive.position - end - - def assert_valid_typed_inline_fragment(inline_fragment) - assert_equal "OtherType", inline_fragment.type.name - assert_equal 2, inline_fragment.selections.length - assert_equal 1, inline_fragment.directives.length - assert_equal [10, 15], inline_fragment.position - end - - def assert_valid_typeless_inline_fragment(inline_fragment) - assert_equal nil, inline_fragment.type - assert_equal 1, inline_fragment.selections.length - assert_equal 0, inline_fragment.directives.length - end - end - end - end -end diff --git a/lib/graphql/compatibility/schema_parser_specification.rb b/lib/graphql/compatibility/schema_parser_specification.rb deleted file mode 100644 index 0517d1e7f8b..00000000000 --- a/lib/graphql/compatibility/schema_parser_specification.rb +++ /dev/null @@ -1,682 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Compatibility - # This asserts that a given parse function turns a string into - # the proper tree of {{GraphQL::Language::Nodes}}. - module SchemaParserSpecification - # @yieldparam query_string [String] A query string to parse - # @yieldreturn [GraphQL::Language::Nodes::Document] - # @return [Class] A test suite for this parse function - def self.build_suite(&block) - GraphQL::Deprecation.warn "#{self} will be removed from GraphQL-Ruby 2.0. There is no replacement, please open an issue on GitHub if you need support." - - Class.new(Minitest::Test) do - @@parse_fn = block - - def parse(query_string) - @@parse_fn.call(query_string) - end - - def test_it_parses_object_types - document = parse(' - # This is what - # somebody said about something - type Comment implements Node @deprecated(reason: "No longer supported") { - id: ID! - } - ') - - type = document.definitions.first - assert_equal GraphQL::Language::Nodes::ObjectTypeDefinition, type.class - assert_equal 'Comment', type.name - assert_equal "This is what\nsomebody said about something", type.description - assert_equal ['Node'], type.interfaces.map(&:name) - assert_equal ['id'], type.fields.map(&:name) - assert_equal [], type.fields[0].arguments - assert_equal 'ID', type.fields[0].type.of_type.name - assert_equal 1, type.directives.length - - deprecated_directive = type.directives[0] - assert_equal 'deprecated', deprecated_directive.name - assert_equal 'reason', deprecated_directive.arguments[0].name - assert_equal 'No longer supported', deprecated_directive.arguments[0].value - end - - def test_it_parses_scalars - document = parse('scalar DateTime') - - type = document.definitions.first - assert_equal GraphQL::Language::Nodes::ScalarTypeDefinition, type.class - assert_equal 'DateTime', type.name - end - - def test_it_parses_enum_types - document = parse(' - enum DogCommand { - # Good dog - SIT - DOWN @deprecated(reason: "No longer supported") - HEEL - } - ') - - type = document.definitions.first - assert_equal GraphQL::Language::Nodes::EnumTypeDefinition, type.class - assert_equal 'DogCommand', type.name - assert_equal 3, type.values.length - - assert_equal 'SIT', type.values[0].name - assert_equal [], type.values[0].directives - assert_equal "Good dog", type.values[0].description - - assert_equal 'DOWN', type.values[1].name - assert_equal 1, type.values[1].directives.length - deprecated_directive = type.values[1].directives[0] - assert_equal 'deprecated', deprecated_directive.name - assert_equal 'reason', deprecated_directive.arguments[0].name - assert_equal 'No longer supported', deprecated_directive.arguments[0].value - - assert_equal 'HEEL', type.values[2].name - assert_equal [], type.values[2].directives - end - - def test_it_parses_union_types - document = parse( - "union BagOfThings = \n" \ - "A |\n" \ - "B |\n" \ - "C" - ) - - union = document.definitions.first - - assert_equal GraphQL::Language::Nodes::UnionTypeDefinition, union.class - assert_equal 'BagOfThings', union.name - assert_equal 3, union.types.length - assert_equal [1, 1], union.position - - assert_equal GraphQL::Language::Nodes::TypeName, union.types[0].class - assert_equal 'A', union.types[0].name - assert_equal [2, 1], union.types[0].position - - assert_equal GraphQL::Language::Nodes::TypeName, union.types[1].class - assert_equal 'B', union.types[1].name - assert_equal [3, 1], union.types[1].position - - assert_equal GraphQL::Language::Nodes::TypeName, union.types[2].class - assert_equal 'C', union.types[2].name - assert_equal [4, 1], union.types[2].position - end - - def test_it_parses_input_types - document = parse(' - input EmptyMutationInput { - clientMutationId: String - } - ') - - type = document.definitions.first - assert_equal GraphQL::Language::Nodes::InputObjectTypeDefinition, type.class - assert_equal 'EmptyMutationInput', type.name - assert_equal ['clientMutationId'], type.fields.map(&:name) - assert_equal 'String', type.fields[0].type.name - assert_equal nil, type.fields[0].default_value - end - - def test_it_parses_directives - document = parse(' - directive @include(if: Boolean!) - on FIELD - | FRAGMENT_SPREAD - | INLINE_FRAGMENT - ') - - type = document.definitions.first - assert_equal GraphQL::Language::Nodes::DirectiveDefinition, type.class - assert_equal 'include', type.name - - assert_equal 1, type.arguments.length - assert_equal 'if', type.arguments[0].name - assert_equal 'Boolean', type.arguments[0].type.of_type.name - - assert_equal 3, type.locations.length - - assert_instance_of GraphQL::Language::Nodes::DirectiveLocation, type.locations[0] - assert_equal 'FIELD', type.locations[0].name - assert_equal [3, 20], type.locations[0].position - - assert_instance_of GraphQL::Language::Nodes::DirectiveLocation, type.locations[1] - assert_equal 'FRAGMENT_SPREAD', type.locations[1].name - assert_equal [4, 19], type.locations[1].position - - assert_instance_of GraphQL::Language::Nodes::DirectiveLocation, type.locations[2] - assert_equal 'INLINE_FRAGMENT', type.locations[2].name - assert_equal [5, 19], type.locations[2].position - end - - def test_it_parses_field_arguments - document = parse(' - type Mutation { - post( - id: ID! @deprecated(reason: "Not used"), - # This is what goes in the post - data: String - ): Post - } - ') - - field = document.definitions.first.fields.first - assert_equal ['id', 'data'], field.arguments.map(&:name) - id_arg = field.arguments[0] - - deprecated_directive = id_arg.directives[0] - assert_equal 'deprecated', deprecated_directive.name - assert_equal 'reason', deprecated_directive.arguments[0].name - assert_equal 'Not used', deprecated_directive.arguments[0].value - - data_arg = field.arguments[1] - assert_equal "data", data_arg.name - assert_equal "This is what goes in the post", data_arg.description - end - - def test_it_parses_schema_definition - document = parse(' - schema { - query: QueryRoot - mutation: MutationRoot - subscription: SubscriptionRoot - } - ') - - schema = document.definitions.first - assert_equal 'QueryRoot', schema.query - assert_equal 'MutationRoot', schema.mutation - assert_equal 'SubscriptionRoot', schema.subscription - end - - def test_it_parses_schema_extensions - document = parse(' - extend schema { - query: QueryRoot - mutation: MutationRoot - subscription: SubscriptionRoot - } - ') - - schema_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::SchemaExtension, schema_extension.class - assert_equal [2, 15], schema_extension.position - - assert_equal 'QueryRoot', schema_extension.query - assert_equal 'MutationRoot', schema_extension.mutation - assert_equal 'SubscriptionRoot', schema_extension.subscription - end - - def test_it_parses_schema_extensions_with_directives - document = parse(' - extend schema @something { - query: QueryRoot - } - ') - - schema_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::SchemaExtension, schema_extension.class - - assert_equal 1, schema_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, schema_extension.directives.first.class - assert_equal 'something', schema_extension.directives.first.name - - assert_equal 'QueryRoot', schema_extension.query - assert_equal nil, schema_extension.mutation - assert_equal nil, schema_extension.subscription - end - - def test_it_parses_schema_extensions_with_only_directives - document = parse(' - extend schema @something - ') - - schema_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::SchemaExtension, schema_extension.class - - assert_equal 1, schema_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, schema_extension.directives.first.class - assert_equal 'something', schema_extension.directives.first.name - - assert_equal nil, schema_extension.query - assert_equal nil, schema_extension.mutation - assert_equal nil, schema_extension.subscription - end - - def test_it_parses_scalar_extensions - document = parse(' - extend scalar Date @something @somethingElse - ') - - scalar_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::ScalarTypeExtension, scalar_extension.class - assert_equal 'Date', scalar_extension.name - assert_equal [2, 15], scalar_extension.position - - assert_equal 2, scalar_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, scalar_extension.directives.first.class - assert_equal 'something', scalar_extension.directives.first.name - assert_equal GraphQL::Language::Nodes::Directive, scalar_extension.directives.last.class - assert_equal 'somethingElse', scalar_extension.directives.last.name - end - - def test_it_parses_object_type_extensions_with_field_definitions - document = parse(' - extend type User { - login: String! - } - ') - - object_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::ObjectTypeExtension, object_type_extension.class - assert_equal 'User', object_type_extension.name - assert_equal [2, 15], object_type_extension.position - - assert_equal 1, object_type_extension.fields.length - assert_equal GraphQL::Language::Nodes::FieldDefinition, object_type_extension.fields.first.class - end - - def test_it_parses_object_type_extensions_with_field_definitions_and_directives - document = parse(' - extend type User @deprecated { - login: String! - } - ') - - object_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::ObjectTypeExtension, object_type_extension.class - assert_equal 'User', object_type_extension.name - assert_equal [2, 15], object_type_extension.position - - assert_equal 1, object_type_extension.fields.length - assert_equal GraphQL::Language::Nodes::FieldDefinition, object_type_extension.fields.first.class - - assert_equal 1, object_type_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, object_type_extension.directives.first.class - end - - def test_it_parses_object_type_extensions_with_field_definitions_and_implements - document = parse(' - extend type User implements Node { - login: String! - } - ') - - object_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::ObjectTypeExtension, object_type_extension.class - assert_equal 'User', object_type_extension.name - assert_equal [2, 15], object_type_extension.position - - assert_equal 1, object_type_extension.fields.length - assert_equal GraphQL::Language::Nodes::FieldDefinition, object_type_extension.fields.first.class - - assert_equal 1, object_type_extension.interfaces.length - assert_equal GraphQL::Language::Nodes::TypeName, object_type_extension.interfaces.first.class - end - - def test_it_parses_object_type_extensions_with_only_directives - document = parse(' - extend type User @deprecated - ') - - object_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::ObjectTypeExtension, object_type_extension.class - assert_equal 'User', object_type_extension.name - assert_equal [2, 15], object_type_extension.position - - assert_equal 1, object_type_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, object_type_extension.directives.first.class - assert_equal 'deprecated', object_type_extension.directives.first.name - end - - def test_it_parses_object_type_extensions_with_implements_and_directives - document = parse(' - extend type User implements Node @deprecated - ') - - object_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::ObjectTypeExtension, object_type_extension.class - assert_equal 'User', object_type_extension.name - assert_equal [2, 15], object_type_extension.position - - assert_equal 1, object_type_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, object_type_extension.directives.first.class - assert_equal 'deprecated', object_type_extension.directives.first.name - - assert_equal 1, object_type_extension.interfaces.length - assert_equal GraphQL::Language::Nodes::TypeName, object_type_extension.interfaces.first.class - assert_equal 'Node', object_type_extension.interfaces.first.name - end - - def test_it_parses_object_type_extensions_with_only_implements - document = parse(' - extend type User implements Node - ') - - object_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::ObjectTypeExtension, object_type_extension.class - assert_equal 'User', object_type_extension.name - assert_equal [2, 15], object_type_extension.position - - assert_equal 1, object_type_extension.interfaces.length - assert_equal GraphQL::Language::Nodes::TypeName, object_type_extension.interfaces.first.class - assert_equal 'Node', object_type_extension.interfaces.first.name - end - - def test_it_parses_interface_type_extensions_with_directives_and_fields - document = parse(' - extend interface Node @directive { - field: String - } - ') - - interface_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::InterfaceTypeExtension, interface_type_extension.class - assert_equal 'Node', interface_type_extension.name - assert_equal [2, 15], interface_type_extension.position - - assert_equal 1, interface_type_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, interface_type_extension.directives.first.class - assert_equal 'directive', interface_type_extension.directives.first.name - - assert_equal 1, interface_type_extension.fields.length - assert_equal GraphQL::Language::Nodes::FieldDefinition, interface_type_extension.fields.first.class - assert_equal 'field', interface_type_extension.fields.first.name - end - - def test_it_parses_interface_type_extensions_with_fields - document = parse(' - extend interface Node { - field: String - } - ') - - interface_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::InterfaceTypeExtension, interface_type_extension.class - assert_equal 'Node', interface_type_extension.name - assert_equal [2, 15], interface_type_extension.position - - assert_equal 0, interface_type_extension.directives.length - - assert_equal 1, interface_type_extension.fields.length - assert_equal GraphQL::Language::Nodes::FieldDefinition, interface_type_extension.fields.first.class - assert_equal 'field', interface_type_extension.fields.first.name - end - - def test_it_parses_interface_type_extensions_with_directives - document = parse(' - extend interface Node @directive - ') - - interface_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::InterfaceTypeExtension, interface_type_extension.class - assert_equal 'Node', interface_type_extension.name - assert_equal [2, 15], interface_type_extension.position - - assert_equal 1, interface_type_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, interface_type_extension.directives.first.class - assert_equal 'directive', interface_type_extension.directives.first.name - end - - def test_it_parses_union_type_extension_with_union_members - document = parse(' - extend union BagOfThings = A | B - ') - - union_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::UnionTypeExtension, union_type_extension.class - assert_equal 'BagOfThings', union_type_extension.name - assert_equal [2, 15], union_type_extension.position - - assert_equal 0, union_type_extension.directives.length - - assert_equal 2, union_type_extension.types.length - assert_equal GraphQL::Language::Nodes::TypeName, union_type_extension.types.first.class - assert_equal 'A', union_type_extension.types.first.name - end - - def test_it_parses_union_type_extension_with_directives_and_union_members - document = parse(' - extend union BagOfThings @directive = A | B - ') - - union_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::UnionTypeExtension, union_type_extension.class - assert_equal 'BagOfThings', union_type_extension.name - assert_equal [2, 15], union_type_extension.position - - assert_equal 1, union_type_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, union_type_extension.directives.first.class - assert_equal 'directive', union_type_extension.directives.first.name - - assert_equal 2, union_type_extension.types.length - assert_equal GraphQL::Language::Nodes::TypeName, union_type_extension.types.first.class - assert_equal 'A', union_type_extension.types.first.name - end - - def test_it_parses_union_type_extension_with_directives - document = parse(' - extend union BagOfThings @directive - ') - - union_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::UnionTypeExtension, union_type_extension.class - assert_equal 'BagOfThings', union_type_extension.name - assert_equal [2, 15], union_type_extension.position - - assert_equal 1, union_type_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, union_type_extension.directives.first.class - assert_equal 'directive', union_type_extension.directives.first.name - - assert_equal 0, union_type_extension.types.length - end - - def test_it_parses_enum_type_extension_with_values - document = parse(' - extend enum Status { - DRAFT - PUBLISHED - } - ') - - enum_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::EnumTypeExtension, enum_type_extension.class - assert_equal 'Status', enum_type_extension.name - assert_equal [2, 15], enum_type_extension.position - - assert_equal 0, enum_type_extension.directives.length - - assert_equal 2, enum_type_extension.values.length - assert_equal GraphQL::Language::Nodes::EnumValueDefinition, enum_type_extension.values.first.class - assert_equal 'DRAFT', enum_type_extension.values.first.name - end - - def test_it_parses_enum_type_extension_with_directives_and_values - document = parse(' - extend enum Status @directive { - DRAFT - PUBLISHED - } - ') - - enum_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::EnumTypeExtension, enum_type_extension.class - assert_equal 'Status', enum_type_extension.name - assert_equal [2, 15], enum_type_extension.position - - assert_equal 1, enum_type_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, enum_type_extension.directives.first.class - assert_equal 'directive', enum_type_extension.directives.first.name - - assert_equal 2, enum_type_extension.values.length - assert_equal GraphQL::Language::Nodes::EnumValueDefinition, enum_type_extension.values.first.class - assert_equal 'DRAFT', enum_type_extension.values.first.name - end - - def test_it_parses_enum_type_extension_with_directives - document = parse(' - extend enum Status @directive - ') - - enum_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::EnumTypeExtension, enum_type_extension.class - assert_equal 'Status', enum_type_extension.name - assert_equal [2, 15], enum_type_extension.position - - assert_equal 1, enum_type_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, enum_type_extension.directives.first.class - assert_equal 'directive', enum_type_extension.directives.first.name - - assert_equal 0, enum_type_extension.values.length - end - - def test_it_parses_input_object_type_extension_with_fields - document = parse(' - extend input UserInput { - login: String! - } - ') - - input_object_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::InputObjectTypeExtension, input_object_type_extension.class - assert_equal 'UserInput', input_object_type_extension.name - assert_equal [2, 15], input_object_type_extension.position - - assert_equal 1, input_object_type_extension.fields.length - assert_equal GraphQL::Language::Nodes::InputValueDefinition, input_object_type_extension.fields.first.class - assert_equal 'login', input_object_type_extension.fields.first.name - - assert_equal 0, input_object_type_extension.directives.length - end - - def test_it_parses_input_object_type_extension_with_directives_and_fields - document = parse(' - extend input UserInput @deprecated { - login: String! - } - ') - - input_object_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::InputObjectTypeExtension, input_object_type_extension.class - assert_equal 'UserInput', input_object_type_extension.name - assert_equal [2, 15], input_object_type_extension.position - - assert_equal 1, input_object_type_extension.fields.length - assert_equal GraphQL::Language::Nodes::InputValueDefinition, input_object_type_extension.fields.first.class - assert_equal 'login', input_object_type_extension.fields.first.name - - assert_equal 1, input_object_type_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, input_object_type_extension.directives.first.class - assert_equal 'deprecated', input_object_type_extension.directives.first.name - end - - def test_it_parses_input_object_type_extension_with_directives - document = parse(' - extend input UserInput @deprecated - ') - - input_object_type_extension = document.definitions.first - assert_equal GraphQL::Language::Nodes::InputObjectTypeExtension, input_object_type_extension.class - assert_equal 'UserInput', input_object_type_extension.name - assert_equal [2, 15], input_object_type_extension.position - - assert_equal 0, input_object_type_extension.fields.length - - assert_equal 1, input_object_type_extension.directives.length - assert_equal GraphQL::Language::Nodes::Directive, input_object_type_extension.directives.first.class - assert_equal 'deprecated', input_object_type_extension.directives.first.name - end - - def test_it_parses_whole_definition_with_descriptions - document = parse(SCHEMA_DEFINITION_STRING) - - assert_equal 6, document.definitions.size - - schema_definition, directive_definition, enum_type_definition, object_type_definition, input_object_type_definition, interface_type_definition = document.definitions - - assert_equal GraphQL::Language::Nodes::SchemaDefinition, schema_definition.class - - assert_equal GraphQL::Language::Nodes::DirectiveDefinition, directive_definition.class - assert_equal 'This is a directive', directive_definition.description - - assert_equal GraphQL::Language::Nodes::EnumTypeDefinition, enum_type_definition.class - assert_equal "Multiline comment\n\nWith an enum", enum_type_definition.description - - assert_nil enum_type_definition.values[0].description - assert_equal 'Not a creative color', enum_type_definition.values[1].description - - assert_equal GraphQL::Language::Nodes::ObjectTypeDefinition, object_type_definition.class - assert_equal 'Comment without preceding space', object_type_definition.description - assert_equal 'And a field to boot', object_type_definition.fields[0].description - - assert_equal GraphQL::Language::Nodes::InputObjectTypeDefinition, input_object_type_definition.class - assert_equal 'Comment for input object types', input_object_type_definition.description - assert_equal 'Color of the car', input_object_type_definition.fields[0].description - - assert_equal GraphQL::Language::Nodes::InterfaceTypeDefinition, interface_type_definition.class - assert_equal 'Comment for interface definitions', interface_type_definition.description - assert_equal 'Amount of wheels', interface_type_definition.fields[0].description - - brand_field = interface_type_definition.fields[1] - assert_equal 1, brand_field.arguments.length - assert_equal 'argument', brand_field.arguments[0].name - assert_instance_of GraphQL::Language::Nodes::NullValue, brand_field.arguments[0].default_value - end - end - end - - SCHEMA_DEFINITION_STRING = %| - # Schema at beginning of file - - schema { - query: Hello - } - - # Comment between two definitions are omitted - - # This is a directive - directive @foo( - # It has an argument - arg: Int - ) on FIELD - - # Multiline comment - # - # With an enum - enum Color { - RED - - # Not a creative color - GREEN - BLUE - } - - #Comment without preceding space - type Hello { - # And a field to boot - str: String - } - - # Comment for input object types - input Car { - # Color of the car - color: String! - } - - # Comment for interface definitions - interface Vehicle { - # Amount of wheels - wheels: Int! - brand(argument: String = null): String! - } - - # Comment at the end of schema - | - end - end -end diff --git a/lib/graphql/current.rb b/lib/graphql/current.rb new file mode 100644 index 00000000000..5d5bbe08636 --- /dev/null +++ b/lib/graphql/current.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module GraphQL + # This module exposes Fiber-level runtime information. + # + # It won't work across unrelated fibers, although it will work in child Fibers. + # + # @example Setting Up ActiveRecord::QueryLogs + # + # config.active_record.query_log_tags = [ + # :namespaced_controller, + # :action, + # :job, + # # ... + # { + # # GraphQL runtime info: + # current_graphql_operation: -> { GraphQL::Current.operation_name }, + # current_graphql_field: -> { GraphQL::Current.field&.path }, + # current_dataloader_source: -> { GraphQL::Current.dataloader_source_class }, + # # ... + # }, + # ] + # + module Current + # @return [String, nil] Comma-joined operation names for the currently-running {Execution::Multiplex}. `nil` if all operations are anonymous. + def self.operation_name + if (m = Fiber[:__graphql_current_multiplex]) + m.context[:__graphql_current_operation_name] ||= begin + names = m.queries.map { |q| q.selected_operation_name } + if names.all?(&:nil?) + nil + else + names.join(",") + end + end + else + nil + end + end + + # @see GraphQL::Field#path for a string identifying this field + # @return [GraphQL::Field, nil] The currently-running field, if there is one. + def self.field + if (interpreter_info = Fiber[:__graphql_runtime_info]) + interpreter_info.values&.first&.current_field + elsif (field = Fiber[:__graphql_current_field]) + field + else + nil + end + end + + # @return [Class, nil] The currently-running {Dataloader::Source} class, if there is one. + def self.dataloader_source_class + Fiber[:__graphql_current_dataloader_source]&.class + end + + # @return [GraphQL::Dataloader::Source, nil] The currently-running source, if there is one + def self.dataloader_source + Fiber[:__graphql_current_dataloader_source] + end + end +end diff --git a/lib/graphql/dashboard.rb b/lib/graphql/dashboard.rb new file mode 100644 index 00000000000..30be95caa9f --- /dev/null +++ b/lib/graphql/dashboard.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true +require 'rails/engine' +require 'action_controller' +module Graphql + # `GraphQL::Dashboard` is a `Rails::Engine`-based dashboard for viewing metadata about your GraphQL schema. + # + # Pass the class name of your schema when mounting it. + # @see GraphQL::Tracing::DetailedTrace DetailedTrace for viewing production traces in the Dashboard + # + # @example Mounting the Dashboard in your app + # mount GraphQL::Dashboard, at: "graphql_dashboard", schema: "MySchema" + # + # @example Authenticating the Dashboard with HTTP Basic Auth + # # config/initializers/graphql_dashboard.rb + # GraphQL::Dashboard.middleware.use(Rack::Auth::Basic) do |username, password| + # # Compare the provided username/password to an application setting: + # ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.graphql_dashboard_username, username) && + # ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.graphql_dashboard_username, password) + # end + # + # @example Custom Rails authentication + # # config/initializers/graphql_dashboard.rb + # ActiveSupport.on_load(:graphql_dashboard_application_controller) do + # # context here is GraphQL::Dashboard::ApplicationController + # + # before_action do + # raise ActionController::RoutingError.new('Not Found') unless current_user&.admin? + # end + # + # def current_user + # # load current user + # end + # end + # + class Dashboard < Rails::Engine + engine_name "graphql_dashboard" + isolate_namespace(Graphql::Dashboard) + + autoload :ApplicationController, "graphql/dashboard/application_controller" + autoload :LandingsController, "graphql/dashboard/landings_controller" + autoload :StaticsController, "graphql/dashboard/statics_controller" + autoload :DetailedTraces, "graphql/dashboard/detailed_traces" + autoload :Subscriptions, "graphql/dashboard/subscriptions" + autoload :OperationStore, "graphql/dashboard/operation_store" + autoload :Limiters, "graphql/dashboard/limiters" + + routes do + root "landings#show" + resources :statics, only: :show, constraints: { id: /[0-9A-Za-z\-.]+/ } + + namespace :detailed_traces do + resources :traces, only: [:index, :show, :destroy] do + collection do + delete :delete_all, to: "traces#delete_all", as: :delete_all + end + end + end + + namespace :limiters do + resources :limiters, only: [:show, :update], param: :name + end + + namespace :operation_store do + resources :clients, param: :name do + resources :operations, param: :digest, only: [:index] do + collection do + get :archived, to: "operations#index", archived_status: :archived, as: :archived + post :archive, to: "operations#update", modification: :archive, as: :archive + post :unarchive, to: "operations#update", modification: :unarchive, as: :unarchive + end + end + end + + resources :operations, param: :digest, only: [:index, :show] do + collection do + get :archived, to: "operations#index", archived_status: :archived, as: :archived + post :archive, to: "operations#update", modification: :archive, as: :archive + post :unarchive, to: "operations#update", modification: :unarchive, as: :unarchive + end + end + resources :index_entries, only: [:index, :show], param: :name, constraints: { name: /[A-Za-z0-9_.]+/} + end + + namespace :subscriptions do + resources :topics, only: [:index, :show], param: :name, constraints: { name: /.*/ } + resources :subscriptions, only: [:show], constraints: { id: /[a-zA-Z0-9\-]+/ } + post "/subscriptions/clear_all", to: "subscriptions#clear_all", as: :clear_all + end + end + end +end + +# Rails expects the engine to be called `Graphql::Dashboard`, +# but `GraphQL::Dashboard` is consistent with this gem's naming. +# So define both constants to refer to the same class. +GraphQL::Dashboard = Graphql::Dashboard diff --git a/lib/graphql/dashboard/application_controller.rb b/lib/graphql/dashboard/application_controller.rb new file mode 100644 index 00000000000..25ee87c4a96 --- /dev/null +++ b/lib/graphql/dashboard/application_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +require "action_controller" + +module Graphql + class Dashboard < Rails::Engine + class ApplicationController < ActionController::Base + protect_from_forgery with: :exception + prepend_view_path(File.expand_path("../views", __FILE__)) + + content_security_policy do |policy| + policy.default_src(:self) if policy.default_src(*policy.default_src).blank? + policy.connect_src(:self) if policy.connect_src(*policy.connect_src).blank? + policy.base_uri(:none) if policy.base_uri(*policy.base_uri).blank? + policy.font_src(:self) if policy.font_src(*policy.font_src).blank? + policy.img_src(:self, :data) if policy.img_src(*policy.img_src).blank? + policy.object_src(:none) if policy.object_src(*policy.object_src).blank? + policy.script_src(:self) if policy.script_src(*policy.script_src).blank? + policy.style_src(:self) if policy.style_src(*policy.style_src).blank? + policy.form_action(:self) if policy.form_action(*policy.form_action).blank? + policy.frame_ancestors(:none) if policy.frame_ancestors(*policy.frame_ancestors).blank? + end + + def schema_class + @schema_class ||= begin + schema_param = request.query_parameters["schema"] || params[:schema] + case schema_param + when Class + schema_param + when String + schema_param.constantize + else + raise "Missing `params[:schema]`, please provide a class or string to `mount GraphQL::Dashboard, schema: ...`" + end + end + end + helper_method :schema_class + end + end +end + +ActiveSupport.run_load_hooks(:graphql_dashboard_application_controller, GraphQL::Dashboard::ApplicationController) diff --git a/lib/graphql/dashboard/detailed_traces.rb b/lib/graphql/dashboard/detailed_traces.rb new file mode 100644 index 00000000000..b20f8108ae7 --- /dev/null +++ b/lib/graphql/dashboard/detailed_traces.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +require_relative "./installable" +module Graphql + class Dashboard < Rails::Engine + module DetailedTraces + class TracesController < Graphql::Dashboard::ApplicationController + include Installable + + def index + @last = params[:last]&.to_i || 50 + @before = params[:before]&.to_i + @traces = schema_class.detailed_trace.traces(last: @last, before: @before) + end + + def show + trace = schema_class.detailed_trace.find_trace(params[:id].to_i) + send_data(trace.trace_data) + end + + def destroy + schema_class.detailed_trace.delete_trace(params[:id]) + flash[:success] = "Trace deleted." + head :no_content + end + + def delete_all + schema_class.detailed_trace.delete_all_traces + flash[:success] = "Deleted all traces." + head :no_content + end + + private + + def feature_installed? + !!schema_class.detailed_trace + end + + INSTALLABLE_COMPONENT_HEADER_HTML = "Detailed traces aren't installed yet." + INSTALLABLE_COMPONENT_MESSAGE_HTML = <<~HTML.html_safe + GraphQL-Ruby can instrument production traffic and save tracing artifacts here for later review. +
+ Read more in the detailed tracing docs. + HTML + end + end + end +end diff --git a/lib/graphql/dashboard/installable.rb b/lib/graphql/dashboard/installable.rb new file mode 100644 index 00000000000..bdc9ac2903d --- /dev/null +++ b/lib/graphql/dashboard/installable.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +module Graphql + class Dashboard < Rails::Engine + module Installable + def self.included(child_module) + child_module.before_action(:check_installed) + end + + def feature_installed? + raise "Implement #{self.class}#feature_installed? to check whether this should render `not_installed` or not." + end + + def check_installed + if !feature_installed? + @component_header_html = self.class::INSTALLABLE_COMPONENT_HEADER_HTML + @component_message_html = self.class::INSTALLABLE_COMPONENT_MESSAGE_HTML + render "graphql/dashboard/not_installed" + end + end + end + end +end diff --git a/lib/graphql/dashboard/landings_controller.rb b/lib/graphql/dashboard/landings_controller.rb new file mode 100644 index 00000000000..39d61909546 --- /dev/null +++ b/lib/graphql/dashboard/landings_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +module Graphql + class Dashboard < Rails::Engine + class LandingsController < ApplicationController + def show + end + end + end +end diff --git a/lib/graphql/dashboard/limiters.rb b/lib/graphql/dashboard/limiters.rb new file mode 100644 index 00000000000..b4ca3da5141 --- /dev/null +++ b/lib/graphql/dashboard/limiters.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true +require_relative "./installable" +module Graphql + class Dashboard < Rails::Engine + module Limiters + class LimitersController < Dashboard::ApplicationController + include Installable + FALLBACK_CSP_NONCE_GENERATOR = ->(_req) { SecureRandom.hex(32) } + + def show + name = params[:name] + @title = case name + when "runtime" + "Runtime Limiter" + when "active_operations" + "Active Operation Limiter" + when "mutations" + "Mutation Limiter" + else + raise ArgumentError, "Unknown limiter name: #{name}" + end + + limiter = limiter_for(name) + if limiter.nil? + @install_path = "http://graphql-ruby.org/limiters/#{name}" + else + @chart_mode = params[:chart] || "day" + @current_soft = limiter.soft_limit_enabled? + @histogram = limiter.dashboard_histogram(@chart_mode) + + # These configs may have already been defined by the application; provide overrides here if not. + request.content_security_policy_nonce_generator ||= FALLBACK_CSP_NONCE_GENERATOR + nonce_dirs = request.content_security_policy_nonce_directives || [] + if !nonce_dirs.include?("style-src") + nonce_dirs += ["style-src"] + request.content_security_policy_nonce_directives = nonce_dirs + end + @csp_nonce = request.content_security_policy_nonce + end + end + + def update + name = params[:name] + limiter = limiter_for(name) + if limiter + limiter.toggle_soft_limit + flash[:success] = if limiter.soft_limit_enabled? + "Enabled soft limiting -- over-limit traffic will be logged but not rejected." + else + "Disabled soft limiting -- over-limit traffic will be rejected." + end + else + flash[:warning] = "No limiter configured for #{name.inspect}" + end + + redirect_to graphql_dashboard.limiters_limiter_path(name, chart: params[:chart]) + end + + private + + def limiter_for(name) + case name + when "runtime" + schema_class.enterprise_runtime_limiter + when "active_operations" + schema_class.enterprise_active_operation_limiter + when "mutations" + schema_class.enterprise_mutation_limiter + else + raise ArgumentError, "Unknown limiter: #{name}" + end + end + + def feature_installed? + defined?(GraphQL::Enterprise::Limiter) && + ( + schema_class.enterprise_active_operation_limiter || + schema_class.enterprise_runtime_limiter || + (schema_class.respond_to?(:enterprise_mutation_limiter) && schema_class.enterprise_mutation_limiter) + ) + end + + + INSTALLABLE_COMPONENT_HEADER_HTML = "Rate limiters aren't installed on this schema yet." + INSTALLABLE_COMPONENT_MESSAGE_HTML = <<-HTML.html_safe + Check out the docs to get started with GraphQL-Enterprise's + runtime limiter or + active operation limiter. + HTML + end + end + end +end diff --git a/lib/graphql/dashboard/operation_store.rb b/lib/graphql/dashboard/operation_store.rb new file mode 100644 index 00000000000..5fe1d2a1517 --- /dev/null +++ b/lib/graphql/dashboard/operation_store.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true +require_relative "./installable" +module Graphql + class Dashboard < Rails::Engine + module OperationStore + class BaseController < Dashboard::ApplicationController + include Installable + + private + + def feature_installed? + schema_class.respond_to?(:operation_store) && schema_class.operation_store.is_a?(GraphQL::Pro::OperationStore) + end + + INSTALLABLE_COMPONENT_HEADER_HTML = "OperationStore isn't installed for this schema yet.".html_safe + INSTALLABLE_COMPONENT_MESSAGE_HTML = <<-HTML.html_safe + Learn more about improving performance and security with stored operations + in the OperationStore docs. + HTML + end + + class ClientsController < BaseController + def index + @order_by = params[:order_by] || "name" + @order_dir = params[:order_dir].presence || "asc" + clients_page = schema_class.operation_store.all_clients( + page: params[:page]&.to_i || 1, + per_page: params[:per_page]&.to_i || 25, + order_by: @order_by, + order_dir: @order_dir, + ) + + @clients_page = clients_page + end + + def new + @client = init_client(secret: SecureRandom.hex(32)) + end + + def create + client_params = params.require(:client).permit(:name, :secret) + schema_class.operation_store.upsert_client(client_params[:name], client_params[:secret]) + flash[:success] = "Created #{client_params[:name].inspect}" + redirect_to graphql_dashboard.operation_store_clients_path + end + + def edit + @client = schema_class.operation_store.get_client(params[:name]) + end + + def update + client_name = params[:name] + client_secret = params.require(:client).permit(:secret)[:secret] + schema_class.operation_store.upsert_client(client_name, client_secret) + flash[:success] = "Updated #{client_name.inspect}" + redirect_to graphql_dashboard.operation_store_clients_path + end + + def destroy + client_name = params[:name] + schema_class.operation_store.delete_client(client_name) + flash[:success] = "Deleted #{client_name.inspect}" + redirect_to graphql_dashboard.operation_store_clients_path + end + + private + + def init_client(name: nil, secret: nil) + GraphQL::Pro::OperationStore::ClientRecord.new( + name: name, + secret: secret, + created_at: nil, + operations_count: 0, + archived_operations_count: 0, + last_synced_at: nil, + last_used_at: nil, + ) + end + end + + class OperationsController < BaseController + def index + @client_operations = client_name = params[:client_name] + per_page = params[:per_page]&.to_i || 25 + page = params[:page]&.to_i || 1 + @is_archived = params[:archived_status] == :archived + order_by = params[:order_by] || "name" + order_dir = params[:order_dir]&.to_sym || :asc + if @client_operations + @operations_page = schema_class.operation_store.get_client_operations_by_client( + client_name, + page: page, + per_page: per_page, + is_archived: @is_archived, + order_by: order_by, + order_dir: order_dir, + ) + opposite_archive_mode_count = schema_class.operation_store.get_client_operations_by_client( + client_name, + page: 1, + per_page: 1, + is_archived: !@is_archived, + order_by: order_by, + order_dir: order_dir, + ).total_count + else + @operations_page = schema_class.operation_store.all_operations( + page: page, + per_page: per_page, + is_archived: @is_archived, + order_by: order_by, + order_dir: order_dir, + ) + opposite_archive_mode_count = schema_class.operation_store.all_operations( + page: 1, + per_page: 1, + is_archived: !@is_archived, + order_by: order_by, + order_dir: order_dir, + ).total_count + end + + if @is_archived + @archived_operations_count = @operations_page.total_count + @unarchived_operations_count = opposite_archive_mode_count + else + @archived_operations_count = opposite_archive_mode_count + @unarchived_operations_count = @operations_page.total_count + end + end + + def show + digest = params[:digest] + @operation = schema_class.operation_store.get_operation_by_digest(digest) + if @operation + # Parse & re-format the query + document = GraphQL.parse(@operation.body) + @graphql_source = document.to_query_string + + @client_operations = schema_class.operation_store.get_client_operations_by_digest(digest) + @entries = schema_class.operation_store.get_index_entries_by_digest(digest) + end + end + + def update + is_archived = case params[:modification] + when :archive + true + when :unarchive + false + else + raise ArgumentError, "Unexpected modification: #{params[:modification].inspect}" + end + + if (client_name = params[:client_name]) + operation_aliases = params[:operation_aliases] + schema_class.operation_store.archive_client_operations( + client_name: client_name, + operation_aliases: operation_aliases, + is_archived: is_archived + ) + flash[:success] = "#{is_archived ? "Archived" : "Activated"} #{operation_aliases.size} #{"operation".pluralize(operation_aliases.size)}" + else + digests = params[:digests] + schema_class.operation_store.archive_operations( + digests: digests, + is_archived: is_archived + ) + flash[:success] = "#{is_archived ? "Archived" : "Activated"} #{digests.size} #{"operation".pluralize(digests.size)}" + end + head :no_content + end + end + + class IndexEntriesController < BaseController + def index + @search_term = if request.params["q"] && request.params["q"].length > 0 + request.params["q"] + else + nil + end + + @index_entries_page = schema_class.operation_store.all_index_entries( + search_term: @search_term, + page: params[:page]&.to_i || 1, + per_page: params[:per_page]&.to_i || 25, + ) + end + + def show + name = params[:name] + @entry = schema_class.operation_store.index.get_entry(name) + @chain = schema_class.operation_store.index.index_entry_chain(name) + @operations = schema_class.operation_store.get_operations_by_index_entry(name) + end + end + end + end +end diff --git a/lib/graphql/dashboard/statics/bootstrap-5.3.3.min.css b/lib/graphql/dashboard/statics/bootstrap-5.3.3.min.css new file mode 100644 index 00000000000..aac8bee0ab3 --- /dev/null +++ b/lib/graphql/dashboard/statics/bootstrap-5.3.3.min.css @@ -0,0 +1,6 @@ +@charset "UTF-8";/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#a6b5cc;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#b5b6b7;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#a7b9b1;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#a6c3ca;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#ccc2a4;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#c6acae;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#c6c7c8;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#4d5154;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:not(:-moz-placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control-plaintext~label::after,.form-floating>.form-control:focus~label::after,.form-floating>.form-control:not(:placeholder-shown)~label::after,.form-floating>.form-select~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control:-webkit-autofill~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.form-floating>.form-control:disabled~label::after,.form-floating>:disabled~label::after{background-color:var(--bs-secondary-bg)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(var(--bs-border-width) * -1);border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(var(--bs-border-width) * -1)}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(var(--bs-border-width) * -1)}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}.accordion-flush>.accordion-item>.accordion-collapse{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;--bs-btn-close-white-filter:invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{filter:var(--bs-btn-close-white-filter)}[data-bs-theme=dark] .btn-close{filter:var(--bs-btn-close-white-filter)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}[data-bs-theme=dark] .carousel .carousel-control-next-icon,[data-bs-theme=dark] .carousel .carousel-control-prev-icon,[data-bs-theme=dark].carousel .carousel-control-next-icon,[data-bs-theme=dark].carousel .carousel-control-prev-icon{filter:invert(1) grayscale(100)}[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target],[data-bs-theme=dark].carousel .carousel-indicators [data-bs-target]{background-color:#000}[data-bs-theme=dark] .carousel .carousel-caption,[data-bs-theme=dark].carousel .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin:calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ diff --git a/lib/graphql/dashboard/statics/bootstrap-5.3.3.min.js b/lib/graphql/dashboard/statics/bootstrap-5.3.3.min.js new file mode 100644 index 00000000000..d705b8dac58 --- /dev/null +++ b/lib/graphql/dashboard/statics/bootstrap-5.3.3.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=I(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return P(s,{delegateTarget:r}),n.oneOff&&N.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return P(n,{delegateTarget:t}),i.oneOff&&N.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function $(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function I(t){return t=t.replace(y,""),T[t]||t}const N={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))$(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(w,"");a&&!e.includes(s)||D(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;D(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=u();let s=null,o=!0,r=!0,a=!1;e!==I(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=P(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function P(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}function j(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function M(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const F={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${M(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${M(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=j(t.dataset[n])}return e},getDataAttribute:(t,e)=>j(t.getAttribute(`data-bs-${M(e)}`))};class H{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=o(e)?F.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...o(e)?F.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[n,s]of Object.entries(e)){const e=t[n],r=o(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(s).test(r))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${r}" but expected type "${s}".`)}var i}}class W extends H{constructor(t,i){super(),(t=r(t))&&(this._element=t,this._config=this._getConfig(i),e.set(this._element,this.constructor.DATA_KEY,this))}dispose(){e.remove(this._element,this.constructor.DATA_KEY),N.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){_(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return e.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const B=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e?e.split(",").map((t=>n(t))).join(","):null},z={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!l(t)&&a(t)))},getSelectorFromElement(t){const e=B(t);return e&&z.findOne(e)?e:null},getElementFromSelector(t){const e=B(t);return e?z.findOne(e):null},getMultipleElementsFromSelector(t){const e=B(t);return e?z.find(e):[]}},R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;N.on(document,i,`[data-bs-dismiss="${n}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),l(this))return;const s=z.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()}))},q=".bs.alert",V=`close${q}`,K=`closed${q}`;class Q extends W{static get NAME(){return"alert"}close(){if(N.trigger(this._element,V).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),N.trigger(this._element,K),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=Q.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(Q,"close"),m(Q);const X='[data-bs-toggle="button"]';class Y extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=Y.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}N.on(document,"click.bs.button.data-api",X,(t=>{t.preventDefault();const e=t.target.closest(X);Y.getOrCreateInstance(e).toggle()})),m(Y);const U=".bs.swipe",G=`touchstart${U}`,J=`touchmove${U}`,Z=`touchend${U}`,tt=`pointerdown${U}`,et=`pointerup${U}`,it={endCallback:null,leftCallback:null,rightCallback:null},nt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class st extends H{constructor(t,e){super(),this._element=t,t&&st.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return it}static get DefaultType(){return nt}static get NAME(){return"swipe"}dispose(){N.off(this._element,U)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),g(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&g(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(N.on(this._element,tt,(t=>this._start(t))),N.on(this._element,et,(t=>this._end(t))),this._element.classList.add("pointer-event")):(N.on(this._element,G,(t=>this._start(t))),N.on(this._element,J,(t=>this._move(t))),N.on(this._element,Z,(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const ot=".bs.carousel",rt=".data-api",at="next",lt="prev",ct="left",ht="right",dt=`slide${ot}`,ut=`slid${ot}`,ft=`keydown${ot}`,pt=`mouseenter${ot}`,mt=`mouseleave${ot}`,gt=`dragstart${ot}`,_t=`load${ot}${rt}`,bt=`click${ot}${rt}`,vt="carousel",yt="active",wt=".active",At=".carousel-item",Et=wt+At,Tt={ArrowLeft:ht,ArrowRight:ct},Ct={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Ot={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class xt extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=z.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===vt&&this.cycle()}static get Default(){return Ct}static get DefaultType(){return Ot}static get NAME(){return"carousel"}next(){this._slide(at)}nextWhenVisible(){!document.hidden&&a(this._element)&&this.next()}prev(){this._slide(lt)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?N.one(this._element,ut,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void N.one(this._element,ut,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?at:lt;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&N.on(this._element,ft,(t=>this._keydown(t))),"hover"===this._config.pause&&(N.on(this._element,pt,(()=>this.pause())),N.on(this._element,mt,(()=>this._maybeEnableCycle()))),this._config.touch&&st.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of z.find(".carousel-item img",this._element))N.on(t,gt,(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(ct)),rightCallback:()=>this._slide(this._directionToOrder(ht)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new st(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=Tt[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=z.findOne(wt,this._indicatorsElement);e.classList.remove(yt),e.removeAttribute("aria-current");const i=z.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(yt),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===at,s=e||b(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>N.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(dt).defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),d(s),i.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,c),s.classList.add(yt),i.classList.remove(yt,c,l),this._isSliding=!1,r(ut)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return z.findOne(Et,this._element)}_getItems(){return z.find(At,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===ct?lt:at:t===ct?at:lt}_orderToDirection(t){return p()?t===lt?ct:ht:t===lt?ht:ct}static jQueryInterface(t){return this.each((function(){const e=xt.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}N.on(document,bt,"[data-bs-slide], [data-bs-slide-to]",(function(t){const e=z.getElementFromSelector(this);if(!e||!e.classList.contains(vt))return;t.preventDefault();const i=xt.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===F.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),N.on(window,_t,(()=>{const t=z.find('[data-bs-ride="carousel"]');for(const e of t)xt.getOrCreateInstance(e)})),m(xt);const kt=".bs.collapse",Lt=`show${kt}`,St=`shown${kt}`,Dt=`hide${kt}`,$t=`hidden${kt}`,It=`click${kt}.data-api`,Nt="show",Pt="collapse",jt="collapsing",Mt=`:scope .${Pt} .${Pt}`,Ft='[data-bs-toggle="collapse"]',Ht={parent:null,toggle:!0},Wt={parent:"(null|element)",toggle:"boolean"};class Bt extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=z.find(Ft);for(const t of i){const e=z.getSelectorFromElement(t),i=z.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return Ht}static get DefaultType(){return Wt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>Bt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(N.trigger(this._element,Lt).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Pt),this._element.classList.add(jt),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(jt),this._element.classList.add(Pt,Nt),this._element.style[e]="",N.trigger(this._element,St)}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(N.trigger(this._element,Dt).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,d(this._element),this._element.classList.add(jt),this._element.classList.remove(Pt,Nt);for(const t of this._triggerArray){const e=z.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(jt),this._element.classList.add(Pt),N.trigger(this._element,$t)}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(Nt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=r(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Ft);for(const e of t){const t=z.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=z.find(Mt,this._config.parent);return z.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=Bt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}N.on(document,It,Ft,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of z.getMultipleElementsFromSelector(this))Bt.getOrCreateInstance(t,{toggle:!1}).toggle()})),m(Bt);var zt="top",Rt="bottom",qt="right",Vt="left",Kt="auto",Qt=[zt,Rt,qt,Vt],Xt="start",Yt="end",Ut="clippingParents",Gt="viewport",Jt="popper",Zt="reference",te=Qt.reduce((function(t,e){return t.concat([e+"-"+Xt,e+"-"+Yt])}),[]),ee=[].concat(Qt,[Kt]).reduce((function(t,e){return t.concat([e,e+"-"+Xt,e+"-"+Yt])}),[]),ie="beforeRead",ne="read",se="afterRead",oe="beforeMain",re="main",ae="afterMain",le="beforeWrite",ce="write",he="afterWrite",de=[ie,ne,se,oe,re,ae,le,ce,he];function ue(t){return t?(t.nodeName||"").toLowerCase():null}function fe(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function pe(t){return t instanceof fe(t).Element||t instanceof Element}function me(t){return t instanceof fe(t).HTMLElement||t instanceof HTMLElement}function ge(t){return"undefined"!=typeof ShadowRoot&&(t instanceof fe(t).ShadowRoot||t instanceof ShadowRoot)}const _e={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];me(s)&&ue(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});me(n)&&ue(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function be(t){return t.split("-")[0]}var ve=Math.max,ye=Math.min,we=Math.round;function Ae(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function Ee(){return!/^((?!chrome|android).)*safari/i.test(Ae())}function Te(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&me(t)&&(s=t.offsetWidth>0&&we(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&we(n.height)/t.offsetHeight||1);var r=(pe(t)?fe(t):window).visualViewport,a=!Ee()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function Ce(t){var e=Te(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Oe(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&ge(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function xe(t){return fe(t).getComputedStyle(t)}function ke(t){return["table","td","th"].indexOf(ue(t))>=0}function Le(t){return((pe(t)?t.ownerDocument:t.document)||window.document).documentElement}function Se(t){return"html"===ue(t)?t:t.assignedSlot||t.parentNode||(ge(t)?t.host:null)||Le(t)}function De(t){return me(t)&&"fixed"!==xe(t).position?t.offsetParent:null}function $e(t){for(var e=fe(t),i=De(t);i&&ke(i)&&"static"===xe(i).position;)i=De(i);return i&&("html"===ue(i)||"body"===ue(i)&&"static"===xe(i).position)?e:i||function(t){var e=/firefox/i.test(Ae());if(/Trident/i.test(Ae())&&me(t)&&"fixed"===xe(t).position)return null;var i=Se(t);for(ge(i)&&(i=i.host);me(i)&&["html","body"].indexOf(ue(i))<0;){var n=xe(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function Ie(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function Ne(t,e,i){return ve(t,ye(e,i))}function Pe(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function je(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const Me={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=be(i.placement),l=Ie(a),c=[Vt,qt].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return Pe("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:je(t,Qt))}(s.padding,i),d=Ce(o),u="y"===l?zt:Vt,f="y"===l?Rt:qt,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=$e(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=Ne(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Oe(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function Fe(t){return t.split("-")[1]}var He={top:"auto",right:"auto",bottom:"auto",left:"auto"};function We(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,m=void 0===p?0:p,g="function"==typeof h?h({x:f,y:m}):{x:f,y:m};f=g.x,m=g.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=Vt,y=zt,w=window;if(c){var A=$e(i),E="clientHeight",T="clientWidth";A===fe(i)&&"static"!==xe(A=Le(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===zt||(s===Vt||s===qt)&&o===Yt)&&(y=Rt,m-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,m*=l?1:-1),s!==Vt&&(s!==zt&&s!==Rt||o!==Yt)||(v=qt,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&He),x=!0===h?function(t,e){var i=t.x,n=t.y,s=e.devicePixelRatio||1;return{x:we(i*s)/s||0,y:we(n*s)/s||0}}({x:f,y:m},fe(i)):{x:f,y:m};return f=x.x,m=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?m+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const Be={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:be(e.placement),variation:Fe(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,We(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,We(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var ze={passive:!0};const Re={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=fe(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,ze)})),a&&l.addEventListener("resize",i.update,ze),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,ze)})),a&&l.removeEventListener("resize",i.update,ze)}},data:{}};var qe={left:"right",right:"left",bottom:"top",top:"bottom"};function Ve(t){return t.replace(/left|right|bottom|top/g,(function(t){return qe[t]}))}var Ke={start:"end",end:"start"};function Qe(t){return t.replace(/start|end/g,(function(t){return Ke[t]}))}function Xe(t){var e=fe(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Ye(t){return Te(Le(t)).left+Xe(t).scrollLeft}function Ue(t){var e=xe(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ge(t){return["html","body","#document"].indexOf(ue(t))>=0?t.ownerDocument.body:me(t)&&Ue(t)?t:Ge(Se(t))}function Je(t,e){var i;void 0===e&&(e=[]);var n=Ge(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=fe(n),r=s?[o].concat(o.visualViewport||[],Ue(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Je(Se(r)))}function Ze(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function ti(t,e,i){return e===Gt?Ze(function(t,e){var i=fe(t),n=Le(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Ee();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+Ye(t),y:l}}(t,i)):pe(e)?function(t,e){var i=Te(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):Ze(function(t){var e,i=Le(t),n=Xe(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=ve(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=ve(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+Ye(t),l=-n.scrollTop;return"rtl"===xe(s||i).direction&&(a+=ve(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Le(t)))}function ei(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?be(s):null,r=s?Fe(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case zt:e={x:a,y:i.y-n.height};break;case Rt:e={x:a,y:i.y+i.height};break;case qt:e={x:i.x+i.width,y:l};break;case Vt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?Ie(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case Xt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Yt:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ii(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?Ut:a,c=i.rootBoundary,h=void 0===c?Gt:c,d=i.elementContext,u=void 0===d?Jt:d,f=i.altBoundary,p=void 0!==f&&f,m=i.padding,g=void 0===m?0:m,_=Pe("number"!=typeof g?g:je(g,Qt)),b=u===Jt?Zt:Jt,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=Je(Se(t)),i=["absolute","fixed"].indexOf(xe(t).position)>=0&&me(t)?$e(t):t;return pe(i)?e.filter((function(t){return pe(t)&&Oe(t,i)&&"body"!==ue(t)})):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce((function(e,i){var s=ti(t,i,n);return e.top=ve(s.top,e.top),e.right=ye(s.right,e.right),e.bottom=ye(s.bottom,e.bottom),e.left=ve(s.left,e.left),e}),ti(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(pe(y)?y:y.contextElement||Le(t.elements.popper),l,h,r),A=Te(t.elements.reference),E=ei({reference:A,element:v,strategy:"absolute",placement:s}),T=Ze(Object.assign({},v,E)),C=u===Jt?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===Jt&&x){var k=x[s];Object.keys(O).forEach((function(t){var e=[qt,Rt].indexOf(t)>=0?1:-1,i=[zt,Rt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function ni(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?ee:l,h=Fe(n),d=h?a?te:te.filter((function(t){return Fe(t)===h})):Qt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ii(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[be(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const si={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=be(g),b=l||(_!==g&&p?function(t){if(be(t)===Kt)return[];var e=Ve(t);return[Qe(t),e,Qe(e)]}(g):[Ve(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(be(i)===Kt?ni(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C=0,S=L?"width":"height",D=ii(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),$=L?k?qt:Vt:k?Rt:zt;y[S]>w[S]&&($=Ve($));var I=Ve($),N=[];if(o&&N.push(D[x]<=0),a&&N.push(D[$]<=0,D[I]<=0),N.every((function(t){return t}))){T=O,E=!1;break}A.set(O,N)}if(E)for(var P=function(t){var e=v.find((function(e){var i=A.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},j=p?3:1;j>0&&"break"!==P(j);j--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function oi(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function ri(t){return[zt,qt,Rt,Vt].some((function(e){return t[e]>=0}))}const ai={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ii(e,{elementContext:"reference"}),a=ii(e,{altBoundary:!0}),l=oi(r,n),c=oi(a,s,o),h=ri(l),d=ri(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},li={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=ee.reduce((function(t,i){return t[i]=function(t,e,i){var n=be(t),s=[Vt,zt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[Vt,qt].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},ci={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=ei({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},hi={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ii(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=be(e.placement),b=Fe(e.placement),v=!b,y=Ie(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,S="y"===y?zt:Vt,D="y"===y?Rt:qt,$="y"===y?"height":"width",I=A[y],N=I+g[S],P=I-g[D],j=f?-T[$]/2:0,M=b===Xt?E[$]:T[$],F=b===Xt?-T[$]:-E[$],H=e.elements.arrow,W=f&&H?Ce(H):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=B[S],R=B[D],q=Ne(0,E[$],W[$]),V=v?E[$]/2-j-q-z-O.mainAxis:M-q-z-O.mainAxis,K=v?-E[$]/2+j+q+R+O.mainAxis:F+q+R+O.mainAxis,Q=e.elements.arrow&&$e(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=I+K-Y,G=Ne(f?ye(N,I+V-Y-X):N,I,f?ve(P,U):P);A[y]=G,k[y]=G-I}if(a){var J,Z="x"===y?zt:Vt,tt="x"===y?Rt:qt,et=A[w],it="y"===w?"height":"width",nt=et+g[Z],st=et-g[tt],ot=-1!==[zt,Vt].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=Ne(t,e,i);return n>i?i:n}(at,et,lt):Ne(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function di(t,e,i){void 0===i&&(i=!1);var n,s,o=me(e),r=me(e)&&function(t){var e=t.getBoundingClientRect(),i=we(e.width)/t.offsetWidth||1,n=we(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=Le(e),l=Te(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==ue(e)||Ue(a))&&(c=(n=e)!==fe(n)&&me(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:Xe(n)),me(e)?((h=Te(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=Ye(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function ui(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var fi={placement:"bottom",modifiers:[],strategy:"absolute"};function pi(){for(var t=arguments.length,e=new Array(t),i=0;iNumber.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(F.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...g(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=z.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>a(t)));i.length&&b(i,e,t===Ti,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=qi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=z.find(Ni);for(const i of e){const e=qi.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Ei,Ti].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Ii)?this:z.prev(this,Ii)[0]||z.next(this,Ii)[0]||z.findOne(Ii,t.delegateTarget.parentNode),o=qi.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}N.on(document,Si,Ii,qi.dataApiKeydownHandler),N.on(document,Si,Pi,qi.dataApiKeydownHandler),N.on(document,Li,qi.clearMenus),N.on(document,Di,qi.clearMenus),N.on(document,Li,Ii,(function(t){t.preventDefault(),qi.getOrCreateInstance(this).toggle()})),m(qi);const Vi="backdrop",Ki="show",Qi=`mousedown.bs.${Vi}`,Xi={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},Yi={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class Ui extends H{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return Xi}static get DefaultType(){return Yi}static get NAME(){return Vi}show(t){if(!this._config.isVisible)return void g(t);this._append();const e=this._getElement();this._config.isAnimated&&d(e),e.classList.add(Ki),this._emulateAnimation((()=>{g(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(Ki),this._emulateAnimation((()=>{this.dispose(),g(t)}))):g(t)}dispose(){this._isAppended&&(N.off(this._element,Qi),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=r(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),N.on(t,Qi,(()=>{g(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){_(t,this._getElement(),this._config.isAnimated)}}const Gi=".bs.focustrap",Ji=`focusin${Gi}`,Zi=`keydown.tab${Gi}`,tn="backward",en={autofocus:!0,trapElement:null},nn={autofocus:"boolean",trapElement:"element"};class sn extends H{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return en}static get DefaultType(){return nn}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),N.off(document,Gi),N.on(document,Ji,(t=>this._handleFocusin(t))),N.on(document,Zi,(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,N.off(document,Gi))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=z.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===tn?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?tn:"forward")}}const on=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",rn=".sticky-top",an="padding-right",ln="margin-right";class cn{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,an,(e=>e+t)),this._setElementAttributes(on,an,(e=>e+t)),this._setElementAttributes(rn,ln,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,an),this._resetElementAttributes(on,an),this._resetElementAttributes(rn,ln)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&F.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=F.getDataAttribute(t,e);null!==i?(F.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(o(t))e(t);else for(const i of z.find(t,this._element))e(i)}}const hn=".bs.modal",dn=`hide${hn}`,un=`hidePrevented${hn}`,fn=`hidden${hn}`,pn=`show${hn}`,mn=`shown${hn}`,gn=`resize${hn}`,_n=`click.dismiss${hn}`,bn=`mousedown.dismiss${hn}`,vn=`keydown.dismiss${hn}`,yn=`click${hn}.data-api`,wn="modal-open",An="show",En="modal-static",Tn={backdrop:!0,focus:!0,keyboard:!0},Cn={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class On extends W{constructor(t,e){super(t,e),this._dialog=z.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new cn,this._addEventListeners()}static get Default(){return Tn}static get DefaultType(){return Cn}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||N.trigger(this._element,pn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(wn),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(N.trigger(this._element,dn).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(An),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){N.off(window,hn),N.off(this._dialog,hn),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new Ui({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=z.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),d(this._element),this._element.classList.add(An),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,N.trigger(this._element,mn,{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){N.on(this._element,vn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),N.on(window,gn,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),N.on(this._element,bn,(t=>{N.one(this._element,_n,(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(wn),this._resetAdjustments(),this._scrollBar.reset(),N.trigger(this._element,fn)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(N.trigger(this._element,un).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(En)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(En),this._queueCallback((()=>{this._element.classList.remove(En),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=On.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}N.on(document,yn,'[data-bs-toggle="modal"]',(function(t){const e=z.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),N.one(e,pn,(t=>{t.defaultPrevented||N.one(e,fn,(()=>{a(this)&&this.focus()}))}));const i=z.findOne(".modal.show");i&&On.getInstance(i).hide(),On.getOrCreateInstance(e).toggle(this)})),R(On),m(On);const xn=".bs.offcanvas",kn=".data-api",Ln=`load${xn}${kn}`,Sn="show",Dn="showing",$n="hiding",In=".offcanvas.show",Nn=`show${xn}`,Pn=`shown${xn}`,jn=`hide${xn}`,Mn=`hidePrevented${xn}`,Fn=`hidden${xn}`,Hn=`resize${xn}`,Wn=`click${xn}${kn}`,Bn=`keydown.dismiss${xn}`,zn={backdrop:!0,keyboard:!0,scroll:!1},Rn={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class qn extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return zn}static get DefaultType(){return Rn}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||N.trigger(this._element,Nn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new cn).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Dn),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Sn),this._element.classList.remove(Dn),N.trigger(this._element,Pn,{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(N.trigger(this._element,jn).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add($n),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(Sn,$n),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new cn).reset(),N.trigger(this._element,Fn)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new Ui({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():N.trigger(this._element,Mn)}:null})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_addEventListeners(){N.on(this._element,Bn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():N.trigger(this._element,Mn))}))}static jQueryInterface(t){return this.each((function(){const e=qn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}N.on(document,Wn,'[data-bs-toggle="offcanvas"]',(function(t){const e=z.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this))return;N.one(e,Fn,(()=>{a(this)&&this.focus()}));const i=z.findOne(In);i&&i!==e&&qn.getInstance(i).hide(),qn.getOrCreateInstance(e).toggle(this)})),N.on(window,Ln,(()=>{for(const t of z.find(In))qn.getOrCreateInstance(t).show()})),N.on(window,Hn,(()=>{for(const t of z.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&qn.getOrCreateInstance(t).hide()})),R(qn),m(qn);const Vn={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Kn=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Qn=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Xn=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!Kn.has(i)||Boolean(Qn.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},Yn={allowList:Vn,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Un={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Gn={entry:"(string|element|function|null)",selector:"(string|element)"};class Jn extends H{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Yn}static get DefaultType(){return Un}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},Gn)}_setContent(t,e,i){const n=z.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?o(e)?this._putElementInTemplate(r(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Xn(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return g(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Zn=new Set(["sanitize","allowList","sanitizeFn"]),ts="fade",es="show",is=".modal",ns="hide.bs.modal",ss="hover",os="focus",rs={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},as={allowList:Vn,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},ls={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class cs extends W{constructor(t,e){if(void 0===vi)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return as}static get DefaultType(){return ls}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),N.off(this._element.closest(is),ns,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=N.trigger(this._element,this.constructor.eventName("show")),e=(c(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),N.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.on(t,"mouseover",h);this._queueCallback((()=>{N.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!N.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.off(t,"mouseover",h);this._activeTrigger.click=!1,this._activeTrigger[os]=!1,this._activeTrigger[ss]=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),N.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(ts,es),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(ts),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Jn({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(ts)}_isShown(){return this.tip&&this.tip.classList.contains(es)}_createPopper(t){const e=g(this._config.placement,[this,t,this._element]),i=rs[e.toUpperCase()];return bi(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return g(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...g(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)N.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===ss?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===ss?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");N.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?os:ss]=!0,e._enter()})),N.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?os:ss]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},N.on(this._element.closest(is),ns,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=F.getDataAttributes(this._element);for(const t of Object.keys(e))Zn.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=cs.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(cs);const hs={...cs.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},ds={...cs.DefaultType,content:"(null|string|element|function)"};class us extends cs{static get Default(){return hs}static get DefaultType(){return ds}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=us.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(us);const fs=".bs.scrollspy",ps=`activate${fs}`,ms=`click${fs}`,gs=`load${fs}.data-api`,_s="active",bs="[href]",vs=".nav-link",ys=`${vs}, .nav-item > ${vs}, .list-group-item`,ws={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},As={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Es extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return ws}static get DefaultType(){return As}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=r(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(N.off(this._config.target,ms),N.on(this._config.target,ms,bs,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=z.find(bs,this._config.target);for(const e of t){if(!e.hash||l(e))continue;const t=z.findOne(decodeURI(e.hash),this._element);a(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(_s),this._activateParents(t),N.trigger(this._element,ps,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))z.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(_s);else for(const e of z.parents(t,".nav, .list-group"))for(const t of z.prev(e,ys))t.classList.add(_s)}_clearActiveClass(t){t.classList.remove(_s);const e=z.find(`${bs}.${_s}`,t);for(const t of e)t.classList.remove(_s)}static jQueryInterface(t){return this.each((function(){const e=Es.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(window,gs,(()=>{for(const t of z.find('[data-bs-spy="scroll"]'))Es.getOrCreateInstance(t)})),m(Es);const Ts=".bs.tab",Cs=`hide${Ts}`,Os=`hidden${Ts}`,xs=`show${Ts}`,ks=`shown${Ts}`,Ls=`click${Ts}`,Ss=`keydown${Ts}`,Ds=`load${Ts}`,$s="ArrowLeft",Is="ArrowRight",Ns="ArrowUp",Ps="ArrowDown",js="Home",Ms="End",Fs="active",Hs="fade",Ws="show",Bs=".dropdown-toggle",zs=`:not(${Bs})`,Rs='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',qs=`.nav-link${zs}, .list-group-item${zs}, [role="tab"]${zs}, ${Rs}`,Vs=`.${Fs}[data-bs-toggle="tab"], .${Fs}[data-bs-toggle="pill"], .${Fs}[data-bs-toggle="list"]`;class Ks extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),N.on(this._element,Ss,(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?N.trigger(e,Cs,{relatedTarget:t}):null;N.trigger(t,xs,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(Fs),this._activate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),N.trigger(t,ks,{relatedTarget:e})):t.classList.add(Ws)}),t,t.classList.contains(Hs)))}_deactivate(t,e){t&&(t.classList.remove(Fs),t.blur(),this._deactivate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),N.trigger(t,Os,{relatedTarget:e})):t.classList.remove(Ws)}),t,t.classList.contains(Hs)))}_keydown(t){if(![$s,Is,Ns,Ps,js,Ms].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter((t=>!l(t)));let i;if([js,Ms].includes(t.key))i=e[t.key===js?0:e.length-1];else{const n=[Is,Ps].includes(t.key);i=b(e,t.target,n,!0)}i&&(i.focus({preventScroll:!0}),Ks.getOrCreateInstance(i).show())}_getChildren(){return z.find(qs,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=z.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=z.findOne(t,i);s&&s.classList.toggle(n,e)};n(Bs,Fs),n(".dropdown-menu",Ws),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(Fs)}_getInnerElement(t){return t.matches(qs)?t:z.findOne(qs,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=Ks.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(document,Ls,Rs,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this)||Ks.getOrCreateInstance(this).show()})),N.on(window,Ds,(()=>{for(const t of z.find(Vs))Ks.getOrCreateInstance(t)})),m(Ks);const Qs=".bs.toast",Xs=`mouseover${Qs}`,Ys=`mouseout${Qs}`,Us=`focusin${Qs}`,Gs=`focusout${Qs}`,Js=`hide${Qs}`,Zs=`hidden${Qs}`,to=`show${Qs}`,eo=`shown${Qs}`,io="hide",no="show",so="showing",oo={animation:"boolean",autohide:"boolean",delay:"number"},ro={animation:!0,autohide:!0,delay:5e3};class ao extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return ro}static get DefaultType(){return oo}static get NAME(){return"toast"}show(){N.trigger(this._element,to).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(io),d(this._element),this._element.classList.add(no,so),this._queueCallback((()=>{this._element.classList.remove(so),N.trigger(this._element,eo),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(N.trigger(this._element,Js).defaultPrevented||(this._element.classList.add(so),this._queueCallback((()=>{this._element.classList.add(io),this._element.classList.remove(so,no),N.trigger(this._element,Zs)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(no),super.dispose()}isShown(){return this._element.classList.contains(no)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){N.on(this._element,Xs,(t=>this._onInteraction(t,!0))),N.on(this._element,Ys,(t=>this._onInteraction(t,!1))),N.on(this._element,Us,(t=>this._onInteraction(t,!0))),N.on(this._element,Gs,(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=ao.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(ao),m(ao),{Alert:Q,Button:Y,Carousel:xt,Collapse:Bt,Dropdown:qi,Modal:On,Offcanvas:qn,Popover:us,ScrollSpy:Es,Tab:Ks,Toast:ao,Tooltip:cs}})); +//# sourceMappingURL=bootstrap.bundle.min.js.map diff --git a/lib/graphql/dashboard/statics/charts.min.css b/lib/graphql/dashboard/statics/charts.min.css new file mode 100644 index 00000000000..b13a43b6d3a --- /dev/null +++ b/lib/graphql/dashboard/statics/charts.min.css @@ -0,0 +1 @@ +@property --color-1{syntax:"";initial-value:transparent;inherits:true}@property --color-2{syntax:"";initial-value:transparent;inherits:true}@property --color-3{syntax:"";initial-value:transparent;inherits:true}@property --color-4{syntax:"";initial-value:transparent;inherits:true}@property --color-5{syntax:"";initial-value:transparent;inherits:true}@property --color-6{syntax:"";initial-value:transparent;inherits:true}@property --color-7{syntax:"";initial-value:transparent;inherits:true}@property --color-8{syntax:"";initial-value:transparent;inherits:true}@property --color-9{syntax:"";initial-value:transparent;inherits:true}@property --color-10{syntax:"";initial-value:transparent;inherits:true}@property --color{syntax:"";inherits:true}@property --chart-bg-color{syntax:"";inherits:true}@property --aspect-ratio{syntax:"";initial-value:auto;inherits:true}@property --labels-size{syntax:"";initial-value:0;inherits:true}@property --labels-align-block{syntax:"";inherits:true}@property --labels-align-inline{syntax:"";inherits:true}@property --primary-axis-width{syntax:"";initial-value:1px;inherits:true}@property --secondary-axes-width{syntax:"";initial-value:1px;inherits:true}@property --data-axes-width{syntax:"";initial-value:1px;inherits:true}@property --legend-border-width{syntax:"";initial-value:1px;inherits:true}@property --primary-axis-style{syntax:"";initial-value:solid;inherits:true}@property --secondary-axes-style{syntax:"";initial-value:solid;inherits:true}@property --data-axes-style{syntax:"";initial-value:solid;inherits:true}@property --legend-border-style{syntax:"";initial-value:solid;inherits:true}@property --primary-axis-color{syntax:"";initial-value:transparent;inherits:true}@property --secondary-axes-color{syntax:"";initial-value:transparent;inherits:true}@property --data-axes-color{syntax:"";initial-value:transparent;inherits:true}@property --legend-border-color{syntax:"";initial-value:transparent;inherits:true}@property --start{syntax:"";inherits:true}@property --end{syntax:"";inherits:true}@property --size{syntax:"";inherits:true}@property --line-size{syntax:"";inherits:true}.charts-css{--color-1:rgba(240,50,50,.75);--color-2:rgba(255,180,50,.75);--color-3:rgba(255,220,90,.75);--color-4:rgba(100,210,80,.75);--color-5:rgba(90,165,255,.75);--color-6:rgba(170,90,240,.75);--color-7:hsla(0,0%,71%,.75);--color-8:hsla(0,0%,43%,.75);--color-9:hsla(40,26%,55%,.75);--color-10:rgba(130,50,20,.75);--chart-bg-color:#f5f5f5;--primary-axis-color:#000;--primary-axis-style:solid;--primary-axis-width:1px;--secondary-axes-color:rgba(0,0,0,.15);--secondary-axes-style:solid;--secondary-axes-width:1px;--data-axes-color:rgba(0,0,0,.15);--data-axes-style:solid;--data-axes-width:1px;--legend-border-color:#c8c8c8;--legend-border-style:solid;--legend-border-width:1px;border:0;display:block;height:100%;margin:0 auto;padding:0;position:relative;-webkit-print-color-adjust:exact;print-color-adjust:exact;width:100%}.charts-css,.charts-css *,.charts-css ::after,.charts-css ::before,.charts-css::after,.charts-css::before{-webkit-box-sizing:border-box;box-sizing:border-box}table.charts-css{background-color:transparent;border-collapse:collapse;border-spacing:0;empty-cells:show;overflow:initial}table.charts-css caption,table.charts-css colgroup,table.charts-css tbody,table.charts-css td,table.charts-css th,table.charts-css thead,table.charts-css tr{background-color:transparent;border:0;display:block;margin:0;padding:0}.charts-css.area.show-labels th.hide-label,.charts-css.area.show-labels tr.hide-label th,.charts-css.area:not(.show-labels) tbody tr th,.charts-css.bar.show-labels th.hide-label,.charts-css.bar.show-labels tr.hide-label th,.charts-css.bar:not(.show-labels) tbody tr th,.charts-css.column.show-labels th.hide-label,.charts-css.column.show-labels tr.hide-label th,.charts-css.column:not(.show-labels) tbody tr th,.charts-css.hide-data .data,.charts-css.hide-data .data:not(:focus):not(:focus-within),.charts-css.line.show-labels th.hide-label,.charts-css.line.show-labels tr.hide-label th,.charts-css.line:not(.show-labels) tbody tr th,.charts-css.pie tbody tr th,.charts-css.polar tbody tr,.charts-css.radar tbody tr,.charts-css.radial tbody tr,.charts-css:not(.show-heading) caption,table.charts-css colgroup,table.charts-css tfoot,table.charts-css thead{clip:rect(0,0,0,0);border:0;-webkit-clip-path:inset(50%);clip-path:inset(50%);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}table.charts-css tbody{position:relative}ol.charts-css,ul.charts-css{list-style-type:none}ol.charts-css li,ul.charts-css li{border:0;margin:0;padding:0}.charts-css.show-heading caption{display:block;width:100%}.charts-css.area tbody tr td:nth-of-type(10n+1)::before,.charts-css.bar tbody tr:nth-of-type(10n+1) td,.charts-css.bar.multiple tbody tr td:nth-of-type(10n+1),.charts-css.column tbody tr:nth-of-type(10n+1) td,.charts-css.column.multiple tbody tr td:nth-of-type(10n+1),.charts-css.line tbody tr td:nth-of-type(10n+1)::before{background:var(--color,var(--color-1))}.charts-css.pie tbody tr:nth-of-type(10n+1) td,.charts-css.pie.multiple tbody tr td:nth-of-type(10n+1){--c:var(--color,var(--color-1,transparent))}.charts-css.area tbody tr td:nth-of-type(10n+2)::before,.charts-css.bar tbody tr:nth-of-type(10n+2) td,.charts-css.bar.multiple tbody tr td:nth-of-type(10n+2),.charts-css.column tbody tr:nth-of-type(10n+2) td,.charts-css.column.multiple tbody tr td:nth-of-type(10n+2),.charts-css.line tbody tr td:nth-of-type(10n+2)::before{background:var(--color,var(--color-2))}.charts-css.pie tbody tr:nth-of-type(10n+2) td,.charts-css.pie.multiple tbody tr td:nth-of-type(10n+2){--c:var(--color,var(--color-2,transparent))}.charts-css.area tbody tr td:nth-of-type(10n+3)::before,.charts-css.bar tbody tr:nth-of-type(10n+3) td,.charts-css.bar.multiple tbody tr td:nth-of-type(10n+3),.charts-css.column tbody tr:nth-of-type(10n+3) td,.charts-css.column.multiple tbody tr td:nth-of-type(10n+3),.charts-css.line tbody tr td:nth-of-type(10n+3)::before{background:var(--color,var(--color-3))}.charts-css.pie tbody tr:nth-of-type(10n+3) td,.charts-css.pie.multiple tbody tr td:nth-of-type(10n+3){--c:var(--color,var(--color-3,transparent))}.charts-css.area tbody tr td:nth-of-type(10n+4)::before,.charts-css.bar tbody tr:nth-of-type(10n+4) td,.charts-css.bar.multiple tbody tr td:nth-of-type(10n+4),.charts-css.column tbody tr:nth-of-type(10n+4) td,.charts-css.column.multiple tbody tr td:nth-of-type(10n+4),.charts-css.line tbody tr td:nth-of-type(10n+4)::before{background:var(--color,var(--color-4))}.charts-css.pie tbody tr:nth-of-type(10n+4) td,.charts-css.pie.multiple tbody tr td:nth-of-type(10n+4){--c:var(--color,var(--color-4,transparent))}.charts-css.area tbody tr td:nth-of-type(10n+5)::before,.charts-css.bar tbody tr:nth-of-type(10n+5) td,.charts-css.bar.multiple tbody tr td:nth-of-type(10n+5),.charts-css.column tbody tr:nth-of-type(10n+5) td,.charts-css.column.multiple tbody tr td:nth-of-type(10n+5),.charts-css.line tbody tr td:nth-of-type(10n+5)::before{background:var(--color,var(--color-5))}.charts-css.pie tbody tr:nth-of-type(10n+5) td,.charts-css.pie.multiple tbody tr td:nth-of-type(10n+5){--c:var(--color,var(--color-5,transparent))}.charts-css.area tbody tr td:nth-of-type(10n+6)::before,.charts-css.bar tbody tr:nth-of-type(10n+6) td,.charts-css.bar.multiple tbody tr td:nth-of-type(10n+6),.charts-css.column tbody tr:nth-of-type(10n+6) td,.charts-css.column.multiple tbody tr td:nth-of-type(10n+6),.charts-css.line tbody tr td:nth-of-type(10n+6)::before{background:var(--color,var(--color-6))}.charts-css.pie tbody tr:nth-of-type(10n+6) td,.charts-css.pie.multiple tbody tr td:nth-of-type(10n+6){--c:var(--color,var(--color-6,transparent))}.charts-css.area tbody tr td:nth-of-type(10n+7)::before,.charts-css.bar tbody tr:nth-of-type(10n+7) td,.charts-css.bar.multiple tbody tr td:nth-of-type(10n+7),.charts-css.column tbody tr:nth-of-type(10n+7) td,.charts-css.column.multiple tbody tr td:nth-of-type(10n+7),.charts-css.line tbody tr td:nth-of-type(10n+7)::before{background:var(--color,var(--color-7))}.charts-css.pie tbody tr:nth-of-type(10n+7) td,.charts-css.pie.multiple tbody tr td:nth-of-type(10n+7){--c:var(--color,var(--color-7,transparent))}.charts-css.area tbody tr td:nth-of-type(10n+8)::before,.charts-css.bar tbody tr:nth-of-type(10n+8) td,.charts-css.bar.multiple tbody tr td:nth-of-type(10n+8),.charts-css.column tbody tr:nth-of-type(10n+8) td,.charts-css.column.multiple tbody tr td:nth-of-type(10n+8),.charts-css.line tbody tr td:nth-of-type(10n+8)::before{background:var(--color,var(--color-8))}.charts-css.pie tbody tr:nth-of-type(10n+8) td,.charts-css.pie.multiple tbody tr td:nth-of-type(10n+8){--c:var(--color,var(--color-8,transparent))}.charts-css.area tbody tr td:nth-of-type(10n+9)::before,.charts-css.bar tbody tr:nth-of-type(10n+9) td,.charts-css.bar.multiple tbody tr td:nth-of-type(10n+9),.charts-css.column tbody tr:nth-of-type(10n+9) td,.charts-css.column.multiple tbody tr td:nth-of-type(10n+9),.charts-css.line tbody tr td:nth-of-type(10n+9)::before{background:var(--color,var(--color-9))}.charts-css.pie tbody tr:nth-of-type(10n+9) td,.charts-css.pie.multiple tbody tr td:nth-of-type(10n+9){--c:var(--color,var(--color-9,transparent))}.charts-css.area tbody tr td:nth-of-type(10n+10)::before,.charts-css.bar tbody tr:nth-of-type(10n+10) td,.charts-css.bar.multiple tbody tr td:nth-of-type(10n+10),.charts-css.column tbody tr:nth-of-type(10n+10) td,.charts-css.column.multiple tbody tr td:nth-of-type(10n+10),.charts-css.line tbody tr td:nth-of-type(10n+10)::before{background:var(--color,var(--color-10))}.charts-css.pie tbody tr:nth-of-type(10n+10) td,.charts-css.pie.multiple tbody tr td:nth-of-type(10n+10){--c:var(--color,var(--color-10,transparent))}.charts-css .data{display:-webkit-box;display:-ms-flexbox;display:flex}.charts-css.show-data-on-hover .data{opacity:0;-webkit-transition-duration:.3s;transition-duration:.3s}.charts-css.pie.show-data-on-hover tbody:hover .data,.charts-css.polar.show-data-on-hover tbody:hover .data,.charts-css.radar.show-data-on-hover tbody:hover .data,.charts-css.radial.show-data-on-hover tbody:hover .data,.charts-css.show-data-on-hover tr:hover .data{opacity:1;-webkit-transition-duration:.3s;transition-duration:.3s}.charts-css.bar.data-center tbody tr td,.charts-css.column.data-center tbody tr td{--data-position:center}.charts-css.bar.data-end.reverse tbody tr td,.charts-css.bar.data-outside.reverse tbody tr td,.charts-css.bar.data-start:not(.reverse) tbody tr td,.charts-css.column.data-end:not(.reverse) tbody tr td,.charts-css.column.data-outside:not(.reverse) tbody tr td,.charts-css.column.data-start.reverse tbody tr td{--data-position:flex-start}.charts-css.bar.data-end:not(.reverse) tbody tr td,.charts-css.bar.data-outside:not(.reverse) tbody tr td,.charts-css.bar.data-start.reverse tbody tr td,.charts-css.column.data-end.reverse tbody tr td,.charts-css.column.data-outside.reverse tbody tr td,.charts-css.column.data-start:not(.reverse) tbody tr td{--data-position:flex-end}.charts-css.bar.data-outside:not(.reverse) tbody tr td .data{-webkit-transform:translateX(100%);transform:translateX(100%)}.charts-css.bar.data-outside.reverse tbody tr td .data{-webkit-transform:translateX(-100%);transform:translateX(-100%)}.charts-css.column.data-outside:not(.reverse) tbody tr td .data,.charts-css.column:not(.reverse) tbody tr td .data.outside{-webkit-transform:translateY(-100%);transform:translateY(-100%)}.charts-css.column.data-outside.reverse tbody tr td .data,.charts-css.column.reverse tbody tr td .data.outside{-webkit-transform:translateY(100%);transform:translateY(100%)}.charts-css.area.reverse tbody tr td .data.inside,.charts-css.area.reverse tbody tr td.inside .data,.charts-css.area:not(.reverse) tbody tr td .data.inside,.charts-css.area:not(.reverse) tbody tr td.inside .data,.charts-css.bar.reverse tbody tr td .data.inside,.charts-css.bar.reverse tbody tr td.inside .data,.charts-css.bar:not(.reverse) tbody tr td .data.inside,.charts-css.bar:not(.reverse) tbody tr td.inside .data,.charts-css.column.reverse tbody tr td .data.inside,.charts-css.column.reverse tbody tr td.inside .data,.charts-css.column:not(.reverse) tbody tr td .data.inside,.charts-css.column:not(.reverse) tbody tr td.inside .data,.charts-css.line.reverse tbody tr td .data.inside,.charts-css.line.reverse tbody tr td.inside .data,.charts-css.line:not(.reverse) tbody tr td .data.inside,.charts-css.line:not(.reverse) tbody tr td.inside .data{-webkit-transform:unset;transform:unset}.charts-css.bar{--labels-size:80px}.charts-css.area:not(.show-labels),.charts-css.bar:not(.show-labels),.charts-css.column:not(.show-labels),.charts-css.line:not(.show-labels){--labels-size:0}.charts-css.bar.show-labels tbody tr th{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-box-align:var(--labels-align-block,center);-ms-flex-align:var(--labels-align-block,center);align-items:var(--labels-align-block,center);display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-direction:row;flex-direction:row;gap:5px}.charts-css.bar.show-labels.reverse.reverse-labels tbody tr th,.charts-css.bar.show-labels:not(.reverse):not(.reverse-labels) tbody tr th{-webkit-box-pack:var(--labels-align-inline,flex-start);-ms-flex-pack:var(--labels-align-inline,flex-start);justify-content:var(--labels-align-inline,flex-start)}.charts-css.bar.show-labels.reverse:not(.reverse-labels) tbody tr th,.charts-css.bar.show-labels:not(.reverse).reverse-labels tbody tr th{-webkit-box-pack:var(--labels-align-inline,flex-end);-ms-flex-pack:var(--labels-align-inline,flex-end);justify-content:var(--labels-align-inline,flex-end)}.charts-css.area,.charts-css.column,.charts-css.line{--labels-size:1.5rem}.charts-css.area.show-labels tbody tr th,.charts-css.column.show-labels tbody tr th,.charts-css.line.show-labels tbody tr th{-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-box-align:var(--labels-align-inline,center);-ms-flex-align:var(--labels-align-inline,center);align-items:var(--labels-align-inline,center);display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.charts-css.area.show-labels.reverse.reverse-labels tbody tr th,.charts-css.area.show-labels:not(.reverse):not(.reverse-labels) tbody tr th,.charts-css.column.show-labels.reverse.reverse-labels tbody tr th,.charts-css.column.show-labels:not(.reverse):not(.reverse-labels) tbody tr th,.charts-css.line.show-labels.reverse.reverse-labels tbody tr th,.charts-css.line.show-labels:not(.reverse):not(.reverse-labels) tbody tr th{-webkit-box-pack:var(--labels-align-block,flex-end);-ms-flex-pack:var(--labels-align-block,flex-end);justify-content:var(--labels-align-block,flex-end)}.charts-css.area.show-labels.reverse:not(.reverse-labels) tbody tr th,.charts-css.area.show-labels:not(.reverse).reverse-labels tbody tr th,.charts-css.column.show-labels.reverse:not(.reverse-labels) tbody tr th,.charts-css.column.show-labels:not(.reverse).reverse-labels tbody tr th,.charts-css.line.show-labels.reverse:not(.reverse-labels) tbody tr th,.charts-css.line.show-labels:not(.reverse).reverse-labels tbody tr th{-webkit-box-pack:var(--labels-align-block,flex-start);-ms-flex-pack:var(--labels-align-block,flex-start);justify-content:var(--labels-align-block,flex-start)}.charts-css.area.labels-align-inline-start tbody tr th,.charts-css.bar.labels-align-inline-start tbody tr th,.charts-css.column.labels-align-inline-start tbody tr th,.charts-css.line.labels-align-inline-start tbody tr th{--labels-align-inline:flex-start}.charts-css.area.labels-align-inline-end tbody tr th,.charts-css.bar.labels-align-inline-end tbody tr th,.charts-css.column.labels-align-inline-end tbody tr th,.charts-css.line.labels-align-inline-end tbody tr th{--labels-align-inline:flex-end}.charts-css.area.labels-align-inline-center tbody tr th,.charts-css.bar.labels-align-inline-center tbody tr th,.charts-css.column.labels-align-inline-center tbody tr th,.charts-css.line.labels-align-inline-center tbody tr th{--labels-align-inline:center}.charts-css.area.labels-align-block-start tbody tr th,.charts-css.bar.labels-align-block-start tbody tr th,.charts-css.column.labels-align-block-start tbody tr th,.charts-css.line.labels-align-block-start tbody tr th{--labels-align-block:flex-start}.charts-css.area.labels-align-block-end tbody tr th,.charts-css.bar.labels-align-block-end tbody tr th,.charts-css.column.labels-align-block-end tbody tr th,.charts-css.line.labels-align-block-end tbody tr th{--labels-align-block:flex-end}.charts-css.area.labels-align-block-center tbody tr th,.charts-css.bar.labels-align-block-center tbody tr th,.charts-css.column.labels-align-block-center tbody tr th,.charts-css.line.labels-align-block-center tbody tr th{--labels-align-block:center}.charts-css.area.show-primary-axis:not(.reverse) tbody tr,.charts-css.column.show-primary-axis:not(.reverse) tbody tr,.charts-css.line.show-primary-axis:not(.reverse) tbody tr{-webkit-border-after:var(--primary-axis-width) var(--primary-axis-style) var(--primary-axis-color);border-block-end:var(--primary-axis-width) var(--primary-axis-style) var(--primary-axis-color)}.charts-css.area.show-primary-axis.reverse tbody tr,.charts-css.column.show-primary-axis.reverse tbody tr,.charts-css.line.show-primary-axis.reverse tbody tr{-webkit-border-before:var(--primary-axis-width) var(--primary-axis-style) var(--primary-axis-color);border-block-start:var(--primary-axis-width) var(--primary-axis-style) var(--primary-axis-color)}.charts-css.area.show-1-secondary-axes:not(.reverse) tbody tr,.charts-css.column.show-1-secondary-axes:not(.reverse) tbody tr,.charts-css.line.show-1-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,left top,left bottom,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 1)}.charts-css.area.show-1-secondary-axes.reverse tbody tr,.charts-css.column.show-1-secondary-axes.reverse tbody tr,.charts-css.line.show-1-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left bottom,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(0deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 1)}.charts-css.area.show-2-secondary-axes:not(.reverse) tbody tr,.charts-css.column.show-2-secondary-axes:not(.reverse) tbody tr,.charts-css.line.show-2-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,left top,left bottom,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 2)}.charts-css.area.show-2-secondary-axes.reverse tbody tr,.charts-css.column.show-2-secondary-axes.reverse tbody tr,.charts-css.line.show-2-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left bottom,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(0deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 2)}.charts-css.area.show-3-secondary-axes:not(.reverse) tbody tr,.charts-css.column.show-3-secondary-axes:not(.reverse) tbody tr,.charts-css.line.show-3-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,left top,left bottom,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 3)}.charts-css.area.show-3-secondary-axes.reverse tbody tr,.charts-css.column.show-3-secondary-axes.reverse tbody tr,.charts-css.line.show-3-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left bottom,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(0deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 3)}.charts-css.area.show-4-secondary-axes:not(.reverse) tbody tr,.charts-css.column.show-4-secondary-axes:not(.reverse) tbody tr,.charts-css.line.show-4-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,left top,left bottom,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 4)}.charts-css.area.show-4-secondary-axes.reverse tbody tr,.charts-css.column.show-4-secondary-axes.reverse tbody tr,.charts-css.line.show-4-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left bottom,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(0deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 4)}.charts-css.area.show-5-secondary-axes:not(.reverse) tbody tr,.charts-css.column.show-5-secondary-axes:not(.reverse) tbody tr,.charts-css.line.show-5-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,left top,left bottom,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 5)}.charts-css.area.show-5-secondary-axes.reverse tbody tr,.charts-css.column.show-5-secondary-axes.reverse tbody tr,.charts-css.line.show-5-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left bottom,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(0deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 5)}.charts-css.area.show-6-secondary-axes:not(.reverse) tbody tr,.charts-css.column.show-6-secondary-axes:not(.reverse) tbody tr,.charts-css.line.show-6-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,left top,left bottom,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 6)}.charts-css.area.show-6-secondary-axes.reverse tbody tr,.charts-css.column.show-6-secondary-axes.reverse tbody tr,.charts-css.line.show-6-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left bottom,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(0deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 6)}.charts-css.area.show-7-secondary-axes:not(.reverse) tbody tr,.charts-css.column.show-7-secondary-axes:not(.reverse) tbody tr,.charts-css.line.show-7-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,left top,left bottom,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 7)}.charts-css.area.show-7-secondary-axes.reverse tbody tr,.charts-css.column.show-7-secondary-axes.reverse tbody tr,.charts-css.line.show-7-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left bottom,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(0deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 7)}.charts-css.area.show-8-secondary-axes:not(.reverse) tbody tr,.charts-css.column.show-8-secondary-axes:not(.reverse) tbody tr,.charts-css.line.show-8-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,left top,left bottom,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 8)}.charts-css.area.show-8-secondary-axes.reverse tbody tr,.charts-css.column.show-8-secondary-axes.reverse tbody tr,.charts-css.line.show-8-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left bottom,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(0deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 8)}.charts-css.area.show-9-secondary-axes:not(.reverse) tbody tr,.charts-css.column.show-9-secondary-axes:not(.reverse) tbody tr,.charts-css.line.show-9-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,left top,left bottom,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 9)}.charts-css.area.show-9-secondary-axes.reverse tbody tr,.charts-css.column.show-9-secondary-axes.reverse tbody tr,.charts-css.line.show-9-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left bottom,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(0deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 9)}.charts-css.area.show-10-secondary-axes:not(.reverse) tbody tr,.charts-css.column.show-10-secondary-axes:not(.reverse) tbody tr,.charts-css.line.show-10-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,left top,left bottom,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 10)}.charts-css.area.show-10-secondary-axes.reverse tbody tr,.charts-css.column.show-10-secondary-axes.reverse tbody tr,.charts-css.line.show-10-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left bottom,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(0deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:100% calc(100% / 10)}.charts-css.area.show-data-axes tbody tr,.charts-css.area.show-dataset-axes tbody tr td,.charts-css.column.show-data-axes tbody tr,.charts-css.column.show-dataset-axes tbody tr td,.charts-css.line.show-data-axes tbody tr,.charts-css.line.show-dataset-axes tbody tr td{-webkit-border-end:var(--data-axes-width) var(--data-axes-style) var(--data-axes-color);border-inline-end:var(--data-axes-width) var(--data-axes-style) var(--data-axes-color)}.charts-css.area.show-data-axes.reverse-data tbody tr:last-of-type,.charts-css.area.show-data-axes:not(.reverse-data) tbody tr:first-of-type,.charts-css.area.show-dataset-axes.reverse-data tbody tr:last-of-type td,.charts-css.area.show-dataset-axes:not(.reverse-data) tbody tr:first-of-type td,.charts-css.column.show-data-axes.reverse-data tbody tr:last-of-type,.charts-css.column.show-data-axes:not(.reverse-data) tbody tr:first-of-type,.charts-css.column.show-dataset-axes.reverse-data tbody tr:last-of-type td,.charts-css.column.show-dataset-axes:not(.reverse-data) tbody tr:first-of-type td,.charts-css.line.show-data-axes.reverse-data tbody tr:last-of-type,.charts-css.line.show-data-axes:not(.reverse-data) tbody tr:first-of-type,.charts-css.line.show-dataset-axes.reverse-data tbody tr:last-of-type td,.charts-css.line.show-dataset-axes:not(.reverse-data) tbody tr:first-of-type td{-webkit-border-start:var(--data-axes-width) var(--data-axes-style) var(--data-axes-color);border-inline-start:var(--data-axes-width) var(--data-axes-style) var(--data-axes-color)}.charts-css.bar.show-primary-axis:not(.reverse) tbody tr{-webkit-border-start:var(--primary-axis-width) var(--primary-axis-style) var(--primary-axis-color);border-inline-start:var(--primary-axis-width) var(--primary-axis-style) var(--primary-axis-color)}.charts-css.bar.show-primary-axis.reverse tbody tr{-webkit-border-end:var(--primary-axis-width) var(--primary-axis-style) var(--primary-axis-color);border-inline-end:var(--primary-axis-width) var(--primary-axis-style) var(--primary-axis-color)}.charts-css.bar.show-1-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,right top,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(-90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 1) 100%}.charts-css.bar.show-1-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left top,right top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 1) 100%}.charts-css.bar.show-2-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,right top,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(-90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 2) 100%}.charts-css.bar.show-2-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left top,right top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 2) 100%}.charts-css.bar.show-3-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,right top,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(-90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 3) 100%}.charts-css.bar.show-3-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left top,right top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 3) 100%}.charts-css.bar.show-4-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,right top,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(-90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 4) 100%}.charts-css.bar.show-4-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left top,right top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 4) 100%}.charts-css.bar.show-5-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,right top,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(-90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 5) 100%}.charts-css.bar.show-5-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left top,right top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 5) 100%}.charts-css.bar.show-6-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,right top,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(-90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 6) 100%}.charts-css.bar.show-6-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left top,right top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 6) 100%}.charts-css.bar.show-7-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,right top,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(-90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 7) 100%}.charts-css.bar.show-7-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left top,right top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 7) 100%}.charts-css.bar.show-8-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,right top,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(-90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 8) 100%}.charts-css.bar.show-8-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left top,right top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 8) 100%}.charts-css.bar.show-9-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,right top,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(-90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 9) 100%}.charts-css.bar.show-9-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left top,right top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 9) 100%}.charts-css.bar.show-10-secondary-axes:not(.reverse) tbody tr{background-image:-webkit-gradient(linear,right top,left top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(-90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 10) 100%}.charts-css.bar.show-10-secondary-axes.reverse tbody tr{background-image:-webkit-gradient(linear,left top,right top,from(var(--secondary-axes-color)),to(transparent));background-image:linear-gradient(90deg,var(--secondary-axes-color) var(--secondary-axes-width),transparent var(--secondary-axes-width));background-size:calc(100% / 10) 100%}.charts-css.bar.show-data-axes tbody tr,.charts-css.bar.show-dataset-axes tbody tr td{-webkit-border-after:var(--data-axes-width) var(--data-axes-style) var(--data-axes-color);border-block-end:var(--data-axes-width) var(--data-axes-style) var(--data-axes-color)}.charts-css.bar.show-data-axes.reverse-data tbody tr:last-of-type,.charts-css.bar.show-data-axes:not(.reverse-data) tbody tr:first-of-type,.charts-css.bar.show-dataset-axes.reverse-data tbody tr:last-of-type td,.charts-css.bar.show-dataset-axes:not(.reverse-data) tbody tr:first-of-type td{-webkit-border-before:var(--data-axes-width) var(--data-axes-style) var(--data-axes-color);border-block-start:var(--data-axes-width) var(--data-axes-style) var(--data-axes-color)}.charts-css.pie.show-primary-axis tbody,.charts-css.polar.show-primary-axis tbody,.charts-css.radar.show-primary-axis tbody,.charts-css.radial.show-primary-axis tbody{border:var(--primary-axis-width) var(--primary-axis-style) var(--primary-axis-color)}.charts-css.pie.show-1-secondary-axes tbody::after,.charts-css.polar.show-1-secondary-axes tbody::after,.charts-css.radar.show-1-secondary-axes tbody::after,.charts-css.radial.show-1-secondary-axes tbody::after{background:repeating-radial-gradient(closest-side,transparent 0,transparent calc(100% / 2 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 2 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 2),transparent calc(100% / 2 + var(--secondary-axes-width)),transparent calc(100% / 2 + var(--secondary-axes-width)));border-radius:50%;bottom:0;content:"";height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:2}.charts-css.pie.show-2-secondary-axes tbody::after,.charts-css.polar.show-2-secondary-axes tbody::after,.charts-css.radar.show-2-secondary-axes tbody::after,.charts-css.radial.show-2-secondary-axes tbody::after{background:repeating-radial-gradient(closest-side,transparent 0,transparent calc(100% / 3 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 3 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 3),transparent calc(100% / 3 + var(--secondary-axes-width)),transparent calc(100% / 3 + var(--secondary-axes-width)));border-radius:50%;bottom:0;content:"";height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:2}.charts-css.pie.show-3-secondary-axes tbody::after,.charts-css.polar.show-3-secondary-axes tbody::after,.charts-css.radar.show-3-secondary-axes tbody::after,.charts-css.radial.show-3-secondary-axes tbody::after{background:repeating-radial-gradient(closest-side,transparent 0,transparent calc(100% / 4 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 4 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 4),transparent calc(100% / 4 + var(--secondary-axes-width)),transparent calc(100% / 4 + var(--secondary-axes-width)));border-radius:50%;bottom:0;content:"";height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:2}.charts-css.pie.show-4-secondary-axes tbody::after,.charts-css.polar.show-4-secondary-axes tbody::after,.charts-css.radar.show-4-secondary-axes tbody::after,.charts-css.radial.show-4-secondary-axes tbody::after{background:repeating-radial-gradient(closest-side,transparent 0,transparent calc(100% / 5 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 5 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 5),transparent calc(100% / 5 + var(--secondary-axes-width)),transparent calc(100% / 5 + var(--secondary-axes-width)));border-radius:50%;bottom:0;content:"";height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:2}.charts-css.pie.show-5-secondary-axes tbody::after,.charts-css.polar.show-5-secondary-axes tbody::after,.charts-css.radar.show-5-secondary-axes tbody::after,.charts-css.radial.show-5-secondary-axes tbody::after{background:repeating-radial-gradient(closest-side,transparent 0,transparent calc(100% / 6 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 6 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 6),transparent calc(100% / 6 + var(--secondary-axes-width)),transparent calc(100% / 6 + var(--secondary-axes-width)));border-radius:50%;bottom:0;content:"";height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:2}.charts-css.pie.show-6-secondary-axes tbody::after,.charts-css.polar.show-6-secondary-axes tbody::after,.charts-css.radar.show-6-secondary-axes tbody::after,.charts-css.radial.show-6-secondary-axes tbody::after{background:repeating-radial-gradient(closest-side,transparent 0,transparent calc(100% / 7 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 7 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 7),transparent calc(100% / 7 + var(--secondary-axes-width)),transparent calc(100% / 7 + var(--secondary-axes-width)));border-radius:50%;bottom:0;content:"";height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:2}.charts-css.pie.show-7-secondary-axes tbody::after,.charts-css.polar.show-7-secondary-axes tbody::after,.charts-css.radar.show-7-secondary-axes tbody::after,.charts-css.radial.show-7-secondary-axes tbody::after{background:repeating-radial-gradient(closest-side,transparent 0,transparent calc(100% / 8 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 8 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 8),transparent calc(100% / 8 + var(--secondary-axes-width)),transparent calc(100% / 8 + var(--secondary-axes-width)));border-radius:50%;bottom:0;content:"";height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:2}.charts-css.pie.show-8-secondary-axes tbody::after,.charts-css.polar.show-8-secondary-axes tbody::after,.charts-css.radar.show-8-secondary-axes tbody::after,.charts-css.radial.show-8-secondary-axes tbody::after{background:repeating-radial-gradient(closest-side,transparent 0,transparent calc(100% / 9 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 9 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 9),transparent calc(100% / 9 + var(--secondary-axes-width)),transparent calc(100% / 9 + var(--secondary-axes-width)));border-radius:50%;bottom:0;content:"";height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:2}.charts-css.pie.show-9-secondary-axes tbody::after,.charts-css.polar.show-9-secondary-axes tbody::after,.charts-css.radar.show-9-secondary-axes tbody::after,.charts-css.radial.show-9-secondary-axes tbody::after{background:repeating-radial-gradient(closest-side,transparent 0,transparent calc(100% / 10 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 10 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 10),transparent calc(100% / 10 + var(--secondary-axes-width)),transparent calc(100% / 10 + var(--secondary-axes-width)));border-radius:50%;bottom:0;content:"";height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:2}.charts-css.pie.show-10-secondary-axes tbody::after,.charts-css.polar.show-10-secondary-axes tbody::after,.charts-css.radar.show-10-secondary-axes tbody::after,.charts-css.radial.show-10-secondary-axes tbody::after{background:repeating-radial-gradient(closest-side,transparent 0,transparent calc(100% / 11 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 11 - var(--secondary-axes-width)),var(--secondary-axes-color) calc(100% / 11),transparent calc(100% / 11 + var(--secondary-axes-width)),transparent calc(100% / 11 + var(--secondary-axes-width)));border-radius:50%;bottom:0;content:"";height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:2}.charts-css.legend{border:var(--legend-border-width) var(--legend-border-style) var(--legend-border-color);font-size:1rem;list-style:none;padding:1rem}.charts-css.legend li{-webkit-box-align:center;-ms-flex-align:center;align-items:center;display:-webkit-box;display:-ms-flexbox;display:flex;line-height:2}.charts-css.legend li::before{-webkit-margin-end:.5rem;border-style:solid;border-width:2px;content:"";display:inline-block;margin-inline-end:.5rem;vertical-align:middle}.charts-css.legend li:nth-child(10n+1)::before{background-color:var(--color-1,transparent);border-color:var(--border-color-1,var(--border-color,#000))}.charts-css.legend li:nth-child(10n+2)::before{background-color:var(--color-2,transparent);border-color:var(--border-color-2,var(--border-color,#000))}.charts-css.legend li:nth-child(10n+3)::before{background-color:var(--color-3,transparent);border-color:var(--border-color-3,var(--border-color,#000))}.charts-css.legend li:nth-child(10n+4)::before{background-color:var(--color-4,transparent);border-color:var(--border-color-4,var(--border-color,#000))}.charts-css.legend li:nth-child(10n+5)::before{background-color:var(--color-5,transparent);border-color:var(--border-color-5,var(--border-color,#000))}.charts-css.legend li:nth-child(10n+6)::before{background-color:var(--color-6,transparent);border-color:var(--border-color-6,var(--border-color,#000))}.charts-css.legend li:nth-child(10n+7)::before{background-color:var(--color-7,transparent);border-color:var(--border-color-7,var(--border-color,#000))}.charts-css.legend li:nth-child(10n+8)::before{background-color:var(--color-8,transparent);border-color:var(--border-color-8,var(--border-color,#000))}.charts-css.legend li:nth-child(10n+9)::before{background-color:var(--color-9,transparent);border-color:var(--border-color-9,var(--border-color,#000))}.charts-css.legend li:nth-child(10n+10)::before{background-color:var(--color-10,transparent);border-color:var(--border-color-10,var(--border-color,#000))}.charts-css:not(.legend-inline){-webkit-box-orient:vertical;-ms-flex-direction:column;flex-direction:column;-ms-flex-wrap:nowrap;flex-wrap:nowrap}.charts-css.legend-inline,.charts-css:not(.legend-inline){-webkit-box-direction:normal;display:-webkit-box;display:-ms-flexbox;display:flex}.charts-css.legend-inline{-webkit-box-orient:horizontal;-ms-flex-direction:row;flex-direction:row;-ms-flex-wrap:wrap;flex-wrap:wrap}.charts-css.legend-inline li{-webkit-margin-end:1rem;margin-inline-end:1rem}.charts-css.legend-circle li::before{border-radius:50%;height:1rem;width:1rem}.charts-css.legend-ellipse li::before{border-radius:50%;height:1rem;width:2rem}.charts-css.legend-rhombus li::before,.charts-css.legend-square li::before{border-radius:3px;height:1rem;width:1rem}.charts-css.legend-rhombus li::before{-webkit-transform:rotate(45deg) scale(.85);transform:rotate(45deg) scale(.85)}.charts-css.legend-rectangle li::before{border-radius:3px;height:1rem;width:2rem}.charts-css.legend-line li::before{border-radius:2px;-webkit-box-sizing:content-box;box-sizing:content-box;height:3px;width:2rem}.charts-css .tooltip{background-color:#555;border-radius:6px;bottom:50%;color:#fff;font-size:.9rem;left:50%;opacity:0;padding:5px 10px;position:absolute;text-align:center;-webkit-transform:translateX(-50%);transform:translateX(-50%);-webkit-transition:opacity .3s;transition:opacity .3s;visibility:hidden;width:-webkit-max-content;width:-moz-max-content;width:max-content;z-index:1}.charts-css .tooltip::after{border:5px solid transparent;border-top-color:#555;content:"";left:50%;margin-left:-5px;position:absolute;top:100%}.charts-css td:hover .tooltip{opacity:1;visibility:visible}.charts-css.bar tbody{-webkit-box-pack:justify;-ms-flex-pack:justify;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch;aspect-ratio:var(--aspect-ratio,auto);display:-webkit-box;display:-ms-flexbox;display:flex;justify-content:space-between;width:100%}.charts-css.area tbody tr,.charts-css.bar tbody tr,.charts-css.column tbody tr,.charts-css.line tbody tr{-webkit-box-pack:start;-ms-flex-pack:start;-webkit-box-flex:1;-ms-flex-positive:1;-ms-flex-negative:1;-ms-flex-preferred-size:0;display:-webkit-box;display:-ms-flexbox;display:flex;flex-basis:0;flex-grow:1;flex-shrink:1;justify-content:flex-start;overflow-wrap:anywhere;position:relative}.charts-css.bar tbody tr th{bottom:0;left:0;position:absolute;right:0;top:0;width:var(--labels-size)}.charts-css.bar tbody tr td{-webkit-box-align:center;-ms-flex-align:center;-webkit-padding-before:10px;-webkit-padding-after:10px;align-items:center;display:-webkit-box;display:-ms-flexbox;display:flex;height:100%;min-height:1rem;padding-block-end:10px;padding-block-start:10px;position:relative;width:calc(100% * var(--end, var(--size, 1)))}.charts-css.bar:not(.reverse) tbody tr td{-webkit-box-pack:var(--data-position,flex-end);-ms-flex-pack:var(--data-position,flex-end);justify-content:var(--data-position,flex-end)}.charts-css.bar:not(.reverse) tbody tr td .data.outside{-webkit-transform:translateX(100%);transform:translateX(100%);white-space:nowrap}.charts-css.bar.reverse tbody tr td{-webkit-box-pack:var(--data-position,flex-start);-ms-flex-pack:var(--data-position,flex-start);justify-content:var(--data-position,flex-start)}.charts-css.bar.reverse tbody tr td .data.outside{-webkit-transform:translateX(-100%);transform:translateX(-100%);white-space:nowrap}.charts-css.area.reverse tbody tr,.charts-css.area:not(.reverse) tbody tr td .data,.charts-css.bar:not(.reverse) tbody tr,.charts-css.column.reverse tbody tr,.charts-css.line.reverse tbody tr,.charts-css.line:not(.reverse) tbody tr td .data{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.charts-css.area.reverse tbody tr td .data,.charts-css.area:not(.reverse) tbody tr,.charts-css.bar.reverse tbody tr,.charts-css.column:not(.reverse) tbody tr,.charts-css.line.reverse tbody tr td .data,.charts-css.line:not(.reverse) tbody tr{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.charts-css.bar.reverse-labels.reverse tbody tr,.charts-css.bar:not(.reverse-labels):not(.reverse) tbody tr{-webkit-margin-start:var(--labels-size);margin-inline-start:var(--labels-size)}.charts-css.bar:not(.reverse-labels):not(.reverse) tbody tr th{-webkit-margin-end:auto;-webkit-margin-start:calc(-1 * var(--labels-size) - var(--primary-axis-width));margin-inline-end:auto;margin-inline-start:calc(-1 * var(--labels-size) - var(--primary-axis-width))}.charts-css.bar.reverse-labels:not(.reverse) tbody tr,.charts-css.bar:not(.reverse-labels).reverse tbody tr{-webkit-margin-end:var(--labels-size);margin-inline-end:var(--labels-size)}.charts-css.bar:not(.reverse-labels).reverse tbody tr th{-webkit-margin-start:auto;-webkit-margin-end:calc(-1 * var(--labels-size) - var(--primary-axis-width));margin-inline-end:calc(-1 * var(--labels-size) - var(--primary-axis-width));margin-inline-start:auto}.charts-css.bar.reverse-labels:not(.reverse) tbody tr th{-webkit-margin-start:auto;-webkit-margin-end:calc(-1 * var(--labels-size));margin-inline-end:calc(-1 * var(--labels-size));margin-inline-start:auto}.charts-css.bar.reverse-labels.reverse tbody tr th{-webkit-margin-end:auto;-webkit-margin-start:calc(-1 * var(--labels-size));margin-inline-end:auto;margin-inline-start:calc(-1 * var(--labels-size))}.charts-css.bar:not(.stacked) tbody tr td,.charts-css.column:not(.stacked) tbody tr td{-webkit-box-flex:1;-ms-flex-positive:1;-ms-flex-negative:1;-ms-flex-preferred-size:0;flex-basis:0;flex-grow:1;flex-shrink:1}.charts-css.bar.stacked tbody tr td,.charts-css.column.stacked tbody tr td{-webkit-box-flex:unset;-ms-flex-positive:unset;-ms-flex-negative:unset;-ms-flex-preferred-size:unset;flex-basis:unset;flex-grow:unset;flex-shrink:unset}.charts-css.area:not(.reverse) tbody tr th,.charts-css.bar.stacked.reverse-datasets tbody tr,.charts-css.column.stacked.reverse-datasets tbody tr,.charts-css.column:not(.reverse) tbody tr th,.charts-css.line:not(.reverse) tbody tr th{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.charts-css.bar:not(.reverse-data) tbody,.charts-css.bar:not(.reverse-datasets):not(.stacked) tbody tr,.charts-css.column.reverse-datasets.stacked:not(.reverse) tbody tr,.charts-css.column:not(.reverse-datasets).stacked.reverse tbody tr{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.charts-css.bar.reverse-data tbody,.charts-css.bar.reverse-datasets:not(.stacked) tbody tr,.charts-css.column.reverse-datasets.stacked.reverse tbody tr,.charts-css.column:not(.reverse-datasets).stacked:not(.reverse) tbody tr{-webkit-box-orient:vertical;-webkit-box-direction:reverse;-ms-flex-direction:column-reverse;flex-direction:column-reverse}.charts-css.area:not(.reverse-data) tbody,.charts-css.area:not(.reverse-datasets) tbody tr,.charts-css.bar.reverse-datasets.stacked.reverse tbody tr,.charts-css.bar:not(.reverse-datasets).stacked:not(.reverse) tbody tr,.charts-css.column.reverse-labels.reverse-data tbody,.charts-css.column:not(.reverse-datasets):not(.stacked) tbody tr,.charts-css.column:not(.reverse-labels):not(.reverse-data) tbody,.charts-css.line:not(.reverse-data) tbody,.charts-css.line:not(.reverse-datasets) tbody tr{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.charts-css.area.reverse-data tbody,.charts-css.area.reverse-datasets tbody tr,.charts-css.bar.reverse-datasets.stacked:not(.reverse) tbody tr,.charts-css.bar:not(.reverse-datasets).stacked.reverse tbody tr,.charts-css.column.reverse-datasets:not(.stacked) tbody tr,.charts-css.column.reverse-labels:not(.reverse-data) tbody,.charts-css.column:not(.reverse-labels).reverse-data tbody,.charts-css.line.reverse-data tbody,.charts-css.line.reverse-datasets tbody tr{-webkit-box-orient:horizontal;-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.charts-css.bar.data-spacing-1 tbody tr{-webkit-padding-before:1px;-webkit-padding-after:1px;padding-block-end:1px;padding-block-start:1px}.charts-css.bar.data-spacing-2 tbody tr{-webkit-padding-before:2px;-webkit-padding-after:2px;padding-block-end:2px;padding-block-start:2px}.charts-css.bar.data-spacing-3 tbody tr{-webkit-padding-before:3px;-webkit-padding-after:3px;padding-block-end:3px;padding-block-start:3px}.charts-css.bar.data-spacing-4 tbody tr{-webkit-padding-before:4px;-webkit-padding-after:4px;padding-block-end:4px;padding-block-start:4px}.charts-css.bar.data-spacing-5 tbody tr{-webkit-padding-before:5px;-webkit-padding-after:5px;padding-block-end:5px;padding-block-start:5px}.charts-css.bar.data-spacing-6 tbody tr{-webkit-padding-before:6px;-webkit-padding-after:6px;padding-block-end:6px;padding-block-start:6px}.charts-css.bar.data-spacing-7 tbody tr{-webkit-padding-before:7px;-webkit-padding-after:7px;padding-block-end:7px;padding-block-start:7px}.charts-css.bar.data-spacing-8 tbody tr{-webkit-padding-before:8px;-webkit-padding-after:8px;padding-block-end:8px;padding-block-start:8px}.charts-css.bar.data-spacing-9 tbody tr{-webkit-padding-before:9px;-webkit-padding-after:9px;padding-block-end:9px;padding-block-start:9px}.charts-css.bar.data-spacing-10 tbody tr{-webkit-padding-before:10px;-webkit-padding-after:10px;padding-block-end:10px;padding-block-start:10px}.charts-css.bar.data-spacing-11 tbody tr{-webkit-padding-before:11px;-webkit-padding-after:11px;padding-block-end:11px;padding-block-start:11px}.charts-css.bar.data-spacing-12 tbody tr{-webkit-padding-before:12px;-webkit-padding-after:12px;padding-block-end:12px;padding-block-start:12px}.charts-css.bar.data-spacing-13 tbody tr{-webkit-padding-before:13px;-webkit-padding-after:13px;padding-block-end:13px;padding-block-start:13px}.charts-css.bar.data-spacing-14 tbody tr{-webkit-padding-before:14px;-webkit-padding-after:14px;padding-block-end:14px;padding-block-start:14px}.charts-css.bar.data-spacing-15 tbody tr{-webkit-padding-before:15px;-webkit-padding-after:15px;padding-block-end:15px;padding-block-start:15px}.charts-css.bar.data-spacing-16 tbody tr{-webkit-padding-before:16px;-webkit-padding-after:16px;padding-block-end:16px;padding-block-start:16px}.charts-css.bar.data-spacing-17 tbody tr{-webkit-padding-before:17px;-webkit-padding-after:17px;padding-block-end:17px;padding-block-start:17px}.charts-css.bar.data-spacing-18 tbody tr{-webkit-padding-before:18px;-webkit-padding-after:18px;padding-block-end:18px;padding-block-start:18px}.charts-css.bar.data-spacing-19 tbody tr{-webkit-padding-before:19px;-webkit-padding-after:19px;padding-block-end:19px;padding-block-start:19px}.charts-css.bar.data-spacing-20 tbody tr{-webkit-padding-before:20px;-webkit-padding-after:20px;padding-block-end:20px;padding-block-start:20px}.charts-css.bar.datasets-spacing-1 tbody tr td{-webkit-margin-before:1px;-webkit-margin-after:1px;margin-block-end:1px;margin-block-start:1px}.charts-css.bar.datasets-spacing-2 tbody tr td{-webkit-margin-before:2px;-webkit-margin-after:2px;margin-block-end:2px;margin-block-start:2px}.charts-css.bar.datasets-spacing-3 tbody tr td{-webkit-margin-before:3px;-webkit-margin-after:3px;margin-block-end:3px;margin-block-start:3px}.charts-css.bar.datasets-spacing-4 tbody tr td{-webkit-margin-before:4px;-webkit-margin-after:4px;margin-block-end:4px;margin-block-start:4px}.charts-css.bar.datasets-spacing-5 tbody tr td{-webkit-margin-before:5px;-webkit-margin-after:5px;margin-block-end:5px;margin-block-start:5px}.charts-css.bar.datasets-spacing-6 tbody tr td{-webkit-margin-before:6px;-webkit-margin-after:6px;margin-block-end:6px;margin-block-start:6px}.charts-css.bar.datasets-spacing-7 tbody tr td{-webkit-margin-before:7px;-webkit-margin-after:7px;margin-block-end:7px;margin-block-start:7px}.charts-css.bar.datasets-spacing-8 tbody tr td{-webkit-margin-before:8px;-webkit-margin-after:8px;margin-block-end:8px;margin-block-start:8px}.charts-css.bar.datasets-spacing-9 tbody tr td{-webkit-margin-before:9px;-webkit-margin-after:9px;margin-block-end:9px;margin-block-start:9px}.charts-css.bar.datasets-spacing-10 tbody tr td{-webkit-margin-before:10px;-webkit-margin-after:10px;margin-block-end:10px;margin-block-start:10px}.charts-css.bar.datasets-spacing-11 tbody tr td{-webkit-margin-before:11px;-webkit-margin-after:11px;margin-block-end:11px;margin-block-start:11px}.charts-css.bar.datasets-spacing-12 tbody tr td{-webkit-margin-before:12px;-webkit-margin-after:12px;margin-block-end:12px;margin-block-start:12px}.charts-css.bar.datasets-spacing-13 tbody tr td{-webkit-margin-before:13px;-webkit-margin-after:13px;margin-block-end:13px;margin-block-start:13px}.charts-css.bar.datasets-spacing-14 tbody tr td{-webkit-margin-before:14px;-webkit-margin-after:14px;margin-block-end:14px;margin-block-start:14px}.charts-css.bar.datasets-spacing-15 tbody tr td{-webkit-margin-before:15px;-webkit-margin-after:15px;margin-block-end:15px;margin-block-start:15px}.charts-css.bar.datasets-spacing-16 tbody tr td{-webkit-margin-before:16px;-webkit-margin-after:16px;margin-block-end:16px;margin-block-start:16px}.charts-css.bar.datasets-spacing-17 tbody tr td{-webkit-margin-before:17px;-webkit-margin-after:17px;margin-block-end:17px;margin-block-start:17px}.charts-css.bar.datasets-spacing-18 tbody tr td{-webkit-margin-before:18px;-webkit-margin-after:18px;margin-block-end:18px;margin-block-start:18px}.charts-css.bar.datasets-spacing-19 tbody tr td{-webkit-margin-before:19px;-webkit-margin-after:19px;margin-block-end:19px;margin-block-start:19px}.charts-css.bar.datasets-spacing-20 tbody tr td{-webkit-margin-before:20px;-webkit-margin-after:20px;margin-block-end:20px;margin-block-start:20px}.charts-css.area tbody,.charts-css.column tbody,.charts-css.line tbody{-webkit-box-pack:justify;-ms-flex-pack:justify;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch;aspect-ratio:var(--aspect-ratio,21/9);display:-webkit-box;display:-ms-flexbox;display:flex;justify-content:space-between;width:100%}.charts-css.area tbody tr th,.charts-css.column tbody tr th,.charts-css.line tbody tr th{bottom:0;height:var(--labels-size);left:0;position:absolute;right:0;top:0}.charts-css.column tbody tr td{-webkit-box-pack:center;-ms-flex-pack:center;display:-webkit-box;display:-ms-flexbox;display:flex;height:calc(100% * var(--end, var(--size, 1)));justify-content:center;position:relative;width:100%}.charts-css.column:not(.reverse) tbody tr td{-webkit-box-align:var(--data-position,flex-start);-ms-flex-align:var(--data-position,flex-start);align-items:var(--data-position,flex-start)}.charts-css.column.reverse tbody tr td{-webkit-box-align:var(--data-position,flex-end);-ms-flex-align:var(--data-position,flex-end);align-items:var(--data-position,flex-end)}.charts-css.area.reverse tbody tr td,.charts-css.area:not(.reverse) tbody tr td,.charts-css.column.reverse tbody tr td,.charts-css.column:not(.reverse) tbody tr td,.charts-css.line.reverse tbody tr td,.charts-css.line:not(.reverse) tbody tr td{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.charts-css.area.reverse tbody tr th,.charts-css.column.reverse tbody tr th,.charts-css.line.reverse tbody tr th{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.charts-css.area.reverse-labels.reverse tbody tr,.charts-css.area:not(.reverse-labels):not(.reverse) tbody tr,.charts-css.column.reverse-labels.reverse tbody tr,.charts-css.column:not(.reverse-labels):not(.reverse) tbody tr,.charts-css.line.reverse-labels.reverse tbody tr,.charts-css.line:not(.reverse-labels):not(.reverse) tbody tr{-webkit-margin-after:var(--labels-size);margin-block-end:var(--labels-size)}.charts-css.area:not(.reverse-labels):not(.reverse) tbody tr th,.charts-css.column:not(.reverse-labels):not(.reverse) tbody tr th,.charts-css.line:not(.reverse-labels):not(.reverse) tbody tr th{-webkit-margin-before:auto;-webkit-margin-after:calc(-1 * var(--labels-size) - var(--primary-axis-width));margin-block-end:calc(-1 * var(--labels-size) - var(--primary-axis-width));margin-block-start:auto}.charts-css.area.reverse-labels:not(.reverse) tbody tr,.charts-css.area:not(.reverse-labels).reverse tbody tr,.charts-css.column.reverse-labels:not(.reverse) tbody tr,.charts-css.column:not(.reverse-labels).reverse tbody tr,.charts-css.line.reverse-labels:not(.reverse) tbody tr,.charts-css.line:not(.reverse-labels).reverse tbody tr{-webkit-margin-before:var(--labels-size);margin-block-start:var(--labels-size)}.charts-css.area:not(.reverse-labels).reverse tbody tr th,.charts-css.column:not(.reverse-labels).reverse tbody tr th,.charts-css.line:not(.reverse-labels).reverse tbody tr th{-webkit-margin-after:auto;-webkit-margin-before:calc(-1 * var(--labels-size) - var(--primary-axis-width));margin-block-end:auto;margin-block-start:calc(-1 * var(--labels-size) - var(--primary-axis-width))}.charts-css.area.reverse-labels:not(.reverse) tbody tr th,.charts-css.column.reverse-labels:not(.reverse) tbody tr th,.charts-css.line.reverse-labels:not(.reverse) tbody tr th{-webkit-margin-after:auto;-webkit-margin-before:calc(-1 * var(--labels-size));margin-block-end:auto;margin-block-start:calc(-1 * var(--labels-size))}.charts-css.area.reverse-labels.reverse tbody tr th,.charts-css.column.reverse-labels.reverse tbody tr th,.charts-css.line.reverse-labels.reverse tbody tr th{-webkit-margin-before:auto;-webkit-margin-after:calc(-1 * var(--labels-size));margin-block-end:calc(-1 * var(--labels-size));margin-block-start:auto}.charts-css.column.data-spacing-1 tbody tr{-webkit-padding-start:1px;-webkit-padding-end:1px;padding-inline-end:1px;padding-inline-start:1px}.charts-css.column.data-spacing-2 tbody tr{-webkit-padding-start:2px;-webkit-padding-end:2px;padding-inline-end:2px;padding-inline-start:2px}.charts-css.column.data-spacing-3 tbody tr{-webkit-padding-start:3px;-webkit-padding-end:3px;padding-inline-end:3px;padding-inline-start:3px}.charts-css.column.data-spacing-4 tbody tr{-webkit-padding-start:4px;-webkit-padding-end:4px;padding-inline-end:4px;padding-inline-start:4px}.charts-css.column.data-spacing-5 tbody tr{-webkit-padding-start:5px;-webkit-padding-end:5px;padding-inline-end:5px;padding-inline-start:5px}.charts-css.column.data-spacing-6 tbody tr{-webkit-padding-start:6px;-webkit-padding-end:6px;padding-inline-end:6px;padding-inline-start:6px}.charts-css.column.data-spacing-7 tbody tr{-webkit-padding-start:7px;-webkit-padding-end:7px;padding-inline-end:7px;padding-inline-start:7px}.charts-css.column.data-spacing-8 tbody tr{-webkit-padding-start:8px;-webkit-padding-end:8px;padding-inline-end:8px;padding-inline-start:8px}.charts-css.column.data-spacing-9 tbody tr{-webkit-padding-start:9px;-webkit-padding-end:9px;padding-inline-end:9px;padding-inline-start:9px}.charts-css.column.data-spacing-10 tbody tr{-webkit-padding-start:10px;-webkit-padding-end:10px;padding-inline-end:10px;padding-inline-start:10px}.charts-css.column.data-spacing-11 tbody tr{-webkit-padding-start:11px;-webkit-padding-end:11px;padding-inline-end:11px;padding-inline-start:11px}.charts-css.column.data-spacing-12 tbody tr{-webkit-padding-start:12px;-webkit-padding-end:12px;padding-inline-end:12px;padding-inline-start:12px}.charts-css.column.data-spacing-13 tbody tr{-webkit-padding-start:13px;-webkit-padding-end:13px;padding-inline-end:13px;padding-inline-start:13px}.charts-css.column.data-spacing-14 tbody tr{-webkit-padding-start:14px;-webkit-padding-end:14px;padding-inline-end:14px;padding-inline-start:14px}.charts-css.column.data-spacing-15 tbody tr{-webkit-padding-start:15px;-webkit-padding-end:15px;padding-inline-end:15px;padding-inline-start:15px}.charts-css.column.data-spacing-16 tbody tr{-webkit-padding-start:16px;-webkit-padding-end:16px;padding-inline-end:16px;padding-inline-start:16px}.charts-css.column.data-spacing-17 tbody tr{-webkit-padding-start:17px;-webkit-padding-end:17px;padding-inline-end:17px;padding-inline-start:17px}.charts-css.column.data-spacing-18 tbody tr{-webkit-padding-start:18px;-webkit-padding-end:18px;padding-inline-end:18px;padding-inline-start:18px}.charts-css.column.data-spacing-19 tbody tr{-webkit-padding-start:19px;-webkit-padding-end:19px;padding-inline-end:19px;padding-inline-start:19px}.charts-css.column.data-spacing-20 tbody tr{-webkit-padding-start:20px;-webkit-padding-end:20px;padding-inline-end:20px;padding-inline-start:20px}.charts-css.column.datasets-spacing-1 tbody tr td{-webkit-margin-start:1px;-webkit-margin-end:1px;margin-inline-end:1px;margin-inline-start:1px}.charts-css.column.datasets-spacing-2 tbody tr td{-webkit-margin-start:2px;-webkit-margin-end:2px;margin-inline-end:2px;margin-inline-start:2px}.charts-css.column.datasets-spacing-3 tbody tr td{-webkit-margin-start:3px;-webkit-margin-end:3px;margin-inline-end:3px;margin-inline-start:3px}.charts-css.column.datasets-spacing-4 tbody tr td{-webkit-margin-start:4px;-webkit-margin-end:4px;margin-inline-end:4px;margin-inline-start:4px}.charts-css.column.datasets-spacing-5 tbody tr td{-webkit-margin-start:5px;-webkit-margin-end:5px;margin-inline-end:5px;margin-inline-start:5px}.charts-css.column.datasets-spacing-6 tbody tr td{-webkit-margin-start:6px;-webkit-margin-end:6px;margin-inline-end:6px;margin-inline-start:6px}.charts-css.column.datasets-spacing-7 tbody tr td{-webkit-margin-start:7px;-webkit-margin-end:7px;margin-inline-end:7px;margin-inline-start:7px}.charts-css.column.datasets-spacing-8 tbody tr td{-webkit-margin-start:8px;-webkit-margin-end:8px;margin-inline-end:8px;margin-inline-start:8px}.charts-css.column.datasets-spacing-9 tbody tr td{-webkit-margin-start:9px;-webkit-margin-end:9px;margin-inline-end:9px;margin-inline-start:9px}.charts-css.column.datasets-spacing-10 tbody tr td{-webkit-margin-start:10px;-webkit-margin-end:10px;margin-inline-end:10px;margin-inline-start:10px}.charts-css.column.datasets-spacing-11 tbody tr td{-webkit-margin-start:11px;-webkit-margin-end:11px;margin-inline-end:11px;margin-inline-start:11px}.charts-css.column.datasets-spacing-12 tbody tr td{-webkit-margin-start:12px;-webkit-margin-end:12px;margin-inline-end:12px;margin-inline-start:12px}.charts-css.column.datasets-spacing-13 tbody tr td{-webkit-margin-start:13px;-webkit-margin-end:13px;margin-inline-end:13px;margin-inline-start:13px}.charts-css.column.datasets-spacing-14 tbody tr td{-webkit-margin-start:14px;-webkit-margin-end:14px;margin-inline-end:14px;margin-inline-start:14px}.charts-css.column.datasets-spacing-15 tbody tr td{-webkit-margin-start:15px;-webkit-margin-end:15px;margin-inline-end:15px;margin-inline-start:15px}.charts-css.column.datasets-spacing-16 tbody tr td{-webkit-margin-start:16px;-webkit-margin-end:16px;margin-inline-end:16px;margin-inline-start:16px}.charts-css.column.datasets-spacing-17 tbody tr td{-webkit-margin-start:17px;-webkit-margin-end:17px;margin-inline-end:17px;margin-inline-start:17px}.charts-css.column.datasets-spacing-18 tbody tr td{-webkit-margin-start:18px;-webkit-margin-end:18px;margin-inline-end:18px;margin-inline-start:18px}.charts-css.column.datasets-spacing-19 tbody tr td{-webkit-margin-start:19px;-webkit-margin-end:19px;margin-inline-end:19px;margin-inline-start:19px}.charts-css.column.datasets-spacing-20 tbody tr td{-webkit-margin-start:20px;-webkit-margin-end:20px;margin-inline-end:20px;margin-inline-start:20px}.charts-css.area tbody tr td,.charts-css.line tbody tr td{-webkit-box-orient:vertical;-webkit-box-direction:normal;bottom:0;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-flow:column;flex-flow:column;height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:0}.charts-css.area tbody tr td::before,.charts-css.line tbody tr td::before{bottom:0;content:"";left:0;position:absolute;right:0;top:0;z-index:-1}.charts-css.area tbody tr td::after,.charts-css.line tbody tr td::after,.charts-css.pie tbody tr td::after{content:"";width:100%}.charts-css.area.reverse:not(.reverse-data) tbody tr td,.charts-css.area:not(.reverse):not(.reverse-data) tbody tr td,.charts-css.line.reverse:not(.reverse-data) tbody tr td,.charts-css.line:not(.reverse):not(.reverse-data) tbody tr td{-webkit-box-pack:end;-ms-flex-pack:end;-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end;justify-content:flex-end}.charts-css.area:not(.reverse):not(.reverse-data) tbody tr td::before{-webkit-clip-path:polygon(0 calc(100% * (1 - var(--start, var(--end, var(--size))))),100% calc(100% * (1 - var(--end, var(--size)))),100% 100%,0 100%);clip-path:polygon(0 calc(100% * (1 - var(--start, var(--end, var(--size))))),100% calc(100% * (1 - var(--end, var(--size)))),100% 100%,0 100%)}.charts-css.area.reverse:not(.reverse-data) tbody tr td .data,.charts-css.area:not(.reverse):not(.reverse-data) tbody tr td .data,.charts-css.line.reverse:not(.reverse-data) tbody tr td .data,.charts-css.line:not(.reverse):not(.reverse-data) tbody tr td .data{-webkit-transform:translateX(50%);transform:translateX(50%)}.charts-css.area:not(.reverse).reverse-data tbody tr td::after,.charts-css.area:not(.reverse):not(.reverse-data) tbody tr td::after,.charts-css.line:not(.reverse).reverse-data tbody tr td::after,.charts-css.line:not(.reverse):not(.reverse-data) tbody tr td::after{height:calc(100% * var(--end, var(--size)))}.charts-css.area.reverse.reverse-data tbody tr td,.charts-css.area:not(.reverse).reverse-data tbody tr td,.charts-css.line.reverse.reverse-data tbody tr td,.charts-css.line:not(.reverse).reverse-data tbody tr td{-webkit-box-pack:end;-ms-flex-pack:end;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;justify-content:flex-end}.charts-css.area:not(.reverse).reverse-data tbody tr td::before{-webkit-clip-path:polygon(0 calc(100% * (1 - var(--end, var(--size)))),100% calc(100% * (1 - var(--start, var(--end, var(--size))))),100% 100%,0 100%);clip-path:polygon(0 calc(100% * (1 - var(--end, var(--size)))),100% calc(100% * (1 - var(--start, var(--end, var(--size))))),100% 100%,0 100%)}.charts-css.area.reverse.reverse-data tbody tr td .data,.charts-css.area:not(.reverse).reverse-data tbody tr td .data,.charts-css.line.reverse.reverse-data tbody tr td .data,.charts-css.line:not(.reverse).reverse-data tbody tr td .data{-webkit-transform:translateX(-50%);transform:translateX(-50%)}.charts-css.area.reverse:not(.reverse-data) tbody tr td::before{-webkit-clip-path:polygon(0 0,100% 0,100% calc(100% * var(--end, var(--size))),0 calc(100% * var(--start, var(--end, var(--size)))));clip-path:polygon(0 0,100% 0,100% calc(100% * var(--end, var(--size))),0 calc(100% * var(--start, var(--end, var(--size)))))}.charts-css.area.reverse.reverse-data tbody tr td::after,.charts-css.area.reverse:not(.reverse-data) tbody tr td::after,.charts-css.line.reverse.reverse-data tbody tr td::after,.charts-css.line.reverse:not(.reverse-data) tbody tr td::after{height:calc(100% * (1 - var(--end, var(--size))))}.charts-css.area.reverse.reverse-data tbody tr td::before{-webkit-clip-path:polygon(0 0,100% 0,100% calc(100% * var(--start, var(--end, var(--size)))),0 calc(100% * var(--end, var(--size))));clip-path:polygon(0 0,100% 0,100% calc(100% * var(--start, var(--end, var(--size)))),0 calc(100% * var(--end, var(--size))))}.charts-css.line{--line-size:3px}.charts-css.line:not(.reverse):not(.reverse-data) tbody tr td::before{-webkit-clip-path:polygon(0 calc(100% * (1 - var(--start, var(--end, var(--size))))),100% calc(100% * (1 - var(--end, var(--size)))),100% calc(100% * (1 - var(--end, var(--size))) - var(--line-size)),0 calc(100% * (1 - var(--start, var(--end, var(--size)))) - var(--line-size)));clip-path:polygon(0 calc(100% * (1 - var(--start, var(--end, var(--size))))),100% calc(100% * (1 - var(--end, var(--size)))),100% calc(100% * (1 - var(--end, var(--size))) - var(--line-size)),0 calc(100% * (1 - var(--start, var(--end, var(--size)))) - var(--line-size)))}.charts-css.line:not(.reverse).reverse-data tbody tr td::before{-webkit-clip-path:polygon(0 calc(100% * (1 - var(--end, var(--size)))),100% calc(100% * (1 - var(--start, var(--end, var(--size))))),100% calc(100% * (1 - var(--start, var(--end, var(--size)))) - var(--line-size)),0 calc(100% * (1 - var(--end, var(--size))) - var(--line-size)));clip-path:polygon(0 calc(100% * (1 - var(--end, var(--size)))),100% calc(100% * (1 - var(--start, var(--end, var(--size))))),100% calc(100% * (1 - var(--start, var(--end, var(--size)))) - var(--line-size)),0 calc(100% * (1 - var(--end, var(--size))) - var(--line-size)))}.charts-css.line.reverse:not(.reverse-data) tbody tr td::before{-webkit-clip-path:polygon(0 calc(100% * var(--start, var(--end, var(--size))) - var(--line-size)),100% calc(100% * var(--end, var(--size)) - var(--line-size)),100% calc(100% * var(--end, var(--size))),0 calc(100% * var(--start, var(--end, var(--size)))));clip-path:polygon(0 calc(100% * var(--start, var(--end, var(--size))) - var(--line-size)),100% calc(100% * var(--end, var(--size)) - var(--line-size)),100% calc(100% * var(--end, var(--size))),0 calc(100% * var(--start, var(--end, var(--size)))))}.charts-css.line.reverse.reverse-data tbody tr td::before{-webkit-clip-path:polygon(0 calc(100% * var(--end, var(--size)) - var(--line-size)),100% calc(100% * var(--start, var(--end, var(--size))) - var(--line-size)),100% calc(100% * var(--start, var(--end, var(--size)))),0 calc(100% * var(--end, var(--size))));clip-path:polygon(0 calc(100% * var(--end, var(--size)) - var(--line-size)),100% calc(100% * var(--start, var(--end, var(--size))) - var(--line-size)),100% calc(100% * var(--start, var(--end, var(--size)))),0 calc(100% * var(--end, var(--size))))}.charts-css.pie tbody,.charts-css.polar tbody,.charts-css.radar tbody,.charts-css.radial tbody{aspect-ratio:1;background-color:var(--chart-bg-color);border-radius:50%;display:block;width:100%}.charts-css.pie tbody tr td{-webkit-box-pack:center;-ms-flex-pack:center;background:conic-gradient(transparent 0 calc(1turn * var(--start)),var(--c,transparent) calc(1turn * var(--start, 0)) calc(1turn * var(--end)),transparent calc(1turn * var(--end)) 1turn);border-radius:50%;display:-webkit-box;display:-ms-flexbox;display:flex;justify-content:center}.charts-css.pie tbody tr td,.charts-css.pie tbody tr td::before{bottom:0;height:100%;left:0;position:absolute;right:0;top:0;width:100%}.charts-css.pie tbody tr td::before{content:""}.charts-css.pie tbody tr td .data{-webkit-box-pack:center;-ms-flex-pack:center;border-radius:50%;bottom:0;display:-webkit-box;display:-ms-flexbox;display:flex;height:100%;justify-content:center;left:0;position:absolute;right:0;top:0;-webkit-transform:rotate(calc(.5turn * var(--start, 0) + .5turn * var(--end, 0)));transform:rotate(calc(.5turn * var(--start, 0) + .5turn * var(--end, 0)));width:100%} \ No newline at end of file diff --git a/lib/graphql/dashboard/statics/dashboard.css b/lib/graphql/dashboard/statics/dashboard.css new file mode 100644 index 00000000000..ccd15ce6482 --- /dev/null +++ b/lib/graphql/dashboard/statics/dashboard.css @@ -0,0 +1,30 @@ +#header-icon { + max-height: 2em; +} + +.graphql-highlight { + font-family:'Courier New', Courier, monospace; + width: 100%; + white-space: pre-wrap; +} + +#limiter-histogram .column { + max-height: 300px; +} + +#limiter-histogram .column td { + --color-1: var(--bs-gray); + --color-2: var(--bs-red); + opacity: 0.6; +} + +#limiter-histogram .column td:hover { + opacity: 1; +} + +#limiter-histogram .column tbody tr th[scope=row] { + width: 150px; + transform: rotate(-75deg) translateY(55px) translateX(-50px); + left: auto; + --labels-align-inline: end; +} diff --git a/lib/graphql/dashboard/statics/dashboard.js b/lib/graphql/dashboard/statics/dashboard.js new file mode 100644 index 00000000000..b9040d40ed2 --- /dev/null +++ b/lib/graphql/dashboard/statics/dashboard.js @@ -0,0 +1,143 @@ +function detectTheme() { + var storedTheme = localStorage.getItem("graphql_dashboard:theme") + var preferredTheme = !!window.matchMedia('(prefers-color-scheme: dark)').matches ? "dark" : "light" + setTheme(storedTheme || preferredTheme) +} + +function toggleTheme() { + var nextTheme = document.documentElement.getAttribute("data-bs-theme") == "dark" ? "light" : "dark" + setTheme(nextTheme) +} + +function setTheme(theme) { + localStorage.setItem("graphql_dashboard:theme", theme) + document.documentElement.setAttribute("data-bs-theme", theme) + var icon = theme == "dark" ? "🌙" : "🌞" + var toggle = document.getElementById("themeToggle") + if (toggle) { + toggle.innerText = icon + } else { + document.addEventListener("DOMContentLoaded", function(_ev) { + document.getElementById("themeToggle").innerText = icon + }) + } +} + +detectTheme() + +var perfettoUrl = "https://ui.perfetto.dev" +async function openOnPerfetto(operationName, tracePath) { + var resp = await fetch(tracePath); + var blob = await resp.blob(); + var nextPerfettoData = await blob.arrayBuffer(); + nextPerfettoWindow = window.open(perfettoUrl) + + var messageHandler = function(event) { + if (event.origin == perfettoUrl && event.data == "PONG") { + clearInterval(perfettoWaiting) + window.removeEventListener("message", messageHandler) + nextPerfettoWindow.postMessage({ + perfetto: { + buffer: nextPerfettoData, + title: operationName + " - GraphQL", + filename: "perfetto-" + operationName + ".dump", + } + }, perfettoUrl) + } + } + + window.addEventListener("message", messageHandler, false) + perfettoWaiting = setInterval(function() { + nextPerfettoWindow.postMessage("PING", perfettoUrl) + }, 100) +} + +function getCsrfToken() { + return document.querySelector("meta[name='csrf-token']").content +} + +function deleteTrace(tracePath) { + if (confirm("Are you sure you want to permanently delete this trace?")) { + fetch(tracePath, { method: "DELETE", headers: { + "X-CSRF-Token": getCsrfToken() + } }).then(function(_response) { + window.location.reload() + }) + } +} + +function deleteAllTraces(path) { + if (confirm("Are you sure you want to permanently delete ALL traces?")) { + fetch(path, { method: "DELETE", headers: { + "X-CSRF-Token": getCsrfToken() + } }).then(function(_response) { + window.location.reload() + }) + } +} + +function deleteAllSubscriptions(path) { + if (confirm("This will:\n\n- Remove all subscriptions from the database\n- Stop updates to all current subscribers\n\nAre you sure?")) { + fetch(path, { method: "POST", headers: { + "X-CSRF-Token": getCsrfToken() + } }).then(function(_response) { + window.location.reload() + }) + } +} + +function sendArchive(clientName) { + var values = [] + document.querySelectorAll(".archive-check:checked").forEach(function(el) { + values.push(el.value) + }) + if (values.length == 0) { + return + } + var mode = window.location.pathname.includes("/archived") ? "/unarchive" : "/archive" + if (mode == "/archive") { + if (!confirm("Are you sure you want to archive these operations? They won't be usable by clients while archived.")) { + return + } + } else { + if (!confirm("Are you sure you want to reactivate these operations? They'll be available to clients again.")) { + return + } + } + var url = window.location.pathname.replace("/archived", "") + url += mode + var data + + if (clientName) { + data = { + operation_aliases: values + } + } else { + data = { + digests: values + } + } + fetch(url, { method: "POST", body: JSON.stringify(data), headers: { + "X-CSRF-Token": getCsrfToken(), + "Content-Type": "application/json", + }}).then(function(_response) { + window.location.reload() + }) +} + +document.addEventListener("click", function(event) { + var dataset = event.target.dataset + if (dataset.perfettoOpen) { + openOnPerfetto(dataset.perfettoOpen, dataset.perfettoPath) + } else if (dataset.perfettoDelete) { + deleteTrace(dataset.perfettoDelete, event) + } else if (dataset.perfettoDeleteAll) { + deleteAllTraces(dataset.perfettoDeleteAll) + } else if (dataset.subscriptionsDeleteAll) { + deleteAllSubscriptions(dataset.subscriptionsDeleteAll) + } else if (event.target.id == "themeToggle") { + toggleTheme() + } else if (dataset.archiveClient || dataset.archiveAll) { + sendArchive(dataset.archiveClient) + } +}) diff --git a/lib/graphql/dashboard/statics/header-icon.png b/lib/graphql/dashboard/statics/header-icon.png new file mode 100644 index 00000000000..ebdcc2762ed Binary files /dev/null and b/lib/graphql/dashboard/statics/header-icon.png differ diff --git a/lib/graphql/dashboard/statics/icon.png b/lib/graphql/dashboard/statics/icon.png new file mode 100644 index 00000000000..47c4bdb26e4 Binary files /dev/null and b/lib/graphql/dashboard/statics/icon.png differ diff --git a/lib/graphql/dashboard/statics_controller.rb b/lib/graphql/dashboard/statics_controller.rb new file mode 100644 index 00000000000..87a8f470637 --- /dev/null +++ b/lib/graphql/dashboard/statics_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true +module Graphql + class Dashboard < Rails::Engine + class StaticsController < ApplicationController + skip_forgery_protection + # Use an explicit list of files to avoid any chance of reading other files from disk + STATICS = {} + + [ + "icon.png", + "header-icon.png", + "charts.min.css", + "dashboard.css", + "dashboard.js", + "bootstrap-5.3.3.min.css", + "bootstrap-5.3.3.min.js", + ].each do |static_file| + STATICS[static_file] = File.expand_path("../statics/#{static_file}", __FILE__) + end + + def show + expires_in 1.year, public: true + if (filepath = STATICS[params[:id]]) + render file: filepath + else + head :not_found + end + end + end + end +end diff --git a/lib/graphql/dashboard/subscriptions.rb b/lib/graphql/dashboard/subscriptions.rb new file mode 100644 index 00000000000..d8074176101 --- /dev/null +++ b/lib/graphql/dashboard/subscriptions.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true +require_relative "./installable" +module Graphql + class Dashboard < Rails::Engine + module Subscriptions + class BaseController < Graphql::Dashboard::ApplicationController + include Installable + + def feature_installed? + defined?(GraphQL::Pro::Subscriptions) && schema_class.subscriptions.is_a?(GraphQL::Pro::Subscriptions) + end + + INSTALLABLE_COMPONENT_HEADER_HTML = "GraphQL-Pro Subscriptions aren't installed on this schema yet.".html_safe + INSTALLABLE_COMPONENT_MESSAGE_HTML = <<-HTML.html_safe + Deliver live updates over + Pusher or + Ably + with GraphQL-Pro's subscription integrations. + HTML + end + + class TopicsController < BaseController + def show + topic_name = params[:name] + all_subscription_ids = [] + schema_class.subscriptions.each_subscription_id(topic_name) do |sid| + all_subscription_ids << sid + end + + page = params[:page]&.to_i || 1 + limit = params[:per_page]&.to_i || 20 + offset = limit * (page - 1) + subscription_ids = all_subscription_ids[offset, limit] + subs = schema_class.subscriptions.read_subscriptions(subscription_ids) + show_broadcast_subscribers_count = schema_class.subscriptions.show_broadcast_subscribers_count? + subs.each do |sub| + sub[:is_broadcast] = is_broadcast = schema_class.subscriptions.broadcast_subscription_id?(sub[:id]) + if is_broadcast && show_broadcast_subscribers_count + sub[:subscribers_count] = sub_count =schema_class.subscriptions.count_broadcast_subscribed(sub[:id]) + sub[:still_subscribed] = sub_count > 0 + else + sub[:still_subscribed] = schema_class.subscriptions.still_subscribed?(sub[:id]) + sub[:subscribers_count] = nil + end + end + + @topic_last_triggered_at = schema_class.subscriptions.topic_last_triggered_at(topic_name) + @subscriptions = subs + @subscriptions_count = all_subscription_ids.size + @show_broadcast_subscribers_count = show_broadcast_subscribers_count + @has_next_page = all_subscription_ids.size > offset + limit ? page + 1 : false + end + + def index + page = params[:page]&.to_i || 1 + per_page = params[:per_page]&.to_i || 20 + offset = per_page * (page - 1) + limit = per_page + topics, all_topics_count, has_next_page = schema_class.subscriptions.topics(offset: offset, limit: limit) + + @topics = topics + @all_topics_count = all_topics_count + @has_next_page = has_next_page + @page = page + end + end + + class SubscriptionsController < BaseController + def show + subscription_id = params[:id] + subscriptions = schema_class.subscriptions + query_data = subscriptions.read_subscription(subscription_id) + is_broadcast = subscriptions.broadcast_subscription_id?(subscription_id) + + if is_broadcast && subscriptions.show_broadcast_subscribers_count? + subscribers_count = subscriptions.count_broadcast_subscribed(subscription_id) + is_still_subscribed = subscribers_count > 0 + else + subscribers_count = nil + is_still_subscribed = subscriptions.still_subscribed?(subscription_id) + end + + @query_data = query_data + @still_subscribed = is_still_subscribed + @is_broadcast = is_broadcast + @subscribers_count = subscribers_count + end + + def clear_all + schema_class.subscriptions.clear + flash[:success] = "All subscription data cleared." + head :no_content + end + end + end + end +end diff --git a/lib/graphql/dashboard/views/graphql/dashboard/detailed_traces/traces/index.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/detailed_traces/traces/index.html.erb new file mode 100644 index 00000000000..8d26c247d84 --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/detailed_traces/traces/index.html.erb @@ -0,0 +1,45 @@ +<% content_for(:title, "Profiles") %> +
+
+

Detailed Profiles

+
+
+ <%= button_tag "Delete All Traces", class: "btn btn-sm btn-outline-danger", data: { perfetto_delete_all: graphql_dashboard.delete_all_detailed_traces_traces_path } %> +
+
+ +
+
+ + + + + + + + + + + <% if @traces.empty? %> + + + + <% end %> + <% @traces.each do |trace| %> + + + + + + + + <% end %> + +
OperationDuration (ms) TimestampOpen in Perfetto UI
+ No traces saved yet. Read about saving traces <%= link_to "in the docs", "https://graphql-ruby.org/queries/tracing#detailed-profiles" %>. +
<%= trace.operation_name %><%= trace.duration_ms.round(2) %><%= Time.at(trace.begin_ms / 1000.0).strftime("%Y-%m-%d %H:%M:%S.%L") %><%= link_to "View ↗", "#", data: { perfetto_open: trace.operation_name, perfetto_path: graphql_dashboard.detailed_traces_trace_path(trace.id) } %><%= link_to "Delete", "#", data: { perfetto_delete: graphql_dashboard.detailed_traces_trace_path(trace.id) }, class: "text-danger" %>
+ <% if @last && @traces.size >= @last %> + <%= link_to("Previous >", graphql_dashboard.detailed_traces_traces_path(last: @last, before: @traces.last.begin_ms), class: "btn btn-outline-primary") %> + <% end %> +
+
diff --git a/lib/graphql/dashboard/views/graphql/dashboard/landings/show.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/landings/show.html.erb new file mode 100644 index 00000000000..b12cf0ea164 --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/landings/show.html.erb @@ -0,0 +1,18 @@ +<% content_for(:title, "Landing") %> + +
+
+
+
+
+

+ Welcome to the GraphQL-Ruby Dashboard +

+
+

+ Click the links above to see data about your schema (<%= schema_class %>). +

+
+
+
+
diff --git a/lib/graphql/dashboard/views/graphql/dashboard/limiters/limiters/show.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/limiters/limiters/show.html.erb new file mode 100644 index 00000000000..4a7166833d0 --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/limiters/limiters/show.html.erb @@ -0,0 +1,62 @@ +<% content_for(:title, @title) %> +<% if @install_path %> +
+
+

<%= @title %>

+

It looks like this limiter isn't installed yet. Install it now.

+
+
+<% else %> +
+
+

<%= @title %>

+
+
+
+ <%= link_to("This Hour", graphql_dashboard.limiters_limiter_path(params[:name], chart: "hour"), class: "btn btn-sm btn-outline-primary #{@chart_mode == "hour" ? "active" : "inactive"}", params: { chart: "hour" }) %> + <%= link_to("Today", graphql_dashboard.limiters_limiter_path(params[:name], chart: "day"), class: "btn btn-sm btn-outline-primary #{@chart_mode == "day" ? "active" : "inactive"}", params: { chart: "day" }) %> + <%= link_to("This Month", graphql_dashboard.limiters_limiter_path(params[:name], chart: "month"), class: "btn btn-sm btn-outline-primary #{@chart_mode == "month" ? "active" : "inactive"}", params: { chart: "month" }) %> +
+
+
+ <%= form_tag graphql_dashboard.limiters_limiter_path(params[:name], chart: @chart_mode), method: "patch" do %> + <%= submit_tag "#{@current_soft ? "Disable" : "Enable"} Soft Limiting", class: "btn btn-sm btn-outline-warning" %> + <% end %> +
+
+ +
+
+
+ + + + + + + + <% @histogram.columns.each_with_index do |col, col_idx| %> + + + <% col.values.each_with_index do |value, val_idx| %> + + <% end %> + + <% end %> + +
DateLimited RequestsUnlimited Requests
<%= col.label %> + <%= value.formatted_value %> + <%= value.label %>: <%= value.formatted_value %>
<%= col.label %>
+
+
+
+
+ <%= content_tag "style", nonce: @csp_nonce do %> + <% @histogram.columns.each_with_index do |col, col_idx| %> + <% col_max = @histogram.max_column_value.to_f %> + <% col.values.each_with_index do |val, val_idx| %> + #data-<%= col_idx %>-<%= val_idx %> { --size: <%= val.value / col_max %>} + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/lib/graphql/dashboard/views/graphql/dashboard/not_installed.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/not_installed.html.erb new file mode 100644 index 00000000000..44564cccf51 --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/not_installed.html.erb @@ -0,0 +1,18 @@ +<% content_for(:title, "Operation Store") %> + +
+
+
+
+
+

+ <%= @component_header_html %> +

+
+

+ <%= @component_message_html %> +

+
+
+
+
diff --git a/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/_form.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/_form.html.erb new file mode 100644 index 00000000000..f01bcde282f --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/_form.html.erb @@ -0,0 +1,24 @@ +<%= form_tag((@client.persisted? ? graphql_dashboard.operation_store_client_path(name: @client.name) : graphql_dashboard.operation_store_clients_path), method: (@client.persisted? ? "patch" : "post")) do %> +
+ +
+ <%= text_field_tag "client[name]", @client.name, class: "form-control", disabled: @client.persisted? %> +
a unique identifier for this owner of persisted operations
+
+
+
+ +
+ <%= textarea_tag "client[secret]", @client.secret, class: "form-control" %> +
authentication credential for sync transactions
+
+
+
+
+ <%= submit_tag "Save", class: "btn btn-outline-primary" %> +
+
+ <%= link_to "Back", graphql_dashboard.operation_store_clients_path, class: "btn btn-outline-secondary" %> +
+
+<% end %> diff --git a/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/edit.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/edit.html.erb new file mode 100644 index 00000000000..f89dfd4b030 --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/edit.html.erb @@ -0,0 +1,21 @@ +<% content_for(:title, "Edit #{@client.name}") %> +
+
+

Edit <%= @client.name %>

+
+
+<%= render partial: "graphql/dashboard/operation_store/clients/form" %> + +
+
+
+
+

Delete <%= @client.name %>

+

If you delete this client, it will no longer be able to use stored operations.

+

There is no way to undo this action.

+ <%= form_tag(graphql_dashboard.operation_store_client_path(name: @client.name), method: "delete") do %> + <%= submit_tag "Permanently Delete #{@client.name.inspect}", class: "btn btn-outline-danger" %> + <% end %> +
+
+
diff --git a/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/index.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/index.html.erb new file mode 100644 index 00000000000..f79cd7b65c2 --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/index.html.erb @@ -0,0 +1,69 @@ +<% content_for(:title, "Clients") %> +
+
+

+ <%= pluralize(@clients_page.total_count, "Client") %> +

+
+
+ <%= link_to("New Client", graphql_dashboard.new_operation_store_client_path, class: "btn btn-outline-primary") %> +
+
+ + + + + + + + + + + + + <% if @clients_page.total_count == 0 %> + + + + <% else %> + <% @clients_page.items.each do |client| %> + + + + + + + + <% end %> + <% end %> + +
<%= link_to("Name", graphql_dashboard.operation_store_clients_path, params: { order_by: "name", order_dir: ((@order_by == "name" && @order_dir != :desc) ? "desc" : "asc" )}) %>OperationsCreated AtLast Updated<%= link_to("Last Used At", graphql_dashboard.operation_store_clients_path, params: { order_by: "last_used_at", order_dir: ((@order_by == "last_used_at" && @order_dir != :desc) ? "desc": "asc")}) %>
+ To get started, create a <%= link_to "new client", graphql_dashboard.new_operation_store_client_path %>, then <%= link_to "sync operations", "https://graphql-ruby.org/operation_store/client_workflow.html" %> to your schema. +
<%= link_to(client.name, graphql_dashboard.edit_operation_store_client_path(name: client.name)) %> + <%= link_to(graphql_dashboard.operation_store_client_operations_path(client_name: client.name)) do %> + <%= client.operations_count %><% if client.archived_operations_count > 0 %> (<%= client.archived_operations_count %> archived)<% end %> + <% end %> + <%= client.created_at %> + <% if client.operations_count == 0 %> + — + <% else %> + <%= client.last_synced_at %> + <% end %> + <%= client.last_used_at || "—" %>
+ +
+
+ <% if @clients_page.prev_page %> + <%= link_to("« prev", graphql_dashboard.operation_store_clients_path(per_page: params[:per_page], page: @clients_page.prev_page), class: "btn btn-outline-secondary") %> + <% else %> + + <% end %> +
+
+ <% if @clients_page.next_page %> + <%= link_to("next »", graphql_dashboard.operation_store_clients_path(per_page: params[:per_page], page: @clients_page.next_page), class: "btn btn-outline-secondary") %> + <% else %> + + <% end %> +
+
diff --git a/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/new.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/new.html.erb new file mode 100644 index 00000000000..79c135efad7 --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/new.html.erb @@ -0,0 +1,7 @@ +<% content_for(:title, "New Client") %> +
+
+

New Client

+
+
+<%= render partial: "graphql/dashboard/operation_store/clients/form" %> diff --git a/lib/graphql/dashboard/views/graphql/dashboard/operation_store/index_entries/index.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/index_entries/index.html.erb new file mode 100644 index 00000000000..80454a8c0b3 --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/index_entries/index.html.erb @@ -0,0 +1,39 @@ +<% content_for(:title, "Index#{@search_term ? " - #{@search_term}" : ""}") %> +
+

Schema Index

+
+

+ <%= pluralize(@index_entries_page.total_count, @search_term ? "result" : "entry") %> +

+
+
+
+
+ <%= text_field_tag "q", @search_term, class: "form-control", placeholder: "Find types, fields, arguments, or enum values" %> + +
+
+
+
+ + + + + + + + + + <% @index_entries_page.items.each do |entry| %> + + + + + + <% end %> + +
Name# UsagesLast Used At
<%= link_to(entry.name, graphql_dashboard.operation_store_index_entry_path(name: entry.name)) %><%= entry.references_count %><% if entry.archived_references_count.nil? %>(missing data - call `YourSchema.operation_store.reindex` to repair index)<% elsif entry.archived_references_count > 0 %> (<%= entry.archived_references_count %> archived)<% end %><%= entry.last_used_at %>
+ +<%= +# render_partial("_pagination") +%> diff --git a/lib/graphql/dashboard/views/graphql/dashboard/operation_store/index_entries/show.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/index_entries/show.html.erb new file mode 100644 index 00000000000..6e082b937af --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/index_entries/show.html.erb @@ -0,0 +1,32 @@ +<% name = @chain.pop %> +<% content_for(:title, "Index - #{@entry.name}") %> +
+
+ <%= link_to("Index", graphql_dashboard.operation_store_index_entries_path) %> + <% @chain.each do |c| %> + > <%= link_to(c.split(".").last, graphql_dashboard.operation_store_index_entry_path(name: c)) %> + <% end %> + > <%= name.split(".").last %> +
+
+
+
+

<%= name %>

+

+ Used By: + <% if @operations.any? %> +

    + <% @operations.each do |operation| %> +
  • + <%= link_to(operation.name, graphql_dashboard.operation_store_operation_path(digest: operation.digest)) %><% if operation.is_archived %> (archived)<% end %> +
  • + <% end %> +
+ <% else %> + none + <% end %> +

+ +

Last used at: <%= @entry.last_used_at || "—" %>

+
+
diff --git a/lib/graphql/dashboard/views/graphql/dashboard/operation_store/operations/index.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/operations/index.html.erb new file mode 100644 index 00000000000..4039ba0433b --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/operations/index.html.erb @@ -0,0 +1,81 @@ +
+ <% if @client_operations %> + <%= content_for(:title, "#{params[:client_name]} Operations") %> +
+

<%= params[:client_name] %>

+ +
+ <% else %> + <%= content_for(:title, "Operations") %> +
+ +
+ <% end %> +
+ +
+
+ + + + + <% if @client_operations %> + + <% else %> + + <% end %> + + + + + + + <% if @operations_page.total_count == 0 %> + + + + <% else %> + <% @operations_page.items.each do |operation| %> + + + <% if @client_operations %> + + <% else %> + + <% end %> + + + + + <% end %> + <% end %> + +
<%= link_to "Name", graphql_dashboard.operation_store_operations_path({ order_by: "name", order_dir: params[:order_dir] == "asc" ? "desc" : "asc" }) %>Alias# ClientsDigest<%= link_to "Last Used At", graphql_dashboard.operation_store_operations_path({ order_by: "last_used_at", order_dir: params[:order_dir] == "asc" ? "desc" : "asc" }) %> + +
+ <% if @is_archived %> + <%= link_to "Archived operations", "https://graphql-ruby.org/operation_store/server_management.html#archiving-and-deleting-data" %> will appear here. + <% else %> + Add your first stored operations with <%= link_to "sync", "https://graphql-ruby.org/operation_store/client_workflow.html" %>. + <% end %> +
<%= link_to(operation.name, graphql_dashboard.operation_store_operation_path(digest: operation.digest)) %><%= operation.operation_alias %><%= operation.clients_count %><%= operation.digest %><%= operation.last_used_at %> + <%= check_box_tag("value", (@client_operations ? operation.operation_alias : operation.digest), class: "archive-check form-check-input") %> +
+
+
diff --git a/lib/graphql/dashboard/views/graphql/dashboard/operation_store/operations/show.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/operations/show.html.erb new file mode 100644 index 00000000000..e76ab8eaa52 --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/operation_store/operations/show.html.erb @@ -0,0 +1,71 @@ +<% content_for(:title, "View #{params[:digest]}") %> +<% if @operation.nil? %> +
+
+

No stored operation found for <%= params[:digest] %>

+
+
+<% else %> +
+
+

+ <%= @operation.name %> + <% if @operation.is_archived %> (archived)<% end %> +

+
+
+
+
+

Aliases

+ <% if @client_operations.empty? %> +

None

+ <% else %> +
    + <% @client_operations.each do |cl_op| %> +
  • + <%= cl_op.operation_alias %> + <%= link_to(cl_op.client_name, graphql_dashboard.operation_store_client_operations_path(client_name: cl_op.client_name)) %> + <%= cl_op.is_archived ? " (archived)" : "" %> +
  • + <% end %> +
+ <% end %> +
+
+
+
+

Last Used At

+

<%= @operation.last_used_at %>

+
+
+
+
+

Source

+ <%= textarea_tag "_source", @graphql_source, class: "graphql-highlight form-control", disabled: true, rows: @graphql_source.count("\n") + 1 %> +
+
+
+
+

References

+
    + <% @entries.each do |entry| %> +
  • + <%= link_to(entry.name, graphql_dashboard.operation_store_index_entry_path(name: entry.name)) %> +
  • + <% end %> +
+
+
+
+
+

Digest

+

<%= @operation.digest %>

+
+
+
+
+

Minified Source

+ <%= textarea_tag "_source", @operation.body, class: "graphql-highlight form-control", disabled: true, rows: @operation.body.count("\n") + 1 %> +
+
+<% end %> diff --git a/lib/graphql/dashboard/views/graphql/dashboard/subscriptions/subscriptions/show.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/subscriptions/subscriptions/show.html.erb new file mode 100644 index 00000000000..ece1676e298 --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/subscriptions/subscriptions/show.html.erb @@ -0,0 +1,41 @@ +<% content_for(:title, "Subscription #{params[:id]}") %> +
+
+

Subscription: <%= params[:id] %>

+
+
+ +<% if @query_data.nil? %> +
+
+

This subscription was not found or is no longer active.

+
+
+<% else %> +
+
+

Created at <%= @query_data[:created_at] %>, last triggered at <%= @query_data[:last_triggered_at] || "--" %>

+ +

Subscribed? <%= @still_subscribed ? "YES" : "NO" %>

+

Broadcast? <%= @is_broadcast ? "YES" : "NO" %> <% if @is_broadcast %> + <% if @subscribers_count.nil? %> + This subscription may have multiple subscribers. + <% else %> + (<%= pluralize(@subscribers_count, "subscriber") %>) + <% end %> + <% end %>

+ +

Context:

+
<%= @query_data[:context].inspect %>
+ +

Variables:

+
<%= @query_data[:variables].inspect %>
+ +

Operation Name:

+
<%= @query_data[:operation_name].inspect %>
+ +

Query String:

+ <%= textarea_tag "_source", @query_data[:query_string], class: "graphql-highlight form-control", disabled: true, rows: @query_data[:query_string].count("\n") + 1 %> +
+
+<% end %> diff --git a/lib/graphql/dashboard/views/graphql/dashboard/subscriptions/topics/index.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/subscriptions/topics/index.html.erb new file mode 100644 index 00000000000..6e3f941bd11 --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/subscriptions/topics/index.html.erb @@ -0,0 +1,55 @@ +<% content_for(:title, "Subscriptions - Topics") %> +
+
+

+ <%= pluralize(@all_topics_count, "Subscription Topic") %> +

+
+
+ <%= button_tag "Clear All", class: "btn btn-outline-danger", data: { subscriptions_delete_all: graphql_dashboard.subscriptions_clear_all_path } %> +
+
+ + + + + + + + + + + <% if @all_topics_count == 0 %> + + + + <% else %> + <% @topics.each do |topic| %> + + + + + + <% end %> + <% end %> + +
Name# SubscriptionsLast Triggered At
+ There aren't any subscriptions right now. +
<%= link_to(topic.name, graphql_dashboard.subscriptions_topic_path(name: topic.name)) %><%= topic.subscriptions_count %><%= topic.last_triggered_at || "--" %>
+ +
+
+ <% if @page > 1 %> + <%= link_to("« prev", graphql_dashboard.subscriptions_topics_path(per_page: params[:per_page], page: @page - 1), class: "btn btn-outline-secondary") %> + <% else %> + + <% end %> +
+
+ <% if @has_next_page %> + <%= link_to("next »", graphql_dashboard.subscriptions_topics_path(per_page: params[:per_page], page: @page + 1), class: "btn btn-outline-secondary") %> + <% else %> + + <% end %> +
+
diff --git a/lib/graphql/dashboard/views/graphql/dashboard/subscriptions/topics/show.html.erb b/lib/graphql/dashboard/views/graphql/dashboard/subscriptions/topics/show.html.erb new file mode 100644 index 00000000000..29b92baa9f2 --- /dev/null +++ b/lib/graphql/dashboard/views/graphql/dashboard/subscriptions/topics/show.html.erb @@ -0,0 +1,40 @@ +<%= content_for(:title, "Subscriptions - #{params[:name]}") %> +
+
+

Topic: <%= params[:name] %>

+
+
+ +
+
+

Last triggered: <%= @topic_last_triggered_at || "none" %>

+

<%= pluralize(@subscriptions_count, "Subscription") %>

+
+
+ +
+
+ + + + + + + + <% if @show_broadcast_subscribers_count %><% end %> + + + + <% @subscriptions.each do |subscription| %> + + + + + + <% if @show_broadcast_subscribers_count %><% end %> + + <% end %> + +
Subscription IDCreated AtSubscribed?Broadcast?Subscribers
<%= link_to(subscription[:id], graphql_dashboard.subscriptions_subscription_path(subscription[:id])) %><%= subscription[:created_at] %><%= subscription[:still_subscribed] ? "YES" : "NO" %><%= subscription[:is_broadcast] ? "YES" : "NO" %><%= subscription[:subscribers_count] %>
+
+
diff --git a/lib/graphql/dashboard/views/layouts/graphql/dashboard/application.html.erb b/lib/graphql/dashboard/views/layouts/graphql/dashboard/application.html.erb new file mode 100644 index 00000000000..875323d1467 --- /dev/null +++ b/lib/graphql/dashboard/views/layouts/graphql/dashboard/application.html.erb @@ -0,0 +1,108 @@ + + + + "> + + + GraphQL Dashboard <%= content_for?(:title) ? " · #{content_for(:title)}" : "" %> + " media="screen"> + " media="screen"> + " media="screen"> + + + <%= csrf_meta_tags %> + + +
+
+
+
+ +
+
+ <% flash.each do |flash_type, flash_message| %> +
+
+ +
+
+ <% end %> +
+
+ <%= yield %> +
+
+
+
+
+
+
+

+ GraphQL-Ruby v<%= GraphQL::VERSION %> · <%= schema_class %> +

+
+
+
+
+
+ + diff --git a/lib/graphql/dataloader.rb b/lib/graphql/dataloader.rb index b46a4d830d4..f58209995b9 100644 --- a/lib/graphql/dataloader.rb +++ b/lib/graphql/dataloader.rb @@ -4,6 +4,8 @@ require "graphql/dataloader/request" require "graphql/dataloader/request_all" require "graphql/dataloader/source" +require "graphql/dataloader/active_record_association_source" +require "graphql/dataloader/active_record_source" module GraphQL # This plugin supports Fiber-based concurrency, along with {GraphQL::Dataloader::Source}. @@ -23,23 +25,83 @@ module GraphQL # end # class Dataloader - def self.use(schema) - schema.dataloader_class = self + class << self + attr_accessor :default_nonblocking, :default_fiber_limit end - def initialize - @source_cache = Hash.new { |h, source_class| h[source_class] = Hash.new { |h2, batch_parameters| - source = if RUBY_VERSION < "3" - source_class.new(*batch_parameters) - else - batch_args, batch_kwargs = batch_parameters - source_class.new(*batch_args, **batch_kwargs) - end - source.setup(self) - h2[batch_parameters] = source - } + def self.use(schema, nonblocking: nil, fiber_limit: nil) + dataloader_class = if nonblocking + warn("`nonblocking: true` is deprecated from `GraphQL::Dataloader`, please use `GraphQL::Dataloader::AsyncDataloader` instead. Docs: https://graphql-ruby.org/dataloader/async_dataloader.") + Class.new(self) { self.default_nonblocking = true } + else + self + end + + if fiber_limit + dataloader_class = Class.new(dataloader_class) + dataloader_class.default_fiber_limit = fiber_limit + end + + schema.dataloader_class = dataloader_class + end + + # Call the block with a Dataloader instance, + # then run all enqueued jobs and return the result of the block. + def self.with_dataloading(&block) + dataloader = self.new + result = nil + dataloader.append_job { + result = block.call(dataloader) } + dataloader.run + result + end + + def initialize(nonblocking: self.class.default_nonblocking, fiber_limit: self.class.default_fiber_limit) + @source_cache = Hash.new { |h, k| h[k] = {} }.compare_by_identity + @pending_source_set = Set.new.compare_by_identity + @pending_sources = [] @pending_jobs = [] + if !nonblocking.nil? + @nonblocking = nonblocking + end + @fiber_limit = fiber_limit + @lazies_at_depth = Hash.new { |h, k| h[k] = [] } + end + + # @return [Integer, nil] + attr_reader :fiber_limit + + def nonblocking? + @nonblocking + end + + # This is called before the fiber is spawned, from the parent context (i.e. from + # the thread or fiber that it is scheduled from). + # + # @return [Hash] Current fiber-local variables + def get_fiber_variables + fiber_vars = {} + Thread.current.keys.each do |fiber_var_key| + fiber_vars[fiber_var_key] = Thread.current[fiber_var_key] + end + fiber_vars + end + + # Set up the fiber variables in a new fiber. + # + # This is called within the fiber, right after it is spawned. + # + # @param vars [Hash] Fiber-local variables from {get_fiber_variables} + # @return [void] + def set_fiber_variables(vars) + vars.each { |k, v| Thread.current[k] = v } + nil + end + + # This method is called when Dataloader is finished using a fiber. + # Use it to perform any cleanup, such as releasing database connections (if required manually) + def cleanup_fiber end # Get a Source instance from this dataloader, for calling `.load(...)` or `.request(...)` on. @@ -48,38 +110,80 @@ def initialize # @param batch_parameters [Array] # @return [GraphQL::Dataloader::Source] An instance of {source_class}, initialized with `self, *batch_parameters`, # and cached for the lifetime of this {Multiplex}. - if RUBY_VERSION < "3" - def with(source_class, *batch_parameters) - @source_cache[source_class][batch_parameters] + if (RUBY_ENGINE == "ruby" && RUBY_VERSION < "3") || RUBY_ENGINE == "truffleruby" # truffle-ruby wasn't doing well with the implementation below + def with(source_class, *batch_args) + batch_key = source_class.batch_key_for(*batch_args) + @source_cache[source_class][batch_key] ||= begin + source = source_class.new(*batch_args) + source.setup(self) + source + end end else def with(source_class, *batch_args, **batch_kwargs) - batch_parameters = [batch_args, batch_kwargs] - @source_cache[source_class][batch_parameters] + batch_key = source_class.batch_key_for(*batch_args, **batch_kwargs) + @source_cache[source_class][batch_key] ||= begin + source = source_class.new(*batch_args, **batch_kwargs) + source.setup(self) + source + end end end - # Tell the dataloader that this fiber is waiting for data. # # Dataloader will resume the fiber after the requested data has been loaded (by another Fiber). # # @return [void] - def yield + def yield(source = Fiber[:__graphql_current_dataloader_source]) + trace = Fiber[:__graphql_current_multiplex]&.current_trace + trace&.dataloader_fiber_yield(source) Fiber.yield + trace&.dataloader_fiber_resume(source) nil end # @api private Nothing to see here - def append_job(&job) + def append_job(callable = nil, &job) # Given a block, queue it up to be worked through when `#run` is called. - # (If the dataloader is already running, than a Fiber will pick this up later.) - @pending_jobs.push(job) + # (If the dataloader is already running, then a Fiber will pick this up later.) + @pending_jobs.push(callable || job) + nil + end + + # @api private + def queue_pending_source(source) + if @pending_source_set.add?(source) + @pending_sources << source + end + nil + end + + # Clear any already-loaded objects from {Source} caches + # @return [void] + def clear_cache + @source_cache.each do |_source_class, batched_sources| + batched_sources.each_value(&:clear_cache) + end nil end # Use a self-contained queue for the work in the block. def run_isolated prev_queue = @pending_jobs + prev_pending_keys = {} + prev_lazies_at_depth = @lazies_at_depth + @lazies_at_depth = @lazies_at_depth.dup.clear + # Clear pending loads but keep already-cached records + # in case they are useful to the given block. + @source_cache.each do |source_class, batched_sources| + batched_sources.each do |batch_args, batched_source_instance| + if batched_source_instance.pending? + prev_pending_keys[batched_source_instance] = batched_source_instance.pending.dup + batched_source_instance.pending.clear + end + end + end + @pending_jobs = [] res = nil # Make sure the block is inside a Fiber, so it can `Fiber.yield` @@ -90,163 +194,209 @@ def run_isolated res ensure @pending_jobs = prev_queue + @lazies_at_depth = prev_lazies_at_depth + prev_pending_keys.each do |source_instance, pending| + pending.each do |key, value| + next if source_instance.results.key?(key) + + queue_pending_source(source_instance) if source_instance.pending.empty? + source_instance.pending[key] = value + end + end end - # @api private Move along, move along - def run - # At a high level, the algorithm is: - # - # A) Inside Fibers, run jobs from the queue one-by-one - # - When one of the jobs yields to the dataloader (`Fiber.yield`), then that fiber will pause - # - In that case, if there are still pending jobs, a new Fiber will be created to run jobs - # - Continue until all jobs have been _started_ by a Fiber. (Any number of those Fibers may be waiting to be resumed, after their data is loaded) - # B) Once all known jobs have been run until they are complete or paused for data, run all pending data sources. - # - Similarly, create a Fiber to consume pending sources and tell them to load their data. - # - If one of those Fibers pauses, then create a new Fiber to continue working through remaining pending sources. - # - When a source causes another source to become pending, run the newly-pending source _first_, since it's a dependency of the previous one. - # C) After all pending sources have been completely loaded (there are no more pending sources), resume any Fibers that were waiting for data. - # - Those Fibers assume that source caches will have been populated with the data they were waiting for. - # - Those Fibers may request data from a source again, in which case they will yeilded and be added to a new pending fiber list. - # D) Once all pending fibers have been resumed once, return to `A` above. - # - # For whatever reason, the best implementation I could find was to order the steps `[D, A, B, C]`, with a special case for skipping `D` - # on the first pass. I just couldn't find a better way to write the loops in a way that was DRY and easy to read. - # - pending_fibers = [] - next_fibers = [] + # @param trace_query_lazy [nil, Execution::Multiplex] + def run(trace_query_lazy: nil) + trace = Fiber[:__graphql_current_multiplex]&.current_trace + jobs_fiber_limit, total_fiber_limit = calculate_fiber_limit + job_fibers = [] + next_job_fibers = [] + source_fibers = [] + next_source_fibers = [] first_pass = true - - while first_pass || (f = pending_fibers.shift) - if first_pass + manager = spawn_fiber do + trace&.begin_dataloader(self) + while first_pass || !job_fibers.empty? first_pass = false - else - # These fibers were previously waiting for sources to load data, - # resume them. (They might wait again, in which case, re-enqueue them.) - resume(f) - if f.alive? - next_fibers << f - end - end - while @pending_jobs.any? - # Create a Fiber to consume jobs until one of the jobs yields - # or jobs run out - f = spawn_fiber { - while (job = @pending_jobs.shift) - job.call + run_pending_steps(trace, job_fibers, next_job_fibers, jobs_fiber_limit, source_fibers, next_source_fibers, total_fiber_limit) + + if !@lazies_at_depth.empty? + with_trace_query_lazy(trace_query_lazy) do + if enqueue_next_pending_lazies(@lazies_at_depth) + job_fibers.unshift(spawn_job_fiber(trace)) + run_pending_steps(trace, job_fibers, next_job_fibers, jobs_fiber_limit, source_fibers, next_source_fibers, total_fiber_limit) + end end - } - resume(f) - # In this case, the job yielded. Queue it up to run again after - # we load whatever it's waiting for. - if f.alive? - next_fibers << f end end - if pending_fibers.empty? - # Now, run all Sources which have become pending _before_ resuming GraphQL execution. - # Sources might queue up other Sources, which is fine -- those will also run before resuming execution. - # - # This is where an evented approach would be even better -- can we tell which - # fibers are ready to continue, and continue execution there? - # - source_fiber_queue = if (first_source_fiber = create_source_fiber) - [first_source_fiber] - else - nil - end + trace&.end_dataloader(self) + end - if source_fiber_queue - while (outer_source_fiber = source_fiber_queue.shift) - resume(outer_source_fiber) + run_fiber(manager) - # If this source caused more sources to become pending, run those before running this one again: - next_source_fiber = create_source_fiber - if next_source_fiber - source_fiber_queue << next_source_fiber - end + if manager.alive? + raise "Invariant: Manager fiber didn't terminate properly." + end - if outer_source_fiber.alive? - source_fiber_queue << outer_source_fiber - end - end - end - # Move newly-enqueued Fibers on to the list to be resumed. - # Clear out the list of next-round Fibers, so that - # any Fibers that pause can be put on it. - pending_fibers.concat(next_fibers) - next_fibers.clear - end + if !job_fibers.empty? + raise "Invariant: job fibers should have exited but #{job_fibers.size} remained" + end + if !source_fibers.empty? + raise "Invariant: source fibers should have exited but #{source_fibers.size} remained" end - if @pending_jobs.any? - raise "Invariant: #{@pending_jobs.size} pending jobs" - elsif pending_fibers.any? - raise "Invariant: #{pending_fibers.size} pending fibers" - elsif next_fibers.any? - raise "Invariant: #{next_fibers.size} next fibers" + rescue UncaughtThrowError => e + throw e.tag, e.value + end + + def run_fiber(f) + f.resume + end + + # @api private + def lazy_at_depth(depth, lazy) + @lazies_at_depth[depth] << lazy + end + + def spawn_fiber + fiber_vars = get_fiber_variables + Fiber.new(blocking: !@nonblocking) { + set_fiber_variables(fiber_vars) + yield + cleanup_fiber + } + end + + # Pre-warm the Dataloader cache with ActiveRecord objects which were loaded elsewhere. + # These will be used by {Dataloader::ActiveRecordSource}, {Dataloader::ActiveRecordAssociationSource} and their helper + # methods, `dataload_record` and `dataload_association`. + # @param records [Array] Already-loaded records to warm the cache with + # @param index_by [Symbol] The attribute to use as the cache key. (Should match `find_by:` when using {ActiveRecordSource}) + # @return [void] + def merge_records(records, index_by: :id) + records_by_class = Hash.new { |h, k| h[k] = {} } + records.each do |r| + records_by_class[r.class][r.public_send(index_by)] = r + end + records_by_class.each do |r_class, records| + with(ActiveRecordSource, r_class).merge(records) end - nil end private - # If there are pending sources, return a fiber for running them. - # Otherwise, return `nil`. - # - # @return [Fiber, nil] - def create_source_fiber - pending_sources = nil - @source_cache.each_value do |source_by_batch_params| - source_by_batch_params.each_value do |source| - if source.pending? - pending_sources ||= [] - pending_sources << source + # Returns true if anything was actually enqueued + def enqueue_next_pending_lazies(lazies_at_depth) + smallest_depth = lazies_at_depth.each_key.min + return false if smallest_depth.nil? + + lazies = lazies_at_depth.delete(smallest_depth) + return false if lazies.empty? + + lazies.each do |lazy| + append_job { lazy.value } + end + + true + end + + def run_pending_steps(trace, job_fibers, next_job_fibers, jobs_fiber_limit, source_fibers, next_source_fibers, total_fiber_limit) + while (f = (job_fibers.shift || (((next_job_fibers.size + job_fibers.size) < jobs_fiber_limit) && spawn_job_fiber(trace)))) + if f.alive? + finished = run_fiber(f) + if !finished + next_job_fibers << f end end end + join_queues(job_fibers, next_job_fibers) - if pending_sources - # By passing the whole array into this Fiber, it's possible that we set ourselves up for a bunch of no-ops. - # For example, if you have sources `[a, b, c]`, and `a` is loaded, then `b` yields to wait for `d`, then - # the next fiber would be dispatched with `[c, d]`. It would fulfill `c`, then `d`, then eventually - # the previous fiber would start up again. `c` would no longer be pending, but it would still receive `.run_pending_keys`. - # That method is short-circuited since it isn't pending any more, but it's still a waste. - # - # This design could probably be improved by maintaining a `@pending_sources` queue which is shared by the fibers, - # similar to `@pending_jobs`. That way, when a fiber is resumed, it would never pick up work that was finished by a different fiber. - source_fiber = spawn_fiber do - pending_sources.each(&:run_pending_keys) + while (!source_fibers.empty? || !@pending_sources.empty?) + while (f = source_fibers.shift || (((job_fibers.size + source_fibers.size + next_source_fibers.size + next_job_fibers.size) < total_fiber_limit) && spawn_source_fiber(trace))) + if f.alive? + finished = run_fiber(f) + if !finished + next_source_fibers << f + end + end end + join_queues(source_fibers, next_source_fibers) end + end - source_fiber + def with_trace_query_lazy(multiplex_or_nil, &block) + if (multiplex = multiplex_or_nil) + query = multiplex.queries.length == 1 ? multiplex.queries[0] : nil + multiplex.current_trace.execute_query_lazy(query: query, multiplex: multiplex, &block) + else + yield + end end - def resume(fiber) - fiber.resume - rescue UncaughtThrowError => e - throw e.tag, e.value + def calculate_fiber_limit + total_fiber_limit = @fiber_limit || Float::INFINITY + if total_fiber_limit < 4 + raise ArgumentError, "Dataloader fiber limit is too low (#{total_fiber_limit}), it must be at least 4" + end + total_fiber_limit -= 1 # deduct one fiber for `manager` + # Deduct at least one fiber for sources + jobs_fiber_limit = total_fiber_limit - 2 + return jobs_fiber_limit, total_fiber_limit end - # Copies the thread local vars into the fiber thread local vars. Many - # gems (such as RequestStore, MiniRacer, etc.) rely on thread local vars - # to keep track of execution context, and without this they do not - # behave as expected. - # - # @see https://github.com/rmosolgo/graphql-ruby/issues/3449 - def spawn_fiber - fiber_locals = {} + def join_queues(prev_queue, new_queue) + @nonblocking && Fiber.scheduler.run + prev_queue.concat(new_queue) + new_queue.clear + end - Thread.current.keys.each do |fiber_var_key| - fiber_locals[fiber_var_key] = Thread.current[fiber_var_key] + def spawn_job_fiber(trace) + if !@pending_jobs.empty? + spawn_fiber do + trace&.dataloader_spawn_execution_fiber(@pending_jobs) + while job = @pending_jobs.shift + job.call + end + trace&.dataloader_fiber_exit + end end + end - Fiber.new do - fiber_locals.each { |k, v| Thread.current[k] = v } - yield + def drain_pending_sources + pending_sources = @pending_sources + @pending_sources = [] + @pending_source_set.clear + + pending_sources.select!(&:pending?) + pending_sources.empty? ? nil : pending_sources + end + + def dequeue_pending_source + while (source = @pending_sources.shift) + @pending_source_set.delete(source) + return source if source.pending? + end + end + + def spawn_source_fiber(trace) + if !@pending_sources.empty? + spawn_fiber do + trace&.dataloader_spawn_source_fiber(@pending_sources) + # This will find sources which were enqueued during `#fetch`: + while (source = dequeue_pending_source) + next if !source.pending? + Fiber[:__graphql_current_dataloader_source] = source + trace&.begin_dataloader_source(source) + source.run_pending_keys + trace&.end_dataloader_source(source) + end + trace&.dataloader_fiber_exit + end end end end end + +require "graphql/dataloader/async_dataloader" diff --git a/lib/graphql/dataloader/active_record_association_source.rb b/lib/graphql/dataloader/active_record_association_source.rb new file mode 100644 index 00000000000..a3e729d6d16 --- /dev/null +++ b/lib/graphql/dataloader/active_record_association_source.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true +require "graphql/dataloader/source" +require "graphql/dataloader/active_record_source" + +module GraphQL + class Dataloader + class ActiveRecordAssociationSource < GraphQL::Dataloader::Source + RECORD_SOURCE_CLASS = ActiveRecordSource + + def initialize(association, scope = nil) + @association = association + @scope = scope + end + + def self.batch_key_for(association, scope = nil) + if scope + [association, scope.to_sql] + else + [association] + end + end + + def load(record) + if (assoc = record.association(@association)).loaded? + assoc.target + else + super + end + end + + def fetch(records) + record_classes = Set.new.compare_by_identity + associated_classes = Set.new.compare_by_identity + scoped_fetch = !@scope.nil? + records.each do |record| + if scoped_fetch + assoc = record.association(@association) + assoc.reset + end + if record_classes.add?(record.class) + reflection = record.class.reflect_on_association(@association) + if !reflection.polymorphic? && reflection.klass + associated_classes.add(reflection.klass) + end + end + end + + available_records = [] + associated_classes.each do |assoc_class| + already_loaded_records = dataloader.with(RECORD_SOURCE_CLASS, assoc_class).results.values + available_records.concat(already_loaded_records) + end + + ::ActiveRecord::Associations::Preloader.new(records: records, associations: @association, available_records: available_records, scope: @scope).call + + loaded_associated_records = records.map { |r| + assoc = r.association(@association) + lar = assoc.target + if scoped_fetch + assoc.reset + end + lar + } + + if !scoped_fetch + # Don't cache records loaded via scope because they might have reduced `SELECT`s + # Could check .select_values here? + records_by_model = {} + loaded_associated_records.flatten.each do |record| + if record + updates = records_by_model[record.class] ||= {} + updates[record.id] = record + end + end + records_by_model.each do |model_class, updates| + dataloader.with(RECORD_SOURCE_CLASS, model_class).merge(updates) + end + end + + loaded_associated_records + end + end + end +end diff --git a/lib/graphql/dataloader/active_record_source.rb b/lib/graphql/dataloader/active_record_source.rb new file mode 100644 index 00000000000..30b26942975 --- /dev/null +++ b/lib/graphql/dataloader/active_record_source.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +require "graphql/dataloader/source" + +module GraphQL + class Dataloader + class ActiveRecordSource < GraphQL::Dataloader::Source + def initialize(model_class, find_by: model_class.primary_key) + @model_class = model_class + @find_by = find_by + @find_by_many = find_by.is_a?(Array) + if @find_by_many + @type_for_column = @find_by.map { |fb| @model_class.type_for_attribute(fb) } + else + @type_for_column = @model_class.type_for_attribute(@find_by) + end + end + + def result_key_for(requested_key) + normalize_fetch_key(requested_key) + end + + def normalize_fetch_key(requested_key) + if @find_by_many + requested_key.each_with_index.map do |k, idx| + @type_for_column[idx].cast(k) + end + else + @type_for_column.cast(requested_key) + end + end + + def fetch(record_ids) + records = @model_class.where(@find_by => record_ids) + record_lookup = {} + if @find_by_many + records.each do |r| + key = @find_by.map { |fb| r.public_send(fb) } + record_lookup[key] = r + end + else + records.each { |r| record_lookup[r.public_send(@find_by)] = r } + end + record_ids.map { |id| record_lookup[id] } + end + end + end +end diff --git a/lib/graphql/dataloader/async_dataloader.rb b/lib/graphql/dataloader/async_dataloader.rb new file mode 100644 index 00000000000..a1ddf0ff3f1 --- /dev/null +++ b/lib/graphql/dataloader/async_dataloader.rb @@ -0,0 +1,351 @@ +# frozen_string_literal: true + +module GraphQL + class Dataloader + class AsyncDataloader < Dataloader + def self.use(...) + install_graphql_methods + super + end + + def self.install_graphql_methods + if !Async::Task.method_defined?(:cancel) + Async::Task.alias_method(:cancel, :stop) + end + if !Async::Task.method_defined?(:graphql_async_dataloader_run) + Async::Task.attr_accessor(:graphql_async_dataloader_run) + Async::Task.attr_accessor(:graphql_async_dataloader_condition) + end + end + + def initialize(...) + super + create_pending_run + end + + # @api private + attr_reader :pending_sources + + def create_pending_run + jobs_fiber_limit, total_fiber_limit = calculate_fiber_limit + @pending_run = Run.new(self, total_fiber_limit, jobs_fiber_limit) + end + + def yield(source = Fiber[:__graphql_current_dataloader_source]) + task = Async::Task.current + run = task.graphql_async_dataloader_run + trace = run.trace + trace&.dataloader_fiber_yield(source) + run.tasks_channel.push([:paused_task, task]) + condition = task.graphql_async_dataloader_condition + condition.wait + run.tasks_channel.push([:resumed_task, task]) + trace&.dataloader_fiber_resume(source) + nil + end + + class Run + def initialize(dataloader, total_fiber_limit, jobs_fiber_limit) + @dataloader = dataloader + @root_task = nil + @trace = nil + @jobs = [] + + @total_fiber_limit = total_fiber_limit + @jobs_fiber_limit = jobs_fiber_limit + @lazies_at_depth = Hash.new { |h, k| h[k] = [] } + + @running_tasks = nil + @tasks_channel = nil + @tasks_channel_task = nil + @activity = nil + @task_error = nil + @expected_resumes = 0 + @mode = nil + + @snoozed_jobs_condition = Async::Condition.new + @snoozed_sources_condition = Async::Condition.new + end + + attr_accessor :trace, :root_task + + attr_reader :jobs, :lazies_at_depth, :jobs_fiber_limit, :snoozed_jobs_condition, :snoozed_sources_condition, :tasks_channel + + def jobs_bandwidth? + running_count < @jobs_fiber_limit + end + + def sources_bandwidth? + running_count < current_sources_fiber_limit + end + + def close_queues + @tasks_channel.close + @tasks_channel_task.cancel + end + + def wait_for_activity + @activity.wait + end + + def quiesced? + @running_tasks.empty? && @tasks_channel.empty? && @expected_resumes == 0 + end + + def has_pending_work? + @mode == :jobs ? @jobs.any? : @dataloader.pending_sources.any?(&:pending?) # rubocop:disable Development/NoneWithoutBlockCop + end + + def has_bandwidth? + @mode == :jobs ? jobs_bandwidth? : sources_bandwidth? + end + + # Signalled tasks don't appear in any accounting until their first slice + # pushes `:resumed_task`, so they have to be counted at signal time: + def expect_resumes(count) + @expected_resumes = count + end + + def check_error! + if (err = @task_error) + @task_error = nil + raise err + end + end + + def new_queues(mode) + @mode = mode + @tasks_channel = Async::Queue.new(parent: @root_task) + @activity = Async::Condition.new + @task_error = nil + @expected_resumes = 0 + @running_tasks = [] + @tasks_channel_task = @root_task.async do |_t| + while ((msg, data) = @tasks_channel.wait) + case msg + when :started_task + @running_tasks.push(data) + data.run + when :resumed_task + if @expected_resumes > 0 + @expected_resumes -= 1 + end + @running_tasks.push(data) + when :finished_task, :paused_task + @running_tasks.delete(data) + when :task_error + @task_error ||= data + else + raise ArgumentError, "Unknown tasks_channel action: #{msg.inspect}" + end + @activity.signal + end + end + end + + def running? + @snoozed_jobs_condition.waiting? || @snoozed_sources_condition.waiting? + end + + def current_sources_fiber_limit + within_limit = @total_fiber_limit - running_count + if within_limit < 1 + 1 + else + within_limit + end + end + + private + + def running_count + @snoozed_jobs_condition.instance_variable_get(:@ready).num_waiting + + @snoozed_sources_condition.instance_variable_get(:@ready).num_waiting + + (@running_tasks&.size || 0) + end + end + + def append_job(callable = nil, &block) + active_run.jobs.push(callable || block) + nil + end + + def lazy_at_depth(depth, lazy) + active_run.lazies_at_depth[depth] << lazy + end + + def active_run + @pending_run || Async::Task.current?&.graphql_async_dataloader_run || raise(GraphQL::Error, "No available Run to append to, GraphQL-Ruby bug") + end + + def run_isolated + previous_run = Async::Task.current?&.graphql_async_dataloader_run + prev_pending_keys = {} + # Clear pending loads but keep already-cached records + # in case they are useful to the given block. + @source_cache.each do |source_class, batched_sources| + batched_sources.each do |batch_args, batched_source_instance| + if batched_source_instance.pending? + prev_pending_keys[batched_source_instance] = batched_source_instance.pending.dup + batched_source_instance.pending.clear + end + end + end + + res = nil + create_pending_run + @pending_run.jobs << -> { res = yield } + run + res + ensure + if previous_run + Async::Task.current.graphql_async_dataloader_run = previous_run + # clear the one created in #run: + @pending_run = nil + end + prev_pending_keys.each do |source_instance, pending| + pending.each do |key, value| + next if source_instance.results.key?(key) + + queue_pending_source(source_instance) if source_instance.pending.empty? + source_instance.pending[key] = value + end + end + end + + def run(trace_query_lazy: nil) + trace = Fiber[:__graphql_current_multiplex]&.current_trace + run = @pending_run || Async::Task.current?&.graphql_async_dataloader_run || raise(GraphQL::Error, "No available Run, GraphQL-Ruby internal bug") + @pending_run = nil + run.trace = trace + first_pass = true + trace&.begin_dataloader(self) + fiber_vars = get_fiber_variables + raised_error = nil + jobs = run.jobs + Sync do |_maybe_new_task| + # Make sure there's a new task instance to hold `.graphql_...` state: + task = Async::Task.new do |root_task| + run.root_task = root_task + root_task.graphql_async_dataloader_run = run + set_fiber_variables(fiber_vars) + + while first_pass || run.running? || !jobs.empty? + first_pass = false + run_queue(run, run.snoozed_jobs_condition, :jobs) + run_queue(run, run.snoozed_sources_condition, :sources) + + if !run.lazies_at_depth.empty? + with_trace_query_lazy(trace_query_lazy) do + if enqueue_next_pending_lazies(run.lazies_at_depth) + run_queue(run, run.snoozed_jobs_condition, :jobs) + end + end + end + end + rescue StandardError => err + raised_error = err + root_task.cancel + end + + task.run + task.wait + end + create_pending_run + if raised_error + raise raised_error + end + trace&.end_dataloader(self) + rescue UncaughtThrowError => e + throw e.tag, e.value + end + + private + + def run_queue(run, condition, mode) + opened_queues = false + + if condition.waiting? + opened_queues = true + run.new_queues(mode) + run.expect_resumes(condition.instance_variable_get(:@ready).num_waiting) + condition.signal + end + + loop do + pending_work = (mode == :jobs) ? (!run.jobs.empty? && run.jobs_bandwidth? ? run.jobs : nil) : (drain_pending_sources) + if pending_work + if opened_queues == false + opened_queues = true + run.new_queues(mode) + end + num_tasks = mode == :sources ? run.current_sources_fiber_limit : 1 + if num_tasks > pending_work.size + num_tasks = pending_work.size + end + spawn_tasks(run, mode, condition, pending_work, num_tasks) + end + + if !opened_queues + break + end + + run.check_error! + + if run.quiesced? + if !run.has_pending_work? || !run.has_bandwidth? + break + end + # Quiesced, but more work appeared - loop around to drain it. + else + run.wait_for_activity + end + end + ensure + if opened_queues + run.close_queues + end + end + + # Use a separate method for this so that the outer loop's reassignment of `pending_work` + # doesn't affect already-running tasks which (would) close over that variable + def spawn_tasks(run, mode, condition, pending_work, num_tasks) + fiber_vars = get_fiber_variables + trace = run.trace + num_tasks.times do + new_task = Async::Task.new(run.root_task) do |task| + task.graphql_async_dataloader_run = run + task.graphql_async_dataloader_condition = condition + set_fiber_variables(fiber_vars) + case mode + when :jobs + trace&.dataloader_spawn_execution_fiber(pending_work) + while job = pending_work.shift + job.call + end + when :sources + trace&.dataloader_spawn_source_fiber(pending_work) + while (source = pending_work.shift) + Fiber[:__graphql_current_dataloader_source] = source + trace&.begin_dataloader_source(source) + source.run_pending_keys + trace&.end_dataloader_source(source) + end + else + raise ArgumentError, "Unknown mode: #{mode.inspect}" + end + nil + rescue StandardError => err + run.tasks_channel.push([:task_error, err]) + else + run.tasks_channel.push([:finished_task, task]) + ensure + cleanup_fiber + trace&.dataloader_fiber_exit + end + run.tasks_channel.push([:started_task, new_task]) + end + end + end + end +end diff --git a/lib/graphql/dataloader/null_dataloader.rb b/lib/graphql/dataloader/null_dataloader.rb index 7bb6ef8adc3..33e5e114f9f 100644 --- a/lib/graphql/dataloader/null_dataloader.rb +++ b/lib/graphql/dataloader/null_dataloader.rb @@ -2,21 +2,68 @@ module GraphQL class Dataloader - # The default implementation of dataloading -- all no-ops. + # GraphQL-Ruby uses this when Dataloader isn't enabled. # - # The Dataloader interface isn't public, but it enables - # simple internal code while adding the option to add Dataloader. + # It runs execution code inline and gathers lazy objects (eg. Promises) + # and resolves them during {#run}. class NullDataloader < Dataloader - # These are all no-ops because code was - # executed sychronously. - def run; end - def run_isolated; yield; end - def yield; end - - def append_job - yield + def initialize(*) + @lazies_at_depth = Hash.new { |h,k| h[k] = [] } + end + + def freeze + @lazies_at_depth.default_proc = nil + @lazies_at_depth.freeze + super + end + + def run(trace_query_lazy: nil) + with_trace_query_lazy(trace_query_lazy) do + while !@lazies_at_depth.empty? + smallest_depth = nil + @lazies_at_depth.each_key do |depth_key| + smallest_depth ||= depth_key + if depth_key < smallest_depth + smallest_depth = depth_key + end + end + + if smallest_depth + lazies = @lazies_at_depth.delete(smallest_depth) + lazies.each(&:value) # resolve these Lazy instances + end + end + end + end + + def run_isolated + # Reuse this instance because execution code may already have a reference to _this_ `dataloader` inside the given block. + prev_lazies_at_depth = @lazies_at_depth + @lazies_at_depth = @lazies_at_depth.dup.clear + res = nil + append_job { + res = yield + } + run + res + ensure + @lazies_at_depth = prev_lazies_at_depth + end + + def clear_cache; end + + def yield(_source) + raise GraphQL::Error, "GraphQL::Dataloader is not running -- add `use GraphQL::Dataloader` to your schema to use Dataloader sources." + end + + def append_job(callable = nil) + callable ? callable.call : yield nil end + + def with(*) + raise GraphQL::Error, "GraphQL::Dataloader is not running -- add `use GraphQL::Dataloader` to your schema to use Dataloader sources." + end end end end diff --git a/lib/graphql/dataloader/request.rb b/lib/graphql/dataloader/request.rb index aa4ae9c76aa..c66a41fed3c 100644 --- a/lib/graphql/dataloader/request.rb +++ b/lib/graphql/dataloader/request.rb @@ -14,6 +14,11 @@ def initialize(source, key) def load @source.load(@key) end + + def load_with_deprecation_warning + warn("Returning `.request(...)` from GraphQL::Dataloader is deprecated, use `.load(...)` instead. (See usage of #{@source} with #{@key.inspect}).") + load + end end end end diff --git a/lib/graphql/dataloader/source.rb b/lib/graphql/dataloader/source.rb index 7c665c611a6..d3f94903852 100644 --- a/lib/graphql/dataloader/source.rb +++ b/lib/graphql/dataloader/source.rb @@ -6,7 +6,11 @@ class Source # Called by {Dataloader} to prepare the {Source}'s internal state # @api private def setup(dataloader) - @pending_keys = [] + # These keys have been requested but haven't been fetched yet + @pending = {} + # These keys have been passed to `fetch` but haven't been finished yet + @fetching = {} + # { key => result } @results = {} @dataloader = dataloader end @@ -14,42 +18,74 @@ def setup(dataloader) attr_reader :dataloader # @return [Dataloader::Request] a pending request for a value from `key`. Call `.load` on that object to wait for the result. - def request(key) - if !@results.key?(key) - @pending_keys << key - end - Dataloader::Request.new(self, key) + def request(value) + res_key = result_key_for(value) + add_pending_key(res_key, value) + Dataloader::Request.new(self, value) + end + + # Implement this method to return a stable identifier if different + # key objects should load the same data value. + # + # @param value [Object] A value passed to `.request` or `.load`, for which a value will be loaded + # @return [Object] The key for tracking this pending data + def result_key_for(value) + value + end + + # Implement this method if varying values given to {load} (etc) should be consolidated + # or normalized before being handed off to your {fetch} implementation. + # + # This is different than {result_key_for} because _that_ method handles unification inside Dataloader's cache, + # but this method changes the value passed into {fetch}. + # + # @param value [Object] The value passed to {load}, {load_all}, {request}, or {request_all} + # @return [Object] The value given to {fetch} + def normalize_fetch_key(value) + value end # @return [Dataloader::Request] a pending request for a values from `keys`. Call `.load` on that object to wait for the results. - def request_all(keys) - pending_keys = keys.select { |k| !@results.key?(k) } - @pending_keys.concat(pending_keys) - Dataloader::RequestAll.new(self, keys) + def request_all(values) + values.each do |v| + res_key = result_key_for(v) + add_pending_key(res_key, v) + end + Dataloader::RequestAll.new(self, values) end - # @param key [Object] A loading key which will be passed to {#fetch} if it isn't already in the internal cache. + # @param value [Object] A loading value which will be passed to {#fetch} if it isn't already in the internal cache. # @return [Object] The result from {#fetch} for `key`. If `key` hasn't been loaded yet, the Fiber will yield until it's loaded. - def load(key) - if @results.key?(key) - result_for(key) + def load(value) + result_key = result_key_for(value) + if @results.key?(result_key) + result_for(result_key) else - @pending_keys << key - sync - result_for(key) + add_pending_key(result_key, value) + sync([result_key]) + result_for(result_key) end end - # @param keys [Array] Loading keys which will be passed to `#fetch` (or read from the internal cache). + # @param values [Array] Loading keys which will be passed to `#fetch` (or read from the internal cache). # @return [Object] The result from {#fetch} for `keys`. If `keys` haven't been loaded yet, the Fiber will yield until they're loaded. - def load_all(keys) - if keys.any? { |k| !@results.key?(k) } - pending_keys = keys.select { |k| !@results.key?(k) } - @pending_keys.concat(pending_keys) - sync + def load_all(values) + result_keys = [] + pending_keys = [] + values.each { |v| + k = result_key_for(v) + result_keys << k + if add_pending_key(k, v) + pending_keys << k + end + } + + if !pending_keys.empty? + sync(pending_keys) end - keys.map { |k| result_for(k) } + result_keys.map! { |k| result_for(k) } + result_keys end # Subclasses must implement this method to return a value for each of `keys` @@ -60,45 +96,126 @@ def fetch(keys) raise "Implement `#{self.class}#fetch(#{keys.inspect}) to return a record for each of the keys" end + MAX_ITERATIONS = 1000 # Wait for a batch, if there's anything to batch. # Then run the batch and update the cache. # @return [void] - def sync - @dataloader.yield + def sync(pending_result_keys) + @dataloader.queue_pending_source(self) if pending? + @dataloader.yield(self) + iterations = 0 + while pending_result_keys.any? { |key| !@results.key?(key) } + iterations += 1 + if iterations > MAX_ITERATIONS + raise "#{self.class}#sync tried #{MAX_ITERATIONS} times to load pending keys (#{pending_result_keys}), but they still weren't loaded. There is likely a circular dependency#{@dataloader.fiber_limit ? " or `fiber_limit: #{@dataloader.fiber_limit}` is set too low" : ""}." + end + @dataloader.yield(self) + end + nil end # @return [Boolean] True if this source has any pending requests for data. def pending? - @pending_keys.any? + !@pending.empty? + end + + # Add these key-value pairs to this source's cache + # (future loads will use these merged values). + # @param new_results [Hash Object>] key-value pairs to cache in this source + # @return [void] + def merge(new_results) + new_results.each do |new_k, new_v| + key = result_key_for(new_k) + @results[key] = new_v + end + nil end # Called by {GraphQL::Dataloader} to resolve and pending requests to this source. # @api private # @return [void] def run_pending_keys - return if @pending_keys.empty? - fetch_keys = @pending_keys.uniq - @pending_keys = [] - results = fetch(fetch_keys) - fetch_keys.each_with_index do |key, idx| + @fetching.each_key { |k| @pending.delete(k) } + return if @pending.empty? + fetch_h = @pending + @fetching.merge!(fetch_h) + @pending = {} + results = fetch(fetch_h.values) + idx = 0 + + fetch_h.each_key do |key| @results[key] = results[idx] + @fetching.delete(key) + idx += 1 end + nil rescue StandardError => error - fetch_keys.each { |key| @results[key] = error } - ensure + fetch_h.each_key { |key| + @results[key] = error + @fetching.delete(key) + } + end + + # These arguments are given to `dataloader.with(source_class, ...)`. The object + # returned from this method is used to de-duplicate batch loads under the hood + # by using it as a Hash key. + # + # By default, the arguments are all put in an Array. To customize how this source's + # batches are merged, override this method to return something else. + # + # For example, if you pass `ActiveRecord::Relation`s to `.with(...)`, you could override + # this method to call `.to_sql` on them, thus merging `.load(...)` calls when they apply + # to equivalent relations. + # + # @param batch_args [Array] + # @param batch_kwargs [Hash] + # @return [Object] + def self.batch_key_for(*batch_args, **batch_kwargs) + if batch_kwargs.any? # rubocop:disable Development/NoneWithoutBlockCop + [*batch_args, **batch_kwargs] + else + batch_args + end + end + + # Clear any already-loaded objects for this source + # @return [void] + def clear_cache + @results.clear nil end + attr_reader :pending, :results + private + def add_pending_key(result_key, value) + return false if @results.key?(result_key) + + was_empty = @pending.empty? + @pending[result_key] ||= normalize_fetch_key(value) + @dataloader.queue_pending_source(self) if was_empty + true + end + # Reads and returns the result for the key from the internal cache, or raises an error if the result was an error # @param key [Object] key passed to {#load} or {#load_all} # @return [Object] The result from {#fetch} for `key`. # @api private def result_for(key) - result = @results[key] + if !@results.key?(key) + raise GraphQL::InvariantError, <<-ERR +Fetching result for a key on #{self.class} that hasn't been loaded yet (#{key.inspect}, loaded: #{@results.keys}) - raise result if result.class <= StandardError +This key should have been loaded already. This is a bug in GraphQL::Dataloader, please report it on GitHub: https://github.com/rmosolgo/graphql-ruby/issues/new. +ERR + end + result = @results[key] + if result.is_a?(StandardError) + # Dup it because the rescuer may modify it. + # (This happens for GraphQL::ExecutionErrors, at least) + raise result.dup + end result end diff --git a/lib/graphql/date_encoding_error.rb b/lib/graphql/date_encoding_error.rb new file mode 100644 index 00000000000..f80b2eb0880 --- /dev/null +++ b/lib/graphql/date_encoding_error.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +module GraphQL + # This error is raised when `Types::ISO8601Date` is asked to return a value + # that cannot be parsed to a Ruby Date. + # + # @see GraphQL::Types::ISO8601Date which raises this error + class DateEncodingError < GraphQL::RuntimeTypeError + # The value which couldn't be encoded + attr_reader :date_value + + def initialize(value) + @date_value = value + super("Date cannot be parsed: #{value}. \nDate must be able to be parsed as a Ruby Date object.") + end + end +end diff --git a/lib/graphql/define.rb b/lib/graphql/define.rb deleted file mode 100644 index 709425a1b12..00000000000 --- a/lib/graphql/define.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true -require "graphql/define/assign_argument" -require "graphql/define/assign_connection" -require "graphql/define/assign_enum_value" -require "graphql/define/assign_global_id_field" -require "graphql/define/assign_mutation_function" -require "graphql/define/assign_object_field" -require "graphql/define/defined_object_proxy" -require "graphql/define/instance_definable" -require "graphql/define/no_definition_error" -require "graphql/define/non_null_with_bang" -require "graphql/define/type_definer" - -module GraphQL - module Define - # A helper for definitions that store their value in `#metadata`. - # - # @example Storing application classes with GraphQL types - # # Make a custom definition - # GraphQL::ObjectType.accepts_definitions(resolves_to_class_names: GraphQL::Define.assign_metadata_key(:resolves_to_class_names)) - # - # # After definition, read the key from metadata - # PostType.metadata[:resolves_to_class_names] # => [...] - # - # @param key [Object] the key to assign in metadata - # @return [#call(defn, value)] an assignment for `.accepts_definitions` which writes `key` to `#metadata` - def self.assign_metadata_key(key) - GraphQL::Define::InstanceDefinable::AssignMetadataKey.new(key) - end - end -end diff --git a/lib/graphql/define/assign_argument.rb b/lib/graphql/define/assign_argument.rb deleted file mode 100644 index 29a6715450e..00000000000 --- a/lib/graphql/define/assign_argument.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Define - # Turn argument configs into a {GraphQL::Argument}. - module AssignArgument - def self.call(target, *args, **kwargs, &block) - argument = GraphQL::Argument.from_dsl(*args, **kwargs, &block) - target.arguments[argument.name] = argument - end - end - end -end diff --git a/lib/graphql/define/assign_connection.rb b/lib/graphql/define/assign_connection.rb deleted file mode 100644 index 8bfae0817d5..00000000000 --- a/lib/graphql/define/assign_connection.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Define - module AssignConnection - def self.call(type_defn, *field_args, max_page_size: nil, **field_kwargs, &field_block) - underlying_field = GraphQL::Define::AssignObjectField.call(type_defn, *field_args, **field_kwargs, &field_block) - underlying_field.connection_max_page_size = max_page_size - underlying_field.connection = true - type_defn.fields[underlying_field.name] = underlying_field - end - end - end -end diff --git a/lib/graphql/define/assign_enum_value.rb b/lib/graphql/define/assign_enum_value.rb deleted file mode 100644 index 53bb8472854..00000000000 --- a/lib/graphql/define/assign_enum_value.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Define - # @api deprecated - module AssignEnumValue - def self.call(enum_type, name, desc = nil, deprecation_reason: nil, value: name, &block) - enum_value = GraphQL::EnumType::EnumValue.define( - name: name.to_s, - description: desc, - deprecation_reason: deprecation_reason, - value: value, - &block - ) - enum_type.add_value(enum_value) - end - end - end -end diff --git a/lib/graphql/define/assign_global_id_field.rb b/lib/graphql/define/assign_global_id_field.rb deleted file mode 100644 index 9065e1c0da5..00000000000 --- a/lib/graphql/define/assign_global_id_field.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Define - module AssignGlobalIdField - def self.call(type_defn, field_name, **field_kwargs) - resolve = GraphQL::Relay::GlobalIdResolve.new(type: type_defn) - GraphQL::Define::AssignObjectField.call(type_defn, field_name, **field_kwargs, type: GraphQL::DEPRECATED_ID_TYPE.to_non_null_type, resolve: resolve) - end - end - end -end diff --git a/lib/graphql/define/assign_mutation_function.rb b/lib/graphql/define/assign_mutation_function.rb deleted file mode 100644 index 8ec28d62d5b..00000000000 --- a/lib/graphql/define/assign_mutation_function.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Define - module AssignMutationFunction - def self.call(target, function) - # TODO: get all this logic somewhere easier to test - - if !function.type.is_a?(GraphQL::ObjectType) - raise "Mutation functions must return object types (not #{function.type.unwrap})" - end - - target.return_type = function.type.redefine { - name(target.name + "Payload") - field :clientMutationId, types.String, "A unique identifier for the client performing the mutation.", property: :client_mutation_id - } - - target.arguments = function.arguments - target.description = function.description - target.resolve = ->(o, a, c) { - res = function.call(o, a, c) - ResultProxy.new(res, a[:clientMutationId]) - } - end - - class ResultProxy < SimpleDelegator - attr_reader :client_mutation_id - def initialize(target, client_mutation_id) - @client_mutation_id = client_mutation_id - super(target) - end - end - end - end -end diff --git a/lib/graphql/define/assign_object_field.rb b/lib/graphql/define/assign_object_field.rb deleted file mode 100644 index 41f34f8b343..00000000000 --- a/lib/graphql/define/assign_object_field.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Define - # @api deprecated - module AssignObjectField - def self.call(owner_type, name, type_or_field = nil, desc = nil, function: nil, field: nil, relay_mutation_function: nil, **kwargs, &block) - name_s = name.to_s - - # Move some positional args into keywords if they're present - desc && kwargs[:description] ||= desc - name && kwargs[:name] ||= name_s - - if !type_or_field.nil? && !type_or_field.is_a?(GraphQL::Field) - # Maybe a string, proc or BaseType - kwargs[:type] = type_or_field - end - - base_field = if type_or_field.is_a?(GraphQL::Field) - type_or_field.redefine(name: name_s) - elsif function - func_field = GraphQL::Function.build_field(function) - func_field.name = name_s - func_field - elsif field.is_a?(GraphQL::Field) - field.redefine(name: name_s) - else - nil - end - - obj_field = if base_field - base_field.redefine(**kwargs, &block) - else - GraphQL::Field.define(**kwargs, &block) - end - - - # Attach the field to the type - owner_type.fields[name_s] = obj_field - end - end - end -end diff --git a/lib/graphql/define/defined_object_proxy.rb b/lib/graphql/define/defined_object_proxy.rb deleted file mode 100644 index 25ffbd7fd95..00000000000 --- a/lib/graphql/define/defined_object_proxy.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module GraphQL - module Define - # This object delegates most methods to a dictionary of functions, {@dictionary}. - # {@target} is passed to the specified function, along with any arguments and block. - # This allows a method-based DSL without adding methods to the defined class. - class DefinedObjectProxy - extend GraphQL::Ruby2Keywords - # The object which will be defined by definition functions - attr_reader :target - - def initialize(target) - @target = target - @dictionary = target.class.dictionary - end - - # Provides shorthand access to GraphQL's built-in types - def types - GraphQL::Define::TypeDefiner.instance - end - - # Allow `plugin` to perform complex initialization on the definition. - # Calls `plugin.use(defn, **kwargs)`. - # @param plugin [<#use(defn, **kwargs)>] A plugin object - # @param kwargs [Hash] Any options for the plugin - def use(plugin, **kwargs) - # https://bugs.ruby-lang.org/issues/10708 - if kwargs == {} - plugin.use(self) - else - plugin.use(self, **kwargs) - end - end - - # Lookup a function from the dictionary and call it if it's found. - def method_missing(name, *args, &block) - definition = @dictionary[name] - if definition - definition.call(@target, *args, &block) - else - msg = "#{@target.class.name} can't define '#{name}'" - raise NoDefinitionError, msg, caller - end - end - ruby2_keywords :method_missing - - def respond_to_missing?(name, include_private = false) - @dictionary[name] || super - end - end - end -end diff --git a/lib/graphql/define/instance_definable.rb b/lib/graphql/define/instance_definable.rb deleted file mode 100644 index 1c3d1cc6966..00000000000 --- a/lib/graphql/define/instance_definable.rb +++ /dev/null @@ -1,240 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Define - # @api deprecated - module InstanceDefinable - module DeprecatedDefine - def define(**kwargs, &block) - deprecated_caller = caller(1, 1).first - if deprecated_caller.include?("lib/graphql") - deprecated_caller = caller(2, 10).find { |c| !c.include?("lib/graphql") } - end - - if deprecated_caller - GraphQL::Deprecation.warn <<-ERR -#{self}.define will be removed in GraphQL-Ruby 2.0; use a class-based definition instead. See https://graphql-ruby.org/schema/class_based_api.html. - -> called from #{deprecated_caller} -ERR - end - deprecated_define(**kwargs, &block) - end - end - - def self.included(base) - base.extend(ClassMethods) - base.ensure_defined(:metadata) - end - - # @api deprecated - def metadata - @metadata ||= {} - end - - # @api deprecated - def deprecated_define(**kwargs, &block) - # make sure the previous definition_proc was executed: - ensure_defined - stash_dependent_methods - @pending_definition = Definition.new(kwargs, block) - nil - end - - # @api deprecated - def define(**kwargs, &block) - deprecated_define(**kwargs, &block) - end - - # @api deprecated - def redefine(**kwargs, &block) - ensure_defined - new_inst = self.dup - new_inst.deprecated_define(**kwargs, &block) - new_inst - end - - def initialize_copy(other) - super - @metadata = other.metadata.dup - end - - private - - # Run the definition block if it hasn't been run yet. - # This can only be run once: the block is deleted after it's used. - # You have to call this before using any value which could - # come from the definition block. - # @return [void] - def ensure_defined - if @pending_definition - defn = @pending_definition - @pending_definition = nil - - revive_dependent_methods - - begin - defn_proxy = DefinedObjectProxy.new(self) - # Apply definition from `define(...)` kwargs - defn.define_keywords.each do |keyword, value| - # Don't splat string hashes, which blows up on Rubies before 2.7 - if value.is_a?(Hash) && value.each_key.all? { |k| k.is_a?(Symbol) } - defn_proxy.public_send(keyword, **value) - else - defn_proxy.public_send(keyword, value) - end - end - # and/or apply definition from `define { ... }` block - if defn.define_proc - defn_proxy.instance_eval(&defn.define_proc) - end - rescue StandardError - # The definition block failed to run, so make this object pending again: - stash_dependent_methods - @pending_definition = defn - raise - end - end - nil - end - - # Take the pending methods and put them back on this object's singleton class. - # This reverts the process done by {#stash_dependent_methods} - # @return [void] - def revive_dependent_methods - pending_methods = @pending_methods - self.singleton_class.class_eval { - pending_methods.each do |method| - undef_method(method.name) if method_defined?(method.name) - define_method(method.name, method) - end - } - @pending_methods = nil - end - - # Find the method names which were declared as definition-dependent, - # then grab the method definitions off of this object's class - # and store them for later. - # - # Then make a dummy method for each of those method names which: - # - # - Triggers the pending definition, if there is one - # - Calls the same method again. - # - # It's assumed that {#ensure_defined} will put the original method definitions - # back in place with {#revive_dependent_methods}. - # @return [void] - def stash_dependent_methods - method_names = self.class.ensure_defined_method_names - @pending_methods = method_names.map { |n| self.class.instance_method(n) } - self.singleton_class.class_eval do - method_names.each do |method_name| - undef_method(method_name) if method_defined?(method_name) - define_method(method_name) { |*args, &block| - ensure_defined - self.send(method_name, *args, &block) - } - end - end - end - - class Definition - attr_reader :define_keywords, :define_proc - def initialize(define_keywords, define_proc) - @define_keywords = define_keywords - @define_proc = define_proc - end - end - - module ClassMethods - # Create a new instance - # and prepare a definition using its {.definitions}. - # @api deprecated - # @param kwargs [Hash] Key-value pairs corresponding to defininitions from `accepts_definitions` - # @param block [Proc] Block which calls helper methods from `accepts_definitions` - def deprecated_define(**kwargs, &block) - instance = self.new - instance.deprecated_define(**kwargs, &block) - instance - end - - # @api deprecated - def define(**kwargs, &block) - instance = self.new - instance.define(**kwargs, &block) - instance - end - - # Attach definitions to this class. - # Each symbol in `accepts` will be assigned with `{key}=`. - # The last entry in accepts may be a hash of name-proc pairs for custom definitions. - def accepts_definitions(*accepts) - new_assignments = if accepts.last.is_a?(Hash) - accepts.pop.dup - else - {} - end - - accepts.each do |key| - new_assignments[key] = AssignAttribute.new(key) - end - - @own_dictionary = own_dictionary.merge(new_assignments) - end - - def ensure_defined(*method_names) - @ensure_defined_method_names ||= [] - @ensure_defined_method_names.concat(method_names) - nil - end - - def ensure_defined_method_names - own_method_names = @ensure_defined_method_names || [] - if superclass.respond_to?(:ensure_defined_method_names) - superclass.ensure_defined_method_names + own_method_names - else - own_method_names - end - end - - # @return [Hash] combined definitions for self and ancestors - def dictionary - if superclass.respond_to?(:dictionary) - own_dictionary.merge(superclass.dictionary) - else - own_dictionary - end - end - - # @return [Hash] definitions for this class only - def own_dictionary - @own_dictionary ||= {} - end - end - - class AssignMetadataKey - def initialize(key) - @key = key - end - - def call(defn, value = true) - defn.metadata[@key] = value - end - end - - class AssignAttribute - extend GraphQL::Ruby2Keywords - - def initialize(attr_name) - @attr_assign_method = :"#{attr_name}=" - end - - # Even though we're just using the first value here, - # We have to add a splat here to use `ruby2_keywords`, - # so that it will accept a `[{}]` input from the caller. - def call(defn, *value) - defn.public_send(@attr_assign_method, value.first) - end - ruby2_keywords :call - end - end - end -end diff --git a/lib/graphql/define/no_definition_error.rb b/lib/graphql/define/no_definition_error.rb deleted file mode 100644 index a27899bc69e..00000000000 --- a/lib/graphql/define/no_definition_error.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Define - class NoDefinitionError < GraphQL::Error - end - end -end diff --git a/lib/graphql/define/non_null_with_bang.rb b/lib/graphql/define/non_null_with_bang.rb deleted file mode 100644 index bb6476bfe41..00000000000 --- a/lib/graphql/define/non_null_with_bang.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Define - # Wrap the object in NonNullType in response to `!` - # @example required Int type - # !GraphQL::INT_TYPE - # - module NonNullWithBang - # Make the type non-null - # @return [GraphQL::NonNullType] a non-null type which wraps the original type - def ! - to_non_null_type - end - end - end -end diff --git a/lib/graphql/define/type_definer.rb b/lib/graphql/define/type_definer.rb deleted file mode 100644 index bf0804eaf78..00000000000 --- a/lib/graphql/define/type_definer.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Define - # Some conveniences for definining return & argument types. - # - # Passed into initialization blocks, eg {ObjectType#initialize}, {Field#initialize} - class TypeDefiner - include Singleton - # rubocop:disable Naming/MethodName - def Int; GraphQL::DEPRECATED_INT_TYPE; end - def String; GraphQL::DEPRECATED_STRING_TYPE; end - def Float; GraphQL::DEPRECATED_FLOAT_TYPE; end - def Boolean; GraphQL::DEPRECATED_BOOLEAN_TYPE; end - def ID; GraphQL::DEPRECATED_ID_TYPE; end - # rubocop:enable Naming/MethodName - - # Make a {ListType} which wraps the input type - # - # @example making a list type - # list_of_strings = types[types.String] - # list_of_strings.inspect - # # => "[String]" - # - # @param type [Type] A type to be wrapped in a ListType - # @return [GraphQL::ListType] A ListType wrapping `type` - def [](type) - type.to_list_type - end - end - end -end diff --git a/lib/graphql/deprecated_dsl.rb b/lib/graphql/deprecated_dsl.rb deleted file mode 100644 index 7d331f231bc..00000000000 --- a/lib/graphql/deprecated_dsl.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # There are two ways to apply the deprecated `!` DSL to class-style schema definitions: - # - # 1. Scoped by file (CRuby only), add to the top of the file: - # - # using GraphQL::DeprecationDSL - # - # (This is a "refinement", there are also other ways to scope it.) - # - # 2. Global application, add before schema definition: - # - # GraphQL::DeprecationDSL.activate - # - module DeprecatedDSL - TYPE_CLASSES = [ - GraphQL::Schema::Scalar, - GraphQL::Schema::Enum, - GraphQL::Schema::InputObject, - GraphQL::Schema::Union, - GraphQL::Schema::Interface, - GraphQL::Schema::Object, - ] - - def self.activate - deprecated_caller = caller(1, 1).first - GraphQL::Deprecation.warn "DeprecatedDSL will be removed from GraphQL-Ruby 2.0, use `.to_non_null_type` instead of `!` and remove `.activate` from #{deprecated_caller}" - TYPE_CLASSES.each { |c| c.extend(Methods) } - GraphQL::Schema::List.include(Methods) - GraphQL::Schema::NonNull.include(Methods) - end - - module Methods - def ! - deprecated_caller = caller(1, 1).first - GraphQL::Deprecation.warn "DeprecatedDSL will be removed from GraphQL-Ruby 2.0, use `.to_non_null_type` instead of `!` at #{deprecated_caller}" - to_non_null_type - end - end - - TYPE_CLASSES.each do |type_class| - refine type_class.singleton_class do - include Methods - end - end - end -end diff --git a/lib/graphql/deprecation.rb b/lib/graphql/deprecation.rb deleted file mode 100644 index 71c094ba335..00000000000 --- a/lib/graphql/deprecation.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module GraphQL - module Deprecation - def self.warn(message) - if defined?(ActiveSupport::Deprecation) - ActiveSupport::Deprecation.warn(message) - else - Kernel.warn(message) - end - end - end -end diff --git a/lib/graphql/dig.rb b/lib/graphql/dig.rb index 8d2ac97dc8c..89be7130a16 100644 --- a/lib/graphql/dig.rb +++ b/lib/graphql/dig.rb @@ -2,10 +2,11 @@ module GraphQL module Dig # implemented using the old activesupport #dig instead of the ruby built-in - # so we can use some of the magic in Schema::InputObject and Query::Arguments + # so we can use some of the magic in Schema::InputObject and Interpreter::Arguments # to handle stringified/symbolized keys. # - # @param args [Array<[String, Symbol>] Retrieves the value object corresponding to the each key objects repeatedly + # @param own_key [String, Symbol] A key to retrieve + # @param rest_keys [Array<[String, Symbol>] Retrieves the value object corresponding to the each key objects repeatedly # @return [Object] def dig(own_key, *rest_keys) val = self[own_key] diff --git a/lib/graphql/directive.rb b/lib/graphql/directive.rb deleted file mode 100644 index 91e1ca9be6a..00000000000 --- a/lib/graphql/directive.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # Directives are server-defined hooks for modifying execution. - # - # Two directives are included out-of-the-box: - # - `@skip(if: ...)` Skips the tagged field if the value of `if` is true - # - `@include(if: ...)` Includes the tagged field _only_ if `if` is true - # - class Directive - include GraphQL::Define::InstanceDefinable - accepts_definitions :locations, :name, :description, :arguments, :default_directive, argument: GraphQL::Define::AssignArgument - - attr_accessor :locations, :arguments, :name, :description, :arguments_class - attr_accessor :ast_node - # @api private - attr_writer :default_directive - ensure_defined(:locations, :arguments, :graphql_name, :name, :description, :default_directive?) - - # Future-compatible alias - # @see {GraphQL::SchemaMember} - alias :graphql_name :name - - # Future-compatible alias - # @see {GraphQL::SchemaMember} - alias :graphql_definition :itself - - LOCATIONS = [ - QUERY = :QUERY, - MUTATION = :MUTATION, - SUBSCRIPTION = :SUBSCRIPTION, - FIELD = :FIELD, - FRAGMENT_DEFINITION = :FRAGMENT_DEFINITION, - FRAGMENT_SPREAD = :FRAGMENT_SPREAD, - INLINE_FRAGMENT = :INLINE_FRAGMENT, - SCHEMA = :SCHEMA, - SCALAR = :SCALAR, - OBJECT = :OBJECT, - FIELD_DEFINITION = :FIELD_DEFINITION, - ARGUMENT_DEFINITION = :ARGUMENT_DEFINITION, - INTERFACE = :INTERFACE, - UNION = :UNION, - ENUM = :ENUM, - ENUM_VALUE = :ENUM_VALUE, - INPUT_OBJECT = :INPUT_OBJECT, - INPUT_FIELD_DEFINITION = :INPUT_FIELD_DEFINITION, - ] - - LOCATION_DESCRIPTIONS = { - QUERY: 'Location adjacent to a query operation.', - MUTATION: 'Location adjacent to a mutation operation.', - SUBSCRIPTION: 'Location adjacent to a subscription operation.', - FIELD: 'Location adjacent to a field.', - FRAGMENT_DEFINITION: 'Location adjacent to a fragment definition.', - FRAGMENT_SPREAD: 'Location adjacent to a fragment spread.', - INLINE_FRAGMENT: 'Location adjacent to an inline fragment.', - SCHEMA: 'Location adjacent to a schema definition.', - SCALAR: 'Location adjacent to a scalar definition.', - OBJECT: 'Location adjacent to an object type definition.', - FIELD_DEFINITION: 'Location adjacent to a field definition.', - ARGUMENT_DEFINITION: 'Location adjacent to an argument definition.', - INTERFACE: 'Location adjacent to an interface definition.', - UNION: 'Location adjacent to a union definition.', - ENUM: 'Location adjacent to an enum definition.', - ENUM_VALUE: 'Location adjacent to an enum value definition.', - INPUT_OBJECT: 'Location adjacent to an input object type definition.', - INPUT_FIELD_DEFINITION: 'Location adjacent to an input object field definition.', - } - - def initialize - @arguments = {} - @default_directive = false - end - - def to_s - "" - end - - def on_field? - locations.include?(FIELD) - end - - def on_fragment? - locations.include?(FRAGMENT_SPREAD) && locations.include?(INLINE_FRAGMENT) - end - - def on_operation? - locations.include?(QUERY) && locations.include?(MUTATION) && locations.include?(SUBSCRIPTION) - end - - # @return [Boolean] Is this directive supplied by default? (eg `@skip`) - def default_directive? - @default_directive - end - - def inspect - "#" - end - - def type_class - metadata[:type_class] - end - - def get_argument(argument_name) - arguments[argument_name] - end - end -end - -require "graphql/directive/include_directive" -require "graphql/directive/skip_directive" -require "graphql/directive/deprecated_directive" diff --git a/lib/graphql/directive/deprecated_directive.rb b/lib/graphql/directive/deprecated_directive.rb deleted file mode 100644 index 6d7739bd096..00000000000 --- a/lib/graphql/directive/deprecated_directive.rb +++ /dev/null @@ -1,2 +0,0 @@ -# frozen_string_literal: true -GraphQL::Directive::DeprecatedDirective = GraphQL::Schema::Directive::Deprecated.graphql_definition diff --git a/lib/graphql/directive/include_directive.rb b/lib/graphql/directive/include_directive.rb deleted file mode 100644 index d4c47375d24..00000000000 --- a/lib/graphql/directive/include_directive.rb +++ /dev/null @@ -1,2 +0,0 @@ -# frozen_string_literal: true -GraphQL::Directive::IncludeDirective = GraphQL::Schema::Directive::Include.graphql_definition diff --git a/lib/graphql/directive/skip_directive.rb b/lib/graphql/directive/skip_directive.rb deleted file mode 100644 index 9ed3ffb5ca5..00000000000 --- a/lib/graphql/directive/skip_directive.rb +++ /dev/null @@ -1,2 +0,0 @@ -# frozen_string_literal: true -GraphQL::Directive::SkipDirective = GraphQL::Schema::Directive::Skip.graphql_definition diff --git a/lib/graphql/duration_encoding_error.rb b/lib/graphql/duration_encoding_error.rb new file mode 100644 index 00000000000..9611bb77950 --- /dev/null +++ b/lib/graphql/duration_encoding_error.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +module GraphQL + # This error is raised when `Types::ISO8601Duration` is asked to return a value + # that cannot be parsed as an ISO8601-formatted duration by ActiveSupport::Duration. + # + # @see GraphQL::Types::ISO8601Duration which raises this error + class DurationEncodingError < GraphQL::RuntimeTypeError + # The value which couldn't be encoded + attr_reader :duration_value + + def initialize(value) + @duration_value = value + super("Duration cannot be parsed: #{value}. \nDuration must be an ISO8601-formatted duration.") + end + end +end diff --git a/lib/graphql/enum_type.rb b/lib/graphql/enum_type.rb deleted file mode 100644 index e6e4b38b3c9..00000000000 --- a/lib/graphql/enum_type.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # @api deprecated - class EnumType < GraphQL::BaseType - extend Define::InstanceDefinable::DeprecatedDefine - - accepts_definitions :values, value: GraphQL::Define::AssignEnumValue - ensure_defined(:values, :validate_non_null_input, :coerce_non_null_input, :coerce_result) - attr_accessor :ast_node - - def initialize - super - @values_by_name = {} - end - - def initialize_copy(other) - super - self.values = other.values.values - end - - # @param new_values [Array] The set of values contained in this type - def values=(new_values) - @values_by_name = {} - new_values.each { |enum_value| add_value(enum_value) } - end - - # @param enum_value [EnumValue] A value to add to this type's set of values - def add_value(enum_value) - if @values_by_name.key?(enum_value.name) - raise "Enum value names must be unique. Value `#{enum_value.name}` already exists on Enum `#{name}`." - end - - @values_by_name[enum_value.name] = enum_value - end - - # @return [Hash EnumValue>] `{name => value}` pairs contained in this type - def values - @values_by_name - end - - def kind - GraphQL::TypeKinds::ENUM - end - - def coerce_result(value, ctx = nil) - if ctx.nil? - warn_deprecated_coerce("coerce_isolated_result") - ctx = GraphQL::Query::NullContext - end - - warden = ctx.warden - all_values = warden ? warden.enum_values(self) : @values_by_name.each_value - enum_value = all_values.find { |val| val.value == value } - if enum_value - enum_value.name - else - raise(UnresolvedValueError, "Can't resolve enum #{name} for #{value.inspect}") - end - end - - def to_s - name - end - - # A value within an {EnumType} - # - # Created with the `value` helper - class EnumValue - include GraphQL::Define::InstanceDefinable - ATTRIBUTES = [:name, :description, :deprecation_reason, :value] - accepts_definitions(*ATTRIBUTES) - attr_accessor(*ATTRIBUTES) - attr_accessor :ast_node - ensure_defined(*ATTRIBUTES) - - undef name= - def name=(new_name) - # Validate that the name is correct - GraphQL::NameValidator.validate!(new_name) - @name = new_name - end - - def graphql_name - name - end - - def type_class - metadata[:type_class] - end - end - - class UnresolvedValueError < GraphQL::Error - end - - private - - # Get the underlying value for this enum value - # - # @example get episode value from Enum - # episode = EpisodeEnum.coerce("NEWHOPE") - # episode # => 6 - # - # @param value_name [String] the string representation of this enum value - # @return [Object] the underlying value for this enum value - def coerce_non_null_input(value_name, ctx) - if @values_by_name.key?(value_name) - @values_by_name.fetch(value_name).value - elsif match_by_value = @values_by_name.find { |k, v| v.value == value_name } - # this is for matching default values, which are "inputs", but they're - # the Ruby value, not the GraphQL string. - match_by_value[1].value - else - nil - end - end - - def validate_non_null_input(value_name, ctx) - result = GraphQL::Query::InputValidationResult.new - allowed_values = ctx.warden.enum_values(self) - matching_value = allowed_values.find { |v| v.name == value_name } - - if matching_value.nil? - result.add_problem("Expected #{GraphQL::Language.serialize(value_name)} to be one of: #{allowed_values.map(&:name).join(', ')}") - end - - result - end - end -end diff --git a/lib/graphql/execution.rb b/lib/graphql/execution.rb index b00befd841e..5eba80dc3bc 100644 --- a/lib/graphql/execution.rb +++ b/lib/graphql/execution.rb @@ -1,11 +1,29 @@ # frozen_string_literal: true require "graphql/execution/directive_checks" -require "graphql/execution/execute" -require "graphql/execution/flatten" -require "graphql/execution/instrumentation" +require "graphql/execution/next" require "graphql/execution/interpreter" require "graphql/execution/lazy" require "graphql/execution/lookahead" require "graphql/execution/multiplex" -require "graphql/execution/typecast" require "graphql/execution/errors" + +module GraphQL + module Execution + # @api private + class Skip < GraphQL::RuntimeError + attr_accessor :path + def ast_nodes=(_ignored); end + + def finalize_graphql_result(query, result_data, key) + case result_data + when Hash + result_data.delete(key) + when Array + result_data.delete_at(key) + else + raise "Unexpected result data #{result_data.class}: #{result_data}" + end + end + end + end +end diff --git a/lib/graphql/execution/directive_checks.rb b/lib/graphql/execution/directive_checks.rb index 52e4738b14c..d9fbe4e7dca 100644 --- a/lib/graphql/execution/directive_checks.rb +++ b/lib/graphql/execution/directive_checks.rb @@ -18,11 +18,13 @@ def include?(directive_ast_nodes, query) case name when SKIP args = query.arguments_for(directive_ast_node, directive_defn) + next if args.is_a?(GraphQL::ExecutionError) if args[:if] == true return false end when INCLUDE args = query.arguments_for(directive_ast_node, directive_defn) + next if args.is_a?(GraphQL::ExecutionError) if args[:if] == false return false end diff --git a/lib/graphql/execution/errors.rb b/lib/graphql/execution/errors.rb index ce281dddaa4..d4dcb775093 100644 --- a/lib/graphql/execution/errors.rb +++ b/lib/graphql/execution/errors.rb @@ -2,59 +2,15 @@ module GraphQL module Execution - # A plugin that wraps query execution with error handling. - # Supports class-based schemas and the new {Interpreter} runtime only. - # - # @example Handling ActiveRecord::NotFound - # - # class MySchema < GraphQL::Schema - # use GraphQL::Execution::Errors - # - # rescue_from(ActiveRecord::NotFound) do |err, obj, args, ctx, field| - # ErrorTracker.log("Not Found: #{err.message}") - # nil - # end - # end - # class Errors - def self.use(schema) - definition_line = caller(2, 1).first - GraphQL::Deprecation.warn("GraphQL::Execution::Errors is now installed by default, remove `use GraphQL::Execution::Errors` from #{definition_line}") - end - - NEW_HANDLER_HASH = ->(h, k) { - h[k] = { - class: k, - handler: nil, - subclass_handlers: Hash.new(&NEW_HANDLER_HASH), - } - } - - def initialize(schema) - @schema = schema - @handlers = { - class: nil, - handler: nil, - subclass_handlers: Hash.new(&NEW_HANDLER_HASH), - } - end - - # @api private - def each_rescue - handlers = @handlers.values - while (handler = handlers.shift) do - yield(handler[:class], handler[:handler]) - handlers.concat(handler[:subclass_handlers].values) - end - end - # Register this handler, updating the # internal handler index to maintain least-to-most specific. # # @param error_class [Class] + # @param error_handlers [Hash] # @param error_handler [Proc] # @return [void] - def rescue_from(error_class, error_handler) + def self.register_rescue_from(error_class, error_handlers, error_handler) subclasses_handlers = {} this_level_subclasses = [] # During this traversal, do two things: @@ -62,13 +18,12 @@ def rescue_from(error_class, error_handler) # and gather them up to be inserted _under_ this class # - Find the point in the index where this handler should be inserted # (That is, _under_ any superclasses, or at top-level, if there are no superclasses registered) - handlers = @handlers[:subclass_handlers] - while (handlers) do + while (error_handlers) do this_level_subclasses.clear # First, identify already-loaded handlers that belong # _under_ this one. (That is, they're handlers # for subclasses of `error_class`.) - handlers.each do |err_class, handler| + error_handlers.each do |err_class, handler| if err_class < error_class subclasses_handlers[err_class] = handler this_level_subclasses << err_class @@ -76,13 +31,13 @@ def rescue_from(error_class, error_handler) end # Any handlers that we'll be moving, delete them from this point in the index this_level_subclasses.each do |err_class| - handlers.delete(err_class) + error_handlers.delete(err_class) end # See if any keys in this hash are superclasses of this new class: - next_index_point = handlers.find { |err_class, handler| error_class < err_class } + next_index_point = error_handlers.find { |err_class, handler| error_class < err_class } if next_index_point - handlers = next_index_point[1][:subclass_handlers] + error_handlers = next_index_point[1][:subclass_handlers] else # this new handler doesn't belong to any sub-handlers, # so insert it in the current set of `handlers` @@ -91,39 +46,15 @@ def rescue_from(error_class, error_handler) end # Having found the point at which to insert this handler, # register it and merge any subclass handlers back in at this point. - this_class_handlers = handlers[error_class] + this_class_handlers = error_handlers[error_class] this_class_handlers[:handler] = error_handler this_class_handlers[:subclass_handlers].merge!(subclasses_handlers) nil end - # Call the given block with the schema's configured error handlers. - # - # If the block returns a lazy value, it's not wrapped with error handling. That area will have to be wrapped itself. - # - # @param ctx [GraphQL::Query::Context] - # @return [Object] Either the result of the given block, or some object to replace the result, in case of error handling. - def with_error_handling(ctx) - yield - rescue StandardError => err - handler = find_handler_for(err.class) - if handler - runtime_info = ctx.namespace(:interpreter) || {} - obj = runtime_info[:current_object] - args = runtime_info[:current_arguments] - field = runtime_info[:current_field] - if obj.is_a?(GraphQL::Schema::Object) - obj = obj.object - end - handler[:handler].call(err, obj, args, ctx, field) - else - raise err - end - end - # @return [Proc, nil] The handler for `error_class`, if one was registered on this schema or inherited - def find_handler_for(error_class) - handlers = @handlers[:subclass_handlers] + def self.find_handler_for(schema, error_class) + handlers = schema.error_handlers[:subclass_handlers] handler = nil while (handlers) do _err_class, next_handler = handlers.find { |err_class, handler| error_class <= err_class } @@ -138,8 +69,8 @@ def find_handler_for(error_class) end # check for a handler from a parent class: - if @schema.superclass.respond_to?(:error_handler) && (parent_errors = @schema.superclass.error_handler) - parent_handler = parent_errors.find_handler_for(error_class) + if schema.superclass.respond_to?(:error_handlers) + parent_handler = find_handler_for(schema.superclass, error_class) end # If the inherited handler is more specific than the one defined here, diff --git a/lib/graphql/execution/execute.rb b/lib/graphql/execution/execute.rb deleted file mode 100644 index 027a12e06aa..00000000000 --- a/lib/graphql/execution/execute.rb +++ /dev/null @@ -1,333 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Execution - # A valid execution strategy - # @api private - class Execute - - # @api private - class Skip < GraphQL::Error; end - - # Just a singleton for implementing {Query::Context#skip} - # @api private - SKIP = Skip.new - - # @api private - class PropagateNull - end - # @api private - PROPAGATE_NULL = PropagateNull.new - - def self.use(schema_class) - schema_class.query_execution_strategy(self) - schema_class.mutation_execution_strategy(self) - schema_class.subscription_execution_strategy(self) - end - - def execute(ast_operation, root_type, query) - GraphQL::Deprecation.warn "#{self.class} will be removed in GraphQL-Ruby 2.0, please upgrade to the Interpreter: https://graphql-ruby.org/queries/interpreter.html" - result = resolve_root_selection(query) - lazy_resolve_root_selection(result, **{query: query}) - GraphQL::Execution::Flatten.call(query.context) - end - - def self.begin_multiplex(_multiplex) - end - - def self.begin_query(query, _multiplex) - ExecutionFunctions.resolve_root_selection(query) - end - - def self.finish_multiplex(results, multiplex) - ExecutionFunctions.lazy_resolve_root_selection(results, multiplex: multiplex) - end - - def self.finish_query(query, _multiplex) - { - "data" => Execution::Flatten.call(query.context) - } - end - - # @api private - module ExecutionFunctions - module_function - - def resolve_root_selection(query) - query.trace("execute_query", query: query) do - operation = query.selected_operation - op_type = operation.operation_type - root_type = query.root_type_for_operation(op_type) - if query.context[:__root_unauthorized] - # This was set by member/instrumentation.rb so that we wouldn't continue. - else - resolve_selection( - query.root_value, - root_type, - query.context, - mutation: query.mutation? - ) - end - end - end - - def lazy_resolve_root_selection(result, query: nil, multiplex: nil) - if query.nil? && multiplex.queries.length == 1 - query = multiplex.queries[0] - end - - tracer = (query || multiplex) - tracer.trace("execute_query_lazy", {multiplex: multiplex, query: query}) do - GraphQL::Execution::Lazy.resolve(result) - end - end - - def resolve_selection(object, current_type, current_ctx, mutation: false ) - # Assign this _before_ resolving the children - # so that when a child propagates null, the selection result is - # ready for it. - current_ctx.value = {} - - selections_on_type = current_ctx.irep_node.typed_children[current_type] - - selections_on_type.each do |name, child_irep_node| - field_ctx = current_ctx.spawn_child( - key: name, - object: object, - irep_node: child_irep_node, - ) - - field_result = resolve_field( - object, - field_ctx - ) - - if field_result.is_a?(Skip) - next - end - - if mutation - GraphQL::Execution::Lazy.resolve(field_ctx) - end - - - # If the last subselection caused a null to propagate to _this_ selection, - # then we may as well quit executing fields because they - # won't be in the response - if current_ctx.invalid_null? - break - else - current_ctx.value[name] = field_ctx - end - end - - current_ctx.value - end - - def resolve_field(object, field_ctx) - query = field_ctx.query - irep_node = field_ctx.irep_node - parent_type = irep_node.owner_type - field = field_ctx.field - - raw_value = begin - begin - arguments = query.arguments_for(irep_node, field) - field_ctx.trace("execute_field", { context: field_ctx }) do - field_ctx.schema.middleware.invoke([parent_type, object, field, arguments, field_ctx]) - end - rescue GraphQL::UnauthorizedFieldError => err - err.field ||= field - field_ctx.schema.unauthorized_field(err) - rescue GraphQL::UnauthorizedError => err - field_ctx.schema.unauthorized_object(err) - end - rescue GraphQL::ExecutionError => err - err - end - - if field_ctx.schema.lazy?(raw_value) - field_ctx.value = Execution::Lazy.new { - inner_value = field_ctx.trace("execute_field_lazy", {context: field_ctx}) { - begin - begin - field_ctx.field.lazy_resolve(raw_value, arguments, field_ctx) - rescue GraphQL::UnauthorizedError => err - field_ctx.schema.unauthorized_object(err) - end - rescue GraphQL::ExecutionError => err - err - end - } - continue_or_wait(inner_value, field_ctx.type, field_ctx) - } - else - continue_or_wait(raw_value, field_ctx.type, field_ctx) - end - end - - # If the returned object is lazy (unfinished), - # assign the lazy object to `.value=` so we can resolve it later. - # When we resolve it later, reassign it to `.value=` so that - # the finished value replaces the unfinished one. - # - # If the returned object is finished, continue to coerce - # and resolve child fields - def continue_or_wait(raw_value, field_type, field_ctx) - if field_ctx.schema.lazy?(raw_value) - field_ctx.value = Execution::Lazy.new { - inner_value = begin - begin - field_ctx.schema.sync_lazy(raw_value) - rescue GraphQL::UnauthorizedError => err - field_ctx.schema.unauthorized_object(err) - end - rescue GraphQL::ExecutionError => err - err - end - - field_ctx.value = continue_or_wait(inner_value, field_type, field_ctx) - } - else - field_ctx.value = continue_resolve_field(raw_value, field_type, field_ctx) - end - end - - def continue_resolve_field(raw_value, field_type, field_ctx) - if field_ctx.parent.invalid_null? - return nil - end - query = field_ctx.query - - case raw_value - when GraphQL::ExecutionError - raw_value.ast_node ||= field_ctx.ast_node - raw_value.path = field_ctx.path - query.context.errors.push(raw_value) - when Array - if field_type.non_null? - # List type errors are handled above, this is for the case of fields returning an array of errors - list_errors = raw_value.each_with_index.select { |value, _| value.is_a?(GraphQL::ExecutionError) } - if list_errors.any? - list_errors.each do |error, index| - error.ast_node = field_ctx.ast_node - error.path = field_ctx.path + (field_ctx.type.list? ? [index] : []) - query.context.errors.push(error) - end - end - end - end - - resolve_value( - raw_value, - field_type, - field_ctx, - ) - end - - def resolve_value(value, field_type, field_ctx) - field_defn = field_ctx.field - - if value.nil? - if field_type.kind.non_null? - parent_type = field_ctx.irep_node.owner_type - type_error = GraphQL::InvalidNullError.new(parent_type, field_defn, value) - field_ctx.schema.type_error(type_error, field_ctx) - PROPAGATE_NULL - else - nil - end - elsif value.is_a?(GraphQL::ExecutionError) - if field_type.kind.non_null? - PROPAGATE_NULL - else - nil - end - elsif value.is_a?(Array) && value.any? && value.all? {|v| v.is_a?(GraphQL::ExecutionError)} - if field_type.kind.non_null? - PROPAGATE_NULL - else - nil - end - elsif value.is_a?(Skip) - field_ctx.value = value - else - case field_type.kind - when GraphQL::TypeKinds::SCALAR, GraphQL::TypeKinds::ENUM - field_type.coerce_result(value, field_ctx) - when GraphQL::TypeKinds::LIST - inner_type = field_type.of_type - i = 0 - result = [] - field_ctx.value = result - - value.each do |inner_value| - inner_ctx = field_ctx.spawn_child( - key: i, - object: inner_value, - irep_node: field_ctx.irep_node, - ) - - inner_result = continue_or_wait( - inner_value, - inner_type, - inner_ctx, - ) - - return PROPAGATE_NULL if inner_result == PROPAGATE_NULL - - result << inner_ctx - i += 1 - end - - result - when GraphQL::TypeKinds::NON_NULL - inner_type = field_type.of_type - resolve_value( - value, - inner_type, - field_ctx, - ) - when GraphQL::TypeKinds::OBJECT - resolve_selection( - value, - field_type, - field_ctx - ) - when GraphQL::TypeKinds::UNION, GraphQL::TypeKinds::INTERFACE - query = field_ctx.query - resolved_type_or_lazy = field_type.resolve_type(value, field_ctx) - query.schema.after_lazy(resolved_type_or_lazy) do |resolved_type| - possible_types = query.possible_types(field_type) - - if !possible_types.include?(resolved_type) - parent_type = field_ctx.irep_node.owner_type - type_error = GraphQL::UnresolvedTypeError.new(value, field_defn, parent_type, resolved_type, possible_types) - field_ctx.schema.type_error(type_error, field_ctx) - PROPAGATE_NULL - else - resolve_value( - value, - resolved_type, - field_ctx, - ) - end - end - else - raise("Unknown type kind: #{field_type.kind}") - end - end - end - end - - include ExecutionFunctions - - # A `.call`-able suitable to be the last step in a middleware chain - module FieldResolveStep - # Execute the field's resolve method - def self.call(_parent_type, parent_object, field_definition, field_args, context, _next = nil) - field_definition.resolve(parent_object, field_args, context) - end - end - end - end -end diff --git a/lib/graphql/execution/field_resolve_step.rb b/lib/graphql/execution/field_resolve_step.rb new file mode 100644 index 00000000000..85774338331 --- /dev/null +++ b/lib/graphql/execution/field_resolve_step.rb @@ -0,0 +1,797 @@ +# frozen_string_literal: true +module GraphQL + module Execution + class FieldResolveStep + def initialize(parent_type:, runner:, key:, selections_step:) + @selections_step = selections_step + @key = key + @parent_type = parent_type + @ast_node = @ast_nodes = nil + @runner = runner + @field_definition = nil + @arguments = nil + @field_results = nil + @path = nil + @enqueued_authorization = false + @all_next_objects = nil + @all_next_results = nil + @static_type = nil + @next_selections = nil + @results = nil + @finish_extension_idx = nil + @was_scoped = nil + @pending_steps = nil + @arguments_without_loads = @post_processors = @directive_finalizers = nil + end + + attr_reader :ast_node, :key, :parent_type, :selections_step, :runner, + :field_definition, :object_is_authorized, :was_scoped, :field_results + + attr_accessor :pending_steps, :arguments, :static_type + + def path + @path ||= [*@selections_step.path, @key].freeze + end + + def ast_nodes + @ast_nodes ||= [@ast_node] + end + + def append_selection(ast_node) + if @ast_node.nil? + @ast_node = ast_node + elsif @ast_nodes.nil? + @ast_nodes = [@ast_node, ast_node] + else + @ast_nodes << ast_node + end + nil + end + + def value + return nil if @selections_step.killed + query = @selections_step.query + set_current_field + query.current_trace.begin_execute_field(@field_definition, @field_results, @arguments, query) + sync(@field_results) + query.current_trace.end_execute_field(@field_definition, @field_results, @arguments, query, @field_results) + @runner.add_step(self) + true + ensure + set_current_field(nil) + end + + def sync(lazy) + if lazy.is_a?(Array) + lazy.map! { |l| sync(l)} + else + @runner.schema.sync_lazy(lazy) + end + rescue GraphQL::UnauthorizedError => auth_err + @runner.schema.unauthorized_object(auth_err) + rescue GraphQL::ExecutionError => err + err + rescue StandardError => stderr + begin + @selections_step.query.handle_or_reraise(stderr, field: @field_definition, arguments: @arguments, object: nil) + rescue GraphQL::ExecutionError => ex_err + ex_err + end + end + + def call + return nil if @selections_step.killed + set_current_field if @field_definition + + if @enqueued_authorization + enqueue_next_steps + elsif @finish_extension_idx + finish_extensions + elsif @field_results + build_results + elsif @arguments + execute_field + else + build_arguments + end + rescue StandardError => err + if @field_definition && !err.message.start_with?("Resolving ") + # TODO remove this check ^^^^^^ when NullDataloader isn't recursive + raise err, "Resolving #{@field_definition.path}: #{err.message}", err.backtrace + else + raise + end + ensure + set_current_field(nil) + end + + def add_graphql_error(result, key, err, return_type: @field_definition.type) + err.path = path + if err.ast_node.nil? + err.ast_nodes = ast_nodes + end + @runner.add_finalizer(@selections_step.query, result, key, err) + if !err.is_a?(GraphQL::Execution::Skip) + field_type = return_type + should_propagate_null = field_type.non_null? + while (should_propagate_null == false && field_type.kind.wraps?) + field_type = field_type.of_type + should_propagate_null = field_type.non_null? + end + if should_propagate_null + propagate_nulls + end + end + err + end + + def propagate_nulls + propagating_null = true + highest_nulled_depth = path.size + highest_list_depth = nil + current_field_step = self + while current_field_step + return_type = current_field_step.field_definition.type + if propagating_null && return_type.non_null? + highest_nulled_depth = current_field_step.path.size + else + propagating_null = false + end + + if return_type.list? + highest_list_depth = current_field_step.path.size + end + + current_field_step = current_field_step.selections_step.field_resolve_step + end + + if highest_list_depth.nil? || highest_nulled_depth <= highest_list_depth + kill_field_step = self + while kill_field_step && highest_nulled_depth <= kill_field_step.path.size + kill_field_step.selections_step.killed = true + kill_field_step = kill_field_step.selections_step.field_resolve_step + end + end + end + + def build_errors_result(errors, single_error) + first_error = errors.nil? ? single_error : errors.pop + @field_results = [first_error] + @results = [@selections_step.results.first] + if errors + errors.each do |e| + add_graphql_error(@results.first, key, e) + end + end + build_results + end + + def build_arguments + query = @selections_step.query + field_name = @ast_node.name + @field_definition = query.types.field(@parent_type, field_name) || raise(GraphQL::Error, "No field definition found for #{@parent_type.to_type_signature}.#{ast_node.name} (at #{@ast_node.position})") + set_current_field + @arguments, errors = @runner.input_values[query].argument_values(@field_definition, @ast_node.arguments, self) # rubocop:disable Development/ContextIsPassedCop + if errors + build_errors_result(errors, nil) + return + end + + if (@pending_steps.nil? || @pending_steps.size == 0) && + @field_results.nil? # Make sure the arguments flow didn't already call through + execute_field + end + ensure + set_current_field(nil) + end + + # Used for compatibility in Schema::Subscription + def arguments_without_loads + if @arguments_without_loads.nil? + @arguments_without_loads, _errors = @runner.input_values[@selections_step.query].argument_values(@field_definition, ast_node.arguments, nil) + end + @arguments_without_loads + end + + def execute_field + objects = @selections_step.objects + if @arguments.is_a?(GraphQL::RuntimeError) + build_errors_result(nil, @arguments) + return + end + + @results = @selections_step.results + query = @selections_step.query + ctx = query.context + if (v = @field_definition.validators).any? # rubocop:disable Development/NoneWithoutBlockCop + begin + Schema::Validator.validate!(v, nil, ctx, @arguments) + rescue GraphQL::RuntimeError => err + build_errors_result(nil, err) + return + end + end + + @field_definition.extras.each do |extra| + case extra + when :lookahead + if @arguments.frozen? + @arguments = @arguments.dup + end + @arguments[:lookahead] = Execution::Lookahead.new( + query: query, + ast_nodes: ast_nodes, + field: @field_definition, + ) + when :ast_node + if @arguments.frozen? + @arguments = @arguments.dup + end + @arguments[:ast_node] = ast_node + else + raise ArgumentError, "This `extra` isn't supported yet: #{extra.inspect}. Open an issue on GraphQL-Ruby to add compatibility for it." + end + end + + if @field_definition.dynamic_introspection + objects = @selections_step.graphql_objects.map { |o| @field_definition.owner.wrap(o, ctx) } + end + + if @runner.authorizes?(@field_definition, ctx) + authorized_objects = [] + authorized_results = [] + l = objects.size + i = 0 + while i < l + o = objects[i] + err = nil + begin + field_authed = @field_definition.authorized?(o, @arguments, ctx) + if @runner.resolves_lazies && @runner.lazy?(field_authed) + # TODO batch this properly... + field_authed = sync(field_authed) + end + rescue GraphQL::UnauthorizedFieldError => field_auth_err + err = field_auth_err + err.field ||= @field_definition + field_authed = false + end + + if field_authed + authorized_results << @results[i] + authorized_objects << o + else + begin + err ||= GraphQL::UnauthorizedFieldError.new(object: o, type: @parent_type, context: ctx, field: @field_definition) + new_obj = query.schema.unauthorized_field(err) + if !new_obj.nil? + authorized_objects << new_obj + authorized_results << @results[i] + end + rescue GraphQL::ExecutionError => exec_err + add_graphql_error(@results[i], key, exec_err) + end + end + i += 1 + end + + if authorized_objects.size == 0 + return + end + @results = authorized_results + else + authorized_objects = objects + end + + if @parent_type.default_relay? && authorized_objects.all? { |o| o.respond_to?(:was_authorized_by_scope_items?) && o.was_authorized_by_scope_items? } + @was_scoped = true + end + + query.current_trace.begin_execute_field(@field_definition, authorized_objects, @arguments, query) + + if @runner.uses_runtime_directives + if @ast_nodes.nil? || @ast_nodes.size == 1 + directives = if !@ast_node.directives.empty? + @ast_node.directives + else + nil + end + else + directives = nil + @ast_nodes.each do |n| + if (d = n.directives).any? # rubocop:disable Development/NoneWithoutBlockCop + directives ||= [] + directives.concat(d) + end + end + end + + if directives + directives.each do |dir_node| + if (dir_defn = @runner.runtime_directives[dir_node.name]) + dir_args, errors = @runner.input_values[query].argument_values(dir_defn, dir_node.arguments, self) # rubocop:disable Development/ContextIsPassedCop + if errors + @results.each { |r| r.delete(@key) } + errors.each { |e| e.ast_node = dir_node } + build_errors_result(errors, nil) + return + else + begin + dir_defn.validate!(dir_args, query.context) + if !(result = dir_defn.resolve_field(ast_nodes, @parent_type, field_definition, authorized_objects, dir_args, ctx)).nil? + if result.is_a?(Finalizer) + result.path = path + @directive_finalizers ||= [] + @directive_finalizers << result + end + + if result.is_a?(PostProcessor) + @post_processors ||= [] + @post_processors << result + end + + if result.is_a?(HaltExecution) + @directive_finalizers&.each { |f| + @selections_step.results.each { |r| @runner.add_finalizer(query, r, key, f) } + } + return + end + end + rescue GraphQL::RuntimeError => err + err.ast_node = dir_node + raise + end + end + end + end + end + end + + has_extensions = @field_definition.extensions.size > 0 + if has_extensions + @extended = GraphQL::Schema::Field::ExtendedState.new(@arguments, authorized_objects) + @field_results = @field_definition.run_next_extensions_before_resolve(authorized_objects, @arguments, ctx, @extended) do |objs, args| + if (added_extras = @extended.added_extras) + args = args.dup + added_extras.each { |e| args.delete(e) } + end + resolve_batch(objs, ctx, args) + end + @finish_extension_idx = 0 + else + @field_results = resolve_batch(authorized_objects, ctx, @arguments) + end + + query.current_trace.end_execute_field(@field_definition, authorized_objects, @arguments, query, @field_results) + + if any_lazy_results? + @runner.dataloader.lazy_at_depth(path.size, self) + elsif @pending_steps.nil? || @pending_steps.empty? + if has_extensions + finish_extensions + else + build_results + end + end + rescue GraphQL::ExecutionError => err + build_errors_result(nil, err) + rescue StandardError => stderr + begin + @selections_step.query.handle_or_reraise(stderr, field: @field_definition, arguments: @arguments, object: nil) + rescue GraphQL::ExecutionError => err + add_graphql_error(@results[0], key, err) + end + end + + def any_lazy_results? + lazies = false + if @runner.resolves_lazies # TODO extract this + @field_results.each do |field_result| + if @runner.lazy?(field_result) + lazies = true + break + elsif field_result.is_a?(Array) + field_result.each do |inner_fr| + if @runner.lazy?(inner_fr) + break lazies = true + end + end + if lazies + break + end + end + end + end + lazies + end + + def finish_extensions + ctx = @selections_step.query.context + memos = @extended.memos || EmptyObjects::EMPTY_HASH + while ext = @field_definition.extensions[@finish_extension_idx] + # These two are hardcoded here because of how they need to interact with runtime metadata. + # It would probably be better + case ext + when Schema::Field::ConnectionExtension + conns = ctx.schema.connections + @field_results.map!.each_with_index do |value, idx| + object = @extended.object[idx] + conn = conns.populate_connection(@field_definition, object, value, @arguments, ctx) + if conn + conn.was_authorized_by_scope_items = @was_scoped + end + conn + rescue GraphQL::RuntimeError => err + err + end + when Schema::Field::ScopeExtension + if @was_scoped.nil? + if (rt = @field_definition.type.unwrap).respond_to?(:scope_items) + @was_scoped = true + @field_results.map! { |v| v.nil? ? v : rt.scope_items(v, ctx) } + else + @was_scoped = false + end + end + else + memo = memos[@finish_extension_idx] + @field_results = ext.after_resolve(objects: @extended.object, arguments: @extended.arguments, context: ctx, values: @field_results, memo: memo) # rubocop:disable Development/ContextIsPassedCop + end + @finish_extension_idx += 1 + if any_lazy_results? + @runner.dataloader.lazy_at_depth(path.size, self) + return + end + end + + @finish_extension_idx = nil + build_results + end + + def build_results + return_type = @field_definition.type + return_result_type = return_type.unwrap + + @post_processors&.each do |post_processor| + @field_results = post_processor.after_resolve(@field_results) + end + + if return_result_type.kind.composite? + @static_type = return_result_type + if @ast_nodes + @next_selections = [] + @ast_nodes.each do |ast_node| + @next_selections.concat(ast_node.selections) + end + else + @next_selections = @ast_node.selections + end + + @all_next_objects = [] + @all_next_results = [] + + is_list = return_type.list? + is_non_null = return_type.non_null? + i = 0 + s = @results.size + while i < s do + result_h = @results[i] + result = @field_results[i] + i += 1 + build_graphql_result(result_h, @key, result, return_type, is_non_null, is_list, false) + end + @enqueued_authorization = true + + if @pending_steps.nil? || @pending_steps.size == 0 + enqueue_next_steps + else + # Do nothing -- it will enqueue itself later + end + else + ctx = @selections_step.query.context + i = 0 + s = @results.size + while i < s do + result_h = @results[i] + field_result = @field_results[i] + i += 1 + finish_leaf_result(result_h, @key, field_result, return_type, ctx) + end + end + end + + def finish_leaf_result(result_h, key, field_result, return_type, ctx) + final_field_result = build_leaf_result(result_h, key, field_result, return_type, ctx, false) + + @directive_finalizers&.each { |f| @runner.add_finalizer(ctx.query, result_h, key, f) } + result_h[@key] = final_field_result + end + + def build_leaf_result(result_h, result_key, field_result, return_type, ctx, is_from_array) + if field_result.nil? + if return_type.non_null? + add_non_null_error(is_from_array) + else + nil + end + elsif field_result.is_a?(Finalizer) + if field_result.is_a?(GraphQL::RuntimeError) + add_graphql_error(result_h, result_key, field_result, return_type: return_type) + else + field_result.path = path + @runner.add_finalizer(ctx.query, result_h, key, field_result) + end + elsif return_type.list? + if return_type.non_null? + return_type = return_type.of_type + end + + inner_type = return_type.of_type + result_a = Array.new(field_result.size) + field_result.each_with_index do |item, idx| + result_a[idx] = build_leaf_result(result_a, idx, item, inner_type, ctx, true) + end + result_a + else + return_type.coerce_result(field_result, ctx) + end + end + + def enqueue_next_steps + if !@all_next_results.empty? + @all_next_objects.compact! + + query = @selections_step.query + ctx = query.context + if @static_type.kind.abstract? + next_objects_by_type = Hash.new { |h, obj_t| h[obj_t] = [] }.compare_by_identity + next_results_by_type = Hash.new { |h, obj_t| h[obj_t] = [] }.compare_by_identity + + @all_next_objects.each_with_index do |next_object, i| + result = @all_next_results[i] + if (object_type = @runner.runtime_type_at[result]) + # OK + else + query.current_trace.begin_resolve_type(@static_type, next_object, query.context) + object_type = ResolveTypeStep.resolve_type(@static_type, next_object, query) + if object_type.is_a?(Array) + object_type, next_object = object_type + end + if @runner.resolves_lazies && @runner.lazy?(object_type) + # TODO batch this + object_type, next_object = sync(object_type) + end + ResolveTypeStep.assert_valid_resolved_type(@static_type, object_type, next_object, self) + query.current_trace.end_resolve_type(@static_type, next_object, query.context, object_type) + @runner.runtime_type_at[result] = object_type + end + next_objects_by_type[object_type] << next_object + next_results_by_type[object_type] << result + end + + next_objects_by_type.each do |obj_type, next_objects| + query.current_trace.objects(obj_type, next_objects, ctx) + @runner.add_step(SelectionsStep.new( + path: path, + field_resolve_step: self, + parent_type: obj_type, + selections: @next_selections, + objects: next_objects, + results: next_results_by_type[obj_type], + runner: @runner, + query: query, + )) + end + else + query.current_trace.objects(@static_type, @all_next_objects, ctx) + @runner.add_step(SelectionsStep.new( + path: path, + field_resolve_step: self, + parent_type: @static_type, + selections: @next_selections, + objects: @all_next_objects, + results: @all_next_results, + runner: @runner, + query: query, + )) + end + end + end + + def authorized_finished(step) + @pending_steps.delete(step) + if @enqueued_authorization && @pending_steps.size == 0 + @runner.add_step(self) + end + end + + def add_non_null_error(is_from_array) + err = @parent_type::InvalidNullError.new(@parent_type, @field_definition, ast_nodes, is_from_array: is_from_array, path: path) + nn_result = @runner.schema.type_error(err, @selections_step.query.context) + if nn_result.nil? + propagate_nulls + end + nn_result + end + + def set_current_field(new_value = @field_definition) + Fiber[:__graphql_current_field] = new_value + end + + private + + def build_graphql_result(graphql_result, key, field_result, return_type, is_nn, is_list, is_from_array) # rubocop:disable Metrics/ParameterLists + if field_result.nil? + if is_nn + graphql_result[key] = add_non_null_error(is_from_array) + else + graphql_result[key] = nil + end + elsif field_result.is_a?(Finalizer) + graphql_result[key] = if field_result.is_a?(GraphQL::RuntimeError) + add_graphql_error(graphql_result, key, field_result) + else + field_result.path = path + @runner.add_finalizer(@selections_step.query, graphql_result, key, field_result) + field_result + end + elsif is_list + if is_nn + return_type = return_type.of_type + end + inner_type = return_type.of_type + inner_type_nn = inner_type.non_null? + inner_type_l = inner_type.list? + list_result = graphql_result[key] = [] + @directive_finalizers&.each { |f| @runner.add_finalizer(@selections_step.query, list_result, nil, f) } + i = 0 + s = field_result.size + while i < s + inner_f_r = field_result[i] + build_graphql_result(list_result, i, inner_f_r, inner_type, inner_type_nn, inner_type_l, true) + i += 1 + end + elsif @runner.resolves_lazies || ( + @static_type.kind.object? ? + @runner.authorizes?(@static_type, @selections_step.query.context) : + ( + (runtime_type, _ignored_new_value = ResolveTypeStep.resolve_type(@static_type, field_result, @selections_step.query)) && + (@runner.runtime_type_at[graphql_result] = runtime_type) && + @runner.authorizes?(runtime_type, @selections_step.query.context) + )) + obj_step = PrepareObjectStep.new( + object: field_result, + runner: @runner, + field_resolve_step: self, + graphql_result: graphql_result, + next_objects: @all_next_objects, + next_results: @all_next_results, + is_non_null: is_nn, + key: key, + is_from_array: is_from_array, + ) + ps = @pending_steps ||= [] + ps << obj_step + @runner.add_step(obj_step) + else + next_result_h = {}.compare_by_identity + @all_next_results << next_result_h + @directive_finalizers&.each { |f| @runner.add_finalizer(@selections_step.query, next_result_h, nil, f) } + @all_next_objects << field_result + @runner.static_type_at[next_result_h] = @static_type + graphql_result[key] = next_result_h + end + end + + def resolve_batch(objects, context, args_hash) + dyn_ins = @field_definition.dynamic_introspection + method_receiver = dyn_ins ? @field_definition.owner : @parent_type + case @field_definition.execution_mode + when :resolve_batch + begin + method_receiver.public_send(@field_definition.execution_mode_key, objects, context, **args_hash) + rescue GraphQL::ExecutionError => exec_err + error_instance_array(objects.size, exec_err) + rescue StandardError => stderr + begin + context.query.handle_or_reraise(stderr, field: @field_definition, arguments: @arguments, object: nil) + rescue GraphQL::ExecutionError => exec_err + error_instance_array(objects.size, exec_err) + end + end + when :resolve_static + result = begin + method_receiver.public_send(@field_definition.execution_mode_key, context, **args_hash) + rescue GraphQL::ExecutionError => err + err + rescue StandardError => stderr + begin + context.query.handle_or_reraise(stderr, field: @field_definition, arguments: @arguments, object: nil) + rescue GraphQL::ExecutionError => err + err + end + end + Array.new(objects.size, result) + when :resolve_each + objects.map do |o| + passed_in_obj = dyn_ins ? o.object : o + method_receiver.public_send(@field_definition.execution_mode_key, passed_in_obj, context, **args_hash) + rescue GraphQL::ExecutionError => err + err + rescue StandardError => stderr + begin + context.query.handle_or_reraise(stderr, field: @field_definition, arguments: @arguments, object: o) + rescue GraphQL::ExecutionError => err + err + end + end + when :hash_key + k = @field_definition.execution_mode_key + objects.map { |o| o[k] } + when :direct_send + m = @field_definition.execution_mode_key + objects.map do |o| + o.public_send(m, **args_hash) + rescue GraphQL::ExecutionError => err + err + rescue StandardError => stderr + begin + @selections_step.query.handle_or_reraise(stderr, object: o, field: @field_definition, arguments: args_hash) + rescue GraphQL::ExecutionError => ex_err + ex_err + end + end + when :dig + objects.map { |o| o.dig(*@field_definition.execution_mode_key) } + when :dataload + if (k = @field_definition.execution_mode_key).is_a?(Class) + context.dataload_all(k, objects) + elsif (source_class = k[:with]) + if (batch_args = k[:by]) + context.dataload_all(source_class, *batch_args, objects) + else + context.dataload_all(source_class, objects) + end + elsif (model = k[:model]) + value_method = k[:using] + values = objects.map(&value_method) + context.dataload_all_records(model, values, find_by: k[:find_by]) + elsif (assoc = k[:association]) + if assoc == true + assoc = @field_definition.original_name + end + context.dataload_all_associations(objects, assoc, scope: k[:scope]) + else + raise ArgumentError, "Unexpected `dataload: ...` configuration: #{k.inspect}" + end + when :resolver_class + results = Array.new(objects.size, nil) + ps = @pending_steps ||= [] + objects.each_with_index do |o, idx| + resolver_inst = @field_definition.resolver.new(object: o, context: context, field: @field_definition) + ps << resolver_inst + resolver_inst.field_resolve_step = self + resolver_inst.prepared_arguments = args_hash + resolver_inst.exec_result = results + resolver_inst.exec_index = idx + @runner.add_step(resolver_inst) + resolver_inst + end + results + when :resolve_legacy_instance_method + @selections_step.graphql_objects.map do |obj_inst| + obj_inst.public_send(@field_definition.execution_mode_key, **args_hash) + rescue GraphQL::ExecutionError => exec_err + exec_err + end + else + raise "Batching execution for #{path} not implemented (execution_mode: #{@execution_mode.inspect}); provide `resolve_static:`, `resolve_batch:`, `hash_key:`, `method:`, or use a compatibility plug-in" + end + end + + def error_instance_array(size, err_prototype) + Array.new(size) { err_prototype.dup } + end + end + end +end diff --git a/lib/graphql/execution/finalize.rb b/lib/graphql/execution/finalize.rb new file mode 100644 index 00000000000..34ad59432ee --- /dev/null +++ b/lib/graphql/execution/finalize.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true +module GraphQL + module Execution + class Finalize + def initialize(query, data, runner) + @query = query + @data = data + @static_type_at = runner.static_type_at + @runner = runner + @current_exec_path = query.path.dup + @current_result_path = query.path.dup + @finalizers = runner.finalizers ? runner.finalizers[query] : {}.compare_by_identity + @finalizers_count = 0 + @finalizers.each do |key, values| + values.each do |key2, values2| + case values2 + when Array + @finalizers_count += values2.size + else + @finalizers_count += 1 + end + end + end + + query.context.errors.each do |err| + err_path = err.path - @current_exec_path + key = err_path.pop + targets = [data] + while (part = err_path.shift) + targets.map! { |t| t[part] } + targets.flatten! + end + + targets.each_with_index do |target, idx| + if target.is_a?(Hash) + value_at_key = target[key] + if value_at_key.equal?(err) + tf = @finalizers[target] ||= {}.compare_by_identity + tf[key] = err + @finalizers_count += 1 + elsif value_at_key.is_a?(Array) + value_at_key.each_with_index do |el, idx| + if el.equal?(err) + tf = @finalizers[value_at_key] ||= {}.compare_by_identity + tf[idx] = err + @finalizers_count += 1 + end + end + end + end + end + end + end + + def run + if (selected_operation = @query.selected_operation) && @data + if @data.is_a?(Hash) + check_object_result(@data, @query.root_type, selected_operation.selections) + elsif @data.is_a?(Array) + check_list_result(@data, @query.root_type, selected_operation.selections) + elsif @data.is_a?(Finalizer) + dummy_data = {} + dummy_key = "__dummy" + @data.path = @query.path + @data.finalize_graphql_result(@query, dummy_data, dummy_key) + dummy_data[dummy_key] + else + raise ArgumentError, "Unexpected @data: #{@data.inspect}" + end + else + @data + end + end + + private + + def run_finalizers(result_path, finalizer_or_finalizers, result_data, result_key) + if finalizer_or_finalizers.is_a?(Array) + finalizer_or_finalizers.each { |f| + f.path = result_path + f.finalize_graphql_result(@query, result_data, result_key) + } + @finalizers_count -= finalizer_or_finalizers.size + else + f = finalizer_or_finalizers + f.path = result_path + f.finalize_graphql_result(@query, result_data, result_key) + @finalizers_count -= 1 + end + end + + def finalizers(result_value, key) + finalizers_for_value = @finalizers[result_value] + finalizers_for_value && finalizers_for_value[key] + end + + def check_object_result(result_h, parent_type, ast_selections) + if (f = finalizers(result_h, nil)) + run_finalizers(@current_result_path.dup, f, result_h, nil) + return result_h if @finalizers_count == 0 + end + + if parent_type.kind.abstract? + parent_type = @runner.runtime_type_at[result_h] + end + + ast_selections.each do |ast_selection| + case ast_selection + when Language::Nodes::Field + key = ast_selection.alias || ast_selection.name + if (f = finalizers(result_h, key)) + result_value = result_h[key] + run_finalizers(@current_result_path.dup << key, f, result_h, key) + new_result_value = result_h.key?(key) ? result_h[key] : :unassigned + end + next if !(f || result_h.key?(key)) + begin + @current_exec_path << key + @current_result_path << key + + field_defn = @query.context.types.field(parent_type, ast_selection.name) || raise("Invariant: No field found for #{parent_type.to_type_signature}.#{ast_selection.name}") + result_type = field_defn.type + if (result_type_non_null = result_type.non_null?) + result_type = result_type.of_type + end + + if !f + result_value = result_h[key] + new_result_value = if result_type.list? && result_value + check_list_result(result_value, result_type.of_type, ast_selection.selections) + elsif !result_type.kind.leaf? && result_value + check_object_result(result_value, result_type, ast_selection.selections) + else + result_value + end + end + + if new_result_value.nil? && result_type_non_null + return nil + elsif :unassigned.equal?(new_result_value) + # Do nothing + break if @finalizers_count == 0 + elsif !new_result_value.equal?(result_value) + result_h[key] = new_result_value + break if @finalizers_count == 0 + end + ensure + @current_exec_path.pop + @current_result_path.pop + end + when Language::Nodes::InlineFragment + static_type_at_result = @static_type_at[result_h] + if static_type_at_result && ( + (t = ast_selection.type).nil? || + @runner.type_condition_applies?(@query.context, static_type_at_result, t.name) + ) + result_h = check_object_result(result_h, parent_type, ast_selection.selections) + return nil if result_h.nil? + end + when Language::Nodes::FragmentSpread + fragment_defn = @query.document.definitions.find { |defn| defn.is_a?(Language::Nodes::FragmentDefinition) && defn.name == ast_selection.name } + static_type_at_result = @static_type_at[result_h] + if static_type_at_result && @runner.type_condition_applies?(@query.context, static_type_at_result, fragment_defn.type.name) + result_h = check_object_result(result_h, parent_type, fragment_defn.selections) + return nil if result_h.nil? + end + end + end + + result_h + end + + def check_list_result(result_arr, inner_type, ast_selections) + if (inner_type_non_null = inner_type.non_null?) + inner_type = inner_type.of_type + end + + new_invalid_null = false + + if (f = finalizers(result_arr, nil)) + run_finalizers(@current_result_path.dup, f, result_arr, nil) + return result_arr if @finalizers_count == 0 + end + + effective_idx = -1 + result_arr.each_with_index do |result_item, before_idx| + effective_idx += 1 + @current_result_path << before_idx + new_result = if (f = finalizers(result_arr, before_idx)) + before_size = result_arr.size + run_finalizers(@current_result_path.dup, f, result_arr, effective_idx) + after_size = result_arr.size + if after_size < before_size + effective_idx -= 1 + :unassigned + else + result_arr[effective_idx] + end + elsif inner_type.list? && result_item + check_list_result(result_item, inner_type.of_type, ast_selections) + elsif !inner_type.kind.leaf? && result_item + check_object_result(result_item, inner_type, ast_selections) + else + result_item + end + + if new_result.nil? && inner_type_non_null + new_invalid_null = true + result_arr[effective_idx] = nil + break if @finalizers_count == 0 + elsif :unassigned.equal?(new_result) + break if @finalizers_count == 0 + elsif !new_result.equal?(result_item) + result_arr[effective_idx] = new_result + break if @finalizers_count == 0 + end + ensure + @current_result_path.pop + end + + if new_invalid_null + nil + else + result_arr + end + end + end + end +end diff --git a/lib/graphql/execution/flatten.rb b/lib/graphql/execution/flatten.rb deleted file mode 100644 index f7f75fbfb2a..00000000000 --- a/lib/graphql/execution/flatten.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Execution - # Starting from a root context, - # create a hash out of the context tree. - # @api private - module Flatten - def self.call(ctx) - flatten(ctx) - end - - class << self - private - - def flatten(obj) - case obj - when Hash - flattened = {} - obj.each do |key, val| - flattened[key] = flatten(val) - end - flattened - when Array - obj.map { |v| flatten(v) } - when Query::Context::SharedMethods - if obj.invalid_null? - nil - elsif obj.skipped? && obj.value.empty? - nil - else - flatten(obj.value) - end - else - obj - end - end - end - end - end -end diff --git a/lib/graphql/execution/input_values.rb b/lib/graphql/execution/input_values.rb new file mode 100644 index 00000000000..7e9176dc265 --- /dev/null +++ b/lib/graphql/execution/input_values.rb @@ -0,0 +1,333 @@ +# frozen_string_literal: true +module GraphQL + module Execution + class InputValues + def initialize(query, runner) + @query = query + @runner = runner + @variable_values = nil + end + + def variable_values + @variable_values ||= begin + variable_nodes = @query.selected_operation.variables + if variable_nodes.empty? + EmptyObjects::EMPTY_HASH + else + raw_values = @query.provided_variables + values = {} + variable_nodes.each do |var_node| + var_ast_value = if raw_values.key?(var_node.name) + raw_values[var_node.name] + elsif raw_values.key?(sym_name = var_node.name.to_sym) + raw_values[sym_name] + elsif !var_node.default_value.nil? + var_node.default_value + else + next + end + + var_type = @runner.schema.type_from_ast(var_node.type, context: @query.context) + values[var_node.name] = variable_value(var_ast_value, var_type) + end + values + end + end + end + + def argument_values(owner_defn, argument_nodes, field_resolve_step) + arg_defns = @query.types.arguments(owner_defn) + argument_values = {} + errors = nil + + arg_defns.each do |argument_definition| + arg_ruby_key = argument_definition.keyword + arg_graphql_key = argument_definition.graphql_name + arg_node = argument_nodes.find { |a| a.name == arg_graphql_key } + if arg_node.nil? || (arg_node.value.is_a?(Language::Nodes::VariableIdentifier) && !variable_values.key?(arg_node.value.name)) + if argument_definition.default_value? + arg_value = value_from_ast(argument_definition.default_value, argument_definition.type) + argument_value(argument_values, arg_ruby_key, argument_definition, arg_value, nil, field_resolve_step) + end + else + arg_value = value_from_ast(arg_node.value, argument_definition.type) + argument_value(argument_values, arg_ruby_key, argument_definition, arg_value, nil, field_resolve_step) + end + rescue GraphQL::RuntimeError => exec_err + errors ||= [] + errors << exec_err + end + + return argument_values, errors + end + + private + + def variable_value(value, type) + if type.non_null? + type = type.of_type + end + + if value.is_a?(Language::Nodes::Enum) + value = value.name + end + + if value.nil? + nil + elsif type.list? + inner_type = type.of_type + if value.is_a?(Array) + value.map { |v| variable_value(v, inner_type) }.freeze + else + [variable_value(value, inner_type)].freeze + end + elsif type.kind.input_object? + coerced_obj = {} + + if value.is_a?(Hash) + @query.types.arguments(type).each do |arg| + arg_key = arg.keyword + if value.key?(arg.graphql_name) + arg_value = value[arg.graphql_name] + elsif value.key?(sym_name = arg.graphql_name.to_sym) + arg_value = value[sym_name] + elsif arg.default_value? + coerced_obj[arg_key] = arg.default_value + next + else + next + end + + if arg_value.nil? && arg.replace_null_with_default? + arg_value = arg.default_value + end + + coerced_obj[arg_key] = variable_value(arg_value, arg.type) + end + else + @query.types.arguments(type).each do |arg| + arg_key = arg.keyword + arg_name = arg.graphql_name + if (v_node = value.arguments.find { |a| a.name == arg_name }) # rubocop:disable Development/ContextIsPassedCop + arg_value = v_node.value + coerced_obj[arg_key] = if arg_value.nil? && arg.replace_null_with_default? + arg.default_value + else + variable_value(arg_value, arg.type) + end + elsif arg.default_value? + coerced_obj[arg_key] = arg.default_value + else + # Nothing + end + end + end + + coerced_obj + elsif type.kind.leaf? + type.coerce_input(value, @query.context) + else + raise GraphQL::Error, "Unexpected input type: #{type.graphql_name}." + end + end + + def argument_value(argument_values, argument_key, argument_definition, arg_value, override_type, field_resolve_step) + treat_as_type = override_type || argument_definition.type + if treat_as_type.non_null? + if arg_value.nil? + treat_as_type.coerce_input(arg_value, @query.context) + end + treat_as_type = treat_as_type.of_type + end + + if arg_value.nil? && argument_definition.replace_null_with_default? + arg_value = argument_definition.default_value + end + + if treat_as_type.kind.list? && !arg_value.nil? + inner_t = treat_as_type.of_type + arg_value = if arg_value.is_a?(Array) + values = Array.new(arg_value.size) + arg_value.each_with_index { |inner_v, idx| argument_value(values, idx, argument_definition, inner_v, inner_t, field_resolve_step)} + values + else + values = [nil] + argument_value(values, 0, argument_definition, arg_value, inner_t, field_resolve_step) + values + end + end + + if arg_value && treat_as_type.kind.input_object? + arg_defns = @query.types.arguments(treat_as_type) + new_arg_value = {} + arg_defns.each do |inner_arg_defn| + inner_arg_key = inner_arg_defn.keyword + if arg_value.is_a?(Hash) + if arg_value.key?(inner_arg_key) + inner_arg_value = arg_value[inner_arg_key] + argument_value(new_arg_value, inner_arg_key, inner_arg_defn, inner_arg_value, nil, field_resolve_step) + end + else + inner_arg_name = inner_arg_defn.graphql_name + inner_arg_value = arg_value.arguments.find { |a| a.name == inner_arg_name } # rubocop:disable Development/ContextIsPassedCop + if inner_arg_value + argument_value(new_arg_value, inner_arg_key, inner_arg_defn, inner_arg_value, nil, field_resolve_step) + end + end + end + arg_value = treat_as_type.new(nil, ruby_kwargs: new_arg_value, context: @query.context, defaults_used: nil) + end + + if override_type.nil? # only on root arguments, not list elements + arg_value = begin + argument_definition.prepare_value(nil, arg_value, context: @query.context) + rescue StandardError => err + @runner.schema.handle_or_reraise(@query.context, err, object: nil, arguments: argument_values, field: field_resolve_step&.field_definition) + end + end + + if field_resolve_step && arg_value && override_type.nil? && argument_definition.loads + field_defn = field_resolve_step.field_definition + load_receiver = if (r = field_defn.resolver) + r.new(field: field_defn, context: @query.context, object: nil) + else + field_defn + end + ps = field_resolve_step.pending_steps ||= [] + + if argument_definition.type.list? + results = Array.new(arg_value.size, nil) + argument_values[argument_key] = results + arg_value.each_with_index do |inner_v, idx| + loads_step = LoadArgumentStep.new( + field_resolve_step: field_resolve_step, + load_receiver: load_receiver, + argument_value: inner_v, + argument_definition: argument_definition, + arguments: results, + argument_key: idx, + ) + ps.push(loads_step) + @runner.add_step(loads_step) + end + else + loads_step = LoadArgumentStep.new( + field_resolve_step: field_resolve_step, + load_receiver: load_receiver, + argument_value: arg_value, + argument_definition: argument_definition, + arguments: argument_values, + argument_key: argument_key, + ) + ps.push(loads_step) + @runner.add_step(loads_step) + end + else + argument_values[argument_key] = arg_value + end + nil + end + + def value_from_ast(value_node, type) + if type.non_null? + type = type.of_type + end + + if value_node.nil? + nil + elsif value_node.is_a?(GraphQL::Language::Nodes::VariableIdentifier) + variable_values[value_node.name] + elsif type.list? + inner_type = type.of_type + if value_node.is_a?(Array) + coerced_items = value_node.map do |inner_value_node| + value_from_ast(inner_value_node, inner_type) + end + coerced_items.freeze + elsif value_node.is_a?(Language::Nodes::NullValue) + nil + else + item_value = value_from_ast(value_node, inner_type) + [item_value].freeze + end + elsif type.kind.input_object? + coerced_obj = {} + # TODO manually handle NullValue here? + if value_node.is_a?(Hash) + @query.types.arguments(type).each do |arg| + arg_value = value_node[arg.keyword] + arg_key = arg.keyword + if arg_value.nil? + if arg.default_value? + coerced_obj[arg_key] = arg.default_value + end + next + end + + coerced_obj[arg_key] = value_from_ast(arg_value, arg.type) + end + else + arg_nodes_by_name = value_node.arguments.each_with_object({}) do |arg_node, acc| # rubocop:disable Development/ContextIsPassedCop + acc[arg_node.name] = arg_node + end + + @query.types.arguments(type).each do |arg| + arg_node = arg_nodes_by_name[arg.graphql_name] + arg_key = arg.keyword + if arg_node.nil? || (arg_node.value.is_a?(Language::Nodes::VariableIdentifier) && !variable_values.key?(arg_node.value.name)) + if arg.default_value? + coerced_obj[arg_key] = arg.default_value + end + next + end + + arg_value = value_from_ast(arg_node.value, arg.type) + coerced_obj[arg_key] = arg_value + end + end + + + coerced_obj + elsif type.kind.leaf? + if value_node.is_a?(Language::Nodes::AbstractNode) || value_node.is_a?(Array) + value_node = coerce_untyped_input(value_node) + end + + begin + type.coerce_input(value_node, @query.context) + rescue GraphQL::UnauthorizedEnumValueError => enum_err + @runner.schema.unauthorized_object(enum_err) + end + else + raise "Unexpected input type: #{type.to_type_signature}." + end + end + + private + + def coerce_untyped_input(input_value) + case input_value + when Language::Nodes::AbstractNode + case input_value + when Language::Nodes::NullValue + nil + when Language::Nodes::Enum + input_value.name + when Language::Nodes::InputObject + value_h = {} + input_value.arguments.each do |arg| # rubocop:disable Development/ContextIsPassedCop + value_h[arg.name] = coerce_untyped_input(arg.value) + end + value_h + else + raise "Unhandled untyped input AST node: #{input_value.class}" + end + when Array + input_value.map { |v| coerce_untyped_input(v) } + else + input_value + end + end + end + end +end diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb index 2314d31a579..cafdafa756b 100644 --- a/lib/graphql/execution/interpreter.rb +++ b/lib/graphql/execution/interpreter.rb @@ -11,98 +11,136 @@ module GraphQL module Execution class Interpreter - def initialize - end + class << self + # Used internally to signal that the query shouldn't be executed + # @api private + NO_OPERATION = GraphQL::EmptyObjects::EMPTY_HASH - # Support `Executor` :S - def execute(_operation, _root_type, query) - runtime = evaluate(query) - sync_lazies(query: query) - runtime.final_result - end + # @param schema [GraphQL::Schema] + # @param queries [Array] + # @param context [Hash] + # @param max_complexity [Integer, nil] + # @return [Array] One result per query + def run_all(schema, query_options, context: {}, max_complexity: schema.max_complexity) + queries = query_options.map do |opts| + query = case opts + when Hash + schema.query_class.new(schema, nil, **opts) + when GraphQL::Query, GraphQL::Query::Partial + opts + else + raise "Expected Hash or GraphQL::Query, not #{opts.class} (#{opts.inspect})" + end + query + end - def self.use(schema_class) - if schema_class.interpreter? - definition_line = caller(2, 1).first - GraphQL::Deprecation.warn("GraphQL::Execution::Interpreter is now the default; remove `use GraphQL::Execution::Interpreter` from the schema definition (#{definition_line})") - else - schema_class.query_execution_strategy(self) - schema_class.mutation_execution_strategy(self) - schema_class.subscription_execution_strategy(self) - schema_class.add_subscription_extension_if_necessary - end - end + return GraphQL::EmptyObjects::EMPTY_ARRAY if queries.empty? - def self.begin_multiplex(multiplex) - # Since this is basically the batching context, - # share it for a whole multiplex - multiplex.context[:interpreter_instance] ||= self.new - end + multiplex = Execution::Multiplex.new(schema: schema, queries: queries, context: context, max_complexity: max_complexity) + trace = multiplex.current_trace + Fiber[:__graphql_current_multiplex] = multiplex + trace.execute_multiplex(multiplex: multiplex) do + schema = multiplex.schema + queries = multiplex.queries + multiplex_analyzers = schema.multiplex_analyzers + if multiplex.max_complexity + multiplex_analyzers += [GraphQL::Analysis::MaxQueryComplexity] + end - def self.begin_query(query, multiplex) - # The batching context is shared by the multiplex, - # so fetch it out and use that instance. - interpreter = - query.context.namespace(:interpreter)[:interpreter_instance] = - multiplex.context[:interpreter_instance] - interpreter.evaluate(query) - query - end + trace.begin_analyze_multiplex(multiplex, multiplex_analyzers) + schema.analysis_engine.analyze_multiplex(multiplex, multiplex_analyzers) + trace.end_analyze_multiplex(multiplex, multiplex_analyzers) - def self.finish_multiplex(_results, multiplex) - interpreter = multiplex.context[:interpreter_instance] - interpreter.sync_lazies(multiplex: multiplex) - end + begin + # Since this is basically the batching context, + # share it for a whole multiplex + multiplex.context[:interpreter_instance] ||= multiplex.schema.query_execution_strategy(deprecation_warning: false).new + # Do as much eager evaluation of the query as possible + results = [] + queries.each_with_index do |query, idx| + multiplex.dataloader.append_job { + operation = query.selected_operation + result = if operation.nil? || !query.valid? || !query.context.errors.empty? + NO_OPERATION + else + begin + # Although queries in a multiplex _share_ an Interpreter instance, + # they also have another item of state, which is private to that query + # in particular, assign it here: + runtime = Runtime.new(query: query) + query.context.namespace(:interpreter_runtime)[:runtime] = runtime + if query.subscription? && !query.subscription_update? + schema.subscriptions.initialize_subscriptions(query) + end + query.current_trace.execute_query(query: query) do + runtime.run_eager + end + rescue GraphQL::ExecutionError => err + query.context.errors << err + end + end + results[idx] = result + } + end - def self.finish_query(query, _multiplex) - { - "data" => query.context.namespace(:interpreter)[:runtime].final_result - } - end + multiplex.dataloader.run(trace_query_lazy: multiplex) - # Run the eager part of `query` - # @return {Interpreter::Runtime} - def evaluate(query) - # Although queries in a multiplex _share_ an Interpreter instance, - # they also have another item of state, which is private to that query - # in particular, assign it here: - runtime = Runtime.new(query: query) - query.context.namespace(:interpreter)[:runtime] = runtime + # Then, find all errors and assign the result to the query object + results.each_with_index do |data_result, idx| + query = queries[idx] + # Assign the result so that it can be accessed in instrumentation + query.result_values = if data_result.equal?(NO_OPERATION) + if !query.valid? || !query.context.errors.empty? + # A bit weird, but `Query#static_errors` _includes_ `query.context.errors` + { "errors" => query.static_errors.map(&:to_h) } + else + data_result + end + else + if query.subscription? + schema.subscriptions.finish_subscriptions(query) + end + result = {} - query.trace("execute_query", {query: query}) do - runtime.run_eager - end + if !query.context.errors.empty? + error_result = query.context.errors.map(&:to_h) + result["errors"] = error_result + end - runtime - end + result["data"] = query.context.namespace(:interpreter_runtime)[:runtime].final_result - # Run the lazy part of `query` or `multiplex`. - # @return [void] - def sync_lazies(query: nil, multiplex: nil) - tracer = query || multiplex - if query.nil? && multiplex.queries.length == 1 - query = multiplex.queries[0] - end - queries = multiplex ? multiplex.queries : [query] - final_values = queries.map do |query| - runtime = query.context.namespace(:interpreter)[:runtime] - # it might not be present if the query has an error - runtime ? runtime.final_result : nil - end - final_values.compact! - tracer.trace("execute_query_lazy", {multiplex: multiplex, query: query}) do - Interpreter::Resolve.resolve_all(final_values, multiplex.dataloader) - end - queries.each do |query| - runtime = query.context.namespace(:interpreter)[:runtime] - if runtime - runtime.delete_interpreter_context(:current_path) - runtime.delete_interpreter_context(:current_field) - runtime.delete_interpreter_context(:current_object) - runtime.delete_interpreter_context(:current_arguments) + result + end + if query.context.namespace?(:__query_result_extensions__) + query.result_values["extensions"] = query.context.namespace(:__query_result_extensions__) + end + # Get the Query::Result, not the Hash + results[idx] = query.result + end + + results + rescue SystemStackError => err + queries.map do |query| + schema.query_stack_error(query, err) + query.result_values ||= { "errors" => query.context.errors.map(&:to_h) } + query.result + end + rescue Exception + # TODO rescue at a higher level so it will catch errors in analysis, too + # Assign values here so that the query's `@executed` becomes true + queries.map { |q| q.result_values ||= {} } + raise + ensure + Fiber[:__graphql_current_multiplex] = nil + queries.map { |query| + runtime = query.context.namespace(:interpreter_runtime)[:runtime] + if runtime + runtime.delete_all_interpreter_context + end + } + end end end - nil end class ListResultFailedError < GraphQL::Error diff --git a/lib/graphql/execution/interpreter/argument_value.rb b/lib/graphql/execution/interpreter/argument_value.rb index 4ca37977c00..ca7845b05ba 100644 --- a/lib/graphql/execution/interpreter/argument_value.rb +++ b/lib/graphql/execution/interpreter/argument_value.rb @@ -6,15 +6,19 @@ class Interpreter # A container for metadata regarding arguments present in a GraphQL query. # @see Interpreter::Arguments#argument_values for a hash of these objects. class ArgumentValue - def initialize(definition:, value:, default_used:) + def initialize(definition:, value:, original_value:, default_used:) @definition = definition @value = value + @original_value = original_value @default_used = default_used end # @return [Object] The Ruby-ready value for this Argument attr_reader :value + # @return [Object] The value of this argument _before_ `prepare` is applied. + attr_reader :original_value + # @return [GraphQL::Schema::Argument] The definition instance for this argument attr_reader :definition diff --git a/lib/graphql/execution/interpreter/arguments.rb b/lib/graphql/execution/interpreter/arguments.rb index 8009aef33ff..4da23f25d4a 100644 --- a/lib/graphql/execution/interpreter/arguments.rb +++ b/lib/graphql/execution/interpreter/arguments.rb @@ -59,7 +59,7 @@ def empty? @empty end - def_delegators :keyword_arguments, :key?, :[], :fetch, :keys, :each, :values + def_delegators :keyword_arguments, :key?, :[], :fetch, :keys, :each, :values, :size, :to_h def_delegators :argument_values, :each_value def inspect @@ -80,7 +80,7 @@ def merge_extras(extra_args) ) end - NO_ARGS = {}.freeze + NO_ARGS = GraphQL::EmptyObjects::EMPTY_HASH EMPTY = self.new(argument_values: nil, keyword_arguments: NO_ARGS).freeze end end diff --git a/lib/graphql/execution/interpreter/arguments_cache.rb b/lib/graphql/execution/interpreter/arguments_cache.rb index 041714e2b2e..ec5482d865a 100644 --- a/lib/graphql/execution/interpreter/arguments_cache.rb +++ b/lib/graphql/execution/interpreter/arguments_cache.rb @@ -7,55 +7,55 @@ class ArgumentsCache def initialize(query) @query = query @dataloader = query.context.dataloader - @storage = Hash.new do |h, ast_node| - h[ast_node] = Hash.new do |h2, arg_owner| - h2[arg_owner] = Hash.new do |h3, parent_object| - dataload_for(ast_node, arg_owner, parent_object) do |kwarg_arguments| - h3[parent_object] = @query.schema.after_lazy(kwarg_arguments) do |resolved_args| - h3[parent_object] = resolved_args - end - end - - if !h3.key?(parent_object) - # TODO should i bother putting anything here? - h3[parent_object] = NO_ARGUMENTS - else - h3[parent_object] - end - end + @storage = Hash.new do |h, argument_owner| + h[argument_owner] = if argument_owner.arguments_statically_coercible? + shared_values_cache = {} + Hash.new do |h2, ignored_parent_object| + h2[ignored_parent_object] = shared_values_cache + end.compare_by_identity + else + Hash.new do |h2, parent_object| + h2[parent_object] = {}.compare_by_identity + end.compare_by_identity end - end + end.compare_by_identity end def fetch(ast_node, argument_owner, parent_object) - # If any jobs were enqueued, run them now, - # since this might have been called outside of execution. - # (The jobs are responsible for updating `result` in-place.) - @dataloader.run_isolated do - @storage[ast_node][argument_owner][parent_object] + # This runs eagerly if no block is given + @storage[argument_owner][parent_object][ast_node] ||= begin + args_hash = self.class.prepare_args_hash(@query, ast_node) + kwarg_arguments = argument_owner.coerce_arguments(parent_object, args_hash, @query.context) + @query.after_lazy(kwarg_arguments) do |resolved_args| + @storage[argument_owner][parent_object][ast_node] = resolved_args + end end - # Ack, the _hash_ is updated, but the key is eventually - # overridden with an immutable arguments instance. - # The first call queues up the job, - # then this call fetches the result. - # TODO this should be better, find a solution - # that works with merging the runtime.rb code - @storage[ast_node][argument_owner][parent_object] + end + + def cached_arguments_for(ast_node, argument_owner) + @storage[argument_owner][nil][ast_node] end # @yield [Interpreter::Arguments, Lazy] The finally-loaded arguments def dataload_for(ast_node, argument_owner, parent_object, &block) # First, normalize all AST or Ruby values to a plain Ruby hash - args_hash = self.class.prepare_args_hash(@query, ast_node) - argument_owner.coerce_arguments(parent_object, args_hash, @query.context, &block) + arg_storage = @storage[argument_owner][parent_object] + if (args = arg_storage[ast_node]) + yield(args) + else + args_hash = self.class.prepare_args_hash(@query, ast_node) + argument_owner.coerce_arguments(parent_object, args_hash, @query.context) do |resolved_args| + arg_storage[ast_node] = resolved_args + yield(resolved_args) + end + end nil end private - NO_ARGUMENTS = {}.freeze - - NO_VALUE_GIVEN = Object.new + NO_ARGUMENTS = GraphQL::EmptyObjects::EMPTY_HASH + NO_VALUE_GIVEN = NOT_CONFIGURED def self.prepare_args_hash(query, ast_arg_or_hash_or_value) case ast_arg_or_hash_or_value @@ -71,11 +71,11 @@ def self.prepare_args_hash(query, ast_arg_or_hash_or_value) when Array ast_arg_or_hash_or_value.map { |v| prepare_args_hash(query, v) } when GraphQL::Language::Nodes::Field, GraphQL::Language::Nodes::InputObject, GraphQL::Language::Nodes::Directive - if ast_arg_or_hash_or_value.arguments.empty? + if ast_arg_or_hash_or_value.arguments.empty? # rubocop:disable Development/ContextIsPassedCop -- AST-related return NO_ARGUMENTS end args_hash = {} - ast_arg_or_hash_or_value.arguments.each do |arg| + ast_arg_or_hash_or_value.arguments.each do |arg| # rubocop:disable Development/ContextIsPassedCop -- AST-related v = prepare_args_hash(query, arg.value) if v != NO_VALUE_GIVEN args_hash[arg.name] = v diff --git a/lib/graphql/execution/interpreter/handles_raw_value.rb b/lib/graphql/execution/interpreter/handles_raw_value.rb index 21f62eba964..2b34f2c9c89 100644 --- a/lib/graphql/execution/interpreter/handles_raw_value.rb +++ b/lib/graphql/execution/interpreter/handles_raw_value.rb @@ -5,6 +5,12 @@ module Execution class Interpreter # Wrapper for raw values class RawValue + include GraphQL::Execution::Finalizer + + def finalize_graphql_result(query, result_data, result_key) + result_data[result_key] = @object + end + def initialize(obj = nil) @object = obj end diff --git a/lib/graphql/execution/interpreter/resolve.rb b/lib/graphql/execution/interpreter/resolve.rb index b98dcb90a32..102ceacb3cc 100644 --- a/lib/graphql/execution/interpreter/resolve.rb +++ b/lib/graphql/execution/interpreter/resolve.rb @@ -6,25 +6,42 @@ class Interpreter module Resolve # Continue field results in `results` until there's nothing else to continue. # @return [void] + # @deprecated Call `dataloader.run` instead def self.resolve_all(results, dataloader) + warn "#{self}.#{__method__} is deprecated; Use `dataloader.run` instead.#{caller(1, 5).map { |l| "\n #{l}"}.join}" dataloader.append_job { resolve(results, dataloader) } nil end - # After getting `results` back from an interpreter evaluation, - # continue it until you get a response-ready Ruby value. - # - # `results` is one level of _depth_ of a query or multiplex. - # - # Resolve all lazy values in that depth before moving on - # to the next level. - # - # It's assumed that the lazies will - # return {Lazy} instances if there's more work to be done, - # or return {Hash}/{Array} if the query should be continued. - # - # @return [void] + # @deprecated Call `dataloader.run` instead + def self.resolve_each_depth(lazies_at_depth, dataloader) + warn "#{self}.#{__method__} is deprecated; Use `dataloader.run` instead.#{caller(1, 5).map { |l| "\n #{l}"}.join}" + + smallest_depth = nil + lazies_at_depth.each_key do |depth_key| + smallest_depth ||= depth_key + if depth_key < smallest_depth + smallest_depth = depth_key + end + end + + if smallest_depth + lazies = lazies_at_depth.delete(smallest_depth) + if !lazies.empty? + lazies.each do |l| + dataloader.append_job { l.value } + end + # Run lazies _and_ dataloader, see if more are enqueued + dataloader.run + resolve_each_depth(lazies_at_depth, dataloader) + end + end + nil + end + + # @deprecated Call `dataloader.run` instead def self.resolve(results, dataloader) + warn "#{self}.#{__method__} is deprecated; Use `dataloader.run` instead.#{caller(1, 5).map { |l| "\n #{l}"}.join}" # There might be pending jobs here that _will_ write lazies # into the result hash. We should run them out, so we # can be sure that all lazies will be present in the result hashes. @@ -32,7 +49,7 @@ def self.resolve(results, dataloader) # these approaches. dataloader.run next_results = [] - while results.any? + while !results.empty? result_value = results.shift if result_value.is_a?(Runtime::GraphQLResultHash) || result_value.is_a?(Hash) results.concat(result_value.values) @@ -58,7 +75,14 @@ def self.resolve(results, dataloader) end end - if next_results.any? + if !next_results.empty? + # Any pending data loader jobs may populate the + # resutl arrays or result hashes accumulated in + # `next_results``. Run those **to completion** + # before continuing to resolve `next_results`. + # (Just `.append_job` doesn't work if any pending + # jobs require multiple passes.) + dataloader.run dataloader.append_job { resolve(next_results, dataloader) } end diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb index 0734c0112c7..029c00ac931 100644 --- a/lib/graphql/execution/interpreter/runtime.rb +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require "graphql/execution/interpreter/runtime/graphql_result" module GraphQL module Execution @@ -8,126 +9,21 @@ class Interpreter # # @api private class Runtime - - module GraphQLResult - attr_accessor :graphql_dead, :graphql_parent, :graphql_result_name - # Although these are used by only one of the Result classes, - # it's handy to have the methods implemented on both (even though they just return `nil`) - # because it makes it easy to check if anything is assigned. - # @return [nil, Array] - attr_accessor :graphql_non_null_field_names - # @return [nil, true] - attr_accessor :graphql_non_null_list_items - - # @return [Hash] Plain-Ruby result data (`@graphql_metadata` contains Result wrapper objects) - attr_accessor :graphql_result_data - end - - class GraphQLResultHash - def initialize - # Jump through some hoops to avoid creating this duplicate hash if at all possible. - @graphql_metadata = nil - @graphql_result_data = {} - end - - include GraphQLResult - - attr_accessor :graphql_merged_into - - def []=(key, value) - # This is a hack. - # Basically, this object is merged into the root-level result at some point. - # But the problem is, some lazies are created whose closures retain reference to _this_ - # object. When those lazies are resolved, they cause an update to this object. - # - # In order to return a proper top-level result, we have to update that top-level result object. - # In order to return a proper partial result (eg, for a directive), we have to update this object, too. - # Yowza. - if (t = @graphql_merged_into) - t[key] = value - end - - if value.respond_to?(:graphql_result_data) - @graphql_result_data[key] = value.graphql_result_data - # If we encounter some part of this response that requires metadata tracking, - # then create the metadata hash if necessary. It will be kept up-to-date after this. - (@graphql_metadata ||= @graphql_result_data.dup)[key] = value - else - @graphql_result_data[key] = value - # keep this up-to-date if it's been initialized - @graphql_metadata && @graphql_metadata[key] = value - end - - value - end - - def delete(key) - @graphql_metadata && @graphql_metadata.delete(key) - @graphql_result_data.delete(key) - end - - def each - (@graphql_metadata || @graphql_result_data).each { |k, v| yield(k, v) } - end - - def values - (@graphql_metadata || @graphql_result_data).values - end - - def key?(k) - @graphql_result_data.key?(k) - end - - def [](k) - (@graphql_metadata || @graphql_result_data)[k] - end - end - - class GraphQLResultArray - include GraphQLResult - + class CurrentState def initialize - # Avoid this duplicate allocation if possible - - # but it will require some work to keep it up-to-date if it's created. - @graphql_metadata = nil - @graphql_result_data = [] - end - - def graphql_skip_at(index) - # Mark this index as dead. It's tricky because some indices may already be storing - # `Lazy`s. So the runtime is still holding indexes _before_ skipping, - # this object has to coordinate incoming writes to account for any already-skipped indices. - @skip_indices ||= [] - @skip_indices << index - offset_by = @skip_indices.count { |skipped_idx| skipped_idx < index} - delete_at_index = index - offset_by - @graphql_metadata && @graphql_metadata.delete_at(delete_at_index) - @graphql_result_data.delete_at(delete_at_index) - end - - def []=(idx, value) - if @skip_indices - offset_by = @skip_indices.count { |skipped_idx| skipped_idx < idx } - idx -= offset_by - end - if value.respond_to?(:graphql_result_data) - @graphql_result_data[idx] = value.graphql_result_data - (@graphql_metadata ||= @graphql_result_data.dup)[idx] = value - else - @graphql_result_data[idx] = value - @graphql_metadata && @graphql_metadata[idx] = value - end - - value + @current_field = nil + @current_arguments = nil + @current_result_name = nil + @current_result = nil + @was_authorized_by_scope_items = nil end - def values - (@graphql_metadata || @graphql_result_data) + def current_object + @current_result.graphql_application_value end - end - class GraphQLSelectionSet < Hash - attr_accessor :graphql_directives + attr_accessor :current_result, :current_result_name, + :current_arguments, :current_field, :was_authorized_by_scope_items end # @return [GraphQL::Query] @@ -141,140 +37,190 @@ class GraphQLSelectionSet < Hash def initialize(query:) @query = query + @current_trace = query.current_trace @dataloader = query.multiplex.dataloader @schema = query.schema @context = query.context - @multiplex_context = query.multiplex.context - @interpreter_context = @context.namespace(:interpreter) - @response = GraphQLResultHash.new + @response = nil # Identify runtime directives by checking which of this schema's directives have overridden `def self.resolve` @runtime_directive_names = [] noop_resolve_owner = GraphQL::Schema::Directive.singleton_class - schema.directives.each do |name, dir_defn| + @schema_directives = schema.directives + @schema_directives.each do |name, dir_defn| if dir_defn.method(:resolve).owner != noop_resolve_owner @runtime_directive_names << name end end - # A cache of { Class => { String => Schema::Field } } - # Which assumes that MyObject.get_field("myField") will return the same field - # during the lifetime of a query - @fields_cache = Hash.new { |h, k| h[k] = {} } # { Class => Boolean } - @lazy_cache = {} + @lazy_cache = {}.compare_by_identity end def final_result - @response && @response.graphql_result_data + @response.respond_to?(:graphql_result_data) ? @response.graphql_result_data : @response end def inspect "#<#{self.class.name} response=#{@response.inspect}>" end - def tap_or_each(obj_or_array) - if obj_or_array.is_a?(Array) - obj_or_array.each do |item| - yield(item, true) - end - else - yield(obj_or_array, false) - end - end - - # This _begins_ the execution. Some deferred work - # might be stored up in lazies. # @return [void] def run_eager - root_operation = query.selected_operation - root_op_type = root_operation.operation_type || "query" - root_type = schema.root_type_for_operation(root_op_type) - path = [] - set_all_interpreter_context(query.root_value, nil, nil, path) - object_proxy = authorized_new(root_type, query.root_value, context) - object_proxy = schema.sync_lazy(object_proxy) - - if object_proxy.nil? - # Root .authorized? returned false. - @response = nil + root_type = query.root_type + case query + when GraphQL::Query + ast_node = query.selected_operation + selections = ast_node.selections + object = query.root_value + is_eager = ast_node.operation_type == "mutation" + base_path = nil + when GraphQL::Query::Partial + ast_node = query.ast_nodes.first + selections = query.ast_nodes.map(&:selections).inject(&:+) + object = query.object + is_eager = false + base_path = query.path else - resolve_with_directives(object_proxy, root_operation.directives) do # execute query level directives - gathered_selections = gather_selections(object_proxy, root_type, root_operation.selections) - # This is kind of a hack -- `gathered_selections` is an Array if any of the selections - # require isolation during execution (because of runtime directives). In that case, - # make a new, isolated result hash for writing the result into. (That isolated response - # is eventually merged back into the main response) - # - # Otherwise, `gathered_selections` is a hash of selections which can be - # directly evaluated and the results can be written right into the main response hash. - tap_or_each(gathered_selections) do |selections, is_selection_array| - if is_selection_array - selection_response = GraphQLResultHash.new - final_response = @response - else - selection_response = @response - final_response = nil - end + raise ArgumentError, "Unexpected Runnable, can't execute: #{query.class} (#{query.inspect})" + end + object = schema.sync_lazy(object) # TODO test query partial with lazy root object + runtime_state = get_current_runtime_state + case root_type.kind.name + when "OBJECT" + object_proxy = root_type.wrap(object, context) + object_proxy = schema.sync_lazy(object_proxy) + if object_proxy.nil? + @response = nil + else + @response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, is_eager, ast_node, nil, nil) + @response.base_path = base_path + runtime_state.current_result = @response + call_method_on_directives(:resolve, object, ast_node.directives) do + each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| + @response.ordered_result_keys ||= ordered_result_keys + if is_selection_array + selection_response = GraphQLResultHash.new(nil, root_type, object_proxy, nil, false, selections, is_eager, ast_node, nil, nil) + selection_response.ordered_result_keys = ordered_result_keys + final_response = @response + else + selection_response = @response + final_response = nil + end - @dataloader.append_job { - set_all_interpreter_context(query.root_value, nil, nil, path) - resolve_with_directives(object_proxy, selections.graphql_directives) do + @dataloader.append_job { evaluate_selections( - path, - context.scoped_context, - object_proxy, - root_type, - root_op_type == "mutation", selections, selection_response, final_response, + nil, ) - end - } + } + end end end - end - delete_interpreter_context(:current_path) - delete_interpreter_context(:current_field) - delete_interpreter_context(:current_object) - delete_interpreter_context(:current_arguments) - nil - end - - # @return [void] - def deep_merge_selection_result(from_result, into_result) - from_result.each do |key, value| - if !into_result.key?(key) - into_result[key] = value + when "LIST" + inner_type = root_type.unwrap + case inner_type.kind.name + when "SCALAR", "ENUM" + result_name = ast_node.alias || ast_node.name + field_defn = query.field_definition + owner_type = field_defn.owner + selection_result = GraphQLResultHash.new(nil, owner_type, nil, nil, false, EmptyObjects::EMPTY_ARRAY, false, ast_node, nil, nil) + selection_result.base_path = base_path + selection_result.ordered_result_keys = [result_name] + runtime_state = get_current_runtime_state + runtime_state.current_result = selection_result + runtime_state.current_result_name = result_name + continue_value = continue_value(object, field_defn, false, ast_node, result_name, selection_result) + if HALT != continue_value + continue_field(continue_value, owner_type, field_defn, root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists + end + @response = selection_result[result_name] else - case value - when GraphQLResultHash - deep_merge_selection_result(value, into_result[key]) - else - # We have to assume that, since this passed the `fields_will_merge` selection, - # that the old and new values are the same. - # There's no special handling of arrays because currently, there's no way to split the execution - # of a list over several concurrent flows. - into_result[key] = value + @response = GraphQLResultArray.new(nil, root_type, nil, nil, false, selections, false, ast_node, nil, nil) + @response.base_path = base_path + idx = nil + object.each do |inner_value| + idx ||= 0 + this_idx = idx + idx += 1 + @dataloader.append_job do + runtime_state.current_result_name = this_idx + runtime_state.current_result = @response + continue_field( + inner_value, root_type, nil, inner_type, nil, @response.graphql_selections, false, object_proxy, + nil, this_idx, @response, false, runtime_state + ) + end end end + when "SCALAR", "ENUM" + result_name = ast_node.alias || ast_node.name + field_defn = query.field_definition + owner_type = field_defn.owner + selection_result = GraphQLResultHash.new(nil, owner_type, nil, nil, false, EmptyObjects::EMPTY_ARRAY, false, ast_node, nil, nil) + selection_result.ordered_result_keys = [result_name] + selection_result.base_path = base_path + runtime_state = get_current_runtime_state + runtime_state.current_result = selection_result + runtime_state.current_result_name = result_name + continue_value = continue_value(object, field_defn, false, ast_node, result_name, selection_result) + if HALT != continue_value + continue_field(continue_value, owner_type, field_defn, query.root_type, ast_node, nil, false, nil, nil, result_name, selection_result, false, runtime_state) # rubocop:disable Metrics/ParameterLists + end + @response = selection_result[result_name] + when "UNION", "INTERFACE" + resolved_type, _resolved_obj = resolve_type(root_type, object) + resolved_type = schema.sync_lazy(resolved_type) + object_proxy = resolved_type.wrap(object, context) + object_proxy = schema.sync_lazy(object_proxy) + @response = GraphQLResultHash.new(nil, resolved_type, object_proxy, nil, false, selections, false, query.ast_nodes.first, nil, nil) + @response.base_path = base_path + each_gathered_selections(@response) do |selections, is_selection_array, ordered_result_keys| + @response.ordered_result_keys ||= ordered_result_keys + if is_selection_array == true + raise "This isn't supported yet" + end + + @dataloader.append_job { + evaluate_selections( + selections, + @response, + nil, + runtime_state, + ) + } + end + else + raise "Invariant: unsupported type kind for partial execution: #{root_type.kind.inspect} (#{root_type})" end - from_result.graphql_merged_into = into_result nil end - def gather_selections(owner_object, owner_type, selections, selections_to_run = nil, selections_by_name = GraphQLSelectionSet.new) - selections.each do |node| - # Skip gathering this if the directive says so - if !directives_include?(node, owner_object, owner_type) - next + def each_gathered_selections(response_hash) + ordered_result_keys = [] + gathered_selections = gather_selections(response_hash, response_hash.graphql_application_value, response_hash.graphql_result_type, response_hash.graphql_selections, nil, {}, ordered_result_keys) + ordered_result_keys.uniq! + if gathered_selections.is_a?(Array) + gathered_selections.each do |item| + yield(item, true, ordered_result_keys) end + else + yield(gathered_selections, false, ordered_result_keys) + end + end + def gather_selections(graphql_response, owner_object, owner_type, selections, selections_to_run, selections_by_name, ordered_result_keys) + selections.each do |node| if node.is_a?(GraphQL::Language::Nodes::Field) response_key = node.alias || node.name + if !directives_include?(node, owner_object, owner_type, graphql_response, response_key) + next + end + ordered_result_keys << response_key selections = selections_by_name[response_key] # if there was already a selection of this field, # use an array to hold all selections, - # otherise, use the single node to represent the selection + # otherwise, use the single node to represent the selection if selections # This field was already selected at least once, # add this node to the list of selections @@ -286,10 +232,13 @@ def gather_selections(owner_object, owner_type, selections, selections_to_run = selections_by_name[response_key] = node end else + if !directives_include?(node, owner_object, owner_type, graphql_response, nil) + next + end # This is an InlineFragment or a FragmentSpread - if @runtime_directive_names.any? && node.directives.any? { |d| @runtime_directive_names.include?(d.name) } - next_selections = GraphQLSelectionSet.new - next_selections.graphql_directives = node.directives + if !@runtime_directive_names.empty? && node.directives.any? { |d| @runtime_directive_names.include?(d.name) } + next_selections = {} + next_selections[:graphql_directives] = node.directives if selections_to_run selections_to_run << next_selections else @@ -304,27 +253,28 @@ def gather_selections(owner_object, owner_type, selections, selections_to_run = case node when GraphQL::Language::Nodes::InlineFragment if node.type - type_defn = schema.get_type(node.type.name) + type_defn = query.types.type(node.type.name) - # Faster than .map{}.include?() - query.warden.possible_types(type_defn).each do |t| - if t == owner_type - gather_selections(owner_object, owner_type, node.selections, selections_to_run, next_selections) - break + if query.types.possible_types(type_defn).include?(owner_type) + result = gather_selections(graphql_response, owner_object, owner_type, node.selections, selections_to_run, next_selections, ordered_result_keys) + if !result.equal?(next_selections) + selections_to_run = result end end else # it's an untyped fragment, definitely continue - gather_selections(owner_object, owner_type, node.selections, selections_to_run, next_selections) + result = gather_selections(graphql_response, owner_object, owner_type, node.selections, selections_to_run, next_selections, ordered_result_keys) + if !result.equal?(next_selections) + selections_to_run = result + end end when GraphQL::Language::Nodes::FragmentSpread fragment_def = query.fragments[node.name] - type_defn = schema.get_type(fragment_def.type.name) - possible_types = query.warden.possible_types(type_defn) - possible_types.each do |t| - if t == owner_type - gather_selections(owner_object, owner_type, fragment_def.selections, selections_to_run, next_selections) - break + type_defn = query.types.type(fragment_def.type.name) + if query.types.possible_types(type_defn).include?(owner_type) + result = gather_selections(graphql_response, owner_object, owner_type, fragment_def.selections, selections_to_run, next_selections, ordered_result_keys) + if !result.equal?(next_selections) + selections_to_run = result end end else @@ -335,33 +285,49 @@ def gather_selections(owner_object, owner_type, selections, selections_to_run = selections_to_run || selections_by_name end - NO_ARGS = {}.freeze + NO_ARGS = GraphQL::EmptyObjects::EMPTY_HASH # @return [void] - def evaluate_selections(path, scoped_context, owner_object, owner_type, is_eager_selection, gathered_selections, selections_result, target_result) # rubocop:disable Metrics/ParameterLists - set_all_interpreter_context(owner_object, nil, nil, path) - - finished_jobs = 0 - enqueued_jobs = gathered_selections.size - gathered_selections.each do |result_name, field_ast_nodes_or_ast_node| - @dataloader.append_job { - evaluate_selection( - path, result_name, field_ast_nodes_or_ast_node, scoped_context, owner_object, owner_type, is_eager_selection, selections_result - ) - finished_jobs += 1 - if target_result && finished_jobs == enqueued_jobs - deep_merge_selection_result(selections_result, target_result) + def evaluate_selections(gathered_selections, selections_result, target_result, runtime_state) # rubocop:disable Metrics/ParameterLists + runtime_state ||= get_current_runtime_state + runtime_state.current_result_name = nil + runtime_state.current_result = selections_result + # This is a less-frequent case; use a fast check since it's often not there. + if (directives = gathered_selections[:graphql_directives]) + gathered_selections.delete(:graphql_directives) + end + + call_method_on_directives(:resolve, selections_result.graphql_application_value, directives) do + gathered_selections.each do |result_name, field_ast_nodes_or_ast_node| + # Field resolution may pause the fiber, + # so it wouldn't get to the `Resolve` call that happens below. + # So instead trigger a run from this outer context. + if selections_result.graphql_is_eager + @dataloader.clear_cache + @dataloader.run_isolated { + evaluate_selection( + result_name, field_ast_nodes_or_ast_node, selections_result + ) + @dataloader.clear_cache + } + else + @dataloader.append_job { + evaluate_selection( + result_name, field_ast_nodes_or_ast_node, selections_result + ) + } end - } + end + if target_result + selections_result.merge_into(target_result) + end + selections_result end - - selections_result end - attr_reader :progress_path - # @return [void] - def evaluate_selection(path, result_name, field_ast_nodes_or_ast_node, scoped_context, owner_object, owner_type, is_eager_field, selections_result) # rubocop:disable Metrics/ParameterLists + def evaluate_selection(result_name, field_ast_nodes_or_ast_node, selections_result) # rubocop:disable Metrics/ParameterLists + return if selections_result.graphql_dead # As a performance optimization, the hash key will be a `Node` if # there's only one selection of the field. But if there are multiple # selections of the field, it will be an Array of nodes @@ -373,65 +339,58 @@ def evaluate_selection(path, result_name, field_ast_nodes_or_ast_node, scoped_co ast_node = field_ast_nodes_or_ast_node end field_name = ast_node.name - field_defn = @fields_cache[owner_type][field_name] ||= owner_type.get_field(field_name) - is_introspection = false - if field_defn.nil? - field_defn = if owner_type == schema.query && (entry_point_field = schema.introspection_system.entry_point(name: field_name)) - is_introspection = true - entry_point_field - elsif (dynamic_field = schema.introspection_system.dynamic_field(name: field_name)) - is_introspection = true - dynamic_field - else - raise "Invariant: no field for #{owner_type}.#{field_name}" - end - end - return_type = field_defn.type - - next_path = path.dup - next_path << result_name - next_path.freeze + owner_type = selections_result.graphql_result_type + field_defn = query.types.field(owner_type, field_name) || raise(GraphQL::Error, "No field definition found for #{owner_type.graphql_name}.#{field_name} (at #{ast_node.position})") - # This seems janky, but we need to know - # the field's return type at this path in order - # to propagate `null` - if return_type.non_null? - (selections_result.graphql_non_null_field_names ||= []).push(result_name) - end # Set this before calling `run_with_directives`, so that the directive can have the latest path - set_all_interpreter_context(nil, field_defn, nil, next_path) + runtime_state = get_current_runtime_state + runtime_state.current_field = field_defn + runtime_state.current_result = selections_result + runtime_state.current_result_name = result_name - context.scoped_context = scoped_context - object = owner_object - - if is_introspection - object = authorized_new(field_defn.owner, object, context) + owner_object = selections_result.graphql_application_value + if field_defn.dynamic_introspection + owner_object = field_defn.owner.wrap(owner_object, context) end - total_args_count = field_defn.arguments.size - if total_args_count == 0 - kwarg_arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY - evaluate_selection_with_args(kwarg_arguments, field_defn, next_path, ast_node, field_ast_nodes, scoped_context, owner_type, object, is_eager_field, result_name, selections_result) + if !field_defn.any_arguments? + resolved_arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY + if field_defn.extras.size == 0 + evaluate_selection_with_resolved_keyword_args( + NO_ARGS, resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state + ) + else + evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state) + end else - # TODO remove all arguments(...) usages? - @query.arguments_cache.dataload_for(ast_node, field_defn, object) do |resolved_arguments| - evaluate_selection_with_args(resolved_arguments, field_defn, next_path, ast_node, field_ast_nodes, scoped_context, owner_type, object, is_eager_field, result_name, selections_result) + @query.arguments_cache.dataload_for(ast_node, field_defn, owner_object) do |resolved_arguments| + runtime_state = get_current_runtime_state # This might be in a different fiber + runtime_state.current_field = field_defn + runtime_state.current_arguments = resolved_arguments + runtime_state.current_result_name = result_name + runtime_state.current_result = selections_result + evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_object, result_name, selections_result, runtime_state) end end end - def evaluate_selection_with_args(kwarg_arguments, field_defn, next_path, ast_node, field_ast_nodes, scoped_context, owner_type, object, is_eager_field, result_name, selection_result) # rubocop:disable Metrics/ParameterLists - context.scoped_context = scoped_context - return_type = field_defn.type - after_lazy(kwarg_arguments, owner: owner_type, field: field_defn, path: next_path, ast_node: ast_node, scoped_context: context.scoped_context, owner_object: object, arguments: kwarg_arguments, result_name: result_name, result: selection_result) do |resolved_arguments| + def evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_nodes, object, result_name, selection_result, runtime_state) # rubocop:disable Metrics/ParameterLists + after_lazy(arguments, field: field_defn, ast_node: ast_node, owner_object: object, arguments: arguments, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |resolved_arguments, runtime_state| if resolved_arguments.is_a?(GraphQL::ExecutionError) || resolved_arguments.is_a?(GraphQL::UnauthorizedError) - continue_value(next_path, resolved_arguments, owner_type, field_defn, return_type.non_null?, ast_node, result_name, selection_result) + next if selection_result.collect_result(result_name, resolved_arguments) + + return_type_non_null = field_defn.type.non_null? + continue_value(resolved_arguments, field_defn, return_type_non_null, ast_node, result_name, selection_result) next end - kwarg_arguments = if resolved_arguments.empty? && field_defn.extras.empty? - # We can avoid allocating the `{ Symbol => Object }` hash in this case - NO_ARGS + kwarg_arguments = if field_defn.extras.empty? + if resolved_arguments.empty? + # We can avoid allocating the `{ Symbol => Object }` hash in this case + NO_ARGS + else + resolved_arguments.keyword_arguments + end else # Bundle up the extras, then make a new arguments instance # that includes the extras, too. @@ -441,9 +400,9 @@ def evaluate_selection_with_args(kwarg_arguments, field_defn, next_path, ast_nod when :ast_node extra_args[:ast_node] = ast_node when :execution_errors - extra_args[:execution_errors] = ExecutionErrors.new(context, ast_node, next_path) + extra_args[:execution_errors] = ExecutionErrors.new(context, ast_node, current_path) when :path - extra_args[:path] = next_path + extra_args[:path] = current_path when :lookahead if !field_ast_nodes field_ast_nodes = [ast_node] @@ -458,77 +417,86 @@ def evaluate_selection_with_args(kwarg_arguments, field_defn, next_path, ast_nod # Use this flag to tell Interpreter::Arguments to add itself # to the keyword args hash _before_ freezing everything. extra_args[:argument_details] = :__arguments_add_self - when :irep_node - # This is used by `__typename` in order to support the legacy runtime, - # but it has no use here (and it's always `nil`). - # Stop adding it here to avoid the overhead of `.merge_extras` below. + when :parent + parent_result = selection_result.graphql_parent + extra_args[:parent] = parent_result&.graphql_application_value&.object else extra_args[extra] = field_defn.fetch_extra(extra, context) end end - if extra_args.any? + if !extra_args.empty? resolved_arguments = resolved_arguments.merge_extras(extra_args) end resolved_arguments.keyword_arguments end - set_all_interpreter_context(nil, nil, kwarg_arguments, nil) + evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, object, result_name, selection_result, runtime_state) + end + end - # Optimize for the case that field is selected only once - if field_ast_nodes.nil? || field_ast_nodes.size == 1 - next_selections = ast_node.selections - directives = ast_node.directives - else - next_selections = [] - directives = [] - field_ast_nodes.each { |f| - next_selections.concat(f.selections) - directives.concat(f.directives) - } - end + def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, object, result_name, selection_result, runtime_state) # rubocop:disable Metrics/ParameterLists + runtime_state.current_field = field_defn + runtime_state.current_arguments = resolved_arguments + runtime_state.current_result_name = result_name + runtime_state.current_result = selection_result + # Optimize for the case that field is selected only once + if field_ast_nodes.nil? || field_ast_nodes.size == 1 + next_selections = ast_node.selections + directives = ast_node.directives + else + next_selections = [] + directives = [] + field_ast_nodes.each { |f| + next_selections.concat(f.selections) + directives.concat(f.directives) + } + end - field_result = resolve_with_directives(object, directives) do - # Actually call the field resolver and capture the result - app_result = begin - query.with_error_handling do - query.trace("execute_field", {owner: owner_type, field: field_defn, path: next_path, ast_node: ast_node, query: query, object: object, arguments: kwarg_arguments}) do - field_defn.resolve(object, kwarg_arguments, context) - end - end - rescue GraphQL::ExecutionError => err - err + call_method_on_directives(:resolve, object, directives) do + if !directives.empty? + # This might be executed in a different context; reset this info + runtime_state = get_current_runtime_state + runtime_state.current_field = field_defn + runtime_state.current_arguments = resolved_arguments + runtime_state.current_result_name = result_name + runtime_state.current_result = selection_result + end + # Actually call the field resolver and capture the result + app_result = begin + @current_trace.begin_execute_field(field_defn, object, kwarg_arguments, query) + @current_trace.execute_field(field: field_defn, ast_node: ast_node, query: query, object: object, arguments: kwarg_arguments) do + field_defn.resolve(object, kwarg_arguments, context) end - after_lazy(app_result, owner: owner_type, field: field_defn, path: next_path, ast_node: ast_node, scoped_context: context.scoped_context, owner_object: object, arguments: kwarg_arguments, result_name: result_name, result: selection_result) do |inner_result| - continue_value = continue_value(next_path, inner_result, owner_type, field_defn, return_type.non_null?, ast_node, result_name, selection_result) - if HALT != continue_value - continue_field(next_path, continue_value, owner_type, field_defn, return_type, ast_node, next_selections, false, object, kwarg_arguments, result_name, selection_result) - end + rescue GraphQL::ExecutionError => err + err + rescue StandardError => err + begin + query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + ex_err end end + @current_trace.end_execute_field(field_defn, object, kwarg_arguments, query, app_result) + after_lazy(app_result, field: field_defn, ast_node: ast_node, owner_object: object, arguments: resolved_arguments, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |inner_result, runtime_state| + next if selection_result.collect_result(result_name, inner_result) - # If this field is a root mutation field, immediately resolve - # all of its child fields before moving on to the next root mutation field. - # (Subselections of this mutation will still be resolved level-by-level.) - if is_eager_field - Interpreter::Resolve.resolve_all([field_result], @dataloader) - else - # Return this from `after_lazy` because it might be another lazy that needs to be resolved - field_result + owner_type = selection_result.graphql_result_type + return_type = field_defn.type + continue_value = continue_value(inner_result, field_defn, return_type.non_null?, ast_node, result_name, selection_result) + if HALT != continue_value + was_scoped = runtime_state.was_authorized_by_scope_items + runtime_state.was_authorized_by_scope_items = nil + continue_field(continue_value, owner_type, field_defn, return_type, ast_node, next_selections, false, object, resolved_arguments, result_name, selection_result, was_scoped, runtime_state) + else + nil + end end end end - def dead_result?(selection_result) - selection_result.graphql_dead || ((parent = selection_result.graphql_parent) && parent.graphql_dead) - end - - def set_result(selection_result, result_name, value) - if !dead_result?(selection_result) - if value.nil? && - ( # there are two conditions under which `nil` is not allowed in the response: - (selection_result.graphql_non_null_list_items) || # this value would be written into a list that doesn't allow nils - ((nn = selection_result.graphql_non_null_field_names) && nn.include?(result_name)) # this value would be written into a field that doesn't allow nils - ) + def set_result(selection_result, result_name, value, is_child_result, is_non_null) + if !selection_result.graphql_dead + if value.nil? && is_non_null # This is an invalid nil that should be propagated # One caller of this method passes a block, # namely when application code returns a `nil` to GraphQL and it doesn't belong there. @@ -538,15 +506,18 @@ def set_result(selection_result, result_name, value) # TODO the code is trying to tell me something. yield if block_given? parent = selection_result.graphql_parent - name_in_parent = selection_result.graphql_result_name if parent.nil? # This is a top-level result hash @response = nil else - set_result(parent, name_in_parent, nil) + name_in_parent = selection_result.graphql_result_name + is_non_null_in_parent = selection_result.graphql_is_non_null_in_parent + set_result(parent, name_in_parent, nil, false, is_non_null_in_parent) set_graphql_dead(selection_result) end + elsif is_child_result + selection_result.set_child_result(result_name, value) else - selection_result[result_name] = value + selection_result.set_leaf(result_name, value) end end end @@ -566,18 +537,32 @@ def set_graphql_dead(selection_result) end end - HALT = Object.new - def continue_value(path, value, parent_type, field, is_non_null, ast_node, result_name, selection_result) # rubocop:disable Metrics/ParameterLists + def current_path + st = get_current_runtime_state + result = st.current_result + path = result && result.path + if path && (rn = st.current_result_name) + path = path.dup + path.push(rn) + end + path + end + + HALT = Object.new.freeze + def continue_value(value, field, is_non_null, ast_node, result_name, selection_result) # rubocop:disable Metrics/ParameterLists case value when nil if is_non_null - set_result(selection_result, result_name, nil) do + set_result(selection_result, result_name, nil, false, is_non_null) do + # When this comes from a list item, use the parent object: + is_from_array = selection_result.is_a?(GraphQLResultArray) + parent_type = is_from_array ? selection_result.graphql_parent.graphql_result_type : selection_result.graphql_result_type # This block is called if `result_name` is not dead. (Maybe a previous invalid nil caused it be marked dead.) - err = parent_type::InvalidNullError.new(parent_type, field, value) + err = parent_type::InvalidNullError.new(parent_type, field, ast_node, is_from_array: is_from_array) schema.type_error(err, context) end else - set_result(selection_result, result_name, nil) + set_result(selection_result, result_name, nil, false, is_non_null) end HALT when GraphQL::Error @@ -585,15 +570,25 @@ def continue_value(path, value, parent_type, field, is_non_null, ast_node, resul # to avoid the overhead of checking three different classes # every time. if value.is_a?(GraphQL::ExecutionError) - if selection_result.nil? || !dead_result?(selection_result) - value.path ||= path + if selection_result.nil? || !selection_result.graphql_dead + value.path ||= current_path value.ast_node ||= ast_node context.errors << value - if selection_result - set_result(selection_result, result_name, nil) + if selection_result && result_name + set_result(selection_result, result_name, nil, false, is_non_null) end end HALT + elsif value.is_a?(GraphQL::UnauthorizedFieldError) + value.field ||= field + # this hook might raise & crash, or it might return + # a replacement value + next_value = begin + schema.unauthorized_field(value) + rescue GraphQL::ExecutionError => err + err + end + continue_value(next_value, field, is_non_null, ast_node, result_name, selection_result) elsif value.is_a?(GraphQL::UnauthorizedError) # this hook might raise & crash, or it might return # a replacement value @@ -602,8 +597,8 @@ def continue_value(path, value, parent_type, field, is_non_null, ast_node, resul rescue GraphQL::ExecutionError => err err end - continue_value(path, next_value, parent_type, field, is_non_null, ast_node, result_name, selection_result) - elsif GraphQL::Execution::Execute::SKIP == value + continue_value(next_value, field, is_non_null, ast_node, result_name, selection_result) + elsif value.is_a?(GraphQL::Execution::Skip) # It's possible a lazy was already written here case selection_result when GraphQLResultHash @@ -623,15 +618,21 @@ def continue_value(path, value, parent_type, field, is_non_null, ast_node, resul end when Array # It's an array full of execution errors; add them all. - if value.any? && value.all? { |v| v.is_a?(GraphQL::ExecutionError) } - if selection_result.nil? || !dead_result?(selection_result) + if !value.empty? && value.all?(GraphQL::ExecutionError) + list_type_at_all = (field && (field.type.list?)) + if selection_result.nil? || !selection_result.graphql_dead value.each_with_index do |error, index| error.ast_node ||= ast_node - error.path ||= path + ((field && field.type.list?) ? [index] : []) + error.path ||= current_path + (list_type_at_all ? [index] : []) context.errors << error end if selection_result - set_result(selection_result, result_name, nil) + if list_type_at_all + result_without_errors = value.map { |v| v.is_a?(GraphQL::ExecutionError) ? nil : v } + set_result(selection_result, result_name, result_without_errors, false, is_non_null) + else + set_result(selection_result, result_name, nil, false, is_non_null) + end end end HALT @@ -640,7 +641,7 @@ def continue_value(path, value, parent_type, field, is_non_null, ast_node, resul end when GraphQL::Execution::Interpreter::RawValue # Write raw value directly to the response without resolving nested objects - set_result(selection_result, result_name, value.resolve) + set_result(selection_result, result_name, value.resolve, false, is_non_null) HALT else value @@ -655,7 +656,7 @@ def continue_value(path, value, parent_type, field, is_non_null, ast_node, resul # Location information from `path` and `ast_node`. # # @return [Lazy, Array, Hash, Object] Lazy, Array, and Hash are all traversed to resolve lazy values later - def continue_field(path, value, owner_type, field, current_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result) # rubocop:disable Metrics/ParameterLists + def continue_field(value, owner_type, field, current_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result, was_scoped, runtime_state) # rubocop:disable Metrics/ParameterLists if current_type.non_null? current_type = current_type.of_type is_non_null = true @@ -663,137 +664,174 @@ def continue_field(path, value, owner_type, field, current_type, ast_node, next_ case current_type.kind.name when "SCALAR", "ENUM" - r = current_type.coerce_result(value, context) - set_result(selection_result, result_name, r) + r = begin + current_type.coerce_result(value, context) + rescue GraphQL::ExecutionError => ex_err + return continue_value(ex_err, field, is_non_null, ast_node, result_name, selection_result) + rescue StandardError => err + begin + query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + return continue_value(ex_err, field, is_non_null, ast_node, result_name, selection_result) + end + end + set_result(selection_result, result_name, r, false, is_non_null) r when "UNION", "INTERFACE" - resolved_type_or_lazy, resolved_value = resolve_type(current_type, value, path) - resolved_value ||= value - - after_lazy(resolved_type_or_lazy, owner: current_type, path: path, ast_node: ast_node, scoped_context: context.scoped_context, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result) do |resolved_type| - possible_types = query.possible_types(current_type) + resolved_type_or_lazy = begin + resolve_type(current_type, value) + rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err + return continue_value(ex_err, field, is_non_null, ast_node, result_name, selection_result) + rescue StandardError => err + begin + query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + return continue_value(ex_err, field, is_non_null, ast_node, result_name, selection_result) + end + end + after_lazy(resolved_type_or_lazy, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |resolved_type_result, runtime_state| + if resolved_type_result.is_a?(Array) && resolved_type_result.length == 2 + resolved_type, resolved_value = resolved_type_result + else + resolved_type = resolved_type_result + resolved_value = value + end + possible_types = query.types.possible_types(current_type) if !possible_types.include?(resolved_type) parent_type = field.owner_type err_class = current_type::UnresolvedTypeError type_error = err_class.new(resolved_value, field, parent_type, resolved_type, possible_types) schema.type_error(type_error, context) - set_result(selection_result, result_name, nil) + set_result(selection_result, result_name, nil, false, is_non_null) nil else - continue_field(path, resolved_value, owner_type, field, resolved_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result) + continue_field(resolved_value, owner_type, field, resolved_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result, was_scoped, runtime_state) end end when "OBJECT" object_proxy = begin - authorized_new(current_type, value, context) + was_scoped ? current_type.wrap_scoped(value, context) : current_type.wrap(value, context) rescue GraphQL::ExecutionError => err err end - after_lazy(object_proxy, owner: current_type, path: path, ast_node: ast_node, scoped_context: context.scoped_context, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result) do |inner_object| - continue_value = continue_value(path, inner_object, owner_type, field, is_non_null, ast_node, result_name, selection_result) + after_lazy(object_proxy, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |inner_object, runtime_state| + continue_value = continue_value(inner_object, field, is_non_null, ast_node, result_name, selection_result) if HALT != continue_value - response_hash = GraphQLResultHash.new - response_hash.graphql_parent = selection_result - response_hash.graphql_result_name = result_name - set_result(selection_result, result_name, response_hash) - gathered_selections = gather_selections(continue_value, current_type, next_selections) - # There are two possibilities for `gathered_selections`: - # 1. All selections of this object should be evaluated together (there are no runtime directives modifying execution). - # This case is handled below, and the result can be written right into the main `response_hash` above. - # In this case, `gathered_selections` is a hash of selections. - # 2. Some selections of this object have runtime directives that may or may not modify execution. - # That part of the selection is evaluated in an isolated way, writing into a sub-response object which is - # eventually merged into the final response. In this case, `gathered_selections` is an array of things to run in isolation. - # (Technically, it's possible that one of those entries _doesn't_ require isolation.) - tap_or_each(gathered_selections) do |selections, is_selection_array| + response_hash = GraphQLResultHash.new(result_name, current_type, continue_value, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) + set_result(selection_result, result_name, response_hash, true, is_non_null) + each_gathered_selections(response_hash) do |selections, is_selection_array, ordered_result_keys| + response_hash.ordered_result_keys ||= ordered_result_keys if is_selection_array - this_result = GraphQLResultHash.new - this_result.graphql_parent = selection_result - this_result.graphql_result_name = result_name + this_result = GraphQLResultHash.new(result_name, current_type, continue_value, selection_result, is_non_null, selections, false, ast_node, arguments, field) + this_result.ordered_result_keys = ordered_result_keys final_result = response_hash else this_result = response_hash final_result = nil end - set_all_interpreter_context(continue_value, nil, nil, path) # reset this mutable state - resolve_with_directives(continue_value, selections.graphql_directives) do - evaluate_selections( - path, - context.scoped_context, - continue_value, - current_type, - false, - selections, - this_result, - final_result, - ) - this_result - end + + evaluate_selections( + selections, + this_result, + final_result, + runtime_state, + ) end end end when "LIST" inner_type = current_type.of_type - response_list = GraphQLResultArray.new - response_list.graphql_non_null_list_items = inner_type.non_null? - response_list.graphql_parent = selection_result - response_list.graphql_result_name = result_name - set_result(selection_result, result_name, response_list) - - idx = 0 - scoped_context = context.scoped_context - begin - value.each do |inner_value| - next_path = path.dup - next_path << idx - this_idx = idx - next_path.freeze - idx += 1 - # This will update `response_list` with the lazy - after_lazy(inner_value, owner: inner_type, path: next_path, ast_node: ast_node, scoped_context: scoped_context, field: field, owner_object: owner_object, arguments: arguments, result_name: this_idx, result: response_list) do |inner_inner_value| - continue_value = continue_value(next_path, inner_inner_value, owner_type, field, inner_type.non_null?, ast_node, this_idx, response_list) - if HALT != continue_value - continue_field(next_path, continue_value, owner_type, field, inner_type, ast_node, next_selections, false, owner_object, arguments, this_idx, response_list) + # This is true for objects, unions, and interfaces + use_dataloader_job = !inner_type.unwrap.kind.input? + inner_type_non_null = inner_type.non_null? + response_list = GraphQLResultArray.new(result_name, current_type, owner_object, selection_result, is_non_null, next_selections, false, ast_node, arguments, field) + set_result(selection_result, result_name, response_list, true, is_non_null) + idx = nil + list_value = begin + begin + value.each do |inner_value| + idx ||= 0 + this_idx = idx + idx += 1 + if use_dataloader_job + @dataloader.append_job do + resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, owner_type, was_scoped, nil) + end + else + resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, owner_type, was_scoped, runtime_state) end end + + response_list + rescue NoMethodError => err + if err.name == :each && err.receiver == value + # This happens when the GraphQL schema doesn't match the implementation. Help the dev debug. + raise ListResultFailedError.new(value: value, field: field, path: current_path) + else + # This was some other NoMethodError -- let it bubble to reveal the real error. + raise + end + rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err + ex_err + rescue StandardError => err + begin + query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + ex_err + end end - rescue NoMethodError => err - # Ruby 2.2 doesn't have NoMethodError#receiver, can't check that one in this case. (It's been EOL since 2017.) - if err.name == :each && (err.respond_to?(:receiver) ? err.receiver == value : true) - # This happens when the GraphQL schema doesn't match the implementation. Help the dev debug. - raise ListResultFailedError.new(value: value, field: field, path: path) - else - # This was some other NoMethodError -- let it bubble to reveal the real error. - raise + rescue StandardError => err + begin + query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + ex_err end end - - response_list + # Detect whether this error came while calling `.each` (before `idx` is set) or while running list *items* (after `idx` is set) + error_is_non_null = idx.nil? ? is_non_null : inner_type.non_null? + continue_value(list_value, field, error_is_non_null, ast_node, result_name, selection_result) else raise "Invariant: Unhandled type kind #{current_type.kind} (#{current_type})" end end - def resolve_with_directives(object, directives, &block) + def resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, owner_type, was_scoped, runtime_state) # rubocop:disable Metrics/ParameterLists + runtime_state ||= get_current_runtime_state + runtime_state.current_result_name = this_idx + runtime_state.current_result = response_list + call_method_on_directives(:resolve_each, owner_object, ast_node.directives) do + # This will update `response_list` with the lazy + after_lazy(inner_value, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, result_name: this_idx, result: response_list, runtime_state: runtime_state) do |inner_inner_value, runtime_state| + continue_value = continue_value(inner_inner_value, field, inner_type_non_null, ast_node, this_idx, response_list) + if HALT != continue_value + continue_field(continue_value, owner_type, field, inner_type, ast_node, response_list.graphql_selections, false, owner_object, arguments, this_idx, response_list, was_scoped, runtime_state) + end + end + end + end + + def call_method_on_directives(method_name, object, directives, &block) return yield if directives.nil? || directives.empty? - run_directive(object, directives, 0, &block) + run_directive(method_name, object, directives, 0, &block) end - def run_directive(object, directives, idx, &block) + def run_directive(method_name, object, directives, idx, &block) dir_node = directives[idx] if !dir_node yield else - dir_defn = schema.directives.fetch(dir_node.name) - if !dir_defn.is_a?(Class) - dir_defn = dir_defn.type_class || raise("Only class-based directives are supported (not `@#{dir_node.name}`)") - end + dir_defn = @schema_directives.fetch(dir_node.name) raw_dir_args = arguments(nil, dir_defn, dir_node) + if !raw_dir_args.is_a?(GraphQL::ExecutionError) + begin + dir_defn.validate!(raw_dir_args, context) + rescue GraphQL::ExecutionError => err + raw_dir_args = err + end + end dir_args = continue_value( - @context[:current_path], # path raw_dir_args, # value - dir_defn, # parent_type nil, # field false, # is_non_null dir_node, # ast_node @@ -804,78 +842,118 @@ def run_directive(object, directives, idx, &block) if dir_args == HALT nil else - dir_defn.resolve(object, dir_args, context) do - run_directive(object, directives, idx + 1, &block) + dir_defn.public_send(method_name, object, dir_args, context) do + run_directive(method_name, object, directives, idx + 1, &block) end end end end # Check {Schema::Directive.include?} for each directive that's present - def directives_include?(node, graphql_object, parent_type) + def directives_include?(node, graphql_object, parent_type, selection_result, extra_path_part) node.directives.each do |dir_node| - dir_defn = schema.directives.fetch(dir_node.name).type_class || raise("Only class-based directives are supported (not #{dir_node.name.inspect})") - args = arguments(graphql_object, dir_defn, dir_node) - if !dir_defn.include?(graphql_object, args, context) + dir_defn = @schema_directives.fetch(dir_node.name) + raw_dir_args = arguments(nil, dir_defn, dir_node) + if !raw_dir_args.is_a?(GraphQL::ExecutionError) + begin + dir_defn.validate!(raw_dir_args, context) + rescue GraphQL::ExecutionError => err + raw_dir_args = err + end + end + + if extra_path_part && raw_dir_args.is_a?(GraphQL::ExecutionError) + raw_dir_args.path = current_path + [extra_path_part] + end + + dir_args = continue_value( + raw_dir_args, # value + nil, # field + false, # is_non_null + dir_node, # ast_node + nil, # result_name + selection_result + ) + if dir_args == HALT || !dir_defn.include?(graphql_object, dir_args, context) return false end end true end - def set_all_interpreter_context(object, field, arguments, path) - if object - @context[:current_object] = @interpreter_context[:current_object] = object - end - if field - @context[:current_field] = @interpreter_context[:current_field] = field - end - if arguments - @context[:current_arguments] = @interpreter_context[:current_arguments] = arguments - end - if path - @context[:current_path] = @interpreter_context[:current_path] = path + def get_current_runtime_state + current_state = Fiber[:__graphql_runtime_info] ||= {}.compare_by_identity + current_state[@query] ||= CurrentState.new + end + + def minimal_after_lazy(value, &block) + if lazy?(value) + GraphQL::Execution::Lazy.new do + result = @schema.sync_lazy(value) + # The returned result might also be lazy, so check it, too + minimal_after_lazy(result, &block) + end + else + yield(value) end end # @param obj [Object] Some user-returned value that may want to be batched - # @param path [Array] # @param field [GraphQL::Schema::Field] # @param eager [Boolean] Set to `true` for mutation root fields only # @param trace [Boolean] If `false`, don't wrap this with field tracing # @return [GraphQL::Execution::Lazy, Object] If loading `object` will be deferred, it's a wrapper over it. - def after_lazy(lazy_obj, owner:, field:, path:, scoped_context:, owner_object:, arguments:, ast_node:, result:, result_name:, eager: false, trace: true, &block) + def after_lazy(lazy_obj, field:, owner_object:, arguments:, ast_node:, result:, result_name:, eager: false, runtime_state:, trace: true, &block) if lazy?(lazy_obj) - lazy = GraphQL::Execution::Lazy.new(path: path, field: field) do - set_all_interpreter_context(owner_object, field, arguments, path) - context.scoped_context = scoped_context + was_authorized_by_scope_items = runtime_state.was_authorized_by_scope_items + lazy = GraphQL::Execution::Lazy.new(field: field) do + # This block might be called in a new fiber; + # In that case, this will initialize a new state + # to avoid conflicting with the parent fiber. + runtime_state = get_current_runtime_state + runtime_state.current_field = field + runtime_state.current_arguments = arguments + runtime_state.current_result_name = result_name + runtime_state.current_result = result + runtime_state.was_authorized_by_scope_items = was_authorized_by_scope_items # Wrap the execution of _this_ method with tracing, # but don't wrap the continuation below + sync_result = nil inner_obj = begin - query.with_error_handling do - if trace - query.trace("execute_field_lazy", {owner: owner, field: field, path: path, query: query, object: owner_object, arguments: arguments, ast_node: ast_node}) do - schema.sync_lazy(lazy_obj) - end - else + sync_result = if trace + @current_trace.begin_execute_field(field, owner_object, arguments, query) + @current_trace.execute_field_lazy(field: field, query: query, object: owner_object, arguments: arguments, ast_node: ast_node) do schema.sync_lazy(lazy_obj) end + else + schema.sync_lazy(lazy_obj) + end + rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err + ex_err + rescue StandardError => err + begin + query.handle_or_reraise(err) + rescue GraphQL::ExecutionError => ex_err + ex_err + end + ensure + if trace + @current_trace.end_execute_field(field, owner_object, arguments, query, sync_result) end - rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => err - err end - yield(inner_obj) + yield(inner_obj, runtime_state) end if eager lazy.value else - set_result(result, result_name, lazy) + set_result(result, result_name, lazy, false, false) # is_non_null is irrelevant here + @dataloader.lazy_at_depth(result.depth, lazy) lazy end else - set_all_interpreter_context(owner_object, field, arguments, path) - yield(lazy_obj) + # Don't need to reset state here because it _wasn't_ lazy. + yield(lazy_obj, runtime_state) end end @@ -888,28 +966,31 @@ def arguments(graphql_object, arg_owner, ast_node) end end - # Set this pair in the Query context, but also in the interpeter namespace, - # for compatibility. - def set_interpreter_context(key, value) - @interpreter_context[key] = value - @context[key] = value - end - - def delete_interpreter_context(key) - @interpreter_context.delete(key) - @context.delete(key) + def delete_all_interpreter_context + per_query_state = Fiber[:__graphql_runtime_info] + if per_query_state + per_query_state.delete(@query) + if per_query_state.size == 0 + Fiber[:__graphql_runtime_info] = nil + end + end + nil end - def resolve_type(type, value, path) - trace_payload = { context: context, type: type, object: value, path: path } - resolved_type, resolved_value = query.trace("resolve_type", trace_payload) do + def resolve_type(type, value) + @current_trace.begin_resolve_type(type, value, context) + resolved_type, resolved_value = @current_trace.resolve_type(query: query, type: type, object: value) do query.resolve_type(type, value) end + @current_trace.end_resolve_type(type, value, context, resolved_type) if lazy?(resolved_type) GraphQL::Execution::Lazy.new do - query.trace("resolve_type_lazy", trace_payload) do - schema.sync_lazy(resolved_type) + @current_trace.begin_resolve_type(type, value, context) + @current_trace.resolve_type_lazy(query: query, type: type, object: value) do + rt = schema.sync_lazy(resolved_type) + @current_trace.end_resolve_type(type, value, context, rt) + rt end end else @@ -917,14 +998,13 @@ def resolve_type(type, value, path) end end - def authorized_new(type, value, context) - type.authorized_new(value, context) - end - def lazy?(object) - @lazy_cache.fetch(object.class) { - @lazy_cache[object.class] = @schema.lazy?(object) - } + obj_class = object.class + is_lazy = @lazy_cache[obj_class] + if is_lazy.nil? + is_lazy = @lazy_cache[obj_class] = @schema.lazy?(object) + end + is_lazy end end end diff --git a/lib/graphql/execution/interpreter/runtime/graphql_result.rb b/lib/graphql/execution/interpreter/runtime/graphql_result.rb new file mode 100644 index 00000000000..e42ea4bd21f --- /dev/null +++ b/lib/graphql/execution/interpreter/runtime/graphql_result.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +module GraphQL + module Execution + class Interpreter + class Runtime + module GraphQLResult + def initialize(result_name, result_type, application_value, parent_result, is_non_null_in_parent, selections, is_eager, ast_node, graphql_arguments, graphql_field) # rubocop:disable Metrics/ParameterLists + @ast_node = ast_node + @graphql_arguments = graphql_arguments + @graphql_field = graphql_field + @graphql_parent = parent_result + @graphql_application_value = application_value + @graphql_result_type = result_type + if parent_result && parent_result.graphql_dead + @graphql_dead = true + end + @graphql_result_name = result_name + @graphql_is_non_null_in_parent = is_non_null_in_parent + # Jump through some hoops to avoid creating this duplicate storage if at all possible. + @graphql_metadata = nil + @graphql_selections = selections + @graphql_is_eager = is_eager + @base_path = nil + end + + # TODO test full path in Partial + attr_writer :base_path + + def path + @path ||= build_path([]) + end + + def build_path(path_array) + graphql_result_name && path_array.unshift(graphql_result_name) + if @graphql_parent + @graphql_parent.build_path(path_array) + elsif @base_path + @base_path + path_array + else + path_array + end + end + + def depth + @depth ||= if @graphql_parent + @graphql_parent.depth + 1 + else + 1 + end + end + + attr_accessor :graphql_dead + attr_reader :graphql_parent, :graphql_result_name, :graphql_is_non_null_in_parent, + :graphql_application_value, :graphql_result_type, :graphql_selections, :graphql_is_eager, :ast_node, :graphql_arguments, :graphql_field + + # @return [Hash] Plain-Ruby result data (`@graphql_metadata` contains Result wrapper objects) + attr_accessor :graphql_result_data + end + + class GraphQLResultHash + def initialize(_result_name, _result_type, _application_value, _parent_result, _is_non_null_in_parent, _selections, _is_eager, _ast_node, _graphql_arguments, graphql_field) # rubocop:disable Metrics/ParameterLists + super + @graphql_result_data = {} + @ordered_result_keys = nil + end + + attr_accessor :ordered_result_keys + + include GraphQLResult + + attr_accessor :graphql_merged_into + + def set_leaf(key, value) + # This is a hack. + # Basically, this object is merged into the root-level result at some point. + # But the problem is, some lazies are created whose closures retain reference to _this_ + # object. When those lazies are resolved, they cause an update to this object. + # + # In order to return a proper top-level result, we have to update that top-level result object. + # In order to return a proper partial result (eg, for a directive), we have to update this object, too. + # Yowza. + if (t = @graphql_merged_into) + t.set_leaf(key, value) + end + + before_size = @graphql_result_data.size + @graphql_result_data[key] = value + after_size = @graphql_result_data.size + if after_size > before_size && @ordered_result_keys[before_size] != key + fix_result_order + end + + # keep this up-to-date if it's been initialized + @graphql_metadata && @graphql_metadata[key] = value + + value + end + + def set_child_result(key, value) + if (t = @graphql_merged_into) + t.set_child_result(key, value) + end + before_size = @graphql_result_data.size + @graphql_result_data[key] = value.graphql_result_data + after_size = @graphql_result_data.size + if after_size > before_size && @ordered_result_keys[before_size] != key + fix_result_order + end + + # If we encounter some part of this response that requires metadata tracking, + # then create the metadata hash if necessary. It will be kept up-to-date after this. + (@graphql_metadata ||= @graphql_result_data.dup)[key] = value + value + end + + def delete(key) + @graphql_metadata && @graphql_metadata.delete(key) + @graphql_result_data.delete(key) + end + + def each + (@graphql_metadata || @graphql_result_data).each { |k, v| yield(k, v) } + end + + def values + (@graphql_metadata || @graphql_result_data).values + end + + def key?(k) + @graphql_result_data.key?(k) + end + + def [](k) + (@graphql_metadata || @graphql_result_data)[k] + end + + def merge_into(into_result) + self.each do |key, value| + case value + when GraphQLResultHash + next_into = into_result[key] + if next_into + value.merge_into(next_into) + else + into_result.set_child_result(key, value) + end + when GraphQLResultArray + # There's no special handling of arrays because currently, there's no way to split the execution + # of a list over several concurrent flows. + into_result.set_child_result(key, value) + else + # We have to assume that, since this passed the `fields_will_merge` selection, + # that the old and new values are the same. + into_result.set_leaf(key, value) + end + end + @graphql_merged_into = into_result + end + + def fix_result_order + @ordered_result_keys.each do |k| + if @graphql_result_data.key?(k) + @graphql_result_data[k] = @graphql_result_data.delete(k) + end + end + end + + # hook for breadth-first implementations to signal when collecting results. + def collect_result(result_name, result_value) + false + end + end + + class GraphQLResultArray + include GraphQLResult + + def initialize(_result_name, _result_type, _application_value, _parent_result, _is_non_null_in_parent, _selections, _is_eager, _ast_node, _graphql_arguments, graphql_field) # rubocop:disable Metrics/ParameterLists + super + @graphql_result_data = [] + end + + def graphql_skip_at(index) + # Mark this index as dead. It's tricky because some indices may already be storing + # `Lazy`s. So the runtime is still holding indexes _before_ skipping, + # this object has to coordinate incoming writes to account for any already-skipped indices. + @skip_indices ||= [] + @skip_indices << index + offset_by = @skip_indices.count { |skipped_idx| skipped_idx < index} + delete_at_index = index - offset_by + @graphql_metadata && @graphql_metadata.delete_at(delete_at_index) + @graphql_result_data.delete_at(delete_at_index) + end + + def set_leaf(idx, value) + if @skip_indices + offset_by = @skip_indices.count { |skipped_idx| skipped_idx < idx } + idx -= offset_by + end + @graphql_result_data[idx] = value + @graphql_metadata && @graphql_metadata[idx] = value + value + end + + def set_child_result(idx, value) + if @skip_indices + offset_by = @skip_indices.count { |skipped_idx| skipped_idx < idx } + idx -= offset_by + end + @graphql_result_data[idx] = value.graphql_result_data + # If we encounter some part of this response that requires metadata tracking, + # then create the metadata hash if necessary. It will be kept up-to-date after this. + (@graphql_metadata ||= @graphql_result_data.dup)[idx] = value + value + end + + def values + (@graphql_metadata || @graphql_result_data) + end + + def [](idx) + (@graphql_metadata || @graphql_result_data)[idx] + end + end + end + end + end +end diff --git a/lib/graphql/execution/lazy.rb b/lib/graphql/execution/lazy.rb index 67e4d2109f6..3ad9f3e0505 100644 --- a/lib/graphql/execution/lazy.rb +++ b/lib/graphql/execution/lazy.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true require "graphql/execution/lazy/lazy_method_map" -require "graphql/execution/lazy/resolve" module GraphQL module Execution @@ -13,23 +12,14 @@ module Execution # - It has no error-catching functionality # @api private class Lazy - # Traverse `val`, lazily resolving any values along the way - # @param val [Object] A data structure containing mixed plain values and `Lazy` instances - # @return void - def self.resolve(val) - Resolve.resolve(val) - end - - attr_reader :path, :field + attr_reader :field # Create a {Lazy} which will get its inner value by calling the block - # @param path [Array] # @param field [GraphQL::Schema::Field] # @param get_value_func [Proc] a block to get the inner value (later) - def initialize(path: nil, field: nil, &get_value_func) + def initialize(field: nil, &get_value_func) @get_value_func = get_value_func @resolved = false - @path = path @field = field end @@ -37,22 +27,18 @@ def initialize(path: nil, field: nil, &get_value_func) def value if !@resolved @resolved = true - @value = begin - v = @get_value_func.call - if v.is_a?(Lazy) - v = v.value - end - v - rescue GraphQL::ExecutionError => err - err + v = @get_value_func.call + if v.is_a?(Lazy) + v = v.value end + @value = v end # `SKIP` was made into a subclass of `GraphQL::Error` to improve runtime performance # (fewer clauses in a hot `case` block), but now it requires special handling here. # I think it's still worth it for the performance win, but if the number of special # cases grows, then maybe it's worth rethinking somehow. - if @value.is_a?(StandardError) && @value != GraphQL::Execution::Execute::SKIP + if @value.is_a?(StandardError) && !@value.is_a?(GraphQL::Execution::Skip) raise @value else @value diff --git a/lib/graphql/execution/lazy/resolve.rb b/lib/graphql/execution/lazy/resolve.rb deleted file mode 100644 index c19415f1172..00000000000 --- a/lib/graphql/execution/lazy/resolve.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Execution - class Lazy - # Helpers for dealing with data structures containing {Lazy} instances - # @api private - module Resolve - # Mutate `value`, replacing {Lazy} instances in place with their resolved values - # @return [void] - - # This object can be passed like an array, but it doesn't allocate an - # array until it's used. - # - # There's one crucial difference: you have to _capture_ the result - # of `#<<`. (This _works_ with arrays but isn't required, since it has a side-effect.) - # @api private - module NullAccumulator - def self.<<(item) - [item] - end - - def self.empty? - true - end - end - - def self.resolve(value) - lazies = resolve_in_place(value) - deep_sync(lazies) - end - - def self.resolve_in_place(value) - acc = each_lazy(NullAccumulator, value) - - if acc.empty? - Lazy::NullResult - else - Lazy.new { - acc.each_with_index { |ctx, idx| - acc[idx] = ctx.value.value - } - resolve_in_place(acc) - } - end - end - - # If `value` is a collection, - # add any {Lazy} instances in the collection - # to `acc` - # @return [void] - def self.each_lazy(acc, value) - case value - when Hash - value.each do |key, field_result| - acc = each_lazy(acc, field_result) - end - when Array - value.each do |field_result| - acc = each_lazy(acc, field_result) - end - when Query::Context::SharedMethods - field_value = value.value - case field_value - when Lazy - acc = acc << value - when Enumerable # shortcut for Hash & Array - acc = each_lazy(acc, field_value) - end - end - - acc - end - - # Traverse `val`, triggering resolution for each {Lazy}. - # These {Lazy}s are expected to mutate their owner data structures - # during resolution! (They're created with the `.then` calls in `resolve_in_place`). - # @return [void] - def self.deep_sync(val) - case val - when Lazy - deep_sync(val.value) - when Array - val.each { |v| deep_sync(v.value) } - when Hash - val.each { |k, v| deep_sync(v.value) } - end - end - end - end - end -end diff --git a/lib/graphql/execution/load_argument_step.rb b/lib/graphql/execution/load_argument_step.rb new file mode 100644 index 00000000000..437a0621b13 --- /dev/null +++ b/lib/graphql/execution/load_argument_step.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true +module GraphQL + module Execution + class LoadArgumentStep + def initialize(field_resolve_step:, arguments:, load_receiver:, argument_value:, argument_definition:, argument_key:) + @field_resolve_step = field_resolve_step + @load_receiver = load_receiver + @arguments = arguments + @argument_value = argument_value + @argument_definition = argument_definition + @argument_key = argument_key + @loaded_value = nil + @is_authorized = true + end + + def value + @field_resolve_step.set_current_field + schema = @field_resolve_step.runner.schema + @loaded_value = schema.sync_lazy(@loaded_value) + assign_value + rescue GraphQL::UnauthorizedError => auth_err + @is_authorized = false + schema.unauthorized_object(auth_err) + rescue GraphQL::RuntimeError => err + @loaded_value = if err.is_a?(Schema::Subscription::EarlyUnsubscribe) + err.unsubscribed_result + else + err + end + assign_value + rescue StandardError => stderr + begin + @field_resolve_step.selections_step.query.handle_or_reraise(stderr, field: @field_definition, arguments: @arguments, object: nil) + rescue GraphQL::ExecutionError => ex_err + @loaded_value = ex_err + end + assign_value + ensure + @field_resolve_step.set_current_field(nil) + end + + def call + @field_resolve_step.set_current_field + context = @field_resolve_step.selections_step.query.context + @loaded_value = begin + @load_receiver.load_and_authorize_application_object(@argument_definition, @argument_value, context) + rescue GraphQL::UnauthorizedError => auth_err + @is_authorized = false + context.schema.unauthorized_object(auth_err) + end + if (runner = @field_resolve_step.runner).resolves_lazies && runner.lazy?(@loaded_value) + runner.dataloader.lazy_at_depth(@field_resolve_step.path.size, self) + else + assign_value + end + rescue GraphQL::RuntimeError => err + @loaded_value = if err.is_a?(Schema::Subscription::EarlyUnsubscribe) + @is_authorized = false + err.unsubscribed_result + else + err + end + assign_value + rescue StandardError => stderr + @loaded_value = begin + context.query.handle_or_reraise(stderr, field: @field_resolve_step.field_definition, arguments: @field_resolve_step.arguments, object: nil) # rubocop:disable Development/ContextIsPassedCop + rescue GraphQL::ExecutionError => ex_err + ex_err + end + assign_value + ensure + @field_resolve_step.set_current_field(nil) + end + + private + + def assign_value + if @loaded_value.is_a?(GraphQL::RuntimeError) + @loaded_value.path = @field_resolve_step.path + @field_resolve_step.arguments = @loaded_value + elsif @is_authorized == false + # An unauthorized_object hook ate the error + @field_resolve_step.arguments = EmptyObjects::EMPTY_HASH + field_pending_steps = @field_resolve_step.pending_steps + field_pending_steps.clear + @field_resolve_step.build_errors_result(nil, nil) + return + else + query = @field_resolve_step.selections_step.query + query.current_trace.object_loaded(@argument_definition, @loaded_value, query.context) + @arguments[@argument_key] = @loaded_value + end + + field_pending_steps = @field_resolve_step.pending_steps + field_pending_steps.delete(self) + if @field_resolve_step.arguments && field_pending_steps.size == 0 # rubocop:disable Development/ContextIsPassedCop + @field_resolve_step.runner.add_step(@field_resolve_step) + end + end + end + end +end diff --git a/lib/graphql/execution/lookahead.rb b/lib/graphql/execution/lookahead.rb index abd940c9210..61aa94aae5e 100644 --- a/lib/graphql/execution/lookahead.rb +++ b/lib/graphql/execution/lookahead.rb @@ -55,8 +55,15 @@ def arguments @arguments else @arguments = if @field - @query.schema.after_lazy(@query.arguments_for(@ast_nodes.first, @field)) do |args| - args.is_a?(Execution::Interpreter::Arguments) ? args.keyword_arguments : args + @query.after_lazy(@query.arguments_for(@ast_nodes.first, @field)) do |args| + case args + when Execution::Interpreter::Arguments + args.keyword_arguments + when GraphQL::ExecutionError + EmptyObjects::EMPTY_HASH + else + args + end end else nil @@ -76,8 +83,24 @@ def arguments # @param field_name [String, Symbol] # @param arguments [Hash] Arguments which must match in the selection # @return [Boolean] - def selects?(field_name, arguments: nil) - selection(field_name, arguments: arguments).selected? + def selects?(field_name, selected_type: @selected_type, arguments: nil) + selection(field_name, selected_type: selected_type, arguments: arguments).selected? + end + + # True if this node has a selection with alias matching `alias_name`. + # If `alias_name` is a String, it is treated as a GraphQL-style (camelized) + # field name and used verbatim. If `alias_name` is a Symbol, it is + # treated as a Ruby-style (underscored) name and camelized before comparing. + # + # If `arguments:` is provided, each provided key/value will be matched + # against the arguments in the next selection. This method will return false + # if any of the given `arguments:` are not present and matching in the next selection. + # (But, the next selection may contain _more_ than the given arguments.) + # @param alias_name [String, Symbol] + # @param arguments [Hash] Arguments which must match in the selection + # @return [Boolean] + def selects_alias?(alias_name, arguments: nil) + alias_selection(alias_name, arguments: arguments).selected? end # @return [Boolean] True if this lookahead represents a field that was requested @@ -87,27 +110,57 @@ def selected? # Like {#selects?}, but can be used for chaining. # It returns a null object (check with {#selected?}) + # @param field_name [String, Symbol] # @return [GraphQL::Execution::Lookahead] def selection(field_name, selected_type: @selected_type, arguments: nil) - next_field_name = normalize_name(field_name) - - next_field_defn = get_class_based_field(selected_type, next_field_name) - if next_field_defn - next_nodes = [] - @ast_nodes.each do |ast_node| - ast_node.selections.each do |selection| - find_selected_nodes(selection, next_field_name, next_field_defn, arguments: arguments, matches: next_nodes) - end + next_field_defn = case field_name + when String + @query.types.field(selected_type, field_name) + when Symbol + # Try to avoid the `.to_s` below, if possible + all_fields = if selected_type.kind.fields? + @query.types.fields(selected_type) + else + # Handle unions by checking possible + @query.types + .possible_types(selected_type) + .map { |t| @query.types.fields(t) } + .tap(&:flatten!) end - if next_nodes.any? - Lookahead.new(query: @query, ast_nodes: next_nodes, field: next_field_defn, owner_type: selected_type) + + if (match_by_orig_name = all_fields.find { |f| f.original_name == field_name }) + match_by_orig_name else - NULL_LOOKAHEAD + # Symbol#name is only present on 3.0+ + sym_s = field_name.respond_to?(:name) ? field_name.name : field_name.to_s + guessed_name = Schema::Member::BuildType.camelize(sym_s) + @query.types.field(selected_type, guessed_name) end - else - NULL_LOOKAHEAD end + lookahead_for_selection(next_field_defn, selected_type, arguments) + end + + # Like {#selection}, but for aliases. + # It returns a null object (check with {#selected?}) + # @return [GraphQL::Execution::Lookahead] + def alias_selection(alias_name, selected_type: @selected_type, arguments: nil) + alias_cache_key = [alias_name, arguments] + return alias_selections[key] if alias_selections.key?(alias_name) + + alias_node = lookup_alias_node(ast_nodes, alias_name) + return NULL_LOOKAHEAD unless alias_node + + next_field_defn = @query.types.field(selected_type, alias_node.name) + + alias_arguments = @query.arguments_for(alias_node, next_field_defn) + if alias_arguments.is_a?(::GraphQL::Execution::Interpreter::Arguments) + alias_arguments = alias_arguments.keyword_arguments + end + + return NULL_LOOKAHEAD if arguments && arguments != alias_arguments + + alias_selections[alias_cache_key] = lookahead_for_selection(next_field_defn, selected_type, alias_arguments, alias_name) end # Like {#selection}, but for all nodes. @@ -137,7 +190,7 @@ def selections(arguments: nil) subselections_by_type.each do |type, ast_nodes_by_response_key| ast_nodes_by_response_key.each do |response_key, ast_nodes| - field_defn = get_class_based_field(type, ast_nodes.first.name) + field_defn = @query.types.field(type, ast_nodes.first.name) lookahead = Lookahead.new(query: @query, ast_nodes: ast_nodes, field: field_defn, owner_type: type) subselections.push(lookahead) end @@ -196,34 +249,10 @@ def inspect private - # If it's a symbol, stringify and camelize it - def normalize_name(name) - if name.is_a?(Symbol) - Schema::Member::BuildType.camelize(name.to_s) - else - name - end - end - - def normalize_keyword(keyword) - if keyword.is_a?(String) - Schema::Member::BuildType.underscore(keyword).to_sym - else - keyword - end - end - - # Wrap get_field and ensure that it returns a GraphQL::Schema::Field. - # Remove this when legacy execution is removed. - def get_class_based_field(type, name) - f = @query.get_field(type, name) - f && f.type_class - end - def skipped_by_directive?(ast_selection) ast_selection.directives.each do |directive| dir_defn = @query.schema.directives.fetch(directive.name) - directive_class = dir_defn.type_class + directive_class = dir_defn if directive_class dir_args = @query.arguments_for(directive, dir_defn) return true unless directive_class.static_include?(dir_args, @query.context) @@ -244,7 +273,7 @@ def find_selections(subselections_by_type, selections_on_type, selected_type, as elsif arguments.nil? || arguments.empty? selections_on_type[response_key] = [ast_selection] else - field_defn = get_class_based_field(selected_type, ast_selection.name) + field_defn = @query.types.field(selected_type, ast_selection.name) if arguments_match?(arguments, field_defn, ast_selection) selections_on_type[response_key] = [ast_selection] end @@ -254,14 +283,14 @@ def find_selections(subselections_by_type, selections_on_type, selected_type, as subselections_on_type = selections_on_type if (t = ast_selection.type) # Assuming this is valid, that `t` will be found. - on_type = @query.schema.get_type(t.name).type_class + on_type = @query.types.type(t.name) subselections_on_type = subselections_by_type[on_type] ||= {} end find_selections(subselections_by_type, subselections_on_type, on_type, ast_selection.selections, arguments) when GraphQL::Language::Nodes::FragmentSpread - frag_defn = @query.fragments[ast_selection.name] || raise("Invariant: Can't look ahead to nonexistent fragment #{ast_selection.name} (found: #{@query.fragments.keys})") + frag_defn = lookup_fragment(ast_selection) # Again, assuming a valid AST - on_type = @query.schema.get_type(frag_defn.type.name).type_class + on_type = @query.types.type(frag_defn.type.name) subselections_on_type = subselections_by_type[on_type] ||= {} find_selections(subselections_by_type, subselections_on_type, on_type, frag_defn.selections, arguments) else @@ -272,11 +301,11 @@ def find_selections(subselections_by_type, selections_on_type, selected_type, as # If a selection on `node` matches `field_name` (which is backed by `field_defn`) # and matches the `arguments:` constraints, then add that node to `matches` - def find_selected_nodes(node, field_name, field_defn, arguments:, matches:) + def find_selected_nodes(node, field_name, field_defn, arguments:, matches:, alias_name: NOT_CONFIGURED) return if skipped_by_directive?(node) case node when GraphQL::Language::Nodes::Field - if node.name == field_name + if node.name == field_name && (NOT_CONFIGURED.equal?(alias_name) || node.alias == alias_name) if arguments.nil? || arguments.empty? # No constraint applied matches << node @@ -285,10 +314,10 @@ def find_selected_nodes(node, field_name, field_defn, arguments:, matches:) end end when GraphQL::Language::Nodes::InlineFragment - node.selections.each { |s| find_selected_nodes(s, field_name, field_defn, arguments: arguments, matches: matches) } + node.selections.each { |s| find_selected_nodes(s, field_name, field_defn, arguments: arguments, matches: matches, alias_name: alias_name) } when GraphQL::Language::Nodes::FragmentSpread - frag_defn = @query.fragments[node.name] || raise("Invariant: Can't look ahead to nonexistent fragment #{node.name} (found: #{@query.fragments.keys})") - frag_defn.selections.each { |s| find_selected_nodes(s, field_name, field_defn, arguments: arguments, matches: matches) } + frag_defn = lookup_fragment(node) + frag_defn.selections.each { |s| find_selected_nodes(s, field_name, field_defn, arguments: arguments, matches: matches, alias_name: alias_name) } else raise "Unexpected selection comparison on #{node.class.name} (#{node})" end @@ -297,11 +326,60 @@ def find_selected_nodes(node, field_name, field_defn, arguments:, matches:) def arguments_match?(arguments, field_defn, field_node) query_kwargs = @query.arguments_for(field_node, field_defn) arguments.all? do |arg_name, arg_value| - arg_name = normalize_keyword(arg_name) + arg_name_sym = if arg_name.is_a?(String) + Schema::Member::BuildType.underscore(arg_name).to_sym + else + arg_name + end + # Make sure the constraint is present with a matching value - query_kwargs.key?(arg_name) && query_kwargs[arg_name] == arg_value + query_kwargs.key?(arg_name_sym) && query_kwargs[arg_name_sym] == arg_value + end + end + + def lookahead_for_selection(field_defn, selected_type, arguments, alias_name = NOT_CONFIGURED) + return NULL_LOOKAHEAD unless field_defn + + next_nodes = [] + field_name = field_defn.name + @ast_nodes.each do |ast_node| + ast_node.selections.each do |selection| + find_selected_nodes(selection, field_name, field_defn, arguments: arguments, matches: next_nodes, alias_name: alias_name) + end + end + + return NULL_LOOKAHEAD if next_nodes.empty? + + Lookahead.new(query: @query, ast_nodes: next_nodes, field: field_defn, owner_type: selected_type) + end + + def alias_selections + return @alias_selections if defined?(@alias_selections) + @alias_selections ||= {} + end + + def lookup_alias_node(nodes, name) + return if nodes.empty? + + nodes.flat_map(&:children) + .flat_map { |child| unwrap_fragments(child) } + .find { |child| child.is_a?(GraphQL::Language::Nodes::Field) && child.alias == name } + end + + def unwrap_fragments(node) + case node + when GraphQL::Language::Nodes::InlineFragment + node.children + when GraphQL::Language::Nodes::FragmentSpread + lookup_fragment(node).children + else + [node] end end + + def lookup_fragment(ast_selection) + @query.fragments[ast_selection.name] || raise("Invariant: Can't look ahead to nonexistent fragment #{ast_selection.name} (found: #{@query.fragments.keys})") + end end end end diff --git a/lib/graphql/execution/multiplex.rb b/lib/graphql/execution/multiplex.rb index d5d0c137146..a23efd9e3de 100644 --- a/lib/graphql/execution/multiplex.rb +++ b/lib/graphql/execution/multiplex.rb @@ -23,191 +23,24 @@ module Execution # @see {Schema#multiplex} for public API # @api private class Multiplex - # Used internally to signal that the query shouldn't be executed - # @api private - NO_OPERATION = {}.freeze - include Tracing::Traceable - attr_reader :context, :queries, :schema, :max_complexity, :dataloader + attr_reader :context, :queries, :schema, :max_complexity, :dataloader, :current_trace + def initialize(schema:, queries:, context:, max_complexity:) @schema = schema @queries = queries @queries.each { |q| q.multiplex = self } @context = context - @context[:dataloader] = @dataloader = @schema.dataloader_class.new - @tracers = schema.tracers + (context[:tracers] || []) - # Support `context: {backtrace: true}` - if context[:backtrace] && !@tracers.include?(GraphQL::Backtrace::Tracer) - @tracers << GraphQL::Backtrace::Tracer - end + @dataloader = @context[:dataloader] ||= @schema.dataloader_class.new + @tracers = schema.tracers + (context[:tracers] || EmptyObjects::EMPTY_ARRAY) @max_complexity = max_complexity + @current_trace = context[:trace] ||= schema.new_trace(multiplex: self) + @logger = nil end - class << self - def run_all(schema, query_options, **kwargs) - queries = query_options.map { |opts| GraphQL::Query.new(schema, nil, **opts) } - run_queries(schema, queries, **kwargs) - end - - # @param schema [GraphQL::Schema] - # @param queries [Array] - # @param context [Hash] - # @param max_complexity [Integer, nil] - # @return [Array] One result per query - def run_queries(schema, queries, context: {}, max_complexity: schema.max_complexity) - multiplex = self.new(schema: schema, queries: queries, context: context, max_complexity: max_complexity) - multiplex.trace("execute_multiplex", { multiplex: multiplex }) do - if supports_multiplexing?(schema) - instrument_and_analyze(multiplex) do - run_as_multiplex(multiplex) - end - else - if queries.length != 1 - raise ArgumentError, "Multiplexing doesn't support custom execution strategies, run one query at a time instead" - else - instrument_and_analyze(multiplex) do - [run_one_legacy(schema, queries.first)] - end - end - end - end - end - - # @param query [GraphQL::Query] - def begin_query(results, idx, query, multiplex) - operation = query.selected_operation - result = if operation.nil? || !query.valid? || query.context.errors.any? - NO_OPERATION - else - begin - # These were checked to be the same in `#supports_multiplexing?` - query.schema.query_execution_strategy.begin_query(query, multiplex) - rescue GraphQL::ExecutionError => err - query.context.errors << err - NO_OPERATION - end - end - results[idx] = result - nil - end - - private - - def run_as_multiplex(multiplex) - - multiplex.schema.query_execution_strategy.begin_multiplex(multiplex) - queries = multiplex.queries - # Do as much eager evaluation of the query as possible - results = [] - queries.each_with_index do |query, idx| - multiplex.dataloader.append_job { begin_query(results, idx, query, multiplex) } - end - - multiplex.dataloader.run - - # Then, work through lazy results in a breadth-first way - multiplex.dataloader.append_job { - multiplex.schema.query_execution_strategy.finish_multiplex(results, multiplex) - } - multiplex.dataloader.run - - # Then, find all errors and assign the result to the query object - results.each_with_index do |data_result, idx| - query = queries[idx] - finish_query(data_result, query, multiplex) - # Get the Query::Result, not the Hash - results[idx] = query.result - end - - results - rescue Exception - # TODO rescue at a higher level so it will catch errors in analysis, too - # Assign values here so that the query's `@executed` becomes true - queries.map { |q| q.result_values ||= {} } - raise - end - - # @param data_result [Hash] The result for the "data" key, if any - # @param query [GraphQL::Query] The query which was run - # @return [Hash] final result of this query, including all values and errors - def finish_query(data_result, query, multiplex) - # Assign the result so that it can be accessed in instrumentation - query.result_values = if data_result.equal?(NO_OPERATION) - if !query.valid? || query.context.errors.any? - # A bit weird, but `Query#static_errors` _includes_ `query.context.errors` - { "errors" => query.static_errors.map(&:to_h) } - else - data_result - end - else - # Use `context.value` which was assigned during execution - result = query.schema.query_execution_strategy.finish_query(query, multiplex) - - if query.context.errors.any? - error_result = query.context.errors.map(&:to_h) - result["errors"] = error_result - end - - result - end - end - - # use the old `query_execution_strategy` etc to run this query - def run_one_legacy(schema, query) - GraphQL::Deprecation.warn "Multiplex.run_one_legacy will be removed from GraphQL-Ruby 2.0, upgrade to the Interpreter to avoid this deprecated codepath: https://graphql-ruby.org/queries/interpreter.html" - - query.result_values = if !query.valid? - all_errors = query.validation_errors + query.analysis_errors + query.context.errors - if all_errors.any? - { "errors" => all_errors.map(&:to_h) } - else - nil - end - else - GraphQL::Query::Executor.new(query).result - end - end - - DEFAULT_STRATEGIES = [ - GraphQL::Execution::Execute, - GraphQL::Execution::Interpreter - ] - # @return [Boolean] True if the schema is only using one strategy, and it's one that supports multiplexing. - def supports_multiplexing?(schema) - schema_strategies = [schema.query_execution_strategy, schema.mutation_execution_strategy, schema.subscription_execution_strategy] - schema_strategies.uniq! - schema_strategies.size == 1 && DEFAULT_STRATEGIES.include?(schema_strategies.first) - end - - # Apply multiplex & query instrumentation to `queries`. - # - # It yields when the queries should be executed, then runs teardown. - def instrument_and_analyze(multiplex) - GraphQL::Execution::Instrumentation.apply_instrumenters(multiplex) do - schema = multiplex.schema - if schema.interpreter? && schema.analysis_engine != GraphQL::Analysis::AST - raise <<-ERR -Can't use `GraphQL::Execution::Interpreter` without `GraphQL::Analysis::AST`, please add this plugin to your schema: - - use GraphQL::Analysis::AST - -For information about the new analysis engine: https://graphql-ruby.org/queries/ast_analysis.html -ERR - end - multiplex_analyzers = schema.multiplex_analyzers - if multiplex.max_complexity - multiplex_analyzers += if schema.using_ast_analysis? - [GraphQL::Analysis::AST::MaxQueryComplexity] - else - [GraphQL::Analysis::MaxQueryComplexity.new(multiplex.max_complexity)] - end - end - - schema.analysis_engine.analyze_multiplex(multiplex, multiplex_analyzers) - yield - end - end + def logger + @logger ||= @schema.logger_for(context) end end end diff --git a/lib/graphql/execution/next.rb b/lib/graphql/execution/next.rb new file mode 100644 index 00000000000..7dc42d47864 --- /dev/null +++ b/lib/graphql/execution/next.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true +require "graphql/execution/prepare_object_step" +require "graphql/execution/input_values" +require "graphql/execution/field_resolve_step" +require "graphql/execution/finalize" +require "graphql/execution/load_argument_step" +require "graphql/execution/resolve_type_step" +require "graphql/execution/runner" +require "graphql/execution/selections_step" +module GraphQL + module Execution + module Finalizer + attr_accessor :path + + def finalize_graphql_result(query, result_data, result_key) + raise RequiredImplementationMissingError, "#{self.class} must implement #finalize_graphql_result(query, result_data, result_key)\n\nresult_data: #{result_data}\nresult_key: #{result_key.inspect}" + end + + def ast_node + ast_nodes&.first + end + + def ast_node=(new_node) + @ast_nodes = [new_node] + end + + attr_accessor :ast_nodes + end + + module HaltExecution + end + + module PostProcessor + def after_resolve(field_results) + raise RequiredImplementationMissingError, "#{self.class}#after_resolve should handle `field_results` and return a new value to use" + end + end + + module Next + module SchemaExtension + def execute_next(query_str = nil, query: nil, subscription_topic: nil, context: nil, document: nil, operation_name: nil, variables: nil, warden: nil, root_value: nil, validate: true, visibility_profile: nil) + multiplex_context = if context + { + backtrace: context[:backtrace], + tracers: context[:tracers], + trace: context[:trace], + dataloader: context[:dataloader], + trace_mode: context[:trace_mode], + } + else + {} + end + query_opts = { + query: query || query_str, + subscription_topic: subscription_topic, + document: document, + context: context, + validate: validate, + variables: variables, + root_value: root_value, + operation_name: operation_name, + visibility_profile: visibility_profile, + warden: warden, + } + m_results = multiplex_next([query_opts], context: multiplex_context, max_complexity: nil) + m_results[0] + end + + def multiplex_next(query_options, context: {}, max_complexity: self.max_complexity) + Next.run_all(self, query_options, context: context, max_complexity: max_complexity) + end + end + + def self.use(schema, as_default: false) + schema.extend(SchemaExtension) + schema.default_execution_next(as_default) + end + + def self.run_all(schema, query_options, context: {}, max_complexity: schema.max_complexity) + queries = query_options.map do |opts| + query = case opts + when Hash + schema.query_class.new(schema, nil, **opts) + when GraphQL::Query, GraphQL::Query::Partial + opts + else + raise "Expected Hash or GraphQL::Query, not #{opts.class} (#{opts.inspect})" + end + query.context[:__graphql_execute_next] = true + query + end + multiplex = Execution::Multiplex.new(schema: schema, queries: queries, context: context, max_complexity: max_complexity) + runner = Runner.new(multiplex) + runner.execute + end + end + end +end diff --git a/lib/graphql/execution/prepare_object_step.rb b/lib/graphql/execution/prepare_object_step.rb new file mode 100644 index 00000000000..59ac39fef4b --- /dev/null +++ b/lib/graphql/execution/prepare_object_step.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true +module GraphQL + module Execution + class PrepareObjectStep + def initialize(object:, runner:, graphql_result:, key:, is_non_null:, field_resolve_step:, next_objects:, next_results:, is_from_array:) + @object = object + @runner = runner + @field_resolve_step = field_resolve_step + @is_non_null = is_non_null + @next_objects = next_objects + @next_results = next_results + @graphql_result = graphql_result + @resolved_type = nil + @authorized_value = nil + @authorization_error = nil + @key = key + @next_step = :resolve_type + @is_from_array = is_from_array + end + + def value + @field_resolve_step.set_current_field + if @authorized_value + query = @field_resolve_step.selections_step.query + query.current_trace.begin_authorized(@resolved_type, @object, query.context) + @authorized_value = @field_resolve_step.sync(@authorized_value) + query.current_trace.end_authorized(@resolved_type, @object, query.context, @authorized_value) + elsif @resolved_type + ctx = @field_resolve_step.selections_step.query.context + st = @field_resolve_step.static_type + ctx.query.current_trace.begin_resolve_type(st, @object, ctx) + @resolved_type, new_value = @field_resolve_step.sync(@resolved_type) + ResolveTypeStep.assert_valid_resolved_type(st, @resolved_type, new_value, @field_resolve_step) + if new_value + @object = new_value + end + ctx.query.current_trace.end_resolve_type(st, @object, ctx, @resolved_type) + end + @runner.add_step(self) + ensure + @field_resolve_step.set_current_field(nil) + end + + def call + @field_resolve_step.set_current_field + case @next_step + when :resolve_type + static_type = @field_resolve_step.static_type + if static_type.kind.abstract? + query = @field_resolve_step.selections_step.query + @resolved_type, new_value = ResolveTypeStep.resolve_type(static_type, @object, query) + if new_value + ResolveTypeStep.assert_valid_resolved_type(static_type, @resolved_type, new_value, @field_resolve_step) + @object = new_value + end + else + @resolved_type = static_type + end + if @runner.resolves_lazies && @runner.lazy?(@resolved_type) + @next_step = :authorize + @runner.dataloader.lazy_at_depth(@field_resolve_step.path.size, self) + else + authorize + end + when :authorize + authorize + when :create_result + create_result + else + raise ArgumentError, "This is a bug, unknown step: #{@next_step.inspect}" + end + ensure + @field_resolve_step.set_current_field(nil) + end + + def add_field_error(err) + @field_resolve_step.add_graphql_error(@graphql_result, @key, err) + end + + def authorize + if @field_resolve_step.was_scoped && !@resolved_type.reauthorize_scoped_objects + @authorized_value = @object + create_result + return + end + + query = @field_resolve_step.selections_step.query + begin + query.current_trace.begin_authorized(@resolved_type, @object, query.context) + @authorized_value = @resolved_type.authorized?(@object, query.context) + query.current_trace.end_authorized(@resolved_type, @object, query.context, @authorized_value) + rescue GraphQL::UnauthorizedError => auth_err + @authorization_error = auth_err + end + + if @runner.resolves_lazies && @runner.lazy?(@authorized_value) + @runner.dataloader.lazy_at_depth(@field_resolve_step.path.size, self) + @next_step = :create_result + else + create_result + end + rescue GraphQL::RuntimeError => err + @graphql_result[@key] = add_field_error(err) + rescue StandardError => err + query ||= @field_resolve_step.selections_step.query + begin + query.handle_or_reraise(err, field: @field_resolve_step.field_definition, arguments: @field_resolve_step.arguments, object: @object) # rubocop:disable Development/ContextIsPassedCop + rescue GraphQL::RuntimeError => err + @graphql_result[@key] = add_field_error(err) + end + end + + def create_result + if !@authorized_value + @authorization_error ||= GraphQL::UnauthorizedError.new(object: @object, type: @resolved_type, context: @field_resolve_step.selections_step.query.context) + end + + if @authorization_error + begin + new_obj = @runner.schema.unauthorized_object(@authorization_error) + if new_obj + @authorized_value = true + @object = new_obj + elsif @is_non_null + @graphql_result[@key] = @field_resolve_step.add_non_null_error(@is_from_array) + else + @graphql_result[@key] = add_field_error(@authorization_error) + end + rescue GraphQL::RuntimeError => err + if @is_non_null + @graphql_result[@key] = @field_resolve_step.add_non_null_error(@is_from_array) + else + @graphql_result[@key] = add_field_error(err) + end + end + end + + if @authorized_value + next_result_h = {} + @next_results << next_result_h + @next_objects << @object + @graphql_result[@key] = next_result_h + @runner.runtime_type_at[next_result_h] = @resolved_type + @runner.static_type_at[next_result_h] = @field_resolve_step.static_type + end + + @field_resolve_step.authorized_finished(self) + end + end + end +end diff --git a/lib/graphql/execution/resolve_type_step.rb b/lib/graphql/execution/resolve_type_step.rb new file mode 100644 index 00000000000..eb9a520f8a2 --- /dev/null +++ b/lib/graphql/execution/resolve_type_step.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true +module GraphQL + module Execution + class ResolveTypeStep + def self.resolve_type(type, object, query) + query.current_trace.begin_resolve_type(type, object, query.context) + resolved_type_response = query.resolve_type(type, object) + resolved_type = if resolved_type_response.is_a?(Array) + resolved_type_response.first + else + resolved_type_response + end + query.current_trace.end_resolve_type(type, object, query.context, resolved_type) + resolved_type_response + end + + def self.assert_valid_resolved_type(abstract_type, resolved_type, object, field_resolution_step, query: field_resolution_step.selections_step.query) + possible_types = query.types.possible_types(abstract_type) + if !possible_types.include?(resolved_type) + err_class = abstract_type::UnresolvedTypeError + type_error = err_class.new(object, field_resolution_step.field_definition, abstract_type, resolved_type, possible_types) + query.schema.type_error(type_error, query.context) + end + end + end + end +end diff --git a/lib/graphql/execution/runner.rb b/lib/graphql/execution/runner.rb new file mode 100644 index 00000000000..4b780dca72c --- /dev/null +++ b/lib/graphql/execution/runner.rb @@ -0,0 +1,451 @@ +# frozen_string_literal: true +module GraphQL + module Execution + class Runner + def initialize(multiplex) + @multiplex = multiplex + @schema = multiplex.schema + @steps_queue = [] + @runtime_type_at = {}.compare_by_identity + @static_type_at = {}.compare_by_identity + @finalizers = nil + @selected_operation = nil + @dataloader = multiplex.context[:dataloader] ||= @schema.dataloader_class.new + @resolves_lazies = @schema.resolves_lazies? + @input_values = Hash.new do |h, query| + h[query] = InputValues.new(query, self) + end.compare_by_identity + + @runtime_directives = nil + @schema.directives.each do |name, dir_class| + if dir_class.runtime? && name != "include" && name != "skip" + @runtime_directives ||= {} + @runtime_directives[dir_class.graphql_name] = dir_class + end + end + + if @runtime_directives.nil? + @uses_runtime_directives = false + @runtime_directives = EmptyObjects::EMPTY_HASH + else + @uses_runtime_directives = true + end + + @lazy_cache = resolves_lazies ? {}.compare_by_identity : nil + @authorizes_cache = Hash.new do |h, query_context| + h[query_context] = {}.compare_by_identity + end.compare_by_identity + end + + attr_reader :runtime_directives, :uses_runtime_directives, :finalizer_keys + + def authorizes?(graphql_definition, query_context) + auth_cache = @authorizes_cache[query_context] + case (auth_res = auth_cache[graphql_definition]) + when nil + auth_cache[graphql_definition] = graphql_definition.authorizes?(query_context) + else + auth_res + end + end + + def add_step(step) + @dataloader.append_job(step) + end + + attr_reader :steps_queue, :schema, :variables, :dataloader, :resolves_lazies, :authorizes, :static_type_at, :runtime_type_at, :finalizers, :input_values + + # @return [void] + def add_finalizer(query, result_value, key, finalizer) + @finalizers ||= {}.compare_by_identity + f_for_query = @finalizers[query] ||= {}.compare_by_identity + f_for_result = f_for_query[result_value] ||= {}.compare_by_identity + if (f = f_for_result[key]) + if f.is_a?(Array) + f << finalizer + else + f_for_result[key] = [f, finalizer] + end + else + f_for_result[key] = finalizer + end + nil + end + + def execute + Fiber[:__graphql_current_multiplex] = @multiplex + isolated_steps = [[]] + trace = @multiplex.current_trace + queries = @multiplex.queries + multiplex_analyzers = @schema.multiplex_analyzers + if @multiplex.max_complexity + multiplex_analyzers += [GraphQL::Analysis::MaxQueryComplexity] + end + + trace.execute_multiplex(multiplex: @multiplex) do + trace.begin_analyze_multiplex(@multiplex, multiplex_analyzers) + @schema.analysis_engine.analyze_multiplex(@multiplex, multiplex_analyzers) + trace.end_analyze_multiplex(@multiplex, multiplex_analyzers) + + results = [] + queries.each do |query| + if query.validate && !query.valid? + results << { + "errors" => query.static_errors.map(&:to_h) + } + next + end + + root_type = query.root_type + + if root_type.non_null? + root_type = root_type.of_type + end + + root_value = query.root_value + if resolves_lazies + root_value = schema.sync_lazy(root_value) + end + + trace.execute_query(query: query) do + begin_execute(isolated_steps, results, query, root_type, root_value) + end + rescue GraphQL::RuntimeError => err + err.ast_node = query.selected_operation + err.path = query.path + query.context.add_error(err) + end + + trace.execute_query_lazy(query: @multiplex.queries.size == 1 ? @multiplex.queries.first : nil, multiplex: @multiplex) do + while (next_isolated_steps = isolated_steps.shift) + next_isolated_steps.each do |step| + add_step(step) + end + @dataloader.run + end + end + + queries.each_with_index.map do |query, idx| + result = results[idx] + + fin_result = if (!@finalizers&.key?(query) && query.context.errors.empty?) || !query.valid? + result + else + if result + data = result["data"] + data = Finalize.new(query, data, self).run + end + errors = [] + query.context.errors.each do |err| + if err.respond_to?(:to_h) + errors << err.to_h + end + end + res_h = {} + if !errors.empty? + res_h["errors"] = errors + end + res_h["data"] = data + res_h + end + + query.result_values = fin_result + if query.context.namespace?(:__query_result_extensions__) + query.result_values["extensions"] = query.context.namespace(:__query_result_extensions__) + end + query.result + end + end + ensure + Fiber[:__graphql_current_multiplex] = nil + end + + def gather_selections(type_defn, ast_selections, selections_step, query, all_selections, prototype_result, into:) + ast_selections.each do |ast_selection| + next if !directives_include?(query, ast_selection) + + case ast_selection + when GraphQL::Language::Nodes::Field + key = ast_selection.alias || ast_selection.name + step = into[key] ||= begin + prototype_result[key] = nil + + FieldResolveStep.new( + selections_step: selections_step, + key: key, + parent_type: type_defn, + runner: self, + ) + end + step.append_selection(ast_selection) + when GraphQL::Language::Nodes::InlineFragment + type_condition = ast_selection.type&.name + if type_condition.nil? || type_condition_applies?(query.context, type_defn, type_condition) + if uses_runtime_directives && !ast_selection.directives.empty? + all_selections << (into = { __node: ast_selection }) + all_selections << (prototype_result = {}) + end + gather_selections(type_defn, ast_selection.selections, selections_step, query, all_selections, prototype_result, into: into) + end + when GraphQL::Language::Nodes::FragmentSpread + fragment_definition = query.fragments[ast_selection.name] + type_condition = fragment_definition.type.name + if type_condition_applies?(query.context, type_defn, type_condition) + if uses_runtime_directives && !ast_selection.directives.empty? + all_selections << (into = { __node: ast_selection }) + all_selections << (prototype_result = {}) + end + gather_selections(type_defn, fragment_definition.selections, selections_step, query, all_selections, prototype_result, into: into) + end + else + raise ArgumentError, "Unsupported graphql selection node: #{ast_selection.class} (#{ast_selection.inspect})" + end + end + end + + def lazy?(object) + obj_class = object.class + is_lazy = @lazy_cache[obj_class] + if is_lazy.nil? + is_lazy = @lazy_cache[obj_class] = @schema.lazy?(object) + end + is_lazy + end + + def type_condition_applies?(context, concrete_type, type_name) + if type_name == concrete_type.graphql_name + true + else + abs_t = @schema.get_type(type_name, context) + p_types = @schema.possible_types(abs_t, context) + c_p_types = @schema.possible_types(concrete_type, context) + p_types.any? { |t| c_p_types.include?(t) } + end + end + + private + + def begin_execute(isolated_steps, results, query, root_type, root_value) + data = {} + @static_type_at[data] = root_type + selected_operation = query.selected_operation + beginning_path = query.path + + case root_type.kind.name + when "OBJECT" + if authorizes?(root_type, query.context) + query.current_trace.begin_authorized(root_type, root_value, query.context) + auth_check = schema.sync_lazy(root_type.authorized?(root_value, query.context)) + query.current_trace.end_authorized(root_type, root_value, query.context, auth_check) + root_value = if auth_check + root_value + else + begin + auth_err = GraphQL::UnauthorizedError.new(object: root_value, type: root_type, context: query.context) + new_val = schema.unauthorized_object(auth_err) + if new_val + auth_check = true + end + new_val + rescue GraphQL::ExecutionError => ex_err + # The old runtime didn't add path and ast_nodes to this + ex_err.path = beginning_path + query.context.add_error(ex_err) + nil + end + end + + if !auth_check + results << {} + return + end + end + + results << { "data" => data } + objects = [root_value] + query.current_trace.objects(root_type, objects, query.context) + + if query.is_a?(GraphQL::Query) && uses_runtime_directives && (query_dirs = selected_operation.directives).any? # rubocop:disable Development/NoneWithoutBlockCop + continue_execution = true + query_dirs.each do |dir_node| + dir_defn = runtime_directives[dir_node.name] || raise(GraphQL::Error, "No directive definition found for: #{dir_node.name.inspect}") + dir_args, errors = input_values[query].argument_values(dir_defn, dir_node.arguments, nil) # rubocop:disable Development/ContextIsPassedCop + if errors + errors.each { |e| + e.ast_node = dir_node + e.path = beginning_path + query.context.add_error(e) + } + continue_execution = false + break + end + result = dir_defn.resolve_operation(selected_operation, query, objects, dir_args, query.context) + if result.is_a?(Finalizer) + result.path = beginning_path + add_finalizer(query, data, nil, result) + if result.is_a?(HaltExecution) + continue_execution = false + break + end + end + end + + if !continue_execution + return + end + end + + if query.query? + isolated_steps[0] << SelectionsStep.new( + parent_type: root_type, + field_resolve_step: nil, + selections: selected_operation.selections, + objects: objects, + results: [data], + path: beginning_path, + runner: self, + query: query, + ) + elsif query.mutation? + fields = {} + all_selections = [fields, (prototype_result = {})] + gather_selections(root_type, selected_operation.selections, nil, query, all_selections, prototype_result, into: fields) + if all_selections.length > 2 + # TODO DRY with SelectionsStep with directive handling + raise "Directives on root mutation type not implemented yet" + end + fields.each_value do |field_resolve_step| + isolated_steps << [SelectionsStep.new( + clobber: false, # `data` is being shared among several selections steps + parent_type: root_type, + field_resolve_step: field_resolve_step, + selections: field_resolve_step.ast_nodes || Array(field_resolve_step.ast_node), + objects: objects, + results: [data], + path: beginning_path, + runner: self, + query: query, + )] + end + elsif query.subscription? + if !query.subscription_update? + schema.subscriptions.initialize_subscriptions(query) + add_finalizer(query, data, nil, schema.subscriptions.finalizer) + end + isolated_steps[0] << SelectionsStep.new( + parent_type: root_type, + field_resolve_step: nil, + selections: selected_operation.selections, + objects: objects, + results: [data], + path: beginning_path, + runner: self, + query: query, + ) + else + raise ArgumentError, "Unknown operation type (not query, mutation or subscription): #{query.query_string}" + end + when "UNION", "INTERFACE" + resolved_type = ResolveTypeStep.resolve_type(root_type, root_value, query) + if resolves_lazies && lazy?(resolved_type) + resolved_type = schema.sync_lazy(resolved_type) + end + resolved_type, root_value = resolved_type + ResolveTypeStep.assert_valid_resolved_type(root_type, resolved_type, root_value, nil, query: query) + objects = [root_value] + query.current_trace.objects(resolved_type, objects, query.context) + runtime_type_at[data] = resolved_type + results << { "data" => data } + isolated_steps[0] << SelectionsStep.new( + parent_type: resolved_type, + field_resolve_step: nil, + selections: selected_operation.selections, + objects: objects, + results: [data], + path: beginning_path, + runner: self, + query: query, + ) + when "LIST" + inner_type = root_type.unwrap + case inner_type.kind.name + when "SCALAR", "ENUM" + results << run_isolated_scalar(root_type, query) + else + list_result = Array.new(root_value.size) { Hash.new.compare_by_identity } + results << { "data" => list_result } + isolated_steps[0] << SelectionsStep.new( + parent_type: inner_type, + field_resolve_step: nil, + selections: selected_operation.selections, + objects: root_value, + results: list_result, + path: beginning_path, + runner: self, + query: query, + ) + end + when "SCALAR", "ENUM" + results << run_isolated_scalar(root_type, query) + else + raise "Unhandled root type kind: #{root_type.kind.name.inspect}" + end + end + + def directives_include?(query, ast_selection) + if ast_selection.directives.any? { |dir_node| + case dir_node.name + when "skip" + skip_args, _errors = @input_values[query].argument_values(GraphQL::Schema::Directive::Skip, dir_node.arguments, nil) # rubocop:disable Development/ContextIsPassedCop + skip_args[:if] == true + when "include" + include_args, _errors = @input_values[query].argument_values(GraphQL::Schema::Directive::Include, dir_node.arguments, nil) # rubocop:disable Development/ContextIsPassedCop + include_args[:if] == false + else + dir_defn = runtime_directives[dir_node.name] + dir_args, _errors = @input_values[query].argument_values(dir_defn, dir_node.arguments, nil) # rubocop:disable Development/ContextIsPassedCop + !dir_defn.include?(nil, dir_args, query.context) + end + } + false + else + true + end + end + + def run_isolated_scalar(type, partial) + value = partial.root_value + dummy_path = partial.path.dup + key = dummy_path.pop + is_from_array = key.is_a?(Integer) + + if lazy?(value) + value = @schema.sync_lazy(value) + end + selections = partial.ast_nodes + dummy_ss = SelectionsStep.new( + parent_type: nil, + field_resolve_step: nil, + selections: selections, + objects: nil, + results: nil, + path: dummy_path, + runner: self, + query: partial, + ) + dummy_frs = FieldResolveStep.new( + selections_step: dummy_ss, + key: key, + parent_type: nil, + runner: self, + ) + dummy_frs.static_type = type + selections.each { |s| dummy_frs.append_selection(s) } + + result = is_from_array ? [] : {} + dummy_frs.finish_leaf_result(result, key, value, type, partial.context) + { "data" => result[key] } + end + end + end +end diff --git a/lib/graphql/execution/selections_step.rb b/lib/graphql/execution/selections_step.rb new file mode 100644 index 00000000000..11f4a438fd2 --- /dev/null +++ b/lib/graphql/execution/selections_step.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true +module GraphQL + module Execution + class SelectionsStep + def initialize(parent_type:, field_resolve_step:, selections:, objects:, results:, runner:, query:, path:, clobber: true) + @path = path + @field_resolve_step = field_resolve_step + @parent_type = parent_type + @selections = selections + @runner = runner + @objects = objects + @results = results + @query = query + @graphql_objects = nil + @all_selections = nil + @killed = false + @clobber = clobber + end + + attr_reader :path, :query, :objects, :results, :field_resolve_step + + attr_accessor :killed + + def graphql_objects + @graphql_objects ||= @objects.map do |obj| + @parent_type.scoped_new(obj, @query.context) + end + end + + def call + @all_selections = [{}, (prototype_result = {})] + @runner.gather_selections(@parent_type, @selections, self, self.query, @all_selections, @all_selections[1], into: @all_selections[0]) + continue_selections = [] + i = 0 + l = @all_selections.length + while i < l + grouped_selections = @all_selections[i] + selections_prototype_result = @all_selections[i + 1] + if (directives_owner = grouped_selections.delete(:__node)) + directives = directives_owner.directives + continue_execution = true + directives.each do |dir_node| + dir_defn = @runner.runtime_directives[dir_node.name] + if dir_defn # not present for `skip` or `include` + dir_args, _errors = @runner.input_values[query].argument_values(dir_defn, dir_node.arguments, nil) # rubocop:disable Development/ContextIsPassedCop + result = case directives_owner + when Language::Nodes::FragmentSpread + dir_defn.resolve_fragment_spread(directives_owner, @parent_type, @objects, dir_args, self.query.context) + when Language::Nodes::InlineFragment + dir_defn.resolve_inline_fragment(directives_owner, @parent_type, @objects, dir_args, self.query.context) + else + raise ArgumentError, "Unhandled directive owner (#{directives_owner.class}): #{directives_owner.inspect}" + end + if result.is_a?(Finalizer) + result.path = path + @results.each do |r| + @runner.add_finalizer(@query, r, nil, result) + end + if result.is_a?(HaltExecution) + continue_execution = false + break + end + end + + if continue_execution + prototype_result.merge!(selections_prototype_result) + grouped_selections.each_value { |v| continue_selections << v } + end + else + grouped_selections.each_value { |v| continue_selections << v } + end + end + else + grouped_selections.each_value { |v| continue_selections << v } + end + + if @clobber + i2 = 0 + l2 = @results.length + while i2 < l2 + @results[i2].replace(prototype_result) + i2 += 1 + end + end + + i += 2 + end + + continue_selections.each do |frs| + @runner.add_step(frs) + end + end + end + end +end diff --git a/lib/graphql/execution/typecast.rb b/lib/graphql/execution/typecast.rb deleted file mode 100644 index b0c2fec90a9..00000000000 --- a/lib/graphql/execution/typecast.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Execution - # @api private - module Typecast - # @return [Boolean] - def self.subtype?(parent_type, child_type) - if parent_type == child_type - # Equivalent types are subtypes - true - elsif child_type.is_a?(GraphQL::NonNullType) - # A non-null type is a subtype of a nullable type - # if its inner type is a subtype of that type - if parent_type.is_a?(GraphQL::NonNullType) - subtype?(parent_type.of_type, child_type.of_type) - else - subtype?(parent_type, child_type.of_type) - end - else - case parent_type - when GraphQL::InterfaceType - # A type is a subtype of an interface - # if it implements that interface - case child_type - when GraphQL::ObjectType - child_type.interfaces.include?(parent_type) - else - false - end - when GraphQL::UnionType - # A type is a subtype of that union - # if the union includes that type - parent_type.possible_types.include?(child_type) - when GraphQL::ListType - # A list type is a subtype of another list type - # if its inner type is a subtype of the other inner type - case child_type - when GraphQL::ListType - subtype?(parent_type.of_type, child_type.of_type) - else - false - end - else - false - end - end - end - end - end -end diff --git a/lib/graphql/execution_error.rb b/lib/graphql/execution_error.rb index 96a123bf6fe..b79d05fc944 100644 --- a/lib/graphql/execution_error.rb +++ b/lib/graphql/execution_error.rb @@ -3,10 +3,7 @@ module GraphQL # If a field's resolve function returns a {ExecutionError}, # the error will be inserted into the response's `"errors"` key # and the field will resolve to `nil`. - class ExecutionError < GraphQL::Error - # @return [GraphQL::Language::Nodes::Field] the field where the error occurred - attr_accessor :ast_node - + class ExecutionError < GraphQL::RuntimeError # @return [String] an array describing the JSON-path into the execution # response which corresponds to this error. attr_accessor :path @@ -21,25 +18,33 @@ class ExecutionError < GraphQL::Error # under the `extensions` key. attr_accessor :extensions - def initialize(message, ast_node: nil, options: nil, extensions: nil) - @ast_node = ast_node + def initialize(message, ast_node: nil, ast_nodes: nil, options: nil, extensions: nil) + @ast_nodes = ast_nodes || [ast_node] @options = options @extensions = extensions super(message) end + def finalize_graphql_result(query, result_data, key) + add_this_error = !query.context.errors.any? { |e| e.eql?(self) } + if add_this_error + query.context.add_error(self) + end + if ast_node.is_a?(GraphQL::Language::Nodes::Directive) + # This is for backwards compatibility ... what does the spec say? + result_data.delete(key) + else + result_data[key] = nil + end + end + # @return [Hash] An entry for the response's "errors" key def to_h hash = { "message" => message, } if ast_node - hash["locations"] = [ - { - "line" => ast_node.line, - "column" => ast_node.col, - } - ] + hash["locations"] = @ast_nodes.map { |a| { "line" => a.line, "column" => a.col } } end if path hash["path"] = path diff --git a/lib/graphql/field.rb b/lib/graphql/field.rb deleted file mode 100644 index 88d0744d5a9..00000000000 --- a/lib/graphql/field.rb +++ /dev/null @@ -1,226 +0,0 @@ -# frozen_string_literal: true -require "graphql/field/resolve" - -module GraphQL - # @api deprecated - class Field - include GraphQL::Define::InstanceDefinable - accepts_definitions :name, :description, :deprecation_reason, - :resolve, :lazy_resolve, - :type, :arguments, - :property, :hash_key, :complexity, - :mutation, :function, - :edge_class, - :relay_node_field, - :relay_nodes_field, - :subscription_scope, - :trace, - :introspection, - argument: GraphQL::Define::AssignArgument - - ensure_defined( - :name, :deprecation_reason, :description, :description=, :property, :hash_key, - :mutation, :arguments, :complexity, :function, - :resolve, :resolve=, :lazy_resolve, :lazy_resolve=, :lazy_resolve_proc, :resolve_proc, - :type, :type=, :name=, :property=, :hash_key=, - :relay_node_field, :relay_nodes_field, :edges?, :edge_class, :subscription_scope, - :introspection? - ) - - # @return [Boolean] True if this is the Relay find-by-id field - attr_accessor :relay_node_field - - # @return [Boolean] True if this is the Relay find-by-ids field - attr_accessor :relay_nodes_field - - # @return [<#call(obj, args, ctx)>] A proc-like object which can be called to return the field's value - attr_reader :resolve_proc - - # @return [<#call(obj, args, ctx)>] A proc-like object which can be called trigger a lazy resolution - attr_reader :lazy_resolve_proc - - # @return [String] The name of this field on its {GraphQL::ObjectType} (or {GraphQL::InterfaceType}) - attr_reader :name - alias :graphql_name :name - - # @return [String, nil] The client-facing description of this field - attr_accessor :description - - # @return [String, nil] The client-facing reason why this field is deprecated (if present, the field is deprecated) - attr_accessor :deprecation_reason - - # @return [Hash GraphQL::Argument>] Map String argument names to their {GraphQL::Argument} implementations - attr_accessor :arguments - - # @return [GraphQL::Relay::Mutation, nil] The mutation this field was derived from, if it was derived from a mutation - attr_accessor :mutation - - # @return [Numeric, Proc] The complexity for this field (default: 1), as a constant or a proc like `->(query_ctx, args, child_complexity) { } # Numeric` - attr_accessor :complexity - - # @return [Symbol, nil] The method to call on `obj` to return this field (overrides {#name} if present) - attr_reader :property - - # @return [Object, nil] The key to access with `obj.[]` to resolve this field (overrides {#name} if present) - attr_reader :hash_key - - # @return [Object, GraphQL::Function] The function used to derive this field - attr_accessor :function - - attr_accessor :arguments_class - - attr_writer :connection - attr_writer :introspection - - # @return [nil, String] Prefix for subscription names from this field - attr_accessor :subscription_scope - - # @return [Boolean] True if this field should be traced. By default, fields are only traced if they are not a ScalarType or EnumType. - attr_accessor :trace - - attr_accessor :ast_node - - # Future-compatible alias - # @see {GraphQL::SchemaMember} - alias :graphql_definition :itself - - # @return [Boolean] - def connection? - @connection - end - - # @return [nil, Class] - # @api private - attr_accessor :edge_class - - # @return [Boolean] - def edges? - !!@edge_class - end - - # @return [nil, Integer] - attr_accessor :connection_max_page_size - - def initialize - @complexity = 1 - @arguments = {} - @resolve_proc = build_default_resolver - @lazy_resolve_proc = DefaultLazyResolve - @relay_node_field = false - @connection = false - @connection_max_page_size = nil - @edge_class = nil - @trace = nil - @introspection = false - end - - def initialize_copy(other) - ensure_defined - super - @arguments = other.arguments.dup - end - - # @return [Boolean] Is this field a predefined introspection field? - def introspection? - @introspection - end - - # Get a value for this field - # @example resolving a field value - # field.resolve(obj, args, ctx) - # - # @param object [Object] The object this field belongs to - # @param arguments [Hash] Arguments declared in the query - # @param context [GraphQL::Query::Context] - def resolve(object, arguments, context) - resolve_proc.call(object, arguments, context) - end - - # Provide a new callable for this field's resolve function. If `nil`, - # a new resolve proc will be build based on its {#name}, {#property} or {#hash_key}. - # @param new_resolve_proc [<#call(obj, args, ctx)>, nil] - def resolve=(new_resolve_proc) - @resolve_proc = new_resolve_proc || build_default_resolver - end - - def type=(new_return_type) - @clean_type = nil - @dirty_type = new_return_type - end - - # Get the return type for this field. - def type - @clean_type ||= GraphQL::BaseType.resolve_related_type(@dirty_type) - end - - def name=(new_name) - old_name = defined?(@name) ? @name : nil - @name = new_name - - if old_name != new_name && @resolve_proc.is_a?(Field::Resolve::NameResolve) - # Since the NameResolve would use the old field name, - # reset resolve proc when the name has changed - self.resolve = nil - end - end - - # @param new_property [Symbol] A method to call to resolve this field. Overrides the existing resolve proc. - def property=(new_property) - @property = new_property - self.resolve = nil # reset resolve proc - end - - # @param new_hash_key [Symbol] A key to access with `#[key]` to resolve this field. Overrides the existing resolve proc. - def hash_key=(new_hash_key) - @hash_key = new_hash_key - self.resolve = nil # reset resolve proc - end - - def to_s - "" - end - - # If {#resolve} returned an object which should be handled lazily, - # this method will be called later to force the object to return its value. - # @param obj [Object] The {#resolve}-provided object, registered with {Schema#lazy_resolve} - # @param args [GraphQL::Query::Arguments] Arguments to this field - # @param ctx [GraphQL::Query::Context] Context for this field - # @return [Object] The result of calling the registered method on `obj` - def lazy_resolve(obj, args, ctx) - @lazy_resolve_proc.call(obj, args, ctx) - end - - # Assign a new resolve proc to this field. Used for {#lazy_resolve} - def lazy_resolve=(new_lazy_resolve_proc) - @lazy_resolve_proc = new_lazy_resolve_proc - end - - # Prepare a lazy value for this field. It may be `then`-ed and resolved later. - # @return [GraphQL::Execution::Lazy] A lazy wrapper around `obj` and its registered method name - def prepare_lazy(obj, args, ctx) - GraphQL::Execution::Lazy.new { - lazy_resolve(obj, args, ctx) - } - end - - def type_class - metadata[:type_class] - end - - def get_argument(argument_name) - arguments[argument_name] - end - - private - - def build_default_resolver - GraphQL::Field::Resolve.create_proc(self) - end - - module DefaultLazyResolve - def self.call(obj, args, ctx) - ctx.schema.sync_lazy(obj) - end - end - end -end diff --git a/lib/graphql/field/resolve.rb b/lib/graphql/field/resolve.rb deleted file mode 100644 index c966b19b457..00000000000 --- a/lib/graphql/field/resolve.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Field - # Create resolve procs ahead of time based on a {GraphQL::Field}'s `name`, `property`, and `hash_key` configuration. - module Resolve - module_function - - # @param field [GraphQL::Field] A field that needs a resolve proc - # @return [Proc] A resolver for this field, based on its config - def create_proc(field) - if field.property - MethodResolve.new(field) - elsif !field.hash_key.nil? - HashKeyResolve.new(field.hash_key) - else - NameResolve.new(field) - end - end - - # These only require `obj` as input - class BuiltInResolve - end - - # Resolve the field by `public_send`ing `@method_name` - class MethodResolve < BuiltInResolve - def initialize(field) - @method_name = field.property.to_sym - end - - def call(obj, args, ctx) - obj.public_send(@method_name) - end - end - - # Resolve the field by looking up `@hash_key` with `#[]` - class HashKeyResolve < BuiltInResolve - def initialize(hash_key) - @hash_key = hash_key - end - - def call(obj, args, ctx) - obj[@hash_key] - end - end - - # Call the field's name at query-time since - # it might have changed - class NameResolve < BuiltInResolve - def initialize(field) - @field = field - end - - def call(obj, args, ctx) - obj.public_send(@field.name) - end - end - end - end -end diff --git a/lib/graphql/filter.rb b/lib/graphql/filter.rb deleted file mode 100644 index 250a53ba7ac..00000000000 --- a/lib/graphql/filter.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # @api private - class Filter - def initialize(only: nil, except: nil) - @only = only - @except = except - end - - # Returns true if `member, ctx` passes this filter - def call(member, ctx) - (@only ? @only.call(member, ctx) : true) && - (@except ? !@except.call(member, ctx) : true) - end - - def merge(only: nil, except: nil) - onlies = [self].concat(Array(only)) - merged_only = MergedOnly.build(onlies) - merged_except = MergedExcept.build(Array(except)) - self.class.new(only: merged_only, except: merged_except) - end - - private - - class MergedOnly - def initialize(first, second) - @first = first - @second = second - end - - def call(member, ctx) - @first.call(member, ctx) && @second.call(member, ctx) - end - - def self.build(onlies) - case onlies.size - when 0 - nil - when 1 - onlies[0] - else - onlies.reduce { |memo, only| self.new(memo, only) } - end - end - end - - class MergedExcept < MergedOnly - def call(member, ctx) - @first.call(member, ctx) || @second.call(member, ctx) - end - end - end -end diff --git a/lib/graphql/float_type.rb b/lib/graphql/float_type.rb deleted file mode 100644 index 37cc633bf52..00000000000 --- a/lib/graphql/float_type.rb +++ /dev/null @@ -1,2 +0,0 @@ -# frozen_string_literal: true -GraphQL::FLOAT_TYPE = GraphQL::Types::Float.graphql_definition diff --git a/lib/graphql/function.rb b/lib/graphql/function.rb deleted file mode 100644 index 7be46e825dc..00000000000 --- a/lib/graphql/function.rb +++ /dev/null @@ -1,128 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # @api deprecated - class Function - def self.inherited(subclass) - GraphQL::Deprecation.warn "GraphQL::Function (used for #{subclass}) will be removed from GraphQL-Ruby 2.0, please upgrade to resolvers: https://graphql-ruby.org/fields/resolvers.html" - end - - # @return [Hash GraphQL::Argument>] Arguments, keyed by name - def arguments - self.class.arguments - end - - # @return [GraphQL::BaseType] Return type - def type - self.class.type - end - - # @return [Object] This function's resolver - def call(obj, args, ctx) - raise GraphQL::RequiredImplementationMissingError - end - - # @return [String, nil] - def description - self.class.description - end - - # @return [String, nil] - def deprecation_reason - self.class.deprecation_reason - end - - # @return [Integer, Proc] - def complexity - self.class.complexity || 1 - end - - class << self - # Define an argument for this function & its subclasses - # @see {GraphQL::Field} same arguments as the `argument` definition helper - # @return [void] - def argument(*args, **kwargs, &block) - argument = GraphQL::Argument.from_dsl(*args, **kwargs, &block) - own_arguments[argument.name] = argument - nil - end - - # @return [Hash GraphQL::Argument>] Arguments for this function class, including inherited arguments - def arguments - if parent_function? - own_arguments.merge(superclass.arguments) - else - own_arguments.dup - end - end - - # Provides shorthand access to GraphQL's built-in types - def types - GraphQL::Define::TypeDefiner.instance - end - - # Get or set the return type for this function class & descendants - # @return [GraphQL::BaseType] - def type(premade_type = nil, &block) - if block_given? - @type = GraphQL::ObjectType.define(&block) - elsif premade_type - @type = premade_type - elsif parent_function? - @type || superclass.type - else - @type - end - end - - def build_field(function) - GraphQL::Field.define( - arguments: function.arguments, - complexity: function.complexity, - type: function.type, - resolve: function, - description: function.description, - function: function, - deprecation_reason: function.deprecation_reason, - ) - end - - # Class-level reader/writer which is inherited - # @api private - def self.inherited_value(name) - self.class_eval <<-RUBY - def #{name}(new_value = nil) - if new_value - @#{name} = new_value - elsif parent_function? - @#{name} || superclass.#{name} - else - @#{name} - end - end - RUBY - end - - # @!method description(new_value = nil) - # Get or set this class's description - inherited_value(:description) - # @!method deprecation_reason(new_value = nil) - # Get or set this class's deprecation_reason - inherited_value(:deprecation_reason) - # @!method complexity(new_value = nil) - # Get or set this class's complexity - inherited_value(:complexity) - - private - - # Does this function inherit from another function? - def parent_function? - superclass <= GraphQL::Function - end - - # Arguments defined on this class (not superclasses) - def own_arguments - @own_arguments ||= {} - end - end - end -end diff --git a/lib/graphql/id_type.rb b/lib/graphql/id_type.rb deleted file mode 100644 index 733a28f48ad..00000000000 --- a/lib/graphql/id_type.rb +++ /dev/null @@ -1,2 +0,0 @@ -# frozen_string_literal: true -GraphQL::ID_TYPE = GraphQL::Types::ID.graphql_definition diff --git a/lib/graphql/input_object_type.rb b/lib/graphql/input_object_type.rb deleted file mode 100644 index bd6f0d9ec34..00000000000 --- a/lib/graphql/input_object_type.rb +++ /dev/null @@ -1,138 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # @api deprecated - class InputObjectType < GraphQL::BaseType - extend Define::InstanceDefinable::DeprecatedDefine - - accepts_definitions( - :arguments, :mutation, - input_field: GraphQL::Define::AssignArgument, - argument: GraphQL::Define::AssignArgument - ) - - attr_accessor :mutation, :arguments, :arguments_class - ensure_defined(:mutation, :arguments, :input_fields) - alias :input_fields :arguments - - # @!attribute mutation - # @return [GraphQL::Relay::Mutation, nil] The mutation this field was derived from, if it was derived from a mutation - - # @!attribute arguments - # @return [Hash GraphQL::Argument>] Map String argument names to their {GraphQL::Argument} implementations - - - def initialize - super - @arguments = {} - end - - def initialize_copy(other) - super - @arguments = other.arguments.dup - end - - def kind - GraphQL::TypeKinds::INPUT_OBJECT - end - - def coerce_result(value, ctx = nil) - if ctx.nil? - warn_deprecated_coerce("coerce_isolated_result") - ctx = GraphQL::Query::NullContext - end - - # Allow the application to provide values as :symbols, and convert them to the strings - value = value.reduce({}) { |memo, (k, v)| memo[k.to_s] = v; memo } - - result = {} - - arguments.each do |input_key, input_field_defn| - input_value = value[input_key] - if value.key?(input_key) - result[input_key] = if input_value.nil? - nil - else - input_field_defn.type.coerce_result(input_value, ctx) - end - end - end - - result - end - - def get_argument(argument_name) - arguments[argument_name] - end - - private - - def coerce_non_null_input(value, ctx) - input_values = {} - defaults_used = Set.new - - arguments.each do |input_key, input_field_defn| - field_value = value[input_key] - - if value.key?(input_key) - coerced_value = input_field_defn.type.coerce_input(field_value, ctx) - input_values[input_key] = input_field_defn.prepare(coerced_value, ctx) - elsif input_field_defn.default_value? - coerced_value = input_field_defn.type.coerce_input(input_field_defn.default_value, ctx) - input_values[input_key] = coerced_value - defaults_used << input_key - end - end - - result = arguments_class.new(input_values, context: ctx, defaults_used: defaults_used) - result.prepare - end - - # @api private - INVALID_OBJECT_MESSAGE = "Expected %{object} to be a key, value object responding to `to_h` or `to_unsafe_h`." - - def validate_non_null_input(input, ctx) - warden = ctx.warden - result = GraphQL::Query::InputValidationResult.new - - if input.is_a?(Array) - result.add_problem(INVALID_OBJECT_MESSAGE % { object: JSON.generate(input, quirks_mode: true) }) - return result - end - - # We're not actually _using_ the coerced result, we're just - # using these methods to make sure that the object will - # behave like a hash below, when we call `each` on it. - begin - input.to_h - rescue - begin - # Handle ActionController::Parameters: - input.to_unsafe_h - rescue - # We're not sure it'll act like a hash, so reject it: - result.add_problem(INVALID_OBJECT_MESSAGE % { object: JSON.generate(input, quirks_mode: true) }) - return result - end - end - - visible_arguments_map = warden.arguments(self).reduce({}) { |m, f| m[f.name] = f; m} - - # Items in the input that are unexpected - input.each do |name, value| - if visible_arguments_map[name].nil? - result.add_problem("Field is not defined on #{self.graphql_name}", [name]) - end - end - - # Items in the input that are expected, but have invalid values - visible_arguments_map.map do |name, field| - field_result = field.type.validate_input(input[name], ctx) - if !field_result.valid? - result.merge_result!(name, field_result) - end - end - - result - end - end -end diff --git a/lib/graphql/int_type.rb b/lib/graphql/int_type.rb deleted file mode 100644 index 425592dd4fe..00000000000 --- a/lib/graphql/int_type.rb +++ /dev/null @@ -1,2 +0,0 @@ -# frozen_string_literal: true -GraphQL::INT_TYPE = GraphQL::Types::Int.graphql_definition diff --git a/lib/graphql/integer_encoding_error.rb b/lib/graphql/integer_encoding_error.rb index f69bb318429..5de3b52b34f 100644 --- a/lib/graphql/integer_encoding_error.rb +++ b/lib/graphql/integer_encoding_error.rb @@ -12,9 +12,25 @@ class IntegerEncodingError < GraphQL::RuntimeTypeError # The value which couldn't be encoded attr_reader :integer_value - def initialize(value) + # @return [GraphQL::Schema::Field] The field that returned a too-big integer + attr_reader :field + + # @return [Array] Where the field appeared in the GraphQL response + attr_reader :path + + def initialize(value, context:) @integer_value = value - super("Integer out of bounds: #{value}. \nConsider using ID or GraphQL::Types::BigInt instead.") + @field = context[:current_field] + @path = context[:current_path] + message = "Integer out of bounds: #{value}".dup + if @path + message << " @ #{@path.join(".")}" + end + if @field + message << " (#{@field.path})" + end + message << ". Consider using ID or GraphQL::Types::BigInt instead." + super(message) end end end diff --git a/lib/graphql/interface_type.rb b/lib/graphql/interface_type.rb deleted file mode 100644 index 778609cb727..00000000000 --- a/lib/graphql/interface_type.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # @api deprecated - class InterfaceType < GraphQL::BaseType - extend Define::InstanceDefinable::DeprecatedDefine - - accepts_definitions :fields, :orphan_types, :resolve_type, field: GraphQL::Define::AssignObjectField - - attr_accessor :fields, :orphan_types, :resolve_type_proc - attr_writer :type_membership_class - ensure_defined :fields, :orphan_types, :resolve_type_proc, :resolve_type - - def initialize - super - @fields = {} - @orphan_types = [] - @resolve_type_proc = nil - end - - def initialize_copy(other) - super - @fields = other.fields.dup - @orphan_types = other.orphan_types.dup - end - - def kind - GraphQL::TypeKinds::INTERFACE - end - - def resolve_type(value, ctx) - ctx.query.resolve_type(self, value) - end - - def resolve_type=(resolve_type_callable) - @resolve_type_proc = resolve_type_callable - end - - # @return [GraphQL::Field] The defined field for `field_name` - def get_field(field_name) - fields[field_name] - end - - # These fields don't have instrumenation applied - # @see [Schema#get_fields] Get fields with instrumentation - # @return [Array] All fields on this type - def all_fields - fields.values - end - - # Get a possible type of this {InterfaceType} by type name - # @param type_name [String] - # @param ctx [GraphQL::Query::Context] The context for the current query - # @return [GraphQL::ObjectType, nil] The type named `type_name` if it exists and implements this {InterfaceType}, (else `nil`) - def get_possible_type(type_name, ctx) - type = ctx.query.get_type(type_name) - type if type && ctx.query.warden.possible_types(self).include?(type) - end - - # Check if a type is a possible type of this {InterfaceType} - # @param type [String, GraphQL::BaseType] Name of the type or a type definition - # @param ctx [GraphQL::Query::Context] The context for the current query - # @return [Boolean] True if the `type` exists and is a member of this {InterfaceType}, (else `nil`) - def possible_type?(type, ctx) - type_name = type.is_a?(String) ? type : type.graphql_name - !get_possible_type(type_name, ctx).nil? - end - - def type_membership_class - @type_membership_class || GraphQL::Schema::TypeMembership - end - end -end diff --git a/lib/graphql/internal_representation.rb b/lib/graphql/internal_representation.rb deleted file mode 100644 index df6a7b8b964..00000000000 --- a/lib/graphql/internal_representation.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true -require "graphql/internal_representation/document" -require "graphql/internal_representation/node" -require "graphql/internal_representation/print" -require "graphql/internal_representation/rewrite" -require "graphql/internal_representation/scope" -require "graphql/internal_representation/visit" diff --git a/lib/graphql/internal_representation/document.rb b/lib/graphql/internal_representation/document.rb deleted file mode 100644 index 3279e66dc39..00000000000 --- a/lib/graphql/internal_representation/document.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module InternalRepresentation - class Document - # @return [Hash] Operation Nodes of this query - attr_reader :operation_definitions - - # @return [Hash] Fragment definition Nodes of this query - attr_reader :fragment_definitions - - def initialize - @operation_definitions = {} - @fragment_definitions = {} - end - - def [](key) - GraphQL::Deprecation.warn "#{self.class}#[] is deprecated; use `operation_definitions[]` instead" - operation_definitions[key] - end - - def each(&block) - GraphQL::Deprecation.warn "#{self.class}#each is deprecated; use `operation_definitions.each` instead" - operation_definitions.each(&block) - end - end - end -end diff --git a/lib/graphql/internal_representation/node.rb b/lib/graphql/internal_representation/node.rb deleted file mode 100644 index c3400a0373b..00000000000 --- a/lib/graphql/internal_representation/node.rb +++ /dev/null @@ -1,206 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module InternalRepresentation - class Node - # @api private - DEFAULT_TYPED_CHILDREN = Proc.new { |h, k| h[k] = {} } - - # A specialized, reusable object for leaf nodes. - NO_TYPED_CHILDREN = Hash.new({}.freeze) - def NO_TYPED_CHILDREN.dup; self; end; - NO_TYPED_CHILDREN.freeze - - # @return [String] the name this node has in the response - attr_reader :name - - # @return [GraphQL::ObjectType] - attr_reader :owner_type - - # Each key is a {GraphQL::ObjectType} which this selection _may_ be made on. - # The values for that key are selections which apply to that type. - # - # This value is derived from {#scoped_children} after the rewrite is finished. - # @return [Hash Node>>] - def typed_children - @typed_children ||= begin - if @scoped_children.any? - new_tc = Hash.new(&DEFAULT_TYPED_CHILDREN) - all_object_types = Set.new - scoped_children.each_key { |t| all_object_types.merge(@query.possible_types(t)) } - # Remove any scoped children which don't follow this return type - # (This can happen with fragment merging where lexical scope is lost) - all_object_types &= @query.possible_types(@return_type.unwrap) - all_object_types.each do |t| - new_tc[t] = get_typed_children(t) - end - new_tc - else - NO_TYPED_CHILDREN - end - - end - end - - # These children correspond closely to scopes in the AST. - # Keys _may_ be abstract types. They're assumed to be read-only after rewrite is finished - # because {#typed_children} is derived from them. - # - # Using {#scoped_children} during the rewrite step reduces the overhead of reifying - # abstract types because they're only reified _after_ the rewrite. - # @return [Hash Node>>] - attr_reader :scoped_children - - # @return [Array] AST nodes which are represented by this node - attr_reader :ast_nodes - - # @return [Array] Field definitions for this node (there should only be one!) - attr_reader :definitions - - # @return [GraphQL::BaseType] The expected wrapped type this node must return. - attr_reader :return_type - - # @return [InternalRepresentation::Node, nil] - attr_reader :parent - - def initialize( - name:, owner_type:, query:, return_type:, parent:, - ast_nodes: [], - definitions: [] - ) - @name = name - @query = query - @owner_type = owner_type - @parent = parent - @typed_children = nil - @scoped_children = Hash.new { |h1, k1| h1[k1] = {} } - @ast_nodes = ast_nodes - @definitions = definitions - @return_type = return_type - end - - def initialize_copy(other_node) - super - # Bust some caches: - @typed_children = nil - @definition = nil - @definition_name = nil - @ast_node = nil - # Shallow-copy some state: - @scoped_children = other_node.scoped_children.dup - @ast_nodes = other_node.ast_nodes.dup - @definitions = other_node.definitions.dup - end - - def ==(other) - other.is_a?(self.class) && - other.name == name && - other.parent == parent && - other.return_type == return_type && - other.owner_type == owner_type && - other.scoped_children == scoped_children && - other.definitions == definitions && - other.ast_nodes == ast_nodes - end - - def definition_name - definition && definition.name - end - - def arguments - @query.arguments_for(self, definition) - end - - def definition - @definition ||= begin - first_def = @definitions.first - first_def && @query.get_field(@owner_type, first_def.name) - end - end - - def ast_node - @ast_node ||= ast_nodes.first - end - - def inspect - all_children_names = scoped_children.values.map(&:keys).flatten.uniq.join(", ") - all_locations = ast_nodes.map {|n| "#{n.line}:#{n.col}" }.join(", ") - "# #{@return_type} {#{all_children_names}} @ [#{all_locations}] #{object_id}>" - end - - # Merge selections from `new_parent` into `self`. - # Selections are merged in place, not copied. - def deep_merge_node(new_parent, scope: nil, merge_self: true) - if merge_self - @ast_nodes |= new_parent.ast_nodes - @definitions |= new_parent.definitions - end - new_sc = new_parent.scoped_children - if new_sc.any? - scope ||= Scope.new(@query, @return_type.unwrap) - new_sc.each do |obj_type, new_fields| - inner_scope = scope.enter(obj_type) - inner_scope.each do |scoped_type| - prev_fields = @scoped_children[scoped_type] - new_fields.each do |name, new_node| - prev_node = prev_fields[name] - if prev_node - prev_node.deep_merge_node(new_node) - else - prev_fields[name] = new_node - end - end - end - end - end - end - - # @return [GraphQL::Query] - attr_reader :query - - def subscription_topic - @subscription_topic ||= begin - scope = if definition.subscription_scope - @query.context[definition.subscription_scope] - else - nil - end - Subscriptions::Event.serialize( - definition_name, - @query.arguments_for(self, definition), - definition, - scope: scope - ) - end - end - - protected - - attr_writer :owner_type, :parent - - private - - # Get applicable children from {#scoped_children} - # @param obj_type [GraphQL::ObjectType] - # @return [Hash Node>] - def get_typed_children(obj_type) - new_tc = {} - @scoped_children.each do |scope_type, scope_nodes| - if GraphQL::Execution::Typecast.subtype?(scope_type, obj_type) - scope_nodes.each do |name, new_node| - prev_node = new_tc[name] - if prev_node - prev_node.deep_merge_node(new_node) - else - copied_node = new_node.dup - copied_node.owner_type = obj_type - copied_node.parent = self - new_tc[name] = copied_node - end - end - end - end - new_tc - end - end - end -end diff --git a/lib/graphql/internal_representation/print.rb b/lib/graphql/internal_representation/print.rb deleted file mode 100644 index 5e628ccd350..00000000000 --- a/lib/graphql/internal_representation/print.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module InternalRepresentation - module Print - module_function - - def print(schema, query_string) - query = GraphQL::Query.new(schema, query_string) - print_node(query.irep_selection) - end - - def print_node(node, indent: 0) - padding = " " * indent - typed_children_padding = " " * (indent + 2) - query_str = "".dup - - if !node.definition - op_node = node.ast_node - name = op_node.name ? " " + op_node.name : "" - op_type = op_node.operation_type - query_str << "#{op_type}#{name}" - else - if node.name == node.definition_name - query_str << "#{padding}#{node.name}" - else - query_str << "#{padding}#{node.name}: #{node.definition_name}" - end - - args = node.ast_nodes.map { |n| n.arguments.map(&:to_query_string).join(",") }.uniq - query_str << args.map { |a| "(#{a})"}.join("|") - end - - if node.typed_children.any? - query_str << " {\n" - node.typed_children.each do |type, children| - query_str << "#{typed_children_padding}... on #{type.name} {\n" - children.each do |name, child| - query_str << print_node(child, indent: indent + 4) - end - query_str << "#{typed_children_padding}}\n" - end - query_str << "#{padding}}\n" - else - query_str << "\n" - end - - query_str - end - end - end -end diff --git a/lib/graphql/internal_representation/rewrite.rb b/lib/graphql/internal_representation/rewrite.rb deleted file mode 100644 index ba76655c533..00000000000 --- a/lib/graphql/internal_representation/rewrite.rb +++ /dev/null @@ -1,184 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module InternalRepresentation - # While visiting an AST, build a normalized, flattened tree of {InternalRepresentation::Node}s. - # - # No unions or interfaces are present in this tree, only object types. - # - # Selections from the AST are attached to the object types they apply to. - # - # Inline fragments and fragment spreads are preserved in {InternalRepresentation::Node#ast_spreads}, - # where they can be used to check for the presence of directives. This might not be sufficient - # for future directives, since the selections' grouping is lost. - # - # The rewritten query tree serves as the basis for the `FieldsWillMerge` validation. - # - module Rewrite - include GraphQL::Language - - NO_DIRECTIVES = [].freeze - - # @return InternalRepresentation::Document - attr_reader :rewrite_document - - def initialize(*) - super - @query = context.query - @rewrite_document = InternalRepresentation::Document.new - # Hash Set> - # A record of fragment spreads and the irep nodes that used them - @rewrite_spread_parents = Hash.new { |h, k| h[k] = Set.new } - # Hash Scope> - @rewrite_spread_scopes = {} - # Array> - # The current point of the irep_tree during visitation - @rewrite_nodes_stack = [] - # Array - @rewrite_scopes_stack = [] - @rewrite_skip_nodes = Set.new - - # Resolve fragment spreads. - # Fragment definitions got their own irep trees during visitation. - # Those nodes are spliced in verbatim (not copied), but this is OK - # because fragments are resolved from the "bottom up", each fragment - # can be shared between its usages. - context.on_dependency_resolve do |defn_ast_node, spread_ast_nodes, frag_ast_node| - frag_name = frag_ast_node.name - fragment_node = @rewrite_document.fragment_definitions[frag_name] - - if fragment_node - spread_ast_nodes.each do |spread_ast_node| - parent_nodes = @rewrite_spread_parents[spread_ast_node] - parent_scope = @rewrite_spread_scopes[spread_ast_node] - parent_nodes.each do |parent_node| - parent_node.deep_merge_node(fragment_node, scope: parent_scope, merge_self: false) - end - end - end - end - end - - # @return [Hash] Roots of this query - def operations - GraphQL::Deprecation.warn "#{self.class}#operations is deprecated; use `document.operation_definitions` instead" - @document.operation_definitions - end - - def on_operation_definition(ast_node, parent) - push_root_node(ast_node, @rewrite_document.operation_definitions) { super } - end - - def on_fragment_definition(ast_node, parent) - push_root_node(ast_node, @rewrite_document.fragment_definitions) { super } - end - - def push_root_node(ast_node, definitions) - # Either QueryType or the fragment type condition - owner_type = context.type_definition - defn_name = ast_node.name - - node = Node.new( - parent: nil, - name: defn_name, - owner_type: owner_type, - query: @query, - ast_nodes: [ast_node], - return_type: owner_type, - ) - - definitions[defn_name] = node - @rewrite_scopes_stack.push(Scope.new(@query, owner_type)) - @rewrite_nodes_stack.push([node]) - yield - @rewrite_nodes_stack.pop - @rewrite_scopes_stack.pop - end - - def on_inline_fragment(node, parent) - # Inline fragments provide two things to the rewritten tree: - # - They _may_ narrow the scope by their type condition - # - They _may_ apply their directives to their children - if skip?(node) - @rewrite_skip_nodes.add(node) - end - - if @rewrite_skip_nodes.empty? - @rewrite_scopes_stack.push(@rewrite_scopes_stack.last.enter(context.type_definition)) - end - - super - - if @rewrite_skip_nodes.empty? - @rewrite_scopes_stack.pop - end - - if @rewrite_skip_nodes.include?(node) - @rewrite_skip_nodes.delete(node) - end - end - - def on_field(ast_node, ast_parent) - if skip?(ast_node) - @rewrite_skip_nodes.add(ast_node) - end - - if @rewrite_skip_nodes.empty? - node_name = ast_node.alias || ast_node.name - parent_nodes = @rewrite_nodes_stack.last - next_nodes = [] - - field_defn = context.field_definition - if field_defn.nil? - # It's a non-existent field - new_scope = nil - else - field_return_type = field_defn.type - @rewrite_scopes_stack.last.each do |scope_type| - parent_nodes.each do |parent_node| - node = parent_node.scoped_children[scope_type][node_name] ||= Node.new( - parent: parent_node, - name: node_name, - owner_type: scope_type, - query: @query, - return_type: field_return_type, - ) - node.ast_nodes << ast_node - node.definitions << field_defn - next_nodes << node - end - end - new_scope = Scope.new(@query, field_return_type.unwrap) - end - - @rewrite_nodes_stack.push(next_nodes) - @rewrite_scopes_stack.push(new_scope) - end - - super - - if @rewrite_skip_nodes.empty? - @rewrite_nodes_stack.pop - @rewrite_scopes_stack.pop - end - - if @rewrite_skip_nodes.include?(ast_node) - @rewrite_skip_nodes.delete(ast_node) - end - end - - def on_fragment_spread(ast_node, ast_parent) - if @rewrite_skip_nodes.empty? && !skip?(ast_node) - # Register the irep nodes that depend on this AST node: - @rewrite_spread_parents[ast_node].merge(@rewrite_nodes_stack.last) - @rewrite_spread_scopes[ast_node] = @rewrite_scopes_stack.last - end - super - end - - def skip?(ast_node) - dir = ast_node.directives - dir.any? && !GraphQL::Execution::DirectiveChecks.include?(dir, @query) - end - end - end -end diff --git a/lib/graphql/internal_representation/scope.rb b/lib/graphql/internal_representation/scope.rb deleted file mode 100644 index 943403d4d8a..00000000000 --- a/lib/graphql/internal_representation/scope.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module InternalRepresentation - # At a point in the AST, selections may apply to one or more types. - # {Scope} represents those types which selections may apply to. - # - # Scopes can be defined by: - # - # - A single concrete or abstract type - # - An array of types - # - `nil` - # - # The AST may be scoped to an array of types when two abstractly-typed - # fragments occur in inside one another. - class Scope - NO_TYPES = [].freeze - - # @param query [GraphQL::Query] - # @param type_defn [GraphQL::BaseType, Array, nil] - def initialize(query, type_defn) - @query = query - @type = type_defn - @abstract_type = false - @types = case type_defn - when Array - type_defn - when GraphQL::BaseType - @abstract_type = true - nil - when nil - NO_TYPES - else - raise "Unexpected scope type: #{type_defn}" - end - end - - # From a starting point of `self`, create a new scope by condition `other_type_defn`. - # @param other_type_defn [GraphQL::BaseType, nil] - # @return [Scope] - def enter(other_type_defn) - case other_type_defn - when nil - # The type wasn't found, who cares - Scope.new(@query, nil) - when @type - # The condition is the same as current, so reuse self - self - when GraphQL::UnionType, GraphQL::InterfaceType - # Make a new scope of the intersection between the previous & next conditions - new_types = @query.possible_types(other_type_defn) & concrete_types - Scope.new(@query, new_types) - when GraphQL::BaseType - # If this type is valid within the current scope, - # return a new scope of _exactly_ this type. - # Otherwise, this type is out-of-scope so the scope is null. - if concrete_types.include?(other_type_defn) - Scope.new(@query, other_type_defn) - else - Scope.new(@query, nil) - end - else - raise "Unexpected scope: #{other_type_defn.inspect}" - end - end - - # Call the block for each type in `self`. - # This uses the simplest possible expression of `self`, - # so if this scope is defined by an abstract type, it gets yielded. - def each(&block) - if @abstract_type - yield(@type) - else - @types.each(&block) - end - end - - private - - def concrete_types - @concrete_types ||= if @abstract_type - @query.possible_types(@type) - else - @types - end - end - end - end -end diff --git a/lib/graphql/internal_representation/visit.rb b/lib/graphql/internal_representation/visit.rb deleted file mode 100644 index 6698c18d270..00000000000 --- a/lib/graphql/internal_representation/visit.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module InternalRepresentation - # Traverse a re-written query tree, calling handlers for each node - module Visit - module_function - def visit_each_node(operations, handlers) - return if handlers.empty? - # Post-validation: make some assertions about the rewritten query tree - operations.each do |op_name, op_node| - # Yield each node to listeners which were attached by validators - op_node.typed_children.each do |obj_type, children| - children.each do |name, op_child_node| - each_node(op_child_node) do |node| - for h in handlers - h.call(node) - end - end - end - end - end - end - - # Traverse a node in a rewritten query tree, - # visiting the node itself and each of its typed children. - def each_node(node, &block) - yield(node) - node.typed_children.each do |obj_type, children| - children.each do |name, node| - each_node(node, &block) - end - end - end - end - end -end diff --git a/lib/graphql/introspection.rb b/lib/graphql/introspection.rb index 921e5501725..54d64d676ff 100644 --- a/lib/graphql/introspection.rb +++ b/lib/graphql/introspection.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true module GraphQL module Introspection - def self.query(include_deprecated_args: false) + def self.query(include_deprecated_args: false, include_schema_description: false, include_is_repeatable: false, include_specified_by_url: false, include_is_one_of: false) # The introspection query to end all introspection queries, copied from # https://github.com/graphql/graphql-js/blob/master/src/utilities/introspectionQuery.js - <<-QUERY + <<-QUERY.gsub(/\n{2,}/, "\n") query IntrospectionQuery { __schema { + #{include_schema_description ? "description" : ""} queryType { name } mutationType { name } subscriptionType { name } @@ -17,6 +18,7 @@ def self.query(include_deprecated_args: false) name description locations + #{include_is_repeatable ? "isRepeatable" : ""} args#{include_deprecated_args ? '(includeDeprecated: true)' : ''} { ...InputValue } @@ -27,6 +29,8 @@ def self.query(include_deprecated_args: false) kind name description + #{include_specified_by_url ? "specifiedByURL" : ""} + #{include_is_one_of ? "isOneOf" : ""} fields(includeDeprecated: true) { name description diff --git a/lib/graphql/introspection/directive_location_enum.rb b/lib/graphql/introspection/directive_location_enum.rb index b3271c78ec1..4fe69f53cad 100644 --- a/lib/graphql/introspection/directive_location_enum.rb +++ b/lib/graphql/introspection/directive_location_enum.rb @@ -6,8 +6,8 @@ class DirectiveLocationEnum < GraphQL::Schema::Enum description "A Directive can be adjacent to many parts of the GraphQL language, "\ "a __DirectiveLocation describes one such possible adjacencies." - GraphQL::Directive::LOCATIONS.each do |location| - value(location.to_s, GraphQL::Directive::LOCATION_DESCRIPTIONS[location], value: location) + GraphQL::Schema::Directive::LOCATIONS.each do |location| + value(location.to_s, GraphQL::Schema::Directive::LOCATION_DESCRIPTIONS[location], value: location, value_method: false) end introspection true end diff --git a/lib/graphql/introspection/directive_type.rb b/lib/graphql/introspection/directive_type.rb index d2d332a85ef..1692c955f6c 100644 --- a/lib/graphql/introspection/directive_type.rb +++ b/lib/graphql/introspection/directive_type.rb @@ -10,20 +10,26 @@ class DirectiveType < Introspection::BaseObject "skipping a field. Directives provide this by describing additional information "\ "to the executor." field :name, String, null: false, method: :graphql_name - field :description, String, null: true - field :locations, [GraphQL::Schema::LateBoundType.new("__DirectiveLocation")], null: false - field :args, [GraphQL::Schema::LateBoundType.new("__InputValue")], null: false do + field :description, String + field :locations, [GraphQL::Schema::LateBoundType.new("__DirectiveLocation")], null: false, scope: false + field :args, [GraphQL::Schema::LateBoundType.new("__InputValue")], null: false, scope: false, resolve_each: :resolve_args do argument :include_deprecated, Boolean, required: false, default_value: false end field :on_operation, Boolean, null: false, deprecation_reason: "Use `locations`.", method: :on_operation? field :on_fragment, Boolean, null: false, deprecation_reason: "Use `locations`.", method: :on_fragment? field :on_field, Boolean, null: false, deprecation_reason: "Use `locations`.", method: :on_field? - def args(include_deprecated:) - args = @context.warden.arguments(@object) + field :is_repeatable, Boolean, method: :repeatable? + + def self.resolve_args(object, context, include_deprecated:) + args = context.types.arguments(object) args = args.reject(&:deprecation_reason) unless include_deprecated args end + + def args(include_deprecated:) + self.class.resolve_args(object, context, include_deprecated: include_deprecated) + end end end end diff --git a/lib/graphql/introspection/dynamic_fields.rb b/lib/graphql/introspection/dynamic_fields.rb index 288018a89bd..a4015d0c623 100644 --- a/lib/graphql/introspection/dynamic_fields.rb +++ b/lib/graphql/introspection/dynamic_fields.rb @@ -2,15 +2,14 @@ module GraphQL module Introspection class DynamicFields < Introspection::BaseObject - field :__typename, String, "The name of this type", null: false, extras: [:irep_node] + field :__typename, String, "The name of this type", null: false, dynamic_introspection: true, resolve_each: true - # `irep_node:` will be nil for the interpreter, since there is no such thing - def __typename(irep_node: nil) - if context.interpreter? - object.class.graphql_name - else - irep_node.owner_type.name - end + def __typename + self.class.__typename(object, context) + end + + def self.__typename(object, context) + object.class.graphql_name end end end diff --git a/lib/graphql/introspection/entry_points.rb b/lib/graphql/introspection/entry_points.rb index 2879f766fbe..cde622c56f3 100644 --- a/lib/graphql/introspection/entry_points.rb +++ b/lib/graphql/introspection/entry_points.rb @@ -2,33 +2,34 @@ module GraphQL module Introspection class EntryPoints < Introspection::BaseObject - field :__schema, GraphQL::Schema::LateBoundType.new("__Schema"), "This GraphQL schema", null: false - field :__type, GraphQL::Schema::LateBoundType.new("__Type"), "A type in the GraphQL system", null: true do - argument :name, String, required: true + field :__schema, GraphQL::Schema::LateBoundType.new("__Schema"), "This GraphQL schema", null: false, dynamic_introspection: true, resolve_static: true + field :__type, GraphQL::Schema::LateBoundType.new("__Type"), "A type in the GraphQL system", dynamic_introspection: true, resolve_static: true do + argument :name, String end - def __schema + def self.__schema(context) # Apply wrapping manually since this field isn't wrapped by instrumentation - schema = @context.query.schema + schema = context.schema schema_type = schema.introspection_system.types["__Schema"] - schema_type.type_class.authorized_new(schema, @context) + schema_type.wrap(schema, context) end - def __type(name:) - return unless context.warden.reachable_type?(name) - type = context.warden.get_type(name) + def __schema + self.class.__schema(context) + end - if type && context.interpreter? && !type.is_a?(Module) - type = type.type_class || raise("Invariant: interpreter requires class-based type for #{name}") - end + def __type(name:) + self.class.__type(context, name: name) + end - # The interpreter provides this wrapping, other execution doesnt, so support both. - if type && !context.interpreter? - # Apply wrapping manually since this field isn't wrapped by instrumentation - type_type = context.schema.introspection_system.types["__Type"] - type = type_type.type_class.authorized_new(type, context) + def self.__type(context, name:) + if context.types.reachable_type?(name) && (type = context.types.type(name)) + type + elsif (type = context.schema.extra_types.find { |t| t.graphql_name == name }) + type + else + nil end - type end end end diff --git a/lib/graphql/introspection/enum_value_type.rb b/lib/graphql/introspection/enum_value_type.rb index da19d3bc22f..77eb684dc8f 100644 --- a/lib/graphql/introspection/enum_value_type.rb +++ b/lib/graphql/introspection/enum_value_type.rb @@ -6,17 +6,17 @@ class EnumValueType < Introspection::BaseObject description "One possible value for a given Enum. Enum values are unique values, not a "\ "placeholder for a string or numeric value. However an Enum value is returned in "\ "a JSON response as a string." - field :name, String, null: false - field :description, String, null: true - field :is_deprecated, Boolean, null: false - field :deprecation_reason, String, null: true + field :name, String, null: false, method: :graphql_name + field :description, String + field :is_deprecated, Boolean, null: false, resolve_each: :resolve_is_deprecated + field :deprecation_reason, String - def name - object.graphql_name + def self.resolve_is_deprecated(object, context) + !!object.deprecation_reason end def is_deprecated - !!@object.deprecation_reason + self.class.resolve_is_deprecated(object, context) end end end diff --git a/lib/graphql/introspection/field_type.rb b/lib/graphql/introspection/field_type.rb index a9b15ab4cb1..7e052dc7d18 100644 --- a/lib/graphql/introspection/field_type.rb +++ b/lib/graphql/introspection/field_type.rb @@ -6,23 +6,31 @@ class FieldType < Introspection::BaseObject description "Object and Interface types are described by a list of Fields, each of which has "\ "a name, potentially a list of arguments, and a return type." field :name, String, null: false - field :description, String, null: true - field :args, [GraphQL::Schema::LateBoundType.new("__InputValue")], null: false do + field :description, String + field :args, [GraphQL::Schema::LateBoundType.new("__InputValue")], null: false, scope: false, resolve_each: :resolve_args do argument :include_deprecated, Boolean, required: false, default_value: false end field :type, GraphQL::Schema::LateBoundType.new("__Type"), null: false - field :is_deprecated, Boolean, null: false - field :deprecation_reason, String, null: true + field :is_deprecated, Boolean, null: false, resolve_each: :resolve_is_deprecated + field :deprecation_reason, String + + def self.resolve_is_deprecated(object, _context) + !!object.deprecation_reason + end def is_deprecated - !!@object.deprecation_reason + self.class.resolve_is_deprecated(object, context) end - def args(include_deprecated:) - args = @context.warden.arguments(@object) + def self.resolve_args(object, context, include_deprecated:) + args = context.types.arguments(object) args = args.reject(&:deprecation_reason) unless include_deprecated args end + + def args(include_deprecated:) + self.class.resolve_args(object, context, include_deprecated: include_deprecated) + end end end end diff --git a/lib/graphql/introspection/input_value_type.rb b/lib/graphql/introspection/input_value_type.rb index 33e544ffe88..57217e9bbea 100644 --- a/lib/graphql/introspection/input_value_type.rb +++ b/lib/graphql/introspection/input_value_type.rb @@ -7,49 +7,63 @@ class InputValueType < Introspection::BaseObject "InputObject are represented as Input Values which describe their type and "\ "optionally a default value." field :name, String, null: false - field :description, String, null: true + field :description, String field :type, GraphQL::Schema::LateBoundType.new("__Type"), null: false - field :default_value, String, "A GraphQL-formatted string representing the default value for this input value.", null: true - field :is_deprecated, Boolean, null: false - field :deprecation_reason, String, null: true + field :default_value, String, "A GraphQL-formatted string representing the default value for this input value.", resolve_each: :resolve_default_value + field :is_deprecated, Boolean, null: false, resolve_each: :resolve_is_deprecated + field :deprecation_reason, String + + def self.resolve_is_deprecated(object, context) + !!object.deprecation_reason + end def is_deprecated - !!@object.deprecation_reason + self.class.resolve_is_deprecated(object, context) end - def default_value - if @object.default_value? - value = @object.default_value + def self.resolve_default_value(object, context) + if object.default_value? + value = object.default_value if value.nil? 'null' else - coerced_default_value = @object.type.coerce_result(value, @context) - serialize_default_value(coerced_default_value, @object.type) + if (object.type.kind.list? || (object.type.kind.non_null? && object.type.of_type.kind.list?)) && !value.respond_to?(:map) + # This is a bit odd -- we expect the default value to be an application-style value, so we use coerce result below. + # But coerce_result doesn't wrap single-item lists, which are valid inputs to list types. + # So, apply that wrapper here if needed. + value = [value] + end + coerced_default_value = object.type.coerce_result(value, context) + serialize_default_value(coerced_default_value, object.type, context) end else nil end end + def default_value + self.class.resolve_default_value(object, context) + end + private # Recursively serialize, taking care not to add quotes to enum values - def serialize_default_value(value, type) + def self.serialize_default_value(value, type, context) if value.nil? 'null' elsif type.kind.list? inner_type = type.of_type - "[" + value.map { |v| serialize_default_value(v, inner_type) }.join(", ") + "]" + "[" + value.map { |v| serialize_default_value(v, inner_type, context) }.join(", ") + "]" elsif type.kind.non_null? - serialize_default_value(value, type.of_type) + serialize_default_value(value, type.of_type, context) elsif type.kind.enum? value elsif type.kind.input_object? "{" + value.map do |k, v| - arg_defn = type.arguments[k] - "#{k}: #{serialize_default_value(v, arg_defn.type)}" + arg_defn = type.get_argument(k, context) + "#{k}: #{serialize_default_value(v, arg_defn.type, context)}" end.join(", ") + "}" else diff --git a/lib/graphql/introspection/schema_type.rb b/lib/graphql/introspection/schema_type.rb index 0c4322d4124..efad53600a7 100644 --- a/lib/graphql/introspection/schema_type.rb +++ b/lib/graphql/introspection/schema_type.rb @@ -8,36 +8,42 @@ class SchemaType < Introspection::BaseObject "available types and directives on the server, as well as the entry points for "\ "query, mutation, and subscription operations." - field :types, [GraphQL::Schema::LateBoundType.new("__Type")], "A list of all types supported by this server.", null: false + field :types, [GraphQL::Schema::LateBoundType.new("__Type")], "A list of all types supported by this server.", null: false, scope: false field :query_type, GraphQL::Schema::LateBoundType.new("__Type"), "The type that query operations will be rooted at.", null: false - field :mutation_type, GraphQL::Schema::LateBoundType.new("__Type"), "If this server supports mutation, the type that mutation operations will be rooted at.", null: true - field :subscription_type, GraphQL::Schema::LateBoundType.new("__Type"), "If this server support subscription, the type that subscription operations will be rooted at.", null: true - field :directives, [GraphQL::Schema::LateBoundType.new("__Directive")], "A list of all directives supported by this server.", null: false + field :mutation_type, GraphQL::Schema::LateBoundType.new("__Type"), "If this server supports mutation, the type that mutation operations will be rooted at." + field :subscription_type, GraphQL::Schema::LateBoundType.new("__Type"), "If this server support subscription, the type that subscription operations will be rooted at." + field :directives, [GraphQL::Schema::LateBoundType.new("__Directive")], "A list of all directives supported by this server.", null: false, scope: false + field :description, String, resolver_method: :schema_description, resolve_static: :schema_description + + def self.schema_description(context) + context.schema.description + end + + def schema_description + self.class.schema_description(context) + end def types - @context.warden.reachable_types.sort_by(&:graphql_name) + query_types = context.types.all_types + types = query_types + context.schema.extra_types + types.sort_by!(&:graphql_name) + types end def query_type - permitted_root_type("query") + @context.types.query_root end def mutation_type - permitted_root_type("mutation") + @context.types.mutation_root end def subscription_type - permitted_root_type("subscription") + @context.types.subscription_root end def directives - @context.warden.directives - end - - private - - def permitted_root_type(op_type) - @context.warden.root_type_for_operation(op_type) + @context.types.directives.sort_by(&:graphql_name) end end end diff --git a/lib/graphql/introspection/type_type.rb b/lib/graphql/introspection/type_type.rb index ba834555e57..e551abeb7ad 100644 --- a/lib/graphql/introspection/type_type.rb +++ b/lib/graphql/introspection/type_type.rb @@ -11,35 +11,60 @@ class TypeType < Introspection::BaseObject "they describe. Abstract types, Union and Interface, provide the Object types "\ "possible at runtime. List and NonNull types compose other types." - field :kind, GraphQL::Schema::LateBoundType.new("__TypeKind"), null: false - field :name, String, null: true - field :description, String, null: true - field :fields, [GraphQL::Schema::LateBoundType.new("__Field")], null: true do + field :kind, GraphQL::Schema::LateBoundType.new("__TypeKind"), null: false, resolve_each: :resolve_kind + field :name, String, method: :graphql_name + field :description, String + field :fields, [GraphQL::Schema::LateBoundType.new("__Field")], scope: false, resolve_each: :resolve_fields do argument :include_deprecated, Boolean, required: false, default_value: false end - field :interfaces, [GraphQL::Schema::LateBoundType.new("__Type")], null: true - field :possible_types, [GraphQL::Schema::LateBoundType.new("__Type")], null: true - field :enum_values, [GraphQL::Schema::LateBoundType.new("__EnumValue")], null: true do + field :interfaces, [GraphQL::Schema::LateBoundType.new("__Type")], scope: false, resolve_each: :resolve_interfaces + field :possible_types, [GraphQL::Schema::LateBoundType.new("__Type")], scope: false, resolve_each: :resolve_possible_types + field :enum_values, [GraphQL::Schema::LateBoundType.new("__EnumValue")], scope: false, resolve_each: :resolve_enum_values do argument :include_deprecated, Boolean, required: false, default_value: false end - field :input_fields, [GraphQL::Schema::LateBoundType.new("__InputValue")], null: true do + field :input_fields, [GraphQL::Schema::LateBoundType.new("__InputValue")], scope: false, resolve_each: :resolve_input_fields do argument :include_deprecated, Boolean, required: false, default_value: false end - field :of_type, GraphQL::Schema::LateBoundType.new("__Type"), null: true + field :of_type, GraphQL::Schema::LateBoundType.new("__Type"), resolve_each: :resolve_of_type - def name - object.graphql_name + field :specifiedByURL, String, resolve_each: :resolve_specified_by_url, resolver_method: :specified_by_url + + field :is_one_of, Boolean, null: false, resolve_each: :resolve_is_one_of + + def self.resolve_is_one_of(object, _ctx) + object.kind.input_object? && + object.directives.any? { |d| d.graphql_name == "oneOf" } + end + + def is_one_of + self.class.resolve_is_one_of(object, context) + end + + def self.resolve_specified_by_url(object, _ctx) + if object.kind.scalar? + object.specified_by_url + else + nil + end + end + + def specified_by_url + self.class.resolve_specified_by_url(object, context) + end + + def self.resolve_kind(object, context) + object.kind.name end def kind - @object.kind.name + self.class.resolve_kind(object, context) end - def enum_values(include_deprecated:) - if !@object.kind.enum? + def self.resolve_enum_values(object, context, include_deprecated:) + if !object.kind.enum? nil else - enum_values = @context.warden.enum_values(@object) + enum_values = context.types.enum_values(object) if !include_deprecated enum_values = enum_values.select {|f| !f.deprecation_reason } @@ -49,17 +74,25 @@ def enum_values(include_deprecated:) end end - def interfaces - if @object.kind == GraphQL::TypeKinds::OBJECT - @context.warden.interfaces(@object) + def enum_values(include_deprecated:) + self.class.resolve_enum_values(object, context, include_deprecated: include_deprecated) + end + + def self.resolve_interfaces(object, context) + if object.kind.object? || object.kind.interface? + context.types.interfaces(object).sort_by(&:graphql_name) else nil end end - def input_fields(include_deprecated:) - if @object.kind.input_object? - args = @context.warden.arguments(@object) + def interfaces + self.class.resolve_interfaces(object, context) + end + + def self.resolve_input_fields(object, context, include_deprecated:) + if object.kind.input_object? + args = context.types.arguments(object) args = args.reject(&:deprecation_reason) unless include_deprecated args else @@ -67,19 +100,27 @@ def input_fields(include_deprecated:) end end - def possible_types - if @object.kind.abstract? - @context.warden.possible_types(@object).sort_by(&:graphql_name) + def input_fields(include_deprecated:) + self.class.resolve_input_fields(object, context, include_deprecated: include_deprecated) + end + + def self.resolve_possible_types(object, context) + if object.kind.abstract? + context.types.possible_types(object).sort_by(&:graphql_name) else nil end end - def fields(include_deprecated:) - if !@object.kind.fields? + def possible_types + self.class.resolve_possible_types(object, context) + end + + def self.resolve_fields(object, context, include_deprecated:) + if !object.kind.fields? nil else - fields = @context.warden.fields(@object) + fields = context.types.fields(object) if !include_deprecated fields = fields.select {|f| !f.deprecation_reason } end @@ -87,8 +128,16 @@ def fields(include_deprecated:) end end + def fields(include_deprecated:) + self.class.resolve_fields(object, context, include_deprecated: include_deprecated) + end + + def self.resolve_of_type(object, _ctx) + object.kind.wraps? ? object.of_type : nil + end + def of_type - @object.kind.wraps? ? @object.of_type : nil + self.class.resolve_of_type(object, context) end end end diff --git a/lib/graphql/invalid_name_error.rb b/lib/graphql/invalid_name_error.rb index 3e79735e529..30f19cf5192 100644 --- a/lib/graphql/invalid_name_error.rb +++ b/lib/graphql/invalid_name_error.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true module GraphQL - class InvalidNameError < GraphQL::ExecutionError + class InvalidNameError < GraphQL::Error attr_reader :name, :valid_regex def initialize(name, valid_regex) @name = name diff --git a/lib/graphql/invalid_null_error.rb b/lib/graphql/invalid_null_error.rb index 0547667c00a..ff738bb2314 100644 --- a/lib/graphql/invalid_null_error.rb +++ b/lib/graphql/invalid_null_error.rb @@ -2,31 +2,40 @@ module GraphQL # Raised automatically when a field's resolve function returns `nil` # for a non-null field. - class InvalidNullError < GraphQL::RuntimeTypeError + class InvalidNullError < GraphQL::RuntimeError # @return [GraphQL::BaseType] The owner of {#field} attr_reader :parent_type # @return [GraphQL::Field] The field which failed to return a value attr_reader :field - # @return [nil, GraphQL::ExecutionError] The invalid value for this field - attr_reader :value - - def initialize(parent_type, field, value) - @parent_type = parent_type - @field = field - @value = value - super("Cannot return null for non-nullable field #{@parent_type.graphql_name}.#{@field.graphql_name}") + # @return [GraphQL::Language::Nodes::Field] the field where the error occurred + def ast_node + @ast_nodes.first end - # @return [Hash] An entry for the response's "errors" key - def to_h - { "message" => message } - end + attr_reader :ast_nodes + + # @return [Boolean] indicates an array result caused the error + attr_reader :is_from_array - # @deprecated always false - def parent_error? - false + attr_accessor :path + + def initialize(parent_type, field, ast_node_or_nodes, is_from_array: false, path: nil) + @parent_type = parent_type + @field = field + @ast_nodes = Array(ast_node_or_nodes) + @is_from_array = is_from_array + @path = path + # For List elements, identify the non-null error is for an + # element and the required element type so it's not ambiguous + # whether it was caused by a null instead of the list or a + # null element. + if @is_from_array + super("Cannot return null for non-nullable element of type '#{@field.type.of_type.of_type.to_type_signature}' for #{@parent_type.graphql_name}.#{@field.graphql_name}") + else + super("Cannot return null for non-nullable field #{@parent_type.graphql_name}.#{@field.graphql_name}") + end end class << self @@ -39,7 +48,7 @@ def subclass_for(parent_class) end def inspect - if (name.nil? || parent_class.name.nil?) && parent_class.respond_to?(:mutation) && (mutation = parent_class.mutation) + if (name.nil? || parent_class&.name.nil?) && parent_class.respond_to?(:mutation) && (mutation = parent_class.mutation) "#{mutation.inspect}::#{parent_class.graphql_name}::InvalidNullError" else super diff --git a/lib/graphql/language.rb b/lib/graphql/language.rb index f47889361b5..d8f967ac16a 100644 --- a/lib/graphql/language.rb +++ b/lib/graphql/language.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true require "graphql/language/block_string" +require "graphql/language/comment" require "graphql/language/printer" require "graphql/language/sanitized_printer" require "graphql/language/document_from_schema_definition" @@ -8,9 +9,10 @@ require "graphql/language/nodes" require "graphql/language/cache" require "graphql/language/parser" -require "graphql/language/token" +require "graphql/language/static_visitor" require "graphql/language/visitor" require "graphql/language/definition_slice" +require "strscan" module GraphQL module Language @@ -31,6 +33,80 @@ def self.serialize(value) else JSON.generate(value, quirks_mode: true) end + rescue JSON::GeneratorError + if Float::INFINITY == value + "Infinity" + else + raise + end + end + + # Returns a new string if any single-quoted newlines were escaped. + # Otherwise, returns `query_str` unchanged. + # @return [String] + def self.escape_single_quoted_newlines(query_str) + scanner = StringScanner.new(query_str) + inside_single_quoted_string = false + inside_triple_quoted_string = false + new_query_str = nil + while !scanner.eos? + if scanner.skip('"""') + inside_triple_quoted_string = !inside_triple_quoted_string + new_query_str && (new_query_str << scanner.matched) + elsif scanner.skip(/(?:\\"|[^"\n\r])+/m) + new_query_str && (new_query_str << scanner.matched) + elsif scanner.skip('"') + new_query_str && (new_query_str << '"') + if !inside_triple_quoted_string + inside_single_quoted_string = !inside_single_quoted_string + end + elsif scanner.skip("\n") + if inside_single_quoted_string + new_query_str ||= query_str[0, scanner.pos - 1] + new_query_str << '\\n' + else + new_query_str && (new_query_str << "\n") + end + elsif scanner.skip("\r") + if inside_single_quoted_string + new_query_str ||= query_str[0, scanner.pos - 1] + new_query_str << '\\r' + else + new_query_str && (new_query_str << "\r") + end + elsif scanner.eos? + break + else + raise ArgumentError, "Unmatchable string scanner segment: #{scanner.rest.inspect}" + end + end + new_query_str || query_str + end + + LEADING_REGEX = Regexp.union(" ", *Lexer::Punctuation.constants.map { |const| Lexer::Punctuation.const_get(const) }) + + # Optimized pattern using: + # - Possessive quantifiers (*+, ++) to prevent backtracking in number patterns + # - Atomic group (?>...) for IGNORE to prevent backtracking + # - Single unified number pattern instead of three alternatives + EFFICIENT_NUMBER_REGEXP = /-?(?:0|[1-9][0-9]*+)(?:\.[0-9]++)?(?:[eE][+-]?[0-9]++)?/ + EFFICIENT_IGNORE_REGEXP = /(?>[, \r\n\t]+|\#[^\n]*$)*/ + + MAYBE_INVALID_NUMBER = /\d[_a-zA-Z]/ + + INVALID_NUMBER_FOLLOWED_BY_NAME_REGEXP = %r{ + (?#{LEADING_REGEX}) + (?#{EFFICIENT_NUMBER_REGEXP}) + (?#{Lexer::IDENTIFIER_REGEXP}) + #{EFFICIENT_IGNORE_REGEXP} + : + }x + + def self.add_space_between_numbers_and_names(query_str) + # Fast check for digit followed by identifier char. If this doesn't match, skip the more expensive regexp entirely. + return query_str unless query_str.match?(MAYBE_INVALID_NUMBER) + return query_str unless query_str.match?(INVALID_NUMBER_FOLLOWED_BY_NAME_REGEXP) + query_str.gsub(INVALID_NUMBER_FOLLOWED_BY_NAME_REGEXP, "\\k\\k \\k:") end end end diff --git a/lib/graphql/language/block_string.rb b/lib/graphql/language/block_string.rb index 121f311e757..8b7a97d8692 100644 --- a/lib/graphql/language/block_string.rb +++ b/lib/graphql/language/block_string.rb @@ -2,16 +2,12 @@ module GraphQL module Language module BlockString - if !String.method_defined?(:match?) - using GraphQL::StringMatchBackport - end - # Remove leading and trailing whitespace from a block string. # See "Block Strings" in https://github.com/facebook/graphql/blob/master/spec/Section%202%20--%20Language.md def self.trim_whitespace(str) # Early return for the most common cases: if str == "" - return "" + return "".dup elsif !(has_newline = str.include?("\n")) && !(str.start_with?(" ")) return str end @@ -51,52 +47,68 @@ def self.trim_whitespace(str) end # Remove leading & trailing blank lines - while lines.size > 0 && lines[0].empty? + while lines.size > 0 && contains_only_whitespace?(lines.first) lines.shift end - while lines.size > 0 && lines[-1].empty? + while lines.size > 0 && contains_only_whitespace?(lines.last) lines.pop end # Rebuild the string - lines.size > 1 ? lines.join("\n") : (lines.first || "") + lines.size > 1 ? lines.join("\n") : (lines.first || "".dup) end def self.print(str, indent: '') - lines = str.split("\n") - - block_str = "#{indent}\"\"\"\n".dup - - lines.each do |line| - if line == '' - block_str << "\n" - else - sublines = break_line(line, 120 - indent.length) - sublines.each do |subline| - block_str << "#{indent}#{subline}\n" + line_length = 120 - indent.length + block_str = "".dup + triple_quotes = "\"\"\"\n" + block_str << indent + block_str << triple_quotes + + if str.include?("\n") + str.split("\n") do |line| + if line == '' + block_str << "\n" + else + break_line(line, line_length) do |subline| + block_str << indent + block_str << subline + block_str << "\n" + end end end + else + break_line(str, line_length) do |subline| + block_str << indent + block_str << subline + block_str << "\n" + end end - block_str << "#{indent}\"\"\"\n".dup + block_str << indent + block_str << triple_quotes end private def self.break_line(line, length) - return [line] if line.length < length + 5 + return yield(line) if line.length < length + 5 parts = line.split(Regexp.new("((?: |^).{15,#{length - 40}}(?= |$))")) - return [line] if parts.length < 4 + return yield(line) if parts.length < 4 - sublines = [parts.slice!(0, 3).join] + yield(parts.slice!(0, 3).join) parts.each_with_index do |part, i| next if i % 2 == 1 - sublines << "#{part[1..-1]}#{parts[i + 1]}" + yield "#{part[1..-1]}#{parts[i + 1]}" end - sublines + nil + end + + def self.contains_only_whitespace?(line) + line.match?(/^\s*$/) end end end diff --git a/lib/graphql/language/cache.rb b/lib/graphql/language/cache.rb index ed4a881e5ec..5f30ad2f905 100644 --- a/lib/graphql/language/cache.rb +++ b/lib/graphql/language/cache.rb @@ -5,12 +5,25 @@ module GraphQL module Language + # This cache is used by {GraphQL::Language::Parser.parse_file} when it's enabled. + # + # With Rails, parser caching may enabled by setting `config.graphql.parser_cache = true` in your Rails application. + # + # The cache may be manually built by assigning `GraphQL::Language::Parser.cache = GraphQL::Language::Cache.new("some_dir")`. + # This will create a directory (`tmp/cache/graphql` by default) that stores a cache of parsed files. + # + # Much like [bootsnap](https://github.com/Shopify/bootsnap), the parser cache needs to be cleaned up manually. + # You will need to clear the cache directory for each new deployment of your application. + # Also note that the parser cache will grow as your schema is loaded, so the cache directory must be writable. + # + # @see GraphQL::Railtie for simple Rails integration class Cache def initialize(path) @path = path end DIGEST = Digest::SHA256.new << GraphQL::VERSION + def fetch(filename) hash = DIGEST.dup << filename begin diff --git a/lib/graphql/language/comment.rb b/lib/graphql/language/comment.rb new file mode 100644 index 00000000000..a1064399bfa --- /dev/null +++ b/lib/graphql/language/comment.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +module GraphQL + module Language + module Comment + def self.print(str, indent: '') + lines = str.split("\n").map do |line| + comment_str = "".dup + comment_str << indent + comment_str << "# " + comment_str << line + comment_str.rstrip + end + + lines.join("\n") + "\n" + end + end + end +end diff --git a/lib/graphql/language/definition_slice.rb b/lib/graphql/language/definition_slice.rb index b3ad163c815..71033accb91 100644 --- a/lib/graphql/language/definition_slice.rb +++ b/lib/graphql/language/definition_slice.rb @@ -15,7 +15,7 @@ def slice(document, name) private - class DependencyVisitor < GraphQL::Language::Visitor + class DependencyVisitor < GraphQL::Language::StaticVisitor def initialize(doc, definitions, names) @names = names @definitions = definitions diff --git a/lib/graphql/language/document_from_schema_definition.rb b/lib/graphql/language/document_from_schema_definition.rb index 553e15792ad..4674230cdb7 100644 --- a/lib/graphql/language/document_from_schema_definition.rb +++ b/lib/graphql/language/document_from_schema_definition.rb @@ -14,26 +14,19 @@ module Language # @param include_built_in_directives [Boolean] Whether or not to include built in directives in the AST class DocumentFromSchemaDefinition def initialize( - schema, context: nil, only: nil, except: nil, include_introspection_types: false, + schema, context: nil, include_introspection_types: false, include_built_in_directives: false, include_built_in_scalars: false, always_include_schema: false ) @schema = schema + @context = context @always_include_schema = always_include_schema @include_introspection_types = include_introspection_types @include_built_in_scalars = include_built_in_scalars @include_built_in_directives = include_built_in_directives + @include_one_of = false - filter = GraphQL::Filter.new(only: only, except: except) - if @schema.respond_to?(:visible?) - filter = filter.merge(only: @schema.method(:visible?)) - end - - schema_context = schema.context_class.new(query: nil, object: nil, schema: schema, values: context) - @warden = GraphQL::Schema::Warden.new( - filter, - schema: @schema, - context: schema_context, - ) + dummy_query = @schema.query_class.new(@schema, "{ __typename }", validate: false, context: context) + @types = dummy_query.types # rubocop:disable Development/ContextIsPassedCop end def document @@ -43,23 +36,33 @@ def document end def build_schema_node - GraphQL::Language::Nodes::SchemaDefinition.new( - query: (q = warden.root_type_for_operation("query")) && q.graphql_name, - mutation: (m = warden.root_type_for_operation("mutation")) && m.graphql_name, - subscription: (s = warden.root_type_for_operation("subscription")) && s.graphql_name, - # This only supports directives from parsing, - # use a custom printer to add to this list. - # - # `@schema.directives` is covered by `build_definition_nodes` - directives: ast_directives(@schema), - ) + if !schema_respects_root_name_conventions?(@schema) + GraphQL::Language::Nodes::SchemaDefinition.new( + query: @types.query_root&.graphql_name, + mutation: @types.mutation_root&.graphql_name, + subscription: @types.subscription_root&.graphql_name, + directives: definition_directives(@schema, :schema_directives) + ) + else + # A plain `schema ...` _must_ include root type definitions. + # If the only difference is directives, then you have to use `extend schema` + GraphQL::Language::Nodes::SchemaExtension.new(directives: definition_directives(@schema, :schema_directives)) + end end def build_object_type_node(object_type) + ints = @types.interfaces(object_type) + + if !ints.empty? + ints = ints.sort_by(&:graphql_name) + ints.map! { |iface| build_type_name_node(iface) } + end + GraphQL::Language::Nodes::ObjectTypeDefinition.new( name: object_type.graphql_name, - interfaces: warden.interfaces(object_type).sort_by(&:graphql_name).map { |iface| build_type_name_node(iface) }, - fields: build_field_nodes(warden.fields(object_type)), + comment: object_type.comment, + interfaces: ints, + fields: build_field_nodes(@types.fields(object_type)), description: object_type.description, directives: directives(object_type), ) @@ -68,7 +71,8 @@ def build_object_type_node(object_type) def build_field_node(field) GraphQL::Language::Nodes::FieldDefinition.new( name: field.graphql_name, - arguments: build_argument_nodes(warden.arguments(field)), + comment: field.comment, + arguments: build_argument_nodes(@types.arguments(field)), type: build_type_name_node(field.type), description: field.description, directives: directives(field), @@ -78,8 +82,9 @@ def build_field_node(field) def build_union_type_node(union_type) GraphQL::Language::Nodes::UnionTypeDefinition.new( name: union_type.graphql_name, + comment: union_type.comment, description: union_type.description, - types: warden.possible_types(union_type).sort_by(&:graphql_name).map { |type| build_type_name_node(type) }, + types: @types.possible_types(union_type).sort_by(&:graphql_name).map { |type| build_type_name_node(type) }, directives: directives(union_type), ) end @@ -87,8 +92,10 @@ def build_union_type_node(union_type) def build_interface_type_node(interface_type) GraphQL::Language::Nodes::InterfaceTypeDefinition.new( name: interface_type.graphql_name, + comment: interface_type.comment, + interfaces: @types.interfaces(interface_type).sort_by(&:graphql_name).map { |type| build_type_name_node(type) }, description: interface_type.description, - fields: build_field_nodes(warden.fields(interface_type)), + fields: build_field_nodes(@types.fields(interface_type)), directives: directives(interface_type), ) end @@ -96,7 +103,8 @@ def build_interface_type_node(interface_type) def build_enum_type_node(enum_type) GraphQL::Language::Nodes::EnumTypeDefinition.new( name: enum_type.graphql_name, - values: warden.enum_values(enum_type).sort_by(&:graphql_name).map do |enum_value| + comment: enum_type.comment, + values: @types.enum_values(enum_type).sort_by(&:graphql_name).map do |enum_value| build_enum_value_node(enum_value) end, description: enum_type.description, @@ -107,6 +115,7 @@ def build_enum_type_node(enum_type) def build_enum_value_node(enum_value) GraphQL::Language::Nodes::EnumValueDefinition.new( name: enum_value.graphql_name, + comment: enum_value.comment, description: enum_value.description, directives: directives(enum_value), ) @@ -115,6 +124,7 @@ def build_enum_value_node(enum_value) def build_scalar_type_node(scalar_type) GraphQL::Language::Nodes::ScalarTypeDefinition.new( name: scalar_type.graphql_name, + comment: scalar_type.comment, description: scalar_type.description, directives: directives(scalar_type), ) @@ -129,6 +139,7 @@ def build_argument_node(argument) argument_node = GraphQL::Language::Nodes::InputValueDefinition.new( name: argument.graphql_name, + comment: argument.comment, description: argument.description, type: build_type_name_node(argument.type), default_value: default_value, @@ -141,7 +152,8 @@ def build_argument_node(argument) def build_input_object_node(input_object) GraphQL::Language::Nodes::InputObjectTypeDefinition.new( name: input_object.graphql_name, - fields: build_argument_nodes(warden.arguments(input_object)), + comment: input_object.comment, + fields: build_argument_nodes(@types.arguments(input_object)), description: input_object.description, directives: directives(input_object), ) @@ -150,7 +162,8 @@ def build_input_object_node(input_object) def build_directive_node(directive) GraphQL::Language::Nodes::DirectiveDefinition.new( name: directive.graphql_name, - arguments: build_argument_nodes(warden.arguments(directive)), + repeatable: directive.repeatable?, + arguments: build_argument_nodes(@types.arguments(directive)), locations: build_directive_location_nodes(directive.locations), description: directive.description, ) @@ -177,7 +190,8 @@ def build_type_name_node(type) of_type: build_type_name_node(type.of_type) ) else - GraphQL::Language::Nodes::TypeName.new(name: type.graphql_name) + @cached_type_name_nodes ||= {} + @cached_type_name_nodes[type.graphql_name] ||= GraphQL::Language::Nodes::TypeName.new(name: type.graphql_name) end end @@ -194,10 +208,14 @@ def build_default_value(default_value, type) when "INPUT_OBJECT" GraphQL::Language::Nodes::InputObject.new( arguments: default_value.to_h.map do |arg_name, arg_value| - arg_type = type.arguments.fetch(arg_name.to_s).type + args = @types.arguments(type) + arg = args.find { |a| a.keyword.to_s == arg_name.to_s } + if arg.nil? + raise ArgumentError, "No argument definition on #{type.graphql_name} for argument: #{arg_name.inspect} (expected one of: #{args.map(&:keyword)})" + end GraphQL::Language::Nodes::Argument.new( - name: arg_name.to_s, - value: build_default_value(arg_value, arg_type) + name: arg.graphql_name.to_s, + value: build_default_value(arg_value, arg.type) ) end ) @@ -230,26 +248,65 @@ def build_type_definition_node(type) end def build_argument_nodes(arguments) - arguments - .map { |arg| build_argument_node(arg) } - .sort_by(&:name) + if !arguments.empty? + nodes = arguments.map { |arg| build_argument_node(arg) } + nodes.sort_by!(&:name) + nodes + else + arguments + end end def build_directive_nodes(directives) - if !include_built_in_directives - directives = directives.reject { |directive| directive.default_directive? } - end - directives .map { |directive| build_directive_node(directive) } .sort_by(&:name) end def build_definition_nodes - definitions = [] - definitions << build_schema_node if include_schema_node? - definitions += build_directive_nodes(warden.directives) - definitions += build_type_definition_nodes(warden.reachable_types) + dirs_to_build = @types.directives + if !include_built_in_directives + dirs_to_build = dirs_to_build.reject { |directive| directive.default_directive? } + end + definitions = build_directive_nodes(dirs_to_build) + all_types = @types.all_types + type_nodes = build_type_definition_nodes(all_types) + + if !(ex_t = schema.extra_types).empty? + dummy_query = Class.new(GraphQL::Schema::Object) do + graphql_name "DummyQuery" + (all_types + ex_t).each_with_index do |type, idx| + if !type.kind.input_object? && !type.introspection? + field "f#{idx}", type + end + end + end + + extra_types_schema = Class.new(GraphQL::Schema) do + query(dummy_query) + end + + extra_types_types = GraphQL::Query.new(extra_types_schema, "{ __typename }", context: @context).types # rubocop:disable Development/ContextIsPassedCop + # Temporarily replace `@types` with something from this example schema. + # It'd be much nicer to pass this in, but that would be a big refactor :S + prev_types = @types + @types = extra_types_types + type_nodes += build_type_definition_nodes(ex_t) + @types = prev_types + end + + type_nodes.sort_by!(&:name) + + if @include_one_of + # This may have been set to true when iterating over all types + definitions.concat(build_directive_nodes([GraphQL::Schema::Directive::OneOf])) + end + + definitions.concat(type_nodes) + if include_schema_node? + definitions.unshift(build_schema_node) + end + definitions end @@ -262,21 +319,21 @@ def build_type_definition_nodes(types) types = types.reject { |type| type.kind.scalar? && type.default_scalar? } end - types - .map { |type| build_type_definition_node(type) } - .sort_by(&:name) + types.map { |type| build_type_definition_node(type) } end def build_field_nodes(fields) - fields - .map { |field| build_field_node(field) } - .sort_by(&:name) + f_nodes = fields.map { |field| build_field_node(field) } + f_nodes.sort_by!(&:name) + f_nodes end private def include_schema_node? - always_include_schema || !schema_respects_root_name_conventions?(schema) + always_include_schema || + !schema_respects_root_name_conventions?(schema) || + !schema.schema_directives.empty? end def schema_respects_root_name_conventions?(schema) @@ -286,16 +343,17 @@ def schema_respects_root_name_conventions?(schema) end def directives(member) - definition_directives(member) + definition_directives(member, :directives) end - def definition_directives(member) - dirs = if !member.respond_to?(:directives) || member.directives.empty? - [] + def definition_directives(member, directives_method) + if !member.respond_to?(directives_method) || member.directives.empty? + EmptyObjects::EMPTY_ARRAY else - member.directives.map do |dir| + visible_directives = member.public_send(directives_method).select { |dir| @types.directive_exists?(dir.graphql_name) } + visible_directives.map! do |dir| args = [] - dir.arguments.argument_values.each_value do |arg_value| + dir.arguments.argument_values.each_value do |arg_value| # rubocop:disable Development/ContextIsPassedCop -- directive instance method arg_defn = arg_value.definition if arg_defn.default_value? && arg_value.value == arg_defn.default_value next @@ -307,38 +365,22 @@ def definition_directives(member) ) end end + + # If this schema uses this built-in directive definition, + # include it in the print-out since it's not part of the spec yet. + @include_one_of ||= dir.class == GraphQL::Schema::Directive::OneOf + GraphQL::Language::Nodes::Directive.new( name: dir.class.graphql_name, arguments: args ) end - end - # This is just for printing legacy `.define { ... }` schemas, where `deprecation_reason` isn't added to `.directives`. - if !member.respond_to?(:directives) && member.respond_to?(:deprecation_reason) && (reason = member.deprecation_reason) - arguments = [] - - if reason != GraphQL::Schema::Directive::DEFAULT_DEPRECATION_REASON - arguments << GraphQL::Language::Nodes::Argument.new( - name: "reason", - value: reason - ) - end - - dirs << GraphQL::Language::Nodes::Directive.new( - name: GraphQL::Directive::DeprecatedDirective.graphql_name, - arguments: arguments - ) + visible_directives end - - dirs - end - - def ast_directives(member) - member.ast_node ? member.ast_node.directives : [] end - attr_reader :schema, :warden, :always_include_schema, + attr_reader :schema, :always_include_schema, :include_introspection_types, :include_built_in_directives, :include_built_in_scalars end end diff --git a/lib/graphql/language/lexer.rb b/lib/graphql/language/lexer.rb index eef99ccd1f6..8e395a34298 100644 --- a/lib/graphql/language/lexer.rb +++ b/lib/graphql/language/lexer.rb @@ -1,1470 +1,382 @@ # frozen_string_literal: true - module GraphQL -module Language -module Lexer -if !String.method_defined?(:match?) - using GraphQL::StringMatchBackport -end - -def self.tokenize(query_string) - run_lexer(query_string) -end - -# Replace any escaped unicode or whitespace with the _actual_ characters -# To avoid allocating more strings, this modifies the string passed into it -def self.replace_escaped_characters_in_place(raw_string) - raw_string.gsub!(ESCAPES, ESCAPES_REPLACE) - raw_string.gsub!(UTF_8, &UTF_8_REPLACE) - nil -end - -private - -class << self - attr_accessor :_graphql_lexer_trans_keys - private :_graphql_lexer_trans_keys, :_graphql_lexer_trans_keys= -end -self._graphql_lexer_trans_keys = [ -1, 0, 4, 22, 4, 43, 14, 46, 14, 46, 14, 46, 14, 46, 4, 22, 4, 4, 4, 4, 4, 22, 4, 4, 4, 4, 14, 15, 14, 15, 10, 15, 12, 12, 4, 22, 4, 43, 14, 46, 14, 46, 14, 46, 14, 46, 0, 49, 0, 0, 4, 22, 4, 4, 4, 4, 4, 4, 4, 22, 4, 4, 4, 4, 1, 1, 14, 15, 10, 29, 14, 15, 10, 29, 10, 29, 12, 12, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 14, 46, 4, 4, 0 , -] - -class << self - attr_accessor :_graphql_lexer_char_class - private :_graphql_lexer_char_class, :_graphql_lexer_char_class= -end -self._graphql_lexer_char_class = [ -0, 1, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 3, 4, 5, 6, 2, 7, 2, 8, 9, 2, 10, 0, 11, 12, 13, 14, 15, 15, 15, 15, 15, 15, 15, 15, 15, 16, 2, 2, 17, 2, 2, 18, 19, 19, 19, 19, 20, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 21, 22, 23, 2, 24, 2, 25, 26, 27, 28, 29, 30, 31, 32, 33, 19, 19, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 19, 45, 46, 19, 47, 48, 49, 0 , -] - -class << self - attr_accessor :_graphql_lexer_index_offsets - private :_graphql_lexer_index_offsets, :_graphql_lexer_index_offsets= -end -self._graphql_lexer_index_offsets = [ -0, 0, 19, 59, 92, 125, 158, 191, 210, 211, 212, 231, 232, 233, 235, 237, 243, 244, 263, 303, 336, 369, 402, 435, 485, 486, 505, 506, 507, 508, 527, 528, 529, 530, 532, 552, 554, 574, 594, 595, 628, 661, 694, 727, 760, 793, 826, 859, 892, 925, 958, 991, 1024, 1057, 1090, 1123, 1156, 1189, 1222, 1255, 1288, 1321, 1354, 1387, 1420, 1453, 1486, 1519, 1552, 1585, 1618, 1651, 1684, 1717, 1750, 1783, 1816, 1849, 1882, 1915, 1948, 1981, 2014, 2047, 2080, 2113, 2146, 2179, 2212, 2245, 2278, 2311, 2344, 2377, 2410, 2443, 2476, 2509, 2542, 2575, 2608, 2641, 2674, 2707, 2740, 2773, 2806, 2839, 2872, 2905, 2938, 2971, 3004, 3037, 3070, 3103, 3136, 3169, 3202, 3235, 3268, 3301, 3334, 3367, 3400, 3433, 0 , -] - -class << self - attr_accessor :_graphql_lexer_indicies - private :_graphql_lexer_indicies, :_graphql_lexer_indicies= -end -self._graphql_lexer_indicies = [ -2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 4, 5, 5, 0, 0, 0, 5, 5, 0, 0, 0, 0, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 0, 0, 0, 6, 6, 0, 0, 0, 0, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 7, 0, 0, 0, 7, 7, 0, 0, 0, 0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 10, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 11, 12, 13, 14, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 11, 15, 16, 17, 17, 19, 19, 20, 20, 8, 8, 17, 17, 21, 23, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 24, 22, 25, 25, 25, 25, 25, 25, 25, 25, 22, 25, 25, 25, 25, 25, 25, 25, 25, 22, 25, 25, 25, 22, 25, 25, 25, 22, 25, 25, 25, 25, 25, 22, 25, 25, 25, 22, 25, 22, 26, 27, 27, 25, 25, 25, 27, 27, 25, 25, 25, 25, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 28, 28, 25, 25, 25, 28, 28, 25, 25, 25, 25, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 29, 29, 25, 25, 25, 29, 29, 25, 25, 25, 25, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 29, 22, 22, 25, 25, 25, 22, 22, 25, 25, 25, 25, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 31, 32, 30, 33, 34, 35, 36, 37, 38, 39, 30, 40, 41, 30, 42, 43, 44, 45, 46, 47, 47, 48, 30, 49, 47, 47, 47, 47, 50, 51, 52, 47, 47, 53, 47, 54, 55, 56, 47, 57, 47, 58, 59, 60, 47, 47, 47, 61, 62, 63, 31, 66, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 9, 69, 70, 71, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 11, 72, 13, 73, 42, 43, 20, 20, 75, 74, 17, 17, 74, 74, 74, 74, 76, 74, 74, 74, 74, 74, 74, 74, 74, 76, 17, 17, 20, 20, 77, 77, 19, 19, 77, 77, 77, 77, 76, 77, 77, 77, 77, 77, 77, 77, 77, 76, 20, 20, 75, 74, 43, 43, 74, 74, 74, 74, 76, 74, 74, 74, 74, 74, 74, 74, 74, 76, 78, 47, 47, 8, 8, 8, 47, 47, 8, 8, 8, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 80, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 81, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 82, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 83, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 84, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 85, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 86, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 87, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 88, 47, 47, 47, 47, 47, 47, 47, 47, 89, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 90, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 91, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 92, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 93, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 94, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 95, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 96, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 97, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 98, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 99, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 100, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 101, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 102, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 103, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 104, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 105, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 106, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 107, 108, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 109, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 110, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 111, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 112, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 113, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 114, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 115, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 116, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 117, 47, 47, 47, 118, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 119, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 120, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 121, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 122, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 123, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 124, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 125, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 126, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 127, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 128, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 129, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 130, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 131, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 132, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 133, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 134, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 135, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 136, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 137, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 138, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 139, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 140, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 141, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 142, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 143, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 144, 47, 47, 47, 47, 47, 47, 145, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 146, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 147, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 148, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 149, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 150, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 151, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 152, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 153, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 154, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 155, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 156, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 157, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 158, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 159, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 160, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 161, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 162, 47, 47, 47, 47, 47, 163, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 164, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 165, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 166, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 167, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 168, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 169, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 170, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 79, 79, 79, 47, 47, 79, 79, 79, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 171, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 22, 0 , -] - -class << self - attr_accessor :_graphql_lexer_index_defaults - private :_graphql_lexer_index_defaults, :_graphql_lexer_index_defaults= -end -self._graphql_lexer_index_defaults = [ -0, 1, 0, 0, 0, 0, 0, 9, 9, 9, 9, 9, 9, 8, 18, 8, 0, 22, 25, 25, 25, 25, 25, 30, 64, 1, 67, 68, 68, 9, 9, 9, 35, 65, 74, 77, 77, 74, 65, 8, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, 25, 0 , -] - -class << self - attr_accessor :_graphql_lexer_trans_cond_spaces - private :_graphql_lexer_trans_cond_spaces, :_graphql_lexer_trans_cond_spaces= -end -self._graphql_lexer_trans_cond_spaces = [ --1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0 , -] - -class << self - attr_accessor :_graphql_lexer_cond_targs - private :_graphql_lexer_cond_targs, :_graphql_lexer_cond_targs= -end -self._graphql_lexer_cond_targs = [ -23, 1, 23, 2, 3, 4, 5, 6, 23, 7, 8, 10, 9, 27, 11, 12, 29, 35, 23, 36, 13, 23, 17, 125, 18, 0, 19, 20, 21, 22, 23, 24, 23, 23, 25, 32, 23, 23, 23, 23, 33, 38, 34, 37, 23, 23, 23, 39, 23, 23, 40, 48, 55, 65, 83, 90, 93, 94, 98, 116, 121, 23, 23, 23, 23, 23, 26, 23, 23, 28, 23, 30, 31, 23, 23, 14, 15, 23, 16, 23, 41, 42, 43, 44, 45, 46, 47, 39, 49, 51, 50, 39, 52, 53, 54, 39, 56, 59, 57, 58, 39, 60, 61, 62, 63, 64, 39, 66, 74, 67, 68, 69, 70, 71, 72, 73, 39, 75, 77, 76, 39, 78, 79, 80, 81, 82, 39, 84, 85, 86, 87, 88, 89, 39, 91, 92, 39, 39, 95, 96, 97, 39, 99, 106, 100, 103, 101, 102, 39, 104, 105, 39, 107, 108, 109, 110, 111, 112, 113, 114, 115, 39, 117, 119, 118, 39, 120, 39, 122, 123, 124, 39, 0 , -] - -class << self - attr_accessor :_graphql_lexer_cond_actions - private :_graphql_lexer_cond_actions, :_graphql_lexer_cond_actions= -end -self._graphql_lexer_cond_actions = [ -1, 0, 2, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 5, 6, 0, 7, 0, 8, 0, 0, 0, 0, 0, 0, 11, 0, 12, 13, 14, 0, 15, 16, 17, 18, 0, 14, 19, 19, 20, 21, 22, 23, 24, 25, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 26, 27, 28, 29, 30, 31, 32, 33, 0, 34, 4, 4, 35, 36, 0, 0, 37, 0, 38, 0, 0, 0, 0, 0, 0, 0, 39, 0, 0, 0, 40, 0, 0, 0, 41, 0, 0, 0, 0, 42, 0, 0, 0, 0, 0, 43, 0, 0, 0, 0, 0, 0, 0, 0, 0, 44, 0, 0, 0, 45, 0, 0, 0, 0, 0, 46, 0, 0, 0, 0, 0, 0, 47, 0, 0, 48, 49, 0, 0, 0, 50, 0, 0, 0, 0, 0, 0, 51, 0, 0, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 53, 0, 0, 0, 54, 0, 55, 0, 0, 0, 56, 0 , -] - -class << self - attr_accessor :_graphql_lexer_to_state_actions - private :_graphql_lexer_to_state_actions, :_graphql_lexer_to_state_actions= -end -self._graphql_lexer_to_state_actions = [ -0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0 , -] - -class << self - attr_accessor :_graphql_lexer_from_state_actions - private :_graphql_lexer_from_state_actions, :_graphql_lexer_from_state_actions= -end -self._graphql_lexer_from_state_actions = [ -0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0 , -] - -class << self - attr_accessor :_graphql_lexer_eof_trans - private :_graphql_lexer_eof_trans, :_graphql_lexer_eof_trans= -end -self._graphql_lexer_eof_trans = [ -0, 1, 1, 1, 1, 1, 1, 9, 9, 9, 9, 9, 9, 9, 19, 9, 1, 0, 0, 0, 0, 0, 0, 0, 65, 66, 68, 69, 69, 69, 69, 69, 74, 66, 75, 78, 78, 75, 66, 9, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 0, 0 , -] - -class << self - attr_accessor :_graphql_lexer_nfa_targs - private :_graphql_lexer_nfa_targs, :_graphql_lexer_nfa_targs= -end -self._graphql_lexer_nfa_targs = [ -0, 0 , -] - -class << self - attr_accessor :_graphql_lexer_nfa_offsets - private :_graphql_lexer_nfa_offsets, :_graphql_lexer_nfa_offsets= -end -self._graphql_lexer_nfa_offsets = [ -0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 , -] - -class << self - attr_accessor :_graphql_lexer_nfa_push_actions - private :_graphql_lexer_nfa_push_actions, :_graphql_lexer_nfa_push_actions= -end -self._graphql_lexer_nfa_push_actions = [ -0, 0 , -] - -class << self - attr_accessor :_graphql_lexer_nfa_pop_trans - private :_graphql_lexer_nfa_pop_trans, :_graphql_lexer_nfa_pop_trans= -end -self._graphql_lexer_nfa_pop_trans = [ -0, 0 , -] - -class << self - attr_accessor :graphql_lexer_start -end -self.graphql_lexer_start = 23; - -class << self - attr_accessor :graphql_lexer_first_final -end -self.graphql_lexer_first_final = 23; - -class << self - attr_accessor :graphql_lexer_error -end -self.graphql_lexer_error = 0; - -class << self - attr_accessor :graphql_lexer_en_str -end -self.graphql_lexer_en_str = 125; - -class << self - attr_accessor :graphql_lexer_en_main -end -self.graphql_lexer_en_main = 23; - -def self.run_lexer(query_string) - data = query_string.unpack(PACK_DIRECTIVE) - eof = data.length - - # Since `Lexer` is a module, store all lexer state - # in this local variable: - meta = { - line: 1, - col: 1, - data: data, - tokens: [], - previous_token: nil, - } - - p ||= 0 - pe ||= data.length - - begin - cs = graphql_lexer_start; - ts = 0; - te = 0; - act = 0; - - end - begin - _trans = 0; - _have = 0; - _cont = 1; - _keys = 0; - _inds = 0; - while ( _cont == 1 ) - begin - if ( cs == 0 ) - _cont = 0; - - end - _have = 0; - if ( p == pe ) - begin - if ( p == eof ) - begin - if ( _graphql_lexer_eof_trans[cs] > 0 ) - begin - _trans = _graphql_lexer_eof_trans[cs] - 1; - _have = 1; - - end - - end - if ( _have == 0 ) - begin - - end - - end - - end - - end - if ( _have == 0 ) - _cont = 0; - - end - - end - - end - if ( _cont == 1 ) - begin - if ( _have == 0 ) - begin - case _graphql_lexer_from_state_actions[cs] - when -2 then - begin - end - when 10 then - begin - begin - begin - ts = p; - - end - - end - - - end - end - _keys = (cs<<1) ; - _inds = _graphql_lexer_index_offsets[cs] ; - if ( ( data[p ].ord) <= 125 && ( data[p ].ord) >= 9 ) - begin - _ic = _graphql_lexer_char_class[( data[p ].ord) - 9]; - if ( _ic <= _graphql_lexer_trans_keys[_keys+1 ]&& _ic >= _graphql_lexer_trans_keys[_keys ] ) - _trans = _graphql_lexer_indicies[_inds + ( _ic - _graphql_lexer_trans_keys[_keys ]) ]; - - else - _trans = _graphql_lexer_index_defaults[cs]; - - end - - end - - else - begin - _trans = _graphql_lexer_index_defaults[cs]; - - end - - end - - end - - end - if ( _cont == 1 ) - begin - cs = _graphql_lexer_cond_targs[_trans]; - case _graphql_lexer_cond_actions[_trans] - when -2 then - begin - end - when 14 then - begin - begin - begin - te = p+1; - - end - - end - - end - when 8 then - begin - begin - begin - te = p+1; - begin - emit_string(ts, te, meta, block: false) - end - - end - - end - - end - when 28 then - begin - begin - begin - te = p+1; - begin - emit(:RCURLY, ts, te, meta, "}") - end - - end - - end - - end - when 26 then - begin - begin - begin - te = p+1; - begin - emit(:LCURLY, ts, te, meta, "{") - end - - end - - end - - end - when 18 then - begin - begin - begin - te = p+1; - begin - emit(:RPAREN, ts, te, meta, ")") - end - - end - - end - - end - when 17 then - begin - begin - begin - te = p+1; - begin - emit(:LPAREN, ts, te, meta, "(") - end - - end - - end - - end - when 25 then - begin - begin - begin - te = p+1; - begin - emit(:RBRACKET, ts, te, meta, "]") - end - - end - - end - - end - when 24 then - begin - begin - begin - te = p+1; - begin - emit(:LBRACKET, ts, te, meta, "[") - end - - end - - end - - end - when 20 then - begin - begin - begin - te = p+1; - begin - emit(:COLON, ts, te, meta, ":") - end - - end - - end - - end - when 2 then - begin - begin - begin - te = p+1; - begin - emit_string(ts, te, meta, block: false) - end - - end - - end - - end - when 34 then - begin - begin - begin - te = p+1; - begin - emit_string(ts, te, meta, block: true) - end - - end - - end - - end - when 15 then - begin - begin - begin - te = p+1; - begin - emit(:VAR_SIGN, ts, te, meta, "$") - end - - end - - end - - end - when 22 then - begin - begin - begin - te = p+1; - begin - emit(:DIR_SIGN, ts, te, meta, "@") - end - - end - - end - - end - when 7 then - begin - begin - begin - te = p+1; - begin - emit(:ELLIPSIS, ts, te, meta, "...") - end - - end - - end - - end - when 21 then - begin - begin - begin - te = p+1; - begin - emit(:EQUALS, ts, te, meta, "=") - end - - end - - end - - end - when 13 then - begin - begin - begin - te = p+1; - begin - emit(:BANG, ts, te, meta, "!") - end - - end - - end - - end - when 27 then - begin - begin - begin - te = p+1; - begin - emit(:PIPE, ts, te, meta, "|") - end - - end - - end - - end - when 16 then - begin - begin - begin - te = p+1; - begin - emit(:AMP, ts, te, meta, "&") - end - - end - - end - - end - when 12 then - begin - begin - begin - te = p+1; - begin - meta[:line] += 1 - meta[:col] = 1 - - end - - end - - end - - end - when 11 then - begin - begin - begin - te = p+1; - begin - emit(:UNKNOWN_CHAR, ts, te, meta) - end - - end - - end - - end - when 36 then - begin - begin - begin - te = p; - p = p - 1; - begin - emit(:INT, ts, te, meta) - end - - end - - end - - end - when 37 then - begin - begin - begin - te = p; - p = p - 1; - begin - emit(:FLOAT, ts, te, meta) - end - - end - - end - - end - when 32 then - begin - begin - begin - te = p; - p = p - 1; - begin - emit_string(ts, te, meta, block: false) - end - - end - - end - - end - when 33 then - begin - begin - begin - te = p; - p = p - 1; - begin - emit_string(ts, te, meta, block: true) - end - - end - - end - - end - when 38 then - begin - begin - begin - te = p; - p = p - 1; - begin - emit(:IDENTIFIER, ts, te, meta) - end - - end - - end - - end - when 35 then - begin - begin - begin - te = p; - p = p - 1; - begin - record_comment(ts, te, meta) - end - - end - - end - - end - when 29 then - begin - begin - begin - te = p; - p = p - 1; - begin - meta[:col] += te - ts - end - - end - - end - - end - when 30 then - begin - begin - begin - te = p; - p = p - 1; - begin - emit(:UNKNOWN_CHAR, ts, te, meta) - end - - end - - end - - end - when 5 then - begin - begin - begin - p = ((te))-1; - begin - emit(:INT, ts, te, meta) - end - - end - - end - - end - when 1 then - begin - begin - begin - p = ((te))-1; - begin - emit(:UNKNOWN_CHAR, ts, te, meta) - end - - end - - end - - end - when 3 then - begin - begin - begin - case act - when -2 then - begin - end - when 2 then - begin - p = ((te))-1; - begin - emit(:INT, ts, te, meta) - end - - end - when 3 then - begin - p = ((te))-1; - begin - emit(:FLOAT, ts, te, meta) - end - - end - when 4 then - begin - p = ((te))-1; - begin - emit(:ON, ts, te, meta, "on") - end - - end - when 5 then - begin - p = ((te))-1; - begin - emit(:FRAGMENT, ts, te, meta, "fragment") - end - - end - when 6 then - begin - p = ((te))-1; - begin - emit(:TRUE, ts, te, meta, "true") - end - - end - when 7 then - begin - p = ((te))-1; - begin - emit(:FALSE, ts, te, meta, "false") - end - - end - when 8 then - begin - p = ((te))-1; - begin - emit(:NULL, ts, te, meta, "null") - end - - end - when 9 then - begin - p = ((te))-1; - begin - emit(:QUERY, ts, te, meta, "query") - end - - end - when 10 then - begin - p = ((te))-1; - begin - emit(:MUTATION, ts, te, meta, "mutation") - end - - end - when 11 then - begin - p = ((te))-1; - begin - emit(:SUBSCRIPTION, ts, te, meta, "subscription") - end - - end - when 12 then - begin - p = ((te))-1; - begin - emit(:SCHEMA, ts, te, meta) - end - - end - when 13 then - begin - p = ((te))-1; - begin - emit(:SCALAR, ts, te, meta) - end - - end - when 14 then - begin - p = ((te))-1; - begin - emit(:TYPE, ts, te, meta) - end - - end - when 15 then - begin - p = ((te))-1; - begin - emit(:EXTEND, ts, te, meta) - end - - end - when 16 then - begin - p = ((te))-1; - begin - emit(:IMPLEMENTS, ts, te, meta) - end - - end - when 17 then - begin - p = ((te))-1; - begin - emit(:INTERFACE, ts, te, meta) - end - - end - when 18 then - begin - p = ((te))-1; - begin - emit(:UNION, ts, te, meta) - end - - end - when 19 then - begin - p = ((te))-1; - begin - emit(:ENUM, ts, te, meta) - end - - end - when 20 then - begin - p = ((te))-1; - begin - emit(:INPUT, ts, te, meta) - end - - end - when 21 then - begin - p = ((te))-1; - begin - emit(:DIRECTIVE, ts, te, meta) - end - - end - when 29 then - begin - p = ((te))-1; - begin - emit_string(ts, te, meta, block: false) - end - - end - when 30 then - begin - p = ((te))-1; - begin - emit_string(ts, te, meta, block: true) - end - - end - when 38 then - begin - p = ((te))-1; - begin - emit(:IDENTIFIER, ts, te, meta) - end - - - end - end - - end - - - end - - end - when 19 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 2; - - end - - end - - end - when 6 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 3; - - end - - end - - end - when 49 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 4; - - end - - end - - end - when 43 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 5; - - end - - end - - end - when 54 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 6; - - end - - end - - end - when 42 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 7; - - end - - end - - end - when 48 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 8; - - end - - end - - end - when 50 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 9; - - end - - end - - end - when 47 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 10; - - end - - end - - end - when 53 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 11; - - end - - end - - end - when 52 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 12; - - end - - end - - end - when 51 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 13; - - end - - end - - end - when 55 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 14; - - end - - end - - end - when 41 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 15; - - end - - end - - end - when 44 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 16; - - end - - end - - end - when 46 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 17; - - end - - end - - end - when 56 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 18; - - end - - end - - end - when 40 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 19; - - end - - end - - end - when 45 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 20; - - end - - end - - end - when 39 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 21; - - end - - end - - end - when 31 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 29; - - end - - end - - end - when 4 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 30; - - end - - end - - end - when 23 then - begin - begin - begin - te = p+1; - - end - - end - begin - begin - act = 38; - - end - - end - - - end - end - case _graphql_lexer_to_state_actions[cs] - when -2 then - begin - end - when 9 then - begin - begin - begin - ts = 0; - - end - - end - - - end - end - if ( cs == 0 ) - _cont = 0; - - end - if ( _cont == 1 ) - p += 1; - - end - - end - - end - - end - -end - -end - -end - -end -meta[:tokens] -end - -def self.record_comment(ts, te, meta) -token = GraphQL::Language::Token.new( -:COMMENT, -meta[:data][ts, te - ts].pack(PACK_DIRECTIVE).force_encoding(UTF_8_ENCODING), -meta[:line], -meta[:col], -meta[:previous_token], -) - -meta[:previous_token] = token - -meta[:col] += te - ts -end - -def self.emit(token_name, ts, te, meta, token_value = nil) -token_value ||= meta[:data][ts, te - ts].pack(PACK_DIRECTIVE).force_encoding(UTF_8_ENCODING) -meta[:tokens] << token = GraphQL::Language::Token.new( -token_name, -token_value, -meta[:line], -meta[:col], -meta[:previous_token], -) -meta[:previous_token] = token -# Bump the column counter for the next token -meta[:col] += te - ts -end - -ESCAPES = /\\["\\\/bfnrt]/ -ESCAPES_REPLACE = { -'\\"' => '"', -"\\\\" => "\\", -"\\/" => '/', -"\\b" => "\b", -"\\f" => "\f", -"\\n" => "\n", -"\\r" => "\r", -"\\t" => "\t", -} - -UTF_8 = /\\u[\dAa-f]{4}/i -UTF_8_REPLACE = ->(m) { [m[-4..-1].to_i(16)].pack('U'.freeze) } - -VALID_STRING = /\A(?:[^\\]|#{ESCAPES}|#{UTF_8})*\z/o - -PACK_DIRECTIVE = "c*" -UTF_8_ENCODING = "UTF-8" - -def self.emit_string(ts, te, meta, block:) -quotes_length = block ? 3 : 1 -value = meta[:data][ts + quotes_length, te - ts - 2 * quotes_length].pack(PACK_DIRECTIVE).force_encoding(UTF_8_ENCODING) || '' -line_incr = 0 -if block && !value.empty? -line_incr = value.count("\n") -value = GraphQL::Language::BlockString.trim_whitespace(value) -end -# TODO: replace with `String#match?` when we support only Ruby 2.4+ -# (It's faster: https://bugs.ruby-lang.org/issues/8110) -if !value.valid_encoding? || !value.match?(VALID_STRING) -meta[:tokens] << token = GraphQL::Language::Token.new( -:BAD_UNICODE_ESCAPE, -value, -meta[:line], -meta[:col], -meta[:previous_token], -) -else -replace_escaped_characters_in_place(value) - -if !value.valid_encoding? - meta[:tokens] << token = GraphQL::Language::Token.new( - :BAD_UNICODE_ESCAPE, - value, - meta[:line], - meta[:col], - meta[:previous_token], - ) - else - meta[:tokens] << token = GraphQL::Language::Token.new( - :STRING, - value, - meta[:line], - meta[:col], - meta[:previous_token], - ) - end -end - -meta[:previous_token] = token -meta[:col] += te - ts -meta[:line] += line_incr -end -end -end + module Language + + class Lexer + def initialize(graphql_str, filename: nil, max_tokens: nil) + if !(graphql_str.encoding == Encoding::UTF_8 || graphql_str.ascii_only?) + graphql_str = graphql_str.dup.force_encoding(Encoding::UTF_8) + end + @string = graphql_str + @filename = filename + @scanner = StringScanner.new(graphql_str) + @pos = nil + @max_tokens = max_tokens || Float::INFINITY + @tokens_count = 0 + @finished = false + end + + def finished? + @finished + end + + def freeze + @scanner = nil + super + end + + attr_reader :pos, :tokens_count + + def advance + loop do + @scanner.skip(IGNORE_REGEXP) + if @scanner.skip(COMMENT_REGEXP) + @tokens_count += 1 + next + end + break + end + + if @scanner.eos? + @finished = true + return false + end + @tokens_count += 1 + if @tokens_count > @max_tokens + raise_parse_error("This query is too large to execute.") + end + @pos = @scanner.pos + next_byte = @string.getbyte(@pos) + next_byte_is_for = FIRST_BYTES[next_byte] + case next_byte_is_for + when ByteFor::PUNCTUATION + @scanner.pos += 1 + PUNCTUATION_NAME_FOR_BYTE[next_byte] + when ByteFor::NAME + if len = @scanner.skip(KEYWORD_REGEXP) + case len + when 2 + :ON + when 12 + :SUBSCRIPTION + else + pos = @pos + + # Use bytes 2 and 3 as a unique identifier for this keyword + bytes = (@string.getbyte(pos + 2) << 8) | @string.getbyte(pos + 1) + KEYWORD_BY_TWO_BYTES[_hash(bytes)] + end + else + @scanner.skip(IDENTIFIER_REGEXP) + :IDENTIFIER + end + when ByteFor::IDENTIFIER + @scanner.skip(IDENTIFIER_REGEXP) + :IDENTIFIER + when ByteFor::NUMBER + if len = @scanner.skip(NUMERIC_REGEXP) + + if GraphQL.reject_numbers_followed_by_names + new_pos = @scanner.pos + peek_byte = @string.getbyte(new_pos) + next_first_byte = FIRST_BYTES[peek_byte] + if next_first_byte == ByteFor::NAME || next_first_byte == ByteFor::IDENTIFIER + number_part = token_value + name_part = @scanner.scan(IDENTIFIER_REGEXP) + raise_parse_error("Name after number is not allowed (in `#{number_part}#{name_part}`)") + end + end + # Check for a matched decimal: + @scanner[1] ? :FLOAT : :INT + else + # Attempt to find the part after the `-` + value = @scanner.scan(/-\s?[a-z0-9]*/i) + invalid_byte_for_number_error_message = "Expected type 'number', but it was malformed#{value.nil? ? "" : ": #{value.inspect}"}." + raise_parse_error(invalid_byte_for_number_error_message) + end + when ByteFor::ELLIPSIS + if @string.getbyte(@pos + 1) != 46 || @string.getbyte(@pos + 2) != 46 + raise_parse_error("Expected `...`, actual: #{@string[@pos..@pos + 2].inspect}") + end + @scanner.pos += 3 + :ELLIPSIS + when ByteFor::STRING + if @scanner.skip(BLOCK_STRING_REGEXP) || @scanner.skip(QUOTED_STRING_REGEXP) + :STRING + else + raise_parse_error("Expected string or block string, but it was malformed") + end + else + @scanner.pos += 1 + :UNKNOWN_CHAR + end + rescue ArgumentError => err + if err.message == "invalid byte sequence in UTF-8" + raise_parse_error("Parse error on bad Unicode escape sequence", nil, nil) + end + end + + def token_value + @string.byteslice(@scanner.pos - @scanner.matched_size, @scanner.matched_size) + rescue StandardError => err + raise GraphQL::Error, "(token_value failed: #{err.class}: #{err.message})" + end + + def debug_token_value(token_name) + if token_name && Lexer::Punctuation.const_defined?(token_name) + Lexer::Punctuation.const_get(token_name) + elsif token_name == :ELLIPSIS + "..." + elsif token_name == :STRING + string_value + elsif @scanner.matched_size.nil? + @string.byteslice(@scanner.pos - 1, 1) + else + token_value + end + end + + ESCAPES = /\\["\\\/bfnrt]/ + ESCAPES_REPLACE = { + '\\"' => '"', + "\\\\" => "\\", + "\\/" => '/', + "\\b" => "\b", + "\\f" => "\f", + "\\n" => "\n", + "\\r" => "\r", + "\\t" => "\t", + } + UTF_8 = /\\u(?:([\dAa-f]{4})|\{([\da-f]{4,})\})(?:\\u([\dAa-f]{4}))?/i + VALID_STRING = /\A(?:[^\\]|#{ESCAPES}|#{UTF_8})*\z/o + ESCAPED = /(?:#{ESCAPES}|#{UTF_8})/o + + def string_value + str = token_value + is_block = str.start_with?('"""') + if is_block + str.gsub!(/\A"""|"""\z/, '') + return Language::BlockString.trim_whitespace(str) + else + str.gsub!(/\A"|"\z/, '') + + if !str.valid_encoding? || !str.match?(VALID_STRING) + raise_parse_error("Bad unicode escape in #{str.inspect}") + else + Lexer.replace_escaped_characters_in_place(str) + + if !str.valid_encoding? + raise_parse_error("Bad unicode escape in #{str.inspect}") + else + str + end + end + end + end + + def line_number + @scanner.string[0..@pos].count("\n") + 1 + end + + def column_number + @scanner.string[0..@pos].split("\n").last.length + end + + def raise_parse_error(message, line = line_number, col = column_number) + raise GraphQL::ParseError.new(message, line, col, @string, filename: @filename) + end + + IGNORE_REGEXP = /[, \c\r\n\t]+/ + COMMENT_REGEXP = /\#[^\n]*/ + IDENTIFIER_REGEXP = /[_A-Za-z][_0-9A-Za-z]*/ + INT_REGEXP = /-?(?:[0]|[1-9][0-9]*)/ + FLOAT_DECIMAL_REGEXP = /[.][0-9]+/ + FLOAT_EXP_REGEXP = /[eE][+-]?[0-9]+/ + # TODO: FLOAT_EXP_REGEXP should not be allowed to follow INT_REGEXP, integers are not allowed to have exponent parts. + NUMERIC_REGEXP = /#{INT_REGEXP}(#{FLOAT_DECIMAL_REGEXP}#{FLOAT_EXP_REGEXP}|#{FLOAT_DECIMAL_REGEXP}|#{FLOAT_EXP_REGEXP})?/ + + KEYWORDS = [ + "on", + "fragment", + "true", + "false", + "null", + "query", + "mutation", + "subscription", + "schema", + "scalar", + "type", + "extend", + "implements", + "interface", + "union", + "enum", + "input", + "directive", + "repeatable" + ].freeze + + KEYWORD_REGEXP = /#{Regexp.union(KEYWORDS.sort)}\b/ + KEYWORD_BY_TWO_BYTES = [ + :INTERFACE, + :MUTATION, + :EXTEND, + :FALSE, + :ENUM, + :TRUE, + :NULL, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + :QUERY, + nil, + nil, + :REPEATABLE, + :IMPLEMENTS, + :INPUT, + :TYPE, + :SCHEMA, + nil, + nil, + nil, + :DIRECTIVE, + :UNION, + nil, + nil, + :SCALAR, + nil, + :FRAGMENT + ].freeze + + # This produces a unique integer for bytes 2 and 3 of each keyword string + # See https://tenderlovemaking.com/2023/09/02/fast-tokenizers-with-stringscanner.html + def _hash key + (key * 18592990) >> 27 & 0x1f + end + + module Punctuation + LCURLY = '{' + RCURLY = '}' + LPAREN = '(' + RPAREN = ')' + LBRACKET = '[' + RBRACKET = ']' + COLON = ':' + VAR_SIGN = '$' + DIR_SIGN = '@' + EQUALS = '=' + BANG = '!' + PIPE = '|' + AMP = '&' + end + + # A sparse array mapping the bytes for each punctuation + # to a symbol name for that punctuation + PUNCTUATION_NAME_FOR_BYTE = Punctuation.constants.each_with_object([]) { |name, arr| + punct = Punctuation.const_get(name) + arr[punct.ord] = name + }.freeze + + + QUOTE = '"' + UNICODE_DIGIT = /[0-9A-Za-z]/ + FOUR_DIGIT_UNICODE = /#{UNICODE_DIGIT}{4}/ + N_DIGIT_UNICODE = %r{#{Punctuation::LCURLY}#{UNICODE_DIGIT}{4,}#{Punctuation::RCURLY}}x + UNICODE_ESCAPE = %r{\\u(?:#{FOUR_DIGIT_UNICODE}|#{N_DIGIT_UNICODE})} + STRING_ESCAPE = %r{[\\][\\/bfnrt]} + BLOCK_QUOTE = '"""' + ESCAPED_QUOTE = /\\"/; + STRING_CHAR = /#{ESCAPED_QUOTE}|[^"\\\n\r]|#{UNICODE_ESCAPE}|#{STRING_ESCAPE}/ + QUOTED_STRING_REGEXP = %r{#{QUOTE} (?:#{STRING_CHAR})* #{QUOTE}}x + BLOCK_STRING_REGEXP = %r{ + #{BLOCK_QUOTE} + (?: [^"\\] | # Any characters that aren't a quote or slash + (?= 0xD800 && codepoint_1 <= 0xDBFF) && # leading surrogate + (codepoint_2 >= 0xDC00 && codepoint_2 <= 0xDFFF) # trailing surrogate + # A surrogate pair + combined = ((codepoint_1 - 0xD800) * 0x400) + (codepoint_2 - 0xDC00) + 0x10000 + [combined].pack('U'.freeze) + else + # Two separate code points + [codepoint_1].pack('U'.freeze) + [codepoint_2].pack('U'.freeze) + end + else + [codepoint_1].pack('U'.freeze) + end + else + ESCAPES_REPLACE[matched_str] + end + end + nil + end + + # This is not used during parsing because the parser + # doesn't actually need tokens. + def self.tokenize(string) + lexer = GraphQL::Language::Lexer.new(string) + tokens = [] + while (token_name = lexer.advance) + new_token = [ + token_name, + lexer.line_number, + lexer.column_number, + lexer.debug_token_value(token_name), + ] + tokens << new_token + end + tokens + end + end + end end diff --git a/lib/graphql/language/lexer.rl b/lib/graphql/language/lexer.rl deleted file mode 100644 index 84bcbafb1ed..00000000000 --- a/lib/graphql/language/lexer.rl +++ /dev/null @@ -1,262 +0,0 @@ -%%{ - machine graphql_lexer; - - IDENTIFIER = [_A-Za-z][_0-9A-Za-z]*; - NEWLINE = [\c\r\n]; - BLANK = [, \t]+; - COMMENT = '#' [^\n\r]*; - INT = '-'? ('0'|[1-9][0-9]*); - FLOAT_DECIMAL = '.'[0-9]+; - FLOAT_EXP = ('e' | 'E')?('+' | '-')?[0-9]+; - FLOAT = INT FLOAT_DECIMAL? FLOAT_EXP?; - ON = 'on'; - FRAGMENT = 'fragment'; - TRUE = 'true'; - FALSE = 'false'; - NULL = 'null'; - QUERY = 'query'; - MUTATION = 'mutation'; - SUBSCRIPTION = 'subscription'; - SCHEMA = 'schema'; - SCALAR = 'scalar'; - TYPE = 'type'; - EXTEND = 'extend'; - IMPLEMENTS = 'implements'; - INTERFACE = 'interface'; - UNION = 'union'; - ENUM = 'enum'; - INPUT = 'input'; - DIRECTIVE = 'directive'; - LCURLY = '{'; - RCURLY = '}'; - LPAREN = '('; - RPAREN = ')'; - LBRACKET = '['; - RBRACKET = ']'; - COLON = ':'; - QUOTE = '"'; - BACKSLASH = '\\'; - # Could limit to hex here, but “bad unicode escape” on 0XXF is probably a - # more helpful error than “unknown char” - UNICODE_ESCAPE = '\\u' [0-9A-Za-z]{4}; - # https://graphql.github.io/graphql-spec/June2018/#sec-String-Value - STRING_ESCAPE = '\\' [\\/bfnrt]; - BLOCK_QUOTE = '"""'; - ESCAPED_BLOCK_QUOTE = '\\"""'; - BLOCK_STRING_CHAR = (ESCAPED_BLOCK_QUOTE | ^QUOTE | QUOTE{1,2} ^QUOTE); - ESCAPED_QUOTE = '\\"'; - STRING_CHAR = ((ESCAPED_QUOTE | ^QUOTE) - BACKSLASH) | UNICODE_ESCAPE | STRING_ESCAPE; - VAR_SIGN = '$'; - DIR_SIGN = '@'; - ELLIPSIS = '...'; - EQUALS = '='; - BANG = '!'; - PIPE = '|'; - AMP = '&'; - - QUOTED_STRING = QUOTE STRING_CHAR* QUOTE; - BLOCK_STRING = BLOCK_QUOTE BLOCK_STRING_CHAR* QUOTE{0,2} BLOCK_QUOTE; - # catch-all for anything else. must be at the bottom for precedence. - UNKNOWN_CHAR = /./; - - # Used with ragel -V for graphviz visualization - str := |* - QUOTED_STRING => { emit_string(ts, te, meta, block: false) }; - *|; - - main := |* - INT => { emit(:INT, ts, te, meta) }; - FLOAT => { emit(:FLOAT, ts, te, meta) }; - ON => { emit(:ON, ts, te, meta, "on") }; - FRAGMENT => { emit(:FRAGMENT, ts, te, meta, "fragment") }; - TRUE => { emit(:TRUE, ts, te, meta, "true") }; - FALSE => { emit(:FALSE, ts, te, meta, "false") }; - NULL => { emit(:NULL, ts, te, meta, "null") }; - QUERY => { emit(:QUERY, ts, te, meta, "query") }; - MUTATION => { emit(:MUTATION, ts, te, meta, "mutation") }; - SUBSCRIPTION => { emit(:SUBSCRIPTION, ts, te, meta, "subscription") }; - SCHEMA => { emit(:SCHEMA, ts, te, meta) }; - SCALAR => { emit(:SCALAR, ts, te, meta) }; - TYPE => { emit(:TYPE, ts, te, meta) }; - EXTEND => { emit(:EXTEND, ts, te, meta) }; - IMPLEMENTS => { emit(:IMPLEMENTS, ts, te, meta) }; - INTERFACE => { emit(:INTERFACE, ts, te, meta) }; - UNION => { emit(:UNION, ts, te, meta) }; - ENUM => { emit(:ENUM, ts, te, meta) }; - INPUT => { emit(:INPUT, ts, te, meta) }; - DIRECTIVE => { emit(:DIRECTIVE, ts, te, meta) }; - RCURLY => { emit(:RCURLY, ts, te, meta, "}") }; - LCURLY => { emit(:LCURLY, ts, te, meta, "{") }; - RPAREN => { emit(:RPAREN, ts, te, meta, ")") }; - LPAREN => { emit(:LPAREN, ts, te, meta, "(")}; - RBRACKET => { emit(:RBRACKET, ts, te, meta, "]") }; - LBRACKET => { emit(:LBRACKET, ts, te, meta, "[") }; - COLON => { emit(:COLON, ts, te, meta, ":") }; - QUOTED_STRING => { emit_string(ts, te, meta, block: false) }; - BLOCK_STRING => { emit_string(ts, te, meta, block: true) }; - VAR_SIGN => { emit(:VAR_SIGN, ts, te, meta, "$") }; - DIR_SIGN => { emit(:DIR_SIGN, ts, te, meta, "@") }; - ELLIPSIS => { emit(:ELLIPSIS, ts, te, meta, "...") }; - EQUALS => { emit(:EQUALS, ts, te, meta, "=") }; - BANG => { emit(:BANG, ts, te, meta, "!") }; - PIPE => { emit(:PIPE, ts, te, meta, "|") }; - AMP => { emit(:AMP, ts, te, meta, "&") }; - IDENTIFIER => { emit(:IDENTIFIER, ts, te, meta) }; - COMMENT => { record_comment(ts, te, meta) }; - - NEWLINE => { - meta[:line] += 1 - meta[:col] = 1 - }; - - BLANK => { meta[:col] += te - ts }; - - UNKNOWN_CHAR => { emit(:UNKNOWN_CHAR, ts, te, meta) }; - - *|; -}%% - -# frozen_string_literal: true - -module GraphQL - module Language - module Lexer - if !String.method_defined?(:match?) - using GraphQL::StringMatchBackport - end - - def self.tokenize(query_string) - run_lexer(query_string) - end - - # Replace any escaped unicode or whitespace with the _actual_ characters - # To avoid allocating more strings, this modifies the string passed into it - def self.replace_escaped_characters_in_place(raw_string) - raw_string.gsub!(ESCAPES, ESCAPES_REPLACE) - raw_string.gsub!(UTF_8, &UTF_8_REPLACE) - nil - end - - private - - %% write data; - - def self.run_lexer(query_string) - data = query_string.unpack(PACK_DIRECTIVE) - eof = data.length - - # Since `Lexer` is a module, store all lexer state - # in this local variable: - meta = { - line: 1, - col: 1, - data: data, - tokens: [], - previous_token: nil, - } - - p ||= 0 - pe ||= data.length - - %% write init; - - %% write exec; - - meta[:tokens] - end - - def self.record_comment(ts, te, meta) - token = GraphQL::Language::Token.new( - :COMMENT, - meta[:data][ts, te - ts].pack(PACK_DIRECTIVE).force_encoding(UTF_8_ENCODING), - meta[:line], - meta[:col], - meta[:previous_token], - ) - - meta[:previous_token] = token - - meta[:col] += te - ts - end - - def self.emit(token_name, ts, te, meta, token_value = nil) - token_value ||= meta[:data][ts, te - ts].pack(PACK_DIRECTIVE).force_encoding(UTF_8_ENCODING) - meta[:tokens] << token = GraphQL::Language::Token.new( - token_name, - token_value, - meta[:line], - meta[:col], - meta[:previous_token], - ) - meta[:previous_token] = token - # Bump the column counter for the next token - meta[:col] += te - ts - end - - ESCAPES = /\\["\\\/bfnrt]/ - ESCAPES_REPLACE = { - '\\"' => '"', - "\\\\" => "\\", - "\\/" => '/', - "\\b" => "\b", - "\\f" => "\f", - "\\n" => "\n", - "\\r" => "\r", - "\\t" => "\t", - } - - UTF_8 = /\\u[\dAa-f]{4}/i - UTF_8_REPLACE = ->(m) { [m[-4..-1].to_i(16)].pack('U'.freeze) } - - VALID_STRING = /\A(?:[^\\]|#{ESCAPES}|#{UTF_8})*\z/o - - PACK_DIRECTIVE = "c*" - UTF_8_ENCODING = "UTF-8" - - def self.emit_string(ts, te, meta, block:) - quotes_length = block ? 3 : 1 - value = meta[:data][ts + quotes_length, te - ts - 2 * quotes_length].pack(PACK_DIRECTIVE).force_encoding(UTF_8_ENCODING) || '' - line_incr = 0 - if block && !value.empty? - line_incr = value.count("\n") - value = GraphQL::Language::BlockString.trim_whitespace(value) - end - # TODO: replace with `String#match?` when we support only Ruby 2.4+ - # (It's faster: https://bugs.ruby-lang.org/issues/8110) - if !value.valid_encoding? || !value.match?(VALID_STRING) - meta[:tokens] << token = GraphQL::Language::Token.new( - :BAD_UNICODE_ESCAPE, - value, - meta[:line], - meta[:col], - meta[:previous_token], - ) - else - replace_escaped_characters_in_place(value) - - if !value.valid_encoding? - meta[:tokens] << token = GraphQL::Language::Token.new( - :BAD_UNICODE_ESCAPE, - value, - meta[:line], - meta[:col], - meta[:previous_token], - ) - else - meta[:tokens] << token = GraphQL::Language::Token.new( - :STRING, - value, - meta[:line], - meta[:col], - meta[:previous_token], - ) - end - end - - meta[:previous_token] = token - meta[:col] += te - ts - meta[:line] += line_incr - end - end - end -end diff --git a/lib/graphql/language/nodes.rb b/lib/graphql/language/nodes.rb index 26028fc13c7..80928c5f7c1 100644 --- a/lib/graphql/language/nodes.rb +++ b/lib/graphql/language/nodes.rb @@ -2,6 +2,7 @@ module GraphQL module Language module Nodes + NONE = GraphQL::EmptyObjects::EMPTY_ARRAY # {AbstractNode} is the base class for all nodes in a GraphQL AST. # # It provides some APIs for working with ASTs: @@ -9,32 +10,39 @@ module Nodes # - `scalars` returns all scalar (Ruby) values attached to this one. Used for comparing nodes. # - `to_query_string` turns an AST node into a GraphQL string class AbstractNode + module DefinitionNode # This AST node's {#line} returns the first line, which may be the description. # @return [Integer] The first line of the definition (not the description) attr_reader :definition_line - def initialize(options = {}) - @definition_line = options.delete(:definition_line) - super(options) + def initialize(definition_line: nil, **_rest) + @definition_line = definition_line + super(**_rest) end - end - attr_reader :line, :col, :filename + def marshal_dump + super << @definition_line + end - # Initialize a node by extracting its position, - # then calling the class's `initialize_node` method. - # @param options [Hash] Initial attributes for this node - def initialize(options = {}) - if options.key?(:position_source) - position_source = options.delete(:position_source) - @line = position_source.line - @col = position_source.col + def marshal_load(values) + @definition_line = values.pop + super end + end - @filename = options.delete(:filename) + attr_reader :filename + + def line + @line ||= @source&.line_at(@pos) + end + + def col + @col ||= @source&.column_at(@pos) + end - initialize_node(**options) + def definition_line + @definition_line ||= (@source && @definition_pos) ? @source.line_at(@definition_pos) : nil end # Value equality @@ -46,7 +54,7 @@ def ==(other) other.children == self.children end - NO_CHILDREN = [].freeze + NO_CHILDREN = GraphQL::EmptyObjects::EMPTY_ARRAY # @return [Array] all nodes in the tree below this one def children @@ -75,7 +83,11 @@ def position def to_query_string(printer: GraphQL::Language::Printer.new) if printer.is_a?(GraphQL::Language::Printer) - @query_string ||= printer.print(self) + if frozen? + @query_string || printer.print(self) + else + @query_string ||= printer.print(self) + end else printer.print(self) end @@ -133,6 +145,8 @@ def merge!(new_options) end class << self + # rubocop:disable Development/NoEvalCop This eval takes static inputs at load-time + # Add a default `#visit_method` and `#children_method_name` using the class name def inherited(child_class) super @@ -141,18 +155,26 @@ def inherited(child_class) .gsub(/([a-z])([A-Z])/,'\1_\2') # insert underscores .downcase # remove caps - child_class.module_eval <<-RUBY + child_class.module_eval <<-RUBY, __FILE__, __LINE__ def visit_method :on_#{name_underscored} end class << self attr_accessor :children_method_name + + def visit_method + :on_#{name_underscored} + end end self.children_method_name = :#{name_underscored}s RUBY end + def children_of_type + @children_methods + end + private # Name accessors which return lists of nodes, @@ -183,8 +205,8 @@ def children_methods(children_of_type) module_eval <<-RUBY, __FILE__, __LINE__ # Singular method: create a node with these options # and return a new `self` which includes that node in this list. - def merge_#{method_name.to_s.sub(/s$/, "")}(node_opts) - merge(#{method_name}: #{method_name} + [#{node_type.name}.new(node_opts)]) + def merge_#{method_name.to_s.sub(/s$/, "")}(**node_opts) + merge(#{method_name}: #{method_name} + [#{node_type.name}.new(**node_opts)]) end RUBY end @@ -197,20 +219,30 @@ def merge_#{method_name.to_s.sub(/s$/, "")}(node_opts) else module_eval <<-RUBY, __FILE__, __LINE__ def children - @children ||= (#{children_of_type.keys.map { |k| "@#{k}" }.join(" + ")}).freeze + @children ||= begin + if #{children_of_type.keys.map { |k| "@#{k}.any?" }.join(" || ")} + new_children = [] + #{children_of_type.keys.map { |k| "new_children.concat(@#{k})" }.join("; ")} + new_children.freeze + new_children + else + NO_CHILDREN + end + end end RUBY end end if defined?(@scalar_methods) - if !method_defined?(:initialize_node) - generate_initialize_node + if !@initialize_was_generated + @initialize_was_generated = true + generate_initialize else # This method was defined manually end else - raise "Can't generate_initialize_node because scalar_methods wasn't called; call it before children_methods" + raise "Can't generate_initialize because scalar_methods wasn't called; call it before children_methods" end end @@ -239,34 +271,85 @@ def scalars end end - def generate_initialize_node + DEFAULT_INITIALIZE_OPTIONS = [ + "line: nil", + "col: nil", + "pos: nil", + "filename: nil", + "source: nil" + ] + + IGNORED_MARSHALLING_KEYWORDS = [:comment] + + def generate_initialize + return if method_defined?(:marshal_load, false) # checking for `:initialize` doesn't work right + scalar_method_names = @scalar_methods # TODO: These probably should be scalar methods, but `types` returns an array - [:types, :description].each do |extra_method| + [:types, :description, :comment].each do |extra_method| if method_defined?(extra_method) scalar_method_names += [extra_method] end end - all_method_names = scalar_method_names + @children_methods.keys + children_method_names = @children_methods.keys + + all_method_names = scalar_method_names + children_method_names if all_method_names.include?(:alias) # Rather than complicating this special case, # let it be overridden (in field) return else arguments = scalar_method_names.map { |m| "#{m}: nil"} + - @children_methods.keys.map { |m| "#{m}: NO_CHILDREN" } + children_method_names.map { |m| "#{m}: NO_CHILDREN" } + + DEFAULT_INITIALIZE_OPTIONS assignments = scalar_method_names.map { |m| "@#{m} = #{m}"} + - @children_methods.keys.map { |m| "@#{m} = #{m}.freeze" } + children_method_names.map { |m| "@#{m} = #{m}.freeze" } + + if name.end_with?("Definition") && name != "FragmentDefinition" + arguments << "definition_pos: nil" + assignments << "@definition_pos = definition_pos" + end + + keywords = scalar_method_names.map { |m| "#{m}: #{m}"} + + children_method_names.map { |m| "#{m}: #{m}" } + + ignored_keywords = IGNORED_MARSHALLING_KEYWORDS.map do |keyword| + "#{keyword.to_s}: nil" + end + + marshalling_method_names = all_method_names - IGNORED_MARSHALLING_KEYWORDS module_eval <<-RUBY, __FILE__, __LINE__ - def initialize_node #{arguments.join(", ")} + def initialize(#{arguments.join(", ")}) + @line = line + @col = col + @pos = pos + @filename = filename + @source = source #{assignments.join("\n")} end + + def self.from_a(filename, line, col, #{marshalling_method_names.join(", ")}, #{ignored_keywords.join(", ")}) + self.new(filename: filename, line: line, col: col, #{keywords.join(", ")}) + end + + def marshal_dump + [ + line, col, # use methods here to force them to be calculated + @filename, + #{marshalling_method_names.map { |n| "@#{n}," }.join} + ] + end + + def marshal_load(values) + @line, @col, @filename #{marshalling_method_names.map { |n| ", @#{n}"}.join} = values + end RUBY end end + # rubocop:enable Development/NoEvalCop end end @@ -291,10 +374,10 @@ class Argument < AbstractNode # @return [String] the key for this argument # @!attribute value - # @return [String, Float, Integer, Boolean, Array, InputObject] The value passed for this key + # @return [String, Float, Integer, Boolean, Array, InputObject, VariableIdentifier] The value passed for this key def children - @children ||= Array(value).flatten.select { |v| v.is_a?(AbstractNode) } + @children ||= Array(value).flatten.tap { _1.select! { |v| v.is_a?(AbstractNode) } } end end @@ -307,42 +390,13 @@ class DirectiveLocation < NameOnlyNode end class DirectiveDefinition < AbstractNode - include DefinitionNode attr_reader :description - scalar_methods :name + scalar_methods :name, :repeatable children_methods( - locations: Nodes::DirectiveLocation, arguments: Nodes::Argument, + locations: Nodes::DirectiveLocation, ) - end - - # This is the AST root for normal queries - # - # @example Deriving a document by parsing a string - # document = GraphQL.parse(query_string) - # - # @example Creating a string from a document - # document.to_query_string - # # { ... } - # - # @example Creating a custom string from a document - # class VariableScrubber < GraphQL::Language::Printer - # def print_argument(arg) - # "#{arg.name}: " - # end - # end - # - # document.to_query_string(printer: VariableSrubber.new) - # - class Document < AbstractNode - scalar_methods false - children_methods(definitions: nil) - # @!attribute definitions - # @return [Array] top-level GraphQL units: operations or fragments - - def slice_definition(name) - GraphQL::Language::DefinitionSlice.slice(self, name) - end + self.children_method_name = :definitions end # An enum value. The string is available as {#name}. @@ -355,7 +409,31 @@ class NullValue < NameOnlyNode # A single selection in a GraphQL query. class Field < AbstractNode - NONE = [].freeze + def initialize(name: nil, arguments: NONE, directives: NONE, selections: NONE, field_alias: nil, line: nil, col: nil, pos: nil, filename: nil, source: nil) + @name = name + @arguments = arguments || NONE + @directives = directives || NONE + @selections = selections || NONE + # oops, alias is a keyword: + @alias = field_alias + @line = line + @col = col + @pos = pos + @filename = filename + @source = source + end + + def self.from_a(filename, line, col, field_alias, name, arguments, directives, selections) # rubocop:disable Metrics/ParameterLists + self.new(filename: filename, line: line, col: col, field_alias: field_alias, name: name, arguments: arguments, directives: directives, selections: selections) + end + + def marshal_dump + [line, col, @filename, @name, @arguments, @directives, @selections, @alias] + end + + def marshal_load(values) + @line, @col, @filename, @name, @arguments, @directives, @selections, @alias = values + end scalar_methods :name, :alias children_methods({ @@ -364,34 +442,34 @@ class Field < AbstractNode directives: GraphQL::Language::Nodes::Directive, }) - # @!attribute selections - # @return [Array] Selections on this object (or empty array if this is a scalar field) - - def initialize_node(attributes) - @name = attributes[:name] - @arguments = attributes[:arguments] || NONE - @directives = attributes[:directives] || NONE - @selections = attributes[:selections] || NONE - # oops, alias is a keyword: - @alias = attributes[:alias] - end - # Override this because default is `:fields` self.children_method_name = :selections end # A reusable fragment, defined at document-level. class FragmentDefinition < AbstractNode - # @!attribute name - # @return [String] the identifier for this fragment, which may be applied with `...#{name}` - - # @!attribute type - # @return [String] the type condition for this fragment (name of type which it may apply to) - def initialize_node(name: nil, type: nil, directives: [], selections: []) + def initialize(name: nil, type: nil, directives: NONE, selections: NONE, filename: nil, pos: nil, source: nil, line: nil, col: nil) @name = name @type = type @directives = directives @selections = selections + @filename = filename + @pos = pos + @source = source + @line = line + @col = col + end + + def self.from_a(filename, line, col, name, type, directives, selections) + self.new(filename: filename, line: line, col: col, name: name, type: type, directives: directives, selections: selections) + end + + def marshal_dump + [line, col, @filename, @name, @type, @directives, @selections] + end + + def marshal_load(values) + @line, @col, @filename, @name, @type, @directives, @selections = values end scalar_methods :name, :type @@ -418,8 +496,8 @@ class FragmentSpread < AbstractNode class InlineFragment < AbstractNode scalar_methods :type children_methods({ - selections: GraphQL::Language::Nodes::Field, directives: GraphQL::Language::Nodes::Directive, + selections: GraphQL::Language::Nodes::Field, }) self.children_method_name = :selections @@ -467,7 +545,6 @@ def serialize_value_for_hash(value) end end - # A list type definition, denoted with `[...]` (used for variable type definitions) class ListType < WrapperType end @@ -479,7 +556,7 @@ class NonNullType < WrapperType # An operation-level query variable class VariableDefinition < AbstractNode scalar_methods :name, :type, :default_value - children_methods false + children_methods(directives: Directive) # @!attribute default_value # @return [String, Integer, Float, Boolean, Array, NullValue] A Ruby value to use if no other value is provided @@ -499,8 +576,8 @@ class OperationDefinition < AbstractNode scalar_methods :operation_type, :name children_methods({ variables: GraphQL::Language::Nodes::VariableDefinition, - selections: GraphQL::Language::Nodes::Field, directives: GraphQL::Language::Nodes::Directive, + selections: GraphQL::Language::Nodes::Field, }) # @!attribute variables @@ -518,6 +595,35 @@ class OperationDefinition < AbstractNode self.children_method_name = :definitions end + # This is the AST root for normal queries + # + # @example Deriving a document by parsing a string + # document = GraphQL.parse(query_string) + # + # @example Creating a string from a document + # document.to_query_string + # # { ... } + # + # @example Creating a custom string from a document + # class VariableScrubber < GraphQL::Language::Printer + # def print_argument(arg) + # print_string("#{arg.name}: ") + # end + # end + # + # document.to_query_string(printer: VariableScrubber.new) + # + class Document < AbstractNode + scalar_methods false + children_methods(definitions: nil) + # @!attribute definitions + # @return [Array] top-level GraphQL units: operations or fragments + + def slice_definition(name) + GraphQL::Language::DefinitionSlice.slice(self, name) + end + end + # A type name, used for variable definitions class TypeName < NameOnlyNode end @@ -528,7 +634,6 @@ class VariableIdentifier < NameOnlyNode end class SchemaDefinition < AbstractNode - include DefinitionNode scalar_methods :query, :mutation, :subscription children_methods({ directives: GraphQL::Language::Nodes::Directive, @@ -545,8 +650,7 @@ class SchemaExtension < AbstractNode end class ScalarTypeDefinition < AbstractNode - include DefinitionNode - attr_reader :description + attr_reader :description, :comment scalar_methods :name children_methods({ directives: GraphQL::Language::Nodes::Directive, @@ -563,8 +667,7 @@ class ScalarTypeExtension < AbstractNode end class InputValueDefinition < AbstractNode - include DefinitionNode - attr_reader :description + attr_reader :description, :comment scalar_methods :name, :type, :default_value children_methods({ directives: GraphQL::Language::Nodes::Directive, @@ -573,12 +676,11 @@ class InputValueDefinition < AbstractNode end class FieldDefinition < AbstractNode - include DefinitionNode - attr_reader :description + attr_reader :description, :comment scalar_methods :name, :type children_methods({ - directives: GraphQL::Language::Nodes::Directive, arguments: GraphQL::Language::Nodes::InputValueDefinition, + directives: GraphQL::Language::Nodes::Directive, }) self.children_method_name = :fields @@ -594,8 +696,7 @@ def merge(new_options) end class ObjectTypeDefinition < AbstractNode - include DefinitionNode - attr_reader :description + attr_reader :description, :comment scalar_methods :name, :interfaces children_methods({ directives: GraphQL::Language::Nodes::Directive, @@ -614,10 +715,10 @@ class ObjectTypeExtension < AbstractNode end class InterfaceTypeDefinition < AbstractNode - include DefinitionNode - attr_reader :description + attr_reader :description, :comment scalar_methods :name children_methods({ + interfaces: GraphQL::Language::Nodes::TypeName, directives: GraphQL::Language::Nodes::Directive, fields: GraphQL::Language::Nodes::FieldDefinition, }) @@ -627,6 +728,7 @@ class InterfaceTypeDefinition < AbstractNode class InterfaceTypeExtension < AbstractNode scalar_methods :name children_methods({ + interfaces: GraphQL::Language::Nodes::TypeName, directives: GraphQL::Language::Nodes::Directive, fields: GraphQL::Language::Nodes::FieldDefinition, }) @@ -634,8 +736,7 @@ class InterfaceTypeExtension < AbstractNode end class UnionTypeDefinition < AbstractNode - include DefinitionNode - attr_reader :description, :types + attr_reader :description, :comment, :types scalar_methods :name children_methods({ directives: GraphQL::Language::Nodes::Directive, @@ -653,8 +754,7 @@ class UnionTypeExtension < AbstractNode end class EnumValueDefinition < AbstractNode - include DefinitionNode - attr_reader :description + attr_reader :description, :comment scalar_methods :name children_methods({ directives: GraphQL::Language::Nodes::Directive, @@ -663,8 +763,7 @@ class EnumValueDefinition < AbstractNode end class EnumTypeDefinition < AbstractNode - include DefinitionNode - attr_reader :description + attr_reader :description, :comment scalar_methods :name children_methods({ directives: GraphQL::Language::Nodes::Directive, @@ -683,8 +782,7 @@ class EnumTypeExtension < AbstractNode end class InputObjectTypeDefinition < AbstractNode - include DefinitionNode - attr_reader :description + attr_reader :description, :comment scalar_methods :name children_methods({ directives: GraphQL::Language::Nodes::Directive, diff --git a/lib/graphql/language/parser.rb b/lib/graphql/language/parser.rb index 805b4fb3e8a..23ed0bf301c 100644 --- a/lib/graphql/language/parser.rb +++ b/lib/graphql/language/parser.rb @@ -1,1966 +1,853 @@ -# -# DO NOT MODIFY!!!! -# This file is automatically generated by Racc 1.4.16 -# from Racc grammar file "". -# - -require 'racc/parser.rb' - +# frozen_string_literal: true +require "strscan" +require "graphql/language/nodes" +require "graphql/tracing/null_trace" module GraphQL module Language - class Parser < Racc::Parser - -module_eval(<<'...end parser.y/module_eval...', 'parser.y', 437) - -EMPTY_ARRAY = [].freeze - -def initialize(query_string, filename:, tracer: Tracing::NullTracer) - raise GraphQL::ParseError.new("No query string was present", nil, nil, query_string) if query_string.nil? - @query_string = query_string - @filename = filename - @tracer = tracer - @reused_next_token = [nil, nil] -end - -def parse_document - @document ||= begin - # Break the string into tokens - @tracer.trace("lex", {query_string: @query_string}) do - @tokens ||= GraphQL.scan(@query_string) - end - # From the tokens, build an AST - @tracer.trace("parse", {query_string: @query_string}) do - if @tokens.empty? - raise GraphQL::ParseError.new("Unexpected end of document", nil, nil, @query_string) - else - do_parse + class Parser + include GraphQL::Language::Nodes + include EmptyObjects + + class << self + attr_accessor :cache + + def parse(graphql_str, filename: nil, trace: Tracing::NullTrace, max_tokens: nil) + self.new(graphql_str, filename: filename, trace: trace, max_tokens: max_tokens).parse + end + + def parse_file(filename, trace: Tracing::NullTrace) + if cache + cache.fetch(filename) do + parse(File.read(filename), filename: filename, trace: trace) + end + else + parse(File.read(filename), filename: filename, trace: trace) + end + end end - end - end -end - -class << self - attr_accessor :cache - def parse(query_string, filename: nil, tracer: GraphQL::Tracing::NullTracer) - new(query_string, filename: filename, tracer: tracer).parse_document - end - - def parse_file(filename, tracer: GraphQL::Tracing::NullTracer) - if cache - cache.fetch(filename) do - parse(File.read(filename), filename: filename, tracer: tracer) + def initialize(graphql_str, filename: nil, trace: Tracing::NullTrace, max_tokens: nil) + if graphql_str.nil? + raise GraphQL::ParseError.new("No query string was present", nil, nil, nil) + end + @lexer = Lexer.new(graphql_str, filename: filename, max_tokens: max_tokens) + @graphql_str = graphql_str + @filename = filename + @trace = trace + @dedup_identifiers = false + @lines_at = nil end - else - parse(File.read(filename), filename: filename, tracer: tracer) - end - end -end - -private - -def next_token - lexer_token = @tokens.shift - if lexer_token.nil? - nil - else - @reused_next_token[0] = lexer_token.name - @reused_next_token[1] = lexer_token - @reused_next_token - end -end - -def get_description(token) - comments = [] - - loop do - prev_token = token - token = token.prev_token - break if token.nil? - break if token.name != :COMMENT - break if prev_token.line != token.line + 1 - - comments.unshift(token.to_s.sub(/^#\s*/, "")) - end - - return nil if comments.empty? - - comments.join("\n") -end - -def on_error(parser_token_id, lexer_token, vstack) - if lexer_token == "$" || lexer_token == nil - raise GraphQL::ParseError.new("Unexpected end of document", nil, nil, @query_string, filename: @filename) - else - parser_token_name = token_to_str(parser_token_id) - if parser_token_name.nil? - raise GraphQL::ParseError.new("Parse Error on unknown token: {token_id: #{parser_token_id}, lexer_token: #{lexer_token}} from #{@query_string}", nil, nil, @query_string, filename: @filename) - else - line, col = lexer_token.line_and_column - if lexer_token.name == :BAD_UNICODE_ESCAPE - raise GraphQL::ParseError.new("Parse error on bad Unicode escape sequence: #{lexer_token.to_s.inspect} (#{parser_token_name}) at [#{line}, #{col}]", line, col, @query_string, filename: @filename) - else - raise GraphQL::ParseError.new("Parse error on #{lexer_token.to_s.inspect} (#{parser_token_name}) at [#{line}, #{col}]", line, col, @query_string, filename: @filename) + def parse + @document ||= begin + @trace.parse(query_string: @graphql_str) do + document + end + rescue SystemStackError + raise GraphQL::ParseError.new("This query is too large to execute.", nil, nil, @query_str, filename: @filename) + end end - end - end -end - -def make_node(node_name, assigns) - assigns.each do |key, value| - if key != :position_source && value.is_a?(GraphQL::Language::Token) - assigns[key] = value.to_s - end - end - - assigns[:filename] = @filename - - GraphQL::Language::Nodes.const_get(node_name).new(assigns) -end -...end parser.y/module_eval... -##### State transition tables begin ### - -racc_action_table = [ - -2, 279, 11, -99, 12, 13, 14, 280, 11, -99, - 12, 13, 14, 178, -99, -99, 19, -99, 94, -165, - 194, 93, 19, 12, 13, 14, 15, 196, 71, 35, - 35, 35, 15, 71, 71, 28, -99, 35, 12, 13, - 14, 28, 71, 263, -151, 71, 71, 35, 71, 43, - 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, - 54, 55, 56, 60, 12, 13, 14, 71, -165, -165, - 181, 35, -165, 160, 120, 43, 44, 45, 46, 47, - 48, 49, 50, 51, 52, 53, 54, 55, 56, 90, - 12, 13, 14, 299, 66, 295, 35, 35, -165, 221, - 35, 43, 44, 45, 46, 47, 48, 49, 50, 51, - 52, 53, 54, 55, 56, 223, 12, 13, 14, 273, - 66, 35, 272, 35, 131, 268, 35, 43, 44, 45, - 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, - 56, 219, 71, 12, 13, 14, 66, 35, 217, 278, - 35, 218, 285, 35, 200, 44, 45, 46, 47, 48, - 49, 50, 51, 52, 198, 199, 207, 208, 204, 205, - 206, 216, 302, 131, 12, 13, 14, 35, 300, 71, - 277, 71, 218, 230, 87, 200, 44, 45, 46, 47, - 48, 49, 50, 51, 52, 198, 199, 207, 208, 204, - 205, 206, 216, 219, 233, 12, 13, 14, 35, 303, - 217, 71, 35, 218, 35, 244, 200, 44, 45, 46, - 47, 48, 49, 50, 51, 52, 198, 199, 207, 208, - 204, 205, 206, 216, 302, 142, 12, 13, 14, 226, - 12, 13, 14, 306, 218, 35, 35, 200, 44, 45, - 46, 47, 48, 49, 50, 51, 52, 198, 199, 207, - 208, 204, 205, 206, 216, 219, 35, 12, 13, 14, - 250, 307, 217, 131, 284, 218, 254, 71, 200, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 198, 199, - 207, 208, 204, 205, 206, 216, 219, 138, 12, 13, - 14, 71, 94, 217, 233, 131, 218, 269, 71, 200, - 44, 45, 46, 47, 48, 49, 50, 51, 52, 198, - 199, 207, 208, 204, 205, 206, 216, 219, 71, 12, - 13, 14, 71, 120, 217, 71, 269, 218, 116, 71, - 200, 44, 45, 46, 47, 48, 49, 50, 51, 52, - 198, 199, 207, 208, 204, 205, 206, 216, 12, 13, - 14, 80, 81, 38, 82, 83, 84, 85, 86, 43, - 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, - 54, 55, 56, 12, 13, 14, 284, 101, 66, 174, - 12, 13, 14, 71, 96, 44, 45, 46, 47, 48, - 49, 50, 51, 52, 53, 54, 55, 56, 291, 12, - 13, 14, 71, 12, 13, 14, 290, 98, 194, 71, - 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, - 53, 54, 55, 56, 287, 12, 13, 14, 73, 74, - 75, 71, 76, 77, 78, 79, 43, 44, 45, 46, - 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, - 310, 12, 13, 14, 131, 94, 298, 168, 169, 89, - 71, 131, 43, 44, 45, 46, 47, 48, 49, 50, - 51, 52, 53, 54, 55, 56, 257, 12, 13, 14, - 71, 176, 71, 71, 71, 71, 71, 185, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, 186, 71, 187, 142, 188, - 129, 71, 190, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - 191, 192, 193, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, nil, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - 129, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, 166, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - 129, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - 129, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, nil, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, 129, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 43, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 53, 54, 55, 56, 12, 13, 14, - nil, nil, nil, nil, nil, 129, nil, nil, 43, 44, - 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, - 55, 56, 12, 13, 14, nil, nil, nil, nil, nil, - nil, nil, nil, 200, 44, 45, 46, 47, 48, 49, - 50, 51, 52, 198, 199, -157, nil, nil, nil, -157, - nil, nil, nil, nil, nil, -157, nil, -157, -157 ] - -racc_action_check = [ - 3, 248, 3, 143, 3, 3, 3, 249, 0, 145, - 0, 0, 0, 140, 102, 179, 3, 149, 65, 230, - 158, 65, 0, 138, 138, 138, 3, 162, 145, 248, - 249, 3, 0, 165, 143, 3, 147, 0, 142, 142, - 142, 0, 110, 227, 140, 102, 179, 230, 149, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 11, 11, 11, 11, 147, 190, 185, - 142, 227, 178, 118, 118, 11, 11, 11, 11, 11, - 11, 11, 11, 11, 11, 11, 11, 11, 11, 59, - 59, 59, 59, 276, 11, 266, 190, 185, 244, 170, - 178, 59, 59, 59, 59, 59, 59, 59, 59, 59, - 59, 59, 59, 59, 59, 172, 172, 172, 172, 241, - 59, 276, 240, 266, 171, 235, 244, 172, 172, 172, - 172, 172, 172, 172, 172, 172, 172, 172, 172, 172, - 172, 255, 109, 255, 255, 255, 172, 241, 255, 247, - 240, 255, 255, 235, 255, 255, 255, 255, 255, 255, - 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, - 255, 255, 306, 173, 306, 306, 306, 247, 281, 108, - 245, 107, 306, 180, 38, 306, 306, 306, 306, 306, - 306, 306, 306, 306, 306, 306, 306, 306, 306, 306, - 306, 306, 306, 290, 182, 290, 290, 290, 245, 289, - 290, 106, 187, 290, 188, 189, 290, 290, 290, 290, - 290, 290, 290, 290, 290, 290, 290, 290, 290, 290, - 290, 290, 290, 290, 284, 105, 284, 284, 284, 177, - 177, 177, 177, 293, 284, 192, 193, 284, 284, 284, - 284, 284, 284, 284, 284, 284, 284, 284, 284, 284, - 284, 284, 284, 284, 284, 218, 194, 218, 218, 218, - 195, 294, 218, 197, 308, 218, 218, 104, 218, 218, - 218, 218, 218, 218, 218, 218, 218, 218, 218, 218, - 218, 218, 218, 218, 218, 218, 303, 103, 303, 303, - 303, 312, 100, 303, 231, 97, 303, 236, 95, 303, - 303, 303, 303, 303, 303, 303, 303, 303, 303, 303, - 303, 303, 303, 303, 303, 303, 303, 168, 92, 168, - 168, 168, 19, 89, 168, 88, 246, 168, 86, 73, - 168, 168, 168, 168, 168, 168, 168, 168, 168, 168, - 168, 168, 168, 168, 168, 168, 168, 168, 131, 131, - 131, 37, 37, 1, 37, 37, 37, 37, 37, 131, - 131, 131, 131, 131, 131, 131, 131, 131, 131, 131, - 131, 131, 131, 66, 66, 66, 251, 72, 131, 135, - 135, 135, 135, 69, 66, 66, 66, 66, 66, 66, - 66, 66, 66, 66, 66, 66, 66, 66, 261, 261, - 261, 261, 66, 101, 101, 101, 260, 67, 265, 313, - 261, 261, 261, 261, 261, 261, 261, 261, 261, 261, - 261, 261, 261, 261, 258, 258, 258, 258, 28, 28, - 28, 270, 28, 28, 28, 28, 258, 258, 258, 258, - 258, 258, 258, 258, 258, 258, 258, 258, 258, 258, - 302, 302, 302, 302, 121, 122, 275, 125, 127, 40, - 130, 117, 302, 302, 302, 302, 302, 302, 302, 302, - 302, 302, 302, 302, 302, 302, 219, 219, 219, 219, - 133, 137, 139, 115, 141, 113, 114, 144, 219, 219, - 219, 219, 219, 219, 219, 219, 219, 219, 219, 219, - 219, 219, 98, 98, 98, 146, 112, 148, 111, 150, - 98, 152, 154, 98, 98, 98, 98, 98, 98, 98, - 98, 98, 98, 98, 98, 98, 98, 10, 10, 10, - 155, 156, 157, nil, nil, nil, nil, nil, 10, 10, - 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, - 10, 10, 15, 15, 15, nil, nil, nil, nil, nil, - nil, nil, nil, nil, 15, 15, 15, 15, 15, 15, - 15, 15, 15, 15, 15, 15, 15, 71, 71, 71, - nil, nil, nil, nil, nil, nil, nil, nil, 71, 71, - 71, 71, 71, 71, 71, 71, 71, 71, 71, 71, - 71, 71, 74, 74, 74, nil, nil, nil, nil, nil, - nil, nil, nil, 74, 74, 74, 74, 74, 74, 74, - 74, 74, 74, 74, 74, 74, 74, 75, 75, 75, - nil, nil, nil, nil, nil, nil, nil, nil, 75, 75, - 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, - 75, 75, 76, 76, 76, nil, nil, nil, nil, nil, - nil, nil, nil, 76, 76, 76, 76, 76, 76, 76, - 76, 76, 76, 76, 76, 76, 76, 77, 77, 77, - nil, nil, nil, nil, nil, nil, nil, nil, 77, 77, - 77, 77, 77, 77, 77, 77, 77, 77, 77, 77, - 77, 77, 78, 78, 78, nil, nil, nil, nil, nil, - nil, nil, nil, 78, 78, 78, 78, 78, 78, 78, - 78, 78, 78, 78, 78, 78, 78, 79, 79, 79, - nil, nil, nil, nil, nil, nil, nil, nil, 79, 79, - 79, 79, 79, 79, 79, 79, 79, 79, 79, 79, - 79, 79, 80, 80, 80, nil, nil, nil, nil, nil, - nil, nil, nil, 80, 80, 80, 80, 80, 80, 80, - 80, 80, 80, 80, 80, 80, 80, 81, 81, 81, - nil, nil, nil, nil, nil, nil, nil, nil, 81, 81, - 81, 81, 81, 81, 81, 81, 81, 81, 81, 81, - 81, 81, 82, 82, 82, nil, nil, nil, nil, nil, - nil, nil, nil, 82, 82, 82, 82, 82, 82, 82, - 82, 82, 82, 82, 82, 82, 82, 83, 83, 83, - nil, nil, nil, nil, nil, nil, nil, nil, 83, 83, - 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, - 83, 83, 84, 84, 84, nil, nil, nil, nil, nil, - nil, nil, nil, 84, 84, 84, 84, 84, 84, 84, - 84, 84, 84, 84, 84, 84, 84, 85, 85, 85, - nil, nil, nil, nil, nil, nil, nil, nil, 85, 85, - 85, 85, 85, 85, 85, 85, 85, 85, 85, 85, - 85, 85, 93, 93, 93, nil, nil, nil, nil, nil, - nil, nil, nil, 93, 93, 93, 93, 93, 93, 93, - 93, 93, 93, 93, 93, 93, 93, 94, 94, 94, - nil, nil, nil, nil, nil, nil, nil, nil, 94, 94, - 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, - 94, 94, 96, 96, 96, nil, nil, nil, nil, nil, - 96, nil, nil, 96, 96, 96, 96, 96, 96, 96, - 96, 96, 96, 96, 96, 96, 96, 116, 116, 116, - nil, nil, nil, nil, nil, nil, nil, nil, 116, 116, - 116, 116, 116, 116, 116, 116, 116, 116, 116, 116, - 116, 116, 120, 120, 120, nil, nil, nil, nil, nil, - nil, nil, nil, 120, 120, 120, 120, 120, 120, 120, - 120, 120, 120, 120, 120, 120, 120, 123, 123, 123, - nil, 123, nil, nil, nil, nil, nil, nil, 123, 123, - 123, 123, 123, 123, 123, 123, 123, 123, 123, 123, - 123, 123, 129, 129, 129, nil, nil, nil, nil, nil, - 129, nil, nil, 129, 129, 129, 129, 129, 129, 129, - 129, 129, 129, 129, 129, 129, 129, 176, 176, 176, - nil, nil, nil, nil, nil, nil, nil, nil, 176, 176, - 176, 176, 176, 176, 176, 176, 176, 176, 176, 176, - 176, 176, 181, 181, 181, nil, nil, nil, nil, nil, - nil, nil, nil, 181, 181, 181, 181, 181, 181, 181, - 181, 181, 181, 181, 181, 181, 181, 183, 183, 183, - nil, nil, nil, nil, nil, nil, nil, nil, 183, 183, - 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, - 183, 183, 186, 186, 186, nil, nil, nil, nil, nil, - nil, nil, nil, 186, 186, 186, 186, 186, 186, 186, - 186, 186, 186, 186, 186, 186, 186, 191, 191, 191, - nil, nil, nil, nil, nil, nil, nil, nil, 191, 191, - 191, 191, 191, 191, 191, 191, 191, 191, 191, 191, - 191, 191, 196, 196, 196, nil, nil, nil, nil, nil, - 196, nil, nil, 196, 196, 196, 196, 196, 196, 196, - 196, 196, 196, 196, 196, 196, 196, 217, 217, 217, - nil, nil, nil, nil, nil, nil, nil, nil, 217, 217, - 217, 217, 217, 217, 217, 217, 217, 217, 217, 217, - 217, 217, 228, 228, 228, nil, nil, nil, nil, nil, - nil, nil, nil, 228, 228, 228, 228, 228, 228, 228, - 228, 228, 228, 228, 228, 228, 228, 233, 233, 233, - nil, nil, nil, nil, nil, nil, nil, nil, 233, 233, - 233, 233, 233, 233, 233, 233, 233, 233, 233, 233, - 233, 233, 242, 242, 242, nil, nil, nil, nil, nil, - nil, nil, nil, 242, 242, 242, 242, 242, 242, 242, - 242, 242, 242, 242, 242, 242, 242, 250, 250, 250, - nil, nil, nil, nil, nil, nil, nil, nil, 250, 250, - 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, - 250, 250, 269, 269, 269, nil, nil, nil, nil, nil, - nil, nil, nil, 269, 269, 269, 269, 269, 269, 269, - 269, 269, 269, 269, 269, 269, 269, 298, 298, 298, - nil, nil, nil, nil, nil, 298, nil, nil, 298, 298, - 298, 298, 298, 298, 298, 298, 298, 298, 298, 298, - 298, 298, 300, 300, 300, nil, nil, nil, nil, nil, - nil, nil, nil, 300, 300, 300, 300, 300, 300, 300, - 300, 300, 300, 300, 300, 300, 300, 307, 307, 307, - nil, nil, nil, nil, nil, 307, nil, nil, 307, 307, - 307, 307, 307, 307, 307, 307, 307, 307, 307, 307, - 307, 307, 238, 238, 238, nil, nil, nil, nil, nil, - nil, nil, nil, 238, 238, 238, 238, 238, 238, 238, - 238, 238, 238, 238, 238, 184, nil, nil, nil, 184, - nil, nil, nil, nil, nil, 184, nil, 184, 184 ] - -racc_action_pointer = [ - 6, 363, nil, 0, nil, nil, nil, nil, nil, nil, - 533, 60, nil, nil, nil, 558, nil, nil, nil, 299, - nil, nil, nil, nil, nil, nil, nil, nil, 422, nil, - nil, nil, nil, nil, nil, nil, nil, 344, 184, nil, - 462, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, 86, - nil, nil, nil, nil, nil, 11, 379, 402, nil, 360, - nil, 583, 385, 306, 608, 633, 658, 683, 708, 733, - 758, 783, 808, 833, 858, 883, 305, nil, 302, 324, - nil, nil, 295, 908, 933, 275, 958, 303, 508, nil, - 295, 409, 12, 295, 244, 216, 178, 148, 146, 109, - 9, 499, 483, 462, 463, 460, 983, 469, 65, nil, - 1008, 462, 458, 1033, nil, 457, nil, 457, nil, 1058, - 437, 354, nil, 457, nil, 386, nil, 481, 19, 459, - 11, 461, 34, 1, 495, -5, 501, 34, 515, 15, - 517, nil, 488, nil, 520, 526, 539, 540, 13, nil, - nil, nil, 17, nil, nil, 0, nil, nil, 325, nil, - 86, 122, 112, 171, nil, nil, 1083, 236, 69, 13, - 181, 1108, 168, 1133, 1470, 66, 1158, 181, 183, 213, - 65, 1183, 214, 215, 235, 255, 1208, 271, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, nil, 1233, 263, 483, - nil, nil, nil, nil, nil, nil, nil, 40, 1258, nil, - 16, 268, nil, 1283, nil, 122, 270, nil, 1458, nil, - 119, 116, 1308, nil, 95, 177, 299, 146, -2, -1, - 1333, 372, nil, nil, nil, 139, nil, nil, 431, nil, - 406, 405, nil, nil, nil, 411, 92, nil, nil, 1358, - 408, nil, nil, nil, nil, 456, 90, nil, nil, nil, - nil, 141, nil, nil, 232, nil, nil, nil, nil, 199, - 201, nil, nil, 233, 261, nil, nil, nil, 1383, nil, - 1408, nil, 457, 294, nil, nil, 170, 1433, 260, nil, - nil, nil, 268, 386, nil, nil ] - -racc_action_default = [ - -146, -177, -1, -146, -3, -5, -6, -7, -8, -9, - -16, -177, -13, -14, -15, -107, -109, -110, -111, -98, - -116, -117, -118, -119, -120, -121, -122, -123, -177, -126, - -127, -128, -129, -130, -131, -145, -147, -177, -177, -4, - -18, -17, -39, -40, -41, -42, -43, -44, -45, -46, - -47, -48, -49, -50, -51, -52, -53, -54, -55, -177, - -12, -32, -34, -35, -36, -64, -98, -177, -108, -99, - -100, -177, -177, -98, -177, -177, -177, -177, -177, -177, - -177, -177, -177, -177, -177, -177, -177, 316, -98, -177, - -11, -33, -98, -177, -177, -98, -177, -177, -177, -101, - -64, -177, -125, -177, -177, -150, -98, -98, -98, -98, - -98, -150, -98, -98, -98, -98, -177, -177, -177, -20, - -177, -30, -64, -177, -66, -177, -103, -23, -25, -177, - -98, -177, -105, -98, -102, -177, -113, -177, -177, -132, - -136, -98, -177, -138, -177, -140, -177, -142, -177, -144, - -177, -148, -98, -151, -177, -177, -177, -177, -162, -10, - -19, -21, -177, -31, -37, -98, -65, -67, -177, -24, - -177, -177, -177, -177, -112, -114, -177, -177, -146, -135, - -177, -177, -153, -154, -155, -146, -177, -146, -146, -177, - -146, -177, -146, -146, -146, -177, -177, -30, -56, -57, - -58, -59, -60, -68, -69, -70, -71, -72, -73, -74, - -75, -76, -77, -78, -79, -80, -81, -177, -177, -177, - -97, -26, -104, -29, -106, -115, -124, -146, -177, -166, - -146, -152, -155, -177, -158, -146, -139, -169, -177, -62, - -146, -146, -177, -160, -146, -146, -171, -146, -146, -146, - -177, -27, -38, -82, -83, -177, -85, -87, -177, -89, - -177, -177, -94, -133, -167, -162, -146, -156, -137, -177, - -98, -63, -141, -143, -161, -177, -146, -168, -172, -173, - -163, -174, -175, -22, -177, -84, -86, -88, -90, -177, - -177, -93, -95, -177, -177, -134, -170, -61, -177, -149, - -177, -28, -177, -177, -78, -91, -177, -177, -27, -176, - -92, -96, -98, -98, -164, -159 ] - -racc_goto_table = [ - 10, 72, 59, 10, 91, 132, 130, 195, 133, 68, - 164, 203, 283, 270, 227, 202, 102, 175, 92, 37, - 135, 235, 37, 274, 264, 159, 245, 124, 259, 99, - 274, 274, 264, 182, 271, 119, 118, 2, 241, 170, - 140, 271, 264, 248, 249, 88, 153, 139, 97, 143, - 145, 147, 149, 134, 281, 103, 167, 177, 1, 175, - 95, 256, 99, 264, 161, 202, 266, 288, 4, 313, - 117, 39, 231, 264, 121, 165, 141, 126, 236, 222, - 276, 224, 152, 246, 179, 202, 252, 40, 144, 146, - 148, 150, 151, 183, 154, 155, 156, 157, 286, 99, - 3, 137, 202, 99, 258, 99, 251, 99, 240, 99, - 301, 292, 171, 247, 294, 173, 304, 91, 255, 123, - 67, nil, 172, 180, nil, nil, 41, 65, nil, nil, - nil, 202, 311, nil, 189, 137, nil, 202, 137, 99, - nil, nil, nil, nil, nil, nil, nil, 197, nil, nil, - 202, nil, nil, 202, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, 201, nil, - nil, nil, nil, nil, nil, 65, nil, 137, nil, nil, - nil, nil, nil, nil, nil, nil, nil, 100, nil, nil, - 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, - 114, 115, nil, nil, nil, nil, 238, 242, 308, 122, - 125, 238, 242, 242, nil, nil, nil, 312, 201, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, 158, nil, nil, nil, 162, nil, 201, 125, - nil, nil, nil, nil, nil, nil, nil, 65, nil, nil, - nil, nil, 297, nil, nil, 201, nil, nil, 184, 238, - 242, nil, nil, nil, nil, nil, 238, 242, 242, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, 201, nil, nil, nil, 65, nil, - 201, nil, 225, nil, 314, 315, nil, 232, nil, 234, - nil, nil, 237, 201, nil, nil, 201, 237, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, 253, nil, 260, nil, nil, nil, nil, - nil, nil, nil, nil, 265, nil, nil, nil, nil, 267, - nil, nil, nil, nil, nil, nil, nil, nil, 275, nil, - nil, nil, nil, nil, nil, nil, 282, nil, nil, nil, - nil, nil, nil, nil, 289, nil, nil, 293, nil, nil, - nil, nil, nil, nil, nil, 296, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, 309, nil, 293 ] - -racc_goto_check = [ - 10, 13, 15, 10, 24, 14, 19, 80, 19, 29, - 23, 37, 20, 31, 72, 30, 49, 56, 28, 33, - 55, 72, 33, 79, 81, 14, 72, 36, 46, 50, - 79, 79, 81, 77, 32, 18, 17, 2, 75, 19, - 71, 32, 81, 75, 75, 12, 71, 49, 13, 49, - 49, 49, 49, 28, 82, 13, 36, 55, 1, 56, - 29, 37, 50, 81, 18, 30, 72, 46, 4, 20, - 13, 4, 77, 81, 13, 28, 73, 13, 74, 14, - 72, 14, 73, 74, 49, 30, 23, 11, 13, 13, - 13, 13, 13, 78, 13, 13, 13, 13, 37, 50, - 3, 10, 30, 50, 45, 50, 19, 50, 34, 50, - 22, 48, 13, 34, 80, 13, 22, 24, 44, 35, - 51, nil, 15, 13, nil, nil, 16, 16, nil, nil, - nil, 30, 22, nil, 13, 10, nil, 30, 10, 50, - nil, nil, nil, nil, nil, nil, nil, 13, nil, nil, - 30, nil, nil, 30, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, 10, nil, - nil, nil, nil, nil, nil, 16, nil, 10, nil, nil, - nil, nil, nil, nil, nil, nil, nil, 16, nil, nil, - 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, - 16, 16, nil, nil, nil, nil, 33, 33, 19, 16, - 16, 33, 33, 33, nil, nil, nil, 19, 10, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, 16, nil, nil, nil, 16, nil, 10, 16, - nil, nil, nil, nil, nil, nil, nil, 16, nil, nil, - nil, nil, 13, nil, nil, 10, nil, nil, 16, 33, - 33, nil, nil, nil, nil, nil, 33, 33, 33, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, 10, nil, nil, nil, 16, nil, - 10, nil, 16, nil, 13, 13, nil, 16, nil, 16, - nil, nil, 16, 10, nil, nil, 10, 16, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, 16, nil, 16, nil, nil, nil, nil, - nil, nil, nil, nil, 16, nil, nil, nil, nil, 16, - nil, nil, nil, nil, nil, nil, nil, nil, 16, nil, - nil, nil, nil, nil, nil, nil, 16, nil, nil, nil, - nil, nil, nil, nil, 16, nil, nil, 16, nil, nil, - nil, nil, nil, nil, nil, 16, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, 16, nil, 16 ] - -racc_goto_pointer = [ - nil, 58, 37, 100, 68, nil, nil, nil, nil, nil, - 0, 77, 5, -18, -92, -9, 116, -53, -54, -90, - -239, nil, -174, -111, -55, nil, nil, nil, -47, -6, - -153, -225, -206, 19, -79, 25, -67, -157, nil, nil, - nil, nil, nil, nil, -100, -115, -191, nil, -150, -57, - -40, 105, nil, nil, nil, -81, -118, nil, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, -65, -164, -29, -108, -150, nil, -109, -49, -218, - -151, -203, -196 ] - -racc_goto_default = [ - nil, nil, nil, nil, nil, 5, 6, 7, 8, 9, - 57, nil, nil, nil, 163, nil, 128, nil, nil, nil, - nil, 127, 213, nil, 61, 62, 63, 64, nil, 42, - 58, 220, 239, 228, nil, nil, nil, 305, 209, 210, - 211, 212, 214, 215, nil, nil, nil, 261, 262, 69, - 70, nil, 16, 17, 18, nil, 136, 20, 21, 22, - 23, 24, 25, 26, 27, 29, 30, 31, 32, 33, - 34, nil, nil, nil, nil, nil, 36, nil, nil, 243, - nil, 229, nil ] - -racc_reduce_table = [ - 0, 0, :racc_error, - 1, 39, :_reduce_none, - 1, 40, :_reduce_2, - 1, 41, :_reduce_3, - 2, 41, :_reduce_4, - 1, 42, :_reduce_none, - 1, 42, :_reduce_none, - 1, 42, :_reduce_none, - 1, 43, :_reduce_none, - 1, 43, :_reduce_none, - 5, 46, :_reduce_10, - 3, 46, :_reduce_11, - 2, 46, :_reduce_12, - 1, 48, :_reduce_none, - 1, 48, :_reduce_none, - 1, 48, :_reduce_none, - 0, 49, :_reduce_16, - 1, 49, :_reduce_none, - 0, 50, :_reduce_18, - 3, 50, :_reduce_19, - 1, 55, :_reduce_20, - 2, 55, :_reduce_21, - 5, 56, :_reduce_22, - 1, 57, :_reduce_23, - 2, 57, :_reduce_24, - 1, 59, :_reduce_25, - 3, 59, :_reduce_26, - 0, 58, :_reduce_27, - 2, 58, :_reduce_28, - 3, 52, :_reduce_29, - 0, 61, :_reduce_30, - 1, 61, :_reduce_31, - 1, 53, :_reduce_32, - 2, 53, :_reduce_33, - 1, 62, :_reduce_none, - 1, 62, :_reduce_none, - 1, 62, :_reduce_none, - 4, 63, :_reduce_37, - 6, 63, :_reduce_38, - 1, 54, :_reduce_none, - 1, 54, :_reduce_none, - 1, 68, :_reduce_none, - 1, 68, :_reduce_none, - 1, 68, :_reduce_none, - 1, 68, :_reduce_none, - 1, 68, :_reduce_none, - 1, 68, :_reduce_none, - 1, 68, :_reduce_none, - 1, 68, :_reduce_none, - 1, 68, :_reduce_none, - 1, 67, :_reduce_none, - 1, 67, :_reduce_none, - 1, 67, :_reduce_none, - 1, 67, :_reduce_none, - 1, 67, :_reduce_none, - 1, 67, :_reduce_none, - 1, 69, :_reduce_none, - 1, 69, :_reduce_none, - 1, 69, :_reduce_none, - 1, 69, :_reduce_none, - 1, 69, :_reduce_none, - 3, 70, :_reduce_61, - 1, 72, :_reduce_62, - 2, 72, :_reduce_63, - 0, 66, :_reduce_64, - 3, 66, :_reduce_65, - 1, 73, :_reduce_66, - 2, 73, :_reduce_67, - 3, 74, :_reduce_68, - 1, 60, :_reduce_69, - 1, 60, :_reduce_70, - 1, 60, :_reduce_71, - 1, 60, :_reduce_72, - 1, 60, :_reduce_73, - 1, 60, :_reduce_none, - 1, 60, :_reduce_none, - 1, 60, :_reduce_none, - 1, 60, :_reduce_none, - 1, 75, :_reduce_none, - 1, 75, :_reduce_none, - 1, 75, :_reduce_none, - 1, 76, :_reduce_81, - 2, 80, :_reduce_82, - 2, 78, :_reduce_83, - 3, 78, :_reduce_84, - 1, 82, :_reduce_85, - 2, 82, :_reduce_86, - 2, 81, :_reduce_87, - 3, 81, :_reduce_88, - 1, 83, :_reduce_89, - 2, 83, :_reduce_90, - 3, 84, :_reduce_91, - 2, 79, :_reduce_92, - 3, 79, :_reduce_93, - 1, 85, :_reduce_94, - 2, 85, :_reduce_95, - 3, 86, :_reduce_96, - 1, 77, :_reduce_97, - 0, 51, :_reduce_98, - 1, 51, :_reduce_none, - 1, 87, :_reduce_100, - 2, 87, :_reduce_101, - 3, 88, :_reduce_102, - 3, 64, :_reduce_103, - 5, 65, :_reduce_104, - 3, 65, :_reduce_105, - 6, 47, :_reduce_106, - 0, 89, :_reduce_107, - 1, 89, :_reduce_none, - 1, 44, :_reduce_none, - 1, 44, :_reduce_none, - 1, 44, :_reduce_none, - 5, 90, :_reduce_112, - 1, 93, :_reduce_none, - 2, 93, :_reduce_114, - 3, 94, :_reduce_115, - 1, 91, :_reduce_none, - 1, 91, :_reduce_none, - 1, 91, :_reduce_none, - 1, 91, :_reduce_none, - 1, 91, :_reduce_none, - 1, 91, :_reduce_none, - 1, 45, :_reduce_none, - 1, 45, :_reduce_none, - 6, 101, :_reduce_124, - 3, 101, :_reduce_125, - 1, 102, :_reduce_none, - 1, 102, :_reduce_none, - 1, 102, :_reduce_none, - 1, 102, :_reduce_none, - 1, 102, :_reduce_none, - 1, 102, :_reduce_none, - 4, 103, :_reduce_132, - 7, 104, :_reduce_133, - 8, 104, :_reduce_134, - 5, 104, :_reduce_135, - 4, 104, :_reduce_136, - 7, 105, :_reduce_137, - 4, 105, :_reduce_138, - 6, 106, :_reduce_139, - 4, 106, :_reduce_140, - 7, 107, :_reduce_141, - 4, 107, :_reduce_142, - 7, 108, :_reduce_143, - 4, 108, :_reduce_144, - 1, 114, :_reduce_none, - 0, 71, :_reduce_none, - 1, 71, :_reduce_none, - 4, 95, :_reduce_148, - 8, 96, :_reduce_149, - 0, 111, :_reduce_150, - 1, 111, :_reduce_none, - 3, 109, :_reduce_152, - 2, 109, :_reduce_153, - 2, 109, :_reduce_154, - 1, 115, :_reduce_155, - 3, 115, :_reduce_156, - 1, 116, :_reduce_157, - 2, 116, :_reduce_158, - 6, 117, :_reduce_159, - 1, 113, :_reduce_160, - 2, 113, :_reduce_161, - 0, 118, :_reduce_162, - 3, 118, :_reduce_163, - 6, 119, :_reduce_164, - 0, 110, :_reduce_165, - 1, 110, :_reduce_166, - 2, 110, :_reduce_167, - 7, 97, :_reduce_168, - 1, 112, :_reduce_169, - 3, 112, :_reduce_170, - 6, 98, :_reduce_171, - 7, 99, :_reduce_172, - 7, 100, :_reduce_173, - 7, 92, :_reduce_174, - 1, 120, :_reduce_175, - 3, 120, :_reduce_176 ] - -racc_reduce_n = 177 - -racc_shift_n = 316 - -racc_token_table = { - false => 0, - :error => 1, - :LCURLY => 2, - :RCURLY => 3, - :QUERY => 4, - :MUTATION => 5, - :SUBSCRIPTION => 6, - :LPAREN => 7, - :RPAREN => 8, - :VAR_SIGN => 9, - :COLON => 10, - :BANG => 11, - :LBRACKET => 12, - :RBRACKET => 13, - :EQUALS => 14, - :ON => 15, - :SCHEMA => 16, - :SCALAR => 17, - :TYPE => 18, - :IMPLEMENTS => 19, - :INTERFACE => 20, - :UNION => 21, - :ENUM => 22, - :INPUT => 23, - :DIRECTIVE => 24, - :IDENTIFIER => 25, - :FRAGMENT => 26, - :TRUE => 27, - :FALSE => 28, - :FLOAT => 29, - :INT => 30, - :STRING => 31, - :NULL => 32, - :DIR_SIGN => 33, - :ELLIPSIS => 34, - :EXTEND => 35, - :AMP => 36, - :PIPE => 37 } - -racc_nt_base = 38 - -racc_use_result_var = true - -Racc_arg = [ - racc_action_table, - racc_action_check, - racc_action_default, - racc_action_pointer, - racc_goto_table, - racc_goto_check, - racc_goto_default, - racc_goto_pointer, - racc_nt_base, - racc_reduce_table, - racc_token_table, - racc_shift_n, - racc_reduce_n, - racc_use_result_var ] - -Racc_token_to_s_table = [ - "$end", - "error", - "LCURLY", - "RCURLY", - "QUERY", - "MUTATION", - "SUBSCRIPTION", - "LPAREN", - "RPAREN", - "VAR_SIGN", - "COLON", - "BANG", - "LBRACKET", - "RBRACKET", - "EQUALS", - "ON", - "SCHEMA", - "SCALAR", - "TYPE", - "IMPLEMENTS", - "INTERFACE", - "UNION", - "ENUM", - "INPUT", - "DIRECTIVE", - "IDENTIFIER", - "FRAGMENT", - "TRUE", - "FALSE", - "FLOAT", - "INT", - "STRING", - "NULL", - "DIR_SIGN", - "ELLIPSIS", - "EXTEND", - "AMP", - "PIPE", - "$start", - "target", - "document", - "definitions_list", - "definition", - "executable_definition", - "type_system_definition", - "type_system_extension", - "operation_definition", - "fragment_definition", - "operation_type", - "operation_name_opt", - "variable_definitions_opt", - "directives_list_opt", - "selection_set", - "selection_list", - "name", - "variable_definitions_list", - "variable_definition", - "type", - "default_value_opt", - "nullable_type", - "literal_value", - "selection_set_opt", - "selection", - "field", - "fragment_spread", - "inline_fragment", - "arguments_opt", - "name_without_on", - "schema_keyword", - "enum_name", - "enum_value_definition", - "description_opt", - "enum_value_definitions", - "arguments_list", - "argument", - "input_value", - "null_value", - "enum_value", - "list_value", - "object_literal_value", - "variable", - "object_value", - "list_value_list", - "object_value_list", - "object_value_field", - "object_literal_value_list", - "object_literal_value_field", - "directives_list", - "directive", - "fragment_name_opt", - "schema_definition", - "type_definition", - "directive_definition", - "operation_type_definition_list", - "operation_type_definition", - "scalar_type_definition", - "object_type_definition", - "interface_type_definition", - "union_type_definition", - "enum_type_definition", - "input_object_type_definition", - "schema_extension", - "type_extension", - "scalar_type_extension", - "object_type_extension", - "interface_type_extension", - "union_type_extension", - "enum_type_extension", - "input_object_type_extension", - "implements", - "field_definition_list", - "implements_opt", - "union_members", - "input_value_definition_list", - "description", - "interfaces_list", - "legacy_interfaces_list", - "input_value_definition", - "arguments_definitions_opt", - "field_definition", - "directive_locations" ] - -Racc_debug_parser = false - -##### State transition tables end ##### - -# reduce 0 omitted - -# reduce 1 omitted - -module_eval(<<'.,.,', 'parser.y', 4) - def _reduce_2(val, _values, result) - result = make_node(:Document, definitions: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 7) - def _reduce_3(val, _values, result) - result = [val[0]] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 8) - def _reduce_4(val, _values, result) - val[0] << val[1] - result - end -.,., - -# reduce 5 omitted - -# reduce 6 omitted - -# reduce 7 omitted - -# reduce 8 omitted - -# reduce 9 omitted - -module_eval(<<'.,.,', 'parser.y', 21) - def _reduce_10(val, _values, result) - result = make_node( - :OperationDefinition, { - operation_type: val[0], - name: val[1], - variables: val[2], - directives: val[3], - selections: val[4], - position_source: val[0], - } - ) - - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 33) - def _reduce_11(val, _values, result) - result = make_node( - :OperationDefinition, { - operation_type: "query", - selections: val[1], - position_source: val[0], - } - ) - - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 42) - def _reduce_12(val, _values, result) - result = make_node( - :OperationDefinition, { - operation_type: "query", - selections: [], - position_source: val[0], - } - ) - - result - end -.,., - -# reduce 13 omitted - -# reduce 14 omitted - -# reduce 15 omitted - -module_eval(<<'.,.,', 'parser.y', 57) - def _reduce_16(val, _values, result) - result = nil - result - end -.,., - -# reduce 17 omitted - -module_eval(<<'.,.,', 'parser.y', 61) - def _reduce_18(val, _values, result) - result = EMPTY_ARRAY - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 62) - def _reduce_19(val, _values, result) - result = val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 65) - def _reduce_20(val, _values, result) - result = [val[0]] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 66) - def _reduce_21(val, _values, result) - val[0] << val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 70) - def _reduce_22(val, _values, result) - result = make_node(:VariableDefinition, { - name: val[1], - type: val[3], - default_value: val[4], - position_source: val[0], - }) - - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 79) - def _reduce_23(val, _values, result) - result = val[0] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 80) - def _reduce_24(val, _values, result) - result = make_node(:NonNullType, of_type: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 83) - def _reduce_25(val, _values, result) - result = make_node(:TypeName, name: val[0]) - result - end -.,., -module_eval(<<'.,.,', 'parser.y', 84) - def _reduce_26(val, _values, result) - result = make_node(:ListType, of_type: val[1]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 87) - def _reduce_27(val, _values, result) - result = nil - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 88) - def _reduce_28(val, _values, result) - result = val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 91) - def _reduce_29(val, _values, result) - result = val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 94) - def _reduce_30(val, _values, result) - result = EMPTY_ARRAY - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 95) - def _reduce_31(val, _values, result) - result = val[0] - result - end -.,., + def tokens_count + parse + @lexer.tokens_count + end -module_eval(<<'.,.,', 'parser.y', 98) - def _reduce_32(val, _values, result) - result = [result] - result - end -.,., + def line_at(pos) + line = lines_at.bsearch_index { |l| l >= pos } + if line.nil? + @lines_at.size + 1 + else + line + 1 + end + end -module_eval(<<'.,.,', 'parser.y', 99) - def _reduce_33(val, _values, result) - val[0] << val[1] - result - end -.,., + def column_at(pos) + next_line_idx = lines_at.bsearch_index { |l| l >= pos } || 0 + if next_line_idx > 0 + line_pos = @lines_at[next_line_idx - 1] + pos - line_pos + else + pos + 1 + end + end -# reduce 34 omitted + private + + # @return [Array] Positions of each line break in the original string + def lines_at + @lines_at ||= begin + la = [] + idx = 0 + while idx + idx = @graphql_str.index("\n", idx) + if idx + la << idx + idx += 1 + end + end + la + end + end -# reduce 35 omitted + attr_reader :token_name -# reduce 36 omitted + def advance_token + @token_name = @lexer.advance + end -module_eval(<<'.,.,', 'parser.y', 108) - def _reduce_37(val, _values, result) - result = make_node( - :Field, { - name: val[0], - arguments: val[1], - directives: val[2], - selections: val[3], - position_source: val[0], - } - ) + def pos + @lexer.pos + end - result - end -.,., + def document + any_tokens = advance_token + defns = [] + if any_tokens + defns << definition + else + # Only ignored characters is not a valid document + raise GraphQL::ParseError.new("Unexpected end of document", nil, nil, @graphql_str) + end + while !@lexer.finished? + defns << definition + end + Document.new(pos: 0, definitions: defns, filename: @filename, source: self) + end -module_eval(<<'.,.,', 'parser.y', 119) - def _reduce_38(val, _values, result) - result = make_node( - :Field, { - alias: val[0], - name: val[2], - arguments: val[3], - directives: val[4], - selections: val[5], - position_source: val[0], - } + def definition + case token_name + when :FRAGMENT + loc = pos + expect_token :FRAGMENT + f_name = if !at?(:ON) + parse_name + end + expect_token :ON + f_type = parse_type_name + directives = parse_directives + selections = selection_set + Nodes::FragmentDefinition.new( + pos: loc, + name: f_name, + type: f_type, + directives: directives, + selections: selections, + filename: @filename, + source: self + ) + when :QUERY, :MUTATION, :SUBSCRIPTION, :LCURLY + op_loc = pos + op_type = case token_name + when :LCURLY + "query" + else + parse_operation_type + end + + op_name = case token_name + when :LPAREN, :LCURLY, :DIR_SIGN + nil + else + parse_name + end + + variable_definitions = if at?(:LPAREN) + expect_token(:LPAREN) + defs = [] + while !at?(:RPAREN) + loc = pos + expect_token(:VAR_SIGN) + var_name = parse_name + expect_token(:COLON) + var_type = self.type || raise_parse_error("Missing type definition for variable: $#{var_name}") + default_value = if at?(:EQUALS) + advance_token + value + end + + directives = parse_directives + + defs << Nodes::VariableDefinition.new( + pos: loc, + name: var_name, + type: var_type, + default_value: default_value, + directives: directives, + filename: @filename, + source: self + ) + end + expect_token(:RPAREN) + defs + else + EmptyObjects::EMPTY_ARRAY + end + + directives = parse_directives + + OperationDefinition.new( + pos: op_loc, + operation_type: op_type, + name: op_name, + variables: variable_definitions, + directives: directives, + selections: selection_set, + filename: @filename, + source: self + ) + when :EXTEND + loc = pos + advance_token + case token_name + when :SCALAR + advance_token + name = parse_name + directives = parse_directives + ScalarTypeExtension.new(pos: loc, name: name, directives: directives, filename: @filename, source: self) + when :TYPE + advance_token + name = parse_name + implements_interfaces = parse_implements + directives = parse_directives + field_defns = at?(:LCURLY) ? parse_field_definitions : EMPTY_ARRAY + + ObjectTypeExtension.new(pos: loc, name: name, interfaces: implements_interfaces, directives: directives, fields: field_defns, filename: @filename, source: self) + when :INTERFACE + advance_token + name = parse_name + directives = parse_directives + interfaces = parse_implements + fields_definition = at?(:LCURLY) ? parse_field_definitions : EMPTY_ARRAY + InterfaceTypeExtension.new(pos: loc, name: name, directives: directives, fields: fields_definition, interfaces: interfaces, filename: @filename, source: self) + when :UNION + advance_token + name = parse_name + directives = parse_directives + union_member_types = parse_union_members + UnionTypeExtension.new(pos: loc, name: name, directives: directives, types: union_member_types, filename: @filename, source: self) + when :ENUM + advance_token + name = parse_name + directives = parse_directives + enum_values_definition = parse_enum_value_definitions + Nodes::EnumTypeExtension.new(pos: loc, name: name, directives: directives, values: enum_values_definition, filename: @filename, source: self) + when :INPUT + advance_token + name = parse_name + directives = parse_directives + input_fields_definition = parse_input_object_field_definitions + InputObjectTypeExtension.new(pos: loc, name: name, directives: directives, fields: input_fields_definition, filename: @filename, source: self) + when :SCHEMA + advance_token + directives = parse_directives + query = mutation = subscription = nil + if at?(:LCURLY) + advance_token + while !at?(:RCURLY) + if at?(:QUERY) + advance_token + expect_token(:COLON) + query = parse_name + elsif at?(:MUTATION) + advance_token + expect_token(:COLON) + mutation = parse_name + elsif at?(:SUBSCRIPTION) + advance_token + expect_token(:COLON) + subscription = parse_name + else + expect_one_of([:QUERY, :MUTATION, :SUBSCRIPTION]) + end + end + expect_token :RCURLY + end + SchemaExtension.new( + subscription: subscription, + mutation: mutation, + query: query, + directives: directives, + pos: loc, + filename: @filename, + source: self, ) + else + expect_one_of([:SCHEMA, :SCALAR, :TYPE, :ENUM, :INPUT, :UNION, :INTERFACE]) + end + else + loc = pos + desc = at?(:STRING) ? string_value : nil + defn_loc = pos + case token_name + when :SCHEMA + advance_token + directives = parse_directives + query = mutation = subscription = nil + expect_token :LCURLY + while !at?(:RCURLY) + if at?(:QUERY) + advance_token + expect_token(:COLON) + query = parse_name + elsif at?(:MUTATION) + advance_token + expect_token(:COLON) + mutation = parse_name + elsif at?(:SUBSCRIPTION) + advance_token + expect_token(:COLON) + subscription = parse_name + else + expect_one_of([:QUERY, :MUTATION, :SUBSCRIPTION]) + end + end + expect_token :RCURLY + SchemaDefinition.new(pos: loc, definition_pos: defn_loc, query: query, mutation: mutation, subscription: subscription, directives: directives, filename: @filename, source: self) + when :DIRECTIVE + advance_token + expect_token :DIR_SIGN + name = parse_name + arguments_definition = parse_argument_definitions + repeatable = if at?(:REPEATABLE) + advance_token + true + else + false + end + expect_token :ON + directive_locations = [DirectiveLocation.new(pos: pos, name: parse_name, filename: @filename, source: self)] + while at?(:PIPE) + advance_token + directive_locations << DirectiveLocation.new(pos: pos, name: parse_name, filename: @filename, source: self) + end + DirectiveDefinition.new(pos: loc, definition_pos: defn_loc, description: desc, name: name, arguments: arguments_definition, locations: directive_locations, repeatable: repeatable, filename: @filename, source: self) + when :TYPE + advance_token + name = parse_name + implements_interfaces = parse_implements + directives = parse_directives + field_defns = at?(:LCURLY) ? parse_field_definitions : EMPTY_ARRAY + + ObjectTypeDefinition.new(pos: loc, definition_pos: defn_loc, description: desc, name: name, interfaces: implements_interfaces, directives: directives, fields: field_defns, filename: @filename, source: self) + when :INTERFACE + advance_token + name = parse_name + interfaces = parse_implements + directives = parse_directives + fields_definition = parse_field_definitions + InterfaceTypeDefinition.new(pos: loc, definition_pos: defn_loc, description: desc, name: name, directives: directives, fields: fields_definition, interfaces: interfaces, filename: @filename, source: self) + when :UNION + advance_token + name = parse_name + directives = parse_directives + union_member_types = parse_union_members + UnionTypeDefinition.new(pos: loc, definition_pos: defn_loc, description: desc, name: name, directives: directives, types: union_member_types, filename: @filename, source: self) + when :SCALAR + advance_token + name = parse_name + directives = parse_directives + ScalarTypeDefinition.new(pos: loc, definition_pos: defn_loc, description: desc, name: name, directives: directives, filename: @filename, source: self) + when :ENUM + advance_token + name = parse_name + directives = parse_directives + enum_values_definition = parse_enum_value_definitions + Nodes::EnumTypeDefinition.new(pos: loc, definition_pos: defn_loc, description: desc, name: name, directives: directives, values: enum_values_definition, filename: @filename, source: self) + when :INPUT + advance_token + name = parse_name + directives = parse_directives + input_fields_definition = parse_input_object_field_definitions + InputObjectTypeDefinition.new(pos: loc, definition_pos: defn_loc, description: desc, name: name, directives: directives, fields: input_fields_definition, filename: @filename, source: self) + else + expect_one_of([:SCHEMA, :SCALAR, :TYPE, :ENUM, :INPUT, :UNION, :INTERFACE]) + end + end + end - result - end -.,., - -# reduce 39 omitted - -# reduce 40 omitted - -# reduce 41 omitted - -# reduce 42 omitted - -# reduce 43 omitted - -# reduce 44 omitted - -# reduce 45 omitted - -# reduce 46 omitted - -# reduce 47 omitted - -# reduce 48 omitted - -# reduce 49 omitted - -# reduce 50 omitted - -# reduce 51 omitted - -# reduce 52 omitted - -# reduce 53 omitted - -# reduce 54 omitted - -# reduce 55 omitted - -# reduce 56 omitted - -# reduce 57 omitted - -# reduce 58 omitted - -# reduce 59 omitted - -# reduce 60 omitted - -module_eval(<<'.,.,', 'parser.y', 162) - def _reduce_61(val, _values, result) - result = make_node(:EnumValueDefinition, name: val[1], directives: val[2], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 165) - def _reduce_62(val, _values, result) - result = [val[0]] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 166) - def _reduce_63(val, _values, result) - result = val[0] << val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 169) - def _reduce_64(val, _values, result) - result = EMPTY_ARRAY - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 170) - def _reduce_65(val, _values, result) - result = val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 173) - def _reduce_66(val, _values, result) - result = [val[0]] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 174) - def _reduce_67(val, _values, result) - val[0] << val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 177) - def _reduce_68(val, _values, result) - result = make_node(:Argument, name: val[0], value: val[2], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 180) - def _reduce_69(val, _values, result) - result = val[0].to_f - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 181) - def _reduce_70(val, _values, result) - result = val[0].to_i - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 182) - def _reduce_71(val, _values, result) - result = val[0].to_s - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 183) - def _reduce_72(val, _values, result) - result = true - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 184) - def _reduce_73(val, _values, result) - result = false - result - end -.,., - -# reduce 74 omitted - -# reduce 75 omitted - -# reduce 76 omitted - -# reduce 77 omitted - -# reduce 78 omitted - -# reduce 79 omitted - -# reduce 80 omitted - -module_eval(<<'.,.,', 'parser.y', 195) - def _reduce_81(val, _values, result) - result = make_node(:NullValue, name: val[0], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 196) - def _reduce_82(val, _values, result) - result = make_node(:VariableIdentifier, name: val[1], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 199) - def _reduce_83(val, _values, result) - result = EMPTY_ARRAY - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 200) - def _reduce_84(val, _values, result) - result = val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 203) - def _reduce_85(val, _values, result) - result = [val[0]] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 204) - def _reduce_86(val, _values, result) - val[0] << val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 207) - def _reduce_87(val, _values, result) - result = make_node(:InputObject, arguments: [], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 208) - def _reduce_88(val, _values, result) - result = make_node(:InputObject, arguments: val[1], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 211) - def _reduce_89(val, _values, result) - result = [val[0]] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 212) - def _reduce_90(val, _values, result) - val[0] << val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 215) - def _reduce_91(val, _values, result) - result = make_node(:Argument, name: val[0], value: val[2], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 219) - def _reduce_92(val, _values, result) - result = make_node(:InputObject, arguments: [], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 220) - def _reduce_93(val, _values, result) - result = make_node(:InputObject, arguments: val[1], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 223) - def _reduce_94(val, _values, result) - result = [val[0]] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 224) - def _reduce_95(val, _values, result) - val[0] << val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 227) - def _reduce_96(val, _values, result) - result = make_node(:Argument, name: val[0], value: val[2], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 229) - def _reduce_97(val, _values, result) - result = make_node(:Enum, name: val[0], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 232) - def _reduce_98(val, _values, result) - result = EMPTY_ARRAY - result - end -.,., - -# reduce 99 omitted - -module_eval(<<'.,.,', 'parser.y', 236) - def _reduce_100(val, _values, result) - result = [val[0]] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 237) - def _reduce_101(val, _values, result) - val[0] << val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 239) - def _reduce_102(val, _values, result) - result = make_node(:Directive, name: val[1], arguments: val[2], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 242) - def _reduce_103(val, _values, result) - result = make_node(:FragmentSpread, name: val[1], directives: val[2], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 246) - def _reduce_104(val, _values, result) - result = make_node(:InlineFragment, { - type: val[2], - directives: val[3], - selections: val[4], - position_source: val[0] - }) - - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 254) - def _reduce_105(val, _values, result) - result = make_node(:InlineFragment, { - type: nil, - directives: val[1], - selections: val[2], - position_source: val[0] - }) - - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 264) - def _reduce_106(val, _values, result) - result = make_node(:FragmentDefinition, { - name: val[1], - type: val[3], - directives: val[4], - selections: val[5], - position_source: val[0], - } - ) - - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 275) - def _reduce_107(val, _values, result) - result = nil - result - end -.,., - -# reduce 108 omitted - -# reduce 109 omitted - -# reduce 110 omitted - -# reduce 111 omitted - -module_eval(<<'.,.,', 'parser.y', 284) - def _reduce_112(val, _values, result) - result = make_node(:SchemaDefinition, position_source: val[0], definition_line: val[0].line, directives: val[1], **val[3]) - result - end -.,., - -# reduce 113 omitted - -module_eval(<<'.,.,', 'parser.y', 288) - def _reduce_114(val, _values, result) - result = val[0].merge(val[1]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 291) - def _reduce_115(val, _values, result) - result = { val[0].to_s.to_sym => val[2] } - result - end -.,., - -# reduce 116 omitted - -# reduce 117 omitted - -# reduce 118 omitted - -# reduce 119 omitted - -# reduce 120 omitted - -# reduce 121 omitted - -# reduce 122 omitted - -# reduce 123 omitted - -module_eval(<<'.,.,', 'parser.y', 306) - def _reduce_124(val, _values, result) - result = make_node(:SchemaExtension, position_source: val[0], directives: val[2], **val[4]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 307) - def _reduce_125(val, _values, result) - result = make_node(:SchemaExtension, position_source: val[0], directives: val[2]) - result - end -.,., - -# reduce 126 omitted - -# reduce 127 omitted - -# reduce 128 omitted - -# reduce 129 omitted - -# reduce 130 omitted - -# reduce 131 omitted - -module_eval(<<'.,.,', 'parser.y', 317) - def _reduce_132(val, _values, result) - result = make_node(:ScalarTypeExtension, name: val[2], directives: val[3], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 321) - def _reduce_133(val, _values, result) - result = make_node(:ObjectTypeExtension, name: val[2], interfaces: val[3], directives: [], fields: val[5], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 322) - def _reduce_134(val, _values, result) - result = make_node(:ObjectTypeExtension, name: val[2], interfaces: val[3], directives: val[4], fields: val[6], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 323) - def _reduce_135(val, _values, result) - result = make_node(:ObjectTypeExtension, name: val[2], interfaces: val[3], directives: val[4], fields: [], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 324) - def _reduce_136(val, _values, result) - result = make_node(:ObjectTypeExtension, name: val[2], interfaces: val[3], directives: [], fields: [], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 327) - def _reduce_137(val, _values, result) - result = make_node(:InterfaceTypeExtension, name: val[2], directives: val[3], fields: val[5], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 328) - def _reduce_138(val, _values, result) - result = make_node(:InterfaceTypeExtension, name: val[2], directives: val[3], fields: [], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 331) - def _reduce_139(val, _values, result) - result = make_node(:UnionTypeExtension, name: val[2], directives: val[3], types: val[5], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 332) - def _reduce_140(val, _values, result) - result = make_node(:UnionTypeExtension, name: val[2], directives: val[3], types: [], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 335) - def _reduce_141(val, _values, result) - result = make_node(:EnumTypeExtension, name: val[2], directives: val[3], values: val[5], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 336) - def _reduce_142(val, _values, result) - result = make_node(:EnumTypeExtension, name: val[2], directives: val[3], values: [], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 339) - def _reduce_143(val, _values, result) - result = make_node(:InputObjectTypeExtension, name: val[2], directives: val[3], fields: val[5], position_source: val[0]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 340) - def _reduce_144(val, _values, result) - result = make_node(:InputObjectTypeExtension, name: val[2], directives: val[3], fields: [], position_source: val[0]) - result - end -.,., - -# reduce 145 omitted - -# reduce 146 omitted - -# reduce 147 omitted - -module_eval(<<'.,.,', 'parser.y', 350) - def _reduce_148(val, _values, result) - result = make_node(:ScalarTypeDefinition, name: val[2], directives: val[3], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) - - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 355) - def _reduce_149(val, _values, result) - result = make_node(:ObjectTypeDefinition, name: val[2], interfaces: val[3], directives: val[4], fields: val[6], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) - - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 359) - def _reduce_150(val, _values, result) - result = EMPTY_ARRAY - result - end -.,., - -# reduce 151 omitted - -module_eval(<<'.,.,', 'parser.y', 363) - def _reduce_152(val, _values, result) - result = val[2] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 364) - def _reduce_153(val, _values, result) - result = val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 365) - def _reduce_154(val, _values, result) - result = val[1] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 368) - def _reduce_155(val, _values, result) - result = [make_node(:TypeName, name: val[0], position_source: val[0])] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 369) - def _reduce_156(val, _values, result) - val[0] << make_node(:TypeName, name: val[2], position_source: val[2]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 372) - def _reduce_157(val, _values, result) - result = [make_node(:TypeName, name: val[0], position_source: val[0])] - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 373) - def _reduce_158(val, _values, result) - val[0] << make_node(:TypeName, name: val[1], position_source: val[1]) - result - end -.,., - -module_eval(<<'.,.,', 'parser.y', 377) - def _reduce_159(val, _values, result) - result = make_node(:InputValueDefinition, name: val[1], type: val[3], default_value: val[4], directives: val[5], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) + def parse_input_object_field_definitions + if at?(:LCURLY) + expect_token :LCURLY + list = [] + while !at?(:RCURLY) + list << parse_input_value_definition + end + expect_token :RCURLY + list + else + EMPTY_ARRAY + end + end - result - end -.,., + def parse_enum_value_definitions + if at?(:LCURLY) + expect_token :LCURLY + list = [] + while !at?(:RCURLY) + v_loc = pos + description = if at?(:STRING); string_value; end + defn_loc = pos + # Any identifier, but not true, false, or null + enum_value = if at?(:TRUE) || at?(:FALSE) || at?(:NULL) + expect_token(:IDENTIFIER) + else + parse_name + end + v_directives = parse_directives + list << EnumValueDefinition.new(pos: v_loc, definition_pos: defn_loc, description: description, name: enum_value, directives: v_directives, filename: @filename, source: self) + end + expect_token :RCURLY + list + else + EMPTY_ARRAY + end + end -module_eval(<<'.,.,', 'parser.y', 381) - def _reduce_160(val, _values, result) - result = [val[0]] - result - end -.,., + def parse_union_members + if at?(:EQUALS) + expect_token :EQUALS + if at?(:PIPE) + advance_token + end + list = [parse_type_name] + while at?(:PIPE) + advance_token + list << parse_type_name + end + list + else + EMPTY_ARRAY + end + end -module_eval(<<'.,.,', 'parser.y', 382) - def _reduce_161(val, _values, result) - val[0] << val[1] - result - end -.,., + def parse_implements + if at?(:IMPLEMENTS) + advance_token + list = [] + while true + advance_token if at?(:AMP) + break unless at?(:IDENTIFIER) + list << parse_type_name + end + list + else + EMPTY_ARRAY + end + end -module_eval(<<'.,.,', 'parser.y', 385) - def _reduce_162(val, _values, result) - result = EMPTY_ARRAY - result - end -.,., + def parse_field_definitions + expect_token :LCURLY + list = [] + while !at?(:RCURLY) + loc = pos + description = if at?(:STRING); string_value; end + defn_loc = pos + name = parse_name + arguments_definition = parse_argument_definitions + expect_token :COLON + type = self.type + directives = parse_directives + + list << FieldDefinition.new(pos: loc, definition_pos: defn_loc, description: description, name: name, arguments: arguments_definition, type: type, directives: directives, filename: @filename, source: self) + end + expect_token :RCURLY + list + end -module_eval(<<'.,.,', 'parser.y', 386) - def _reduce_163(val, _values, result) - result = val[1] - result - end -.,., + def parse_argument_definitions + if at?(:LPAREN) + advance_token + list = [] + while !at?(:RPAREN) + list << parse_input_value_definition + end + expect_token :RPAREN + list + else + EMPTY_ARRAY + end + end -module_eval(<<'.,.,', 'parser.y', 390) - def _reduce_164(val, _values, result) - result = make_node(:FieldDefinition, name: val[1], arguments: val[2], type: val[4], directives: val[5], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) + def parse_input_value_definition + loc = pos + description = if at?(:STRING); string_value; end + defn_loc = pos + name = parse_name + expect_token :COLON + type = self.type + default_value = if at?(:EQUALS) + advance_token + value + else + nil + end + directives = parse_directives + InputValueDefinition.new(pos: loc, definition_pos: defn_loc, description: description, name: name, type: type, default_value: default_value, directives: directives, filename: @filename, source: self) + end - result - end -.,., + def type + parsed_type = case token_name + when :IDENTIFIER + parse_type_name + when :LBRACKET + list_type + else + nil + end + + if at?(:BANG) && parsed_type + parsed_type = Nodes::NonNullType.new(pos: pos, of_type: parsed_type, source: self) + expect_token(:BANG) + end + parsed_type + end -module_eval(<<'.,.,', 'parser.y', 394) - def _reduce_165(val, _values, result) - result = EMPTY_ARRAY - result - end -.,., + def list_type + loc = pos + expect_token(:LBRACKET) + inner_type = self.type + parsed_list_type = if inner_type + Nodes::ListType.new(pos: loc, of_type: inner_type, source: self) + else + nil + end + expect_token(:RBRACKET) + parsed_list_type + end -module_eval(<<'.,.,', 'parser.y', 395) - def _reduce_166(val, _values, result) - result = [val[0]] - result - end -.,., + def parse_operation_type + val = if at?(:QUERY) + "query" + elsif at?(:MUTATION) + "mutation" + elsif at?(:SUBSCRIPTION) + "subscription" + else + expect_one_of([:QUERY, :MUTATION, :SUBSCRIPTION]) + end + advance_token + val + end -module_eval(<<'.,.,', 'parser.y', 396) - def _reduce_167(val, _values, result) - val[0] << val[1] - result - end -.,., + def selection_set + expect_token(:LCURLY) + selections = [] + while @token_name != :RCURLY + selections << if at?(:ELLIPSIS) + loc = pos + advance_token + case token_name + when :ON, :DIR_SIGN, :LCURLY + if_type = if at?(:ON) + advance_token + parse_type_name + else + nil + end + + directives = parse_directives + + Nodes::InlineFragment.new(pos: loc, type: if_type, directives: directives, selections: selection_set, filename: @filename, source: self) + else + name = parse_name_without_on + directives = parse_directives + + # Can this ever happen? + # expect_token(:IDENTIFIER) if at?(:ON) + + FragmentSpread.new(pos: loc, name: name, directives: directives, filename: @filename, source: self) + end + else + loc = pos + name = parse_name + + field_alias = nil + + if at?(:COLON) + advance_token + field_alias = name + name = parse_name + end + + arguments = at?(:LPAREN) ? parse_arguments : nil + directives = at?(:DIR_SIGN) ? parse_directives : nil + selection_set = at?(:LCURLY) ? self.selection_set : nil + + Nodes::Field.new(pos: loc, field_alias: field_alias, name: name, arguments: arguments, directives: directives, selections: selection_set, filename: @filename, source: self) + end + end + expect_token(:RCURLY) + selections + end -module_eval(<<'.,.,', 'parser.y', 400) - def _reduce_168(val, _values, result) - result = make_node(:InterfaceTypeDefinition, name: val[2], directives: val[3], fields: val[5], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) + def parse_name + case token_name + when :IDENTIFIER + expect_token_value(:IDENTIFIER) + when :SCHEMA + advance_token + "schema" + when :SCALAR + advance_token + "scalar" + when :IMPLEMENTS + advance_token + "implements" + when :INTERFACE + advance_token + "interface" + when :UNION + advance_token + "union" + when :ENUM + advance_token + "enum" + when :INPUT + advance_token + "input" + when :DIRECTIVE + advance_token + "directive" + when :TYPE + advance_token + "type" + when :QUERY + advance_token + "query" + when :MUTATION + advance_token + "mutation" + when :SUBSCRIPTION + advance_token + "subscription" + when :TRUE + advance_token + "true" + when :FALSE + advance_token + "false" + when :FRAGMENT + advance_token + "fragment" + when :REPEATABLE + advance_token + "repeatable" + when :NULL + advance_token + "null" + when :ON + advance_token + "on" + when :EXTEND + advance_token + "extend" + else + expect_token(:NAME) + end + end - result - end -.,., + def parse_name_without_on + if at?(:ON) + expect_token(:IDENTIFIER) + else + parse_name + end + end -module_eval(<<'.,.,', 'parser.y', 404) - def _reduce_169(val, _values, result) - result = [make_node(:TypeName, name: val[0], position_source: val[0])] - result - end -.,., + def parse_type_name + TypeName.new(pos: pos, name: parse_name, filename: @filename, source: self) + end -module_eval(<<'.,.,', 'parser.y', 405) - def _reduce_170(val, _values, result) - val[0] << make_node(:TypeName, name: val[2], position_source: val[2]) - result - end -.,., + def parse_directives + if at?(:DIR_SIGN) + dirs = [] + while at?(:DIR_SIGN) + loc = pos + advance_token + name = parse_name + arguments = parse_arguments + + dirs << Nodes::Directive.new(pos: loc, name: name, arguments: arguments, filename: @filename, source: self) + end + dirs + else + EMPTY_ARRAY + end + end -module_eval(<<'.,.,', 'parser.y', 409) - def _reduce_171(val, _values, result) - result = make_node(:UnionTypeDefinition, name: val[2], directives: val[3], types: val[5], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) + def parse_arguments + if at?(:LPAREN) + advance_token + args = [] + while !at?(:RPAREN) + loc = pos + name = parse_name + expect_token(:COLON) + args << Nodes::Argument.new(pos: loc, name: name, value: value, filename: @filename, source: self) + end + if args.empty? + expect_token(:ARGUMENT_NAME) # At least one argument is required + end + expect_token(:RPAREN) + args + else + EMPTY_ARRAY + end + end - result - end -.,., + def string_value + token_value = @lexer.string_value + expect_token :STRING + token_value + end -module_eval(<<'.,.,', 'parser.y', 414) - def _reduce_172(val, _values, result) - result = make_node(:EnumTypeDefinition, name: val[2], directives: val[3], values: val[5], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) + def value + case token_name + when :INT + expect_token_value(:INT).to_i + when :FLOAT + expect_token_value(:FLOAT).to_f + when :STRING + string_value + when :TRUE + advance_token + true + when :FALSE + advance_token + false + when :NULL + advance_token + NullValue.new(pos: pos, name: "null", filename: @filename, source: self) + when :IDENTIFIER + Nodes::Enum.new(pos: pos, name: expect_token_value(:IDENTIFIER), filename: @filename, source: self) + when :LBRACKET + advance_token + list = [] + while !at?(:RBRACKET) + list << value + end + expect_token(:RBRACKET) + list + when :LCURLY + start = pos + advance_token + args = [] + while !at?(:RCURLY) + loc = pos + n = parse_name + expect_token(:COLON) + args << Argument.new(pos: loc, name: n, value: value, filename: @filename, source: self) + end + expect_token(:RCURLY) + InputObject.new(pos: start, arguments: args, filename: @filename, source: self) + when :VAR_SIGN + loc = pos + advance_token + VariableIdentifier.new(pos: loc, name: parse_name, filename: @filename, source: self) + when :SCHEMA + advance_token + Nodes::Enum.new(pos: pos, name: "schema", filename: @filename, source: self) + when :SCALAR + advance_token + Nodes::Enum.new(pos: pos, name: "scalar", filename: @filename, source: self) + when :IMPLEMENTS + advance_token + Nodes::Enum.new(pos: pos, name: "implements", filename: @filename, source: self) + when :INTERFACE + advance_token + Nodes::Enum.new(pos: pos, name: "interface", filename: @filename, source: self) + when :UNION + advance_token + Nodes::Enum.new(pos: pos, name: "union", filename: @filename, source: self) + when :ENUM + advance_token + Nodes::Enum.new(pos: pos, name: "enum", filename: @filename, source: self) + when :INPUT + advance_token + Nodes::Enum.new(pos: pos, name: "input", filename: @filename, source: self) + when :DIRECTIVE + advance_token + Nodes::Enum.new(pos: pos, name: "directive", filename: @filename, source: self) + when :TYPE + advance_token + Nodes::Enum.new(pos: pos, name: "type", filename: @filename, source: self) + when :QUERY + advance_token + Nodes::Enum.new(pos: pos, name: "query", filename: @filename, source: self) + when :MUTATION + advance_token + Nodes::Enum.new(pos: pos, name: "mutation", filename: @filename, source: self) + when :SUBSCRIPTION + advance_token + Nodes::Enum.new(pos: pos, name: "subscription", filename: @filename, source: self) + when :FRAGMENT + advance_token + Nodes::Enum.new(pos: pos, name: "fragment", filename: @filename, source: self) + when :REPEATABLE + advance_token + Nodes::Enum.new(pos: pos, name: "repeatable", filename: @filename, source: self) + when :ON + advance_token + Nodes::Enum.new(pos: pos, name: "on", filename: @filename, source: self) + when :EXTEND + advance_token + Nodes::Enum.new(pos: pos, name: "extend", filename: @filename, source: self) + else + expect_token(:VALUE) + end + end - result - end -.,., + def at?(expected_token_name) + @token_name == expected_token_name + end -module_eval(<<'.,.,', 'parser.y', 419) - def _reduce_173(val, _values, result) - result = make_node(:InputObjectTypeDefinition, name: val[2], directives: val[3], fields: val[5], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) + def expect_token(expected_token_name) + unless @token_name == expected_token_name + raise_parse_error("Expected #{expected_token_name}, #{@token_name == false ? "not end of file" : "actual: #{@token_name} (#{debug_token_value.inspect})"}") + end + advance_token + end - result - end -.,., + def expect_one_of(token_names) + raise_parse_error("Expected one of #{token_names.join(", ")}, actual: #{token_name || "NOTHING"} (#{debug_token_value.inspect})") + end -module_eval(<<'.,.,', 'parser.y', 424) - def _reduce_174(val, _values, result) - result = make_node(:DirectiveDefinition, name: val[3], arguments: val[4], locations: val[6], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) + def raise_parse_error(message) + message += " at [#{@lexer.line_number}, #{@lexer.column_number}]" + raise GraphQL::ParseError.new( + message, + @lexer.line_number, + @lexer.column_number, + @graphql_str, + filename: @filename, + ) - result - end -.,., + end -module_eval(<<'.,.,', 'parser.y', 428) - def _reduce_175(val, _values, result) - result = [make_node(:DirectiveLocation, name: val[0].to_s, position_source: val[0])] - result - end -.,., + # Only use when we care about the expected token's value + def expect_token_value(tok) + token_value = @lexer.token_value + if @dedup_identifiers + token_value = -token_value + end + expect_token(tok) + token_value + end -module_eval(<<'.,.,', 'parser.y', 429) - def _reduce_176(val, _values, result) - val[0] << make_node(:DirectiveLocation, name: val[2].to_s, position_source: val[2]) - result + # token_value works for when the scanner matched something + # which is usually fine and it's good for it to be fast at that. + def debug_token_value + @lexer.debug_token_value(token_name) + end + class SchemaParser < Parser + def initialize(*args, **kwargs) + super + @dedup_identifiers = true + end + end + end end -.,., - -def _reduce_none(val, _values, result) - val[0] end - - end # class Parser - end # module Language -end # module GraphQL diff --git a/lib/graphql/language/parser.y b/lib/graphql/language/parser.y deleted file mode 100644 index 094dc98b806..00000000000 --- a/lib/graphql/language/parser.y +++ /dev/null @@ -1,543 +0,0 @@ -class GraphQL::Language::Parser -rule - target: document - - document: definitions_list { result = make_node(:Document, definitions: val[0])} - - definitions_list: - definition { result = [val[0]]} - | definitions_list definition { val[0] << val[1] } - - definition: - executable_definition - | type_system_definition - | type_system_extension - - executable_definition: - operation_definition - | fragment_definition - - operation_definition: - operation_type operation_name_opt variable_definitions_opt directives_list_opt selection_set { - result = make_node( - :OperationDefinition, { - operation_type: val[0], - name: val[1], - variables: val[2], - directives: val[3], - selections: val[4], - position_source: val[0], - } - ) - } - | LCURLY selection_list RCURLY { - result = make_node( - :OperationDefinition, { - operation_type: "query", - selections: val[1], - position_source: val[0], - } - ) - } - | LCURLY RCURLY { - result = make_node( - :OperationDefinition, { - operation_type: "query", - selections: [], - position_source: val[0], - } - ) - } - - operation_type: - QUERY - | MUTATION - | SUBSCRIPTION - - operation_name_opt: - /* none */ { result = nil } - | name - - variable_definitions_opt: - /* none */ { result = EMPTY_ARRAY } - | LPAREN variable_definitions_list RPAREN { result = val[1] } - - variable_definitions_list: - variable_definition { result = [val[0]] } - | variable_definitions_list variable_definition { val[0] << val[1] } - - variable_definition: - VAR_SIGN name COLON type default_value_opt { - result = make_node(:VariableDefinition, { - name: val[1], - type: val[3], - default_value: val[4], - position_source: val[0], - }) - } - - type: - nullable_type { result = val[0] } - | nullable_type BANG { result = make_node(:NonNullType, of_type: val[0]) } - - nullable_type: - name { result = make_node(:TypeName, name: val[0])} - | LBRACKET type RBRACKET { result = make_node(:ListType, of_type: val[1]) } - - default_value_opt: - /* none */ { result = nil } - | EQUALS literal_value { result = val[1] } - - selection_set: - LCURLY selection_list RCURLY { result = val[1] } - - selection_set_opt: - /* none */ { result = EMPTY_ARRAY } - | selection_set { result = val[0] } - - selection_list: - selection { result = [result] } - | selection_list selection { val[0] << val[1] } - - selection: - field - | fragment_spread - | inline_fragment - - field: - name arguments_opt directives_list_opt selection_set_opt { - result = make_node( - :Field, { - name: val[0], - arguments: val[1], - directives: val[2], - selections: val[3], - position_source: val[0], - } - ) - } - | name COLON name arguments_opt directives_list_opt selection_set_opt { - result = make_node( - :Field, { - alias: val[0], - name: val[2], - arguments: val[3], - directives: val[4], - selections: val[5], - position_source: val[0], - } - ) - } - - name: - name_without_on - | ON - - schema_keyword: - SCHEMA - | SCALAR - | TYPE - | IMPLEMENTS - | INTERFACE - | UNION - | ENUM - | INPUT - | DIRECTIVE - - name_without_on: - IDENTIFIER - | FRAGMENT - | TRUE - | FALSE - | operation_type - | schema_keyword - - enum_name: /* any identifier, but not "true", "false" or "null" */ - IDENTIFIER - | FRAGMENT - | ON - | operation_type - | schema_keyword - - enum_value_definition: - description_opt enum_name directives_list_opt { result = make_node(:EnumValueDefinition, name: val[1], directives: val[2], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) } - - enum_value_definitions: - enum_value_definition { result = [val[0]] } - | enum_value_definitions enum_value_definition { result = val[0] << val[1] } - - arguments_opt: - /* none */ { result = EMPTY_ARRAY } - | LPAREN arguments_list RPAREN { result = val[1] } - - arguments_list: - argument { result = [val[0]] } - | arguments_list argument { val[0] << val[1] } - - argument: - name COLON input_value { result = make_node(:Argument, name: val[0], value: val[2], position_source: val[0])} - - literal_value: - FLOAT { result = val[0].to_f } - | INT { result = val[0].to_i } - | STRING { result = val[0].to_s } - | TRUE { result = true } - | FALSE { result = false } - | null_value - | enum_value - | list_value - | object_literal_value - - input_value: - literal_value - | variable - | object_value - - null_value: NULL { result = make_node(:NullValue, name: val[0], position_source: val[0]) } - variable: VAR_SIGN name { result = make_node(:VariableIdentifier, name: val[1], position_source: val[0]) } - - list_value: - LBRACKET RBRACKET { result = EMPTY_ARRAY } - | LBRACKET list_value_list RBRACKET { result = val[1] } - - list_value_list: - input_value { result = [val[0]] } - | list_value_list input_value { val[0] << val[1] } - - object_value: - LCURLY RCURLY { result = make_node(:InputObject, arguments: [], position_source: val[0])} - | LCURLY object_value_list RCURLY { result = make_node(:InputObject, arguments: val[1], position_source: val[0])} - - object_value_list: - object_value_field { result = [val[0]] } - | object_value_list object_value_field { val[0] << val[1] } - - object_value_field: - name COLON input_value { result = make_node(:Argument, name: val[0], value: val[2], position_source: val[0])} - - /* like the previous, but with literals only: */ - object_literal_value: - LCURLY RCURLY { result = make_node(:InputObject, arguments: [], position_source: val[0])} - | LCURLY object_literal_value_list RCURLY { result = make_node(:InputObject, arguments: val[1], position_source: val[0])} - - object_literal_value_list: - object_literal_value_field { result = [val[0]] } - | object_literal_value_list object_literal_value_field { val[0] << val[1] } - - object_literal_value_field: - name COLON literal_value { result = make_node(:Argument, name: val[0], value: val[2], position_source: val[0])} - - enum_value: enum_name { result = make_node(:Enum, name: val[0], position_source: val[0]) } - - directives_list_opt: - /* none */ { result = EMPTY_ARRAY } - | directives_list - - directives_list: - directive { result = [val[0]] } - | directives_list directive { val[0] << val[1] } - - directive: DIR_SIGN name arguments_opt { result = make_node(:Directive, name: val[1], arguments: val[2], position_source: val[0]) } - - fragment_spread: - ELLIPSIS name_without_on directives_list_opt { result = make_node(:FragmentSpread, name: val[1], directives: val[2], position_source: val[0]) } - - inline_fragment: - ELLIPSIS ON type directives_list_opt selection_set { - result = make_node(:InlineFragment, { - type: val[2], - directives: val[3], - selections: val[4], - position_source: val[0] - }) - } - | ELLIPSIS directives_list_opt selection_set { - result = make_node(:InlineFragment, { - type: nil, - directives: val[1], - selections: val[2], - position_source: val[0] - }) - } - - fragment_definition: - FRAGMENT fragment_name_opt ON type directives_list_opt selection_set { - result = make_node(:FragmentDefinition, { - name: val[1], - type: val[3], - directives: val[4], - selections: val[5], - position_source: val[0], - } - ) - } - - fragment_name_opt: - /* none */ { result = nil } - | name_without_on - - type_system_definition: - schema_definition - | type_definition - | directive_definition - - schema_definition: - SCHEMA directives_list_opt LCURLY operation_type_definition_list RCURLY { result = make_node(:SchemaDefinition, position_source: val[0], definition_line: val[0].line, directives: val[1], **val[3]) } - - operation_type_definition_list: - operation_type_definition - | operation_type_definition_list operation_type_definition { result = val[0].merge(val[1]) } - - operation_type_definition: - operation_type COLON name { result = { val[0].to_s.to_sym => val[2] } } - - type_definition: - scalar_type_definition - | object_type_definition - | interface_type_definition - | union_type_definition - | enum_type_definition - | input_object_type_definition - - type_system_extension: - schema_extension - | type_extension - - schema_extension: - EXTEND SCHEMA directives_list_opt LCURLY operation_type_definition_list RCURLY { result = make_node(:SchemaExtension, position_source: val[0], directives: val[2], **val[4]) } - | EXTEND SCHEMA directives_list { result = make_node(:SchemaExtension, position_source: val[0], directives: val[2]) } - - type_extension: - scalar_type_extension - | object_type_extension - | interface_type_extension - | union_type_extension - | enum_type_extension - | input_object_type_extension - - scalar_type_extension: EXTEND SCALAR name directives_list { result = make_node(:ScalarTypeExtension, name: val[2], directives: val[3], position_source: val[0]) } - - object_type_extension: - /* TODO - This first one shouldn't be necessary but parser is getting confused */ - EXTEND TYPE name implements LCURLY field_definition_list RCURLY { result = make_node(:ObjectTypeExtension, name: val[2], interfaces: val[3], directives: [], fields: val[5], position_source: val[0]) } - | EXTEND TYPE name implements_opt directives_list_opt LCURLY field_definition_list RCURLY { result = make_node(:ObjectTypeExtension, name: val[2], interfaces: val[3], directives: val[4], fields: val[6], position_source: val[0]) } - | EXTEND TYPE name implements_opt directives_list { result = make_node(:ObjectTypeExtension, name: val[2], interfaces: val[3], directives: val[4], fields: [], position_source: val[0]) } - | EXTEND TYPE name implements { result = make_node(:ObjectTypeExtension, name: val[2], interfaces: val[3], directives: [], fields: [], position_source: val[0]) } - - interface_type_extension: - EXTEND INTERFACE name directives_list_opt LCURLY field_definition_list RCURLY { result = make_node(:InterfaceTypeExtension, name: val[2], directives: val[3], fields: val[5], position_source: val[0]) } - | EXTEND INTERFACE name directives_list { result = make_node(:InterfaceTypeExtension, name: val[2], directives: val[3], fields: [], position_source: val[0]) } - - union_type_extension: - EXTEND UNION name directives_list_opt EQUALS union_members { result = make_node(:UnionTypeExtension, name: val[2], directives: val[3], types: val[5], position_source: val[0]) } - | EXTEND UNION name directives_list { result = make_node(:UnionTypeExtension, name: val[2], directives: val[3], types: [], position_source: val[0]) } - - enum_type_extension: - EXTEND ENUM name directives_list_opt LCURLY enum_value_definitions RCURLY { result = make_node(:EnumTypeExtension, name: val[2], directives: val[3], values: val[5], position_source: val[0]) } - | EXTEND ENUM name directives_list { result = make_node(:EnumTypeExtension, name: val[2], directives: val[3], values: [], position_source: val[0]) } - - input_object_type_extension: - EXTEND INPUT name directives_list_opt LCURLY input_value_definition_list RCURLY { result = make_node(:InputObjectTypeExtension, name: val[2], directives: val[3], fields: val[5], position_source: val[0]) } - | EXTEND INPUT name directives_list { result = make_node(:InputObjectTypeExtension, name: val[2], directives: val[3], fields: [], position_source: val[0]) } - - description: STRING - - description_opt: - /* none */ - | description - - scalar_type_definition: - description_opt SCALAR name directives_list_opt { - result = make_node(:ScalarTypeDefinition, name: val[2], directives: val[3], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) - } - - object_type_definition: - description_opt TYPE name implements_opt directives_list_opt LCURLY field_definition_list RCURLY { - result = make_node(:ObjectTypeDefinition, name: val[2], interfaces: val[3], directives: val[4], fields: val[6], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) - } - - implements_opt: - /* none */ { result = EMPTY_ARRAY } - | implements - - implements: - IMPLEMENTS AMP interfaces_list { result = val[2] } - | IMPLEMENTS interfaces_list { result = val[1] } - | IMPLEMENTS legacy_interfaces_list { result = val[1] } - - interfaces_list: - name { result = [make_node(:TypeName, name: val[0], position_source: val[0])] } - | interfaces_list AMP name { val[0] << make_node(:TypeName, name: val[2], position_source: val[2]) } - - legacy_interfaces_list: - name { result = [make_node(:TypeName, name: val[0], position_source: val[0])] } - | legacy_interfaces_list name { val[0] << make_node(:TypeName, name: val[1], position_source: val[1]) } - - input_value_definition: - description_opt name COLON type default_value_opt directives_list_opt { - result = make_node(:InputValueDefinition, name: val[1], type: val[3], default_value: val[4], directives: val[5], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) - } - - input_value_definition_list: - input_value_definition { result = [val[0]] } - | input_value_definition_list input_value_definition { val[0] << val[1] } - - arguments_definitions_opt: - /* none */ { result = EMPTY_ARRAY } - | LPAREN input_value_definition_list RPAREN { result = val[1] } - - field_definition: - description_opt name arguments_definitions_opt COLON type directives_list_opt { - result = make_node(:FieldDefinition, name: val[1], arguments: val[2], type: val[4], directives: val[5], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) - } - - field_definition_list: - /* none */ { result = EMPTY_ARRAY } - | field_definition { result = [val[0]] } - | field_definition_list field_definition { val[0] << val[1] } - - interface_type_definition: - description_opt INTERFACE name directives_list_opt LCURLY field_definition_list RCURLY { - result = make_node(:InterfaceTypeDefinition, name: val[2], directives: val[3], fields: val[5], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) - } - - union_members: - name { result = [make_node(:TypeName, name: val[0], position_source: val[0])]} - | union_members PIPE name { val[0] << make_node(:TypeName, name: val[2], position_source: val[2]) } - - union_type_definition: - description_opt UNION name directives_list_opt EQUALS union_members { - result = make_node(:UnionTypeDefinition, name: val[2], directives: val[3], types: val[5], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) - } - - enum_type_definition: - description_opt ENUM name directives_list_opt LCURLY enum_value_definitions RCURLY { - result = make_node(:EnumTypeDefinition, name: val[2], directives: val[3], values: val[5], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) - } - - input_object_type_definition: - description_opt INPUT name directives_list_opt LCURLY input_value_definition_list RCURLY { - result = make_node(:InputObjectTypeDefinition, name: val[2], directives: val[3], fields: val[5], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) - } - - directive_definition: - description_opt DIRECTIVE DIR_SIGN name arguments_definitions_opt ON directive_locations { - result = make_node(:DirectiveDefinition, name: val[3], arguments: val[4], locations: val[6], description: val[0] || get_description(val[1]), definition_line: val[1].line, position_source: val[0] || val[1]) - } - - directive_locations: - name { result = [make_node(:DirectiveLocation, name: val[0].to_s, position_source: val[0])] } - | directive_locations PIPE name { val[0] << make_node(:DirectiveLocation, name: val[2].to_s, position_source: val[2]) } -end - ----- header ---- - - ----- inner ---- - -EMPTY_ARRAY = [].freeze - -def initialize(query_string, filename:, tracer: Tracing::NullTracer) - raise GraphQL::ParseError.new("No query string was present", nil, nil, query_string) if query_string.nil? - @query_string = query_string - @filename = filename - @tracer = tracer - @reused_next_token = [nil, nil] -end - -def parse_document - @document ||= begin - # Break the string into tokens - @tracer.trace("lex", {query_string: @query_string}) do - @tokens ||= GraphQL.scan(@query_string) - end - # From the tokens, build an AST - @tracer.trace("parse", {query_string: @query_string}) do - if @tokens.empty? - raise GraphQL::ParseError.new("Unexpected end of document", nil, nil, @query_string) - else - do_parse - end - end - end -end - -class << self - attr_accessor :cache - - def parse(query_string, filename: nil, tracer: GraphQL::Tracing::NullTracer) - new(query_string, filename: filename, tracer: tracer).parse_document - end - - def parse_file(filename, tracer: GraphQL::Tracing::NullTracer) - if cache - cache.fetch(filename) do - parse(File.read(filename), filename: filename, tracer: tracer) - end - else - parse(File.read(filename), filename: filename, tracer: tracer) - end - end -end - -private - -def next_token - lexer_token = @tokens.shift - if lexer_token.nil? - nil - else - @reused_next_token[0] = lexer_token.name - @reused_next_token[1] = lexer_token - @reused_next_token - end -end - -def get_description(token) - comments = [] - - loop do - prev_token = token - token = token.prev_token - - break if token.nil? - break if token.name != :COMMENT - break if prev_token.line != token.line + 1 - - comments.unshift(token.to_s.sub(/^#\s*/, "")) - end - - return nil if comments.empty? - - comments.join("\n") -end - -def on_error(parser_token_id, lexer_token, vstack) - if lexer_token == "$" || lexer_token == nil - raise GraphQL::ParseError.new("Unexpected end of document", nil, nil, @query_string, filename: @filename) - else - parser_token_name = token_to_str(parser_token_id) - if parser_token_name.nil? - raise GraphQL::ParseError.new("Parse Error on unknown token: {token_id: #{parser_token_id}, lexer_token: #{lexer_token}} from #{@query_string}", nil, nil, @query_string, filename: @filename) - else - line, col = lexer_token.line_and_column - if lexer_token.name == :BAD_UNICODE_ESCAPE - raise GraphQL::ParseError.new("Parse error on bad Unicode escape sequence: #{lexer_token.to_s.inspect} (#{parser_token_name}) at [#{line}, #{col}]", line, col, @query_string, filename: @filename) - else - raise GraphQL::ParseError.new("Parse error on #{lexer_token.to_s.inspect} (#{parser_token_name}) at [#{line}, #{col}]", line, col, @query_string, filename: @filename) - end - end - end -end - -def make_node(node_name, assigns) - assigns.each do |key, value| - if key != :position_source && value.is_a?(GraphQL::Language::Token) - assigns[key] = value.to_s - end - end - - assigns[:filename] = @filename - - GraphQL::Language::Nodes.const_get(node_name).new(assigns) -end diff --git a/lib/graphql/language/printer.rb b/lib/graphql/language/printer.rb index 0495e27de50..c861f33404d 100644 --- a/lib/graphql/language/printer.rb +++ b/lib/graphql/language/printer.rb @@ -2,6 +2,32 @@ module GraphQL module Language class Printer + OMISSION = "... (truncated)" + + class TruncatableBuffer + class TruncateSizeReached < StandardError; end + + DEFAULT_INIT_CAPACITY = 500 + + def initialize(truncate_size: nil) + @out = String.new(capacity: truncate_size || DEFAULT_INIT_CAPACITY) + @truncate_size = truncate_size + end + + def append(other) + if @truncate_size && (@out.size + other.size) > @truncate_size + @out << other.slice(0, @truncate_size - @out.size) + raise(TruncateSizeReached, "Truncate size reached") + else + @out << other + end + end + + def to_string + @out + end + end + # Turn an arbitrary AST node back into a string. # # @example Turning a document into a query string @@ -14,276 +40,440 @@ class Printer # # class MyPrinter < GraphQL::Language::Printer # def print_argument(arg) - # "#{arg.name}: " + # print_string("#{arg.name}: ") # end # end # # MyPrinter.new.print(document) # # => "mutation { pay(creditCard: ) { success } }" # - # + # @param node [Nodes::AbstractNode] # @param indent [String] Whitespace to add to the printed node + # @param truncate_size [Integer, nil] The size to truncate to. # @return [String] Valid GraphQL for `node` - def print(node, indent: "") + def print(node, indent: "", truncate_size: nil) + truncate_size = truncate_size ? [truncate_size - OMISSION.size, 0].max : nil + @out = TruncatableBuffer.new(truncate_size: truncate_size) print_node(node, indent: indent) + @out.to_string + rescue TruncatableBuffer::TruncateSizeReached + @out.to_string << OMISSION end protected + def print_string(str) + @out.append(str) + end + def print_document(document) - document.definitions.map { |d| print_node(d) }.join("\n\n") + document.definitions.each_with_index do |d, i| + print_node(d) + print_string("\n\n") if i < document.definitions.size - 1 + end end def print_argument(argument) - "#{argument.name}: #{print_node(argument.value)}".dup + print_string(argument.name) + print_string(": ") + print_node(argument.value) end - def print_directive(directive) - out = "@#{directive.name}".dup - - if directive.arguments.any? - out << "(#{directive.arguments.map { |a| print_argument(a) }.join(", ")})" + def print_input_object(input_object) + print_string("{") + input_object.arguments.each_with_index do |a, i| + print_argument(a) + print_string(", ") if i < input_object.arguments.size - 1 end + print_string("}") + end - out + def print_directive(directive) + print_string("@") + print_string(directive.name) + + if !directive.arguments.empty? + print_string("(") + directive.arguments.each_with_index do |a, i| + print_argument(a) + print_string(", ") if i < directive.arguments.size - 1 + end + print_string(")") + end end def print_enum(enum) - "#{enum.name}".dup + print_string(enum.name) end def print_null_value - "null".dup + print_string("null") end def print_field(field, indent: "") - out = "#{indent}".dup - out << "#{field.alias}: " if field.alias - out << "#{field.name}" - out << "(#{field.arguments.map { |a| print_argument(a) }.join(", ")})" if field.arguments.any? - out << print_directives(field.directives) - out << print_selections(field.selections, indent: indent) - out + print_string(indent) + if field.alias + print_string(field.alias) + print_string(": ") + end + print_string(field.name) + if !field.arguments.empty? + print_string("(") + field.arguments.each_with_index do |a, i| + print_argument(a) + print_string(", ") if i < field.arguments.size - 1 + end + print_string(")") + end + print_directives(field.directives) + print_selections(field.selections, indent: indent) end def print_fragment_definition(fragment_def, indent: "") - out = "#{indent}fragment #{fragment_def.name}".dup + print_string(indent) + print_string("fragment") + if fragment_def.name + print_string(" ") + print_string(fragment_def.name) + end + if fragment_def.type - out << " on #{print_node(fragment_def.type)}" + print_string(" on ") + print_node(fragment_def.type) end - out << print_directives(fragment_def.directives) - out << print_selections(fragment_def.selections, indent: indent) - out + print_directives(fragment_def.directives) + print_selections(fragment_def.selections, indent: indent) end def print_fragment_spread(fragment_spread, indent: "") - out = "#{indent}...#{fragment_spread.name}".dup - out << print_directives(fragment_spread.directives) - out + print_string(indent) + print_string("...") + print_string(fragment_spread.name) + print_directives(fragment_spread.directives) end def print_inline_fragment(inline_fragment, indent: "") - out = "#{indent}...".dup + print_string(indent) + print_string("...") if inline_fragment.type - out << " on #{print_node(inline_fragment.type)}" + print_string(" on ") + print_node(inline_fragment.type) end - out << print_directives(inline_fragment.directives) - out << print_selections(inline_fragment.selections, indent: indent) - out - end - - def print_input_object(input_object) - "{#{input_object.arguments.map { |a| print_argument(a) }.join(", ")}}" + print_directives(inline_fragment.directives) + print_selections(inline_fragment.selections, indent: indent) end def print_list_type(list_type) - "[#{print_node(list_type.of_type)}]".dup + print_string("[") + print_node(list_type.of_type) + print_string("]") end def print_non_null_type(non_null_type) - "#{print_node(non_null_type.of_type)}!".dup + print_node(non_null_type.of_type) + print_string("!") end def print_operation_definition(operation_definition, indent: "") - out = "#{indent}#{operation_definition.operation_type}".dup - out << " #{operation_definition.name}" if operation_definition.name + print_string(indent) + print_string(operation_definition.operation_type) + if operation_definition.name + print_string(" ") + print_string(operation_definition.name) + end - if operation_definition.variables.any? - out << "(#{operation_definition.variables.map { |v| print_variable_definition(v) }.join(", ")})" + if !operation_definition.variables.empty? + print_string("(") + operation_definition.variables.each_with_index do |v, i| + print_variable_definition(v) + print_string(", ") if i < operation_definition.variables.size - 1 + end + print_string(")") end - out << print_directives(operation_definition.directives) - out << print_selections(operation_definition.selections, indent: indent) - out + print_directives(operation_definition.directives) + print_selections(operation_definition.selections, indent: indent) end def print_type_name(type_name) - "#{type_name.name}".dup + print_string(type_name.name) end def print_variable_definition(variable_definition) - out = "$#{variable_definition.name}: #{print_node(variable_definition.type)}".dup - out << " = #{print_node(variable_definition.default_value)}" unless variable_definition.default_value.nil? - out + print_string("$") + print_string(variable_definition.name) + print_string(": ") + print_node(variable_definition.type) + unless variable_definition.default_value.nil? + print_string(" = ") + print_node(variable_definition.default_value) + end + variable_definition.directives.each do |dir| + print_string(" ") + print_directive(dir) + end end def print_variable_identifier(variable_identifier) - "$#{variable_identifier.name}".dup + print_string("$") + print_string(variable_identifier.name) end - def print_schema_definition(schema) - if (schema.query.nil? || schema.query == 'Query') && - (schema.mutation.nil? || schema.mutation == 'Mutation') && - (schema.subscription.nil? || schema.subscription == 'Subscription') && - (schema.directives.empty?) + def print_schema_definition(schema, extension: false) + has_conventional_names = (schema.query.nil? || schema.query == 'Query') && + (schema.mutation.nil? || schema.mutation == 'Mutation') && + (schema.subscription.nil? || schema.subscription == 'Subscription') + + if has_conventional_names && schema.directives.empty? return end - out = "schema".dup - if schema.directives.any? + extension ? print_string("extend schema") : print_string("schema") + + if !schema.directives.empty? schema.directives.each do |dir| - out << "\n " - out << print_node(dir) + print_string("\n ") + print_node(dir) + end + + if !has_conventional_names + print_string("\n") end - out << "\n{" - else - out << " {\n" end - out << " query: #{schema.query}\n" if schema.query - out << " mutation: #{schema.mutation}\n" if schema.mutation - out << " subscription: #{schema.subscription}\n" if schema.subscription - out << "}" + + if !has_conventional_names + if schema.directives.empty? + print_string(" ") + end + print_string("{\n") + print_string(" query: #{schema.query}\n") if schema.query + print_string(" mutation: #{schema.mutation}\n") if schema.mutation + print_string(" subscription: #{schema.subscription}\n") if schema.subscription + print_string("}") + end + end + + + def print_scalar_type_definition(scalar_type, extension: false) + extension ? print_string("extend ") : print_description_and_comment(scalar_type) + print_string("scalar ") + print_string(scalar_type.name) + print_directives(scalar_type.directives) end - def print_scalar_type_definition(scalar_type) - out = print_description(scalar_type) - out << "scalar #{scalar_type.name}" - out << print_directives(scalar_type.directives) + def print_object_type_definition(object_type, extension: false) + extension ? print_string("extend ") : print_description_and_comment(object_type) + print_string("type ") + print_string(object_type.name) + print_implements(object_type) unless object_type.interfaces.empty? + print_directives(object_type.directives) + print_field_definitions(object_type.fields) end - def print_object_type_definition(object_type) - out = print_description(object_type) - out << "type #{object_type.name}" - out << " implements " << object_type.interfaces.map(&:name).join(" & ") unless object_type.interfaces.empty? - out << print_directives(object_type.directives) - out << print_field_definitions(object_type.fields) + def print_implements(type) + print_string(" implements ") + i = 0 + type.interfaces.each do |int| + if i > 0 + print_string(" & ") + end + print_string(int.name) + i += 1 + end end def print_input_value_definition(input_value) - out = "#{input_value.name}: #{print_node(input_value.type)}".dup - out << " = #{print_node(input_value.default_value)}" unless input_value.default_value.nil? - out << print_directives(input_value.directives) + print_string(input_value.name) + print_string(": ") + print_node(input_value.type) + unless input_value.default_value.nil? + print_string(" = ") + print_node(input_value.default_value) + end + print_directives(input_value.directives) end def print_arguments(arguments, indent: "") - if arguments.all?{ |arg| !arg.description } - return "(#{arguments.map{ |arg| print_input_value_definition(arg) }.join(", ")})" + if arguments.all? { |arg| !arg.description && !arg.comment } + print_string("(") + arguments.each_with_index do |arg, i| + print_input_value_definition(arg) + print_string(", ") if i < arguments.size - 1 + end + print_string(")") + return end - out = "(\n".dup - out << arguments.map.with_index{ |arg, i| - "#{print_description(arg, indent: " " + indent, first_in_block: i == 0)} #{indent}"\ - "#{print_input_value_definition(arg)}" - }.join("\n") - out << "\n#{indent})" + print_string("(\n") + arguments.each_with_index do |arg, i| + print_comment(arg, indent: " " + indent, first_in_block: i == 0) + print_description(arg, indent: " " + indent, first_in_block: i == 0) + print_string(" ") + print_string(indent) + print_input_value_definition(arg) + print_string("\n") if i < arguments.size - 1 + end + print_string("\n") + print_string(indent) + print_string(")") end def print_field_definition(field) - out = field.name.dup + print_string(field.name) unless field.arguments.empty? - out << print_arguments(field.arguments, indent: " ") + print_arguments(field.arguments, indent: " ") + end + print_string(": ") + print_node(field.type) + print_directives(field.directives) + end + + def print_interface_type_definition(interface_type, extension: false) + extension ? print_string("extend ") : print_description_and_comment(interface_type) + print_string("interface ") + print_string(interface_type.name) + print_implements(interface_type) if !interface_type.interfaces.empty? + print_directives(interface_type.directives) + print_field_definitions(interface_type.fields) + end + + def print_union_type_definition(union_type, extension: false) + extension ? print_string("extend ") : print_description_and_comment(union_type) + print_string("union ") + print_string(union_type.name) + print_directives(union_type.directives) + if !union_type.types.empty? + print_string(" = ") + i = 0 + union_type.types.each do |t| + if i > 0 + print_string(" | ") + end + print_string(t.name) + i += 1 + end end - out << ": #{print_node(field.type)}" - out << print_directives(field.directives) - end - - def print_interface_type_definition(interface_type) - out = print_description(interface_type) - out << "interface #{interface_type.name}" - out << print_directives(interface_type.directives) - out << print_field_definitions(interface_type.fields) - end - - def print_union_type_definition(union_type) - out = print_description(union_type) - out << "union #{union_type.name}" - out << print_directives(union_type.directives) - out << " = " + union_type.types.map(&:name).join(" | ") end - def print_enum_type_definition(enum_type) - out = print_description(enum_type) - out << "enum #{enum_type.name}#{print_directives(enum_type.directives)} {\n" - enum_type.values.each.with_index do |value, i| - out << print_description(value, indent: ' ', first_in_block: i == 0) - out << print_enum_value_definition(value) + def print_enum_type_definition(enum_type, extension: false) + extension ? print_string("extend ") : print_description_and_comment(enum_type) + print_string("enum ") + print_string(enum_type.name) + print_directives(enum_type.directives) + if !enum_type.values.empty? + print_string(" {\n") + enum_type.values.each.with_index do |value, i| + print_description(value, indent: " ", first_in_block: i == 0) + print_comment(value, indent: " ", first_in_block: i == 0) + print_enum_value_definition(value) + end + print_string("}") end - out << "}" end def print_enum_value_definition(enum_value) - out = " #{enum_value.name}".dup - out << print_directives(enum_value.directives) - out << "\n" - end - - def print_input_object_type_definition(input_object_type) - out = print_description(input_object_type) - out << "input #{input_object_type.name}" - out << print_directives(input_object_type.directives) - out << " {\n" - input_object_type.fields.each.with_index do |field, i| - out << print_description(field, indent: ' ', first_in_block: i == 0) - out << " #{print_input_value_definition(field)}\n" + print_string(" ") + print_string(enum_value.name) + print_directives(enum_value.directives) + print_string("\n") + end + + def print_input_object_type_definition(input_object_type, extension: false) + extension ? print_string("extend ") : print_description_and_comment(input_object_type) + print_string("input ") + print_string(input_object_type.name) + print_directives(input_object_type.directives) + if !input_object_type.fields.empty? + print_string(" {\n") + input_object_type.fields.each.with_index do |field, i| + print_description(field, indent: " ", first_in_block: i == 0) + print_comment(field, indent: " ", first_in_block: i == 0) + print_string(" ") + print_input_value_definition(field) + print_string("\n") + end + print_string("}") end - out << "}" end def print_directive_definition(directive) - out = print_description(directive) - out << "directive @#{directive.name}" + print_description(directive) + print_string("directive @") + print_string(directive.name) + + if !directive.arguments.empty? + print_arguments(directive.arguments) + end - if directive.arguments.any? - out << print_arguments(directive.arguments) + if directive.repeatable + print_string(" repeatable") end - out << " on #{directive.locations.map(&:name).join(' | ')}" + print_string(" on ") + i = 0 + directive.locations.each do |loc| + if i > 0 + print_string(" | ") + end + print_string(loc.name) + i += 1 + end end def print_description(node, indent: "", first_in_block: true) - return ''.dup unless node.description + return unless node.description + + print_string("\n") if indent != "" && !first_in_block + print_string(GraphQL::Language::BlockString.print(node.description, indent: indent)) + end + + def print_comment(node, indent: "", first_in_block: true) + return unless node.comment + + print_string("\n") if indent != "" && !first_in_block + print_string(GraphQL::Language::Comment.print(node.comment, indent: indent)) + end - description = indent != '' && !first_in_block ? "\n".dup : "".dup - description << GraphQL::Language::BlockString.print(node.description, indent: indent) + def print_description_and_comment(node) + print_description(node) + print_comment(node) end def print_field_definitions(fields) - out = " {\n".dup - fields.each.with_index do |field, i| - out << print_description(field, indent: ' ', first_in_block: i == 0) - out << " #{print_field_definition(field)}\n" + return if fields.empty? + + print_string(" {\n") + i = 0 + fields.each do |field| + print_description(field, indent: " ", first_in_block: i == 0) + print_comment(field, indent: " ", first_in_block: i == 0) + print_string(" ") + print_field_definition(field) + print_string("\n") + i += 1 end - out << "}" + print_string("}") end def print_directives(directives) - if directives.any? - directives.map { |d| " #{print_directive(d)}" }.join - else - "" + return if directives.empty? + + directives.each do |d| + print_string(" ") + print_directive(d) end end def print_selections(selections, indent: "") - if selections.any? - out = " {\n".dup - selections.each do |selection| - out << print_node(selection, indent: indent + " ") << "\n" - end - out << "#{indent}}" - else - "" + return if selections.empty? + + print_string(" {\n") + selections.each do |selection| + print_node(selection, indent: indent + " ") + print_string("\n") end + print_string(indent) + print_string("}") end def print_node(node, indent: "") @@ -322,40 +512,62 @@ def print_node(node, indent: "") print_variable_identifier(node) when Nodes::SchemaDefinition print_schema_definition(node) + when Nodes::SchemaExtension + print_schema_definition(node, extension: true) when Nodes::ScalarTypeDefinition print_scalar_type_definition(node) + when Nodes::ScalarTypeExtension + print_scalar_type_definition(node, extension: true) when Nodes::ObjectTypeDefinition print_object_type_definition(node) + when Nodes::ObjectTypeExtension + print_object_type_definition(node, extension: true) when Nodes::InputValueDefinition print_input_value_definition(node) when Nodes::FieldDefinition print_field_definition(node) when Nodes::InterfaceTypeDefinition print_interface_type_definition(node) + when Nodes::InterfaceTypeExtension + print_interface_type_definition(node, extension: true) when Nodes::UnionTypeDefinition print_union_type_definition(node) + when Nodes::UnionTypeExtension + print_union_type_definition(node, extension: true) when Nodes::EnumTypeDefinition print_enum_type_definition(node) + when Nodes::EnumTypeExtension + print_enum_type_definition(node, extension: true) when Nodes::EnumValueDefinition print_enum_value_definition(node) when Nodes::InputObjectTypeDefinition print_input_object_type_definition(node) + when Nodes::InputObjectTypeExtension + print_input_object_type_definition(node, extension: true) when Nodes::DirectiveDefinition print_directive_definition(node) when FalseClass, Float, Integer, NilClass, String, TrueClass, Symbol - GraphQL::Language.serialize(node) + print_string(GraphQL::Language.serialize(node)) when Array - "[#{node.map { |v| print_node(v) }.join(", ")}]".dup + print_string("[") + node.each_with_index do |v, i| + print_node(v) + print_string(", ") if i < node.length - 1 + end + print_string("]") when Hash - "{#{node.map { |k, v| "#{k}: #{print_node(v)}" }.join(", ")}}".dup + print_string("{") + node.each_with_index do |(k, v), i| + print_string(k) + print_string(": ") + print_node(v) + print_string(", ") if i < node.length - 1 + end + print_string("}") else - GraphQL::Language.serialize(node.to_s) + print_string(GraphQL::Language.serialize(node.to_s)) end end - - private - - attr_reader :node end end end diff --git a/lib/graphql/language/sanitized_printer.rb b/lib/graphql/language/sanitized_printer.rb index 406b01cb0e4..82d576ff63d 100644 --- a/lib/graphql/language/sanitized_printer.rb +++ b/lib/graphql/language/sanitized_printer.rb @@ -40,7 +40,7 @@ def print_node(node, indent: "") case node when FalseClass, Float, Integer, String, TrueClass if @current_argument && redact_argument_value?(@current_argument, node) - redacted_argument_value(@current_argument) + print_string(redacted_argument_value(@current_argument)) else super end @@ -51,9 +51,8 @@ def print_node(node, indent: "") @current_input_type = @current_input_type.of_type if @current_input_type.non_null? end - res = super + super @current_input_type = old_input_type - res else super end @@ -79,7 +78,7 @@ def print_argument(argument) arg_owner = @current_input_type || @current_directive || @current_field old_current_argument = @current_argument - @current_argument = arg_owner.arguments[argument.name] + @current_argument = arg_owner.get_argument(argument.name, @query.context) old_input_type = @current_input_type @current_input_type = @current_argument.type.non_null? ? @current_argument.type.of_type : @current_argument.type @@ -89,11 +88,12 @@ def print_argument(argument) else argument.value end - res = "#{argument.name}: #{print_node(argument_value)}".dup + + print_string("#{argument.name}: ") + print_node(argument_value) @current_input_type = old_input_type @current_argument = old_current_argument - res end def coerce_argument_value_to_list?(type, value) @@ -113,46 +113,40 @@ def print_variable_identifier(variable_id) end def print_field(field, indent: "") - @current_field = query.schema.get_field(@current_type, field.name) + @current_field = query.types.field(@current_type, field.name) old_type = @current_type @current_type = @current_field.type.unwrap - res = super + super @current_type = old_type - res end def print_inline_fragment(inline_fragment, indent: "") old_type = @current_type if inline_fragment.type - @current_type = query.schema.types[inline_fragment.type.name] + @current_type = query.get_type(inline_fragment.type.name) end - res = super + super @current_type = old_type - - res end def print_fragment_definition(fragment_def, indent: "") old_type = @current_type - @current_type = query.schema.types[fragment_def.type.name] + @current_type = query.get_type(fragment_def.type.name) - res = super + super @current_type = old_type - - res end def print_directive(directive) @current_directive = query.schema.directives[directive.name] - res = super + super @current_directive = nil - res end # Print the operation definition but do not include the variable @@ -162,16 +156,15 @@ def print_operation_definition(operation_definition, indent: "") @current_type = query.schema.public_send(operation_definition.operation_type) if @inline_variables - out = "#{indent}#{operation_definition.operation_type}".dup - out << " #{operation_definition.name}" if operation_definition.name - out << print_directives(operation_definition.directives) - out << print_selections(operation_definition.selections, indent: indent) + print_string("#{indent}#{operation_definition.operation_type}") + print_string(" #{operation_definition.name}") if operation_definition.name + print_directives(operation_definition.directives) + print_selections(operation_definition.selections, indent: indent) else - out = super + super end @current_type = old_type - out end private @@ -193,7 +186,7 @@ def value_to_ast(value, type) end arguments = value.map do |key, val| - sub_type = type.arguments[key.to_s].type + sub_type = type.get_argument(key.to_s, @query.context).type GraphQL::Language::Nodes::Argument.new( name: key.to_s, @@ -210,7 +203,12 @@ def value_to_ast(value, type) [value].map { |v| value_to_ast(v, type.of_type) } end when "ENUM" - GraphQL::Language::Nodes::Enum.new(name: value) + if value.is_a?(GraphQL::Language::Nodes::Enum) + # if it was a default value, it's already wrapped + value + else + GraphQL::Language::Nodes::Enum.new(name: value) + end else value end diff --git a/lib/graphql/language/static_visitor.rb b/lib/graphql/language/static_visitor.rb new file mode 100644 index 00000000000..6a7dfcbf425 --- /dev/null +++ b/lib/graphql/language/static_visitor.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true +module GraphQL + module Language + # Like `GraphQL::Language::Visitor` except it doesn't support + # making changes to the document -- only visiting it as-is. + class StaticVisitor + def initialize(document) + @document = document + end + + # Visit `document` and all children + # @return [void] + def visit + # `@document` may be any kind of node: + visit_method = @document.visit_method + result = public_send(visit_method, @document, nil) + @result = if result.is_a?(Array) + result.first + else + # The node wasn't modified + @document + end + end + + def on_document_children(document_node) + document_node.children.each do |child_node| + visit_method = child_node.visit_method + public_send(visit_method, child_node, document_node) + end + end + + def on_field_children(new_node) + new_node.arguments.each do |arg_node| # rubocop:disable Development/ContextIsPassedCop + on_argument(arg_node, new_node) + end + visit_directives(new_node) + visit_selections(new_node) + end + + def visit_directives(new_node) + new_node.directives.each do |dir_node| + on_directive(dir_node, new_node) + end + end + + def visit_selections(new_node) + new_node.selections.each do |selection| + case selection + when GraphQL::Language::Nodes::Field + on_field(selection, new_node) + when GraphQL::Language::Nodes::InlineFragment + on_inline_fragment(selection, new_node) + when GraphQL::Language::Nodes::FragmentSpread + on_fragment_spread(selection, new_node) + else + raise ArgumentError, "Invariant: unexpected field selection #{selection.class} (#{selection.inspect})" + end + end + end + + def on_fragment_definition_children(new_node) + visit_directives(new_node) + visit_selections(new_node) + end + + alias :on_inline_fragment_children :on_fragment_definition_children + + def on_operation_definition_children(new_node) + new_node.variables.each do |arg_node| + on_variable_definition(arg_node, new_node) + end + visit_directives(new_node) + visit_selections(new_node) + end + + def on_argument_children(new_node) + new_node.children.each do |value_node| + case value_node + when Language::Nodes::VariableIdentifier + on_variable_identifier(value_node, new_node) + when Language::Nodes::InputObject + on_input_object(value_node, new_node) + when Language::Nodes::Enum + on_enum(value_node, new_node) + when Language::Nodes::NullValue + on_null_value(value_node, new_node) + else + raise ArgumentError, "Invariant: unexpected argument value node #{value_node.class} (#{value_node.inspect})" + end + end + end + + # rubocop:disable Development/NoEvalCop This eval takes static inputs at load-time + + # We don't use `alias` here because it breaks `super` + def self.make_visit_methods(ast_node_class) + node_method = ast_node_class.visit_method + children_of_type = ast_node_class.children_of_type + child_visit_method = :"#{node_method}_children" + + class_eval(<<-RUBY, __FILE__, __LINE__ + 1) + # The default implementation for visiting an AST node. + # It doesn't _do_ anything, but it continues to visiting the node's children. + # To customize this hook, override one of its make_visit_methods (or the base method?) + # in your subclasses. + # + # @param node [GraphQL::Language::Nodes::AbstractNode] the node being visited + # @param parent [GraphQL::Language::Nodes::AbstractNode, nil] the previously-visited node, or `nil` if this is the root node. + # @return [void] + def #{node_method}(node, parent) + #{ + if method_defined?(child_visit_method) + "#{child_visit_method}(node)" + elsif children_of_type + children_of_type.map do |child_accessor, child_class| + "node.#{child_accessor}.each do |child_node| + #{child_class.visit_method}(child_node, node) + end" + end.join("\n") + else + "" + end + } + end + RUBY + end + + [ + Language::Nodes::Argument, + Language::Nodes::Directive, + Language::Nodes::DirectiveDefinition, + Language::Nodes::DirectiveLocation, + Language::Nodes::Document, + Language::Nodes::Enum, + Language::Nodes::EnumTypeDefinition, + Language::Nodes::EnumTypeExtension, + Language::Nodes::EnumValueDefinition, + Language::Nodes::Field, + Language::Nodes::FieldDefinition, + Language::Nodes::FragmentDefinition, + Language::Nodes::FragmentSpread, + Language::Nodes::InlineFragment, + Language::Nodes::InputObject, + Language::Nodes::InputObjectTypeDefinition, + Language::Nodes::InputObjectTypeExtension, + Language::Nodes::InputValueDefinition, + Language::Nodes::InterfaceTypeDefinition, + Language::Nodes::InterfaceTypeExtension, + Language::Nodes::ListType, + Language::Nodes::NonNullType, + Language::Nodes::NullValue, + Language::Nodes::ObjectTypeDefinition, + Language::Nodes::ObjectTypeExtension, + Language::Nodes::OperationDefinition, + Language::Nodes::ScalarTypeDefinition, + Language::Nodes::ScalarTypeExtension, + Language::Nodes::SchemaDefinition, + Language::Nodes::SchemaExtension, + Language::Nodes::TypeName, + Language::Nodes::UnionTypeDefinition, + Language::Nodes::UnionTypeExtension, + Language::Nodes::VariableDefinition, + Language::Nodes::VariableIdentifier, + ].each do |ast_node_class| + make_visit_methods(ast_node_class) + end + + # rubocop:disable Development/NoEvalCop + end + end +end diff --git a/lib/graphql/language/token.rb b/lib/graphql/language/token.rb deleted file mode 100644 index 923c7e2a454..00000000000 --- a/lib/graphql/language/token.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Language - # Emitted by the lexer and passed to the parser. - # Contains type, value and position data. - class Token - if !String.method_defined?(:-@) - using GraphQL::StringDedupBackport - end - - # @return [Symbol] The kind of token this is - attr_reader :name - # @return [String] The text of this token - attr_reader :value - attr_reader :prev_token, :line, :col - - def initialize(name, value, line, col, prev_token) - @name = name - @value = -value - @line = line - @col = col - @prev_token = prev_token - end - - alias to_s value - def to_i; @value.to_i; end - def to_f; @value.to_f; end - - def line_and_column - [@line, @col] - end - - def inspect - "(#{@name} #{@value.inspect} [#{@line}:#{@col}])" - end - end - end -end diff --git a/lib/graphql/language/visitor.rb b/lib/graphql/language/visitor.rb index 1dfb17d69b3..e2e842fb012 100644 --- a/lib/graphql/language/visitor.rb +++ b/lib/graphql/language/visitor.rb @@ -30,12 +30,9 @@ module Language # # Check the result # visitor.count # # => 3 + # + # @see GraphQL::Language::StaticVisitor for a faster visitor that doesn't support modifying the document class Visitor - # If any hook returns this value, the {Visitor} stops visiting this - # node right away - # @deprecated Use `super` to continue the visit; or don't call it to halt. - SKIP = :_skip - class DeleteNode; end # When this is returned from a visitor method, @@ -44,28 +41,18 @@ class DeleteNode; end def initialize(document) @document = document - @visitors = {} @result = nil end # @return [GraphQL::Language::Nodes::Document] The document with any modifications applied attr_reader :result - # Get a {NodeVisitor} for `node_class` - # @param node_class [Class] The node class that you want to listen to - # @return [NodeVisitor] - # - # @example Run a hook whenever you enter a new Field - # visitor[GraphQL::Language::Nodes::Field] << ->(node, parent) { p "Here's a field" } - # @deprecated see `on_` methods, like {#on_field} - def [](node_class) - @visitors[node_class] ||= NodeVisitor.new - end - - # Visit `document` and all children, applying hooks as you go + # Visit `document` and all children # @return [void] def visit - result = on_node_with_modifications(@document, nil) + # `@document` may be any kind of node: + visit_method = :"#{@document.visit_method}_with_modifications" + result = public_send(visit_method, @document, nil) @result = if result.is_a?(Array) result.first else @@ -74,104 +61,208 @@ def visit end end - # Call the user-defined handler for `node`. - def visit_node(node, parent) - public_send(node.visit_method, node, parent) + def on_document_children(document_node) + new_node = document_node + document_node.children.each do |child_node| + visit_method = :"#{child_node.visit_method}_with_modifications" + new_child_and_node = public_send(visit_method, child_node, new_node) + # Reassign `node` in case the child hook makes a modification + if new_child_and_node.is_a?(Array) + new_node = new_child_and_node[1] + end + end + new_node end - # The default implementation for visiting an AST node. - # It doesn't _do_ anything, but it continues to visiting the node's children. - # To customize this hook, override one of its make_visit_methodes (or the base method?) - # in your subclasses. - # - # For compatibility, it calls hook procs, too. - # @param node [GraphQL::Language::Nodes::AbstractNode] the node being visited - # @param parent [GraphQL::Language::Nodes::AbstractNode, nil] the previously-visited node, or `nil` if this is the root node. - # @return [Array, nil] If there were modifications, it returns an array of new nodes, otherwise, it returns `nil`. - def on_abstract_node(node, parent) - if node.equal?(DELETE_NODE) - # This might be passed to `super(DELETE_NODE, ...)` - # by a user hook, don't want to keep visiting in that case. - nil - else - # Run hooks if there are any - new_node = node - no_hooks = !@visitors.key?(node.class) - if no_hooks || begin_visit(new_node, parent) - node.children.each do |child_node| - new_child_and_node = on_node_with_modifications(child_node, new_node) - # Reassign `node` in case the child hook makes a modification - if new_child_and_node.is_a?(Array) - new_node = new_child_and_node[1] - end - end + def on_field_children(new_node) + new_node.arguments.each do |arg_node| # rubocop:disable Development/ContextIsPassedCop + new_child_and_node = on_argument_with_modifications(arg_node, new_node) + # Reassign `node` in case the child hook makes a modification + if new_child_and_node.is_a?(Array) + new_node = new_child_and_node[1] end - end_visit(new_node, parent) unless no_hooks + end + new_node = visit_directives(new_node) + new_node = visit_selections(new_node) + new_node + end - if new_node.equal?(node) - nil + def visit_directives(new_node) + new_node.directives.each do |dir_node| + new_child_and_node = on_directive_with_modifications(dir_node, new_node) + # Reassign `node` in case the child hook makes a modification + if new_child_and_node.is_a?(Array) + new_node = new_child_and_node[1] + end + end + new_node + end + + def visit_selections(new_node) + new_node.selections.each do |selection| + new_child_and_node = case selection + when GraphQL::Language::Nodes::Field + on_field_with_modifications(selection, new_node) + when GraphQL::Language::Nodes::InlineFragment + on_inline_fragment_with_modifications(selection, new_node) + when GraphQL::Language::Nodes::FragmentSpread + on_fragment_spread_with_modifications(selection, new_node) else - [new_node, parent] + raise ArgumentError, "Invariant: unexpected field selection #{selection.class} (#{selection.inspect})" + end + # Reassign `node` in case the child hook makes a modification + if new_child_and_node.is_a?(Array) + new_node = new_child_and_node[1] end end + new_node end + def on_fragment_definition_children(new_node) + new_node = visit_directives(new_node) + new_node = visit_selections(new_node) + new_node + end + + alias :on_inline_fragment_children :on_fragment_definition_children + + def on_operation_definition_children(new_node) + new_node.variables.each do |arg_node| + new_child_and_node = on_variable_definition_with_modifications(arg_node, new_node) + # Reassign `node` in case the child hook makes a modification + if new_child_and_node.is_a?(Array) + new_node = new_child_and_node[1] + end + end + new_node = visit_directives(new_node) + new_node = visit_selections(new_node) + new_node + end + + def on_argument_children(new_node) + new_node.children.each do |value_node| + new_child_and_node = case value_node + when Language::Nodes::VariableIdentifier + on_variable_identifier_with_modifications(value_node, new_node) + when Language::Nodes::InputObject + on_input_object_with_modifications(value_node, new_node) + when Language::Nodes::Enum + on_enum_with_modifications(value_node, new_node) + when Language::Nodes::NullValue + on_null_value_with_modifications(value_node, new_node) + else + raise ArgumentError, "Invariant: unexpected argument value node #{value_node.class} (#{value_node.inspect})" + end + # Reassign `node` in case the child hook makes a modification + if new_child_and_node.is_a?(Array) + new_node = new_child_and_node[1] + end + end + new_node + end + + # rubocop:disable Development/NoEvalCop This eval takes static inputs at load-time + # We don't use `alias` here because it breaks `super` - def self.make_visit_method(node_method) + def self.make_visit_methods(ast_node_class) + node_method = ast_node_class.visit_method + children_of_type = ast_node_class.children_of_type + child_visit_method = :"#{node_method}_children" + class_eval(<<-RUBY, __FILE__, __LINE__ + 1) + # The default implementation for visiting an AST node. + # It doesn't _do_ anything, but it continues to visiting the node's children. + # To customize this hook, override one of its make_visit_methods (or the base method?) + # in your subclasses. + # + # @param node [GraphQL::Language::Nodes::AbstractNode] the node being visited + # @param parent [GraphQL::Language::Nodes::AbstractNode, nil] the previously-visited node, or `nil` if this is the root node. + # @return [Array, nil] If there were modifications, it returns an array of new nodes, otherwise, it returns `nil`. def #{node_method}(node, parent) - child_mod = on_abstract_node(node, parent) - # If visiting the children returned changes, continue passing those. - child_mod || [node, parent] + if node.equal?(DELETE_NODE) + # This might be passed to `super(DELETE_NODE, ...)` + # by a user hook, don't want to keep visiting in that case. + [node, parent] + else + new_node = node + #{ + if method_defined?(child_visit_method) + "new_node = #{child_visit_method}(new_node)" + elsif children_of_type + children_of_type.map do |child_accessor, child_class| + "node.#{child_accessor}.each do |child_node| + new_child_and_node = #{child_class.visit_method}_with_modifications(child_node, new_node) + # Reassign `node` in case the child hook makes a modification + if new_child_and_node.is_a?(Array) + new_node = new_child_and_node[1] + end + end" + end.join("\n") + else + "" + end + } + + if new_node.equal?(node) + [node, parent] + else + [new_node, parent] + end + end + end + + def #{node_method}_with_modifications(node, parent) + new_node_and_new_parent = #{node_method}(node, parent) + apply_modifications(node, parent, new_node_and_new_parent) end RUBY end - make_visit_method :on_argument - make_visit_method :on_directive - make_visit_method :on_directive_definition - make_visit_method :on_directive_location - make_visit_method :on_document - make_visit_method :on_enum - make_visit_method :on_enum_type_definition - make_visit_method :on_enum_type_extension - make_visit_method :on_enum_value_definition - make_visit_method :on_field - make_visit_method :on_field_definition - make_visit_method :on_fragment_definition - make_visit_method :on_fragment_spread - make_visit_method :on_inline_fragment - make_visit_method :on_input_object - make_visit_method :on_input_object_type_definition - make_visit_method :on_input_object_type_extension - make_visit_method :on_input_value_definition - make_visit_method :on_interface_type_definition - make_visit_method :on_interface_type_extension - make_visit_method :on_list_type - make_visit_method :on_non_null_type - make_visit_method :on_null_value - make_visit_method :on_object_type_definition - make_visit_method :on_object_type_extension - make_visit_method :on_operation_definition - make_visit_method :on_scalar_type_definition - make_visit_method :on_scalar_type_extension - make_visit_method :on_schema_definition - make_visit_method :on_schema_extension - make_visit_method :on_type_name - make_visit_method :on_union_type_definition - make_visit_method :on_union_type_extension - make_visit_method :on_variable_definition - make_visit_method :on_variable_identifier + [ + Language::Nodes::Argument, + Language::Nodes::Directive, + Language::Nodes::DirectiveDefinition, + Language::Nodes::DirectiveLocation, + Language::Nodes::Document, + Language::Nodes::Enum, + Language::Nodes::EnumTypeDefinition, + Language::Nodes::EnumTypeExtension, + Language::Nodes::EnumValueDefinition, + Language::Nodes::Field, + Language::Nodes::FieldDefinition, + Language::Nodes::FragmentDefinition, + Language::Nodes::FragmentSpread, + Language::Nodes::InlineFragment, + Language::Nodes::InputObject, + Language::Nodes::InputObjectTypeDefinition, + Language::Nodes::InputObjectTypeExtension, + Language::Nodes::InputValueDefinition, + Language::Nodes::InterfaceTypeDefinition, + Language::Nodes::InterfaceTypeExtension, + Language::Nodes::ListType, + Language::Nodes::NonNullType, + Language::Nodes::NullValue, + Language::Nodes::ObjectTypeDefinition, + Language::Nodes::ObjectTypeExtension, + Language::Nodes::OperationDefinition, + Language::Nodes::ScalarTypeDefinition, + Language::Nodes::ScalarTypeExtension, + Language::Nodes::SchemaDefinition, + Language::Nodes::SchemaExtension, + Language::Nodes::TypeName, + Language::Nodes::UnionTypeDefinition, + Language::Nodes::UnionTypeExtension, + Language::Nodes::VariableDefinition, + Language::Nodes::VariableIdentifier, + ].each do |ast_node_class| + make_visit_methods(ast_node_class) + end + + # rubocop:enable Development/NoEvalCop private - # Run the hooks for `node`, and if the hooks return a copy of `node`, - # copy `parent` so that it contains the copy of that node as a child, - # then return the copies - # If a non-array value is returned, consuming functions should ignore - # said value - def on_node_with_modifications(node, parent) - new_node_and_new_parent = visit_node(node, parent) + def apply_modifications(node, parent, new_node_and_new_parent) if new_node_and_new_parent.is_a?(Array) new_node = new_node_and_new_parent[0] new_parent = new_node_and_new_parent[1] @@ -197,46 +288,6 @@ def on_node_with_modifications(node, parent) new_node_and_new_parent end end - - def begin_visit(node, parent) - node_visitor = self[node.class] - self.class.apply_hooks(node_visitor.enter, node, parent) - end - - # Should global `leave` visitors come first or last? - def end_visit(node, parent) - node_visitor = self[node.class] - self.class.apply_hooks(node_visitor.leave, node, parent) - end - - # If one of the visitors returns SKIP, stop visiting this node - def self.apply_hooks(hooks, node, parent) - hooks.each do |proc| - return false if proc.call(node, parent) == SKIP - end - true - end - - # Collect `enter` and `leave` hooks for classes in {GraphQL::Language::Nodes} - # - # Access {NodeVisitor}s via {GraphQL::Language::Visitor#[]} - class NodeVisitor - # @return [Array] Hooks to call when entering a node of this type - attr_reader :enter - # @return [Array] Hooks to call when leaving a node of this type - attr_reader :leave - - def initialize - @enter = [] - @leave = [] - end - - # Shorthand to add a hook to the {#enter} array - # @param hook [Proc] A hook to add - def <<(hook) - enter << hook - end - end end end end diff --git a/lib/graphql/list_type.rb b/lib/graphql/list_type.rb deleted file mode 100644 index bdd2acaae09..00000000000 --- a/lib/graphql/list_type.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # A list type modifies another type. - # - # List types can be created with the type helper (`types[InnerType]`) - # or {BaseType#to_list_type} (`InnerType.to_list_type`) - # - # For return types, it says that the returned value will be a list of the modified. - # - # @example A field which returns a list of items - # field :items, types[ItemType] - # # or - # field :items, ItemType.to_list_type - # - # For input types, it says that the incoming value will be a list of the modified type. - # - # @example A field which accepts a list of strings - # field :newNames do - # # ... - # argument :values, types[types.String] - # # or - # argument :values, types.String.to_list_type - # end - # - # Given a list type, you can always get the underlying type with {#unwrap}. - # - class ListType < GraphQL::BaseType - include GraphQL::BaseType::ModifiesAnotherType - attr_reader :of_type - def initialize(of_type:) - super() - @of_type = of_type - end - - def kind - GraphQL::TypeKinds::LIST - end - - def to_s - "[#{of_type.to_s}]" - end - alias_method :inspect, :to_s - alias :to_type_signature :to_s - - def coerce_result(value, ctx = nil) - if ctx.nil? - warn_deprecated_coerce("coerce_isolated_result") - ctx = GraphQL::Query::NullContext - end - ensure_array(value).map { |item| item.nil? ? nil : of_type.coerce_result(item, ctx) } - end - - def list? - true - end - - private - - def coerce_non_null_input(value, ctx) - ensure_array(value).map { |item| of_type.coerce_input(item, ctx) } - end - - def validate_non_null_input(value, ctx) - result = GraphQL::Query::InputValidationResult.new - - ensure_array(value).each_with_index do |item, index| - item_result = of_type.validate_input(item, ctx) - if !item_result.valid? - result.merge_result!(index, item_result) - end - end - - result - end - - def ensure_array(value) - value.is_a?(Array) ? value : [value] - end - end -end diff --git a/lib/graphql/load_application_object_failed_error.rb b/lib/graphql/load_application_object_failed_error.rb index b54d1eda113..6546d5ba45b 100644 --- a/lib/graphql/load_application_object_failed_error.rb +++ b/lib/graphql/load_application_object_failed_error.rb @@ -12,10 +12,14 @@ class LoadApplicationObjectFailedError < GraphQL::ExecutionError attr_reader :id # @return [Object] The value found with this ID attr_reader :object - def initialize(argument:, id:, object:) + # @return [GraphQL::Query::Context] + attr_reader :context + + def initialize(argument:, id:, object:, context:) @id = id @argument = argument @object = object + @context = context super("No object found for `#{argument.graphql_name}: #{id.inspect}`") end end diff --git a/lib/graphql/name_validator.rb b/lib/graphql/name_validator.rb index f58a791b5bc..a8584ffd967 100644 --- a/lib/graphql/name_validator.rb +++ b/lib/graphql/name_validator.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true module GraphQL class NameValidator - if !String.method_defined?(:match?) - using GraphQL::StringMatchBackport - end - VALID_NAME_REGEX = /^[_a-zA-Z][_a-zA-Z0-9]*$/ def self.validate!(name) diff --git a/lib/graphql/non_null_type.rb b/lib/graphql/non_null_type.rb deleted file mode 100644 index 03791e0a632..00000000000 --- a/lib/graphql/non_null_type.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # A non-null type modifies another type. - # - # Non-null types can be created with `!` (`InnerType!`) - # or {BaseType#to_non_null_type} (`InnerType.to_non_null_type`) - # - # For return types, it says that the returned value will _always_ be present. - # - # @example A field which _always_ returns an error - # field :items, !ItemType - # # or - # field :items, ItemType.to_non_null_type - # - # (If the application fails to return a value, {InvalidNullError} will be passed to {Schema#type_error}.) - # - # For input types, it says that the incoming value _must_ be provided by the query. - # - # @example A field which _requires_ a string input - # field :newNames do - # # ... - # argument :values, !types.String - # # or - # argument :values, types.String.to_non_null_type - # end - # - # (If a value isn't provided, {Query::VariableValidationError} will be raised). - # - # Given a non-null type, you can always get the underlying type with {#unwrap}. - # - class NonNullType < GraphQL::BaseType - include GraphQL::BaseType::ModifiesAnotherType - extend Forwardable - - attr_reader :of_type - def initialize(of_type:) - super() - @of_type = of_type - end - - def valid_input?(value, ctx) - validate_input(value, ctx).valid? - end - - def validate_input(value, ctx) - if value.nil? - result = GraphQL::Query::InputValidationResult.new - result.add_problem("Expected value to not be null") - result - else - of_type.validate_input(value, ctx) - end - end - - def_delegators :@of_type, :coerce_input, :coerce_result, :list? - - def kind - GraphQL::TypeKinds::NON_NULL - end - - def to_s - "#{of_type.to_s}!" - end - alias_method :inspect, :to_s - alias :to_type_signature :to_s - - def non_null? - true - end - end -end diff --git a/lib/graphql/object_type.rb b/lib/graphql/object_type.rb deleted file mode 100644 index eacdaa33d35..00000000000 --- a/lib/graphql/object_type.rb +++ /dev/null @@ -1,130 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # @api deprecated - class ObjectType < GraphQL::BaseType - extend Define::InstanceDefinable::DeprecatedDefine - - accepts_definitions :interfaces, :fields, :mutation, :relay_node_type, field: GraphQL::Define::AssignObjectField - accepts_definitions implements: ->(type, *interfaces, inherit: false) { type.implements(interfaces, inherit: inherit) } - - attr_accessor :fields, :mutation, :relay_node_type - ensure_defined(:fields, :mutation, :interfaces, :relay_node_type) - - # @!attribute fields - # @return [Hash GraphQL::Field>] Map String fieldnames to their {GraphQL::Field} implementations - - # @!attribute mutation - # @return [GraphQL::Relay::Mutation, nil] The mutation this object type was derived from, if it is an auto-generated payload type. - - def initialize - super - @fields = {} - @clean_inherited_fields = nil - @structural_interface_type_memberships = [] - @inherited_interface_type_memberships = [] - end - - def initialize_copy(other) - super - @structural_interface_type_memberships = other.structural_interface_type_memberships.dup - @inherited_interface_type_memberships = other.inherited_interface_type_memberships.dup - @fields = other.fields.dup - end - - # This method declares interfaces for this type AND inherits any field definitions - # @param new_interfaces [Array] interfaces that this type implements - # @deprecated Use `implements` instead of `interfaces`. - def interfaces=(new_interfaces) - @structural_interface_type_memberships = [] - @inherited_interface_type_memberships = [] - @clean_inherited_fields = nil - implements(new_interfaces, inherit: true) - end - - def interfaces(ctx = GraphQL::Query::NullContext) - ensure_defined - visible_ifaces = [] - unfiltered = ctx == GraphQL::Query::NullContext - [@structural_interface_type_memberships, @inherited_interface_type_memberships].each do |tms| - tms.each do |type_membership| - if unfiltered || type_membership.visible?(ctx) - # if this is derived from a class-based object, we have to - # get the `.graphql_definition` of the attached interface. - visible_ifaces << GraphQL::BaseType.resolve_related_type(type_membership.abstract_type) - end - end - end - - visible_ifaces - end - - def kind - GraphQL::TypeKinds::OBJECT - end - - # This fields doesnt have instrumenation applied - # @see [Schema#get_field] Get field with instrumentation - # @return [GraphQL::Field] The field definition for `field_name` (may be inherited from interfaces) - def get_field(field_name) - fields[field_name] || interface_fields[field_name] - end - - # These fields don't have instrumenation applied - # @see [Schema#get_fields] Get fields with instrumentation - # @return [Array] All fields, including ones inherited from interfaces - def all_fields - interface_fields.merge(self.fields).values - end - - # Declare that this object implements this interface. - # This declaration will be validated when the schema is defined. - # @param interfaces [Array] add a new interface that this type implements - # @param inherits [Boolean] If true, copy the interfaces' field definitions to this type - def implements(interfaces, inherit: false, **options) - if !interfaces.is_a?(Array) - raise ArgumentError, "`implements(interfaces)` must be an array, not #{interfaces.class} (#{interfaces})" - end - @clean_inherited_fields = nil - - type_memberships = inherit ? @inherited_interface_type_memberships : @structural_interface_type_memberships - interfaces.each do |iface| - iface = BaseType.resolve_related_type(iface) - if iface.is_a?(GraphQL::InterfaceType) - type_memberships << iface.type_membership_class.new(iface, self, **options) - end - end - end - - def resolve_type_proc - nil - end - - attr_writer :structural_interface_type_memberships - - protected - - attr_reader :structural_interface_type_memberships, :inherited_interface_type_memberships - - private - - def normalize_interfaces(ifaces) - ifaces.map { |i_type| GraphQL::BaseType.resolve_related_type(i_type) } - end - - def interface_fields - if @clean_inherited_fields - @clean_inherited_fields - else - ensure_defined - @clean_inherited_fields = {} - @inherited_interface_type_memberships.each do |type_membership| - iface = GraphQL::BaseType.resolve_related_type(type_membership.abstract_type) - if iface.is_a?(GraphQL::InterfaceType) - @clean_inherited_fields.merge!(iface.fields) - end - end - @clean_inherited_fields - end - end - end -end diff --git a/lib/graphql/pagination/active_record_relation_connection.rb b/lib/graphql/pagination/active_record_relation_connection.rb index e54a73795db..4c7d027d450 100644 --- a/lib/graphql/pagination/active_record_relation_connection.rb +++ b/lib/graphql/pagination/active_record_relation_connection.rb @@ -7,13 +7,10 @@ module Pagination class ActiveRecordRelationConnection < Pagination::RelationConnection private - def relation_larger_than(relation, size) - initial_offset = relation.offset_value || 0 - relation.offset(initial_offset + size).exists? - end - def relation_count(relation) - int_or_hash = if relation.respond_to?(:unscope) + int_or_hash = if already_loaded?(relation) + relation.size + elsif relation.respond_to?(:unscope) relation.unscope(:order).count(:all) else # Rails 3 @@ -28,11 +25,19 @@ def relation_count(relation) end def relation_limit(relation) - relation.limit_value + if relation.is_a?(Array) + nil + else + relation.limit_value + end end def relation_offset(relation) - relation.offset_value + if relation.is_a?(Array) + nil + else + relation.offset_value + end end def null_relation(relation) @@ -43,6 +48,30 @@ def null_relation(relation) relation.where("1=2") end end + + def set_limit(nodes, limit) + if already_loaded?(nodes) + nodes.take(limit) + else + super + end + end + + def set_offset(nodes, offset) + if already_loaded?(nodes) + # If the client sent a bogus cursor beyond the size of the relation, + # it might get `nil` from `#[...]`, so return an empty array in that case + nodes[offset..-1] || [] + else + super + end + end + + private + + def already_loaded?(relation) + relation.is_a?(Array) || relation.loaded? + end end end end diff --git a/lib/graphql/pagination/array_connection.rb b/lib/graphql/pagination/array_connection.rb index 94836873d84..4a4718c72fb 100644 --- a/lib/graphql/pagination/array_connection.rb +++ b/lib/graphql/pagination/array_connection.rb @@ -35,9 +35,11 @@ def index_from_cursor(cursor) def load_nodes @nodes ||= begin sliced_nodes = if before && after - items[index_from_cursor(after)..index_from_cursor(before)-1] || [] + end_idx = index_from_cursor(before) - 2 + end_idx < 0 ? [] : items[index_from_cursor(after)..end_idx] || [] elsif before - items[0..index_from_cursor(before)-2] || [] + end_idx = index_from_cursor(before) - 2 + end_idx < 0 ? [] : items[0..end_idx] || [] elsif after items[index_from_cursor(after)..-1] || [] else @@ -54,12 +56,12 @@ def load_nodes false end - @has_next_page = if first - # There are more items after these items - sliced_nodes.count > first - elsif before + @has_next_page = if before # The original array is longer than the `before` index index_from_cursor(before) < items.length + 1 + elsif first + # There are more items after these items + sliced_nodes.count > first else false end diff --git a/lib/graphql/pagination/connection.rb b/lib/graphql/pagination/connection.rb index 3ec9c416b6c..32ca4141096 100644 --- a/lib/graphql/pagination/connection.rb +++ b/lib/graphql/pagination/connection.rb @@ -19,7 +19,15 @@ class PaginationImplementationMissingError < GraphQL::Error attr_reader :items # @return [GraphQL::Query::Context] - attr_accessor :context + attr_reader :context + + def context=(new_ctx) + @context = new_ctx + if @was_authorized_by_scope_items.nil? + @was_authorized_by_scope_items = detect_was_authorized_by_scope_items + end + @context + end # @return [Object] the object this collection belongs to attr_accessor :parent @@ -56,8 +64,9 @@ def after # @param last [Integer, nil] Limit parameter from the client, if provided # @param before [String, nil] A cursor for pagination, if the client provided one. # @param arguments [Hash] The arguments to the field that returned the collection wrapped by this connection - # @param max_page_size [Integer, nil] A configured value to cap the result size. Applied as `first` if neither first or last are given. - def initialize(items, parent: nil, field: nil, context: nil, first: nil, after: nil, max_page_size: :not_given, last: nil, before: nil, edge_class: nil, arguments: nil) + # @param max_page_size [Integer, nil] A configured value to cap the result size. Applied as `first` if neither first or last are given and no `default_page_size` is set. + # @param default_page_size [Integer, nil] A configured value to determine the result size when neither first or last are given. + def initialize(items, parent: nil, field: nil, context: nil, first: nil, after: nil, max_page_size: NOT_CONFIGURED, default_page_size: NOT_CONFIGURED, last: nil, before: nil, edge_class: nil, arguments: nil) @items = items @parent = parent @context = context @@ -70,12 +79,25 @@ def initialize(items, parent: nil, field: nil, context: nil, first: nil, after: @edge_class = edge_class || self.class::Edge # This is only true if the object was _initialized_ with an override # or if one is assigned later. - @has_max_page_size_override = max_page_size != :not_given - @max_page_size = if max_page_size == :not_given + @has_max_page_size_override = max_page_size != NOT_CONFIGURED + @max_page_size = if max_page_size == NOT_CONFIGURED nil else max_page_size end + @has_default_page_size_override = default_page_size != NOT_CONFIGURED + @default_page_size = if default_page_size == NOT_CONFIGURED + nil + else + default_page_size + end + @was_authorized_by_scope_items = detect_was_authorized_by_scope_items + end + + attr_writer :was_authorized_by_scope_items + + def was_authorized_by_scope_items? + @was_authorized_by_scope_items end def max_page_size=(new_value) @@ -95,16 +117,36 @@ def has_max_page_size_override? @has_max_page_size_override end + def default_page_size=(new_value) + @has_default_page_size_override = true + @default_page_size = new_value + end + + def default_page_size + if @has_default_page_size_override + @default_page_size + else + context.schema.default_page_size + end + end + + def has_default_page_size_override? + @has_default_page_size_override + end + attr_writer :first # @return [Integer, nil] # A clamped `first` value. # (The underlying instance variable doesn't have limits on it.) - # If neither `first` nor `last` is given, but `max_page_size` is present, max_page_size is used for first. + # If neither `first` nor `last` is given, but `default_page_size` is + # present, default_page_size is used for first. If `default_page_size` + # is greater than `max_page_size``, it'll be clamped down to + # `max_page_size`. If `default_page_size` is nil, use `max_page_size`. def first @first ||= begin capped = limit_pagination_argument(@first_value, max_page_size) if capped.nil? && last.nil? - capped = max_page_size + capped = limit_pagination_argument(default_page_size, max_page_size) || max_page_size end capped end @@ -181,6 +223,16 @@ def cursor_for(item) private + def detect_was_authorized_by_scope_items + if @context && + (current_runtime_state = Fiber[:__graphql_runtime_info]) && + (query_runtime_state = current_runtime_state[@context.query]) + query_runtime_state.was_authorized_by_scope_items + else + nil + end + end + # @param argument [nil, Integer] `first` or `last`, as provided by the client # @param max_page_size [nil, Integer] # @return [nil, Integer] `nil` if the input was `nil`, otherwise a value between `0` and `max_page_size` @@ -220,6 +272,10 @@ def parent def cursor @cursor ||= @connection.cursor_for(@node) end + + def was_authorized_by_scope_items? + @connection.was_authorized_by_scope_items? + end end end end diff --git a/lib/graphql/pagination/connections.rb b/lib/graphql/pagination/connections.rb index cb33c534d5c..dfcb63c3ee0 100644 --- a/lib/graphql/pagination/connections.rb +++ b/lib/graphql/pagination/connections.rb @@ -21,13 +21,6 @@ class Connections class ImplementationMissingError < GraphQL::Error end - def self.use(schema_defn) - if schema_defn.plugins.any? { |(plugin, args)| plugin == self } - GraphQL::Deprecation.warn("#{self} is now the default, remove `use #{self}` from #{caller(2,1).first}") - end - schema_defn.connections = self.new(schema: schema_defn) - end - def initialize(schema:) @schema = schema @wrappers = {} @@ -70,31 +63,62 @@ def wrap(field, parent, items, arguments, context) wrappers = context ? context.namespace(:connections)[:all_wrappers] : all_wrappers impl = wrapper_for(items, wrappers: wrappers) - if impl.nil? + if impl + impl.new( + items, + context: context, + parent: parent, + field: field, + max_page_size: field.has_max_page_size? ? field.max_page_size : context.schema.default_max_page_size, + default_page_size: field.has_default_page_size? ? field.default_page_size : context.schema.default_page_size, + first: arguments[:first], + after: arguments[:after], + last: arguments[:last], + before: arguments[:before], + arguments: arguments, + edge_class: edge_class_for_field(field), + ) + else raise ImplementationMissingError, "Couldn't find a connection wrapper for #{items.class} during #{field.path} (#{items.inspect})" end - - impl.new( - items, - context: context, - parent: parent, - field: field, - max_page_size: field.has_max_page_size? ? field.max_page_size : context.schema.default_max_page_size, - first: arguments[:first], - after: arguments[:after], - last: arguments[:last], - before: arguments[:before], - arguments: arguments, - edge_class: edge_class_for_field(field), - ) end + def populate_connection(field, object, value, original_arguments, context) + if value.is_a? GraphQL::ExecutionError + raise value + elsif value.nil? + nil + elsif value.is_a?(GraphQL::Pagination::Connection) + # update the connection with some things that may not have been provided + value.context ||= context + value.parent ||= object + value.first_value ||= original_arguments[:first] + value.after_value ||= original_arguments[:after] + value.last_value ||= original_arguments[:last] + value.before_value ||= original_arguments[:before] + value.arguments ||= original_arguments # rubocop:disable Development/ContextIsPassedCop -- unrelated .arguments method + value.field ||= field + if field.has_max_page_size? && !value.has_max_page_size_override? + value.max_page_size = field.max_page_size + end + if field.has_default_page_size? && !value.has_default_page_size_override? + value.default_page_size = field.default_page_size + end + if (custom_t = context.schema.connections.edge_class_for_field(field)) + value.edge_class = custom_t + end + value + else + context.namespace(:connections)[:all_wrappers] ||= context.schema.connections.all_wrappers + context.schema.connections.wrap(field, object, value, original_arguments, context) + end + end # use an override if there is one # @api private def edge_class_for_field(field) conn_type = field.type.unwrap conn_type_edge_type = conn_type.respond_to?(:edge_class) && conn_type.edge_class - if conn_type_edge_type && conn_type_edge_type != Relay::Edge + if conn_type_edge_type && conn_type_edge_type != Pagination::Connection::Edge conn_type_edge_type else nil @@ -130,6 +154,11 @@ def add_default if defined?(Mongoid::Association::Referenced::HasMany::Targets::Enumerable) add(Mongoid::Association::Referenced::HasMany::Targets::Enumerable, Pagination::MongoidRelationConnection) end + + # Mongoid 7.3+ + if defined?(Mongoid::Association::Referenced::HasMany::Enumerable) + add(Mongoid::Association::Referenced::HasMany::Enumerable, Pagination::MongoidRelationConnection) + end end end end diff --git a/lib/graphql/pagination/mongoid_relation_connection.rb b/lib/graphql/pagination/mongoid_relation_connection.rb index b96ae3a1b16..29d92642049 100644 --- a/lib/graphql/pagination/mongoid_relation_connection.rb +++ b/lib/graphql/pagination/mongoid_relation_connection.rb @@ -13,8 +13,7 @@ def relation_limit(relation) end def relation_count(relation) - # Mongo's `.count` doesn't apply limit or skip, which we need. So we have to load _everything_! - relation.to_a.count + relation.all.count(relation.options.slice(:limit, :skip)) end def null_relation(relation) diff --git a/lib/graphql/pagination/relation_connection.rb b/lib/graphql/pagination/relation_connection.rb index fd66975b381..0e6b091474f 100644 --- a/lib/graphql/pagination/relation_connection.rb +++ b/lib/graphql/pagination/relation_connection.rb @@ -35,7 +35,7 @@ def has_next_page if @nodes && @nodes.count < first false else - relation_larger_than(sliced_nodes, first) + relation_larger_than(sliced_nodes, @sliced_nodes_offset, first) end else false @@ -47,16 +47,17 @@ def has_next_page def cursor_for(item) load_nodes # index in nodes + existing offset + 1 (because it's offset, not index) - offset = nodes.index(item) + 1 + (@paged_nodes_offset || 0) + (relation_offset(items) || 0) + offset = nodes.index(item) + 1 + (@paged_nodes_offset || 0) - (relation_offset(items) || 0) encode(offset.to_s) end private # @param relation [Object] A database query object + # @param _initial_offset [Integer] The number of items already excluded from the relation # @param size [Integer] The value against which we check the relation size # @return [Boolean] True if the number of items in this relation is larger than `size` - def relation_larger_than(relation, size) + def relation_larger_than(relation, _initial_offset, size) relation_count(set_limit(relation, size + 1)) == size + 1 end @@ -111,30 +112,53 @@ def set_limit(relation, limit_value) end end - # Apply `before` and `after` to the underlying `items`, - # returning a new relation. - def sliced_nodes - @sliced_nodes ||= begin - paginated_nodes = items + def calculate_sliced_nodes_parameters + if defined?(@sliced_nodes_limit) + return + else + next_offset = relation_offset(items) || 0 + relation_limit = relation_limit(items) if after_offset - previous_offset = relation_offset(items) || 0 - paginated_nodes = set_offset(paginated_nodes, previous_offset + after_offset) + next_offset += after_offset end if before_offset && after_offset if after_offset < before_offset # Get the number of items between the two cursors space_between = before_offset - after_offset - 1 - paginated_nodes = set_limit(paginated_nodes, space_between) + relation_limit = space_between else - # TODO I think this is untested # The cursors overextend one another to an empty set - paginated_nodes = null_relation(paginated_nodes) + @sliced_nodes_null_relation = true end elsif before_offset # Use limit to cut off the tail of the relation - paginated_nodes = set_limit(paginated_nodes, before_offset - 1) + relation_limit = before_offset - 1 + end + + @sliced_nodes_limit = relation_limit + @sliced_nodes_offset = next_offset + end + end + + # Apply `before` and `after` to the underlying `items`, + # returning a new relation. + def sliced_nodes + @sliced_nodes ||= begin + calculate_sliced_nodes_parameters + paginated_nodes = items + + if @sliced_nodes_null_relation + paginated_nodes = null_relation(paginated_nodes) + else + if @sliced_nodes_limit + paginated_nodes = set_limit(paginated_nodes, @sliced_nodes_limit) + end + + if @sliced_nodes_offset + paginated_nodes = set_offset(paginated_nodes, @sliced_nodes_offset) + end end paginated_nodes @@ -155,32 +179,40 @@ def after_offset # returning a new relation def limited_nodes @limited_nodes ||= begin - paginated_nodes = sliced_nodes - previous_limit = relation_limit(paginated_nodes) + calculate_sliced_nodes_parameters + if @sliced_nodes_null_relation + # it's an empty set + return sliced_nodes + end + relation_limit = @sliced_nodes_limit + relation_offset = @sliced_nodes_offset - if first && (previous_limit.nil? || previous_limit > first) + if first && (relation_limit.nil? || relation_limit > first) # `first` would create a stricter limit that the one already applied, so add it - paginated_nodes = set_limit(paginated_nodes, first) + relation_limit = first end if last - if (lv = relation_limit(paginated_nodes)) - if last <= lv + if relation_limit + if last <= relation_limit # `last` is a smaller slice than the current limit, so apply it - offset = (relation_offset(paginated_nodes) || 0) + (lv - last) - paginated_nodes = set_offset(paginated_nodes, offset) - paginated_nodes = set_limit(paginated_nodes, last) + relation_offset += (relation_limit - last) + relation_limit = last end else # No limit, so get the last items - sliced_nodes_count = relation_count(@sliced_nodes) - offset = (relation_offset(paginated_nodes) || 0) + sliced_nodes_count - [last, sliced_nodes_count].min - paginated_nodes = set_offset(paginated_nodes, offset) - paginated_nodes = set_limit(paginated_nodes, last) + sliced_nodes_count = relation_count(sliced_nodes) + relation_offset += (sliced_nodes_count - [last, sliced_nodes_count].min) + relation_limit = last end end - @paged_nodes_offset = relation_offset(paginated_nodes) + @paged_nodes_offset = relation_offset + paginated_nodes = items + paginated_nodes = set_offset(paginated_nodes, relation_offset) + if relation_limit + paginated_nodes = set_limit(paginated_nodes, relation_limit) + end paginated_nodes end end diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 8f6d161f308..2057270fb91 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -1,24 +1,56 @@ # frozen_string_literal: true -require "graphql/query/arguments" -require "graphql/query/arguments_cache" -require "graphql/query/context" -require "graphql/query/executor" -require "graphql/query/fingerprint" -require "graphql/query/literal_input" -require "graphql/query/null_context" -require "graphql/query/result" -require "graphql/query/serial_execution" -require "graphql/query/variables" -require "graphql/query/input_validation_result" -require "graphql/query/variable_validation_error" -require "graphql/query/validation_pipeline" module GraphQL # A combination of query string and {Schema} instance which can be reduced to a {#result}. class Query + extend Autoload include Tracing::Traceable extend Forwardable + autoload :Context, "graphql/query/context" + autoload :Fingerprint, "graphql/query/fingerprint" + autoload :NullContext, "graphql/query/null_context" + autoload :Partial, "graphql/query/partial" + autoload :Result, "graphql/query/result" + autoload :Variables, "graphql/query/variables" + autoload :InputValidationResult, "graphql/query/input_validation_result" + autoload :VariableValidationError, "graphql/query/variable_validation_error" + autoload :ValidationPipeline, "graphql/query/validation_pipeline" + + # Code shared with {Partial} + module Runnable + def after_lazy(value, &block) + if !defined?(@runtime_instance) + @runtime_instance = context.namespace(:interpreter_runtime)[:runtime] + end + + if @runtime_instance + @runtime_instance.minimal_after_lazy(value, &block) + else + @schema.after_lazy(value, &block) + end + end + + # Node-level cache for calculating arguments. Used during execution and query analysis. + # @param ast_node [GraphQL::Language::Nodes::AbstractNode] + # @param definition [GraphQL::Schema::Field] + # @param parent_object [GraphQL::Schema::Object] + # @return [Hash{Symbol => Object}] + def arguments_for(ast_node, definition, parent_object: nil) + arguments_cache.fetch(ast_node, definition, parent_object) + end + + def arguments_cache + @arguments_cache ||= Execution::Interpreter::ArgumentsCache.new(self) + end + + # @api private + def handle_or_reraise(err, **kwargs) + @schema.handle_or_reraise(context, err, **kwargs) + end + end + + include Runnable class OperationNameMissingError < GraphQL::ExecutionError def initialize(name) msg = if name.nil? @@ -39,7 +71,30 @@ def initialize(name) attr_accessor :operation_name # @return [Boolean] if false, static validation is skipped (execution behavior for invalid queries is undefined) - attr_accessor :validate + attr_reader :validate + + # @param new_validate [Boolean] if false, static validation is skipped. This can't be reasssigned after validation. + def validate=(new_validate) + if defined?(@validation_pipeline) && @validation_pipeline && @validation_pipeline.has_validated? + raise ArgumentError, "Can't reassign Query#validate= after validation has run, remove this assignment." + else + @validate = new_validate + end + end + + # @return [GraphQL::StaticValidation::Validator] if present, the query will validate with these rules. + attr_reader :static_validator + + # @param new_validator [GraphQL::StaticValidation::Validator] if present, the query will validate with these rules. This can't be reasssigned after validation. + def static_validator=(new_validator) + if defined?(@validation_pipeline) && @validation_pipeline && @validation_pipeline.has_validated? + raise ArgumentError, "Can't reassign Query#static_validator= after validation has run, remove this assignment." + elsif !new_validator.is_a?(GraphQL::StaticValidation::Validator) + raise ArgumentError, "Expected a `GraphQL::StaticValidation::Validator` instance." + else + @static_validator = new_validator + end + end attr_writer :query_string @@ -77,30 +132,41 @@ def selected_operation_name # @param root_value [Object] the object used to resolve fields on the root type # @param max_depth [Numeric] the maximum number of nested selections allowed for this query (falls back to schema-level value) # @param max_complexity [Numeric] the maximum field complexity for this query (falls back to schema-level value) - # @param except [<#call(schema_member, context)>] If provided, objects will be hidden from the schema when `.call(schema_member, context)` returns truthy - # @param only [<#call(schema_member, context)>] If provided, objects will be hidden from the schema when `.call(schema_member, context)` returns false - def initialize(schema, query_string = nil, query: nil, document: nil, context: nil, variables: nil, validate: true, subscription_topic: nil, operation_name: nil, root_value: nil, max_depth: schema.max_depth, max_complexity: schema.max_complexity, except: nil, only: nil, warden: nil) + # @param visibility_profile [Symbol] Another way to assign `context[:visibility_profile]` + def initialize(schema, query_string = nil, query: nil, document: nil, context: nil, variables: nil, multiplex: nil, validate: true, static_validator: nil, visibility_profile: nil, subscription_topic: nil, operation_name: nil, root_value: nil, max_depth: schema.max_depth, max_complexity: schema.max_complexity, warden: nil, use_visibility_profile: nil) # Even if `variables: nil` is passed, use an empty hash for simpler logic variables ||= {} + @multiplex = multiplex + @schema = schema + @context = schema.context_class.new(query: self, values: context) + if visibility_profile + @context[:visibility_profile] ||= visibility_profile + end - # Use the `.graphql_definition` here which will return legacy types instead of classes - if schema.is_a?(Class) && !schema.interpreter? - schema = schema.graphql_definition + if use_visibility_profile.nil? + use_visibility_profile = warden ? false : schema.use_visibility_profile? end - @schema = schema - @interpreter = @schema.interpreter? - @filter = schema.default_filter.merge(except: except, only: only) - @context = schema.context_class.new(query: self, object: root_value, values: context) - @warden = warden + + if use_visibility_profile + @visibility_profile = @schema.visibility.profile_for(@context) + @warden = Schema::Warden::NullWarden.new(context: @context, schema: @schema) + else + @visibility_profile = nil + @warden = warden + end + @subscription_topic = subscription_topic @root_value = root_value @fragments = nil @operations = nil + @finalizers = @top_level_finalizers = nil @validate = validate - @tracers = schema.tracers + (context ? context.fetch(:tracers, []) : []) - # Support `ctx[:backtrace] = true` for wrapping backtraces - if context && context[:backtrace] && !@tracers.include?(GraphQL::Backtrace::Tracer) - @tracers << GraphQL::Backtrace::Tracer + self.static_validator = static_validator if static_validator + context_tracers = (context ? context.fetch(:tracers, []) : []) + @tracers = schema.tracers + context_tracers + + if !context_tracers.empty? && !(schema.trace_class <= GraphQL::Tracing::CallLegacyTracers) + raise ArgumentError, "context[:tracers] are not supported without `trace_with(GraphQL::Tracing::CallLegacyTracers)` in the schema configuration, please add it." end @analysis_errors = [] @@ -117,6 +183,10 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n raise ArgumentError, "Query should only be provided a query string or a document, not both." end + if @query_string && !@query_string.is_a?(String) + raise ArgumentError, "Query string argument should be a String, got #{@query_string.class.name} instead." + end + # A two-layer cache of type resolution: # { abstract_type => { value => resolved_type } } @resolved_types_cache = Hash.new do |h1, k1| @@ -138,10 +208,7 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n @result_values = nil @executed = false - # TODO add a general way to define schema-level filters - if @schema.respond_to?(:visible?) - merge_filters(only: @schema.method(:visible?)) - end + @logger = schema.logger_for(context) end # If a document was provided to `GraphQL::Schema#execute` instead of the raw query string, we will need to get it from the document @@ -149,12 +216,16 @@ def query_string @query_string ||= (document ? document.to_query_string : nil) end - def interpreter? - @interpreter - end + # @return [Symbol, nil] + attr_reader :visibility_profile attr_accessor :multiplex + # @return [GraphQL::Tracing::Trace] + def current_trace + @current_trace ||= context[:trace] || (multiplex ? multiplex.current_trace : schema.new_trace(multiplex: multiplex, query: self)) + end + def subscription_update? @subscription_topic && subscription? end @@ -163,10 +234,11 @@ def subscription_update? # @return [GraphQL::Execution::Lookahead] def lookahead @lookahead ||= begin - ast_node = selected_operation - root_type = warden.root_type_for_operation(ast_node.operation_type || "query") - root_type = root_type.type_class || raise("Invariant: `lookahead` only works with class-based types") - GraphQL::Execution::Lookahead.new(query: self, root_type: root_type, ast_nodes: [ast_node]) + if selected_operation.nil? + GraphQL::Execution::Lookahead::NULL_LOOKAHEAD + else + GraphQL::Execution::Lookahead.new(query: self, root_type: root_type, ast_nodes: [selected_operation]) + end end end @@ -191,11 +263,31 @@ def operations with_prepared_ast { @operations } end + def path + EmptyObjects::EMPTY_ARRAY + end + + # Run subtree partials of this query and return their results. + # Each partial is identified with a `path:` and `object:` + # where the path references a field in the AST and the object will be treated + # as the return value from that field. Subfields of the field named by `path` + # will be executed with `object` as the starting point + # @param partials_hashes [Array Object}>] Hashes with `path:` and `object:` keys + # @return [Array] + def run_partials(partials_hashes) + partials = partials_hashes.map { |partial_options| Partial.new(query: self, **partial_options) } + if context[:__graphql_execute_next] + Execution::Next.run_all(@schema, partials, context: @context) + else + Execution::Interpreter.run_all(@schema, partials, context: @context) + end + end + # Get the result for this query, executing it once - # @return [Hash] A GraphQL response, with `"data"` and/or `"errors"` keys + # @return [GraphQL::Query::Result] A Hash-like GraphQL response, with `"data"` and/or `"errors"` keys def result if !@executed - Execution::Multiplex.run_queries(@schema, [self], context: @context) + Execution::Interpreter.run_all(@schema, [self], context: @context) end @result ||= Query::Result.new(query: self, values: @result_values) end @@ -233,37 +325,6 @@ def variables end end - def irep_selection - @selection ||= begin - if selected_operation && internal_representation - internal_representation.operation_definitions[selected_operation.name] - else - nil - end - end - end - - # Node-level cache for calculating arguments. Used during execution and query analysis. - # @param ast_node [GraphQL::Language::Nodes::AbstractNode] - # @param definition [GraphQL::Schema::Field] - # @param parent_object [GraphQL::Schema::Object] - # @return Hash{Symbol => Object} - def arguments_for(ast_node, definition, parent_object: nil) - if interpreter? - arguments_cache.fetch(ast_node, definition, parent_object) - else - arguments_cache[ast_node][definition] - end - end - - def arguments_cache - if interpreter? - @arguments_cache ||= Execution::Interpreter::ArgumentsCache.new(self) - else - @arguments_cache ||= ArgumentsCache.build(self) - end - end - # A version of the given query string, with: # - Variables inlined to the query # - Strings replaced with `` @@ -292,7 +353,7 @@ def fingerprint # @return [String] An opaque hash for identifying this query's given query string and selected operation def operation_fingerprint - @operation_fingerprint ||= "#{selected_operation_name || "anonymous"}/#{Fingerprint.generate(query_string)}" + @operation_fingerprint ||= "#{selected_operation_name || "anonymous"}/#{Fingerprint.generate(query_string || "")}" end # @return [String] An opaque hash for identifying this query's given a variable values (not including defaults) @@ -304,8 +365,8 @@ def validation_pipeline with_prepared_ast { @validation_pipeline } end - def_delegators :validation_pipeline, :validation_errors, :internal_representation, - :analyzers, :ast_analyzers, :max_depth, :max_complexity + def_delegators :validation_pipeline, :validation_errors, + :analyzers, :ast_analyzers, :max_depth, :max_complexity, :validate_timeout_remaining attr_accessor :analysis_errors def valid? @@ -316,14 +377,45 @@ def warden with_prepared_ast { @warden } end - def_delegators :warden, :get_type, :get_field, :possible_types, :root_type_for_operation + def get_type(type_name) + types.type(type_name) # rubocop:disable Development/ContextIsPassedCop + end + + def get_field(owner, field_name) + types.field(owner, field_name) # rubocop:disable Development/ContextIsPassedCop + end + + def possible_types(type) + types.possible_types(type) # rubocop:disable Development/ContextIsPassedCop + end + + def root_type_for_operation(op_type) + case op_type + when "query", nil + types.query_root # rubocop:disable Development/ContextIsPassedCop + when "mutation" + types.mutation_root # rubocop:disable Development/ContextIsPassedCop + when "subscription" + types.subscription_root # rubocop:disable Development/ContextIsPassedCop + else + raise ArgumentError, "unexpected root type name: #{op_type.inspect}; expected nil, 'query', 'mutation', or 'subscription'" + end + end + + def root_type + root_type_for_operation(selected_operation.operation_type) + end + + def types + @visibility_profile || warden.visibility_profile + end # @param abstract_type [GraphQL::UnionType, GraphQL::InterfaceType] # @param value [Object] Any runtime value # @return [GraphQL::ObjectType, nil] The runtime type of `value` from {Schema#resolve_type} # @see {#possible_types} to apply filtering from `only` / `except` - def resolve_type(abstract_type, value = :__undefined__) - if value.is_a?(Symbol) && value == :__undefined__ + def resolve_type(abstract_type, value = NOT_CONFIGURED) + if value.is_a?(Symbol) && value == NOT_CONFIGURED # Old method signature value = abstract_type abstract_type = nil @@ -342,26 +434,11 @@ def query? with_prepared_ast { @query } end - # @return [void] - def merge_filters(only: nil, except: nil) - if @prepared_ast - raise "Can't add filters after preparing the query" - else - @filter = @filter.merge(only: only, except: except) - end - nil - end - def subscription? with_prepared_ast { @subscription } end - # @api private - def with_error_handling - schema.error_handler.with_error_handling(context) do - yield - end - end + attr_reader :logger private @@ -377,11 +454,11 @@ def find_operation(operations, operation_name) def prepare_ast @prepared_ast = true - @warden ||= GraphQL::Schema::Warden.new(@filter, schema: @schema, context: @context) + @warden ||= @schema.warden_class.new(schema: @schema, context: @context) parse_error = nil @document ||= begin if query_string - GraphQL.parse(query_string, tracer: self) + GraphQL.parse(query_string, trace: self.current_trace, max_tokens: @schema.max_query_string_tokens) end rescue GraphQL::ParseError => err parse_error = err @@ -413,7 +490,7 @@ def prepare_ast @mutation = false @subscription = false operation_name_error = nil - if @operations.any? + if !@operations.empty? @selected_operation = find_operation(@operations, @operation_name) if @selected_operation.nil? operation_name_error = GraphQL::Query::OperationNameMissingError.new(@operation_name) @@ -430,7 +507,6 @@ def prepare_ast @validation_pipeline = GraphQL::Query::ValidationPipeline.new( query: self, - validate: @validate, parse_error: parse_error, operation_name_error: operation_name_error, max_depth: @max_depth, diff --git a/lib/graphql/query/arguments.rb b/lib/graphql/query/arguments.rb deleted file mode 100644 index b81a08c3ee4..00000000000 --- a/lib/graphql/query/arguments.rb +++ /dev/null @@ -1,189 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Query - # Read-only access to values, normalizing all keys to strings - # - # {Arguments} recursively wraps the input in {Arguments} instances. - class Arguments - extend Forwardable - include GraphQL::Dig - - def self.construct_arguments_class(argument_owner) - argument_definitions = argument_owner.arguments - argument_owner.arguments_class = Class.new(self) do - self.argument_owner = argument_owner - self.argument_definitions = argument_definitions - - argument_definitions.each do |_arg_name, arg_definition| - if arg_definition.method_access? - expose_as = arg_definition.expose_as.to_s.freeze - expose_as_underscored = GraphQL::Schema::Member::BuildType.underscore(expose_as).freeze - method_names = [expose_as, expose_as_underscored].uniq - method_names.each do |method_name| - # Don't define a helper method if it would override something. - if method_defined?(method_name) - GraphQL::Deprecation.warn( - "Unable to define a helper for argument with name '#{method_name}' "\ - "as this is a reserved name. Add `method_access: false` to stop this warning." - ) - else - define_method(method_name) do - # Always use `expose_as` here, since #[] doesn't accept underscored names - self[expose_as] - end - end - end - end - end - end - end - - attr_reader :argument_values - - def initialize(values, context:, defaults_used:) - @argument_values = values.inject({}) do |memo, (inner_key, inner_value)| - arg_name = inner_key.to_s - arg_defn = self.class.argument_definitions[arg_name] || raise("Not found #{arg_name} among #{self.class.argument_definitions.keys}") - arg_default_used = defaults_used.include?(arg_name) - arg_value = wrap_value(inner_value, arg_defn.type, context) - string_key = arg_defn.expose_as - memo[string_key] = ArgumentValue.new(string_key, arg_value, arg_defn, arg_default_used) - memo - end - end - - # @param key [String, Symbol] name or index of value to access - # @return [Object] the argument at that key - def [](key) - key_s = key.is_a?(String) ? key : key.to_s - @argument_values.fetch(key_s, NULL_ARGUMENT_VALUE).value - end - - # @param key [String, Symbol] name of value to access - # @return [Boolean] true if the argument was present in this field - def key?(key) - key_s = key.is_a?(String) ? key : key.to_s - @argument_values.key?(key_s) - end - - # @param key [String, Symbol] name of value to access - # @return [Boolean] true if the argument default was passed as the argument value to the resolver - def default_used?(key) - key_s = key.is_a?(String) ? key : key.to_s - @argument_values.fetch(key_s, NULL_ARGUMENT_VALUE).default_used? - end - - # Get the hash of all values, with stringified keys - # @return [Hash] the stringified hash - def to_h - @to_h ||= begin - h = {} - each_value do |arg_value| - arg_key = arg_value.definition.expose_as - h[arg_key] = unwrap_value(arg_value.value) - end - h - end - end - - def_delegators :to_h, :keys, :values, :each - def_delegators :@argument_values, :any? - - def prepare - self - end - - # Access each key, value and type for the arguments in this set. - # @yield [argument_value] The {ArgumentValue} for each argument - # @yieldparam argument_value [ArgumentValue] - def each_value - @argument_values.each_value do |argument_value| - yield(argument_value) - end - end - - class << self - attr_accessor :argument_definitions, :argument_owner - end - - NoArguments = Class.new(self) do - self.argument_definitions = [] - end - - NO_ARGS = NoArguments.new({}, context: nil, defaults_used: Set.new) - - # Convert this instance into valid Ruby keyword arguments - # @return [{Symbol=>Object}] - def to_kwargs - ruby_kwargs = {} - - keys.each do |key| - ruby_kwargs[Schema::Member::BuildType.underscore(key).to_sym] = self[key] - end - - ruby_kwargs - end - - alias :to_hash :to_kwargs - - private - - class ArgumentValue - attr_reader :key, :value, :definition - attr_writer :default_used - - def initialize(key, value, definition, default_used) - @key = key - @value = value - @definition = definition - @default_used = default_used - end - - # @return [Boolean] true if the argument default was passed as the argument value to the resolver - def default_used? - @default_used - end - end - - NULL_ARGUMENT_VALUE = ArgumentValue.new(nil, nil, nil, nil) - - def wrap_value(value, arg_defn_type, context) - if value.nil? - nil - else - case arg_defn_type - when GraphQL::ListType - value.map { |item| wrap_value(item, arg_defn_type.of_type, context) } - when GraphQL::NonNullType - wrap_value(value, arg_defn_type.of_type, context) - when GraphQL::InputObjectType - if value.is_a?(Hash) - result = arg_defn_type.arguments_class.new(value, context: context, defaults_used: Set.new) - result.prepare - else - value - end - else - value - end - end - end - - def unwrap_value(value) - case value - when Array - value.map { |item| unwrap_value(item) } - when Hash - value.inject({}) do |memo, (key, value)| - memo[key] = unwrap_value(value) - memo - end - when GraphQL::Query::Arguments, GraphQL::Schema::InputObject - value.to_h - else - value - end - end - end - end -end diff --git a/lib/graphql/query/arguments_cache.rb b/lib/graphql/query/arguments_cache.rb deleted file mode 100644 index 783c4db3a1e..00000000000 --- a/lib/graphql/query/arguments_cache.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Query - module ArgumentsCache - # @return [Hash Hash GraphQL::Query::Arguments>>] - def self.build(query) - Hash.new do |h1, irep_or_ast_node| - h1[irep_or_ast_node] = Hash.new do |h2, definition| - ast_node = irep_or_ast_node.is_a?(GraphQL::InternalRepresentation::Node) ? irep_or_ast_node.ast_node : irep_or_ast_node - h2[definition] = if definition.arguments.empty? - GraphQL::Query::Arguments::NO_ARGS - else - GraphQL::Query::LiteralInput.from_arguments( - ast_node.arguments, - definition, - query.variables, - ) - end - end - end - end - end - end -end diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index a4bc8fe16af..3896b714263 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -1,84 +1,10 @@ # frozen_string_literal: true + module GraphQL class Query # Expose some query-specific info to field resolve functions. # It delegates `[]` to the hash that's passed to `GraphQL::Query#initialize`. class Context - module SharedMethods - # @return [Object] The target for field resolution - attr_accessor :object - - # @return [Hash, Array, String, Integer, Float, Boolean, nil] The resolved value for this field - attr_reader :value - - # @return [Boolean] were any fields of this selection skipped? - attr_reader :skipped - alias :skipped? :skipped - - # @api private - attr_writer :skipped - - # Return this value to tell the runtime - # to exclude this field from the response altogether - def skip - GraphQL::Execution::Execute::SKIP - end - - # @return [Boolean] True if this selection has been nullified by a null child - def invalid_null? - @invalid_null - end - - # Remove this child from the result value - # (used for null propagation and skip) - # @api private - def delete_child(child_ctx) - @value.delete(child_ctx.key) - end - - # Create a child context to use for `key` - # @param key [String, Integer] The key in the response (name or index) - # @param irep_node [InternalRepresentation::Node] The node being evaluated - # @api private - def spawn_child(key:, irep_node:, object:) - FieldResolutionContext.new( - @context, - key, - irep_node, - self, - object - ) - end - - # Add error at query-level. - # @param error [GraphQL::ExecutionError] an execution error - # @return [void] - def add_error(error) - if !error.is_a?(ExecutionError) - raise TypeError, "expected error to be a ExecutionError, but was #{error.class}" - end - errors << error - nil - end - - # @example Print the GraphQL backtrace during field resolution - # puts ctx.backtrace - # - # @return [GraphQL::Backtrace] The backtrace for this point in query execution - def backtrace - GraphQL::Backtrace.new(self) - end - - def execution_errors - @execution_errors ||= ExecutionErrors.new(self) - end - - def lookahead - ast_nodes = irep_node.ast_nodes - field = irep_node.definition.metadata[:type_class] || raise("Lookahead is only compatible with class-based schemas") - Execution::Lookahead.new(query: query, ast_nodes: ast_nodes, field: field) - end - end class ExecutionErrors def initialize(ctx) @@ -102,29 +28,8 @@ def add(err_or_msg) alias :push :add end - include SharedMethods extend Forwardable - - attr_reader :execution_strategy - # `strategy` is required by GraphQL::Batch - alias_method :strategy, :execution_strategy - - def execution_strategy=(new_strategy) - # GraphQL::Batch re-assigns this value but it was previously not used - # (ExecutionContext#strategy was used instead) - # now it _is_ used, but it breaks GraphQL::Batch tests - @execution_strategy ||= new_strategy - end - - # @return [GraphQL::InternalRepresentation::Node] The internal representation for this query node - def irep_node - @irep_node ||= query.irep_selection - end - - # @return [GraphQL::Language::Nodes::Field] The AST node for the currently-executing field - def ast_node - @irep_node.ast_node - end + include Schema::Member::HasDataloader # @return [Array] errors returned during execution attr_reader :errors @@ -135,29 +40,28 @@ def ast_node # @return [GraphQL::Schema] attr_reader :schema - # @return [Array] The current position in the result - attr_reader :path - # Make a new context which delegates key lookup to `values` # @param query [GraphQL::Query] the query who owns this context # @param values [Hash] A hash of arbitrary values which will be accessible at query-time - def initialize(query:, schema: query.schema, values:, object:) + def initialize(query:, schema: query.schema, values:) @query = query @schema = schema @provided_values = values || {} - @object = object # Namespaced storage, where user-provided values are in `nil` namespace: @storage = Hash.new { |h, k| h[k] = {} } @storage[nil] = @provided_values @errors = [] - @path = [] - @value = nil - @context = self # for SharedMethods - @scoped_context = {} + @scoped_context = ScopedContext.new(self) + end + + # Modify this hash to return extensions to client. + # @return [Hash] A hash that will be added verbatim to the result hash, as `"extensions" => { ... }` + def response_extensions + namespace(:__query_result_extensions__) end def dataloader - @dataloader ||= query.multiplex ? query.multiplex.dataloader : schema.dataloader_class.new + @dataloader ||= self[:dataloader] || (query.multiplex ? query.multiplex.dataloader : schema.dataloader_class.new) end # @api private @@ -167,21 +71,91 @@ def dataloader attr_writer :value # @api private - attr_accessor :scoped_context + attr_reader :scoped_context def []=(key, value) @provided_values[key] = value end - def_delegators :@query, :trace, :interpreter? + def_delegators :@query, :trace + def types + @types ||= @query.types + end + + attr_writer :types + + RUNTIME_METADATA_KEYS = Set.new([:current_object, :current_arguments, :current_field, :current_path]).freeze # @!method []=(key, value) # Reassign `key` to the hash passed to {Schema#execute} as `context:` # Lookup `key` from the hash passed to {Schema#execute} as `context:` def [](key) - return @scoped_context[key] if @scoped_context.key?(key) - @provided_values[key] + if @scoped_context.key?(key) + @scoped_context[key] + elsif @provided_values.key?(key) + @provided_values[key] + elsif RUNTIME_METADATA_KEYS.include?(key) + if key == :current_path + current_path + else + (current_runtime_state = Fiber[:__graphql_runtime_info]) && + (query_runtime_state = current_runtime_state[@query]) && + (query_runtime_state.public_send(key)) + end + else + # not found + nil + end + end + + # Return this value to tell the runtime + # to exclude this field from the response altogether + def skip + GraphQL::Execution::Skip.new + end + + # Add error at query-level. + # @param error [GraphQL::ExecutionError] an execution error + # @return [void] + def add_error(error) + if !error.is_a?(GraphQL::RuntimeError) + raise TypeError, "expected error to be a GraphQL::RuntimeError, but was #{error.class}" + end + errors << error + nil + end + + # @param value [Object] Any object to be inserted directly into the final response + # @return [GraphQL::Execution::Interpreter::RawValue] Return this from the field + def raw_value(value) + GraphQL::Execution::Interpreter::RawValue.new(value) + end + + # @example Print the GraphQL backtrace during field resolution + # puts ctx.backtrace + # + # @return [GraphQL::Backtrace] The backtrace for this point in query execution + def backtrace + GraphQL::Backtrace.new(self) + end + + def execution_errors + @execution_errors ||= ExecutionErrors.new(self) + end + + def current_path + current_runtime_state = Fiber[:__graphql_runtime_info] + query_runtime_state = current_runtime_state && current_runtime_state[@query] + + path = query_runtime_state && + (result = query_runtime_state.current_result) && + (result.path) + if path && (rn = query_runtime_state.current_result_name) + path = path.dup + path.push(rn) + end + path end def delete(key) @@ -195,8 +169,12 @@ def delete(key) UNSPECIFIED_FETCH_DEFAULT = Object.new def fetch(key, default = UNSPECIFIED_FETCH_DEFAULT) - if @scoped_context.key?(key) - @scoped_context[key] + if RUNTIME_METADATA_KEYS.include?(key) + (runtime = Fiber[:__graphql_runtime_info]) && + (query_runtime_state = runtime[@query]) && + (query_runtime_state.public_send(key)) + elsif @scoped_context.key?(key) + scoped_context[key] elsif @provided_values.key?(key) @provided_values[key] elsif default != UNSPECIFIED_FETCH_DEFAULT @@ -209,12 +187,30 @@ def fetch(key, default = UNSPECIFIED_FETCH_DEFAULT) end def dig(key, *other_keys) - @scoped_context.key?(key) ? @scoped_context.dig(key, *other_keys) : @provided_values.dig(key, *other_keys) + if RUNTIME_METADATA_KEYS.include?(key) + (current_runtime_state = Fiber[:__graphql_runtime_info]) && + (query_runtime_state = current_runtime_state[@query]) && + (obj = query_runtime_state.public_send(key)) && + if other_keys.empty? + obj + else + obj.dig(*other_keys) + end + elsif @scoped_context.key?(key) + @scoped_context.dig(key, *other_keys) + else + @provided_values.dig(key, *other_keys) + end end def to_h - @provided_values.merge(@scoped_context) + if (current_scoped_context = @scoped_context.merged_context) + @provided_values.merge(current_scoped_context) + else + @provided_values + end end + alias :to_hash :to_h def key?(key) @@ -223,28 +219,38 @@ def key?(key) # @return [GraphQL::Schema::Warden] def warden - @warden ||= @query.warden + @warden ||= (@query && @query.warden) end + # @api private + attr_writer :warden + # Get an isolated hash for `ns`. Doesn't affect user-provided storage. # @param ns [Object] a usage-specific namespace identifier # @return [Hash] namespaced storage def namespace(ns) - @storage[ns] + if ns == :interpreter + self + else + @storage[ns] + end end - def inspect - "#" + # @return [Boolean] true if this namespace was accessed before + def namespace?(ns) + @storage.key?(ns) end - # @api private - def received_null_child - @invalid_null = true - @value = nil + def logger + @query && @query.logger + end + + def inspect + "#<#{self.class} ...>" end def scoped_merge!(hash) - @scoped_context = @scoped_context.merge(hash) + @scoped_context.merge!(hash) end def scoped_set!(key, value) @@ -252,117 +258,37 @@ def scoped_set!(key, value) nil end - class FieldResolutionContext - include SharedMethods - include Tracing::Traceable - extend Forwardable - - attr_reader :irep_node, :field, :parent_type, :query, :schema, :parent, :key, :type - alias :selection :irep_node - - def initialize(context, key, irep_node, parent, object) - @context = context - @key = key - @parent = parent - @object = object - @irep_node = irep_node - @field = irep_node.definition - @parent_type = irep_node.owner_type - @type = field.type - # This is needed constantly, so set it ahead of time: - @query = context.query - @schema = context.schema - @tracers = @query.tracers - # This hack flag is required by ConnectionResolve - @wrapped_connection = false - @wrapped_object = false - end - - # @api private - attr_accessor :wrapped_connection, :wrapped_object + # Use this when you need to do a scoped set _inside_ a lazy-loaded (or batch-loaded) + # block of code. + # + # @example using scoped context inside a promise + # scoped_ctx = context.scoped + # SomeBatchLoader.load(...).then do |thing| + # # use a scoped_ctx which was created _before_ dataloading: + # scoped_ctx.set!(:thing, thing) + # end + # @return [Context::Scoped] + def scoped + Scoped.new(@scoped_context, current_path) + end - def path - @path ||= @parent.path.dup << @key + class Scoped + def initialize(scoped_context, path) + @path = path + @scoped_context = scoped_context end - def_delegators :@context, - :[], :[]=, :key?, :fetch, :to_h, :namespace, :dig, - :spawn, :warden, :errors, - :execution_strategy, :strategy, :interpreter? - - # @return [GraphQL::Language::Nodes::Field] The AST node for the currently-executing field - def ast_node - @irep_node.ast_node + def merge!(hash) + @scoped_context.merge!(hash, at: @path) end - # Add error to current field resolution. - # @param error [GraphQL::ExecutionError] an execution error - # @return [void] - def add_error(error) - super - error.ast_node ||= irep_node.ast_node - error.path ||= path + def set!(key, value) + @scoped_context.merge!({ key => value }, at: @path) nil end - - def inspect - "#" - end - - # Set a new value for this field in the response. - # It may be updated after resolving a {Lazy}. - # If it is {Execute::PROPAGATE_NULL}, tell the owner to propagate null. - # If it's {Execute::Execution::SKIP}, remove this field result from its parent - # @param new_value [Any] The GraphQL-ready value - # @api private - def value=(new_value) - case new_value - when GraphQL::Execution::Execute::PROPAGATE_NULL, nil - @invalid_null = true - @value = nil - if @type.kind.non_null? - @parent.received_null_child - end - when GraphQL::Execution::Execute::SKIP - @parent.skipped = true - @parent.delete_child(self) - else - @value = new_value - end - end - - protected - - def received_null_child - case @value - when Hash - self.value = GraphQL::Execution::Execute::PROPAGATE_NULL - when Array - if list_of_non_null_items?(@type) - self.value = GraphQL::Execution::Execute::PROPAGATE_NULL - end - when nil - # TODO This is a hack - # It was already nulled out but it's getting reassigned - else - raise "Unexpected value for received_null_child (#{self.value.class}): #{value}" - end - end - - private - - def list_of_non_null_items?(type) - case type - when GraphQL::NonNullType - # Unwrap [T]! - list_of_non_null_items?(type.of_type) - when GraphQL::ListType - type.of_type.is_a?(GraphQL::NonNullType) - else - raise "Unexpected list_of_non_null_items check: #{type}" - end - end end end end end + +require "graphql/query/context/scoped_context" diff --git a/lib/graphql/query/context/scoped_context.rb b/lib/graphql/query/context/scoped_context.rb new file mode 100644 index 00000000000..c0b3d80f5a8 --- /dev/null +++ b/lib/graphql/query/context/scoped_context.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true +module GraphQL + class Query + class Context + class ScopedContext + def initialize(query_context) + @query_context = query_context + @scoped_contexts = nil + @all_keys = nil + end + + def merged_context + if @scoped_contexts.nil? + GraphQL::EmptyObjects::EMPTY_HASH + else + merged_ctx = {} + each_present_path_ctx do |path_ctx| + merged_ctx = path_ctx.merge(merged_ctx) + end + merged_ctx + end + end + + def merge!(hash, at: current_path) + @all_keys ||= Set.new + @all_keys.merge(hash.keys) + ctx = @scoped_contexts ||= {} + at.each do |path_part| + ctx = ctx[path_part] ||= { parent: ctx } + end + this_scoped_ctx = ctx[:scoped_context] ||= {} + this_scoped_ctx.merge!(hash) + end + + def key?(key) + if @all_keys && @all_keys.include?(key) + each_present_path_ctx do |path_ctx| + if path_ctx.key?(key) + return true + end + end + end + false + end + + def [](key) + each_present_path_ctx do |path_ctx| + if path_ctx.key?(key) + return path_ctx[key] + end + end + nil + end + + def current_path + @query_context.current_path || GraphQL::EmptyObjects::EMPTY_ARRAY + end + + def dig(key, *other_keys) + each_present_path_ctx do |path_ctx| + if path_ctx.key?(key) + found_value = path_ctx[key] + if !other_keys.empty? + return found_value.dig(*other_keys) + else + return found_value + end + end + end + nil + end + + private + + # Start at the current location, + # but look up the tree for previously-assigned scoped values + def each_present_path_ctx + ctx = @scoped_contexts + if ctx.nil? + # no-op + else + current_path.each do |path_part| + if ctx.key?(path_part) + ctx = ctx[path_part] + else + break + end + end + + while ctx + if (scoped_ctx = ctx[:scoped_context]) + yield(scoped_ctx) + end + ctx = ctx[:parent] + end + end + end + end + end + end +end diff --git a/lib/graphql/query/executor.rb b/lib/graphql/query/executor.rb deleted file mode 100644 index 6e6f31162d3..00000000000 --- a/lib/graphql/query/executor.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Query - class Executor - class PropagateNull < StandardError; end - - # @return [GraphQL::Query] the query being executed - attr_reader :query - - def initialize(query) - @query = query - end - - # Evaluate {operation_name} on {query}. - # Handle {GraphQL::ExecutionError}s by putting them in the "errors" key. - # @return [Hash] A GraphQL response, with either a "data" key or an "errors" key - def result - execute - rescue GraphQL::ExecutionError => err - query.context.errors << err - {"errors" => [err.to_h]} - end - - private - - def execute - operation = query.selected_operation - return {} if operation.nil? - - op_type = operation.operation_type - root_type = query.root_type_for_operation(op_type) - execution_strategy_class = query.schema.execution_strategy_for_operation(op_type) - execution_strategy = execution_strategy_class.new - - query.context.execution_strategy = execution_strategy - data_result = begin - execution_strategy.execute(operation, root_type, query) - rescue PropagateNull - nil - end - result = { "data" => data_result } - error_result = query.context.errors.map(&:to_h) - - if error_result.any? - result["errors"] = error_result - end - - result - end - end - end -end diff --git a/lib/graphql/query/input_validation_result.rb b/lib/graphql/query/input_validation_result.rb index 1ece9804a38..44b4f9aa159 100644 --- a/lib/graphql/query/input_validation_result.rb +++ b/lib/graphql/query/input_validation_result.rb @@ -4,6 +4,12 @@ class Query class InputValidationResult attr_accessor :problems + def self.from_problem(explanation, path = nil, extensions: nil, message: nil) + result = self.new + result.add_problem(explanation, path, extensions: extensions, message: message) + result + end + def initialize(valid: true, problems: nil) @valid = valid @problems = problems @@ -27,7 +33,7 @@ def add_problem(explanation, path = nil, extensions: nil, message: nil) end def merge_result!(path, inner_result) - return if inner_result.valid? + return if inner_result.nil? || inner_result.valid? if inner_result.problems inner_result.problems.each do |p| @@ -38,6 +44,9 @@ def merge_result!(path, inner_result) # It could have been explicitly set on inner_result (if it had no problems) @valid = false end + + VALID = self.new + VALID.freeze end end end diff --git a/lib/graphql/query/literal_input.rb b/lib/graphql/query/literal_input.rb deleted file mode 100644 index 5f163e76e66..00000000000 --- a/lib/graphql/query/literal_input.rb +++ /dev/null @@ -1,136 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Query - # Turn query string values into something useful for query execution - class LiteralInput - def self.coerce(type, ast_node, variables) - case ast_node - when nil - nil - when Language::Nodes::NullValue - nil - when Language::Nodes::VariableIdentifier - variables[ast_node.name] - else - case type.kind.name - when "SCALAR" - # TODO smell - # This gets used for plain values during subscriber.trigger - if variables - type.coerce_input(ast_node, variables.context) - else - type.coerce_isolated_input(ast_node) - end - when "ENUM" - # TODO smell - # This gets used for plain values sometimes - v = ast_node.is_a?(GraphQL::Language::Nodes::Enum) ? ast_node.name : ast_node - if variables - type.coerce_input(v, variables.context) - else - type.coerce_isolated_input(v) - end - when "NON_NULL" - LiteralInput.coerce(type.of_type, ast_node, variables) - when "LIST" - if ast_node.is_a?(Array) - ast_node.map { |element_ast| LiteralInput.coerce(type.of_type, element_ast, variables) } - else - [LiteralInput.coerce(type.of_type, ast_node, variables)] - end - when "INPUT_OBJECT" - # TODO smell: handling AST vs handling plain Ruby - next_args = ast_node.is_a?(Hash) ? ast_node : ast_node.arguments - from_arguments(next_args, type, variables) - else - raise "Invariant: unexpected type to coerce to: #{type}" - end - end - end - - def self.from_arguments(ast_arguments, argument_owner, variables) - context = variables ? variables.context : nil - values_hash = {} - defaults_used = Set.new - - indexed_arguments = case ast_arguments - when Hash - ast_arguments - when Array - ast_arguments.each_with_object({}) { |a, memo| memo[a.name] = a } - else - raise ArgumentError, "Unexpected ast_arguments: #{ast_arguments}" - end - - argument_defns = argument_owner.arguments - argument_defns.each do |arg_name, arg_defn| - ast_arg = indexed_arguments[arg_name] - # First, check the argument in the AST. - # If the value is a variable, - # only add a value if the variable is actually present. - # Otherwise, coerce the value in the AST, prepare the value and add it. - # - # TODO: since indexed_arguments can come from a plain Ruby hash, - # have to check for `false` or `nil` as hash values. This is getting smelly :S - if indexed_arguments.key?(arg_name) - arg_value = ast_arg.is_a?(GraphQL::Language::Nodes::Argument) ? ast_arg.value : ast_arg - - value_is_a_variable = arg_value.is_a?(GraphQL::Language::Nodes::VariableIdentifier) - - if (!value_is_a_variable || (value_is_a_variable && variables.key?(arg_value.name))) - - value = coerce(arg_defn.type, arg_value, variables) - # Legacy `prepare` application - if arg_defn.is_a?(GraphQL::Argument) - value = arg_defn.prepare(value, context) - end - - if value.is_a?(GraphQL::ExecutionError) - value.ast_node = ast_arg - raise value - end - - values_hash[arg_name] = value - end - end - - # Then, the definition for a default value. - # If the definition has a default value and - # a value wasn't provided from the AST, - # then add the default value. - if arg_defn.default_value? && !values_hash.key?(arg_name) - value = arg_defn.default_value - defaults_used << arg_name - # `context` isn't present when pre-calculating defaults - if context - if arg_defn.is_a?(GraphQL::Argument) - value = arg_defn.prepare(value, context) - end - if value.is_a?(GraphQL::ExecutionError) - value.ast_node = ast_arg - raise value - end - end - values_hash[arg_name] = value - end - end - - if argument_owner.is_a?(Class) || argument_owner.is_a?(GraphQL::Schema::Field) - # A Schema::InputObject, Schema::GraphQL::Field, Schema::Directive, logic from Query::Arguments#to_kwargs - ruby_kwargs = {} - values_hash.each do |key, value| - ruby_kwargs[Schema::Member::BuildType.underscore(key).to_sym] = value - end - if argument_owner.is_a?(Class) && argument_owner < GraphQL::Schema::InputObject - argument_owner.new(ruby_kwargs: ruby_kwargs, context: context, defaults_used: defaults_used) - else - ruby_kwargs - end - else - result = argument_owner.arguments_class.new(values_hash, context: context, defaults_used: defaults_used) - result.prepare - end - end - end - end -end diff --git a/lib/graphql/query/null_context.rb b/lib/graphql/query/null_context.rb index 7d3beaf975d..de1089864e5 100644 --- a/lib/graphql/query/null_context.rb +++ b/lib/graphql/query/null_context.rb @@ -1,49 +1,38 @@ # frozen_string_literal: true +require "graphql/query/context" module GraphQL class Query # This object can be `ctx` in places where there is no query - class NullContext - class NullWarden < GraphQL::Schema::Warden - def visible?(t); true; end - def visible_field?(t); true; end - def visible_type?(t); true; end + class NullContext < Context + def self.instance + @instance ||= self.new end - class NullQuery - def with_error_handling - yield - end + def self.instance=(new_inst) + @instance = new_inst end - attr_reader :schema, :query, :warden, :dataloader - - def initialize - @query = NullQuery.new - @dataloader = GraphQL::Dataloader::NullDataloader.new - @schema = GraphQL::Schema.new - @warden = NullWarden.new( - GraphQL::Filter.new, - context: self, - schema: @schema, - ) + class NullQuery + def after_lazy(value) + yield(value) + end end - def [](key); end - - def interpreter? - false + class NullSchema < GraphQL::Schema end - class << self - extend Forwardable + extend Forwardable - def [](key); end - - def instance - @instance = self.new - end + attr_reader :schema, :query, :warden, :dataloader + def_delegators GraphQL::EmptyObjects::EMPTY_HASH, :[], :fetch, :dig, :key?, :to_h - def_delegators :instance, :query, :schema, :warden, :interpreter?, :dataloader + def initialize(schema: NullSchema) + @query = NullQuery.new + @dataloader = GraphQL::Dataloader::NullDataloader.new + @schema = schema + @warden = Schema::Warden::NullWarden.new(context: self, schema: @schema) + @types = @warden.visibility_profile + freeze end end end diff --git a/lib/graphql/query/partial.rb b/lib/graphql/query/partial.rb new file mode 100644 index 00000000000..6e4c0e17555 --- /dev/null +++ b/lib/graphql/query/partial.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true +module GraphQL + class Query + # This class is _like_ a {GraphQL::Query}, except it can run on an arbitrary path within a query string. + # + # It depends on a "parent" {Query}. + # + # During execution, it calls query-related tracing hooks but passes itself as `query:`. + # + # The {Partial} will use your {Schema.resolve_type} hook to find the right GraphQL type to use for + # `object` in some cases. + # + # @see Query#run_partials Run via {Query#run_partials} + class Partial + include Query::Runnable + + # @param path [Array] A path in `query.query_string` to start executing from + # @param object [Object] A starting object for execution + # @param query [GraphQL::Query] A full query instance that this partial is based on. Caches are shared. + # @param context [Hash] Extra context values to merge into `query.context`, if provided + # @param fragment_node [GraphQL::Language::Nodes::InlineFragment, GraphQL::Language::Nodes::FragmentDefinition] + def initialize(path: nil, object:, query:, context: nil, fragment_node: nil, type: nil) + @path = path + @object = object + @query = query + @schema = query.schema + context_vals = @query.context.to_h + if context + context_vals = context_vals.merge(context) + end + @context = GraphQL::Query::Context.new(query: self, schema: @query.schema, values: context_vals) + @multiplex = nil + @result_values = nil + @result = nil + @finalizers = @top_level_finalizers = nil + + if fragment_node + @ast_nodes = [fragment_node] + @root_type = type || raise(ArgumentError, "Pass `type:` when using `node:`") + # This is only used when `@leaf` + @field_definition = nil + elsif path.nil? + raise ArgumentError, "`path:` is required if `node:` is not given; add `path:`" + else + set_type_info_from_path + end + + @leaf = @root_type.unwrap.kind.leaf? + end + + def leaf? + @leaf + end + + def root_value + object + end + + attr_reader :context, :query, :ast_nodes, :root_type, :object, :field_definition, :path, :schema + + attr_accessor :multiplex, :result_values + + class Result < GraphQL::Query::Result + def path + @query.path + end + + # @return [GraphQL::Query::Partial] + def partial + @query + end + end + + def result + @result ||= Result.new(query: self, values: result_values) + end + + def current_trace + @query.current_trace + end + + def types + @query.types + end + + def resolve_type(...) + @query.resolve_type(...) + end + + def variables + @query.variables + end + + def fragments + @query.fragments + end + + def validate + @query.validate + end + + def valid? + @query.valid? + end + + def query? + true + end + + def run_partials(...) + @query.run_partials(...) + end + + def analyzers + EmptyObjects::EMPTY_ARRAY + end + + def analysis_errors=(_ignored) + # pass + end + + def subscription? + @query.subscription? + end + + def selected_operation + Language::Nodes::OperationDefinition.new(selections: ast_nodes.flat_map(&:selections)) + end + + def static_errors + @query.static_errors + end + + def selected_operation_name + @query.selected_operation_name + end + + private + + def set_type_info_from_path + selections = [@query.selected_operation] + type = @query.root_type + field_defn = nil + + @path.each do |name_in_doc| + if name_in_doc.is_a?(Integer) + if type.list? + type = type.unwrap + next + else + raise ArgumentError, "Received path with index `#{name_in_doc}`, but type wasn't a list. Type: #{type.to_type_signature}, path: #{@path}" + end + end + + next_selections = [] + selections.each do |selection| + selections_to_check = [] + selections_to_check.concat(selection.selections) + while (sel = selections_to_check.shift) + case sel + when GraphQL::Language::Nodes::InlineFragment + selections_to_check.concat(sel.selections) + when GraphQL::Language::Nodes::FragmentSpread + fragment = @query.fragments[sel.name] + selections_to_check.concat(fragment.selections) + when GraphQL::Language::Nodes::Field + if sel.alias == name_in_doc || sel.name == name_in_doc + next_selections << sel + end + else + raise "Unexpected selection in partial path: #{sel.class}, #{sel.inspect}" + end + end + end + + if next_selections.empty? + raise ArgumentError, "Path `#{@path.inspect}` is not present in this query. `#{name_in_doc.inspect}` was not found. Try a different path or rewrite the query to include it." + end + field_name = next_selections.first.name + field_defn = @schema.get_field(type, field_name, @query.context) || raise("Invariant: no field called #{field_name} on #{type.graphql_name}") + type = field_defn.type + if type.non_null? + type = type.of_type + end + selections = next_selections + end + + @ast_nodes = selections + @root_type = type + @field_definition = field_defn + end + end + end +end diff --git a/lib/graphql/query/serial_execution.rb b/lib/graphql/query/serial_execution.rb deleted file mode 100644 index f5b91c99c5f..00000000000 --- a/lib/graphql/query/serial_execution.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true -require "graphql/query/serial_execution/value_resolution" -require "graphql/query/serial_execution/field_resolution" -require "graphql/query/serial_execution/operation_resolution" -require "graphql/query/serial_execution/selection_resolution" - -module GraphQL - class Query - class SerialExecution - # This is the only required method for an Execution strategy. - # You could create a custom execution strategy and configure your schema to - # use that custom strategy instead. - # - # @param ast_operation [GraphQL::Language::Nodes::OperationDefinition] The operation definition to run - # @param root_type [GraphQL::ObjectType] either the query type or the mutation type - # @param query_object [GraphQL::Query] the query object for this execution - # @return [Hash] a spec-compliant GraphQL result, as a hash - def execute(ast_operation, root_type, query_object) - GraphQL::Deprecation.warn "#{self.class} will be removed in GraphQL-Ruby 2.0, please upgrade to the Interpreter: https://graphql-ruby.org/queries/interpreter.html" - operation_resolution.resolve( - query_object.irep_selection, - root_type, - query_object - ) - end - - def field_resolution - self.class::FieldResolution - end - - def operation_resolution - self.class::OperationResolution - end - - def selection_resolution - self.class::SelectionResolution - end - end - end -end diff --git a/lib/graphql/query/serial_execution/field_resolution.rb b/lib/graphql/query/serial_execution/field_resolution.rb deleted file mode 100644 index f9d2394192c..00000000000 --- a/lib/graphql/query/serial_execution/field_resolution.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Query - class SerialExecution - class FieldResolution - attr_reader :irep_node, :parent_type, :target, :field, :arguments, :query - - def initialize(selection, parent_type, target, query_ctx) - @irep_node = selection - @selection = selection - @parent_type = parent_type - @target = target - @query = query_ctx.query - @field = irep_node.definition - @field_ctx = query_ctx.spawn_child( - key: irep_node.name, - object: target, - irep_node: irep_node, - ) - @arguments = @query.arguments_for(irep_node, @field) - end - - def result - result_name = irep_node.name - raw_value = get_raw_value - if raw_value.is_a?(GraphQL::Execution::Execute::Skip) - {} - else - { result_name => get_finished_value(raw_value) } - end - end - - # GraphQL::Batch depends on this - def execution_context - @field_ctx - end - - private - - # After getting the value from the field's resolve method, - # continue by "finishing" the value, eg. executing sub-fields or coercing values - def get_finished_value(raw_value) - case raw_value - when GraphQL::ExecutionError - raw_value.ast_node = @field_ctx.ast_node - raw_value.path = @field_ctx.path - @query.context.errors.push(raw_value) - when Array - list_errors = raw_value.each_with_index.select { |value, _| value.is_a?(GraphQL::ExecutionError) } - if list_errors.any? - list_errors.each do |error, index| - error.ast_node = @field_ctx.ast_node - error.path = @field_ctx.path + [index] - @query.context.errors.push(error) - end - end - end - - begin - GraphQL::Query::SerialExecution::ValueResolution.resolve( - parent_type, - field, - field.type, - raw_value, - @selection, - @field_ctx, - ) - rescue GraphQL::Query::Executor::PropagateNull - if field.type.kind.non_null? - raise - else - nil - end - end - end - - # Get the result of: - # - Any middleware on this schema - # - The field's resolve method - # If the middleware chain returns a GraphQL::ExecutionError, its message - # is added to the "errors" key. - def get_raw_value - begin - @field_ctx.schema.middleware.invoke([parent_type, target, field, arguments, @field_ctx]) - rescue GraphQL::ExecutionError => err - err - end - end - end - end - end -end diff --git a/lib/graphql/query/serial_execution/operation_resolution.rb b/lib/graphql/query/serial_execution/operation_resolution.rb deleted file mode 100644 index cb771bcb2cf..00000000000 --- a/lib/graphql/query/serial_execution/operation_resolution.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Query - class SerialExecution - module OperationResolution - def self.resolve(selection, target, query) - result = query.context.execution_strategy.selection_resolution.resolve( - query.root_value, - target, - selection, - query.context, - ) - - result - end - end - end - end -end diff --git a/lib/graphql/query/serial_execution/selection_resolution.rb b/lib/graphql/query/serial_execution/selection_resolution.rb deleted file mode 100644 index b294802f356..00000000000 --- a/lib/graphql/query/serial_execution/selection_resolution.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Query - class SerialExecution - module SelectionResolution - def self.resolve(target, current_type, selection, query_ctx) - selection_result = {} - - selection.typed_children[current_type].each do |name, subselection| - selection_result.merge!(query_ctx.execution_strategy.field_resolution.new( - subselection, - current_type, - target, - query_ctx - ).result) - end - - selection_result - end - end - end - end -end diff --git a/lib/graphql/query/serial_execution/value_resolution.rb b/lib/graphql/query/serial_execution/value_resolution.rb deleted file mode 100644 index d28b1042f76..00000000000 --- a/lib/graphql/query/serial_execution/value_resolution.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Query - class SerialExecution - module ValueResolution - def self.resolve(parent_type, field_defn, field_type, value, selection, query_ctx) - if value.nil? || value.is_a?(GraphQL::ExecutionError) - if field_type.kind.non_null? - if value.nil? - type_error = GraphQL::InvalidNullError.new(parent_type, field_defn, value) - query_ctx.schema.type_error(type_error, query_ctx) - end - raise GraphQL::Query::Executor::PropagateNull - else - nil - end - else - case field_type.kind - when GraphQL::TypeKinds::SCALAR, GraphQL::TypeKinds::ENUM - field_type.coerce_result(value, query_ctx) - when GraphQL::TypeKinds::LIST - wrapped_type = field_type.of_type - result = [] - i = 0 - value.each do |inner_value| - inner_ctx = query_ctx.spawn_child( - key: i, - object: inner_value, - irep_node: selection, - ) - - result << resolve( - parent_type, - field_defn, - wrapped_type, - inner_value, - selection, - inner_ctx, - ) - i += 1 - end - result - when GraphQL::TypeKinds::NON_NULL - wrapped_type = field_type.of_type - resolve( - parent_type, - field_defn, - wrapped_type, - value, - selection, - query_ctx, - ) - when GraphQL::TypeKinds::OBJECT - query_ctx.execution_strategy.selection_resolution.resolve( - value, - field_type, - selection, - query_ctx - ) - when GraphQL::TypeKinds::UNION, GraphQL::TypeKinds::INTERFACE - query = query_ctx.query - resolved_type = query.resolve_type(value) - possible_types = query.possible_types(field_type) - - if !possible_types.include?(resolved_type) - type_error = GraphQL::UnresolvedTypeError.new(value, field_defn, parent_type, resolved_type, possible_types) - query.schema.type_error(type_error, query_ctx) - raise GraphQL::Query::Executor::PropagateNull - else - resolve( - parent_type, - field_defn, - resolved_type, - value, - selection, - query_ctx, - ) - end - else - raise("Unknown type kind: #{field_type.kind}") - end - end - end - end - end - end -end diff --git a/lib/graphql/query/validation_pipeline.rb b/lib/graphql/query/validation_pipeline.rb index 1ec126c31a0..58dc7142b1a 100644 --- a/lib/graphql/query/validation_pipeline.rb +++ b/lib/graphql/query/validation_pipeline.rb @@ -14,12 +14,10 @@ class Query # # @api private class ValidationPipeline - attr_reader :max_depth, :max_complexity + attr_reader :max_depth, :max_complexity, :validate_timeout_remaining - def initialize(query:, validate:, parse_error:, operation_name_error:, max_depth:, max_complexity:) + def initialize(query:, parse_error:, operation_name_error:, max_depth:, max_complexity:) @validation_errors = [] - @internal_representation = nil - @validate = validate @parse_error = parse_error @operation_name_error = operation_name_error @query = query @@ -42,17 +40,15 @@ def validation_errors @validation_errors end - # @return [Hash GraphQL::InternalRepresentation::Node] Operation name -> Irep node pairs - def internal_representation - ensure_has_validated - @internal_representation - end - def analyzers ensure_has_validated @query_analyzers end + def has_validated? + @has_validated == true + end + private # If the pipeline wasn't run yet, run it. @@ -63,7 +59,7 @@ def ensure_has_validated if @parse_error # This is kind of crazy: we push the parse error into `ctx` - # in {DefaultParseError} so that users can _opt out_ by redefining that hook. + # in `def self.parse_error` by default so that users can _opt out_ by redefining that hook. # That means we can't _re-add_ the error here (otherwise we'd either # add it twice _or_ override the user's choice to not add it). # So we just have to know that it was invalid and go from there. @@ -72,10 +68,10 @@ def ensure_has_validated elsif @operation_name_error @validation_errors << @operation_name_error else - validation_result = @schema.static_validator.validate(@query, validate: @validate, timeout: @schema.validate_timeout) + validator = @query.static_validator || @schema.static_validator + validation_result = validator.validate(@query, validate: @query.validate, timeout: @schema.validate_timeout, max_errors: @schema.validate_max_errors) @validation_errors.concat(validation_result[:errors]) - @internal_representation = validation_result[:irep] - + @validate_timeout_remaining = validation_result[:remaining_timeout] if @validation_errors.empty? @validation_errors.concat(@query.variables.errors) end @@ -100,35 +96,15 @@ def ensure_has_validated def build_analyzers(schema, max_depth, max_complexity) qa = schema.query_analyzers.dup - # Filter out the built in authorization analyzer. - # It is deprecated and does not have an AST analyzer alternative. - qa = qa.select do |analyzer| - if analyzer == GraphQL::Authorization::Analyzer && schema.using_ast_analysis? - raise "The Authorization analyzer is not supported with AST Analyzers" - else - true - end - end - if max_depth || max_complexity # Depending on the analysis engine, we must use different analyzers # remove this once everything has switched over to AST analyzers - if schema.using_ast_analysis? - if max_depth - qa << GraphQL::Analysis::AST::MaxQueryDepth - end - if max_complexity - qa << GraphQL::Analysis::AST::MaxQueryComplexity - end - else - if max_depth - qa << GraphQL::Analysis::MaxQueryDepth.new(max_depth) - end - if max_complexity - qa << GraphQL::Analysis::MaxQueryComplexity.new(max_complexity) - end + if max_depth + qa << GraphQL::Analysis::MaxQueryDepth + end + if max_complexity + qa << GraphQL::Analysis::MaxQueryComplexity end - qa else qa diff --git a/lib/graphql/query/variable_validation_error.rb b/lib/graphql/query/variable_validation_error.rb index e1144b4455a..3a934dfc164 100644 --- a/lib/graphql/query/variable_validation_error.rb +++ b/lib/graphql/query/variable_validation_error.rb @@ -4,13 +4,13 @@ class Query class VariableValidationError < GraphQL::ExecutionError attr_accessor :value, :validation_result - def initialize(variable_ast, type, value, validation_result) + def initialize(variable_ast, type, value, validation_result, msg: nil) @value = value @validation_result = validation_result - msg = "Variable $#{variable_ast.name} of type #{type.to_type_signature} was provided invalid value" + msg ||= "Variable $#{variable_ast.name} of type #{type.to_type_signature} was provided invalid value" - if problem_fields.any? + if !problem_fields.empty? msg += " for #{problem_fields.join(", ")}" end diff --git a/lib/graphql/query/variables.rb b/lib/graphql/query/variables.rb index 1b53d4aadb7..4b2c12e0918 100644 --- a/lib/graphql/query/variables.rb +++ b/lib/graphql/query/variables.rb @@ -14,41 +14,37 @@ def initialize(ctx, ast_variables, provided_variables) schema = ctx.schema @context = ctx - @provided_variables = GraphQL::Argument.deep_stringify(provided_variables) + @provided_variables = deep_stringify(provided_variables) @errors = [] @storage = ast_variables.each_with_object({}) do |ast_variable, memo| + if schema.validate_max_errors && schema.validate_max_errors <= @errors.count + add_max_errors_reached_message + break memo + end # Find the right value for this variable: # - First, use the value provided at runtime # - Then, fall back to the default value from the query string # If it's still nil, raise an error if it's required. variable_type = schema.type_from_ast(ast_variable.type, context: ctx) - if variable_type.nil? + if variable_type.nil? || !variable_type.unwrap.kind.input? # Pass -- it will get handled by a validator else variable_name = ast_variable.name default_value = ast_variable.default_value provided_value = @provided_variables[variable_name] value_was_provided = @provided_variables.key?(variable_name) + max_errors = schema.validate_max_errors - @errors.count if schema.validate_max_errors begin - validation_result = variable_type.validate_input(provided_value, ctx) + validation_result = variable_type.validate_input(provided_value, ctx, max_errors: max_errors) if validation_result.valid? if value_was_provided # Add the variable if a value was provided - memo[variable_name] = if ctx.interpreter? - provided_value - elsif provided_value.nil? + memo[variable_name] = provided_value + elsif default_value != nil + memo[variable_name] = if default_value.is_a?(Language::Nodes::NullValue) nil else - schema.error_handler.with_error_handling(context) do - variable_type.coerce_input(provided_value, ctx) - end - end - elsif default_value != nil - memo[variable_name] = if ctx.interpreter? default_value - else - # Add the variable if it wasn't provided but it has a default value (including `null`) - GraphQL::Query::LiteralInput.coerce(variable_type, default_value, self) end end end @@ -57,8 +53,7 @@ def initialize(ctx, ast_variables, provided_variables) # like InputValidationResults generated by validate_non_null_input but unfortunately we don't # have this information available in the coerce_input call chain. Note this path is the path # that appears under errors.extensions.problems.path and NOT the result path under errors.path. - validation_result = GraphQL::Query::InputValidationResult.new - validation_result.add_problem(ex.message) + validation_result = GraphQL::Query::InputValidationResult.from_problem(ex.message) end if !validation_result.valid? @@ -69,6 +64,29 @@ def initialize(ctx, ast_variables, provided_variables) end def_delegators :@storage, :length, :key?, :[], :fetch, :to_h + + private + + def deep_stringify(val) + case val + when Array + val.map { |v| deep_stringify(v) } + when Hash + new_val = {} + val.each do |k, v| + new_val[k.to_s] = deep_stringify(v) + end + new_val + else + val + end + end + + def add_max_errors_reached_message + message = "Too many errors processing variables, max validation error limit reached. Execution aborted" + validation_result = GraphQL::Query::InputValidationResult.from_problem(message) + errors << GraphQL::Query::VariableValidationError.new(nil, nil, nil, validation_result, msg: message) + end end end end diff --git a/lib/graphql/railtie.rb b/lib/graphql/railtie.rb index ab1d37ff3b7..b99b50f0576 100644 --- a/lib/graphql/railtie.rb +++ b/lib/graphql/railtie.rb @@ -1,116 +1,22 @@ # frozen_string_literal: true module GraphQL + # Support {GraphQL::Parser::Cache} and {GraphQL.eager_load!} + # + # @example Enable the parser cache with default directory + # + # config.graphql.parser_cache = true + # class Railtie < Rails::Railtie - config.before_configuration do - # Bootsnap compile cache has similar expiration properties, - # so we assume that if the user has bootsnap setup it's ok - # to piggy back on it. - if ::Object.const_defined?("Bootsnap::CompileCache::ISeq") && Bootsnap::CompileCache::ISeq.cache_dir - Language::Parser.cache ||= Language::Cache.new(Pathname.new(Bootsnap::CompileCache::ISeq.cache_dir).join('graphql')) - end - end - - rake_tasks do - # Defer this so that you only need the `parser` gem when you _run_ the upgrader - def load_upgraders - require_relative './upgrader/member' - require_relative './upgrader/schema' - end - - namespace :graphql do - task :upgrade, [:dir] do |t, args| - unless (dir = args[:dir]) - fail 'You have to give me a directory where your GraphQL schema and types live. ' \ - 'For example: `bin/rake graphql:upgrade[app/graphql/**/*]`' - end - - Dir[dir].each do |file| - # Members (types, interfaces, etc.) - if file =~ /.*_(type|interface|enum|union|)\.rb$/ - Rake::Task["graphql:upgrade:member"].execute(Struct.new(:member_file).new(file)) - end - end - - puts "Upgrade complete! Note that this is a best-effort approach, and may very well contain some bugs." - puts "Don't forget to create the base objects. For example, you could run:" - puts "\tbin/rake graphql:upgrade:create_base_objects[app/graphql]" - end - - namespace :upgrade do - task :create_base_objects, [:base_dir] do |t, args| - unless (base_dir = args[:base_dir]) - fail 'You have to give me a directory where your GraphQL types live. ' \ - 'For example: `bin/rake graphql:upgrade:create_base_objects[app/graphql]`' - end - - destination_file = File.join(base_dir, "types", "base_scalar.rb") - unless File.exists?(destination_file) - FileUtils.mkdir_p(File.dirname(destination_file)) - File.open(destination_file, 'w') do |f| - f.puts "class Types::BaseScalar < GraphQL::Schema::Scalar\nend" - end - end - - destination_file = File.join(base_dir, "types", "base_input_object.rb") - unless File.exists?(destination_file) - FileUtils.mkdir_p(File.dirname(destination_file)) - File.open(destination_file, 'w') do |f| - f.puts "class Types::BaseInputObject < GraphQL::Schema::InputObject\nend" - end - end - - destination_file = File.join(base_dir, "types", "base_enum.rb") - unless File.exists?(destination_file) - FileUtils.mkdir_p(File.dirname(destination_file)) - File.open(destination_file, 'w') do |f| - f.puts "class Types::BaseEnum < GraphQL::Schema::Enum\nend" - end - end - - destination_file = File.join(base_dir, "types", "base_union.rb") - unless File.exists?(destination_file) - FileUtils.mkdir_p(File.dirname(destination_file)) - File.open(destination_file, 'w') do |f| - f.puts "class Types::BaseUnion < GraphQL::Schema::Union\nend" - end - end - - destination_file = File.join(base_dir, "types", "base_interface.rb") - unless File.exists?(destination_file) - FileUtils.mkdir_p(File.dirname(destination_file)) - File.open(destination_file, 'w') do |f| - f.puts "module Types::BaseInterface\n include GraphQL::Schema::Interface\nend" - end - end - - destination_file = File.join(base_dir, "types", "base_object.rb") - unless File.exists?(destination_file) - File.open(destination_file, 'w') do |f| - f.puts "class Types::BaseObject < GraphQL::Schema::Object\nend" - end - end - end - - task :schema, [:schema_file] do |t, args| - schema_file = args.schema_file - load_upgraders - upgrader = GraphQL::Upgrader::Schema.new File.read(schema_file) - - puts "- Transforming schema #{schema_file}" - File.open(schema_file, 'w') { |f| f.write upgrader.upgrade } - end - - task :member, [:member_file] do |t, args| - member_file = args.member_file - load_upgraders - upgrader = GraphQL::Upgrader::Member.new File.read(member_file) - next unless upgrader.upgradeable? - - puts "- Transforming member #{member_file}" - File.open(member_file, 'w') { |f| f.write upgrader.upgrade } - end - end + config.graphql = ActiveSupport::OrderedOptions.new + config.graphql.parser_cache = false + config.eager_load_namespaces << GraphQL + + initializer("graphql.cache") do |app| + if config.graphql.parser_cache + Language::Parser.cache ||= Language::Cache.new( + app.root.join("tmp/cache/graphql") + ) end end end diff --git a/lib/graphql/rake_task.rb b/lib/graphql/rake_task.rb index c9ddb824baf..e5601843b92 100644 --- a/lib/graphql/rake_task.rb +++ b/lib/graphql/rake_task.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true require "fileutils" +require "rake" require "graphql/rake_task/validate" module GraphQL @@ -8,8 +9,7 @@ module GraphQL # By default, schemas are looked up by name as constants using `schema_name:`. # You can provide a `load_schema` function to return your schema another way. # - # `load_context:`, `only:` and `except:` are supported so that - # you can keep an eye on how filters affect your schema. + # Use `load_context:` and `visible?` to dump schemas under certain visibility constraints. # # @example Dump a Schema to .graphql + .json files # require "graphql/rake_task" @@ -22,6 +22,10 @@ module GraphQL # @example Invoking the task from Ruby # require "rake" # Rake::Task["graphql:schema:dump"].invoke + # + # @example Providing arguments to build the introspection query + # require "graphql/rake_task" + # GraphQL::RakeTask.new(schema_name: "MySchema", include_is_one_of: true) class RakeTask include Rake::DSL @@ -31,11 +35,14 @@ class RakeTask schema_name: nil, load_schema: ->(task) { Object.const_get(task.schema_name) }, load_context: ->(task) { {} }, - only: nil, - except: nil, directory: ".", idl_outfile: "schema.graphql", json_outfile: "schema.json", + include_deprecated_args: true, + include_schema_description: false, + include_is_repeatable: false, + include_specified_by_url: false, + include_is_one_of: false } # @return [String] Namespace for generated tasks @@ -58,12 +65,6 @@ def rake_namespace # @return [<#call(task)>] A callable for loading the query context attr_accessor :load_context - # @return [<#call(member, ctx)>, nil] A filter for this task - attr_accessor :only - - # @return [<#call(member, ctx)>, nil] A filter for this task - attr_accessor :except - # @return [String] target for IDL task attr_accessor :idl_outfile @@ -73,6 +74,10 @@ def rake_namespace # @return [String] directory for IDL & JSON files attr_accessor :directory + # @return [Boolean] Options for additional fields in the introspection query JSON response + # @see GraphQL::Schema.as_json + attr_accessor :include_deprecated_args, :include_schema_description, :include_is_repeatable, :include_specified_by_url, :include_is_one_of + # Set the parameters of this task by passing keyword arguments # or assigning attributes inside the block def initialize(options = {}) @@ -95,7 +100,21 @@ def initialize(options = {}) def write_outfile(method_name, file) schema = @load_schema.call(self) context = @load_context.call(self) - result = schema.public_send(method_name, only: @only, except: @except, context: context) + result = case method_name + when :to_json + schema.to_json( + include_is_one_of: include_is_one_of, + include_deprecated_args: include_deprecated_args, + include_is_repeatable: include_is_repeatable, + include_specified_by_url: include_specified_by_url, + include_schema_description: include_schema_description, + context: context + ) + when :to_definition + schema.to_definition(context: context) + else + raise ArgumentError, "Unexpected schema dump method: #{method_name.inspect}" + end dir = File.dirname(file) FileUtils.mkdir_p(dir) if !result.end_with?("\n") diff --git a/lib/graphql/rake_task/validate.rb b/lib/graphql/rake_task/validate.rb index 4f00c032f26..e0e87d5abf8 100644 --- a/lib/graphql/rake_task/validate.rb +++ b/lib/graphql/rake_task/validate.rb @@ -15,7 +15,7 @@ class RakeTask puts "Validating graphql-pro v#{version}" puts " - Checking for graphql-pro credentials..." - creds = `bundle config gems.graphql.pro`[/[a-z0-9]{11}:[a-z0-9]{11}/] + creds = `bundle config gems.graphql.pro --parseable`[/[a-z0-9]{11}:[a-z0-9]{11}/] if creds.nil? puts " #{ex} failed, please set with `bundle config gems.graphql.pro $MY_CREDENTIALS`" exit(1) diff --git a/lib/graphql/relay.rb b/lib/graphql/relay.rb index bc6a7061931..e11682b68aa 100644 --- a/lib/graphql/relay.rb +++ b/lib/graphql/relay.rb @@ -1,18 +1,3 @@ # frozen_string_literal: true -require 'graphql/relay/page_info' -require 'graphql/relay/edge' -require 'graphql/relay/edge_type' -require 'graphql/relay/edges_instrumentation' -require 'graphql/relay/base_connection' -require 'graphql/relay/array_connection' require 'graphql/relay/range_add' -require 'graphql/relay/relation_connection' -require 'graphql/relay/mongo_relation_connection' -require 'graphql/relay/global_id_resolve' -require 'graphql/relay/mutation' -require 'graphql/relay/node' -require 'graphql/relay/connection_instrumentation' -require 'graphql/relay/connection_resolve' -require 'graphql/relay/connection_type' -require 'graphql/relay/type_extensions' diff --git a/lib/graphql/relay/array_connection.rb b/lib/graphql/relay/array_connection.rb deleted file mode 100644 index f3bc47f1380..00000000000 --- a/lib/graphql/relay/array_connection.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - class ArrayConnection < BaseConnection - def cursor_from_node(item) - idx = (after ? index_from_cursor(after) : 0) + sliced_nodes.find_index(item) + 1 - encode(idx.to_s) - end - - def has_next_page - if first - # There are more items after these items - sliced_nodes.count > first - elsif GraphQL::Relay::ConnectionType.bidirectional_pagination && before - # The original array is longer than the `before` index - index_from_cursor(before) < nodes.length + 1 - else - false - end - end - - def has_previous_page - if last - # There are items preceding the ones in this result - sliced_nodes.count > last - elsif GraphQL::Relay::ConnectionType.bidirectional_pagination && after - # We've paginated into the Array a bit, there are some behind us - index_from_cursor(after) > 0 - else - false - end - end - - def first - @first ||= begin - capped = limit_pagination_argument(arguments[:first], max_page_size) - if capped.nil? && last.nil? - capped = max_page_size - end - capped - end - end - - def last - @last ||= limit_pagination_argument(arguments[:last], max_page_size) - end - - private - - # apply first / last limit results - def paged_nodes - @paged_nodes ||= begin - items = sliced_nodes - - items = items.first(first) if first - items = items.last(last) if last - items = items.first(max_page_size) if max_page_size && !first && !last - - items - end - end - - # Apply cursors to edges - def sliced_nodes - @sliced_nodes ||= if before && after - nodes[index_from_cursor(after)..index_from_cursor(before)-1] || [] - elsif before - nodes[0..index_from_cursor(before)-2] || [] - elsif after - nodes[index_from_cursor(after)..-1] || [] - else - nodes - end - end - - def index_from_cursor(cursor) - decode(cursor).to_i - end - end - - BaseConnection.register_connection_implementation(Array, ArrayConnection) - end -end diff --git a/lib/graphql/relay/base_connection.rb b/lib/graphql/relay/base_connection.rb deleted file mode 100644 index 8645f8851c5..00000000000 --- a/lib/graphql/relay/base_connection.rb +++ /dev/null @@ -1,189 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - # Subclasses must implement: - # - {#cursor_from_node}, which returns an opaque cursor for the given item - # - {#sliced_nodes}, which slices by `before` & `after` - # - {#paged_nodes}, which applies `first` & `last` limits - # - # In a subclass, you have access to - # - {#nodes}, the collection which the connection will wrap - # - {#first}, {#after}, {#last}, {#before} (arguments passed to the field) - # - {#max_page_size} (the specified maximum page size that can be returned from a connection) - # - class BaseConnection - # Just to encode data in the cursor, use something that won't conflict - CURSOR_SEPARATOR = "---" - - # Map of collection class names -> connection_classes - # eg `{"Array" => ArrayConnection}` - CONNECTION_IMPLEMENTATIONS = {} - - class << self - # Find a connection implementation suitable for exposing `nodes` - # - # @param nodes [Object] A collection of nodes (eg, Array, AR::Relation) - # @return [subclass of BaseConnection] a connection Class for wrapping `nodes` - def connection_for_nodes(nodes) - # If it's a new-style connection object, it's already ready to go - if nodes.is_a?(GraphQL::Pagination::Connection) - return nodes - end - # Check for class _names_ because classes can be redefined in Rails development - nodes.class.ancestors.each do |ancestor| - conn_impl = CONNECTION_IMPLEMENTATIONS[ancestor.name] - if conn_impl - return conn_impl - end - end - # Should have found a connection during the loop: - raise("No connection implementation to wrap #{nodes.class} (#{nodes})") - end - - # Add `connection_class` as the connection wrapper for `nodes_class` - # eg, `RelationConnection` is the implementation for `AR::Relation` - # @param nodes_class [Class] A class representing a collection (eg, Array, AR::Relation) - # @param connection_class [Class] A class implementing Connection methods - def register_connection_implementation(nodes_class, connection_class) - CONNECTION_IMPLEMENTATIONS[nodes_class.name] = connection_class - end - end - - attr_reader :nodes, :arguments, :max_page_size, :parent, :field, :context - - # Make a connection, wrapping `nodes` - # @param nodes [Object] The collection of nodes - # @param arguments [GraphQL::Query::Arguments] Query arguments - # @param field [GraphQL::Field] The underlying field - # @param max_page_size [Int] The maximum number of results to return - # @param parent [Object] The object which this collection belongs to - # @param context [GraphQL::Query::Context] The context from the field being resolved - def initialize(nodes, arguments, field: nil, max_page_size: nil, parent: nil, context: nil) - GraphQL::Deprecation.warn "GraphQL::Relay::BaseConnection (used for #{self.class}) will be removed from GraphQL-Ruby 2.0, use GraphQL::Pagination::Connections instead: https://graphql-ruby.org/pagination/overview.html" - - deprecated_caller = caller(0, 10).find { |c| !c.include?("lib/graphql") } - if deprecated_caller - GraphQL::Deprecation.warn " -> called from #{deprecated_caller}" - end - - @context = context - @nodes = nodes - @arguments = arguments - @field = field - @parent = parent - @encoder = context ? @context.schema.cursor_encoder : GraphQL::Schema::Base64Encoder - @max_page_size = max_page_size.nil? && context ? @context.schema.default_max_page_size : max_page_size - end - - def encode(data) - @encoder.encode(data, nonce: true) - end - - def decode(data) - @encoder.decode(data, nonce: true) - end - - # The value passed as `first:`, if there was one. Negative numbers become `0`. - # @return [Integer, nil] - def first - @first ||= begin - capped = limit_pagination_argument(arguments[:first], max_page_size) - if capped.nil? && last.nil? - capped = max_page_size - end - capped - end - end - - # The value passed as `after:`, if there was one - # @return [String, nil] - def after - arguments[:after] - end - - # The value passed as `last:`, if there was one. Negative numbers become `0`. - # @return [Integer, nil] - def last - @last ||= limit_pagination_argument(arguments[:last], max_page_size) - end - - # The value passed as `before:`, if there was one - # @return [String, nil] - def before - arguments[:before] - end - - # These are the nodes to render for this connection, - # probably wrapped by {GraphQL::Relay::Edge} - def edge_nodes - @edge_nodes ||= paged_nodes - end - - # Support the `pageInfo` field - def page_info - self - end - - # Used by `pageInfo` - def has_next_page - !!(first && sliced_nodes.count > first) - end - - # Used by `pageInfo` - def has_previous_page - !!(last && sliced_nodes.count > last) - end - - # Used by `pageInfo` - def start_cursor - if start_node = (respond_to?(:paged_nodes_array, true) ? paged_nodes_array : paged_nodes).first - return cursor_from_node(start_node) - else - return nil - end - end - - # Used by `pageInfo` - def end_cursor - if end_node = (respond_to?(:paged_nodes_array, true) ? paged_nodes_array : paged_nodes).last - return cursor_from_node(end_node) - else - return nil - end - end - - # An opaque operation which returns a connection-specific cursor. - def cursor_from_node(object) - raise GraphQL::RequiredImplementationMissingError, "must return a cursor for this object/connection pair" - end - - def inspect - "#" - end - - private - - # @param argument [nil, Integer] `first` or `last`, as provided by the client - # @param max_page_size [nil, Integer] - # @return [nil, Integer] `nil` if the input was `nil`, otherwise a value between `0` and `max_page_size` - def limit_pagination_argument(argument, max_page_size) - if argument - if argument < 0 - argument = 0 - elsif max_page_size && argument > max_page_size - argument = max_page_size - end - end - argument - end - - def paged_nodes - raise GraphQL::RequiredImplementationMissingError, "must return nodes for this connection after paging" - end - - def sliced_nodes - raise GraphQL::RequiredImplementationMissingError, "must return all nodes for this connection after chopping off first and last" - end - end - end -end diff --git a/lib/graphql/relay/connection_instrumentation.rb b/lib/graphql/relay/connection_instrumentation.rb deleted file mode 100644 index 3f9b8a09aaa..00000000000 --- a/lib/graphql/relay/connection_instrumentation.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - # Provided a GraphQL field which returns a collection of nodes, - # wrap that field to expose those nodes as a connection. - # - # The original resolve proc is used to fetch nodes, - # then a connection implementation is fetched with {BaseConnection.connection_for_nodes}. - module ConnectionInstrumentation - def self.default_arguments - @default_arguments ||= begin - argument_definitions = [ - ["first", GraphQL::DEPRECATED_INT_TYPE, "Returns the first _n_ elements from the list."], - ["after", GraphQL::DEPRECATED_STRING_TYPE, "Returns the elements in the list that come after the specified cursor."], - ["last", GraphQL::DEPRECATED_INT_TYPE, "Returns the last _n_ elements from the list."], - ["before", GraphQL::DEPRECATED_STRING_TYPE, "Returns the elements in the list that come before the specified cursor."], - ] - - argument_definitions.reduce({}) do |memo, arg_defn| - argument = GraphQL::Argument.new - name, type, description = arg_defn - argument.name = name - argument.type = type - argument.description = description - memo[argument.name.to_s] = argument - memo - end - end - end - - # Build a connection field from a {GraphQL::Field} by: - # - Merging in the default arguments - # - Transforming its resolve function to return a connection object - def self.instrument(type, field) - # Don't apply the wrapper to class-based fields, since they - # use Schema::Field::ConnectionFilter - if field.connection? && !field.metadata[:type_class] - connection_arguments = default_arguments.merge(field.arguments) - original_resolve = field.resolve_proc - original_lazy_resolve = field.lazy_resolve_proc - connection_resolve = GraphQL::Relay::ConnectionResolve.new(field, original_resolve) - connection_lazy_resolve = GraphQL::Relay::ConnectionResolve.new(field, original_lazy_resolve) - field.redefine( - resolve: connection_resolve, - lazy_resolve: connection_lazy_resolve, - arguments: connection_arguments, - ) - else - field - end - end - end - end -end diff --git a/lib/graphql/relay/connection_resolve.rb b/lib/graphql/relay/connection_resolve.rb deleted file mode 100644 index d5de14f6890..00000000000 --- a/lib/graphql/relay/connection_resolve.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - class ConnectionResolve - def initialize(field, underlying_resolve) - @field = field - @underlying_resolve = underlying_resolve - @max_page_size = field.connection_max_page_size - end - - def call(obj, args, ctx) - # in a lazy resolve hook, obj is the promise, - # get the object that the promise was - # originally derived from - parent = ctx.object - - nodes = @underlying_resolve.call(obj, args, ctx) - - if nodes.nil? || ctx.schema.lazy?(nodes) || nodes.is_a?(GraphQL::Execution::Execute::Skip) || ctx.wrapped_connection - nodes - else - ctx.wrapped_connection = true - build_connection(nodes, args, parent, ctx) - end - end - - private - - def build_connection(nodes, args, parent, ctx) - if nodes.is_a? GraphQL::ExecutionError - ctx.add_error(nodes) - nil - else - if parent.is_a?(GraphQL::Schema::Object) - parent = parent.object - end - connection_class = GraphQL::Relay::BaseConnection.connection_for_nodes(nodes) - connection_class.new(nodes, args, field: @field, max_page_size: @max_page_size, parent: parent, context: ctx) - end - end - end - end -end diff --git a/lib/graphql/relay/connection_type.rb b/lib/graphql/relay/connection_type.rb deleted file mode 100644 index d45a4328682..00000000000 --- a/lib/graphql/relay/connection_type.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - # @api deprecated - module ConnectionType - class << self - # @return [Boolean] If true, connection types get a `nodes` shortcut field - attr_accessor :default_nodes_field - # @return [Boolean] If true, connections check for reverse-direction `has*Page` values - attr_accessor :bidirectional_pagination - end - - self.default_nodes_field = false - self.bidirectional_pagination = false - - # @api deprecated - def self.create_type(wrapped_type, edge_type: nil, edge_class: GraphQL::Relay::Edge, nodes_field: ConnectionType.default_nodes_field, &block) - custom_edge_class = edge_class - - # Any call that would trigger `wrapped_type.ensure_defined` - # must be inside this lazy block, otherwise we get weird - # cyclical dependency errors :S - ObjectType.deprecated_define do - type_name = wrapped_type.is_a?(GraphQL::BaseType) ? wrapped_type.name : wrapped_type.graphql_name - edge_type ||= wrapped_type.edge_type - name("#{type_name}Connection") - description("The connection type for #{type_name}.") - field :edges, types[edge_type], "A list of edges.", edge_class: custom_edge_class, property: :edge_nodes - - if nodes_field - field :nodes, types[wrapped_type], "A list of nodes.", property: :edge_nodes - end - - field :pageInfo, !PageInfo, "Information to aid in pagination.", property: :page_info - relay_node_type(wrapped_type) - block && instance_eval(&block) - end - end - end - end -end diff --git a/lib/graphql/relay/edge.rb b/lib/graphql/relay/edge.rb deleted file mode 100644 index 29217059c03..00000000000 --- a/lib/graphql/relay/edge.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - # Mostly an internal concern. - # - # Wraps an object as a `node`, and exposes a connection-specific `cursor`. - class Edge - attr_reader :node, :connection - def initialize(node, connection) - @node = node - @connection = connection - end - - def cursor - @cursor ||= connection.cursor_from_node(node) - end - - def parent - @parent ||= connection.parent - end - - def inspect - "# #{node.inspect})>" - end - end - end -end diff --git a/lib/graphql/relay/edge_type.rb b/lib/graphql/relay/edge_type.rb deleted file mode 100644 index 381156835e0..00000000000 --- a/lib/graphql/relay/edge_type.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - module EdgeType - # @api deprecated - def self.create_type(wrapped_type, name: nil, &block) - GraphQL::ObjectType.define do - type_name = wrapped_type.is_a?(GraphQL::BaseType) ? wrapped_type.name : wrapped_type.graphql_name - name("#{type_name}Edge") - description "An edge in a connection." - field :node, wrapped_type, "The item at the end of the edge." - field :cursor, !types.String, "A cursor for use in pagination." - relay_node_type(wrapped_type) - block && instance_eval(&block) - end - end - end - end -end diff --git a/lib/graphql/relay/edges_instrumentation.rb b/lib/graphql/relay/edges_instrumentation.rb deleted file mode 100644 index b3ffa576ea2..00000000000 --- a/lib/graphql/relay/edges_instrumentation.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - module EdgesInstrumentation - def self.instrument(type, field) - if field.edges? - edges_resolve = EdgesResolve.new(edge_class: field.edge_class, resolve: field.resolve_proc) - edges_lazy_resolve = EdgesResolve.new(edge_class: field.edge_class, resolve: field.lazy_resolve_proc) - - field.redefine( - resolve: edges_resolve, - lazy_resolve: edges_lazy_resolve, - ) - else - field - end - end - - - class EdgesResolve - def initialize(edge_class:, resolve:) - @edge_class = edge_class - @resolve_proc = resolve - end - - # A user's custom Connection may return a lazy object, - # if so, handle it later. - def call(obj, args, ctx) - parent = ctx.object - nodes = @resolve_proc.call(obj, args, ctx) - if ctx.schema.lazy?(nodes) - nodes - else - nodes.map { |item| item.is_a?(GraphQL::Pagination::Connection::Edge) ? item : @edge_class.new(item, parent) } - end - end - end - end - end -end diff --git a/lib/graphql/relay/global_id_resolve.rb b/lib/graphql/relay/global_id_resolve.rb deleted file mode 100644 index 9fd605c521e..00000000000 --- a/lib/graphql/relay/global_id_resolve.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - class GlobalIdResolve - def initialize(type:) - @type = type - end - - def call(obj, args, ctx) - if obj.is_a?(GraphQL::Schema::Object) - obj = obj.object - end - type = @type.respond_to?(:graphql_definition) ? @type.graphql_definition : @type - ctx.query.schema.id_from_object(obj, type, ctx) - end - end - end -end diff --git a/lib/graphql/relay/mongo_relation_connection.rb b/lib/graphql/relay/mongo_relation_connection.rb deleted file mode 100644 index 0f06d215afa..00000000000 --- a/lib/graphql/relay/mongo_relation_connection.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - # A connection implementation to expose MongoDB collection objects. - # It works for: - # - `Mongoid::Criteria` - class MongoRelationConnection < RelationConnection - private - - def relation_offset(relation) - relation.options.skip - end - - def relation_limit(relation) - relation.options.limit - end - - def relation_count(relation) - # Must perform query (hence #to_a) to count results https://jira.mongodb.org/browse/MONGOID-2325 - relation.to_a.count - end - - def limit_nodes(sliced_nodes, limit) - if limit == 0 - if sliced_nodes.respond_to?(:none) # added in Mongoid 4.0 - sliced_nodes.without_options.none - else - sliced_nodes.where(id: nil) # trying to simulate #none for 3.1.7 - end - else - sliced_nodes.limit(limit) - end - end - end - - if defined?(Mongoid::Criteria) - BaseConnection.register_connection_implementation(Mongoid::Criteria, MongoRelationConnection) - end - - # Mongoid 5 and 6 - if defined?(Mongoid::Relations::Targets::Enumerable) - BaseConnection.register_connection_implementation(Mongoid::Relations::Targets::Enumerable, MongoRelationConnection) - end - - # Mongoid 7 - if defined?(Mongoid::Association::Referenced::HasMany::Targets::Enumerable) - BaseConnection.register_connection_implementation(Mongoid::Association::Referenced::HasMany::Targets::Enumerable, MongoRelationConnection) - end - end -end diff --git a/lib/graphql/relay/mutation.rb b/lib/graphql/relay/mutation.rb deleted file mode 100644 index 17c793f4492..00000000000 --- a/lib/graphql/relay/mutation.rb +++ /dev/null @@ -1,106 +0,0 @@ -# frozen_string_literal: true -require "graphql/relay/mutation/instrumentation" -require "graphql/relay/mutation/resolve" -require "graphql/relay/mutation/result" - -module GraphQL - module Relay - # @api deprecated - class Mutation - include GraphQL::Define::InstanceDefinable - accepts_definitions( - :name, :description, :resolve, - :return_type, - :return_interfaces, - input_field: GraphQL::Define::AssignArgument, - return_field: GraphQL::Define::AssignObjectField, - function: GraphQL::Define::AssignMutationFunction, - ) - attr_accessor :name, :description, :fields, :arguments - attr_writer :return_type, :return_interfaces - - ensure_defined( - :input_fields, :return_fields, :name, :description, - :fields, :arguments, :return_type, - :return_interfaces, :resolve=, - :field, :result_class, :input_type - ) - # For backwards compat, but do we need this separate API? - alias :return_fields :fields - alias :input_fields :arguments - - def initialize - GraphQL::Deprecation.warn "GraphQL::Relay::Mutation will be removed from GraphQL-Ruby 2.0, use GraphQL::Schema::RelayClassicMutation instead: https://graphql-ruby.org/mutations/mutation_classes" - @fields = {} - @arguments = {} - @has_generated_return_type = false - end - - def has_generated_return_type? - # Trigger the generation of the return type, if it is dynamically generated: - return_type - @has_generated_return_type - end - - def resolve=(new_resolve_proc) - @resolve_proc = new_resolve_proc - end - - def field - @field ||= begin - relay_mutation = self - field_resolve_proc = @resolve_proc - GraphQL::Field.define do - type(relay_mutation.return_type) - description(relay_mutation.description) - argument :input, !relay_mutation.input_type - resolve(field_resolve_proc) - mutation(relay_mutation) - end - end - end - - def return_interfaces - @return_interfaces ||= [] - end - - def return_type - @return_type ||= begin - @has_generated_return_type = true - relay_mutation = self - GraphQL::ObjectType.define do - name("#{relay_mutation.name}Payload") - description("Autogenerated return type of #{relay_mutation.name}") - field :clientMutationId, types.String, "A unique identifier for the client performing the mutation.", property: :client_mutation_id - interfaces relay_mutation.return_interfaces - relay_mutation.return_fields.each do |name, field_obj| - field name, field: field_obj - end - mutation(relay_mutation) - end - end - end - - def input_type - @input_type ||= begin - relay_mutation = self - input_object_type = GraphQL::InputObjectType.define do - name("#{relay_mutation.name}Input") - description("Autogenerated input type of #{relay_mutation.name}") - input_field :clientMutationId, types.String, "A unique identifier for the client performing the mutation." - mutation(relay_mutation) - end - input_fields.each do |name, arg| - input_object_type.arguments[name] = arg - end - - input_object_type - end - end - - def result_class - @result_class ||= Result.define_subclass(self) - end - end - end -end diff --git a/lib/graphql/relay/mutation/instrumentation.rb b/lib/graphql/relay/mutation/instrumentation.rb deleted file mode 100644 index 0ee417b5f8f..00000000000 --- a/lib/graphql/relay/mutation/instrumentation.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - class Mutation - # @api private - module Instrumentation - # Modify mutation `return_field` resolves by wrapping the returned object - # in a {Mutation::Result}. - # - # By using an instrumention, we can apply our wrapper _last_, - # giving users access to the original resolve function in earlier instrumentation. - def self.instrument(type, field) - if field.mutation.is_a?(GraphQL::Relay::Mutation) || (field.mutation.is_a?(Class) && field.mutation < GraphQL::Schema::RelayClassicMutation) - new_resolve = Mutation::Resolve.new(field.mutation, field.resolve_proc) - field.redefine(resolve: new_resolve) - else - field - end - end - end - end - end -end diff --git a/lib/graphql/relay/mutation/resolve.rb b/lib/graphql/relay/mutation/resolve.rb deleted file mode 100644 index 1c5f090d415..00000000000 --- a/lib/graphql/relay/mutation/resolve.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - class Mutation - # Wrap a user-provided resolve function, - # wrapping the returned value in a {Mutation::Result}. - # Also, pass the `clientMutationId` to that result object. - # @api private - class Resolve - def initialize(mutation, resolve) - @mutation = mutation - @resolve = resolve - @wrap_result = mutation.is_a?(GraphQL::Relay::Mutation) && mutation.has_generated_return_type? - @class_based = mutation.is_a?(Class) - end - - def call(obj, args, ctx) - mutation_result = begin - @resolve.call(obj, args[:input], ctx) - rescue GraphQL::ExecutionError => err - err - end - - ctx.schema.after_lazy(mutation_result) do |res| - build_result(res, args, ctx) - end - end - - private - - def build_result(mutation_result, args, ctx) - if mutation_result.is_a?(GraphQL::ExecutionError) - ctx.add_error(mutation_result) - mutation_result = nil - end - - if mutation_result.nil? - nil - elsif @wrap_result - if mutation_result && !mutation_result.is_a?(Hash) - raise StandardError, "Expected `#{mutation_result}` to be a Hash."\ - " Return a hash when using `return_field` or specify a custom `return_type`." - end - - @mutation.result_class.new(client_mutation_id: args[:input][:clientMutationId], result: mutation_result) - elsif @class_based - mutation_result[:client_mutation_id] = args[:input][:client_mutation_id] - mutation_result - else - mutation_result - end - end - end - end - end -end diff --git a/lib/graphql/relay/mutation/result.rb b/lib/graphql/relay/mutation/result.rb deleted file mode 100644 index e25efd246cf..00000000000 --- a/lib/graphql/relay/mutation/result.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - class Mutation - # Use this when the mutation's return type was generated from `return_field`s. - # It delegates field lookups to the hash returned from `resolve`. - # @api private - class Result - attr_reader :client_mutation_id - def initialize(client_mutation_id:, result:) - @client_mutation_id = client_mutation_id - result && result.each do |key, value| - self.public_send("#{key}=", value) - end - end - - class << self - attr_accessor :mutation - end - - # Build a subclass whose instances have a method - # for each of `mutation_defn`'s `return_field`s - # @param mutation_defn [GraphQL::Relay::Mutation] - # @return [Class] - def self.define_subclass(mutation_defn) - subclass = Class.new(self) do - mutation_result_methods = mutation_defn.return_type.all_fields.map do |f| - f.property || f.name - end - attr_accessor(*mutation_result_methods) - self.mutation = mutation_defn - end - subclass - end - end - end - end -end diff --git a/lib/graphql/relay/node.rb b/lib/graphql/relay/node.rb deleted file mode 100644 index 5170f618c33..00000000000 --- a/lib/graphql/relay/node.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - # Helpers for working with Relay-specific Node objects. - module Node - # @return [GraphQL::Field] a field for finding objects by their global ID. - def self.field(**kwargs, &block) - GraphQL::Deprecation.warn "GraphQL::Relay::Node.field will be removed from GraphQL-Ruby 2.0, use GraphQL::Types::Relay::NodeField instead" - # We have to define it fresh each time because - # its name will be modified and its description - # _may_ be modified. - field = GraphQL::Types::Relay::NodeField.graphql_definition - - if kwargs.any? || block - field = field.redefine(**kwargs, &block) - end - - field - end - - def self.plural_field(**kwargs, &block) - GraphQL::Deprecation.warn "GraphQL::Relay::Nodes.field will be removed from GraphQL-Ruby 2.0, use GraphQL::Types::Relay::NodesField instead" - field = GraphQL::Types::Relay::NodesField.graphql_definition - - if kwargs.any? || block - field = field.redefine(**kwargs, &block) - end - - field - end - - # @return [GraphQL::InterfaceType] The interface which all Relay types must implement - def self.interface - GraphQL::Deprecation.warn "GraphQL::Relay::Node.interface will be removed from GraphQL-Ruby 2.0, use GraphQL::Types::Relay::Node instead" - @interface ||= GraphQL::Types::Relay::Node.graphql_definition - end - end - end -end diff --git a/lib/graphql/relay/page_info.rb b/lib/graphql/relay/page_info.rb deleted file mode 100644 index 7bb4f10584f..00000000000 --- a/lib/graphql/relay/page_info.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - # Wrap a Connection and expose its page info - PageInfo = GraphQL::Types::Relay::PageInfo.graphql_definition - end -end diff --git a/lib/graphql/relay/range_add.rb b/lib/graphql/relay/range_add.rb index f04ae90ffae..13358fb670e 100644 --- a/lib/graphql/relay/range_add.rb +++ b/lib/graphql/relay/range_add.rb @@ -31,25 +31,18 @@ class RangeAdd # @param collection [Object] The list of items to wrap in a connection # @param item [Object] The newly-added item (will be wrapped in `edge_class`) + # @param context [GraphQL::Query::Context] The surrounding `ctx`, will be passed to the connection # @param parent [Object] The owner of `collection`, will be passed to the connection if provided - # @param context [GraphQL::Query::Context] The surrounding `ctx`, will be passed to the connection if provided (this is required for cursor encoders) # @param edge_class [Class] The class to wrap `item` with (defaults to the connection's edge class) - def initialize(collection:, item:, parent: nil, context: nil, edge_class: nil) - if context && context.schema.new_connections? - conn_class = context.schema.connections.wrapper_for(collection) - # The rest will be added by ConnectionExtension - @connection = conn_class.new(collection, parent: parent, context: context, edge_class: edge_class) - # Check if this connection supports it, to support old versions of GraphQL-Pro - @edge = if @connection.respond_to?(:range_add_edge) - @connection.range_add_edge(item) - else - @connection.edge_class.new(item, @connection) - end + def initialize(collection:, item:, context:, parent: nil, edge_class: nil) + conn_class = context.schema.connections.wrapper_for(collection) + # The rest will be added by ConnectionExtension + @connection = conn_class.new(collection, parent: parent, context: context, edge_class: edge_class) + # Check if this connection supports it, to support old versions of GraphQL-Pro + @edge = if @connection.respond_to?(:range_add_edge) + @connection.range_add_edge(item) else - connection_class = BaseConnection.connection_for_nodes(collection) - @connection = connection_class.new(collection, {}, parent: parent, context: context) - edge_class ||= Relay::Edge - @edge = edge_class.new(item, @connection) + @connection.edge_class.new(item, @connection) end @parent = parent diff --git a/lib/graphql/relay/relation_connection.rb b/lib/graphql/relay/relation_connection.rb deleted file mode 100644 index 98bb6f61082..00000000000 --- a/lib/graphql/relay/relation_connection.rb +++ /dev/null @@ -1,188 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - # A connection implementation to expose SQL collection objects. - # It works for: - # - `ActiveRecord::Relation` - # - `Sequel::Dataset` - class RelationConnection < BaseConnection - def cursor_from_node(item) - item_index = paged_nodes.index(item) - if item_index.nil? - raise("Can't generate cursor, item not found in connection: #{item}") - else - offset = item_index + 1 + ((paged_nodes_offset || 0) - (relation_offset(sliced_nodes) || 0)) - - if after - offset += offset_from_cursor(after) - elsif before - offset += offset_from_cursor(before) - 1 - sliced_nodes_count - end - - encode(offset.to_s) - end - end - - def has_next_page - if first - if defined?(ActiveRecord::Relation) && nodes.is_a?(ActiveRecord::Relation) - initial_offset = after ? offset_from_cursor(after) : 0 - return paged_nodes.length >= first && nodes.offset(first + initial_offset).exists? - end - return paged_nodes.length >= first && sliced_nodes_count > first - end - if GraphQL::Relay::ConnectionType.bidirectional_pagination && last - return sliced_nodes_count >= last - end - false - end - - def has_previous_page - if last - paged_nodes.length >= last && sliced_nodes_count > last - elsif GraphQL::Relay::ConnectionType.bidirectional_pagination && after - # We've already paginated through the collection a bit, - # there are nodes behind us - offset_from_cursor(after) > 0 - else - false - end - end - - def first - @first ||= begin - capped = limit_pagination_argument(arguments[:first], max_page_size) - if capped.nil? && last.nil? - capped = max_page_size - end - capped - end - end - - def last - @last ||= limit_pagination_argument(arguments[:last], max_page_size) - end - - private - - # apply first / last limit results - # @return [Array] - def paged_nodes - return @paged_nodes if defined? @paged_nodes - - items = sliced_nodes - - if first - if relation_limit(items).nil? || relation_limit(items) > first - items = items.limit(first) - end - end - - if last - if relation_limit(items) - if last <= relation_limit(items) - offset = (relation_offset(items) || 0) + (relation_limit(items) - last) - items = items.offset(offset).limit(last) - end - else - slice_count = relation_count(items) - offset = (relation_offset(items) || 0) + slice_count - [last, slice_count].min - items = items.offset(offset).limit(last) - end - end - - if max_page_size && !first && !last - if relation_limit(items).nil? || relation_limit(items) > max_page_size - items = items.limit(max_page_size) - end - end - - # Store this here so we can convert the relation to an Array - # (this avoids an extra DB call on Sequel) - @paged_nodes_offset = relation_offset(items) - @paged_nodes = items.to_a - end - - def paged_nodes_offset - paged_nodes && @paged_nodes_offset - end - - def relation_offset(relation) - if relation.respond_to?(:offset_value) - relation.offset_value - else - relation.opts[:offset] - end - end - - def relation_limit(relation) - if relation.respond_to?(:limit_value) - relation.limit_value - else - relation.opts[:limit] - end - end - - # If a relation contains a `.group` clause, a `.count` will return a Hash. - def relation_count(relation) - count_or_hash = if(defined?(ActiveRecord::Relation) && relation.is_a?(ActiveRecord::Relation)) - relation.respond_to?(:unscope)? relation.unscope(:order).count(:all) : relation.count(:all) - else # eg, Sequel::Dataset, don't mess up others - relation.count - end - count_or_hash.is_a?(Integer) ? count_or_hash : count_or_hash.length - end - - # Apply cursors to edges - def sliced_nodes - return @sliced_nodes if defined? @sliced_nodes - - @sliced_nodes = nodes - - if after - offset = (relation_offset(@sliced_nodes) || 0) + offset_from_cursor(after) - @sliced_nodes = @sliced_nodes.offset(offset) - end - - if before && after - if offset_from_cursor(after) < offset_from_cursor(before) - @sliced_nodes = limit_nodes(@sliced_nodes, offset_from_cursor(before) - offset_from_cursor(after) - 1) - else - @sliced_nodes = limit_nodes(@sliced_nodes, 0) - end - - elsif before - @sliced_nodes = limit_nodes(@sliced_nodes, offset_from_cursor(before) - 1) - end - - @sliced_nodes - end - - def limit_nodes(sliced_nodes, limit) - if limit > 0 || defined?(ActiveRecord::Relation) && sliced_nodes.is_a?(ActiveRecord::Relation) - sliced_nodes.limit(limit) - else - sliced_nodes.where(false) - end - end - - def sliced_nodes_count - return @sliced_nodes_count if defined? @sliced_nodes_count - - # If a relation contains a `.group` clause, a `.count` will return a Hash. - @sliced_nodes_count = relation_count(sliced_nodes) - end - - def offset_from_cursor(cursor) - decode(cursor).to_i - end - end - - if defined?(ActiveRecord::Relation) - BaseConnection.register_connection_implementation(ActiveRecord::Relation, RelationConnection) - end - if defined?(Sequel::Dataset) - BaseConnection.register_connection_implementation(Sequel::Dataset, RelationConnection) - end - end -end diff --git a/lib/graphql/relay/type_extensions.rb b/lib/graphql/relay/type_extensions.rb deleted file mode 100644 index f1ca9d27f32..00000000000 --- a/lib/graphql/relay/type_extensions.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Relay - # Mixin for Relay-related methods in type objects - # (used by BaseType and Schema::Member). - module TypeExtensions - # @return [GraphQL::ObjectType] The default connection type for this object type - def connection_type - @connection_type ||= define_connection - end - - # Define a custom connection type for this object type - # @return [GraphQL::ObjectType] - def define_connection(**kwargs, &block) - GraphQL::Deprecation.warn ".connection_type and .define_connection will be removed from GraphQL-Ruby 2.0, use class-based type definitions instead: https://graphql-ruby.org/schema/class_based_api.html" - GraphQL::Relay::ConnectionType.create_type(self, **kwargs, &block) - end - - # @return [GraphQL::ObjectType] The default edge type for this object type - def edge_type - @edge_type ||= define_edge - end - - # Define a custom edge type for this object type - # @return [GraphQL::ObjectType] - def define_edge(**kwargs, &block) - GraphQL::Deprecation.warn ".edge_type and .define_edge will be removed from GraphQL-Ruby 2.0, use class-based type definitions instead: https://graphql-ruby.org/schema/class_based_api.html" - GraphQL::Relay::EdgeType.create_type(self, **kwargs, &block) - end - end - end -end diff --git a/lib/graphql/rubocop.rb b/lib/graphql/rubocop.rb new file mode 100644 index 00000000000..c537e0b530f --- /dev/null +++ b/lib/graphql/rubocop.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require "graphql/rubocop/graphql/default_null_true" +require "graphql/rubocop/graphql/default_required_true" +require "graphql/rubocop/graphql/field_type_in_block" +require "graphql/rubocop/graphql/root_types_in_block" diff --git a/lib/graphql/rubocop/graphql/base_cop.rb b/lib/graphql/rubocop/graphql/base_cop.rb new file mode 100644 index 00000000000..25b46749823 --- /dev/null +++ b/lib/graphql/rubocop/graphql/base_cop.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +require "rubocop" + +module GraphQL + module Rubocop + module GraphQL + class BaseCop < RuboCop::Cop::Base + extend RuboCop::Cop::AutoCorrector + + # Return the source of `send_node`, but without the keyword argument represented by `pair_node` + def source_without_keyword_argument(send_node, pair_node) + # work back to the preceding comma + first_pos = pair_node.location.expression.begin_pos + end_pos = pair_node.location.expression.end_pos + node_source = send_node.source_range.source + node_first_pos = send_node.location.expression.begin_pos + + relative_first_pos = first_pos - node_first_pos + relative_last_pos = end_pos - node_first_pos + + begin_removal_pos = relative_first_pos + while node_source[begin_removal_pos] != "," + begin_removal_pos -= 1 + if begin_removal_pos < 1 + raise "Invariant: somehow backtracked to beginning of node looking for a comma (node source: #{node_source.inspect})" + end + end + + end_removal_pos = relative_last_pos + cleaned_node_source = node_source[0...begin_removal_pos] + node_source[end_removal_pos..-1] + cleaned_node_source + end + end + end + end +end diff --git a/lib/graphql/rubocop/graphql/default_null_true.rb b/lib/graphql/rubocop/graphql/default_null_true.rb new file mode 100644 index 00000000000..edfc641bf38 --- /dev/null +++ b/lib/graphql/rubocop/graphql/default_null_true.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +require_relative "base_cop" + +module GraphQL + module Rubocop + module GraphQL + # Identify (and auto-correct) any field configuration which duplicates + # the default `null: true` property. + # + # `null: true` is default because nullable fields can always be converted + # to non-null fields (`null: false`) without a breaking change. (The opposite change, from `null: false` + # to `null: true`, change.) + # + # @example + # # Both of these define `name: String` in GraphQL: + # + # # bad + # field :name, String, null: true + # + # # good + # field :name, String + # + class DefaultNullTrue < BaseCop + MSG = "`null: true` is the default and can be removed." + + def_node_matcher :field_config_with_null_true?, <<-Pattern + ( + send nil? :field ... (hash $(pair (sym :null) (true)) ...) + ) + Pattern + + def on_send(node) + field_config_with_null_true?(node) do |null_config| + add_offense(null_config) do |corrector| + cleaned_node_source = source_without_keyword_argument(node, null_config) + corrector.replace(node.source_range, cleaned_node_source) + end + end + end + end + end + end +end diff --git a/lib/graphql/rubocop/graphql/default_required_true.rb b/lib/graphql/rubocop/graphql/default_required_true.rb new file mode 100644 index 00000000000..d3ba15a9018 --- /dev/null +++ b/lib/graphql/rubocop/graphql/default_required_true.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +require_relative "./base_cop" + +module GraphQL + module Rubocop + module GraphQL + # Identify (and auto-correct) any argument configuration which duplicates + # the default `required: true` property. + # + # `required: true` is default because required arguments can always be converted + # to optional arguments (`required: false`) without a breaking change. (The opposite change, from `required: false` + # to `required: true`, change.) + # + # @example + # # Both of these define `id: ID!` in GraphQL: + # + # # bad + # argument :id, ID, required: true + # + # # good + # argument :id, ID + # + class DefaultRequiredTrue < BaseCop + MSG = "`required: true` is the default and can be removed." + + def_node_matcher :argument_config_with_required_true?, <<-Pattern + ( + send {nil? _} :argument ... (hash <$(pair (sym :required) (true)) ...>) + ) + Pattern + + def on_send(node) + argument_config_with_required_true?(node) do |required_config| + add_offense(required_config) do |corrector| + cleaned_node_source = source_without_keyword_argument(node, required_config) + corrector.replace(node, cleaned_node_source) + end + end + end + end + end + end +end diff --git a/lib/graphql/rubocop/graphql/field_type_in_block.rb b/lib/graphql/rubocop/graphql/field_type_in_block.rb new file mode 100644 index 00000000000..d98838a6d7b --- /dev/null +++ b/lib/graphql/rubocop/graphql/field_type_in_block.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true +require_relative "./base_cop" + +module GraphQL + module Rubocop + module GraphQL + # Identify (and auto-correct) any field whose type configuration isn't given + # in the configuration block. + # + # @example + # # bad, immediately causes Rails to load `app/graphql/types/thing.rb` + # field :thing, Types::Thing + # + # # good, defers loading until the file is needed + # field :thing do + # type(Types::Thing) + # end + # + class FieldTypeInBlock < BaseCop + MSG = "type configuration can be moved to a block to defer loading the type's file" + + BUILT_IN_SCALAR_NAMES = ["Float", "Int", "Integer", "String", "ID", "Boolean"] + def_node_matcher :field_config_with_inline_type, <<-Pattern + ( + send {nil? _} :field sym ${const array} ... + ) + Pattern + + def_node_matcher :field_config_with_inline_type_and_block, <<-Pattern + ( + block + (send {nil? _} :field sym ${const array} ...) ... + (args) + _ + + ) + Pattern + + def on_block(node) + ignore_node(node) + field_config_with_inline_type_and_block(node) do |type_const| + type_const_str = get_type_argument_str(node, type_const) + if ignore_inline_type_str?(type_const_str) + # Do nothing ... + else + add_offense(type_const) do |corrector| + cleaned_node_source = delete_type_argument(node, type_const) + field_indent = determine_field_indent(node) + cleaned_node_source.sub!(/(\{|do)/, "\\1\n#{field_indent} type #{type_const_str}") + corrector.replace(node, cleaned_node_source) + end + end + end + end + + def on_send(node) + return if part_of_ignored_node?(node) + field_config_with_inline_type(node) do |type_const| + type_const_str = get_type_argument_str(node, type_const) + if ignore_inline_type_str?(type_const_str) + # Do nothing -- not loading from another file + else + add_offense(type_const) do |corrector| + cleaned_node_source = delete_type_argument(node, type_const) + field_indent = determine_field_indent(node) + cleaned_node_source += " do\n#{field_indent} type #{type_const_str}\n#{field_indent}end" + corrector.replace(node, cleaned_node_source) + end + end + end + end + + + private + + def ignore_inline_type_str?(type_str) + if BUILT_IN_SCALAR_NAMES.include?(type_str) + true + elsif (inner_type_str = type_str.sub(/\[([A-Za-z]+)(, null: (true|false))?\]/, '\1')) && BUILT_IN_SCALAR_NAMES.include?(inner_type_str) + true + else + false + end + end + + def get_type_argument_str(send_node, type_const) + first_pos = type_const.location.expression.begin_pos + end_pos = type_const.location.expression.end_pos + node_source = send_node.source_range.source + node_first_pos = send_node.location.expression.begin_pos + + relative_first_pos = first_pos - node_first_pos + end_removal_pos = end_pos - node_first_pos + + node_source[relative_first_pos...end_removal_pos] + end + + def delete_type_argument(send_node, type_const) + first_pos = type_const.location.expression.begin_pos + end_pos = type_const.location.expression.end_pos + node_source = send_node.source_range.source + node_first_pos = send_node.location.expression.begin_pos + + relative_first_pos = first_pos - node_first_pos + end_removal_pos = end_pos - node_first_pos + + begin_removal_pos = relative_first_pos + while node_source[begin_removal_pos] != "," + begin_removal_pos -= 1 + if begin_removal_pos < 1 + raise "Invariant: somehow backtracked to beginning of node looking for a comma (node source: #{node_source.inspect})" + end + end + + node_source[0...begin_removal_pos] + node_source[end_removal_pos..-1] + end + + def determine_field_indent(send_node) + type_defn_node = send_node + + while (type_defn_node && !(type_defn_node.class_definition? || type_defn_node.module_definition?)) + type_defn_node = type_defn_node.parent + end + + if type_defn_node.nil? + raise "Invariant: Something went wrong in GraphQL-Ruby, couldn't find surrounding class definition for field (#{send_node}).\n\nPlease report this error on GitHub." + end + + type_defn_source = type_defn_node.source + indent_test_idx = send_node.location.expression.begin_pos - type_defn_node.source_range.begin_pos - 1 + field_indent = "".dup + while type_defn_source[indent_test_idx] == " " + field_indent << " " + indent_test_idx -= 1 + if indent_test_idx == 0 + raise "Invariant: somehow backtracted to beginning of class when looking for field indent (source: #{node_source.inspect})" + end + end + field_indent + end + end + end + end +end diff --git a/lib/graphql/rubocop/graphql/root_types_in_block.rb b/lib/graphql/rubocop/graphql/root_types_in_block.rb new file mode 100644 index 00000000000..80cbb0a5b21 --- /dev/null +++ b/lib/graphql/rubocop/graphql/root_types_in_block.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +require_relative "./base_cop" + +module GraphQL + module Rubocop + module GraphQL + # Identify (and auto-correct) any root types in your schema file. + # + # @example + # # bad, immediately causes Rails to load `app/graphql/types/query.rb` + # query Types::Query + # + # # good, defers loading until the file is needed + # query { Types::Query } + # + class RootTypesInBlock < BaseCop + MSG = "type configuration can be moved to a block to defer loading the type's file" + + def_node_matcher :root_type_config_without_block, <<-Pattern + ( + send nil? {:query :mutation :subscription} const + ) + Pattern + + def on_send(node) + root_type_config_without_block(node) do + add_offense(node) do |corrector| + new_node_source = node.source_range.source + new_node_source.sub!(/(query|mutation|subscription)/, '\1 {') + new_node_source << " }" + corrector.replace(node, new_node_source) + end + end + end + end + end + end +end diff --git a/lib/graphql/runtime_error.rb b/lib/graphql/runtime_error.rb new file mode 100644 index 00000000000..c6838afdc3e --- /dev/null +++ b/lib/graphql/runtime_error.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module GraphQL + class RuntimeError < Error + include GraphQL::Execution::Finalizer + end +end diff --git a/lib/graphql/scalar_type.rb b/lib/graphql/scalar_type.rb deleted file mode 100644 index 8526646cbcf..00000000000 --- a/lib/graphql/scalar_type.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # @api deprecated - class ScalarType < GraphQL::BaseType - extend Define::InstanceDefinable::DeprecatedDefine - - accepts_definitions :coerce, :coerce_input, :coerce_result - ensure_defined :coerce_non_null_input, :coerce_result - - module NoOpCoerce - def self.call(val, ctx) - val - end - end - - def initialize - super - self.coerce = NoOpCoerce - end - - def coerce=(proc) - self.coerce_input = proc - self.coerce_result = proc - end - - def coerce_input=(coerce_input_fn) - if !coerce_input_fn.nil? - @coerce_input_proc = ensure_two_arg(coerce_input_fn, :coerce_input) - end - end - - def coerce_result(value, ctx = nil) - if ctx.nil? - warn_deprecated_coerce("coerce_isolated_result") - ctx = GraphQL::Query::NullContext - end - @coerce_result_proc.call(value, ctx) - end - - def coerce_result=(coerce_result_fn) - if !coerce_result_fn.nil? - @coerce_result_proc = ensure_two_arg(coerce_result_fn, :coerce_result) - end - end - - def kind - GraphQL::TypeKinds::SCALAR - end - - private - - def ensure_two_arg(callable, method_name) - GraphQL::BackwardsCompatibility.wrap_arity(callable, from: 1, to: 2, name: "#{name}.#{method_name}(val, ctx)") - end - - def coerce_non_null_input(value, ctx) - @coerce_input_proc.call(raw_coercion_input(value), ctx) - end - - def raw_coercion_input(value) - if value.is_a?(GraphQL::Language::Nodes::InputObject) - value.to_h - elsif value.is_a?(Array) - value.map { |element| raw_coercion_input(element) } - else - value - end - end - - def validate_non_null_input(value, ctx) - result = Query::InputValidationResult.new - - coerced_result = begin - coerce_non_null_input(value, ctx) - rescue GraphQL::CoercionError => err - err - end - - if value.is_a?(GraphQL::Language::Nodes::Enum) || coerced_result.nil? - result.add_problem("Could not coerce value #{GraphQL::Language.serialize(value)} to #{name}") - elsif coerced_result.is_a?(GraphQL::CoercionError) - result.add_problem( - coerced_result.message, - message: coerced_result.message, - extensions: coerced_result.extensions - ) - end - result - end - end -end diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index 62f32b308e7..f6801ddbfb2 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -1,24 +1,16 @@ # frozen_string_literal: true +require "logger" require "graphql/schema/addition" +require "graphql/schema/always_visible" require "graphql/schema/base_64_encoder" -require "graphql/schema/catchall_middleware" -require "graphql/schema/default_parse_error" -require "graphql/schema/default_type_error" require "graphql/schema/find_inherited_value" require "graphql/schema/finder" -require "graphql/schema/invalid_type_error" require "graphql/schema/introspection_system" require "graphql/schema/late_bound_type" -require "graphql/schema/middleware_chain" -require "graphql/schema/null_mask" -require "graphql/schema/possible_types" -require "graphql/schema/rescue_middleware" +require "graphql/schema/ractor_shareable" require "graphql/schema/timeout" -require "graphql/schema/timeout_middleware" -require "graphql/schema/traversal" require "graphql/schema/type_expression" require "graphql/schema/unique_within_type" -require "graphql/schema/validation" require "graphql/schema/warden" require "graphql/schema/build_from_definition" @@ -40,16 +32,20 @@ require "graphql/schema/directive" require "graphql/schema/directive/deprecated" require "graphql/schema/directive/include" +require "graphql/schema/directive/one_of" require "graphql/schema/directive/skip" require "graphql/schema/directive/feature" require "graphql/schema/directive/flagged" require "graphql/schema/directive/transform" +require "graphql/schema/directive/specified_by" require "graphql/schema/type_membership" require "graphql/schema/resolver" require "graphql/schema/mutation" +require "graphql/schema/has_single_input_argument" require "graphql/schema/relay_classic_mutation" require "graphql/schema/subscription" +require "graphql/schema/visibility" module GraphQL # A GraphQL schema which may be queried with {GraphQL::Query}. @@ -65,12 +61,8 @@ module GraphQL # Any undiscoverable types may be provided with the `types` configuration. # # Schemas can restrict large incoming queries with `max_depth` and `max_complexity` configurations. - # (These configurations can be overridden by specific calls to {Schema#execute}) + # (These configurations can be overridden by specific calls to {Schema.execute}) # - # Schemas can specify how queries should be executed against them. - # `query_execution_strategy`, `mutation_execution_strategy` and `subscription_execution_strategy` - # each apply to corresponding root types. - # # # @example defining a schema # class MySchema < GraphQL::Schema # query QueryType @@ -79,16 +71,19 @@ module GraphQL # end # class Schema - extend Forwardable - extend GraphQL::Schema::Member::AcceptsDefinition extend GraphQL::Schema::Member::HasAstNode - include GraphQL::Define::InstanceDefinable - extend GraphQL::Define::InstanceDefinable::DeprecatedDefine extend GraphQL::Schema::FindInheritedValue + extend Autoload - class DuplicateTypeNamesError < GraphQL::Error - def initialize(type_name:, first_definition:, second_definition:, path:) - super("Multiple definitions for `#{type_name}`. Previously found #{first_definition.inspect} (#{first_definition.class}), then found #{second_definition.inspect} (#{second_definition.class}) at #{path.join(".")}") + autoload :BUILT_IN_TYPES, "graphql/schema/built_in_types" + + class DuplicateNamesError < GraphQL::Error + attr_reader :duplicated_name + def initialize(duplicated_name:, duplicated_definition_1:, duplicated_definition_2:) + @duplicated_name = duplicated_name + super( + "Found two visible definitions for `#{duplicated_name}`: #{duplicated_definition_1}, #{duplicated_definition_2}" + ) end end @@ -100,763 +95,164 @@ def initialize(type:) end end - module LazyHandlingMethods - # Call the given block at the right time, either: - # - Right away, if `value` is not registered with `lazy_resolve` - # - After resolving `value`, if it's registered with `lazy_resolve` (eg, `Promise`) - # @api private - def after_lazy(value, &block) - if lazy?(value) - GraphQL::Execution::Lazy.new do - result = sync_lazy(value) - # The returned result might also be lazy, so check it, too - after_lazy(result, &block) - end + # Error that is raised when [#Schema#from_definition] is passed an invalid schema definition string. + class InvalidDocumentError < Error; end; + + class << self + # Create schema with the result of an introspection query. + # @param introspection_result [Hash] A response from {GraphQL::Introspection::INTROSPECTION_QUERY} + # @return [Class] the schema described by `input` + def from_introspection(introspection_result) + GraphQL::Schema::Loader.load(introspection_result) + end + + # Create schema from an IDL schema or file containing an IDL definition. + # @param definition_or_path [String] A schema definition string, or a path to a file containing the definition + # @param default_resolve [<#call(type, field, obj, args, ctx)>] A callable for handling field resolution + # @param parser [Object] An object for handling definition string parsing (must respond to `parse`) + # @param using [Hash] Plugins to attach to the created schema with `use(key, value)` + # @return [Class] the schema described by `document` + def from_definition(definition_or_path, default_resolve: nil, parser: GraphQL.default_parser, using: {}, base_types: {}) + # If the file ends in `.graphql` or `.graphqls`, treat it like a filepath + if definition_or_path.end_with?(".graphql") || definition_or_path.end_with?(".graphqls") + GraphQL::Schema::BuildFromDefinition.from_definition_path( + self, + definition_or_path, + default_resolve: default_resolve, + parser: parser, + using: using, + base_types: base_types, + ) else - yield(value) if block_given? + GraphQL::Schema::BuildFromDefinition.from_definition( + self, + definition_or_path, + default_resolve: default_resolve, + parser: parser, + using: using, + base_types: base_types, + ) end end - # Override this method to handle lazy objects in a custom way. - # @param value [Object] an instance of a class registered with {.lazy_resolve} - # @return [Object] A GraphQL-ready (non-lazy) object - # @api private - def sync_lazy(value) - lazy_method = lazy_method_name(value) - if lazy_method - synced_value = value.public_send(lazy_method) - sync_lazy(synced_value) - else - value - end + def deprecated_graphql_definition + graphql_definition(silence_deprecation_warning: true) end - # @return [Symbol, nil] The method name to lazily resolve `obj`, or nil if `obj`'s class wasn't registered with {#lazy_resolve}. - def lazy_method_name(obj) - lazy_methods.get(obj) + # @return [GraphQL::Subscriptions] + def subscriptions(inherited: true) + defined?(@subscriptions) ? @subscriptions : (inherited ? find_inherited_value(:subscriptions, nil) : nil) end - # @return [Boolean] True if this object should be lazily resolved - def lazy?(obj) - !!lazy_method_name(obj) + def subscriptions=(new_implementation) + @subscriptions = new_implementation end - # Return a lazy if any of `maybe_lazies` are lazy, - # otherwise, call the block eagerly and return the result. - # @param maybe_lazies [Array] - # @api private - def after_any_lazies(maybe_lazies) - if maybe_lazies.any? { |l| lazy?(l) } - GraphQL::Execution::Lazy.all(maybe_lazies).then do |result| - yield result - end + # @param new_mode [Symbol] If configured, this will be used when `context: { trace_mode: ... }` isn't set. + def default_trace_mode(new_mode = NOT_CONFIGURED) + if !NOT_CONFIGURED.equal?(new_mode) + @default_trace_mode = new_mode + elsif defined?(@default_trace_mode) && + !@default_trace_mode.nil? # This `nil?` check seems necessary because of + # Ractors silently initializing @default_trace_mode somehow + @default_trace_mode + elsif superclass.respond_to?(:default_trace_mode) + superclass.default_trace_mode else - yield maybe_lazies + :default end end - end - - include LazyHandlingMethods - extend LazyHandlingMethods - - accepts_definitions \ - :query_execution_strategy, :mutation_execution_strategy, :subscription_execution_strategy, - :validate_timeout, :max_depth, :max_complexity, :default_max_page_size, - :orphan_types, :resolve_type, :type_error, :parse_error, - :error_bubbling, - :raise_definition_error, - :object_from_id, :id_from_object, - :default_mask, - :cursor_encoder, - # If these are given as classes, normalize them. Accept `nil` when building from string. - query: ->(schema, t) { schema.query = t.respond_to?(:graphql_definition) ? t.graphql_definition : t }, - mutation: ->(schema, t) { schema.mutation = t.respond_to?(:graphql_definition) ? t.graphql_definition : t }, - subscription: ->(schema, t) { schema.subscription = t.respond_to?(:graphql_definition) ? t.graphql_definition : t }, - disable_introspection_entry_points: ->(schema) { schema.disable_introspection_entry_points = true }, - disable_schema_introspection_entry_point: ->(schema) { schema.disable_schema_introspection_entry_point = true }, - disable_type_introspection_entry_point: ->(schema) { schema.disable_type_introspection_entry_point = true }, - directives: ->(schema, directives) { schema.directives = directives.reduce({}) { |m, d| m[d.graphql_name] = d; m } }, - directive: ->(schema, directive) { schema.directives[directive.graphql_name] = directive }, - instrument: ->(schema, type, instrumenter, after_built_ins: false) { - if type == :field && after_built_ins - type = :field_after_built_ins - end - schema.instrumenters[type] << instrumenter - }, - query_analyzer: ->(schema, analyzer) { - if analyzer == GraphQL::Authorization::Analyzer - GraphQL::Deprecation.warn("The Authorization query analyzer is deprecated. Authorizing at query runtime is generally a better idea.") - end - schema.query_analyzers << analyzer - }, - multiplex_analyzer: ->(schema, analyzer) { schema.multiplex_analyzers << analyzer }, - middleware: ->(schema, middleware) { schema.middleware << middleware }, - lazy_resolve: ->(schema, lazy_class, lazy_value_method) { schema.lazy_methods.set(lazy_class, lazy_value_method) }, - rescue_from: ->(schema, err_class, &block) { schema.rescue_from(err_class, &block) }, - tracer: ->(schema, tracer) { schema.tracers.push(tracer) } - - ensure_defined :introspection_system - - attr_accessor \ - :query, :mutation, :subscription, - :query_execution_strategy, :mutation_execution_strategy, :subscription_execution_strategy, - :validate_timeout, :max_depth, :max_complexity, :default_max_page_size, - :orphan_types, :directives, - :query_analyzers, :multiplex_analyzers, :instrumenters, :lazy_methods, - :cursor_encoder, - :ast_node, - :raise_definition_error, - :introspection_namespace, - :analysis_engine - - # [Boolean] True if this object bubbles validation errors up from a field into its parent InputObject, if there is one. - attr_accessor :error_bubbling - - # Single, long-lived instance of the provided subscriptions class, if there is one. - # @return [GraphQL::Subscriptions] - attr_accessor :subscriptions - - # @return [MiddlewareChain] MiddlewareChain which is applied to fields during execution - attr_accessor :middleware - - # @return [<#call(member, ctx)>] A callable for filtering members of the schema - # @see {Query.new} for query-specific filters with `except:` - attr_accessor :default_mask - - # @see {GraphQL::Query::Context} The parent class of these classes - # @return [Class] Instantiated for each query - attr_accessor :context_class - - # [Boolean] True if this object disables the introspection entry point fields - attr_accessor :disable_introspection_entry_points - - def disable_introspection_entry_points? - !!@disable_introspection_entry_points - end - - # [Boolean] True if this object disables the __schema introspection entry point field - attr_accessor :disable_schema_introspection_entry_point - - def disable_schema_introspection_entry_point? - !!@disable_schema_introspection_entry_point - end - - # [Boolean] True if this object disables the __type introspection entry point field - attr_accessor :disable_type_introspection_entry_point - - def disable_type_introspection_entry_point? - !!@disable_type_introspection_entry_point - end - - class << self - attr_writer :default_execution_strategy - end - - def default_filter - GraphQL::Filter.new(except: default_mask) - end - - # @return [Array<#trace(key, data)>] Tracers applied to every query - # @see {Query#tracers} for query-specific tracers - attr_reader :tracers - - DYNAMIC_FIELDS = ["__type", "__typename", "__schema"].freeze - - attr_reader :static_validator, :object_from_id_proc, :id_from_object_proc, :resolve_type_proc - - def initialize - @tracers = [] - @definition_error = nil - @orphan_types = [] - @directives = {} - self.class.default_directives.each do |name, dir| - @directives[name] = dir.graphql_definition - end - @static_validator = GraphQL::StaticValidation::Validator.new(schema: self) - @middleware = MiddlewareChain.new(final_step: GraphQL::Execution::Execute::FieldResolveStep) - @query_analyzers = [] - @multiplex_analyzers = [] - @resolve_type_proc = nil - @object_from_id_proc = nil - @id_from_object_proc = nil - @type_error_proc = DefaultTypeError - @parse_error_proc = DefaultParseError - @instrumenters = Hash.new { |h, k| h[k] = [] } - @lazy_methods = GraphQL::Execution::Lazy::LazyMethodMap.new - @lazy_methods.set(GraphQL::Execution::Lazy, :value) - @cursor_encoder = Base64Encoder - # For schema instances, default to legacy runtime modules - @analysis_engine = GraphQL::Analysis - @query_execution_strategy = GraphQL::Execution::Execute - @mutation_execution_strategy = GraphQL::Execution::Execute - @subscription_execution_strategy = GraphQL::Execution::Execute - @default_mask = GraphQL::Schema::NullMask - @rebuilding_artifacts = false - @context_class = GraphQL::Query::Context - @introspection_namespace = nil - @introspection_system = nil - @interpreter = false - @error_bubbling = false - @disable_introspection_entry_points = false - @disable_schema_introspection_entry_point = false - @disable_type_introspection_entry_point = false - end - - # @return [Boolean] True if using the new {GraphQL::Execution::Interpreter} - def interpreter? - query_execution_strategy == GraphQL::Execution::Interpreter && - mutation_execution_strategy == GraphQL::Execution::Interpreter && - subscription_execution_strategy == GraphQL::Execution::Interpreter - end - - def inspect - "#<#{self.class.name} ...>" - end - - def initialize_copy(other) - super - @orphan_types = other.orphan_types.dup - @directives = other.directives.dup - @static_validator = GraphQL::StaticValidation::Validator.new(schema: self) - @middleware = other.middleware.dup - @query_analyzers = other.query_analyzers.dup - @multiplex_analyzers = other.multiplex_analyzers.dup - @tracers = other.tracers.dup - @possible_types = GraphQL::Schema::PossibleTypes.new(self) - - @lazy_methods = other.lazy_methods.dup - - @instrumenters = Hash.new { |h, k| h[k] = [] } - other.instrumenters.each do |key, insts| - @instrumenters[key].concat(insts) - end - - if other.rescues? - @rescue_middleware = other.rescue_middleware - end - # This will be rebuilt when it's requested - # or during a later `define` call - @types = nil - @introspection_system = nil - end - - def rescue_from(*args, &block) - rescue_middleware.rescue_from(*args, &block) - end - - def remove_handler(*args, &block) - rescue_middleware.remove_handler(*args, &block) - end - - def using_ast_analysis? - @analysis_engine == GraphQL::Analysis::AST - end - - # For forwards-compatibility with Schema classes - alias :graphql_definition :itself - - def deprecated_define(**kwargs, &block) - super - ensure_defined - # Assert that all necessary configs are present: - validation_error = Validation.validate(self) - validation_error && raise(GraphQL::RequiredImplementationMissingError, validation_error) - rebuild_artifacts - - @definition_error = nil - nil - rescue StandardError => err - if @raise_definition_error || err.is_a?(CyclicalDefinitionError) || err.is_a?(GraphQL::RequiredImplementationMissingError) - raise - else - # Raise this error _later_ to avoid messing with Rails constant loading - @definition_error = err - end - nil - end - - # Attach `instrumenter` to this schema for instrumenting events of `instrumentation_type`. - # @param instrumentation_type [Symbol] - # @param instrumenter - # @return [void] - def instrument(instrumentation_type, instrumenter) - @instrumenters[instrumentation_type] << instrumenter - if instrumentation_type == :field - rebuild_artifacts - end - end - - # @return [Array] The root types of this schema - def root_types - @root_types ||= begin - rebuild_artifacts - @root_types - end - end - - # @see [GraphQL::Schema::Warden] Restricted access to members of a schema - # @return [GraphQL::Schema::TypeMap] `{ name => type }` pairs of types in this schema - def types - @types ||= begin - rebuild_artifacts - @types - end - end - - def get_type(type_name) - @types[type_name] - end - - # @api private - def introspection_system - @introspection_system ||= begin - rebuild_artifacts - @introspection_system - end - end - - # Returns a list of Arguments and Fields referencing a certain type - # @param type_name [String] - # @return [Hash] - def references_to(type_name = nil) - rebuild_artifacts unless defined?(@type_reference_map) - if type_name - @type_reference_map.fetch(type_name, []) - else - @type_reference_map - end - end - - # Returns a list of Union types in which a type is a member - # @param type [GraphQL::ObjectType] - # @return [Array] list of union types of which the type is a member - def union_memberships(type) - rebuild_artifacts unless defined?(@union_memberships) - @union_memberships.fetch(type.name, []) - end - - # Execute a query on itself. Raises an error if the schema definition is invalid. - # @see {Query#initialize} for arguments. - # @return [Hash] query result, ready to be serialized as JSON - def execute(query_str = nil, **kwargs) - if query_str - kwargs[:query] = query_str - end - # Some of the query context _should_ be passed to the multiplex, too - multiplex_context = if (ctx = kwargs[:context]) - { - backtrace: ctx[:backtrace], - tracers: ctx[:tracers], - } - else - {} - end - # Since we're running one query, don't run a multiplex-level complexity analyzer - all_results = multiplex([kwargs], max_complexity: nil, context: multiplex_context) - all_results[0] - end - - # Execute several queries on itself. Raises an error if the schema definition is invalid. - # @example Run several queries at once - # context = { ... } - # queries = [ - # { query: params[:query_1], variables: params[:variables_1], context: context }, - # { query: params[:query_2], variables: params[:variables_2], context: context }, - # ] - # results = MySchema.multiplex(queries) - # render json: { - # result_1: results[0], - # result_2: results[1], - # } - # - # @see {Query#initialize} for query keyword arguments - # @see {Execution::Multiplex#run_queries} for multiplex keyword arguments - # @param queries [Array] Keyword arguments for each query - # @param context [Hash] Multiplex-level context - # @return [Array] One result for each query in the input - def multiplex(queries, **kwargs) - with_definition_error_check { - GraphQL::Execution::Multiplex.run_all(self, queries, **kwargs) - } - end - - # Search for a schema member using a string path - # @example Finding a Field - # Schema.find("Ensemble.musicians") - # - # @see {GraphQL::Schema::Finder} for more examples - # @param path [String] A dot-separated path to the member - # @raise [Schema::Finder::MemberNotFoundError] if path could not be found - # @return [GraphQL::BaseType, GraphQL::Field, GraphQL::Argument, GraphQL::Directive] A GraphQL Schema Member - def find(path) - rebuild_artifacts unless defined?(@finder) - @find_cache[path] ||= @finder.find(path) - end - - # Resolve field named `field_name` for type `parent_type`. - # Handles dynamic fields `__typename`, `__type` and `__schema`, too - # @param parent_type [String, GraphQL::BaseType] - # @param field_name [String] - # @return [GraphQL::Field, nil] The field named `field_name` on `parent_type` - # @see [GraphQL::Schema::Warden] Restricted access to members of a schema - def get_field(parent_type, field_name) - with_definition_error_check do - parent_type_name = case parent_type - when GraphQL::BaseType, Class, Module - parent_type.graphql_name - when String - parent_type - else - raise "Unexpected parent_type: #{parent_type}" + def trace_class(new_class = nil) + if new_class + # If any modules were already added for `:default`, + # re-apply them here + mods = trace_modules_for(:default) + mods.each { |mod| new_class.include(mod) } + new_class.include(DefaultTraceClass) + trace_mode(:default, new_class) end + trace_class_for(:default, build: true) + end - defined_field = @instrumented_field_map[parent_type_name][field_name] - if defined_field - defined_field - elsif parent_type == query && (entry_point_field = introspection_system.entry_point(name: field_name)) - entry_point_field - elsif (dynamic_field = introspection_system.dynamic_field(name: field_name)) - dynamic_field + # @return [Class] Return the trace class to use for this mode, looking one up on the superclass if this Schema doesn't have one defined. + def trace_class_for(mode, build: false) + if (trace_class = own_trace_modes[mode]) + trace_class + elsif superclass.respond_to?(:trace_class_for) && (trace_class = superclass.trace_class_for(mode, build: false)) + trace_class + elsif build + own_trace_modes[mode] = build_trace_mode(mode) else nil end end - end - - # Fields for this type, after instrumentation is applied - # @return [Hash] - def get_fields(type) - @instrumented_field_map[type.graphql_name] - end - - def type_from_ast(ast_node, context:) - GraphQL::Schema::TypeExpression.build_type(self, ast_node) - end - - # @see [GraphQL::Schema::Warden] Restricted access to members of a schema - # @param type_defn [GraphQL::InterfaceType, GraphQL::UnionType] the type whose members you want to retrieve - # @param context [GraphQL::Query::Context] The context for the current query - # @return [Array] types which belong to `type_defn` in this schema - def possible_types(type_defn, context = GraphQL::Query::NullContext) - if context == GraphQL::Query::NullContext - @possible_types ||= GraphQL::Schema::PossibleTypes.new(self) - @possible_types.possible_types(type_defn, context) - else - # Use the incoming context to cache this instance -- - # if it were cached on the schema, we'd have a memory leak - # https://github.com/rmosolgo/graphql-ruby/issues/2878 - ns = context.namespace(:possible_types) - per_query_possible_types = ns[:possible_types] ||= GraphQL::Schema::PossibleTypes.new(self) - per_query_possible_types.possible_types(type_defn, context) - end - end - - # @see [GraphQL::Schema::Warden] Resticted access to root types - # @return [GraphQL::ObjectType, nil] - def root_type_for_operation(operation) - case operation - when "query" - query - when "mutation" - mutation - when "subscription" - subscription - else - raise ArgumentError, "unknown operation type: #{operation}" - end - end - - def execution_strategy_for_operation(operation) - case operation - when "query" - query_execution_strategy - when "mutation" - mutation_execution_strategy - when "subscription" - subscription_execution_strategy - else - raise ArgumentError, "unknown operation type: #{operation}" - end - end - - # Determine the GraphQL type for a given object. - # This is required for unions and interfaces (including Relay's `Node` interface) - # @see [GraphQL::Schema::Warden] Restricted access to members of a schema - # @param type [GraphQL::UnionType, GraphQL:InterfaceType] the abstract type which is being resolved - # @param object [Any] An application object which GraphQL is currently resolving on - # @param ctx [GraphQL::Query::Context] The context for the current query - # @return [GraphQL::ObjectType] The type for exposing `object` in GraphQL - def resolve_type(type, object, ctx = :__undefined__) - check_resolved_type(type, object, ctx) do |ok_type, ok_object, ok_ctx| - if @resolve_type_proc.nil? - raise(GraphQL::RequiredImplementationMissingError, "Can't determine GraphQL type for: #{ok_object.inspect}, define `resolve_type (type, obj, ctx) -> { ... }` inside `Schema.define`.") - end - @resolve_type_proc.call(ok_type, ok_object, ok_ctx) - end - end - - # This is a compatibility hack so that instance-level and class-level - # methods can get correctness checks without calling one another - # @api private - def check_resolved_type(type, object, ctx = :__undefined__) - if ctx == :__undefined__ - # Old method signature - ctx = object - object = type - type = nil - end - - if object.is_a?(GraphQL::Schema::Object) - object = object.object - end - if type.respond_to?(:graphql_definition) - type = type.graphql_definition + # Configure `trace_class` to be used whenever `context: { trace_mode: mode_name }` is requested. + # {default_trace_mode} is used when no `trace_mode: ...` is requested. + # + # When a `trace_class` is added this way, it will _not_ receive other modules added with `trace_with(...)` + # unless `trace_mode` is explicitly given. (This class will not receive any default trace modules.) + # + # Subclasses of the schema will use `trace_class` as a base class for this mode and those + # subclass also will _not_ receive default tracing modules. + # + # @param mode_name [Symbol] + # @param trace_class [Class] subclass of GraphQL::Tracing::Trace + # @return void + def trace_mode(mode_name, trace_class) + own_trace_modes[mode_name] = trace_class + nil end - # Prefer a type-local function; fall back to the schema-level function - type_proc = type && type.resolve_type_proc - type_result = if type_proc - type_proc.call(object, ctx) - else - yield(type, object, ctx) + def own_trace_modes + @own_trace_modes ||= {} end - if type_result.nil? - nil - else - after_lazy(type_result) do |resolved_type_result| - if resolved_type_result.respond_to?(:graphql_definition) - resolved_type_result = resolved_type_result.graphql_definition + def build_trace_mode(mode) + case mode + when :default + # Use the superclass's default mode if it has one, or else start an inheritance chain at the built-in base class. + base_class = (superclass.respond_to?(:trace_class_for) && superclass.trace_class_for(mode, build: true)) || GraphQL::Tracing::Trace + const_set(:DefaultTrace, Class.new(base_class) do + include DefaultTraceClass + end) + else + # First, see if the superclass has a custom-defined class for this. + # Then, if it doesn't, use this class's default trace + base_class = (superclass.respond_to?(:trace_class_for) && superclass.trace_class_for(mode)) || trace_class_for(:default, build: true) + # Prepare the default trace class if it hasn't been initialized yet + base_class ||= (own_trace_modes[:default] = build_trace_mode(:default)) + mods = trace_modules_for(mode) + if base_class < DefaultTraceClass + mods = trace_modules_for(:default) + mods end - if !resolved_type_result.is_a?(GraphQL::BaseType) - type_str = "#{resolved_type_result} (#{resolved_type_result.class.name})" - raise "resolve_type(#{object}) returned #{type_str}, but it should return a GraphQL type" - else - resolved_type_result + # Copy the existing default options into this mode's options + default_options = trace_options_for(:default) + add_trace_options_for(mode, default_options) + + Class.new(base_class) do + !mods.empty? && include(*mods) end end end - end - - def resolve_type=(new_resolve_type_proc) - callable = GraphQL::BackwardsCompatibility.wrap_arity(new_resolve_type_proc, from: 2, to: 3, last: true, name: "Schema#resolve_type(type, obj, ctx)") - @resolve_type_proc = callable - end - - # Fetch an application object by its unique id - # @param id [String] A unique identifier, provided previously by this GraphQL schema - # @param ctx [GraphQL::Query::Context] The context for the current query - # @return [Any] The application object identified by `id` - def object_from_id(id, ctx) - if @object_from_id_proc.nil? - raise(GraphQL::RequiredImplementationMissingError, "Can't fetch an object for id \"#{id}\" because the schema's `object_from_id (id, ctx) -> { ... }` function is not defined") - else - @object_from_id_proc.call(id, ctx) - end - end - - # @param new_proc [#call] A new callable for fetching objects by ID - def object_from_id=(new_proc) - @object_from_id_proc = new_proc - end - - # When we encounter a type error during query execution, we call this hook. - # - # You can use this hook to write a log entry, - # add a {GraphQL::ExecutionError} to the response (with `ctx.add_error`) - # or raise an exception and halt query execution. - # - # @example A `nil` is encountered by a non-null field - # type_error ->(err, query_ctx) { - # err.is_a?(GraphQL::InvalidNullError) # => true - # } - # - # @example An object doesn't resolve to one of a {UnionType}'s members - # type_error ->(err, query_ctx) { - # err.is_a?(GraphQL::UnresolvedTypeError) # => true - # } - # - # @see {DefaultTypeError} is the default behavior. - # @param err [GraphQL::TypeError] The error encountered during execution - # @param ctx [GraphQL::Query::Context] The context for the field where the error occurred - # @return void - def type_error(err, ctx) - @type_error_proc.call(err, ctx) - end - - # @param new_proc [#call] A new callable for handling type errors during execution - def type_error=(new_proc) - @type_error_proc = new_proc - end - - # Can't delegate to `class` - alias :_schema_class :class - def_delegators :_schema_class, :unauthorized_object, :unauthorized_field, :inaccessible_fields - def_delegators :_schema_class, :directive - def_delegators :_schema_class, :error_handler - def_delegators :_schema_class, :validate - - - # Given this schema member, find the class-based definition object - # whose `method_name` should be treated as an application hook - # @see {.visible?} - # @see {.accessible?} - def call_on_type_class(member, method_name, context, default:) - member = if member.respond_to?(:type_class) - member.type_class - else - member - end - if member.respond_to?(:relay_node_type) && (t = member.relay_node_type) - member = t + def own_trace_modules + @own_trace_modules ||= Hash.new { |h, k| h[k] = [] } end - if member.respond_to?(method_name) - member.public_send(method_name, context) - else - default - end - end - - def visible?(member, context) - call_on_type_class(member, :visible?, context, default: true) - end - - def accessible?(member, context) - call_on_type_class(member, :accessible?, context, default: true) - end - - # A function to call when {#execute} receives an invalid query string - # - # @see {DefaultParseError} is the default behavior. - # @param err [GraphQL::ParseError] The error encountered during parsing - # @param ctx [GraphQL::Query::Context] The context for the query where the error occurred - # @return void - def parse_error(err, ctx) - @parse_error_proc.call(err, ctx) - end - - # @param new_proc [#call] A new callable for handling parse errors during execution - def parse_error=(new_proc) - @parse_error_proc = new_proc - end - - # Get a unique identifier from this object - # @param object [Any] An application object - # @param type [GraphQL::BaseType] The current type definition - # @param ctx [GraphQL::Query::Context] the context for the current query - # @return [String] a unique identifier for `object` which clients can use to refetch it - def id_from_object(object, type, ctx) - if @id_from_object_proc.nil? - raise(GraphQL::RequiredImplementationMissingError, "Can't generate an ID for #{object.inspect} of type #{type}, schema's `id_from_object` must be defined") - else - @id_from_object_proc.call(object, type, ctx) - end - end - - # @param new_proc [#call] A new callable for generating unique IDs - def id_from_object=(new_proc) - @id_from_object_proc = new_proc - end - - # Create schema with the result of an introspection query. - # @param introspection_result [Hash] A response from {GraphQL::Introspection::INTROSPECTION_QUERY} - # @return [GraphQL::Schema] the schema described by `input` - def self.from_introspection(introspection_result) - GraphQL::Schema::Loader.load(introspection_result) - end - - # Create schema from an IDL schema or file containing an IDL definition. - # @param definition_or_path [String] A schema definition string, or a path to a file containing the definition - # @param default_resolve [<#call(type, field, obj, args, ctx)>] A callable for handling field resolution - # @param parser [Object] An object for handling definition string parsing (must respond to `parse`) - # @param using [Hash] Plugins to attach to the created schema with `use(key, value)` - # @return [Class] the schema described by `document` - def self.from_definition(definition_or_path, default_resolve: nil, parser: GraphQL.default_parser, using: {}) - # If the file ends in `.graphql`, treat it like a filepath - if definition_or_path.end_with?(".graphql") - GraphQL::Schema::BuildFromDefinition.from_definition_path( - definition_or_path, - default_resolve: default_resolve, - parser: parser, - using: using, - ) - else - GraphQL::Schema::BuildFromDefinition.from_definition( - definition_or_path, - default_resolve: default_resolve, - parser: parser, - using: using, - ) + # @return [Array] Modules added for tracing in `trace_mode`, including inherited ones + def trace_modules_for(trace_mode) + modules = own_trace_modules[trace_mode] + if superclass.respond_to?(:trace_modules_for) + modules += superclass.trace_modules_for(trace_mode) + end + modules end - end - # Error that is raised when [#Schema#from_definition] is passed an invalid schema definition string. - class InvalidDocumentError < Error; end; - - # Return the GraphQL IDL for the schema - # @param context [Hash] - # @param only [<#call(member, ctx)>] - # @param except [<#call(member, ctx)>] - # @return [String] - def to_definition(only: nil, except: nil, context: {}) - GraphQL::Schema::Printer.print_schema(self, only: only, except: except, context: context) - end - - # Return the GraphQL::Language::Document IDL AST for the schema - # @param context [Hash] - # @param only [<#call(member, ctx)>] - # @param except [<#call(member, ctx)>] - # @return [GraphQL::Language::Document] - def to_document(only: nil, except: nil, context: {}) - GraphQL::Language::DocumentFromSchemaDefinition.new(self, only: only, except: except, context: context).document - end - - # Return the Hash response of {Introspection::INTROSPECTION_QUERY}. - # @param context [Hash] - # @param only [<#call(member, ctx)>] - # @param except [<#call(member, ctx)>] - # @return [Hash] GraphQL result - def as_json(only: nil, except: nil, context: {}) - execute(Introspection.query(include_deprecated_args: true), only: only, except: except, context: context).to_h - end - - # Returns the JSON response of {Introspection::INTROSPECTION_QUERY}. - # @see {#as_json} - # @return [String] - def to_json(*args) - JSON.pretty_generate(as_json(*args)) - end - - def new_connections? - !!connections - end - - attr_accessor :connections - - class << self - extend Forwardable - # For compatibility, these methods all: - # - Cause the Schema instance to be created, if it hasn't been created yet - # - Delegate to that instance - # Eventually, the methods will be moved into this class, removing the need for the singleton. - def_delegators :graphql_definition, - # Execution - :execution_strategy_for_operation, - # Configuration - :metadata, :redefine, - :id_from_object_proc, :object_from_id_proc, - :id_from_object=, :object_from_id=, - :remove_handler - - # @return [GraphQL::Subscriptions] - attr_accessor :subscriptions # Returns the JSON response of {Introspection::INTROSPECTION_QUERY}. - # @see {#as_json} + # @see #as_json Return a Hash representation of the schema # @return [String] def to_json(**args) JSON.pretty_generate(as_json(**args)) @@ -864,20 +260,29 @@ def to_json(**args) # Return the Hash response of {Introspection::INTROSPECTION_QUERY}. # @param context [Hash] - # @param only [<#call(member, ctx)>] - # @param except [<#call(member, ctx)>] + # @param include_deprecated_args [Boolean] If true, deprecated arguments will be included in the JSON response + # @param include_schema_description [Boolean] If true, the schema's description will be queried and included in the response + # @param include_is_repeatable [Boolean] If true, `isRepeatable: true|false` will be included with the schema's directives + # @param include_specified_by_url [Boolean] If true, scalar types' `specifiedByUrl:` will be included in the response + # @param include_is_one_of [Boolean] If true, `isOneOf: true|false` will be included with input objects # @return [Hash] GraphQL result - def as_json(only: nil, except: nil, context: {}) - execute(Introspection.query(include_deprecated_args: true), only: only, except: except, context: context).to_h + def as_json(context: {}, include_deprecated_args: true, include_schema_description: false, include_is_repeatable: false, include_specified_by_url: false, include_is_one_of: false) + introspection_query = Introspection.query( + include_deprecated_args: include_deprecated_args, + include_schema_description: include_schema_description, + include_is_repeatable: include_is_repeatable, + include_is_one_of: include_is_one_of, + include_specified_by_url: include_specified_by_url, + ) + + execute(introspection_query, context: context).to_h end # Return the GraphQL IDL for the schema # @param context [Hash] - # @param only [<#call(member, ctx)>] - # @param except [<#call(member, ctx)>] # @return [String] - def to_definition(only: nil, except: nil, context: {}) - GraphQL::Schema::Printer.print_schema(self, only: only, except: except, context: context) + def to_definition(context: {}) + GraphQL::Schema::Printer.print_schema(self, context: context) end # Return the GraphQL::Language::Document IDL AST for the schema @@ -886,6 +291,17 @@ def to_document GraphQL::Language::DocumentFromSchemaDefinition.new(self).document end + # @return [String, nil] + def description(new_description = nil) + if new_description + @description = new_description + elsif defined?(@description) + @description + else + find_inherited_value(:description, nil) + end + end + def find(path) if !@finder @find_cache = {} @@ -894,28 +310,15 @@ def find(path) @find_cache[path] ||= @finder.find(path) end - def graphql_definition - @graphql_definition ||= to_graphql - end - - def default_filter - GraphQL::Filter.new(except: default_mask) - end - - def default_mask(new_mask = nil) - if new_mask - @own_default_mask = new_mask - else - @own_default_mask || find_inherited_value(:default_mask, Schema::NullMask) - end - end - def static_validator GraphQL::StaticValidation::Validator.new(schema: self) end + # Add `plugin` to this schema + # @param plugin [#use] A Schema plugin + # @return void def use(plugin, **kwargs) - if kwargs.any? + if !kwargs.empty? plugin.use(self, **kwargs) else plugin.use(self) @@ -927,84 +330,90 @@ def plugins find_inherited_value(:plugins, EMPTY_ARRAY) + own_plugins end - def to_graphql - schema_defn = self.new - schema_defn.raise_definition_error = true - schema_defn.query = query && query.graphql_definition - schema_defn.mutation = mutation && mutation.graphql_definition - schema_defn.subscription = subscription && subscription.graphql_definition - schema_defn.validate_timeout = validate_timeout - schema_defn.max_complexity = max_complexity - schema_defn.error_bubbling = error_bubbling - schema_defn.max_depth = max_depth - schema_defn.default_max_page_size = default_max_page_size - schema_defn.orphan_types = orphan_types.map(&:graphql_definition) - schema_defn.disable_introspection_entry_points = disable_introspection_entry_points? - schema_defn.disable_schema_introspection_entry_point = disable_schema_introspection_entry_point? - schema_defn.disable_type_introspection_entry_point = disable_type_introspection_entry_point? - - prepped_dirs = {} - directives.each { |k, v| prepped_dirs[k] = v.graphql_definition} - schema_defn.directives = prepped_dirs - schema_defn.introspection_namespace = introspection - schema_defn.resolve_type = method(:resolve_type) - schema_defn.object_from_id = method(:object_from_id) - schema_defn.id_from_object = method(:id_from_object) - schema_defn.type_error = method(:type_error) - schema_defn.context_class = context_class - schema_defn.cursor_encoder = cursor_encoder - schema_defn.tracers.concat(tracers) - schema_defn.query_analyzers.concat(query_analyzers) - schema_defn.analysis_engine = analysis_engine - - schema_defn.middleware.concat(all_middleware) - schema_defn.multiplex_analyzers.concat(multiplex_analyzers) - schema_defn.query_execution_strategy = query_execution_strategy - schema_defn.mutation_execution_strategy = mutation_execution_strategy - schema_defn.subscription_execution_strategy = subscription_execution_strategy - schema_defn.default_mask = default_mask - instrumenters.each do |step, insts| - insts.each do |inst| - schema_defn.instrumenters[step] << inst - end - end - - lazy_methods.each do |lazy_class, value_method| - schema_defn.lazy_methods.set(lazy_class, value_method) - end - - error_handler.each_rescue do |err_class, handler| - schema_defn.rescue_from(err_class, &handler) - end - - schema_defn.subscriptions ||= self.subscriptions + attr_writer :null_context - if !schema_defn.interpreter? - schema_defn.instrumenters[:query] << GraphQL::Schema::Member::Instrumentation - end - - if new_connections? - schema_defn.connections = self.connections - end - - schema_defn.send(:rebuild_artifacts) - - schema_defn + def null_context + @null_context || GraphQL::Query::NullContext.instance end # Build a map of `{ name => type }` and return it # @return [Hash Class>] A dictionary of type classes by their GraphQL name # @see get_type Which is more efficient for finding _one type_ by name, because it doesn't merge hashes. - def types - non_introspection_types.merge(introspection_system.types) + def types(context = null_context) + if use_visibility_profile? + types = Visibility::Profile.from_context(context, self) + return types.all_types_h + end + all_types = non_introspection_types.merge(introspection_system.types) + visible_types = {} + all_types.each do |k, v| + visible_types[k] =if v.is_a?(Array) + visible_t = nil + v.each do |t| + if t.visible?(context) + if visible_t.nil? + visible_t = t + else + raise DuplicateNamesError.new( + duplicated_name: k, duplicated_definition_1: visible_t.inspect, duplicated_definition_2: t.inspect + ) + end + end + end + visible_t + else + v + end + end + visible_types end # @param type_name [String] + # @param context [GraphQL::Query::Context] Used for filtering definitions at query-time + # @param use_visibility_profile Private, for migration to {Schema::Visibility} # @return [Module, nil] A type, or nil if there's no type called `type_name` - def get_type(type_name) - own_types[type_name] || - introspection_system.types[type_name] || - find_inherited_value(:types, EMPTY_HASH)[type_name] + def get_type(type_name, context = null_context, use_visibility_profile = use_visibility_profile?) + if use_visibility_profile + profile = Visibility::Profile.from_context(context, self) + return profile.type(type_name) + end + local_entry = own_types[type_name] + type_defn = case local_entry + when nil + nil + when Array + if context.respond_to?(:types) && context.types.is_a?(GraphQL::Schema::Visibility::Profile) + local_entry + else + visible_t = nil + warden = Warden.from_context(context) + local_entry.each do |t| + if warden.visible_type?(t, context) + if visible_t.nil? + visible_t = t + else + raise DuplicateNamesError.new( + duplicated_name: type_name, duplicated_definition_1: visible_t.inspect, duplicated_definition_2: t.inspect + ) + end + end + end + visible_t + end + when Module + local_entry + else + raise "Invariant: unexpected own_types[#{type_name.inspect}]: #{local_entry.inspect}" + end + + type_defn || + introspection_system.types[type_name] || # todo context-specific introspection? + (superclass.respond_to?(:get_type) ? superclass.get_type(type_name, context, use_visibility_profile) : nil) + end + + # @return [Boolean] Does this schema have _any_ definition for a type named `type_name`, regardless of visibility? + def has_defined_type?(type_name) + own_types.key?(type_name) || introspection_system.types.key?(type_name) || (superclass.respond_to?(:has_defined_type?) ? superclass.has_defined_type?(type_name) : false) end # @api private @@ -1026,55 +435,127 @@ def connections end end - def new_connections? - !!connections - end - - def query(new_query_object = nil) - if new_query_object + # Get or set the root `query { ... }` object for this schema. + # + # @example Using `Types::Query` as the entry-point + # query { Types::Query } + # + # @param new_query_object [Class] The root type to use for queries + # @param lazy_load_block If a block is given, then it will be called when GraphQL-Ruby needs the root query type. + # @return [Class, nil] The configured query root type, if there is one. + def query(new_query_object = nil, &lazy_load_block) + if new_query_object || block_given? if @query_object - raise GraphQL::Error, "Second definition of `query(...)` (#{new_query_object.inspect}) is invalid, already configured with #{@query_object.inspect}" + dup_defn = new_query_object || yield + raise GraphQL::Error, "Second definition of `query(...)` (#{dup_defn.inspect}) is invalid, already configured with #{@query_object.inspect}" + elsif use_visibility_profile? + if block_given? + if visibility.preload? + @query_object = lazy_load_block.call + self.visibility.query_configured(@query_object) + else + @query_object = lazy_load_block + end + else + @query_object = new_query_object + self.visibility.query_configured(@query_object) + end else - @query_object = new_query_object - add_type_and_traverse(new_query_object, root: true) - nil + @query_object = new_query_object || lazy_load_block.call + add_type_and_traverse(@query_object, root: true) end + nil + elsif @query_object.is_a?(Proc) + @query_object = @query_object.call + self.visibility&.query_configured(@query_object) + @query_object else @query_object || find_inherited_value(:query) end end - def mutation(new_mutation_object = nil) - if new_mutation_object + # Get or set the root `mutation { ... }` object for this schema. + # + # @example Using `Types::Mutation` as the entry-point + # mutation { Types::Mutation } + # + # @param new_mutation_object [Class] The root type to use for mutations + # @param lazy_load_block If a block is given, then it will be called when GraphQL-Ruby needs the root mutation type. + # @return [Class, nil] The configured mutation root type, if there is one. + def mutation(new_mutation_object = nil, &lazy_load_block) + if new_mutation_object || block_given? if @mutation_object - raise GraphQL::Error, "Second definition of `mutation(...)` (#{new_mutation_object.inspect}) is invalid, already configured with #{@mutation_object.inspect}" + dup_defn = new_mutation_object || yield + raise GraphQL::Error, "Second definition of `mutation(...)` (#{dup_defn.inspect}) is invalid, already configured with #{@mutation_object.inspect}" + elsif use_visibility_profile? + if block_given? + if visibility.preload? + @mutation_object = lazy_load_block.call + self.visibility.mutation_configured(@mutation_object) + else + @mutation_object = lazy_load_block + end + else + @mutation_object = new_mutation_object + self.visibility.mutation_configured(@mutation_object) + end else - @mutation_object = new_mutation_object - add_type_and_traverse(new_mutation_object, root: true) - nil + @mutation_object = new_mutation_object || lazy_load_block.call + add_type_and_traverse(@mutation_object, root: true) end + nil + elsif @mutation_object.is_a?(Proc) + @mutation_object = @mutation_object.call + self.visibility&.mutation_configured(@mutation_object) + @mutation_object else @mutation_object || find_inherited_value(:mutation) end end - def subscription(new_subscription_object = nil) - if new_subscription_object + # Get or set the root `subscription { ... }` object for this schema. + # + # @example Using `Types::Subscription` as the entry-point + # subscription { Types::Subscription } + # + # @param new_subscription_object [Class] The root type to use for subscriptions + # @param lazy_load_block If a block is given, then it will be called when GraphQL-Ruby needs the root subscription type. + # @return [Class, nil] The configured subscription root type, if there is one. + def subscription(new_subscription_object = nil, &lazy_load_block) + if new_subscription_object || block_given? if @subscription_object - raise GraphQL::Error, "Second definition of `subscription(...)` (#{new_subscription_object.inspect}) is invalid, already configured with #{@subscription_object.inspect}" + dup_defn = new_subscription_object || yield + raise GraphQL::Error, "Second definition of `subscription(...)` (#{dup_defn.inspect}) is invalid, already configured with #{@subscription_object.inspect}" + elsif use_visibility_profile? + if block_given? + if visibility.preload? + @subscription_object = lazy_load_block.call + visibility.subscription_configured(@subscription_object) + else + @subscription_object = lazy_load_block + end + else + @subscription_object = new_subscription_object + self.visibility.subscription_configured(@subscription_object) + end + add_subscription_extension_if_necessary else - @subscription_object = new_subscription_object + @subscription_object = new_subscription_object || lazy_load_block.call add_subscription_extension_if_necessary - add_type_and_traverse(new_subscription_object, root: true) - nil + add_type_and_traverse(@subscription_object, root: true) end + nil + elsif @subscription_object.is_a?(Proc) + @subscription_object = @subscription_object.call + add_subscription_extension_if_necessary + self.visibility.subscription_configured(@subscription_object) + @subscription_object else @subscription_object || find_inherited_value(:subscription) end end - # @see [GraphQL::Schema::Warden] Resticted access to root types - # @return [GraphQL::ObjectType, nil] + # @api private def root_type_for_operation(operation) case operation when "query" @@ -1088,34 +569,86 @@ def root_type_for_operation(operation) end end + # @return [Array] The root types (query, mutation, subscription) defined for this schema def root_types - @root_types + if use_visibility_profile? + [query, mutation, subscription].compact + else + @root_types + end + end + + # @api private + def warden_class + if defined?(@warden_class) + @warden_class + elsif superclass.respond_to?(:warden_class) + superclass.warden_class + else + GraphQL::Schema::Warden + end + end + + # @api private + attr_writer :warden_class + + # @api private + def visibility_profile_class + if defined?(@visibility_profile_class) + @visibility_profile_class + elsif superclass.respond_to?(:visibility_profile_class) + superclass.visibility_profile_class + else + GraphQL::Schema::Visibility::Profile + end + end + + # @api private + attr_writer :visibility_profile_class, :use_visibility_profile + # @api private + attr_accessor :visibility + # @api private + def use_visibility_profile? + if defined?(@use_visibility_profile) + @use_visibility_profile + elsif superclass.respond_to?(:use_visibility_profile?) + superclass.use_visibility_profile? + else + false + end end # @param type [Module] The type definition whose possible types you want to see + # @param context [GraphQL::Query::Context] used for filtering visible possible types at runtime + # @param use_visibility_profile Private, for migration to {Schema::Visibility} # @return [Hash] All possible types, if no `type` is given. # @return [Array] Possible types for `type`, if it's given. - def possible_types(type = nil, context = GraphQL::Query::NullContext) + def possible_types(type = nil, context = null_context, use_visibility_profile = use_visibility_profile?) + if use_visibility_profile + if type + return Visibility::Profile.from_context(context, self).possible_types(type) + else + raise "Schema.possible_types is not implemented for `use_visibility_profile?`" + end + end if type # TODO duck-typing `.possible_types` would probably be nicer here if type.kind.union? type.possible_types(context: context) else - stored_possible_types = own_possible_types[type.graphql_name] + stored_possible_types = own_possible_types[type] visible_possible_types = if stored_possible_types && type.kind.interface? stored_possible_types.select do |possible_type| - # Use `.graphql_name` comparison to match legacy vs class-based types. - # When we don't need to support legacy `.define` types, use `.include?(type)` instead. - possible_type.interfaces(context).any? { |interface| interface.graphql_name == type.graphql_name } + possible_type.interfaces(context).include?(type) end else stored_possible_types end visible_possible_types || - introspection_system.possible_types[type.graphql_name] || + introspection_system.possible_types[type] || ( superclass.respond_to?(:possible_types) ? - superclass.possible_types(type, context) : + superclass.possible_types(type, context, use_visibility_profile) : EMPTY_ARRAY ) end @@ -1132,8 +665,8 @@ def union_memberships(type = nil) inherited_um = find_inherited_value(:union_memberships, EMPTY_HASH).fetch(type.graphql_name, EMPTY_ARRAY) own_um + inherited_um else - joined_um = own_union_memberships.dup - find_inherited_value(:union_memberhips, EMPTY_HASH).each do |k, v| + joined_um = own_union_memberships.transform_values(&:dup) + find_inherited_value(:union_memberships, EMPTY_HASH).each do |k, v| um = joined_um[k] ||= [] um.concat(v) end @@ -1150,50 +683,57 @@ def dataloader_class attr_writer :dataloader_class def references_to(to_type = nil, from: nil) - @own_references_to ||= Hash.new { |h, k| h[k] = [] } if to_type - if !to_type.is_a?(String) - to_type = to_type.graphql_name - end - if from - @own_references_to[to_type] << from + refs = own_references_to[to_type] ||= [] + refs << from else - own_refs = @own_references_to[to_type] - inherited_refs = find_inherited_value(:references_to, EMPTY_HASH)[to_type] || EMPTY_ARRAY - own_refs + inherited_refs + get_references_to(to_type) || EMPTY_ARRAY end else # `@own_references_to` can be quite large for big schemas, # and generally speaking, we won't inherit any values. # So optimize the most common case -- don't create a duplicate Hash. inherited_value = find_inherited_value(:references_to, EMPTY_HASH) - if inherited_value.any? - inherited_value.merge(@own_references_to) + if !inherited_value.empty? + inherited_value.merge(own_references_to) else - @own_references_to + own_references_to end end end - def type_from_ast(ast_node, context: nil) - type_owner = context ? context.warden : self - GraphQL::Schema::TypeExpression.build_type(type_owner, ast_node) + def type_from_ast(ast_node, context: self.query_class.new(self, "{ __typename }").context) + GraphQL::Schema::TypeExpression.build_type(context.query.types, ast_node) end - def get_field(type_or_name, field_name) + def get_field(type_or_name, field_name, context = null_context, use_visibility_profile = use_visibility_profile?) + if use_visibility_profile + profile = Visibility::Profile.from_context(context, self) + parent_type = case type_or_name + when String + profile.type(type_or_name) + when Module + type_or_name + when LateBoundType + profile.type(type_or_name.name) + else + raise GraphQL::InvariantError, "Unexpected field owner for #{field_name.inspect}: #{type_or_name.inspect} (#{type_or_name.class})" + end + return profile.field(parent_type, field_name) + end parent_type = case type_or_name when LateBoundType - get_type(type_or_name.name) + get_type(type_or_name.name, context) when String - get_type(type_or_name) + get_type(type_or_name, context) when Module type_or_name else - raise ArgumentError, "unexpected field owner for #{field_name.inspect}: #{type_or_name.inspect} (#{type_or_name.class})" + raise GraphQL::InvariantError, "Unexpected field owner for #{field_name.inspect}: #{type_or_name.inspect} (#{type_or_name.class})" end - if parent_type.kind.fields? && (field = parent_type.get_field(field_name)) + if parent_type.kind.fields? && (field = parent_type.get_field(field_name, context)) field elsif parent_type == query && (entry_point_field = introspection_system.entry_point(name: field_name)) entry_point_field @@ -1204,20 +744,27 @@ def get_field(type_or_name, field_name) end end - def get_fields(type) - type.fields + def get_fields(type, context = null_context) + type.fields(context) end + # Pass a custom introspection module here to use it for this schema. + # @param new_introspection_namespace [Module] If given, use this module for custom introspection on the schema + # @return [Module, nil] The configured namespace, if there is one def introspection(new_introspection_namespace = nil) if new_introspection_namespace @introspection = new_introspection_namespace # reset this cached value: @introspection_system = nil + introspection_system + self.visibility&.introspection_system_configured(introspection_system) + @introspection else @introspection || find_inherited_value(:introspection) end end + # @return [Schema::IntrospectionSystem] Based on {introspection} def introspection_system if !@introspection_system @introspection_system = Schema::IntrospectionSystem.new(self) @@ -1241,39 +788,70 @@ def default_max_page_size(new_default_max_page_size = nil) end end - def query_execution_strategy(new_query_execution_strategy = nil) + # A limit on the number of tokens to accept on incoming query strings. + # Use this to prevent parsing maliciously-large query strings. + # @return [nil, Integer] + def max_query_string_tokens(new_max_tokens = NOT_CONFIGURED) + if NOT_CONFIGURED.equal?(new_max_tokens) + defined?(@max_query_string_tokens) ? @max_query_string_tokens : find_inherited_value(:max_query_string_tokens) + else + @max_query_string_tokens = new_max_tokens + end + end + + def default_page_size(new_default_page_size = nil) + if new_default_page_size + @default_page_size = new_default_page_size + else + @default_page_size || find_inherited_value(:default_page_size) + end + end + + def query_execution_strategy(new_query_execution_strategy = nil, deprecation_warning: true) + if deprecation_warning + warn "GraphQL::Schema.query_execution_strategy is deprecated without replacement. Use `GraphQL::Query.new` directly to create and execute a custom query instead." + warn " #{caller(1, 1).first}" + end if new_query_execution_strategy @query_execution_strategy = new_query_execution_strategy else - @query_execution_strategy || find_inherited_value(:query_execution_strategy, self.default_execution_strategy) + @query_execution_strategy || (superclass.respond_to?(:query_execution_strategy) ? superclass.query_execution_strategy(deprecation_warning: false) : self.default_execution_strategy) end end - def mutation_execution_strategy(new_mutation_execution_strategy = nil) + def mutation_execution_strategy(new_mutation_execution_strategy = nil, deprecation_warning: true) + if deprecation_warning + warn "GraphQL::Schema.mutation_execution_strategy is deprecated without replacement. Use `GraphQL::Query.new` directly to create and execute a custom query instead." + warn " #{caller(1, 1).first}" + end if new_mutation_execution_strategy @mutation_execution_strategy = new_mutation_execution_strategy else - @mutation_execution_strategy || find_inherited_value(:mutation_execution_strategy, self.default_execution_strategy) + @mutation_execution_strategy || (superclass.respond_to?(:mutation_execution_strategy) ? superclass.mutation_execution_strategy(deprecation_warning: false) : self.default_execution_strategy) end end - def subscription_execution_strategy(new_subscription_execution_strategy = nil) + def subscription_execution_strategy(new_subscription_execution_strategy = nil, deprecation_warning: true) + if deprecation_warning + warn "GraphQL::Schema.subscription_execution_strategy is deprecated without replacement. Use `GraphQL::Query.new` directly to create and execute a custom query instead." + warn " #{caller(1, 1).first}" + end if new_subscription_execution_strategy @subscription_execution_strategy = new_subscription_execution_strategy else - @subscription_execution_strategy || find_inherited_value(:subscription_execution_strategy, self.default_execution_strategy) + @subscription_execution_strategy || (superclass.respond_to?(:subscription_execution_strategy) ? superclass.subscription_execution_strategy(deprecation_warning: false) : self.default_execution_strategy) end end attr_writer :validate_timeout - def validate_timeout(new_validate_timeout = nil) - if new_validate_timeout + def validate_timeout(new_validate_timeout = NOT_CONFIGURED) + if !NOT_CONFIGURED.equal?(new_validate_timeout) @validate_timeout = new_validate_timeout elsif defined?(@validate_timeout) @validate_timeout else - find_inherited_value(:validate_timeout) + find_inherited_value(:validate_timeout) || 3 end end @@ -1282,23 +860,43 @@ def validate_timeout(new_validate_timeout = nil) # @return [Array] def validate(string_or_document, rules: nil, context: nil) doc = if string_or_document.is_a?(String) - GraphQL.parse(string_or_document) + GraphQL.parse(string_or_document, max_tokens: max_query_string_tokens) else string_or_document end - query = GraphQL::Query.new(self, document: doc, context: context) + query = query_class.new(self, document: doc, context: context) validator_opts = { schema: self } rules && (validator_opts[:rules] = rules) validator = GraphQL::StaticValidation::Validator.new(**validator_opts) - res = validator.validate(query, timeout: validate_timeout) + res = validator.validate(query, timeout: validate_timeout, max_errors: validate_max_errors) res[:errors] end + # @param new_query_class [Class] A subclass to use when executing queries + def query_class(new_query_class = NOT_CONFIGURED) + if NOT_CONFIGURED.equal?(new_query_class) + @query_class || (superclass.respond_to?(:query_class) ? superclass.query_class : GraphQL::Query) + else + @query_class = new_query_class + end + end + + attr_writer :validate_max_errors + + def validate_max_errors(new_validate_max_errors = NOT_CONFIGURED) + if NOT_CONFIGURED.equal?(new_validate_max_errors) + defined?(@validate_max_errors) ? @validate_max_errors : find_inherited_value(:validate_max_errors) + else + @validate_max_errors = new_validate_max_errors + end + end + attr_writer :max_complexity - def max_complexity(max_complexity = nil) + def max_complexity(max_complexity = nil, count_introspection_fields: true) if max_complexity @max_complexity = max_complexity + @max_complexity_count_introspection_fields = count_introspection_fields elsif defined?(@max_complexity) @max_complexity else @@ -1306,26 +904,23 @@ def max_complexity(max_complexity = nil) end end + def max_complexity_count_introspection_fields + if defined?(@max_complexity_count_introspection_fields) + @max_complexity_count_introspection_fields + else + find_inherited_value(:max_complexity_count_introspection_fields, true) + end + end + attr_writer :analysis_engine def analysis_engine @analysis_engine || find_inherited_value(:analysis_engine, self.default_analysis_engine) end - def using_ast_analysis? - analysis_engine == GraphQL::Analysis::AST - end - - def interpreter? - query_execution_strategy == GraphQL::Execution::Interpreter && - mutation_execution_strategy == GraphQL::Execution::Interpreter && - subscription_execution_strategy == GraphQL::Execution::Interpreter - end - - attr_writer :interpreter - def error_bubbling(new_error_bubbling = nil) if !new_error_bubbling.nil? + warn("error_bubbling(#{new_error_bubbling.inspect}) is deprecated; the default value of `false` will be the only option in GraphQL-Ruby 3.0") @error_bubbling = new_error_bubbling else @error_bubbling.nil? ? find_inherited_value(:error_bubbling) : @error_bubbling @@ -1336,9 +931,10 @@ def error_bubbling(new_error_bubbling = nil) attr_writer :max_depth - def max_depth(new_max_depth = nil) + def max_depth(new_max_depth = nil, count_introspection_fields: true) if new_max_depth @max_depth = new_max_depth + @count_introspection_fields = count_introspection_fields elsif defined?(@max_depth) @max_depth else @@ -1346,6 +942,14 @@ def max_depth(new_max_depth = nil) end end + def count_introspection_fields + if defined?(@count_introspection_fields) + @count_introspection_fields + else + find_inherited_value(:count_introspection_fields, true) + end + end + def disable_introspection_entry_points @disable_introspection_entry_points = true # TODO: this clears the cache made in `def types`. But this is not a great solution. @@ -1388,15 +992,62 @@ def disable_type_introspection_entry_point? end end + # @param new_extra_types [Module] Type definitions to include in printing and introspection, even though they aren't referenced in the schema + # @return [Array] Type definitions added to this schema + def extra_types(*new_extra_types) + if !new_extra_types.empty? + new_extra_types = new_extra_types.flatten + @own_extra_types ||= [] + @own_extra_types.concat(new_extra_types) + end + inherited_et = find_inherited_value(:extra_types, nil) + if inherited_et + if @own_extra_types + inherited_et + @own_extra_types + else + inherited_et + end + else + @own_extra_types || EMPTY_ARRAY + end + end + + # Tell the schema about these types so that they can be registered as implementations of interfaces in the schema. + # + # This method must be used when an object type is connected to the schema as an interface implementor but + # not as a return type of a field. In that case, if the object type isn't registered here, GraphQL-Ruby won't be able to find it. + # + # @param new_orphan_types [Array>] Object types to register as implementations of interfaces in the schema. + # @return [Array>] All previously-registered orphan types for this schema def orphan_types(*new_orphan_types) - if new_orphan_types.any? + if !new_orphan_types.empty? new_orphan_types = new_orphan_types.flatten - add_type_and_traverse(new_orphan_types, root: false) - @orphan_types = new_orphan_types + non_object_types = new_orphan_types.reject { |ot| ot.is_a?(Class) && ot < GraphQL::Schema::Object } + if !non_object_types.empty? + raise ArgumentError, <<~ERR + Only object type classes should be added as `orphan_types(...)`. + + - Remove these no-op types from `orphan_types`: #{non_object_types.map { |t| "#{t.inspect} (#{t.kind.name})"}.join(", ")} + - See https://graphql-ruby.org/type_definitions/interfaces.html#orphan-types + + To add other types to your schema, you might want `extra_types`: https://graphql-ruby.org/schema/definition.html#extra-types + ERR + end + add_type_and_traverse(new_orphan_types, root: false) unless use_visibility_profile? own_orphan_types.concat(new_orphan_types.flatten) + self.visibility&.orphan_types_configured(new_orphan_types) end - find_inherited_value(:orphan_types, EMPTY_ARRAY) + own_orphan_types + inherited_ot = find_inherited_value(:orphan_types, nil) + if inherited_ot + if !own_orphan_types.empty? + inherited_ot + own_orphan_types + else + inherited_ot + end + else + own_orphan_types + end end def default_execution_strategy @@ -1415,36 +1066,127 @@ def default_analysis_engine end end - def context_class(new_context_class = nil) - if new_context_class - @context_class = new_context_class - else - @context_class || find_inherited_value(:context_class, GraphQL::Query::Context) + + # @param new_default_logger [#log] Something to use for logging messages + def default_logger(new_default_logger = NOT_CONFIGURED) + if NOT_CONFIGURED.equal?(new_default_logger) + if defined?(@default_logger) + @default_logger + elsif superclass.respond_to?(:default_logger) + superclass.default_logger + elsif defined?(Rails) && Rails.respond_to?(:logger) && (rails_logger = Rails.logger) + rails_logger + else + def_logger = Logger.new($stdout) + def_logger.info! # It doesn't output debug info by default + def_logger + end + elsif new_default_logger == nil + @default_logger = Logger.new(IO::NULL) + else + @default_logger = new_default_logger + end + end + + # @param context [GraphQL::Query::Context, nil] + # @return [Logger] A logger to use for this context configuration, falling back to {.default_logger} + def logger_for(context) + if context && context[:logger] == false + Logger.new(IO::NULL) + elsif context && (l = context[:logger]) + l + else + default_logger + end + end + + # @param new_context_class [Class] A subclass to use when executing queries + def context_class(new_context_class = nil) + if new_context_class + @context_class = new_context_class + else + @context_class || find_inherited_value(:context_class, GraphQL::Query::Context) + end + end + + # Register a handler for errors raised during execution. The handlers can return a new value or raise a new error. + # + # @example Handling "not found" with a client-facing error + # rescue_from(ActiveRecord::NotFound) { raise GraphQL::ExecutionError, "An object could not be found" } + # + # @param err_classes [Array] Classes which should be rescued by `handler_block` + # @param handler_block The code to run when one of those errors is raised during execution + # @yieldparam error [StandardError] An instance of one of the configured `err_classes` + # @yieldparam object [Object] The current application object in the query when the error was raised + # @yieldparam arguments [GraphQL::Query::Arguments] The current field arguments when the error was raised + # @yieldparam context [GraphQL::Query::Context] The context for the currently-running operation + # @yieldreturn [Object] Some object to use in the place where this error was raised + # @raise [GraphQL::ExecutionError] In the handler, raise to add a client-facing error to the response + # @raise [StandardError] In the handler, raise to crash the query with a developer-facing error + def rescue_from(*err_classes, &handler_block) + err_classes.each do |err_class| + Execution::Errors.register_rescue_from(err_class, error_handlers[:subclass_handlers], handler_block) + end + end + + def error_handlers + @error_handlers ||= begin + new_handler_hash = ->(h, k) { + h[k] = { + class: k, + handler: nil, + subclass_handlers: Hash.new(&new_handler_hash), + } + } + { + class: nil, + handler: nil, + subclass_handlers: Hash.new(&new_handler_hash), + } end end - def rescue_from(*err_classes, &handler_block) - err_classes.each do |err_class| - error_handler.rescue_from(err_class, handler_block) + # @api private + attr_accessor :using_backtrace + + # @api private + def handle_or_reraise(context, err, object: context[:current_object], arguments: context[:current_arguments], field: context[:current_field]) + handler = Execution::Errors.find_handler_for(self, err.class) + if handler + arguments = arguments.respond_to?(:keyword_arguments) ? arguments.keyword_arguments : arguments + if object.is_a?(GraphQL::Schema::Object) + object = object.object + end + handler[:handler].call(err, object, arguments, context, field) + else + if (context[:backtrace] || using_backtrace) && !err.is_a?(GraphQL::ExecutionError) + err = GraphQL::Backtrace::TracedError.new(err, context) + end + + raise err end end # rubocop:disable Lint/DuplicateMethods module ResolveTypeWithType def resolve_type(type, obj, ctx) - first_resolved_type, resolved_value = if type.is_a?(Module) && type.respond_to?(:resolve_type) + maybe_lazy_resolve_type_result = if type.is_a?(Module) && type.respond_to?(:resolve_type) type.resolve_type(obj, ctx) else super end - after_lazy(first_resolved_type) do |resolved_type| - if resolved_type.nil? || (resolved_type.is_a?(Module) && resolved_type.respond_to?(:kind)) || resolved_type.is_a?(GraphQL::BaseType) - if resolved_value - [resolved_type, resolved_value] - else - resolved_type - end + after_lazy(maybe_lazy_resolve_type_result) do |resolve_type_result| + if resolve_type_result.is_a?(Array) && resolve_type_result.size == 2 + resolved_type = resolve_type_result[0] + resolved_value = resolve_type_result[1] + else + resolved_type = resolve_type_result + resolved_value = obj + end + + if resolved_type.nil? || (resolved_type.is_a?(Module) && resolved_type.respond_to?(:kind)) + [resolved_type, resolved_value] else raise ".resolve_type should return a type definition, but got #{resolved_type.inspect} (#{resolved_type.class}) from `resolve_type(#{type}, #{obj}, #{ctx})`" end @@ -1452,51 +1194,97 @@ def resolve_type(type, obj, ctx) end end - def resolve_type(type, obj, ctx) - if type.kind.object? - type - else - raise GraphQL::RequiredImplementationMissingError, "#{self.name}.resolve_type(type, obj, ctx) must be implemented to use Union types or Interface types (tried to resolve: #{type.name})" - end + # GraphQL-Ruby calls this method during execution when it needs the application to determine the type to use for an object. + # + # Usually, this object was returned from a field whose return type is an {GraphQL::Schema::Interface} or a {GraphQL::Schema::Union}. + # But this method is called in other cases, too -- for example, when {GraphQL::Schema::Argument#loads} cases an object to be directly loaded from the database. + # + # @example Returning a GraphQL type based on the object's class name + # class MySchema < GraphQL::Schema + # def resolve_type(_abs_type, object, _context) + # graphql_type_name = "Types::#{object.class.name}Type" + # graphql_type_name.constantize # If this raises a NameError, then come implement special cases in this method + # end + # end + # @param abstract_type [Class, Module, nil] The Interface or Union type which is being resolved, if there is one + # @param application_object [Object] The object returned from a field whose type must be determined + # @param context [GraphQL::Query::Context] The query context for the currently-executing query + # @return [Class 2 + end - step = if instrument_step == :field && options[:after_built_ins] - :field_after_built_ins - else - instrument_step - end + def instrument(instrument_step, instrumenter, options = {}) + warn <<~WARN + Schema.instrument is deprecated, use `trace_with` instead: https://graphql-ruby.org/queries/tracing.html" + (From `#{self}.instrument(#{instrument_step}, #{instrumenter})` at #{caller(1, 1).first}) - own_instrumenters[step] << instrumenter + WARN + trace_with(Tracing::LegacyHooksTrace) + own_instrumenters[instrument_step] << instrumenter end # Add several directives at once # @param new_directives [Class] def directives(*new_directives) - if new_directives.any? + if !new_directives.empty? new_directives.flatten.each { |d| directive(d) } end - find_inherited_value(:directives, default_directives).merge(own_directives) + inherited_dirs = find_inherited_value(:directives, default_directives) + if !own_directives.empty? + inherited_dirs.merge(own_directives) + else + inherited_dirs + end end # Attach a single directive to this schema # @param new_directive [Class] # @return void def directive(new_directive) - add_type_and_traverse(new_directive, root: false) + if use_visibility_profile? + own_directives[new_directive.graphql_name] = new_directive + else + add_type_and_traverse(new_directive, root: false) + end end def default_directives @@ -1591,38 +1410,160 @@ def default_directives "include" => GraphQL::Schema::Directive::Include, "skip" => GraphQL::Schema::Directive::Skip, "deprecated" => GraphQL::Schema::Directive::Deprecated, + "oneOf" => GraphQL::Schema::Directive::OneOf, + "specifiedBy" => GraphQL::Schema::Directive::SpecifiedBy, }.freeze end - def tracer(new_tracer) + # @return [GraphQL::Tracing::DetailedTrace] if it has been configured for this schema + attr_accessor :detailed_trace + + # @param query [GraphQL::Query, GraphQL::Execution::Multiplex] Called with a multiplex when multiple queries are executed at once (with {.multiplex}) + # @return [Boolean] When `true`, save a detailed trace for this query. + # @see Tracing::DetailedTrace DetailedTrace saves traces when this method returns true + def detailed_trace?(query) + raise "#{self} must implement `def.detailed_trace?(query)` to use DetailedTrace. Implement this method in your schema definition." + end + + def tracer(new_tracer, silence_deprecation_warning: false) + if !silence_deprecation_warning + warn("`Schema.tracer(#{new_tracer.inspect})` is deprecated; use module-based `trace_with` instead. See: https://graphql-ruby.org/queries/tracing.html") + warn " #{caller(1, 1).first}" + end + default_trace = trace_class_for(:default, build: true) + if default_trace.nil? || !(default_trace < GraphQL::Tracing::CallLegacyTracers) + trace_with(GraphQL::Tracing::CallLegacyTracers) + end + own_tracers << new_tracer end def tracers - find_inherited_value(:tracers, EMPTY_ARRAY) + own_tracers + inherited = find_inherited_value(:tracers, EMPTY_ARRAY) + if inherited.length > 0 + if own_tracers.length > 0 + inherited + own_tracers + else + inherited + end + else + own_tracers + end end - def query_analyzer(new_analyzer) - if new_analyzer == GraphQL::Authorization::Analyzer - GraphQL::Deprecation.warn("The Authorization query analyzer is deprecated. Authorizing at query runtime is generally a better idea.") + # Mix `trace_mod` into this schema's `Trace` class so that its methods will be called at runtime. + # + # You can attach a module to run in only _some_ circumstances by using `mode:`. When a module is added with `mode:`, + # it will only run for queries with a matching `context[:trace_mode]`. + # + # Any custom trace modes _also_ include the default `trace_with ...` modules (that is, those added _without_ any particular `mode: ...` configuration). + # + # @example Adding a trace in a special mode + # # only runs when `query.context[:trace_mode]` is `:special` + # trace_with SpecialTrace, mode: :special + # + # @param trace_mod [Module] A module that implements tracing methods + # @param mode [Symbol] Trace module will only be used for this trade mode + # @param options [Hash] Keywords that will be passed to the tracing class during `#initialize` + # @return [void] + # @see GraphQL::Tracing::Trace Tracing::Trace for available tracing methods + def trace_with(trace_mod, mode: :default, **options) + if mode.is_a?(Array) + mode.each { |m| trace_with(trace_mod, mode: m, **options) } + else + tc = own_trace_modes[mode] ||= build_trace_mode(mode) + tc.include(trace_mod) + own_trace_modules[mode] << trace_mod + add_trace_options_for(mode, options) + if mode == :default + # This module is being added as a default tracer. If any other mode classes + # have already been created, but get their default behavior from a superclass, + # Then mix this into this schema's subclass. + # (But don't mix it into mode classes that aren't default-based.) + own_trace_modes.each do |other_mode_name, other_mode_class| + if other_mode_class < DefaultTraceClass + # Don't add it back to the inheritance tree if it's already there + if !(other_mode_class < trace_mod) + other_mode_class.include(trace_mod) + end + # Add any options so they'll be available + add_trace_options_for(other_mode_name, options) + end + end + end end - own_query_analyzers << new_analyzer + nil end - def query_analyzers - find_inherited_value(:query_analyzers, EMPTY_ARRAY) + own_query_analyzers + # The options hash for this trace mode + # @return [Hash] + def trace_options_for(mode) + @trace_options_for_mode ||= {} + @trace_options_for_mode[mode] ||= begin + # It may be time to create an options hash for a mode that wasn't registered yet. + # Mix in the default options in that case. + default_options = mode == :default ? EMPTY_HASH : trace_options_for(:default) + # Make sure this returns a new object so that other hashes aren't modified later + if superclass.respond_to?(:trace_options_for) + superclass.trace_options_for(mode).merge(default_options) + else + default_options.dup + end + end end - def middleware(new_middleware = nil) - if new_middleware - GraphQL::Deprecation.warn "Middleware will be removed in GraphQL-Ruby 2.0, please upgrade to Field Extensions: https://graphql-ruby.org/type_definitions/field_extensions.html" - own_middleware << new_middleware + # Create a trace instance which will include the trace modules specified for the optional mode. + # + # If no `mode:` is given, then {default_trace_mode} will be used. + # + # If this schema is using {Tracing::DetailedTrace} and {.detailed_trace?} returns `true`, then + # DetailedTrace's mode will override the passed-in `mode`. + # + # @param mode [Symbol] Trace modules for this trade mode will be included + # @param options [Hash] Keywords that will be passed to the tracing class during `#initialize` + # @return [Tracing::Trace] + def new_trace(mode: nil, **options) + should_sample = if detailed_trace + if (query = options[:query]) + detailed_trace?(query) + elsif (multiplex = options[:multiplex]) + if multiplex.queries.length == 1 + detailed_trace?(multiplex.queries.first) + else + detailed_trace?(multiplex) + end + end + else + false + end + + if should_sample + mode = detailed_trace.trace_mode else - # TODO make sure this is cached when running a query - MiddlewareChain.new(steps: all_middleware, final_step: GraphQL::Execution::Execute::FieldResolveStep) + target = options[:query] || options[:multiplex] + mode ||= target && target.context[:trace_mode] end + + trace_mode = mode || default_trace_mode + base_trace_options = trace_options_for(trace_mode) + trace_options = base_trace_options.merge(options) + trace_class_for_mode = trace_class_for(trace_mode, build: true) + trace_class_for_mode.new(**trace_options) + end + + # @param new_analyzer [Class] An analyzer to run on queries to this schema + # @see GraphQL::Analysis the analysis system + def query_analyzer(new_analyzer) + own_query_analyzers << new_analyzer + end + + def query_analyzers + inherited_qa = find_inherited_value(:query_analyzers, EMPTY_ARRAY) + inherited_qa.empty? ? own_query_analyzers : (inherited_qa + own_query_analyzers) end + # @param new_analyzer [Class] An analyzer to run on multiplexes to this schema + # @see GraphQL::Analysis the analysis system def multiplex_analyzer(new_analyzer) own_multiplex_analyzers << new_analyzer end @@ -1641,8 +1582,16 @@ def sanitized_printer(new_sanitized_printer = nil) # Execute a query on itself. # @see {Query#initialize} for arguments. - # @return [Hash] query result, ready to be serialized as JSON + # @return [GraphQL::Query::Result] query result, ready to be serialized as JSON def execute(query_str = nil, **kwargs) + if default_execution_next + execute_next(query_str, **kwargs) + else + execute_legacy(query_str, **kwargs) + end + end + + def execute_legacy(query_str = nil, **kwargs) if query_str kwargs[:query] = query_str end @@ -1651,6 +1600,9 @@ def execute(query_str = nil, **kwargs) { backtrace: ctx[:backtrace], tracers: ctx[:tracers], + trace: ctx[:trace], + dataloader: ctx[:dataloader], + trace_mode: ctx[:trace_mode], } else {} @@ -1675,17 +1627,29 @@ def execute(query_str = nil, **kwargs) # } # # @see {Query#initialize} for query keyword arguments - # @see {Execution::Multiplex#run_queries} for multiplex keyword arguments + # @see {Execution::Multiplex#run_all} for multiplex keyword arguments # @param queries [Array] Keyword arguments for each query - # @param context [Hash] Multiplex-level context - # @return [Array] One result for each query in the input + # @option kwargs [Hash] :context ({}) Multiplex-level context + # @option kwargs [nil, Integer] :max_complexity (nil) + # @return [Array] One result for each query in the input def multiplex(queries, **kwargs) - schema = if interpreter? - self + if @default_execution_next + multiplex_next(queries, **kwargs) + else + GraphQL::Execution::Interpreter.run_all(self, queries, **kwargs) + end + end + + def default_execution_next(new_value = NOT_CONFIGURED) + if !NOT_CONFIGURED.equal?(new_value) + @default_execution_next = new_value + elsif instance_variable_defined?(:@default_execution_next) + @default_execution_next + elsif superclass.respond_to?(:default_execution_next) + superclass.default_execution_next else - graphql_definition + false end - GraphQL::Execution::Multiplex.run_all(schema, queries, **kwargs) end def instrumenters @@ -1697,24 +1661,292 @@ def instrumenters # @api private def add_subscription_extension_if_necessary - if interpreter? && !defined?(@subscription_extension_added) && subscription && self.subscriptions + # TODO: when there's a proper API for extending root types, migrat this to use it. + if !defined?(@subscription_extension_added) && @subscription_object.is_a?(Class) && self.subscriptions @subscription_extension_added = true - if subscription.singleton_class.ancestors.include?(Subscriptions::SubscriptionRoot) - GraphQL::Deprecation.warn("`extend Subscriptions::SubscriptionRoot` is no longer required; you may remove it from #{self}'s `subscription` root type (#{subscription}).") - else - subscription.fields.each do |name, field| + subscription.all_field_definitions.each do |field| + if !field.extensions.any? { |ext| ext.is_a?(Subscriptions::DefaultSubscriptionResolveExtension) } field.extension(Subscriptions::DefaultSubscriptionResolveExtension) end end end end + # Called when execution encounters a `SystemStackError`. By default, it adds a client-facing error to the response. + # You could modify this method to report this error to your bug tracker. + # @param query [GraphQL::Query] + # @param err [SystemStackError] + # @return [void] def query_stack_error(query, err) query.context.errors.push(GraphQL::ExecutionError.new("This query is too large to execute.")) end + # Call the given block at the right time, either: + # - Right away, if `value` is not registered with `lazy_resolve` + # - After resolving `value`, if it's registered with `lazy_resolve` (eg, `Promise`) + # @api private + def after_lazy(value, &block) + if lazy?(value) + GraphQL::Execution::Lazy.new do + result = sync_lazy(value) + # The returned result might also be lazy, so check it, too + after_lazy(result, &block) + end + else + yield(value) if block_given? + end + end + + # Override this method to handle lazy objects in a custom way. + # @param value [Object] an instance of a class registered with {.lazy_resolve} + # @return [Object] A GraphQL-ready (non-lazy) object + # @api private + def sync_lazy(value) + lazy_method = lazy_method_name(value) + if lazy_method + synced_value = value.public_send(lazy_method) + sync_lazy(synced_value) + else + value + end + end + + # @return [Symbol, nil] The method name to lazily resolve `obj`, or nil if `obj`'s class wasn't registered with {.lazy_resolve}. + def lazy_method_name(obj) + lazy_methods.get(obj) + end + + # @return [Boolean] True if this object should be lazily resolved + def lazy?(obj) + !!lazy_method_name(obj) + end + + # Return a lazy if any of `maybe_lazies` are lazy, + # otherwise, call the block eagerly and return the result. + # @param maybe_lazies [Array] + # @api private + def after_any_lazies(maybe_lazies) + if maybe_lazies.any? { |l| lazy?(l) } + GraphQL::Execution::Lazy.all(maybe_lazies).then do |result| + yield result + end + else + yield maybe_lazies + end + end + + # Returns `DidYouMean` if it's defined. + # Override this to return `nil` if you don't want to use `DidYouMean` + def did_you_mean(new_dym = NOT_CONFIGURED) + if NOT_CONFIGURED.equal?(new_dym) + if defined?(@did_you_mean) + @did_you_mean + else + find_inherited_value(:did_you_mean, defined?(DidYouMean) ? DidYouMean : nil) + end + else + @did_you_mean = new_dym + end + end + + + # This setting controls how GraphQL-Ruby handles empty selections on Union types. + # + # To opt into future, spec-compliant behavior where these selections are rejected, set this to `false`. + # + # If you need to support previous, non-spec behavior which allowed selecting union fields + # but *not* selecting any fields on that union, set this to `true` to continue allowing that behavior. + # + # If this is `true`, then {.legacy_invalid_empty_selections_on_union_with_type} will be called with {Query} objects + # with that kind of selections. You must implement that method + # @param new_value [Boolean] + # @return [true, false, nil] + def allow_legacy_invalid_empty_selections_on_union(new_value = NOT_CONFIGURED) + if NOT_CONFIGURED.equal?(new_value) + if defined?(@allow_legacy_invalid_empty_selections_on_union) + @allow_legacy_invalid_empty_selections_on_union + else + find_inherited_value(:allow_legacy_invalid_empty_selections_on_union) + end + else + @allow_legacy_invalid_empty_selections_on_union = new_value + end + end + + # This method is called during validation when a previously-allowed, but non-spec + # query is encountered where a union field has no child selections on it. + # + # If `legacy_invalid_empty_selections_on_union_with_type` is overridden, this method will not be called. + # + # You should implement this method or `legacy_invalid_empty_selections_on_union_with_type` + # to log the violation so that you can contact clients and notify them about changing their queries. + # Then return a suitable value to tell GraphQL-Ruby how to continue. + # @param query [GraphQL::Query] + # @return [:return_validation_error] Let GraphQL-Ruby return the (new) normal validation error for this query + # @return [String] A validation error to return for this query + # @return [nil] Don't send the client an error, continue the legacy behavior (allow this query to execute) + def legacy_invalid_empty_selections_on_union(query) + raise "Implement `def self.legacy_invalid_empty_selections_on_union_with_type(query, type)` or `def self.legacy_invalid_empty_selections_on_union(query)` to handle this scenario" + end + + # This method is called during validation when a previously-allowed, but non-spec + # query is encountered where a union field has no child selections on it. + # + # You should implement this method to log the violation so that you can contact clients + # and notify them about changing their queries. Then return a suitable value to + # tell GraphQL-Ruby how to continue. + # @param query [GraphQL::Query] + # @param type [Module] A GraphQL type definition + # @return [:return_validation_error] Let GraphQL-Ruby return the (new) normal validation error for this query + # @return [String] A validation error to return for this query + # @return [nil] Don't send the client an error, continue the legacy behavior (allow this query to execute) + def legacy_invalid_empty_selections_on_union_with_type(query, type) + legacy_invalid_empty_selections_on_union(query) + end + + # This setting controls how GraphQL-Ruby handles overlapping selections on scalar types when the types + # don't match. + # + # When set to `false`, GraphQL-Ruby will reject those queries with a validation error (as per the GraphQL spec). + # + # When set to `true`, GraphQL-Ruby will call {.legacy_invalid_return_type_conflicts} when the scenario is encountered. + # + # @param new_value [Boolean] `true` permits the legacy behavior, `false` rejects it. + # @return [true, false, nil] + def allow_legacy_invalid_return_type_conflicts(new_value = NOT_CONFIGURED) + if NOT_CONFIGURED.equal?(new_value) + if defined?(@allow_legacy_invalid_return_type_conflicts) + @allow_legacy_invalid_return_type_conflicts + else + find_inherited_value(:allow_legacy_invalid_return_type_conflicts) + end + else + @allow_legacy_invalid_return_type_conflicts = new_value + end + end + + # This method is called when the query contains fields which don't contain matching scalar types. + # This was previously allowed by GraphQL-Ruby but it's a violation of the GraphQL spec. + # + # You should implement this method to log the violation so that you observe usage of these fields. + # Fixing this scenario might mean adding new fields, and telling clients to use those fields. + # (Changing the field return type would be a breaking change, but if it works for your client use cases, + # that might work, too.) + # + # @param query [GraphQL::Query] + # @param type1 [Module] A GraphQL type definition + # @param type2 [Module] A GraphQL type definition + # @param node1 [GraphQL::Language::Nodes::Field] This node is recognized as conflicting. You might call `.line` and `.col` for custom error reporting. + # @param node2 [GraphQL::Language::Nodes::Field] The other node recognized as conflicting. + # @return [:return_validation_error] Let GraphQL-Ruby return the (new) normal validation error for this query + # @return [String] A validation error to return for this query + # @return [nil] Don't send the client an error, continue the legacy behavior (allow this query to execute) + def legacy_invalid_return_type_conflicts(query, type1, type2, node1, node2) + raise "Implement #{self}.legacy_invalid_return_type_conflicts to handle this invalid selection" + end + + # The legacy complexity implementation included several bugs: + # + # - In some cases, it used the lexically _last_ field to determine a cost, instead of calculating the maximum among selections + # - In some cases, it called field complexity hooks repeatedly (when it should have only called them once) + # + # The future implementation may produce higher total complexity scores, so it's not active by default yet. You can opt into + # the future default behavior by configuring `:future` here. Or, you can choose a mode for each query with {.complexity_cost_calculation_mode_for}. + # + # The legacy mode is currently maintained alongside the future one, but it will be removed in a future GraphQL-Ruby version. + # + # If you choose `:compare`, you must also implement {.legacy_complexity_cost_calculation_mismatch} to handle the input somehow. + # + # @example Opting into the future calculation mode + # complexity_cost_calculation_mode(:future) + # + # @example Choosing the legacy mode (which will work until that mode is removed...) + # complexity_cost_calculation_mode(:legacy) + # + # @example Run both modes for every query, call {.legacy_complexity_cost_calculation_mismatch} when they don't match: + # complexity_cost_calculation_mode(:compare) + def complexity_cost_calculation_mode(new_mode = NOT_CONFIGURED) + if NOT_CONFIGURED.equal?(new_mode) + if defined?(@complexity_cost_calculation_mode) + @complexity_cost_calculation_mode + else + find_inherited_value(:complexity_cost_calculation_mode) + end + else + @complexity_cost_calculation_mode = new_mode + end + end + + # Implement this method to produce a per-query complexity cost calculation mode. (Technically, it's per-multiplex.) + # + # This is a way to check the compatibility of queries coming to your API without adding overhead of running `:compare` + # for every query. You could sample traffic, turn it off/on with feature flags, or anything else. + # + # @example Sampling traffic + # def self.complexity_cost_calculation_mode_for(_context) + # if rand < 0.1 # 10% of the time + # :compare + # else + # :legacy + # end + # end + # + # @example Using a feature flag to manage future mode + # def complexity_cost_calculation_mode_for(context) + # current_user = context[:current_user] + # if Flipper.enabled?(:future_complexity_cost, current_user) + # :future + # elsif rand < 0.5 # 50% + # :compare + # else + # :legacy + # end + # end + # + # @param multiplex_context [Hash] The context for the currently-running {Execution::Multiplex} (which contains one or more queries) + # @return [:future] Use the new calculation algorithm -- may be higher than `:legacy` + # @return [:legacy] Use the legacy calculation algorithm, warts and all + # @return [:compare] Run both algorithms and call {.legacy_complexity_cost_calculation_mismatch} if they don't match + def complexity_cost_calculation_mode_for(multiplex_context) + complexity_cost_calculation_mode + end + + # Implement this method in your schema to handle mismatches when `:compare` is used. + # + # @example Logging the mismatch + # def self.legacy_cost_calculation_mismatch(multiplex, future_cost, legacy_cost) + # client_id = multiplex.context[:api_client].id + # operation_names = multiplex.queries.map { |q| q.selected_operation_name || "anonymous" }.join(", ") + # Stats.increment(:complexity_mismatch, tags: { client: client_id, ops: operation_names }) + # legacy_cost + # end + # @see Query::Context#add_error Adding an error to the response to notify the client + # @see Query::Context#response_extensions Adding key-value pairs to the response `"extensions" => { ... }` + # @param multiplex [GraphQL::Execution::Multiplex] + # @param future_complexity_cost [Integer] + # @param legacy_complexity_cost [Integer] + # @return [Integer] the cost to use for this query (probably one of `future_complexity_cost` or `legacy_complexity_cost`) + def legacy_complexity_cost_calculation_mismatch(multiplex, future_complexity_cost, legacy_complexity_cost) + raise "Implement #{self}.legacy_complexity_cost(multiplex, future_complexity_cost, legacy_complexity_cost) to handle this mismatch (#{future_complexity_cost} vs. #{legacy_complexity_cost}) and return a value to use" + end + private + def add_trace_options_for(mode, new_options) + if mode == :default + own_trace_modes.each do |mode_name, t_class| + if t_class <= DefaultTraceClass + t_opts = trace_options_for(mode_name) + t_opts.merge!(new_options) + end + end + else + t_opts = trace_options_for(mode) + t_opts.merge!(new_options) + end + nil + end + # @param t [Module, Array] # @return [void] def add_type_and_traverse(t, root:) @@ -1724,12 +1956,42 @@ def add_type_and_traverse(t, root:) end new_types = Array(t) addition = Schema::Addition.new(schema: self, own_types: own_types, new_types: new_types) - own_types.merge!(addition.types) + addition.types.each do |name, types_entry| # rubocop:disable Development/ContextIsPassedCop -- build-time, not query-time + if (prev_entry = own_types[name]) + prev_entries = case prev_entry + when Array + prev_entry + when Module + own_types[name] = [prev_entry] + else + raise "Invariant: unexpected prev_entry at #{name.inspect} when adding #{t.inspect}" + end + + case types_entry + when Array + prev_entries.concat(types_entry) + prev_entries.uniq! # in case any are being re-visited + when Module + if !prev_entries.include?(types_entry) + prev_entries << types_entry + end + else + raise "Invariant: unexpected types_entry at #{name} when adding #{t.inspect}" + end + else + if types_entry.is_a?(Array) + types_entry.uniq! + end + own_types[name] = types_entry + end + end + own_possible_types.merge!(addition.possible_types) { |key, old_val, new_val| old_val + new_val } own_union_memberships.merge!(addition.union_memberships) addition.references.each { |thing, pointers| - pointers.each { |pointer| references_to(thing, from: pointer) } + prev_refs = own_references_to[thing] || [] + own_references_to[thing] = prev_refs | pointers.to_a } addition.directives.each { |dir_class| own_directives[dir_class.graphql_name] = dir_class } @@ -1747,7 +2009,7 @@ def lazy_methods else @lazy_methods = GraphQL::Execution::Lazy::LazyMethodMap.new @lazy_methods.set(GraphQL::Execution::Lazy, :value) - @lazy_methods.set(GraphQL::Dataloader::Request, :load) + @lazy_methods.set(GraphQL::Dataloader::Request, :load_with_deprecation_warning) end end @lazy_methods @@ -1757,6 +2019,10 @@ def own_types @own_types ||= {} end + def own_references_to + @own_references_to ||= {}.compare_by_identity + end + def non_introspection_types find_inherited_value(:non_introspection_types, EMPTY_HASH).merge(own_types) end @@ -1770,7 +2036,7 @@ def own_orphan_types end def own_possible_types - @own_possible_types ||= {} + @own_possible_types ||= {}.compare_by_identity end def own_union_memberships @@ -1793,68 +2059,40 @@ def own_query_analyzers @defined_query_analyzers ||= [] end - def all_middleware - find_inherited_value(:all_middleware, EMPTY_ARRAY) + own_middleware - end - - def own_middleware - @own_middleware ||= [] - end - def own_multiplex_analyzers @own_multiplex_analyzers ||= [] end - end - - def dataloader_class - self.class.dataloader_class - end - - # Install these here so that subclasses will also install it. - use(GraphQL::Pagination::Connections) - - protected - - def rescues? - !!@rescue_middleware - end - # Lazily create a middleware and add it to the schema - # (Don't add it if it's not used) - def rescue_middleware - @rescue_middleware ||= GraphQL::Schema::RescueMiddleware.new.tap { |m| middleware.insert(0, m) } + # This is overridden in subclasses to check the inheritance chain + def get_references_to(type_defn) + own_references_to[type_defn] + end end - private - - def rebuild_artifacts - if @rebuilding_artifacts - raise CyclicalDefinitionError, "Part of the schema build process re-triggered the schema build process, causing an infinite loop. Avoid using Schema#types, Schema#possible_types, and Schema#get_field during schema build." - else - @rebuilding_artifacts = true - @introspection_system = Schema::IntrospectionSystem.new(self) - traversal = Traversal.new(self) - @types = traversal.type_map - @root_types = [query, mutation, subscription] - @instrumented_field_map = traversal.instrumented_field_map - @type_reference_map = traversal.type_reference_map - @union_memberships = traversal.union_memberships - @find_cache = {} - @finder = Finder.new(self) - end - ensure - @rebuilding_artifacts = false + module SubclassGetReferencesTo + def get_references_to(type_defn) + own_refs = own_references_to[type_defn] + inherited_refs = superclass.references_to(type_defn) + if inherited_refs&.any? + if own_refs&.any? + own_refs + inherited_refs + else + inherited_refs + end + else + own_refs + end + end end - class CyclicalDefinitionError < GraphQL::Error - end + # Install these here so that subclasses will also install it. + self.connections = GraphQL::Pagination::Connections.new(schema: self) - def with_definition_error_check - if @definition_error - raise @definition_error - else - yield - end + # @api private + module DefaultTraceClass end end end + +require "graphql/schema/loader" +require "graphql/schema/printer" diff --git a/lib/graphql/schema/addition.rb b/lib/graphql/schema/addition.rb index 2c417c925c9..accb8c4ff83 100644 --- a/lib/graphql/schema/addition.rb +++ b/lib/graphql/schema/addition.rb @@ -12,7 +12,7 @@ def initialize(schema:, own_types:, new_types:) @possible_types = {} @types = {} @union_memberships = {} - @references = Hash.new { |h, k| h[k] = [] } + @references = Hash.new { |h, k| h[k] = Set.new } @arguments_with_default_values = [] add_type_and_traverse(new_types) end @@ -20,11 +20,17 @@ def initialize(schema:, own_types:, new_types:) private def references_to(thing, from:) - @references[thing] << from + @references[thing].add(from) end def get_type(name) - @types[name] || @schema.get_type(name) + local_type = @types[name] + # This isn't really sophisticated, but + # I think it's good enough to support the current usage of LateBoundTypes + if local_type.is_a?(Array) + local_type = local_type.first + end + local_type || @schema.get_type(name) end # Lookup using `own_types` here because it's ok to override @@ -34,14 +40,21 @@ def get_local_type(name) end def add_directives_from(owner) - dirs = owner.directives.map(&:class) - @directives.merge(dirs) - add_type_and_traverse(dirs) + if !(dir_instances = owner.directives).empty? + dirs = dir_instances.map(&:class) + @directives.merge(dirs) + add_type_and_traverse(dirs) + end end def add_type_and_traverse(new_types) late_types = [] - new_types.each { |t| add_type(t, owner: nil, late_types: late_types, path: [t.graphql_name]) } + path = [] + new_types.each do |t| + path.push(t.graphql_name) + add_type(t, owner: nil, late_types: late_types, path: path) + path.pop + end missed_late_types = 0 while (late_type_vals = late_types.shift) type_owner, lt = late_type_vals @@ -77,13 +90,13 @@ def add_type_and_traverse(new_types) def update_type_owner(owner, type) case owner - when Class + when Module if owner.kind.union? # It's a union with possible_types # Replace the item by class name owner.assign_type_membership_object_type(type) - @possible_types[owner.graphql_name] = owner.possible_types - elsif type.kind.interface? && owner.kind.object? + @possible_types[owner] = owner.possible_types + elsif type.kind.interface? && (owner.kind.object? || owner.kind.interface?) new_interfaces = [] owner.interfaces.each do |int_t| if int_t.is_a?(String) && int_t == type.graphql_name @@ -97,18 +110,23 @@ def update_type_owner(owner, type) end owner.implements(*new_interfaces) new_interfaces.each do |int| - pt = @possible_types[int.graphql_name] ||= [] - if !pt.include?(owner) + pt = @possible_types[int] ||= [] + if !pt.include?(owner) && owner.is_a?(Class) pt << owner end + int.interfaces.each do |indirect_int| + if indirect_int.is_a?(LateBoundType) && (indirect_int_type = get_type(indirect_int.graphql_name)) + update_type_owner(owner, indirect_int_type) + end + end end end - when nil # It's a root type @types[type.graphql_name] = type when GraphQL::Schema::Field, GraphQL::Schema::Argument orig_type = owner.type + unwrapped_t = type # Apply list/non-null wrapper as needed if orig_type.respond_to?(:of_type) transforms = [] @@ -125,20 +143,14 @@ def update_type_owner(owner, type) transforms.reverse_each { |t| type = type.public_send(t) } end owner.type = type + references_to(unwrapped_t, from: owner) else raise "Unexpected update: #{owner.inspect} #{type.inspect}" end end def add_type(type, owner:, late_types:, path:) - if type.respond_to?(:metadata) && type.metadata.is_a?(Hash) - type_class = type.metadata[:type_class] - if type_class.nil? - raise ArgumentError, "Can't add legacy type: #{type} (#{type.class})" - else - type = type_class - end - elsif type.is_a?(String) || type.is_a?(GraphQL::Schema::LateBoundType) + if type.is_a?(String) || type.is_a?(GraphQL::Schema::LateBoundType) late_types << [owner, type] return end @@ -148,87 +160,121 @@ def add_type(type, owner:, late_types:, path:) um << owner end - if (prev_type = get_local_type(type.graphql_name)) - if prev_type != type - raise DuplicateTypeNamesError.new( - type_name: type.graphql_name, - first_definition: prev_type, - second_definition: type, - path: path, - ) - else - # This type was already added - end + if (prev_type = get_local_type(type.graphql_name)) && (prev_type == type || (prev_type.is_a?(Array) && prev_type.include?(type))) + # No need to re-visit elsif type.is_a?(Class) && type < GraphQL::Schema::Directive @directives << type - type.arguments.each do |name, arg| + type.all_argument_definitions.each do |arg| arg_type = arg.type.unwrap - references_to(arg_type, from: arg) - add_type(arg_type, owner: arg, late_types: late_types, path: path + [name]) + if !arg_type.is_a?(GraphQL::Schema::LateBoundType) + references_to(arg_type, from: arg) + end + path.push(arg.graphql_name) + add_type(arg_type, owner: arg, late_types: late_types, path: path) + path.pop if arg.default_value? @arguments_with_default_values << arg end end else - @types[type.graphql_name] = type + prev_type = @types[type.graphql_name] + if prev_type.nil? + @types[type.graphql_name] = type + elsif prev_type.is_a?(Array) + prev_type << type + else + @types[type.graphql_name] = [prev_type, type] + end + add_directives_from(type) if type.kind.fields? - type.fields.each do |name, field| + type.all_field_definitions.each do |field| + field.ensure_loaded + name = field.graphql_name field_type = field.type.unwrap - references_to(field_type, from: field) - field_path = path + [name] - add_type(field_type, owner: field, late_types: late_types, path: field_path) + if !field_type.is_a?(GraphQL::Schema::LateBoundType) + references_to(field_type, from: field) + end + path.push(name) + add_type(field_type, owner: field, late_types: late_types, path: path) add_directives_from(field) - field.arguments.each do |arg_name, arg| + field.all_argument_definitions.each do |arg| add_directives_from(arg) arg_type = arg.type.unwrap - references_to(arg_type, from: arg) - add_type(arg_type, owner: arg, late_types: late_types, path: field_path + [arg_name]) + if !arg_type.is_a?(GraphQL::Schema::LateBoundType) + references_to(arg_type, from: arg) + end + path.push(arg.graphql_name) + add_type(arg_type, owner: arg, late_types: late_types, path: path) + path.pop if arg.default_value? @arguments_with_default_values << arg end end + path.pop end end if type.kind.input_object? - type.arguments.each do |arg_name, arg| + type.all_argument_definitions.each do |arg| add_directives_from(arg) arg_type = arg.type.unwrap - references_to(arg_type, from: arg) - add_type(arg_type, owner: arg, late_types: late_types, path: path + [arg_name]) + if !arg_type.is_a?(GraphQL::Schema::LateBoundType) + references_to(arg_type, from: arg) + end + path.push(arg.graphql_name) + add_type(arg_type, owner: arg, late_types: late_types, path: path) + path.pop if arg.default_value? @arguments_with_default_values << arg end end end if type.kind.union? - @possible_types[type.graphql_name] = type.possible_types - type.possible_types.each do |t| - add_type(t, owner: type, late_types: late_types, path: path + ["possible_types"]) + @possible_types[type] = type.all_possible_types + path.push("possible_types") + type.all_possible_types.each do |t| + add_type(t, owner: type, late_types: late_types, path: path) end + path.pop end if type.kind.interface? + path.push("orphan_types") type.orphan_types.each do |t| - add_type(t, owner: type, late_types: late_types, path: path + ["orphan_types"]) + add_type(t, owner: type, late_types: late_types, path: path) end + path.pop end if type.kind.object? - @possible_types[type.graphql_name] = [type] + possible_types_for_this_name = @possible_types[type] ||= [] + possible_types_for_this_name << type + end + + if type.kind.object? || type.kind.interface? + path.push("implements") type.interface_type_memberships.each do |interface_type_membership| case interface_type_membership when Schema::TypeMembership interface_type = interface_type_membership.abstract_type # We can get these now; we'll have to get late-bound types later - if interface_type.is_a?(Module) - implementers = @possible_types[interface_type.graphql_name] ||= [] - implementers << type + if interface_type.is_a?(Module) && type.is_a?(Class) + implementers = @possible_types[interface_type] ||= [] + if !implementers.include?(type) + implementers << type + end end when String, Schema::LateBoundType interface_type = interface_type_membership else raise ArgumentError, "Invariant: unexpected type membership for #{type.graphql_name}: #{interface_type_membership.class} (#{interface_type_membership.inspect})" end - add_type(interface_type, owner: type, late_types: late_types, path: path + ["implements"]) + add_type(interface_type, owner: type, late_types: late_types, path: path) + end + path.pop + end + + if type.kind.enum? + type.all_enum_value_definitions.each do |value_definition| + add_directives_from(value_definition) end end end diff --git a/lib/graphql/schema/always_visible.rb b/lib/graphql/schema/always_visible.rb new file mode 100644 index 00000000000..d8135a8c5d9 --- /dev/null +++ b/lib/graphql/schema/always_visible.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +module GraphQL + class Schema + module AlwaysVisible + def self.use(schema, **opts) + schema.use(GraphQL::Schema::Visibility, profiles: { nil => {} }) + schema.extend(self) + end + + def visible?(_member, _context) + true + end + end + end +end diff --git a/lib/graphql/schema/argument.rb b/lib/graphql/schema/argument.rb index fce25df7f35..214d6c6ef91 100644 --- a/lib/graphql/schema/argument.rb +++ b/lib/graphql/schema/argument.rb @@ -2,20 +2,13 @@ module GraphQL class Schema class Argument - if !String.method_defined?(:-@) - using GraphQL::StringDedupBackport - end - - include GraphQL::Schema::Member::CachedGraphQLDefinition - include GraphQL::Schema::Member::AcceptsDefinition include GraphQL::Schema::Member::HasPath include GraphQL::Schema::Member::HasAstNode + include GraphQL::Schema::Member::HasAuthorization include GraphQL::Schema::Member::HasDirectives include GraphQL::Schema::Member::HasDeprecationReason include GraphQL::Schema::Member::HasValidators - include GraphQL::Schema::FindInheritedValue::EmptyObjects - - NO_DEFAULT = :__no_default__ + include GraphQL::EmptyObjects # @return [String] the GraphQL name for this argument, camelized unless `camelize: false` is provided attr_reader :name @@ -24,8 +17,14 @@ class Argument # @return [GraphQL::Schema::Field, Class] The field or input object this argument belongs to attr_reader :owner - # @return [Symbol] A method to call to transform this value before sending it to field resolution method - attr_reader :prepare + # @param new_prepare [Method, Proc] + # @return [Symbol] A method or proc to call to transform this value before sending it to field resolution method + def prepare(new_prepare = NOT_CONFIGURED) + if new_prepare != NOT_CONFIGURED + @prepare = new_prepare + end + @prepare + end # @return [Symbol] This argument's name in Ruby keyword arguments attr_reader :keyword @@ -41,24 +40,40 @@ def from_resolver? # @param arg_name [Symbol] # @param type_expr # @param desc [String] - # @param required [Boolean] if true, this argument is non-null; if false, this argument is nullable + # @param type [Class, Array] Input type; positional argument also accepted + # @param name [Symbol] positional argument also accepted # @param loads [Class, Array] A GraphQL type to load for the given ID when one is present + # @param definition_block [Proc] Called with the newly-created {Argument} + # @param owner [Class] Private, used by GraphQL-Ruby during schema definition + # @param required [Boolean, :nullable] if true, this argument is non-null; if false, this argument is nullable. If `:nullable`, then the argument must be provided, though it may be `null`. # @param description [String] # @param default_value [Object] + # @param loads [Class, Array] A GraphQL type to load for the given ID when one is present # @param as [Symbol] Override the keyword name when passed to a method # @param prepare [Symbol] A method to call to transform this argument's valuebefore sending it to field resolution # @param camelize [Boolean] if true, the name will be camelized when building the schema # @param from_resolver [Boolean] if true, a Resolver class defined this argument - # @param method_access [Boolean] If false, don't build method access on legacy {Query::Arguments} instances. # @param directives [Hash{Class => Hash}] # @param deprecation_reason [String] # @param validates [Hash, nil] Options for building validators, if any should be applied - def initialize(arg_name = nil, type_expr = nil, desc = nil, required:, type: nil, name: nil, loads: nil, description: nil, ast_node: nil, default_value: NO_DEFAULT, as: nil, from_resolver: false, camelize: true, prepare: nil, method_access: true, owner:, validates: nil, directives: nil, deprecation_reason: nil, &definition_block) + # @param replace_null_with_default [Boolean] if `true`, incoming values of `null` will be replaced with the configured `default_value` + # @param comment [String] Private, used by GraphQL-Ruby when parsing GraphQL schema files + # @param ast_node [GraphQL::Language::Nodes::InputValueDefinition] Private, used by GraphQL-Ruby when parsing schema files + def initialize(arg_name = nil, type_expr = nil, desc = nil, required: true, type: nil, name: nil, loads: nil, description: nil, comment: nil, ast_node: nil, default_value: NOT_CONFIGURED, as: nil, from_resolver: false, camelize: true, prepare: nil, owner:, validates: nil, directives: nil, deprecation_reason: nil, replace_null_with_default: false, &definition_block) arg_name ||= name @name = -(camelize ? Member::BuildType.camelize(arg_name.to_s) : arg_name.to_s) + NameValidator.validate!(@name) @type_expr = type_expr || type @description = desc || description - @null = !required + @comment = comment + @null = required != true @default_value = default_value + if replace_null_with_default + if !default_value? + raise ArgumentError, "`replace_null_with_default: true` requires a default value, please provide one with `default_value: ...`" + end + @replace_null_with_default = true + end + @owner = owner @as = as @loads = loads @@ -66,7 +81,6 @@ def initialize(arg_name = nil, type_expr = nil, desc = nil, required:, type: nil @prepare = prepare @ast_node = ast_node @from_resolver = from_resolver - @method_access = method_access self.deprecation_reason = deprecation_reason if directives @@ -75,23 +89,40 @@ def initialize(arg_name = nil, type_expr = nil, desc = nil, required:, type: nil end end - self.validates(validates) + if validates && !validates.empty? + self.validates(validates) + end + + if required == :nullable + self.owner.validates(required: { argument: @keyword }) + end if definition_block - if definition_block.arity == 1 - instance_exec(self, &definition_block) - else - instance_eval(&definition_block) - end + # `self` will still be self, it will also be the first argument to the block: + instance_exec(self, &definition_block) end end + def inspect + "#<#{self.class} #{path}: #{type.to_type_signature}#{description ? " @description=#{description.inspect}" : ""}>" + end + + # @param default_value [Object] The value to use when the client doesn't provide one # @return [Object] the value used when the client doesn't provide a value for this argument - attr_reader :default_value + def default_value(new_default_value = NOT_CONFIGURED) + if new_default_value != NOT_CONFIGURED + @default_value = new_default_value + end + @default_value + end # @return [Boolean] True if this argument has a default value def default_value? - @default_value != NO_DEFAULT + @default_value != NOT_CONFIGURED + end + + def replace_null_with_default? + @replace_null_with_default end attr_writer :description @@ -105,6 +136,17 @@ def description(text = nil) end end + attr_writer :comment + + # @return [String] Comment for this argument + def comment(text = nil) + if text + @comment = text + else + @comment + end + end + # @return [String] Deprecation reason for this argument def deprecation_reason(text = nil) if text @@ -123,8 +165,8 @@ def visible?(context) true end - def accessible?(context) - true + def authorizes?(context) + self.method(:authorized?).owner != GraphQL::Schema::Argument || type.unwrap.authorizes?(context) end def authorized?(obj, value, ctx) @@ -147,38 +189,13 @@ def authorized_as_type?(obj, value, ctx, as_type:) end end elsif as_type.kind.input_object? - as_type.arguments.each do |_name, input_obj_arg| - input_obj_arg = input_obj_arg.type_class - # TODO: this skips input objects whose values were alread replaced with application objects. - # See: https://github.com/rmosolgo/graphql-ruby/issues/2633 - if value.respond_to?(:key?) && value.key?(input_obj_arg.keyword) && !input_obj_arg.authorized?(obj, value[input_obj_arg.keyword], ctx) - return false - end - end + return as_type.authorized?(obj, value, ctx) end # None of the early-return conditions were activated, # so this is authorized. true end - def to_graphql - argument = GraphQL::Argument.new - argument.name = @name - argument.type = -> { type } - argument.description = @description - argument.metadata[:type_class] = self - argument.as = @as - argument.ast_node = ast_node - argument.method_access = @method_access - if NO_DEFAULT != @default_value - argument.default_value = @default_value - end - if self.deprecation_reason - argument.deprecation_reason = self.deprecation_reason - end - argument - end - def type=(new_type) validate_input_type(new_type) # This isn't true for LateBoundTypes, but we can assume those will @@ -203,16 +220,21 @@ def type def statically_coercible? return @statically_coercible if defined?(@statically_coercible) + requires_parent_object = @prepare.is_a?(String) || @prepare.is_a?(Symbol) || @own_validators + @statically_coercible = !requires_parent_object + end - @statically_coercible = !@prepare.is_a?(String) && !@prepare.is_a?(Symbol) + def freeze + statically_coercible? + super end # Apply the {prepare} configuration to `value`, using methods from `obj`. # Used by the runtime. # @api private def prepare_value(obj, value, context: nil) - if value.is_a?(GraphQL::Schema::InputObject) - value = value.prepare + if type.unwrap.kind.input_object? + value = recursively_prepare_input_object(value, type, context) end Schema::Validator.validate!(validators, obj, context, value) @@ -226,8 +248,13 @@ def prepare_value(obj, value, context: nil) # # This will have to be called later, when the runtime object _is_ available. value - else + elsif obj.respond_to?(@prepare) obj.public_send(@prepare, value) + elsif owner.respond_to?(@prepare) + owner.public_send(@prepare, value, context || obj.context) + else + raise "Invalid prepare for #{@owner.name}.name: #{@prepare.inspect}. "\ + "Could not find prepare method #{@prepare} on #{obj.class} or #{owner}." end elsif @prepare.respond_to?(:call) @prepare.call(value, context || obj.context) @@ -255,50 +282,120 @@ def coerce_into_values(parent_object, values, context, argument_values) return end + if value.nil? && replace_null_with_default? + value = default_value + default_used = true + end + loaded_value = nil - coerced_value = context.schema.error_handler.with_error_handling(context) do + coerced_value = begin type.coerce_input(value, context) + rescue StandardError => err + context.schema.handle_or_reraise(context, err) end - # TODO this should probably be inside after_lazy - if loads && !from_resolver? - loaded_value = if type.list? - loaded_values = coerced_value.map { |val| owner.load_application_object(self, loads, val, context) } - context.schema.after_any_lazies(loaded_values) { |result| result } - else - context.query.with_error_handling do - owner.load_application_object(self, loads, coerced_value, context) + # If this isn't lazy, then the block returns eagerly and assigns the result here + # If it _is_ lazy, then we write the lazy to the hash, then update it later + argument_values[arg_key] = context.query.after_lazy(coerced_value) do |resolved_coerced_value| + owner.validate_directive_argument(self, resolved_coerced_value) + prepared_value = begin + prepare_value(parent_object, resolved_coerced_value, context: context) + rescue StandardError => err + context.schema.handle_or_reraise(context, err) + end + + if loads && !from_resolver? + loaded_value = begin + load_and_authorize_value(owner, prepared_value, context) + rescue StandardError => err + context.schema.handle_or_reraise(context, err) end end - end - coerced_value = if loaded_value - loaded_value - else - coerced_value + maybe_loaded_value = loaded_value || prepared_value + context.query.after_lazy(maybe_loaded_value) do |resolved_loaded_value| + # TODO code smell to access such a deeply-nested constant in a distant module + argument_values[arg_key] = GraphQL::Execution::Interpreter::ArgumentValue.new( + value: resolved_loaded_value, + original_value: resolved_coerced_value, + definition: self, + default_used: default_used, + ) + end end + end - # If this isn't lazy, then the block returns eagerly and assigns the result here - # If it _is_ lazy, then we write the lazy to the hash, then update it later - argument_values[arg_key] = context.schema.after_lazy(coerced_value) do |coerced_value| - owner.validate_directive_argument(self, coerced_value) - prepared_value = context.schema.error_handler.with_error_handling(context) do - prepare_value(parent_object, coerced_value, context: context) + def load_and_authorize_value(load_method_owner, coerced_value, context) + if coerced_value.nil? + return nil + end + arg_load_method = "load_#{keyword}" + if load_method_owner.respond_to?(arg_load_method) + custom_loaded_value = if load_method_owner.is_a?(Class) + load_method_owner.public_send(arg_load_method, coerced_value, context) + else + load_method_owner.public_send(arg_load_method, coerced_value) end - - # TODO code smell to access such a deeply-nested constant in a distant module - argument_values[arg_key] = GraphQL::Execution::Interpreter::ArgumentValue.new( - value: prepared_value, - definition: self, - default_used: default_used, - ) + context.query.after_lazy(custom_loaded_value) do |custom_value| + if loads + if type.list? + loaded_values = [] + context.dataloader.run_isolated do + custom_value.each_with_index.map { |custom_val, idx| + id = coerced_value[idx] + context.dataloader.append_job do + loaded_values[idx] = load_method_owner.authorize_application_object(self, id, context, custom_val) + end + } + end + context.schema.after_any_lazies(loaded_values, &:itself) + else + load_method_owner.authorize_application_object(self, coerced_value, context, custom_loaded_value) + end + else + custom_value + end + end + elsif loads + if type.list? + loaded_values = [] + # We want to run these list items all together, + # but we also need to wait for the result so we can return it :S + context.dataloader.run_isolated do + coerced_value.each_with_index { |val, idx| + context.dataloader.append_job do + loaded_values[idx] = load_method_owner.load_and_authorize_application_object(self, val, context) + end + } + end + context.schema.after_any_lazies(loaded_values, &:itself) + else + load_method_owner.load_and_authorize_application_object(self, coerced_value, context) + end + else + coerced_value end end # @api private def validate_default_value + return unless default_value? coerced_default_value = begin - type.coerce_isolated_result(default_value) unless default_value.nil? + # This is weird, but we should accept single-item default values for list-type arguments. + # If we used `coerce_isolated_input` below, it would do this for us, but it's not really + # the right thing here because we expect default values in application format (Ruby values) + # not GraphQL format (scalar values). + # + # But I don't think Schema::List#coerce_result should apply wrapping to single-item lists. + prepped_default_value = if default_value.nil? + nil + elsif (type.kind.list? || (type.kind.non_null? && type.of_type.list?)) && !default_value.respond_to?(:map) + [default_value] + else + default_value + end + + type.coerce_isolated_result(prepped_default_value) unless prepped_default_value.nil? rescue GraphQL::Schema::Enum::UnresolvedValueError # It raises this, which is helpful at runtime, but not here... default_value @@ -318,6 +415,22 @@ def initialize(argument) private + def recursively_prepare_input_object(value, type, context) + if type.non_null? + type = type.of_type + end + + if type.list? && !value.nil? + inner_type = type.of_type + value.map { |v| recursively_prepare_input_object(v, inner_type, context) } + elsif value.is_a?(GraphQL::Schema::InputObject) + value.validate_for(context) + value.prepare + else + value + end + end + def validate_input_type(input_type) if input_type.is_a?(String) || input_type.is_a?(GraphQL::Schema::LateBoundType) # Do nothing; assume this will be validated later diff --git a/lib/graphql/schema/base_64_bp.rb b/lib/graphql/schema/base_64_bp.rb deleted file mode 100644 index a011f1fb7f8..00000000000 --- a/lib/graphql/schema/base_64_bp.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require 'base64' - -# backport from ruby v2.5 to v2.2 that has no `padding` things -# @api private -module Base64Bp - extend Base64 - - module_function - - def urlsafe_encode64(bin, padding:) - str = strict_encode64(bin) - str.tr!("+/", "-_") - str.delete!("=") unless padding - str - end - - def urlsafe_decode64(str) - str = str.tr("-_", "+/") - if !str.end_with?("=") && str.length % 4 != 0 - str = str.ljust((str.length + 3) & ~3, "=") - end - strict_decode64(str) - end -end diff --git a/lib/graphql/schema/base_64_encoder.rb b/lib/graphql/schema/base_64_encoder.rb index 82e288106a3..3679465460e 100644 --- a/lib/graphql/schema/base_64_encoder.rb +++ b/lib/graphql/schema/base_64_encoder.rb @@ -1,18 +1,16 @@ # frozen_string_literal: true - -require 'graphql/schema/base_64_bp' - +require "base64" module GraphQL class Schema # @api private module Base64Encoder def self.encode(unencoded_text, nonce: false) - Base64Bp.urlsafe_encode64(unencoded_text, padding: false) + Base64.urlsafe_encode64(unencoded_text, padding: false) end def self.decode(encoded_text, nonce: false) # urlsafe_decode64 is for forward compatibility - Base64Bp.urlsafe_decode64(encoded_text) + Base64.urlsafe_decode64(encoded_text) rescue ArgumentError raise GraphQL::ExecutionError, "Invalid input: #{encoded_text.inspect}" end diff --git a/lib/graphql/schema/build_from_definition.rb b/lib/graphql/schema/build_from_definition.rb index 373c457e965..0490eaa5346 100644 --- a/lib/graphql/schema/build_from_definition.rb +++ b/lib/graphql/schema/build_from_definition.rb @@ -3,34 +3,45 @@ module GraphQL class Schema - # TODO Populate `.directive(...)` from here module BuildFromDefinition - if !String.method_defined?(:-@) - using GraphQL::StringDedupBackport - end - class << self # @see {Schema.from_definition} - def from_definition(definition_string, parser: GraphQL.default_parser, **kwargs) - from_document(parser.parse(definition_string), **kwargs) + def from_definition(schema_superclass, definition_string, parser: GraphQL.default_parser, **kwargs) + if defined?(parser::SchemaParser) + parser = parser::SchemaParser + end + from_document(schema_superclass, parser.parse(definition_string), **kwargs) end - def from_definition_path(definition_path, parser: GraphQL.default_parser, **kwargs) - from_document(parser.parse_file(definition_path), **kwargs) + def from_definition_path(schema_superclass, definition_path, parser: GraphQL.default_parser, **kwargs) + if defined?(parser::SchemaParser) + parser = parser::SchemaParser + end + from_document(schema_superclass, parser.parse_file(definition_path), **kwargs) end - def from_document(document, default_resolve:, using: {}, relay: false) - Builder.build(document, default_resolve: default_resolve || {}, relay: relay, using: using) + def from_document(schema_superclass, document, default_resolve:, using: {}, base_types: {}, relay: false) + Builder.build(schema_superclass, document, default_resolve: default_resolve || {}, relay: relay, using: using, base_types: base_types) end end # @api private module Builder + include GraphQL::EmptyObjects extend self - def build(document, default_resolve:, using: {}, relay:) + def build(schema_superclass, document, default_resolve:, using: {}, base_types: {}, relay:) raise InvalidDocumentError.new('Must provide a document ast.') if !document || !document.is_a?(GraphQL::Language::Nodes::Document) + base_types = { + object: GraphQL::Schema::Object, + interface: GraphQL::Schema::Interface, + union: GraphQL::Schema::Union, + scalar: GraphQL::Schema::Scalar, + enum: GraphQL::Schema::Enum, + input_object: GraphQL::Schema::InputObject, + }.merge!(base_types) + if default_resolve.is_a?(Hash) default_resolve = ResolveMap.new(default_resolve) end @@ -41,39 +52,72 @@ def build(document, default_resolve:, using: {}, relay:) end schema_definition = schema_defns.first types = {} - directives = {} + directives = schema_superclass.directives.dup type_resolver = build_resolve_type(types, directives, ->(type_name) { types[type_name] ||= Schema::LateBoundType.new(type_name)}) # Make a different type resolver because we need to coerce directive arguments # _while_ building the schema. # It will dig for a type if it encounters a custom type. This could be a problem if there are cycles. directive_type_resolver = nil - directive_type_resolver = build_resolve_type(GraphQL::Schema::BUILT_IN_TYPES, directives, ->(type_name) { + directive_type_resolver = build_resolve_type(types, directives, ->(type_name) { types[type_name] ||= begin defn = document.definitions.find { |d| d.respond_to?(:name) && d.name == type_name } - build_definition_from_node(defn, directive_type_resolver, default_resolve) + if defn + build_definition_from_node(defn, directive_type_resolver, default_resolve, base_types) + elsif (built_in_defn = GraphQL::Schema::BUILT_IN_TYPES[type_name]) + built_in_defn + else + raise "No definition for #{type_name.inspect} found in schema document or built-in types. Add a definition for it or remove it." + end end }) + directives.merge!(GraphQL::Schema.default_directives) document.definitions.each do |definition| if definition.is_a?(GraphQL::Language::Nodes::DirectiveDefinition) directives[definition.name] = build_directive(definition, directive_type_resolver) end end - directives = GraphQL::Schema.default_directives.merge(directives) - # In case any directives referenced built-in types for their arguments: replace_late_bound_types_with_built_in(types) + schema_extensions = nil + definitions_by_name = nil document.definitions.each do |definition| case definition when GraphQL::Language::Nodes::SchemaDefinition, GraphQL::Language::Nodes::DirectiveDefinition nil # already handled + when GraphQL::Language::Nodes::SchemaExtension, + GraphQL::Language::Nodes::ScalarTypeExtension, + GraphQL::Language::Nodes::ObjectTypeExtension, + GraphQL::Language::Nodes::InterfaceTypeExtension, + GraphQL::Language::Nodes::UnionTypeExtension, + GraphQL::Language::Nodes::EnumTypeExtension, + GraphQL::Language::Nodes::InputObjectTypeExtension + schema_extensions ||= [] + schema_extensions << definition else # It's possible that this was already loaded by the directives prev_type = types[definition.name] if prev_type.nil? || prev_type.is_a?(Schema::LateBoundType) - types[definition.name] = build_definition_from_node(definition, type_resolver, default_resolve) + if definition.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) || definition.is_a?(Language::Nodes::InterfaceTypeDefinition) + interface_names = definition.interfaces.map(&:name) + if !interface_names.empty? + definitions_by_name ||= document.definitions.each_with_object({}) do |d, by_name| + by_name[d.name] ||= d if d.respond_to?(:name) + end + transitive_names = interface_names.map { |n| definitions_by_name[n]&.interfaces&.map(&:name) } + transitive_names.flatten! + transitive_names.compact! + else + transitive_names = interface_names + end + if !(missing_transitive_interfaces = transitive_names - interface_names).empty? + raise GraphQL::Schema::InvalidDocumentError, "type #{definition.name} is missing one or more transitive interface names: #{missing_transitive_interfaces.join(", ")}. Add them to the type's `implements` list and try again." + end + end + + types[definition.name] = build_definition_from_node(definition, type_resolver, default_resolve, base_types) end end end @@ -95,6 +139,16 @@ def build(document, default_resolve:, using: {}, relay:) raise InvalidDocumentError.new("Specified subscription type \"#{schema_definition.subscription}\" not found in document.") unless types[schema_definition.subscription] subscription_root_type = types[schema_definition.subscription] end + + if schema_definition.query.nil? && + schema_definition.mutation.nil? && + schema_definition.subscription.nil? + # This schema may have been given with directives only, + # check for defaults: + query_root_type = types['Query'] + mutation_root_type = types['Mutation'] + subscription_root_type = types['Subscription'] + end else query_root_type = types['Query'] mutation_root_type = types['Mutation'] @@ -103,10 +157,43 @@ def build(document, default_resolve:, using: {}, relay:) raise InvalidDocumentError.new('Must provide schema definition with query type or a type named Query.') unless query_root_type - Class.new(GraphQL::Schema) do + schema_extensions&.each do |ext| + next if ext.is_a?(GraphQL::Language::Nodes::SchemaExtension) + + built_type = types[ext.name] + + case ext + when GraphQL::Language::Nodes::ScalarTypeExtension + build_directives(built_type, ext, type_resolver) + when GraphQL::Language::Nodes::ObjectTypeExtension + build_directives(built_type, ext, type_resolver) + build_fields(built_type, ext.fields, type_resolver, default_resolve: true) + build_interfaces(built_type, ext.interfaces, type_resolver) + when GraphQL::Language::Nodes::InterfaceTypeExtension + build_directives(built_type, ext, type_resolver) + build_fields(built_type, ext.fields, type_resolver, default_resolve: nil) + build_interfaces(built_type, ext.interfaces, type_resolver) + when GraphQL::Language::Nodes::UnionTypeExtension + build_directives(built_type, ext, type_resolver) + built_type.possible_types(*ext.types.map { |type_name| type_resolver.call(type_name) }) + when GraphQL::Language::Nodes::EnumTypeExtension + build_directives(built_type, ext, type_resolver) + build_values(built_type, ext.values, type_resolver) + when GraphQL::Language::Nodes::InputObjectTypeExtension + build_directives(built_type, ext, type_resolver) + build_arguments(built_type, ext.fields, type_resolver) + end + end + + builder = self + + found_types = types.values + object_types = found_types.select { |t| t.respond_to?(:kind) && t.kind.object? } + schema_class = Class.new(schema_superclass) do begin # Add these first so that there's some chance of resolving late-bound types - orphan_types types.values + add_type_and_traverse(found_types, root: false) + orphan_types(object_types) query query_root_type mutation mutation_root_type subscription subscription_root_type @@ -116,6 +203,16 @@ def build(document, default_resolve:, using: {}, relay:) raise InvalidDocumentError, "Type \"#{type_name}\" not found in document.", err_backtrace end + object_types.each do |t| + t.interfaces.each do |int_t| + if int_t.is_a?(LateBoundType) + int_t = types[int_t.graphql_name] + t.implements(int_t) + end + int_t.orphan_types(t) + end + end + if default_resolve.respond_to?(:resolve_type) def self.resolve_type(*args) self.definition_default_resolve.resolve_type(*args) @@ -130,6 +227,7 @@ def self.resolve_type(*args) if schema_definition ast_node(schema_definition) + builder.build_directives(self, schema_definition, type_resolver) end using.each do |plugin, options| @@ -155,28 +253,39 @@ def definition_default_resolve def self.inherited(child_class) child_class.definition_default_resolve = self.definition_default_resolve + super + end + end + + schema_extensions&.each do |ext| + if ext.is_a?(GraphQL::Language::Nodes::SchemaExtension) + build_directives(schema_class, ext, type_resolver) end end + + schema_class end NullResolveType = ->(type, obj, ctx) { raise(GraphQL::RequiredImplementationMissingError, "Generated Schema cannot use Interface or Union types for execution. Implement resolve_type on your resolver.") } - def build_definition_from_node(definition, type_resolver, default_resolve) + def build_definition_from_node(definition, type_resolver, default_resolve, base_types) case definition when GraphQL::Language::Nodes::EnumTypeDefinition - build_enum_type(definition, type_resolver) + build_enum_type(definition, type_resolver, base_types[:enum]) when GraphQL::Language::Nodes::ObjectTypeDefinition - build_object_type(definition, type_resolver) + build_object_type(definition, type_resolver, base_types[:object]) when GraphQL::Language::Nodes::InterfaceTypeDefinition - build_interface_type(definition, type_resolver) + build_interface_type(definition, type_resolver, base_types[:interface]) when GraphQL::Language::Nodes::UnionTypeDefinition - build_union_type(definition, type_resolver) + build_union_type(definition, type_resolver, base_types[:union]) when GraphQL::Language::Nodes::ScalarTypeDefinition - build_scalar_type(definition, type_resolver, default_resolve: default_resolve) + build_scalar_type(definition, type_resolver, base_types[:scalar], default_resolve: default_resolve) when GraphQL::Language::Nodes::InputObjectTypeDefinition - build_input_object_type(definition, type_resolver) + build_input_object_type(definition, type_resolver, base_types[:input_object]) + when GraphQL::Language::Nodes::DirectiveDefinition + build_directive(definition, type_resolver) end end @@ -196,13 +305,18 @@ def replace_late_bound_types_with_built_in(types) def build_directives(definition, ast_node, type_resolver) dirs = prepare_directives(ast_node, type_resolver) - dirs.each do |dir_class, options| - definition.directive(dir_class, **options) + dirs.each do |(dir_class, options)| + if definition.respond_to?(:schema_directive) + # it's a schema + definition.schema_directive(dir_class, **options) + else + definition.directive(dir_class, **options) + end end end def prepare_directives(ast_node, type_resolver) - dirs = {} + dirs = [] ast_node.directives.each do |dir_node| if dir_node.name == "deprecated" # This is handled using `deprecation_reason` @@ -210,10 +324,10 @@ def prepare_directives(ast_node, type_resolver) else dir_class = type_resolver.call(dir_node.name) if dir_class.nil? - raise ArgumentError, "No definition for @#{dir_node.name} on #{ast_node.name} at #{ast_node.line}:#{ast_node.col}" + raise ArgumentError, "No definition for @#{dir_node.name} #{ast_node.respond_to?(:name) ? "on #{ast_node.name} " : ""}at #{ast_node.line}:#{ast_node.col}" end options = args_to_kwargs(dir_class, dir_node) - dirs[dir_class] = options + dirs << [dir_class, options] end end dirs @@ -237,22 +351,26 @@ def args_to_kwargs(arg_owner, node) end end - def build_enum_type(enum_type_definition, type_resolver) + def build_enum_type(enum_type_definition, type_resolver, base_type) builder = self - Class.new(GraphQL::Schema::Enum) do + Class.new(base_type) do graphql_name(enum_type_definition.name) builder.build_directives(self, enum_type_definition, type_resolver) description(enum_type_definition.description) ast_node(enum_type_definition) - enum_type_definition.values.each do |enum_value_definition| - value(enum_value_definition.name, - value: enum_value_definition.name, - deprecation_reason: builder.build_deprecation_reason(enum_value_definition.directives), - description: enum_value_definition.description, - directives: builder.prepare_directives(enum_value_definition, type_resolver), - ast_node: enum_value_definition, - ) - end + builder.build_values(self, enum_type_definition.values, type_resolver) + end + end + + def build_values(type_class, enum_value_definitions, type_resolver) + enum_value_definitions.each do |enum_value_definition| + type_class.value(enum_value_definition.name, + value: enum_value_definition.name, + deprecation_reason: build_deprecation_reason(enum_value_definition.directives), + description: enum_value_definition.description, + directives: prepare_directives(enum_value_definition, type_resolver), + ast_node: enum_value_definition, + ) end end @@ -266,9 +384,9 @@ def build_deprecation_reason(directives) reason.value end - def build_scalar_type(scalar_type_definition, type_resolver, default_resolve:) + def build_scalar_type(scalar_type_definition, type_resolver, base_type, default_resolve:) builder = self - Class.new(GraphQL::Schema::Scalar) do + Class.new(base_type) do graphql_name(scalar_type_definition.name) description(scalar_type_definition.description) ast_node(scalar_type_definition) @@ -289,9 +407,9 @@ def build_scalar_type_coerce_method(scalar_class, method_name, default_definitio end end - def build_union_type(union_type_definition, type_resolver) + def build_union_type(union_type_definition, type_resolver, base_type) builder = self - Class.new(GraphQL::Schema::Union) do + Class.new(base_type) do graphql_name(union_type_definition.name) description(union_type_definition.description) possible_types(*union_type_definition.types.map { |type_name| type_resolver.call(type_name) }) @@ -300,27 +418,28 @@ def build_union_type(union_type_definition, type_resolver) end end - def build_object_type(object_type_definition, type_resolver) + def build_object_type(object_type_definition, type_resolver, base_type) builder = self - Class.new(GraphQL::Schema::Object) do + Class.new(base_type) do graphql_name(object_type_definition.name) description(object_type_definition.description) ast_node(object_type_definition) builder.build_directives(self, object_type_definition, type_resolver) - - object_type_definition.interfaces.each do |interface_name| - interface_defn = type_resolver.call(interface_name) - implements(interface_defn) - end - + builder.build_interfaces(self, object_type_definition.interfaces, type_resolver) builder.build_fields(self, object_type_definition.fields, type_resolver, default_resolve: true) end end - def build_input_object_type(input_object_type_definition, type_resolver) + def build_interfaces(type_class, interface_names, type_resolver) + interface_names.each do |interface_name| + type_class.implements(type_resolver.call(interface_name)) + end + end + + def build_input_object_type(input_object_type_definition, type_resolver, base_type) builder = self - Class.new(GraphQL::Schema::InputObject) do + Class.new(base_type) do graphql_name(input_object_type_definition.name) description(input_object_type_definition.description) ast_node(input_object_type_definition) @@ -344,8 +463,6 @@ def build_default_value(default_value) end end - NO_DEFAULT_VALUE = {}.freeze - def build_arguments(type_class, arguments, type_resolver) builder = self @@ -353,7 +470,7 @@ def build_arguments(type_class, arguments, type_resolver) default_value_kwargs = if !argument_defn.default_value.nil? { default_value: builder.build_default_value(argument_defn.default_value) } else - NO_DEFAULT_VALUE + EMPTY_HASH end type_class.argument( @@ -364,7 +481,6 @@ def build_arguments(type_class, arguments, type_resolver) deprecation_reason: builder.build_deprecation_reason(argument_defn.directives), ast_node: argument_defn, camelize: false, - method_access: false, directives: prepare_directives(argument_defn, type_resolver), **default_value_kwargs ) @@ -376,18 +492,20 @@ def build_directive(directive_definition, type_resolver) Class.new(GraphQL::Schema::Directive) do graphql_name(directive_definition.name) description(directive_definition.description) + repeatable(directive_definition.repeatable) locations(*directive_definition.locations.map { |location| location.name.to_sym }) ast_node(directive_definition) builder.build_arguments(self, directive_definition.arguments, type_resolver) end end - def build_interface_type(interface_type_definition, type_resolver) + def build_interface_type(interface_type_definition, type_resolver, base_type) builder = self Module.new do - include GraphQL::Schema::Interface + include base_type graphql_name(interface_type_definition.name) description(interface_type_definition.description) + builder.build_interfaces(self, interface_type_definition.interfaces, type_resolver) ast_node(interface_type_definition) builder.build_directives(self, interface_type_definition, type_resolver) @@ -399,14 +517,12 @@ def build_fields(owner, field_definitions, type_resolver, default_resolve:) builder = self field_definitions.each do |field_definition| - type_name = resolve_type_name(field_definition.type) resolve_method_name = -"resolve_field_#{field_definition.name}" schema_field_defn = owner.field( field_definition.name, description: field_definition.description, type: type_resolver.call(field_definition.type), null: true, - connection: type_name.end_with?("Connection"), connection_extension: nil, deprecation_reason: build_deprecation_reason(field_definition.directives), ast_node: field_definition, @@ -414,23 +530,31 @@ def build_fields(owner, field_definitions, type_resolver, default_resolve:) camelize: false, directives: prepare_directives(field_definition, type_resolver), resolver_method: resolve_method_name, + resolve_batch: resolve_method_name, ) builder.build_arguments(schema_field_defn, field_definition.arguments, type_resolver) # Don't do this for interfaces if default_resolve - owner.class_eval <<-RUBY, __FILE__, __LINE__ - # frozen_string_literal: true - def #{resolve_method_name}(**args) - field_instance = self.class.get_field("#{field_definition.name}") - context.schema.definition_default_resolve.call(self.class, field_instance, object, args, context) - end - RUBY + define_field_resolve_method(owner, resolve_method_name, field_definition.name) end end end + def define_field_resolve_method(owner, method_name, field_name) + owner.define_method(method_name) { |**args| + field_instance = context.types.field(owner, field_name) + context.schema.definition_default_resolve.call(self.class, field_instance, object, args, context) + } + owner.define_singleton_method(method_name) { |objects, context, **args| + field_instance = context.types.field(owner, field_name) + objects.map do |object| + context.schema.definition_default_resolve.call(self, field_instance, object, args, context) + end + } + end + def build_resolve_type(lookup_hash, directives, missing_type_handler) resolve_type_proc = nil resolve_type_proc = ->(ast_node) { @@ -447,22 +571,13 @@ def build_resolve_type(lookup_hash, directives, missing_type_handler) when GraphQL::Language::Nodes::ListType resolve_type_proc.call(ast_node.of_type).to_list_type when String - directives[ast_node] + directives[ast_node] ||= missing_type_handler.call(ast_node) else raise "Unexpected ast_node: #{ast_node.inspect}" end } resolve_type_proc end - - def resolve_type_name(type) - case type - when GraphQL::Language::Nodes::TypeName - return type.name - else - resolve_type_name(type.of_type) - end - end end private_constant :Builder diff --git a/lib/graphql/schema/catchall_middleware.rb b/lib/graphql/schema/catchall_middleware.rb deleted file mode 100644 index 4105ff4aff9..00000000000 --- a/lib/graphql/schema/catchall_middleware.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Schema - # In early GraphQL versions, errors would be "automatically" - # rescued and replaced with `"Internal error"`. That behavior - # was undesirable but this middleware is offered for people who - # want to preserve it. - # - # It has a couple of differences from the previous behavior: - # - # - Other parts of the query _will_ be run (previously, - # execution would stop when the error was raised and the result - # would have no `"data"` key at all) - # - The entry in {Query::Context#errors} is a {GraphQL::ExecutionError}, _not_ - # the originally-raised error. - # - The entry in the `"errors"` key includes the location of the field - # which raised the errors. - # - # @example Use CatchallMiddleware with your schema - # # All errors will be suppressed and replaced with "Internal error" messages - # MySchema.middleware << GraphQL::Schema::CatchallMiddleware - # - module CatchallMiddleware - MESSAGE = "Internal error" - - # Rescue any error and replace it with a {GraphQL::ExecutionError} - # whose message is {MESSAGE} - def self.call(parent_type, parent_object, field_definition, field_args, query_context) - yield - rescue StandardError - GraphQL::ExecutionError.new(MESSAGE) - end - end - end -end diff --git a/lib/graphql/schema/default_parse_error.rb b/lib/graphql/schema/default_parse_error.rb deleted file mode 100644 index 5da542f1c34..00000000000 --- a/lib/graphql/schema/default_parse_error.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Schema - module DefaultParseError - def self.call(parse_error, ctx) - ctx.errors.push(parse_error) - end - end - end -end diff --git a/lib/graphql/schema/default_type_error.rb b/lib/graphql/schema/default_type_error.rb deleted file mode 100644 index bebfd0de49f..00000000000 --- a/lib/graphql/schema/default_type_error.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Schema - module DefaultTypeError - def self.call(type_error, ctx) - case type_error - when GraphQL::InvalidNullError - ctx.errors << type_error - when GraphQL::UnresolvedTypeError, GraphQL::StringEncodingError, GraphQL::IntegerEncodingError - raise type_error - when GraphQL::IntegerDecodingError - nil - end - end - end - end -end diff --git a/lib/graphql/schema/directive.rb b/lib/graphql/schema/directive.rb index a0bc5bd4615..baa47515da6 100644 --- a/lib/graphql/schema/directive.rb +++ b/lib/graphql/schema/directive.rb @@ -8,6 +8,9 @@ class Schema # - {.resolve}: Wraps field resolution (so it should call `yield` to continue) class Directive < GraphQL::Schema::Member extend GraphQL::Schema::Member::HasArguments + extend GraphQL::Schema::Member::HasArguments::HasDirectiveArguments + extend GraphQL::Schema::Member::HasValidators + class << self # Directives aren't types, they don't have kinds. undef_method :kind @@ -20,25 +23,33 @@ def path # but downcase the first letter. def default_graphql_name @default_graphql_name ||= begin - camelized_name = super + camelized_name = super.dup camelized_name[0] = camelized_name[0].downcase - camelized_name + -camelized_name end end def locations(*new_locations) - if new_locations.any? + if !new_locations.empty? + is_runtime = false new_locations.each do |new_loc| - if !LOCATIONS.include?(new_loc.to_sym) + loc_sym = new_loc.to_sym + if !LOCATIONS.include?(loc_sym) raise ArgumentError, "#{self} (#{self.graphql_name}) has an invalid directive location: `locations #{new_loc}` " end + is_runtime ||= RUNTIME_LOCATIONS.include?(loc_sym) end @locations = new_locations + @is_runtime = is_runtime else @locations ||= (superclass.respond_to?(:locations) ? superclass.locations : []) end end + def runtime? + @is_runtime + end + def default_directive(new_default_directive = nil) if new_default_directive != nil @default_directive = new_default_directive @@ -53,24 +64,6 @@ def default_directive? default_directive end - def to_graphql - defn = GraphQL::Directive.new - defn.name = self.graphql_name - defn.description = self.description - defn.locations = self.locations - defn.default_directive = self.default_directive - defn.ast_node = ast_node - defn.metadata[:type_class] = self - arguments.each do |name, arg_defn| - arg_graphql = arg_defn.to_graphql - defn.arguments[arg_graphql.name] = arg_graphql - end - # Make a reference to a classic-style Arguments class - defn.arguments_class = GraphQL::Query::Arguments.construct_arguments_class(defn) - - defn - end - # If false, this part of the query won't be evaluated def include?(_object, arguments, context) static_include?(arguments, context) @@ -86,6 +79,15 @@ def resolve(object, arguments, context) yield end + # Continuing is passed as a block, yield to continue. + def resolve_each(object, arguments, context) + yield + end + + def validate!(arguments, context) + Schema::Validator.validate!(validators, self, context, arguments) + end + def on_field? locations.include?(FIELD) end @@ -97,6 +99,27 @@ def on_fragment? def on_operation? locations.include?(QUERY) && locations.include?(MUTATION) && locations.include?(SUBSCRIPTION) end + + def repeatable? + !!@repeatable + end + + def repeatable(new_value) + @repeatable = new_value + end + + private + + def inherited(subclass) + super + parent_class = self + subclass.class_exec do + @default_graphql_name ||= nil + @locations = parent_class.locations + @is_runtime = parent_class.runtime? + @repeatable = false + end + end end # @return [GraphQL::Schema::Field, GraphQL::Schema::Argument, Class, Module] @@ -105,6 +128,9 @@ def on_operation? # @return [GraphQL::Interpreter::Arguments] attr_reader :arguments + class InvalidArgumentError < GraphQL::Error + end + def initialize(owner, **arguments) @owner = owner assert_valid_owner @@ -113,17 +139,69 @@ def initialize(owner, **arguments) # - lazy resolution # Probably, those won't be needed here, since these are configuration arguments, # not runtime arguments. - @arguments = self.class.coerce_arguments(nil, arguments, Query::NullContext) + context = Query::NullContext.instance + self.class.all_argument_definitions.each do |arg_defn| + keyword = arg_defn.keyword + arg_type = arg_defn.type + if arguments.key?(keyword) + value = arguments[keyword] + # This is a Ruby-land value; convert it to graphql for validation + graphql_value = begin + coerce_value = value + if arg_type.list? && (!coerce_value.nil?) && (!coerce_value.is_a?(Array)) + # When validating inputs, GraphQL accepts a single item + # and implicitly converts it to a one-item list. + # However, we're using result coercion here to go from Ruby value + # to GraphQL value, so it doesn't have that feature. + # Keep the GraphQL-type behavior but implement it manually: + wrap_type = arg_type + while wrap_type.list? + if wrap_type.non_null? + wrap_type = wrap_type.of_type + end + wrap_type = wrap_type.of_type + coerce_value = [coerce_value] + end + end + arg_type.coerce_isolated_result(coerce_value) + rescue GraphQL::Schema::Enum::UnresolvedValueError + # Let validation handle this + value + end + elsif arg_defn.default_value? + value = arg_defn.default_value + graphql_value = arg_type.coerce_isolated_result(value) unless value.nil? + else + value = graphql_value = nil + end + + result = arg_type.validate_input(graphql_value, context) + if !result.valid? + raise InvalidArgumentError, "@#{graphql_name}.#{arg_defn.graphql_name} on #{owner.path} is invalid (#{value.inspect}): #{result.problems.first["explanation"]}" + end + end + self.class.validate!(arguments, context) + @arguments = self.class.coerce_arguments(nil, arguments, context) + if @arguments.is_a?(GraphQL::ExecutionError) + raise @arguments + end + end + + def graphql_name + self.class.graphql_name end LOCATIONS = [ - QUERY = :QUERY, - MUTATION = :MUTATION, - SUBSCRIPTION = :SUBSCRIPTION, - FIELD = :FIELD, - FRAGMENT_DEFINITION = :FRAGMENT_DEFINITION, - FRAGMENT_SPREAD = :FRAGMENT_SPREAD, - INLINE_FRAGMENT = :INLINE_FRAGMENT, + *(RUNTIME_LOCATIONS = [ + QUERY = :QUERY, + MUTATION = :MUTATION, + SUBSCRIPTION = :SUBSCRIPTION, + FIELD = :FIELD, + FRAGMENT_DEFINITION = :FRAGMENT_DEFINITION, + FRAGMENT_SPREAD = :FRAGMENT_SPREAD, + INLINE_FRAGMENT = :INLINE_FRAGMENT, + VARIABLE_DEFINITION = :VARIABLE_DEFINITION, + ]), SCHEMA = :SCHEMA, SCALAR = :SCALAR, OBJECT = :OBJECT, @@ -157,6 +235,7 @@ def initialize(owner, **arguments) ENUM_VALUE: 'Location adjacent to an enum value definition.', INPUT_OBJECT: 'Location adjacent to an input object type definition.', INPUT_FIELD_DEFINITION: 'Location adjacent to an input object field definition.', + VARIABLE_DEFINITION: 'Location adjacent to a variable definition.', } private @@ -176,6 +255,8 @@ def assert_valid_owner assert_has_location(SCALAR) elsif @owner < GraphQL::Schema assert_has_location(SCHEMA) + elsif @owner < GraphQL::Schema::Resolver + assert_has_location(FIELD_DEFINITION) else raise "Unexpected directive owner class: #{@owner}" end diff --git a/lib/graphql/schema/directive/feature.rb b/lib/graphql/schema/directive/feature.rb index 740c4928632..94aea2d7e7d 100644 --- a/lib/graphql/schema/directive/feature.rb +++ b/lib/graphql/schema/directive/feature.rb @@ -42,7 +42,7 @@ class Feature < Schema::Directive GraphQL::Schema::Directive::INLINE_FRAGMENT ) - argument :flag, String, required: true, + argument :flag, String, description: "The name of the feature to check before continuing" # Implement the Directive API @@ -60,6 +60,10 @@ def self.include?(object, arguments, context) def self.enabled?(flag_name, object, context) raise GraphQL::RequiredImplementationMissingError, "Implement `.enabled?(flag_name, object, context)` to return true or false for the feature flag (#{flag_name.inspect})" end + + def self.resolve_field(...); end + def self.resolve_fragment_spread(...); end + def self.resolve_inline_fragment(...); end end end end diff --git a/lib/graphql/schema/directive/flagged.rb b/lib/graphql/schema/directive/flagged.rb index 1f1bebc4e45..5cf25c8c428 100644 --- a/lib/graphql/schema/directive/flagged.rb +++ b/lib/graphql/schema/directive/flagged.rb @@ -7,7 +7,7 @@ class Directive < GraphQL::Schema::Member # In this case, the server hides types and fields _entirely_, unless the current context has certain `:flags` present. class Flagged < GraphQL::Schema::Directive def initialize(target, **options) - if target.is_a?(Module) && !target.ancestors.include?(VisibleByFlag) + if target.is_a?(Module) # This is type class of some kind, `include` will put this module # in between the type class itself and its super class, so `super` will work fine target.include(VisibleByFlag) @@ -35,7 +35,9 @@ def initialize(target, **options) GraphQL::Schema::Directive::INPUT_FIELD_DEFINITION, ) - argument :by, [String], "Flags to check for this schema member", required: true + argument :by, [String], "Flags to check for this schema member" + + repeatable(true) module VisibleByFlag def self.included(schema_class) @@ -44,8 +46,8 @@ def self.included(schema_class) def visible?(context) if dir = self.directives.find { |d| d.is_a?(Flagged) } - relevant_flags = (f = context[:flags]) && dir.arguments[:by] & f - relevant_flags && relevant_flags.any? && super + relevant_flags = (f = context[:flags]) && dir.arguments[:by] & f # rubocop:disable Development/ContextIsPassedCop -- definition-related + relevant_flags && !relevant_flags.empty? && super else super end diff --git a/lib/graphql/schema/directive/include.rb b/lib/graphql/schema/directive/include.rb index 107bdac4e68..ab6d83b1246 100644 --- a/lib/graphql/schema/directive/include.rb +++ b/lib/graphql/schema/directive/include.rb @@ -11,7 +11,7 @@ class Include < GraphQL::Schema::Directive GraphQL::Schema::Directive::INLINE_FRAGMENT ) - argument :if, Boolean, required: true, + argument :if, Boolean, description: "Included when true." default_directive true diff --git a/lib/graphql/schema/directive/one_of.rb b/lib/graphql/schema/directive/one_of.rb new file mode 100644 index 00000000000..39355037fa7 --- /dev/null +++ b/lib/graphql/schema/directive/one_of.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +module GraphQL + class Schema + class Directive < GraphQL::Schema::Member + class OneOf < GraphQL::Schema::Directive + description "Requires that exactly one field must be supplied and that field must not be `null`." + locations(GraphQL::Schema::Directive::INPUT_OBJECT) + default_directive true + + def initialize(...) + super + + owner.extend(IsOneOf) + end + + module IsOneOf + def one_of? + true + end + end + end + end + end +end diff --git a/lib/graphql/schema/directive/skip.rb b/lib/graphql/schema/directive/skip.rb index 2b2d778d755..312dac992bf 100644 --- a/lib/graphql/schema/directive/skip.rb +++ b/lib/graphql/schema/directive/skip.rb @@ -11,7 +11,7 @@ class Skip < Schema::Directive GraphQL::Schema::Directive::INLINE_FRAGMENT ) - argument :if, Boolean, required: true, + argument :if, Boolean, description: "Skipped when true." default_directive true diff --git a/lib/graphql/schema/directive/specified_by.rb b/lib/graphql/schema/directive/specified_by.rb new file mode 100644 index 00000000000..73fe77ee9ed --- /dev/null +++ b/lib/graphql/schema/directive/specified_by.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +module GraphQL + class Schema + class Directive < GraphQL::Schema::Member + class SpecifiedBy < GraphQL::Schema::Directive + description "Exposes a URL that specifies the behavior of this scalar." + locations(GraphQL::Schema::Directive::SCALAR) + default_directive true + + argument :url, String, description: "The URL that specifies the behavior of this scalar." + end + end + end +end diff --git a/lib/graphql/schema/directive/transform.rb b/lib/graphql/schema/directive/transform.rb index 1a899366d54..1ad15912152 100644 --- a/lib/graphql/schema/directive/transform.rb +++ b/lib/graphql/schema/directive/transform.rb @@ -24,7 +24,7 @@ class Transform < Schema::Directive GraphQL::Schema::Directive::FIELD, ) - argument :by, String, required: true, + argument :by, String, description: "The name of the transform to run if applicable" TRANSFORMS = [ @@ -39,7 +39,7 @@ def self.resolve(object, arguments, context) transform_name = arguments[:by] if TRANSFORMS.include?(transform_name) && return_value.respond_to?(transform_name) return_value = return_value.public_send(transform_name) - response = context.namespace(:interpreter)[:runtime].final_result + response = context.namespace(:interpreter_runtime)[:runtime].final_result *keys, last = path keys.each do |key| if response && (response = response[key]) @@ -54,6 +54,26 @@ def self.resolve(object, arguments, context) nil end end + + def self.resolve_field(ast_nodes, parent_type, field_defn, objects, arguments, context) + transform_name = arguments[:by] + if TRANSFORMS.include?(transform_name) + Transformer.new(transform_name) + else + nil + end + end + + class Transformer + include Execution::PostProcessor + def initialize(transform) + @transform = transform + end + def after_resolve(field_results) + field_results.map! { |r| r.respond_to?(@transform) ? r.public_send(@transform) : r } + field_results + end + end end end end diff --git a/lib/graphql/schema/enum.rb b/lib/graphql/schema/enum.rb index 3203dbd8b0d..4be001fe92c 100644 --- a/lib/graphql/schema/enum.rb +++ b/lib/graphql/schema/enum.rb @@ -1,31 +1,42 @@ # frozen_string_literal: true module GraphQL - # Extend this class to define GraphQL enums in your schema. - # - # By default, GraphQL enum values are translated into Ruby strings. - # You can provide a custom value with the `value:` keyword. - # - # @example - # # equivalent to - # # enum PizzaTopping { - # # MUSHROOMS - # # ONIONS - # # PEPPERS - # # } - # class PizzaTopping < GraphQL::Enum - # value :MUSHROOMS - # value :ONIONS - # value :PEPPERS - # end class Schema + # Extend this class to define GraphQL enums in your schema. + # + # By default, GraphQL enum values are translated into Ruby strings. + # You can provide a custom value with the `value:` keyword. + # + # @example + # # equivalent to + # # enum PizzaTopping { + # # MUSHROOMS + # # ONIONS + # # PEPPERS + # # } + # class PizzaTopping < GraphQL::Schema::Enum + # value :MUSHROOMS + # value :ONIONS + # value :PEPPERS + # end class Enum < GraphQL::Schema::Member - extend GraphQL::Schema::Member::AcceptsDefinition extend GraphQL::Schema::Member::ValidatesInput - class UnresolvedValueError < GraphQL::EnumType::UnresolvedValueError - def initialize(value:, enum:, context:) - fix_message = ", but this isn't a valid value for `#{enum.graphql_name}`. Update the field or resolver to return one of `#{enum.graphql_name}`'s values instead." + # This is raised when either: + # + # - A resolver returns a value which doesn't match any of the enum's configured values; + # - Or, the resolver returns a value which matches a value, but that value's `authorized?` check returns false. + # + # In either case, the field should be modified so that the invalid value isn't returned. + # + # {GraphQL::Schema::Enum} subclasses get their own subclass of this error, so that bug trackers can better show where they came from. + class UnresolvedValueError < GraphQL::Error + def initialize(value:, enum:, context:, authorized:) + fix_message = if authorized == false + ", but this value was unauthorized. Update the field or resolver to return a different value in this case (or return `nil`)." + else + ", but this isn't a valid value for `#{enum.graphql_name}`. Update the field or resolver to return one of `#{enum.graphql_name}`'s values instead." + end message = if (cp = context[:current_path]) && (cf = context[:current_field]) "`#{cf.path}` returned `#{value.inspect}` at `#{cp.join(".")}`#{fix_message}" else @@ -35,43 +46,108 @@ def initialize(value:, enum:, context:) end end + # Raised when a {GraphQL::Schema::Enum} is defined to have no values. + # This can also happen when all values return false for `.visible?`. + class MissingValuesError < GraphQL::Error + def initialize(enum_type) + @enum_type = enum_type + super("Enum types require at least one value, but #{enum_type.graphql_name} didn't provide any for this query. Make sure at least one value is defined and visible for this query.") + end + end + class << self # Define a value for this enum - # @param graphql_name [String, Symbol] the GraphQL value for this, usually `SCREAMING_CASE` - # @param description [String], the GraphQL description for this value, present in documentation - # @param value [Object], the translated Ruby value for this object (defaults to `graphql_name`) - # @param deprecation_reason [String] if this object is deprecated, include a message here + # @option kwargs [String, Symbol] :graphql_name the GraphQL value for this, usually `SCREAMING_CASE` + # @option kwargs [String] :description, the GraphQL description for this value, present in documentation + # @option kwargs [String] :comment, the GraphQL comment for this value, present in documentation + # @option kwargs [::Object] :value the translated Ruby value for this object (defaults to `graphql_name`) + # @option kwargs [::Object] :value_method, the method name to fetch `graphql_name` (defaults to `graphql_name.downcase`) + # @option kwargs [String] :deprecation_reason if this object is deprecated, include a message here + # @param value_method [Symbol, false] A method to generate for this value, or `false` to skip generation # @return [void] # @see {Schema::EnumValue} which handles these inputs by default - def value(*args, **kwargs, &block) + def value(*args, value_method: nil, **kwargs, &block) kwargs[:owner] = self value = enum_value_class.new(*args, **kwargs, &block) - if own_values.key?(value.graphql_name) - raise ArgumentError, "#{value.graphql_name} is already defined for #{self.graphql_name}, please remove one of the definitions." + + if value_method || (value_methods && value_method != false) + generate_value_method(value, value_method) + end + + key = value.graphql_name + prev_value = own_values[key] + case prev_value + when nil + own_values[key] = value + when GraphQL::Schema::EnumValue + own_values[key] = [prev_value, value] + when Array + prev_value << value + else + raise "Invariant: Unexpected enum value for #{key.inspect}: #{prev_value.inspect}" end - own_values[value.graphql_name] = value - nil + value end - # @return [Hash GraphQL::Schema::Enum::Value>] Possible values of this enum, keyed by name - def values - inherited_values = superclass <= GraphQL::Schema::Enum ? superclass.values : {} - # Local values take precedence over inherited ones - inherited_values.merge(own_values) + # @return [Array] Possible values of this enum + def enum_values(context = GraphQL::Query::NullContext.instance) + inherited_values = superclass.respond_to?(:enum_values) ? superclass.enum_values(context) : nil + visible_values = [] + types = Warden.types_from_context(context) + own_values.each do |key, values_entry| + visible_value = nil + if values_entry.is_a?(Array) + values_entry.each do |v| + if types.visible_enum_value?(v, context) + if visible_value.nil? + visible_value = v + visible_values << v + else + raise DuplicateNamesError.new( + duplicated_name: v.path, duplicated_definition_1: visible_value.inspect, duplicated_definition_2: v.inspect + ) + end + end + end + elsif types.visible_enum_value?(values_entry, context) + visible_values << values_entry + end + end + + if inherited_values + # Local values take precedence over inherited ones + inherited_values.each do |i_val| + if !visible_values.any? { |v| v.graphql_name == i_val.graphql_name } + visible_values << i_val + end + end + end + + visible_values end - # @return [GraphQL::EnumType] - def to_graphql - enum_type = GraphQL::EnumType.new - enum_type.name = graphql_name - enum_type.description = description - enum_type.introspection = introspection - enum_type.ast_node = ast_node - values.each do |name, val| - enum_type.add_value(val.to_graphql) + # @return [Array] An unfiltered list of all definitions + def all_enum_value_definitions + all_defns = if superclass.respond_to?(:all_enum_value_definitions) + superclass.all_enum_value_definitions + else + [] end - enum_type.metadata[:type_class] = self - enum_type + + @own_values && @own_values.each do |_key, value| + if value.is_a?(Array) + all_defns.concat(value) + else + all_defns << value + end + end + + all_defns + end + + # @return [Hash GraphQL::Schema::EnumValue>] Possible values of this enum, keyed by name. + def values(context = GraphQL::Query::NullContext.instance) + enum_values(context).each_with_object({}) { |val, obj| obj[val.graphql_name] = val } end # @return [Class] for handling `value(...)` inputs and building `GraphQL::Enum::EnumValue`s out of them @@ -85,50 +161,80 @@ def enum_value_class(new_enum_value_class = nil) end end + def value_methods(new_value = NOT_CONFIGURED) + if NOT_CONFIGURED.equal?(new_value) + if @value_methods != nil + @value_methods + else + find_inherited_value(:value_methods, false) + end + else + @value_methods = new_value + end + end + def kind GraphQL::TypeKinds::ENUM end - def validate_non_null_input(value_name, ctx) - result = GraphQL::Query::InputValidationResult.new - - allowed_values = ctx.warden.enum_values(self) + def validate_non_null_input(value_name, ctx, max_errors: nil) + allowed_values = ctx.types.enum_values(self) matching_value = allowed_values.find { |v| v.graphql_name == value_name } if matching_value.nil? - result.add_problem("Expected #{GraphQL::Language.serialize(value_name)} to be one of: #{allowed_values.map(&:graphql_name).join(', ')}") + GraphQL::Query::InputValidationResult.from_problem("Expected #{GraphQL::Language.serialize(value_name)} to be one of: #{allowed_values.map(&:graphql_name).join(', ')}") + else + nil end - - result + # rescue MissingValuesError + # nil end + # Called by the runtime when a field returns a value to give back to the client. + # This method checks that the incoming {value} matches one of the enum's defined values. + # @param value [Object] Any value matching the values for this enum. + # @param ctx [GraphQL::Query::Context] + # @raise [GraphQL::Schema::Enum::UnresolvedValueError] if {value} doesn't match a configured value or if the matching value isn't authorized. + # @return [String] The GraphQL-ready string for {value} def coerce_result(value, ctx) - warden = ctx.warden - all_values = warden ? warden.enum_values(self) : values.each_value + types = ctx.types + all_values = types ? types.enum_values(self) : values.each_value enum_value = all_values.find { |val| val.value == value } - if enum_value + if enum_value && (was_authed = enum_value.authorized?(ctx)) enum_value.graphql_name else - raise self::UnresolvedValueError.new(enum: self, value: value, context: ctx) + raise self::UnresolvedValueError.new(enum: self, value: value, context: ctx, authorized: was_authed) end end + # Called by the runtime with incoming string representations from a query. + # It will match the string to a configured by name or by Ruby value. + # @param value_name [String, Object] A string from a GraphQL query, or a Ruby value matching a `value(..., value: ...)` configuration + # @param ctx [GraphQL::Query::Context] + # @raise [GraphQL::UnauthorizedEnumValueError] if an {EnumValue} matches but returns false for `.authorized?`. Goes to {Schema.unauthorized_object}. + # @return [Object] The Ruby value for the matched {GraphQL::Schema::EnumValue} def coerce_input(value_name, ctx) - all_values = ctx.warden ? ctx.warden.enum_values(self) : values.each_value - - if v = all_values.find { |val| val.graphql_name == value_name } - v.value - elsif v = all_values.find { |val| val.value == value_name } - # this is for matching default values, which are "inputs", but they're - # the Ruby value, not the GraphQL string. - v.value + all_values = ctx.types ? ctx.types.enum_values(self) : values.each_value + + # This tries matching by incoming GraphQL string, then checks Ruby-defined values + if v = (all_values.find { |val| val.graphql_name == value_name } || all_values.find { |val| val.value == value_name }) + if v.authorized?(ctx) + v.value + else + raise GraphQL::UnauthorizedEnumValueError.new(type: self, enum_value: v, context: ctx) + end else nil end end def inherited(child_class) - child_class.const_set(:UnresolvedValueError, Class.new(Schema::Enum::UnresolvedValueError)) + if child_class.name + # Don't assign a custom error class to anonymous classes + # because they would end up with names like `#::UnresolvedValueError` which messes up bug trackers + child_class.const_set(:UnresolvedValueError, Class.new(Schema::Enum::UnresolvedValueError)) + end + child_class.class_exec { @value_methods = nil } super end @@ -137,6 +243,21 @@ def inherited(child_class) def own_values @own_values ||= {} end + + def generate_value_method(value, configured_value_method) + return if configured_value_method == false + + value_method_name = configured_value_method || value.graphql_name.downcase + + if respond_to?(value_method_name.to_sym) + warn "Failed to define value method for :#{value_method_name}, because " \ + "#{value.owner.name || value.owner.graphql_name} already responds to that method. Use `value_method:` to override the method name " \ + "or `value_method: false` to disable Enum value method generation." + return + end + + define_singleton_method(value_method_name) { value.graphql_name } + end end enum_value_class(GraphQL::Schema::EnumValue) diff --git a/lib/graphql/schema/enum_value.rb b/lib/graphql/schema/enum_value.rb index 170bfb24470..74484f60cb9 100644 --- a/lib/graphql/schema/enum_value.rb +++ b/lib/graphql/schema/enum_value.rb @@ -13,12 +13,6 @@ class Schema # # arguments to `value(...)` in Enum classes are passed here # super # end - # - # def to_graphql - # enum_value = super - # # customize the derived GraphQL::EnumValue here - # enum_value - # end # end # # class BaseEnum < GraphQL::Schema::Enum @@ -26,8 +20,6 @@ class Schema # enum_value_class CustomEnumValue # end class EnumValue < GraphQL::Schema::Member - include GraphQL::Schema::Member::CachedGraphQLDefinition - include GraphQL::Schema::Member::AcceptsDefinition include GraphQL::Schema::Member::HasPath include GraphQL::Schema::Member::HasAstNode include GraphQL::Schema::Member::HasDirectives @@ -38,11 +30,12 @@ class EnumValue < GraphQL::Schema::Member # @return [Class] The enum type that owns this value attr_reader :owner - def initialize(graphql_name, desc = nil, owner:, ast_node: nil, directives: nil, description: nil, value: nil, deprecation_reason: nil, &block) + def initialize(graphql_name, desc = nil, owner:, ast_node: nil, directives: nil, description: nil, comment: nil, value: NOT_CONFIGURED, deprecation_reason: nil, &block) @graphql_name = graphql_name.to_s GraphQL::NameValidator.validate!(@graphql_name) @description = desc || description - @value = value.nil? ? @graphql_name : value + @comment = comment + @value = value == NOT_CONFIGURED ? @graphql_name : value if deprecation_reason self.deprecation_reason = deprecation_reason end @@ -55,7 +48,7 @@ def initialize(graphql_name, desc = nil, owner:, ast_node: nil, directives: nil, end if block_given? - instance_eval(&block) + instance_exec(self, &block) end end @@ -66,6 +59,13 @@ def description(new_desc = nil) @description end + def comment(new_comment = nil) + if new_comment + @comment = new_comment + end + @comment + end + def value(new_val = nil) unless new_val.nil? @value = new_val @@ -73,20 +73,11 @@ def value(new_val = nil) @value end - # @return [GraphQL::EnumType::EnumValue] A runtime-ready object derived from this object - def to_graphql - enum_value = GraphQL::EnumType::EnumValue.new - enum_value.name = @graphql_name - enum_value.description = @description - enum_value.value = @value - enum_value.deprecation_reason = self.deprecation_reason - enum_value.metadata[:type_class] = self - enum_value.ast_node = ast_node - enum_value + def inspect + "#<#{self.class} #{path} @value=#{@value.inspect}#{description ? " @description=#{description.inspect}" : ""}#{deprecation_reason ? " @deprecation_reason=#{deprecation_reason.inspect}" : ""}>" end def visible?(_ctx); true; end - def accessible?(_ctx); true; end def authorized?(_ctx); true; end end end diff --git a/lib/graphql/schema/field.rb b/lib/graphql/schema/field.rb index cf6219f3ab7..9d284e02c69 100644 --- a/lib/graphql/schema/field.rb +++ b/lib/graphql/schema/field.rb @@ -5,21 +5,19 @@ module GraphQL class Schema class Field - if !String.method_defined?(:-@) - using GraphQL::StringDedupBackport - end - - include GraphQL::Schema::Member::CachedGraphQLDefinition - include GraphQL::Schema::Member::AcceptsDefinition include GraphQL::Schema::Member::HasArguments + include GraphQL::Schema::Member::HasArguments::FieldConfigured include GraphQL::Schema::Member::HasAstNode + include GraphQL::Schema::Member::HasAuthorization include GraphQL::Schema::Member::HasPath include GraphQL::Schema::Member::HasValidators extend GraphQL::Schema::FindInheritedValue - include GraphQL::Schema::FindInheritedValue::EmptyObjects + include GraphQL::EmptyObjects include GraphQL::Schema::Member::HasDirectives include GraphQL::Schema::Member::HasDeprecationReason + class FieldImplementationFailed < GraphQL::Error; end + # @return [String] the GraphQL name for this field, camelized unless `camelize: false` is provided attr_reader :name alias :graphql_name :name @@ -32,15 +30,52 @@ class Field # @return [String] Method or hash key on the underlying object to look up attr_reader :method_str + attr_reader :hash_key + attr_reader :dig_keys + # @return [Symbol] The method on the type to look up - attr_reader :resolver_method + def resolver_method + if @resolver_class + @resolver_class.resolver_method + else + @resolver_method + end + end + + # @return [String, nil] + def deprecation_reason + super || @resolver_class&.deprecation_reason + end + + def directives + if @resolver_class && !(r_dirs = @resolver_class.directives).empty? + if !(own_dirs = super).empty? + new_dirs = own_dirs.dup + r_dirs.each do |r_dir| + if r_dir.class.repeatable? || + ( (r_dir_name = r_dir.graphql_name) && + (!new_dirs.any? { |d| d.graphql_name == r_dir_name }) + ) + new_dirs << r_dir + end + end + new_dirs + else + r_dirs + end + else + super + end + end # @return [Class] The thing this field was defined on (type, mutation, resolver) attr_accessor :owner # @return [Class] The GraphQL type this field belongs to. (For fields defined on mutations, it's the payload type) def owner_type - @owner_type ||= if owner < GraphQL::Schema::Mutation + @owner_type ||= if owner.nil? + raise GraphQL::InvariantError, "Field #{original_name.inspect} (graphql name: #{graphql_name.inspect}) has no owner, but all fields should have an owner. How did this happen?!" + elsif owner < GraphQL::Schema::Mutation owner.payload_type else owner @@ -61,7 +96,7 @@ def introspection? end def inspect - "#<#{self.class} #{path}#{arguments.any? ? "(...)" : ""}: #{type.to_type_signature}>" + "#<#{self.class} #{path}#{!all_argument_definitions.empty? ? "(...)" : ""}: #{type.to_type_signature}>" end alias :mutation :resolver @@ -70,76 +105,30 @@ def inspect attr_reader :trace # @return [String, nil] - attr_accessor :subscription_scope - - # Create a field instance from a list of arguments, keyword arguments, and a block. - # - # This method implements prioritization between the `resolver` or `mutation` defaults - # and the local overrides via other keywords. - # - # It also normalizes positional arguments into keywords for {Schema::Field#initialize}. - # @param resolver [Class] A {GraphQL::Schema::Resolver} class to use for field configuration - # @param mutation [Class] A {GraphQL::Schema::Mutation} class to use for field configuration - # @param subscription [Class] A {GraphQL::Schema::Subscription} class to use for field configuration - # @return [GraphQL::Schema:Field] an instance of `self - # @see {.initialize} for other options - def self.from_options(name = nil, type = nil, desc = nil, resolver: nil, mutation: nil, subscription: nil,**kwargs, &block) - if kwargs[:field] - if kwargs[:field].is_a?(GraphQL::Field) && kwargs[:field] == GraphQL::Types::Relay::NodeField.graphql_definition - GraphQL::Deprecation.warn("Legacy-style `GraphQL::Relay::Node.field` is being added to a class-based type. See `GraphQL::Types::Relay::NodeField` for a replacement.") - return GraphQL::Types::Relay::NodeField - elsif kwargs[:field].is_a?(GraphQL::Field) && kwargs[:field] == GraphQL::Types::Relay::NodesField.graphql_definition - GraphQL::Deprecation.warn("Legacy-style `GraphQL::Relay::Node.plural_field` is being added to a class-based type. See `GraphQL::Types::Relay::NodesField` for a replacement.") - return GraphQL::Types::Relay::NodesField - end - end - - if (parent_config = resolver || mutation || subscription) - # Get the parent config, merge in local overrides - kwargs = parent_config.field_options.merge(kwargs) - # Add a reference to that parent class - kwargs[:resolver_class] = parent_config - end - - if name - kwargs[:name] = name - end - - if !type.nil? - if type.is_a?(GraphQL::Field) - raise ArgumentError, "A GraphQL::Field was passed as the second argument, use the `field:` keyword for this instead." - end - if desc - if kwargs[:description] - raise ArgumentError, "Provide description as a positional argument or `description:` keyword, but not both (#{desc.inspect}, #{kwargs[:description].inspect})" - end - - kwargs[:description] = desc - kwargs[:type] = type - elsif (kwargs[:field] || kwargs[:function] || resolver || mutation) && type.is_a?(String) - # The return type should be copied from `field` or `function`, and the second positional argument is the description - kwargs[:description] = type - else - kwargs[:type] = type - end - end - new(**kwargs, &block) + def subscription_scope + @subscription_scope || (@resolver_class.respond_to?(:subscription_scope) ? @resolver_class.subscription_scope : nil) end + attr_writer :subscription_scope # Can be set with `connection: true|false` or inferred from a type name ending in `*Connection` # @return [Boolean] if true, this field will be wrapped with Relay connection behavior def connection? if @connection.nil? # Provide default based on type name - return_type_name = if (contains_type = @field || @function) - Member::BuildType.to_type_name(contains_type.type) - elsif @return_type_expr + return_type_name = if @return_type_expr Member::BuildType.to_type_name(@return_type_expr) - else + elsif @resolver_class && @resolver_class.type + Member::BuildType.to_type_name(@resolver_class.type) + elsif type # As a last ditch, try to force loading the return type: type.unwrap.name end - @connection = return_type_name.end_with?("Connection") + if return_type_name + @connection = return_type_name.end_with?("Connection") && return_type_name != "Connection" + else + # TODO set this when type is set by method + false # not loaded yet? + end else @connection end @@ -150,8 +139,18 @@ def scoped? if !@scope.nil? # The default was overridden @scope + elsif @return_type_expr + # Detect a list return type, but don't call `type` since that may eager-load an otherwise lazy-loaded type + @return_type_expr.is_a?(Array) || + (@return_type_expr.is_a?(String) && @return_type_expr.include?("[")) || + connection? + elsif @resolver_class + resolver_type = @resolver_class.type_expr + resolver_type.is_a?(Array) || + (resolver_type.is_a?(String) && resolver_type.include?("[")) || + connection? else - @return_type_expr && (@return_type_expr.is_a?(Array) || (@return_type_expr.is_a?(String) && @return_type_expr.include?("[")) || connection?) + false end end @@ -173,6 +172,8 @@ def self.connection_extension(new_extension_class = nil) # @return Boolean attr_reader :relay_node_field + # @return Boolean + attr_reader :relay_nodes_field # @return [Boolean] Should we warn if this field's name conflicts with a built-in method? def method_conflict_warning? @@ -182,19 +183,24 @@ def method_conflict_warning? # @param name [Symbol] The underscore-cased version of this field name (will be camelized for the GraphQL API) # @param type [Class, GraphQL::BaseType, Array] The return type of this field # @param owner [Class] The type that this field belongs to - # @param null [Boolean] `true` if this field may return `null`, `false` if it is never `null` + # @param null [Boolean] (defaults to `true`) `true` if this field may return `null`, `false` if it is never `null` # @param description [String] Field description + # @param comment [String] Field comment # @param deprecation_reason [String] If present, the field is marked "deprecated" with this message # @param method [Symbol] The method to call on the underlying object to resolve this field (defaults to `name`) # @param hash_key [String, Symbol] The hash key to lookup on the underlying object (if its a Hash) to resolve this field (defaults to `name` or `name.to_s`) + # @param dig [Array] The nested hash keys to lookup on the underlying hash to resolve this field using dig # @param resolver_method [Symbol] The method on the type to call to resolve this field (defaults to `name`) # @param connection [Boolean] `true` if this field should get automagic connection behavior; default is to infer by `*Connection` in the return type name # @param connection_extension [Class] The extension to add, to implement connections. If `nil`, no extension is added. + # @param resolve_static [Symbol, true, nil] Used by {Schema.execute_next} to produce a single value, shared by all objects which resolve this field. Called on the owner type class with `context, **arguments` + # @param resolve_batch [Symbol, true, nil] Used by {Schema.execute_next} map `objects` to a same-sized Array of results. Called on the owner type class with `objects, context, **arguments`. + # @param resolve_each [Symbol, true, nil] Used by {Schema.execute_next} to get a value value for each item. Called on the owner type class with `object, context, **arguments`. + # @param resolve_legacy_instance_method [Symbol, true, nil] Used by {Schema.execute_next} to get a value value for each item. Calls an instance method on the object type class. + # @param dataload [Class, Hash] Shorthand for making dataloader calls # @param max_page_size [Integer, nil] For connections, the maximum number of items to return from this field, or `nil` to allow unlimited results. + # @param default_page_size [Integer, nil] For connections, the default number of items to return from this field, or `nil` to return unlimited results. # @param introspection [Boolean] If true, this field will be marked as `#introspection?` and the name may begin with `__` - # @param resolve [<#call(obj, args, ctx)>] **deprecated** for compatibility with <1.8.0 - # @param field [GraphQL::Field, GraphQL::Schema::Field] **deprecated** for compatibility with <1.8.0 - # @param function [GraphQL::Function] **deprecated** for compatibility with <1.8.0 # @param resolver_class [Class] (Private) A {Schema::Resolver} which this field was derived from. Use `resolver:` to create a field with a resolver. # @param arguments [{String=>GraphQL::Schema::Argument, Hash}] Arguments for this field (may be added in the block, also) # @param camelize [Boolean] If true, the field name will be camelized when building the schema @@ -208,38 +214,35 @@ def method_conflict_warning? # @param ast_node [Language::Nodes::FieldDefinition, nil] If this schema was parsed from definition, this AST node defined the field # @param method_conflict_warning [Boolean] If false, skip the warning if this field's method conflicts with a built-in method # @param validates [Array] Configurations for validating this field - # @param legacy_edge_class [Class, nil] (DEPRECATED) If present, pass this along to the legacy field definition - def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function: nil, description: nil, deprecation_reason: nil, method: nil, hash_key: nil, resolver_method: nil, resolve: nil, connection: nil, max_page_size: :not_given, scope: nil, introspection: false, camelize: true, trace: nil, complexity: 1, ast_node: nil, extras: EMPTY_ARRAY, extensions: EMPTY_ARRAY, connection_extension: self.class.connection_extension, resolver_class: nil, subscription_scope: nil, relay_node_field: false, relay_nodes_field: false, method_conflict_warning: true, broadcastable: nil, arguments: EMPTY_HASH, directives: EMPTY_HASH, validates: EMPTY_ARRAY, legacy_edge_class: nil, &definition_block) + # @param fallback_value [Object] A fallback value if the method is not defined + # @param dynamic_introspection [Boolean] (Private, used by GraphQL-Ruby) + # @param relay_node_field [Boolean] (Private, used by GraphQL-Ruby) + # @param relay_nodes_field [Boolean] (Private, used by GraphQL-Ruby) + # @param extras [Array<:ast_node, :parent, :lookahead, :owner, :execution_errors, :graphql_name, :argument_details, Symbol>] Extra arguments to be injected into the resolver for this field + # @param definition_block [Proc] an additional block for configuring the field. Receive the field as a block param, or, if no block params are defined, then the block is `instance_eval`'d on the new {Field}. + def initialize(type: nil, name: nil, owner: nil, null: nil, description: NOT_CONFIGURED, comment: NOT_CONFIGURED, deprecation_reason: nil, method: nil, resolve_legacy_instance_method: nil, resolve_static: nil, resolve_each: nil, resolve_batch: nil, hash_key: nil, dig: nil, resolver_method: nil, connection: nil, max_page_size: NOT_CONFIGURED, default_page_size: NOT_CONFIGURED, scope: nil, introspection: false, camelize: true, trace: nil, complexity: nil, dataload: nil, ast_node: nil, extras: EMPTY_ARRAY, extensions: EMPTY_ARRAY, connection_extension: self.class.connection_extension, resolver_class: nil, subscription_scope: nil, relay_node_field: false, relay_nodes_field: false, method_conflict_warning: true, broadcastable: NOT_CONFIGURED, arguments: EMPTY_HASH, directives: EMPTY_HASH, validates: EMPTY_ARRAY, fallback_value: NOT_CONFIGURED, dynamic_introspection: false, &definition_block) if name.nil? raise ArgumentError, "missing first `name` argument or keyword `name:`" end - if !(field || function || resolver_class) - if type.nil? - raise ArgumentError, "missing second `type` argument or keyword `type:`" - end - if null.nil? - raise ArgumentError, "missing keyword argument null:" + if !(resolver_class) + if type.nil? && !block_given? + raise ArgumentError, "missing second `type` argument, keyword `type:`, or a block containing `type(...)`" end end - if (field || function || resolve) && extras.any? - raise ArgumentError, "keyword `extras:` may only be used with method-based resolve and class-based field such as mutation class, please remove `field:`, `function:` or `resolve:`" - end @original_name = name name_s = -name.to_s + @underscored_name = -Member::BuildType.underscore(name_s) @name = -(camelize ? Member::BuildType.camelize(name_s) : name_s) + NameValidator.validate!(@name) @description = description - if field.is_a?(GraphQL::Schema::Field) - raise ArgumentError, "Instead of passing a field as `field:`, use `add_field(field)` to add an already-defined field." - else - @field = field - end - @function = function - @resolve = resolve + @comment = comment + @type = @owner_type = @own_validators = @own_directives = @own_arguments = @arguments_statically_coercible = nil # these will be prepared later if necessary + self.deprecation_reason = deprecation_reason - if method && hash_key - raise ArgumentError, "Provide `method:` _or_ `hash_key:`, not both. (called with: `method: #{method.inspect}, hash_key: #{hash_key.inspect}`)" + if method && hash_key && dig + raise ArgumentError, "Provide `method:`, `hash_key:` _or_ `dig:`, not multiple. (called with: `method: #{method.inspect}, hash_key: #{hash_key.inspect}, dig: #{dig.inspect}`)" end if resolver_method @@ -247,24 +250,67 @@ def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function raise ArgumentError, "Provide `method:` _or_ `resolver_method:`, not both. (called with: `method: #{method.inspect}, resolver_method: #{resolver_method.inspect}`)" end - if hash_key - raise ArgumentError, "Provide `hash_key:` _or_ `resolver_method:`, not both. (called with: `hash_key: #{hash_key.inspect}, resolver_method: #{resolver_method.inspect}`)" + if hash_key || dig + raise ArgumentError, "Provide `hash_key:`, `dig:`, _or_ `resolver_method:`, not multiple. (called with: `hash_key: #{hash_key.inspect}, dig: #{dig.inspect}, resolver_method: #{resolver_method.inspect}`)" end end - # TODO: I think non-string/symbol hash keys are wrongly normalized (eg `1` will not work) method_name = method || hash_key || name_s - resolver_method ||= name_s.to_sym + @dig_keys = dig + if hash_key + @hash_key = hash_key + @hash_key_str = hash_key.to_s + else + @hash_key = NOT_CONFIGURED + @hash_key_str = NOT_CONFIGURED + end @method_str = -method_name.to_s @method_sym = method_name.to_sym - @resolver_method = resolver_method + @resolver_method = (resolver_method || name_s).to_sym + + if resolve_static + @execution_mode = :resolve_static + @execution_mode_key = resolve_static == true ? @method_sym : resolve_static + elsif resolve_batch + @execution_mode = :resolve_batch + @execution_mode_key = resolve_batch == true ? @method_sym : resolve_batch + elsif resolve_each + @execution_mode = :resolve_each + @execution_mode_key = resolve_each == true ? @method_sym : resolve_each + elsif hash_key + @execution_mode = :hash_key + @execution_mode_key = hash_key + elsif dig + @execution_mode = :dig + @execution_mode_key = dig + elsif resolver_class + @execution_mode = :resolver_class + @execution_mode_key = resolver_class + elsif resolve_legacy_instance_method + @execution_mode = :resolve_legacy_instance_method + @execution_mode_key = resolve_legacy_instance_method == true ? @method_sym : resolve_legacy_instance_method + elsif dataload + @execution_mode = :dataload + @execution_mode_key = dataload + else + @execution_mode = :direct_send + @execution_mode_key = @method_sym + end + @complexity = complexity + @dynamic_introspection = dynamic_introspection @return_type_expr = type - @return_type_null = null + @return_type_null = if !null.nil? + null + elsif resolver_class + nil + else + true + end @connection = connection - @has_max_page_size = max_page_size != :not_given - @max_page_size = max_page_size == :not_given ? nil : max_page_size + @max_page_size = max_page_size + @default_page_size = default_page_size @introspection = introspection @extras = extras @broadcastable = broadcastable @@ -275,13 +321,19 @@ def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function @relay_nodes_field = relay_nodes_field @ast_node = ast_node @method_conflict_warning = method_conflict_warning - @legacy_edge_class = legacy_edge_class + @fallback_value = fallback_value + @definition_block = definition_block arguments.each do |name, arg| - if arg.is_a?(Hash) + case arg + when Hash argument(name: name, **arg) - else + when GraphQL::Schema::Argument add_argument(arg) + when Array + arg.each { |a| add_argument(a) } + else + raise ArgumentError, "Unexpected argument config (#{arg.class}): #{arg.inspect}" end end @@ -289,45 +341,68 @@ def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function @subscription_scope = subscription_scope @extensions = EMPTY_ARRAY - # This should run before connection extension, - # but should it run after the definition block? - if scoped? - self.extension(ScopeExtension) - end - - # The problem with putting this after the definition_block - # is that it would override arguments - if connection? && connection_extension - self.extension(connection_extension) - end - + @call_after_define = false + set_pagination_extensions(connection_extension: NOT_CONFIGURED.equal?(connection_extension) ? self.class.connection_extension : connection_extension) # Do this last so we have as much context as possible when initializing them: - if extensions.any? + if !extensions.empty? self.extensions(extensions) end - if directives.any? + if resolver_class && !resolver_class.extensions.empty? + self.extensions(resolver_class.extensions) + end + + if !directives.empty? directives.each do |(dir_class, options)| self.directive(dir_class, **options) end end - self.validates(validates) + if !validates.empty? + self.validates(validates) + end + + if @definition_block.nil? + self.extensions.each(&:after_define_apply) + @call_after_define = true + end + end - if definition_block - if definition_block.arity == 1 - yield self + # @api private + attr_reader :execution_mode_key, :execution_mode + + # Calls the definition block, if one was given. + # This is deferred so that references to the return type + # can be lazily evaluated, reducing Rails boot time. + # @return [self] + # @api private + def ensure_loaded + if @definition_block + if @definition_block.arity == 1 + @definition_block.call(self) else - instance_eval(&definition_block) + instance_exec(self, &@definition_block) end + self.extensions.each(&:after_define_apply) + @call_after_define = true + @definition_block = nil end + self end + attr_accessor :dynamic_introspection + # If true, subscription updates with this field can be shared between viewers # @return [Boolean, nil] # @see GraphQL::Subscriptions::BroadcastAnalyzer def broadcastable? - @broadcastable + if !NOT_CONFIGURED.equal?(@broadcastable) + @broadcastable + elsif @resolver_class + @resolver_class.broadcastable? + else + nil + end end # @param text [String] @@ -335,8 +410,26 @@ def broadcastable? def description(text = nil) if text @description = text - else + elsif !NOT_CONFIGURED.equal?(@description) @description + elsif @resolver_class + @resolver_class.description + else + nil + end + end + + # @param text [String] + # @return [String, nil] + def comment(text = nil) + if text + @comment = text + elsif !NOT_CONFIGURED.equal?(@comment) + @comment + elsif @resolver_class + @resolver_class.comment + else + nil end end @@ -353,27 +446,20 @@ def description(text = nil) # @example adding an extension with options # extensions([MyExtensionClass, { AnotherExtensionClass => { filter: true } }]) # - # @param extensions [Array Object>>] Add extensions to this field. For hash elements, only the first key/value is used. + # @param extensions [Array Hash>>] Add extensions to this field. For hash elements, only the first key/value is used. # @return [Array] extensions to apply to this field def extensions(new_extensions = nil) - if new_extensions.nil? - # Read the value - @extensions - else - if @extensions.frozen? - @extensions = @extensions.dup - end - new_extensions.each do |extension| - if extension.is_a?(Hash) - extension = extension.to_a[0] - extension_class, options = *extension - @extensions << extension_class.new(field: self, options: options) + if new_extensions + new_extensions.each do |extension_config| + if extension_config.is_a?(Hash) + extension_class, options = *extension_config.to_a[0] + self.extension(extension_class, **options) else - extension_class = extension - @extensions << extension_class.new(field: self, options: nil) + self.extension(extension_config) end end end + @extensions end # Add `extension` to this field, initialized with `options` if provided. @@ -384,10 +470,19 @@ def extensions(new_extensions = nil) # @example adding an extension with options # extension(MyExtensionClass, filter: true) # - # @param extension [Class] subclass of {Schema::Fieldextension} - # @param options [Object] if provided, given as `options:` when initializing `extension`. - def extension(extension, options = nil) - extensions([{extension => options}]) + # @param extension_class [Class] subclass of {Schema::FieldExtension} + # @param options [Hash] if provided, given as `options:` when initializing `extension`. + # @return [void] + def extension(extension_class, **options) + extension_inst = extension_class.new(field: self, options: options) + if @extensions.frozen? + @extensions = @extensions.dup + end + if @call_after_define + extension_inst.after_define_apply + end + @extensions << extension_inst + nil end # Read extras (as symbols) from this field, @@ -398,7 +493,12 @@ def extension(extension, options = nil) def extras(new_extras = nil) if new_extras.nil? # Read the value - @extras + field_extras = @extras + if @resolver_class && !@resolver_class.extras.empty? + field_extras + @resolver_class.extras + else + field_extras + end else if @extras.frozen? @extras = @extras.dup @@ -408,6 +508,56 @@ def extras(new_extras = nil) end end + def calculate_complexity(query:, nodes:, child_complexity:) + if respond_to?(:complexity_for) + lookahead = GraphQL::Execution::Lookahead.new(query: query, field: self, ast_nodes: nodes, owner_type: owner) + complexity_for(child_complexity: child_complexity, query: query, lookahead: lookahead) + elsif connection? + arguments = query.arguments_for(nodes.first, self) + max_possible_page_size = nil + if arguments.respond_to?(:[]) # It might have been an error + if arguments[:first] + max_possible_page_size = arguments[:first] + end + + if arguments[:last] && (max_possible_page_size.nil? || arguments[:last] > max_possible_page_size) + max_possible_page_size = arguments[:last] + end + elsif arguments.is_a?(GraphQL::ExecutionError) || arguments.is_a?(GraphQL::UnauthorizedError) + raise arguments + end + + if max_possible_page_size.nil? + max_possible_page_size = default_page_size || query.schema.default_page_size || max_page_size || query.schema.default_max_page_size + end + + if max_possible_page_size.nil? + raise GraphQL::Error, "Can't calculate complexity for #{path}, no `first:`, `last:`, `default_page_size`, `max_page_size` or `default_max_page_size`" + else + metadata_complexity = 0 + lookahead = GraphQL::Execution::Lookahead.new(query: query, field: self, ast_nodes: nodes, owner_type: owner) + + lookahead.selections.each do |next_lookahead| + # this includes `pageInfo`, `nodes` and `edges` and any custom fields + # TODO this doesn't support procs yet -- unlikely to need it. + metadata_complexity += next_lookahead.field.complexity + if next_lookahead.name != :nodes && next_lookahead.name != :edges + # subfields, eg, for pageInfo -- assumes no subselections + metadata_complexity += next_lookahead.selections.size + end + end + + # Possible bug: selections on `edges` and `nodes` are _both_ multiplied here. Should they be? + items_complexity = child_complexity - metadata_complexity + subfields_complexity = (max_possible_page_size * items_complexity) + metadata_complexity + # Apply this field's own complexity + apply_own_complexity_to(subfields_complexity, query, nodes) + end + else + apply_own_complexity_to(child_complexity, query, nodes) + end + end + def complexity(new_complexity = nil) case new_complexity when Proc @@ -422,7 +572,11 @@ def complexity(new_complexity = nil) when Numeric @complexity = new_complexity when nil - @complexity + if @resolver_class + @complexity || @resolver_class.complexity || 1 + else + @complexity || 1 + end else raise("Invalid complexity: #{new_complexity.inspect} on #{@name}") end @@ -430,101 +584,76 @@ def complexity(new_complexity = nil) # @return [Boolean] True if this field's {#max_page_size} should override the schema default. def has_max_page_size? - @has_max_page_size + !NOT_CONFIGURED.equal?(@max_page_size) || (@resolver_class && @resolver_class.has_max_page_size?) end # @return [Integer, nil] Applied to connections if {#has_max_page_size?} - attr_reader :max_page_size - - # @return [GraphQL::Field] - def to_graphql - field_defn = if @field - @field.dup - elsif @function - GraphQL::Function.build_field(@function) + def max_page_size + if !NOT_CONFIGURED.equal?(@max_page_size) + @max_page_size + elsif @resolver_class && @resolver_class.has_max_page_size? + @resolver_class.max_page_size else - GraphQL::Field.new - end - - field_defn.name = @name - if @return_type_expr - field_defn.type = -> { type } - end - - if @description - field_defn.description = @description - end - - if self.deprecation_reason - field_defn.deprecation_reason = self.deprecation_reason - end - - if @resolver_class - if @resolver_class < GraphQL::Schema::Mutation - field_defn.mutation = @resolver_class - end - field_defn.metadata[:resolver] = @resolver_class - end - - if !@trace.nil? - field_defn.trace = @trace - end - - if @relay_node_field - field_defn.relay_node_field = @relay_node_field - end - - if @relay_nodes_field - field_defn.relay_nodes_field = @relay_nodes_field - end - - if @legacy_edge_class - field_defn.edge_class = @legacy_edge_class + nil end + end - field_defn.resolve = self.method(:resolve_field) - field_defn.connection = connection? - field_defn.connection_max_page_size = max_page_size - field_defn.introspection = @introspection - field_defn.complexity = @complexity - field_defn.subscription_scope = @subscription_scope - field_defn.ast_node = ast_node - - arguments.each do |name, defn| - arg_graphql = defn.to_graphql - field_defn.arguments[arg_graphql.name] = arg_graphql - end + # @return [Boolean] True if this field's {#default_page_size} should override the schema default. + def has_default_page_size? + !NOT_CONFIGURED.equal?(@default_page_size) || (@resolver_class && @resolver_class.has_default_page_size?) + end - # Support a passed-in proc, one way or another - @resolve_proc = if @resolve - @resolve - elsif @function - @function - elsif @field - @field.resolve_proc + # @return [Integer, nil] Applied to connections if {#has_default_page_size?} + def default_page_size + if !NOT_CONFIGURED.equal?(@default_page_size) + @default_page_size + elsif @resolver_class && @resolver_class.has_default_page_size? + @resolver_class.default_page_size + else + nil end + end - # Ok, `self` isn't a class, but this is for consistency with the classes - field_defn.metadata[:type_class] = self - field_defn.arguments_class = GraphQL::Query::Arguments.construct_arguments_class(field_defn) - field_defn + def freeze + type + owner_type + arguments_statically_coercible? + connection? + super end + class MissingReturnTypeError < GraphQL::Error; end attr_writer :type - def type - @type ||= if @function - Member::BuildType.parse_type(@function.type, null: false) - elsif @field - Member::BuildType.parse_type(@field.type, null: false) + # Get or set the return type of this field. + # + # It may return nil if no type was configured or if the given definition block wasn't called yet. + # @param new_type [Module, GraphQL::Schema::NonNull, GraphQL::Schema::List] A GraphQL return type + # @return [Module, GraphQL::Schema::NonNull, GraphQL::Schema::List, nil] the configured type for this field + def type(new_type = NOT_CONFIGURED) + if NOT_CONFIGURED.equal?(new_type) + if @resolver_class + return_type = @return_type_expr || @resolver_class.type_expr + if return_type.nil? + raise MissingReturnTypeError, "Can't determine the return type for #{self.path} (it has `resolver: #{@resolver_class}`, perhaps that class is missing a `type ...` declaration, or perhaps its type causes a cyclical loading issue)" + end + nullable = @return_type_null.nil? ? @resolver_class.null : @return_type_null + Member::BuildType.parse_type(return_type, null: nullable) + elsif !@return_type_expr.nil? + @type ||= Member::BuildType.parse_type(@return_type_expr, null: @return_type_null) + end else - Member::BuildType.parse_type(@return_type_expr, null: @return_type_null) + @return_type_expr = new_type + # If `type` is set in the definition block, then the `connection_extension: ...` given as a keyword won't be used, hmm... + # Also, arguments added by `connection_extension` will clobber anything previously defined, + # so `type(...)` should go first. + set_pagination_extensions(connection_extension: self.class.connection_extension) end - rescue GraphQL::Schema::InvalidDocumentError => err + rescue GraphQL::Schema::InvalidDocumentError, MissingReturnTypeError => err # Let this propagate up raise err rescue StandardError => err - raise ArgumentError, "Failed to build return type for #{@owner.graphql_name}.#{name} from #{@return_type_expr.inspect}: (#{err.class}) #{err.message}", err.backtrace + raise MissingReturnTypeError, "Failed to build return type for #{@owner.graphql_name}.#{name} from #{@return_type_expr.inspect}: (#{err.class}) #{err.message}", err.backtrace end def visible?(context) @@ -535,57 +664,53 @@ def visible?(context) end end - def accessible?(context) - if @resolver_class - @resolver_class.accessible?(context) - else - true - end + def authorizes?(context) + method(:authorized?).owner != GraphQL::Schema::Field || + ((args = context.types.arguments(self)) && (args.any? { |a| a.authorizes?(context) })) || + (@resolver_class&.authorizes?(context)) || false end def authorized?(object, args, context) if @resolver_class - # The resolver will check itself during `resolve()` + # The resolver _instance_ will check itself during `resolve()` @resolver_class.authorized?(object, context) else - # Faster than `.any?` - arguments.each_value do |arg| - if args.key?(arg.keyword) && !arg.authorized?(object, args[arg.keyword], context) - return false + if args.size > 0 + if (arg_values = context[:current_arguments]) + # ^^ that's provided by the interpreter at runtime, and includes info about whether the default value was used or not. + using_arg_values = true + arg_values = arg_values.argument_values + else + arg_values = args + using_arg_values = false end - end - true - end - end - # Implement {GraphQL::Field}'s resolve API. - # - # Eventually, we might hook up field instances to execution in another way. TBD. - # @see #resolve for how the interpreter hooks up to it - def resolve_field(obj, args, ctx) - ctx.schema.after_lazy(obj) do |after_obj| - # First, apply auth ... - query_ctx = ctx.query.context - # Some legacy fields can have `nil` here, not exactly sure why. - # @see https://github.com/rmosolgo/graphql-ruby/issues/1990 before removing - inner_obj = after_obj && after_obj.object - ctx.schema.after_lazy(to_ruby_args(after_obj, args, ctx)) do |ruby_args| - if authorized?(inner_obj, ruby_args, query_ctx) - # Then if it passed, resolve the field - if @resolve_proc - # Might be nil, still want to call the func in that case - with_extensions(inner_obj, ruby_args, query_ctx) do |extended_obj, extended_args| - # Pass the GraphQL args here for compatibility: - @resolve_proc.call(extended_obj, args, ctx) + args = context.types.arguments(self) + args.each do |arg| + arg_key = arg.keyword + if arg_values.key?(arg_key) + arg_value = arg_values[arg_key] + if using_arg_values + if arg_value.default_used? + # pass -- no auth required for default used + next + else + application_arg_value = arg_value.value + if application_arg_value.is_a?(GraphQL::Execution::Interpreter::Arguments) + application_arg_value.keyword_arguments + end + end + else + application_arg_value = arg_value + end + + if !arg.authorized?(object, application_arg_value, context) + return false end - else - public_send_field(after_obj, ruby_args, query_ctx) end - else - err = GraphQL::UnauthorizedFieldError.new(object: inner_obj, type: obj.class, context: ctx, field: self) - query_ctx.schema.unauthorized_field(err) end end + true end end @@ -594,35 +719,112 @@ def resolve_field(obj, args, ctx) # @param object [GraphQL::Schema::Object] An instance of some type class, wrapping an application object # @param args [Hash] A symbol-keyed hash of Ruby keyword arguments. (Empty if no args) # @param ctx [GraphQL::Query::Context] - def resolve(object, args, ctx) - if @resolve_proc - raise "Can't run resolve proc for #{path} when using GraphQL::Execution::Interpreter" - end - begin - # Unwrap the GraphQL object to get the application object. - application_object = object.object + def resolve(object, args, query_ctx) + # Unwrap the GraphQL object to get the application object. + application_object = object.object + method_receiver = nil + method_to_call = nil + method_args = nil + + @own_validators && Schema::Validator.validate!(validators, application_object, query_ctx, args) + + query_ctx.query.after_lazy(self.authorized?(application_object, args, query_ctx)) do |is_authorized| + if is_authorized + with_extensions(object, args, query_ctx) do |obj, ruby_kwargs| + method_args = ruby_kwargs + if @resolver_class + if obj.is_a?(GraphQL::Schema::Object) + obj = obj.object + end + obj = @resolver_class.new(object: obj, context: query_ctx, field: self) + end - Schema::Validator.validate!(validators, application_object, ctx, args) + inner_object = obj.object - ctx.schema.after_lazy(self.authorized?(application_object, args, ctx)) do |is_authorized| - if is_authorized - public_send_field(object, args, ctx) - else - err = GraphQL::UnauthorizedFieldError.new(object: application_object, type: object.class, context: ctx, field: self) - ctx.schema.unauthorized_field(err) + if !NOT_CONFIGURED.equal?(@hash_key) + hash_value = if inner_object.is_a?(Hash) + inner_object.key?(@hash_key) ? inner_object[@hash_key] : inner_object[@hash_key_str] + elsif inner_object.respond_to?(:[]) + inner_object[@hash_key] + else + nil + end + if hash_value == false + hash_value + else + hash_value || (@fallback_value != NOT_CONFIGURED ? @fallback_value : nil) + end + elsif obj.respond_to?(resolver_method) + method_to_call = resolver_method + method_receiver = obj + # Call the method with kwargs, if there are any + if !ruby_kwargs.empty? + obj.public_send(resolver_method, **ruby_kwargs) + else + obj.public_send(resolver_method) + end + elsif inner_object.is_a?(Hash) + if @dig_keys + inner_object.dig(*@dig_keys) + elsif inner_object.key?(@method_sym) + inner_object[@method_sym] + elsif inner_object.key?(@method_str) || !inner_object.default_proc.nil? + inner_object[@method_str] + elsif @fallback_value != NOT_CONFIGURED + @fallback_value + else + nil + end + elsif inner_object.respond_to?(@method_sym) + method_to_call = @method_sym + method_receiver = obj.object + if !ruby_kwargs.empty? + inner_object.public_send(@method_sym, **ruby_kwargs) + else + inner_object.public_send(@method_sym) + end + elsif @fallback_value != NOT_CONFIGURED + @fallback_value + else + raise <<-ERR + Failed to implement #{@owner.graphql_name}.#{@name}, tried: + + - `#{obj.class}##{resolver_method}`, which did not exist + - `#{inner_object.class}##{@method_sym}`, which did not exist + - Looking up hash key `#{@method_sym.inspect}` or `#{@method_str.inspect}` on `#{inner_object}`, but it wasn't a Hash + + To implement this field, define one of the methods above (and check for typos), or supply a `fallback_value`. + ERR + end end + else + raise GraphQL::UnauthorizedFieldError.new(object: application_object, type: object.class, context: query_ctx, field: self) end - rescue GraphQL::UnauthorizedFieldError => err - err.field ||= self - ctx.schema.unauthorized_field(err) - rescue GraphQL::UnauthorizedError => err - ctx.schema.unauthorized_object(err) end + rescue GraphQL::UnauthorizedFieldError => err + err.field ||= self + begin + query_ctx.schema.unauthorized_field(err) + rescue GraphQL::ExecutionError => err + err + end + rescue GraphQL::UnauthorizedError => err + begin + query_ctx.schema.unauthorized_object(err) + rescue GraphQL::ExecutionError => err + err + end + rescue ArgumentError + if method_receiver && method_to_call + assert_satisfactory_implementation(method_receiver, method_to_call, method_args) + end + # if the line above doesn't raise, re-raise + raise rescue GraphQL::ExecutionError => err err end - # @param ctx [GraphQL::Query::Context::FieldResolutionContext] + # @param ctx [GraphQL::Query::Context] def fetch_extra(extra_name, ctx) if extra_name != :path && extra_name != :ast_node && respond_to?(extra_name) self.public_send(extra_name) @@ -635,111 +837,55 @@ def fetch_extra(extra_name, ctx) private - NO_ARGS = {}.freeze - - # Convert a GraphQL arguments instance into a Ruby-style hash. - # - # @param obj [GraphQL::Schema::Object] The object where this field is being resolved - # @param graphql_args [GraphQL::Query::Arguments] - # @param field_ctx [GraphQL::Query::Context::FieldResolutionContext] - # @return [Hash Any>] - def to_ruby_args(obj, graphql_args, field_ctx) - if graphql_args.any? || @extras.any? - # Splat the GraphQL::Arguments to Ruby keyword arguments - ruby_kwargs = graphql_args.to_kwargs - maybe_lazies = [] - # Apply any `prepare` methods. Not great code organization, can this go somewhere better? - arguments.each do |name, arg_defn| - ruby_kwargs_key = arg_defn.keyword - - if ruby_kwargs.key?(ruby_kwargs_key) - loads = arg_defn.loads - value = ruby_kwargs[ruby_kwargs_key] - loaded_value = if loads && !arg_defn.from_resolver? - if arg_defn.type.list? - loaded_values = value.map { |val| load_application_object(arg_defn, loads, val, field_ctx.query.context) } - field_ctx.schema.after_any_lazies(loaded_values) { |result| result } - else - load_application_object(arg_defn, loads, value, field_ctx.query.context) - end - elsif arg_defn.type.list? && value.is_a?(Array) - field_ctx.schema.after_any_lazies(value, &:itself) - else - value - end - - maybe_lazies << field_ctx.schema.after_lazy(loaded_value) do |loaded_value| - prepared_value = if arg_defn.prepare - arg_defn.prepare_value(obj, loaded_value) - else - loaded_value - end - - ruby_kwargs[ruby_kwargs_key] = prepared_value - end + def assert_satisfactory_implementation(receiver, method_name, ruby_kwargs) + method_defn = receiver.method(method_name) + unsatisfied_ruby_kwargs = ruby_kwargs.dup + unsatisfied_method_params = [] + encountered_keyrest = false + method_defn.parameters.each do |(param_type, param_name)| + case param_type + when :key + unsatisfied_ruby_kwargs.delete(param_name) + when :keyreq + if unsatisfied_ruby_kwargs.key?(param_name) + unsatisfied_ruby_kwargs.delete(param_name) + else + unsatisfied_method_params << "- `#{param_name}:` is required by Ruby, but not by GraphQL. Consider `#{param_name}: nil` instead, or making this argument required in GraphQL." end + when :keyrest + encountered_keyrest = true + when :req + unsatisfied_method_params << "- `#{param_name}` is required by Ruby, but GraphQL doesn't pass positional arguments. If it's meant to be a GraphQL argument, use `#{param_name}:` instead. Otherwise, remove it." + when :opt, :rest + # This is fine, although it will never be present end + end - @extras.each do |extra_arg| - ruby_kwargs[extra_arg] = fetch_extra(extra_arg, field_ctx) - end - - field_ctx.schema.after_any_lazies(maybe_lazies) do - ruby_kwargs - end - else - NO_ARGS + if encountered_keyrest + unsatisfied_ruby_kwargs.clear end - end - def public_send_field(unextended_obj, unextended_ruby_kwargs, query_ctx) - with_extensions(unextended_obj, unextended_ruby_kwargs, query_ctx) do |obj, ruby_kwargs| - if @resolver_class - if obj.is_a?(GraphQL::Schema::Object) - obj = obj.object - end - obj = @resolver_class.new(object: obj, context: query_ctx, field: self) - end + if !unsatisfied_ruby_kwargs.empty? || !unsatisfied_method_params.empty? + raise FieldImplementationFailed.new, <<-ERR +Failed to call `#{method_name.inspect}` on #{receiver.inspect} because the Ruby method params were incompatible with the GraphQL arguments: - # Find a way to resolve this field, checking: - # - # - A method on the type instance; - # - Hash keys, if the wrapped object is a hash; - # - A method on the wrapped object; - # - Or, raise not implemented. - # - if obj.respond_to?(@resolver_method) - # Call the method with kwargs, if there are any - if ruby_kwargs.any? - obj.public_send(@resolver_method, **ruby_kwargs) - else - obj.public_send(@resolver_method) - end - elsif obj.object.is_a?(Hash) - inner_object = obj.object - if inner_object.key?(@method_sym) - inner_object[@method_sym] - else - inner_object[@method_str] - end - elsif obj.object.respond_to?(@method_sym) - if ruby_kwargs.any? - obj.object.public_send(@method_sym, **ruby_kwargs) - else - obj.object.public_send(@method_sym) - end - else - raise <<-ERR - Failed to implement #{@owner.graphql_name}.#{@name}, tried: - - - `#{obj.class}##{@resolver_method}`, which did not exist - - `#{obj.object.class}##{@method_sym}`, which did not exist - - Looking up hash key `#{@method_sym.inspect}` or `#{@method_str.inspect}` on `#{obj.object}`, but it wasn't a Hash +#{ unsatisfied_ruby_kwargs + .map { |key, value| "- `#{key}: #{value}` was given by GraphQL but not defined in the Ruby method. Add `#{key}:` to the method parameters." } + .concat(unsatisfied_method_params) + .join("\n") } +ERR + end + end - To implement this field, define one of the methods above (and check for typos) - ERR - end + class ExtendedState + def initialize(args, object) + @arguments = args + @object = object + @memos = nil + @added_extras = nil end + + attr_accessor :arguments, :object, :memos, :added_extras end # Wrap execution with hooks. @@ -752,16 +898,20 @@ def with_extensions(obj, args, ctx) # This is a hack to get the _last_ value for extended obj and args, # in case one of the extensions doesn't `yield`. # (There's another implementation that uses multiple-return, but I'm wary of the perf cost of the extra arrays) - extended = { args: args, obj: obj, memos: nil } + extended = ExtendedState.new(args, obj) value = run_extensions_before_resolve(obj, args, ctx, extended) do |obj, args| + if (added_extras = extended.added_extras) + args = args.dup + added_extras.each { |e| args.delete(e) } + end yield(obj, args) end - extended_obj = extended[:obj] - extended_args = extended[:args] - memos = extended[:memos] || EMPTY_HASH + extended_obj = extended.object + extended_args = extended.arguments # rubocop:disable Development/ContextIsPassedCop + memos = extended.memos || EMPTY_HASH - ctx.schema.after_lazy(value) do |resolved_value| + ctx.query.after_lazy(value) do |resolved_value| idx = 0 @extensions.each do |ext| memo = memos[idx] @@ -774,22 +924,87 @@ def with_extensions(obj, args, ctx) end end + public + + def run_next_extensions_before_resolve(objs, args, ctx, extended, idx: 0, &block) + extension = @extensions[idx] + if extension + extension.resolve(objects: objs, arguments: args, context: ctx) do |extended_objs, extended_args, memo| + if memo + memos = extended.memos ||= {} + memos[idx] = memo + end + + if (extras = extension.added_extras) + ae = extended.added_extras ||= [] + ae.concat(extras) + end + + extended.object = extended_objs + extended.arguments = extended_args + run_next_extensions_before_resolve(extended_objs, extended_args, ctx, extended, idx: idx + 1, &block) + end + else + yield(objs, args) + end + end + + private + def run_extensions_before_resolve(obj, args, ctx, extended, idx: 0) extension = @extensions[idx] if extension extension.resolve(object: obj, arguments: args, context: ctx) do |extended_obj, extended_args, memo| if memo - memos = extended[:memos] ||= {} + memos = extended.memos ||= {} memos[idx] = memo end - extended[:obj] = extended_obj - extended[:args] = extended_args + + if (extras = extension.added_extras) + ae = extended.added_extras ||= [] + ae.concat(extras) + end + + extended.object = extended_obj + extended.arguments = extended_args run_extensions_before_resolve(extended_obj, extended_args, ctx, extended, idx: idx + 1) { |o, a| yield(o, a) } end else yield(obj, args) end end + + def apply_own_complexity_to(child_complexity, query, nodes) + case (own_complexity = complexity) + when Numeric + own_complexity + child_complexity + when Proc + arguments = query.arguments_for(nodes.first, self) + if arguments.is_a?(GraphQL::ExecutionError) + return child_complexity + elsif arguments.respond_to?(:keyword_arguments) + arguments = arguments.keyword_arguments + end + + own_complexity.call(query.context, arguments, child_complexity) + else + raise ArgumentError, "Invalid complexity for #{self.path}: #{own_complexity.inspect}" + end + end + + def set_pagination_extensions(connection_extension:) + # This should run before connection extension, + # but should it run after the definition block? + if scoped? + self.extension(ScopeExtension, call_after_define: false) + end + + # The problem with putting this after the definition_block + # is that it would override arguments + if connection? && connection_extension + self.extension(connection_extension, call_after_define: false) + end + end end end end diff --git a/lib/graphql/schema/field/connection_extension.rb b/lib/graphql/schema/field/connection_extension.rb index 099d4c32883..0a1c13d4a1c 100644 --- a/lib/graphql/schema/field/connection_extension.rb +++ b/lib/graphql/schema/field/connection_extension.rb @@ -12,62 +12,19 @@ def apply end # Remove pagination args before passing it to a user method - def resolve(object:, arguments:, context:) + def resolve(object: nil, objects: nil, arguments:, context:) next_args = arguments.dup next_args.delete(:first) next_args.delete(:last) next_args.delete(:before) next_args.delete(:after) - yield(object, next_args, arguments) + yield(object || objects, next_args, arguments) end def after_resolve(value:, object:, arguments:, context:, memo:) original_arguments = memo - # rename some inputs to avoid conflicts inside the block - maybe_lazy = value - value = nil - context.schema.after_lazy(maybe_lazy) do |resolved_value| - value = resolved_value - if value.is_a? GraphQL::ExecutionError - # This isn't even going to work because context doesn't have ast_node anymore - context.add_error(value) - nil - elsif value.nil? - nil - elsif value.is_a?(GraphQL::Pagination::Connection) - # update the connection with some things that may not have been provided - value.context ||= context - value.parent ||= object.object - value.first_value ||= original_arguments[:first] - value.after_value ||= original_arguments[:after] - value.last_value ||= original_arguments[:last] - value.before_value ||= original_arguments[:before] - value.arguments ||= original_arguments - value.field ||= field - if field.has_max_page_size? && !value.has_max_page_size_override? - value.max_page_size = field.max_page_size - end - if context.schema.new_connections? && (custom_t = context.schema.connections.edge_class_for_field(@field)) - value.edge_class = custom_t - end - value - elsif context.schema.new_connections? - context.namespace(:connections)[:all_wrappers] ||= context.schema.connections.all_wrappers - context.schema.connections.wrap(field, object.object, value, original_arguments, context) - else - if object.is_a?(GraphQL::Schema::Object) - object = object.object - end - connection_class = GraphQL::Relay::BaseConnection.connection_for_nodes(value) - connection_class.new( - value, - original_arguments, - field: field, - max_page_size: field.max_page_size, - parent: object, - context: context, - ) - end + context.query.after_lazy(value) do |resolved_value| + context.schema.connections.populate_connection(field, object.object, resolved_value, original_arguments, context) end end end diff --git a/lib/graphql/schema/field/scope_extension.rb b/lib/graphql/schema/field/scope_extension.rb index c21d87bfc61..5a4878a9523 100644 --- a/lib/graphql/schema/field/scope_extension.rb +++ b/lib/graphql/schema/field/scope_extension.rb @@ -5,15 +5,27 @@ class Schema class Field class ScopeExtension < GraphQL::Schema::FieldExtension def after_resolve(object:, arguments:, context:, value:, memo:) - if value.nil? - value - else - ret_type = @field.type.unwrap - if ret_type.respond_to?(:scope_items) - ret_type.scope_items(value, context) - else + if object.is_a?(GraphQL::Schema::Object) + if value.nil? value + else + return_type = field.type.unwrap + if return_type.respond_to?(:scope_items) + scoped_items = return_type.scope_items(value, context) + if !scoped_items.equal?(value) && !return_type.reauthorize_scoped_objects + if (current_runtime_state = Fiber[:__graphql_runtime_info]) && + (query_runtime_state = current_runtime_state[context.query]) + query_runtime_state.was_authorized_by_scope_items = true + end + end + scoped_items + else + value + end end + else + # TODO skip this entirely? + value end end end diff --git a/lib/graphql/schema/field_extension.rb b/lib/graphql/schema/field_extension.rb index 79acc6a9699..b101f479626 100644 --- a/lib/graphql/schema/field_extension.rb +++ b/lib/graphql/schema/field_extension.rb @@ -15,15 +15,65 @@ class FieldExtension # @return [Object] attr_reader :options + # @return [Array, nil] `default_argument`s added, if any were added (otherwise, `nil`) + attr_reader :added_default_arguments + # Called when the extension is mounted with `extension(name, options)`. - # The instance is frozen to avoid improper use of state during execution. + # The instance will be frozen to avoid improper use of state during execution. # @param field [GraphQL::Schema::Field] The field where this extension was mounted # @param options [Object] The second argument to `extension`, or `{}` if nothing was passed. def initialize(field:, options:) @field = field @options = options || {} + @added_default_arguments = nil apply - freeze + end + + class << self + # @return [Array(Array, Hash), nil] A list of default argument configs, or `nil` if there aren't any + def default_argument_configurations + args = superclass.respond_to?(:default_argument_configurations) ? superclass.default_argument_configurations : nil + if @own_default_argument_configurations + if args + args.concat(@own_default_argument_configurations) + else + args = @own_default_argument_configurations.dup + end + end + args + end + + # @see Argument#initialize + # @see HasArguments#argument + def default_argument(*argument_args, **argument_kwargs) + configs = @own_default_argument_configurations ||= [] + configs << [argument_args, argument_kwargs] + end + + # If configured, these `extras` will be added to the field if they aren't already present, + # but removed by from `arguments` before the field's `resolve` is called. + # (The extras _will_ be present for other extensions, though.) + # + # @param new_extras [Array] If provided, assign extras used by this extension + # @return [Array] any extras assigned to this extension + def extras(new_extras = nil) + if new_extras + @own_extras = new_extras + end + + inherited_extras = self.superclass.respond_to?(:extras) ? superclass.extras : nil + if @own_extras + if inherited_extras + inherited_extras + @own_extras + else + @own_extras + end + elsif inherited_extras + inherited_extras + else + GraphQL::EmptyObjects::EMPTY_ARRAY + end + end end # Called when this extension is attached to a field. @@ -32,6 +82,40 @@ def initialize(field:, options:) def apply end + # Called after the field's definition block has been executed. + # (Any arguments from the block are present on `field`) + # @return [void] + def after_define + end + + # @api private + def after_define_apply + after_define + if (configs = self.class.default_argument_configurations) + existing_keywords = field.all_argument_definitions.map(&:keyword) + existing_keywords.uniq! + @added_default_arguments = [] + configs.each do |config| + argument_args, argument_kwargs = config + arg_name = argument_args[0] + if !existing_keywords.include?(arg_name) + @added_default_arguments << arg_name + field.argument(*argument_args, **argument_kwargs) + end + end + end + if !(extras = self.class.extras).empty? + @added_extras = extras - field.extras + field.extras(@added_extras) + else + @added_extras = nil + end + freeze + end + + # @api private + attr_reader :added_extras + # Called before resolving {#field}. It should either: # # - `yield` values to continue execution; OR @@ -39,15 +123,16 @@ def apply # # Whatever this method returns will be used for execution. # - # @param object [Object] The object the field is being resolved on + # @param object [Object] The object the field is being resolved on (not passed by new execution) + # @param objects [Array] The objects the field is being resolved on (passed by new execution) # @param arguments [Hash] Ruby keyword arguments for resolving this field # @param context [Query::Context] the context for this query - # @yieldparam object [Object] The object to continue resolving the field on + # @yieldparam object_or_objects [Object, Array] The object or objects (new execution) to continue resolving the field on # @yieldparam arguments [Hash] The keyword arguments to continue resolving with # @yieldparam memo [Object] Any extension-specific value which will be passed to {#after_resolve} later # @return [Object] The return value for this field. - def resolve(object:, arguments:, context:) - yield(object, arguments, nil) + def resolve(object: nil, objects: nil, arguments:, context:) + yield(object.nil? ? objects : object, arguments, nil) end # Called after {#field} was resolved, and after any lazy values (like `Promise`s) were synced, @@ -55,14 +140,16 @@ def resolve(object:, arguments:, context:) # # Whatever this hook returns will be used as the return value. # - # @param object [Object] The object the field is being resolved on + # @param object [Object] The object the field is being resolved on (not passed by new execution) + # @param objects [Array] The object the field is being resolved on (passed by new execution) # @param arguments [Hash] Ruby keyword arguments for resolving this field # @param context [Query::Context] the context for this query - # @param value [Object] Whatever the field previously returned + # @param value [Object] Whatever the field previously returned (not passed by new execution) + # @param values [Array] Whatever the field previously returned (passed by new execution) # @param memo [Object] The third value yielded by {#resolve}, or `nil` if there wasn't one # @return [Object] The return value for this field. - def after_resolve(object:, arguments:, context:, value:, memo:) - value + def after_resolve(object: nil, objects: nil, arguments:, context:, values: nil, value: nil, memo:) + value.nil? ? values : value end end end diff --git a/lib/graphql/schema/find_inherited_value.rb b/lib/graphql/schema/find_inherited_value.rb index a4f60b05e08..08a4fab9b9e 100644 --- a/lib/graphql/schema/find_inherited_value.rb +++ b/lib/graphql/schema/find_inherited_value.rb @@ -1,17 +1,13 @@ +# frozen_string_literal: true module GraphQL class Schema module FindInheritedValue - module EmptyObjects - EMPTY_HASH = {}.freeze - EMPTY_ARRAY = [].freeze - end - def self.extended(child_cls) - child_cls.singleton_class.include(EmptyObjects) + child_cls.singleton_class.include(GraphQL::EmptyObjects) end def self.included(child_cls) - child_cls.include(EmptyObjects) + child_cls.include(GraphQL::EmptyObjects) end private diff --git a/lib/graphql/schema/finder.rb b/lib/graphql/schema/finder.rb index 1cd982e7deb..6982986263e 100644 --- a/lib/graphql/schema/finder.rb +++ b/lib/graphql/schema/finder.rb @@ -38,7 +38,7 @@ def find(path) find_in_directive(directive, path: path) else - type = schema.get_type(type_or_directive) + type = schema.get_type(type_or_directive) # rubocop:disable Development/ContextIsPassedCop -- build-time if type.nil? raise MemberNotFoundError, "Could not find type `#{type_or_directive}` in schema." @@ -56,7 +56,7 @@ def find(path) def find_in_directive(directive, path:) argument_name = path.shift - argument = directive.arguments[argument_name] + argument = directive.get_argument(argument_name) # rubocop:disable Development/ContextIsPassedCop -- build-time if argument.nil? raise MemberNotFoundError, "Could not find argument `#{argument_name}` on directive #{directive}." @@ -102,7 +102,7 @@ def find_in_fields_type(type, kind:, path:) def find_in_field(field, path:) argument_name = path.shift - argument = field.arguments[argument_name] + argument = field.get_argument(argument_name) # rubocop:disable Development/ContextIsPassedCop -- build-time if argument.nil? raise MemberNotFoundError, "Could not find argument `#{argument_name}` on field `#{field.name}`." @@ -119,7 +119,7 @@ def find_in_field(field, path:) def find_in_input_object(input_object, path:) field_name = path.shift - input_field = input_object.arguments[field_name] + input_field = input_object.get_argument(field_name) # rubocop:disable Development/ContextIsPassedCop -- build-time if input_field.nil? raise MemberNotFoundError, "Could not find input field `#{field_name}` on input object type `#{input_object.graphql_name}`." @@ -136,7 +136,7 @@ def find_in_input_object(input_object, path:) def find_in_enum_type(enum_type, path:) value_name = path.shift - enum_value = enum_type.values[value_name] + enum_value = enum_type.enum_values.find { |v| v.graphql_name == value_name } # rubocop:disable Development/ContextIsPassedCop -- build-time, not runtime if enum_value.nil? raise MemberNotFoundError, "Could not find enum value `#{value_name}` on enum type `#{enum_type.graphql_name}`." diff --git a/lib/graphql/schema/has_single_input_argument.rb b/lib/graphql/schema/has_single_input_argument.rb new file mode 100644 index 00000000000..6dd4d3c421c --- /dev/null +++ b/lib/graphql/schema/has_single_input_argument.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +module GraphQL + class Schema + module HasSingleInputArgument + def resolve_with_support(**inputs) + input_kwargs = flatten_arguments(inputs) + if !input_kwargs.empty? + super(**input_kwargs) + else + super() + end + end + + def self.included(base) + base.extend(ClassMethods) + end + + def call + @prepared_arguments = flatten_arguments(@prepared_arguments) + super + end + + private + + def flatten_arguments(inputs) + input = if inputs[:input].is_a?(InputObject) + inputs[:input].to_kwargs + else + inputs[:input] + end + + new_extras = field ? field.extras : [] + all_extras = self.class.extras + new_extras + + # Transfer these from the top-level hash to the + # shortcutted `input:` object + all_extras.each do |ext| + # It's possible that the `extra` was not passed along by this point, + # don't re-add it if it wasn't given here. + if inputs.key?(ext) + input[ext] = inputs[ext] + end + end + + if input + input_kwargs = input.to_h + else + # Relay Classic Mutations with no `argument`s + # don't require `input:` + input_kwargs = {} + end + + input_kwargs + end + + module ClassMethods + def dummy + @dummy ||= begin + d = Class.new(GraphQL::Schema::Resolver) + d.graphql_name "#{self.graphql_name}DummyResolver" + d.argument_class(self.argument_class) + # TODO make this lazier? + d.argument(:input, input_type, description: "Parameters for #{self.graphql_name}") + d + end + end + + def field_arguments(context = GraphQL::Query::NullContext.instance) + dummy.arguments(context) + end + + def get_field_argument(name, context = GraphQL::Query::NullContext.instance) + dummy.get_argument(name, context) + end + + def own_field_arguments + dummy.own_arguments + end + + def any_field_arguments? + dummy.any_arguments? + end + + def all_field_argument_definitions + dummy.all_argument_definitions + end + + # Also apply this argument to the input type: + def argument(*args, own_argument: false, **kwargs, &block) + it = input_type # make sure any inherited arguments are already added to it + arg = super(*args, **kwargs, &block) + + # This definition might be overriding something inherited; + # if it is, remove the inherited definition so it's not confused at runtime as having multiple definitions + prev_args = it.own_arguments[arg.graphql_name] + case prev_args + when GraphQL::Schema::Argument + if prev_args.owner != self + it.own_arguments.delete(arg.graphql_name) + end + when Array + prev_args.reject! { |a| a.owner != self } + if prev_args.empty? + it.own_arguments.delete(arg.graphql_name) + end + end + + it.add_argument(arg) + arg + end + + # The base class for generated input object types + # @param new_class [Class] The base class to use for generating input object definitions + # @return [Class] The base class for this mutation's generated input object (default is {GraphQL::Schema::InputObject}) + def input_object_class(new_class = nil) + if new_class + @input_object_class = new_class + end + @input_object_class || (superclass.respond_to?(:input_object_class) ? superclass.input_object_class : GraphQL::Schema::InputObject) + end + + # @param new_input_type [Class, nil] If provided, it configures this mutation to accept `new_input_type` instead of generating an input type + # @return [Class] The generated {Schema::InputObject} class for this mutation's `input` + def input_type(new_input_type = nil) + if new_input_type + @input_type = new_input_type + end + @input_type ||= generate_input_type + end + + private + + # Generate the input type for the `input:` argument + # To customize how input objects are generated, override this method + # @return [Class] a subclass of {.input_object_class} + def generate_input_type + mutation_args = all_argument_definitions + mutation_class = self + Class.new(input_object_class) do + class << self + def default_graphql_name + "#{self.mutation.graphql_name}Input" + end + + def description(new_desc = nil) + super || "Autogenerated input type of #{self.mutation.graphql_name}" + end + end + # For compatibility, in case no arguments are defined: + has_no_arguments(true) + mutation(mutation_class) + # these might be inherited: + mutation_args.each do |arg| + add_argument(arg) + end + end + end + end + + private + + def authorize_arguments(args, values) + # remove the `input` wrapper to match values + input_type = args.find { |a| a.graphql_name == "input" }.type.unwrap + input_args = context.types.arguments(input_type) + super(input_args, values) + end + end + end +end diff --git a/lib/graphql/schema/input_object.rb b/lib/graphql/schema/input_object.rb index c73407311c9..e5d49515c53 100644 --- a/lib/graphql/schema/input_object.rb +++ b/lib/graphql/schema/input_object.rb @@ -2,7 +2,6 @@ module GraphQL class Schema class InputObject < GraphQL::Schema::Member - extend GraphQL::Schema::Member::AcceptsDefinition extend Forwardable extend GraphQL::Schema::Member::HasArguments extend GraphQL::Schema::Member::HasArguments::ArgumentObjectLoader @@ -11,55 +10,39 @@ class InputObject < GraphQL::Schema::Member include GraphQL::Dig + # Raised when an InputObject doesn't have any arguments defined and hasn't explicitly opted out of this requirement + class ArgumentsAreRequiredError < GraphQL::Error + def initialize(input_object_type) + message = "Input Object types must have arguments, but #{input_object_type.graphql_name} doesn't have any. Define an argument for this type, remove it from your schema, or add `has_no_arguments(true)` to its definition." + super(message) + end + end + # @return [GraphQL::Query::Context] The context for this query attr_reader :context - # @return [GraphQL::Query::Arguments, GraphQL::Execution::Interpereter::Arguments] The underlying arguments instance + # @return [GraphQL::Execution::Interpereter::Arguments] The underlying arguments instance attr_reader :arguments # Ruby-like hash behaviors, read-only def_delegators :@ruby_style_hash, :keys, :values, :each, :map, :any?, :empty? - def initialize(arguments = nil, ruby_kwargs: nil, context:, defaults_used:) + def initialize(arguments, ruby_kwargs:, context:, defaults_used:) @context = context - if ruby_kwargs - @ruby_style_hash = ruby_kwargs - @arguments = arguments - else - @arguments = self.class.arguments_class.new(arguments, context: context, defaults_used: defaults_used) - # Symbolized, underscored hash: - @ruby_style_hash = @arguments.to_kwargs - end + @ruby_style_hash = ruby_kwargs + @arguments = arguments # Apply prepares, not great to have it duplicated here. - maybe_lazies = [] - self.class.arguments.each_value do |arg_defn| + arg_defns = context ? context.types.arguments(self.class) : self.class.arguments(context).each_value + arg_defns.each do |arg_defn| ruby_kwargs_key = arg_defn.keyword - if @ruby_style_hash.key?(ruby_kwargs_key) - loads = arg_defn.loads - # Resolvers do this loading themselves; - # With the interpreter, it's done during `coerce_arguments` - if loads && !arg_defn.from_resolver? && !context.interpreter? - value = @ruby_style_hash[ruby_kwargs_key] - loaded_value = if arg_defn.type.list? - value.map { |val| load_application_object(arg_defn, loads, val, context) } - else - load_application_object(arg_defn, loads, value, context) - end - maybe_lazies << context.schema.after_lazy(loaded_value) do |loaded_value| - overwrite_argument(ruby_kwargs_key, loaded_value) - end - end - # Weirdly, procs are applied during coercion, but not methods. # Probably because these methods require a `self`. - if arg_defn.prepare.is_a?(Symbol) || context.nil? || !context.interpreter? - prepared_value = arg_defn.prepare_value(self, @ruby_style_hash[ruby_kwargs_key]) + if arg_defn.prepare.is_a?(Symbol) || context.nil? + prepared_value = arg_defn.prepare_value(self, @ruby_style_hash[ruby_kwargs_key], context: context) overwrite_argument(ruby_kwargs_key, prepared_value) end end end - - @maybe_lazies = maybe_lazies end def to_h @@ -70,19 +53,20 @@ def to_hash to_h end - def prepare - if context - context.schema.after_any_lazies(@maybe_lazies) do - object = context[:current_object] - # Pass this object's class with `as` so that messages are rendered correctly from inherited validators - Schema::Validator.validate!(self.class.validators, object, context, @ruby_style_hash, as: self.class) - self - end + def deconstruct_keys(keys = nil) + if keys.nil? + @ruby_style_hash else - self + new_h = {} + keys.each { |k| @ruby_style_hash.key?(k) && new_h[k] = @ruby_style_hash[k] } + new_h end end + def prepare + self + end + def unwrap_value(value) case value when Array @@ -120,36 +104,61 @@ def to_kwargs @ruby_style_hash.dup end + # @api private + def validate_for(context) + object = context[:current_object] + # Pass this object's class with `as` so that messages are rendered correctly from inherited validators + Schema::Validator.validate!(self.class.validators, object, context, @ruby_style_hash, as: self.class) + nil + end + class << self - # @return [Class] - attr_accessor :arguments_class + def authorizes?(ctx) + self.method(:authorized?).owner != GraphQL::Schema::InputObject + end - def argument(*args, **kwargs, &block) - argument_defn = super(*args, **kwargs, &block) - # Add a method access - method_name = argument_defn.keyword - class_eval <<-RUBY, __FILE__, __LINE__ - def #{method_name} - self[#{method_name.inspect}] + def authorized?(obj, value, ctx) + # Authorize each argument (but this doesn't apply if `prepare` is implemented): + if value.respond_to?(:key?) + ctx.types.arguments(self).each do |input_obj_arg| + if value.key?(input_obj_arg.keyword) && + !input_obj_arg.authorized?(obj, value[input_obj_arg.keyword], ctx) + return false + end + end + end + # It didn't early-return false: + true + end + + def one_of + if !one_of? + if all_argument_definitions.any? { |arg| arg.type.non_null? } + raise ArgumentError, "`one_of` may not be used with required arguments -- add `required: false` to argument definitions to use `one_of`" end - RUBY + directive(GraphQL::Schema::Directive::OneOf) + end end - def to_graphql - type_defn = GraphQL::InputObjectType.new - type_defn.name = graphql_name - type_defn.description = description - type_defn.metadata[:type_class] = self - type_defn.mutation = mutation - type_defn.ast_node = ast_node - arguments.each do |name, arg| - type_defn.arguments[arg.graphql_definition.name] = arg.graphql_definition + def one_of? + false # Re-defined when `OneOf` is added + end + + def argument(*args, **kwargs, &block) + argument_defn = super(*args, **kwargs, &block) + if one_of? + if argument_defn.type.non_null? + raise ArgumentError, "Argument '#{argument_defn.path}' must be nullable because it is part of a OneOf type, add `required: false`." + end + if argument_defn.default_value? + raise ArgumentError, "Argument '#{argument_defn.path}' cannot have a default value because it is part of a OneOf type, remove `default_value: ...`." + end end - # Make a reference to a classic-style Arguments class - self.arguments_class = GraphQL::Query::Arguments.construct_arguments_class(type_defn) - # But use this InputObject class at runtime - type_defn.arguments_class = self - type_defn + # Add a method access + suppress_redefinition_warning do + define_accessor_method(argument_defn.keyword) + end + argument_defn end def kind @@ -157,45 +166,66 @@ def kind end # @api private - INVALID_OBJECT_MESSAGE = "Expected %{object} to be a key-value object responding to `to_h` or `to_unsafe_h`." - - def validate_non_null_input(input, ctx) - result = GraphQL::Query::InputValidationResult.new + INVALID_OBJECT_MESSAGE = "Expected %{object} to be a key-value object." - warden = ctx.warden + def validate_non_null_input(input, ctx, max_errors: nil) + types = ctx.types if input.is_a?(Array) - result.add_problem(INVALID_OBJECT_MESSAGE % { object: JSON.generate(input, quirks_mode: true) }) - return result + return GraphQL::Query::InputValidationResult.from_problem(INVALID_OBJECT_MESSAGE % { object: JSON.generate(input, quirks_mode: true) }) end if !(input.respond_to?(:to_h) || input.respond_to?(:to_unsafe_h)) # We're not sure it'll act like a hash, so reject it: - result.add_problem(INVALID_OBJECT_MESSAGE % { object: JSON.generate(input, quirks_mode: true) }) - return result + return GraphQL::Query::InputValidationResult.from_problem(INVALID_OBJECT_MESSAGE % { object: JSON.generate(input, quirks_mode: true) }) end - # Inject missing required arguments - missing_required_inputs = self.arguments.reduce({}) do |m, (argument_name, argument)| - if !input.key?(argument_name) && argument.type.non_null? && warden.get_argument(self, argument_name) - m[argument_name] = nil - end - m - end + result = nil - [input, missing_required_inputs].each do |args_to_validate| - args_to_validate.each do |argument_name, value| - argument = warden.get_argument(self, argument_name) - # Items in the input that are unexpected - unless argument - result.add_problem("Field is not defined on #{self.graphql_name}", [argument_name]) - next - end + input.each do |argument_name, value| + argument = types.argument(self, argument_name) + if argument.nil? && ctx.is_a?(Query::NullContext) && argument_name.is_a?(Symbol) + # Validating definition directive arguments which come in as Symbols + argument = types.arguments(self).find { |arg| arg.keyword == argument_name } + end + # Items in the input that are unexpected + if argument.nil? + result ||= Query::InputValidationResult.new + result.add_problem("Field is not defined on #{self.graphql_name}", [argument_name]) + else # Items in the input that are expected, but have invalid values argument_result = argument.type.validate_input(value, ctx) - result.merge_result!(argument_name, argument_result) unless argument_result.valid? + if !argument_result.valid? + result ||= Query::InputValidationResult.new + result.merge_result!(argument_name, argument_result) + end + end + end + + # Check for missing non-null arguments + ctx.types.arguments(self).each do |argument| + if !input.key?(argument.graphql_name) && argument.type.non_null? && !argument.default_value? + result ||= Query::InputValidationResult.new + argument_result = argument.type.validate_input(nil, ctx) + if !argument_result.valid? + result.merge_result!(argument.graphql_name, argument_result) + end + end + end + + if one_of? + if input.size == 1 + input.each do |name, value| + if value.nil? + result ||= Query::InputValidationResult.new + result.add_problem("'#{graphql_name}' requires exactly one argument, but '#{name}' was `null`.") + end + end + else + result ||= Query::InputValidationResult.new + result.add_problem("'#{graphql_name}' requires exactly one argument, but #{input.size} were provided.") end end @@ -209,12 +239,11 @@ def coerce_input(value, ctx) arguments = coerce_arguments(nil, value, ctx) - ctx.schema.after_lazy(arguments) do |resolved_arguments| + ctx.query.after_lazy(arguments) do |resolved_arguments| if resolved_arguments.is_a?(GraphQL::Error) raise resolved_arguments else - input_obj_instance = self.new(resolved_arguments, ruby_kwargs: resolved_arguments.keyword_arguments, context: ctx, defaults_used: nil) - input_obj_instance.prepare + self.new(resolved_arguments, ruby_kwargs: resolved_arguments.keyword_arguments, context: ctx, defaults_used: nil) end end end @@ -227,7 +256,7 @@ def coerce_result(value, ctx) result = {} - arguments.each do |input_key, input_field_defn| + arguments(ctx).each do |input_key, input_field_defn| input_value = value[input_key] if value.key?(input_key) result[input_key] = if input_value.nil? @@ -240,6 +269,41 @@ def coerce_result(value, ctx) result end + + # @param new_has_no_arguments [Boolean] Call with `true` to make this InputObject type ignore the requirement to have any defined arguments. + # @return [void] + def has_no_arguments(new_has_no_arguments) + @has_no_arguments = new_has_no_arguments + nil + end + + # @return [Boolean] `true` if `has_no_arguments(true)` was configued + def has_no_arguments? + @has_no_arguments + end + + def arguments(context = GraphQL::Query::NullContext.instance, require_defined_arguments = true) + if require_defined_arguments && !has_no_arguments? && !any_arguments? + warn(GraphQL::Schema::InputObject::ArgumentsAreRequiredError.new(self).message + "\n\nThis will raise an error in a future GraphQL-Ruby version.") + end + super(context, false) + end + + private + + # Suppress redefinition warning for objectId arguments + def suppress_redefinition_warning + verbose = $VERBOSE + $VERBOSE = nil + yield + ensure + $VERBOSE = verbose + end + + def define_accessor_method(method_name) + define_method(method_name) { self[method_name] } + alias_method(method_name, method_name) + end end private diff --git a/lib/graphql/schema/interface.rb b/lib/graphql/schema/interface.rb index d04ab48f87f..80b4573cc7b 100644 --- a/lib/graphql/schema/interface.rb +++ b/lib/graphql/schema/interface.rb @@ -4,8 +4,6 @@ class Schema module Interface include GraphQL::Schema::Member::GraphQLTypeNames module DefinitionMethods - include GraphQL::Schema::Member::CachedGraphQLDefinition - include GraphQL::Relay::TypeExtensions include GraphQL::Schema::Member::BaseDSLMethods # ConfigurationExtension's responsibilities are in `def included` below include GraphQL::Schema::Member::TypeSystemHelpers @@ -15,13 +13,44 @@ module DefinitionMethods include GraphQL::Schema::Member::Scoped include GraphQL::Schema::Member::HasAstNode include GraphQL::Schema::Member::HasUnresolvedTypeError + include GraphQL::Schema::Member::HasDataloader include GraphQL::Schema::Member::HasDirectives + include GraphQL::Schema::Member::HasInterfaces # Methods defined in this block will be: # - Added as class methods to this interface # - Added as class methods to all child interfaces def definition_methods(&block) - self::DefinitionMethods.module_eval(&block) + # Use an instance variable to tell whether it's been included previously or not; + # You can't use constant detection because constants are brought into scope + # by `include`, which has already happened at this point. + if !defined?(@_definition_methods) + defn_methods_module = Module.new + @_definition_methods = defn_methods_module + const_set(:DefinitionMethods, defn_methods_module) + extend(self::DefinitionMethods) + end + self::DefinitionMethods.module_exec(&block) + end + + # Instance methods defined in this block will become class methods on objects that implement this interface. + # Use it to implement `resolve_each:`, `resolve_batch:`, and `resolve_static:` fields. + # @example + # field :thing, String, resolve_static: true + # + # resolver_methods do + # def thing + # Somehow.get.thing + # end + # end + def resolver_methods(&block) + if !defined?(@_resolver_methods) + resolver_methods_module = Module.new + @_resolver_methods = resolver_methods_module + const_set(:ResolverMethods, resolver_methods_module) + extend(self::ResolverMethods) + end + self::ResolverMethods.module_exec(&block) end # @see {Schema::Warden} hides interfaces without visible implementations @@ -29,16 +58,6 @@ def visible?(context) true end - # The interface is accessible if any of its possible types are accessible - def accessible?(context) - context.schema.possible_types(self, context).each do |type| - if context.schema.accessible?(type, context) - return true - end - end - false - end - def type_membership_class(membership_class = nil) if membership_class @type_membership_class = membership_class @@ -57,86 +76,71 @@ def included(child_class) child_class.extend(Schema::Interface::DefinitionMethods) child_class.type_membership_class(self.type_membership_class) - child_class.own_interfaces << self - child_class.interfaces.reverse_each do |interface_defn| - child_class.extend(interface_defn::DefinitionMethods) + child_class.ancestors.reverse_each do |ancestor| + if ancestor.const_defined?(:DefinitionMethods) && ancestor != child_class + child_class.extend(ancestor::DefinitionMethods) + end end - # Use an instance variable to tell whether it's been included previously or not; - # You can't use constant detection because constants are brought into scope - # by `include`, which has already happened at this point. - if !child_class.instance_variable_defined?(:@_definition_methods) - defn_methods_module = Module.new - child_class.instance_variable_set(:@_definition_methods, defn_methods_module) - child_class.const_set(:DefinitionMethods, defn_methods_module) - child_class.extend(child_class::DefinitionMethods) - end child_class.introspection(introspection) child_class.description(description) - if overridden_graphql_name - child_class.graphql_name(overridden_graphql_name) - end + child_class.comment(nil) # If interfaces are mixed into each other, only define this class once if !child_class.const_defined?(:UnresolvedTypeError, false) add_unresolved_type_error(child_class) end elsif child_class < GraphQL::Schema::Object # This is being included into an object type, make sure it's using `implements(...)` - backtrace_line = caller(0, 10).find { |line| line.include?("schema/object.rb") && line.include?("in `implements'")} + backtrace_line = caller_locations(0, 10).find do |location| + location.base_label == "implements" && + location.path.end_with?("schema/member/has_interfaces.rb") + end + if !backtrace_line raise "Attach interfaces using `implements(#{self})`, not `include(#{self})`" end + + child_class.ancestors.reverse_each do |ancestor| + if ancestor != child_class && ancestor <= GraphQL::Schema::Interface && ancestor.const_defined?(:ResolverMethods, false) + child_class.extend(ancestor::ResolverMethods) + end + end end super end + # Register other Interface or Object types as implementers of this Interface. + # + # When those Interfaces or Objects aren't used as the return values of fields, + # they may have to be registered using this method so that GraphQL-Ruby can find them. + # @param types [Class, Module] + # @return [Array] Implementers of this interface, if they're registered def orphan_types(*types) - if types.any? - @orphan_types = types + if !types.empty? + @orphan_types ||= [] + @orphan_types.concat(types) else - all_orphan_types = @orphan_types || [] - all_orphan_types += super if defined?(super) - all_orphan_types.uniq - end - end - - def to_graphql - type_defn = GraphQL::InterfaceType.new - type_defn.name = graphql_name - type_defn.description = description - type_defn.orphan_types = orphan_types - type_defn.type_membership_class = self.type_membership_class - type_defn.ast_node = ast_node - fields.each do |field_name, field_inst| - field_defn = field_inst.graphql_definition - type_defn.fields[field_defn.name] = field_defn - end - type_defn.metadata[:type_class] = self - if respond_to?(:resolve_type) - type_defn.resolve_type = method(:resolve_type) + if defined?(@orphan_types) + all_orphan_types = @orphan_types.dup + if defined?(super) + all_orphan_types += super + all_orphan_types.uniq! + end + all_orphan_types + elsif defined?(super) + super + else + EmptyObjects::EMPTY_ARRAY + end end - type_defn end def kind GraphQL::TypeKinds::INTERFACE end - - protected - - def own_interfaces - @own_interfaces ||= [] - end - - def interfaces - own_interfaces + (own_interfaces.map { |i| i.own_interfaces }).flatten - end end - # Extend this _after_ `DefinitionMethods` is defined, so it will be used - extend GraphQL::Schema::Member::AcceptsDefinition - extend DefinitionMethods def unwrap diff --git a/lib/graphql/schema/introspection_system.rb b/lib/graphql/schema/introspection_system.rb index df8f32daaa9..8051bda2a1c 100644 --- a/lib/graphql/schema/introspection_system.rb +++ b/lib/graphql/schema/introspection_system.rb @@ -25,10 +25,10 @@ def initialize(schema) load_constant(:DirectiveLocationEnum) ] @types = {} - @possible_types = {} + @possible_types = {}.compare_by_identity type_defns.each do |t| @types[t.graphql_name] = t - @possible_types[t.graphql_name] = [t] + @possible_types[t] = [t] end @entry_point_fields = if schema.disable_introspection_entry_points? @@ -39,7 +39,9 @@ def initialize(schema) entry_point_fields.delete('__type') if schema.disable_type_introspection_entry_point? entry_point_fields end + @entry_point_fields.each { |k, v| v.dynamic_introspection = true } @dynamic_fields = get_fields_from_class(class_sym: :DynamicFields) + @dynamic_fields.each { |k, v| v.dynamic_introspection = true } end def entry_points @@ -67,7 +69,7 @@ def dynamic_field(name:) def resolve_late_bindings @types.each do |name, t| if t.kind.fields? - t.fields.each do |_name, field_defn| + t.all_field_definitions.each do |field_defn| field_defn.type = resolve_late_binding(field_defn.type) end end @@ -88,10 +90,11 @@ def resolve_late_bindings def resolve_late_binding(late_bound_type) case late_bound_type when GraphQL::Schema::LateBoundType - @schema.get_type(late_bound_type.name) - when GraphQL::Schema::List, GraphQL::ListType + type_name = late_bound_type.name + @types[type_name] || @schema.get_type(type_name) + when GraphQL::Schema::List resolve_late_binding(late_bound_type.of_type).to_list_type - when GraphQL::Schema::NonNull, GraphQL::NonNullType + when GraphQL::Schema::NonNull resolve_late_binding(late_bound_type.of_type).to_non_null_type when Module # It's a normal type -- no change required @@ -102,33 +105,18 @@ def resolve_late_binding(late_bound_type) end def load_constant(class_name) - const = @custom_namespace.const_get(class_name) - if @class_based - dup_type_class(const) - else - # Use `.to_graphql` to get a freshly-made version, not shared between schemas - const.to_graphql + const = begin + @custom_namespace.const_get(class_name) + rescue NameError + # Dup the built-in so that the cached fields aren't shared + @built_in_namespace.const_get(class_name) end - rescue NameError - # Dup the built-in so that the cached fields aren't shared - dup_type_class(@built_in_namespace.const_get(class_name)) + dup_type_class(const) end def get_fields_from_class(class_sym:) object_type_defn = load_constant(class_sym) - - if object_type_defn.is_a?(Module) - object_type_defn.fields - else - extracted_field_defns = {} - object_class = object_type_defn.metadata[:type_class] - object_type_defn.all_fields.each do |field_defn| - inner_resolve = field_defn.resolve_proc - resolve_with_instantiate = PerFieldProxyResolve.new(object_class: object_class, inner_resolve: inner_resolve) - extracted_field_defns[field_defn.name] = field_defn.redefine(resolve: resolve_with_instantiate) - end - extracted_field_defns - end + object_type_defn.fields end # This is probably not 100% robust -- but it has to be good enough to avoid modifying the built-in introspection types @@ -147,23 +135,6 @@ def dup_type_class(type_class) end end end - - class PerFieldProxyResolve - def initialize(object_class:, inner_resolve:) - @object_class = object_class - @inner_resolve = inner_resolve - end - - def call(obj, args, ctx) - query_ctx = ctx.query.context - # Remove the QueryType wrapper - if obj.is_a?(GraphQL::Schema::Object) - obj = obj.object - end - wrapped_object = @object_class.authorized_new(obj, query_ctx) - @inner_resolve.call(wrapped_object, args, ctx) - end - end end end end diff --git a/lib/graphql/schema/invalid_type_error.rb b/lib/graphql/schema/invalid_type_error.rb deleted file mode 100644 index e68d47fc726..00000000000 --- a/lib/graphql/schema/invalid_type_error.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Schema - class InvalidTypeError < GraphQL::Error - end - end -end diff --git a/lib/graphql/schema/late_bound_type.rb b/lib/graphql/schema/late_bound_type.rb index e5cb8fb1b25..e8ec59660af 100644 --- a/lib/graphql/schema/late_bound_type.rb +++ b/lib/graphql/schema/late_bound_type.rb @@ -9,6 +9,8 @@ class LateBoundType alias :graphql_name :name def initialize(local_name) @name = local_name + @to_non_null_type = nil + @to_list_type = nil end def unwrap @@ -16,17 +18,25 @@ def unwrap end def to_non_null_type - @to_non_null_type ||= GraphQL::NonNullType.new(of_type: self) + @to_non_null_type ||= GraphQL::Schema::NonNull.new(self) end def to_list_type - @to_list_type ||= GraphQL::ListType.new(of_type: self) + @to_list_type ||= GraphQL::Schema::List.new(self) + end + + def to_type_signature + name end def inspect "#" end + def non_null? + false + end + alias :to_s :inspect end end diff --git a/lib/graphql/schema/list.rb b/lib/graphql/schema/list.rb index 595d4998710..56e0928f58f 100644 --- a/lib/graphql/schema/list.rb +++ b/lib/graphql/schema/list.rb @@ -4,14 +4,10 @@ module GraphQL class Schema # Represents a list type in the schema. # Wraps a {Schema::Member} as a list type. - # @see {Schema::Member::TypeSystemHelpers#to_list_type} + # @see Schema::Member::TypeSystemHelpers#to_list_type Create a list type from another GraphQL type class List < GraphQL::Schema::Wrapper include Schema::Member::ValidatesInput - def to_graphql - @of_type.graphql_definition.to_list_type - end - # @return [GraphQL::TypeKinds::LIST] def kind GraphQL::TypeKinds::LIST @@ -23,7 +19,11 @@ def list? end def to_type_signature - "[#{@of_type.to_type_signature}]" + @type_signature ||= -"[#{@of_type.to_type_signature}]" + end + + def authorizes?(ctx) + of_type.authorizes?(ctx) end # This is for introspection, where it's expected the name will be `null` @@ -49,15 +49,24 @@ def coerce_input(value, ctx) end end - def validate_non_null_input(value, ctx) + def validate_non_null_input(value, ctx, max_errors: nil) result = GraphQL::Query::InputValidationResult.new ensure_array(value).each_with_index do |item, index| item_result = of_type.validate_input(item, ctx) - if !item_result.valid? + unless item_result.valid? + if max_errors + if max_errors == 0 + add_max_errors_reached_message(result) + break + end + + max_errors -= 1 + end + result.merge_result!(index, item_result) end end - result + result.valid? ? nil : result end private @@ -70,6 +79,12 @@ def ensure_array(value) [value] end end + + def add_max_errors_reached_message(result) + message = "Too many errors processing list variable, max validation error limit reached. Execution aborted" + item_result = GraphQL::Query::InputValidationResult.from_problem(message) + result.merge_result!(nil, item_result) + end end end end diff --git a/lib/graphql/schema/loader.rb b/lib/graphql/schema/loader.rb index 2c0d2014184..228cb5161b0 100644 --- a/lib/graphql/schema/loader.rb +++ b/lib/graphql/schema/loader.rb @@ -32,8 +32,10 @@ def load(introspection_result) end Class.new(GraphQL::Schema) do - orphan_types(types.values) + add_type_and_traverse(types.values, root: false) + orphan_types(types.values.select { |t| t.kind.object? }) directives(directives) + description(schema["description"]) def self.resolve_type(*) raise(GraphQL::RequiredImplementationMissingError, "This schema was loaded from string, so it can't resolve types for objects") @@ -141,6 +143,7 @@ def define_type(type, type_resolver) Class.new(GraphQL::Schema::Scalar) do graphql_name(type["name"]) description(type["description"]) + specified_by_url(type["specifiedByURL"]) end end when "UNION" @@ -160,6 +163,7 @@ def define_directive(directive, type_resolver) graphql_name(directive["name"]) description(directive["description"]) locations(*directive["locations"].map(&:to_sym)) + repeatable(directive["isRepeatable"]) loader.build_arguments(self, directive["args"], type_resolver) end end @@ -173,7 +177,6 @@ def build_fields(type_defn, fields, type_resolver) while (of_type = unwrapped_field_hash["ofType"]) unwrapped_field_hash = of_type end - type_name = unwrapped_field_hash["name"] type_defn.field( field_hash["name"], @@ -183,9 +186,8 @@ def build_fields(type_defn, fields, type_resolver) null: true, camelize: false, connection_extension: nil, - connection: type_name.end_with?("Connection"), ) do - if field_hash["args"].any? + if !field_hash["args"].empty? loader.build_arguments(self, field_hash["args"], type_resolver) end end @@ -199,7 +201,6 @@ def build_arguments(arg_owner, args, type_resolver) description: arg["description"], deprecation_reason: arg["deprecationReason"], required: false, - method_access: false, camelize: false, } diff --git a/lib/graphql/schema/member.rb b/lib/graphql/schema/member.rb index c8a191e7e21..11c15cb858e 100644 --- a/lib/graphql/schema/member.rb +++ b/lib/graphql/schema/member.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true -require 'graphql/schema/member/accepts_definition' require 'graphql/schema/member/base_dsl_methods' -require 'graphql/schema/member/cached_graphql_definition' require 'graphql/schema/member/graphql_type_names' require 'graphql/schema/member/has_ast_node' +require 'graphql/schema/member/has_authorization' +require 'graphql/schema/member/has_dataloader' require 'graphql/schema/member/has_directives' require 'graphql/schema/member/has_deprecation_reason' +require 'graphql/schema/member/has_interfaces' require 'graphql/schema/member/has_path' require 'graphql/schema/member/has_unresolved_type_error' require 'graphql/schema/member/has_validators' @@ -13,7 +14,6 @@ require 'graphql/schema/member/scoped' require 'graphql/schema/member/type_system_helpers' require 'graphql/schema/member/validates_input' -require "graphql/relay/type_extensions" module GraphQL class Schema @@ -23,8 +23,6 @@ class Schema # @api private class Member include GraphQLTypeNames - extend CachedGraphQLDefinition - extend GraphQL::Relay::TypeExtensions extend BaseDSLMethods extend BaseDSLMethods::ConfigurationExtension introspection(false) @@ -34,11 +32,14 @@ class Member extend HasPath extend HasAstNode extend HasDirectives + + def self.authorizes?(_ctx) + false + end end end end require 'graphql/schema/member/has_arguments' require 'graphql/schema/member/has_fields' -require 'graphql/schema/member/instrumentation' require 'graphql/schema/member/build_type' diff --git a/lib/graphql/schema/member/accepts_definition.rb b/lib/graphql/schema/member/accepts_definition.rb deleted file mode 100644 index e450a7b6838..00000000000 --- a/lib/graphql/schema/member/accepts_definition.rb +++ /dev/null @@ -1,152 +0,0 @@ -# frozen_string_literal: true - -module GraphQL - class Schema - class Member - # Support for legacy `accepts_definitions` functions. - # - # Keep the legacy handler hooked up. Class-based types and fields - # will call those legacy handlers during their `.to_graphql` - # methods. - # - # This can help out while transitioning from one to the other. - # Eventually, `GraphQL::{X}Type` objects will be removed entirely, - # But this can help during the transition. - # - # @example Applying a function to base object class - # # Here's the legacy-style config, which we're calling back to: - # GraphQL::ObjectType.accepts_definition({ - # permission_level: ->(defn, value) { defn.metadata[:permission_level] = value } - # }) - # - # class BaseObject < GraphQL::Schema::Object - # # Setup a named pass-through to the legacy config functions - # accepts_definition :permission_level - # end - # - # class Account < BaseObject - # # This value will be passed to the legacy handler: - # permission_level 1 - # end - # - # # The class gets a reader method which returns the args, - # # only marginally useful. - # Account.permission_level # => [1] - # - # # The legacy handler is called, as before: - # Account.graphql_definition.metadata[:permission_level] # => 1 - module AcceptsDefinition - def self.included(child) - child.extend(AcceptsDefinitionDefinitionMethods) - child.prepend(ToGraphQLExtension) - child.prepend(InitializeExtension) - end - - def self.extended(child) - if defined?(child::DefinitionMethods) - child::DefinitionMethods.include(AcceptsDefinitionDefinitionMethods) - child::DefinitionMethods.prepend(ToGraphQLExtension) - else - child.extend(AcceptsDefinitionDefinitionMethods) - # I tried to use `super`, but super isn't quite right - # since the method is defined in the same class itself, - # not the superclass - child.class_eval do - class << self - prepend(ToGraphQLExtension) - end - end - end - end - - module AcceptsDefinitionDefinitionMethods - def accepts_definition(name) - own_accepts_definition_methods << name - - ivar_name = "@#{name}_args" - if self.is_a?(Class) - define_singleton_method(name) do |*args| - if args.any? - instance_variable_set(ivar_name, args) - end - instance_variable_get(ivar_name) || (superclass.respond_to?(name) ? superclass.public_send(name) : nil) - end - - define_method(name) do |*args| - if args.any? - instance_variable_set(ivar_name, args) - end - instance_variable_get(ivar_name) - end - else - # Special handling for interfaces, define it here - # so it's appropriately passed down - self::DefinitionMethods.module_eval do - define_method(name) do |*args| - if args.any? - instance_variable_set(ivar_name, args) - end - instance_variable_get(ivar_name) || ((int = interfaces.first { |i| i.respond_to?()}) && int.public_send(name)) - end - end - end - end - - def accepts_definition_methods - inherited_methods = if self.is_a?(Class) - superclass.respond_to?(:accepts_definition_methods) ? superclass.accepts_definition_methods : [] - elsif self.is_a?(Module) - m = [] - ancestors.each do |a| - if a.respond_to?(:own_accepts_definition_methods) - m.concat(a.own_accepts_definition_methods) - end - end - m - else - self.class.accepts_definition_methods - end - - own_accepts_definition_methods + inherited_methods - end - - def own_accepts_definition_methods - @own_accepts_definition_methods ||= [] - end - end - - module ToGraphQLExtension - def to_graphql - defn = super - accepts_definition_methods.each do |method_name| - value = public_send(method_name) - if !value.nil? - defn = defn.redefine { public_send(method_name, *value) } - end - end - defn - end - end - - module InitializeExtension - def initialize(*args, **kwargs, &block) - self.class.accepts_definition_methods.each do |method_name| - if kwargs.key?(method_name) - value = kwargs.delete(method_name) - if !value.is_a?(Array) - value = [value] - end - instance_variable_set("@#{method_name}_args", value) - end - end - super(*args, **kwargs, &block) - end - - def accepts_definition_methods - self.class.accepts_definition_methods - end - end - end - end - end -end diff --git a/lib/graphql/schema/member/base_dsl_methods.rb b/lib/graphql/schema/member/base_dsl_methods.rb index 938bd16205c..ce91290004e 100644 --- a/lib/graphql/schema/member/base_dsl_methods.rb +++ b/lib/graphql/schema/member/base_dsl_methods.rb @@ -22,24 +22,10 @@ def graphql_name(new_name = nil) GraphQL::NameValidator.validate!(new_name) @graphql_name = new_name else - overridden_graphql_name || default_graphql_name + @graphql_name ||= default_graphql_name end end - def overridden_graphql_name - defined?(@graphql_name) ? @graphql_name : nil - end - - # Just a convenience method to point out that people should use graphql_name instead - def name(new_name = nil) - return super() if new_name.nil? - - fail( - "The new name override method is `graphql_name`, not `name`. Usage: "\ - "graphql_name \"#{new_name}\"" - ) - end - # Call this method to provide a new description; OR # call it without an argument to get the description # @param new_description [String] @@ -49,6 +35,20 @@ def description(new_description = nil) @description = new_description elsif defined?(@description) @description + else + @description = nil + end + end + + # Call this method to provide a new comment; OR + # call it without an argument to get the comment + # @param new_comment [String] + # @return [String, nil] + def comment(new_comment = NOT_CONFIGURED) + if !NOT_CONFIGURED.equal?(new_comment) + @comment = new_comment + elsif defined?(@comment) + @comment else nil end @@ -60,8 +60,13 @@ module ConfigurationExtension def inherited(child_class) child_class.introspection(introspection) child_class.description(description) - if overridden_graphql_name - child_class.graphql_name(overridden_graphql_name) + child_class.comment(nil) + child_class.default_graphql_name = nil + + if defined?(@graphql_name) && @graphql_name && (self.name.nil? || graphql_name != default_graphql_name) + child_class.graphql_name(graphql_name) + else + child_class.graphql_name = nil end super end @@ -79,7 +84,7 @@ def introspection(new_introspection = nil) end def introspection? - introspection + !!@introspection end # The mutation this type was derived from, if it was derived from a mutation @@ -94,11 +99,6 @@ def mutation(mutation_class = nil) end end - # @return [GraphQL::BaseType] Convert this type to a legacy-style object. - def to_graphql - raise GraphQL::RequiredImplementationMissingError - end - alias :unwrap :itself # Creates the default name for a schema member. @@ -107,8 +107,8 @@ def to_graphql def default_graphql_name @default_graphql_name ||= begin raise GraphQL::RequiredImplementationMissingError, 'Anonymous class should declare a `graphql_name`' if name.nil? - - name.split("::").last.sub(/Type\Z/, "") + g_name = -name.split("::").last + g_name.end_with?("Type") ? g_name.sub(/Type\Z/, "") : g_name end end @@ -116,13 +116,17 @@ def visible?(context) true end - def accessible?(context) + def authorized?(object, context) true end - def authorized?(object, context) - true + def default_relay? + false end + + protected + + attr_writer :default_graphql_name, :graphql_name end end end diff --git a/lib/graphql/schema/member/build_type.rb b/lib/graphql/schema/member/build_type.rb index cda959b2c11..3f8dcec16b4 100644 --- a/lib/graphql/schema/member/build_type.rb +++ b/lib/graphql/schema/member/build_type.rb @@ -4,10 +4,6 @@ class Schema class Member # @api private module BuildType - if !String.method_defined?(:match?) - using GraphQL::StringMatchBackport - end - LIST_TYPE_ERROR = "Use an array of [T] or [T, null: true] for list types; other arrays are not supported" module_function @@ -39,18 +35,16 @@ def parse_type(type_expr, null:) else maybe_type = constantize(type_expr) case maybe_type - when GraphQL::BaseType - maybe_type when Module # This is a way to check that it's the right kind of module: - if maybe_type.respond_to?(:graphql_definition) + if maybe_type.respond_to?(:kind) maybe_type else raise ArgumentError, "Unexpected class/module found for GraphQL type: #{type_expr} (must be type definition class/module)" end end end - when GraphQL::BaseType, GraphQL::Schema::LateBoundType + when GraphQL::Schema::LateBoundType type_expr when Array case type_expr.length @@ -72,7 +66,7 @@ def parse_type(type_expr, null:) type_expr when Module # This is a way to check that it's the right kind of module: - if type_expr.respond_to?(:graphql_definition) + if type_expr.respond_to?(:kind) type_expr else # Eg `String` => GraphQL::Types::String @@ -104,7 +98,7 @@ def parse_type(type_expr, null:) def to_type_name(something) case something - when GraphQL::BaseType, GraphQL::Schema::LateBoundType + when GraphQL::Schema::LateBoundType something.unwrap.name when Array to_type_name(something.first) @@ -115,7 +109,14 @@ def to_type_name(something) to_type_name(something.name) end when String - something.gsub(/\]\[\!/, "").split("::").last + if something.include?("]") || + something.include?("[") || + something.include?("!") || + something.include?("::") + something.gsub(/\]\[\!/, "").split("::").last + else + something + end when GraphQL::Schema::NonNull, GraphQL::Schema::List to_type_name(something.unwrap) else @@ -126,9 +127,10 @@ def to_type_name(something) def camelize(string) return string if string == '_' return string unless string.include?("_") - camelized = string.split('_').map(&:capitalize).join + camelized = string.split('_').each(&:capitalize!).join camelized[0] = camelized[0].downcase - if (match_data = string.match(/\A(_+)/)) + if string.start_with?("_") + match_data = string.match(/\A(_+)/) camelized = "#{match_data[0]}#{camelized}" end camelized diff --git a/lib/graphql/schema/member/cached_graphql_definition.rb b/lib/graphql/schema/member/cached_graphql_definition.rb deleted file mode 100644 index 1122900739f..00000000000 --- a/lib/graphql/schema/member/cached_graphql_definition.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module GraphQL - class Schema - class Member - # Adds a layer of caching over user-supplied `.to_graphql` methods. - # Users override `.to_graphql`, but all runtime code should use `.graphql_definition`. - # @api private - # @see concrete classes that extend this, eg {Schema::Object} - module CachedGraphQLDefinition - # A cached result of {.to_graphql}. - # It's cached here so that user-overridden {.to_graphql} implementations - # are also cached - def graphql_definition - @graphql_definition ||= to_graphql - end - - # This is for a common interface with .define-based types - def type_class - self - end - - # Wipe out the cached graphql_definition so that `.to_graphql` will be called again. - def initialize_copy(original) - super - @graphql_definition = nil - end - end - end - end -end diff --git a/lib/graphql/schema/member/has_arguments.rb b/lib/graphql/schema/member/has_arguments.rb index 2d8f39571c5..98b36a8de6e 100644 --- a/lib/graphql/schema/member/has_arguments.rb +++ b/lib/graphql/schema/member/has_arguments.rb @@ -11,32 +11,57 @@ def self.included(cls) def self.extended(cls) cls.extend(ArgumentClassAccessor) cls.include(ArgumentObjectLoader) + cls.extend(ClassConfigured) end - # @see {GraphQL::Schema::Argument#initialize} for parameters - # @return [GraphQL::Schema::Argument] An instance of {arguments_class}, created from `*args` - def argument(*args, **kwargs, &block) - kwargs[:owner] = self - loads = kwargs[:loads] - if loads - name = args[0] - name_as_string = name.to_s + # @param arg_name [Symbol] The underscore-cased name of this argument, `name:` keyword also accepted + # @param type_expr The GraphQL type of this argument; `type:` keyword also accepted + # @param desc [String] Argument description, `description:` keyword also accepted + # @option kwargs [Boolean, :nullable] :required if true, this argument is non-null; if false, this argument is nullable. If `:nullable`, then the argument must be provided, though it may be `null`. + # @option kwargs [String] :description Positional argument also accepted + # @option kwargs [Class, Array] :type Input type; positional argument also accepted + # @option kwargs [Symbol] :name positional argument also accepted + # @option kwargs [Object] :default_value + # @option kwargs [Class, Array] :loads A GraphQL type to load for the given ID when one is present + # @option kwargs [Symbol] :as Override the keyword name when passed to a method + # @option kwargs [Symbol] :prepare A method to call to transform this argument's valuebefore sending it to field resolution + # @option kwargs [Boolean] :camelize if true, the name will be camelized when building the schema + # @option kwargs [Boolean] :from_resolver if true, a Resolver class defined this argument + # @option kwargs [Hash{Class => Hash}] :directives + # @option kwargs [String] :deprecation_reason + # @option kwargs [String] :comment Private, used by GraphQL-Ruby when parsing GraphQL schema files + # @option kwargs [GraphQL::Language::Nodes::InputValueDefinition] :ast_node Private, used by GraphQL-Ruby when parsing schema files + # @option kwargs [Hash, nil] :validates Options for building validators, if any should be applied + # @option kwargs [Boolean] :replace_null_with_default if `true`, incoming values of `null` will be replaced with the configured `default_value` + # @param definition_block [Proc] Called with the newly-created {Argument} + # @param kwargs [Hash] Keywords for defining an argument. Any keywords not documented here must be handled by your base Argument class. + # @return [GraphQL::Schema::Argument] An instance of {argument_class} created from these arguments + def argument(arg_name = nil, type_expr = nil, desc = nil, **kwargs, &definition_block) + if kwargs[:loads] + loads_name = arg_name || kwargs[:name] + loads_name_as_string = loads_name.to_s - inferred_arg_name = case name_as_string + inferred_arg_name = case loads_name_as_string when /_id$/ - name_as_string.sub(/_id$/, "").to_sym + loads_name_as_string.sub(/_id$/, "").to_sym when /_ids$/ - name_as_string.sub(/_ids$/, "") + loads_name_as_string.sub(/_ids$/, "") .sub(/([^s])$/, "\\1s") .to_sym else - name + loads_name end kwargs[:as] ||= inferred_arg_name end - arg_defn = self.argument_class.new(*args, **kwargs, &block) + kwargs[:owner] = self + arg_defn = self.argument_class.new( + arg_name, type_expr, desc, + **kwargs, + &definition_block + ) add_argument(arg_defn) + arg_defn end # Register this argument with the class. @@ -44,33 +69,175 @@ def argument(*args, **kwargs, &block) # @return [GraphQL::Schema::Argument] def add_argument(arg_defn) @own_arguments ||= {} - own_arguments[arg_defn.name] = arg_defn + prev_defn = @own_arguments[arg_defn.name] + case prev_defn + when nil + @own_arguments[arg_defn.name] = arg_defn + when Array + prev_defn << arg_defn + when GraphQL::Schema::Argument + @own_arguments[arg_defn.name] = [prev_defn, arg_defn] + else + raise "Invariant: unexpected `@own_arguments[#{arg_defn.name.inspect}]`: #{prev_defn.inspect}" + end arg_defn end - # @return [Hash GraphQL::Schema::Argument] Arguments defined on this thing, keyed by name. Includes inherited definitions - def arguments - inherited_arguments = ((self.is_a?(Class) && superclass.respond_to?(:arguments)) ? superclass.arguments : nil) - # Local definitions override inherited ones - if inherited_arguments - inherited_arguments.merge(own_arguments) + def remove_argument(arg_defn) + prev_defn = @own_arguments[arg_defn.name] + case prev_defn + when nil + # done + when Array + prev_defn.delete(arg_defn) + when GraphQL::Schema::Argument + @own_arguments.delete(arg_defn.name) else - own_arguments + raise "Invariant: unexpected `@own_arguments[#{arg_defn.name.inspect}]`: #{prev_defn.inspect}" end + nil end - # @return [GraphQL::Schema::Argument, nil] Argument defined on this thing, fetched by name. - def get_argument(argument_name) - a = own_arguments[argument_name] + # @return [Hash GraphQL::Schema::Argument] Arguments defined on this thing, keyed by name. Includes inherited definitions + def arguments(context = GraphQL::Query::NullContext.instance, _require_defined_arguments = nil) + if !own_arguments.empty? + own_arguments_that_apply = {} + own_arguments.each do |name, args_entry| + if (visible_defn = Warden.visible_entry?(:visible_argument?, args_entry, context)) + own_arguments_that_apply[visible_defn.graphql_name] = visible_defn + end + end + end + # might be nil if there are actually no arguments + own_arguments_that_apply || own_arguments + end - if a || !self.is_a?(Class) - a - else - for ancestor in ancestors - if ancestor.respond_to?(:own_arguments) && a = ancestor.own_arguments[argument_name] - return a + def any_arguments? + !own_arguments.empty? + end + + module ClassConfigured + def inherited(child_class) + super + child_class.extend(InheritedArguments) + end + + module InheritedArguments + def arguments(context = GraphQL::Query::NullContext.instance, require_defined_arguments = true) + own_arguments = super(context, require_defined_arguments) + inherited_arguments = superclass.arguments(context, false) + + if !own_arguments.empty? + if !inherited_arguments.empty? + # Local definitions override inherited ones + inherited_arguments.merge(own_arguments) + else + own_arguments + end + else + inherited_arguments + end + end + + def any_arguments? + super || superclass.any_arguments? + end + + def all_argument_definitions + all_defns = {} + ancestors.reverse_each do |ancestor| + if ancestor.respond_to?(:own_arguments) + all_defns.merge!(ancestor.own_arguments) + end + end + all_defns = all_defns.values + all_defns.flatten! + all_defns + end + + + def get_argument(argument_name, context = GraphQL::Query::NullContext.instance) + warden = Warden.from_context(context) + skip_visible = context.respond_to?(:types) && context.types.is_a?(GraphQL::Schema::Visibility::Profile) + for ancestor in ancestors + if ancestor.respond_to?(:own_arguments) && + (a = ancestor.own_arguments[argument_name]) && + (skip_visible || (a = Warden.visible_entry?(:visible_argument?, a, context, warden))) + return a + end + end + nil + end + end + end + + module FieldConfigured + def arguments(context = GraphQL::Query::NullContext.instance, _require_defined_arguments = nil) + own_arguments = super + if @resolver_class + inherited_arguments = @resolver_class.field_arguments(context) + if !own_arguments.empty? + if !inherited_arguments.empty? + inherited_arguments.merge(own_arguments) + else + own_arguments + end + else + inherited_arguments + end + else + own_arguments + end + end + + def any_arguments? + super || (@resolver_class && @resolver_class.any_field_arguments?) + end + + def all_argument_definitions + if @resolver_class + all_defns = {} + @resolver_class.all_field_argument_definitions.each do |arg_defn| + key = arg_defn.graphql_name + case (current_value = all_defns[key]) + when nil + all_defns[key] = arg_defn + when Array + current_value << arg_defn + when GraphQL::Schema::Argument + all_defns[key] = [current_value, arg_defn] + else + raise "Invariant: Unexpected argument definition, #{current_value.class}: #{current_value.inspect}" + end end + all_defns.merge!(own_arguments) + all_defns = all_defns.values + all_defns.flatten! + all_defns + else + super end + end + end + + def all_argument_definitions + if !own_arguments.empty? + all_defns = own_arguments.values + all_defns.flatten! + all_defns + else + EmptyObjects::EMPTY_ARRAY + end + end + + # @return [GraphQL::Schema::Argument, nil] Argument defined on this thing, fetched by name. + def get_argument(argument_name, context = GraphQL::Query::NullContext.instance) + warden = Warden.from_context(context) + if (arg_config = own_arguments[argument_name]) && ((context.respond_to?(:types) && context.types.is_a?(GraphQL::Schema::Visibility::Profile)) || (visible_arg = Warden.visible_entry?(:visible_argument?, arg_config, context, warden))) + visible_arg || arg_config + elsif defined?(@resolver_class) && @resolver_class + @resolver_class.get_field_argument(argument_name, context) + else nil end end @@ -87,61 +254,59 @@ def argument_class(new_arg_class = nil) # # @param values [Hash] # @param context [GraphQL::Query::Context] - # @yield [Interpreter::Arguments, Execution::Lazy] - # @return [Interpreter::Arguments, Execution::Lazy] + # @yield [Interpreter::Arguments, Execution::Lazy] + # @return [Interpreter::Arguments, Execution::Lazy] def coerce_arguments(parent_object, values, context, &block) # Cache this hash to avoid re-merging it - arg_defns = self.arguments + arg_defns = context.types.arguments(self) total_args_count = arg_defns.size - if total_args_count == 0 - final_args = GraphQL::Execution::Interpreter::Arguments::EMPTY - if block_given? - block.call(final_args) - nil + finished_args = nil + prepare_finished_args = -> { + if total_args_count == 0 + finished_args = GraphQL::Execution::Interpreter::Arguments::EMPTY + if block_given? + block.call(finished_args) + end else - final_args - end - else - finished_args = nil - argument_values = {} - resolved_args_count = 0 - raised_error = false - arg_defns.each do |arg_name, arg_defn| - context.dataloader.append_job do - begin - arg_defn.coerce_into_values(parent_object, values, context, argument_values) - rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => err - raised_error = true - if block_given? - block.call(err) - else + argument_values = {} + resolved_args_count = 0 + raised_error = false + arg_defns.each do |arg_defn| + context.dataloader.append_job do + begin + arg_defn.coerce_into_values(parent_object, values, context, argument_values) + rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => err + raised_error = true finished_args = err + if block_given? + block.call(finished_args) + end end - end - resolved_args_count += 1 - if resolved_args_count == total_args_count && !raised_error - finished_args = context.schema.after_any_lazies(argument_values.values) { - GraphQL::Execution::Interpreter::Arguments.new( - argument_values: argument_values, - ) - } - - if block_given? - block.call(finished_args) + resolved_args_count += 1 + if resolved_args_count == total_args_count && !raised_error + finished_args = context.schema.after_any_lazies(argument_values.values) { + GraphQL::Execution::Interpreter::Arguments.new( + argument_values: argument_values, + ) + } + if block_given? + block.call(finished_args) + end end end end end + } - if block_given? - nil - else - # This API returns eagerly, gotta run it now - context.dataloader.run - finished_args - end + if block_given? + prepare_finished_args.call + nil + else + # This API returns eagerly, gotta run it now + context.dataloader.run_isolated(&prepare_finished_args) + finished_args end end @@ -149,7 +314,12 @@ def coerce_arguments(parent_object, values, context, &block) # but not for directives. # TODO apply static validations on schema definitions? def validate_directive_argument(arg_defn, value) - if arg_defn.owner.is_a?(Class) && arg_defn.owner < GraphQL::Schema::Directive + # this is only implemented on directives. + nil + end + + module HasDirectiveArguments + def validate_directive_argument(arg_defn, value) if value.nil? && arg_defn.type.non_null? raise ArgumentError, "#{arg_defn.path} is required, but no value was given" end @@ -157,9 +327,11 @@ def validate_directive_argument(arg_defn, value) end def arguments_statically_coercible? - return @arguments_statically_coercible if defined?(@arguments_statically_coercible) - - @arguments_statically_coercible = arguments.each_value.all?(&:statically_coercible?) + if defined?(@arguments_statically_coercible) && !@arguments_statically_coercible.nil? + @arguments_statically_coercible + else + @arguments_statically_coercible = all_argument_definitions.all?(&:statically_coercible?) + end end module ArgumentClassAccessor @@ -186,55 +358,96 @@ def object_from_id(type, id, context) context.schema.object_from_id(id, context) end - def load_application_object(argument, lookup_as_type, id, context) + def load_application_object(argument, id, context) # See if any object can be found for this ID if id.nil? return nil end - loaded_application_object = object_from_id(lookup_as_type, id, context) - context.schema.after_lazy(loaded_application_object) do |application_object| + object_from_id(argument.loads, id, context) + end + + def load_and_authorize_application_object(argument, id, context) + loaded_application_object = load_application_object(argument, id, context) + authorize_application_object(argument, id, context, loaded_application_object) + end + + def authorize_application_object(argument, id, context, loaded_application_object) + context.query.after_lazy(loaded_application_object) do |application_object| if application_object.nil? - err = GraphQL::LoadApplicationObjectFailedError.new(argument: argument, id: id, object: application_object) - load_application_object_failed(err) + err = GraphQL::LoadApplicationObjectFailedError.new(context: context, argument: argument, id: id, object: application_object) + application_object = load_application_object_failed(err) end # Double-check that the located object is actually of this type # (Don't want to allow arbitrary access to objects this way) - resolved_application_object_type = context.schema.resolve_type(lookup_as_type, application_object, context) - context.schema.after_lazy(resolved_application_object_type) do |application_object_type| - possible_object_types = context.warden.possible_types(lookup_as_type) - if !possible_object_types.include?(application_object_type) - err = GraphQL::LoadApplicationObjectFailedError.new(argument: argument, id: id, object: application_object) - load_application_object_failed(err) - else - # This object was loaded successfully - # and resolved to the right type, - # now apply the `.authorized?` class method if there is one - if (class_based_type = application_object_type.type_class) - context.schema.after_lazy(class_based_type.authorized?(application_object, context)) do |authed| + if application_object.nil? + nil + else + arg_loads_type = argument.loads + maybe_lazy_resolve_type = context.schema.resolve_type(arg_loads_type, application_object, context) + context.query.after_lazy(maybe_lazy_resolve_type) do |resolve_type_result| + if resolve_type_result.is_a?(Array) && resolve_type_result.size == 2 + application_object_type, application_object = resolve_type_result + else + application_object_type = resolve_type_result + # application_object is already assigned + end + + passes_possible_types_check = if context.types.loadable?(arg_loads_type, context) + if arg_loads_type.kind.abstract? + # This union/interface is used in `loads:` but not otherwise visible to this query + context.types.loadable_possible_types(arg_loads_type, context).include?(application_object_type) + else + true + end + else + context.types.possible_types(arg_loads_type).include?(application_object_type) + end + if !passes_possible_types_check + err = GraphQL::LoadApplicationObjectFailedError.new(context: context, argument: argument, id: id, object: application_object) + application_object = load_application_object_failed(err) + end + + if application_object.nil? + nil + else + # This object was loaded successfully + # and resolved to the right type, + # now apply the `.authorized?` class method if there is one + context.query.after_lazy(application_object_type.authorized?(application_object, context)) do |authed| if authed application_object else - raise GraphQL::UnauthorizedError.new( + err = GraphQL::UnauthorizedError.new( object: application_object, - type: class_based_type, + type: application_object_type, context: context, ) + if self.respond_to?(:unauthorized_object) + err.set_backtrace(caller) + unauthorized_object(err) + else + raise err + end end end - else - application_object end end end end end + # Called when an argument's `loads:` configuration fails to fetch an application object. + # By default, this method raises the given error, but you can override it to handle failures differently. + # + # @param err [GraphQL::LoadApplicationObjectFailedError] The error that occurred + # @return [Object, nil] If a value is returned, it will be used instead of the failed load + # @api public def load_application_object_failed(err) raise err end end - NO_ARGUMENTS = {}.freeze + NO_ARGUMENTS = GraphQL::EmptyObjects::EMPTY_HASH def own_arguments @own_arguments || NO_ARGUMENTS end diff --git a/lib/graphql/schema/member/has_ast_node.rb b/lib/graphql/schema/member/has_ast_node.rb index 796a779def8..2457a6a7281 100644 --- a/lib/graphql/schema/member/has_ast_node.rb +++ b/lib/graphql/schema/member/has_ast_node.rb @@ -3,6 +3,16 @@ module GraphQL class Schema class Member module HasAstNode + def self.extended(child_cls) + super + child_cls.ast_node = nil + end + + def inherited(child_cls) + super + child_cls.ast_node = nil + end + # If this schema was parsed from a `.graphql` file (or other SDL), # this is the AST node that defined this part of the schema. def ast_node(new_ast_node = nil) @@ -14,6 +24,8 @@ def ast_node(new_ast_node = nil) nil end end + + attr_writer :ast_node end end end diff --git a/lib/graphql/schema/member/has_authorization.rb b/lib/graphql/schema/member/has_authorization.rb new file mode 100644 index 00000000000..3e315ff5c90 --- /dev/null +++ b/lib/graphql/schema/member/has_authorization.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true +module GraphQL + class Schema + class Member + module HasAuthorization + def self.included(child_class) + child_class.include(InstanceConfigured) + end + + def self.extended(child_class) + child_class.extend(ClassConfigured) + child_class.class_exec do + @authorizes = false + end + end + + def authorized?(object, context) + true + end + + module InstanceConfigured + def authorizes?(context) + raise RequiredImplementationMissingError, "#{self.class} must implement #authorizes?(context)" + end + end + + module ClassConfigured + def authorizes?(context) + method(:authorized?).owner != GraphQL::Schema::Member::HasAuthorization + end + end + end + end + end +end diff --git a/lib/graphql/schema/member/has_dataloader.rb b/lib/graphql/schema/member/has_dataloader.rb new file mode 100644 index 00000000000..fb50b18273b --- /dev/null +++ b/lib/graphql/schema/member/has_dataloader.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module GraphQL + class Schema + class Member + # @api public + # Shared methods for working with {Dataloader} inside GraphQL runtime objects. + module HasDataloader + # @return [GraphQL::Dataloader] The dataloader for the currently-running query + def dataloader + context.dataloader + end + + # A shortcut method for loading a key from a source. + # Identical to `dataloader.with(source_class, *source_args).load(load_key)` + # @param source_class [Class] + # @param source_args [Array] Any extra parameters defined in `source_class`'s `initialize` method + # @param load_key [Object] The key to look up using `def fetch` + def dataload(source_class, *source_args, load_key) + dataloader.with(source_class, *source_args).load(load_key) + end + + # A shortcut method for loading many keys from a source. + # Identical to `dataloader.with(source_class, *source_args).load_all(load_keys)` + # + # @example + # field :score, Integer, resolve_batch: true + # + # def self.score(posts) + # dataload_all(PostScoreSource, posts.map(&:id)) + # end + # + # @param source_class [Class] + # @param source_args [Array] Any extra parameters defined in `source_class`'s `initialize` method + # @param load_keys [Array] The keys to look up using `def fetch` + def dataload_all(source_class, *source_args, load_keys) + dataloader.with(source_class, *source_args).load_all(load_keys) + end + + # Find an object with ActiveRecord via {Dataloader::ActiveRecordSource}. + # @param model [Class] + # @param find_by_value [Object] Usually an `id`, might be another value if `find_by:` is also provided + # @param find_by [Symbol, String] A column name to look the record up by. (Defaults to the model's primary key.) + # @return [ActiveRecord::Base, nil] + # @example Finding a record by ID + # dataload_record(Post, 5) # Like `Post.find(5)`, but dataloaded + # @example Finding a record by another attribute + # dataload_record(User, "matz", find_by: :handle) # Like `User.find_by(handle: "matz")`, but dataloaded + def dataload_record(model, find_by_value, find_by: nil) + source = if find_by + dataloader.with(Dataloader::ActiveRecordSource, model, find_by: find_by) + else + dataloader.with(Dataloader::ActiveRecordSource, model) + end + + source.load(find_by_value) + end + + # @see dataload_record Like `dataload_record`, but accepts an Array of `find_by_values` + def dataload_all_records(model, find_by_values, find_by: nil) + source = if find_by + dataloader.with(Dataloader::ActiveRecordSource, model, find_by: find_by) + else + dataloader.with(Dataloader::ActiveRecordSource, model) + end + source.load_all(find_by_values) + end + + # Look up an associated record using a Rails association (via {Dataloader::ActiveRecordAssociationSource}) + # @param association_name [Symbol] A `belongs_to` or `has_one` association. (If a `has_many` association is named here, it will be selected without pagination.) + # @param record [ActiveRecord::Base] The object that the association belongs to. + # @param scope [ActiveRecord::Relation] A scope to look up the associated record in + # @return [ActiveRecord::Base, nil] The associated record, if there is one + # @example Looking up a belongs_to on the current object + # dataload_association(:parent) # Equivalent to `object.parent`, but dataloaded + # @example Looking up an associated record on some other object + # dataload_association(comment, :post) # Equivalent to `comment.post`, but dataloaded + def dataload_association(record = object, association_name, scope: nil) + source = if scope + dataloader.with(Dataloader::ActiveRecordAssociationSource, association_name, scope) + else + dataloader.with(Dataloader::ActiveRecordAssociationSource, association_name) + end + source.load(record) + end + + # @see dataload_association Like `dataload_assocation` but accepts an Array of records (required param) + def dataload_all_associations(records, association_name, scope: nil) + source = if scope + dataloader.with(Dataloader::ActiveRecordAssociationSource, association_name, scope) + else + dataloader.with(Dataloader::ActiveRecordAssociationSource, association_name) + end + source.load_all(records) + end + end + end + end +end diff --git a/lib/graphql/schema/member/has_deprecation_reason.rb b/lib/graphql/schema/member/has_deprecation_reason.rb index c4af76e2794..8c98f61ab92 100644 --- a/lib/graphql/schema/member/has_deprecation_reason.rb +++ b/lib/graphql/schema/member/has_deprecation_reason.rb @@ -5,20 +5,34 @@ class Schema class Member module HasDeprecationReason # @return [String, nil] Explains why this member was deprecated (if present, this will be marked deprecated in introspection) - def deprecation_reason - dir = self.directives.find { |d| d.is_a?(GraphQL::Schema::Directive::Deprecated) } - dir && dir.arguments[:reason] - end + attr_reader :deprecation_reason # Set the deprecation reason for this member, or remove it by assigning `nil` # @param text [String, nil] def deprecation_reason=(text) + @deprecation_reason = text if text.nil? remove_directive(GraphQL::Schema::Directive::Deprecated) else + # This removes a previously-attached directive, if there is one: directive(GraphQL::Schema::Directive::Deprecated, reason: text) end end + + def self.extended(child_class) + super + child_class.extend(ClassMethods) + end + + module ClassMethods + def deprecation_reason(new_reason = NOT_CONFIGURED) + if NOT_CONFIGURED.equal?(new_reason) + super() + else + self.deprecation_reason = new_reason + end + end + end end end end diff --git a/lib/graphql/schema/member/has_directives.rb b/lib/graphql/schema/member/has_directives.rb index 11e3daf17a0..651c5cdfb7f 100644 --- a/lib/graphql/schema/member/has_directives.rb +++ b/lib/graphql/schema/member/has_directives.rb @@ -4,6 +4,16 @@ module GraphQL class Schema class Member module HasDirectives + def self.extended(child_cls) + super + child_cls.module_exec { self.own_directives = nil } + end + + def inherited(child_cls) + super + child_cls.own_directives = nil + end + # Create an instance of `dir_class` for `self`, using `options`. # # It removes a previously-attached instance of `dir_class`, if there is one. @@ -11,8 +21,7 @@ module HasDirectives # @return [void] def directive(dir_class, **options) @own_directives ||= [] - remove_directive(dir_class) - @own_directives << dir_class.new(self, **options) + HasDirectives.add_directive(self, @own_directives, dir_class, options) nil end @@ -20,78 +29,89 @@ def directive(dir_class, **options) # @param dir_class [Class] # @return [viod] def remove_directive(dir_class) - @own_directives && @own_directives.reject! { |d| d.is_a?(dir_class) } + HasDirectives.remove_directive(@own_directives, dir_class) nil end - NO_DIRECTIVES = [].freeze - def directives - case self - when Class - inherited_directives = if superclass.respond_to?(:directives) - superclass.directives - else - NO_DIRECTIVES - end - if inherited_directives.any? && @own_directives - dirs = [] - merge_directives(dirs, inherited_directives) - merge_directives(dirs, @own_directives) - dirs - elsif @own_directives - @own_directives - elsif inherited_directives.any? - inherited_directives - else - NO_DIRECTIVES - end - when Module - dirs = nil - self.ancestors.reverse_each do |ancestor| - if ancestor.respond_to?(:own_directives) && - (anc_dirs = ancestor.own_directives).any? + HasDirectives.get_directives(self, @own_directives, :directives) + end + + class << self + def add_directive(schema_member, directives, directive_class, directive_options) + remove_directive(directives, directive_class) unless directive_class.repeatable? + directives << directive_class.new(schema_member, **directive_options) + end + + def remove_directive(directives, directive_class) + directives && directives.reject! { |d| d.is_a?(directive_class) } + end + + def get_directives(schema_member, directives, directives_method) + case schema_member + when Class + inherited_directives = if schema_member.superclass.respond_to?(directives_method) + get_directives(schema_member.superclass, schema_member.superclass.public_send(directives_method), directives_method) + else + GraphQL::EmptyObjects::EMPTY_ARRAY + end + if !inherited_directives.empty? && directives + dirs = [] + merge_directives(dirs, inherited_directives) + merge_directives(dirs, directives) + dirs + elsif directives + directives + elsif !inherited_directives.empty? + inherited_directives + else + GraphQL::EmptyObjects::EMPTY_ARRAY + end + when Module + dirs = nil + schema_member.ancestors.reverse_each do |ancestor| + if ancestor.respond_to?(:own_directives) && + !(anc_dirs = ancestor.own_directives).empty? + dirs ||= [] + merge_directives(dirs, anc_dirs) + end + end + if directives dirs ||= [] - merge_directives(dirs, anc_dirs) + merge_directives(dirs, directives) end + dirs || GraphQL::EmptyObjects::EMPTY_ARRAY + when HasDirectives + directives || GraphQL::EmptyObjects::EMPTY_ARRAY + else + raise "Invariant: how could #{schema_member} not be a Class, Module, or instance of HasDirectives?" end - if own_directives - dirs ||= [] - merge_directives(dirs, own_directives) - end - dirs || NO_DIRECTIVES - when HasDirectives - @own_directives || NO_DIRECTIVES - else - raise "Invariant: how could #{self} not be a Class, Module, or instance of HasDirectives?" end - end - - protected - - def own_directives - @own_directives - end - private + private - # Modify `target` by adding items from `dirs` such that: - # - Any name conflict is overriden by the incoming member of `dirs` - # - Any other member of `dirs` is appended - # @param target [Array] - # @param dirs [Array] - # @return [void] - def merge_directives(target, dirs) - dirs.each do |dir| - if (idx = target.find_index { |d| d.graphql_name == dir.graphql_name }) - target.slice!(idx) - target.insert(idx, dir) - else - target << dir + # Modify `target` by adding items from `dirs` such that: + # - Any name conflict is overridden by the incoming member of `dirs` + # - Any other member of `dirs` is appended + # @param target [Array] + # @param dirs [Array] + # @return [void] + def merge_directives(target, dirs) + dirs.each do |dir| + if (idx = target.find_index { |d| d.graphql_name == dir.graphql_name }) + target.slice!(idx) + target.insert(idx, dir) + else + target << dir + end end + nil end - nil end + + protected + + attr_accessor :own_directives end end end diff --git a/lib/graphql/schema/member/has_fields.rb b/lib/graphql/schema/member/has_fields.rb index 6c76ea0c10c..6e328a70e60 100644 --- a/lib/graphql/schema/member/has_fields.rb +++ b/lib/graphql/schema/member/has_fields.rb @@ -3,40 +3,92 @@ module GraphQL class Schema class Member - # Shared code for Object and Interface + # Shared code for Objects, Interfaces, Mutations, Subscriptions module HasFields + include EmptyObjects # Add a field to this object or interface with the given definition - # @see {GraphQL::Schema::Field#initialize} for method signature + # @param name_positional [Symbol] The underscore-cased version of this field name (will be camelized for the GraphQL API); `name:` keyword is also accepted + # @param type_positional [Class, GraphQL::BaseType, Array] The return type of this field; `type:` keyword is also accepted + # @param desc_positional [String] Field description; `description:` keyword is also accepted + # @option kwargs [Symbol] :name The underscore-cased version of this field name (will be camelized for the GraphQL API); positional argument also accepted + # @option kwargs [Class, GraphQL::BaseType, Array] :type The return type of this field; positional argument is also accepted + # @option kwargs [Boolean] :null (defaults to `true`) `true` if this field may return `null`, `false` if it is never `null` + # @option kwargs [String] :description Field description; positional argument also accepted + # @option kwargs [String] :comment Field comment + # @option kwargs [String] :deprecation_reason If present, the field is marked "deprecated" with this message + # @option kwargs [Symbol] :method The method to call on the underlying object to resolve this field (defaults to `name`) + # @option kwargs [String, Symbol] :hash_key The hash key to lookup on the underlying object (if its a Hash) to resolve this field (defaults to `name` or `name.to_s`) + # @option kwargs [Array] :dig The nested hash keys to lookup on the underlying hash to resolve this field using dig + # @option kwargs [Symbol, true] :resolver_method The method on the type to call to resolve this field (defaults to `name`) + # @option kwargs [Symbol, true] :resolve_static Used by {Schema.execute_next} to produce a single value, shared by all objects which resolve this field. Called on the owner type class with `context, **arguments` + # @option kwargs [Symbol, true] :resolve_batch Used by {Schema.execute_next} map `objects` to a same-sized Array of results. Called on the owner type class with `objects, context, **arguments`. + # @option kwargs [Symbol, true] :resolve_each Used by {Schema.execute_next} to get a value value for each item. Called on the owner type class with `object, context, **arguments`. + # @option kwargs [Symbol, true] :resolve_legacy_instance_method Used by {Schema.execute_next} to get a value value for each item. Calls an instance method on the object type class. + # @option kwargs [Boolean] :connection `true` if this field should get automagic connection behavior; default is to infer by `*Connection` in the return type name + # @option kwargs [Class] :connection_extension The extension to add, to implement connections. If `nil`, no extension is added. + # @option kwargs [Integer, nil] :max_page_size For connections, the maximum number of items to return from this field, or `nil` to allow unlimited results. + # @option kwargs [Integer, nil] :default_page_size For connections, the default number of items to return from this field, or `nil` to return unlimited results. + # @option kwargs [Boolean] :introspection If true, this field will be marked as `#introspection?` and the name may begin with `__` + # @option kwargs [{String=>GraphQL::Schema::Argument, Hash}] :arguments Arguments for this field (may be added in the block, also) + # @option kwargs [Boolean] :camelize If true, the field name will be camelized when building the schema + # @option kwargs [Numeric] :complexity When provided, set the complexity for this field + # @option kwargs [Boolean] :scope If true, the return type's `.scope_items` method will be called on the return value + # @option kwargs [Symbol, String] :subscription_scope A key in `context` which will be used to scope subscription payloads + # @option kwargs [Array Object>>] :extensions Named extensions to apply to this field (see also {#extension}) + # @option kwargs [Hash{Class => Hash}] :directives Directives to apply to this field + # @option kwargs [Boolean] :trace If true, a {GraphQL::Tracing} tracer will measure this scalar field + # @option kwargs [Boolean] :broadcastable Whether or not this field can be distributed in subscription broadcasts + # @option kwargs [Language::Nodes::FieldDefinition, nil] :ast_node If this schema was parsed from definition, this AST node defined the field + # @option kwargs [Boolean] :method_conflict_warning If false, skip the warning if this field's method conflicts with a built-in method + # @option kwargs [Array] :validates Configurations for validating this field + # @option kwargs [Object] :fallback_value A fallback value if the method is not defined + # @option kwargs [Class] :mutation + # @option kwargs [Class] :resolver + # @option kwargs [Class] :subscription + # @option kwargs [Boolean] :dynamic_introspection (Private, used by GraphQL-Ruby) + # @option kwargs [Boolean] :relay_node_field (Private, used by GraphQL-Ruby) + # @option kwargs [Boolean] :relay_nodes_field (Private, used by GraphQL-Ruby) + # @option kwargs [Class, Hash] :dataload Shorthand for dataloader lookups + # @option kwargs [Array<:ast_node, :parent, :lookahead, :owner, :execution_errors, :graphql_name, :argument_details, Symbol>] :extras Extra arguments to be injected into the resolver for this field + # @param kwargs [Hash] Keywords for defining the field. Any not documented here will be passed to your base field class where they must be handled. + # @param definition_block [Proc] an additional block for configuring the field. Receive the field as a block param, or, if no block params are defined, then the block is `instance_eval`'d on the new {Field}. + # @yieldparam field [GraphQL::Schema::Field] The newly-created field instance + # @yieldreturn [void] # @return [GraphQL::Schema::Field] - def field(*args, **kwargs, &block) - field_defn = field_class.from_options(*args, owner: self, **kwargs, &block) - add_field(field_defn) - field_defn - end - - # @return [Hash GraphQL::Schema::Field>] Fields on this object, keyed by name, including inherited fields - def fields - # Local overrides take precedence over inherited fields - all_fields = {} - ancestors.reverse_each do |ancestor| - if ancestor.respond_to?(:own_fields) - all_fields.merge!(ancestor.own_fields) - end + def field(name_positional = nil, type_positional = nil, desc_positional = nil, **kwargs, &definition_block) + resolver = kwargs.delete(:resolver) + mutation = kwargs.delete(:mutation) + subscription = kwargs.delete(:subscription) + if (resolver_class = resolver || mutation || subscription) + # Add a reference to that parent class + kwargs[:resolver_class] = resolver_class end - all_fields - end - def get_field(field_name) - if (f = own_fields[field_name]) - f - else - for ancestor in ancestors - if ancestor.respond_to?(:own_fields) && f = ancestor.own_fields[field_name] - return f + kwargs[:name] ||= name_positional + if !type_positional.nil? + if desc_positional + if kwargs[:description] + raise ArgumentError, "Provide description as a positional argument or `description:` keyword, but not both (#{desc_positional.inspect}, #{kwargs[:description].inspect})" end + + kwargs[:description] = desc_positional + kwargs[:type] = type_positional + elsif (resolver || mutation) && type_positional.is_a?(String) + # The return type should be copied from the resolver, and the second positional argument is the description + kwargs[:description] = type_positional + else + kwargs[:type] = type_positional + end + + if type_positional.is_a?(Class) && type_positional < GraphQL::Schema::Mutation + raise ArgumentError, "Use `field #{name_positional.inspect}, mutation: Mutation, ...` to provide a mutation to this field instead" end - nil end + + kwargs[:owner] = self + field_defn = field_class.new(**kwargs, &definition_block) + add_field(field_defn) + field_defn end # A list of Ruby keywords. @@ -61,10 +113,27 @@ def get_field(field_name) def add_field(field_defn, method_conflict_warning: field_defn.method_conflict_warning?) # Check that `field_defn.original_name` equals `resolver_method` and `method_sym` -- # that shows that no override value was given manually. - if method_conflict_warning && CONFLICT_FIELD_NAMES.include?(field_defn.resolver_method) && field_defn.original_name == field_defn.resolver_method && field_defn.original_name == field_defn.method_sym + if method_conflict_warning && + CONFLICT_FIELD_NAMES.include?(field_defn.resolver_method) && + field_defn.original_name == field_defn.resolver_method && + field_defn.original_name == field_defn.method_sym && + field_defn.hash_key == NOT_CONFIGURED && + field_defn.dig_keys.nil? warn(conflict_field_name_warning(field_defn)) end - own_fields[field_defn.name] = field_defn + prev_defn = own_fields[field_defn.name] + + case prev_defn + when nil + own_fields[field_defn.name] = field_defn + when Array + prev_defn << field_defn + when GraphQL::Schema::Field + own_fields[field_defn.name] = [prev_defn, field_defn] + else + raise "Invariant: unexpected previous field definition for #{field_defn.name.inspect}: #{prev_defn.inspect}" + end + nil end @@ -80,21 +149,171 @@ def field_class(new_field_class = nil) end def global_id_field(field_name, **kwargs) - id_resolver = GraphQL::Relay::GlobalIdResolve.new(type: self) - field field_name, "ID", **kwargs, null: false + type = self + field field_name, "ID", **kwargs, null: false, resolve_each: true define_method(field_name) do - id_resolver.call(object, {}, context) + context.schema.id_from_object(object, type, context) + end + + define_singleton_method(field_name) do |object, context| + context.schema.id_from_object(object, type, context) end end - # @return [Array] Fields defined on this class _specifically_, not parent classes + # @param new_has_no_fields [Boolean] Call with `true` to make this Object type ignore the requirement to have any defined fields. + # @return [void] + def has_no_fields(new_has_no_fields) + @has_no_fields = new_has_no_fields + nil + end + + # @return [Boolean] `true` if `has_no_fields(true)` was configued + def has_no_fields? + @has_no_fields + end + + # @return [Hash GraphQL::Schema::Field, Array>] Fields defined on this class _specifically_, not parent classes def own_fields @own_fields ||= {} end + def all_field_definitions + all_fields = {} + ancestors.reverse_each do |ancestor| + if ancestor.respond_to?(:own_fields) + all_fields.merge!(ancestor.own_fields) + end + end + all_fields = all_fields.values + all_fields.flatten! + all_fields + end + + module InterfaceMethods + def get_field(field_name, context = GraphQL::Query::NullContext.instance) + warden = Warden.from_context(context) + skip_visible = context.respond_to?(:types) && context.types.is_a?(GraphQL::Schema::Visibility::Profile) + for ancestor in ancestors + if ancestor.respond_to?(:own_fields) && + (f_entry = ancestor.own_fields[field_name]) && + (skip_visible || (f_entry = Warden.visible_entry?(:visible_field?, f_entry, context, warden))) + return f_entry + end + end + nil + end + + # @return [Hash GraphQL::Schema::Field>] Fields on this object, keyed by name, including inherited fields + def fields(context = GraphQL::Query::NullContext.instance) + warden = Warden.from_context(context) + # Local overrides take precedence over inherited fields + visible_fields = {} + for ancestor in ancestors + if ancestor.respond_to?(:own_fields) + ancestor.own_fields.each do |field_name, fields_entry| + # Choose the most local definition that passes `.visible?` -- + # stop checking for fields by name once one has been found. + if !visible_fields.key?(field_name) && (f = Warden.visible_entry?(:visible_field?, fields_entry, context, warden)) + visible_fields[field_name] = f.ensure_loaded + end + end + end + end + visible_fields + end + end + + module ObjectMethods + def get_field(field_name, context = GraphQL::Query::NullContext.instance) + # Objects need to check that the interface implementation is visible, too + warden = Warden.from_context(context) + ancs = ancestors + skip_visible = context.respond_to?(:types) && context.types.is_a?(GraphQL::Schema::Visibility::Profile) + i = 0 + while (ancestor = ancs[i]) + if ancestor.respond_to?(:own_fields) && + visible_interface_implementation?(ancestor, context, warden) && + (f_entry = ancestor.own_fields[field_name]) && + (skip_visible || (f_entry = Warden.visible_entry?(:visible_field?, f_entry, context, warden))) + return (skip_visible ? f_entry : f_entry.ensure_loaded) + end + i += 1 + end + nil + end + + # @return [Hash GraphQL::Schema::Field>] Fields on this object, keyed by name, including inherited fields + def fields(context = GraphQL::Query::NullContext.instance) + # Objects need to check that the interface implementation is visible, too + warden = Warden.from_context(context) + # Local overrides take precedence over inherited fields + visible_fields = {} + had_any_fields_at_all = false + for ancestor in ancestors + if ancestor.respond_to?(:own_fields) && visible_interface_implementation?(ancestor, context, warden) + ancestor.own_fields.each do |field_name, fields_entry| + had_any_fields_at_all = true + # Choose the most local definition that passes `.visible?` -- + # stop checking for fields by name once one has been found. + if !visible_fields.key?(field_name) && (f = Warden.visible_entry?(:visible_field?, fields_entry, context, warden)) + visible_fields[field_name] = f.ensure_loaded + end + end + end + end + if !had_any_fields_at_all && !has_no_fields? + warn(GraphQL::Schema::Object::FieldsAreRequiredError.new(self).message + "\n\nThis will raise an error in a future GraphQL-Ruby version.") + end + visible_fields + end + end + + def self.included(child_class) + # Included in an interface definition methods module + child_class.include(InterfaceMethods) + super + end + + def self.extended(child_class) + child_class.extend(ObjectMethods) + super + end + private - # @param [GraphQL::Schema::Field] + def inherited(subclass) + super + subclass.class_exec do + @own_fields ||= nil + @field_class ||= nil + @has_no_fields ||= false + end + end + + # If `type` is an interface, and `self` has a type membership for `type`, then make sure it's visible. + def visible_interface_implementation?(type, context, warden) + if type.respond_to?(:kind) && type.kind.interface? + implements_this_interface = false + implementation_is_visible = false + warden.interface_type_memberships(self, context).each do |tm| + if tm.abstract_type == type + implements_this_interface ||= true + if warden.visible_type_membership?(tm, context) + implementation_is_visible = true + break + end + end + end + # It's possible this interface came by way of `include` in another interface which this + # object type _does_ implement, and that's ok + implements_this_interface ? implementation_is_visible : true + else + # If there's no implementation, then we're looking at Ruby-style inheritance instead + true + end + end + + # @param field_defn [GraphQL::Schema::Field] # @return [String] A warning to give when this field definition might conflict with a built-in method def conflict_field_name_warning(field_defn) "#{self.graphql_name}'s `field :#{field_defn.original_name}` conflicts with a built-in method, use `resolver_method:` to pick a different resolver method for this field (for example, `resolver_method: :resolve_#{field_defn.resolver_method}` and `def resolve_#{field_defn.resolver_method}`). Or use `method_conflict_warning: false` to suppress this warning." diff --git a/lib/graphql/schema/member/has_interfaces.rb b/lib/graphql/schema/member/has_interfaces.rb new file mode 100644 index 00000000000..2329f299023 --- /dev/null +++ b/lib/graphql/schema/member/has_interfaces.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module GraphQL + class Schema + class Member + module HasInterfaces + def implements(*new_interfaces, **options) + new_memberships = [] + new_interfaces.each do |int| + if int.is_a?(Module) + unless int.include?(GraphQL::Schema::Interface) && !int.is_a?(Class) + raise "#{int.respond_to?(:graphql_name) ? "#{int.graphql_name} (#{int})" : int.inspect} cannot be implemented since it's not a GraphQL Interface. Use `include` for plain Ruby modules." + end + + new_memberships << int.type_membership_class.new(int, self, **options) + + # Include the methods here, + # `.fields` will use the inheritance chain + # to find inherited fields + include(int) + + # If this interface has interfaces of its own, add those, too + int.interfaces.each do |next_interface| + implements(next_interface) + end + elsif int.is_a?(String) || int.is_a?(GraphQL::Schema::LateBoundType) + if !options.empty? + raise ArgumentError, "`implements(...)` doesn't support options with late-loaded types yet. Remove #{options} and open an issue to request this feature." + end + new_memberships << int + else + raise ArgumentError, "Unexpected interface definition (expected module): #{int} (#{int.class})" + end + end + + # Remove any String or late-bound interfaces which are being replaced + own_interface_type_memberships.reject! { |old_i_m| + if !(old_i_m.respond_to?(:abstract_type) && old_i_m.abstract_type.is_a?(Module)) + old_int_type = old_i_m.respond_to?(:abstract_type) ? old_i_m.abstract_type : old_i_m + old_name = Schema::Member::BuildType.to_type_name(old_int_type) + + new_memberships.any? { |new_i_m| + new_int_type = new_i_m.respond_to?(:abstract_type) ? new_i_m.abstract_type : new_i_m + new_name = Schema::Member::BuildType.to_type_name(new_int_type) + + new_name == old_name + } + end + } + own_interface_type_memberships.concat(new_memberships) + end + + def own_interface_type_memberships + @own_interface_type_memberships ||= [] + end + + def interface_type_memberships + own_interface_type_memberships + end + + module ClassConfigured + # This combination of extended -> inherited -> extended + # means that the base class (`Schema::Object`) *won't* + # have the superclass-related code in `InheritedInterfaces`, + # but child classes of `Schema::Object` will have it. + # That way, we don't need a `superclass.respond_to?(...)` check. + def inherited(child_class) + super + child_class.extend(InheritedInterfaces) + end + + module InheritedInterfaces + def interfaces(context = GraphQL::Query::NullContext.instance) + visible_interfaces = super + inherited_interfaces = superclass.interfaces(context) + if !visible_interfaces.empty? + if !inherited_interfaces.empty? + visible_interfaces.concat(inherited_interfaces) + visible_interfaces.uniq! + end + visible_interfaces + elsif !inherited_interfaces.empty? + inherited_interfaces + else + EmptyObjects::EMPTY_ARRAY + end + end + + def interface_type_memberships + own_tms = super + inherited_tms = superclass.interface_type_memberships + if inherited_tms.size > 0 + own_tms + inherited_tms + else + own_tms + end + end + end + end + + # param context [Query::Context] If omitted, skip filtering. + def interfaces(context = GraphQL::Query::NullContext.instance) + warden = Warden.from_context(context) + visible_interfaces = nil + own_interface_type_memberships.each do |type_membership| + case type_membership + when Schema::TypeMembership + if warden.visible_type_membership?(type_membership, context) + visible_interfaces ||= [] + visible_interfaces << type_membership.abstract_type + end + when String, Schema::LateBoundType + # During initialization, `type_memberships` can hold late-bound types + visible_interfaces ||= [] + visible_interfaces << type_membership + else + raise "Invariant: Unexpected type_membership #{type_membership.class}: #{type_membership.inspect}" + end + end + if visible_interfaces + visible_interfaces.uniq! + visible_interfaces + else + EmptyObjects::EMPTY_ARRAY + end + end + + private + + def self.extended(child_class) + child_class.extend(ClassConfigured) + end + + def inherited(subclass) + super + subclass.class_exec do + @own_interface_type_memberships ||= nil + end + end + end + end + end +end diff --git a/lib/graphql/schema/member/has_unresolved_type_error.rb b/lib/graphql/schema/member/has_unresolved_type_error.rb index 36f54df1528..a812049480d 100644 --- a/lib/graphql/schema/member/has_unresolved_type_error.rb +++ b/lib/graphql/schema/member/has_unresolved_type_error.rb @@ -7,7 +7,11 @@ class Member module HasUnresolvedTypeError private def add_unresolved_type_error(child_class) - child_class.const_set(:UnresolvedTypeError, Class.new(GraphQL::UnresolvedTypeError)) + if child_class.name # Don't set this for anonymous classes + child_class.const_set(:UnresolvedTypeError, Class.new(GraphQL::UnresolvedTypeError)) + else + child_class.const_set(:UnresolvedTypeError, UnresolvedTypeError) + end end end end diff --git a/lib/graphql/schema/member/has_validators.rb b/lib/graphql/schema/member/has_validators.rb index cf496becee8..7e877f93259 100644 --- a/lib/graphql/schema/member/has_validators.rb +++ b/lib/graphql/schema/member/has_validators.rb @@ -3,7 +3,7 @@ module GraphQL class Schema class Member module HasValidators - include Schema::FindInheritedValue::EmptyObjects + include GraphQL::EmptyObjects # Build {GraphQL::Schema::Validator}s based on the given configuration # and use them for this schema member @@ -18,12 +18,38 @@ def validates(validation_config) # @return [Array] def validators - own_validators = @own_validators || EMPTY_ARRAY - if self.is_a?(Class) && superclass.respond_to?(:validators) && (inherited_validators = superclass.validators).any? - inherited_validators + own_validators - else - own_validators + @own_validators || EMPTY_ARRAY + end + + module ClassConfigured + def inherited(child_cls) + super + child_cls.extend(ClassValidators) end + + module ClassValidators + include GraphQL::EmptyObjects + + def validators + inherited_validators = superclass.validators + if !inherited_validators.empty? + if @own_validators.nil? + inherited_validators + else + inherited_validators + @own_validators + end + elsif @own_validators.nil? + EMPTY_ARRAY + else + @own_validators + end + end + end + end + + def self.extended(child_cls) + super + child_cls.extend(ClassConfigured) end end end diff --git a/lib/graphql/schema/member/instrumentation.rb b/lib/graphql/schema/member/instrumentation.rb deleted file mode 100644 index 8a372e64086..00000000000 --- a/lib/graphql/schema/member/instrumentation.rb +++ /dev/null @@ -1,131 +0,0 @@ -# frozen_string_literal: true - -module GraphQL - class Schema - class Member - module Instrumentation - module_function - def instrument(type, field) - return_type = field.type.unwrap - if (return_type.is_a?(GraphQL::ObjectType) && return_type.metadata[:type_class]) || - return_type.is_a?(GraphQL::InterfaceType) || - (return_type.is_a?(GraphQL::UnionType) && return_type.possible_types.any? { |t| t.metadata[:type_class] }) - field = apply_proxy(field) - end - - field - end - - def before_query(query) - # Get the root type for this query - root_node = query.irep_selection - if root_node.nil? - # It's an invalid query, nothing to do here - else - root_type = query.irep_selection.return_type - # If it has a wrapper, apply it - wrapper_class = root_type.metadata[:type_class] - if wrapper_class - new_root_value = wrapper_class.authorized_new(query.root_value, query.context) - new_root_value = query.schema.sync_lazy(new_root_value) - if new_root_value.nil? - # This is definitely a hack, - # but we need some way to tell execute.rb not to run. - query.context[:__root_unauthorized] = true - end - query.root_value = new_root_value - end - end - end - - def after_query(_query) - end - - private - - module_function - - def apply_proxy(field) - resolve_proc = field.resolve_proc - lazy_resolve_proc = field.lazy_resolve_proc - inner_return_type = field.type.unwrap - depth = list_depth(field.type) - - field.redefine( - resolve: ProxiedResolve.new(inner_resolve: resolve_proc, list_depth: depth, inner_return_type: inner_return_type), - lazy_resolve: ProxiedResolve.new(inner_resolve: lazy_resolve_proc, list_depth: depth, inner_return_type: inner_return_type), - ) - end - - def list_depth(type, starting_at = 0) - case type - when GraphQL::ListType - list_depth(type.of_type, starting_at + 1) - when GraphQL::NonNullType - list_depth(type.of_type, starting_at) - else - starting_at - end - end - - class ProxiedResolve - def initialize(inner_resolve:, list_depth:, inner_return_type:) - @inner_resolve = inner_resolve - @inner_return_type = inner_return_type - @list_depth = list_depth - end - - def call(obj, args, ctx) - result = @inner_resolve.call(obj, args, ctx) - if ctx.skip == result || ctx.schema.lazy?(result) || result.nil? || execution_errors?(result) || ctx.wrapped_object - result - else - ctx.wrapped_object = true - proxy_to_depth(result, @list_depth, ctx) - end - end - - private - - def execution_errors?(result) - result.is_a?(GraphQL::ExecutionError) || - (result.is_a?(Array) && result.any? && result.all? { |v| v.is_a?(GraphQL::ExecutionError) }) - end - - def proxy_to_depth(inner_obj, depth, ctx) - if depth > 0 - inner_obj.map { |i| proxy_to_depth(i, depth - 1, ctx) } - else - ctx.schema.after_lazy(inner_obj) do |inner_obj| - if inner_obj.nil? - # For lists with nil, we need another nil check here - nil - else - concrete_type_or_lazy = case @inner_return_type - when GraphQL::UnionType, GraphQL::InterfaceType - ctx.query.resolve_type(@inner_return_type, inner_obj) - when GraphQL::ObjectType - @inner_return_type - else - raise "unexpected proxying type #{@inner_return_type} for #{inner_obj} at #{ctx.owner_type}.#{ctx.field.name}" - end - - # .resolve_type may have returned a lazy - ctx.schema.after_lazy(concrete_type_or_lazy) do |concrete_type| - if concrete_type && (object_class = concrete_type.metadata[:type_class]) - # use the query-level context here, since it won't be field-specific anyways - query_ctx = ctx.query.context - object_class.authorized_new(inner_obj, query_ctx) - else - inner_obj - end - end - end - end - end - end - end - end - end - end -end diff --git a/lib/graphql/schema/member/relay_shortcuts.rb b/lib/graphql/schema/member/relay_shortcuts.rb index cc9bc1a8ba8..a4dfccbd609 100644 --- a/lib/graphql/schema/member/relay_shortcuts.rb +++ b/lib/graphql/schema/member/relay_shortcuts.rb @@ -6,21 +6,40 @@ class Member module RelayShortcuts def edge_type_class(new_edge_type_class = nil) if new_edge_type_class + initialize_relay_metadata @edge_type_class = new_edge_type_class else - @edge_type_class || find_inherited_value(:edge_type_class, Types::Relay::BaseEdge) + # Don't call `ancestor.edge_type_class` + # because we don't want a fallback from any ancestors -- + # only apply the fallback if _no_ ancestor has a configured value! + for ancestor in self.ancestors + if ancestor.respond_to?(:configured_edge_type_class, true) && (etc = ancestor.configured_edge_type_class) + return etc + end + end + Types::Relay::BaseEdge end end def connection_type_class(new_connection_type_class = nil) if new_connection_type_class + initialize_relay_metadata @connection_type_class = new_connection_type_class else - @connection_type_class || find_inherited_value(:connection_type_class, Types::Relay::BaseConnection) + # Don't call `ancestor.connection_type_class` + # because we don't want a fallback from any ancestors -- + # only apply the fallback if _no_ ancestor has a configured value! + for ancestor in self.ancestors + if ancestor.respond_to?(:configured_connection_type_class, true) && (ctc = ancestor.configured_connection_type_class) + return ctc + end + end + Types::Relay::BaseConnection end end def edge_type + initialize_relay_metadata @edge_type ||= begin edge_name = self.graphql_name + "Edge" node_type_class = self @@ -32,6 +51,7 @@ def edge_type end def connection_type + initialize_relay_metadata @connection_type ||= begin conn_name = self.graphql_name + "Connection" edge_type_class = self.edge_type @@ -41,6 +61,31 @@ def connection_type end end end + + protected + + def configured_connection_type_class + @connection_type_class + end + + def configured_edge_type_class + @edge_type_class + end + + attr_writer :edge_type, :connection_type, :connection_type_class, :edge_type_class + + private + + # If one of these values is accessed, initialize all the instance variables to retain + # a consistent object shape. + def initialize_relay_metadata + if !defined?(@connection_type) + @connection_type = nil + @edge_type = nil + @connection_type_class = nil + @edge_type_class = nil + end + end end end end diff --git a/lib/graphql/schema/member/scoped.rb b/lib/graphql/schema/member/scoped.rb index 32b24292ce6..1e676cdbe02 100644 --- a/lib/graphql/schema/member/scoped.rb +++ b/lib/graphql/schema/member/scoped.rb @@ -15,6 +15,25 @@ module Scoped def scope_items(items, context) items end + + def reauthorize_scoped_objects(new_value = nil) + if new_value.nil? + if @reauthorize_scoped_objects != nil + @reauthorize_scoped_objects + else + find_inherited_value(:reauthorize_scoped_objects, true) + end + else + @reauthorize_scoped_objects = new_value + end + end + + def inherited(subclass) + super + subclass.class_exec do + @reauthorize_scoped_objects = nil + end + end end end end diff --git a/lib/graphql/schema/member/type_system_helpers.rb b/lib/graphql/schema/member/type_system_helpers.rb index febed0a5ad2..109325747a6 100644 --- a/lib/graphql/schema/member/type_system_helpers.rb +++ b/lib/graphql/schema/member/type_system_helpers.rb @@ -4,14 +4,34 @@ module GraphQL class Schema class Member module TypeSystemHelpers + def initialize(...) + super + @to_non_null_type ||= nil + @to_list_type ||= nil + end + # @return [Schema::NonNull] Make a non-null-type representation of this type def to_non_null_type - @to_non_null_type ||= GraphQL::Schema::NonNull.new(self) + @to_non_null_type || begin + t = GraphQL::Schema::NonNull.new(self) + if frozen? + t + else + @to_non_null_type = t + end + end end # @return [Schema::List] Make a list-type representation of this type def to_list_type - @to_list_type ||= GraphQL::Schema::List.new(self) + @to_list_type || begin + t = GraphQL::Schema::List.new(self) + if frozen? + t + else + @to_list_type = t + end + end end # @return [Boolean] true if this is a non-nullable type. A nullable list of non-nullables is considered nullable. @@ -32,6 +52,16 @@ def to_type_signature def kind raise GraphQL::RequiredImplementationMissingError, "No `.kind` defined for #{self}" end + + private + + def inherited(subclass) + subclass.class_exec do + @to_non_null_type ||= nil + @to_list_type ||= nil + end + super + end end end end diff --git a/lib/graphql/schema/member/validates_input.rb b/lib/graphql/schema/member/validates_input.rb index 2f424bf699f..80113863fe5 100644 --- a/lib/graphql/schema/member/validates_input.rb +++ b/lib/graphql/schema/member/validates_input.rb @@ -8,24 +8,24 @@ def valid_input?(val, ctx) validate_input(val, ctx).valid? end - def validate_input(val, ctx) + def validate_input(val, ctx, max_errors: nil) if val.nil? - GraphQL::Query::InputValidationResult.new + Query::InputValidationResult::VALID else - validate_non_null_input(val, ctx) + validate_non_null_input(val, ctx, max_errors: max_errors) || Query::InputValidationResult::VALID end end def valid_isolated_input?(v) - valid_input?(v, GraphQL::Query::NullContext) + valid_input?(v, GraphQL::Query::NullContext.instance) end def coerce_isolated_input(v) - coerce_input(v, GraphQL::Query::NullContext) + coerce_input(v, GraphQL::Query::NullContext.instance) end def coerce_isolated_result(v) - coerce_result(v, GraphQL::Query::NullContext) + coerce_result(v, GraphQL::Query::NullContext.instance) end end end diff --git a/lib/graphql/schema/middleware_chain.rb b/lib/graphql/schema/middleware_chain.rb deleted file mode 100644 index 81db6b6fbe8..00000000000 --- a/lib/graphql/schema/middleware_chain.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Schema - # Given {steps} and {arguments}, call steps in order, passing `(*arguments, next_step)`. - # - # Steps should call `next_step.call` to continue the chain, or _not_ call it to stop the chain. - class MiddlewareChain - extend Forwardable - - # @return [Array<#call(*args)>] Steps in this chain, will be called with arguments and `next_middleware` - attr_reader :steps, :final_step - - def initialize(steps: [], final_step: nil) - @steps = steps - @final_step = final_step - end - - def initialize_copy(other) - super - @steps = other.steps.dup - end - - def_delegators :@steps, :[], :first, :insert, :delete - - def <<(callable) - add_middleware(callable) - end - - def push(callable) - add_middleware(callable) - end - - def ==(other) - steps == other.steps && final_step == other.final_step - end - - def invoke(arguments) - invoke_core(0, arguments) - end - - def concat(callables) - callables.each { |c| add_middleware(c) } - end - - private - - def invoke_core(index, arguments) - if index >= steps.length - final_step.call(*arguments) - else - steps[index].call(*arguments) { |next_args = arguments| invoke_core(index + 1, next_args) } - end - end - - def add_middleware(callable) - # TODO: Stop wrapping callables once deprecated middleware becomes unsupported - steps << wrap(callable) - end - - # TODO: Remove this code once deprecated middleware becomes unsupported - class MiddlewareWrapper - attr_reader :callable - def initialize(callable) - @callable = callable - end - - def call(*args, &next_middleware) - callable.call(*args, next_middleware) - end - end - - def wrap(callable) - if BackwardsCompatibility.get_arity(callable) == 6 - GraphQL::Deprecation.warn("Middleware that takes a next_middleware parameter is deprecated (#{callable.inspect}); instead, accept a block and use yield.") - MiddlewareWrapper.new(callable) - else - callable - end - end - end - end -end diff --git a/lib/graphql/schema/mutation.rb b/lib/graphql/schema/mutation.rb index 53280255efd..b0ba8f3f5ef 100644 --- a/lib/graphql/schema/mutation.rb +++ b/lib/graphql/schema/mutation.rb @@ -62,16 +62,14 @@ class Mutation < GraphQL::Schema::Resolver extend GraphQL::Schema::Member::HasFields extend GraphQL::Schema::Resolver::HasPayloadType - class << self - # Override this method to handle legacy-style usages of `MyMutation.field` - def field(*args, **kwargs, &block) - if args.empty? - raise ArgumentError, "#{name}.field is used for adding fields to this mutation. Use `mutation: #{name}` to attach this mutation instead." - else - super - end - end + # @api private + def call_resolve(_args_hash) + # Clear any cached values from `loads` or authorization: + dataloader.clear_cache + super + end + class << self def visible?(context) true end diff --git a/lib/graphql/schema/non_null.rb b/lib/graphql/schema/non_null.rb index 7db32b82e67..92267f707ae 100644 --- a/lib/graphql/schema/non_null.rb +++ b/lib/graphql/schema/non_null.rb @@ -8,11 +8,7 @@ class Schema class NonNull < GraphQL::Schema::Wrapper include Schema::Member::ValidatesInput - def to_graphql - @of_type.graphql_definition.to_non_null_type - end - - # @return [GraphQL::TypeKinds::NON_NULL] + # @return [GraphQL::TypeKinds::NON_NULL] def kind GraphQL::TypeKinds::NON_NULL end @@ -28,20 +24,20 @@ def list? end def to_type_signature - "#{@of_type.to_type_signature}!" + @type_signature ||= -"#{@of_type.to_type_signature}!" end def inspect "#<#{self.class.name} @of_type=#{@of_type.inspect}>" end - def validate_input(value, ctx) + def validate_input(value, ctx, max_errors: nil) if value.nil? result = GraphQL::Query::InputValidationResult.new result.add_problem("Expected value to not be null") result else - of_type.validate_input(value, ctx) + of_type.validate_input(value, ctx, max_errors: max_errors) end end @@ -51,6 +47,10 @@ def graphql_name end def coerce_input(value, ctx) + # `.validate_input` above is used for variables, but this method is used for arguments + if value.nil? + raise GraphQL::ExecutionError, "`null` is not a valid input for `#{to_type_signature}`, please provide a value for this argument." + end of_type.coerce_input(value, ctx) end diff --git a/lib/graphql/schema/null_mask.rb b/lib/graphql/schema/null_mask.rb deleted file mode 100644 index 42e47922ec9..00000000000 --- a/lib/graphql/schema/null_mask.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Schema - # @api private - module NullMask - def self.call(member, ctx) - false - end - end - end -end diff --git a/lib/graphql/schema/object.rb b/lib/graphql/schema/object.rb index 1689f036ef4..b7282999870 100644 --- a/lib/graphql/schema/object.rb +++ b/lib/graphql/schema/object.rb @@ -5,8 +5,18 @@ module GraphQL class Schema class Object < GraphQL::Schema::Member - extend GraphQL::Schema::Member::AcceptsDefinition + extend GraphQL::Schema::Member::HasAuthorization extend GraphQL::Schema::Member::HasFields + extend GraphQL::Schema::Member::HasInterfaces + include Member::HasDataloader + + # Raised when an Object doesn't have any field defined and hasn't explicitly opted out of this requirement + class FieldsAreRequiredError < GraphQL::Error + def initialize(object_type) + message = "Object types must have fields, but #{object_type.graphql_name} doesn't have any. Define a field for this type, remove it from your schema, or add `has_no_fields(true)` to its definition." + super(message) + end + end # @return [Object] the application object this type is wrapping attr_reader :object @@ -30,6 +40,15 @@ class << self # @see authorized_new to make instances protected :new + def wrap_scoped(object, context) + scoped_new(object, context) + end + + # This is called by the runtime to return an object to call methods on. + def wrap(object, context) + authorized_new(object, context) + end + # Make a new instance of this type _if_ the auth check passes, # otherwise, raise an error. # @@ -48,29 +67,35 @@ class << self # @return [GraphQL::Schema::Object, GraphQL::Execution::Lazy] # @raise [GraphQL::UnauthorizedError] if the user-provided hook returns `false` def authorized_new(object, context) - trace_payload = { context: context, type: self, object: object, path: context[:current_path] } - - maybe_lazy_auth_val = context.query.trace("authorized", trace_payload) do - context.query.with_error_handling do + context.query.current_trace.begin_authorized(self, object, context) + begin + maybe_lazy_auth_val = context.query.current_trace.authorized(query: context.query, type: self, object: object) do begin authorized?(object, context) rescue GraphQL::UnauthorizedError => err context.schema.unauthorized_object(err) + rescue StandardError => err + context.query.handle_or_reraise(err) end end + ensure + context.query.current_trace.end_authorized(self, object, context, maybe_lazy_auth_val) end auth_val = if context.schema.lazy?(maybe_lazy_auth_val) GraphQL::Execution::Lazy.new do - context.query.trace("authorized_lazy", trace_payload) do - context.schema.sync_lazy(maybe_lazy_auth_val) + context.query.current_trace.begin_authorized(self, object, context) + context.query.current_trace.authorized_lazy(query: context.query, type: self, object: object) do + res = context.schema.sync_lazy(maybe_lazy_auth_val) + context.query.current_trace.end_authorized(self, object, context, res) + res end end else maybe_lazy_auth_val end - context.schema.after_lazy(auth_val) do |is_authorized| + context.query.after_lazy(auth_val) do |is_authorized| if is_authorized self.new(object, context) else @@ -88,6 +113,10 @@ def authorized_new(object, context) end end end + + def scoped_new(object, context) + self.new(object, context) + end end def initialize(object, context) @@ -98,114 +127,14 @@ def initialize(object, context) class << self # Set up a type-specific invalid null error to use when this object's non-null fields wrongly return `nil`. # It should help with debugging and bug tracker integrations. - def inherited(child_class) - child_class.const_set(:InvalidNullError, GraphQL::InvalidNullError.subclass_for(child_class)) - super - end - - def implements(*new_interfaces, **options) - new_memberships = [] - new_interfaces.each do |int| - if int.is_a?(Module) - unless int.include?(GraphQL::Schema::Interface) - raise "#{int} cannot be implemented since it's not a GraphQL Interface. Use `include` for plain Ruby modules." - end - - new_memberships << int.type_membership_class.new(int, self, **options) - - # Include the methods here, - # `.fields` will use the inheritance chain - # to find inherited fields - include(int) - elsif int.is_a?(GraphQL::InterfaceType) - new_memberships << int.type_membership_class.new(int, self, **options) - elsif int.is_a?(String) || int.is_a?(GraphQL::Schema::LateBoundType) - if options.any? - raise ArgumentError, "`implements(...)` doesn't support options with late-loaded types yet. Remove #{options} and open an issue to request this feature." - end - new_memberships << int - else - raise ArgumentError, "Unexpected interface definition (expected module): #{int} (#{int.class})" - end - end - - # Remove any interfaces which are being replaced (late-bound types are updated in place this way) - own_interface_type_memberships.reject! { |old_i_m| - old_int_type = old_i_m.respond_to?(:abstract_type) ? old_i_m.abstract_type : old_i_m - old_name = Schema::Member::BuildType.to_type_name(old_int_type) - - new_memberships.any? { |new_i_m| - new_int_type = new_i_m.respond_to?(:abstract_type) ? new_i_m.abstract_type : new_i_m - new_name = Schema::Member::BuildType.to_type_name(new_int_type) - - new_name == old_name - } - } - own_interface_type_memberships.concat(new_memberships) - end - - def own_interface_type_memberships - @own_interface_type_memberships ||= [] - end - - def interface_type_memberships - own_interface_type_memberships + (superclass.respond_to?(:interface_type_memberships) ? superclass.interface_type_memberships : []) - end - - # param context [Query::Context] If omitted, skip filtering. - def interfaces(context = GraphQL::Query::NullContext) - visible_interfaces = [] - unfiltered = context == GraphQL::Query::NullContext - own_interface_type_memberships.each do |type_membership| - # During initialization, `type_memberships` can hold late-bound types - case type_membership - when String, Schema::LateBoundType - visible_interfaces << type_membership - when Schema::TypeMembership - if unfiltered || type_membership.visible?(context) - visible_interfaces << type_membership.abstract_type - end - else - raise "Invariant: Unexpected type_membership #{type_membership.class}: #{type_membership.inspect}" - end - end - visible_interfaces + (superclass <= GraphQL::Schema::Object ? superclass.interfaces(context) : []) - end - - # @return [Hash GraphQL::Schema::Field>] All of this object's fields, indexed by name - # @see get_field A faster way to find one field by name ({#fields} merges hashes of inherited fields; {#get_field} just looks up one field.) - def fields - all_fields = super - interfaces.each do |int| - # Include legacy-style interfaces, too - if int.is_a?(GraphQL::InterfaceType) - int_f = {} - int.fields.each do |name, legacy_field| - int_f[name] = field_class.from_options(name, field: legacy_field) - end - all_fields = int_f.merge(all_fields) - end - end - all_fields - end - - # @return [GraphQL::ObjectType] - def to_graphql - obj_type = GraphQL::ObjectType.new - obj_type.name = graphql_name - obj_type.description = description - obj_type.structural_interface_type_memberships = interface_type_memberships - obj_type.introspection = introspection - obj_type.mutation = mutation - obj_type.ast_node = ast_node - fields.each do |field_name, field_inst| - field_defn = field_inst.to_graphql - obj_type.fields[field_defn.name] = field_defn + def const_missing(name) + if name == :InvalidNullError + custom_err_class = GraphQL::InvalidNullError.subclass_for(self) + const_set(:InvalidNullError, custom_err_class) + custom_err_class + else + super end - - obj_type.metadata[:type_class] = self - - obj_type end def kind diff --git a/lib/graphql/schema/possible_types.rb b/lib/graphql/schema/possible_types.rb deleted file mode 100644 index 0c804525153..00000000000 --- a/lib/graphql/schema/possible_types.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Schema - # Find the members of a union or interface within a given schema. - # - # (Although its members never change, unions are handled this way to simplify execution code.) - # - # Internally, the calculation is cached. It's assumed that schema members _don't_ change after creating the schema! - # - # @example Get an interface's possible types - # possible_types = GraphQL::Schema::PossibleTypes(MySchema) - # possible_types.possible_types(MyInterface) - # # => [MyObjectType, MyOtherObjectType] - class PossibleTypes - def initialize(schema) - @object_types = schema.types.values.select { |type| type.kind.object? } - @interface_implementers = Hash.new do |h1, ctx| - h1[ctx] = Hash.new do |h2, int_type| - h2[int_type] = @object_types.select { |type| type.interfaces(ctx).include?(int_type) }.sort_by(&:name) - end - end - end - - def possible_types(type_defn, ctx) - case type_defn - when Module - possible_types(type_defn.graphql_definition, ctx) - when GraphQL::UnionType - type_defn.possible_types(ctx) - when GraphQL::InterfaceType - interface_implementers(ctx, type_defn) - when GraphQL::BaseType - [type_defn] - else - raise "Unexpected possible_types object: #{type_defn.inspect}" - end - end - - def interface_implementers(ctx, type_defn) - @interface_implementers[ctx][type_defn] - end - end - end -end diff --git a/lib/graphql/schema/printer.rb b/lib/graphql/schema/printer.rb index 6b25ac6ab91..b058f6cbb90 100644 --- a/lib/graphql/schema/printer.rb +++ b/lib/graphql/schema/printer.rb @@ -36,15 +36,11 @@ class Printer < GraphQL::Language::Printer # @param schema [GraphQL::Schema] # @param context [Hash] - # @param only [<#call(member, ctx)>] - # @param except [<#call(member, ctx)>] # @param introspection [Boolean] Should include the introspection types in the string? - def initialize(schema, context: nil, only: nil, except: nil, introspection: false) + def initialize(schema, context: nil, introspection: false) @document_from_schema = GraphQL::Language::DocumentFromSchemaDefinition.new( schema, context: context, - only: only, - except: except, include_introspection_types: introspection, ) @@ -56,13 +52,21 @@ def initialize(schema, context: nil, only: nil, except: nil, introspection: fals def self.print_introspection_schema query_root = Class.new(GraphQL::Schema::Object) do graphql_name "Root" - field :throwaway_field, String, null: true + field :throwaway_field, String + def self.visible?(ctx) + false + end end - schema = Class.new(GraphQL::Schema) { query(query_root) } + schema = Class.new(GraphQL::Schema) { + query(query_root) + use GraphQL::Schema::Visibility + def self.visible?(member, _ctx) + member.graphql_name != "Root" + end + } introspection_schema_ast = GraphQL::Language::DocumentFromSchemaDefinition.new( schema, - except: ->(member, _) { member.graphql_name == "Root" }, include_introspection_types: true, include_built_in_directives: true, ).document @@ -92,7 +96,7 @@ def print_type(type) class IntrospectionPrinter < GraphQL::Language::Printer def print_schema_definition(schema) - "schema {\n query: Root\n}" + print_string("schema {\n query: Root\n}") end end end diff --git a/lib/graphql/schema/ractor_shareable.rb b/lib/graphql/schema/ractor_shareable.rb new file mode 100644 index 00000000000..23ce93cdc1e --- /dev/null +++ b/lib/graphql/schema/ractor_shareable.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true +module GraphQL + class Schema + module RactorShareable + def self.extended(schema_class) + schema_class.extend(SchemaExtension) + schema_class.freeze_schema + end + + module SchemaExtension + + def freeze_error_handlers(handlers) + handlers[:subclass_handlers].default_proc = nil + handlers[:subclass_handlers].each do |_class, subclass_handlers| + freeze_error_handlers(subclass_handlers) + end + Ractor.make_shareable(handlers) + end + + def freeze_schema + # warm some ivars: + default_analysis_engine + default_execution_strategy + GraphQL.default_parser + default_logger + freeze_error_handlers(error_handlers) + # TODO: this freezes errors of parent classes which could cause trouble + parent_class = superclass + while parent_class.respond_to?(:error_handlers) + freeze_error_handlers(parent_class.error_handlers) + parent_class = parent_class.superclass + end + + own_tracers.freeze + @frozen_tracers = tracers.freeze + own_trace_modes.each do |m| + trace_options_for(m) + build_trace_mode(m) + end + build_trace_mode(:default) + Ractor.make_shareable(@trace_options_for_mode) + Ractor.make_shareable(own_trace_modes) + Ractor.make_shareable(own_multiplex_analyzers) + @frozen_multiplex_analyzers = Ractor.make_shareable(multiplex_analyzers) + Ractor.make_shareable(own_query_analyzers) + @frozen_query_analyzers = Ractor.make_shareable(query_analyzers) + Ractor.make_shareable(own_plugins) + own_plugins.each do |(plugin, options)| + Ractor.make_shareable(plugin) + Ractor.make_shareable(options) + end + @frozen_plugins = Ractor.make_shareable(plugins) + Ractor.make_shareable(own_references_to) + @frozen_directives = Ractor.make_shareable(directives) + + Ractor.make_shareable(visibility) + Ractor.make_shareable(introspection_system) + extend(FrozenMethods) + + Ractor.make_shareable(self) + superclass.respond_to?(:freeze_schema) && superclass.freeze_schema + end + + module FrozenMethods + def tracers; @frozen_tracers; end + def multiplex_analyzers; @frozen_multiplex_analyzers; end + def query_analyzers; @frozen_query_analyzers; end + def plugins; @frozen_plugins; end + def directives; @frozen_directives; end + + # This actually accumulates info during execution... + # How to support it? + def lazy?(_obj); false; end + def sync_lazy(obj); obj; end + def resolves_lazies?; false; end + end + end + end + end +end diff --git a/lib/graphql/schema/relay_classic_mutation.rb b/lib/graphql/schema/relay_classic_mutation.rb index be75092ccf6..e83de7319ad 100644 --- a/lib/graphql/schema/relay_classic_mutation.rb +++ b/lib/graphql/schema/relay_classic_mutation.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -require "graphql/types/string" module GraphQL class Schema @@ -21,112 +20,49 @@ class Schema # @see {GraphQL::Schema::Mutation} for an example, it's basically the same. # class RelayClassicMutation < GraphQL::Schema::Mutation + include GraphQL::Schema::HasSingleInputArgument + + argument :client_mutation_id, String, "A unique identifier for the client performing the mutation.", required: false + # The payload should always include this field - field(:client_mutation_id, String, "A unique identifier for the client performing the mutation.", null: true) + field(:client_mutation_id, String, "A unique identifier for the client performing the mutation.", hash_key: :client_mutation_id) # Relay classic default: null(true) # Override {GraphQL::Schema::Resolver#resolve_with_support} to # delete `client_mutation_id` from the kwargs. def resolve_with_support(**inputs) - # Without the interpreter, the inputs are unwrapped by an instrumenter. - # But when using the interpreter, no instrumenters are applied. - if context.interpreter? - input = inputs[:input].to_kwargs - - new_extras = field ? field.extras : [] - all_extras = self.class.extras + new_extras - - # Transfer these from the top-level hash to the - # shortcutted `input:` object - all_extras.each do |ext| - # It's possible that the `extra` was not passed along by this point, - # don't re-add it if it wasn't given here. - if inputs.key?(ext) - input[ext] = inputs[ext] - end - end - else - input = inputs - end + input = inputs[:input].to_kwargs if input - # This is handled by Relay::Mutation::Resolve, a bit hacky, but here we are. input_kwargs = input.to_h client_mutation_id = input_kwargs.delete(:client_mutation_id) - else - # Relay Classic Mutations with no `argument`s - # don't require `input:` - input_kwargs = {} + inputs[:input] = input_kwargs end - return_value = if input_kwargs.any? - super(**input_kwargs) - else - super() - end + return_value = super(**inputs) - # Again, this is done by an instrumenter when using non-interpreter execution. - if context.interpreter? - context.schema.after_lazy(return_value) do |return_hash| - # It might be an error - if return_hash.is_a?(Hash) - return_hash[:client_mutation_id] = client_mutation_id - end - return_hash + context.query.after_lazy(return_value) do |return_hash| + # It might be an error + if return_hash.is_a?(Hash) + return_hash[:client_mutation_id] = client_mutation_id end - else - return_value + return_hash end end - class << self - # The base class for generated input object types - # @param new_class [Class] The base class to use for generating input object definitions - # @return [Class] The base class for this mutation's generated input object (default is {GraphQL::Schema::InputObject}) - def input_object_class(new_class = nil) - if new_class - @input_object_class = new_class - end - @input_object_class || (superclass.respond_to?(:input_object_class) ? superclass.input_object_class : GraphQL::Schema::InputObject) - end + def call + input = @prepared_arguments[:input]&.to_kwargs - # @param new_input_type [Class, nil] If provided, it configures this mutation to accept `new_input_type` instead of generating an input type - # @return [Class] The generated {Schema::InputObject} class for this mutation's `input` - def input_type(new_input_type = nil) - if new_input_type - @input_type = new_input_type - end - @input_type ||= generate_input_type - end - - # Extend {Schema::Mutation.field_options} to add the `input` argument - def field_options - sig = super - # Arguments were added at the root, but they should be nested - sig[:arguments].clear - sig[:arguments][:input] = { type: input_type, required: true, description: "Parameters for #{graphql_name}" } - sig + if input + client_mutation_id = input.delete(:client_mutation_id) + @prepared_arguments[:input] = input end - private + super - # Generate the input type for the `input:` argument - # To customize how input objects are generated, override this method - # @return [Class] a subclass of {.input_object_class} - def generate_input_type - mutation_args = arguments - mutation_name = graphql_name - mutation_class = self - Class.new(input_object_class) do - graphql_name("#{mutation_name}Input") - description("Autogenerated input type of #{mutation_name}") - mutation(mutation_class) - mutation_args.each do |_name, arg| - add_argument(arg) - end - argument :client_mutation_id, String, "A unique identifier for the client performing the mutation.", required: false - end + if (return_value = exec_result[exec_index]).is_a?(Hash) + return_value[:client_mutation_id] = client_mutation_id end end end diff --git a/lib/graphql/schema/rescue_middleware.rb b/lib/graphql/schema/rescue_middleware.rb deleted file mode 100644 index 719af49a44d..00000000000 --- a/lib/graphql/schema/rescue_middleware.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Schema - # - Store a table of errors & handlers - # - Rescue errors in a middleware chain, then check for a handler - # - If a handler is found, use it & return a {GraphQL::ExecutionError} - # - If no handler is found, re-raise the error - class RescueMiddleware - # @return [Hash] `{class => proc}` pairs for handling errors - attr_reader :rescue_table - def initialize - @rescue_table = {} - end - - # @example Rescue from not-found by telling the user - # MySchema.rescue_from(ActiveRecord::RecordNotFound) { "An item could not be found" } - # - # @param error_classes [Class] one or more classes of errors to rescue from - # @yield [err] A handler to return a message for these error instances - # @yieldparam [Exception] an error that was rescued - # @yieldreturn [String] message to put in GraphQL response - def rescue_from(*error_classes, &block) - error_classes.map{ |error_class| rescue_table[error_class] = block } - end - - # Remove the handler for `error_classs` - # @param error_class [Class] the error class whose handler should be removed - def remove_handler(*error_classes) - error_classes.map{ |error_class| rescue_table.delete(error_class) } - end - - # Implement the requirement for {GraphQL::Schema::MiddlewareChain} - def call(*args) - begin - yield - rescue StandardError => err - attempt_rescue(err) - end - end - - private - - def attempt_rescue(err) - rescue_table.each { |klass, handler| - if klass.is_a?(Class) && err.is_a?(klass) && handler - result = handler.call(err) - case result - when String - return GraphQL::ExecutionError.new(result) - when GraphQL::ExecutionError - return result - end - end - } - - raise(err) - end - end - end -end diff --git a/lib/graphql/schema/resolver.rb b/lib/graphql/schema/resolver.rb index 81a89a5d571..6a9c741e675 100644 --- a/lib/graphql/schema/resolver.rb +++ b/lib/graphql/schema/resolver.rb @@ -8,6 +8,7 @@ class Schema # - Arguments, via `.argument(...)` helper, which will be applied to the field. # - Return type, via `.type(..., null: ...)`, which will be applied to the field. # - Description, via `.description(...)`, which will be applied to the field + # - Comment, via `.comment(...)`, which will be applied to the field # - Resolution, via `#resolve(**args)` method, which will be called to resolve the field. # - `#object` and `#context` accessors for use during `#resolve`. # @@ -15,20 +16,22 @@ class Schema # # A resolver's configuration may be overridden with other keywords in the `field(...)` call. # - # See the {.field_options} to see how a Resolver becomes a set of field configuration options. - # # @see {GraphQL::Schema::Mutation} for a concrete subclass of `Resolver`. # @see {GraphQL::Function} `Resolver` is a replacement for `GraphQL::Function` class Resolver include Schema::Member::GraphQLTypeNames - # Really we only need description from here, but: + # Really we only need description & comment from here, but: extend Schema::Member::BaseDSLMethods extend GraphQL::Schema::Member::HasArguments + extend GraphQL::Schema::Member::HasAuthorization extend GraphQL::Schema::Member::HasValidators include Schema::Member::HasPath extend Schema::Member::HasPath + extend Schema::Member::HasDirectives + include Schema::Member::HasDataloader + extend Schema::Member::HasDeprecationReason - # @param object [Object] the initialize object, pass to {Query.initialize} as `root_value` + # @param object [Object] The application object that this field is being resolved on # @param context [GraphQL::Query::Context] # @param field [GraphQL::Schema::Field] def initialize(object:, context:, field:) @@ -37,27 +40,91 @@ def initialize(object:, context:, field:) @field = field # Since this hash is constantly rebuilt, cache it for this call @arguments_by_keyword = {} - self.class.arguments.each do |name, arg| + context.types.arguments(self.class).each do |arg| @arguments_by_keyword[arg.keyword] = arg end - @arguments_loads_as_type = self.class.arguments_loads_as_type @prepared_arguments = nil end + attr_accessor :exec_result, :exec_index, :field_resolve_step, :raw_arguments + # @return [Object] The application object this field is being resolved on - attr_reader :object + attr_accessor :object # @return [GraphQL::Query::Context] attr_reader :context - # @return [GraphQL::Dataloader] - def dataloader - context.dataloader - end - # @return [GraphQL::Schema::Field] attr_reader :field + attr_writer :prepared_arguments + + def call + q = context.query + trace_objs = [object] + q.current_trace.begin_execute_field(field, @prepared_arguments, trace_objs, q) + is_ready = ready?(**@prepared_arguments) + runner = @field_resolve_step.runner + if runner.resolves_lazies && runner.schema.lazy?(is_ready) + is_ready, new_return_value = runner.schema.sync_lazy(is_ready) + end + + if is_ready.is_a?(Array) + is_ready, new_return_value = is_ready + if is_ready != false + raise "Unexpected result from #ready? (expected `true`, `false` or `[false, {...}]`): [#{is_ready.inspect}, #{new_return_value.inspect}]" + else + new_return_value + end + end + + if is_ready + begin + is_authed, new_return_value = authorized?(**@prepared_arguments) + rescue GraphQL::UnauthorizedError => err + new_return_value = q.schema.unauthorized_object(err) + is_authed = false + end + end + + if runner.resolves_lazies && runner.schema.lazy?(is_authed) + is_authed, new_return_value = runner.schema.sync_lazy(is_authed) + end + + result = if is_authed + Schema::Validator.validate!(self.class.validators, object, context, @prepared_arguments, as: @field) + call_resolve(@prepared_arguments) + elsif new_return_value.nil? + err = UnauthorizedFieldError.new(object: object, type: @field_resolve_step.parent_type, context: context, field: @field) + context.schema.unauthorized_field(err) + else + new_return_value + end + q = context.query + q.current_trace.end_execute_field(field, @prepared_arguments, trace_objs, q, [result]) + exec_result[exec_index] = result + rescue GraphQL::UnauthorizedError => auth_err + exec_result[exec_index] = begin + context.schema.unauthorized_object(auth_err) + rescue GraphQL::ExecutionError => exec_err + exec_err + end + rescue GraphQL::RuntimeError => err + exec_result[exec_index] = err + rescue StandardError => stderr + exec_result[exec_index] = begin + context.query.handle_or_reraise(stderr) + rescue GraphQL::ExecutionError => ex_err + ex_err + end + ensure + field_pending_steps = field_resolve_step.pending_steps + field_pending_steps.delete(self) + if field_pending_steps.size == 0 && field_resolve_step.field_results + field_resolve_step.runner.add_step(field_resolve_step) + end + end + def arguments @prepared_arguments || raise("Arguments have not been prepared yet, still waiting for #load_arguments to resolve. (Call `.arguments` later in the code.)") end @@ -68,49 +135,47 @@ def arguments # @api private def resolve_with_support(**args) # First call the ready? hook which may raise - ready_val = if args.any? + raw_ready_val = if !args.empty? ready?(**args) else ready? end - context.schema.after_lazy(ready_val) do |is_ready, ready_early_return| - if ready_early_return + context.query.after_lazy(raw_ready_val) do |ready_val| + if ready_val.is_a?(Array) + is_ready, ready_early_return = ready_val if is_ready != false - raise "Unexpected result from #ready? (expected `true`, `false` or `[false, {...}]`): [#{authorized_result.inspect}, #{ready_early_return.inspect}]" + raise "Unexpected result from #ready? (expected `true`, `false` or `[false, {...}]`): [#{is_ready.inspect}, #{ready_early_return.inspect}]" else ready_early_return end - elsif is_ready + elsif ready_val # Then call each prepare hook, which may return a different value # for that argument, or may return a lazy object load_arguments_val = load_arguments(args) - context.schema.after_lazy(load_arguments_val) do |loaded_args| + context.query.after_lazy(load_arguments_val) do |loaded_args| @prepared_arguments = loaded_args Schema::Validator.validate!(self.class.validators, object, context, loaded_args, as: @field) # Then call `authorized?`, which may raise or may return a lazy object - authorized_val = if loaded_args.any? + raw_authorized_val = if !loaded_args.empty? authorized?(**loaded_args) else authorized? end - context.schema.after_lazy(authorized_val) do |(authorized_result, early_return)| + context.query.after_lazy(raw_authorized_val) do |authorized_val| # If the `authorized?` returned two values, `false, early_return`, # then use the early return value instead of continuing - if early_return + if authorized_val.is_a?(Array) + authorized_result, early_return = authorized_val if authorized_result == false early_return else raise "Unexpected result from #authorized? (expected `true`, `false` or `[false, {...}]`): [#{authorized_result.inspect}, #{early_return.inspect}]" end - elsif authorized_result + elsif authorized_val # Finally, all the hooks have passed, so resolve it - if loaded_args.any? - public_send(self.class.resolve_method, **loaded_args) - else - public_send(self.class.resolve_method) - end + call_resolve(loaded_args) else - nil + raise GraphQL::UnauthorizedFieldError.new(context: context, object: object, type: field.owner, field: field) end end end @@ -118,6 +183,15 @@ def resolve_with_support(**args) end end + # @api private {GraphQL::Schema::Mutation} uses this to clear the dataloader cache + def call_resolve(args_hash) + if !args_hash.empty? + public_send(self.class.resolve_method, **args_hash) + else + public_send(self.class.resolve_method) + end + end + # Do the work. Everything happens here. # @return [Object] An object corresponding to the return type def resolve(**args) @@ -146,23 +220,46 @@ def ready?(**args) # @raise [GraphQL::UnauthorizedError] To signal an authorization failure # @return [Boolean, early_return_data] If `false`, execution will stop (and `early_return_data` will be returned instead, if present.) def authorized?(**inputs) - self.class.arguments.each_value do |argument| + arg_owner = @field # || self.class + args = context.types.arguments(arg_owner) + authorize_arguments(args, inputs) + end + + def self.authorizes?(context) + self.instance_method(:authorized?).owner != GraphQL::Schema::Resolver + end + + # Called when an object loaded by `loads:` fails the `.authorized?` check for its resolved GraphQL object type. + # + # By default, the error is re-raised and passed along to {{Schema.unauthorized_object}}. + # + # Any value returned here will be used _instead of_ of the loaded object. + # @param err [GraphQL::UnauthorizedError] + def unauthorized_object(err) + raise err + end + + private + + def authorize_arguments(args, inputs) + args.each do |argument| arg_keyword = argument.keyword if inputs.key?(arg_keyword) && !(arg_value = inputs[arg_keyword]).nil? && (arg_value != argument.default_value) - arg_auth, err = argument.authorized?(self, arg_value, context) - if !arg_auth - return arg_auth, err - else - true + auth_result = argument.authorized?(self, arg_value, context) + if auth_result.is_a?(Array) + # only return this second value if the application returned a second value + arg_auth, err = auth_result + if !arg_auth + return arg_auth, err + end + elsif auth_result == false + return auth_result end - else - true end end + true end - private - def load_arguments(args) prepared_args = {} prepare_lazies = [] @@ -170,35 +267,47 @@ def load_arguments(args) args.each do |key, value| arg_defn = @arguments_by_keyword[key] if arg_defn - if value.nil? - prepared_args[key] = value - else - prepped_value = prepared_args[key] = load_argument(key, value) - if context.schema.lazy?(prepped_value) - prepare_lazies << context.schema.after_lazy(prepped_value) do |finished_prepped_value| - prepared_args[key] = finished_prepped_value - end + prepped_value = prepared_args[key] = arg_defn.load_and_authorize_value(self, value, context) + if context.schema.lazy?(prepped_value) + prepare_lazies << context.query.after_lazy(prepped_value) do |finished_prepped_value| + prepared_args[key] = finished_prepped_value end end else - # These are `extras: [...]` + # these are `extras:` prepared_args[key] = value end end # Avoid returning a lazy if none are needed - if prepare_lazies.any? + if !prepare_lazies.empty? GraphQL::Execution::Lazy.all(prepare_lazies).then { prepared_args } else prepared_args end end - def load_argument(name, value) - public_send("load_#{name}", value) + def get_argument(name, context = GraphQL::Query::NullContext.instance) + self.class.get_argument(name, context) end class << self + def field_arguments(context = GraphQL::Query::NullContext.instance) + arguments(context) + end + + def any_field_arguments? + any_arguments? + end + + def get_field_argument(name, context = GraphQL::Query::NullContext.instance) + get_argument(name, context) + end + + def all_field_argument_definitions + all_argument_definitions + end + # Default `:resolve` set below. # @return [Symbol] The method to call on instances of this object to resolve the field def resolve_method(new_method = nil) @@ -218,8 +327,10 @@ def extras(new_extras = nil) own_extras + (superclass.respond_to?(:extras) ? superclass.extras : []) end - # Specifies whether or not the field is nullable. Defaults to `true` - # TODO unify with {#type} + # If `true` (default), then the return type for this resolver will be nullable. + # If `false`, then the return type is non-null. + # + # @see #type which sets the return type of this field and accepts a `null:` option # @param allow_null [Boolean] Whether or not the response can be null def null(allow_null = nil) if !allow_null.nil? @@ -229,6 +340,14 @@ def null(allow_null = nil) @null.nil? ? (superclass.respond_to?(:null) ? superclass.null : true) : @null end + def resolver_method(new_method_name = nil) + if new_method_name + @resolver_method = new_method_name + else + @resolver_method || :resolve_with_support + end + end + # Call this method to get the return type of the field, # or use it as a configuration method to assign a return type # instead of generating one. @@ -244,8 +363,8 @@ def type(new_type = nil, null: nil) @type_expr = new_type @null = null else - if @type_expr - GraphQL::Schema::Member::BuildType.parse_type(@type_expr, null: @null) + if type_expr + GraphQL::Schema::Member::BuildType.parse_type(type_expr, null: self.null) elsif superclass.respond_to?(:type) superclass.type else @@ -280,8 +399,8 @@ def broadcastable? # (`nil` means "unlimited max page size".) # @param max_page_size [Integer, nil] Set a new value # @return [Integer, nil] The `max_page_size` assigned to fields that use this resolver - def max_page_size(new_max_page_size = :not_given) - if new_max_page_size != :not_given + def max_page_size(new_max_page_size = NOT_CONFIGURED) + if new_max_page_size != NOT_CONFIGURED @max_page_size = new_max_page_size elsif defined?(@max_page_size) @max_page_size @@ -294,33 +413,28 @@ def max_page_size(new_max_page_size = :not_given) # @return [Boolean] `true` if this resolver or a superclass has an assigned `max_page_size` def has_max_page_size? - defined?(@max_page_size) || (superclass.respond_to?(:has_max_page_size?) && superclass.has_max_page_size?) - end - - def field_options - field_opts = { - type: type_expr, - description: description, - extras: extras, - resolver_method: :resolve_with_support, - resolver_class: self, - arguments: arguments, - null: null, - complexity: complexity, - broadcastable: broadcastable?, - } - - # If there aren't any, then the returned array is `[].freeze`, - # but passing that along breaks some user code. - if (exts = extensions).any? - field_opts[:extensions] = exts - end + (!!defined?(@max_page_size)) || (superclass.respond_to?(:has_max_page_size?) && superclass.has_max_page_size?) + end - if has_max_page_size? - field_opts[:max_page_size] = max_page_size + # Get or set the `default_page_size:` which will be configured for fields using this resolver + # (`nil` means "unlimited default page size".) + # @param default_page_size [Integer, nil] Set a new value + # @return [Integer, nil] The `default_page_size` assigned to fields that use this resolver + def default_page_size(new_default_page_size = NOT_CONFIGURED) + if new_default_page_size != NOT_CONFIGURED + @default_page_size = new_default_page_size + elsif defined?(@default_page_size) + @default_page_size + elsif superclass.respond_to?(:default_page_size) + superclass.default_page_size + else + nil end + end - field_opts + # @return [Boolean] `true` if this resolver or a superclass has an assigned `default_page_size` + def has_default_page_size? + (!!defined?(@default_page_size)) || (superclass.respond_to?(:has_default_page_size?) && superclass.has_default_page_size?) end # A non-normalized type configuration, without `null` applied @@ -332,47 +446,9 @@ def type_expr # also add some preparation hook methods which will be used for this argument # @see {GraphQL::Schema::Argument#initialize} for the signature def argument(*args, **kwargs, &block) - loads = kwargs[:loads] # Use `from_resolver: true` to short-circuit the InputObject's own `loads:` implementation # so that we can support `#load_{x}` methods below. - arg_defn = super(*args, from_resolver: true, **kwargs) - own_arguments_loads_as_type[arg_defn.keyword] = loads if loads - - if !method_defined?(:"load_#{arg_defn.keyword}") - if loads && arg_defn.type.list? - class_eval <<-RUBY, __FILE__, __LINE__ + 1 - def load_#{arg_defn.keyword}(values) - argument = @arguments_by_keyword[:#{arg_defn.keyword}] - lookup_as_type = @arguments_loads_as_type[:#{arg_defn.keyword}] - context.schema.after_lazy(values) do |values2| - GraphQL::Execution::Lazy.all(values2.map { |value| load_application_object(argument, lookup_as_type, value, context) }) - end - end - RUBY - elsif loads - class_eval <<-RUBY, __FILE__, __LINE__ + 1 - def load_#{arg_defn.keyword}(value) - argument = @arguments_by_keyword[:#{arg_defn.keyword}] - lookup_as_type = @arguments_loads_as_type[:#{arg_defn.keyword}] - load_application_object(argument, lookup_as_type, value, context) - end - RUBY - else - class_eval <<-RUBY, __FILE__, __LINE__ + 1 - def load_#{arg_defn.keyword}(value) - value - end - RUBY - end - end - - arg_defn - end - - # @api private - def arguments_loads_as_type - inherited_lookups = superclass.respond_to?(:arguments_loads_as_type) ? superclass.arguments_loads_as_type : {} - inherited_lookups.merge(own_arguments_loads_as_type) + super(*args, from_resolver: true, **kwargs) end # Registers new extension @@ -390,7 +466,7 @@ def extensions if superclass.respond_to?(:extensions) s_exts = superclass.extensions if own_exts - if s_exts.any? + if !s_exts.empty? own_exts + s_exts else own_exts @@ -403,15 +479,14 @@ def extensions end end - private - - def own_extensions - @own_extensions + def inherited(child_class) + child_class.description(description) + super end - def own_arguments_loads_as_type - @own_arguments_loads_as_type ||= {} - end + private + + attr_reader :own_extensions end end end diff --git a/lib/graphql/schema/resolver/has_payload_type.rb b/lib/graphql/schema/resolver/has_payload_type.rb index fadd3b6d3d0..89a974164de 100644 --- a/lib/graphql/schema/resolver/has_payload_type.rb +++ b/lib/graphql/schema/resolver/has_payload_type.rb @@ -20,7 +20,17 @@ def payload_type(new_payload_type = nil) @payload_type ||= generate_payload_type end - alias :type :payload_type + def type(new_type = nil, null: nil) + if new_type + payload_type(new_type) + if !null.nil? + self.null(null) + end + else + super() + end + end + alias :type_expr :payload_type def field_class(new_class = nil) @@ -38,6 +48,9 @@ def field_class(new_class = nil) # @return [Class] def object_class(new_class = nil) if new_class + if defined?(@payload_type) + raise "Can't configure `object_class(...)` after the payload type has already been initialized. Move this configuration higher up the class definition." + end @object_class = new_class else @object_class || find_inherited_value(:object_class, GraphQL::Schema::Object) @@ -46,6 +59,28 @@ def object_class(new_class = nil) NO_INTERFACES = [].freeze + def field(*args, **kwargs, &block) + pt = payload_type # make sure it's initialized with any inherited fields + field_defn = super + + # Remove any inherited fields to avoid false conflicts at runtime + prev_fields = pt.own_fields[field_defn.graphql_name] + case prev_fields + when GraphQL::Schema::Field + if prev_fields.owner != self + pt.own_fields.delete(field_defn.graphql_name) + end + when Array + prev_fields.reject! { |f| f.owner != self } + if prev_fields.empty? + pt.own_fields.delete(field_defn.graphql_name) + end + end + + pt.add_field(field_defn, method_conflict_warning: false) + field_defn + end + private # Build a subclass of {.object_class} based on `self`. @@ -53,17 +88,17 @@ def object_class(new_class = nil) # Override this hook to customize return type generation. def generate_payload_type resolver_name = graphql_name - resolver_fields = fields - Class.new(object_class) do - graphql_name("#{resolver_name}Payload") - description("Autogenerated return type of #{resolver_name}") - resolver_fields.each do |name, f| - # Reattach the already-defined field here - # (The field's `.owner` will still point to the mutation, not the object type, I think) - # Don't re-warn about a method conflict. Since this type is generated, it should be fixed in the resolver instead. - add_field(f, method_conflict_warning: false) - end + resolver_fields = all_field_definitions + pt = Class.new(object_class) + pt.graphql_name("#{resolver_name}Payload") + pt.description("Autogenerated return type of #{resolver_name}.") + resolver_fields.each do |f| + # Reattach the already-defined field here + # (The field's `.owner` will still point to the mutation, not the object type, I think) + # Don't re-warn about a method conflict. Since this type is generated, it should be fixed in the resolver instead. + pt.add_field(f, method_conflict_warning: false) end + pt end end end diff --git a/lib/graphql/schema/scalar.rb b/lib/graphql/schema/scalar.rb index 07f60ee2908..17b8196116e 100644 --- a/lib/graphql/schema/scalar.rb +++ b/lib/graphql/schema/scalar.rb @@ -2,7 +2,6 @@ module GraphQL class Schema class Scalar < GraphQL::Schema::Member - extend GraphQL::Schema::Member::AcceptsDefinition extend GraphQL::Schema::Member::ValidatesInput class << self @@ -14,22 +13,22 @@ def coerce_result(val, ctx) val end - def to_graphql - type_defn = GraphQL::ScalarType.new - type_defn.name = graphql_name - type_defn.description = description - type_defn.coerce_result = method(:coerce_result) - type_defn.coerce_input = method(:coerce_input) - type_defn.metadata[:type_class] = self - type_defn.default_scalar = default_scalar - type_defn.ast_node = ast_node - type_defn - end - def kind GraphQL::TypeKinds::SCALAR end + def specified_by_url(new_url = nil) + if new_url + directive(GraphQL::Schema::Directive::SpecifiedBy, url: new_url) + elsif (directive = directives.find { |dir| dir.graphql_name == "specifiedBy" }) + directive.arguments[:url] # rubocop:disable Development/ContextIsPassedCop + elsif superclass.respond_to?(:specified_by_url) + superclass.specified_by_url + else + nil + end + end + def default_scalar(is_default = nil) if !is_default.nil? @default_scalar = is_default @@ -41,27 +40,22 @@ def default_scalar? @default_scalar ||= false end - def validate_non_null_input(value, ctx) - result = Query::InputValidationResult.new + def validate_non_null_input(value, ctx, max_errors: nil) coerced_result = begin - ctx.query.with_error_handling do - coerce_input(value, ctx) - end + coerce_input(value, ctx) rescue GraphQL::CoercionError => err err + rescue StandardError => err + ctx.query.handle_or_reraise(err) end if coerced_result.nil? - str_value = if value == Float::INFINITY - "" - else - " #{GraphQL::Language.serialize(value)}" - end - result.add_problem("Could not coerce value#{str_value} to #{graphql_name}") + Query::InputValidationResult.from_problem("Could not coerce value #{GraphQL::Language.serialize(value)} to #{graphql_name}") elsif coerced_result.is_a?(GraphQL::CoercionError) - result.add_problem(coerced_result.message, message: coerced_result.message, extensions: coerced_result.extensions) + Query::InputValidationResult.from_problem(coerced_result.message, message: coerced_result.message, extensions: coerced_result.extensions) + else + nil end - result end end end diff --git a/lib/graphql/schema/subscription.rb b/lib/graphql/schema/subscription.rb index f5aa2516450..b0e11a689da 100644 --- a/lib/graphql/schema/subscription.rb +++ b/lib/graphql/schema/subscription.rb @@ -14,34 +14,81 @@ class Schema class Subscription < GraphQL::Schema::Resolver extend GraphQL::Schema::Resolver::HasPayloadType extend GraphQL::Schema::Member::HasFields - - # The generated payload type is required; If there's no payload, - # propagate null. + NO_UPDATE = :no_update null false + # @api private def initialize(object:, context:, field:) super # Figure out whether this is an update or an initial subscription @mode = context.query.subscription_update? ? :update : :subscribe + @subscription_written = false + @original_arguments = nil + if (subs_ns = context.namespace(:subscriptions)) && + (sub_insts = subs_ns[:subscriptions]) + sub_insts[context.current_path] = self + end + end + + # @api private + def call_resolve(args_hash) + if @field_resolve_step.nil? + super + else + context.namespace(:subscriptions)[:update_event] = event + result = nil + unsubscribed = true + unsubscribed_result = nil + begin + result = super + unsubscribed = false + rescue EarlyUnsubscribe => err + unsubscribed_result = err.unsubscribed_result + end + + + if unsubscribed + if unsubscribed_result + context.namespace(:subscriptions)[:final_update] = true + unsubscribed_result + else + context.skip + end + else + result + end + end end + # @api private def resolve_with_support(**args) + @original_arguments = args # before `loads:` have been run result = nil unsubscribed = true - catch :graphql_subscription_unsubscribed do + unsubscribed_result = nil + begin result = super unsubscribed = false + rescue EarlyUnsubscribe => err + unsubscribed_result = err.unsubscribed_result end if unsubscribed - context.skip + if unsubscribed_result + context.namespace(:subscriptions)[:final_update] = true + unsubscribed_result + else + context.skip + end else result end end - # Implement the {Resolve} API + # Implement the {Resolve} API. + # You can implement this if you want code to run for _both_ the initial subscription + # and for later updates. Or, implement {#subscribe} and {#update} def resolve(**args) # Dispatch based on `@mode`, which will raise a `NoMethodError` if we ever # have an unexpected `@mode` @@ -49,8 +96,9 @@ def resolve(**args) end # Wrap the user-defined `#subscribe` hook + # @api private def resolve_subscribe(**args) - ret_val = args.any? ? subscribe(**args) : subscribe + ret_val = !args.empty? ? subscribe(**args) : subscribe if ret_val == :no_response context.skip else @@ -58,19 +106,18 @@ def resolve_subscribe(**args) end end - # Default implementation returns the root object. + # The default implementation returns nothing on subscribe. # Override it to return an object or - # `:no_response` to return nothing. - # - # The default is `:no_response`. + # `:no_response` to (explicitly) return nothing. def subscribe(args = {}) :no_response end # Wrap the user-provided `#update` hook + # @api private def resolve_update(**args) - ret_val = args.any? ? update(**args) : update - if ret_val == :no_update + ret_val = !args.empty? ? update(**args) : update + if ret_val == NO_UPDATE context.namespace(:subscriptions)[:no_update] = true context.skip else @@ -79,7 +126,7 @@ def resolve_update(**args) end # The default implementation returns the root object. - # Override it to return `:no_update` if you want to + # Override it to return {NO_UPDATE} if you want to # skip updates sometimes. Or override it to return a different object. def update(args = {}) object @@ -96,19 +143,28 @@ def load_application_object_failed(err) end # Call this to halt execution and remove this subscription from the system - def unsubscribe + # @param update_value [Object] if present, deliver this update before unsubscribing + # @return [void] + def unsubscribe(update_value = nil) context.namespace(:subscriptions)[:unsubscribed] = true - throw :graphql_subscription_unsubscribed + err = EarlyUnsubscribe.new + err.unsubscribed_result = update_value + raise err + end + + class EarlyUnsubscribe < GraphQL::RuntimeError + attr_accessor :unsubscribed_result end - READING_SCOPE = ::Object.new # Call this method to provide a new subscription_scope; OR # call it without an argument to get the subscription_scope # @param new_scope [Symbol] + # @param optional [Boolean] If true, then don't require `scope:` to be provided to updates to this subscription. # @return [Symbol] - def self.subscription_scope(new_scope = READING_SCOPE) - if new_scope != READING_SCOPE + def self.subscription_scope(new_scope = NOT_CONFIGURED, optional: false) + if new_scope != NOT_CONFIGURED @subscription_scope = new_scope + @subscription_scope_optional = optional elsif defined?(@subscription_scope) @subscription_scope else @@ -116,11 +172,72 @@ def self.subscription_scope(new_scope = READING_SCOPE) end end - # Overriding Resolver#field_options to include subscription_scope - def self.field_options - super.merge( - subscription_scope: subscription_scope - ) + def self.subscription_scope_optional? + if defined?(@subscription_scope_optional) + @subscription_scope_optional + else + find_inherited_value(:subscription_scope_optional, false) + end + end + + # This is called during initial subscription to get a "name" for this subscription. + # Later, when `.trigger` is called, this will be called again to build another "name". + # Any subscribers with matching topic will begin the update flow. + # + # The default implementation creates a string using the field name, subscription scope, and argument keys and values. + # In that implementation, only `.trigger` calls with _exact matches_ result in updates to subscribers. + # + # To implement a filtered stream-type subscription flow, override this method to return a string with field name and subscription scope. + # Then, implement {#update} to compare its arguments to the current `object` and return {NO_UPDATE} when an + # update should be filtered out. + # + # @see {#update} for how to skip updates when an event comes with a matching topic. + # @param arguments [Hash Object>] The arguments for this topic, in GraphQL-style (camelized strings) + # @param field [GraphQL::Schema::Field] + # @param scope [Object, nil] A value corresponding to `.trigger(... scope:)` (for updates) or the `subscription_scope` found in `context` (for initial subscriptions). + # @return [String] An identifier corresponding to a stream of updates + def self.topic_for(arguments:, field:, scope:) + Subscriptions::Serialize.dump_recursive([scope, field.graphql_name, arguments]) + end + + # Calls through to `schema.subscriptions` to register this subscription with the backend. + # This is automatically called by GraphQL-Ruby after a query finishes successfully, + # but if you need to commit the subscription during `#subscribe`, you can call it there. + # (This method also sets a flag showing that this subscription was already written.) + # + # If you call this method yourself, you may also need to {#unsubscribe} + # or call `subscriptions.delete_subscription` to clean up the database if the query crashes with an error + # later in execution. + # @return [void] + def write_subscription + if subscription_written? + raise GraphQL::Error, "`write_subscription` was called but `#{self.class}#subscription_written?` is already true. Remove a call to `write subscription`." + else + @subscription_written = true + context.schema.subscriptions.write_subscription(context.query, [event]) + end + nil + end + + # @return [Boolean] `true` if {#write_subscription} was called already + def subscription_written? + @subscription_written + end + + # @return [Subscriptions::Event] This object is used as a representation of this subscription for the backend + def event + @event ||= begin + if @original_arguments.nil? && @field_resolve_step + @original_arguments, _errors = @field_resolve_step.arguments_without_loads + end + + Subscriptions::Event.new( + name: field.name, + arguments: @original_arguments, + context: context, + field: field, + ) + end end end end diff --git a/lib/graphql/schema/timeout.rb b/lib/graphql/schema/timeout.rb index f27692fd9a5..fae8ca44820 100644 --- a/lib/graphql/schema/timeout.rb +++ b/lib/graphql/schema/timeout.rb @@ -33,60 +33,64 @@ class Schema # end # class Timeout - def self.use(schema, **options) - tracer = new(**options) - schema.tracer(tracer) + def self.use(schema, max_seconds: nil) + timeout = self.new(max_seconds: max_seconds) + schema.trace_with(self::Trace, timeout: timeout) end - # @param max_seconds [Numeric] how many seconds the query should be allowed to resolve new fields def initialize(max_seconds:) @max_seconds = max_seconds end - def trace(key, data) - case key - when 'execute_multiplex' - data.fetch(:multiplex).queries.each do |query| - timeout_duration_s = max_seconds(query) + module Trace + # @param max_seconds [Numeric] how many seconds the query should be allowed to resolve new fields + def initialize(timeout:, **rest) + @timeout = timeout + super + end + + def execute_multiplex(multiplex:) + multiplex.queries.each do |query| + timeout_duration_s = @timeout.max_seconds(query) timeout_state = if timeout_duration_s == false # if the method returns `false`, don't apply a timeout false else now = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - timeout_at = now + (max_seconds(query) * 1000) + timeout_at = now + (timeout_duration_s * 1000) { timeout_at: timeout_at, timed_out: false } end - query.context.namespace(self.class)[:state] = timeout_state + query.context.namespace(@timeout)[:state] = timeout_state end + super + end - yield - when 'execute_field', 'execute_field_lazy' - query_context = data[:context] || data[:query].context - timeout_state = query_context.namespace(self.class).fetch(:state) + def begin_execute_field(field, _arguments, _objects, query) + timeout_state = query.context.namespace(@timeout).fetch(:state) # If the `:state` is `false`, then `max_seconds(query)` opted out of timeout for this query. - if timeout_state != false && Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) > timeout_state.fetch(:timeout_at) - error = if data[:context] - GraphQL::Schema::Timeout::TimeoutError.new(query_context.parent_type, query_context.field) - else - field = data.fetch(:field) - GraphQL::Schema::Timeout::TimeoutError.new(field.owner, field) - end - + if timeout_state == false + super + elsif Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) > timeout_state.fetch(:timeout_at) + error = GraphQL::Schema::Timeout::TimeoutError.new(field) # Only invoke the timeout callback for the first timeout if !timeout_state[:timed_out] timeout_state[:timed_out] = true - handle_timeout(error, query_context.query) + @timeout.handle_timeout(error, query) + timeout_state = query.context.namespace(@timeout).fetch(:state) end - error + # `handle_timeout` may have set this to be `false` + if timeout_state != false + raise error + else + super + end else - yield + super end - else - yield end end @@ -94,7 +98,7 @@ def trace(key, data) # The default implementation returns the `max_seconds:` value from installing this plugin. # # @param query [GraphQL::Query] The query that's about to run - # @return [Integer, false] The number of seconds after which to interrupt query execution and call {#handle_error}, or `false` to bypass the timeout. + # @return [Numeric, false] The number of seconds after which to interrupt query execution and call {#handle_error}, or `false` to bypass the timeout. def max_seconds(query) @max_seconds end @@ -106,6 +110,15 @@ def handle_timeout(error, query) # override to do something interesting end + # Call this method (eg, from {#handle_timeout}) to disable timeout tracking + # for the given query. + # @param query [GraphQL::Query] + # @return [void] + def disable_timeout(query) + query.context.namespace(self)[:state] = false + nil + end + # This error is raised when a query exceeds `max_seconds`. # Since it's a child of {GraphQL::ExecutionError}, # its message will be added to the response's `errors` key. @@ -114,8 +127,8 @@ def handle_timeout(error, query) # to take this error and raise a new one which _doesn't_ descend from {GraphQL::ExecutionError}, # such as `RuntimeError`. class TimeoutError < GraphQL::ExecutionError - def initialize(parent_type, field) - super("Timeout on #{parent_type.graphql_name}.#{field.graphql_name}") + def initialize(field) + super("Timeout on #{field.path}") end end end diff --git a/lib/graphql/schema/timeout_middleware.rb b/lib/graphql/schema/timeout_middleware.rb deleted file mode 100644 index 1c3dddcf0d7..00000000000 --- a/lib/graphql/schema/timeout_middleware.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true -require "delegate" - -module GraphQL - class Schema - # This middleware will stop resolving new fields after `max_seconds` have elapsed. - # After the time has passed, any remaining fields will be `nil`, with errors added - # to the `errors` key. Any already-resolved fields will be in the `data` key, so - # you'll get a partial response. - # - # You can provide a block which will be called with any timeout errors that occur. - # - # Note that this will stop a query _in between_ field resolutions, but - # it doesn't interrupt long-running `resolve` functions. Be sure to use - # timeout options for external connections. For more info, see - # www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/ - # - # @example Stop resolving fields after 2 seconds - # MySchema.middleware << GraphQL::Schema::TimeoutMiddleware.new(max_seconds: 2) - # - # @example Notifying Bugsnag on a timeout - # MySchema.middleware << GraphQL::Schema::TimeoutMiddleware(max_seconds: 1.5) do |timeout_error, query| - # Bugsnag.notify(timeout_error, {query_string: query_ctx.query.query_string}) - # end - # - # @api deprecated - # @see Schema::Timeout - class TimeoutMiddleware - # @param max_seconds [Numeric] how many seconds the query should be allowed to resolve new fields - def initialize(max_seconds:, context_key: nil, &block) - @max_seconds = max_seconds - if context_key - GraphQL::Deprecation.warn("TimeoutMiddleware's `context_key` is ignored, timeout data is now stored in isolated storage") - end - @error_handler = block - end - - def call(parent_type, parent_object, field_definition, field_args, query_context) - ns = query_context.namespace(self.class) - now = Process.clock_gettime(Process::CLOCK_MONOTONIC) - timeout_at = ns[:timeout_at] ||= now + @max_seconds - - if timeout_at < now - on_timeout(parent_type, parent_object, field_definition, field_args, query_context) - else - yield - end - end - - # This is called when a field _would_ be resolved, except that we're over the time limit. - # @return [GraphQL::Schema::TimeoutMiddleware::TimeoutError] An error whose message will be added to the `errors` key - def on_timeout(parent_type, parent_object, field_definition, field_args, field_context) - err = GraphQL::Schema::TimeoutMiddleware::TimeoutError.new(parent_type, field_definition) - if @error_handler - query_proxy = TimeoutQueryProxy.new(field_context.query, field_context) - @error_handler.call(err, query_proxy) - end - err - end - - # This behaves like {GraphQL::Query} but {#context} returns - # the _field-level_ context, not the query-level context. - # This means you can reliably get the `irep_node` and `path` - # from it after the fact. - class TimeoutQueryProxy < SimpleDelegator - def initialize(query, ctx) - @context = ctx - super(query) - end - - attr_reader :context - end - - # This error is raised when a query exceeds `max_seconds`. - # Since it's a child of {GraphQL::ExecutionError}, - # its message will be added to the response's `errors` key. - # - # To raise an error that will stop query resolution, use a custom block - # to take this error and raise a new one which _doesn't_ descend from {GraphQL::ExecutionError}, - # such as `RuntimeError`. - class TimeoutError < GraphQL::ExecutionError - def initialize(parent_type, field_defn) - super("Timeout on #{parent_type.name}.#{field_defn.name}") - end - end - end - end -end diff --git a/lib/graphql/schema/traversal.rb b/lib/graphql/schema/traversal.rb deleted file mode 100644 index 8c6759b98e7..00000000000 --- a/lib/graphql/schema/traversal.rb +++ /dev/null @@ -1,228 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Schema - # Visit the members of this schema and build up artifacts for runtime. - # @api private - class Traversal - # @return [Hash GraphQL::BaseType] - attr_reader :type_map - - # @return [Hash Hash GraphQL::Field>>] - attr_reader :instrumented_field_map - - # @return [Hash Array] - attr_reader :type_reference_map - - # @return [Hash Array] - attr_reader :union_memberships - - - # @param schema [GraphQL::Schema] - def initialize(schema, introspection: true) - @schema = schema - @introspection = introspection - built_in_insts = [ - GraphQL::Relay::ConnectionInstrumentation, - GraphQL::Relay::EdgesInstrumentation, - GraphQL::Relay::Mutation::Instrumentation, - ] - - if schema.query_execution_strategy != GraphQL::Execution::Interpreter - built_in_insts << GraphQL::Schema::Member::Instrumentation - end - - @field_instrumenters = - schema.instrumenters[:field] + - built_in_insts + - schema.instrumenters[:field_after_built_ins] - - # These fields have types specified by _name_, - # So we need to inspect the schema and find those types, - # then update their references. - @late_bound_fields = [] - @type_map = {} - @instrumented_field_map = Hash.new { |h, k| h[k] = {} } - @type_reference_map = Hash.new { |h, k| h[k] = [] } - @union_memberships = Hash.new { |h, k| h[k] = [] } - visit(schema, schema, nil) - resolve_late_bound_fields - end - - private - - # A brute-force appraoch to late binding. - # Just keep trying the whole list, hoping that they - # eventually all resolve. - # This could be replaced with proper dependency tracking. - def resolve_late_bound_fields - # This is a bit tricky, with the writes going to internal state. - prev_late_bound_fields = @late_bound_fields - # Things might get added here during `visit...` - # or they might be added manually if we can't find them by hand - @late_bound_fields = [] - prev_late_bound_fields.each do |(owner_type, field_defn, dynamic_field)| - if @type_map.key?(field_defn.type.unwrap.name) - late_bound_return_type = field_defn.type - resolved_type = @type_map.fetch(late_bound_return_type.unwrap.name) - wrapped_resolved_type = rewrap_resolved_type(late_bound_return_type, resolved_type) - # Update the field definition in place? :thinking_face: - field_defn.type = wrapped_resolved_type - visit_field_on_type(@schema, owner_type, field_defn, dynamic_field: dynamic_field) - else - @late_bound_fields << [owner_type, field_defn, dynamic_field] - end - end - - if @late_bound_fields.any? - # If we visited each field and failed to resolve _any_, - # then we're stuck. - if @late_bound_fields == prev_late_bound_fields - type_names = prev_late_bound_fields.map { |f| f[1] }.map(&:type).map(&:unwrap).map(&:name).uniq - raise <<-ERR -Some late-bound types couldn't be resolved: - -- #{type_names} -- Found __* types: #{@type_map.keys.select { |k| k.start_with?("__") }} - ERR - else - resolve_late_bound_fields - end - end - end - - # The late-bound type may be wrapped with list or non-null types. - # Apply the same wrapping to the resolve type and - # return the maybe-wrapped type - def rewrap_resolved_type(late_bound_type, resolved_inner_type) - case late_bound_type - when GraphQL::NonNullType - rewrap_resolved_type(late_bound_type.of_type, resolved_inner_type).to_non_null_type - when GraphQL::ListType - rewrap_resolved_type(late_bound_type.of_type, resolved_inner_type).to_list_type - when GraphQL::Schema::LateBoundType - resolved_inner_type - else - raise "Unexpected late_bound_type: #{late_bound_type.inspect} (#{late_bound_type.class})" - end - end - - def visit(schema, member, context_description) - case member - when GraphQL::Schema - member.directives.each { |name, directive| visit(schema, directive, "Directive #{name}") } - # Find the starting points, then visit them - visit_roots = [member.query, member.mutation, member.subscription] - if @introspection - introspection_types = schema.introspection_system.types.values - visit_roots.concat(introspection_types) - if member.query - member.introspection_system.entry_points.each do |introspection_field| - # Visit this so that arguments class is preconstructed - # Skip validation since it begins with "__" - visit_field_on_type(schema, member.query, introspection_field, dynamic_field: true) - end - end - end - visit_roots.concat(member.orphan_types) - visit_roots.compact! - visit_roots.each { |t| visit(schema, t, t.name) } - when GraphQL::Directive - member.arguments.each do |name, argument| - @type_reference_map[argument.type.unwrap.to_s] << argument - visit(schema, argument.type, "Directive argument #{member.name}.#{name}") - end - # Construct arguments class here, which is later used to generate GraphQL::Query::Arguments - # to be passed to a resolver proc - GraphQL::Query::Arguments.construct_arguments_class(member) - when GraphQL::BaseType - type_defn = member.unwrap - prev_type = @type_map[type_defn.name] - # Continue to visit this type if it's the first time we've seen it: - if prev_type.nil? - validate_type(type_defn, context_description) - @type_map[type_defn.name] = type_defn - case type_defn - when GraphQL::ObjectType - type_defn.interfaces.each { |i| visit(schema, i, "Interface on #{type_defn.name}") } - visit_fields(schema, type_defn) - when GraphQL::InterfaceType - visit_fields(schema, type_defn) - type_defn.orphan_types.each do |t| - visit(schema, t, "Orphan type for #{type_defn.name}") - end - when GraphQL::UnionType - type_defn.possible_types.each do |t| - @union_memberships[t.name] << type_defn - visit(schema, t, "Possible type for #{type_defn.name}") - end - when GraphQL::InputObjectType - type_defn.arguments.each do |name, arg| - @type_reference_map[arg.type.unwrap.to_s] << arg - visit(schema, arg.type, "Input field #{type_defn.name}.#{name}") - end - - # Construct arguments class here, which is later used to generate GraphQL::Query::Arguments - # to be passed to a resolver proc - if type_defn.arguments_class.nil? - GraphQL::Query::Arguments.construct_arguments_class(type_defn) - end - end - elsif !prev_type.equal?(type_defn) - # If the previous entry in the map isn't the same object we just found, raise. - raise("Duplicate type definition found for name '#{type_defn.name}' at '#{context_description}' (#{prev_type.metadata[:type_class] || prev_type}, #{type_defn.metadata[:type_class] || type_defn})") - end - when Class - if member.respond_to?(:graphql_definition) - graphql_member = member.graphql_definition - visit(schema, graphql_member, context_description) - else - raise GraphQL::Schema::InvalidTypeError.new("Unexpected traversal member: #{member} (#{member.class.name})") - end - else - message = "Unexpected schema traversal member: #{member} (#{member.class.name})" - raise GraphQL::Schema::InvalidTypeError.new(message) - end - end - - def visit_fields(schema, type_defn) - type_defn.all_fields.each do |field_defn| - visit_field_on_type(schema, type_defn, field_defn) - end - end - - def visit_field_on_type(schema, type_defn, field_defn, dynamic_field: false) - base_return_type = field_defn.type.unwrap - if base_return_type.is_a?(GraphQL::Schema::LateBoundType) - @late_bound_fields << [type_defn, field_defn, dynamic_field] - return - end - if dynamic_field - # Don't apply instrumentation to dynamic fields since they're shared constants - instrumented_field_defn = field_defn - else - instrumented_field_defn = @field_instrumenters.reduce(field_defn) do |defn, inst| - inst.instrument(type_defn, defn) - end - @instrumented_field_map[type_defn.name][instrumented_field_defn.name] = instrumented_field_defn - end - @type_reference_map[instrumented_field_defn.type.unwrap.name] << instrumented_field_defn - visit(schema, instrumented_field_defn.type, "Field #{type_defn.name}.#{instrumented_field_defn.name}'s return type") - instrumented_field_defn.arguments.each do |name, arg| - @type_reference_map[arg.type.unwrap.to_s] << arg - visit(schema, arg.type, "Argument #{name} on #{type_defn.name}.#{instrumented_field_defn.name}") - end - - # Construct arguments class here, which is later used to generate GraphQL::Query::Arguments - # to be passed to a resolver proc - GraphQL::Query::Arguments.construct_arguments_class(instrumented_field_defn) - end - - def validate_type(member, context_description) - error_message = GraphQL::Schema::Validation.validate(member) - if error_message - raise GraphQL::Schema::InvalidTypeError.new("#{context_description} is invalid: #{error_message}") - end - end - end - end -end diff --git a/lib/graphql/schema/type_expression.rb b/lib/graphql/schema/type_expression.rb index fc501b8ffc0..5ade5c70145 100644 --- a/lib/graphql/schema/type_expression.rb +++ b/lib/graphql/schema/type_expression.rb @@ -5,13 +5,13 @@ class Schema module TypeExpression # Fetch a type from a type map by its AST specification. # Return `nil` if not found. - # @param type_owner [#get_type] A thing for looking up types by name + # @param type_owner [#type] A thing for looking up types by name # @param ast_node [GraphQL::Language::Nodes::AbstractNode] # @return [Class, GraphQL::Schema::NonNull, GraphQL::Schema:List] def self.build_type(type_owner, ast_node) case ast_node when GraphQL::Language::Nodes::TypeName - type_owner.get_type(ast_node.name) + type_owner.type(ast_node.name) # rubocop:disable Development/ContextIsPassedCop -- this is a `context` or `warden`, it's already query-aware when GraphQL::Language::Nodes::NonNullType ast_inner_type = ast_node.of_type inner_type = build_type(type_owner, ast_inner_type) diff --git a/lib/graphql/schema/type_membership.rb b/lib/graphql/schema/type_membership.rb index 8cc174aaf16..6f25da49db0 100644 --- a/lib/graphql/schema/type_membership.rb +++ b/lib/graphql/schema/type_membership.rb @@ -4,8 +4,6 @@ module GraphQL class Schema # This class joins an object type to an abstract type (interface or union) of which # it is a member. - # - # TODO: Not yet implemented for interfaces. class TypeMembership # @return [Class] attr_accessor :object_type @@ -13,6 +11,9 @@ class TypeMembership # @return [Class, Module] attr_reader :abstract_type + # @return [Hash] + attr_reader :options + # Called when an object is hooked up to an abstract type, such as {Schema::Union.possible_types} # or {Schema::Object.implements} (for interfaces). # @@ -26,9 +27,25 @@ def initialize(abstract_type, object_type, **options) end # @return [Boolean] if false, {#object_type} will be treated as _not_ a member of {#abstract_type} - def visible?(_ctx) - true + def visible?(ctx) + warden = Warden.from_context(ctx) + (@object_type.respond_to?(:visible?) ? warden.visible_type?(@object_type, ctx) : true) && + (@abstract_type.respond_to?(:visible?) ? warden.visible_type?(@abstract_type, ctx) : true) + end + + def graphql_name + "#{@object_type.graphql_name}.#{@abstract_type.kind.interface? ? "implements" : "belongsTo" }.#{@abstract_type.graphql_name}" + end + + def path + graphql_name + end + + def inspect + "#<#{self.class} #{@object_type.inspect} => #{@abstract_type.inspect}>" end + + alias :type_class :itself end end end diff --git a/lib/graphql/schema/union.rb b/lib/graphql/schema/union.rb index 3be086ec6d2..f0ad4feec7f 100644 --- a/lib/graphql/schema/union.rb +++ b/lib/graphql/schema/union.rb @@ -2,7 +2,6 @@ module GraphQL class Schema class Union < GraphQL::Schema::Member - extend GraphQL::Schema::Member::AcceptsDefinition extend GraphQL::Schema::Member::HasUnresolvedTypeError class << self @@ -11,16 +10,17 @@ def inherited(child_class) super end - def possible_types(*types, context: GraphQL::Query::NullContext, **options) - if types.any? + def possible_types(*types, context: GraphQL::Query::NullContext.instance, **options) + if !types.empty? types.each do |t| assert_valid_union_member(t) type_memberships << type_membership_class.new(self, t, **options) end else visible_types = [] + warden = Warden.from_context(context) type_memberships.each do |type_membership| - if type_membership.visible?(context) + if warden.visible_type_membership?(type_membership, context) visible_types << type_membership.object_type end end @@ -28,17 +28,8 @@ def possible_types(*types, context: GraphQL::Query::NullContext, **options) end end - def to_graphql - type_defn = GraphQL::UnionType.new - type_defn.name = graphql_name - type_defn.description = description - type_defn.ast_node = ast_node - type_defn.type_memberships = type_memberships - if respond_to?(:resolve_type) - type_defn.resolve_type = method(:resolve_type) - end - type_defn.metadata[:type_class] = self - type_defn + def all_possible_types + type_memberships.map(&:object_type) end def type_membership_class(membership_class = nil) @@ -79,9 +70,18 @@ def assign_type_membership_object_type(object_type) private def assert_valid_union_member(type_defn) - if type_defn.is_a?(Module) && !type_defn.is_a?(Class) + case type_defn + when Class + if !type_defn.kind.object? + raise ArgumentError, "Union possible_types can only be object types (not #{type_defn.kind.name}, #{type_defn.inspect})" + end + when Module # it's an interface type, defined as a module raise ArgumentError, "Union possible_types can only be object types (not interface types), remove #{type_defn.graphql_name} (#{type_defn.inspect})" + when String, GraphQL::Schema::LateBoundType + # Ok - assume it will get checked later + else + raise ArgumentError, "Union possible_types can only be class-based GraphQL types (not #{type_defn.inspect} (#{type_defn.class.name}))." end end end diff --git a/lib/graphql/schema/unique_within_type.rb b/lib/graphql/schema/unique_within_type.rb index 9356c9dc156..55c5f715296 100644 --- a/lib/graphql/schema/unique_within_type.rb +++ b/lib/graphql/schema/unique_within_type.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -require 'graphql/schema/base_64_bp' +require "base64" module GraphQL class Schema diff --git a/lib/graphql/schema/validation.rb b/lib/graphql/schema/validation.rb deleted file mode 100644 index 83a08d47d7f..00000000000 --- a/lib/graphql/schema/validation.rb +++ /dev/null @@ -1,313 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Schema - # This module provides a function for validating GraphQL types. - # - # Its {RULES} contain objects that respond to `#call(type)`. Rules are - # looked up for given types (by class ancestry), then applied to - # the object until an error is returned. - # - # Remove this in GraphQL-Ruby 2.0 when schema instances are removed. - class Validation - # Lookup the rules for `object` based on its class, - # Then returns an error message or `nil` - # @param object [Object] something to be validated - # @return [String, Nil] error message, if there was one - def self.validate(object) - RULES.each do |parent_class, validations| - if object.is_a?(parent_class) - validations.each do |rule| - if error = rule.call(object) - return error - end - end - end - end - nil - end - - module Rules - # @param property_name [Symbol] The method to validate - # @param allowed_classes [Class] Classes which the return value may be an instance of - # @return [Proc] A proc which will validate the input by calling `property_name` and asserting it is an instance of one of `allowed_classes` - def self.assert_property(property_name, *allowed_classes) - # Hide LateBoundType from user-facing errors - allowed_classes_message = allowed_classes.map(&:name).reject {|n| n.include?("LateBoundType") }.join(" or ") - ->(obj) { - property_value = obj.public_send(property_name) - is_valid_value = allowed_classes.any? { |allowed_class| property_value.is_a?(allowed_class) } - is_valid_value ? nil : "#{property_name} must return #{allowed_classes_message}, not #{property_value.class.name} (#{property_value.inspect})" - } - end - - # @param property_name [Symbol] The method whose return value will be validated - # @param from_class [Class] The class for keys in the return value - # @param to_class [Class] The class for values in the return value - # @return [Proc] A proc to validate that validates the input by calling `property_name` and asserting that the return value is a Hash of `{from_class => to_class}` pairs - def self.assert_property_mapping(property_name, from_class, to_class) - ->(obj) { - property_value = obj.public_send(property_name) - if !property_value.is_a?(Hash) - "#{property_name} must be a hash of {#{from_class.name} => #{to_class.name}}, not a #{property_value.class.name} (#{property_value.inspect})" - else - invalid_key, invalid_value = property_value.find { |key, value| !key.is_a?(from_class) || !value.is_a?(to_class) } - if invalid_key - "#{property_name} must map #{from_class} => #{to_class}, not #{invalid_key.class.name} => #{invalid_value.class.name} (#{invalid_key.inspect} => #{invalid_value.inspect})" - else - nil # OK - end - end - } - end - - # @param property_name [Symbol] The method whose return value will be validated - # @param list_member_class [Class] The class which each member of the returned array should be an instance of - # @return [Proc] A proc to validate the input by calling `property_name` and asserting that the return is an Array of `list_member_class` instances - def self.assert_property_list_of(property_name, list_member_class) - ->(obj) { - property_value = obj.public_send(property_name) - if !property_value.is_a?(Array) - "#{property_name} must be an Array of #{list_member_class.name}, not a #{property_value.class.name} (#{property_value.inspect})" - else - invalid_member = property_value.find { |value| !value.is_a?(list_member_class) } - if invalid_member - "#{property_name} must contain #{list_member_class.name}, not #{invalid_member.class.name} (#{invalid_member.inspect})" - else - nil # OK - end - end - } - end - - def self.count_at_least(item_name, minimum_count, get_items_proc) - ->(type) { - items = get_items_proc.call(type) - - if items.size < minimum_count - "#{type.name} must define at least #{minimum_count} #{item_name}. #{items.size} defined." - else - nil - end - } - end - - def self.assert_named_items_are_valid(item_name, get_items_proc) - ->(type) { - items = get_items_proc.call(type) - error_message = nil - items.each do |item| - item_message = GraphQL::Schema::Validation.validate(item) - if item_message - error_message = "#{item_name} #{item.name.inspect} #{item_message}" - break - end - end - error_message - } - end - - HAS_AT_LEAST_ONE_FIELD = Rules.count_at_least("field", 1, ->(type) { type.all_fields }) - FIELDS_ARE_VALID = Rules.assert_named_items_are_valid("field", ->(type) { type.all_fields }) - HAS_AT_LEAST_ONE_ARGUMENT = Rules.count_at_least("argument", 1, ->(type) { type.arguments }) - - HAS_ONE_OR_MORE_POSSIBLE_TYPES = ->(type) { - type.possible_types.length >= 1 ? nil : "must have at least one possible type" - } - - NAME_IS_STRING = Rules.assert_property(:name, String) - DESCRIPTION_IS_STRING_OR_NIL = Rules.assert_property(:description, String, NilClass) - ARGUMENTS_ARE_STRING_TO_ARGUMENT = Rules.assert_property_mapping(:arguments, String, GraphQL::Argument) - ARGUMENTS_ARE_VALID = Rules.assert_named_items_are_valid("argument", ->(type) { type.arguments.values }) - - DEFAULT_VALUE_IS_VALID_FOR_TYPE = ->(type) { - if !type.default_value.nil? - coerced_value = begin - type.type.coerce_isolated_result(type.default_value) - rescue => ex - ex - end - - if coerced_value.nil? || coerced_value.is_a?(StandardError) - msg = "default value #{type.default_value.inspect} is not valid for type #{type.type}" - msg += " (#{coerced_value})" if coerced_value.is_a?(StandardError) - msg - end - end - } - - DEPRECATED_ARGUMENTS_ARE_OPTIONAL = ->(argument) { - if argument.deprecation_reason && argument.type.non_null? - "must be optional because it's deprecated" - end - } - - TYPE_IS_VALID_INPUT_TYPE = ->(type) { - outer_type = type.type - inner_type = outer_type.respond_to?(:unwrap) ? outer_type.unwrap : nil - - case inner_type - when GraphQL::ScalarType, GraphQL::InputObjectType, GraphQL::EnumType - # OK - else - "type must be a valid input type (Scalar or InputObject), not #{outer_type.class} (#{outer_type})" - end - } - - SCHEMA_CAN_RESOLVE_TYPES = ->(schema) { - if schema.types.values.any? { |type| type.kind.abstract? } && schema.resolve_type_proc.nil? - "schema contains Interfaces or Unions, so you must define a `resolve_type -> (obj, ctx) { ... }` function" - else - # :+1: - end - } - - SCHEMA_CAN_FETCH_IDS = ->(schema) { - has_node_field = schema.query && schema.query.fields.each_value.any?(&:relay_node_field) - if has_node_field && schema.object_from_id_proc.nil? - "schema contains `node(id:...)` field, so you must define a `object_from_id -> (id, ctx) { ... }` function" - else - # :rocket: - end - } - - SCHEMA_CAN_GENERATE_IDS = ->(schema) { - has_id_field = schema.types.values.any? { |t| t.kind.fields? && t.all_fields.any? { |f| f.resolve_proc.is_a?(GraphQL::Relay::GlobalIdResolve) } } - if has_id_field && schema.id_from_object_proc.nil? - "schema contains `global_id_field`, so you must define a `id_from_object -> (obj, type, ctx) { ... }` function" - else - # :ok_hand: - end - } - - SCHEMA_INSTRUMENTERS_ARE_VALID = ->(schema) { - errs = [] - schema.instrumenters[:query].each do |inst| - if !inst.respond_to?(:before_query) || !inst.respond_to?(:after_query) - errs << "`instrument(:query, #{inst})` is invalid: must respond to `before_query(query)` and `after_query(query)` " - end - end - - schema.instrumenters[:field].each do |inst| - if !inst.respond_to?(:instrument) - errs << "`instrument(:field, #{inst})` is invalid: must respond to `instrument(type, field)`" - end - end - - if errs.any? - errs.join("Invalid instrumenters:\n" + errs.join("\n")) - else - nil - end - } - - RESERVED_TYPE_NAME = ->(type) { - if type.name.start_with?('__') && !type.introspection? - # TODO: make this a hard failure in a later version - GraphQL::Deprecation.warn("Name #{type.name.inspect} must not begin with \"__\", which is reserved by GraphQL introspection.") - nil - else - # ok name - end - } - - RESERVED_NAME = ->(named_thing) { - if named_thing.name.start_with?('__') - # TODO: make this a hard failure in a later version - GraphQL::Deprecation.warn("Name #{named_thing.name.inspect} must not begin with \"__\", which is reserved by GraphQL introspection.") - nil - else - # no worries - end - } - - INTERFACES_ARE_IMPLEMENTED = ->(obj_type) { - field_errors = [] - obj_type.interfaces.each do |interface_type| - interface_type.fields.each do |field_name, field_defn| - object_field = obj_type.get_field(field_name) - if object_field.nil? - field_errors << %|"#{field_name}" is required by #{interface_type.name} but not implemented by #{obj_type.name}| - elsif !GraphQL::Execution::Typecast.subtype?(field_defn.type, object_field.type) - field_errors << %|"#{field_name}" is required by #{interface_type.name} to return #{field_defn.type} but #{obj_type.name}.#{field_name} returns #{object_field.type}| - else - field_defn.arguments.each do |arg_name, arg_defn| - object_field_arg = object_field.arguments[arg_name] - if object_field_arg.nil? - field_errors << %|"#{arg_name}" argument is required by #{interface_type.name}.#{field_name} but not accepted by #{obj_type.name}.#{field_name}| - elsif arg_defn.type != object_field_arg.type - field_errors << %|"#{arg_name}" is required by #{interface_type.name}.#{field_defn.name} to accept #{arg_defn.type} but #{obj_type.name}.#{field_name} accepts #{object_field_arg.type} for "#{arg_name}"| - end - end - - object_field.arguments.each do |arg_name, arg_defn| - if field_defn.arguments[arg_name].nil? && arg_defn.type.is_a?(GraphQL::NonNullType) - field_errors << %|"#{arg_name}" is not accepted by #{interface_type.name}.#{field_name} but required by #{obj_type.name}.#{field_name}| - end - end - end - end - end - if field_errors.any? - "#{obj_type.name} failed to implement some interfaces: #{field_errors.join(", ")}" - else - nil - end - } - end - - # A mapping of `{Class => [Proc, Proc...]}` pairs. - # To validate an instance, find entries where `object.is_a?(key)` is true. - # Then apply each rule from the matching values. - RULES = { - GraphQL::Field => [ - Rules::NAME_IS_STRING, - Rules::RESERVED_NAME, - Rules::DESCRIPTION_IS_STRING_OR_NIL, - Rules.assert_property(:deprecation_reason, String, NilClass), - Rules.assert_property(:type, GraphQL::BaseType, GraphQL::Schema::LateBoundType), - Rules.assert_property(:property, Symbol, NilClass), - Rules::ARGUMENTS_ARE_STRING_TO_ARGUMENT, - Rules::ARGUMENTS_ARE_VALID, - ], - GraphQL::Argument => [ - Rules::NAME_IS_STRING, - Rules::RESERVED_NAME, - Rules::DESCRIPTION_IS_STRING_OR_NIL, - Rules.assert_property(:deprecation_reason, String, NilClass), - Rules::TYPE_IS_VALID_INPUT_TYPE, - Rules::DEFAULT_VALUE_IS_VALID_FOR_TYPE, - Rules::DEPRECATED_ARGUMENTS_ARE_OPTIONAL, - ], - GraphQL::BaseType => [ - Rules::NAME_IS_STRING, - Rules::RESERVED_TYPE_NAME, - Rules::DESCRIPTION_IS_STRING_OR_NIL, - ], - GraphQL::ObjectType => [ - Rules::HAS_AT_LEAST_ONE_FIELD, - Rules.assert_property_list_of(:interfaces, GraphQL::InterfaceType), - Rules::FIELDS_ARE_VALID, - Rules::INTERFACES_ARE_IMPLEMENTED, - ], - GraphQL::InputObjectType => [ - Rules::HAS_AT_LEAST_ONE_ARGUMENT, - Rules::ARGUMENTS_ARE_STRING_TO_ARGUMENT, - Rules::ARGUMENTS_ARE_VALID, - ], - GraphQL::UnionType => [ - Rules.assert_property_list_of(:possible_types, GraphQL::ObjectType), - Rules::HAS_ONE_OR_MORE_POSSIBLE_TYPES, - ], - GraphQL::InterfaceType => [ - Rules::FIELDS_ARE_VALID, - ], - GraphQL::Schema => [ - Rules::SCHEMA_INSTRUMENTERS_ARE_VALID, - Rules::SCHEMA_CAN_RESOLVE_TYPES, - Rules::SCHEMA_CAN_FETCH_IDS, - Rules::SCHEMA_CAN_GENERATE_IDS, - ], - } - end - end -end diff --git a/lib/graphql/schema/validator.rb b/lib/graphql/schema/validator.rb index 2ed00dad1e4..dd3b606cff1 100644 --- a/lib/graphql/schema/validator.rb +++ b/lib/graphql/schema/validator.rb @@ -7,7 +7,6 @@ class Validator # @return [GraphQL::Schema::Argument, GraphQL::Schema::Field, GraphQL::Schema::Resolver, Class] attr_reader :validated - # TODO should this implement `if:` and `unless:` ? # @param validated [GraphQL::Schema::Argument, GraphQL::Schema::Field, GraphQL::Schema::Resolver, Class] The argument or argument owner this validator is attached to # @param allow_blank [Boolean] if `true`, then objects that respond to `.blank?` and return true for `.blank?` will skip this validation # @param allow_null [Boolean] if `true`, then incoming `null`s will skip this validation @@ -25,26 +24,6 @@ def validate(object, context, value) raise GraphQL::RequiredImplementationMissingError, "Validator classes should implement #validate" end - # This is called by the validation system and eventually calls {#validate}. - # @api private - def apply(object, context, value) - if value.nil? - if @allow_null - nil # skip this - else - "%{validated} can't be null" - end - elsif value.respond_to?(:blank?) && value.blank? - if @allow_blank - nil # skip this - else - "%{validated} can't be blank" - end - else - validate(object, context, value) - end - end - # This is like `String#%`, but it supports the case that only some of `string`'s # values are present in `substitutions` def partial_format(string, substitutions) @@ -55,6 +34,21 @@ def partial_format(string, substitutions) string end + # @return [Object] The current value to use for validation, based on `config_value` from configuration time. If a Proc is given, this calls it and returns it. + def validation_parameter(config_value) + if config_value.is_a?(Proc) + config_value.call + else + config_value + end + end + + # @return [Boolean] `true` if `value` is `nil` and this validator has `allow_null: true` or if value is `.blank?` and this validator has `allow_blank: true` + def permitted_empty_value?(value) + (value.nil? && @allow_null) || + (@allow_blank && value.respond_to?(:blank?) && value.blank?) + end + # @param schema_member [GraphQL::Schema::Field, GraphQL::Schema::Argument, Class] # @param validates_hash [Hash{Symbol => Hash}, Hash{Class => Hash} nil] A configuration passed as `validates:` # @return [Array] @@ -62,6 +56,21 @@ def self.from_config(schema_member, validates_hash) if validates_hash.nil? || validates_hash.empty? EMPTY_ARRAY else + validates_hash = validates_hash.dup + + default_options = {} + if validates_hash[:allow_null] + default_options[:allow_null] = validates_hash.delete(:allow_null) + end + if validates_hash[:allow_blank] + default_options[:allow_blank] = validates_hash.delete(:allow_blank) + end + + # allow_nil or allow_blank are the _only_ validations: + if validates_hash.empty? + validates_hash = default_options + end + validates_hash.map do |validator_name, options| validator_class = case validator_name when Class @@ -69,7 +78,11 @@ def self.from_config(schema_member, validates_hash) else all_validators[validator_name] || raise(ArgumentError, "unknown validation: #{validator_name.inspect}") end - validator_class.new(validated: schema_member, **options) + if options.is_a?(Hash) + validator_class.new(validated: schema_member, **(default_options.merge(options))) + else + validator_class.new(options, validated: schema_member, **default_options) + end end end end @@ -98,7 +111,7 @@ class << self self.all_validators = {} - include Schema::FindInheritedValue::EmptyObjects + include GraphQL::EmptyObjects class ValidationFailedError < GraphQL::ExecutionError attr_reader :errors @@ -122,14 +135,14 @@ def self.validate!(validators, object, context, value, as: nil) validators.each do |validator| validated = as || validator.validated - errors = validator.apply(object, context, value) + errors = validator.validate(object, context, value) if errors && - (errors.is_a?(Array) && errors != EMPTY_ARRAY) || - (errors.is_a?(String)) + (errors.is_a?(Array) && errors != EMPTY_ARRAY) || + (errors.is_a?(String)) if all_errors.frozen? # It's empty all_errors = [] end - interpolation_vars = { validated: validated.graphql_name } + interpolation_vars = { validated: validated.graphql_name, value: value.inspect } if errors.is_a?(String) all_errors << (errors % interpolation_vars) else @@ -139,7 +152,7 @@ def self.validate!(validators, object, context, value, as: nil) end end - if all_errors.any? + if !all_errors.empty? raise ValidationFailedError.new(errors: all_errors) end nil @@ -161,3 +174,9 @@ def self.validate!(validators, object, context, value, as: nil) GraphQL::Schema::Validator.install(:exclusion, GraphQL::Schema::Validator::ExclusionValidator) require "graphql/schema/validator/required_validator" GraphQL::Schema::Validator.install(:required, GraphQL::Schema::Validator::RequiredValidator) +require "graphql/schema/validator/allow_null_validator" +GraphQL::Schema::Validator.install(:allow_null, GraphQL::Schema::Validator::AllowNullValidator) +require "graphql/schema/validator/allow_blank_validator" +GraphQL::Schema::Validator.install(:allow_blank, GraphQL::Schema::Validator::AllowBlankValidator) +require "graphql/schema/validator/all_validator" +GraphQL::Schema::Validator.install(:all, GraphQL::Schema::Validator::AllValidator) diff --git a/lib/graphql/schema/validator/all_validator.rb b/lib/graphql/schema/validator/all_validator.rb new file mode 100644 index 00000000000..5850144a02f --- /dev/null +++ b/lib/graphql/schema/validator/all_validator.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module GraphQL + class Schema + class Validator + # Use this to validate each member of an array value. + # + # @example validate format of all strings in an array + # + # argument :handles, [String], + # validates: { all: { format: { with: /\A[a-z0-9_]+\Z/ } } } + # + # @example multiple validators can be combined + # + # argument :handles, [String], + # validates: { all: { format: { with: /\A[a-z0-9_]+\Z/ }, length: { maximum: 32 } } } + # + # @example any type can be used + # + # argument :choices, [Integer], + # validates: { all: { inclusion: { in: 1..12 } } } + # + class AllValidator < Validator + def initialize(validated:, allow_blank: false, allow_null: false, **validators) + super(validated: validated, allow_blank: allow_blank, allow_null: allow_null) + + @validators = Validator.from_config(validated, validators) + end + + def validate(object, context, value) + return EMPTY_ARRAY if permitted_empty_value?(value) + + all_errors = EMPTY_ARRAY + + value.each do |subvalue| + @validators.each do |validator| + errors = validator.validate(object, context, subvalue) + if errors && + (errors.is_a?(Array) && errors != EMPTY_ARRAY) || + (errors.is_a?(String)) + if all_errors.frozen? # It's empty + all_errors = [] + end + if errors.is_a?(String) + all_errors << errors + else + all_errors.concat(errors) + end + end + end + end + + unless all_errors.frozen? + all_errors.uniq! + end + + all_errors + end + end + end + end +end diff --git a/lib/graphql/schema/validator/allow_blank_validator.rb b/lib/graphql/schema/validator/allow_blank_validator.rb new file mode 100644 index 00000000000..c2df2b5f672 --- /dev/null +++ b/lib/graphql/schema/validator/allow_blank_validator.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module GraphQL + class Schema + class Validator + # Use this to specifically reject values that respond to `.blank?` and respond truthy for that method. + # + # @example Require a non-empty string for an argument + # argument :name, String, required: true, validate: { allow_blank: false } + class AllowBlankValidator < Validator + def initialize(allow_blank_positional = nil, allow_blank: nil, message: "%{validated} can't be blank", **default_options) + @message = message + super(**default_options) + @allow_blank = allow_blank.nil? ? allow_blank_positional : allow_blank + end + + def validate(_object, _context, value) + if value.respond_to?(:blank?) && value.blank? + if (value.nil? && validation_parameter(@allow_null)) || validation_parameter(@allow_blank) + # pass + else + validation_parameter(@message) + end + end + end + end + end + end +end diff --git a/lib/graphql/schema/validator/allow_null_validator.rb b/lib/graphql/schema/validator/allow_null_validator.rb new file mode 100644 index 00000000000..9089f00f945 --- /dev/null +++ b/lib/graphql/schema/validator/allow_null_validator.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module GraphQL + class Schema + class Validator + # Use this to specifically reject or permit `nil` values (given as `null` from GraphQL). + # + # @example require a non-null value for an argument if it is provided + # argument :name, String, required: false, validates: { allow_null: false } + class AllowNullValidator < Validator + MESSAGE = "%{validated} can't be null" + def initialize(allow_null_positional = nil, allow_null: nil, message: MESSAGE, **default_options) + @message = message + super(**default_options) + @allow_null = allow_null.nil? ? allow_null_positional : allow_null + end + + def validate(_object, _context, value) + if value.nil? && !validation_parameter(@allow_null) + validation_parameter(@message) + end + end + end + end + end +end diff --git a/lib/graphql/schema/validator/exclusion_validator.rb b/lib/graphql/schema/validator/exclusion_validator.rb index 164b1578311..1e915eea8e1 100644 --- a/lib/graphql/schema/validator/exclusion_validator.rb +++ b/lib/graphql/schema/validator/exclusion_validator.rb @@ -21,8 +21,10 @@ def initialize(message: "%{validated} is reserved", in:, **default_options) end def validate(_object, _context, value) - if @in_list.include?(value) - @message + if permitted_empty_value?(value) + # pass + elsif validation_parameter(@in_list).include?(value) + validation_parameter(@message) end end end diff --git a/lib/graphql/schema/validator/format_validator.rb b/lib/graphql/schema/validator/format_validator.rb index cca2c774e8a..e790e3ecd17 100644 --- a/lib/graphql/schema/validator/format_validator.rb +++ b/lib/graphql/schema/validator/format_validator.rb @@ -18,10 +18,6 @@ class Validator # # It's pretty hard to come up with a legitimate use case for `without:` # class FormatValidator < Validator - if !String.method_defined?(:match?) - using GraphQL::StringMatchBackport - end - # @param with [RegExp, nil] # @param without [Regexp, nil] # @param message [String] @@ -38,9 +34,12 @@ def initialize( end def validate(_object, _context, value) - if (@with_pattern && !value.match?(@with_pattern)) || - (@without_pattern && value.match?(@without_pattern)) - @message + if permitted_empty_value?(value) + # Do nothing + elsif value.nil? || + (@with_pattern && !value.match?(validation_parameter(@with_pattern))) || + (@without_pattern && value.match?(validation_parameter(@without_pattern))) + validation_parameter(@message) end end end diff --git a/lib/graphql/schema/validator/inclusion_validator.rb b/lib/graphql/schema/validator/inclusion_validator.rb index bd1f8d1c4f9..2cf4f3e0a57 100644 --- a/lib/graphql/schema/validator/inclusion_validator.rb +++ b/lib/graphql/schema/validator/inclusion_validator.rb @@ -23,8 +23,10 @@ def initialize(in:, message: "%{validated} is not included in the list", **defau end def validate(_object, _context, value) - if !@in_list.include?(value) - @message + if permitted_empty_value?(value) + # pass + elsif !validation_parameter(@in_list).include?(value) + validation_parameter(@message) end end end diff --git a/lib/graphql/schema/validator/length_validator.rb b/lib/graphql/schema/validator/length_validator.rb index cb366b15eee..69a0b2037ab 100644 --- a/lib/graphql/schema/validator/length_validator.rb +++ b/lib/graphql/schema/validator/length_validator.rb @@ -43,12 +43,14 @@ def initialize( end def validate(_object, _context, value) - if @maximum && value.length > @maximum - partial_format(@too_long, { count: @maximum }) - elsif @minimum && value.length < @minimum - partial_format(@too_short, { count: @minimum }) - elsif @is && value.length != @is - partial_format(@wrong_length, { count: @is }) + return if permitted_empty_value?(value) # pass in this case + length = value.nil? ? 0 : value.length + if (current_max = validation_parameter(@maximum)) && length > current_max + partial_format(validation_parameter(@too_long), { count: current_max }) + elsif (current_min = validation_parameter(@minimum)) && length < current_min + partial_format(validation_parameter(@too_short), { count: current_min }) + elsif (current_is = validation_parameter(@is)) && length != current_is + partial_format(validation_parameter(@wrong_length), { count: current_is }) end end end diff --git a/lib/graphql/schema/validator/numericality_validator.rb b/lib/graphql/schema/validator/numericality_validator.rb index e053beb2f93..f59b08d8685 100644 --- a/lib/graphql/schema/validator/numericality_validator.rb +++ b/lib/graphql/schema/validator/numericality_validator.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true module GraphQL class Schema class Validator @@ -24,13 +25,15 @@ class NumericalityValidator < Validator # @param other_than [Integer] # @param odd [Boolean] # @param even [Boolean] + # @param within [Range] # @param message [String] used for all validation failures def initialize( greater_than: nil, greater_than_or_equal_to: nil, less_than: nil, less_than_or_equal_to: nil, equal_to: nil, other_than: nil, - odd: nil, even: nil, + odd: nil, even: nil, within: nil, message: "%{validated} must be %{comparison} %{target}", + null_message: Validator::AllowNullValidator::MESSAGE, **default_options ) @@ -42,27 +45,35 @@ def initialize( @other_than = other_than @odd = odd @even = even + @within = within @message = message + @null_message = null_message super(**default_options) end def validate(object, context, value) - if @greater_than && value <= @greater_than - partial_format(@message, { comparison: "greater than", target: @greater_than }) - elsif @greater_than_or_equal_to && value < @greater_than_or_equal_to - partial_format(@message, { comparison: "greater than or equal to", target: @greater_than_or_equal_to }) - elsif @less_than && value >= @less_than - partial_format(@message, { comparison: "less than", target: @less_than }) - elsif @less_than_or_equal_to && value > @less_than_or_equal_to - partial_format(@message, { comparison: "less than or equal to", target: @less_than_or_equal_to }) - elsif @equal_to && value != @equal_to - partial_format(@message, { comparison: "equal to", target: @equal_to }) - elsif @other_than && value == @other_than - partial_format(@message, { comparison: "something other than", target: @other_than }) - elsif @even && !value.even? - (partial_format(@message, { comparison: "even", target: "" })).strip - elsif @odd && !value.odd? - (partial_format(@message, { comparison: "odd", target: "" })).strip + if permitted_empty_value?(value) + # pass in this case + elsif value.nil? # @allow_null is handled in the parent class + validation_parameter(@null_message) + elsif (current_greater_than = validation_parameter(@greater_than)) && value <= current_greater_than + partial_format(validation_parameter(@message), { comparison: "greater than", target: current_greater_than }) + elsif (current_greater_than_or_equal_to = validation_parameter(@greater_than_or_equal_to)) && value < current_greater_than_or_equal_to + partial_format(validation_parameter(@message), { comparison: "greater than or equal to", target: current_greater_than_or_equal_to }) + elsif (current_less_than = validation_parameter(@less_than)) && value >= current_less_than + partial_format(validation_parameter(@message), { comparison: "less than", target: current_less_than }) + elsif (current_less_than_or_equal_to = validation_parameter(@less_than_or_equal_to)) && value > current_less_than_or_equal_to + partial_format(validation_parameter(@message), { comparison: "less than or equal to", target: current_less_than_or_equal_to }) + elsif (current_equal_to = validation_parameter(@equal_to)) && value != current_equal_to + partial_format(validation_parameter(@message), { comparison: "equal to", target: current_equal_to }) + elsif (current_other_than = validation_parameter(@other_than)) && value == current_other_than + partial_format(validation_parameter(@message), { comparison: "something other than", target: current_other_than }) + elsif validation_parameter(@even) && !value.even? + (partial_format(validation_parameter(@message), { comparison: "even", target: "" })).strip + elsif validation_parameter(@odd) && !value.odd? + (partial_format(validation_parameter(@message), { comparison: "odd", target: "" })).strip + elsif (current_within = validation_parameter(@within)) && !current_within.include?(value) + partial_format(validation_parameter(@message), { comparison: "within", target: current_within }) end end end diff --git a/lib/graphql/schema/validator/required_validator.rb b/lib/graphql/schema/validator/required_validator.rb index 1a24eff5f9c..dfeabe80bea 100644 --- a/lib/graphql/schema/validator/required_validator.rb +++ b/lib/graphql/schema/validator/required_validator.rb @@ -8,13 +8,20 @@ class Validator # # (This is for specifying mutually exclusive sets of arguments.) # + # If you use {GraphQL::Schema::Visibility} to hide all the arguments in a `one_of: [..]` set, + # then a developer-facing {GraphQL::Error} will be raised during execution. Pass `allow_all_hidden: true` to + # skip validation in this case instead. + # + # This validator also implements `argument ... required: :nullable`. If an argument has `required: :nullable` + # but it's hidden with {GraphQL::Schema::Visibility}, then this validator doesn't run. + # # @example Require exactly one of these arguments # # field :update_amount, IngredientAmount, null: false do # argument :ingredient_id, ID, required: true # argument :cups, Integer, required: false # argument :tablespoons, Integer, required: false - # argument :teaspoons, Integer, required: true + # argument :teaspoons, Integer, required: false # validates required: { one_of: [:cups, :tablespoons, :teaspoons] } # end # @@ -28,40 +35,130 @@ class Validator # validates required: { one_of: [:node_id, [:object_type, :object_id]] } # end # + # @example require _some_ value for an argument, even if it's null + # field :update_settings, AccountSettings do + # # `required: :nullable` means this argument must be given, but may be `null` + # argument :age, Integer, required: :nullable + # end + # class RequiredValidator < Validator - # @param one_of [Symbol, Array] An argument, or a list of arguments, that represents a valid set of inputs for this field + # @param one_of [Array] A list of arguments, exactly one of which is required for this field + # @param argument [Symbol] An argument that is required for this field + # @param allow_all_hidden [Boolean] If `true`, then this validator won't run if all the `one_of: ...` arguments have been hidden # @param message [String] - def initialize(one_of:, message: "%{validated} has the wrong arguments", **default_options) - @one_of = one_of + def initialize(one_of: nil, argument: nil, allow_all_hidden: nil, message: nil, **default_options) + @one_of = if one_of + one_of + elsif argument + [ argument ] + else + raise ArgumentError, "`one_of:` or `argument:` must be given in `validates required: {...}`" + end + @allow_all_hidden = allow_all_hidden.nil? ? !!argument : allow_all_hidden @message = message super(**default_options) end - def validate(_object, _context, value) - matched_conditions = 0 + def validate(_object, context, value) + fully_matched_conditions = 0 + partially_matched_conditions = 0 - @one_of.each do |one_of_condition| - case one_of_condition - when Symbol - if value.key?(one_of_condition) - matched_conditions += 1 - end - when Array - if one_of_condition.all? { |k| value.key?(k) } - matched_conditions += 1 - break + visible_keywords = context.types.arguments(@validated).map(&:keyword) + no_visible_conditions = true + + if !value.nil? + validation_parameter(@one_of).each do |one_of_condition| + one_of_condition = validation_parameter(one_of_condition) + case one_of_condition + when Symbol + if no_visible_conditions && visible_keywords.include?(one_of_condition) + no_visible_conditions = false + end + + if value.key?(one_of_condition) + fully_matched_conditions += 1 + end + when Array + any_match = false + full_match = true + + one_of_condition.each do |k| + if no_visible_conditions && visible_keywords.include?(k) + no_visible_conditions = false + end + if value.key?(k) + any_match = true + else + full_match = false + end + end + + partial_match = !full_match && any_match + + if full_match + fully_matched_conditions += 1 + end + + if partial_match + partially_matched_conditions += 1 + end + else + raise ArgumentError, "Unknown one_of condition: #{one_of_condition.inspect}" end + end + end + + if no_visible_conditions + if validation_parameter(@allow_all_hidden) + return nil else - raise ArgumentError, "Unknown one_of condition: #{one_of_condition.inspect}" + raise GraphQL::Error, <<~ERR + #{@validated.path} validates `required: ...` but all required arguments were hidden. + + Update your schema definition to allow the client to see some fields or skip validation by adding `required: { ..., allow_all_hidden: true }` + ERR end end - if matched_conditions == 1 + if fully_matched_conditions == 1 && partially_matched_conditions == 0 nil # OK else - @message + validation_parameter(@message) || build_message(context) + end + end + + def build_message(context) + argument_definitions = context.types.arguments(@validated) + + required_names = @one_of.map do |arg_keyword| + arg_keyword = validation_parameter(arg_keyword) + if arg_keyword.is_a?(Array) + names = arg_keyword.map { |arg| arg_keyword_to_graphql_name(argument_definitions, validation_parameter(arg)) } + names.compact! # hidden arguments are `nil` + "(" + names.join(" and ") + ")" + else + arg_keyword_to_graphql_name(argument_definitions, arg_keyword) + end + end + required_names.compact! # remove entries for hidden arguments + + + case required_names.size + when 0 + # The required definitions were hidden from the client. + # Another option here would be to raise an error in the application.... + "%{validated} is missing a required argument." + when 1 + "%{validated} must include the following argument: #{required_names.first}." + else + "%{validated} must include exactly one of the following arguments: #{required_names.join(", ")}." end end + + def arg_keyword_to_graphql_name(argument_definitions, arg_keyword) + argument_definition = argument_definitions.find { |defn| defn.keyword == arg_keyword } + argument_definition&.graphql_name + end end end end diff --git a/lib/graphql/schema/visibility.rb b/lib/graphql/schema/visibility.rb new file mode 100644 index 00000000000..cae29642833 --- /dev/null +++ b/lib/graphql/schema/visibility.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true +require "graphql/schema/visibility/profile" +require "graphql/schema/visibility/migration" +require "graphql/schema/visibility/visit" + +module GraphQL + class Schema + # Use this plugin to make some parts of your schema hidden from some viewers. + # + class Visibility + class TypeConfigurationError < GraphQL::Error + def initialize(config_message, config_str) + message = "GraphQL::Schema::Visibility already preloaded, but #{config_message} added to the schema. Move this `#{config_str}` configuration above `use(GraphQL::Schema::Visibility)" + super(message) + end + end + # @param schema [Class] + # @param profiles [Hash Hash>] A hash of `name => context` pairs for preloading visibility profiles + # @param preload [Boolean] if `true`, load the default schema profile and all named profiles immediately (defaults to `true` for `Rails.env.production?` and `Rails.env.staging?`) + # @param migration_errors [Boolean] if `true`, raise an error when `Visibility` and `Warden` return different results + def self.use(schema, dynamic: false, profiles: EmptyObjects::EMPTY_HASH, preload: (defined?(Rails.env) ? (Rails.env.production? || Rails.env.staging? || nil) : false), migration_errors: false) + profiles&.each { |name, ctx| + ctx[:visibility_profile] = name + ctx.freeze + } + schema.visibility = self.new(schema, dynamic: dynamic, preload: preload, profiles: profiles, migration_errors: migration_errors) + end + + def initialize(schema, dynamic:, preload:, profiles:, migration_errors:, configuration_inherited: false) + @schema = schema + schema.use_visibility_profile = true + schema.visibility_profile_class = if migration_errors + Visibility::Migration + else + Visibility::Profile + end + @preload = preload + @profiles = profiles + @cached_profiles = {} + @dynamic = dynamic + @migration_errors = migration_errors + # Top-level type caches: + @visit = nil + @interface_type_memberships = nil + @directives = nil + @types = nil + @all_references = nil + @loaded_all = false + @configuration_inherited = configuration_inherited + if preload + self.preload + end + end + + def freeze + load_all + @visit = true + @interface_type_memberships.default_proc = nil + @all_references.default_proc = nil + super + end + + def all_directives + load_all + @directives + end + + def all_interface_type_memberships + load_all + @interface_type_memberships + end + + def all_references + load_all + @all_references + end + + def get_type(type_name) + load_all + @types[type_name] + end + + attr_accessor :types + + def preload? + @preload + end + + def preload + # Traverse the schema now (and in the *_configured hooks below) + # To make sure things are loaded during boot + @preloaded_types = Set.new + types_to_visit = [ + @schema.query, + @schema.mutation, + @schema.subscription, + *@schema.introspection_system.types.values, + *@schema.introspection_system.entry_points.map { |ep| ep.type.unwrap }, + *@schema.orphan_types, + ] + # Root types may have been nil: + types_to_visit.compact! + ensure_all_loaded(types_to_visit) + @cached_profiles.clear + @profiles.each do |profile_name, example_ctx| + prof = profile_for(example_ctx) + prof.preload + end + end + + # @api private + def query_configured(query_type) + require_if_preloaded("a query type was", "query(...)") + end + + # @api private + def mutation_configured(mutation_type) + require_if_preloaded("a mutation type was", "mutation(...)") + end + + # @api private + def subscription_configured(subscription_type) + require_if_preloaded("a mutation type was", "subscription(...)") + end + + # @api private + def orphan_types_configured(orphan_types) + require_if_preloaded("orphan types were", "orphan_types(...)") + end + + # @api private + def introspection_system_configured(introspection_system) + require_if_preloaded("custom introspection was", "introspection(...)") + end + + # Make another Visibility for `schema` based on this one + # @return [Visibility] + # @api private + def dup_for(other_schema) + self.class.new( + other_schema, + dynamic: @dynamic, + preload: @preload, + profiles: @profiles, + migration_errors: @migration_errors, + configuration_inherited: true, + ) + end + + def migration_errors? + @migration_errors + end + + attr_reader :cached_profiles + + def profile_for(context) + if !@profiles.empty? + visibility_profile = context[:visibility_profile] + if @profiles.include?(visibility_profile) + profile_ctx = @profiles[visibility_profile] + @cached_profiles[visibility_profile] ||= @schema.visibility_profile_class.new(name: visibility_profile, context: profile_ctx, schema: @schema, visibility: self) + elsif @dynamic + if context.is_a?(Query::NullContext) + top_level_profile + else + @schema.visibility_profile_class.new(context: context, schema: @schema, visibility: self) + end + elsif !context.key?(:visibility_profile) + raise ArgumentError, "#{@schema} expects a visibility profile, but `visibility_profile:` wasn't passed. Provide a `visibility_profile:` value or add `dynamic: true` to your visibility configuration." + else + raise ArgumentError, "`#{visibility_profile.inspect}` isn't allowed for `visibility_profile:` (must be one of #{@profiles.keys.map(&:inspect).join(", ")}). Or, add `#{visibility_profile.inspect}` to the list of profiles in the schema definition." + end + elsif context.is_a?(Query::NullContext) + top_level_profile + else + @schema.visibility_profile_class.new(context: context, schema: @schema, visibility: self) + end + end + + attr_reader :top_level + + # @api private + attr_reader :unfiltered_interface_type_memberships + + def top_level_profile(refresh: false) + if refresh + @top_level_profile = nil + end + @top_level_profile ||= @schema.visibility_profile_class.new(context: @schema.null_context, schema: @schema, visibility: self) + end + + private + + def require_if_preloaded(config_message, config_code) + case @preload + when false + # Rails.env wasn't defined, so this won't try to preload unless manually set to true + when true, nil + if @configuration_inherited + preload + else + raise TypeConfigurationError.new(config_message, config_code) + end + end + end + + def ensure_all_loaded(types_to_visit) + while (type = types_to_visit.shift) + if type.kind.fields? && @preloaded_types.add?(type) + type.all_field_definitions.each do |field_defn| + field_defn.ensure_loaded + types_to_visit << field_defn.type.unwrap + end + end + end + top_level_profile(refresh: true) + nil + end + + def load_all(types: nil) + if @visit.nil? + # Set up the visit system + @interface_type_memberships = Hash.new { |h, interface_type| + h[interface_type] = Hash.new { |h2, obj_type| + h2[obj_type] = [] + }.compare_by_identity + }.compare_by_identity + @directives = [] + @types = {} # String => Module + @all_references = Hash.new { |h, member| h[member] = Set.new.compare_by_identity }.compare_by_identity + @unions_for_references = Set.new + @visit = Visibility::Visit.new(@schema) do |member| + if member.is_a?(Module) + type_name = member.graphql_name + if (prev_t = @types[type_name]) + if prev_t.is_a?(Array) + prev_t << member + else + @types[type_name] = [member, prev_t] + end + else + @types[member.graphql_name] = member + end + member.directives.each { |dir| @all_references[dir.class] << member } + if member < GraphQL::Schema::Directive + @directives << member + elsif member.respond_to?(:interface_type_memberships) + member.interface_type_memberships.each do |itm| + @all_references[itm.abstract_type] << member + # `itm.object_type` may not actually be `member` if this implementation + # is inherited from a superclass + @interface_type_memberships[itm.abstract_type][member] << itm + end + elsif member < GraphQL::Schema::Union + @unions_for_references << member + end + elsif member.is_a?(GraphQL::Schema::Argument) + member.validate_default_value + @all_references[member.type.unwrap] << member + if !(dirs = member.directives).empty? + dir_owner = member.owner + if dir_owner.respond_to?(:owner) + dir_owner = dir_owner.owner + end + dirs.each { |dir| @all_references[dir.class] << dir_owner } + end + elsif member.is_a?(GraphQL::Schema::Field) + @all_references[member.type.unwrap] << member + if !(dirs = member.directives).empty? + dir_owner = member.owner + dirs.each { |dir| @all_references[dir.class] << dir_owner } + end + elsif member.is_a?(GraphQL::Schema::EnumValue) + if !(dirs = member.directives).empty? + dir_owner = member.owner + dirs.each { |dir| @all_references[dir.class] << dir_owner } + end + end + true + end + + @schema.root_types.each { |t| @all_references[t] << true } + @schema.introspection_system.types.each_value { |t| @all_references[t] << true } + @schema.directives.each_value { |dir_class| @all_references[dir_class] << true } + + @visit.visit_each(types: []) # visit default directives + end + + if types + @visit.visit_each(types: types, directives: []) + elsif @loaded_all == false + @loaded_all = true + @visit.visit_each + else + # already loaded all + return + end + + # TODO: somehow don't iterate over all these, + # only the ones that may have been modified + @interface_type_memberships.each do |int_type, obj_type_memberships| + referrers = @all_references[int_type].select { |r| r.is_a?(GraphQL::Schema::Field) } + if !referrers.empty? + obj_type_memberships.each_key do |impl_type| + @all_references[impl_type] |= referrers + end + end + end + + @unions_for_references.each do |union_type| + refs = @all_references[union_type] + union_type.all_possible_types.each do |object_type| + @all_references[object_type] |= refs # Add new items + end + end + end + end + end +end diff --git a/lib/graphql/schema/visibility/migration.rb b/lib/graphql/schema/visibility/migration.rb new file mode 100644 index 00000000000..fa545f130e6 --- /dev/null +++ b/lib/graphql/schema/visibility/migration.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true +module GraphQL + class Schema + class Visibility + # You can use this to see how {GraphQL::Schema::Warden} and {GraphQL::Schema::Visibility::Profile} + # handle `.visible?` differently in your schema. + # + # It runs the same method on both implementations and raises an error when the results diverge. + # + # To fix the error, modify your schema so that both implementations return the same thing. + # Or, open an issue on GitHub to discuss the difference. + # + # This plugin adds overhead to runtime and may cause unexpected crashes -- **don't** use it in production! + # + # This plugin adds two keys to `context` when running: + # + # - `visibility_migration_running: true` + # - For the {Schema::Warden} which it instantiates, it adds `visibility_migration_warden_running: true`. + # + # Use those keys to modify your `visible?` behavior as needed. + # + # Also, in a pinch, you can set `skip_visibility_migration_error: true` in context to turn off this behavior per-query. + # (In that case, it uses {Profile} directly.) + # + # @example Adding this plugin + # + # use GraphQL::Schema::Visibility, migration_errors: true + # + class Migration < GraphQL::Schema::Visibility::Profile + class RuntimeTypesMismatchError < GraphQL::Error + def initialize(method_called, warden_result, profile_result, method_args) + super(<<~ERR) + Mismatch in types for `##{method_called}(#{method_args.map(&:inspect).join(", ")})`: + + #{compare_results(warden_result, profile_result)} + + Update your `.visible?` implementation to make these implementations return the same value. + + See: https://graphql-ruby.org/authorization/visibility_migration.html + ERR + end + + private + def compare_results(warden_result, profile_result) + if warden_result.is_a?(Array) && profile_result.is_a?(Array) + all_results = warden_result | profile_result + all_results.sort_by!(&:graphql_name) + + entries_text = all_results.map { |entry| "#{entry.graphql_name} (#{entry})"} + width = entries_text.map(&:size).max + yes = " ✔ " + no = " " + res = "".dup + res << "#{"Result".center(width)} Warden Profile \n" + all_results.each_with_index do |entry, idx| + res << "#{entries_text[idx].ljust(width)}#{warden_result.include?(entry) ? yes : no}#{profile_result.include?(entry) ? yes : no}\n" + end + res << "\n" + else + "- Warden returned: #{humanize(warden_result)}\n\n- Visibility::Profile returned: #{humanize(profile_result)}" + end + end + def humanize(val) + case val + when Array + "#{val.size}: #{val.map { |v| humanize(v) }.sort.inspect}" + when Module + if val.respond_to?(:graphql_name) + "#{val.graphql_name} (#{val.inspect})" + else + val.inspect + end + else + val.inspect + end + end + end + + def initialize(context:, schema:, name: nil, visibility:) + @name = name + @skip_error = context[:skip_visibility_migration_error] || context.is_a?(Query::NullContext) || context.is_a?(Hash) + @profile_types = GraphQL::Schema::Visibility::Profile.new(context: context, schema: schema, visibility: visibility) + if !@skip_error + context[:visibility_migration_running] = true + warden_ctx_vals = context.to_h.dup + warden_ctx_vals[:visibility_migration_warden_running] = true + if schema.const_defined?(:WardenCompatSchema, false) # don't use a defn from a superclass + warden_schema = schema.const_get(:WardenCompatSchema, false) + else + warden_schema = Class.new(schema) + warden_schema.use_visibility_profile = false + # TODO public API + warden_schema.send(:add_type_and_traverse, [warden_schema.query, warden_schema.mutation, warden_schema.subscription].compact, root: true) + warden_schema.send(:add_type_and_traverse, warden_schema.directives.values + warden_schema.orphan_types, root: false) + schema.const_set(:WardenCompatSchema, warden_schema) + end + warden_ctx = GraphQL::Query::Context.new(query: context.query, values: warden_ctx_vals) + warden_ctx.warden = GraphQL::Schema::Warden.new(schema: warden_schema, context: warden_ctx) + warden_ctx.warden.skip_warning = true + warden_ctx.types = @warden_types = warden_ctx.warden.visibility_profile + end + end + + def loaded_types + @profile_types.loaded_types + end + + PUBLIC_PROFILE_METHODS = [ + :enum_values, + :interfaces, + :all_types, + :all_types_h, + :fields, + :loadable?, + :loadable_possible_types, + :type, + :arguments, + :argument, + :directive_exists?, + :directives, + :field, + :query_root, + :mutation_root, + :possible_types, + :subscription_root, + :reachable_type?, + :visible_enum_value?, + ] + + PUBLIC_PROFILE_METHODS.each do |profile_method| + define_method(profile_method) do |*args| + call_method_and_compare(profile_method, args) + end + end + + def call_method_and_compare(method, args) + res_1 = @profile_types.public_send(method, *args) + if @skip_error + return res_1 + end + + res_2 = @warden_types.public_send(method, *args) + normalized_res_1 = res_1.is_a?(Array) ? Set.new(res_1) : res_1 + normalized_res_2 = res_2.is_a?(Array) ? Set.new(res_2) : res_2 + if !equivalent_schema_members?(normalized_res_1, normalized_res_2) + # Raise the errors with the orignally returned values: + err = RuntimeTypesMismatchError.new(method, res_2, res_1, args) + raise err + else + res_1 + end + end + + def equivalent_schema_members?(member1, member2) + if member1.class != member2.class + return false + end + + case member1 + when Set + member1_array = member1.to_a.sort_by(&:graphql_name) + member2_array = member2.to_a.sort_by(&:graphql_name) + member1_array.each_with_index do |inner_member1, idx| + inner_member2 = member2_array[idx] + equivalent_schema_members?(inner_member1, inner_member2) + end + when GraphQL::Schema::Field + member1.ensure_loaded + member2.ensure_loaded + if member1.introspection? && member2.introspection? + member1.inspect == member2.inspect + else + member1 == member2 + end + when Module + if member1.introspection? && member2.introspection? + member1.graphql_name == member2.graphql_name + else + member1 == member2 + end + else + member1 == member2 + end + end + end + end + end +end diff --git a/lib/graphql/schema/visibility/profile.rb b/lib/graphql/schema/visibility/profile.rb new file mode 100644 index 00000000000..9fab2198ca3 --- /dev/null +++ b/lib/graphql/schema/visibility/profile.rb @@ -0,0 +1,466 @@ +# frozen_string_literal: true + +module GraphQL + class Schema + class Visibility + # This class filters the types, fields, arguments, enum values, and directives in a schema + # based on the given `context`. + # + # It's like {Warden}, but has some differences: + # + # - It doesn't use {Schema}'s top-level caches (eg {Schema.references_to}, {Schema.possible_types}, {Schema.types}) + # - It doesn't hide Interface or Union types when all their possible types are hidden. (Instead, those types should implement `.visible?` to hide in that case.) + # - It checks `.visible?` on root introspection types + # - It can be used to cache profiles by name for re-use across queries + class Profile + # @return [Schema::Visibility::Profile] + def self.from_context(ctx, schema) + if ctx.respond_to?(:types) && (types = ctx.types).is_a?(self) + types + else + schema.visibility.profile_for(ctx) + end + end + + def self.null_profile(context:, schema:) + profile = self.new(name: "NullProfile", context: context, schema: schema) + profile.instance_variable_set(:@cached_visible, Hash.new { |k, v| k[v] = true }.compare_by_identity) + profile + end + + # @return [Symbol, nil] + attr_reader :name + + def freeze + @cached_visible.default_proc = nil + @cached_visible_fields.default_proc = nil + @cached_visible_fields.each do |type, fields| + fields.default_proc = nil + end + @cached_visible_arguments.default_proc = nil + @cached_visible_arguments.each do |type, fields| + fields.default_proc = nil + end + @cached_parent_fields.default_proc = nil + @cached_parent_fields.each do |type, fields| + fields.default_proc = nil + end + @cached_parent_arguments.default_proc = nil + @cached_parent_arguments.each do |type, args| + args.default_proc = nil + end + @cached_possible_types.default_proc = nil + @cached_enum_values.default_proc = nil + @cached_fields.default_proc = nil + @cached_arguments.default_proc = nil + @loadable_possible_types.default_proc = nil + @cached_field_result.default_proc = nil + @cached_field_result.each { |_, h| h.default_proc = nil } + @cached_type_result.default_proc = nil + super + end + + def initialize(name: nil, context:, schema:, visibility:) + @name = name + @context = context + @schema = schema + @visibility = visibility + @all_types = {} + @all_types_loaded = false + @unvisited_types = [] + @all_directives = nil + @cached_visible = Hash.new { |h, member| h[member] = @schema.visible?(member, @context) }.compare_by_identity + + @cached_visible_fields = Hash.new { |h, owner| + h[owner] = Hash.new do |h2, field| + h2[field] = visible_field_for(owner, field) + end.compare_by_identity + }.compare_by_identity + + @cached_visible_arguments = Hash.new do |h, owner| + h[owner] = Hash.new do |h2, arg| + h2[arg] = if @cached_visible[arg] && (arg_type = arg.type.unwrap) && @cached_visible[arg_type] + case owner + when GraphQL::Schema::Field + @cached_visible_fields[owner.owner][owner] + when Class + @cached_visible[owner] + else + raise "Unexpected argument owner for `#{arg.path}`: #{owner.inspect}" + end + else + false + end + end.compare_by_identity + end.compare_by_identity + + @cached_parent_fields = Hash.new do |h, type| + h[type] = Hash.new do |h2, field_name| + h2[field_name] = type.get_field(field_name, @context) + end + end.compare_by_identity + + @cached_parent_arguments = Hash.new do |h, arg_owner| + h[arg_owner] = Hash.new do |h2, arg_name| + h2[arg_name] = arg_owner.get_argument(arg_name, @context) + end + end.compare_by_identity + + @cached_possible_types = Hash.new { |h, type| h[type] = possible_types_for(type) }.compare_by_identity + + @cached_enum_values = Hash.new do |h, enum_t| + values = non_duplicate_items(enum_t.enum_values(@context), @cached_visible) + if values.size == 0 + raise GraphQL::Schema::Enum::MissingValuesError.new(enum_t) + end + h[enum_t] = values + end.compare_by_identity + + @cached_fields = Hash.new do |h, owner| + h[owner] = non_duplicate_items(owner.all_field_definitions.each(&:ensure_loaded), @cached_visible_fields[owner]) + end.compare_by_identity + + @cached_arguments = Hash.new do |h, owner| + h[owner] = non_duplicate_items(owner.all_argument_definitions, @cached_visible_arguments[owner]) + end.compare_by_identity + + @loadable_possible_types = Hash.new { |h, union_type| h[union_type] = union_type.possible_types }.compare_by_identity + + # Combined cache for field(owner, field_name) — avoids repeated kind check + parent lookup + visibility check + @cached_field_result = Hash.new { |h, owner| + h[owner] = Hash.new { |h2, field_name| h2[field_name] = compute_field(owner, field_name) } + }.compare_by_identity + + # Cache for type(type_name) — avoids repeated get_type + visibility + referenced? checks + @cached_type_result = Hash.new { |h, type_name| h[type_name] = compute_type(type_name) } + end + + def field_on_visible_interface?(field, owner) + ints = owner.interface_type_memberships.map(&:abstract_type) + field_name = field.graphql_name + filtered_ints = interfaces(owner) + any_interface_has_field = false + any_interface_has_visible_field = false + ints.each do |int_t| + if (_int_f_defn = @cached_parent_fields[int_t][field_name]) + any_interface_has_field = true + + if filtered_ints.include?(int_t) # TODO cycles, or maybe not necessary since previously checked? && @cached_visible_fields[owner][field] + any_interface_has_visible_field = true + break + end + end + end + + if any_interface_has_field + any_interface_has_visible_field + else + true + end + end + + def type(type_name) + @cached_type_result[type_name] + end + + def field(owner, field_name) + @cached_field_result[owner][field_name] + end + + def fields(owner) + @cached_fields[owner] + end + + def arguments(owner) + @cached_arguments[owner] + end + + def argument(owner, arg_name) + arg = @cached_parent_arguments[owner][arg_name] + if arg.is_a?(Array) + visible_arg = nil + arg.each do |arg_defn| + if @cached_visible_arguments[owner][arg_defn] + if visible_arg.nil? + visible_arg = arg_defn + else + raise_duplicate_definition(visible_arg, arg_defn) + end + end + end + visible_arg + else + if arg && @cached_visible_arguments[owner][arg] + arg + else + nil + end + end + end + + def possible_types(type) + @cached_possible_types[type] + end + + def interfaces(obj_or_int_type) + ints = obj_or_int_type.interface_type_memberships + .select { |itm| @cached_visible[itm] && @cached_visible[itm.abstract_type] } + .map!(&:abstract_type) + ints.uniq! # Remove any duplicate interfaces implemented via other interfaces + ints + end + + def query_root + ((t = @schema.query) && @cached_visible[t]) ? t : nil + end + + def mutation_root + ((t = @schema.mutation) && @cached_visible[t]) ? t : nil + end + + def subscription_root + ((t = @schema.subscription) && @cached_visible[t]) ? t : nil + end + + def all_types + load_all_types + @all_types.values + end + + def all_types_h + load_all_types + @all_types + end + + def enum_values(owner) + @cached_enum_values[owner] + end + + def directive_exists?(dir_name) + directives.any? { |d| d.graphql_name == dir_name } + end + + def directives + @all_directives ||= @visibility.all_directives.select { |dir| + @cached_visible[dir] && @visibility.all_references[dir].any? { |ref| ref == true || (@cached_visible[ref] && referenced?(ref)) } + } + end + + def loadable?(t, _ctx) + @cached_visible[t] && !referenced?(t) + end + + def loadable_possible_types(t, _ctx) + @loadable_possible_types[t] + end + + def loaded_types + @all_types.values + end + + def reachable_type?(type_name) + load_all_types + !!@all_types[type_name] + end + + def visible_enum_value?(enum_value, _ctx = nil) + @cached_visible[enum_value] + end + + def preload + load_all_types + @all_types.each do |type_name, type_defn| + type(type_name) + if type_defn.kind.fields? + fields(type_defn).each do |f| + field(type_defn, f.graphql_name) + arguments(f).each do |arg| + argument(f, arg.graphql_name) + end + end + @schema.introspection_system.dynamic_fields.each do |f| + field(type_defn, f.graphql_name) + end + elsif type_defn.kind.input_object? + arguments(type_defn).each do |arg| + argument(type_defn, arg.graphql_name) + end + elsif type_defn.kind.enum? + enum_values(type_defn) + end + # Lots more to do here + end + if @schema.query + @schema.introspection_system.entry_points.each do |f| + arguments(f).each do |arg| + argument(f, arg.graphql_name) + end + field(@schema.query, f.graphql_name) + end + end + @schema.introspection_system.dynamic_fields.each do |f| + arguments(f).each do |arg| + argument(f, arg.graphql_name) + end + end + + end + + private + + def compute_type(type_name) + t = @visibility.get_type(type_name) # rubocop:disable Development/ContextIsPassedCop + if t + if t.is_a?(Array) + vis_t = nil + t.each do |t_defn| + if @cached_visible[t_defn] && referenced?(t_defn) + if vis_t.nil? + vis_t = t_defn + else + raise_duplicate_definition(vis_t, t_defn) + end + end + end + vis_t + else + if t && @cached_visible[t] && referenced?(t) + t + else + nil + end + end + end + end + + def compute_field(owner, field_name) + f = if owner.kind.fields? && (field = @cached_parent_fields[owner][field_name]) + field + elsif owner == query_root && (entry_point_field = @schema.introspection_system.entry_point(name: field_name)) + entry_point_field + elsif (dynamic_field = @schema.introspection_system.dynamic_field(name: field_name)) + dynamic_field + else + nil + end + if f.is_a?(Array) + visible_f = nil + f.each do |f_defn| + if @cached_visible_fields[owner][f_defn] + if visible_f.nil? + visible_f = f_defn + else + raise_duplicate_definition(visible_f, f_defn) + end + end + end + visible_f&.ensure_loaded + elsif f && @cached_visible_fields[owner][f.ensure_loaded] + f + else + nil + end + end + + def non_duplicate_items(definitions, visibility_cache) + non_dups = [] + names = Set.new + definitions.each do |defn| + if visibility_cache[defn] + if !names.add?(defn.graphql_name) + dup_defn = non_dups.find { |d| d.graphql_name == defn.graphql_name } + raise_duplicate_definition(dup_defn, defn) + end + non_dups << defn + end + end + non_dups + end + + def raise_duplicate_definition(first_defn, second_defn) + raise DuplicateNamesError.new(duplicated_name: first_defn.path, duplicated_definition_1: first_defn.inspect, duplicated_definition_2: second_defn.inspect) + end + + def load_all_types + return if @all_types_loaded + @all_types_loaded = true + visit = Visibility::Visit.new(@schema) do |member| + if member.is_a?(Module) && member.respond_to?(:kind) + if @cached_visible[member] && referenced?(member) + type_name = member.graphql_name + if (prev_t = @all_types[type_name]) && !prev_t.equal?(member) + raise_duplicate_definition(member, prev_t) + end + @all_types[type_name] = member + true + else + false + end + else + @cached_visible[member] + end + end + visit.visit_each + @all_types.delete_if { |type_name, type_defn| !referenced?(type_defn) } + nil + end + + def referenced?(type_defn) + @visibility.all_references[type_defn].any? do |ref| + case ref + when GraphQL::Schema::Argument + @cached_visible_arguments[ref.owner][ref] + when GraphQL::Schema::Field + @cached_visible_fields[ref.owner][ref] + when Module + @cached_visible[ref] + when true + true + end + end + end + + def possible_types_for(type) + case type.kind.name + when "INTERFACE" + pts = [] + @visibility.all_interface_type_memberships[type].each do |impl_type, type_memberships| + if impl_type.kind.object? && referenced?(impl_type) && @cached_visible[impl_type] + if type_memberships.any? { |itm| @cached_visible[itm] } + pts << impl_type + end + end + end + pts + when "UNION" + pts = [] + type.type_memberships.each { |tm| + if @cached_visible[tm] && + (ot = tm.object_type) && + @cached_visible[ot] && + referenced?(ot) + pts << ot + end + } + pts + when "OBJECT" + if @cached_visible[type] + [type] + else + EmptyObjects::EMPTY_ARRAY + end + else + GraphQL::EmptyObjects::EMPTY_ARRAY + end + end + + def visible_field_for(owner, field) + @cached_visible[field] && + (ret_type = field.type.unwrap) && + @cached_visible[ret_type] && + (owner == field.owner || (!owner.kind.object?) || field_on_visible_interface?(field, owner)) + end + end + end + end +end diff --git a/lib/graphql/schema/visibility/visit.rb b/lib/graphql/schema/visibility/visit.rb new file mode 100644 index 00000000000..beef8e29d15 --- /dev/null +++ b/lib/graphql/schema/visibility/visit.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true +module GraphQL + class Schema + class Visibility + class Visit + def initialize(schema, &visit_block) + @schema = schema + @late_bound_types = nil + @unvisited_types = nil + # These accumulate between calls to prevent re-visiting the same types + @visited_types = Set.new.compare_by_identity + @visited_directives = Set.new.compare_by_identity + @visit_block = visit_block + end + + def entry_point_types + ept = [ + @schema.query, + @schema.mutation, + @schema.subscription, + *@schema.introspection_system.types.values, + *@schema.orphan_types, + ] + ept.compact! + ept + end + + def entry_point_directives + @schema.directives.values + end + + def visit_each(types: entry_point_types, directives: entry_point_directives) + @unvisited_types && raise("Can't re-enter `visit_each` on this Visit (another visit is already in progress)") + @unvisited_types = types + @late_bound_types = [] + directives_to_visit = directives + + while !@unvisited_types.empty? || !@late_bound_types.empty? + while (type = @unvisited_types.pop) + if @visited_types.add?(type) && @visit_block.call(type) + directives_to_visit.concat(type.directives) + case type.kind.name + when "OBJECT", "INTERFACE" + type.interface_type_memberships.each do |itm| + append_unvisited_type(type, itm.abstract_type) + end + if type.kind.interface? + type.orphan_types.each do |orphan_type| + append_unvisited_type(type, orphan_type) + end + end + + type.all_field_definitions.each do |field| + field.ensure_loaded + if @visit_block.call(field) + directives_to_visit.concat(field.directives) + append_unvisited_type(type, field.type.unwrap) + field.all_argument_definitions.each do |argument| + if @visit_block.call(argument) + directives_to_visit.concat(argument.directives) + append_unvisited_type(field, argument.type.unwrap) + end + end + end + end + when "INPUT_OBJECT" + type.all_argument_definitions.each do |argument| + if @visit_block.call(argument) + directives_to_visit.concat(argument.directives) + append_unvisited_type(type, argument.type.unwrap) + end + end + when "UNION" + type.type_memberships.each do |tm| + append_unvisited_type(type, tm.object_type) + end + when "ENUM" + type.all_enum_value_definitions.each do |val| + if @visit_block.call(val) + directives_to_visit.concat(val.directives) + end + end + when "SCALAR" + # pass -- nothing else to visit + else + raise "Invariant: unhandled type kind: #{type.kind.inspect}" + end + end + end + + directives_to_visit.each do |dir| + dir_class = dir.is_a?(Class) ? dir : dir.class + if @visited_directives.add?(dir_class) && @visit_block.call(dir_class) + dir_class.all_argument_definitions.each do |arg_defn| + if @visit_block.call(arg_defn) + directives_to_visit.concat(arg_defn.directives) + append_unvisited_type(dir_class, arg_defn.type.unwrap) + end + end + end + end + + missed_late_types_streak = 0 + while (owner, late_type = @late_bound_types.shift) + if (late_type.is_a?(String) && (type = Member::BuildType.constantize(late_type))) || + (late_type.is_a?(LateBoundType) && (type = @visited_types.find { |t| t.graphql_name == late_type.graphql_name })) + missed_late_types_streak = 0 # might succeed next round + update_type_owner(owner, type) + append_unvisited_type(owner, type) + else + # Didn't find it -- keep trying + missed_late_types_streak += 1 + @late_bound_types << [owner, late_type] + if missed_late_types_streak == @late_bound_types.size + raise UnresolvedLateBoundTypeError.new(type: late_type) + end + end + end + end + + @unvisited_types = nil + nil + end + + private + + def append_unvisited_type(owner, type) + if type.is_a?(LateBoundType) || type.is_a?(String) + @late_bound_types << [owner, type] + else + @unvisited_types << type + end + end + + def update_type_owner(owner, type) + case owner + when Module + if owner.kind.union? + owner.assign_type_membership_object_type(type) + elsif type.kind.interface? + new_interfaces = [] + owner.interfaces.each do |int_t| + if int_t.is_a?(String) && int_t == type.graphql_name + new_interfaces << type + elsif int_t.is_a?(LateBoundType) && int_t.graphql_name == type.graphql_name + new_interfaces << type + else + # Don't re-add proper interface definitions, + # they were probably already added, maybe with options. + end + end + owner.implements(*new_interfaces) + new_interfaces.each do |int| + pt = @possible_types[int] ||= [] + if !pt.include?(owner) && owner.is_a?(Class) + pt << owner + end + int.interfaces.each do |indirect_int| + if indirect_int.is_a?(LateBoundType) && (indirect_int_type = get_type(indirect_int.graphql_name)) # rubocop:disable Development/ContextIsPassedCop + update_type_owner(owner, indirect_int_type) + end + end + end + end + when GraphQL::Schema::Argument, GraphQL::Schema::Field + orig_type = owner.type + # Apply list/non-null wrapper as needed + if orig_type.respond_to?(:of_type) + transforms = [] + while (orig_type.respond_to?(:of_type)) + if orig_type.kind.non_null? + transforms << :to_non_null_type + elsif orig_type.kind.list? + transforms << :to_list_type + else + raise "Invariant: :of_type isn't non-null or list" + end + orig_type = orig_type.of_type + end + transforms.reverse_each { |t| type = type.public_send(t) } + end + owner.type = type + else + raise "Unexpected update: #{owner.inspect} #{type.inspect}" + end + end + end + end + end +end diff --git a/lib/graphql/schema/warden.rb b/lib/graphql/schema/warden.rb index 81fc82d514b..d74956738f2 100644 --- a/lib/graphql/schema/warden.rb +++ b/lib/graphql/schema/warden.rb @@ -4,58 +4,225 @@ module GraphQL class Schema - # Restrict access to a {GraphQL::Schema} with a user-defined filter. + # Restrict access to a {GraphQL::Schema} with a user-defined `visible?` implementations. # # When validating and executing a query, all access to schema members # should go through a warden. If you access the schema directly, # you may show a client something that it shouldn't be allowed to see. # - # @example Hidding private fields - # private_members = -> (member, ctx) { member.metadata[:private] } - # result = Schema.execute(query_string, except: private_members) - # - # @example Custom filter implementation - # # It must respond to `#call(member)`. - # class MissingRequiredFlags - # def initialize(user) - # @user = user - # end - # - # # Return `false` if any required flags are missing - # def call(member, ctx) - # member.metadata[:required_flags].any? do |flag| - # !@user.has_flag?(flag) - # end - # end - # end - # - # # Then, use the custom filter in query: - # missing_required_flags = MissingRequiredFlags.new(current_user) - # - # # This query can only access members which match the user's flags - # result = Schema.execute(query_string, except: missing_required_flags) - # # @api private class Warden - # @param filter [<#call(member)>] Objects are hidden when `.call(member, ctx)` returns true + def self.from_context(context) + context.warden || PassThruWarden + rescue NoMethodError + # this might be a hash which won't respond to #warden + PassThruWarden + end + + def self.types_from_context(context) + context.types || PassThruWarden + rescue NoMethodError + # this might be a hash which won't respond to #warden + PassThruWarden + end + + def self.use(schema) + # no-op + end + + # @param visibility_method [Symbol] a Warden method to call for this entry + # @param entry [Object, Array] One or more definitions for a given name in a GraphQL Schema + # @param context [GraphQL::Query::Context] + # @param warden [Warden] + # @return [Object] `entry` or one of `entry`'s items if exactly one of them is visible for this context + # @return [nil] If neither `entry` nor any of `entry`'s items are visible for this context + def self.visible_entry?(visibility_method, entry, context, warden = Warden.from_context(context)) + if entry.is_a?(Array) + visible_item = nil + entry.each do |item| + if warden.public_send(visibility_method, item, context) + if visible_item.nil? + visible_item = item + else + raise DuplicateNamesError.new( + duplicated_name: item.path, duplicated_definition_1: visible_item.inspect, duplicated_definition_2: item.inspect + ) + end + end + end + visible_item + elsif warden.public_send(visibility_method, entry, context) + entry + else + nil + end + end + + # This is used when a caller provides a Hash for context. + # We want to call the schema's hooks, but we don't have a full-blown warden. + # The `context` arguments to these methods exist purely to simplify the code that + # calls methods on this object, so it will have everything it needs. + class PassThruWarden + class << self + def visible_field?(field, ctx); field.visible?(ctx); end + def visible_argument?(arg, ctx); arg.visible?(ctx); end + def visible_type?(type, ctx); type.visible?(ctx); end + def visible_enum_value?(ev, ctx); ev.visible?(ctx); end + def visible_type_membership?(tm, ctx); tm.visible?(ctx); end + def interface_type_memberships(obj_t, ctx); obj_t.interface_type_memberships; end + def arguments(owner, ctx); owner.arguments(ctx); end + def loadable?(type, ctx); type.visible?(ctx); end + def loadable_possible_types(type, ctx); type.possible_types(ctx); end + def visibility_profile + @visibility_profile ||= Warden::VisibilityProfile.new(self) + end + end + end + + class NullWarden + def initialize(_filter = nil, context:, schema:) + @schema = schema + @visibility_profile = Warden::VisibilityProfile.new(self) + end + + # No-op, but for compatibility: + attr_writer :skip_warning + + attr_reader :visibility_profile + + def visible_field?(field_defn, _ctx = nil, owner = nil); true; end + def visible_argument?(arg_defn, _ctx = nil); true; end + def visible_type?(type_defn, _ctx = nil); true; end + def visible_enum_value?(enum_value, _ctx = nil); enum_value.visible?(Query::NullContext.instance); end + def visible_type_membership?(type_membership, _ctx = nil); true; end + def interface_type_memberships(obj_type, _ctx = nil); obj_type.interface_type_memberships; end + def get_type(type_name); @schema.get_type(type_name, Query::NullContext.instance, false); end # rubocop:disable Development/ContextIsPassedCop + def arguments(argument_owner, ctx = nil); argument_owner.all_argument_definitions; end + def enum_values(enum_defn); enum_defn.enum_values(Query::NullContext.instance); end # rubocop:disable Development/ContextIsPassedCop + def get_argument(parent_type, argument_name); parent_type.get_argument(argument_name); end # rubocop:disable Development/ContextIsPassedCop + def types; @schema.types; end # rubocop:disable Development/ContextIsPassedCop + def root_type_for_operation(op_name); @schema.root_type_for_operation(op_name); end + def directives; @schema.directives.values; end + def fields(type_defn); type_defn.all_field_definitions; end # rubocop:disable Development/ContextIsPassedCop + def get_field(parent_type, field_name); @schema.get_field(parent_type, field_name); end + def reachable_type?(type_name); true; end + def loadable?(type, _ctx); true; end + def loadable_possible_types(abstract_type, _ctx); union_type.possible_types; end + def reachable_types; @schema.types.values; end # rubocop:disable Development/ContextIsPassedCop + def possible_types(type_defn); @schema.possible_types(type_defn, Query::NullContext.instance, false); end + def interfaces(obj_type); obj_type.interfaces; end + end + + def visibility_profile + @visibility_profile ||= VisibilityProfile.new(self) + end + + class VisibilityProfile + def initialize(warden) + @warden = warden + end + + def directives + @warden.directives + end + + def directive_exists?(dir_name) + @warden.directives.any? { |d| d.graphql_name == dir_name } + end + + def type(name) + @warden.get_type(name) + end + + def field(owner, field_name) + @warden.get_field(owner, field_name) + end + + def argument(owner, arg_name) + @warden.get_argument(owner, arg_name) + end + + def query_root + @warden.root_type_for_operation("query") + end + + def mutation_root + @warden.root_type_for_operation("mutation") + end + + def subscription_root + @warden.root_type_for_operation("subscription") + end + + def arguments(owner) + @warden.arguments(owner) + end + + def fields(owner) + @warden.fields(owner) + end + + def possible_types(type) + @warden.possible_types(type) + end + + def enum_values(enum_type) + @warden.enum_values(enum_type) + end + + def all_types + @warden.reachable_types + end + + def interfaces(obj_type) + @warden.interfaces(obj_type) + end + + def loadable?(t, ctx) # TODO remove ctx here? + @warden.loadable?(t, ctx) + end + + def loadable_possible_types(t, ctx) + @warden.loadable_possible_types(t, ctx) + end + + def reachable_type?(type_name) + !!@warden.reachable_type?(type_name) + end + + def visible_enum_value?(enum_value, ctx = nil) + @warden.visible_enum_value?(enum_value, ctx) + end + end + # @param context [GraphQL::Query::Context] # @param schema [GraphQL::Schema] - def initialize(filter, context:, schema:) - @schema = schema.interpreter? ? schema : schema.graphql_definition + def initialize(context:, schema:) + @schema = schema # Cache these to avoid repeated hits to the inheritance chain when one isn't present @query = @schema.query @mutation = @schema.mutation @subscription = @schema.subscription @context = context - @visibility_cache = read_through { |m| filter.call(m, context) } + @visibility_cache = read_through { |m| check_visible(schema, m) } + # Initialize all ivars to improve object shape consistency: + @types = @visible_types = @reachable_types = @visible_parent_fields = + @visible_possible_types = @visible_fields = @visible_arguments = @visible_enum_arrays = + @visible_enum_values = @visible_interfaces = @type_visibility = @type_memberships = + @visible_and_reachable_type = @unions = @unfiltered_interfaces = + @reachable_type_set = @visibility_profile = @loadable_possible_types = + nil + @skip_warning = schema.plugins.any? { |(plugin, _opts)| plugin == GraphQL::Schema::Warden } end + attr_writer :skip_warning + # @return [Hash] Visible types in the schema def types @types ||= begin vis_types = {} - @schema.types.each do |n, t| - if visible_type?(t) + @schema.types(@context).each do |n, t| + if visible_and_reachable_type?(t) vis_types[n] = t end end @@ -63,11 +230,31 @@ def types end end + # @return [Boolean] True if this type is used for `loads:` but not in the schema otherwise and not _explicitly_ hidden. + def loadable?(type, _ctx) + visible_type?(type) && + !referenced?(type) && + (type.respond_to?(:interfaces) ? interfaces(type).all? { |i| loadable?(i, _ctx) } : true) + end + + # This abstract type was determined to be used for `loads` only. + # All its possible types are valid possibilities here -- no filtering. + def loadable_possible_types(abstract_type, _ctx) + @loadable_possible_types ||= read_through do |t| + if t.is_a?(Class) # union + t.possible_types + else + @schema.possible_types(abstract_type) + end + end + @loadable_possible_types[abstract_type] + end + # @return [GraphQL::BaseType, nil] The type named `type_name`, if it exists (else `nil`) def get_type(type_name) @visible_types ||= read_through do |name| - type_defn = @schema.get_type(name) - if type_defn && visible_type?(type_defn) + type_defn = @schema.get_type(name, @context, false) + if type_defn && visible_and_reachable_type?(type_defn) type_defn else nil @@ -84,7 +271,7 @@ def reachable_types # @return Boolean True if the type is visible and reachable in the schema def reachable_type?(type_name) - type = get_type(type_name) + type = get_type(type_name) # rubocop:disable Development/ContextIsPassedCop -- `self` is query-aware type && reachable_type_set.include?(type) end @@ -92,8 +279,8 @@ def reachable_type?(type_name) def get_field(parent_type, field_name) @visible_parent_fields ||= read_through do |type| read_through do |f_name| - field_defn = @schema.get_field(type, f_name) - if field_defn && visible_field?(type, field_defn) + field_defn = @schema.get_field(type, f_name, @context) + if field_defn && visible_field?(field_defn, nil, type) field_defn else nil @@ -106,15 +293,15 @@ def get_field(parent_type, field_name) # @return [GraphQL::Argument, nil] The argument named `argument_name` on `parent_type`, if it exists and is visible def get_argument(parent_type, argument_name) - argument = parent_type.get_argument(argument_name) - return argument if argument && visible_argument?(argument) + argument = parent_type.get_argument(argument_name, @context) + return argument if argument && visible_argument?(argument, @context) end # @return [Array] The types which may be member of `type_defn` def possible_types(type_defn) @visible_possible_types ||= read_through { |type_defn| - pt = @schema.possible_types(type_defn, @context) - pt.select { |t| visible_type?(t) } + pt = @schema.possible_types(type_defn, @context, false) + pt.select { |t| visible_and_reachable_type?(t) } } @visible_possible_types[type_defn] end @@ -122,26 +309,52 @@ def possible_types(type_defn) # @param type_defn [GraphQL::ObjectType, GraphQL::InterfaceType] # @return [Array] Fields on `type_defn` def fields(type_defn) - @visible_fields ||= read_through { |t| @schema.get_fields(t).each_value.select { |f| visible_field?(t, f) } } + @visible_fields ||= read_through { |t| @schema.get_fields(t, @context).values } @visible_fields[type_defn] end # @param argument_owner [GraphQL::Field, GraphQL::InputObjectType] # @return [Array] Visible arguments on `argument_owner` - def arguments(argument_owner) - @visible_arguments ||= read_through { |o| o.arguments.each_value.select { |a| visible_argument?(a) } } + def arguments(argument_owner, ctx = nil) + @visible_arguments ||= read_through { |o| + args = o.arguments(@context) + if !args.empty? + args = args.values + args.select! { |a| visible_argument?(a, @context) } + args + else + EmptyObjects::EMPTY_ARRAY + end + } @visible_arguments[argument_owner] end # @return [Array] Visible members of `enum_defn` def enum_values(enum_defn) - @visible_enum_values ||= read_through { |e| e.values.each_value.select { |enum_value_defn| visible?(enum_value_defn) } } - @visible_enum_values[enum_defn] + @visible_enum_arrays ||= read_through { |e| + values = e.enum_values(@context) + if values.size == 0 + raise GraphQL::Schema::Enum::MissingValuesError.new(e) + end + values + } + @visible_enum_arrays[enum_defn] + end + + def visible_enum_value?(enum_value, _ctx = nil) + @visible_enum_values ||= read_through { |ev| visible?(ev) } + @visible_enum_values[enum_value] end # @return [Array] Visible interfaces implemented by `obj_type` def interfaces(obj_type) - @visible_interfaces ||= read_through { |t| t.interfaces(@context).select { |i| visible?(i) } } + @visible_interfaces ||= read_through { |t| + ints = t.interfaces(@context) + if !ints.empty? + ints.select! { |i| visible_type?(i) } + end + ints + } @visible_interfaces[obj_type] end @@ -158,25 +371,74 @@ def root_type_for_operation(op_name) end end - private - - def union_memberships(obj_type) - @unions ||= read_through { |obj_type| @schema.union_memberships(obj_type).select { |u| visible?(u) } } - @unions[obj_type] - end - - def visible_argument?(arg_defn) - visible?(arg_defn) && visible_type?(arg_defn.type.unwrap) - end - - def visible_field?(owner_type, field_defn) + # @param owner [Class, Module] If provided, confirm that field has the given owner. + def visible_field?(field_defn, _ctx = nil, owner = field_defn.owner) # This field is visible in its own right visible?(field_defn) && # This field's return type is visible - visible_type?(field_defn.type.unwrap) && + visible_and_reachable_type?(field_defn.type.unwrap) && # This field is either defined on this object type, # or the interface it's inherited from is also visible - ((field_defn.respond_to?(:owner) && field_defn.owner == owner_type) || field_on_visible_interface?(field_defn, owner_type)) + ((field_defn.respond_to?(:owner) && field_defn.owner == owner) || field_on_visible_interface?(field_defn, owner)) + end + + def visible_argument?(arg_defn, _ctx = nil) + visible?(arg_defn) && visible_and_reachable_type?(arg_defn.type.unwrap) + end + + def visible_type?(type_defn, _ctx = nil) + @type_visibility ||= read_through { |type_defn| visible?(type_defn) } + @type_visibility[type_defn] + end + + def visible_type_membership?(type_membership, _ctx = nil) + visible?(type_membership) + end + + def interface_type_memberships(obj_type, _ctx = nil) + @type_memberships ||= read_through do |obj_t| + obj_t.interface_type_memberships + end + @type_memberships[obj_type] + end + + private + + def visible_and_reachable_type?(type_defn) + @visible_and_reachable_type ||= read_through do |type_defn| + next false unless visible_type?(type_defn) + next true if root_type?(type_defn) || type_defn.introspection? + + if type_defn.kind.union? + !possible_types(type_defn).empty? && (referenced?(type_defn) || orphan_type?(type_defn)) + elsif type_defn.kind.interface? + if !possible_types(type_defn).empty? + true + else + if @context.respond_to?(:logger) && (logger = @context.logger) + logger.debug { "Interface `#{type_defn.graphql_name}` hidden because it has no visible implementers" } + end + false + end + else + if referenced?(type_defn) + true + elsif type_defn.kind.object? + # Show this object if it belongs to ... + interfaces(type_defn).any? { |t| referenced?(t) } || # an interface which is referenced in the schema + union_memberships(type_defn).any? { |t| referenced?(t) || orphan_type?(t) } # or a union which is referenced or added via orphan_types + else + false + end + end + end + + @visible_and_reachable_type[type_defn] + end + + def union_memberships(obj_type) + @unions ||= read_through { |obj_type| @schema.union_memberships(obj_type).select { |u| visible?(u) } } + @unions[obj_type] end # We need this to tell whether a field was inherited by an interface @@ -195,10 +457,10 @@ def field_on_visible_interface?(field_defn, type_defn) any_interface_has_visible_field = false ints = unfiltered_interfaces(type_defn) ints.each do |interface_type| - if (iface_field_defn = interface_type.get_field(field_defn.graphql_name)) + if (iface_field_defn = interface_type.get_field(field_defn.graphql_name, @context)) any_interface_has_field = true - if interfaces(type_defn).include?(interface_type) && visible_field?(interface_type, iface_field_defn) + if interfaces(type_defn).include?(interface_type) && visible_field?(iface_field_defn, nil, interface_type) any_interface_has_visible_field = true end end @@ -215,23 +477,6 @@ def field_on_visible_interface?(field_defn, type_defn) end end - def visible_type?(type_defn) - @type_visibility ||= read_through do |type_defn| - next false unless visible?(type_defn) - next true if root_type?(type_defn) || type_defn.introspection? - - if type_defn.kind.union? - visible_possible_types?(type_defn) && (referenced?(type_defn) || orphan_type?(type_defn)) - elsif type_defn.kind.interface? - visible_possible_types?(type_defn) - else - referenced?(type_defn) || visible_abstract_type?(type_defn) - end - end - - @type_visibility[type_defn] - end - def root_type?(type_defn) @query == type_defn || @mutation == type_defn || @@ -239,41 +484,79 @@ def root_type?(type_defn) end def referenced?(type_defn) - @references_to ||= @schema.references_to - graphql_name = type_defn.unwrap.graphql_name - members = @references_to[graphql_name] || NO_REFERENCES + members = @schema.references_to(type_defn) members.any? { |m| visible?(m) } end - NO_REFERENCES = [].freeze - def orphan_type?(type_defn) @schema.orphan_types.include?(type_defn) end - def visible_abstract_type?(type_defn) - type_defn.kind.object? && ( - interfaces(type_defn).any? || - union_memberships(type_defn).any? - ) - end - - def visible_possible_types?(type_defn) - possible_types(type_defn).any? { |t| visible_type?(t) } - end - def visible?(member) @visibility_cache[member] end def read_through - Hash.new { |h, k| h[k] = yield(k) } + Hash.new { |h, k| h[k] = yield(k) }.compare_by_identity end + def check_visible(schema, member) + if schema.visible?(member, @context) + true + elsif @skip_warning + false + else + member_s = member.respond_to?(:path) ? member.path : member.inspect + member_type = case member + when Module + if member.respond_to?(:kind) + member.kind.name.downcase + else + "" + end + when GraphQL::Schema::Field + "field" + when GraphQL::Schema::EnumValue + "enum value" + when GraphQL::Schema::Argument + "argument" + else + "" + end + + schema_s = schema.name ? "#{schema.name}'s" : "" + schema_name = schema.name ? "#{schema.name}" : "your schema" + warn(ADD_WARDEN_WARNING % { schema_s: schema_s, schema_name: schema_name, member: member_s, member_type: member_type }) + @skip_warning = true # only warn once per query + # If there's no schema name, add the backtrace for additional context: + if schema_s == "" + puts caller.map { |l| " #{l}"} + end + false + end + end + + ADD_WARDEN_WARNING = <<~WARNING +DEPRECATION: %{schema_s} "%{member}" %{member_type} returned `false` for `.visible?` but `GraphQL::Schema::Visibility` isn't configured yet. + + Address this warning by adding: + + use GraphQL::Schema::Visibility + + to the definition for %{schema_name}. (Future GraphQL-Ruby versions won't check `.visible?` methods by default.) + + Alternatively, for legacy behavior, add: + + use GraphQL::Schema::Warden # legacy visibility behavior + + For more information see: https://graphql-ruby.org/authorization/visibility.html + WARNING + def reachable_type_set - return @reachable_type_set if defined?(@reachable_type_set) + return @reachable_type_set if @reachable_type_set @reachable_type_set = Set.new + rt_hash = {} unvisited_types = [] ['query', 'mutation', 'subscription'].each do |op_name| @@ -283,62 +566,76 @@ def reachable_type_set unvisited_types.concat(@schema.introspection_system.types.values) directives.each do |dir_class| - dir_class.arguments.values.each do |arg_defn| + arguments(dir_class).each do |arg_defn| arg_t = arg_defn.type.unwrap - if get_type(arg_t.graphql_name) + if get_type(arg_t.graphql_name) # rubocop:disable Development/ContextIsPassedCop -- `self` is query-aware unvisited_types << arg_t end end end @schema.orphan_types.each do |orphan_type| - if get_type(orphan_type.graphql_name) + if get_type(orphan_type.graphql_name) == orphan_type # rubocop:disable Development/ContextIsPassedCop -- `self` is query-aware unvisited_types << orphan_type end end + included_interface_possible_types_set = Set.new + until unvisited_types.empty? type = unvisited_types.pop - if @reachable_type_set.add?(type) - if type.kind.input_object? - # recurse into visible arguments - arguments(type).each do |argument| - argument_type = argument.type.unwrap - unvisited_types << argument_type - end - elsif type.kind.union? - # recurse into visible possible types - possible_types(type).each do |possible_type| - unvisited_types << possible_type + visit_type(type, unvisited_types, @reachable_type_set, rt_hash, included_interface_possible_types_set, include_interface_possible_types: false) + end + + @reachable_type_set + end + + def visit_type(type, unvisited_types, visited_type_set, type_by_name_hash, included_interface_possible_types_set, include_interface_possible_types:) + if visited_type_set.add?(type) || (include_interface_possible_types && type.kind.interface? && included_interface_possible_types_set.add?(type)) + type_by_name = type_by_name_hash[type.graphql_name] ||= type + if type_by_name != type + name_1, name_2 = [type.inspect, type_by_name.inspect].sort + raise DuplicateNamesError.new( + duplicated_name: type.graphql_name, duplicated_definition_1: name_1, duplicated_definition_2: name_2 + ) + end + if type.kind.input_object? + # recurse into visible arguments + arguments(type).each do |argument| + argument_type = argument.type.unwrap + unvisited_types << argument_type + end + elsif type.kind.union? + # recurse into visible possible types + possible_types(type).each do |possible_type| + unvisited_types << possible_type + end + elsif type.kind.fields? + if type.kind.object? + # recurse into visible implemented interfaces + interfaces(type).each do |interface| + unvisited_types << interface end - elsif type.kind.fields? - if type.kind.interface? - # recurse into visible possible types - possible_types(type).each do |possible_type| - unvisited_types << possible_type - end - elsif type.kind.object? - # recurse into visible implemented interfaces - interfaces(type).each do |interface| - unvisited_types << interface - end + elsif include_interface_possible_types + possible_types(type).each do |pt| + unvisited_types << pt end + end + # Don't visit interface possible types -- it's not enough to justify visibility - # recurse into visible fields - fields(type).each do |field| - field_type = field.type.unwrap - unvisited_types << field_type - # recurse into visible arguments - arguments(field).each do |argument| - argument_type = argument.type.unwrap - unvisited_types << argument_type - end + # recurse into visible fields + fields(type).each do |field| + field_type = field.type.unwrap + # In this case, if it's an interface, we want to include + visit_type(field_type, unvisited_types, visited_type_set, type_by_name_hash, included_interface_possible_types_set, include_interface_possible_types: true) + # recurse into visible arguments + arguments(field).each do |argument| + argument_type = argument.type.unwrap + unvisited_types << argument_type end end end end - - @reachable_type_set end end end diff --git a/lib/graphql/schema/wrapper.rb b/lib/graphql/schema/wrapper.rb index 3fbf891270d..8fd49e19619 100644 --- a/lib/graphql/schema/wrapper.rb +++ b/lib/graphql/schema/wrapper.rb @@ -3,7 +3,6 @@ module GraphQL class Schema class Wrapper - include GraphQL::Schema::Member::CachedGraphQLDefinition include GraphQL::Schema::Member::TypeSystemHelpers # @return [Class, Module] The inner type of this wrapping type, the type of which one or more objects may be present. @@ -13,17 +12,23 @@ def initialize(of_type) @of_type = of_type end - def to_graphql - raise GraphQL::RequiredImplementationMissingError + def unwrap + @unwrapped ||= @of_type.unwrap end - def unwrap - @of_type.unwrap + def freeze + unwrap + to_type_signature + super end def ==(other) self.class == other.class && of_type == other.of_type end + + def deconstruct_keys(_keys) + { of_type: of_type } + end end end end diff --git a/lib/graphql/static_validation.rb b/lib/graphql/static_validation.rb index 9865894cb9b..43d923e937d 100644 --- a/lib/graphql/static_validation.rb +++ b/lib/graphql/static_validation.rb @@ -1,13 +1,11 @@ # frozen_string_literal: true require "graphql/static_validation/error" require "graphql/static_validation/definition_dependencies" -require "graphql/static_validation/type_stack" require "graphql/static_validation/validator" require "graphql/static_validation/validation_context" require "graphql/static_validation/validation_timeout_error" require "graphql/static_validation/literal_validator" require "graphql/static_validation/base_visitor" -require "graphql/static_validation/no_validate_visitor" rules_glob = File.expand_path("../static_validation/rules/*.rb", __FILE__) Dir.glob(rules_glob).each do |file| @@ -15,5 +13,4 @@ end require "graphql/static_validation/all_rules" -require "graphql/static_validation/default_visitor" require "graphql/static_validation/interpreter_visitor" diff --git a/lib/graphql/static_validation/all_rules.rb b/lib/graphql/static_validation/all_rules.rb index 1cd520ec7ec..333fdf78d66 100644 --- a/lib/graphql/static_validation/all_rules.rb +++ b/lib/graphql/static_validation/all_rules.rb @@ -3,7 +3,7 @@ module GraphQL module StaticValidation # Default rules for {GraphQL::StaticValidation::Validator} # - # Order is important here. Some validators return {GraphQL::Language::Visitor::SKIP} + # Order is important here. Some validators skip later hooks. # which stops the visit on that node. That way it doesn't try to find fields on types that # don't exist, etc. ALL_RULES = [ @@ -33,8 +33,10 @@ module StaticValidation GraphQL::StaticValidation::VariablesAreUsedAndDefined, GraphQL::StaticValidation::VariableUsagesAreAllowed, GraphQL::StaticValidation::MutationRootExists, - GraphQL::StaticValidation::SubscriptionRootExists, + GraphQL::StaticValidation::QueryRootExists, + GraphQL::StaticValidation::SubscriptionRootExistsAndSingleSubscriptionSelection, GraphQL::StaticValidation::InputObjectNamesAreUnique, - ] + GraphQL::StaticValidation::OneOfInputObjectsAreValid, + ].freeze end end diff --git a/lib/graphql/static_validation/base_visitor.rb b/lib/graphql/static_validation/base_visitor.rb index 52faa2fd423..c133cc766d1 100644 --- a/lib/graphql/static_validation/base_visitor.rb +++ b/lib/graphql/static_validation/base_visitor.rb @@ -1,53 +1,41 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class BaseVisitor < GraphQL::Language::Visitor + class BaseVisitor < GraphQL::Language::StaticVisitor def initialize(document, context) @path = [] - @object_types = [] - @directives = [] - @field_definitions = [] - @argument_definitions = [] - @directive_definitions = [] + @path_depth = 0 + @current_object_type = nil + @parent_object_type = nil + @current_field_definition = nil + @current_argument_definition = nil + @parent_argument_definition = nil + @current_directive_definition = nil @context = context + @types = context.query.types @schema = context.schema + @inline_fragment_paths = {} + @field_unwrapped_types = {}.compare_by_identity super(document) end - # This will be overwritten by {InternalRepresentation::Rewrite} if it's included - def rewrite_document - nil - end - attr_reader :context - # @return [Array] Types whose scope we've entered - attr_reader :object_types - # @return [Array] The nesting of the current position in the AST def path - @path.dup + @path[0, @path_depth] end # Build a class to visit the AST and perform validation, # or use a pre-built class if rules is `ALL_RULES` or empty. # @param rules [Array] - # @param rewrite [Boolean] if `false`, don't include rewrite # @return [Class] A class for validating `rules` during visitation - def self.including_rules(rules, rewrite: true) + def self.including_rules(rules) if rules.empty? - if rewrite - NoValidateVisitor - else - # It's not doing _anything?!?_ - BaseVisitor - end + # It's not doing _anything?!?_ + BaseVisitor elsif rules == ALL_RULES - if rewrite - DefaultVisitor - else - InterpreterVisitor - end + InterpreterVisitor else visitor_class = Class.new(self) do include(GraphQL::StaticValidation::DefinitionDependencies) @@ -60,9 +48,6 @@ def self.including_rules(rules, rewrite: true) end end - if rewrite - visitor_class.include(GraphQL::InternalRepresentation::Rewrite) - end visitor_class.include(ContextMethods) visitor_class end @@ -71,86 +56,125 @@ def self.including_rules(rules, rewrite: true) module ContextMethods def on_operation_definition(node, parent) object_type = @schema.root_type_for_operation(node.operation_type) - push_type(object_type) - @path.push("#{node.operation_type}#{node.name ? " #{node.name}" : ""}") + prev_parent_ot = @parent_object_type + @parent_object_type = @current_object_type + @current_object_type = object_type + @path[@path_depth] = "#{node.operation_type}#{node.name ? " #{node.name}" : ""}" + @path_depth += 1 super - @object_types.pop - @path.pop + @current_object_type = @parent_object_type + @parent_object_type = prev_parent_ot + @path_depth -= 1 end def on_fragment_definition(node, parent) - on_fragment_with_type(node) do - @path.push("fragment #{node.name}") - super + object_type = if node.type + @types.type(node.type.name) + else + @current_object_type end + prev_parent_ot = @parent_object_type + @parent_object_type = @current_object_type + @current_object_type = object_type + @path[@path_depth] = "fragment #{node.name}" + @path_depth += 1 + super + @current_object_type = @parent_object_type + @parent_object_type = prev_parent_ot + @path_depth -= 1 end + INLINE_FRAGMENT_NO_TYPE = "..." + def on_inline_fragment(node, parent) - on_fragment_with_type(node) do - @path.push("...#{node.type ? " on #{node.type.to_query_string}" : ""}") - super + if node.type + object_type = @types.type(node.type.name) + @path[@path_depth] = @inline_fragment_paths[node.type.name] ||= -"... on #{node.type.to_query_string}" + @path_depth += 1 + else + object_type = @current_object_type + @path[@path_depth] = INLINE_FRAGMENT_NO_TYPE + @path_depth += 1 end + prev_parent_ot = @parent_object_type + @parent_object_type = @current_object_type + @current_object_type = object_type + super + @current_object_type = @parent_object_type + @parent_object_type = prev_parent_ot + @path_depth -= 1 end def on_field(node, parent) - parent_type = @object_types.last - field_definition = @schema.get_field(parent_type, node.name) - @field_definitions.push(field_definition) - if !field_definition.nil? - next_object_type = field_definition.type.unwrap - push_type(next_object_type) + parent_type = @current_object_type + field_definition = @types.field(parent_type, node.name) + prev_field_definition = @current_field_definition + @current_field_definition = field_definition + prev_parent_ot = @parent_object_type + @parent_object_type = @current_object_type + if field_definition + @current_object_type = @field_unwrapped_types[field_definition] ||= field_definition.type.unwrap else - push_type(nil) + @current_object_type = nil end - @path.push(node.alias || node.name) + @path[@path_depth] = node.alias || node.name + @path_depth += 1 super - @field_definitions.pop - @object_types.pop - @path.pop + @current_field_definition = prev_field_definition + @current_object_type = @parent_object_type + @parent_object_type = prev_parent_ot + @path_depth -= 1 end def on_directive(node, parent) - directive_defn = @schema.directives[node.name] - @directive_definitions.push(directive_defn) + directive_defn = @context.schema_directives[node.name] + prev_directive_definition = @current_directive_definition + @current_directive_definition = directive_defn super - @directive_definitions.pop + @current_directive_definition = prev_directive_definition end def on_argument(node, parent) - argument_defn = if (arg = @argument_definitions.last) + argument_defn = if (arg = @current_argument_definition) arg_type = arg.type.unwrap if arg_type.kind.input_object? - arg_type.arguments[node.name] + @types.argument(arg_type, node.name) else nil end - elsif (directive_defn = @directive_definitions.last) - directive_defn.arguments[node.name] - elsif (field_defn = @field_definitions.last) - field_defn.arguments[node.name] + elsif (directive_defn = @current_directive_definition) + @types.argument(directive_defn, node.name) + elsif (field_defn = @current_field_definition) + @types.argument(field_defn, node.name) else nil end - @argument_definitions.push(argument_defn) - @path.push(node.name) + prev_parent = @parent_argument_definition + @parent_argument_definition = @current_argument_definition + @current_argument_definition = argument_defn + @path[@path_depth] = node.name + @path_depth += 1 super - @argument_definitions.pop - @path.pop + @current_argument_definition = @parent_argument_definition + @parent_argument_definition = prev_parent + @path_depth -= 1 end def on_fragment_spread(node, parent) - @path.push("... #{node.name}") + @path[@path_depth] = "... #{node.name}" + @path_depth += 1 super - @path.pop + @path_depth -= 1 end def on_input_object(node, parent) - arg_defn = @argument_definitions.last + arg_defn = @current_argument_definition if arg_defn && arg_defn.type.list? - @path.push(parent.children.index(node)) + @path[@path_depth] = parent.children.index(node) + @path_depth += 1 super - @path.pop + @path_depth -= 1 else super end @@ -158,54 +182,41 @@ def on_input_object(node, parent) # @return [GraphQL::BaseType] The current object type def type_definition - @object_types.last + @current_object_type end # @return [GraphQL::BaseType] The type which the current type came from def parent_type_definition - @object_types[-2] + @parent_object_type end # @return [GraphQL::Field, nil] The most-recently-entered GraphQL::Field, if currently inside one def field_definition - @field_definitions.last + @current_field_definition end # @return [GraphQL::Directive, nil] The most-recently-entered GraphQL::Directive, if currently inside one def directive_definition - @directive_definitions.last + @current_directive_definition end # @return [GraphQL::Argument, nil] The most-recently-entered GraphQL::Argument, if currently inside one def argument_definition - # Don't get the _last_ one because that's the current one. - # Get the second-to-last one, which is the parent of the current one. - @argument_definitions[-2] + # Return the parent argument definition (not the current one). + @parent_argument_definition end private - def on_fragment_with_type(node) - object_type = if node.type - @schema.get_type(node.type.name) - else - @object_types.last - end - push_type(object_type) - yield(node) - @object_types.pop - @path.pop - end - - def push_type(t) - @object_types.push(t) - end end private def add_error(error, path: nil) - error.path ||= (path || @path.dup) + if @context.too_many_errors? + throw :too_many_validation_errors + end + error.path ||= (path || @path[0, @path_depth]) context.errors << error end diff --git a/lib/graphql/static_validation/default_visitor.rb b/lib/graphql/static_validation/default_visitor.rb deleted file mode 100644 index 1202f5b3f29..00000000000 --- a/lib/graphql/static_validation/default_visitor.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module StaticValidation - class DefaultVisitor < BaseVisitor - include(GraphQL::StaticValidation::DefinitionDependencies) - - StaticValidation::ALL_RULES.reverse_each do |r| - include(r) - end - - include(GraphQL::InternalRepresentation::Rewrite) - include(ContextMethods) - end - end -end diff --git a/lib/graphql/static_validation/definition_dependencies.rb b/lib/graphql/static_validation/definition_dependencies.rb index 51ecc92b4ff..2ef2e402b24 100644 --- a/lib/graphql/static_validation/definition_dependencies.rb +++ b/lib/graphql/static_validation/definition_dependencies.rb @@ -70,7 +70,6 @@ def dependency_map(&block) @dependency_map ||= resolve_dependencies(&block) end - # Map definition AST nodes to the definition AST nodes they depend on. # Expose circular dependencies. class DependencyMap @@ -128,8 +127,14 @@ def resolve_dependencies # same name as if they were the same name. If _any_ of the fragments # with that name has a dependency, we record it. independent_fragment_nodes = @defdep_fragment_definitions.values.flatten - @defdep_immediate_dependencies.keys - + visited_fragment_names = Set.new while fragment_node = independent_fragment_nodes.pop + if visited_fragment_names.add?(fragment_node.name) + # this is a new fragment name + else + # this is a duplicate fragment name + next + end loops += 1 if loops > max_loops raise("Resolution loops exceeded the number of definitions; infinite loop detected. (Max: #{max_loops}, Current: #{loops})") diff --git a/lib/graphql/static_validation/error.rb b/lib/graphql/static_validation/error.rb index 7920f699949..d8a4e940362 100644 --- a/lib/graphql/static_validation/error.rb +++ b/lib/graphql/static_validation/error.rb @@ -30,10 +30,12 @@ def to_h }.tap { |h| h["path"] = path unless path.nil? } end + attr_reader :nodes + private def locations - @nodes.map do |node| + nodes.map do |node| h = {"line" => node.line, "column" => node.col} h["filename"] = node.filename if node.filename h diff --git a/lib/graphql/static_validation/literal_validator.rb b/lib/graphql/static_validation/literal_validator.rb index 0cd853b3621..5b2ec5f090c 100644 --- a/lib/graphql/static_validation/literal_validator.rb +++ b/lib/graphql/static_validation/literal_validator.rb @@ -5,7 +5,7 @@ module StaticValidation class LiteralValidator def initialize(context:) @context = context - @warden = context.warden + @types = context.types @invalid_response = GraphQL::Query::InputValidationResult.new(valid: false, problems: []) @valid_response = GraphQL::Query::InputValidationResult.new(valid: true, problems: []) end @@ -18,6 +18,19 @@ def validate(ast_value, type) private + def replace_nulls_in(ast_value) + case ast_value + when Array + ast_value.map { |v| replace_nulls_in(v) } + when GraphQL::Language::Nodes::InputObject + ast_value.to_h + when GraphQL::Language::Nodes::NullValue + nil + else + ast_value + end + end + def recursively_validate(ast_value, type) if type.nil? # this means we're an undefined argument, see #present_input_field_values_are_valid @@ -42,7 +55,8 @@ def recursively_validate(ast_value, type) @valid_response elsif type.kind.scalar? && constant_scalar?(ast_value) maybe_raise_if_invalid(ast_value) do - type.validate_input(ast_value, @context) + ruby_value = replace_nulls_in(ast_value) + type.validate_input(ruby_value, @context) end elsif type.kind.enum? maybe_raise_if_invalid(ast_value) do @@ -95,9 +109,9 @@ def constant_scalar?(ast_value) def required_input_fields_are_present(type, ast_node) # TODO - would be nice to use these to create an error message so the caller knows # that required fields are missing - required_field_names = type.arguments.each_value - .select { |argument| argument.type.kind.non_null? && @warden.get_argument(type, argument.name) } - .map(&:name) + required_field_names = @types.arguments(type) + .select { |argument| argument.type.kind.non_null? && !argument.default_value? } + .map!(&:name) present_field_names = ast_node.arguments.map(&:name) missing_required_field_names = required_field_names - present_field_names @@ -105,16 +119,19 @@ def required_input_fields_are_present(type, ast_node) missing_required_field_names.empty? ? @valid_response : @invalid_response else results = missing_required_field_names.map do |name| - arg_type = @warden.get_argument(type, name).type + arg_type = @types.argument(type, name).type recursively_validate(GraphQL::Language::Nodes::NullValue.new(name: name), arg_type) end + if type.one_of? && ast_node.arguments.size != 1 + results << Query::InputValidationResult.from_problem("`#{type.graphql_name}` is a OneOf type, so only one argument may be given (instead of #{ast_node.arguments.size})") + end merge_results(results) end end def present_input_field_values_are_valid(type, ast_node) results = ast_node.arguments.map do |value| - field = @warden.get_argument(type, value.name) + field = @types.argument(type, value.name) # we want to call validate on an argument even if it's an invalid one # so that our raise exception is on it instead of the entire InputObject field_type = field && field.type diff --git a/lib/graphql/static_validation/no_validate_visitor.rb b/lib/graphql/static_validation/no_validate_visitor.rb deleted file mode 100644 index 4fc33034339..00000000000 --- a/lib/graphql/static_validation/no_validate_visitor.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module StaticValidation - class NoValidateVisitor < StaticValidation::BaseVisitor - include(GraphQL::InternalRepresentation::Rewrite) - include(GraphQL::StaticValidation::DefinitionDependencies) - include(ContextMethods) - end - end -end diff --git a/lib/graphql/static_validation/rules/argument_literals_are_compatible.rb b/lib/graphql/static_validation/rules/argument_literals_are_compatible.rb index ff27b220831..9746843d1d6 100644 --- a/lib/graphql/static_validation/rules/argument_literals_are_compatible.rb +++ b/lib/graphql/static_validation/rules/argument_literals_are_compatible.rb @@ -12,10 +12,10 @@ def on_argument(node, parent) return end - if @context.schema.error_bubbling || context.errors.none? { |err| err.path.take(@path.size) == @path } + if @context.schema.error_bubbling || context.errors.none? { |err| err.path.take(@path_depth) == @path[0, @path_depth] } parent_defn = parent_definition(parent) - if parent_defn && (arg_defn = parent_defn.arguments[node.name]) + if parent_defn && (arg_defn = @types.argument(parent_defn, node.name)) validation_result = context.validate_literal(node.value, arg_defn.type) if !validation_result.valid? kind_of_node = node_type(parent) diff --git a/lib/graphql/static_validation/rules/argument_names_are_unique.rb b/lib/graphql/static_validation/rules/argument_names_are_unique.rb index bc8fcea305a..3ecf8d8d438 100644 --- a/lib/graphql/static_validation/rules/argument_names_are_unique.rb +++ b/lib/graphql/static_validation/rules/argument_names_are_unique.rb @@ -16,12 +16,24 @@ def on_directive(node, parent) def validate_arguments(node) argument_defns = node.arguments - if argument_defns.any? - args_by_name = Hash.new { |h, k| h[k] = [] } - argument_defns.each { |a| args_by_name[a.name] << a } - args_by_name.each do |name, defns| - if defns.size > 1 - add_error(GraphQL::StaticValidation::ArgumentNamesAreUniqueError.new("There can be only one argument named \"#{name}\"", nodes: defns, name: name)) + if argument_defns.size > 1 + seen = {} + argument_defns.each do |a| + name = a.name + if seen.key?(name) + prev = seen[name] + if prev.is_a?(Array) + prev << a + else + seen[name] = [prev, a] + end + else + seen[name] = a + end + end + seen.each do |name, val| + if val.is_a?(Array) + add_error(GraphQL::StaticValidation::ArgumentNamesAreUniqueError.new("There can be only one argument named \"#{name}\"", nodes: val, name: name)) end end end diff --git a/lib/graphql/static_validation/rules/arguments_are_defined.rb b/lib/graphql/static_validation/rules/arguments_are_defined.rb index acc6b4c80c5..69ff5e3af00 100644 --- a/lib/graphql/static_validation/rules/arguments_are_defined.rb +++ b/lib/graphql/static_validation/rules/arguments_are_defined.rb @@ -5,13 +5,17 @@ module ArgumentsAreDefined def on_argument(node, parent) parent_defn = parent_definition(parent) - if parent_defn && context.warden.get_argument(parent_defn, node.name) + if parent_defn && @types.argument(parent_defn, node.name) super elsif parent_defn kind_of_node = node_type(parent) error_arg_name = parent_name(parent, parent_defn) + suggestion = if @schema.did_you_mean + arg_names = context.types.arguments(parent_defn).map(&:graphql_name) + context.did_you_mean_suggestion(node.name, arg_names) + end add_error(GraphQL::StaticValidation::ArgumentsAreDefinedError.new( - "#{kind_of_node} '#{error_arg_name}' doesn't accept argument '#{node.name}'", + "#{kind_of_node} '#{error_arg_name}' doesn't accept argument '#{node.name}'#{suggestion}", nodes: node, name: error_arg_name, type: kind_of_node, @@ -59,7 +63,7 @@ def parent_definition(parent) end end when GraphQL::Language::Nodes::Directive - context.schema.directives[parent.name] + context.schema_directives[parent.name] when GraphQL::Language::Nodes::Field context.field_definition else diff --git a/lib/graphql/static_validation/rules/directives_are_defined.rb b/lib/graphql/static_validation/rules/directives_are_defined.rb index b56a36fa123..c706795afbd 100644 --- a/lib/graphql/static_validation/rules/directives_are_defined.rb +++ b/lib/graphql/static_validation/rules/directives_are_defined.rb @@ -4,16 +4,25 @@ module StaticValidation module DirectivesAreDefined def initialize(*) super - @directive_names = context.warden.directives.map(&:graphql_name) end def on_directive(node, parent) - if !@directive_names.include?(node.name) - add_error(GraphQL::StaticValidation::DirectivesAreDefinedError.new( - "Directive @#{node.name} is not defined", - nodes: node, - directive: node.name - )) + if !@types.directive_exists?(node.name) + @directives_are_defined_errors_by_name ||= {} + error = @directives_are_defined_errors_by_name[node.name] ||= begin + suggestion = if @schema.did_you_mean + @directive_names ||= @types.directives.map(&:graphql_name) + context.did_you_mean_suggestion(node.name, @directive_names) + end + err = GraphQL::StaticValidation::DirectivesAreDefinedError.new( + "Directive @#{node.name} is not defined#{suggestion}", + nodes: [], + directive: node.name + ) + add_error(err) + err + end + error.nodes << node else super end diff --git a/lib/graphql/static_validation/rules/directives_are_in_valid_locations.rb b/lib/graphql/static_validation/rules/directives_are_in_valid_locations.rb index ae7f9e85a84..65f7f2a06ca 100644 --- a/lib/graphql/static_validation/rules/directives_are_in_valid_locations.rb +++ b/lib/graphql/static_validation/rules/directives_are_in_valid_locations.rb @@ -5,27 +5,29 @@ module DirectivesAreInValidLocations include GraphQL::Language def on_directive(node, parent) - validate_location(node, parent, context.schema.directives) + validate_location(node, parent, context.schema_directives) super end private LOCATION_MESSAGE_NAMES = { - GraphQL::Directive::QUERY => "queries", - GraphQL::Directive::MUTATION => "mutations", - GraphQL::Directive::SUBSCRIPTION => "subscriptions", - GraphQL::Directive::FIELD => "fields", - GraphQL::Directive::FRAGMENT_DEFINITION => "fragment definitions", - GraphQL::Directive::FRAGMENT_SPREAD => "fragment spreads", - GraphQL::Directive::INLINE_FRAGMENT => "inline fragments", + GraphQL::Schema::Directive::QUERY => "queries", + GraphQL::Schema::Directive::MUTATION => "mutations", + GraphQL::Schema::Directive::SUBSCRIPTION => "subscriptions", + GraphQL::Schema::Directive::FIELD => "fields", + GraphQL::Schema::Directive::FRAGMENT_DEFINITION => "fragment definitions", + GraphQL::Schema::Directive::FRAGMENT_SPREAD => "fragment spreads", + GraphQL::Schema::Directive::INLINE_FRAGMENT => "inline fragments", + GraphQL::Schema::Directive::VARIABLE_DEFINITION => "variable definitions", } SIMPLE_LOCATIONS = { - Nodes::Field => GraphQL::Directive::FIELD, - Nodes::InlineFragment => GraphQL::Directive::INLINE_FRAGMENT, - Nodes::FragmentSpread => GraphQL::Directive::FRAGMENT_SPREAD, - Nodes::FragmentDefinition => GraphQL::Directive::FRAGMENT_DEFINITION, + Nodes::Field => GraphQL::Schema::Directive::FIELD, + Nodes::InlineFragment => GraphQL::Schema::Directive::INLINE_FRAGMENT, + Nodes::FragmentSpread => GraphQL::Schema::Directive::FRAGMENT_SPREAD, + Nodes::FragmentDefinition => GraphQL::Schema::Directive::FRAGMENT_DEFINITION, + Nodes::VariableDefinition => GraphQL::Schema::Directive::VARIABLE_DEFINITION, } SIMPLE_LOCATION_NODES = SIMPLE_LOCATIONS.keys @@ -34,7 +36,7 @@ def validate_location(ast_directive, ast_parent, directives) directive_defn = directives[ast_directive.name] case ast_parent when Nodes::OperationDefinition - required_location = GraphQL::Directive.const_get(ast_parent.operation_type.upcase) + required_location = GraphQL::Schema::Directive.const_get(ast_parent.operation_type.upcase) assert_includes_location(directive_defn, ast_directive, required_location) when *SIMPLE_LOCATION_NODES required_location = SIMPLE_LOCATIONS[ast_parent.class] diff --git a/lib/graphql/static_validation/rules/fields_are_defined_on_type.rb b/lib/graphql/static_validation/rules/fields_are_defined_on_type.rb index 51576ee6b97..28d3b436d0e 100644 --- a/lib/graphql/static_validation/rules/fields_are_defined_on_type.rb +++ b/lib/graphql/static_validation/rules/fields_are_defined_on_type.rb @@ -3,8 +3,8 @@ module GraphQL module StaticValidation module FieldsAreDefinedOnType def on_field(node, parent) - parent_type = @object_types[-2] - field = context.warden.get_field(parent_type, node.name) + parent_type = @parent_object_type + field = context.query.types.field(parent_type, node.name) if field.nil? if parent_type.kind.union? @@ -14,8 +14,12 @@ def on_field(node, parent) node_name: parent_type.graphql_name )) else + suggestion = if @schema.did_you_mean + context.did_you_mean_suggestion(node.name, possible_fields(context, parent_type)) + end + message = "Field '#{node.name}' doesn't exist on type '#{parent_type.graphql_name}'#{suggestion}" add_error(GraphQL::StaticValidation::FieldsAreDefinedOnTypeError.new( - "Field '#{node.name}' doesn't exist on type '#{parent_type.graphql_name}'", + message, nodes: node, field: node.name, type: parent_type.graphql_name @@ -25,6 +29,13 @@ def on_field(node, parent) super end end + + private + + def possible_fields(context, parent_type) + return EmptyObjects::EMPTY_ARRAY if parent_type.kind.leaf? + context.types.fields(parent_type).map(&:graphql_name) + end end end end diff --git a/lib/graphql/static_validation/rules/fields_have_appropriate_selections.rb b/lib/graphql/static_validation/rules/fields_have_appropriate_selections.rb index e2c64689647..757410302f1 100644 --- a/lib/graphql/static_validation/rules/fields_have_appropriate_selections.rb +++ b/lib/graphql/static_validation/rules/fields_have_appropriate_selections.rb @@ -7,8 +7,7 @@ module FieldsHaveAppropriateSelections include GraphQL::StaticValidation::Error::ErrorHelper def on_field(node, parent) - field_defn = field_definition - if validate_field_selections(node, field_defn.type.unwrap) + if validate_field_selections(node, @current_object_type) super end end @@ -23,16 +22,69 @@ def on_operation_definition(node, _parent) def validate_field_selections(ast_node, resolved_type) + # Fast paths for the two most common cases: + # 1. Leaf type with no selections (scalars, enums) — most fields + # 2. Non-leaf type with selections (objects, interfaces) + if resolved_type + if ast_node.selections.empty? + return true if resolved_type.kind.leaf? + else + return true unless resolved_type.kind.leaf? + end + end + msg = if resolved_type.nil? nil - elsif resolved_type.kind.scalar? && ast_node.selections.any? - if ast_node.selections.first.is_a?(GraphQL::Language::Nodes::InlineFragment) - "Selections can't be made on scalars (%{node_name} returns #{resolved_type.graphql_name} but has inline fragments [#{ast_node.selections.map(&:type).map(&:name).join(", ")}])" + elsif resolved_type.kind.leaf? + if !ast_node.selections.empty? + selection_strs = ast_node.selections.map do |n| + case n + when GraphQL::Language::Nodes::InlineFragment + "\"... on #{n.type.name} { ... }\"" + when GraphQL::Language::Nodes::Field + "\"#{n.name}\"" + when GraphQL::Language::Nodes::FragmentSpread + "\"#{n.name}\"" + else + raise "Invariant: unexpected selection node: #{n}" + end + end + "Selections can't be made on #{resolved_type.kind.name.sub("_", " ").downcase}s (%{node_name} returns #{resolved_type.graphql_name} but has selections [#{selection_strs.join(", ")}])" + else + nil + end + elsif ast_node.selections.empty? + return_validation_error = true + legacy_invalid_empty_selection_result = nil + if !resolved_type.kind.fields? + case @schema.allow_legacy_invalid_empty_selections_on_union + when true + legacy_invalid_empty_selection_result = @schema.legacy_invalid_empty_selections_on_union_with_type(@context.query, resolved_type) + case legacy_invalid_empty_selection_result + when :return_validation_error + # keep `return_validation_error = true` + when String + return_validation_error = false + # the string is returned below + when nil + # No error: + return_validation_error = false + legacy_invalid_empty_selection_result = nil + else + raise GraphQL::InvariantError, "Unexpected return value from legacy_invalid_empty_selections_on_union_with_type, must be `:return_validation_error`, String, or nil (got: #{legacy_invalid_empty_selection_result.inspect})" + end + when false + # pass -- error below + else + return_validation_error = false + @context.query.logger.warn("Unions require selections but #{ast_node.alias || ast_node.name} (#{resolved_type.graphql_name}) doesn't have any. This will fail with a validation error on a future GraphQL-Ruby version. More info: https://graphql-ruby.org/api-doc/#{GraphQL::VERSION}/GraphQL/Schema.html#allow_legacy_invalid_empty_selections_on_union-class_method") + end + end + if return_validation_error + "Field must have selections (%{node_name} returns #{resolved_type.graphql_name} but has no selections. Did you mean '#{ast_node.name} { ... }'?)" else - "Selections can't be made on scalars (%{node_name} returns #{resolved_type.graphql_name} but has selections [#{ast_node.selections.map(&:name).join(", ")}])" + legacy_invalid_empty_selection_result end - elsif resolved_type.kind.fields? && ast_node.selections.empty? - "Field must have selections (%{node_name} returns #{resolved_type.graphql_name} but has no selections. Did you mean '#{ast_node.name} { ... }'?)" else nil end diff --git a/lib/graphql/static_validation/rules/fields_will_merge.rb b/lib/graphql/static_validation/rules/fields_will_merge.rb index 71751762242..45f200285c1 100644 --- a/lib/graphql/static_validation/rules/fields_will_merge.rb +++ b/lib/graphql/static_validation/rules/fields_will_merge.rb @@ -8,211 +8,372 @@ module FieldsWillMerge # fragments) either correspond to distinct response names or can be merged # without ambiguity. # - # Original Algorithm: https://github.com/graphql/graphql-js/blob/master/src/validation/rules/OverlappingFieldsCanBeMerged.js - NO_ARGS = {}.freeze - Field = Struct.new(:node, :definition, :owner_type, :parents) - FragmentSpread = Struct.new(:name, :parents) + # Optimized algorithm based on: + # https://tech.new-work.se/graphql-overlapping-fields-can-be-merged-fast-ea6e92e0a01 + # + # Instead of comparing fields, fields-vs-fragments, and fragments-vs-fragments + # separately (which leads to exponential recursion through nested fragments), + # we flatten all fragment spreads into a single field map and compare within it. + NO_ARGS = GraphQL::EmptyObjects::EMPTY_HASH + + class Field + attr_reader :node, :definition, :owner_type, :parents + + def initialize(node, definition, owner_type, parents) + @node = node + @definition = definition + @owner_type = owner_type + @parents = parents + end + + def return_type + @return_type ||= @definition&.type + end + + def unwrapped_return_type + @unwrapped_return_type ||= return_type&.unwrap + end + end def initialize(*) super - @visited_fragments = {} - @compared_fragments = {} + @conflict_count = 0 + @max_errors = context.max_errors + @fragments = context.fragments + # Track which sub-selection node pairs have been compared to prevent + # infinite recursion with cyclic fragments + @compared_sub_selections = {}.compare_by_identity + # Cache mutually_exclusive? results for type pairs + @mutually_exclusive_cache = {}.compare_by_identity + # Cache collect_fields results for sub-selection comparison + @sub_fields_cache = {}.compare_by_identity end def on_operation_definition(node, _parent) + @conflicts = nil conflicts_within_selection_set(node, type_definition) + @conflicts&.each_value { |error_type| error_type.each_value { |error| add_error(error) } } super end def on_field(node, _parent) - conflicts_within_selection_set(node, type_definition) + if !node.selections.empty? && selections_may_conflict?(node.selections) + @conflicts = nil + conflicts_within_selection_set(node, type_definition) + @conflicts&.each_value { |error_type| error_type.each_value { |error| add_error(error) } } + end super end private - def conflicts_within_selection_set(node, parent_type) - return if parent_type.nil? + # Quick check: can the direct children of this selection set possibly conflict? + # If all direct selections are Fields with unique names and no aliases, + # and there are no fragments, then no response key can have >1 field, + # so there are no merge conflicts to check at this level. + def selections_may_conflict?(selections) + i = 0 + len = selections.size + while i < len + sel = selections[i] + # Fragment spread or inline fragment — needs full check + return true unless sel.is_a?(GraphQL::Language::Nodes::Field) + + # Aliased field — could create duplicate response key + return true if sel.alias + + i += 1 + end - fields, fragment_spreads = fields_and_fragments_from_selection(node, owner_type: parent_type, parents: []) - - # (A) Find find all conflicts "within" the fields of this selection set. - find_conflicts_within(fields) - - fragment_spreads.each_with_index do |fragment_spread, i| - are_mutually_exclusive = mutually_exclusive?( - fragment_spread.parents, - [parent_type] - ) - - # (B) Then find conflicts between these fields and those represented by - # each spread fragment name found. - find_conflicts_between_fields_and_fragment( - fragment_spread, - fields, - mutually_exclusive: are_mutually_exclusive, - ) - - # (C) Then compare this fragment with all other fragments found in this - # selection set to collect conflicts between fragments spread together. - # This compares each item in the list of fragment names to every other - # item in that same list (except for itself). - fragment_spreads[i + 1..-1].each do |fragment_spread2| - are_mutually_exclusive = mutually_exclusive?( - fragment_spread.parents, - fragment_spread2.parents - ) - - find_conflicts_between_fragments( - fragment_spread, - fragment_spread2, - mutually_exclusive: are_mutually_exclusive, - ) + # All are unaliased fields — check for duplicate names + # For small sets, O(n²) is cheaper than hash allocation + if len <= 8 + i = 0 + while i < len + j = i + 1 + name_i = selections[i].name + while j < len + return true if selections[j].name == name_i + j += 1 + end + i += 1 end + + false + else + true # Assume potential conflicts for larger sets end end - def find_conflicts_between_fragments(fragment_spread1, fragment_spread2, mutually_exclusive:) - fragment_name1 = fragment_spread1.name - fragment_name2 = fragment_spread2.name - return if fragment_name1 == fragment_name2 - - cache_key = compared_fragments_key( - fragment_name1, - fragment_name2, - mutually_exclusive, - ) - if @compared_fragments.key?(cache_key) - return - else - @compared_fragments[cache_key] = true + def conflicts + @conflicts ||= Hash.new do |h, error_type| + h[error_type] = Hash.new do |h2, field_name| + h2[field_name] = GraphQL::StaticValidation::FieldsWillMergeError.new(kind: error_type, field_name: field_name) + end end + end - fragment1 = context.fragments[fragment_name1] - fragment2 = context.fragments[fragment_name2] + # Core algorithm: collect ALL fields (expanding fragments inline) into a flat + # map keyed by response key, then compare within each group. + def conflicts_within_selection_set(node, parent_type) + return if parent_type.nil? + return if node.selections.empty? - return if fragment1.nil? || fragment2.nil? + # Collect all fields from this selection set, expanding fragments transitively + response_keys = collect_fields(node.selections, owner_type: parent_type, parents: []) - fragment_type1 = context.warden.get_type(fragment1.type.name) - fragment_type2 = context.warden.get_type(fragment2.type.name) + # Find conflicts within each response key group + find_conflicts_within(response_keys) + end - return if fragment_type1.nil? || fragment_type2.nil? + # Collect all fields from selections, expanding fragment spreads inline. + # Returns a Hash of { response_key => Field | [Field, ...] } + def collect_fields(selections, owner_type:, parents:) + response_keys = {} + collect_fields_inner(selections, owner_type: owner_type, parents: parents, response_keys: response_keys, visited_fragments: nil) + response_keys + end - fragment_fields1, fragment_spreads1 = fields_and_fragments_from_selection( - fragment1, - owner_type: fragment_type1, - parents: [*fragment_spread1.parents, fragment_type1] - ) - fragment_fields2, fragment_spreads2 = fields_and_fragments_from_selection( - fragment2, - owner_type: fragment_type1, - parents: [*fragment_spread2.parents, fragment_type2] - ) + def collect_fields_inner(selections, owner_type:, parents:, response_keys:, visited_fragments:) + deferred_spreads = nil + sel_idx = 0 + sel_len = selections.size - # (F) First, find all conflicts between these two collections of fields - # (not including any nested fragments). - find_conflicts_between( - fragment_fields1, - fragment_fields2, - mutually_exclusive: mutually_exclusive, - ) + while sel_idx < sel_len + sel = selections[sel_idx] - # (G) Then collect conflicts between the first fragment and any nested - # fragments spread in the second fragment. - fragment_spreads2.each do |fragment_spread| - find_conflicts_between_fragments( - fragment_spread1, - fragment_spread, - mutually_exclusive: mutually_exclusive, - ) - end + case sel + when GraphQL::Language::Nodes::Field + definition = @types.field(owner_type, sel.name) + key = sel.alias || sel.name + field = Field.new(sel, definition, owner_type, parents) + existing = response_keys[key] + + if existing.nil? + response_keys[key] = field + elsif existing.is_a?(Field) + response_keys[key] = [existing, field] + else + existing << field + end + when GraphQL::Language::Nodes::InlineFragment + frag_type = sel.type ? @types.type(sel.type.name) : owner_type - # (G) Then collect conflicts between the first fragment and any nested - # fragments spread in the second fragment. - fragment_spreads1.each do |fragment_spread| - find_conflicts_between_fragments( - fragment_spread2, - fragment_spread, - mutually_exclusive: mutually_exclusive, - ) - end - end + if frag_type + new_parents = parents.dup + new_parents << frag_type + collect_fields_inner(sel.selections, owner_type: frag_type, parents: new_parents, response_keys: response_keys, visited_fragments: visited_fragments) + end + when GraphQL::Language::Nodes::FragmentSpread + (deferred_spreads ||= []) << sel + end - def find_conflicts_between_fields_and_fragment(fragment_spread, fields, mutually_exclusive:) - fragment_name = fragment_spread.name - return if @visited_fragments.key?(fragment_name) - @visited_fragments[fragment_name] = true + sel_idx += 1 + end - fragment = context.fragments[fragment_name] - return if fragment.nil? + if deferred_spreads + visited_fragments ||= {} + sel_idx = 0 + sel_len = deferred_spreads.size - fragment_type = context.warden.get_type(fragment.type.name) - return if fragment_type.nil? + while sel_idx < sel_len + sel = deferred_spreads[sel_idx] + sel_idx += 1 + next if visited_fragments.key?(sel.name) - fragment_fields, fragment_spreads = fields_and_fragments_from_selection(fragment, owner_type: fragment_type, parents: [*fragment_spread.parents, fragment_type]) + visited_fragments[sel.name] = true + frag = @fragments[sel.name] + next unless frag - # (D) First find any conflicts between the provided collection of fields - # and the collection of fields represented by the given fragment. - find_conflicts_between( - fields, - fragment_fields, - mutually_exclusive: mutually_exclusive, - ) + frag_type = @types.type(frag.type.name) + next unless frag_type - # (E) Then collect any conflicts between the provided collection of fields - # and any fragment names found in the given fragment. - fragment_spreads.each do |fragment_spread| - find_conflicts_between_fields_and_fragment( - fragment_spread, - fields, - mutually_exclusive: mutually_exclusive, - ) + new_parents = parents.dup + new_parents << frag_type + collect_fields_inner(frag.selections, owner_type: frag_type, parents: new_parents, response_keys: response_keys, visited_fragments: visited_fragments) + end end end def find_conflicts_within(response_keys) response_keys.each do |key, fields| - next if fields.size < 2 - # find conflicts within nodes - for i in 0..fields.size - 1 - for j in i + 1..fields.size - 1 - find_conflict(key, fields[i], fields[j]) + next unless fields.is_a?(Array) + + # Optimization: group fields by signature (name + definition + arguments). + # Fields with the same signature can only conflict on sub-selections, + # so we only need to compare one pair within each group. + if fields.size > 4 + f0 = fields[0] + all_same = true + i = 1 + while i < fields.size + unless fields_same_signature?(f0, fields[i]) + all_same = false + break + end + i += 1 + end + + if all_same + # All fields share a signature, so they can only conflict on + # sub-selections. Deduplicate by AST node identity — fields from + # the same node always have identical sub-selections. + unique_nodes = fields.uniq { |f| f.node.object_id } + i = 0 + while i < unique_nodes.size + j = i + 1 + while j < unique_nodes.size + if unique_nodes[i].node.selections.size > 0 || unique_nodes[j].node.selections.size > 0 + find_conflict(key, unique_nodes[i], unique_nodes[j]) + end + j += 1 + end + i += 1 + end + else + groups = fields.group_by { |f| field_signature(f) } + unique_groups = groups.values + + # Compare representatives across different groups + gi = 0 + while gi < unique_groups.size + gj = gi + 1 + while gj < unique_groups.size + find_conflict(key, unique_groups[gi][0], unique_groups[gj][0]) + gj += 1 + end + + # Within same group, deduplicate by AST node and compare all + # pairs for sub-selection conflicts + group = unique_groups[gi] + if group.size >= 2 + unique_in_group = group.uniq { |f| f.node.object_id } + ui = 0 + while ui < unique_in_group.size + uj = ui + 1 + while uj < unique_in_group.size + if unique_in_group[ui].node.selections.size > 0 || unique_in_group[uj].node.selections.size > 0 + find_conflict(key, unique_in_group[ui], unique_in_group[uj]) + end + uj += 1 + end + ui += 1 + end + end + + gi += 1 + end + end + else + # Small number of fields — original O(n²) is fine + i = 0 + while i < fields.size + j = i + 1 + while j < fields.size + find_conflict(key, fields[i], fields[j]) + j += 1 + end + i += 1 end end end end + def fields_same_signature?(f1, f2) + n1 = f1.node + n2 = f2.node + + f1.definition.equal?(f2.definition) && + n1.name == n2.name && + same_arguments?(n1, n2) + end + + def field_signature(field) + node = field.node + defn = field.definition + args = node.arguments + + if args.empty? + [node.name, defn.object_id] + else + [node.name, defn.object_id, args.map { |a| [a.name, serialize_arg(a.value)] }] + end + end + def find_conflict(response_key, field1, field2, mutually_exclusive: false) + return if @conflict_count >= @max_errors + return if field1.definition.nil? || field2.definition.nil? + node1 = field1.node node2 = field2.node - are_mutually_exclusive = mutually_exclusive || - mutually_exclusive?(field1.parents, field2.parents) + are_mutually_exclusive = mutually_exclusive || mutually_exclusive?(field1.parents, field2.parents) if !are_mutually_exclusive if node1.name != node2.name - errored_nodes = [node1.name, node2.name].sort.join(" or ") - msg = "Field '#{response_key}' has a field conflict: #{errored_nodes}?" - context.errors << GraphQL::StaticValidation::FieldsWillMergeError.new( - msg, - nodes: [node1, node2], - path: [], - field_name: response_key, - conflicts: errored_nodes - ) + conflict = conflicts[:field][response_key] + + conflict.add_conflict(node1, node1.name) + conflict.add_conflict(node2, node2.name) + + @conflict_count += 1 end if !same_arguments?(node1, node2) - args = [serialize_field_args(node1), serialize_field_args(node2)] - conflicts = args.map { |arg| GraphQL::Language.serialize(arg) }.join(" or ") - msg = "Field '#{response_key}' has an argument conflict: #{conflicts}?" - context.errors << GraphQL::StaticValidation::FieldsWillMergeError.new( - msg, - nodes: [node1, node2], - path: [], - field_name: response_key, - conflicts: conflicts - ) + conflict = conflicts[:argument][response_key] + + conflict.add_conflict(node1, GraphQL::Language.serialize(serialize_field_args(node1))) + conflict.add_conflict(node2, GraphQL::Language.serialize(serialize_field_args(node2))) + + @conflict_count += 1 + end + end + + if !conflicts[:field].key?(response_key) && + !field1.definition.equal?(field2.definition) && + (t1 = field1.return_type) && + (t2 = field2.return_type) && + return_types_conflict?(t1, t2) + + return_error = nil + message_override = nil + + case @schema.allow_legacy_invalid_return_type_conflicts + when false + return_error = true + when true + legacy_handling = @schema.legacy_invalid_return_type_conflicts(@context.query, t1, t2, node1, node2) + + case legacy_handling + when nil + return_error = false + when :return_validation_error + return_error = true + when String + return_error = true + message_override = legacy_handling + else + raise GraphQL::Error, "#{@schema}.legacy_invalid_scalar_conflicts returned unexpected value: #{legacy_handling.inspect}. Expected `nil`, String, or `:return_validation_error`." + end + else + return_error = false + @context.query.logger.warn <<~WARN + GraphQL-Ruby encountered mismatched types in this query: `#{t1.to_type_signature}` (at #{node1.line}:#{node1.col}) vs. `#{t2.to_type_signature}` (at #{node2.line}:#{node2.col}). + This will return an error in future GraphQL-Ruby versions, as per the GraphQL specification + Learn about migrating here: https://graphql-ruby.org/api-doc/#{GraphQL::VERSION}/GraphQL/Schema.html#allow_legacy_invalid_return_type_conflicts-class_method + WARN + end + + if return_error + conflict = conflicts[:return_type][response_key] + + if message_override + conflict.message = message_override + end + + conflict.add_conflict(node1, "`#{t1.to_type_signature}`") + conflict.add_conflict(node2, "`#{t2.to_type_signature}`") + @conflict_count += 1 end end @@ -223,112 +384,98 @@ def find_conflict(response_key, field1, field2, mutually_exclusive: false) ) end - def find_conflicts_between_sub_selection_sets(field1, field2, mutually_exclusive:) - return if field1.definition.nil? || field2.definition.nil? + def return_types_conflict?(type1, type2) + if type1.list? + if type2.list? + return_types_conflict?(type1.of_type, type2.of_type) + else + true + end + elsif type2.list? + true + elsif type1.non_null? + if type2.non_null? + return_types_conflict?(type1.of_type, type2.of_type) + else + true + end + elsif type2.non_null? + true + elsif type1.kind.leaf? && type2.kind.leaf? + type1 != type2 + else + false + end + end - return_type1 = field1.definition.type.unwrap - return_type2 = field2.definition.type.unwrap - parents1 = [return_type1] - parents2 = [return_type2] + # When two fields with the same response key both have sub-selections, + # we need to check those sub-selections against each other. + def find_conflicts_between_sub_selection_sets(field1, field2, mutually_exclusive:) + return if field1.definition.nil? || + field2.definition.nil? || + (field1.node.selections.empty? && field2.node.selections.empty?) - fields, fragment_spreads = fields_and_fragments_from_selection( - field1.node, - owner_type: return_type1, - parents: parents1 - ) + node1 = field1.node + node2 = field2.node - fields2, fragment_spreads2 = fields_and_fragments_from_selection( - field2.node, - owner_type: return_type2, - parents: parents2 - ) + # Prevent infinite recursion from cyclic fragments + return if node1.equal?(node2) - # (H) First, collect all conflicts between these two collections of field. - find_conflicts_between(fields, fields2, mutually_exclusive: mutually_exclusive) - - # (I) Then collect conflicts between the first collection of fields and - # those referenced by each fragment name associated with the second. - fragment_spreads2.each do |fragment_spread| - find_conflicts_between_fields_and_fragment( - fragment_spread, - fields, - mutually_exclusive: mutually_exclusive, - ) + inner = @compared_sub_selections[node1] + if inner + return if inner.key?(node2) + inner[node2] = true + else + inner = {}.compare_by_identity + inner[node2] = true + @compared_sub_selections[node1] = inner end - # (I) Then collect conflicts between the second collection of fields and - # those referenced by each fragment name associated with the first. - fragment_spreads.each do |fragment_spread| - find_conflicts_between_fields_and_fragment( - fragment_spread, - fields2, - mutually_exclusive: mutually_exclusive, - ) - end + return_type1 = field1.unwrapped_return_type + return_type2 = field2.unwrapped_return_type - # (J) Also collect conflicts between any fragment names by the first and - # fragment names by the second. This compares each item in the first set of - # names to each item in the second set of names. - fragment_spreads.each do |frag1| - fragment_spreads2.each do |frag2| - find_conflicts_between_fragments( - frag1, - frag2, - mutually_exclusive: mutually_exclusive - ) - end - end - end + response_keys1 = cached_sub_fields(node1, return_type1) + response_keys2 = cached_sub_fields(node2, return_type2) - def find_conflicts_between(response_keys, response_keys2, mutually_exclusive:) - response_keys.each do |key, fields| - fields2 = response_keys2[key] - if fields2 - fields.each do |field| - fields2.each do |field2| - find_conflict( - key, - field, - field2, - mutually_exclusive: mutually_exclusive, - ) - end - end - end - end + find_conflicts_between(response_keys1, response_keys2, mutually_exclusive: mutually_exclusive) end - NO_SELECTIONS = [{}.freeze, [].freeze].freeze + def cached_sub_fields(node, return_type) + inner = @sub_fields_cache[node] - def fields_and_fragments_from_selection(node, owner_type:, parents:) - if node.selections.empty? - NO_SELECTIONS + if inner && inner.key?(return_type) + inner[return_type] else - fields, fragment_spreads = find_fields_and_fragments(node.selections, owner_type: owner_type, parents: parents, fields: [], fragment_spreads: []) - response_keys = fields.group_by { |f| f.node.alias || f.node.name } - [response_keys, fragment_spreads] + result = collect_fields(node.selections, owner_type: return_type, parents: [return_type]) + inner ||= {}.compare_by_identity + inner[return_type] = result + @sub_fields_cache[node] = inner + result end end - def find_fields_and_fragments(selections, owner_type:, parents:, fields:, fragment_spreads:) - selections.each do |node| - case node - when GraphQL::Language::Nodes::Field - definition = context.schema.get_field(owner_type, node.name) - fields << Field.new(node, definition, owner_type, parents) - when GraphQL::Language::Nodes::InlineFragment - fragment_type = node.type ? context.warden.get_type(node.type.name) : owner_type - find_fields_and_fragments(node.selections, parents: [*parents, fragment_type], owner_type: owner_type, fields: fields, fragment_spreads: fragment_spreads) if fragment_type - when GraphQL::Language::Nodes::FragmentSpread - fragment_spreads << FragmentSpread.new(node.name, parents) + def find_conflicts_between(response_keys, response_keys2, mutually_exclusive:) + response_keys.each do |key, fields| + fields2 = response_keys2[key] + next unless fields2 + + fields_arr = fields.is_a?(Field) ? [fields] : fields + fields2_arr = fields2.is_a?(Field) ? [fields2] : fields2 + + fields_arr.each do |field| + fields2_arr.each do |field2| + find_conflict( + key, + field, + field2, + mutually_exclusive: mutually_exclusive, + ) + end end end - - [fields, fragment_spreads] end def same_arguments?(field1, field2) - # Check for incompatible / non-identical arguments on this node: arguments1 = field1.arguments arguments2 = field2.arguments @@ -361,39 +508,47 @@ def serialize_field_args(field) serialized_args end - def compared_fragments_key(frag1, frag2, exclusive) - # Cache key to not compare two fragments more than once. - # The key includes both fragment names sorted (this way we - # avoid computing "A vs B" and "B vs A"). It also includes - # "exclusive" since the result may change depending on the parent_type - "#{[frag1, frag2].sort.join('-')}-#{exclusive}" - end - # Given two list of parents, find out if they are mutually exclusive - # In this context, `parents` represends the "self scope" of the field, - # what types may be found at this point in the query. def mutually_exclusive?(parents1, parents2) if parents1.empty? || parents2.empty? false elsif parents1.length == parents2.length - parents1.length.times.any? do |i| + i = 0 + len = parents1.length + + while i < len type1 = parents1[i - 1] type2 = parents2[i - 1] - if type1 == type2 - # If the types we're comparing are the same type, - # then they aren't mutually exclusive - false - else - # Check if these two scopes have _any_ types in common. - possible_right_types = context.query.possible_types(type1) - possible_left_types = context.query.possible_types(type2) - (possible_right_types & possible_left_types).empty? + unless type1.equal?(type2) + inner = @mutually_exclusive_cache[type1] + if inner + cached = inner[type2] + if cached.nil? + cached = types_mutually_exclusive?(type1, type2) + inner[type2] = cached + end + else + cached = types_mutually_exclusive?(type1, type2) + inner = {}.compare_by_identity + inner[type2] = cached + @mutually_exclusive_cache[type1] = inner + end + return true if cached end + i += 1 end + + false else true end end + + def types_mutually_exclusive?(type1, type2) + possible_right_types = @types.possible_types(type1) + possible_left_types = @types.possible_types(type2) + (possible_right_types & possible_left_types).empty? + end end end end diff --git a/lib/graphql/static_validation/rules/fields_will_merge_error.rb b/lib/graphql/static_validation/rules/fields_will_merge_error.rb index c17e9a947a8..64836842e16 100644 --- a/lib/graphql/static_validation/rules/fields_will_merge_error.rb +++ b/lib/graphql/static_validation/rules/fields_will_merge_error.rb @@ -3,12 +3,41 @@ module GraphQL module StaticValidation class FieldsWillMergeError < StaticValidation::Error attr_reader :field_name - attr_reader :conflicts + attr_reader :kind + + def initialize(kind:, field_name:) + super(nil) - def initialize(message, path: nil, nodes: [], field_name:, conflicts:) - super(message, path: path, nodes: nodes) @field_name = field_name - @conflicts = conflicts + @kind = kind + @conflicts = [] + end + + def message + @message || "Field '#{field_name}' has #{kind == :argument ? 'an' : 'a'} #{kind} conflict: #{conflicts}?" + end + + attr_writer :message + + def path + [] + end + + def conflicts + @conflicts.join(' or ') + end + + def add_conflict(node, conflict_str) + # Check if we already have an error for this exact node. + # Use object identity first (fast path), then fall back to + # value + location comparison for duplicate AST nodes. + if nodes.any? { |n| n.equal?(node) || (n.line == node.line && n.col == node.col && n == node) } + # already have an error for this node + return + end + + @nodes << node + @conflicts << conflict_str end # A hash representation of this Message diff --git a/lib/graphql/static_validation/rules/fragment_spreads_are_possible.rb b/lib/graphql/static_validation/rules/fragment_spreads_are_possible.rb index a800a481d3f..fd9cda29585 100644 --- a/lib/graphql/static_validation/rules/fragment_spreads_are_possible.rb +++ b/lib/graphql/static_validation/rules/fragment_spreads_are_possible.rb @@ -8,8 +8,8 @@ def initialize(*) end def on_inline_fragment(node, parent) - fragment_parent = context.object_types[-2] - fragment_child = context.object_types.last + fragment_parent = @parent_object_type + fragment_child = @current_object_type if fragment_child validate_fragment_in_scope(fragment_parent, fragment_child, node, context, context.path) end @@ -17,7 +17,7 @@ def on_inline_fragment(node, parent) end def on_fragment_spread(node, parent) - fragment_parent = context.object_types.last + fragment_parent = @current_object_type @spreads_to_validate << FragmentSpread.new(node: node, parent_type: fragment_parent, path: context.path) super end @@ -28,7 +28,7 @@ def on_document(node, parent) frag_node = context.fragments[frag_spread.node.name] if frag_node fragment_child_name = frag_node.type.name - fragment_child = context.warden.get_type(fragment_child_name) + fragment_child = @types.type(fragment_child_name) # Might be non-existent type name if fragment_child validate_fragment_in_scope(frag_spread.parent_type, fragment_child, frag_spread.node, context, frag_spread.path) @@ -44,8 +44,8 @@ def validate_fragment_in_scope(parent_type, child_type, node, context, path) # It's not a valid fragment type, this error was handled someplace else return end - parent_types = context.warden.possible_types(parent_type.unwrap) - child_types = context.warden.possible_types(child_type.unwrap) + parent_types = @types.possible_types(parent_type.unwrap) + child_types = @types.possible_types(child_type.unwrap) if child_types.none? { |c| parent_types.include?(c) } name = node.respond_to?(:name) ? " #{node.name}" : "" diff --git a/lib/graphql/static_validation/rules/fragment_types_exist.rb b/lib/graphql/static_validation/rules/fragment_types_exist.rb index 9863eaa9680..87b08d115b1 100644 --- a/lib/graphql/static_validation/rules/fragment_types_exist.rb +++ b/lib/graphql/static_validation/rules/fragment_types_exist.rb @@ -21,10 +21,23 @@ def validate_type_exists(fragment_node) true else type_name = fragment_node.type.name - type = context.warden.get_type(type_name) + type = @types.type(type_name) if type.nil? + suggestion = if @schema.did_you_mean + @all_possible_fragment_type_names ||= begin + names = [] + context.types.all_types.each do |type| + if type.kind.fields? + names << type.graphql_name + end + end + names + end + context.did_you_mean_suggestion(type_name, @all_possible_fragment_type_names) + end + add_error(GraphQL::StaticValidation::FragmentTypesExistError.new( - "No such type #{type_name}, so it can't be a fragment condition", + "No such type #{type_name}, so it can't be a fragment condition#{suggestion}", nodes: fragment_node, type: type_name )) diff --git a/lib/graphql/static_validation/rules/fragments_are_finite.rb b/lib/graphql/static_validation/rules/fragments_are_finite.rb index e0e147c0cd1..45b88247b68 100644 --- a/lib/graphql/static_validation/rules/fragments_are_finite.rb +++ b/lib/graphql/static_validation/rules/fragments_are_finite.rb @@ -7,12 +7,12 @@ def on_document(_n, _p) dependency_map = context.dependencies dependency_map.cyclical_definitions.each do |defn| if defn.node.is_a?(GraphQL::Language::Nodes::FragmentDefinition) - context.errors << GraphQL::StaticValidation::FragmentsAreFiniteError.new( + add_error(GraphQL::StaticValidation::FragmentsAreFiniteError.new( "Fragment #{defn.name} contains an infinite loop", nodes: defn.node, path: defn.path, name: defn.name - ) + )) end end end diff --git a/lib/graphql/static_validation/rules/fragments_are_on_composite_types.rb b/lib/graphql/static_validation/rules/fragments_are_on_composite_types.rb index c97b6f8284d..63f4aa49e8b 100644 --- a/lib/graphql/static_validation/rules/fragments_are_on_composite_types.rb +++ b/lib/graphql/static_validation/rules/fragments_are_on_composite_types.rb @@ -19,7 +19,7 @@ def validate_type_is_composite(node) true else type_name = node_type.to_query_string - type_def = context.warden.get_type(type_name) + type_def = @types.type(type_name) if type_def.nil? || !type_def.kind.composite? add_error(GraphQL::StaticValidation::FragmentsAreOnCompositeTypesError.new( "Invalid fragment on type #{type_name} (must be Union, Interface or Object)", diff --git a/lib/graphql/static_validation/rules/mutation_root_exists.rb b/lib/graphql/static_validation/rules/mutation_root_exists.rb index 8a0929b6875..903221c851e 100644 --- a/lib/graphql/static_validation/rules/mutation_root_exists.rb +++ b/lib/graphql/static_validation/rules/mutation_root_exists.rb @@ -3,7 +3,7 @@ module GraphQL module StaticValidation module MutationRootExists def on_operation_definition(node, _parent) - if node.operation_type == 'mutation' && context.warden.root_type_for_operation("mutation").nil? + if node.operation_type == 'mutation' && context.query.types.mutation_root.nil? add_error(GraphQL::StaticValidation::MutationRootExistsError.new( 'Schema is not configured for mutations', nodes: node diff --git a/lib/graphql/static_validation/rules/no_definitions_are_present.rb b/lib/graphql/static_validation/rules/no_definitions_are_present.rb index 7debd19b55d..00b68246c2d 100644 --- a/lib/graphql/static_validation/rules/no_definitions_are_present.rb +++ b/lib/graphql/static_validation/rules/no_definitions_are_present.rb @@ -32,7 +32,7 @@ def on_invalid_node(node, parent) def on_document(node, parent) super - if @schema_definition_nodes.any? + if !@schema_definition_nodes.empty? add_error(GraphQL::StaticValidation::NoDefinitionsArePresentError.new(%|Query cannot contain schema definitions|, nodes: @schema_definition_nodes)) end end diff --git a/lib/graphql/static_validation/rules/not_single_subscription_error.rb b/lib/graphql/static_validation/rules/not_single_subscription_error.rb new file mode 100644 index 00000000000..726c0c4f415 --- /dev/null +++ b/lib/graphql/static_validation/rules/not_single_subscription_error.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class NotSingleSubscriptionError < StaticValidation::Error + def initialize(message, path: nil, nodes: []) + super(message, path: path, nodes: nodes) + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "notSingleSubscription" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/one_of_input_objects_are_valid.rb b/lib/graphql/static_validation/rules/one_of_input_objects_are_valid.rb new file mode 100644 index 00000000000..a811788e35f --- /dev/null +++ b/lib/graphql/static_validation/rules/one_of_input_objects_are_valid.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + module OneOfInputObjectsAreValid + def on_input_object(node, parent) + return super unless parent.is_a?(GraphQL::Language::Nodes::Argument) + + parent_type = get_parent_type(context, parent) + return super unless parent_type && parent_type.kind.input_object? && parent_type.one_of? + + validate_one_of_input_object(node, context, parent_type) + super + end + + private + + def validate_one_of_input_object(ast_node, context, parent_type) + present_fields = ast_node.arguments.map(&:name) + input_object_type = parent_type.to_type_signature + + if present_fields.count != 1 + add_error( + OneOfInputObjectsAreValidError.new( + "OneOf Input Object '#{input_object_type}' must specify exactly one key.", + path: context.path, + nodes: ast_node, + input_object_type: input_object_type + ) + ) + return + end + + field = present_fields.first + value = ast_node.arguments.first.value + + if value.is_a?(GraphQL::Language::Nodes::NullValue) + add_error( + OneOfInputObjectsAreValidError.new( + "Argument '#{input_object_type}.#{field}' must be non-null.", + path: [*context.path, field], + nodes: ast_node.arguments.first, + input_object_type: input_object_type + ) + ) + return + end + + if value.is_a?(GraphQL::Language::Nodes::VariableIdentifier) + variable_name = value.name + variable_type = @declared_variables[variable_name].type + + unless variable_type.is_a?(GraphQL::Language::Nodes::NonNullType) + add_error( + OneOfInputObjectsAreValidError.new( + "Variable '#{variable_name}' must be non-nullable to be used for OneOf Input Object '#{input_object_type}'.", + path: [*context.path, field], + nodes: ast_node, + input_object_type: input_object_type + ) + ) + end + end + end + end + end +end diff --git a/lib/graphql/static_validation/rules/one_of_input_objects_are_valid_error.rb b/lib/graphql/static_validation/rules/one_of_input_objects_are_valid_error.rb new file mode 100644 index 00000000000..fc925486b2e --- /dev/null +++ b/lib/graphql/static_validation/rules/one_of_input_objects_are_valid_error.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class OneOfInputObjectsAreValidError < StaticValidation::Error + attr_reader :input_object_type + + def initialize(message, path:, nodes:, input_object_type:) + super(message, path: path, nodes: nodes) + @input_object_type = input_object_type + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "inputObjectType" => input_object_type + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "invalidOneOfInputObject" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/query_root_exists.rb b/lib/graphql/static_validation/rules/query_root_exists.rb new file mode 100644 index 00000000000..a27207dbef3 --- /dev/null +++ b/lib/graphql/static_validation/rules/query_root_exists.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + module QueryRootExists + def on_operation_definition(node, _parent) + if (node.operation_type == 'query' || node.operation_type.nil?) && context.query.types.query_root.nil? + add_error(GraphQL::StaticValidation::QueryRootExistsError.new( + 'Schema is not configured for queries', + nodes: node + )) + else + super + end + end + end + end +end diff --git a/lib/graphql/static_validation/rules/query_root_exists_error.rb b/lib/graphql/static_validation/rules/query_root_exists_error.rb new file mode 100644 index 00000000000..650285053ad --- /dev/null +++ b/lib/graphql/static_validation/rules/query_root_exists_error.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class QueryRootExistsError < StaticValidation::Error + + def initialize(message, path: nil, nodes: []) + super(message, path: path, nodes: nodes) + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "missingQueryConfiguration" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/required_arguments_are_present.rb b/lib/graphql/static_validation/rules/required_arguments_are_present.rb index 7c981078ffc..0e65888bd79 100644 --- a/lib/graphql/static_validation/rules/required_arguments_are_present.rb +++ b/lib/graphql/static_validation/rules/required_arguments_are_present.rb @@ -2,13 +2,18 @@ module GraphQL module StaticValidation module RequiredArgumentsArePresent + def initialize(*) + super + @required_args_cache = {}.compare_by_identity + end + def on_field(node, _parent) - assert_required_args(node, field_definition) + assert_required_args(node, @current_field_definition) super end def on_directive(node, _parent) - directive_defn = context.schema.directives[node.name] + directive_defn = context.schema_directives[node.name] assert_required_args(node, directive_defn) super end @@ -16,13 +21,30 @@ def on_directive(node, _parent) private def assert_required_args(ast_node, defn) - present_argument_names = ast_node.arguments.map(&:name) - required_argument_names = defn.arguments.each_value - .select { |a| a.type.kind.non_null? && !a.default_value? && context.warden.get_argument(defn, a.name) } - .map(&:name) + return unless defn + # Cache required argument names per definition to avoid re-iterating + # arguments for the same definition across field instances + if @required_args_cache.key?(defn) + required_argument_names = @required_args_cache[defn] + else + args = @types.arguments(defn) + required_argument_names = nil + if !args.empty? + args.each do |a| + if a.type.kind.non_null? && !a.default_value? && @types.argument(defn, a.name) + (required_argument_names ||= []) << a.graphql_name + end + end + end + @required_args_cache[defn] = required_argument_names + end + + return if required_argument_names.nil? + + present_argument_names = ast_node.arguments.map(&:name) missing_names = required_argument_names - present_argument_names - if missing_names.any? + if !missing_names.empty? add_error(GraphQL::StaticValidation::RequiredArgumentsArePresentError.new( "#{ast_node.class.name.split("::").last} '#{ast_node.name}' is missing required arguments: #{missing_names.join(", ")}", nodes: ast_node, diff --git a/lib/graphql/static_validation/rules/required_input_object_attributes_are_present.rb b/lib/graphql/static_validation/rules/required_input_object_attributes_are_present.rb index 7afff0430d6..08461f49309 100644 --- a/lib/graphql/static_validation/rules/required_input_object_attributes_are_present.rb +++ b/lib/graphql/static_validation/rules/required_input_object_attributes_are_present.rb @@ -26,7 +26,7 @@ def get_parent_type(context, parent) context.directive_definition || context.field_definition end - parent_type = context.warden.get_argument(defn, parent_name(parent, defn)) + parent_type = context.types.argument(defn, parent_name(parent, defn)) parent_type ? parent_type.type.unwrap : nil end @@ -34,16 +34,16 @@ def validate_input_object(ast_node, context, parent) parent_type = get_parent_type(context, parent) return unless parent_type && parent_type.kind.input_object? - required_fields = parent_type.arguments - .select{|k,v| v.type.kind.non_null?} - .keys + required_fields = context.types.arguments(parent_type) + .select{ |arg| arg.type.kind.non_null? && !arg.default_value? } + .map!(&:graphql_name) present_fields = ast_node.arguments.map(&:name) missing_fields = required_fields - present_fields missing_fields.each do |missing_field| path = [*context.path, missing_field] - missing_field_type = parent_type.arguments[missing_field].type + missing_field_type = context.types.argument(parent_type, missing_field).type add_error(RequiredInputObjectAttributesArePresentError.new( "Argument '#{missing_field}' on InputObject '#{parent_type.to_type_signature}' is required. Expected type #{missing_field_type.to_type_signature}", argument_name: missing_field, diff --git a/lib/graphql/static_validation/rules/subscription_root_exists.rb b/lib/graphql/static_validation/rules/subscription_root_exists.rb deleted file mode 100644 index b4ccf10f758..00000000000 --- a/lib/graphql/static_validation/rules/subscription_root_exists.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module StaticValidation - module SubscriptionRootExists - def on_operation_definition(node, _parent) - if node.operation_type == "subscription" && context.warden.root_type_for_operation("subscription").nil? - add_error(GraphQL::StaticValidation::SubscriptionRootExistsError.new( - 'Schema is not configured for subscriptions', - nodes: node - )) - else - super - end - end - end - end -end diff --git a/lib/graphql/static_validation/rules/subscription_root_exists_and_single_subscription_selection.rb b/lib/graphql/static_validation/rules/subscription_root_exists_and_single_subscription_selection.rb new file mode 100644 index 00000000000..fdb7589432f --- /dev/null +++ b/lib/graphql/static_validation/rules/subscription_root_exists_and_single_subscription_selection.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + module SubscriptionRootExistsAndSingleSubscriptionSelection + def on_operation_definition(node, parent) + if node.operation_type == "subscription" + if context.types.subscription_root.nil? + add_error(GraphQL::StaticValidation::SubscriptionRootExistsError.new( + 'Schema is not configured for subscriptions', + nodes: node + )) + elsif node.selections.size != 1 + add_error(GraphQL::StaticValidation::NotSingleSubscriptionError.new( + 'A subscription operation may only have one selection', + nodes: node, + )) + else + super + end + else + super + end + end + end + end +end diff --git a/lib/graphql/static_validation/rules/unique_directives_per_location.rb b/lib/graphql/static_validation/rules/unique_directives_per_location.rb index a04ee2862c0..c4b5f1218ec 100644 --- a/lib/graphql/static_validation/rules/unique_directives_per_location.rb +++ b/lib/graphql/static_validation/rules/unique_directives_per_location.rb @@ -19,13 +19,17 @@ module UniqueDirectivesPerLocation :on_field, ] - DIRECTIVE_NODE_HOOKS.each do |method_name| - define_method(method_name) do |node, parent| - if node.directives.any? + VALIDATE_DIRECTIVE_LOCATION_ON_NODE = <<~RUBY + def %{method_name}(node, parent) + if !node.directives.empty? validate_directive_location(node) end super(node, parent) end + RUBY + DIRECTIVE_NODE_HOOKS.each do |method_name| + # Can't use `define_method {...}` here because the proc can't be isolated for use in non-main Ractors + module_eval(VALIDATE_DIRECTIVE_LOCATION_ON_NODE % { method_name: method_name }) # rubocop:disable Development/NoEvalCop end private @@ -34,13 +38,19 @@ def validate_directive_location(node) used_directives = {} node.directives.each do |ast_directive| directive_name = ast_directive.name - if used_directives[directive_name] - add_error(GraphQL::StaticValidation::UniqueDirectivesPerLocationError.new( - "The directive \"#{directive_name}\" can only be used once at this location.", - nodes: [used_directives[directive_name], ast_directive], - directive: directive_name, - )) - else + if (first_node = used_directives[directive_name]) + @directives_are_unique_errors_by_first_node ||= {} + err = @directives_are_unique_errors_by_first_node[first_node] ||= begin + error = GraphQL::StaticValidation::UniqueDirectivesPerLocationError.new( + "The directive \"#{directive_name}\" can only be used once at this location.", + nodes: [used_directives[directive_name]], + directive: directive_name, + ) + add_error(error) + error + end + err.nodes << ast_directive + elsif !((dir_defn = context.schema_directives[directive_name]) && dir_defn.repeatable?) used_directives[directive_name] = ast_directive end end diff --git a/lib/graphql/static_validation/rules/variable_default_values_are_correctly_typed.rb b/lib/graphql/static_validation/rules/variable_default_values_are_correctly_typed.rb index 26bdda93e82..ba6bb3c108a 100644 --- a/lib/graphql/static_validation/rules/variable_default_values_are_correctly_typed.rb +++ b/lib/graphql/static_validation/rules/variable_default_values_are_correctly_typed.rb @@ -5,36 +5,27 @@ module VariableDefaultValuesAreCorrectlyTyped def on_variable_definition(node, parent) if !node.default_value.nil? value = node.default_value - if node.type.is_a?(GraphQL::Language::Nodes::NonNullType) - add_error(GraphQL::StaticValidation::VariableDefaultValuesAreCorrectlyTypedError.new( - "Non-null variable $#{node.name} can't have a default value", - nodes: node, - name: node.name, - error_type: VariableDefaultValuesAreCorrectlyTypedError::VIOLATIONS[:INVALID_ON_NON_NULL] - )) + type = context.schema.type_from_ast(node.type, context: context) + if type.nil? + # This is handled by another validator else - type = context.schema.type_from_ast(node.type, context: context) - if type.nil? - # This is handled by another validator - else - validation_result = context.validate_literal(value, type) + validation_result = context.validate_literal(value, type) - if !validation_result.valid? - problems = validation_result.problems - first_problem = problems && problems.first - if first_problem - error_message = first_problem["message"] - end - - error_message ||= "Default value for $#{node.name} doesn't match type #{type.to_type_signature}" - add_error(GraphQL::StaticValidation::VariableDefaultValuesAreCorrectlyTypedError.new( - error_message, - nodes: node, - name: node.name, - type: type.to_type_signature, - error_type: VariableDefaultValuesAreCorrectlyTypedError::VIOLATIONS[:INVALID_TYPE], - )) + if !validation_result.valid? + problems = validation_result.problems + first_problem = problems && problems.first + if first_problem + error_message = first_problem["explanation"] end + + error_message ||= "Default value for $#{node.name} doesn't match type #{type.to_type_signature}" + add_error(GraphQL::StaticValidation::VariableDefaultValuesAreCorrectlyTypedError.new( + error_message, + nodes: node, + name: node.name, + type: type.to_type_signature, + error_type: VariableDefaultValuesAreCorrectlyTypedError::VIOLATIONS[:INVALID_TYPE], + )) end end end diff --git a/lib/graphql/static_validation/rules/variable_names_are_unique.rb b/lib/graphql/static_validation/rules/variable_names_are_unique.rb index 88708467711..8b1b44fb9f5 100644 --- a/lib/graphql/static_validation/rules/variable_names_are_unique.rb +++ b/lib/graphql/static_validation/rules/variable_names_are_unique.rb @@ -4,7 +4,7 @@ module StaticValidation module VariableNamesAreUnique def on_operation_definition(node, parent) var_defns = node.variables - if var_defns.any? + if !var_defns.empty? vars_by_name = Hash.new { |h, k| h[k] = [] } var_defns.each { |v| vars_by_name[v.name] << v } vars_by_name.each do |name, defns| diff --git a/lib/graphql/static_validation/rules/variable_usages_are_allowed.rb b/lib/graphql/static_validation/rules/variable_usages_are_allowed.rb index a1398d213aa..af2343753da 100644 --- a/lib/graphql/static_validation/rules/variable_usages_are_allowed.rb +++ b/lib/graphql/static_validation/rules/variable_usages_are_allowed.rb @@ -21,16 +21,16 @@ def on_argument(node, parent) end node_values = node_values.select { |value| value.is_a? GraphQL::Language::Nodes::VariableIdentifier } - if node_values.any? - arguments = case parent + if !node_values.empty? + argument_owner = case parent when GraphQL::Language::Nodes::Field - context.field_definition.arguments + context.field_definition when GraphQL::Language::Nodes::Directive - context.directive_definition.arguments + context.directive_definition when GraphQL::Language::Nodes::InputObject arg_type = context.argument_definition.type.unwrap if arg_type.kind.input_object? - arguments = arg_type.arguments + arg_type else # This is some kind of error nil @@ -43,7 +43,7 @@ def on_argument(node, parent) var_defn_ast = @declared_variables[node_value.name] # Might be undefined :( # VariablesAreUsedAndDefined can't finalize its search until the end of the document. - var_defn_ast && arguments && validate_usage(arguments, node, var_defn_ast) + var_defn_ast && argument_owner && validate_usage(argument_owner, node, var_defn_ast) end end super @@ -51,7 +51,7 @@ def on_argument(node, parent) private - def validate_usage(arguments, arg_node, ast_var) + def validate_usage(argument_owner, arg_node, ast_var) var_type = context.schema.type_from_ast(ast_var.type, context: context) if var_type.nil? return @@ -65,9 +65,15 @@ def validate_usage(arguments, arg_node, ast_var) end end - arg_defn = arguments[arg_node.name] + arg_defn = @types.argument(argument_owner, arg_node.name) arg_defn_type = arg_defn.type + # If the argument is non-null, but it was given a default value, + # then treat it as nullable in practice, see https://github.com/rmosolgo/graphql-ruby/issues/3793 + if arg_defn_type.non_null? && arg_defn.default_value? + arg_defn_type = arg_defn_type.of_type + end + var_inner_type = var_type.unwrap arg_inner_type = arg_defn_type.unwrap diff --git a/lib/graphql/static_validation/rules/variables_are_input_types.rb b/lib/graphql/static_validation/rules/variables_are_input_types.rb index 45cee45a630..0aafdf4c75b 100644 --- a/lib/graphql/static_validation/rules/variables_are_input_types.rb +++ b/lib/graphql/static_validation/rules/variables_are_input_types.rb @@ -4,11 +4,23 @@ module StaticValidation module VariablesAreInputTypes def on_variable_definition(node, parent) type_name = get_type_name(node.type) - type = context.warden.get_type(type_name) + type = context.query.types.type(type_name) if type.nil? + suggestion = if @schema.did_you_mean + @all_possible_input_type_names ||= begin + names = [] + context.types.all_types.each { |(t)| + if t.kind.input? + names << t.graphql_name + end + } + names + end + context.did_you_mean_suggestion(type_name, @all_possible_input_type_names) + end add_error(GraphQL::StaticValidation::VariablesAreInputTypesError.new( - "#{type_name} isn't a defined input type (on $#{node.name})", + "#{type_name} isn't a defined input type (on $#{node.name})#{suggestion}", nodes: node, name: node.name, type: type_name diff --git a/lib/graphql/static_validation/type_stack.rb b/lib/graphql/static_validation/type_stack.rb deleted file mode 100644 index 408045bb6e3..00000000000 --- a/lib/graphql/static_validation/type_stack.rb +++ /dev/null @@ -1,216 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module StaticValidation - # - Ride along with `GraphQL::Language::Visitor` - # - Track type info, expose it to validators - class TypeStack - # These are jumping-off points for infering types down the tree - TYPE_INFERRENCE_ROOTS = [ - GraphQL::Language::Nodes::OperationDefinition, - GraphQL::Language::Nodes::FragmentDefinition, - ] - - # @return [GraphQL::Schema] the schema whose types are present in this document - attr_reader :schema - - # When it enters an object (starting with query or mutation root), it's pushed on this stack. - # When it exits, it's popped off. - # @return [Array] - attr_reader :object_types - - # When it enters a field, it's pushed on this stack (useful for nested fields, args). - # When it exits, it's popped off. - # @return [Array] fields which have been entered - attr_reader :field_definitions - - # Directives are pushed on, then popped off while traversing the tree - # @return [Array] directives which have been entered - attr_reader :directive_definitions - - # @return [Array] arguments which have been entered - attr_reader :argument_definitions - - # @return [Array] fields which have been entered (by their AST name) - attr_reader :path - - # @param schema [GraphQL::Schema] the schema whose types to use when climbing this document - # @param visitor [GraphQL::Language::Visitor] a visitor to follow & watch the types - def initialize(schema, visitor) - @schema = schema - @object_types = [] - @field_definitions = [] - @directive_definitions = [] - @argument_definitions = [] - @path = [] - - PUSH_STRATEGIES.each do |node_class, strategy| - visitor[node_class].enter << EnterWithStrategy.new(self, strategy) - visitor[node_class].leave << LeaveWithStrategy.new(self, strategy) - end - end - - private - - - module FragmentWithTypeStrategy - def push(stack, node) - object_type = if node.type - stack.schema.get_type(node.type.name) - else - stack.object_types.last - end - if !object_type.nil? - object_type = object_type.unwrap - end - stack.object_types.push(object_type) - push_path_member(stack, node) - end - - def pop(stack, node) - stack.object_types.pop - stack.path.pop - end - end - - module FragmentDefinitionStrategy - extend FragmentWithTypeStrategy - module_function - def push_path_member(stack, node) - stack.path.push("fragment #{node.name}") - end - end - - module InlineFragmentStrategy - extend FragmentWithTypeStrategy - module_function - def push_path_member(stack, node) - stack.path.push("...#{node.type ? " on #{node.type.to_query_string}" : ""}") - end - end - - module OperationDefinitionStrategy - module_function - def push(stack, node) - # eg, QueryType, MutationType - object_type = stack.schema.root_type_for_operation(node.operation_type) - stack.object_types.push(object_type) - stack.path.push("#{node.operation_type}#{node.name ? " #{node.name}" : ""}") - end - - def pop(stack, node) - stack.object_types.pop - stack.path.pop - end - end - - module FieldStrategy - module_function - def push(stack, node) - parent_type = stack.object_types.last - parent_type = parent_type.unwrap - - field_definition = stack.schema.get_field(parent_type, node.name) - stack.field_definitions.push(field_definition) - if !field_definition.nil? - next_object_type = field_definition.type - stack.object_types.push(next_object_type) - else - stack.object_types.push(nil) - end - stack.path.push(node.alias || node.name) - end - - def pop(stack, node) - stack.field_definitions.pop - stack.object_types.pop - stack.path.pop - end - end - - module DirectiveStrategy - module_function - def push(stack, node) - directive_defn = stack.schema.directives[node.name] - stack.directive_definitions.push(directive_defn) - end - - def pop(stack, node) - stack.directive_definitions.pop - end - end - - module ArgumentStrategy - module_function - # Push `argument_defn` onto the stack. - # It's possible that `argument_defn` will be nil. - # Push it anyways so `pop` has something to pop. - def push(stack, node) - if stack.argument_definitions.last - arg_type = stack.argument_definitions.last.type.unwrap - if arg_type.kind.input_object? - argument_defn = arg_type.arguments[node.name] - else - argument_defn = nil - end - elsif stack.directive_definitions.last - argument_defn = stack.directive_definitions.last.arguments[node.name] - elsif stack.field_definitions.last - argument_defn = stack.field_definitions.last.arguments[node.name] - else - argument_defn = nil - end - stack.argument_definitions.push(argument_defn) - stack.path.push(node.name) - end - - def pop(stack, node) - stack.argument_definitions.pop - stack.path.pop - end - end - - module FragmentSpreadStrategy - module_function - def push(stack, node) - stack.path.push("... #{node.name}") - end - - def pop(stack, node) - stack.path.pop - end - end - - PUSH_STRATEGIES = { - GraphQL::Language::Nodes::FragmentDefinition => FragmentDefinitionStrategy, - GraphQL::Language::Nodes::InlineFragment => InlineFragmentStrategy, - GraphQL::Language::Nodes::FragmentSpread => FragmentSpreadStrategy, - GraphQL::Language::Nodes::Argument => ArgumentStrategy, - GraphQL::Language::Nodes::Field => FieldStrategy, - GraphQL::Language::Nodes::Directive => DirectiveStrategy, - GraphQL::Language::Nodes::OperationDefinition => OperationDefinitionStrategy, - } - - class EnterWithStrategy - def initialize(stack, strategy) - @stack = stack - @strategy = strategy - end - - def call(node, parent) - @strategy.push(@stack, node) - end - end - - class LeaveWithStrategy - def initialize(stack, strategy) - @stack = stack - @strategy = strategy - end - - def call(node, parent) - @strategy.pop(@stack, node) - end - end - end - end -end diff --git a/lib/graphql/static_validation/validation_context.rb b/lib/graphql/static_validation/validation_context.rb index 5545d00ee49..d57e7d942ab 100644 --- a/lib/graphql/static_validation/validation_context.rb +++ b/lib/graphql/static_validation/validation_context.rb @@ -8,28 +8,31 @@ module StaticValidation # It provides access to the schema & fragments which validators may read from. # # It holds a list of errors which each validator may add to. - # - # It also provides limited access to the {TypeStack} instance, - # which tracks state as you climb in and out of different fields. class ValidationContext extend Forwardable attr_reader :query, :errors, :visitor, - :on_dependency_resolve_handlers + :on_dependency_resolve_handlers, + :max_errors, :types, :schema + - def_delegators :@query, :schema, :document, :fragments, :operations, :warden + def_delegators :@query, :document, :fragments, :operations - def initialize(query, visitor_class) + def initialize(query, visitor_class, max_errors) @query = query + @types = query.types # TODO update migrated callers to use this accessor + @schema = query.schema @literal_validator = LiteralValidator.new(context: query.context) @errors = [] + @max_errors = max_errors || Float::INFINITY @on_dependency_resolve_handlers = [] @visitor = visitor_class.new(document, self) end + # TODO stop using def_delegators because of Array allocations def_delegators :@visitor, :path, :type_definition, :field_definition, :argument_definition, - :parent_type_definition, :directive_definition, :object_types, :dependencies + :parent_type_definition, :directive_definition, :dependencies def on_dependency_resolve(&handler) @on_dependency_resolve_handlers << handler @@ -38,6 +41,29 @@ def on_dependency_resolve(&handler) def validate_literal(ast_value, type) @literal_validator.validate(ast_value, type) end + + def too_many_errors? + @errors.length >= @max_errors + end + + def schema_directives + @schema_directives ||= schema.directives + end + + def did_you_mean_suggestion(name, options) + if did_you_mean = schema.did_you_mean + suggestions = did_you_mean::SpellChecker.new(dictionary: options).correct(name) + case suggestions.size + when 0 + "" + when 1 + " (Did you mean `#{suggestions.first}`?)" + else + last_sugg = suggestions.pop + " (Did you mean #{suggestions.map {|s| "`#{s}`"}.join(", ")} or `#{last_sugg}`?)" + end + end + end end end end diff --git a/lib/graphql/static_validation/validator.rb b/lib/graphql/static_validation/validator.rb index 656c58bcba0..2cd83a4dab6 100644 --- a/lib/graphql/static_validation/validator.rb +++ b/lib/graphql/static_validation/validator.rb @@ -24,35 +24,28 @@ def initialize(schema:, rules: GraphQL::StaticValidation::ALL_RULES) # @param query [GraphQL::Query] # @param validate [Boolean] # @param timeout [Float] Number of seconds to wait before aborting validation. Any positive number may be used, including Floats to specify fractional seconds. + # @param max_errors [Integer] Maximum number of errors before aborting validation. Any positive number will limit the number of errors. Defaults to nil for no limit. # @return [Array] - def validate(query, validate: true, timeout: nil) - query.trace("validate", { validate: validate, query: query }) do - can_skip_rewrite = query.context.interpreter? && query.schema.using_ast_analysis? && query.schema.is_a?(Class) - errors = if validate == false && can_skip_rewrite + def validate(query, validate: true, timeout: nil, max_errors: nil) + errors = nil + query.current_trace.begin_validate(query, validate) + query.current_trace.validate(validate: validate, query: query) do + begin_t = Time.now + errors = if validate == false [] else rules_to_use = validate ? @rules : [] - visitor_class = BaseVisitor.including_rules(rules_to_use, rewrite: !can_skip_rewrite) + visitor_class = BaseVisitor.including_rules(rules_to_use) - context = GraphQL::StaticValidation::ValidationContext.new(query, visitor_class) + context = GraphQL::StaticValidation::ValidationContext.new(query, visitor_class, max_errors) begin # CAUTION: Usage of the timeout module makes the assumption that validation rules are stateless Ruby code that requires no cleanup if process was interrupted. This means no blocking IO calls, native gems, locks, or `rescue` clauses that must be reached. # A timeout value of 0 or nil will execute the block without any timeout. Timeout::timeout(timeout) do - # Attach legacy-style rules. - # Only loop through rules if it has legacy-style rules - unless (legacy_rules = rules_to_use - GraphQL::StaticValidation::ALL_RULES).empty? - legacy_rules.each do |rule_class_or_module| - if rule_class_or_module.method_defined?(:validate) - GraphQL::Deprecation.warn "Legacy validator rules will be removed from GraphQL-Ruby 2.0, use a module instead (see the built-in rules: https://github.com/rmosolgo/graphql-ruby/tree/master/lib/graphql/static_validation/rules)" - GraphQL::Deprecation.warn " -> Legacy validator: #{rule_class_or_module}" - rule_class_or_module.new.validate(context) - end - end + catch(:too_many_validation_errors) do + context.visitor.visit end - - context.visitor.visit end rescue Timeout::Error handle_timeout(query, context) @@ -61,23 +54,19 @@ def validate(query, validate: true, timeout: nil) context.errors end - irep = if errors.empty? && context - # Only return this if there are no errors and validation was actually run - context.visitor.rewrite_document - else - nil - end - { + remaining_timeout: timeout ? (timeout - (Time.now - begin_t)) : nil, errors: errors, - irep: irep, } end rescue GraphQL::ExecutionError => e + errors = [e] { - errors: [e], - irep: nil, + remaining_timeout: nil, + errors: errors, } + ensure + query.current_trace.end_validate(query, validate, errors) end # Invoked when static validation times out. diff --git a/lib/graphql/string_encoding_error.rb b/lib/graphql/string_encoding_error.rb index ff24a7a21a0..d177e45bbc5 100644 --- a/lib/graphql/string_encoding_error.rb +++ b/lib/graphql/string_encoding_error.rb @@ -1,10 +1,20 @@ # frozen_string_literal: true module GraphQL class StringEncodingError < GraphQL::RuntimeTypeError - attr_reader :string - def initialize(str) + attr_reader :string, :field, :path + def initialize(str, context:) @string = str - super("String \"#{str}\" was encoded as #{str.encoding}! GraphQL requires an encoding compatible with UTF-8.") + @field = context[:current_field] + @path = context[:current_path] + message = "String #{str.inspect} was encoded as #{str.encoding}".dup + if @path + message << " @ #{@path.join(".")}" + end + if @field + message << " (#{@field.path})" + end + message << ". GraphQL requires an encoding compatible with UTF-8." + super(message) end end end diff --git a/lib/graphql/string_type.rb b/lib/graphql/string_type.rb deleted file mode 100644 index 472340f293f..00000000000 --- a/lib/graphql/string_type.rb +++ /dev/null @@ -1,2 +0,0 @@ -# frozen_string_literal: true -GraphQL::STRING_TYPE = GraphQL::Types::String.graphql_definition diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index 0cd7aba301d..bc6a1a28390 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -2,10 +2,8 @@ require "securerandom" require "graphql/subscriptions/broadcast_analyzer" require "graphql/subscriptions/event" -require "graphql/subscriptions/instrumentation" require "graphql/subscriptions/serialize" require "graphql/subscriptions/action_cable_subscriptions" -require "graphql/subscriptions/subscription_root" require "graphql/subscriptions/default_subscription_resolve_extension" module GraphQL @@ -16,19 +14,21 @@ class Subscriptions class InvalidTriggerError < GraphQL::Error end + # Raised when either: + # - An initial subscription didn't have a value for `context[subscription_scope]` + # - Or, an update didn't pass `.trigger(..., scope:)` + # When raised, the initial subscription or update fails completely. + class SubscriptionScopeMissingError < GraphQL::Error + end + # @see {Subscriptions#initialize} for options, concrete implementations may add options. def self.use(defn, options = {}) schema = defn.is_a?(Class) ? defn : defn.target - if schema.subscriptions + if schema.subscriptions(inherited: false) raise ArgumentError, "Can't reinstall subscriptions. #{schema} is using #{schema.subscriptions}, can't also add #{self}" end - instrumentation = Subscriptions::Instrumentation.new(schema: schema) - defn.instrument(:query, instrumentation) - if !schema.is_a?(Class) - defn.instrument(:field, instrumentation) - end options[:schema] = schema schema.subscriptions = self.new(**options) schema.add_subscription_extension_if_necessary @@ -36,15 +36,14 @@ def self.use(defn, options = {}) end # @param schema [Class] the GraphQL schema this manager belongs to - def initialize(schema:, broadcast: false, default_broadcastable: false, **rest) + # @param validate_update [Boolean] If false, then validation is skipped when executing updates + def initialize(schema:, validate_update: true, broadcast: false, default_broadcastable: false, **rest) if broadcast - if !schema.using_ast_analysis? - raise ArgumentError, "`broadcast: true` requires AST analysis, add `using GraphQL::Analysis::AST` to your schema or see https://graphql-ruby.org/queries/ast_analysis.html." - end schema.query_analyzer(Subscriptions::BroadcastAnalyzer) end @default_broadcastable = default_broadcastable @schema = schema + @validate_update = validate_update end # @return [Boolean] Used when fields don't have `broadcastable:` explicitly set @@ -56,17 +55,21 @@ def initialize(schema:, broadcast: false, default_broadcastable: false, **rest) # @param args [Hash Object] # @param object [Object] # @param scope [Symbol, String] + # @param context [Hash] # @return [void] - def trigger(event_name, args, object, scope: nil) + def trigger(event_name, args, object, scope: nil, context: {}) + # Make something as context-like as possible, even though there isn't a current query: + dummy_query = @schema.query_class.new(@schema, "{ __typename }", validate: false, context: context) + context = dummy_query.context event_name = event_name.to_s # Try with the verbatim input first: - field = @schema.get_field(@schema.subscription, event_name) + field = dummy_query.types.field(@schema.subscription, event_name) # rubocop:disable Development/ContextIsPassedCop if field.nil? # And if it wasn't found, normalize it: normalized_event_name = normalize_name(event_name) - field = @schema.get_field(@schema.subscription, normalized_event_name) + field = dummy_query.types.field(@schema.subscription, normalized_event_name) # rubocop:disable Development/ContextIsPassedCop if field.nil? raise InvalidTriggerError, "No subscription matching trigger: #{event_name} (looked for #{@schema.subscription.graphql_name}.#{normalized_event_name})" end @@ -76,13 +79,15 @@ def trigger(event_name, args, object, scope: nil) end # Normalize symbol-keyed args to strings, try camelizing them - normalized_args = normalize_arguments(normalized_event_name, field, args) + # Should this accept a real context somehow? + normalized_args = normalize_arguments(normalized_event_name, field, args, @schema.null_context) event = Subscriptions::Event.new( name: normalized_event_name, arguments: normalized_args, field: field, scope: scope, + context: context, ) execute_all(event, object) end @@ -109,24 +114,28 @@ def execute_update(subscription_id, event, object) variables = query_data.fetch(:variables) context = query_data.fetch(:context) operation_name = query_data.fetch(:operation_name) - result = @schema.execute( + execute_options = { query: query_string, context: context, subscription_topic: event.topic, operation_name: operation_name, variables: variables, root_value: object, - ) + } + + # merge event's and query's context together + context.merge!(event.context) unless event.context.nil? || context.nil? + + execute_options[:validate] = validate_update?(**execute_options) + result = @schema.execute(**execute_options) subscriptions_context = result.context.namespace(:subscriptions) if subscriptions_context[:no_update] result = nil end - unsubscribed = subscriptions_context[:unsubscribed] - - if unsubscribed + if subscriptions_context[:unsubscribed] && !subscriptions_context[:final_update] # `unsubscribe` was called, clean up on our side - # TODO also send `{more: false}` to client? + # The transport should also send `{more: false}` to client delete_subscription(subscription_id) result = nil end @@ -134,6 +143,14 @@ def execute_update(subscription_id, event, object) result end + # Define this method to customize whether to validate + # this subscription when executing an update. + # + # @return [Boolean] defaults to `true`, or false if `validate: false` is provided. + def validate_update?(query:, context:, root_value:, subscription_topic:, operation_name:, variables:) + @validate_update + end + # Run the update query for this subscription and deliver it # @see {#execute_update} # @see {#deliver} @@ -142,7 +159,14 @@ def execute(subscription_id, event, object) res = execute_update(subscription_id, event, object) if !res.nil? deliver(subscription_id, res) + + if res.context.namespace(:subscriptions)[:unsubscribed] + # `unsubscribe` was called, clean up on our side + # The transport should also send `{more: false}` to client + delete_subscription(subscription_id) + end end + end # Event `event` occurred on `object`, @@ -151,16 +175,6 @@ def execute(subscription_id, event, object) # @param object [Object] # @return [void] def execute_all(event, object) - each_subscription_id(event) do |subscription_id| - execute(subscription_id, event, object) - end - end - - # Get each `subscription_id` subscribed to `event.topic` and yield them - # @param event [GraphQL::Subscriptions::Event] - # @yieldparam subscription_id [String] - # @return [void] - def each_subscription_id(event) raise GraphQL::RequiredImplementationMissingError end @@ -217,14 +231,49 @@ def normalize_name(event_or_arg_name) # @return [Boolean] if true, then a query like this one would be broadcasted def broadcastable?(query_str, **query_options) - query = GraphQL::Query.new(@schema, query_str, **query_options) + query = @schema.query_class.new(@schema, query_str, **query_options) if !query.valid? raise "Invalid query: #{query.validation_errors.map(&:to_h).inspect}" end - GraphQL::Analysis::AST.analyze_query(query, @schema.query_analyzers) + GraphQL::Analysis.analyze_query(query, @schema.query_analyzers) query.context.namespace(:subscriptions)[:subscription_broadcastable] end + # Called during execution when a new `subscription ...` operation is received + # @param query [GraphQL::Query] + # @return [void] + def initialize_subscriptions(query) + subs_namespace = query.context.namespace(:subscriptions) + subs_namespace[:events] = [] + subs_namespace[:subscriptions] = {} + nil + end + + # Called during execution when a subscription operation has finished + # @param query [GraphQL::Query] + # @return [void] + def finish_subscriptions(query) + if (events = query.context.namespace(:subscriptions)[:events]) && !events.empty? + write_subscription(query, events) + end + nil + end + + def finalizer + Finalizer.new(self) + end + + class Finalizer + include Execution::Finalizer + def initialize(subscriptions) + @subscriptions = subscriptions + end + + def finalize_graphql_result(query, result_data, result_key) + @subscriptions.finish_subscriptions(query) + end + end + private # Recursively normalize `args` as belonging to `arg_owner`: @@ -233,9 +282,11 @@ def broadcastable?(query_str, **query_options) # @param arg_owner [GraphQL::Field, GraphQL::BaseType] # @param args [Hash, Array, Any] some GraphQL input value to coerce as `arg_owner` # @return [Any] normalized arguments value - def normalize_arguments(event_name, arg_owner, args) + def normalize_arguments(event_name, arg_owner, args, context) case arg_owner - when GraphQL::Field, GraphQL::InputObjectType, GraphQL::Schema::Field, Class + when GraphQL::Schema::Field, Class + return args if args.nil? + if arg_owner.is_a?(Class) && !arg_owner.kind.input_object? # it's a type, but not an input object return args @@ -244,19 +295,19 @@ def normalize_arguments(event_name, arg_owner, args) missing_arg_names = [] args.each do |k, v| arg_name = k.to_s - arg_defn = arg_owner.arguments[arg_name] + arg_defn = arg_owner.get_argument(arg_name, context) if arg_defn normalized_arg_name = arg_name else normalized_arg_name = normalize_name(arg_name) - arg_defn = arg_owner.arguments[normalized_arg_name] + arg_defn = arg_owner.get_argument(normalized_arg_name, context) end if arg_defn if arg_defn.loads normalized_arg_name = arg_defn.keyword.to_s end - normalized = normalize_arguments(event_name, arg_defn.type, v) + normalized = normalize_arguments(event_name, arg_defn.type, v, context) normalized_args[normalized_arg_name] = normalized else # Couldn't find a matching argument definition @@ -266,19 +317,17 @@ def normalize_arguments(event_name, arg_owner, args) # Backfill default values so that trigger arguments # match query arguments. - arg_owner.arguments.each do |name, arg_defn| + arg_owner.arguments(context).each do |_name, arg_defn| if arg_defn.default_value? && !normalized_args.key?(arg_defn.name) default_value = arg_defn.default_value # We don't have an underlying "object" here, so it can't call methods. # This is broken. - normalized_args[arg_defn.name] = arg_defn.prepare_value(nil, default_value, context: GraphQL::Query::NullContext) + normalized_args[arg_defn.name] = arg_defn.prepare_value(nil, default_value, context: context) end end - if missing_arg_names.any? - arg_owner_name = if arg_owner.is_a?(GraphQL::Field) - "Subscription.#{arg_owner.name}" - elsif arg_owner.is_a?(GraphQL::Schema::Field) + if !missing_arg_names.empty? + arg_owner_name = if arg_owner.is_a?(GraphQL::Schema::Field) arg_owner.path elsif arg_owner.is_a?(Class) arg_owner.graphql_name @@ -289,10 +338,10 @@ def normalize_arguments(event_name, arg_owner, args) end normalized_args - when GraphQL::ListType, GraphQL::Schema::List - args.map { |a| normalize_arguments(event_name, arg_owner.of_type, a) } - when GraphQL::NonNullType, GraphQL::Schema::NonNull - normalize_arguments(event_name, arg_owner.of_type, args) + when GraphQL::Schema::List + args&.map { |a| normalize_arguments(event_name, arg_owner.of_type, a, context) } + when GraphQL::Schema::NonNull + normalize_arguments(event_name, arg_owner.of_type, args, context) else args end diff --git a/lib/graphql/subscriptions/action_cable_subscriptions.rb b/lib/graphql/subscriptions/action_cable_subscriptions.rb index 31c1c253f75..bc3dc903ee4 100644 --- a/lib/graphql/subscriptions/action_cable_subscriptions.rb +++ b/lib/graphql/subscriptions/action_cable_subscriptions.rb @@ -35,7 +35,7 @@ class Subscriptions # } # # result = MySchema.execute( - # query: query, + # query, # context: context, # variables: variables, # operation_name: operation_name @@ -81,6 +81,7 @@ class Subscriptions # end # end # + # @see GraphQL::Testing::MockActionCable for test helpers class ActionCableSubscriptions < GraphQL::Subscriptions SUBSCRIPTION_PREFIX = "graphql-subscription:" EVENT_PREFIX = "graphql-event:" @@ -91,10 +92,24 @@ def initialize(serializer: Serialize, namespace: '', action_cable: ActionCable, # A per-process map of subscriptions to deliver. # This is provided by Rails, so let's use it @subscriptions = Concurrent::Map.new - @events = Concurrent::Map.new { |h, k| h[k] = Concurrent::Map.new { |h2, k2| h2[k2] = Concurrent::Array.new } } + @events = Concurrent::Map.new do |h, k| + h.compute_if_absent(k) do + Concurrent::Map.new do |h2, k2| + h2.compute_if_absent(k2) { Concurrent::Array.new } + end + end + end @action_cable = action_cable @action_cable_coder = action_cable_coder @serializer = serializer + @serialize_with_context = case @serializer.method(:load).arity + when 1 + false + when 2 + true + else + raise ArgumentError, "#{@serializer} must respond to `.load` accepting one or two arguments" + end @transmit_ns = namespace super end @@ -110,7 +125,8 @@ def execute_all(event, object) # This subscription was re-evaluated. # Send it to the specific stream where this client was waiting. def deliver(subscription_id, result) - payload = { result: result.to_h, more: true } + has_more = !result.context.namespace(:subscriptions)[:final_update] + payload = { result: result.to_h, more: has_more } @action_cable.server.broadcast(stream_subscription_name(subscription_id), payload) end @@ -119,7 +135,13 @@ def deliver(subscription_id, result) # It will receive notifications when events come in # and re-evaluate the query locally. def write_subscription(query, events) - channel = query.context.fetch(:channel) + unless (channel = query.context[:channel]) + raise GraphQL::Error, "This GraphQL Subscription client does not support the transport protocol expected"\ + "by the backend Subscription Server implementation (graphql-ruby ActionCableSubscriptions in this case)."\ + "Some official client implementation including Apollo (https://graphql-ruby.org/javascript_client/apollo_subscriptions.html), "\ + "Relay Modern (https://graphql-ruby.org/javascript_client/relay_subscriptions.html#actioncable)."\ + "GraphiQL via `graphiql-rails` may not work out of box (#1051)." + end subscription_id = query.context[:subscription_id] ||= build_id stream = stream_subscription_name(subscription_id) channel.stream_from(stream) @@ -145,21 +167,24 @@ def write_subscription(query, events) # def setup_stream(channel, initial_event) topic = initial_event.topic - channel.stream_from(stream_event_name(initial_event), coder: @action_cable_coder) do |message| + event_stream = stream_event_name(initial_event) + channel.stream_from(event_stream, coder: @action_cable_coder) do |message| events_by_fingerprint = @events[topic] object = nil events_by_fingerprint.each do |_fingerprint, events| - if events.any? && events.first == initial_event + if !events.empty? && events.first == initial_event # The fingerprint has told us that this response should be shared by all subscribers, # so just run it once, then deliver the result to every subscriber first_event = events.first first_subscription_id = first_event.context.fetch(:subscription_id) - object ||= @serializer.load(message) + object ||= load_action_cable_message(message, first_event.context) result = execute_update(first_subscription_id, first_event, object) - # Having calculated the result _once_, send the same payload to all subscribers - events.each do |event| - subscription_id = event.context.fetch(:subscription_id) - deliver(subscription_id, result) + if !result.nil? + # Having calculated the result _once_, send the same payload to all subscribers + events.each do |event| + subscription_id = event.context.fetch(:subscription_id) + deliver(subscription_id, result) + end end end end @@ -167,6 +192,18 @@ def setup_stream(channel, initial_event) end end + # This is called to turn an ActionCable-broadcasted string (JSON) + # into a query-ready application object. + # @param message [String] n ActionCable-broadcasted string (JSON) + # @param context [GraphQL::Query::Context] the context of the first event for a given subscription fingerprint + def load_action_cable_message(message, context) + if @serialize_with_context + @serializer.load(message, context) + else + @serializer.load(message) + end + end + # Return the query from "storage" (in memory) def read_subscription(subscription_id) query = @subscriptions[subscription_id] @@ -188,6 +225,8 @@ def read_subscription(subscription_id) # The channel was closed, forget about it. def delete_subscription(subscription_id) query = @subscriptions.delete(subscription_id) + # In case this came from the server, tell the client to unsubscribe: + @action_cable.server.broadcast(stream_subscription_name(subscription_id), { more: false }) # This can be `nil` when `.trigger` happens inside an unsubscribed ActionCable channel, # see https://github.com/rmosolgo/graphql-ruby/issues/2478 if query diff --git a/lib/graphql/subscriptions/broadcast_analyzer.rb b/lib/graphql/subscriptions/broadcast_analyzer.rb index dea3dc2c9b1..3a6a43a8f92 100644 --- a/lib/graphql/subscriptions/broadcast_analyzer.rb +++ b/lib/graphql/subscriptions/broadcast_analyzer.rb @@ -9,7 +9,7 @@ class Subscriptions # Assign the result to `context.namespace(:subscriptions)[:subscription_broadcastable]` # @api private # @see Subscriptions#broadcastable? for a public API - class BroadcastAnalyzer < GraphQL::Analysis::AST::Analyzer + class BroadcastAnalyzer < GraphQL::Analysis::Analyzer def initialize(subject) super @default_broadcastable = subject.schema.subscriptions.default_broadcastable @@ -28,9 +28,8 @@ def on_enter_field(node, parent, visitor) end current_field = visitor.field_definition - apply_broadcastable(current_field) - current_type = visitor.parent_type_definition + apply_broadcastable(current_type, current_field) if current_type.kind.interface? pt = @query.possible_types(current_type) pt.each do |object_type| @@ -38,7 +37,7 @@ def on_enter_field(node, parent, visitor) # Inherited fields would be exactly the same object; # only check fields that are overrides of the inherited one if ot_field && ot_field != current_field - apply_broadcastable(ot_field) + apply_broadcastable(object_type, ot_field) end end end @@ -55,10 +54,16 @@ def result private # Modify `@subscription_broadcastable` based on `field_defn`'s configuration (and/or the default value) - def apply_broadcastable(field_defn) + def apply_broadcastable(owner_type, field_defn) current_field_broadcastable = field_defn.introspection? || field_defn.broadcastable? + + if current_field_broadcastable.nil? && owner_type.respond_to?(:default_broadcastable?) + current_field_broadcastable = owner_type.default_broadcastable? + end + case current_field_broadcastable when nil + query.logger.debug { "`broadcastable: nil` for field: #{field_defn.path}" } # If the value wasn't set, mix in the default value: # - If the default is false and the current value is true, make it false # - If the default is true and the current value is true, it stays true @@ -66,6 +71,7 @@ def apply_broadcastable(field_defn) # - If the default is true and the current value is false, keep it false @subscription_broadcastable = @subscription_broadcastable && @default_broadcastable when false + query.logger.debug { "`broadcastable: false` for field: #{field_defn.path}" } # One non-broadcastable field is enough to make the whole subscription non-broadcastable @subscription_broadcastable = false when true diff --git a/lib/graphql/subscriptions/default_subscription_resolve_extension.rb b/lib/graphql/subscriptions/default_subscription_resolve_extension.rb index 3223103ec81..0f263be7100 100644 --- a/lib/graphql/subscriptions/default_subscription_resolve_extension.rb +++ b/lib/graphql/subscriptions/default_subscription_resolve_extension.rb @@ -1,19 +1,88 @@ # frozen_string_literal: true module GraphQL class Subscriptions - class DefaultSubscriptionResolveExtension < GraphQL::Subscriptions::SubscriptionRoot::Extension - def resolve(context:, object:, arguments:) - has_override_implementation = @field.resolver || - object.respond_to?(@field.resolver_method) + class DefaultSubscriptionResolveExtension < GraphQL::Schema::FieldExtension + def resolve(context:, object: nil, objects: nil, arguments:) + if objects + has_override_implementation = @field.execution_mode != :direct_send - if !has_override_implementation - if context.query.subscription_update? - object.object + if !has_override_implementation + if context.query.subscription_update? + objects + else + objects.map { |o| context.skip } + end + else + yield(objects, arguments) + end + else + has_override_implementation = @field.resolver || + object.respond_to?(@field.resolver_method) + + if !has_override_implementation + if context.query.subscription_update? + object.object + else + context.skip + end + else + yield(object, arguments) + end + end + end + + def after_resolve(values: nil, value: nil, context:, objects: nil, object: nil, arguments:, **rest) + if values + values.map do |value| + self.class.write_subscription(@field, value, arguments, context) + end + else + self.class.write_subscription(@field, value, arguments, context) + end + end + + def self.write_subscription(field, value, arguments, context) + if value.is_a?(GraphQL::ExecutionError) + value + elsif field.resolver&.method_defined?(:subscription_written?) && + (subscription_namespace = context.namespace(:subscriptions)) && + (subscriptions_by_path = subscription_namespace[:subscriptions]) + (subscription_instance = subscriptions_by_path[context.current_path]) + # If it was already written, don't append this event to be written later + if !subscription_instance.subscription_written? + events = context.namespace(:subscriptions)[:events] + events << subscription_instance.event + end + value + elsif (exec_next_update_event = context.namespace(:subscriptions)[:update_event]) + if context.query.subscription_topic == exec_next_update_event.topic + value else context.skip end + elsif (events = context.namespace(:subscriptions)[:events]) + # This is the first execution, so gather an Event + # for the backend to register: + event = Subscriptions::Event.new( + name: field.name, + arguments: arguments, + context: context, + field: field, + ) + events << event + value + elsif context.query.subscription_topic == Subscriptions::Event.serialize( + field.name, + arguments, + field, + scope: (field.subscription_scope ? context[field.subscription_scope] : nil), + ) + # This is a subscription update. The resolver returned `skip` if it should be skipped, + # or else it returned an object to resolve the update. + value else - yield(object, arguments) + # This is a subscription update, but this event wasn't triggered. + context.skip end end end diff --git a/lib/graphql/subscriptions/event.rb b/lib/graphql/subscriptions/event.rb index 68e0f3384e6..d2d8ce7abb0 100644 --- a/lib/graphql/subscriptions/event.rb +++ b/lib/graphql/subscriptions/event.rb @@ -9,7 +9,7 @@ class Event # @return [String] Corresponds to the Subscription root field name attr_reader :name - # @return [GraphQL::Query::Arguments] + # @return [GraphQL::Execution::Interpreter::Arguments] attr_reader :arguments # @return [GraphQL::Query::Context] @@ -20,35 +20,28 @@ class Event def initialize(name:, arguments:, field: nil, context: nil, scope: nil) @name = name - @arguments = arguments + @arguments = self.class.arguments_without_field_extras(arguments: arguments, field: field) @context = context field ||= context.field - scope_val = scope || (context && field.subscription_scope && context[field.subscription_scope]) + scope_key = field.subscription_scope + scope_val = scope || (context && scope_key && context[scope_key]) + if scope_key && + (subscription = field.resolver) && + (subscription.respond_to?(:subscription_scope_optional?)) && + !subscription.subscription_scope_optional? && + scope_val.nil? + raise Subscriptions::SubscriptionScopeMissingError, "#{field.path} (#{subscription}) requires a `scope:` value to trigger updates (Set `subscription_scope ..., optional: true` to disable this requirement)" + end - @topic = self.class.serialize(name, arguments, field, scope: scope_val) + @topic = self.class.serialize(name, arguments, field, scope: scope_val, context: context) end # @return [String] an identifier for this unit of subscription - def self.serialize(name, arguments, field, scope:) - normalized_args = case arguments - when GraphQL::Query::Arguments - arguments - when Hash - if field.is_a?(GraphQL::Schema::Field) - stringify_args(field, arguments) - else - GraphQL::Query::LiteralInput.from_arguments( - arguments, - field, - nil, - ) - end - else - raise ArgumentError, "Unexpected arguments: #{arguments}, must be Hash or GraphQL::Arguments" - end - - sorted_h = stringify_args(field, normalized_args.to_h) - Serialize.dump_recursive([scope, name, sorted_h]) + def self.serialize(_name, arguments, field, scope:, context: GraphQL::Query::NullContext.instance) + subscription = field.resolver || GraphQL::Schema::Subscription + arguments = arguments_without_field_extras(field: field, arguments: arguments) + normalized_args = stringify_args(field, arguments.to_h, context) + subscription.topic_for(arguments: normalized_args, field: field, scope: scope) end # @return [String] a logical identifier for this event. (Stable when the query is broadcastable.) @@ -68,38 +61,94 @@ def fingerprint end class << self + def arguments_without_field_extras(arguments:, field:) + if !field.extras.empty? + arguments = arguments.dup + field.extras.each do |extra_key| + arguments.delete(extra_key) + end + end + arguments + end + private - def stringify_args(arg_owner, args) + + # This method does not support cyclic references in the Hash, + # nor does it support Hashes whose keys are not sortable + # with respect to their peers ( cases where a <=> b might throw an error ) + def deep_sort_hash_keys(hash_to_sort) + raise ArgumentError.new("Argument must be a Hash") unless hash_to_sort.is_a?(Hash) + hash_to_sort.keys.sort.map do |k| + if hash_to_sort[k].is_a?(Hash) + [k, deep_sort_hash_keys(hash_to_sort[k])] + elsif hash_to_sort[k].is_a?(Array) + [k, deep_sort_array_hashes(hash_to_sort[k])] + else + [k, hash_to_sort[k]] + end + end.to_h + end + + def deep_sort_array_hashes(array_to_inspect) + raise ArgumentError.new("Argument must be an Array") unless array_to_inspect.is_a?(Array) + array_to_inspect.map do |v| + if v.is_a?(Hash) + deep_sort_hash_keys(v) + elsif v.is_a?(Array) + deep_sort_array_hashes(v) + else + v + end + end + end + + def stringify_args(arg_owner, args, context) + arg_owner = arg_owner.respond_to?(:unwrap) ? arg_owner.unwrap : arg_owner # remove list and non-null wrappers case args when Hash next_args = {} args.each do |k, v| arg_name = k.to_s camelized_arg_name = GraphQL::Schema::Member::BuildType.camelize(arg_name) - arg_defn = get_arg_definition(arg_owner, camelized_arg_name) - - if arg_defn - normalized_arg_name = camelized_arg_name + arg_defn = get_arg_definition(arg_owner, camelized_arg_name, context) + arg_defn ||= get_arg_definition(arg_owner, arg_name, context) + normalized_arg_name = arg_defn.graphql_name + arg_base_type = arg_defn.type.unwrap + # In the case where the value being emitted is seen as a "JSON" + # type, treat the value as one atomic unit of serialization + is_json_definition = arg_base_type && arg_base_type <= GraphQL::Types::JSON + if is_json_definition + sorted_value = if v.is_a?(Hash) + deep_sort_hash_keys(v) + elsif v.is_a?(Array) + deep_sort_array_hashes(v) + else + v + end + next_args[normalized_arg_name] = sorted_value.respond_to?(:to_json) ? sorted_value.to_json : sorted_value else - normalized_arg_name = arg_name - arg_defn = get_arg_definition(arg_owner, normalized_arg_name) + next_args[normalized_arg_name] = stringify_args(arg_base_type, v, context) end - - next_args[normalized_arg_name] = stringify_args(arg_defn.type, v) end # Make sure they're deeply sorted next_args.sort.to_h when Array - args.map { |a| stringify_args(arg_owner, a) } + args.map { |a| stringify_args(arg_owner, a, context) } when GraphQL::Schema::InputObject - stringify_args(arg_owner, args.to_h) + stringify_args(arg_owner, args.to_h, context) else - args + if arg_owner.is_a?(Class) && arg_owner < GraphQL::Schema::Enum + # `prepare:` may have made the value something other than + # a defined value of this enum -- use _that_ in this case. + arg_owner.coerce_isolated_input(args) || args + else + args + end end end - def get_arg_definition(arg_owner, arg_name) - arg_owner.arguments[arg_name] || arg_owner.arguments.each_value.find { |v| v.keyword.to_s == arg_name } + def get_arg_definition(arg_owner, arg_name, context) + context.types.argument(arg_owner, arg_name) || context.types.arguments(arg_owner).find { |v| v.keyword.to_s == arg_name } end end end diff --git a/lib/graphql/subscriptions/instrumentation.rb b/lib/graphql/subscriptions/instrumentation.rb deleted file mode 100644 index 06c7ca3db57..00000000000 --- a/lib/graphql/subscriptions/instrumentation.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true -module GraphQL - class Subscriptions - # Wrap the root fields of the subscription type with special logic for: - # - Registering the subscription during the first execution - # - Evaluating the triggered portion(s) of the subscription during later execution - class Instrumentation - def initialize(schema:) - @schema = schema - end - - def instrument(type, field) - if type == @schema.subscription.graphql_definition - # This is a root field of `subscription` - subscribing_resolve_proc = SubscriptionRegistrationResolve.new(field.resolve_proc) - field.redefine(resolve: subscribing_resolve_proc) - else - field - end - end - - # If needed, prepare to gather events which this query subscribes to - def before_query(query) - if query.subscription? && !query.subscription_update? - query.context.namespace(:subscriptions)[:events] = [] - end - end - - # After checking the root fields, pass the gathered events to the store - def after_query(query) - events = query.context.namespace(:subscriptions)[:events] - if events && events.any? - @schema.subscriptions.write_subscription(query, events) - end - end - - private - - class SubscriptionRegistrationResolve - def initialize(inner_proc) - @inner_proc = inner_proc - end - - # Wrap the proc with subscription registration logic - def call(obj, args, ctx) - result = nil - if @inner_proc && !@inner_proc.is_a?(GraphQL::Field::Resolve::BuiltInResolve) - result = @inner_proc.call(obj, args, ctx) - end - - events = ctx.namespace(:subscriptions)[:events] - - if events - # This is the first execution, so gather an Event - # for the backend to register: - events << Subscriptions::Event.new( - name: ctx.field.name, - arguments: args, - context: ctx, - ) - result - elsif ctx.irep_node.subscription_topic == ctx.query.subscription_topic - if !result.nil? - result - elsif obj.is_a?(GraphQL::Schema::Object) - # The root object is _already_ the subscription update: - obj.object - else - obj - end - else - # This is a subscription update, but this event wasn't triggered. - ctx.skip - end - end - end - end - end -end diff --git a/lib/graphql/subscriptions/serialize.rb b/lib/graphql/subscriptions/serialize.rb index db90eccdb4d..6b1d91becf9 100644 --- a/lib/graphql/subscriptions/serialize.rb +++ b/lib/graphql/subscriptions/serialize.rb @@ -9,7 +9,7 @@ module Serialize SYMBOL_KEY = "__sym__" SYMBOL_KEYS_KEY = "__sym_keys__" TIMESTAMP_KEY = "__timestamp__" - TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S.%N%Z" # eg '2020-01-01 23:59:59.123456789+05:00' + TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S.%N%z" # eg '2020-01-01 23:59:59.123456789+05:00' OPEN_STRUCT_KEY = "__ostruct__" module_function @@ -71,9 +71,17 @@ def load_value(value) when SYMBOL_KEY value[SYMBOL_KEY].to_sym when TIMESTAMP_KEY - timestamp_class_name, timestamp_s = value[TIMESTAMP_KEY] + timestamp_class_name, *timestamp_args = value[TIMESTAMP_KEY] timestamp_class = Object.const_get(timestamp_class_name) - timestamp_class.strptime(timestamp_s, TIMESTAMP_FORMAT) + if defined?(ActiveSupport::TimeWithZone) && timestamp_class <= ActiveSupport::TimeWithZone + zone_name, timestamp_s = timestamp_args + zone = ActiveSupport::TimeZone[zone_name] + raise "Zone #{zone_name} not found, unable to deserialize" unless zone + zone.strptime(timestamp_s, TIMESTAMP_FORMAT) + else + timestamp_s = timestamp_args.first + timestamp_class.strptime(timestamp_s, TIMESTAMP_FORMAT) + end when OPEN_STRUCT_KEY ostruct_values = load_value(value[OPEN_STRUCT_KEY]) OpenStruct.new(ostruct_values) @@ -123,11 +131,25 @@ def dump_value(obj) { SYMBOL_KEY => obj.to_s } elsif obj.respond_to?(:to_gid_param) {GLOBALID_KEY => obj.to_gid_param} + elsif defined?(ActiveSupport::TimeWithZone) && obj.is_a?(ActiveSupport::TimeWithZone) && obj.class.name != Time.name + # This handles a case where Rails prior to 7 would + # make the class ActiveSupport::TimeWithZone return "Time" for + # its name. In Rails 7, it will now return "ActiveSupport::TimeWithZone", + # which happens to be incompatible with expectations we have + # with what a Time class supports ( notably, strptime in `load_value` ). + # + # This now passes along the name of the zone, such that a future deserialization + # of this string will use the correct time zone from the ActiveSupport TimeZone + # list to produce the time. + # + { TIMESTAMP_KEY => [obj.class.name, obj.time_zone.name, obj.strftime(TIMESTAMP_FORMAT)] } elsif obj.is_a?(Date) || obj.is_a?(Time) # DateTime extends Date; for TimeWithZone, call `.utc` first. { TIMESTAMP_KEY => [obj.class.name, obj.strftime(TIMESTAMP_FORMAT)] } - elsif obj.is_a?(OpenStruct) + elsif defined?(OpenStruct) && obj.is_a?(OpenStruct) { OPEN_STRUCT_KEY => dump_value(obj.to_h) } + elsif defined?(ActiveRecord::Relation) && obj.is_a?(ActiveRecord::Relation) + dump_value(obj.to_a) else obj end diff --git a/lib/graphql/subscriptions/subscription_root.rb b/lib/graphql/subscriptions/subscription_root.rb deleted file mode 100644 index 5a47395ad54..00000000000 --- a/lib/graphql/subscriptions/subscription_root.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -module GraphQL - class Subscriptions - # @api private - # @deprecated This module is no longer needed. - module SubscriptionRoot - def self.extended(child_cls) - GraphQL::Deprecation.warn "`extend GraphQL::Subscriptions::SubscriptionRoot` is no longer required; you can remove it from your Subscription type (#{child_cls})" - child_cls.include(InstanceMethods) - end - - # This is for maintaining backwards compatibility: - # if a subscription field is created without a `subscription:` resolver class, - # then implement the method with the previous default behavior. - module InstanceMethods - def skip_subscription_root(*) - if context.query.subscription_update? - object - else - context.skip - end - end - end - - def field(*args, extensions: [], **rest, &block) - extensions += [Extension] - # Backwards-compat for schemas - if !rest[:subscription] - name = args.first - alias_method(name, :skip_subscription_root) - end - super(*args, extensions: extensions, **rest, &block) - end - - class Extension < GraphQL::Schema::FieldExtension - def after_resolve(value:, context:, object:, arguments:, **rest) - if value.is_a?(GraphQL::ExecutionError) - value - elsif (events = context.namespace(:subscriptions)[:events]) - # This is the first execution, so gather an Event - # for the backend to register: - event = Subscriptions::Event.new( - name: field.name, - arguments: arguments_without_field_extras(arguments: arguments), - context: context, - field: field, - ) - events << event - value - elsif context.query.subscription_topic == Subscriptions::Event.serialize( - field.name, - arguments_without_field_extras(arguments: arguments), - field, - scope: (field.subscription_scope ? context[field.subscription_scope] : nil), - ) - # This is a subscription update. The resolver returned `skip` if it should be skipped, - # or else it returned an object to resolve the update. - value - else - # This is a subscription update, but this event wasn't triggered. - context.skip - end - end - - private - - def arguments_without_field_extras(arguments:) - arguments.dup.tap do |event_args| - field.extras.each { |k| event_args.delete(k) } - end - end - end - end - end -end diff --git a/lib/graphql/testing.rb b/lib/graphql/testing.rb new file mode 100644 index 00000000000..7b3080b7e13 --- /dev/null +++ b/lib/graphql/testing.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true +require "graphql/testing/helpers" +require "graphql/testing/mock_action_cable" diff --git a/lib/graphql/testing/helpers.rb b/lib/graphql/testing/helpers.rb new file mode 100644 index 00000000000..be1d4c7f7cc --- /dev/null +++ b/lib/graphql/testing/helpers.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true +module GraphQL + module Testing + module Helpers + # @param schema_class [Class] + # @return [Module] A helpers module which always uses the given schema + def self.for(schema_class) + SchemaHelpers.for(schema_class) + end + + class Error < GraphQL::Error + end + + class TypeNotVisibleError < Error + def initialize(type_name:) + message = "`#{type_name}` should be `visible?` this field resolution and `context`, but it was not" + super(message) + end + end + + class FieldNotVisibleError < Error + def initialize(type_name:, field_name:) + message = "`#{type_name}.#{field_name}` should be `visible?` for this resolution, but it was not" + super(message) + end + end + + class TypeNotDefinedError < Error + def initialize(type_name:) + message = "No type named `#{type_name}` is defined; choose another type name or define this type." + super(message) + end + end + + class FieldNotDefinedError < Error + def initialize(type_name:, field_name:) + message = "`#{type_name}` has no field named `#{field_name}`; pick another name or define this field." + super(message) + end + end + + def run_graphql_field(schema, field_path, object, arguments: {}, context: {}, ast_node: nil, lookahead: nil, visibility_profile: nil) + type_name, *field_names = field_path.split(".") + dummy_query = GraphQL::Query.new(schema, "{ __typename }", context: context, visibility_profile: visibility_profile) + query_context = dummy_query.context + dataloader = query_context.dataloader + object_type = dummy_query.types.type(type_name) # rubocop:disable Development/ContextIsPassedCop + if object_type + graphql_result = object + field_names.each do |field_name| + inner_object = graphql_result + dataloader.run_isolated { + graphql_result = object_type.wrap(inner_object, query_context) + } + if graphql_result.nil? + return nil + end + visible_field = dummy_query.types.field(object_type, field_name) # rubocop:disable Development/ContextIsPassedCop + if visible_field + dataloader.run_isolated { + query_context[:current_field] = visible_field + field_args = visible_field.coerce_arguments(graphql_result, arguments, query_context) + field_args = schema.sync_lazy(field_args) + if !visible_field.extras.empty? + extra_args = {} + visible_field.extras.each do |extra| + extra_args[extra] = case extra + when :ast_node + ast_node ||= GraphQL::Language::Nodes::Field.new(name: visible_field.graphql_name) + when :lookahead + lookahead ||= begin + ast_node ||= GraphQL::Language::Nodes::Field.new(name: visible_field.graphql_name) + Execution::Lookahead.new( + query: dummy_query, + ast_nodes: [ast_node], + field: visible_field, + ) + end + else + raise ArgumentError, "This extra isn't supported in `run_graphql_field` yet: `#{extra.inspect}`. Open an issue on GitHub to request it: https://github.com/rmosolgo/graphql-ruby/issues/new" + end + end + + field_args = field_args.merge_extras(extra_args) + end + graphql_result = visible_field.resolve(graphql_result, field_args.keyword_arguments, query_context) + graphql_result = schema.sync_lazy(graphql_result) + } + object_type = visible_field.type.unwrap + elsif object_type.all_field_definitions.any? { |f| f.graphql_name == field_name } + raise FieldNotVisibleError.new(field_name: field_name, type_name: type_name) + else + raise FieldNotDefinedError.new(type_name: type_name, field_name: field_name) + end + end + graphql_result + else + unfiltered_type = schema.use_visibility_profile? ? schema.visibility.get_type(type_name) : schema.get_type(type_name) # rubocop:disable Development/ContextIsPassedCop + if unfiltered_type + raise TypeNotVisibleError.new(type_name: type_name) + else + raise TypeNotDefinedError.new(type_name: type_name) + end + end + end + + def with_resolution_context(schema, type:, object:, context:{}, visibility_profile: nil) + resolution_context = ResolutionAssertionContext.new( + self, + schema: schema, + type_name: type, + object: object, + context: context, + visibility_profile: visibility_profile, + ) + yield(resolution_context) + end + + class ResolutionAssertionContext + def initialize(test, type_name:, object:, schema:, context:, visibility_profile:) + @test = test + @type_name = type_name + @object = object + @schema = schema + @context = context + @visibility_profile = visibility_profile + end + + attr_reader :visibility_profile + + def run_graphql_field(field_name, arguments: {}) + if @schema + @test.run_graphql_field(@schema, "#{@type_name}.#{field_name}", @object, arguments: arguments, context: @context, visibility_profile: @visibility_profile) + else + @test.run_graphql_field("#{@type_name}.#{field_name}", @object, arguments: arguments, context: @context, visibility_profile: @visibility_profile) + end + end + end + + module SchemaHelpers + include Helpers + + def run_graphql_field(field_path, object, arguments: {}, context: {}, visibility_profile: nil) + super(@@schema_class_for_helpers, field_path, object, arguments: arguments, context: context, visibility_profile: visibility_profile) + end + + def with_resolution_context(*args, **kwargs, &block) + # schema will be added later + super(nil, *args, **kwargs, &block) + end + + def self.for(schema_class) + Module.new do + include SchemaHelpers + @@schema_class_for_helpers = schema_class + end + end + end + end + end +end diff --git a/lib/graphql/testing/mock_action_cable.rb b/lib/graphql/testing/mock_action_cable.rb new file mode 100644 index 00000000000..86fd52aa3bb --- /dev/null +++ b/lib/graphql/testing/mock_action_cable.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true +module GraphQL + module Testing + # A stub implementation of ActionCable. + # Any methods to support the mock backend have `mock` in the name. + # + # @example Configuring your schema to use MockActionCable in the test environment + # class MySchema < GraphQL::Schema + # # Use MockActionCable in test: + # use GraphQL::Subscriptions::ActionCableSubscriptions, + # action_cable: Rails.env.test? ? GraphQL::Testing::MockActionCable : ActionCable + # end + # + # @example Clearing old data before each test + # setup do + # GraphQL::Testing::MockActionCable.clear_mocks + # end + # + # @example Using MockActionCable in a test case + # # Create a channel to use in the test, pass it to GraphQL + # mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel + # ActionCableTestSchema.execute("subscription { newsFlash { text } }", context: { channel: mock_channel }) + # + # # Trigger a subscription update + # ActionCableTestSchema.subscriptions.trigger(:news_flash, {}, {text: "After yesterday's rain, someone stopped on Rio Road to help a box turtle across five lanes of traffic"}) + # + # # Check messages on the channel + # expected_msg = { + # result: { + # "data" => { + # "newsFlash" => { + # "text" => "After yesterday's rain, someone stopped on Rio Road to help a box turtle across five lanes of traffic" + # } + # } + # }, + # more: true, + # } + # assert_equal [expected_msg], mock_channel.mock_broadcasted_messages + # + class MockActionCable + class MockChannel + def initialize + @mock_broadcasted_messages = [] + end + + # @return [Array] Payloads "sent" to this channel by GraphQL-Ruby + attr_reader :mock_broadcasted_messages + + # Called by ActionCableSubscriptions. Implements a Rails API. + def stream_from(stream_name, coder: nil, &block) + # Rails uses `coder`, we don't + block ||= ->(msg) { @mock_broadcasted_messages << msg } + MockActionCable.mock_stream_for(stream_name).add_mock_channel(self, block) + end + end + + # Used by mock code + # @api private + class MockStream + def initialize + @mock_channels = {} + end + + def add_mock_channel(channel, handler) + @mock_channels[channel] = handler + end + + def mock_broadcast(message) + @mock_channels.each do |channel, handler| + handler && handler.call(message) + end + end + end + + class << self + # Call this before each test run to make sure that MockActionCable's data is empty + def clear_mocks + @mock_streams = {} + end + + # Implements Rails API + def server + self + end + + # Implements Rails API + def broadcast(stream_name, message) + stream = @mock_streams[stream_name] + stream && stream.mock_broadcast(message) + end + + # Used by mock code + def mock_stream_for(stream_name) + @mock_streams[stream_name] ||= MockStream.new + end + + # Use this as `context[:channel]` to simulate an ActionCable channel + # + # @return [GraphQL::Testing::MockActionCable::MockChannel] + def get_mock_channel + MockChannel.new + end + + # @return [Array] Streams that currently have subscribers + def mock_stream_names + @mock_streams.keys + end + end + end + end +end diff --git a/lib/graphql/tracing.rb b/lib/graphql/tracing.rb index 3ec112a8bb7..56f06e9f6c8 100644 --- a/lib/graphql/tracing.rb +++ b/lib/graphql/tracing.rb @@ -1,60 +1,40 @@ # frozen_string_literal: true -require "graphql/tracing/active_support_notifications_tracing" -require "graphql/tracing/platform_tracing" -require "graphql/tracing/appoptics_tracing" -require "graphql/tracing/appsignal_tracing" -require "graphql/tracing/data_dog_tracing" -require "graphql/tracing/new_relic_tracing" -require "graphql/tracing/scout_tracing" -require "graphql/tracing/skylight_tracing" -require "graphql/tracing/statsd_tracing" -require "graphql/tracing/prometheus_tracing" -if defined?(PrometheusExporter::Server) - require "graphql/tracing/prometheus_tracing/graphql_collector" -end module GraphQL - # Library entry point for performance metric reporting. - # - # @example Sending custom events - # query.trace("my_custom_event", { ... }) do - # # do stuff ... - # end - # - # @example Adding a tracer to a schema - # class MySchema < GraphQL::Schema - # tracer MyTracer # <= responds to .trace(key, data, &block) - # end - # - # @example Adding a tracer to a single query - # MySchema.execute(query_str, context: { backtrace: true }) - # - # Events: - # - # Key | Metadata - # ----|--------- - # lex | `{ query_string: String }` - # parse | `{ query_string: String }` - # validate | `{ query: GraphQL::Query, validate: Boolean }` - # analyze_multiplex | `{ multiplex: GraphQL::Execution::Multiplex }` - # analyze_query | `{ query: GraphQL::Query }` - # execute_multiplex | `{ multiplex: GraphQL::Execution::Multiplex }` - # execute_query | `{ query: GraphQL::Query }` - # execute_query_lazy | `{ query: GraphQL::Query?, multiplex: GraphQL::Execution::Multiplex? }` - # execute_field | `{ owner: Class, field: GraphQL::Schema::Field, query: GraphQL::Query, path: Array, ast_node: GraphQL::Language::Nodes::Field}` - # execute_field_lazy | `{ owner: Class, field: GraphQL::Schema::Field, query: GraphQL::Query, path: Array, ast_node: GraphQL::Language::Nodes::Field}` - # authorized | `{ context: GraphQL::Query::Context, type: Class, object: Object, path: Array }` - # authorized_lazy | `{ context: GraphQL::Query::Context, type: Class, object: Object, path: Array }` - # resolve_type | `{ context: GraphQL::Query::Context, type: Class, object: Object, path: Array }` - # resolve_type_lazy | `{ context: GraphQL::Query::Context, type: Class, object: Object, path: Array }` - # - # Note that `execute_field` and `execute_field_lazy` receive different data in different settings: - # - # - When using {GraphQL::Execution::Interpreter}, they receive `{field:, path:, query:}` - # - Otherwise, they receive `{context: ...}` - # module Tracing + autoload :Trace, "graphql/tracing/trace" + autoload :CallLegacyTracers, "graphql/tracing/call_legacy_tracers" + autoload :LegacyTrace, "graphql/tracing/legacy_trace" + autoload :LegacyHooksTrace, "graphql/tracing/legacy_hooks_trace" + autoload :NullTrace, "graphql/tracing/null_trace" + + autoload :ActiveSupportNotificationsTracing, "graphql/tracing/active_support_notifications_tracing" + autoload :PlatformTracing, "graphql/tracing/platform_tracing" + autoload :AppOpticsTracing, "graphql/tracing/appoptics_tracing" + autoload :AppsignalTracing, "graphql/tracing/appsignal_tracing" + autoload :DataDogTracing, "graphql/tracing/data_dog_tracing" + autoload :NewRelicTracing, "graphql/tracing/new_relic_tracing" + autoload :NotificationsTracing, "graphql/tracing/notifications_tracing" + autoload :ScoutTracing, "graphql/tracing/scout_tracing" + autoload :StatsdTracing, "graphql/tracing/statsd_tracing" + autoload :PrometheusTracing, "graphql/tracing/prometheus_tracing" + + autoload :ActiveSupportNotificationsTrace, "graphql/tracing/active_support_notifications_trace" + autoload :PlatformTrace, "graphql/tracing/platform_trace" + autoload :AppOpticsTrace, "graphql/tracing/appoptics_trace" + autoload :AppsignalTrace, "graphql/tracing/appsignal_trace" + autoload :DataDogTrace, "graphql/tracing/data_dog_trace" + autoload :MonitorTrace, "graphql/tracing/monitor_trace" + autoload :NewRelicTrace, "graphql/tracing/new_relic_trace" + autoload :NotificationsTrace, "graphql/tracing/notifications_trace" + autoload :SentryTrace, "graphql/tracing/sentry_trace" + autoload :ScoutTrace, "graphql/tracing/scout_trace" + autoload :StatsdTrace, "graphql/tracing/statsd_trace" + autoload :PrometheusTrace, "graphql/tracing/prometheus_trace" + autoload :PerfettoTrace, "graphql/tracing/perfetto_trace" + autoload :DetailedTrace, "graphql/tracing/detailed_trace" + # Objects may include traceable to gain a `.trace(...)` method. # The object must have a `@tracers` ivar of type `Array<<#trace(k, d, &b)>>`. # @api private diff --git a/lib/graphql/tracing/active_support_notifications_trace.rb b/lib/graphql/tracing/active_support_notifications_trace.rb new file mode 100644 index 00000000000..b5ca85ff603 --- /dev/null +++ b/lib/graphql/tracing/active_support_notifications_trace.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "graphql/tracing/notifications_trace" + +module GraphQL + module Tracing + # This implementation forwards events to ActiveSupport::Notifications with a `graphql` suffix. + # + # @example Sending execution events to ActiveSupport::Notifications + # class MySchema < GraphQL::Schema + # trace_with(GraphQL::Tracing::ActiveSupportNotificationsTrace) + # end + # + # @example Subscribing to GraphQL events with ActiveSupport::Notifications + # ActiveSupport::Notifications.subscribe(/graphql/) do |event| + # pp event.name + # pp event.payload + # end + # + module ActiveSupportNotificationsTrace + include NotificationsTrace + def initialize(engine: ActiveSupport::Notifications, **rest) + super + end + end + end +end diff --git a/lib/graphql/tracing/active_support_notifications_tracing.rb b/lib/graphql/tracing/active_support_notifications_tracing.rb index cfc3c5fb0bf..4f60c63a346 100644 --- a/lib/graphql/tracing/active_support_notifications_tracing.rb +++ b/lib/graphql/tracing/active_support_notifications_tracing.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "graphql/tracing/notifications_tracing" + module GraphQL module Tracing # This implementation forwards events to ActiveSupport::Notifications @@ -8,27 +10,11 @@ module Tracing # @see KEYS for event names module ActiveSupportNotificationsTracing # A cache of frequently-used keys to avoid needless string allocations - KEYS = { - "lex" => "lex.graphql", - "parse" => "parse.graphql", - "validate" => "validate.graphql", - "analyze_multiplex" => "analyze_multiplex.graphql", - "analyze_query" => "analyze_query.graphql", - "execute_query" => "execute_query.graphql", - "execute_query_lazy" => "execute_query_lazy.graphql", - "execute_field" => "execute_field.graphql", - "execute_field_lazy" => "execute_field_lazy.graphql", - "authorized" => "authorized.graphql", - "authorized_lazy" => "authorized_lazy.graphql", - "resolve_type" => "resolve_type.graphql", - "resolve_type_lazy" => "resolve_type.graphql", - } + KEYS = NotificationsTracing::KEYS + NOTIFICATIONS_ENGINE = NotificationsTracing.new(ActiveSupport::Notifications) if defined?(ActiveSupport::Notifications) - def self.trace(key, metadata) - prefixed_key = KEYS[key] || "#{key}.graphql" - ActiveSupport::Notifications.instrument(prefixed_key, metadata) do - yield - end + def self.trace(key, metadata, &blk) + NOTIFICATIONS_ENGINE.trace(key, metadata, &blk) end end end diff --git a/lib/graphql/tracing/appoptics_trace.rb b/lib/graphql/tracing/appoptics_trace.rb new file mode 100644 index 00000000000..4fa246407a7 --- /dev/null +++ b/lib/graphql/tracing/appoptics_trace.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +require "graphql/tracing/platform_trace" + +module GraphQL + module Tracing + + # This class uses the AppopticsAPM SDK from the appoptics_apm gem to create + # traces for GraphQL. + # + # There are 4 configurations available. They can be set in the + # appoptics_apm config file or in code. Please see: + # {https://docs.appoptics.com/kb/apm_tracing/ruby/configure} + # + # AppOpticsAPM::Config[:graphql][:enabled] = true|false + # AppOpticsAPM::Config[:graphql][:transaction_name] = true|false + # AppOpticsAPM::Config[:graphql][:sanitize_query] = true|false + # AppOpticsAPM::Config[:graphql][:remove_comments] = true|false + module AppOpticsTrace + # These GraphQL events will show up as 'graphql.prep' spans + PREP_KEYS = ['lex', 'parse', 'validate', 'analyze_query', 'analyze_multiplex'].freeze + # These GraphQL events will show up as 'graphql.execute' spans + EXEC_KEYS = ['execute_multiplex', 'execute_query', 'execute_query_lazy'].freeze + + + # During auto-instrumentation this version of AppOpticsTracing is compared + # with the version provided in the appoptics_apm gem, so that the newer + # version of the class can be used + + + def self.version + Gem::Version.new('1.0.0') + end + + # rubocop:disable Development/NoEvalCop This eval takes static inputs at load-time + + [ + 'lex', + 'parse', + 'validate', + 'analyze_query', + 'analyze_multiplex', + 'execute_multiplex', + 'execute_query', + 'execute_query_lazy', + ].each do |trace_method| + module_eval <<-RUBY, __FILE__, __LINE__ + def #{trace_method}(**data) + return super if !defined?(AppOpticsAPM) || gql_config[:enabled] == false + layer = span_name("#{trace_method}") + kvs = metadata(data, layer) + kvs[:Key] = "#{trace_method}" if (PREP_KEYS + EXEC_KEYS).include?("#{trace_method}") + + transaction_name(kvs[:InboundQuery]) if kvs[:InboundQuery] && layer == 'graphql.execute' + + ::AppOpticsAPM::SDK.trace(layer, kvs) do + kvs.clear # we don't have to send them twice + super + end + end + RUBY + end + + # rubocop:enable Development/NoEvalCop + + def execute_field(query:, field:, ast_node:, arguments:, object:) + return_type = field.type.unwrap + trace_field = if return_type.kind.scalar? || return_type.kind.enum? + (field.trace.nil? && @trace_scalars) || field.trace + else + true + end + platform_key = if trace_field + @platform_key_cache[AppOpticsTrace].platform_field_key_cache[field] + else + nil + end + if platform_key && trace_field + return super if !defined?(AppOpticsAPM) || gql_config[:enabled] == false + layer = platform_key + kvs = metadata({query: query, field: field, ast_node: ast_node, arguments: arguments, object: object}, layer) + + ::AppOpticsAPM::SDK.trace(layer, kvs) do + kvs.clear # we don't have to send them twice + super + end + else + super + end + end + + def execute_field_lazy(query:, field:, ast_node:, arguments:, object:) # rubocop:disable Development/TraceCallsSuperCop + execute_field(query: query, field: field, ast_node: ast_node, arguments: arguments, object: object) + end + + def authorized(**data) + return super if !defined?(AppOpticsAPM) || gql_config[:enabled] == false + layer = @platform_key_cache[AppOpticsTrace].platform_authorized_key_cache[data[:type]] + kvs = metadata(data, layer) + + ::AppOpticsAPM::SDK.trace(layer, kvs) do + kvs.clear # we don't have to send them twice + super + end + end + + def authorized_lazy(**data) + return super if !defined?(AppOpticsAPM) || gql_config[:enabled] == false + layer = @platform_key_cache[AppOpticsTrace].platform_authorized_key_cache[data[:type]] + kvs = metadata(data, layer) + + ::AppOpticsAPM::SDK.trace(layer, kvs) do + kvs.clear # we don't have to send them twice + super + end + end + + def resolve_type(**data) + return super if !defined?(AppOpticsAPM) || gql_config[:enabled] == false + layer = @platform_key_cache[AppOpticsTrace].platform_resolve_type_key_cache[data[:type]] + + kvs = metadata(data, layer) + + ::AppOpticsAPM::SDK.trace(layer, kvs) do + kvs.clear # we don't have to send them twice + super + end + end + + def resolve_type_lazy(**data) + return super if !defined?(AppOpticsAPM) || gql_config[:enabled] == false + layer = @platform_key_cache[AppOpticsTrace].platform_resolve_type_key_cache[data[:type]] + kvs = metadata(data, layer) + + ::AppOpticsAPM::SDK.trace(layer, kvs) do + kvs.clear # we don't have to send them twice + super + end + end + + include PlatformTrace + + def platform_field_key(field) + "graphql.#{field.owner.graphql_name}.#{field.graphql_name}" + end + + def platform_authorized_key(type) + "graphql.authorized.#{type.graphql_name}" + end + + def platform_resolve_type_key(type) + "graphql.resolve_type.#{type.graphql_name}" + end + + private + + def gql_config + ::AppOpticsAPM::Config[:graphql] ||= {} + end + + def transaction_name(query) + return if gql_config[:transaction_name] == false || + ::AppOpticsAPM::SDK.get_transaction_name + + split_query = query.strip.split(/\W+/, 3) + split_query[0] = 'query' if split_query[0].empty? + name = "graphql.#{split_query[0..1].join('.')}" + + ::AppOpticsAPM::SDK.set_transaction_name(name) + end + + def multiplex_transaction_name(names) + return if gql_config[:transaction_name] == false || + ::AppOpticsAPM::SDK.get_transaction_name + + name = "graphql.multiplex.#{names.join('.')}" + name = "#{name[0..251]}..." if name.length > 254 + + ::AppOpticsAPM::SDK.set_transaction_name(name) + end + + def span_name(key) + return 'graphql.prep' if PREP_KEYS.include?(key) + return 'graphql.execute' if EXEC_KEYS.include?(key) + + key[/^graphql\./] ? key : "graphql.#{key}" + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def metadata(data, layer) + data.keys.map do |key| + case key + when :context + graphql_context(data[key], layer) + when :query + graphql_query(data[key]) + when :query_string + graphql_query_string(data[key]) + when :multiplex + graphql_multiplex(data[key]) + when :path + [key, data[key].join(".")] + else + [key, data[key]] + end + end.tap { _1.flatten!(2) }.each_slice(2).to_h.merge(Spec: 'graphql') + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def graphql_context(context, layer) + context.errors && context.errors.each do |err| + AppOpticsAPM::API.log_exception(layer, err) + end + + [[:Path, context.path.join('.')]] + end + + def graphql_query(query) + return [] unless query + + query_string = query.query_string + query_string = remove_comments(query_string) if gql_config[:remove_comments] != false + query_string = sanitize(query_string) if gql_config[:sanitize_query] != false + + [[:InboundQuery, query_string], + [:Operation, query.selected_operation_name]] + end + + def graphql_query_string(query_string) + query_string = remove_comments(query_string) if gql_config[:remove_comments] != false + query_string = sanitize(query_string) if gql_config[:sanitize_query] != false + + [:InboundQuery, query_string] + end + + def graphql_multiplex(data) + names = data.queries.map(&:operations).map!(&:keys).tap(&:flatten!).tap(&:compact!) + multiplex_transaction_name(names) if names.size > 1 + + [:Operations, names.join(', ')] + end + + def sanitize(query) + return unless query + + # remove arguments + query.gsub(/"[^"]*"/, '"?"') # strings + .gsub(/-?[0-9]*\.?[0-9]+e?[0-9]*/, '?') # ints + floats + .gsub(/\[[^\]]*\]/, '[?]') # arrays + end + + def remove_comments(query) + return unless query + + query.gsub(/#[^\n\r]*/, '') + end + end + end +end diff --git a/lib/graphql/tracing/appoptics_tracing.rb b/lib/graphql/tracing/appoptics_tracing.rb index e5183e55c33..381f4ca18fd 100644 --- a/lib/graphql/tracing/appoptics_tracing.rb +++ b/lib/graphql/tracing/appoptics_tracing.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "graphql/tracing/platform_tracing" + module GraphQL module Tracing @@ -20,6 +22,11 @@ class AppOpticsTracing < GraphQL::Tracing::PlatformTracing # These GraphQL events will show up as 'graphql.execute' spans EXEC_KEYS = ['execute_multiplex', 'execute_query', 'execute_query_lazy'].freeze + def initialize(...) + warn "GraphQL::Tracing::AppOptics tracing is deprecated; update to SolarWindsAPM instead, which uses OpenTelemetry." + super + end + # During auto-instrumentation this version of AppOpticsTracing is compared # with the version provided in the appoptics_apm gem, so that the newer # version of the class can be used @@ -117,7 +124,7 @@ def metadata(data, layer) else [key, data[key]] end - end.flatten(2).each_slice(2).to_h.merge(Spec: 'graphql') + end.tap { _1.flatten!(2) }.each_slice(2).to_h.merge(Spec: 'graphql') end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength @@ -148,7 +155,7 @@ def graphql_query_string(query_string) end def graphql_multiplex(data) - names = data.queries.map(&:operations).map(&:keys).flatten.compact + names = data.queries.map(&:operations).map!(&:keys).tap(&:flatten!).tap(&:compact!) multiplex_transaction_name(names) if names.size > 1 [:Operations, names.join(', ')] diff --git a/lib/graphql/tracing/appsignal_trace.rb b/lib/graphql/tracing/appsignal_trace.rb new file mode 100644 index 00000000000..5f60ba3c93a --- /dev/null +++ b/lib/graphql/tracing/appsignal_trace.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true +require "graphql/tracing/monitor_trace" + +module GraphQL + module Tracing + # Instrumentation for reporting GraphQL-Ruby times to Appsignal. + # + # @example Installing the tracer + # class MySchema < GraphQL::Schema + # trace_with GraphQL::Tracing::AppsignalTrace + # end + AppsignalTrace = MonitorTrace.create_module("appsignal") + module AppsignalTrace + # @param set_action_name [Boolean] If true, the GraphQL operation name will be used as the transaction name. + # This is not advised if you run more than one query per HTTP request, for example, with `graphql-client` or multiplexing. + # It can also be specified per-query with `context[:set_appsignal_action_name]`. + def initialize(set_action_name: false, **rest) + rest[:set_transaction_name] ||= set_action_name + setup_appsignal_monitor(**rest) + super + end + + class AppsignalMonitor < MonitorTrace::Monitor + def instrument(keyword, object) + if keyword == :execute + query = object.queries.first + set_this_txn_name = query.context[:set_appsignal_action_name] + if set_this_txn_name == true || (set_this_txn_name.nil? && @set_transaction_name) + Appsignal::Transaction.current.set_action(transaction_name(query)) + end + end + Appsignal.instrument(name_for(keyword, object)) do + yield + end + end + + include MonitorTrace::Monitor::GraphQLSuffixNames + class Event < GraphQL::Tracing::MonitorTrace::Monitor::Event + def start + Appsignal::Transaction.current.start_event + end + + def finish + Appsignal::Transaction.current.finish_event( + @monitor.name_for(@keyword, @object), + "", + "" + ) + end + end + end + end + end +end diff --git a/lib/graphql/tracing/appsignal_tracing.rb b/lib/graphql/tracing/appsignal_tracing.rb index 789c8254bb8..cd552ee71a8 100644 --- a/lib/graphql/tracing/appsignal_tracing.rb +++ b/lib/graphql/tracing/appsignal_tracing.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "graphql/tracing/platform_tracing" + module GraphQL module Tracing class AppsignalTracing < PlatformTracing @@ -14,7 +16,22 @@ class AppsignalTracing < PlatformTracing "execute_query_lazy" => "execute.graphql", } + # @param set_action_name [Boolean] If true, the GraphQL operation name will be used as the transaction name. + # This is not advised if you run more than one query per HTTP request, for example, with `graphql-client` or multiplexing. + # It can also be specified per-query with `context[:set_appsignal_action_name]`. + def initialize(options = {}) + @set_action_name = options.fetch(:set_action_name, false) + super + end + def platform_trace(platform_key, key, data) + if key == "execute_query" + set_this_txn_name = data[:query].context[:set_appsignal_action_name] + if set_this_txn_name == true || (set_this_txn_name.nil? && @set_action_name) + Appsignal::Transaction.current.set_action(transaction_name(data[:query])) + end + end + Appsignal.instrument(platform_key) do yield end diff --git a/lib/graphql/tracing/call_legacy_tracers.rb b/lib/graphql/tracing/call_legacy_tracers.rb new file mode 100644 index 00000000000..c42b99fa185 --- /dev/null +++ b/lib/graphql/tracing/call_legacy_tracers.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module GraphQL + module Tracing + # This trace class calls legacy-style tracer with payload hashes. + # New-style `trace_with` modules significantly reduce the overhead of tracing, + # but that advantage is lost when legacy-style tracers are also used (since the payload hashes are still constructed). + module CallLegacyTracers + def lex(query_string:) + (@multiplex || @query).trace("lex", { query_string: query_string }) { super } + end + + def parse(query_string:) + (@multiplex || @query).trace("parse", { query_string: query_string }) { super } + end + + def validate(query:, validate:) + query.trace("validate", { validate: validate, query: query }) { super } + end + + def analyze_multiplex(multiplex:) + multiplex.trace("analyze_multiplex", { multiplex: multiplex }) { super } + end + + def analyze_query(query:) + query.trace("analyze_query", { query: query }) { super } + end + + def execute_multiplex(multiplex:) + multiplex.trace("execute_multiplex", { multiplex: multiplex }) { super } + end + + def execute_query(query:) + query.trace("execute_query", { query: query }) { super } + end + + def execute_query_lazy(query:, multiplex:) + multiplex.trace("execute_query_lazy", { multiplex: multiplex, query: query }) { super } + end + + def execute_field(field:, query:, ast_node:, arguments:, object:) + query.trace("execute_field", { field: field, query: query, ast_node: ast_node, arguments: arguments, object: object, owner: field.owner, path: query.context[:current_path] }) { super } + end + + def execute_field_lazy(field:, query:, ast_node:, arguments:, object:) + query.trace("execute_field_lazy", { field: field, query: query, ast_node: ast_node, arguments: arguments, object: object, owner: field.owner, path: query.context[:current_path] }) { super } + end + + def authorized(query:, type:, object:) + query.trace("authorized", { context: query.context, type: type, object: object, path: query.context[:current_path] }) { super } + end + + def authorized_lazy(query:, type:, object:) + query.trace("authorized_lazy", { context: query.context, type: type, object: object, path: query.context[:current_path] }) { super } + end + + def resolve_type(query:, type:, object:) + query.trace("resolve_type", { context: query.context, type: type, object: object, path: query.context[:current_path] }) { super } + end + + def resolve_type_lazy(query:, type:, object:) + query.trace("resolve_type_lazy", { context: query.context, type: type, object: object, path: query.context[:current_path] }) { super } + end + end + end +end diff --git a/lib/graphql/tracing/data_dog_trace.rb b/lib/graphql/tracing/data_dog_trace.rb new file mode 100644 index 00000000000..4e390f8c013 --- /dev/null +++ b/lib/graphql/tracing/data_dog_trace.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true +require "graphql/tracing/monitor_trace" + +module GraphQL + module Tracing + # A tracer for reporting to DataDog + # @example Adding this tracer to your schema + # class MySchema < GraphQL::Schema + # trace_with GraphQL::Tracing::DataDogTrace + # end + # @example Skipping `resolve_type` and `authorized` events + # trace_with GraphQL::Tracing::DataDogTrace, trace_authorized: false, trace_resolve_type: false + DataDogTrace = MonitorTrace.create_module("datadog") + module DataDogTrace + class DatadogMonitor < MonitorTrace::Monitor + def initialize(set_transaction_name:, service: nil, tracer: nil, **_rest) + super + if tracer.nil? + tracer = defined?(Datadog::Tracing) ? Datadog::Tracing : Datadog.tracer + end + @tracer = tracer + @service_name = service + @has_prepare_span = @trace.respond_to?(:prepare_span) + end + + attr_reader :tracer, :service_name + + def instrument(keyword, object) + trace_key = name_for(keyword, object) + @tracer.trace(trace_key, service: @service_name, type: 'custom') do |span| + span.set_tag('component', 'graphql') + op_name = keyword.respond_to?(:name) ? keyword.name : keyword.to_s + span.set_tag('operation', op_name) + + if keyword == :execute + operations = object.queries.map(&:selected_operation_name).join(', ') + first_query = object.queries.first + resource = if operations.empty? + fallback_transaction_name(first_query && first_query.context) + else + operations + end + span.resource = resource if resource + + span.set_tag("selected_operation_name", first_query.selected_operation_name) + span.set_tag("selected_operation_type", first_query.selected_operation&.operation_type) + span.set_tag("query_string", first_query.query_string) + end + + if @has_prepare_span + @trace.prepare_span(keyword, object, span) + end + yield + end + end + + include MonitorTrace::Monitor::GraphQLSuffixNames + class Event < MonitorTrace::Monitor::Event + def start + name = @monitor.name_for(keyword, object) + @dd_span = @monitor.tracer.trace(name, service: @monitor.service_name, type: 'custom') + end + + def finish + @dd_span.finish + end + end + end + end + end +end diff --git a/lib/graphql/tracing/data_dog_tracing.rb b/lib/graphql/tracing/data_dog_tracing.rb index 4ee6d740d26..b764c2ba26b 100644 --- a/lib/graphql/tracing/data_dog_tracing.rb +++ b/lib/graphql/tracing/data_dog_tracing.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "graphql/tracing/platform_tracing" + module GraphQL module Tracing class DataDogTracing < PlatformTracing @@ -15,17 +17,23 @@ class DataDogTracing < PlatformTracing } def platform_trace(platform_key, key, data) - tracer.trace(platform_key, service: service_name) do |span| - span.span_type = 'custom' + tracer.trace(platform_key, service: options[:service], type: 'custom') do |span| + span.set_tag('component', 'graphql') + span.set_tag('operation', key) if key == 'execute_multiplex' operations = data[:multiplex].queries.map(&:selected_operation_name).join(', ') - span.resource = operations unless operations.empty? - # For top span of query, set the analytics sample rate tag, if available. - if analytics_enabled? - Datadog::Contrib::Analytics.set_sample_rate(span, analytics_sample_rate) + resource = if operations.empty? + first_query = data[:multiplex].queries.first + fallback_transaction_name(first_query && first_query.context) + else + operations end + span.resource = resource if resource + + # [Deprecated] will be removed in the future + span.set_metric('_dd1.sr.eausr', analytics_sample_rate) if analytics_enabled? end if key == 'execute_query' @@ -34,29 +42,33 @@ def platform_trace(platform_key, key, data) span.set_tag(:query_string, data[:query].query_string) end + prepare_span(key, data, span) + yield end end - def service_name - options.fetch(:service, 'ruby-graphql') + # Implement this method in a subclass to apply custom tags to datadog spans + # @param key [String] The event being traced + # @param data [Hash] The runtime data for this event (@see GraphQL::Tracing for keys for each event) + # @param span [Datadog::Tracing::SpanOperation] The datadog span for this event + def prepare_span(key, data, span) end def tracer - options.fetch(:tracer, Datadog.tracer) - end + default_tracer = defined?(Datadog::Tracing) ? Datadog::Tracing : Datadog.tracer - def analytics_available? - defined?(Datadog::Contrib::Analytics) \ - && Datadog::Contrib::Analytics.respond_to?(:enabled?) \ - && Datadog::Contrib::Analytics.respond_to?(:set_sample_rate) + # [Deprecated] options[:tracer] will be removed in the future + options.fetch(:tracer, default_tracer) end def analytics_enabled? - analytics_available? && Datadog::Contrib::Analytics.enabled?(options.fetch(:analytics_enabled, false)) + # [Deprecated] options[:analytics_enabled] will be removed in the future + options.fetch(:analytics_enabled, false) end def analytics_sample_rate + # [Deprecated] options[:analytics_sample_rate] will be removed in the future options.fetch(:analytics_sample_rate, 1.0) end diff --git a/lib/graphql/tracing/detailed_trace.rb b/lib/graphql/tracing/detailed_trace.rb new file mode 100644 index 00000000000..dd3cb766869 --- /dev/null +++ b/lib/graphql/tracing/detailed_trace.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true +if defined?(ActiveRecord) + require "graphql/tracing/detailed_trace/active_record_backend" +end +require "graphql/tracing/detailed_trace/memory_backend" +require "graphql/tracing/detailed_trace/redis_backend" + +module GraphQL + module Tracing + # `DetailedTrace` can make detailed profiles for a subset of production traffic. Install it in Rails with `rails generate graphql:detailed_trace`. + # + # When `MySchema.detailed_trace?(query)` returns `true`, a profiler-specific `trace_mode: ...` will be used for the query, + # overriding the one in `context[:trace_mode]`. + # + # By default, the detailed tracer calls `.inspect` on application objects returned from fields. You can customize + # this behavior by extending {DetailedTrace} and overriding {#inspect_object}. You can opt out of debug annotations + # entirely with `use ..., debug: false` or for a single query with `context: { detailed_trace_debug: false }`. + # + # You can store saved traces in two ways: + # + # - __ActiveRecord__: With `rails generate graphql:detailed_trace`, a new migration will be added to your app. + # That table will be used to store trace data. + # + # - __Redis__: Pass `redis: ...` to save trace data to a Redis database. Depending on your needs, + # you can configure this database to retain all data (persistent) or to expire data according to your rules. + # + # If you need to save traces indefinitely, you can download them from Perfetto after opening them there. + # + # @example Installing with Rails + # rails generate graphql:detailed_trace # optional: --redis + # + # @example Adding the sampler to your schema + # class MySchema < GraphQL::Schema + # # Add the sampler: + # use GraphQL::Tracing::DetailedTrace, redis: Redis.new(...), limit: 100 + # + # # And implement this hook to tell it when to take a sample: + # def self.detailed_trace?(query) + # # Could use `query.context`, `query.selected_operation_name`, `query.query_string` here + # # Could call out to Flipper, etc + # rand <= 0.000_1 # one in ten thousand + # end + # end + # + # @see Graphql::Dashboard GraphQL::Dashboard for viewing stored results + # + # @example Customizing debug output in traces + # class CustomDetailedTrace < GraphQL::Tracing::DetailedTrace + # def inspect_object(object) + # if object.is_a?(SomeThing) + # # handle it specially ... + # else + # super + # end + # end + # end + # + # @example disabling debug annotations completely + # use DetailedTrace, debug: false, ... + # + # @example disabling debug annotations for one query + # MySchema.execute(query_str, context: { detailed_trace_debug: false }) + # + class DetailedTrace + # @param redis [Redis] If provided, profiles will be stored in Redis for later review + # @param limit [Integer] A maximum number of profiles to store + # @param debug [Boolean] if `false`, it won't create `debug` annotations in Perfetto traces (reduces overhead) + # @param model_class [Class] Overrides {ActiveRecordBackend::GraphqlDetailedTrace} if present + def self.use(schema, trace_mode: :profile_sample, memory: false, debug: debug?, redis: nil, limit: nil, model_class: nil) + storage = if redis + RedisBackend.new(redis: redis, limit: limit) + elsif memory + MemoryBackend.new(limit: limit) + elsif defined?(ActiveRecord) + ActiveRecordBackend.new(limit: limit, model_class: model_class) + else + raise ArgumentError, "To store traces, install ActiveRecord or provide `redis: ...`" + end + detailed_trace = self.new(storage: storage, trace_mode: trace_mode, debug: debug) + schema.detailed_trace = detailed_trace + schema.trace_with(PerfettoTrace, mode: trace_mode, save_profile: true) + end + + def initialize(storage:, trace_mode:, debug:) + @storage = storage + @trace_mode = trace_mode + @debug = debug + end + + # @return [Symbol] The trace mode to use when {Schema.detailed_trace?} returns `true` + attr_reader :trace_mode + + # @return [String] ID of saved trace + def save_trace(operation_name, duration_ms, begin_ms, trace_data) + @storage.save_trace(operation_name, duration_ms, begin_ms, trace_data) + end + + # @return [Boolean] + def debug? + @debug + end + + # @param last [Integer] + # @param before [Integer] Timestamp in milliseconds since epoch + # @return [Enumerable] + def traces(last: nil, before: nil) + @storage.traces(last: last, before: before) + end + + # @return [StoredTrace, nil] + def find_trace(id) + @storage.find_trace(id) + end + + # @return [void] + def delete_trace(id) + @storage.delete_trace(id) + end + + # @return [void] + def delete_all_traces + @storage.delete_all_traces + end + + def inspect_object(object) + self.class.inspect_object(object) + end + + def self.inspect_object(object) + if defined?(ActiveRecord::Relation) && object.is_a?(ActiveRecord::Relation) + "#{object.class}, .to_sql=#{object.to_sql.inspect}" + else + object.inspect + end + end + + # Default debug setting + # @return [true] + def self.debug? + true + end + + class StoredTrace + def initialize(id:, operation_name:, duration_ms:, begin_ms:, trace_data:) + @id = id + @operation_name = operation_name + @duration_ms = duration_ms + @begin_ms = begin_ms + @trace_data = trace_data + end + + attr_reader :id, :operation_name, :duration_ms, :begin_ms, :trace_data + end + end + end +end diff --git a/lib/graphql/tracing/detailed_trace/active_record_backend.rb b/lib/graphql/tracing/detailed_trace/active_record_backend.rb new file mode 100644 index 00000000000..3656c7f1006 --- /dev/null +++ b/lib/graphql/tracing/detailed_trace/active_record_backend.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module GraphQL + module Tracing + class DetailedTrace + class ActiveRecordBackend + class GraphqlDetailedTrace < ActiveRecord::Base + end + + def initialize(limit: nil, model_class: nil) + @limit = limit + @model_class = model_class || GraphqlDetailedTrace + end + + def traces(last:, before:) + gdts = @model_class.all.order("begin_ms DESC") + if before + gdts = gdts.where("begin_ms < ?", before) + end + if last + gdts = gdts.limit(last) + end + gdts.map { |gdt| record_to_stored_trace(gdt) } + end + + def delete_trace(id) + @model_class.where(id: id).destroy_all + nil + end + + def delete_all_traces + @model_class.all.destroy_all + end + + def find_trace(id) + gdt = @model_class.find_by(id: id) + if gdt + record_to_stored_trace(gdt) + else + nil + end + end + + def save_trace(operation_name, duration_ms, begin_ms, trace_data) + gdt = @model_class.create!( + begin_ms: begin_ms, + operation_name: operation_name, + duration_ms: duration_ms, + trace_data: trace_data, + ) + if @limit + @model_class + .where("id NOT IN(SELECT id FROM graphql_detailed_traces ORDER BY begin_ms DESC LIMIT ?)", @limit) + .delete_all + end + gdt.id + end + + private + + def record_to_stored_trace(gdt) + StoredTrace.new( + id: gdt.id, + begin_ms: gdt.begin_ms, + operation_name: gdt.operation_name, + duration_ms: gdt.duration_ms, + trace_data: gdt.trace_data + ) + + end + end + end + end +end diff --git a/lib/graphql/tracing/detailed_trace/memory_backend.rb b/lib/graphql/tracing/detailed_trace/memory_backend.rb new file mode 100644 index 00000000000..e22c5421588 --- /dev/null +++ b/lib/graphql/tracing/detailed_trace/memory_backend.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module GraphQL + module Tracing + class DetailedTrace + # An in-memory trace storage backend. Suitable for testing and development only. + # It won't work for multi-process deployments and everything is erased when the app is restarted. + class MemoryBackend + def initialize(limit: nil) + @limit = limit + @traces = {} + @next_id = 0 + end + + def traces(last:, before:) + page = [] + @traces.values.reverse_each do |trace| + if page.size == last + break + elsif before.nil? || trace.begin_ms < before + page << trace + end + end + page + end + + def find_trace(id) + @traces[id] + end + + def delete_trace(id) + @traces.delete(id.to_i) + nil + end + + def delete_all_traces + @traces.clear + nil + end + + def save_trace(operation_name, duration, begin_ms, trace_data) + id = @next_id + @next_id += 1 + @traces[id] = DetailedTrace::StoredTrace.new( + id: id, + operation_name: operation_name, + duration_ms: duration, + begin_ms: begin_ms, + trace_data: trace_data + ) + if @limit && @traces.size > @limit + del_keys = @traces.keys[0...-@limit] + del_keys.each { |k| @traces.delete(k) } + end + id + end + end + end + end +end diff --git a/lib/graphql/tracing/detailed_trace/redis_backend.rb b/lib/graphql/tracing/detailed_trace/redis_backend.rb new file mode 100644 index 00000000000..242f85b4565 --- /dev/null +++ b/lib/graphql/tracing/detailed_trace/redis_backend.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module GraphQL + module Tracing + class DetailedTrace + class RedisBackend + KEY_PREFIX = "gql:trace:" + def initialize(redis:, limit: nil) + @redis = redis + @key = KEY_PREFIX + "traces" + @remrangebyrank_limit = limit ? -limit - 1 : nil + end + + def traces(last:, before:) + before = case before + when Numeric + "(#{before}" + when nil + "+inf" + end + str_pairs = @redis.zrange(@key, before, 0, byscore: true, rev: true, limit: [0, last || 100], withscores: true) + str_pairs.map do |(str_data, score)| + entry_to_trace(score, str_data) + end + end + + def delete_trace(id) + @redis.zremrangebyscore(@key, id, id) + nil + end + + def delete_all_traces + @redis.del(@key) + end + + def find_trace(id) + str_data = @redis.zrange(@key, id, id, byscore: true).first + if str_data.nil? + nil + else + entry_to_trace(id, str_data) + end + end + + def save_trace(operation_name, duration_ms, begin_ms, trace_data) + id = begin_ms + data = JSON.dump({ "o" => operation_name, "d" => duration_ms, "b" => begin_ms, "t" => Base64.encode64(trace_data) }) + @redis.pipelined do |pipeline| + pipeline.zadd(@key, id, data) + if @remrangebyrank_limit + pipeline.zremrangebyrank(@key, 0, @remrangebyrank_limit) + end + end + id + end + + private + + def entry_to_trace(id, json_str) + data = JSON.parse(json_str) + StoredTrace.new( + id: id, + operation_name: data["o"], + duration_ms: data["d"].to_f, + begin_ms: data["b"].to_i, + trace_data: Base64.decode64(data["t"]), + ) + end + end + end + end +end diff --git a/lib/graphql/execution/instrumentation.rb b/lib/graphql/tracing/legacy_hooks_trace.rb similarity index 59% rename from lib/graphql/execution/instrumentation.rb rename to lib/graphql/tracing/legacy_hooks_trace.rb index d3a88797ad9..d23fe190a14 100644 --- a/lib/graphql/execution/instrumentation.rb +++ b/lib/graphql/tracing/legacy_hooks_trace.rb @@ -1,38 +1,21 @@ # frozen_string_literal: true -module GraphQL - module Execution - module Instrumentation - # This function implements the instrumentation policy: - # - # - Instrumenters are a stack; the first `before_query` will have the last `after_query` - # - If a `before_` hook returned without an error, its corresponding `after_` hook will run. - # - If the `before_` hook did _not_ run, the `after_` hook will not be called. - # - # When errors are raised from `after_` hooks: - # - Subsequent `after_` hooks _are_ called - # - The first raised error is captured; later errors are ignored - # - If an error was capture, it's re-raised after all hooks are finished - # - # Partial runs of instrumentation are possible: - # - If a `before_multiplex` hook raises an error, no `before_query` hooks will run - # - If a `before_query` hook raises an error, subsequent `before_query` hooks will not run (on any query) - def self.apply_instrumenters(multiplex) - schema = multiplex.schema - queries = multiplex.queries - query_instrumenters = schema.instrumenters[:query] - multiplex_instrumenters = schema.instrumenters[:multiplex] +module GraphQL + module Tracing + module LegacyHooksTrace + def execute_multiplex(multiplex:) + multiplex_instrumenters = multiplex.schema.instrumenters[:multiplex] + query_instrumenters = multiplex.schema.instrumenters[:query] # First, run multiplex instrumentation, then query instrumentation for each query - call_hooks(multiplex_instrumenters, multiplex, :before_multiplex, :after_multiplex) do - each_query_call_hooks(query_instrumenters, queries) do - # Let them be executed - yield + RunHooks.call_hooks(multiplex_instrumenters, multiplex, :before_multiplex, :after_multiplex) do + RunHooks.each_query_call_hooks(query_instrumenters, multiplex.queries) do + super end end end - class << self - private + module RunHooks + module_function # Call the before_ hooks of each query, # Then yield if no errors. # `call_hooks` takes care of appropriate cleanup. diff --git a/lib/graphql/tracing/legacy_trace.rb b/lib/graphql/tracing/legacy_trace.rb new file mode 100644 index 00000000000..e4b8207440c --- /dev/null +++ b/lib/graphql/tracing/legacy_trace.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "graphql/tracing/trace" +require "graphql/tracing/call_legacy_tracers" + +module GraphQL + module Tracing + class LegacyTrace < Trace + include CallLegacyTracers + end + end +end diff --git a/lib/graphql/tracing/monitor_trace.rb b/lib/graphql/tracing/monitor_trace.rb new file mode 100644 index 00000000000..2d374583f2b --- /dev/null +++ b/lib/graphql/tracing/monitor_trace.rb @@ -0,0 +1,283 @@ +# frozen_string_literal: true + +module GraphQL + module Tracing + # This module is the basis for Ruby-level integration with third-party monitoring platforms. + # Platform-specific traces include this module and implement an adapter. + # + # @see ActiveSupportNotificationsTrace Integration via ActiveSupport::Notifications, an alternative approach. + module MonitorTrace + class Monitor + def initialize(trace:, set_transaction_name:, **_rest) + @trace = trace + @set_transaction_name = set_transaction_name + @platform_field_key_cache = Hash.new { |h, k| h[k] = platform_field_key(k) }.compare_by_identity + @platform_authorized_key_cache = Hash.new { |h, k| h[k] = platform_authorized_key(k) }.compare_by_identity + @platform_resolve_type_key_cache = Hash.new { |h, k| h[k] = platform_resolve_type_key(k) }.compare_by_identity + @platform_source_class_key_cache = Hash.new { |h, source_cls| h[source_cls] = platform_source_class_key(source_cls) }.compare_by_identity + end + + def instrument(keyword, object, &block) + raise "Implement #{self.class}#instrument to measure the block" + end + + def start_event(keyword, object) + ev = self.class::Event.new(self, keyword, object) + ev.start + ev + end + + # Get the transaction name based on the operation type and name if possible, or fall back to a user provided + # one. Useful for anonymous queries. + def transaction_name(query) + selected_op = query.selected_operation + txn_name = if selected_op + op_type = selected_op.operation_type + op_name = selected_op.name || fallback_transaction_name(query.context) || "anonymous" + "#{op_type}.#{op_name}" + else + "query.anonymous" + end + "GraphQL/#{txn_name}" + end + + def fallback_transaction_name(context) + context[:tracing_fallback_transaction_name] + end + + def name_for(keyword, object) + case keyword + when :execute_field + @platform_field_key_cache[object] + when :authorized + @platform_authorized_key_cache[object] + when :resolve_type + @platform_resolve_type_key_cache[object] + when :dataloader_source + @platform_source_class_key_cache[object.class] + when :parse then self.class::PARSE_NAME + when :lex then self.class::LEX_NAME + when :execute then self.class::EXECUTE_NAME + when :analyze then self.class::ANALYZE_NAME + when :validate then self.class::VALIDATE_NAME + else + raise "No name for #{keyword.inspect}" + end + end + + class Event + def initialize(monitor, keyword, object) + @monitor = monitor + @keyword = keyword + @object = object + end + + attr_reader :keyword, :object + + def start + raise "Implement #{self.class}#start to begin a new event (#{inspect})" + end + + def finish + raise "Implement #{self.class}#finish to end this event (#{inspect})" + end + end + + module GraphQLSuffixNames + PARSE_NAME = "parse.graphql" + LEX_NAME = "lex.graphql" + VALIDATE_NAME = "validate.graphql" + EXECUTE_NAME = "execute.graphql" + ANALYZE_NAME = "analyze.graphql" + + def platform_field_key(field) + "#{field.path}.graphql" + end + + def platform_authorized_key(type) + "#{type.graphql_name}.authorized.graphql" + end + + def platform_resolve_type_key(type) + "#{type.graphql_name}.resolve_type.graphql" + end + + def platform_source_class_key(source_class) + "#{source_class.name.gsub("::", "_")}.fetch.graphql" + end + end + + module GraphQLPrefixNames + PARSE_NAME = "graphql.parse" + LEX_NAME = "graphql.lex" + VALIDATE_NAME = "graphql.validate" + EXECUTE_NAME = "graphql.execute" + ANALYZE_NAME = "graphql.analyze" + + def platform_field_key(field) + "graphql.#{field.path}" + end + + def platform_authorized_key(type) + "graphql.authorized.#{type.graphql_name}" + end + + def platform_resolve_type_key(type) + "graphql.resolve_type.#{type.graphql_name}" + end + + def platform_source_class_key(source_class) + "graphql.fetch.#{source_class.name.gsub("::", "_")}" + end + end + end + + def self.create_module(monitor_name) + if !monitor_name.match?(/[a-z]+/) + raise ArgumentError, "monitor name must be [a-z]+, not: #{monitor_name.inspect}" + end + + trace_module = Module.new + code = MODULE_TEMPLATE % { + monitor: monitor_name, + monitor_class: monitor_name.capitalize + "Monitor", + } + trace_module.module_eval(code, __FILE__, __LINE__ + 5) # rubocop:disable Development/NoEvalCop This is build-time with a validated string + trace_module + end + + MODULE_TEMPLATE = <<~RUBY + # @param set_transaction_name [Boolean] If `true`, use the GraphQL operation name as the request name on the monitoring platform + # @param trace_scalars [Boolean] If `true`, leaf fields will be traced too (Scalars _and_ Enums) + # @param trace_authorized [Boolean] If `false`, skip tracing `authorized?` calls + # @param trace_resolve_type [Boolean] If `false`, skip tracing `resolve_type?` calls + def initialize(...) + setup_%{monitor}_monitor(...) + super + end + + def setup_%{monitor}_monitor(trace_scalars: false, trace_authorized: true, trace_resolve_type: true, set_transaction_name: false, **kwargs) + @trace_scalars = trace_scalars + @trace_authorized = trace_authorized + @trace_resolve_type = trace_resolve_type + @set_transaction_name = set_transaction_name + @%{monitor} = %{monitor_class}.new(trace: self, set_transaction_name: @set_transaction_name, **kwargs) + end + + def parse(query_string:) + @%{monitor}.instrument(:parse, query_string) do + super + end + end + + def lex(query_string:) + @%{monitor}.instrument(:lex, query_string) do + super + end + end + + def validate(query:, validate:) + @%{monitor}.instrument(:validate, query) do + super + end + end + + def begin_analyze_multiplex(multiplex, analyzers) + begin_%{monitor}_event(:analyze, nil) + super + end + + def end_analyze_multiplex(multiplex, analyzers) + finish_%{monitor}_event + super + end + + def execute_multiplex(multiplex:) + @%{monitor}.instrument(:execute, multiplex) do + super + end + end + + def begin_execute_field(field, object, arguments, query) + return_type = field.type.unwrap + trace_field = if return_type.kind.scalar? || return_type.kind.enum? + (field.trace.nil? && @trace_scalars) || field.trace + else + true + end + + if trace_field + begin_%{monitor}_event(:execute_field, field) + end + super + end + + def end_execute_field(field, object, arguments, query, result) + finish_%{monitor}_event + super + end + + def dataloader_fiber_yield(source) + Fiber[PREVIOUS_EV_KEY] = finish_%{monitor}_event + super + end + + def dataloader_fiber_resume(source) + prev_ev = Fiber[PREVIOUS_EV_KEY] + if prev_ev + begin_%{monitor}_event(prev_ev.keyword, prev_ev.object) + end + super + end + + def begin_authorized(type, object, context) + @trace_authorized && begin_%{monitor}_event(:authorized, type) + super + end + + def end_authorized(type, object, context, result) + finish_%{monitor}_event + super + end + + def begin_resolve_type(type, value, context) + @trace_resolve_type && begin_%{monitor}_event(:resolve_type, type) + super + end + + def end_resolve_type(type, value, context, resolved_type) + finish_%{monitor}_event + super + end + + def begin_dataloader_source(source) + begin_%{monitor}_event(:dataloader_source, source) + super + end + + def end_dataloader_source(source) + finish_%{monitor}_event + super + end + + CURRENT_EV_KEY = :__graphql_%{monitor}_trace_event + PREVIOUS_EV_KEY = :__graphql_%{monitor}_trace_previous_event + + private + + def begin_%{monitor}_event(keyword, object) + Fiber[CURRENT_EV_KEY] = @%{monitor}.start_event(keyword, object) + end + + def finish_%{monitor}_event + if ev = Fiber[CURRENT_EV_KEY] + ev.finish + # Use `false` to prevent grabbing an event from a parent fiber + Fiber[CURRENT_EV_KEY] = false + ev + end + end + RUBY + end + end +end diff --git a/lib/graphql/tracing/new_relic_trace.rb b/lib/graphql/tracing/new_relic_trace.rb new file mode 100644 index 00000000000..745f5469d8f --- /dev/null +++ b/lib/graphql/tracing/new_relic_trace.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "graphql/tracing/monitor_trace" + +module GraphQL + module Tracing + # A tracer for reporting GraphQL-Ruby time to New Relic + # + # @example Installing the tracer + # class MySchema < GraphQL::Schema + # trace_with GraphQL::Tracing::NewRelicTrace + # + # # Optional, use the operation name to set the new relic transaction name: + # # trace_with GraphQL::Tracing::NewRelicTrace, set_transaction_name: true + # end + # + # @example Installing without trace events for `authorized?` or `resolve_type` calls + # trace_with GraphQL::Tracing::NewRelicTrace, trace_authorized: false, trace_resolve_type: false + NewRelicTrace = MonitorTrace.create_module("newrelic") + module NewRelicTrace + class NewrelicMonitor < MonitorTrace::Monitor + PARSE_NAME = "GraphQL/parse" + LEX_NAME = "GraphQL/lex" + VALIDATE_NAME = "GraphQL/validate" + EXECUTE_NAME = "GraphQL/execute" + ANALYZE_NAME = "GraphQL/analyze" + + def instrument(keyword, payload, &block) + if keyword == :execute + query = payload.queries.first + set_this_txn_name = query.context[:set_new_relic_transaction_name] + if set_this_txn_name || (set_this_txn_name.nil? && @set_transaction_name) + NewRelic::Agent.set_transaction_name(transaction_name(query)) + end + end + ::NewRelic::Agent::MethodTracerHelpers.trace_execution_scoped(name_for(keyword, payload), &block) + end + + def platform_source_class_key(source_class) + "GraphQL/Source/#{source_class.name}" + end + + def platform_field_key(field) + "GraphQL/#{field.owner.graphql_name}/#{field.graphql_name}" + end + + def platform_authorized_key(type) + "GraphQL/Authorized/#{type.graphql_name}" + end + + def platform_resolve_type_key(type) + "GraphQL/ResolveType/#{type.graphql_name}" + end + + class Event < MonitorTrace::Monitor::Event + def start + name = @monitor.name_for(keyword, object) + @nr_ev = NewRelic::Agent::Tracer.start_transaction_or_segment(partial_name: name, category: :web) + end + + def finish + @nr_ev.finish + end + end + end + end + end +end diff --git a/lib/graphql/tracing/new_relic_tracing.rb b/lib/graphql/tracing/new_relic_tracing.rb index cf7431728e4..a2d05b5203a 100644 --- a/lib/graphql/tracing/new_relic_tracing.rb +++ b/lib/graphql/tracing/new_relic_tracing.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "graphql/tracing/platform_tracing" + module GraphQL module Tracing class NewRelicTracing < PlatformTracing diff --git a/lib/graphql/tracing/notifications_trace.rb b/lib/graphql/tracing/notifications_trace.rb new file mode 100644 index 00000000000..6c592ba9c8e --- /dev/null +++ b/lib/graphql/tracing/notifications_trace.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +module GraphQL + module Tracing + # This implementation forwards events to a notification handler + # (i.e. ActiveSupport::Notifications or Dry::Monitor::Notifications) with a `graphql` suffix. + # + # @see ActiveSupportNotificationsTrace ActiveSupport::Notifications integration + module NotificationsTrace + # @api private + class Adapter + def instrument(keyword, payload, &block) + raise "Implement #{self.class}#instrument to measure the block" + end + + def start_event(keyword, payload) + ev = self.class::Event.new(keyword, payload) + ev.start + ev + end + + class Event + def initialize(name, payload) + @name = name + @payload = payload + end + + attr_reader :name, :payload + + def start + raise "Implement #{self.class}#start to begin a new event (#{inspect})" + end + + def finish + raise "Implement #{self.class}#finish to end this event (#{inspect})" + end + end + end + + # @api private + class DryMonitorAdapter < Adapter + def instrument(...) + Dry::Monitor.instrument(...) + end + + class Event < Adapter::Event + def start + Dry::Monitor.start(@name, @payload) + end + + def finish + Dry::Monitor.stop(@name, @payload) + end + end + end + + # @api private + class ActiveSupportNotificationsAdapter < Adapter + def instrument(...) + ActiveSupport::Notifications.instrument(...) + end + + class Event < Adapter::Event + def start + @asn_event = ActiveSupport::Notifications.instrumenter.new_event(@name, @payload) + @asn_event.start! + end + + def finish + @asn_event.finish! + ActiveSupport::Notifications.publish_event(@asn_event) + end + end + end + + # @param engine [Class] The notifications engine to use, eg `Dry::Monitor` or `ActiveSupport::Notifications` + def initialize(engine:, **rest) + adapter = if defined?(Dry::Monitor) && engine == Dry::Monitor + DryMonitoringAdapter + elsif defined?(ActiveSupport::Notifications) && engine == ActiveSupport::Notifications + ActiveSupportNotificationsAdapter + else + engine + end + @notifications = adapter.new + super + end + + def parse(**payload) + @notifications.instrument("parse.graphql", payload) do + super + end + end + + def lex(**payload) + @notifications.instrument("lex.graphql", payload) do + super + end + end + + def validate(**payload) + @notifications.instrument("validate.graphql", payload) do + super + end + end + + def begin_analyze_multiplex(multiplex, analyzers) + begin_notifications_event("analyze.graphql", {multiplex: multiplex, analyzers: analyzers}) + super + end + + def end_analyze_multiplex(_multiplex, _analyzers) + finish_notifications_event + super + end + + def execute_multiplex(**payload) + @notifications.instrument("execute.graphql", payload) do + super + end + end + + def begin_execute_field(field, object, arguments, query) + begin_notifications_event("execute_field.graphql", {field: field, object: object, arguments: arguments, query: query}) + super + end + + def end_execute_field(_field, _object, _arguments, _query, _result) + finish_notifications_event + super + end + + def dataloader_fiber_yield(source) + Fiber[PREVIOUS_EV_KEY] = finish_notifications_event + super + end + + def dataloader_fiber_resume(source) + prev_ev = Fiber[PREVIOUS_EV_KEY] + if prev_ev + begin_notifications_event(prev_ev.name, prev_ev.payload) + end + super + end + + def begin_authorized(type, object, context) + begin_notifications_event("authorized.graphql", {type: type, object: object, context: context}) + super + end + + def end_authorized(type, object, context, result) + finish_notifications_event + super + end + + def begin_resolve_type(type, object, context) + begin_notifications_event("resolve_type.graphql", {type: type, object: object, context: context}) + super + end + + def end_resolve_type(type, object, context, resolved_type) + finish_notifications_event + super + end + + def begin_dataloader_source(source) + begin_notifications_event("dataloader_source.graphql", { source: source }) + super + end + + def end_dataloader_source(source) + finish_notifications_event + super + end + + CURRENT_EV_KEY = :__notifications_graphql_trace_event + PREVIOUS_EV_KEY = :__notifications_graphql_trace_previous_event + + private + + def begin_notifications_event(name, payload) + Fiber[CURRENT_EV_KEY] = @notifications.start_event(name, payload) + end + + def finish_notifications_event + if ev = Fiber[CURRENT_EV_KEY] + ev.finish + # Use `false` to prevent grabbing an event from a parent fiber + Fiber[CURRENT_EV_KEY] = false + ev + end + end + end + end +end diff --git a/lib/graphql/tracing/notifications_tracing.rb b/lib/graphql/tracing/notifications_tracing.rb new file mode 100644 index 00000000000..c8ad4f88612 --- /dev/null +++ b/lib/graphql/tracing/notifications_tracing.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "graphql/tracing/platform_tracing" + +module GraphQL + module Tracing + # This implementation forwards events to a notification handler (i.e. + # ActiveSupport::Notifications or Dry::Monitor::Notifications) + # with a `graphql` suffix. + # + # @see KEYS for event names + class NotificationsTracing + # A cache of frequently-used keys to avoid needless string allocations + KEYS = { + "lex" => "lex.graphql", + "parse" => "parse.graphql", + "validate" => "validate.graphql", + "analyze_multiplex" => "analyze_multiplex.graphql", + "analyze_query" => "analyze_query.graphql", + "execute_query" => "execute_query.graphql", + "execute_query_lazy" => "execute_query_lazy.graphql", + "execute_field" => "execute_field.graphql", + "execute_field_lazy" => "execute_field_lazy.graphql", + "authorized" => "authorized.graphql", + "authorized_lazy" => "authorized_lazy.graphql", + "resolve_type" => "resolve_type.graphql", + "resolve_type_lazy" => "resolve_type.graphql", + } + + MAX_KEYS_SIZE = 100 + + # Initialize a new NotificationsTracing instance + # + # @param [Object] notifications_engine The notifications engine to use + def initialize(notifications_engine) + @notifications_engine = notifications_engine + end + + # Sends a GraphQL tracing event to the notification handler + # + # @example + # . notifications_engine = Dry::Monitor::Notifications.new(:graphql) + # . tracer = GraphQL::Tracing::NotificationsTracing.new(notifications_engine) + # . tracer.trace("lex") { ... } + # + # @param [string] key The key for the event + # @param [Hash] metadata The metadata for the event + # @yield The block to execute for the event + def trace(key, metadata, &blk) + prefixed_key = KEYS[key] || "#{key}.graphql" + + # Cache the new keys while making sure not to induce a memory leak + if KEYS.size < MAX_KEYS_SIZE + KEYS[key] ||= prefixed_key + end + + @notifications_engine.instrument(prefixed_key, metadata, &blk) + end + end + end +end diff --git a/lib/graphql/tracing/null_trace.rb b/lib/graphql/tracing/null_trace.rb new file mode 100644 index 00000000000..1d6fbb07f64 --- /dev/null +++ b/lib/graphql/tracing/null_trace.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "graphql/tracing/trace" + +module GraphQL + module Tracing + NullTrace = Trace.new.freeze + end +end diff --git a/lib/graphql/tracing/perfetto_trace.rb b/lib/graphql/tracing/perfetto_trace.rb new file mode 100644 index 00000000000..29fc3f44457 --- /dev/null +++ b/lib/graphql/tracing/perfetto_trace.rb @@ -0,0 +1,866 @@ +# frozen_string_literal: true +module GraphQL + module Tracing + # This produces a trace file for inspecting in the [Perfetto Trace Viewer](https://ui.perfetto.dev). + # + # To get the file, call {#write} on the trace. + # + # Use "trace modes" to configure this to run on command or on a sample of traffic. + # + # @example Writing trace output + # + # result = MySchema.execute(...) + # result.query.trace.write(file: "tmp/trace.dump") + # + # @example Running this instrumenter when `trace: true` is present in the request + # + # class MySchema < GraphQL::Schema + # # Only run this tracer when `context[:trace_mode]` is `:trace` + # trace_with GraphQL::Tracing::Perfetto, mode: :trace + # end + # + # # In graphql_controller.rb: + # + # context[:trace_mode] = params[:trace] ? :trace : nil + # result = MySchema.execute(query_str, context: context, variables: variables, ...) + # if context[:trace_mode] == :trace + # result.trace.write(file: ...) + # end + # + module PerfettoTrace + # TODOs: + # - Make debug annotations visible on both parts when dataloader is involved + + PROTOBUF_AVAILABLE = begin + require "google/protobuf" + true + rescue LoadError + false + end + + if PROTOBUF_AVAILABLE + require "graphql/tracing/perfetto_trace/trace_pb" + end + + def self.included(_trace_class) + if !PROTOBUF_AVAILABLE + raise "#{self} can't be used because the `google-protobuf` gem wasn't available. Add it to your project, then try again." + end + end + + DATALOADER_CATEGORY_IIDS = [5] + FIELD_EXECUTE_CATEGORY_IIDS = [6] + ACTIVE_SUPPORT_NOTIFICATIONS_CATEGORY_IIDS = [7] + AUTHORIZED_CATEGORY_IIDS = [8] + RESOLVE_TYPE_CATEGORY_IIDS = [9] + + DA_OBJECT_IID = 10 + DA_RESULT_IID = 11 + DA_ARGUMENTS_IID = 12 + DA_FETCH_KEYS_IID = 13 + DA_STR_VAL_NIL_IID = 14 + + REVERSE_DEBUG_NAME_LOOKUP = { + DA_OBJECT_IID => "object", + DA_RESULT_IID => "result", + DA_ARGUMENTS_IID => "arguments", + DA_FETCH_KEYS_IID => "fetch keys", + } + + ANON_CLASS_NAME = "(anonymous)" + + DEBUG_INSPECT_CATEGORY_IIDS = [15] + DA_DEBUG_INSPECT_CLASS_IID = 16 + DEBUG_INSPECT_EVENT_NAME_IID = 17 + DA_DEBUG_INSPECT_FOR_IID = 18 + + # @param active_support_notifications_pattern [String, RegExp, false] A filter for `ActiveSupport::Notifications`, if it's present. Or `false` to skip subscribing. + def initialize(active_support_notifications_pattern: nil, save_profile: false, **_rest) + super + @active_support_notifications_pattern = active_support_notifications_pattern + @save_profile = save_profile + + query = if @multiplex + @multiplex.queries.first + else + @query # could still be nil in some initializations + end + + @detailed_trace = query&.schema&.detailed_trace || DetailedTrace + @create_debug_annotations = if (ctx = query&.context).nil? || (ctx_debug = ctx[:detailed_trace_debug]).nil? + @detailed_trace.debug? + else + ctx_debug + end + + @arguments_filter = if (ctx = query&.context) && (dtf = ctx[:detailed_trace_filter]) + dtf + elsif defined?(ActiveSupport::ParameterFilter) + fp = if defined?(Rails) && Rails.application && (app_config = Rails.application.config.filter_parameters).present? && !app_config.empty? + app_config + elsif ActiveSupport.respond_to?(:filter_parameters) + ActiveSupport.filter_parameters + else + EmptyObjects::EMPTY_ARRAY + end + ActiveSupport::ParameterFilter.new(fp, mask: ArgumentsFilter::FILTERED) + else + ArgumentsFilter.new + end + + Fiber[:graphql_flow_stack] = nil + @sequence_id = object_id + @pid = Process.pid + @flow_ids = Hash.new { |h, source_inst| h[source_inst] = [] }.compare_by_identity + @new_interned_event_names = {} + @interned_event_name_iids = Hash.new { |h, k| + new_id = 100 + h.size + @new_interned_event_names[k] = new_id + h[k] = new_id + } + + @source_name_iids = Hash.new do |h, source_class| + h[source_class] = @interned_event_name_iids[source_class.name || ANON_CLASS_NAME] + end.compare_by_identity + + @auth_name_iids = Hash.new do |h, graphql_type| + h[graphql_type] = @interned_event_name_iids["Authorize: #{graphql_type.graphql_name}"] + end.compare_by_identity + + @resolve_type_name_iids = Hash.new do |h, graphql_type| + h[graphql_type] = @interned_event_name_iids["Resolve Type: #{graphql_type.graphql_name}"] + end.compare_by_identity + + @new_interned_da_names = {} + @interned_da_name_ids = Hash.new { |h, k| + next_id = 100 + h.size + @new_interned_da_names[k] = next_id + h[k] = next_id + } + + @new_interned_da_string_values = {} + @interned_da_string_values = Hash.new do |h, k| + new_id = 100 + h.size + @new_interned_da_string_values[k] = new_id + h[k] = new_id + end + + @class_name_iids = Hash.new do |h, k| + h[k] = @interned_da_string_values[k.name || ANON_CLASS_NAME] + end.compare_by_identity + + @starting_objects = GC.stat(:total_allocated_objects) + @objects_counter_id = :objects_counter.object_id + @fibers_counter_id = :fibers_counter.object_id + @fields_counter_id = :fields_counter.object_id + @counts_objects = [@objects_counter_id] + @counts_objects_and_fields = [@objects_counter_id, @fields_counter_id] + @counts_fibers = [@fibers_counter_id] + @counts_fibers_and_objects = [@fibers_counter_id, @objects_counter_id] + @begin_validate = nil + @begin_time = nil + @packets = [] + @packets << TracePacket.new( + track_descriptor: TrackDescriptor.new( + uuid: tid, + name: "Main Thread", + child_ordering: TrackDescriptor::ChildTracksOrdering::CHRONOLOGICAL, + ), + first_packet_on_sequence: true, + previous_packet_dropped: true, + trusted_packet_sequence_id: @sequence_id, + sequence_flags: 3, + ) + @packets << TracePacket.new( + interned_data: InternedData.new( + event_categories: [ + EventCategory.new(name: "Dataloader", iid: DATALOADER_CATEGORY_IIDS.first), + EventCategory.new(name: "Field Execution", iid: FIELD_EXECUTE_CATEGORY_IIDS.first), + EventCategory.new(name: "ActiveSupport::Notifications", iid: ACTIVE_SUPPORT_NOTIFICATIONS_CATEGORY_IIDS.first), + EventCategory.new(name: "Authorized", iid: AUTHORIZED_CATEGORY_IIDS.first), + EventCategory.new(name: "Resolve Type", iid: RESOLVE_TYPE_CATEGORY_IIDS.first), + EventCategory.new(name: "Debug Inspect", iid: DEBUG_INSPECT_CATEGORY_IIDS.first), + ], + debug_annotation_names: [ + *REVERSE_DEBUG_NAME_LOOKUP.map { |(iid, name)| DebugAnnotationName.new(name: name, iid: iid) }, + DebugAnnotationName.new(name: "inspect instance of", iid: DA_DEBUG_INSPECT_CLASS_IID), + DebugAnnotationName.new(name: "inspecting for", iid: DA_DEBUG_INSPECT_FOR_IID) + ], + debug_annotation_string_values: [ + InternedString.new(str: "(nil)", iid: DA_STR_VAL_NIL_IID), + ], + event_names: [ + EventName.new(name: "#{(@detailed_trace.is_a?(Class) ? @detailed_trace : @detailed_trace.class).name}#inspect_object", iid: DEBUG_INSPECT_EVENT_NAME_IID) + ], + ), + trusted_packet_sequence_id: @sequence_id, + sequence_flags: 2, + ) + @main_fiber_id = fid + @packets << track_descriptor_packet(tid, fid, "Main Fiber") + @packets << track_descriptor_packet(tid, @objects_counter_id, "Allocated Objects", counter: {}) + @packets << trace_packet( + type: TrackEvent::Type::TYPE_COUNTER, + track_uuid: @objects_counter_id, + counter_value: count_allocations, + ) + @packets << track_descriptor_packet(tid, @fibers_counter_id, "Active Fibers", counter: {}) + @fibers_count = 0 + @packets << trace_packet( + type: TrackEvent::Type::TYPE_COUNTER, + track_uuid: @fibers_counter_id, + counter_value: count_fibers(0), + ) + + @packets << track_descriptor_packet(tid, @fields_counter_id, "Resolved Fields", counter: {}) + @fields_count = -1 + @packets << trace_packet( + type: TrackEvent::Type::TYPE_COUNTER, + track_uuid: @fields_counter_id, + counter_value: count_fields, + ) + end + + def execute_multiplex(multiplex:) + if defined?(ActiveSupport::Notifications) && @active_support_notifications_pattern != false + subscribe_to_active_support_notifications(@active_support_notifications_pattern) + end + @operation_name = multiplex.queries.map { |q| q.selected_operation_name || "anonymous" }.join(",") + @begin_time = Time.now + @packets << trace_packet( + type: TrackEvent::Type::TYPE_SLICE_BEGIN, + track_uuid: fid, + name: "Multiplex" + ) { [ payload_to_debug("query_string", multiplex.queries.map(&:sanitized_query_string).join("\n\n")) ] } + + result = super + + @packets << trace_packet( + type: TrackEvent::Type::TYPE_SLICE_END, + track_uuid: fid, + ) + + result + ensure + unsubscribe_from_active_support_notifications + if @save_profile + begin_ts = (@begin_time.to_f * 1000).round + end_ts = (Time.now.to_f * 1000).round + duration_ms = end_ts - begin_ts + multiplex.schema.detailed_trace.save_trace(@operation_name, duration_ms, begin_ts, Trace.encode(Trace.new(packet: @packets))) + end + end + + def begin_execute_field(field, object, arguments, query) + packet = trace_packet( + type: TrackEvent::Type::TYPE_SLICE_BEGIN, + track_uuid: fid, + name: query.context.current_path&.join(".") || field.path, + category_iids: FIELD_EXECUTE_CATEGORY_IIDS, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations], + ) + @packets << packet + fiber_flow_stack << packet + super + end + + def end_execute_field(field, object, arguments, query, app_result) + end_ts = ts + start_field = fiber_flow_stack.pop + if @create_debug_annotations + start_field.track_event = dup_with(start_field.track_event,{ + debug_annotations: [ + payload_to_debug(nil, (object.is_a?(GraphQL::Schema::Object) ? object.object : object), iid: DA_OBJECT_IID, intern_value: true), + payload_to_debug(nil, arguments, iid: DA_ARGUMENTS_IID), + payload_to_debug(nil, app_result, iid: DA_RESULT_IID, intern_value: true) + ] + }) + end + + @packets << trace_packet( + timestamp: end_ts, + type: TrackEvent::Type::TYPE_SLICE_END, + track_uuid: fid, + extra_counter_track_uuids: @counts_objects_and_fields, + extra_counter_values: [count_allocations, count_fields], + ) + super + end + + def begin_analyze_multiplex(m, analyzers) + @packets << trace_packet( + type: TrackEvent::Type::TYPE_SLICE_BEGIN, + track_uuid: fid, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations], + name: "Analysis") { + [ + payload_to_debug("analyzers_count", analyzers.size), + payload_to_debug("analyzers", analyzers), + ] + } + super + end + + def end_analyze_multiplex(m, analyzers) + end_ts = ts + @packets << trace_packet( + timestamp: end_ts, + type: TrackEvent::Type::TYPE_SLICE_END, + track_uuid: fid, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations], + ) + super + end + + def parse(query_string:) + @packets << trace_packet( + type: TrackEvent::Type::TYPE_SLICE_BEGIN, + track_uuid: fid, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations], + name: "Parse" + ) + result = super + end_ts = ts + @packets << trace_packet( + timestamp: end_ts, + type: TrackEvent::Type::TYPE_SLICE_END, + track_uuid: fid, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations], + ) + result + end + + def begin_validate(query, validate) + @begin_validate = trace_packet( + type: TrackEvent::Type::TYPE_SLICE_BEGIN, + track_uuid: fid, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations], + name: "Validate") { + [payload_to_debug("validate?", validate)] + } + + @packets << @begin_validate + super + end + + def end_validate(query, validate, validation_errors) + end_ts = ts + @packets << trace_packet( + timestamp: end_ts, + type: TrackEvent::Type::TYPE_SLICE_END, + track_uuid: fid, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations], + ) + + if @create_debug_annotations + new_bv_track_event = dup_with( + @begin_validate.track_event, { + debug_annotations: [ + @begin_validate.track_event.debug_annotations.first, + payload_to_debug("valid?", validation_errors.empty?) + ] + } + ) + @begin_validate.track_event = new_bv_track_event + end + super + end + + def dataloader_spawn_execution_fiber(jobs) + @packets << trace_packet( + type: TrackEvent::Type::TYPE_INSTANT, + track_uuid: fid, + name: "Create Execution Fiber", + category_iids: DATALOADER_CATEGORY_IIDS, + extra_counter_track_uuids: @counts_fibers_and_objects, + extra_counter_values: [count_fibers(1), count_allocations] + ) + @packets << track_descriptor_packet(@did, fid, "Exec Fiber ##{fid}") + super + end + + def dataloader_spawn_source_fiber(pending_sources) + @packets << trace_packet( + type: TrackEvent::Type::TYPE_INSTANT, + track_uuid: fid, + name: "Create Source Fiber", + category_iids: DATALOADER_CATEGORY_IIDS, + extra_counter_track_uuids: @counts_fibers_and_objects, + extra_counter_values: [count_fibers(1), count_allocations] + ) + @packets << track_descriptor_packet(@did, fid, "Source Fiber ##{fid}") + super + end + + def dataloader_fiber_yield(source) + ls = fiber_flow_stack.last + if (flow_id = ls.track_event.flow_ids.first) + # got it + else + flow_id = ls.track_event.name.object_id + ls.track_event = dup_with(ls.track_event, {flow_ids: [flow_id] }, delete_counters: true) + end + @flow_ids[source] << flow_id + @packets << trace_packet( + type: TrackEvent::Type::TYPE_SLICE_END, + track_uuid: fid, + ) + @packets << trace_packet( + type: TrackEvent::Type::TYPE_INSTANT, + track_uuid: fid, + name: "Fiber Yield", + category_iids: DATALOADER_CATEGORY_IIDS, + ) + super + end + + def dataloader_fiber_resume(source) + @packets << trace_packet( + type: TrackEvent::Type::TYPE_INSTANT, + track_uuid: fid, + name: "Fiber Resume", + category_iids: DATALOADER_CATEGORY_IIDS, + ) + + ls = fiber_flow_stack.pop + @packets << packet = TracePacket.new( + timestamp: ts, + track_event: dup_with(ls.track_event, { type: TrackEvent::Type::TYPE_SLICE_BEGIN }), + trusted_packet_sequence_id: @sequence_id, + ) + fiber_flow_stack << packet + + super + end + + def dataloader_fiber_exit + @packets << trace_packet( + type: TrackEvent::Type::TYPE_INSTANT, + track_uuid: fid, + name: "Fiber Exit", + category_iids: DATALOADER_CATEGORY_IIDS, + extra_counter_track_uuids: @counts_fibers, + extra_counter_values: [count_fibers(-1)], + ) + super + end + + def begin_dataloader(dl) + @packets << trace_packet( + type: TrackEvent::Type::TYPE_COUNTER, + track_uuid: @fibers_counter_id, + counter_value: count_fibers(1), + ) + @did = fid + @packets << track_descriptor_packet(@main_fiber_id, @did, "Dataloader Fiber ##{@did}") + super + end + + def end_dataloader(dl) + @packets << trace_packet( + type: TrackEvent::Type::TYPE_COUNTER, + track_uuid: @fibers_counter_id, + counter_value: count_fibers(-1), + ) + super + end + + def begin_dataloader_source(source) + fds = @flow_ids[source] + fds_copy = fds.dup + fds.clear + + packet = trace_packet( + type: TrackEvent::Type::TYPE_SLICE_BEGIN, + track_uuid: fid, + name_iid: @source_name_iids[source.class], + category_iids: DATALOADER_CATEGORY_IIDS, + flow_ids: fds_copy, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations]) { + [ + payload_to_debug(nil, source.pending.values, iid: DA_FETCH_KEYS_IID, intern_value: true), + *(source.instance_variables - [:@pending, :@fetching, :@results, :@dataloader]).map { |iv| + payload_to_debug(iv.to_s, source.instance_variable_get(iv), intern_value: true) + } + ] + } + @packets << packet + fiber_flow_stack << packet + super + end + + def end_dataloader_source(source) + end_ts = ts + @packets << trace_packet( + timestamp: end_ts, + type: TrackEvent::Type::TYPE_SLICE_END, + track_uuid: fid, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations], + ) + fiber_flow_stack.pop + super + end + + def begin_authorized(type, obj, ctx) + packet = trace_packet( + type: TrackEvent::Type::TYPE_SLICE_BEGIN, + track_uuid: fid, + category_iids: AUTHORIZED_CATEGORY_IIDS, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations], + name_iid: @auth_name_iids[type], + ) + @packets << packet + fiber_flow_stack << packet + super + end + + def end_authorized(type, obj, ctx, is_authorized) + end_ts = ts + @packets << trace_packet( + timestamp: end_ts, + type: TrackEvent::Type::TYPE_SLICE_END, + track_uuid: fid, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations], + ) + beg_auth = fiber_flow_stack.pop + if @create_debug_annotations + beg_auth.track_event = dup_with(beg_auth.track_event, { debug_annotations: [payload_to_debug("authorized?", is_authorized)] }) + end + super + end + + def begin_resolve_type(type, value, context) + packet = trace_packet( + type: TrackEvent::Type::TYPE_SLICE_BEGIN, + track_uuid: fid, + category_iids: RESOLVE_TYPE_CATEGORY_IIDS, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations], + name_iid: @resolve_type_name_iids[type], + ) + @packets << packet + fiber_flow_stack << packet + super + end + + def end_resolve_type(type, value, context, resolved_type) + end_ts = ts + @packets << trace_packet( + timestamp: end_ts, + type: TrackEvent::Type::TYPE_SLICE_END, + track_uuid: fid, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations], + ) + rt_begin = fiber_flow_stack.pop + if @create_debug_annotations + rt_begin.track_event = dup_with(rt_begin.track_event, { debug_annotations: [payload_to_debug("resolved_type", resolved_type, intern_value: true)] }) + end + super + end + + # Dump protobuf output in the specified file. + # @param file [String] path to a file in a directory that already exists + # @param debug_json [Boolean] True to print JSON instead of binary + # @return [nil, String, Hash] If `file` was given, `nil`. If `file` was `nil`, a Hash if `debug_json: true`, else binary data. + def write(file:, debug_json: false) + trace = Trace.new( + packet: @packets, + ) + data = if debug_json + small_json = Trace.encode_json(trace) + JSON.pretty_generate(JSON.parse(small_json)) + else + Trace.encode(trace) + end + + if file + File.write(file, data, mode: 'wb') + nil + else + data + end + end + + private + + def ts + Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) + end + + def tid + Thread.current.object_id + end + + def fid + Fiber.current.object_id + end + + class ArgumentsFilter + # From Rails defaults + # https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/app/templates/config/initializers/filter_parameter_logging.rb.tt#L6-L8 + SENSITIVE_KEY = /passw|token|crypt|email|_key|salt|certificate|secret|ssn|cvv|cvc|otp/i + FILTERED = "[FILTERED]" + + def filter_param(key, value) + if (key.is_a?(String) && SENSITIVE_KEY.match?(key)) || + (key.is_a?(Symbol) && SENSITIVE_KEY.match?(key.name)) + FILTERED + else + value + end + end + end + + def debug_annotation(iid, value_key, value) + if iid + DebugAnnotation.new(name_iid: iid, value_key => value) + else + DebugAnnotation.new(value_key => value) + end + end + + def payload_to_debug(k, v, iid: nil, intern_value: false) + if iid.nil? + iid = @interned_da_name_ids[k] + end + case v + when String + if intern_value + v = @interned_da_string_values[v] + debug_annotation(iid, :string_value_iid, v) + else + debug_annotation(iid, :string_value, v) + end + when Float + debug_annotation(iid, :double_value, v) + when Integer + debug_annotation(iid, :int_value, v) + when true, false + debug_annotation(iid, :bool_value, v) + when nil + if iid + DebugAnnotation.new(name_iid: iid, string_value_iid: DA_STR_VAL_NIL_IID) + else + DebugAnnotation.new(name: k, string_value_iid: DA_STR_VAL_NIL_IID) + end + when Module + if intern_value + val_iid = @class_name_iids[v] + debug_annotation(iid, :string_value_iid, val_iid) + else + debug_annotation(iid, :string_value, v.name) + end + when Symbol + debug_annotation(iid, :string_value, v.inspect) + when Array + debug_annotation(iid, :array_values, v.each_with_index.map { |v2, idx| payload_to_debug((k ? "#{k}.#{idx}" : String(idx)), v2, intern_value: intern_value) }.compact) + when Hash + debug_v = v.map { |k2, v2| + debug_k = case k2 + when String + k2 + when Symbol + k2.name + else + String(k2) + end + filtered_v2 = @arguments_filter.filter_param(debug_k, v2) + payload_to_debug(debug_k, filtered_v2, intern_value: intern_value) + } + debug_v.compact! + debug_annotation(iid, :dict_entries, debug_v) + when GraphQL::Schema::InputObject + payload_to_debug(k, v.to_h, iid: iid, intern_value: intern_value) + else + class_name_iid = @interned_da_string_values[(v.class.name || ANON_CLASS_NAME)] + da = [ + debug_annotation(DA_DEBUG_INSPECT_CLASS_IID, :string_value_iid, class_name_iid), + ] + if k + k_str_value_iid = @interned_da_string_values[k] + da << debug_annotation(DA_DEBUG_INSPECT_FOR_IID, :string_value_iid, k_str_value_iid) + elsif iid + k = REVERSE_DEBUG_NAME_LOOKUP[iid] || @interned_da_name_ids.key(iid) + if k.nil? + da << debug_annotation(DA_DEBUG_INSPECT_FOR_IID, :string_value_iid, DA_STR_VAL_NIL_IID) + else + k_str_value_iid = @interned_da_string_values[k] + da << debug_annotation(DA_DEBUG_INSPECT_FOR_IID, :string_value_iid, k_str_value_iid) + end + end + + @packets << trace_packet( + type: TrackEvent::Type::TYPE_SLICE_BEGIN, + track_uuid: fid, + name_iid: DEBUG_INSPECT_EVENT_NAME_IID, + category_iids: DEBUG_INSPECT_CATEGORY_IIDS, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations], + debug_annotations: da, + ) + debug_str = @detailed_trace.inspect_object(v) + @packets << trace_packet( + type: TrackEvent::Type::TYPE_SLICE_END, + track_uuid: fid, + ) + if intern_value + str_iid = @interned_da_string_values[debug_str] + debug_annotation(iid, :string_value_iid, str_iid) + else + debug_annotation(iid, :string_value, debug_str) + end + end + end + + def count_allocations + GC.stat(:total_allocated_objects) - @starting_objects + end + + def count_fibers(diff) + @fibers_count += diff + end + + def count_fields + @fields_count += 1 + end + + def dup_with(message, attrs, delete_counters: false) + new_attrs = message.to_h + if delete_counters + new_attrs.delete(:extra_counter_track_uuids) + new_attrs.delete(:extra_counter_values) + end + new_attrs.merge!(attrs) + message.class.new(**new_attrs) + end + + def fiber_flow_stack + Fiber[:graphql_flow_stack] ||= [] + end + + def trace_packet(timestamp: ts, **event_attrs) + if @create_debug_annotations && block_given? + event_attrs[:debug_annotations] = yield + end + track_event = TrackEvent.new(event_attrs) + TracePacket.new( + timestamp: timestamp, + track_event: track_event, + trusted_packet_sequence_id: @sequence_id, + sequence_flags: 2, + interned_data: new_interned_data + ) + end + + def new_interned_data + if !@new_interned_da_names.empty? + da_names = @new_interned_da_names.map { |(name, iid)| DebugAnnotationName.new(iid: iid, name: name) } + @new_interned_da_names.clear + end + + if !@new_interned_event_names.empty? + ev_names = @new_interned_event_names.map { |(name, iid)| EventName.new(iid: iid, name: name) } + @new_interned_event_names.clear + end + + if !@new_interned_da_string_values.empty? + str_vals = @new_interned_da_string_values.map { |name, iid| InternedString.new(iid: iid, str: name.b) } + @new_interned_da_string_values.clear + end + + if ev_names || da_names || str_vals + InternedData.new( + event_names: ev_names, + debug_annotation_names: da_names, + debug_annotation_string_values: str_vals, + ) + else + nil + end + end + + def track_descriptor_packet(parent_uuid, uuid, name, counter: nil) + td = if counter + TrackDescriptor.new( + parent_uuid: parent_uuid, + uuid: uuid, + name: name, + counter: counter + ) + else + TrackDescriptor.new( + parent_uuid: parent_uuid, + uuid: uuid, + name: name, + child_ordering: TrackDescriptor::ChildTracksOrdering::CHRONOLOGICAL, + ) + end + TracePacket.new( + track_descriptor: td, + trusted_packet_sequence_id: @sequence_id, + sequence_flags: 2, + ) + end + + def unsubscribe_from_active_support_notifications + if defined?(@as_subscriber) + ActiveSupport::Notifications.unsubscribe(@as_subscriber) + end + end + + def subscribe_to_active_support_notifications(pattern) + @as_subscriber = ActiveSupport::Notifications.monotonic_subscribe(pattern) do |name, start, finish, id, payload| + metadata = @create_debug_annotations ? payload.map { |k, v| payload_to_debug(String(k), v, intern_value: true) } : nil + metadata&.compact! + te = if metadata.nil? || metadata.empty? + TrackEvent.new( + type: TrackEvent::Type::TYPE_SLICE_BEGIN, + track_uuid: fid, + category_iids: ACTIVE_SUPPORT_NOTIFICATIONS_CATEGORY_IIDS, + name: name, + ) + else + TrackEvent.new( + type: TrackEvent::Type::TYPE_SLICE_BEGIN, + track_uuid: fid, + name: name, + category_iids: ACTIVE_SUPPORT_NOTIFICATIONS_CATEGORY_IIDS, + debug_annotations: metadata, + ) + end + @packets << TracePacket.new( + timestamp: (start * 1_000_000_000).to_i, + track_event: te, + trusted_packet_sequence_id: @sequence_id, + sequence_flags: 2, + interned_data: new_interned_data + ) + @packets << TracePacket.new( + timestamp: (finish * 1_000_000_000).to_i, + track_event: TrackEvent.new( + type: TrackEvent::Type::TYPE_SLICE_END, + track_uuid: fid, + name: name, + extra_counter_track_uuids: @counts_objects, + extra_counter_values: [count_allocations] + ), + trusted_packet_sequence_id: @sequence_id, + sequence_flags: 2, + ) + end + end + end + end +end diff --git a/lib/graphql/tracing/perfetto_trace/trace.proto b/lib/graphql/tracing/perfetto_trace/trace.proto new file mode 100644 index 00000000000..50607f7ad30 --- /dev/null +++ b/lib/graphql/tracing/perfetto_trace/trace.proto @@ -0,0 +1,141 @@ +// This is an abbreviated version of the full Perfetto schema. +// Most of them are for OS or Chrome traces and we'll never use them. +// Full doc: https://github.com/google/perfetto/tree/main/protos/perfetto +// +// Build it with +// protoc --ruby_out=lib/graphql/tracing/perfetto_trace --proto_path=lib/graphql/tracing/perfetto_trace trace.proto +syntax = "proto2"; +package perfetto_trace.protos; +option ruby_package = "GraphQL::Tracing::PerfettoTrace"; + +message Trace { + repeated TracePacket packet = 1; +} + +message TracePacket { + optional uint64 timestamp = 8; + oneof data { + TrackEvent track_event = 11; + TrackDescriptor track_descriptor = 60; + } + oneof optional_trusted_packet_sequence_id { + uint32 trusted_packet_sequence_id = 10; + } + optional InternedData interned_data = 12; + optional bool first_packet_on_sequence = 87; + optional bool previous_packet_dropped = 42; + optional uint32 sequence_flags = 13; +} + +message TrackEvent { + repeated uint64 category_iids = 3; + repeated string categories = 22; + oneof name_field { + uint64 name_iid = 10; + string name = 23; + } + enum Type { + TYPE_UNSPECIFIED = 0; + TYPE_SLICE_BEGIN = 1; + TYPE_SLICE_END = 2; + TYPE_INSTANT = 3; + TYPE_COUNTER = 4; + } + optional Type type = 9; + optional uint64 track_uuid = 11; + oneof counter_value_field { + int64 counter_value = 30; + double double_counter_value = 44; + } + repeated uint64 extra_counter_track_uuids = 31; + repeated int64 extra_counter_values = 12; + repeated uint64 extra_double_counter_track_uuids = 45; + repeated double extra_double_counter_values = 46; + repeated fixed64 flow_ids = 47; + repeated fixed64 terminating_flow_ids = 48; + repeated DebugAnnotation debug_annotations = 4; +} + +message DebugAnnotation { + oneof name_field { + uint64 name_iid = 1; + string name = 10; + } + oneof value { + bool bool_value = 2; + uint64 uint_value = 3; + int64 int_value = 4; + double double_value = 5; + string string_value = 6; + uint64 string_value_iid = 17; + } + repeated DebugAnnotation dict_entries = 11; + repeated DebugAnnotation array_values = 12; + uint64 string_value_iid = 17; +} + +message TrackDescriptor { + optional uint64 uuid = 1; + optional uint64 parent_uuid = 5; + + oneof static_or_dynamic_name { + string name = 2; + } + + optional CounterDescriptor counter = 8; + enum ChildTracksOrdering { + UNKNOWN = 0; + LEXICOGRAPHIC = 1; + CHRONOLOGICAL = 2; + EXPLICIT = 3; + } + optional ChildTracksOrdering child_ordering = 11; + optional int32 sibling_order_rank = 12; +} + +message CounterDescriptor { + enum BuiltinCounterType { + COUNTER_UNSPECIFIED = 0; + COUNTER_THREAD_TIME_NS = 1; + COUNTER_THREAD_INSTRUCTION_COUNT = 2; + } + enum Unit { + UNIT_UNSPECIFIED = 0; + UNIT_TIME_NS = 1; + UNIT_COUNT = 2; + UNIT_SIZE_BYTES = 3; + } + optional BuiltinCounterType type = 1; + repeated string categories = 2; + optional Unit unit = 3; + optional string unit_name = 6; + optional int64 unit_multiplier = 4; + optional bool is_incremental = 5; +} + +message InternedData { + repeated EventCategory event_categories = 1; + repeated EventName event_names = 2; + repeated DebugAnnotationName debug_annotation_names = 3; + repeated InternedString debug_annotation_string_values = 29; +} + +message InternedString { + optional uint64 iid = 1; + optional bytes str = 2; +} + +message EventCategory { + optional uint64 iid = 1; + optional string name = 2; +} + +message EventName { + optional uint64 iid = 1; + optional string name = 2; +} + +message DebugAnnotationName { + optional uint64 iid = 1; + optional string name = 2; +} diff --git a/lib/graphql/tracing/perfetto_trace/trace_pb.rb b/lib/graphql/tracing/perfetto_trace/trace_pb.rb new file mode 100644 index 00000000000..8cb90133ddb --- /dev/null +++ b/lib/graphql/tracing/perfetto_trace/trace_pb.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: trace.proto + +require 'google/protobuf' + + +descriptor_data = "\n\x0btrace.proto\x12\x15perfetto_trace.protos\";\n\x05Trace\x12\x32\n\x06packet\x18\x01 \x03(\x0b\x32\".perfetto_trace.protos.TracePacket\"\x8a\x03\n\x0bTracePacket\x12\x11\n\ttimestamp\x18\x08 \x01(\x04\x12\x38\n\x0btrack_event\x18\x0b \x01(\x0b\x32!.perfetto_trace.protos.TrackEventH\x00\x12\x42\n\x10track_descriptor\x18< \x01(\x0b\x32&.perfetto_trace.protos.TrackDescriptorH\x00\x12$\n\x1atrusted_packet_sequence_id\x18\n \x01(\rH\x01\x12:\n\rinterned_data\x18\x0c \x01(\x0b\x32#.perfetto_trace.protos.InternedData\x12 \n\x18\x66irst_packet_on_sequence\x18W \x01(\x08\x12\x1f\n\x17previous_packet_dropped\x18* \x01(\x08\x12\x16\n\x0esequence_flags\x18\r \x01(\rB\x06\n\x04\x64\x61taB%\n#optional_trusted_packet_sequence_id\"\xf2\x04\n\nTrackEvent\x12\x15\n\rcategory_iids\x18\x03 \x03(\x04\x12\x12\n\ncategories\x18\x16 \x03(\t\x12\x12\n\x08name_iid\x18\n \x01(\x04H\x00\x12\x0e\n\x04name\x18\x17 \x01(\tH\x00\x12\x34\n\x04type\x18\t \x01(\x0e\x32&.perfetto_trace.protos.TrackEvent.Type\x12\x12\n\ntrack_uuid\x18\x0b \x01(\x04\x12\x17\n\rcounter_value\x18\x1e \x01(\x03H\x01\x12\x1e\n\x14\x64ouble_counter_value\x18, \x01(\x01H\x01\x12!\n\x19\x65xtra_counter_track_uuids\x18\x1f \x03(\x04\x12\x1c\n\x14\x65xtra_counter_values\x18\x0c \x03(\x03\x12(\n extra_double_counter_track_uuids\x18- \x03(\x04\x12#\n\x1b\x65xtra_double_counter_values\x18. \x03(\x01\x12\x10\n\x08\x66low_ids\x18/ \x03(\x06\x12\x1c\n\x14terminating_flow_ids\x18\x30 \x03(\x06\x12\x41\n\x11\x64\x65\x62ug_annotations\x18\x04 \x03(\x0b\x32&.perfetto_trace.protos.DebugAnnotation\"j\n\x04Type\x12\x14\n\x10TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10TYPE_SLICE_BEGIN\x10\x01\x12\x12\n\x0eTYPE_SLICE_END\x10\x02\x12\x10\n\x0cTYPE_INSTANT\x10\x03\x12\x10\n\x0cTYPE_COUNTER\x10\x04\x42\x0c\n\nname_fieldB\x15\n\x13\x63ounter_value_field\"\xd5\x02\n\x0f\x44\x65\x62ugAnnotation\x12\x12\n\x08name_iid\x18\x01 \x01(\x04H\x00\x12\x0e\n\x04name\x18\n \x01(\tH\x00\x12\x14\n\nbool_value\x18\x02 \x01(\x08H\x01\x12\x14\n\nuint_value\x18\x03 \x01(\x04H\x01\x12\x13\n\tint_value\x18\x04 \x01(\x03H\x01\x12\x16\n\x0c\x64ouble_value\x18\x05 \x01(\x01H\x01\x12\x16\n\x0cstring_value\x18\x06 \x01(\tH\x01\x12\x1a\n\x10string_value_iid\x18\x11 \x01(\x04H\x01\x12<\n\x0c\x64ict_entries\x18\x0b \x03(\x0b\x32&.perfetto_trace.protos.DebugAnnotation\x12<\n\x0c\x61rray_values\x18\x0c \x03(\x0b\x32&.perfetto_trace.protos.DebugAnnotationB\x0c\n\nname_fieldB\x07\n\x05value\"\xe1\x02\n\x0fTrackDescriptor\x12\x0c\n\x04uuid\x18\x01 \x01(\x04\x12\x13\n\x0bparent_uuid\x18\x05 \x01(\x04\x12\x0e\n\x04name\x18\x02 \x01(\tH\x00\x12\x39\n\x07\x63ounter\x18\x08 \x01(\x0b\x32(.perfetto_trace.protos.CounterDescriptor\x12R\n\x0e\x63hild_ordering\x18\x0b \x01(\x0e\x32:.perfetto_trace.protos.TrackDescriptor.ChildTracksOrdering\x12\x1a\n\x12sibling_order_rank\x18\x0c \x01(\x05\"V\n\x13\x43hildTracksOrdering\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x11\n\rLEXICOGRAPHIC\x10\x01\x12\x11\n\rCHRONOLOGICAL\x10\x02\x12\x0c\n\x08\x45XPLICIT\x10\x03\x42\x18\n\x16static_or_dynamic_name\"\xb9\x03\n\x11\x43ounterDescriptor\x12I\n\x04type\x18\x01 \x01(\x0e\x32;.perfetto_trace.protos.CounterDescriptor.BuiltinCounterType\x12\x12\n\ncategories\x18\x02 \x03(\t\x12;\n\x04unit\x18\x03 \x01(\x0e\x32-.perfetto_trace.protos.CounterDescriptor.Unit\x12\x11\n\tunit_name\x18\x06 \x01(\t\x12\x17\n\x0funit_multiplier\x18\x04 \x01(\x03\x12\x16\n\x0eis_incremental\x18\x05 \x01(\x08\"o\n\x12\x42uiltinCounterType\x12\x17\n\x13\x43OUNTER_UNSPECIFIED\x10\x00\x12\x1a\n\x16\x43OUNTER_THREAD_TIME_NS\x10\x01\x12$\n COUNTER_THREAD_INSTRUCTION_COUNT\x10\x02\"S\n\x04Unit\x12\x14\n\x10UNIT_UNSPECIFIED\x10\x00\x12\x10\n\x0cUNIT_TIME_NS\x10\x01\x12\x0e\n\nUNIT_COUNT\x10\x02\x12\x13\n\x0fUNIT_SIZE_BYTES\x10\x03\"\xa0\x02\n\x0cInternedData\x12>\n\x10\x65vent_categories\x18\x01 \x03(\x0b\x32$.perfetto_trace.protos.EventCategory\x12\x35\n\x0b\x65vent_names\x18\x02 \x03(\x0b\x32 .perfetto_trace.protos.EventName\x12J\n\x16\x64\x65\x62ug_annotation_names\x18\x03 \x03(\x0b\x32*.perfetto_trace.protos.DebugAnnotationName\x12M\n\x1e\x64\x65\x62ug_annotation_string_values\x18\x1d \x03(\x0b\x32%.perfetto_trace.protos.InternedString\"*\n\x0eInternedString\x12\x0b\n\x03iid\x18\x01 \x01(\x04\x12\x0b\n\x03str\x18\x02 \x01(\x0c\"*\n\rEventCategory\x12\x0b\n\x03iid\x18\x01 \x01(\x04\x12\x0c\n\x04name\x18\x02 \x01(\t\"&\n\tEventName\x12\x0b\n\x03iid\x18\x01 \x01(\x04\x12\x0c\n\x04name\x18\x02 \x01(\t\"0\n\x13\x44\x65\x62ugAnnotationName\x12\x0b\n\x03iid\x18\x01 \x01(\x04\x12\x0c\n\x04name\x18\x02 \x01(\tB\"\xea\x02\x1fGraphQL::Tracing::PerfettoTrace" + +pool = Google::Protobuf::DescriptorPool.generated_pool +pool.add_serialized_file(descriptor_data) + +module GraphQL + module Tracing + module PerfettoTrace + Trace = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.Trace").msgclass + TracePacket = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.TracePacket").msgclass + TrackEvent = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.TrackEvent").msgclass + TrackEvent::Type = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.TrackEvent.Type").enummodule + DebugAnnotation = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.DebugAnnotation").msgclass + TrackDescriptor = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.TrackDescriptor").msgclass + TrackDescriptor::ChildTracksOrdering = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.TrackDescriptor.ChildTracksOrdering").enummodule + CounterDescriptor = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.CounterDescriptor").msgclass + CounterDescriptor::BuiltinCounterType = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.CounterDescriptor.BuiltinCounterType").enummodule + CounterDescriptor::Unit = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.CounterDescriptor.Unit").enummodule + InternedData = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.InternedData").msgclass + InternedString = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.InternedString").msgclass + EventCategory = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.EventCategory").msgclass + EventName = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.EventName").msgclass + DebugAnnotationName = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("perfetto_trace.protos.DebugAnnotationName").msgclass + end + end +end diff --git a/lib/graphql/tracing/platform_trace.rb b/lib/graphql/tracing/platform_trace.rb new file mode 100644 index 00000000000..895b7e25be5 --- /dev/null +++ b/lib/graphql/tracing/platform_trace.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module GraphQL + module Tracing + module PlatformTrace + def initialize(trace_scalars: false, **_options) + @trace_scalars = trace_scalars + + @platform_key_cache = Hash.new { |h, mod| h[mod] = mod::KeyCache.new } + super + end + + module BaseKeyCache + def initialize + @platform_field_key_cache = Hash.new { |h, k| h[k] = platform_field_key(k) } + @platform_authorized_key_cache = Hash.new { |h, k| h[k] = platform_authorized_key(k) } + @platform_resolve_type_key_cache = Hash.new { |h, k| h[k] = platform_resolve_type_key(k) } + end + + attr_reader :platform_field_key_cache, :platform_authorized_key_cache, :platform_resolve_type_key_cache + end + + + def platform_execute_field_lazy(*args, &block) + platform_execute_field(*args, &block) + end + + def platform_authorized_lazy(key, &block) + platform_authorized(key, &block) + end + + def platform_resolve_type_lazy(key, &block) + platform_resolve_type(key, &block) + end + + def self.included(child_class) + key_methods_class = Class.new { + include(child_class) + include(BaseKeyCache) + } + child_class.const_set(:KeyCache, key_methods_class) + + # rubocop:disable Development/NoEvalCop This eval takes static inputs at load-time + + [:execute_field, :execute_field_lazy].each do |field_trace_method| + if !child_class.method_defined?(field_trace_method) + child_class.module_eval <<-RUBY, __FILE__, __LINE__ + def #{field_trace_method}(query:, field:, ast_node:, arguments:, object:) + return_type = field.type.unwrap + trace_field = if return_type.kind.scalar? || return_type.kind.enum? + (field.trace.nil? && @trace_scalars) || field.trace + else + true + end + platform_key = if trace_field + @platform_key_cache[#{child_class}].platform_field_key_cache[field] + else + nil + end + if platform_key && trace_field + platform_#{field_trace_method}(platform_key) do + super + end + else + super + end + end + RUBY + end + end + + + [:authorized, :authorized_lazy].each do |auth_trace_method| + if !child_class.method_defined?(auth_trace_method) + child_class.module_eval <<-RUBY, __FILE__, __LINE__ + def #{auth_trace_method}(type:, query:, object:) + platform_key = @platform_key_cache[#{child_class}].platform_authorized_key_cache[type] + platform_#{auth_trace_method}(platform_key) do + super + end + end + RUBY + end + end + + [:resolve_type, :resolve_type_lazy].each do |rt_trace_method| + if !child_class.method_defined?(rt_trace_method) + child_class.module_eval <<-RUBY, __FILE__, __LINE__ + def #{rt_trace_method}(query:, type:, object:) + platform_key = @platform_key_cache[#{child_class}].platform_resolve_type_key_cache[type] + platform_#{rt_trace_method}(platform_key) do + super + end + end + RUBY + end + + # rubocop:enable Development/NoEvalCop + end + end + + private + + # Get the transaction name based on the operation type and name if possible, or fall back to a user provided + # one. Useful for anonymous queries. + def transaction_name(query) + selected_op = query.selected_operation + txn_name = if selected_op + op_type = selected_op.operation_type + op_name = selected_op.name || fallback_transaction_name(query.context) || "anonymous" + "#{op_type}.#{op_name}" + else + "query.anonymous" + end + "GraphQL/#{txn_name}" + end + + def fallback_transaction_name(context) + context[:tracing_fallback_transaction_name] + end + end + end +end diff --git a/lib/graphql/tracing/platform_tracing.rb b/lib/graphql/tracing/platform_tracing.rb index d536f96706d..6f0984d92e2 100644 --- a/lib/graphql/tracing/platform_tracing.rb +++ b/lib/graphql/tracing/platform_tracing.rb @@ -10,6 +10,10 @@ module Tracing class PlatformTracing class << self attr_accessor :platform_keys + + def inherited(child_class) + child_class.platform_keys = self.platform_keys + end end def initialize(options = {}) @@ -26,25 +30,19 @@ def trace(key, data) yield end when "execute_field", "execute_field_lazy" - if data[:context] - field = data[:context].field - platform_key = field.metadata[:platform_key] - trace_field = true # implemented with instrumenter + field = data[:field] + return_type = field.type.unwrap + trace_field = if return_type.kind.scalar? || return_type.kind.enum? + (field.trace.nil? && @trace_scalars) || field.trace else - field = data[:field] - return_type = field.type.unwrap - trace_field = if return_type.kind.scalar? || return_type.kind.enum? - (field.trace.nil? && @trace_scalars) || field.trace - else - true - end + true + end - platform_key = if trace_field - context = data.fetch(:query).context - cached_platform_key(context, field) { platform_field_key(data[:owner], field) } - else - nil - end + platform_key = if trace_field + context = data.fetch(:query).context + cached_platform_key(context, field, :field) { platform_field_key(field.owner, field) } + else + nil end if platform_key && trace_field @@ -57,14 +55,14 @@ def trace(key, data) when "authorized", "authorized_lazy" type = data.fetch(:type) context = data.fetch(:context) - platform_key = cached_platform_key(context, type) { platform_authorized_key(type) } + platform_key = cached_platform_key(context, type, :authorized) { platform_authorized_key(type) } platform_trace(platform_key, key, data) do yield end when "resolve_type", "resolve_type_lazy" type = data.fetch(:type) context = data.fetch(:context) - platform_key = cached_platform_key(context, type) { platform_resolve_type_key(type) } + platform_key = cached_platform_key(context, type, :resolve_type) { platform_resolve_type_key(type) } platform_trace(platform_key, key, data) do yield end @@ -74,47 +72,43 @@ def trace(key, data) end end - def instrument(type, field) - return_type = field.type.unwrap - case return_type - when GraphQL::ScalarType, GraphQL::EnumType - if field.trace || (field.trace.nil? && @trace_scalars) - trace_field(type, field) + def self.use(schema_defn, options = {}) + if options[:legacy_tracing] + tracer = self.new(**options) + schema_defn.tracer(tracer) + else + tracing_name = self.name.split("::").last + trace_name = tracing_name.sub("Tracing", "Trace") + if GraphQL::Tracing.const_defined?(trace_name, false) + trace_module = GraphQL::Tracing.const_get(trace_name) + warn("`use(#{self.name})` is deprecated, use the equivalent `trace_with(#{trace_module.name})` instead. More info: https://graphql-ruby.org/queries/tracing.html") + schema_defn.trace_with(trace_module, **options) else - field + warn("`use(#{self.name})` and `Tracing::PlatformTracing` are deprecated. Use a `trace_with(...)` module instead. More info: https://graphql-ruby.org/queries/tracing.html. Please open an issue on the GraphQL-Ruby repo if you want to discuss further!") + tracer = self.new(**options) + schema_defn.tracer(tracer, silence_deprecation_warning: true) end - else - trace_field(type, field) end end - def trace_field(type, field) - new_f = field.redefine - new_f.metadata[:platform_key] = platform_field_key(type, field) - new_f - end - - def self.use(schema_defn, options = {}) - tracer = self.new(**options) - if !schema_defn.is_a?(Class) - schema_defn.instrument(:field, tracer) - end - schema_defn.tracer(tracer) - end - private - # Get the transaction name based on the operation type and name + # Get the transaction name based on the operation type and name if possible, or fall back to a user provided + # one. Useful for anonymous queries. def transaction_name(query) selected_op = query.selected_operation - if selected_op + txn_name = if selected_op op_type = selected_op.operation_type - op_name = selected_op.name || "anonymous" + op_name = selected_op.name || fallback_transaction_name(query.context) || "anonymous" + "#{op_type}.#{op_name}" else - op_type = "query" - op_name = "anonymous" + "query.anonymous" end - "GraphQL/#{op_type}.#{op_name}" + "GraphQL/#{txn_name}" + end + + def fallback_transaction_name(context) + context[:tracing_fallback_transaction_name] end attr_reader :options @@ -129,8 +123,11 @@ def transaction_name(query) # # If the key isn't present, the given block is called and the result is cached for `key`. # + # @param ctx [GraphQL::Query::Context] + # @param key [Class, GraphQL::Field] A part of the schema + # @param trace_phase [Symbol] The stage of execution being traced (used by OpenTelementry tracing) # @return [String] - def cached_platform_key(ctx, key) + def cached_platform_key(ctx, key, trace_phase) cache = ctx.namespace(self.class)[:platform_key_cache] ||= {} cache.fetch(key) { cache[key] = yield } end diff --git a/lib/graphql/tracing/prometheus_trace.rb b/lib/graphql/tracing/prometheus_trace.rb new file mode 100644 index 00000000000..57cd342327e --- /dev/null +++ b/lib/graphql/tracing/prometheus_trace.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "graphql/tracing/monitor_trace" + +module GraphQL + module Tracing + # A tracer for reporting GraphQL-Ruby times to Prometheus. + # + # The PrometheusExporter server must be run with a custom type collector that extends `GraphQL::Tracing::PrometheusTracing::GraphQLCollector`. + # + # @example Adding this trace to your schema + # require 'prometheus_exporter/client' + # + # class MySchema < GraphQL::Schema + # trace_with GraphQL::Tracing::PrometheusTrace + # end + # + # @example Running a custom type collector + # # lib/graphql_collector.rb + # if defined?(PrometheusExporter::Server) + # require 'graphql/tracing' + # + # class GraphQLCollector < GraphQL::Tracing::PrometheusTrace::GraphQLCollector + # end + # end + # + # # Then run: + # # bundle exec prometheus_exporter -a lib/graphql_collector.rb + PrometheusTrace = MonitorTrace.create_module("prometheus") + module PrometheusTrace + if defined?(PrometheusExporter::Server) + autoload :GraphQLCollector, "graphql/tracing/prometheus_trace/graphql_collector" + end + + def initialize(client: PrometheusExporter::Client.default, keys_whitelist: [:execute_field], collector_type: "graphql", **rest) + @prometheus_client = client + @prometheus_keys_whitelist = keys_whitelist.map(&:to_sym) # handle previous string keys + @prometheus_collector_type = collector_type + setup_prometheus_monitor(**rest) + super + end + + attr_reader :prometheus_collector_type, :prometheus_client, :prometheus_keys_whitelist + + class PrometheusMonitor < MonitorTrace::Monitor + def instrument(keyword, object) + if active?(keyword) + start = gettime + result = yield + duration = gettime - start + send_json(duration, keyword, object) + result + else + yield + end + end + + def active?(keyword) + @trace.prometheus_keys_whitelist.include?(keyword) + end + + def gettime + ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + end + + def send_json(duration, keyword, object) + event_name = name_for(keyword, object) + @trace.prometheus_client.send_json( + type: @trace.prometheus_collector_type, + duration: duration, + platform_key: event_name, + key: keyword + ) + end + + include MonitorTrace::Monitor::GraphQLPrefixNames + + class Event < MonitorTrace::Monitor::Event + def start + @start_time = @monitor.gettime + end + + def finish + if @monitor.active?(keyword) + duration = @monitor.gettime - @start_time + @monitor.send_json(duration, keyword, object) + end + end + end + end + end + end +end diff --git a/lib/graphql/tracing/prometheus_tracing/graphql_collector.rb b/lib/graphql/tracing/prometheus_trace/graphql_collector.rb similarity index 75% rename from lib/graphql/tracing/prometheus_tracing/graphql_collector.rb rename to lib/graphql/tracing/prometheus_trace/graphql_collector.rb index 6a87f9b4ad3..87304af0154 100644 --- a/lib/graphql/tracing/prometheus_tracing/graphql_collector.rb +++ b/lib/graphql/tracing/prometheus_trace/graphql_collector.rb @@ -1,11 +1,13 @@ # frozen_string_literal: true +require "graphql/tracing" + module GraphQL module Tracing - class PrometheusTracing < PlatformTracing + module PrometheusTrace class GraphQLCollector < ::PrometheusExporter::Server::TypeCollector def initialize - @graphql_gauge = PrometheusExporter::Metric::Summary.new( + @graphql_gauge = PrometheusExporter::Metric::Base.default_aggregation.new( 'graphql_duration_seconds', 'Time spent in GraphQL operations, in seconds' ) @@ -28,5 +30,7 @@ def metrics end end end + # Backwards-compat: + PrometheusTracing::GraphQLCollector = PrometheusTrace::GraphQLCollector end end diff --git a/lib/graphql/tracing/prometheus_tracing.rb b/lib/graphql/tracing/prometheus_tracing.rb index 0cd500ab5f8..a10a0bc600c 100644 --- a/lib/graphql/tracing/prometheus_tracing.rb +++ b/lib/graphql/tracing/prometheus_tracing.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "graphql/tracing/platform_tracing" + module GraphQL module Tracing class PrometheusTracing < PlatformTracing @@ -27,9 +29,9 @@ def initialize(opts = {}) super opts end - def platform_trace(platform_key, key, data, &block) + def platform_trace(platform_key, key, _data, &block) return yield unless @keys_whitelist.include?(key) - instrument_execution(platform_key, key, data, &block) + instrument_execution(platform_key, key, &block) end def platform_field_key(type, field) @@ -46,7 +48,7 @@ def platform_resolve_type_key(type) private - def instrument_execution(platform_key, key, data, &block) + def instrument_execution(platform_key, key, &block) start = ::Process.clock_gettime ::Process::CLOCK_MONOTONIC result = block.call duration = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start diff --git a/lib/graphql/tracing/scout_trace.rb b/lib/graphql/tracing/scout_trace.rb new file mode 100644 index 00000000000..e3af68477ec --- /dev/null +++ b/lib/graphql/tracing/scout_trace.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "graphql/tracing/monitor_trace" + +module GraphQL + module Tracing + # A tracer for sending GraphQL-Ruby times to Scout + # + # @example Adding this tracer to your schema + # class MySchema < GraphQL::Schema + # trace_with GraphQL::Tracing::ScoutTrace + # end + ScoutTrace = MonitorTrace.create_module("scout") + module ScoutTrace + class ScoutMonitor < MonitorTrace::Monitor + def instrument(keyword, object) + if keyword == :execute + query = object.queries.first + set_this_txn_name = query.context[:set_scout_transaction_name] + if set_this_txn_name == true || (set_this_txn_name.nil? && @set_transaction_name) + ScoutApm::Transaction.rename(transaction_name(query)) + end + end + + ScoutApm::Tracer.instrument("GraphQL", name_for(keyword, object), INSTRUMENT_OPTS) do + yield + end + end + + INSTRUMENT_OPTS = { scope: true } + + include MonitorTrace::Monitor::GraphQLSuffixNames + + class Event < MonitorTrace::Monitor::Event + def start + layer = ScoutApm::Layer.new("GraphQL", @monitor.name_for(keyword, object)) + layer.subscopable! + @scout_req = ScoutApm::RequestManager.lookup + @scout_req.start_layer(layer) + end + + def finish + @scout_req.stop_layer + end + end + end + end + end +end diff --git a/lib/graphql/tracing/scout_tracing.rb b/lib/graphql/tracing/scout_tracing.rb index 2b5fecaf6a1..c3b20b7ee98 100644 --- a/lib/graphql/tracing/scout_tracing.rb +++ b/lib/graphql/tracing/scout_tracing.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "graphql/tracing/platform_tracing" + module GraphQL module Tracing class ScoutTracing < PlatformTracing diff --git a/lib/graphql/tracing/sentry_trace.rb b/lib/graphql/tracing/sentry_trace.rb new file mode 100644 index 00000000000..68fb0d734ed --- /dev/null +++ b/lib/graphql/tracing/sentry_trace.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "graphql/tracing/monitor_trace" + +module GraphQL + module Tracing + # A tracer for reporting GraphQL-Ruby times to Sentry. + # + # @example Installing the tracer + # class MySchema < GraphQL::Schema + # trace_with GraphQL::Tracing::SentryTrace + # end + # @see MonitorTrace Configuration Options in the parent module + SentryTrace = MonitorTrace.create_module("sentry") + module SentryTrace + class SentryMonitor < MonitorTrace::Monitor + def instrument(keyword, object) + return yield unless Sentry.initialized? + + platform_key = name_for(keyword, object) + + Sentry.with_child_span(op: platform_key, start_timestamp: Sentry.utc_now.to_f) do |span| + result = yield + return result unless span + + span.finish + if keyword == :execute + queries = object.queries + operation_names = queries.map{|q| operation_name(q) } + span.set_description(operation_names.join(", ")) + + if queries.size == 1 + query = queries.first + set_this_txn_name = query.context[:set_sentry_transaction_name] + if set_this_txn_name == true || (set_this_txn_name.nil? && @set_transaction_name) + Sentry.configure_scope do |scope| + scope.set_transaction_name(transaction_name(query)) + end + end + span.set_data('graphql.document', query.query_string) + if query.selected_operation_name + span.set_data('graphql.operation.name', query.selected_operation_name) + end + if query.selected_operation + span.set_data('graphql.operation.type', query.selected_operation.operation_type) + end + end + end + + result + end + end + + include MonitorTrace::Monitor::GraphQLPrefixNames + + private + + def operation_name(query) + selected_op = query.selected_operation + if selected_op + [selected_op.operation_type, selected_op.name].compact.join(' ') + else + 'GraphQL Operation' + end + end + + class Event < MonitorTrace::Monitor::Event + def start + if Sentry.initialized? && (@span = Sentry.get_current_scope.get_span) + span_name = @monitor.name_for(@keyword, @object) + @span.start_child(op: span_name) + end + end + + def finish + @span&.finish + end + end + end + end + end +end diff --git a/lib/graphql/tracing/skylight_tracing.rb b/lib/graphql/tracing/skylight_tracing.rb deleted file mode 100644 index acb12c14159..00000000000 --- a/lib/graphql/tracing/skylight_tracing.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -module GraphQL - module Tracing - class SkylightTracing < PlatformTracing - self.platform_keys = { - "lex" => "graphql.language", - "parse" => "graphql.language", - "validate" => "graphql.prepare", - "analyze_query" => "graphql.prepare", - "analyze_multiplex" => "graphql.prepare", - "execute_multiplex" => "graphql.execute", - "execute_query" => "graphql.execute", - "execute_query_lazy" => "graphql.execute", - } - - # @param set_endpoint_name [Boolean] If true, the GraphQL operation name will be used as the endpoint name. - # This is not advised if you run more than one query per HTTP request, for example, with `graphql-client` or multiplexing. - # It can also be specified per-query with `context[:set_skylight_endpoint_name]`. - def initialize(options = {}) - GraphQL::Deprecation.warn("GraphQL::Tracing::SkylightTracing is deprecated and will be removed in GraphQL-Ruby 2.0, please enable Skylight's GraphQL probe instead: https://www.skylight.io/support/getting-more-from-skylight#graphql.") - @set_endpoint_name = options.fetch(:set_endpoint_name, false) - super - end - - def platform_trace(platform_key, key, data) - if key == "execute_query" - query = data[:query] - title = query.selected_operation_name || "" - category = platform_key - set_endpoint_name_override = query.context[:set_skylight_endpoint_name] - if set_endpoint_name_override == true || (set_endpoint_name_override.nil? && @set_endpoint_name) - # Assign the endpoint so that queries will be grouped - instrumenter = Skylight.instrumenter - if instrumenter - current_trace = instrumenter.current_trace - if current_trace - op_type = query.selected_operation ? query.selected_operation.operation_type : "query" - endpoint = "GraphQL/#{op_type}.#{title}" - current_trace.endpoint = endpoint - end - end - end - elsif key.start_with?("execute_field") - title = platform_key - category = key - else - title = key - category = platform_key - end - - Skylight.instrument(category: category, title: title) do - yield - end - end - - def platform_field_key(type, field) - "graphql.#{type.graphql_name}.#{field.graphql_name}" - end - - def platform_authorized_key(type) - "graphql.authorized.#{type.graphql_name}" - end - - def platform_resolve_type_key(type) - "graphql.resolve_type.#{type.graphql_name}" - end - end - end -end diff --git a/lib/graphql/tracing/statsd_trace.rb b/lib/graphql/tracing/statsd_trace.rb new file mode 100644 index 00000000000..f1148b80ad8 --- /dev/null +++ b/lib/graphql/tracing/statsd_trace.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "graphql/tracing/monitor_trace" + +module GraphQL + module Tracing + # A tracer for reporting GraphQL-Ruby times to Statsd. + # Passing any Statsd client that implements `.time(name) { ... }` + # and `.timing(name, ms)` will work. + # + # @example Installing this tracer + # # eg: + # # $statsd = Statsd.new 'localhost', 9125 + # class MySchema < GraphQL::Schema + # use GraphQL::Tracing::StatsdTrace, statsd: $statsd + # end + StatsdTrace = MonitorTrace.create_module("statsd") + module StatsdTrace + class StatsdMonitor < MonitorTrace::Monitor + def initialize(statsd:, **_rest) + @statsd = statsd + super + end + + attr_reader :statsd + + def instrument(keyword, object) + @statsd.time(name_for(keyword, object)) do + yield + end + end + + include MonitorTrace::Monitor::GraphQLPrefixNames + + class Event < MonitorTrace::Monitor::Event + def start + @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + def finish + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time + @monitor.statsd.timing(@monitor.name_for(keyword, object), elapsed) + end + end + end + end + end +end diff --git a/lib/graphql/tracing/statsd_tracing.rb b/lib/graphql/tracing/statsd_tracing.rb index 28e1a307729..2b1ff10c8d0 100644 --- a/lib/graphql/tracing/statsd_tracing.rb +++ b/lib/graphql/tracing/statsd_tracing.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "graphql/tracing/platform_tracing" + module GraphQL module Tracing class StatsdTracing < PlatformTracing diff --git a/lib/graphql/tracing/trace.rb b/lib/graphql/tracing/trace.rb new file mode 100644 index 00000000000..54490fcddf9 --- /dev/null +++ b/lib/graphql/tracing/trace.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require "graphql/tracing" + +module GraphQL + module Tracing + # This is the base class for a `trace` instance whose methods are called during query execution. + # "Trace modes" are subclasses of this with custom tracing modules mixed in. + # + # A trace module may implement any of the methods on `Trace`, being sure to call `super` + # to continue any tracing hooks and call the actual runtime behavior. + # + class Trace + # @param multiplex [GraphQL::Execution::Multiplex, nil] + # @param query [GraphQL::Query, nil] + def initialize(multiplex: nil, query: nil, **_options) + @multiplex = multiplex + @query = query + end + + # The Ruby parser doesn't call this method (`graphql/c_parser` does.) + def lex(query_string:) + yield + end + + # @param query_string [String] + # @return [void] + def parse(query_string:) + yield + end + + def validate(query:, validate:) + yield + end + + def begin_validate(query, validate) + end + + def end_validate(query, validate, errors) + end + + # @param multiplex [GraphQL::Execution::Multiplex] + # @param analyzers [Array] + # @return [void] + def begin_analyze_multiplex(multiplex, analyzers); end + # @param multiplex [GraphQL::Execution::Multiplex] + # @param analyzers [Array] + # @return [void] + def end_analyze_multiplex(multiplex, analyzers); end + # @param multiplex [GraphQL::Execution::Multiplex] + # @return [void] + def analyze_multiplex(multiplex:) + yield + end + + def analyze_query(query:) + yield + end + + # This wraps an entire `.execute` call. + # @param multiplex [GraphQL::Execution::Multiplex] + # @return [void] + def execute_multiplex(multiplex:) + yield + end + + def execute_query(query:) + yield + end + + def execute_query_lazy(query:, multiplex:) + yield + end + + # GraphQL is about to resolve this field + # @param field [GraphQL::Schema::Field] + # @param object [GraphQL::Schema::Object] + # @param arguments [Hash] + # @param query [GraphQL::Query] + def begin_execute_field(field, object, arguments, query); end + # GraphQL just finished resolving this field + # @param field [GraphQL::Schema::Field] + # @param object [GraphQL::Schema::Object] + # @param arguments [Hash] + # @param query [GraphQL::Query] + # @param result [Object] + def end_execute_field(field, object, arguments, query, result); end + + def execute_field(field:, query:, ast_node:, arguments:, object:) + yield + end + + def execute_field_lazy(field:, query:, ast_node:, arguments:, object:) + yield + end + + def authorized(query:, type:, object:) + yield + end + + def objects(type, object, context) + end + + def object_loaded(argument_definition, object, context) + end + + # A call to `.authorized?` is starting + # @param type [Class] + # @param object [Object] + # @param context [GraphQL::Query::Context] + # @return [void] + def begin_authorized(type, object, context) + end + # A call to `.authorized?` just finished + # @param type [Class] + # @param object [Object] + # @param context [GraphQL::Query::Context] + # @param authorized_result [Boolean] + # @return [void] + def end_authorized(type, object, context, authorized_result) + end + + def authorized_lazy(query:, type:, object:) + yield + end + + def resolve_type(query:, type:, object:) + yield + end + + def resolve_type_lazy(query:, type:, object:) + yield + end + + # A call to `.resolve_type` is starting + # @param type [Class, Module] + # @param value [Object] + # @param context [GraphQL::Query::Context] + # @return [void] + def begin_resolve_type(type, value, context) + end + + # A call to `.resolve_type` just ended + # @param type [Class, Module] + # @param value [Object] + # @param context [GraphQL::Query::Context] + # @param resolved_type [Class] + # @return [void] + def end_resolve_type(type, value, context, resolved_type) + end + + # A dataloader run is starting + # @param dataloader [GraphQL::Dataloader] + # @return [void] + def begin_dataloader(dataloader); end + # A dataloader run has ended + # @param dataloder [GraphQL::Dataloader] + # @return [void] + def end_dataloader(dataloader); end + + # A source with pending keys is about to fetch + # @param source [GraphQL::Dataloader::Source] + # @return [void] + def begin_dataloader_source(source); end + # A fetch call has just ended + # @param source [GraphQL::Dataloader::Source] + # @return [void] + def end_dataloader_source(source); end + + # Called when Dataloader spins up a new fiber for GraphQL execution + # @param jobs [Array<#call>] Execution steps to run + # @return [void] + def dataloader_spawn_execution_fiber(jobs); end + # Called when Dataloader spins up a new fiber for fetching data + # @param pending_sources [GraphQL::Dataloader::Source] Instances with pending keys + # @return [void] + def dataloader_spawn_source_fiber(pending_sources); end + # Called when an execution or source fiber terminates + # @return [void] + def dataloader_fiber_exit; end + + # Called when a Dataloader fiber is paused to wait for data + # @param source [GraphQL::Dataloader::Source] The Source whose `load` call initiated this `yield` + # @return [void] + def dataloader_fiber_yield(source); end + # Called when a Dataloader fiber is resumed because data has been loaded + # @param source [GraphQL::Dataloader::Source] The Source whose `load` call previously caused this Fiber to wait + # @return [void] + def dataloader_fiber_resume(source); end + end + end +end diff --git a/lib/graphql/type_kinds.rb b/lib/graphql/type_kinds.rb index ea62a3e6376..3c052c4d8b2 100644 --- a/lib/graphql/type_kinds.rb +++ b/lib/graphql/type_kinds.rb @@ -5,17 +5,19 @@ module TypeKinds # These objects are singletons, eg `GraphQL::TypeKinds::UNION`, `GraphQL::TypeKinds::SCALAR`. class TypeKind attr_reader :name, :description - def initialize(name, abstract: false, fields: false, wraps: false, input: false, description: nil) + def initialize(name, abstract: false, leaf: false, fields: false, wraps: false, input: false, description: nil) @name = name @abstract = abstract @fields = fields @wraps = wraps @input = input + @leaf = leaf @composite = fields? || abstract? @description = description + freeze end - # Does this TypeKind have multiple possible implementors? + # Does this TypeKind have multiple possible implementers? # @deprecated Use `abstract?` instead of `resolves?`. def resolves?; @abstract; end # Is this TypeKind abstract? @@ -27,6 +29,8 @@ def wraps?; @wraps; end # Is this TypeKind a valid query input? def input?; @input; end def to_s; @name; end + # Is this TypeKind a primitive value? + def leaf?; @leaf; end # Is this TypeKind composed of many values? def composite?; @composite; end @@ -64,11 +68,11 @@ def non_null? end TYPE_KINDS = [ - SCALAR = TypeKind.new("SCALAR", input: true, description: 'Indicates this type is a scalar.'), + SCALAR = TypeKind.new("SCALAR", input: true, leaf: true, description: 'Indicates this type is a scalar.'), OBJECT = TypeKind.new("OBJECT", fields: true, description: 'Indicates this type is an object. `fields` and `interfaces` are valid fields.'), INTERFACE = TypeKind.new("INTERFACE", abstract: true, fields: true, description: 'Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.'), UNION = TypeKind.new("UNION", abstract: true, description: 'Indicates this type is a union. `possibleTypes` is a valid field.'), - ENUM = TypeKind.new("ENUM", input: true, description: 'Indicates this type is an enum. `enumValues` is a valid field.'), + ENUM = TypeKind.new("ENUM", input: true, leaf: true, description: 'Indicates this type is an enum. `enumValues` is a valid field.'), INPUT_OBJECT = TypeKind.new("INPUT_OBJECT", input: true, description: 'Indicates this type is an input object. `inputFields` is a valid field.'), LIST = TypeKind.new("LIST", wraps: true, description: 'Indicates this type is a list. `ofType` is a valid field.'), NON_NULL = TypeKind.new("NON_NULL", wraps: true, description: 'Indicates this type is a non-null. `ofType` is a valid field.'), diff --git a/lib/graphql/types.rb b/lib/graphql/types.rb index 4cf1c569492..f1dcaab99a9 100644 --- a/lib/graphql/types.rb +++ b/lib/graphql/types.rb @@ -1,11 +1,19 @@ # frozen_string_literal: true -require "graphql/types/boolean" -require "graphql/types/big_int" -require "graphql/types/float" -require "graphql/types/id" -require "graphql/types/int" -require "graphql/types/iso_8601_date" -require "graphql/types/iso_8601_date_time" -require "graphql/types/json" -require "graphql/types/string" -require "graphql/types/relay" + +module GraphQL + module Types + extend Autoload + + autoload :Boolean, "graphql/types/boolean" + autoload :BigInt, "graphql/types/big_int" + autoload :Float, "graphql/types/float" + autoload :ID, "graphql/types/id" + autoload :Int, "graphql/types/int" + autoload :JSON, "graphql/types/json" + autoload :String, "graphql/types/string" + autoload :ISO8601Date, "graphql/types/iso_8601_date" + autoload :ISO8601DateTime, "graphql/types/iso_8601_date_time" + autoload :ISO8601Duration, "graphql/types/iso_8601_duration" + autoload :Relay, "graphql/types/relay" + end +end diff --git a/lib/graphql/types/big_int.rb b/lib/graphql/types/big_int.rb index 2f55273a1b0..065996a51c5 100644 --- a/lib/graphql/types/big_int.rb +++ b/lib/graphql/types/big_int.rb @@ -6,7 +6,7 @@ class BigInt < GraphQL::Schema::Scalar description "Represents non-fractional signed whole numeric values. Since the value may exceed the size of a 32-bit integer, it's encoded as a string." def self.coerce_input(value, _ctx) - value && Integer(value) + value && parse_int(value) rescue ArgumentError nil end @@ -14,6 +14,10 @@ def self.coerce_input(value, _ctx) def self.coerce_result(value, _ctx) value.to_i.to_s end + + def self.parse_int(value) + value.is_a?(Numeric) ? value : Integer(value, 10) + end end end end diff --git a/lib/graphql/types/int.rb b/lib/graphql/types/int.rb index 13bc56e835a..e9ec3d55311 100644 --- a/lib/graphql/types/int.rb +++ b/lib/graphql/types/int.rb @@ -25,7 +25,7 @@ def self.coerce_result(value, ctx) if value >= MIN && value <= MAX value else - err = GraphQL::IntegerEncodingError.new(value) + err = GraphQL::IntegerEncodingError.new(value, context: ctx) ctx.schema.type_error(err, ctx) end end diff --git a/lib/graphql/types/iso_8601_date.rb b/lib/graphql/types/iso_8601_date.rb index 8a85e139fb0..f2b45071b35 100644 --- a/lib/graphql/types/iso_8601_date.rb +++ b/lib/graphql/types/iso_8601_date.rb @@ -14,6 +14,7 @@ module Types # own Date type. class ISO8601Date < GraphQL::Schema::Scalar description "An ISO 8601-encoded date" + specified_by_url "https://tools.ietf.org/html/rfc3339" # @param value [Date,Time,DateTime,String] # @return [String] @@ -21,13 +22,23 @@ def self.coerce_result(value, _ctx) Date.parse(value.to_s).iso8601 end - # @param str_value [String] - # @return [Date] - def self.coerce_input(str_value, _ctx) - Date.iso8601(str_value) + # @param str_value [String, Date, DateTime, Time] + # @return [Date, nil] + def self.coerce_input(value, ctx) + if value.is_a?(::Date) + value + elsif value.is_a?(::DateTime) + value.to_date + elsif value.is_a?(::Time) + value.to_date + elsif value.nil? + nil + else + Date.iso8601(value) + end rescue ArgumentError, TypeError - # Invalid input - nil + err = GraphQL::DateEncodingError.new(value) + ctx.schema.type_error(err, ctx) end end end diff --git a/lib/graphql/types/iso_8601_date_time.rb b/lib/graphql/types/iso_8601_date_time.rb index c91b04603f0..73421734e49 100644 --- a/lib/graphql/types/iso_8601_date_time.rb +++ b/lib/graphql/types/iso_8601_date_time.rb @@ -17,6 +17,7 @@ module Types # own DateTime type. class ISO8601DateTime < GraphQL::Schema::Scalar description "An ISO 8601-encoded datetime" + specified_by_url "https://tools.ietf.org/html/rfc3339" # It's not compatible with Rails' default, # i.e. ActiveSupport::JSON::Encoder.time_precision (3 by default) @@ -54,7 +55,17 @@ def self.coerce_input(str_value, _ctx) Time.iso8601(str_value) rescue ArgumentError, TypeError begin - Date.iso8601(str_value).to_time + dt = Date.iso8601(str_value).to_time + # For compatibility, continue accepting dates given without times + # But without this, it would zero out given any time part of `str_value` (hours and/or minutes) + if dt.iso8601.start_with?(str_value) + dt + elsif str_value.length == 8 && str_value.match?(/\A\d{8}\Z/) + # Allow dates that are missing the "-". eg. "20220404" + dt + else + nil + end rescue ArgumentError, TypeError # Invalid input nil diff --git a/lib/graphql/types/iso_8601_duration.rb b/lib/graphql/types/iso_8601_duration.rb new file mode 100644 index 00000000000..9c128900d07 --- /dev/null +++ b/lib/graphql/types/iso_8601_duration.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true +module GraphQL + module Types + # This scalar takes `Duration`s and transmits them as strings, + # using ISO 8601 format. ActiveSupport >= 5.0 must be loaded to use + # this scalar. + # + # Use it for fields or arguments as follows: + # + # field :age, GraphQL::Types::ISO8601Duration, null: false + # + # argument :interval, GraphQL::Types::ISO8601Duration, null: false + # + # Alternatively, use this built-in scalar as inspiration for your + # own Duration type. + class ISO8601Duration < GraphQL::Schema::Scalar + description "An ISO 8601-encoded duration" + + # @return [Integer, nil] + def self.seconds_precision + # ActiveSupport::Duration precision defaults to whatever input was given + @seconds_precision + end + + # @param [Integer, nil] value + def self.seconds_precision=(value) + @seconds_precision = value + end + + # @param value [ActiveSupport::Duration, String] + # @return [String] + # @raise [GraphQL::Error] if ActiveSupport::Duration is not defined or if an incompatible object is passed + def self.coerce_result(value, _ctx) + unless defined?(ActiveSupport::Duration) + raise GraphQL::Error, "ActiveSupport >= 5.0 must be loaded to use the built-in ISO8601Duration type." + end + + begin + case value + when ActiveSupport::Duration + value.iso8601(precision: seconds_precision) + when ::String + ActiveSupport::Duration.parse(value).iso8601(precision: seconds_precision) + else + # Try calling as ActiveSupport::Duration compatible as a fallback + value.iso8601(precision: seconds_precision) + end + rescue StandardError => error + raise GraphQL::Error, "An incompatible object (#{value.class}) was given to #{self}. Make sure that only ActiveSupport::Durations and well-formatted Strings are used with this type. (#{error.message})" + end + end + + # @param value [String, ActiveSupport::Duration] + # @return [ActiveSupport::Duration, nil] + # @raise [GraphQL::Error] if ActiveSupport::Duration is not defined + # @raise [GraphQL::DurationEncodingError] if duration cannot be parsed + def self.coerce_input(value, ctx) + unless defined?(ActiveSupport::Duration) + raise GraphQL::Error, "ActiveSupport >= 5.0 must be loaded to use the built-in ISO8601Duration type." + end + + begin + if value.is_a?(ActiveSupport::Duration) + value + elsif value.nil? + nil + else + ActiveSupport::Duration.parse(value) + end + rescue ArgumentError, TypeError + err = GraphQL::DurationEncodingError.new(value) + ctx.schema.type_error(err, ctx) + end + end + end + end +end diff --git a/lib/graphql/types/relay.rb b/lib/graphql/types/relay.rb index d1b1eaa525f..8f932af67d4 100644 --- a/lib/graphql/types/relay.rb +++ b/lib/graphql/types/relay.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true # behavior modules: -require "graphql/types/relay/default_relay" require "graphql/types/relay/connection_behaviors" require "graphql/types/relay/edge_behaviors" require "graphql/types/relay/node_behaviors" @@ -14,8 +13,6 @@ require "graphql/types/relay/base_connection" require "graphql/types/relay/base_edge" require "graphql/types/relay/node" -require "graphql/types/relay/node_field" -require "graphql/types/relay/nodes_field" module GraphQL module Types diff --git a/lib/graphql/types/relay/base_connection.rb b/lib/graphql/types/relay/base_connection.rb index ae546423d7e..46d60e88e2e 100644 --- a/lib/graphql/types/relay/base_connection.rb +++ b/lib/graphql/types/relay/base_connection.rb @@ -10,6 +10,8 @@ module Relay # so you can extend your own `BaseObject` instead of `GraphQL::Schema::Object`. # # @example Implementation a connection and edge + # class BaseObject < GraphQL::Schema::Object; end + # # # Given some object in your app ... # class Types::Post < BaseObject # end @@ -20,14 +22,22 @@ module Relay # # # Then extend them for the object in your app # class Types::PostEdge < Types::BaseEdge - # node_type(Types::Post) + # node_type Types::Post # end + # # class Types::PostConnection < Types::BaseConnection - # edge_type(Types::PostEdge) - # edges_nullable(true) - # edge_nullable(true) - # node_nullable(true) - # has_nodes_field(true) + # edge_type Types::PostEdge, + # edges_nullable: true, + # edge_nullable: true, + # node_nullable: true, + # nodes_field: true + # + # # Alternatively, you can call the class methods followed by your edge type + # # edges_nullable true + # # edge_nullable true + # # node_nullable true + # # has_nodes_field true + # # edge_type Types::PostEdge # end # # @see Relay::BaseEdge for edge types diff --git a/lib/graphql/types/relay/connection_behaviors.rb b/lib/graphql/types/relay/connection_behaviors.rb index 5b6dc93f496..a855282cfe8 100644 --- a/lib/graphql/types/relay/connection_behaviors.rb +++ b/lib/graphql/types/relay/connection_behaviors.rb @@ -9,16 +9,44 @@ module ConnectionBehaviors def self.included(child_class) child_class.extend(ClassMethods) - child_class.extend(Relay::DefaultRelay) - child_class.default_relay(true) child_class.has_nodes_field(true) child_class.node_nullable(true) child_class.edges_nullable(true) child_class.edge_nullable(true) + child_class.module_exec { + self.edge_type = nil + self.node_type = nil + self.edge_class = nil + } + child_class.default_broadcastable(nil) add_page_info_field(child_class) end module ClassMethods + def inherited(child_class) + super + child_class.has_nodes_field(has_nodes_field) + child_class.node_nullable(node_nullable) + child_class.edges_nullable(edges_nullable) + child_class.edge_nullable(edge_nullable) + child_class.edge_type = nil + child_class.node_type = nil + child_class.edge_class = nil + child_class.default_broadcastable(default_broadcastable?) + end + + def default_relay? + true + end + + def default_broadcastable? + @default_broadcastable + end + + def default_broadcastable(new_value) + @default_broadcastable = new_value + end + # @return [Class] attr_reader :node_type @@ -35,7 +63,8 @@ module ClassMethods # It's called when you subclass this base connection, trying to use the # class name to set defaults. You can call it again in the class definition # to override the default (or provide a value, if the default lookup failed). - def edge_type(edge_type_class, edge_class: GraphQL::Relay::Edge, node_type: edge_type_class.node_type, nodes_field: self.has_nodes_field, node_nullable: self.node_nullable, edges_nullable: self.edges_nullable, edge_nullable: self.edge_nullable) + # @param field_options [Hash] Any extra keyword arguments to pass to the `field :edges, ...` and `field :nodes, ...` configurations + def edge_type(edge_type_class, edge_class: GraphQL::Pagination::Connection::Edge, node_type: edge_type_class.node_type, nodes_field: self.has_nodes_field, node_nullable: self.node_nullable, edges_nullable: self.edges_nullable, edge_nullable: self.edge_nullable, field_options: nil) # Set this connection's graphql name node_type_name = node_type.graphql_name @@ -43,13 +72,22 @@ def edge_type(edge_type_class, edge_class: GraphQL::Relay::Edge, node_type: edge @edge_type = edge_type_class @edge_class = edge_class - field :edges, [edge_type_class, null: edge_nullable], + base_field_options = { + name: :edges, + type: [edge_type_class, null: edge_nullable], null: edges_nullable, description: "A list of edges.", - legacy_edge_class: edge_class, # This is used by the old runtime only, for EdgesInstrumentation - connection: false + scope: false, # Assume that the connection was already scoped. + connection: false, + } + + if field_options + base_field_options.merge!(field_options) + end + + field(**base_field_options) - define_nodes_field(node_nullable) if nodes_field + define_nodes_field(node_nullable, field_options: field_options) if nodes_field description("The connection type for #{node_type_name}.") end @@ -59,21 +97,31 @@ def scope_items(items, context) node_type.scope_items(items, context) end + # The connection will skip auth on its nodes if the node_type is configured for that + def reauthorize_scoped_objects(new_value = nil) + if new_value.nil? + if @reauthorize_scoped_objects != nil + @reauthorize_scoped_objects + else + node_type.reauthorize_scoped_objects + end + else + @reauthorize_scoped_objects = new_value + end + end + # Add the shortcut `nodes` field to this connection and its subclasses - def nodes_field(node_nullable: self.node_nullable) - define_nodes_field(node_nullable) + def nodes_field(node_nullable: self.node_nullable, field_options: nil) + define_nodes_field(node_nullable, field_options: field_options) end def authorized?(obj, ctx) true # Let nodes be filtered out end - def accessible?(ctx) - node_type.accessible?(ctx) - end - def visible?(ctx) - node_type.visible?(ctx) + # if this is an abstract base class, there may be no `node_type` + node_type ? node_type.visible?(ctx) : super end # Set the default `node_nullable` for this class and its child classes. (Defaults to `true`.) @@ -116,13 +164,26 @@ def has_nodes_field(new_value = nil) end end + protected + + attr_writer :edge_type, :node_type, :edge_class + private - def define_nodes_field(nullable) - field :nodes, [@node_type, null: nullable], + def define_nodes_field(nullable, field_options: nil) + base_field_options = { + name: :nodes, + type: [@node_type, null: nullable], null: nullable, description: "A list of nodes.", - connection: false + connection: false, + # Assume that the connection was scoped before this step: + scope: false, + } + if field_options + base_field_options.merge!(field_options) + end + field(**base_field_options) end end @@ -132,23 +193,24 @@ def add_page_info_field(obj_type) end end - # By default this calls through to the ConnectionWrapper's edge nodes method, - # but sometimes you need to override it to support the `nodes` field - def nodes - @object.edge_nodes + def edges + # Assume that whatever authorization needed to happen + # already happened at the connection level. + if (current_runtime_state = Fiber[:__graphql_runtime_info]) + query_runtime_state = current_runtime_state[context.query] + query_runtime_state.was_authorized_by_scope_items = @object.was_authorized_by_scope_items? + end + @object.edges end - def edges - if @object.is_a?(GraphQL::Pagination::Connection) - @object.edges - elsif context.interpreter? - context.schema.after_lazy(object.edge_nodes) do |nodes| - nodes.map { |n| self.class.edge_class.new(n, object) } - end - else - # This is done by edges_instrumentation - @object.edge_nodes + def nodes + # Assume that whatever authorization needed to happen + # already happened at the connection level. + if (current_runtime_state = Fiber[:__graphql_runtime_info]) + query_runtime_state = current_runtime_state[context.query] + query_runtime_state.was_authorized_by_scope_items = @object.was_authorized_by_scope_items? end + @object.nodes end end end diff --git a/lib/graphql/types/relay/default_relay.rb b/lib/graphql/types/relay/default_relay.rb deleted file mode 100644 index 4e554d22b9f..00000000000 --- a/lib/graphql/types/relay/default_relay.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module GraphQL - module Types - module Relay - module DefaultRelay - def self.extended(child_class) - child_class.default_relay(true) - end - - def default_relay(new_value) - @default_relay = new_value - end - - def default_relay? - !!@default_relay - end - - def to_graphql - type_defn = super - type_defn.default_relay = default_relay? - type_defn - end - end - end - end -end diff --git a/lib/graphql/types/relay/edge_behaviors.rb b/lib/graphql/types/relay/edge_behaviors.rb index f401d7e7dca..0734e145664 100644 --- a/lib/graphql/types/relay/edge_behaviors.rb +++ b/lib/graphql/types/relay/edge_behaviors.rb @@ -8,19 +8,59 @@ def self.included(child_class) child_class.description("An edge in a connection.") child_class.field(:cursor, String, null: false, description: "A cursor for use in pagination.") child_class.extend(ClassMethods) + child_class.class_exec { self.node_type = nil } child_class.node_nullable(true) + child_class.default_broadcastable(nil) + end + + def node + if (current_runtime_state = Fiber[:__graphql_runtime_info]) + query_runtime_state = current_runtime_state[context.query] + query_runtime_state.was_authorized_by_scope_items = @object.was_authorized_by_scope_items? + end + @object.node end module ClassMethods + def inherited(child_class) + super + child_class.node_type = nil + child_class.node_nullable = nil + child_class.default_broadcastable(default_broadcastable?) + end + + def default_relay? + true + end + + def default_broadcastable? + @default_broadcastable + end + + def default_broadcastable(new_value) + @default_broadcastable = new_value + end + # Get or set the Object type that this edge wraps. # # @param node_type [Class] A `Schema::Object` subclass # @param null [Boolean] - def node_type(node_type = nil, null: self.node_nullable) + # @param field_options [Hash] Any extra arguments to pass to the `field :node` configuration + def node_type(node_type = nil, null: self.node_nullable, field_options: nil) if node_type @node_type = node_type # Add a default `node` field - field :node, node_type, null: null, description: "The item at the end of the edge.", connection: false + base_field_options = { + name: :node, + type: node_type, + null: null, + description: "The item at the end of the edge.", + connection: false, + } + if field_options + base_field_options.merge!(field_options) + end + field(**base_field_options) end @node_type end @@ -29,10 +69,6 @@ def authorized?(obj, ctx) true end - def accessible?(ctx) - node_type.accessible?(ctx) - end - def visible?(ctx) node_type.visible?(ctx) end @@ -41,11 +77,15 @@ def visible?(ctx) # Use `node_nullable(false)` in your base class to make non-null `node` field. def node_nullable(new_value = nil) if new_value.nil? - defined?(@node_nullable) ? @node_nullable : superclass.node_nullable + @node_nullable != nil ? @node_nullable : superclass.node_nullable else @node_nullable = new_value end end + + protected + + attr_writer :node_type, :node_nullable end end end diff --git a/lib/graphql/types/relay/has_node_field.rb b/lib/graphql/types/relay/has_node_field.rb index 4952cb26e0a..f8fbc28bdee 100644 --- a/lib/graphql/types/relay/has_node_field.rb +++ b/lib/graphql/types/relay/has_node_field.rb @@ -7,6 +7,17 @@ module Relay module HasNodeField def self.included(child_class) child_class.field(**field_options, &field_block) + child_class.extend(ExecutionMethods) + end + + module ExecutionMethods + def get_relay_node(context, id:) + context.schema.object_from_id(id, context) + end + end + + def get_relay_node(id:) + self.class.get_relay_node(context, id: id) end class << self @@ -17,21 +28,15 @@ def field_options null: true, description: "Fetches an object given its ID.", relay_node_field: true, + resolver_method: :get_relay_node, + resolve_static: :get_relay_node, } end def field_block Proc.new { - argument :id, "ID!", required: true, + argument :id, "ID!", description: "ID of the object." - - def resolve(obj, args, ctx) - ctx.schema.object_from_id(args[:id], ctx) - end - - def resolve_field(obj, args, ctx) - resolve(obj, args, ctx) - end } end end diff --git a/lib/graphql/types/relay/has_nodes_field.rb b/lib/graphql/types/relay/has_nodes_field.rb index d3e68274cee..421ff8b24b0 100644 --- a/lib/graphql/types/relay/has_nodes_field.rb +++ b/lib/graphql/types/relay/has_nodes_field.rb @@ -7,6 +7,17 @@ module Relay module HasNodesField def self.included(child_class) child_class.field(**field_options, &field_block) + child_class.extend(ExecutionMethods) + end + + module ExecutionMethods + def get_relay_nodes(context, ids:) + ids.map { |id| context.schema.object_from_id(id, context) } + end + end + + def get_relay_nodes(ids:) + self.class.get_relay_nodes(context, ids: ids) end class << self @@ -17,21 +28,15 @@ def field_options null: false, description: "Fetches a list of objects given a list of IDs.", relay_nodes_field: true, + resolver_method: :get_relay_nodes, + resolve_static: :get_relay_nodes } end def field_block Proc.new { - argument :ids, "[ID!]!", required: true, + argument :ids, "[ID!]!", description: "IDs of the objects." - - def resolve(obj, args, ctx) - args[:ids].map { |id| ctx.schema.object_from_id(id, ctx) } - end - - def resolve_field(obj, args, ctx) - resolve(obj, args, ctx) - end } end end diff --git a/lib/graphql/types/relay/node_behaviors.rb b/lib/graphql/types/relay/node_behaviors.rb index 835c2cbe53f..e49cd6a888f 100644 --- a/lib/graphql/types/relay/node_behaviors.rb +++ b/lib/graphql/types/relay/node_behaviors.rb @@ -5,9 +5,30 @@ module Types module Relay module NodeBehaviors def self.included(child_module) - child_module.extend(DefaultRelay) + child_module.extend(ClassMethods) + child_module.extend(ExecutionMethods) child_module.description("An object with an ID.") - child_module.field(:id, ID, null: false, description: "ID of the object.") + child_module.field(:id, ID, null: false, description: "ID of the object.", resolver_method: :default_global_id, resolve_each: :default_global_id) + end + + def default_global_id + self.class.default_global_id(object, context) + end + + module ClassMethods + def default_relay? + true + end + end + + module ExecutionMethods + def default_global_id(object, context) + context.schema.id_from_object(object, self, context) + end + + def included(child_class) + child_class.extend(ExecutionMethods) + end end end end diff --git a/lib/graphql/types/relay/node_field.rb b/lib/graphql/types/relay/node_field.rb deleted file mode 100644 index e4f6b2b190c..00000000000 --- a/lib/graphql/types/relay/node_field.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Types - module Relay - # This can be used for implementing `Query.node(id: ...)`, - # or use it for inspiration for your own field definition. - # - # @example Adding this field directly - # include GraphQL::Types::Relay::HasNodeField - # - # @example Implementing a similar field in your own Query root - # - # field :node, GraphQL::Types::Relay::Node, null: true, - # description: "Fetches an object given its ID" do - # argument :id, ID, required: true - # end - # - # def node(id:) - # context.schema.object_from_id(id, context) - # end - # - NodeField = GraphQL::Schema::Field.new(owner: nil, **HasNodeField.field_options, &HasNodeField.field_block) - end - end -end diff --git a/lib/graphql/types/relay/nodes_field.rb b/lib/graphql/types/relay/nodes_field.rb deleted file mode 100644 index b69db957714..00000000000 --- a/lib/graphql/types/relay/nodes_field.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Types - module Relay - # This can be used for implementing `Query.nodes(ids: ...)`, - # or use it for inspiration for your own field definition. - # - # @example Adding this field directly - # include GraphQL::Types::Relay::HasNodesField - # - # @example Implementing a similar field in your own Query root - # - # field :nodes, [GraphQL::Types::Relay::Node, null: true], null: false, - # description: Fetches a list of objects given a list of IDs." do - # argument :ids, [ID], required: true - # end - # - # def nodes(ids:) - # ids.map do |id| - # context.schema.object_from_id(context, id) - # end - # end - # - NodesField = GraphQL::Schema::Field.new(owner: nil, **HasNodesField.field_options, &HasNodesField.field_block) - end - end -end diff --git a/lib/graphql/types/relay/page_info_behaviors.rb b/lib/graphql/types/relay/page_info_behaviors.rb index 3f4e8096eb6..46e782567d0 100644 --- a/lib/graphql/types/relay/page_info_behaviors.rb +++ b/lib/graphql/types/relay/page_info_behaviors.rb @@ -4,8 +4,7 @@ module Types module Relay module PageInfoBehaviors def self.included(child_class) - child_class.extend GraphQL::Types::Relay::DefaultRelay - + child_class.extend ClassMethods child_class.description "Information about pagination in a connection." child_class.field :has_next_page, Boolean, null: false, description: "When paginating forwards, are there more items?" @@ -20,6 +19,16 @@ def self.included(child_class) description: "When paginating forwards, the cursor to continue." end end + + module ClassMethods + def default_relay? + true + end + + def default_broadcastable? + true + end + end end end end diff --git a/lib/graphql/types/string.rb b/lib/graphql/types/string.rb index 57094752bcf..3158b971d40 100644 --- a/lib/graphql/types/string.rb +++ b/lib/graphql/types/string.rb @@ -7,7 +7,7 @@ class String < GraphQL::Schema::Scalar def self.coerce_result(value, ctx) str = value.to_s - if str.encoding == Encoding::UTF_8 + if str.encoding == Encoding::UTF_8 || str.ascii_only? str elsif str.frozen? str.encode(Encoding::UTF_8) @@ -15,7 +15,7 @@ def self.coerce_result(value, ctx) str.encode!(Encoding::UTF_8) end rescue EncodingError - err = GraphQL::StringEncodingError.new(str) + err = GraphQL::StringEncodingError.new(str, context: ctx) ctx.schema.type_error(err, ctx) end diff --git a/lib/graphql/unauthorized_enum_value_error.rb b/lib/graphql/unauthorized_enum_value_error.rb new file mode 100644 index 00000000000..f3bfc2acbf1 --- /dev/null +++ b/lib/graphql/unauthorized_enum_value_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +module GraphQL + class UnauthorizedEnumValueError < GraphQL::UnauthorizedError + # @return [GraphQL::Schema::EnumValue] The value whose `#authorized?` check returned false + attr_accessor :enum_value + + def initialize(type:, context:, enum_value:) + @enum_value = enum_value + message ||= "#{enum_value.path} failed authorization" + super(message, object: enum_value.value, type: type, context: context) + end + end +end diff --git a/lib/graphql/unauthorized_error.rb b/lib/graphql/unauthorized_error.rb index 8e41fad0baa..56cf79b2b43 100644 --- a/lib/graphql/unauthorized_error.rb +++ b/lib/graphql/unauthorized_error.rb @@ -4,7 +4,7 @@ module GraphQL # It's passed to {Schema.unauthorized_object}. # # Alternatively, custom code in `authorized?` may raise this error. It will be routed the same way. - class UnauthorizedError < GraphQL::Error + class UnauthorizedError < GraphQL::RuntimeError # @return [Object] the application object that failed the authorization check attr_reader :object @@ -12,18 +12,26 @@ class UnauthorizedError < GraphQL::Error attr_reader :type # @return [GraphQL::Query::Context] the context for the current query - attr_reader :context + attr_accessor :context def initialize(message = nil, object: nil, type: nil, context: nil) if message.nil? && object.nil? && type.nil? raise ArgumentError, "#{self.class.name} requires either a message or keywords" end + @path = nil @object = object @type = type @context = context + @ast_nodes = nil message ||= "An instance of #{object.class} failed #{type.graphql_name}'s authorization check" super(message) end + + attr_accessor :path, :ast_nodes + + def finalize_graphql_result(query, result_data, key) + result_data[key] = nil + end end end diff --git a/lib/graphql/union_type.rb b/lib/graphql/union_type.rb deleted file mode 100644 index 10a95b1a335..00000000000 --- a/lib/graphql/union_type.rb +++ /dev/null @@ -1,115 +0,0 @@ -# frozen_string_literal: true -module GraphQL - # @api deprecated - class UnionType < GraphQL::BaseType - extend Define::InstanceDefinable::DeprecatedDefine - - # Rubocop was unhappy about the syntax when this was a proc literal - class AcceptPossibleTypesDefinition - def self.call(target, possible_types, options = {}) - target.add_possible_types(possible_types, **options) - end - end - - accepts_definitions :resolve_type, :type_membership_class, - possible_types: AcceptPossibleTypesDefinition - ensure_defined :possible_types, :resolve_type, :resolve_type_proc, :type_membership_class - - attr_accessor :resolve_type_proc - attr_reader :type_memberships - attr_accessor :type_membership_class - - def initialize - super - @type_membership_class = GraphQL::Schema::TypeMembership - @type_memberships = [] - @cached_possible_types = nil - @resolve_type_proc = nil - end - - def initialize_copy(other) - super - @type_membership_class = other.type_membership_class - @type_memberships = other.type_memberships.dup - @cached_possible_types = nil - end - - def kind - GraphQL::TypeKinds::UNION - end - - # @return [Boolean] True if `child_type_defn` is a member of this {UnionType} - def include?(child_type_defn, ctx = GraphQL::Query::NullContext) - possible_types(ctx).include?(child_type_defn) - end - - # @return [Array] Types which may be found in this union - def possible_types(ctx = GraphQL::Query::NullContext) - if ctx == GraphQL::Query::NullContext - # Only cache the default case; if we cached for every `ctx`, it would be a memory leak - # (The warden should cache calls to this method, so it's called only once per query, - # unless user code calls it directly.) - @cached_possible_types ||= possible_types_for_context(ctx) - else - possible_types_for_context(ctx) - end - end - - def possible_types=(types) - # This is a re-assignment, so clear the previous values - @type_memberships = [] - @cached_possible_types = nil - add_possible_types(types, **{}) - end - - def add_possible_types(types, **options) - @type_memberships ||= [] - Array(types).each { |t| - @type_memberships << self.type_membership_class.new(self, t, **options) - } - nil - end - - # Get a possible type of this {UnionType} by type name - # @param type_name [String] - # @param ctx [GraphQL::Query::Context] The context for the current query - # @return [GraphQL::ObjectType, nil] The type named `type_name` if it exists and is a member of this {UnionType}, (else `nil`) - def get_possible_type(type_name, ctx) - type = ctx.query.get_type(type_name) - type if type && ctx.query.warden.possible_types(self).include?(type) - end - - # Check if a type is a possible type of this {UnionType} - # @param type [String, GraphQL::BaseType] Name of the type or a type definition - # @param ctx [GraphQL::Query::Context] The context for the current query - # @return [Boolean] True if the `type` exists and is a member of this {UnionType}, (else `nil`) - def possible_type?(type, ctx) - type_name = type.is_a?(String) ? type : type.graphql_name - !get_possible_type(type_name, ctx).nil? - end - - def resolve_type(value, ctx) - ctx.query.resolve_type(self, value) - end - - def resolve_type=(new_resolve_type_proc) - @resolve_type_proc = new_resolve_type_proc - end - - def type_memberships=(type_memberships) - @type_memberships = type_memberships - end - - private - - def possible_types_for_context(ctx) - visible_types = [] - @type_memberships.each do |type_membership| - if type_membership.visible?(ctx) - visible_types << BaseType.resolve_related_type(type_membership.object_type) - end - end - visible_types - end - end -end diff --git a/lib/graphql/upgrader/member.rb b/lib/graphql/upgrader/member.rb deleted file mode 100644 index 6d6cf579f99..00000000000 --- a/lib/graphql/upgrader/member.rb +++ /dev/null @@ -1,937 +0,0 @@ -# frozen_string_literal: true -begin - require 'parser/current' -rescue LoadError - raise LoadError, "GraphQL::Upgrader requires the 'parser' gem, please install it and/or add it to your Gemfile" -end - -module GraphQL - module Upgrader - GRAPHQL_TYPES = '(Object|InputObject|Interface|Enum|Scalar|Union)' - - class Transform - # @param input_text [String] Untransformed GraphQL-Ruby code - # @return [String] The input text, with a transformation applied if necessary - def apply(input_text) - raise GraphQL::RequiredImplementationMissingError, "Return transformed text here" - end - - # Recursively transform a `.define`-DSL-based type expression into a class-ready expression, for example: - # - # - `types[X]` -> `[X, null: true]` - # - `types[X.to_non_null_type]` -> `[X]` - # - `Int` -> `Integer` - # - `X!` -> `X` - # - # Notice that `!` is removed sometimes, because it doesn't exist in the class API. - # - # @param type_expr [String] A `.define`-ready expression of a return type or input type - # @return [String] A class-ready expression of the same type` - def normalize_type_expression(type_expr, preserve_bang: false) - case type_expr - when /\A!/ - # Handle the bang, normalize the inside - "#{preserve_bang ? "!" : ""}#{normalize_type_expression(type_expr[1..-1], preserve_bang: preserve_bang)}" - when /\Atypes\[.*\]\Z/ - # Unwrap the brackets, normalize, then re-wrap - inner_type = type_expr[6..-2] - if inner_type.start_with?("!") - nullable = false - inner_type = inner_type[1..-1] - elsif inner_type.end_with?(".to_non_null_type") - nullable = false - inner_type = inner_type[0...-17] - else - nullable = true - end - - "[#{normalize_type_expression(inner_type, preserve_bang: preserve_bang)}#{nullable ? ", null: true" : ""}]" - when /\Atypes\./ - # Remove the prefix - normalize_type_expression(type_expr[6..-1], preserve_bang: preserve_bang) - when /\A->/ - # Remove the proc wrapper, don't re-apply it - # because stabby is not supported in class-based definition - # (and shouldn't ever be necessary) - unwrapped = type_expr - .sub(/\A->\s?\{\s*/, "") - .sub(/\s*\}/, "") - normalize_type_expression(unwrapped, preserve_bang: preserve_bang) - when "Int" - "Integer" - else - type_expr - end - end - - def underscorize(str) - str - .gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2') # URLDecoder -> URL_Decoder - .gsub(/([a-z\d])([A-Z])/,'\1_\2') # someThing -> some_Thing - .downcase - end - - def apply_processor(input_text, processor) - ruby_ast = Parser::CurrentRuby.parse(input_text) - processor.process(ruby_ast) - processor - rescue Parser::SyntaxError - puts "Error text:" - puts input_text - raise - end - - def reindent_lines(input_text, from_indent:, to_indent:) - prev_indent = " " * from_indent - next_indent = " " * to_indent - # For each line, remove the previous indent, then add the new indent - lines = input_text.split("\n").map do |line| - line = line.sub(prev_indent, "") - "#{next_indent}#{line}".rstrip - end - lines.join("\n") - end - - # Remove trailing whitespace - def trim_lines(input_text) - input_text.gsub(/ +$/, "") - end - end - - # Turns `{X} = GraphQL::{Y}Type.define do` into `class {X} < Types::Base{Y}`. - class TypeDefineToClassTransform < Transform - # @param base_class_pattern [String] Replacement pattern for the base class name. Use this if your base classes have nonstandard names. - def initialize(base_class_pattern: "Types::Base\\3") - @find_pattern = /( *)([a-zA-Z_0-9:]*) = GraphQL::#{GRAPHQL_TYPES}Type\.define do/ - @replace_pattern = "\\1class \\2 < #{base_class_pattern}" - @interface_replace_pattern = "\\1module \\2\n\\1 include #{base_class_pattern}" - end - - def apply(input_text) - if input_text.include?("GraphQL::InterfaceType.define") - input_text.sub(@find_pattern, @interface_replace_pattern) - else - input_text.sub(@find_pattern, @replace_pattern) - end - end - end - - # Turns `{X} = GraphQL::Relay::Mutation.define do` into `class {X} < Mutations::BaseMutation` - class MutationDefineToClassTransform < Transform - # @param base_class_name [String] Replacement pattern for the base class name. Use this if your Mutation base class has a nonstandard name. - def initialize(base_class_name: "Mutations::BaseMutation") - @find_pattern = /([a-zA-Z_0-9:]*) = GraphQL::Relay::Mutation.define do/ - @replace_pattern = "class \\1 < #{base_class_name}" - end - - def apply(input_text) - input_text.gsub(@find_pattern, @replace_pattern) - end - end - - # Remove `name "Something"` if it is redundant with the class name. - # Or, if it is not redundant, move it to `graphql_name "Something"`. - class NameTransform < Transform - def apply(transformable) - last_type_defn = transformable - .split("\n") - .select { |line| line.include?("class ") || line.include?("module ")} - .last - - if last_type_defn && (matches = last_type_defn.match(/(class|module) (?[a-zA-Z_0-9:]*)( <|$)/)) - type_name = matches[:type_name] - # Get the name without any prefixes or suffixes - type_name_without_the_type_part = type_name.split('::').last.gsub(/Type$/, '') - # Find an overridden name value - if matches = transformable.match(/ name ('|")(?.*)('|")/) - name = matches[:overridden_name] - if type_name_without_the_type_part != name - # If the overridden name is still required, use `graphql_name` for it - transformable = transformable.gsub(/ name (.*)/, ' graphql_name \1') - else - # Otherwise, remove it altogether - transformable = transformable.gsub(/\s+name ('|").*('|")/, '') - end - end - end - - transformable - end - end - - # Remove newlines -- normalize the text for processing - class RemoveNewlinesTransform - def apply(input_text) - keep_looking = true - while keep_looking do - keep_looking = false - # Find the `field` call (or other method), and an open paren, but not a close paren, or a comma between arguments - input_text = input_text.gsub(/(?(?:field|input_field|return_field|connection|argument)(?:\([^)]*|.*,))\n\s*(?.+)/) do - keep_looking = true - field = $~[:field].chomp - next_line = $~[:next_line] - - "#{field} #{next_line}" - end - end - input_text - end - end - - # Remove parens from method call - normalize for processing - class RemoveMethodParensTransform < Transform - def apply(input_text) - input_text.sub( - /(field|input_field|return_field|connection|argument)\( *(.*?) *\)( *)/, - '\1 \2\3' - ) - end - end - - # Move `type X` to be the second positional argument to `field ...` - class PositionalTypeArgTransform < Transform - def apply(input_text) - input_text.gsub( - /(?(?:field|input_field|return_field|connection|argument) :(?:[a-zA-Z_0-9]*)) do(?.*?)[ ]*type (?.*?)\n/m - ) do - field = $~[:field] - block_contents = $~[:block_contents] - return_type = normalize_type_expression($~[:return_type], preserve_bang: true) - - "#{field}, #{return_type} do#{block_contents}" - end - end - end - - # Find a configuration in the block and move it to a kwarg, - # for example - # ``` - # do - # property :thing - # end - # ``` - # becomes: - # ``` - # property: thing - # ``` - class ConfigurationToKwargTransform < Transform - def initialize(kwarg:) - @kwarg = kwarg - end - - def apply(input_text) - input_text.gsub( - /(?(?:field|return_field|input_field|connection|argument).*) do(?.*?)[ ]*#{@kwarg} (?.*?)\n/m - ) do - field = $~[:field] - block_contents = $~[:block_contents] - kwarg_value = $~[:kwarg_value].strip - - "#{field}, #{@kwarg}: #{kwarg_value} do#{block_contents}" - end - end - end - - # Transform `property:` kwarg to `method:` kwarg - class PropertyToMethodTransform < Transform - def apply(input_text) - input_text.gsub /property:/, 'method:' - end - end - - # Find a keyword whose value is a string or symbol, - # and if the value is equivalent to the field name, - # remove the keyword altogether. - class RemoveRedundantKwargTransform < Transform - def initialize(kwarg:) - @kwarg = kwarg - @finder_pattern = /(field|return_field|input_field|connection|argument) :(?[a-zA-Z_0-9]*).*#{@kwarg}: ['":](?[a-zA-Z_0-9?!]+)['"]?/ - end - - def apply(input_text) - if input_text =~ @finder_pattern - field_name = $~[:name] - kwarg_value = $~[:kwarg_value] - if field_name == kwarg_value - # It's redundant, remove it - input_text = input_text.sub(/, #{@kwarg}: ['":]#{kwarg_value}['"]?/, "") - end - end - input_text - end - end - - # Take camelized field names and convert them to underscore case. - # (They'll be automatically camelized later.) - class UnderscoreizeFieldNameTransform < Transform - def apply(input_text) - input_text.gsub /(?input_field|return_field|field|connection|argument) :(?[a-zA-Z_0-9_]*)/ do - field_type = $~[:field_type] - camelized_name = $~[:name] - underscored_name = underscorize(camelized_name) - "#{field_type} :#{underscored_name}" - end - end - end - - class ProcToClassMethodTransform < Transform - # @param proc_name [String] The name of the proc to be moved to `def self.#{proc_name}` - def initialize(proc_name) - @proc_name = proc_name - # This will tell us whether to operate on the input or not - @proc_check_pattern = /#{proc_name}\s?->/ - end - - def apply(input_text) - if input_text =~ @proc_check_pattern - processor = apply_processor(input_text, NamedProcProcessor.new(@proc_name)) - processor.proc_to_method_sections.reverse.each do |proc_to_method_section| - proc_body = input_text[proc_to_method_section.proc_body_start..proc_to_method_section.proc_body_end] - method_defn_indent = " " * proc_to_method_section.proc_defn_indent - method_defn = "def self.#{@proc_name}(#{proc_to_method_section.proc_arg_names.join(", ")})\n#{method_defn_indent} #{proc_body}\n#{method_defn_indent}end\n" - method_defn = trim_lines(method_defn) - # replace the proc with the new method - input_text[proc_to_method_section.proc_defn_start..proc_to_method_section.proc_defn_end] = method_defn - end - end - input_text - end - - class NamedProcProcessor < Parser::AST::Processor - attr_reader :proc_to_method_sections - def initialize(proc_name) - @proc_name_sym = proc_name.to_sym - @proc_to_method_sections = [] - end - - class ProcToMethodSection - attr_accessor :proc_arg_names, :proc_defn_start, :proc_defn_end, :proc_defn_indent, :proc_body_start, :proc_body_end, :inside_proc - - def initialize - # @proc_name_sym = proc_name.to_sym - @proc_arg_names = nil - # Beginning of the `#{proc_name} -> {...}` call - @proc_defn_start = nil - # End of the last `end/}` - @proc_defn_end = nil - # Amount of whitespace to insert to the rewritten body - @proc_defn_indent = nil - # First statement of the proc - @proc_body_start = nil - # End of last statement in the proc - @proc_body_end = nil - # Used for identifying the proper block - @inside_proc = false - end - end - - def on_send(node) - receiver, method_name, _args = *node - if method_name == @proc_name_sym && receiver.nil? - proc_section = ProcToMethodSection.new - source_exp = node.loc.expression - proc_section.proc_defn_start = source_exp.begin.begin_pos - proc_section.proc_defn_end = source_exp.end.end_pos - proc_section.proc_defn_indent = source_exp.column - proc_section.inside_proc = true - - @proc_to_method_sections << proc_section - end - res = super(node) - @inside_proc = false - res - end - - def on_block(node) - send_node, args_node, body_node = node.children - _receiver, method_name, _send_args_node = *send_node - if method_name == :lambda && !@proc_to_method_sections.empty? && @proc_to_method_sections[-1].inside_proc - proc_to_method_section = @proc_to_method_sections[-1] - - source_exp = body_node.loc.expression - proc_to_method_section.proc_arg_names = args_node.children.map { |arg_node| arg_node.children[0].to_s } - proc_to_method_section.proc_body_start = source_exp.begin.begin_pos - proc_to_method_section.proc_body_end = source_exp.end.end_pos - proc_to_method_section.inside_proc = false - end - super(node) - end - end - end - - class MutationResolveProcToMethodTransform < Transform - # @param proc_name [String] The name of the proc to be moved to `def self.#{proc_name}` - def initialize(proc_name: "resolve") - @proc_name = proc_name - end - - # TODO dedup with ResolveProcToMethodTransform - def apply(input_text) - if input_text =~ /GraphQL::Relay::Mutation\.define/ - named_proc_processor = apply_processor(input_text, ProcToClassMethodTransform::NamedProcProcessor.new(@proc_name)) - resolve_proc_processor = apply_processor(input_text, ResolveProcToMethodTransform::ResolveProcProcessor.new) - - named_proc_processor.proc_to_method_sections.zip(resolve_proc_processor.resolve_proc_sections).reverse.each do |pair| - proc_to_method_section, resolve_proc_section = *pair - proc_body = input_text[proc_to_method_section.proc_body_start..proc_to_method_section.proc_body_end] - method_defn_indent = " " * proc_to_method_section.proc_defn_indent - - obj_arg_name, args_arg_name, ctx_arg_name = resolve_proc_section.proc_arg_names - # This is not good, it will hit false positives - # Should use AST to make this substitution - if obj_arg_name != "_" - proc_body.gsub!(/([^\w:.]|^)#{obj_arg_name}([^\w:]|$)/, '\1object\2') - end - if ctx_arg_name != "_" - proc_body.gsub!(/([^\w:.]|^)#{ctx_arg_name}([^\w:]|$)/, '\1context\2') - end - - method_defn = "def #{@proc_name}(**#{args_arg_name})\n#{method_defn_indent} #{proc_body}\n#{method_defn_indent}end\n" - method_defn = trim_lines(method_defn) - # Update usage of args keys - method_defn = method_defn.gsub(/#{args_arg_name}(?\.key\?\(?|\[)["':](?[a-zA-Z0-9_]+)["']?(?\]|\))?/) do - method_begin = $~[:method_begin] - arg_name = underscorize($~[:arg_name]) - method_end = $~[:method_end] - "#{args_arg_name}#{method_begin}:#{arg_name}#{method_end}" - end - # replace the proc with the new method - input_text[proc_to_method_section.proc_defn_start..proc_to_method_section.proc_defn_end] = method_defn - end - end - input_text - end - end - - # Find hash literals which are returned from mutation resolves, - # and convert their keys to underscores. This catches a lot of cases but misses - # hashes which are initialized anywhere except in the return expression. - class UnderscorizeMutationHashTransform < Transform - def apply(input_text) - if input_text =~ /def resolve\(\*\*/ - processor = apply_processor(input_text, ReturnedHashLiteralProcessor.new) - # Use reverse_each to avoid messing up positions - processor.keys_to_upgrade.reverse_each do |key_data| - underscored_key = underscorize(key_data[:key].to_s) - if key_data[:operator] == ":" - input_text[key_data[:start]...key_data[:end]] = underscored_key - else - input_text[key_data[:start]...key_data[:end]] = ":#{underscored_key}" - end - end - end - input_text - end - - class ReturnedHashLiteralProcessor < Parser::AST::Processor - attr_reader :keys_to_upgrade - def initialize - @keys_to_upgrade = [] - end - - def on_def(node) - method_name, _args, body = *node - if method_name == :resolve - possible_returned_hashes = find_returned_hashes(body, returning: false) - possible_returned_hashes.each do |hash_node| - pairs = *hash_node - pairs.each do |pair_node| - if pair_node.type == :pair # Skip over :kwsplat - pair_k, _pair_v = *pair_node - if pair_k.type == :sym && pair_k.children[0].to_s =~ /[a-z][A-Z]/ # Does it have any camelcase boundaries? - source_exp = pair_k.loc.expression - @keys_to_upgrade << { - start: source_exp.begin.begin_pos, - end: source_exp.end.end_pos, - key: pair_k.children[0], - operator: pair_node.loc.operator.source, - } - end - end - end - end - end - - end - - private - - # Look for hash nodes, starting from `node`. - # Return hash nodes that are valid candiates for returning from this method. - def find_returned_hashes(node, returning:) - if node.is_a?(Array) - *possible_returns, last_expression = *node - return possible_returns.map { |c| find_returned_hashes(c, returning: false) }.flatten + - # Check the last expression of a method body - find_returned_hashes(last_expression, returning: returning) - end - - case node.type - when :hash - if returning - [node] - else - # This is some random hash literal - [] - end - when :begin - # Check the last expression of a method body - find_returned_hashes(node.children, returning: true) - when :resbody - _condition, _assign, body = *node - find_returned_hashes(body, returning: returning) - when :kwbegin - find_returned_hashes(node.children, returning: returning) - when :rescue - try_body, rescue_body, _ensure_body = *node - find_returned_hashes(try_body, returning: returning) + find_returned_hashes(rescue_body, returning: returning) - when :block - # Check methods with blocks for possible returns - method_call, _args, *body = *node - if method_call.type == :send - find_returned_hashes(body, returning: returning) - end - when :if - # Check each branch of a conditional - _condition, *branches = *node - branches.compact.map { |b| find_returned_hashes(b, returning: returning) }.flatten - when :return - find_returned_hashes(node.children.first, returning: true) - else - [] - end - rescue - p "--- UnderscorizeMutationHashTransform crashed on node: ---" - p node - raise - end - - end - end - - class ResolveProcToMethodTransform < Transform - def apply(input_text) - if input_text =~ /resolve\(? ?->/ - # - Find the proc literal - # - Get the three argument names (obj, arg, ctx) - # - Get the proc body - # - Find and replace: - # - The ctx argument becomes `context` - # - The obj argument becomes `object` - # - Args is trickier: - # - If it's not used, remove it - # - If it's used, abandon ship and make it `**args` - # - Convert string args access to symbol access, since it's a Ruby **splat - # - Convert camelized arg names to underscored arg names - # - (It would be nice to correctly become Ruby kwargs, but that might be too hard) - # - Add a `# TODO` comment to the method source? - # - Rebuild the method: - # - use the field name as the method name - # - handle args as described above - # - put the modified proc body as the method body - - input_text.match(/(?input_field|field|connection|argument) :(?[a-zA-Z_0-9_]*)/) - field_name = $~[:name] - processor = apply_processor(input_text, ResolveProcProcessor.new) - - processor.resolve_proc_sections.reverse.each do |resolve_proc_section| - proc_body = input_text[resolve_proc_section.proc_start..resolve_proc_section.proc_end] - obj_arg_name, args_arg_name, ctx_arg_name = resolve_proc_section.proc_arg_names - # This is not good, it will hit false positives - # Should use AST to make this substitution - if obj_arg_name != "_" - proc_body.gsub!(/([^\w:.]|^)#{obj_arg_name}([^\w:]|$)/, '\1object\2') - end - if ctx_arg_name != "_" - proc_body.gsub!(/([^\w:.]|^)#{ctx_arg_name}([^\w:]|$)/, '\1context\2') - end - - method_def_indent = " " * (resolve_proc_section.resolve_indent - 2) - # Turn the proc body into a method body - method_body = reindent_lines(proc_body, from_indent: resolve_proc_section.resolve_indent + 2, to_indent: resolve_proc_section.resolve_indent) - # Add `def... end` - method_def = if input_text.include?("argument ") - # This field has arguments - "def #{field_name}(**#{args_arg_name})" - else - # No field arguments, so, no method arguments - "def #{field_name}" - end - # Wrap the body in def ... end - method_body = "\n#{method_def_indent}#{method_def}\n#{method_body}\n#{method_def_indent}end\n" - # Update Argument access to be underscore and symbols - # Update `args[...]` and `args.key?` - method_body = method_body.gsub(/#{args_arg_name}(?\.key\?\(?|\[)["':](?[a-zA-Z0-9_]+)["']?(?\]|\))?/) do - method_begin = $~[:method_begin] - arg_name = underscorize($~[:arg_name]) - method_end = $~[:method_end] - "#{args_arg_name}#{method_begin}:#{arg_name}#{method_end}" - end - - # Replace the resolve proc with the method - input_text[resolve_proc_section.resolve_start..resolve_proc_section.resolve_end] = "" - # The replacement above might have left some preceeding whitespace, - # so remove it by deleting all whitespace chars before `resolve`: - preceeding_whitespace = resolve_proc_section.resolve_start - 1 - while input_text[preceeding_whitespace] == " " && preceeding_whitespace > 0 - input_text[preceeding_whitespace] = "" - preceeding_whitespace -= 1 - end - input_text += method_body - input_text - end - end - - input_text - end - - class ResolveProcProcessor < Parser::AST::Processor - attr_reader :resolve_proc_sections - def initialize - @resolve_proc_sections = [] - end - - class ResolveProcSection - attr_accessor :proc_start, :proc_end, :proc_arg_names, :resolve_start, :resolve_end, :resolve_indent - def initialize - @proc_arg_names = nil - @resolve_start = nil - @resolve_end = nil - @resolve_indent = nil - @proc_start = nil - @proc_end = nil - end - end - - def on_send(node) - receiver, method_name, _args = *node - if method_name == :resolve && receiver.nil? - resolve_proc_section = ResolveProcSection.new - source_exp = node.loc.expression - resolve_proc_section.resolve_start = source_exp.begin.begin_pos - resolve_proc_section.resolve_end = source_exp.end.end_pos - resolve_proc_section.resolve_indent = source_exp.column - - @resolve_proc_sections << resolve_proc_section - end - super(node) - end - - def on_block(node) - send_node, args_node, body_node = node.children - _receiver, method_name, _send_args_node = *send_node - # Assume that the first three-argument proc we enter is the resolve - if ( - method_name == :lambda && args_node.children.size == 3 && - !@resolve_proc_sections.empty? && @resolve_proc_sections[-1].proc_arg_names.nil? - ) - resolve_proc_section = @resolve_proc_sections[-1] - source_exp = body_node.loc.expression - resolve_proc_section.proc_arg_names = args_node.children.map { |arg_node| arg_node.children[0].to_s } - resolve_proc_section.proc_start = source_exp.begin.begin_pos - resolve_proc_section.proc_end = source_exp.end.end_pos - end - super(node) - end - end - end - - # Transform `interfaces [A, B, C]` to `implements A\nimplements B\nimplements C\n` - class InterfacesToImplementsTransform < Transform - PATTERN = /(?\s*)(?:interfaces) \[\s*(?(?:[a-zA-Z_0-9:\.,\s]+))\]/m - def apply(input_text) - input_text.gsub(PATTERN) do - indent = $~[:indent] - interfaces = $~[:interfaces].split(',').map(&:strip).reject(&:empty?) - # Preserve leading newlines before the `interfaces ...` - # call, but don't re-insert them between `implements` calls. - extra_leading_newlines = "\n" * (indent[/^\n*/].length - 1) - indent = indent.sub(/^\n*/m, "") - interfaces_calls = interfaces - .map { |interface| "\n#{indent}implements #{interface}" } - .join - extra_leading_newlines + interfaces_calls - end - end - end - - # Transform `possible_types [A, B, C]` to `possible_types(A, B, C)` - class PossibleTypesTransform < Transform - PATTERN = /(?\s*)(?:possible_types) \[\s*(?(?:[a-zA-Z_0-9:\.,\s]+))\]/m - def apply(input_text) - input_text.gsub(PATTERN) do - indent = $~[:indent] - possible_types = $~[:possible_types].split(',').map(&:strip).reject(&:empty?) - extra_leading_newlines = indent[/^\n*/] - method_indent = indent.sub(/^\n*/m, "") - type_indent = " " + method_indent - possible_types_call = "#{method_indent}possible_types(\n#{possible_types.map { |t| "#{type_indent}#{t},"}.join("\n")}\n#{method_indent})" - extra_leading_newlines + trim_lines(possible_types_call) - end - end - end - - class UpdateMethodSignatureTransform < Transform - def apply(input_text) - input_text.scan(/(?:input_field|field|return_field|connection|argument) .*$/).each do |field| - matches = /(?input_field|return_field|field|connection|argument) :(?[a-zA-Z_0-9_]*)?(:?, +(?([A-Za-z\[\]\.\!_0-9\(\)]|::|-> ?\{ ?| ?\})+))?(?( |,|$).*)/.match(field) - if matches - name = matches[:name] - return_type = matches[:return_type] - remainder = matches[:remainder] - field_type = matches[:field_type] - with_block = remainder.gsub!(/\ do$/, '') - - remainder.gsub! /,$/, '' - remainder.gsub! /^,/, '' - remainder.chomp! - - if return_type - non_nullable = return_type.sub! /(^|[^\[])!/, '\1' - non_nullable ||= return_type.sub! /([^\[])\.to_non_null_type([^\]]|$)/, '\1' - nullable = !non_nullable - return_type = normalize_type_expression(return_type) - else - non_nullable = nil - nullable = nil - end - - input_text.sub!(field) do - is_argument = ['argument', 'input_field'].include?(field_type) - f = "#{is_argument ? 'argument' : 'field'} :#{name}" - - if return_type - f += ", #{return_type}" - end - - unless remainder.empty? - f += ',' + remainder - end - - if is_argument - if nullable - f += ', required: false' - elsif non_nullable - f += ', required: true' - end - else - if nullable - f += ', null: true' - elsif non_nullable - f += ', null: false' - end - end - - if field_type == 'connection' - f += ', connection: true' - end - - if with_block - f += ' do' - end - - f - end - end - end - - input_text - end - end - - class RemoveEmptyBlocksTransform < Transform - def apply(input_text) - input_text.gsub(/\s*do\s*end/m, "") - end - end - - # Remove redundant newlines, which may have trailing spaces - # Remove double newline after `do` - # Remove double newline before `end` - # Remove lines with whitespace only - class RemoveExcessWhitespaceTransform < Transform - def apply(input_text) - input_text - .gsub(/\n{3,}/m, "\n\n") - .gsub(/do\n{2,}/m, "do\n") - .gsub(/\n{2,}(\s*)end/m, "\n\\1end") - .gsub(/\n +\n/m, "\n\n") - end - end - - # Skip this file if you see any `field` - # helpers with `null: true` or `null: false` keywords - # or `argument` helpers with `required:` keywords, - # because it's already been transformed - class SkipOnNullKeyword - def skip?(input_text) - input_text =~ /field.*null: (true|false)/ || input_text =~ /argument.*required: (true|false)/ - end - end - - class Member - def initialize(member, skip: SkipOnNullKeyword, type_transforms: DEFAULT_TYPE_TRANSFORMS, field_transforms: DEFAULT_FIELD_TRANSFORMS, clean_up_transforms: DEFAULT_CLEAN_UP_TRANSFORMS) - GraphQL::Deprecation.warn "#{self.class} will be removed from GraphQL-Ruby 2.0 (but there's no point in using it after you've transformed your code, anyways)" - @member = member - @skip = skip - @type_transforms = type_transforms - @field_transforms = field_transforms - @clean_up_transforms = clean_up_transforms - end - - DEFAULT_TYPE_TRANSFORMS = [ - TypeDefineToClassTransform, - MutationResolveProcToMethodTransform, # Do this before switching to class, so we can detect that its a mutation - UnderscorizeMutationHashTransform, - MutationDefineToClassTransform, - NameTransform, - InterfacesToImplementsTransform, - PossibleTypesTransform, - ProcToClassMethodTransform.new("coerce_input"), - ProcToClassMethodTransform.new("coerce_result"), - ProcToClassMethodTransform.new("resolve_type"), - ] - - DEFAULT_FIELD_TRANSFORMS = [ - RemoveNewlinesTransform, - RemoveMethodParensTransform, - PositionalTypeArgTransform, - ConfigurationToKwargTransform.new(kwarg: "property"), - ConfigurationToKwargTransform.new(kwarg: "description"), - ConfigurationToKwargTransform.new(kwarg: "deprecation_reason"), - ConfigurationToKwargTransform.new(kwarg: "hash_key"), - PropertyToMethodTransform, - UnderscoreizeFieldNameTransform, - ResolveProcToMethodTransform, - UpdateMethodSignatureTransform, - RemoveRedundantKwargTransform.new(kwarg: "hash_key"), - RemoveRedundantKwargTransform.new(kwarg: "method"), - ] - - DEFAULT_CLEAN_UP_TRANSFORMS = [ - RemoveExcessWhitespaceTransform, - RemoveEmptyBlocksTransform, - ] - - def upgrade - type_source = @member.dup - should_skip = @skip.new.skip?(type_source) - # return the unmodified code - if should_skip - return type_source - end - # Transforms on type defn code: - type_source = apply_transforms(type_source, @type_transforms) - # Transforms on each field: - field_sources = find_fields(type_source) - field_sources.each do |field_source| - transformed_field_source = apply_transforms(field_source.dup, @field_transforms) - # Replace the original source code with the transformed source code: - type_source = type_source.gsub(field_source, transformed_field_source) - end - # Clean-up: - type_source = apply_transforms(type_source, @clean_up_transforms) - # Return the transformed source: - type_source - end - - def upgradeable? - return false if @member.include? '< GraphQL::Schema::' - return false if @member =~ /< Types::Base#{GRAPHQL_TYPES}/ - - true - end - - private - - def apply_transforms(source_code, transforms, idx: 0) - next_transform = transforms[idx] - case next_transform - when nil - # We got to the end of the list - source_code - when Class - # Apply a class - next_source_code = next_transform.new.apply(source_code) - apply_transforms(next_source_code, transforms, idx: idx + 1) - else - # Apply an already-initialized object which responds to `apply` - next_source_code = next_transform.apply(source_code) - apply_transforms(next_source_code, transforms, idx: idx + 1) - end - end - - # Parse the type, find calls to `field` and `connection` - # Return strings containing those calls - def find_fields(type_source) - type_ast = Parser::CurrentRuby.parse(type_source) - finder = FieldFinder.new - finder.process(type_ast) - field_sources = [] - # For each of the locations we found, extract the text for that definition. - # The text will be transformed independently, - # then the transformed text will replace the original text. - FieldFinder::DEFINITION_METHODS.each do |def_method| - finder.locations[def_method].each do |name, (starting_idx, ending_idx)| - field_source = type_source[starting_idx..ending_idx] - field_sources << field_source - end - end - # Here's a crazy thing: the transformation is pure, - # so definitions like `argument :id, types.ID` can be transformed once - # then replaced everywhere. So: - # - make a unique array here - # - use `gsub` after performing the transformation. - field_sources.uniq! - field_sources - rescue Parser::SyntaxError - puts "Error Source:" - puts type_source - raise - end - - class FieldFinder < Parser::AST::Processor - # These methods are definition DSLs which may accept a block, - # each of these definitions is passed for transformation in its own right. - # `field` and `connection` take priority. In fact, they upgrade their - # own arguments, so those upgrades turn out to be no-ops. - DEFINITION_METHODS = [:field, :connection, :input_field, :return_field, :argument] - attr_reader :locations - - def initialize - # Pairs of `{ { method_name => { name => [start, end] } }`, - # since fields/arguments are unique by name, within their category - @locations = Hash.new { |h,k| h[k] = {} } - end - - # @param send_node [node] The node which might be a `field` call, etc - # @param source_node [node] The node whose source defines the bounds of the definition (eg, the surrounding block) - def add_location(send_node:,source_node:) - receiver_node, method_name, *arg_nodes = *send_node - # Implicit self and one of the recognized methods - if receiver_node.nil? && DEFINITION_METHODS.include?(method_name) - name = arg_nodes[0] - # This field may have already been added because - # we find `(block ...)` nodes _before_ we find `(send ...)` nodes. - if @locations[method_name][name].nil? - starting_idx = source_node.loc.expression.begin.begin_pos - ending_idx = source_node.loc.expression.end.end_pos - @locations[method_name][name] = [starting_idx, ending_idx] - end - end - end - - def on_block(node) - send_node, _args_node, _body_node = *node - add_location(send_node: send_node, source_node: node) - super(node) - end - - def on_send(node) - add_location(send_node: node, source_node: node) - super(node) - end - end - end - end -end diff --git a/lib/graphql/upgrader/schema.rb b/lib/graphql/upgrader/schema.rb deleted file mode 100644 index 58020cb7cea..00000000000 --- a/lib/graphql/upgrader/schema.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module GraphQL - module Upgrader - class Schema - def initialize(schema) - GraphQL::Deprecation.warn "#{self.class} will be removed from GraphQL-Ruby 2.0 (but there's no point in using it after you've transformed your code, anyways)" - @schema = schema - end - - def upgrade - transformable = schema.dup - - transformable.sub!( - /([a-zA-Z_0-9]*) = GraphQL::Schema\.define do/, 'class \1 < GraphQL::Schema' - ) - - transformable.sub!( - /object_from_id ->\s?\((.*)\) do/, 'def self.object_from_id(\1)' - ) - - transformable.sub!( - /resolve_type ->\s?\((.*)\) do/, 'def self.resolve_type(\1)' - ) - - transformable.sub!( - /id_from_object ->\s?\((.*)\) do/, 'def self.id_from_object(\1)' - ) - - transformable - end - - private - - attr_reader :schema - end - end -end diff --git a/lib/graphql/version.rb b/lib/graphql/version.rb index 3d6cc3c4344..4a540b505a8 100644 --- a/lib/graphql/version.rb +++ b/lib/graphql/version.rb @@ -1,4 +1,4 @@ # frozen_string_literal: true module GraphQL - VERSION = "1.12.13" + VERSION = "2.6.7" end diff --git a/readme.md b/readme.md index c3217121b06..b74b2490094 100644 --- a/readme.md +++ b/readme.md @@ -7,7 +7,7 @@ A Ruby implementation of [GraphQL](https://graphql.org/). - [Website](https://graphql-ruby.org/) - [API Documentation](https://www.rubydoc.info/github/rmosolgo/graphql-ruby) -- [Newsletter](https://tinyletter.com/graphql-ruby) +- [Newsletter](https://buttondown.email/graphql-ruby) ## Installation @@ -34,7 +34,17 @@ Or, see ["Getting Started"](https://graphql-ruby.org/getting_started.html). ## Upgrade -I also sell [GraphQL::Pro](https://graphql.pro) which provides several features on top of the GraphQL runtime, including [Pundit authorization](https://graphql-ruby.org/authorization/pundit_integration), [CanCan authorization](https://graphql-ruby.org/authorization/can_can_integration), [Pusher-based subscriptions](https://graphql-ruby.org/subscriptions/pusher_implementation) and [persisted queries](https://graphql-ruby.org/operation_store/overview). Besides that, Pro customers get email support and an opportunity to support graphql-ruby's development! +I also sell [GraphQL::Pro](https://graphql.pro) which provides several features on top of the GraphQL runtime, including: + +- [Persisted queries](https://graphql-ruby.org/operation_store/overview) +- [API versioning](https://graphql-ruby.org/changesets/overview) +- [Streaming payloads](https://graphql-ruby.org/defer/overview) +- [Server-side caching](https://graphql-ruby.org/object_cache/overview) +- [Rate limiters](https://graphql-ruby.org/limiters/overview) +- Subscriptions backends for [Pusher](https://graphql-ruby.org/subscriptions/pusher_implementation) and [Ably](https://graphql-ruby.org/subscriptions/ably_implementation) +- Authorization plugins for [Pundit](https://graphql-ruby.org/authorization/pundit_integration) and [CanCan](https://graphql-ruby.org/authorization/can_can_integration) + +Besides that, Pro customers get email support and an opportunity to support graphql-ruby's development! ## Goals @@ -44,6 +54,6 @@ I also sell [GraphQL::Pro](https://graphql.pro) which provides several features ## Getting Involved -- __Say hi & ask questions__ in the [#ruby channel on Slack](https://graphql-slack.herokuapp.com/) or [on Twitter](https://twitter.com/rmosolgo)! +- __Say hi & ask questions__ in the #graphql-ruby channel on [Discord](https://discord.com/invite/xud7bH9). - __Report bugs__ by posting a description, full stack trace, and all relevant code in a [GitHub issue](https://github.com/rmosolgo/graphql-ruby/issues). - __Start hacking__ with the [Development guide](https://graphql-ruby.org/development). diff --git a/spec/dummy/Gemfile b/spec/dummy/Gemfile deleted file mode 100644 index d90902110e1..00000000000 --- a/spec/dummy/Gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true -source 'https://rubygems.org' - -gem 'bootsnap' -gem 'rails', '~> 5.2.1' -gem 'puma' -gem 'capybara', '3.34.0' -gem 'selenium-webdriver' -gem 'graphql', path: File.expand_path('../../', __dir__) - -group :development do - gem 'listen' -end - -gem "webdrivers", "~> 4.1" diff --git a/spec/dummy/README.md b/spec/dummy/README.md deleted file mode 100644 index 7db80e4ca1b..00000000000 --- a/spec/dummy/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# README - -This README would normally document whatever steps are necessary to get the -application up and running. - -Things you may want to cover: - -* Ruby version - -* System dependencies - -* Configuration - -* Database creation - -* Database initialization - -* How to run the test suite - -* Services (job queues, cache servers, search engines, etc.) - -* Deployment instructions - -* ... diff --git a/spec/dummy/app/assets/javascripts/application.js b/spec/dummy/app/assets/javascripts/application.js index 372d200dee1..8a71c04fa85 100644 --- a/spec/dummy/app/assets/javascripts/application.js +++ b/spec/dummy/app/assets/javascripts/application.js @@ -10,7 +10,6 @@ // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details // about supported directives. // -//= require rails-ujs // Action Cable provides the framework to deal with WebSockets in Rails. // You can generate new channels where WebSocket features live using the `rails generate channel` command. // @@ -26,9 +25,9 @@ var query = options.query var variables = options.variables var receivedCallback = options.received - // Unique-ish - var uuid = Math.round(Date.now() + Math.random() * 100000).toString(16) - return { + var uuid = crypto.randomUUID() + var subscription = { + _subscribed: false, subscription: App.cable.subscriptions.create({ channel: "GraphqlChannel", id: uuid, @@ -40,18 +39,49 @@ }) console.log("Connected", query, variables) }, - received: function(data) { - console.log("received", query, variables, data) - receivedCallback(data) + received: function(payload) { + subscription._subscribed = true + App.logToBody("ActionCable received: " + JSON.stringify(payload)) + if (payload.result) { + receivedCallback(payload) + } + if (!payload.more) { + this.unsubscribe() + App.logToBody("Remaining ActionCable subscriptions: " + App.cable.subscriptions.subscriptions.length) + } } } ), trigger: function(options) { - this.subscription.perform("make_trigger", options) + if (!subscription._subscribed) { + options.retries ||= 0 + options.retries++ + if (options.retries > 5) { + throw new Error("Retried 5 times, failed to trigger: " + JSON.stringify(options)) + } else { + App.logToBody("Retrying trigger " + options.retries + " : " + JSON.stringify(options)) + setTimeout(function() { + subscription.trigger(options) + }, 500) + } + } else { + App.logToBody("Triggering " + JSON.stringify(options)) + this.subscription.perform("make_trigger", options) + } }, unsubscribe: function() { this.subscription.unsubscribe() }, } + return subscription + } + + // Add `text` to the HTML body, for debugging + App.logToBody = function(text) { + var bodyLog = document.getElementById("body-log") + var logEntry = document.createElement("p") + logEntry.innerText = text + bodyLog.appendChild(logEntry) + bodyLog.append("\n") } }).call(this); diff --git a/spec/dummy/app/channels/graphql_channel.rb b/spec/dummy/app/channels/graphql_channel.rb index ae0f807c1ae..03a664fcea8 100644 --- a/spec/dummy/app/channels/graphql_channel.rb +++ b/spec/dummy/app/channels/graphql_channel.rb @@ -12,23 +12,33 @@ class PayloadType < GraphQL::Schema::Object end class CounterIncremented < GraphQL::Schema::Subscription - @@call_count = 0 - subscription_scope :subscriber_id + def self.reset_call_count + @@call_count = 0 + end + + reset_call_count field :new_value, Integer, null: false def update + if object + if object.value == "server-unsubscribe" + unsubscribe + elsif object.value == "server-unsubscribe-with-message" + unsubscribe({ new_value: 9999 }) + end + end result = { new_value: @@call_count += 1 } - puts " -> CounterIncremented#update(#{context[:subscriber_id]}): #{result}" + puts " -> CounterIncremented#update: #{result}" result end end class SubscriptionType < GraphQL::Schema::Object field :payload, PayloadType, null: false do - argument :id, ID, required: true + argument :id, ID end field :counter_incremented, subscription: CounterIncremented @@ -77,12 +87,12 @@ def execute(data) } puts "[GraphQLSchema.execute] #{query} || #{variables}" - result = GraphQLSchema.execute({ + result = GraphQLSchema.execute( query: query, context: context, variables: variables, operation_name: operation_name - }) + ) payload = { result: result.to_h, diff --git a/spec/dummy/app/graphql/dummy_schema.rb b/spec/dummy/app/graphql/dummy_schema.rb new file mode 100644 index 00000000000..9ab1188e439 --- /dev/null +++ b/spec/dummy/app/graphql/dummy_schema.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +begin + require "graphql-pro" +rescue LoadError => err + puts "Skipping GraphQL::Pro: #{err.message}" +end +class DummySchema < GraphQL::Schema + class Query < GraphQL::Schema::Object + field :str, String, fallback_value: "hello" + + field :sleep, Float do + argument :seconds, Float + end + + def sleep(seconds:) + Kernel.sleep(seconds) + seconds + end + end + query(Query) + + class Subscription < GraphQL::Schema::Object + field :message, String do + argument :channel, String + end + end + subscription(Subscription) + + DB_NUMBER = Rails.env.test? ? 1 : 2 + use GraphQL::Tracing::DetailedTrace, redis: Redis.new(db: DB_NUMBER) + + if defined?(GraphQL::Pro) + use GraphQL::Pro::OperationStore, redis: Redis.new(db: DB_NUMBER) + use GraphQL::Pro::PusherSubscriptions, redis: Redis.new(db: DummySchema::DB_NUMBER), pusher: MockPusher.new + class KeyNotRequiredLimiter < GraphQL::Enterprise::RuntimeLimiter + def limiter_key(query) + query. + context[:limiter_key] || "unlimited" + end + + def limit_for(key, query) + key == "unlimited" ? nil : super + end + end + + use KeyNotRequiredLimiter, + redis: Redis.new(db: DummySchema::DB_NUMBER), + limit_ms: 100 + end + + def self.detailed_trace?(query) + query.context[:profile] + end +end + +# To preview rate limiter +# puts "Making Rate-limited requests..." +# 3.times.map do +# pp DummySchema.execute("{ sleep(seconds: 0.02) }", context: { limiter_key: "client-1" }).to_h +# end + +# 3.times.map do +# pp DummySchema.execute("{ sleep(seconds: 0.110) }", context: { limiter_key: "client-2" }).to_h +# end +# puts " ... done" + +# To preview subscription data in the dashboard: +# DummySchema.subscriptions.clear +# res1 = DummySchema.execute("subscription { message(channel: \"cats\") }") +# res2 = DummySchema.execute("subscription { message(channel: \"dogs\") }") +# DummySchema.subscriptions.trigger(:message, { channel: "cats" }, "meow") diff --git a/spec/dummy/app/graphql/mock_pusher.rb b/spec/dummy/app/graphql/mock_pusher.rb new file mode 100644 index 00000000000..6d9b2742f4d --- /dev/null +++ b/spec/dummy/app/graphql/mock_pusher.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true +class MockPusher + class Channel + attr_reader :id, :occupants + + def initialize(id) + @id = id + @occupants = 0 + @inboxes = [[]] + end + + def trigger(event_name, payload) + if event_name != "update" + raise "Invariant: GraphQL is only expected to call update, but received #{event_name.inspect}. Fix tests or implementation." + end + @inboxes.each do |ibx| + ibx << payload + end + nil + end + + def new_inbox + ibx = [] + @inboxes << ibx + ibx + end + + def updates + @inboxes[0] + end + + def occupant_left + @occupants -= 1 + if @occupants < 0 + raise "Invariant: less than 0 occupants for #{self.inspect}" + end + nil + end + + def occupant_entered + @occupants += 1 + nil + end + + def occupied? + @occupants > 0 + end + end + + def initialize + @channels = Hash.new { |h, k| h[k] = Channel.new(k) } + @key = "abcdef" + @secret = "12345" + @batch_sizes = [] + end + + attr_reader :key, :secret, :batch_sizes + + # Mock pusher: + def channel_info(channel_name, info: "") + channel = @channels[channel_name] + res = { occupied: channel.occupied? } + if info.include?("subscription_count") + res[:subscription_count] = channel.occupants + end + res + end + + def trigger(channel_name, action, payload) + @channels[channel_name].trigger(action, payload) + end + + def trigger_batch(triggers) + @batch_sizes << triggers.size + triggers.each do |trigger| + @channels[trigger[:channel]].trigger(trigger[:name], trigger[:data]) + end + end + + # Testing: + def channel(channel_name) + @channels[channel_name] + end +end diff --git a/spec/dummy/app/graphql/not_installed_schema.rb b/spec/dummy/app/graphql/not_installed_schema.rb new file mode 100644 index 00000000000..24e7beebdef --- /dev/null +++ b/spec/dummy/app/graphql/not_installed_schema.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class NotInstalledSchema < GraphQL::Schema + class Query < GraphQL::Schema::Object + field :str, String, fallback_value: "hello" + end + + query(Query) +end diff --git a/spec/dummy/app/views/layouts/application.html.erb b/spec/dummy/app/views/layouts/application.html.erb index 465617889dc..ff780611953 100644 --- a/spec/dummy/app/views/layouts/application.html.erb +++ b/spec/dummy/app/views/layouts/application.html.erb @@ -3,7 +3,9 @@ Dummy <%= csrf_meta_tags %> - <%= javascript_include_tag 'application' %> + <%= + javascript_include_tag 'application', host: "" # work around config.asset_host which is set to test dashboard + %> diff --git a/spec/dummy/app/views/pages/show.html b/spec/dummy/app/views/pages/show.html index ef065867f90..67c80fb909c 100644 --- a/spec/dummy/app/views/pages/show.html +++ b/spec/dummy/app/views/pages/show.html @@ -19,7 +19,9 @@

ActionCable Test Page

+ +
@@ -33,14 +35,6 @@

ActionCable Test Page

diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb index 3b82fb5084b..cb63d0237b0 100644 --- a/spec/dummy/config/application.rb +++ b/spec/dummy/config/application.rb @@ -17,14 +17,8 @@ module Dummy class Application < Rails::Application - # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 5.1 - - # Settings in config/environments/* take precedence over those specified here. - # Application configuration should go into files in config/initializers - # -- all .rb files in that directory are automatically loaded. - # Don't generate system test files. config.generators.system_tests = nil + config.asset_host = "http://some.cdn" end end diff --git a/spec/dummy/config/database.yml b/spec/dummy/config/database.yml new file mode 100644 index 00000000000..01bebb5087c --- /dev/null +++ b/spec/dummy/config/database.yml @@ -0,0 +1,32 @@ +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" +# +default: &default + adapter: sqlite3 + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: storage/test.sqlite3 + + +# SQLite3 write its data on the local filesystem, as such it requires +# persistent disks. If you are deploying to a managed service, you should +# make sure it provides disk persistence, as many don't. +# +# Similarly, if you deploy your application as a Docker container, you must +# ensure the database is located in a persisted volume. +production: + <<: *default + # database: path/to/persistent/storage/production.sqlite3 diff --git a/spec/dummy/config/initializers/graphql_dashboard.rb b/spec/dummy/config/initializers/graphql_dashboard.rb new file mode 100644 index 00000000000..8081bb79f20 --- /dev/null +++ b/spec/dummy/config/initializers/graphql_dashboard.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +ActiveSupport.on_load(:graphql_dashboard_application_controller) do + def self.hook_was_called? + true + end +end diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index b6b7bdc578b..41590eace4b 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true Rails.application.routes.draw do root to: "pages#show" + mount GraphQL::Dashboard, at: "/dash", schema: "DummySchema" end diff --git a/spec/dummy/test/application_system_test_case.rb b/spec/dummy/test/application_system_test_case.rb index 40415398eb1..acf86e36117 100644 --- a/spec/dummy/test/application_system_test_case.rb +++ b/spec/dummy/test/application_system_test_case.rb @@ -2,21 +2,5 @@ require "test_helper" class ApplicationSystemTestCase < ActionDispatch::SystemTestCase - driven_by :selenium, - using: :chrome, - screen_size: [1400, 1400], - options: { - args: ["headless", "disable-gpu", "no-sandbox", "disable-dev-shm-usage"] - } - - - teardown do - # Adapted from https://medium.com/@coorasse/catch-javascript-errors-in-your-system-tests-89c2fe6773b1 - errors = page.driver.browser.manage.logs.get(:browser) - if errors.present? - errors.each do |error| - assert_nil "#{error.level}: #{error.message}" - end - end - end + driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400] end diff --git a/spec/dummy/test/channels/graphql_channel_test.rb b/spec/dummy/test/channels/graphql_channel_test.rb new file mode 100644 index 00000000000..0ab88b9ede9 --- /dev/null +++ b/spec/dummy/test/channels/graphql_channel_test.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true +require "test_helper" + +class GraphqlChannelTest < ActionCable::Channel::TestCase + module RealChannelStub + def confirmed? + subscription_confirmation_sent? + end + + def real_streams + streams + end + end + + def assert_has_real_stream(stream_name) + assert subscription.real_streams.key?(stream_name), "Expected Stream #{stream_name.inspect} to be present in #{subscription.real_streams.keys}" + end + + def setup + @prev_server = ActionCable.server + @server = GraphqlTestServer.new(subscription_adapter: ActionCable::SubscriptionAdapter::Async) + @server.config.allowed_request_origins = [ 'http://rubyonrails.com' ] + + ActionCable.instance_variable_set(:@server, @server) + end + + def teardown + ActionCable.instance_variable_set(:@server, @prev_server) + end + + def wait_for_async + wait_for_executor Concurrent.global_io_executor + end + + def run_in_eventmachine + yield + wait_for_async + end + + def wait_for_executor(executor) + # do not wait forever, wait 2s + timeout = 2 + until executor.completed_task_count == executor.scheduled_task_count + sleep 0.1 + timeout -= 0.1 + raise "Executor could not complete all tasks in 2 seconds" unless timeout > 0 + end + end + + class GraphqlTestConnection < ActionCable::Connection::Base + public :handle_close, :socket + end + class GraphqlTestSocket < ActionCable::Connection::TestSocket + def transmit(msg) + intercepted_messages << msg + super + end + + def intercepted_messages + @intercepted_messages ||= [] + end + end + + test "it subscribes and unsubscribes" do + run_in_eventmachine do + socket = GraphqlTestSocket.new(GraphqlTestSocket.build_request("/graphql")) + + connection = GraphqlTestConnection.new(@server, socket) + connection.connect if connection.respond_to?(:connect) + + # Only set instance variable if connected successfully + @connection = connection + wait_for_async + + + @connection.subscriptions.add({"identifier" => "{\"channel\": \"GraphqlChannel\"}"}) + + @subscription = @connection.subscriptions.instance_variable_get(:@subscriptions).values.first + @subscription.singleton_class.prepend(RealChannelStub) + assert subscription.confirmed? + + subscription.execute({ + "query" => "subscription { payload(id: \"abc\") { value } }" + }) + wait_for_async + + sub_id = subscription.instance_variable_get(:@subscription_ids).first + subscription_stream = "graphql-subscription:#{sub_id}" + assert_has_real_stream subscription_stream + topic_stream = "graphql-event::payload:id:abc" + assert_has_real_stream topic_stream + + subscription.make_trigger({ "field" => "payload", "arguments" => { "id" => "abc"}, "value" => 19 }) + + wait_for_async + + @connection.handle_close + wait_for_async + + expected_data = [ + {identifier: "{\"channel\": \"GraphqlChannel\"}", type: "confirm_subscription"}, + {identifier: "{\"channel\": \"GraphqlChannel\"}", message: {result: {"data"=>{}}, more: true}}, + {identifier: "{\"channel\": \"GraphqlChannel\"}", message: {"result" => {"data"=>{"payload"=>{"value"=>19}}}, "more" => true}}, + {identifier: "{\"channel\": \"GraphqlChannel\"}", message: {"more" => false}}, + ] + + assert_equal expected_data, @connection.socket.intercepted_messages + end + end + + class GraphqlTestServer + include ActionCable::Server::Connections + include ActionCable::Server::Broadcasting + + attr_reader :logger, :config, :mutex + + class FakeConfiguration < ActionCable::Server::Configuration + attr_accessor :subscription_adapter, :log_tags, :filter_parameters + + def initialize(subscription_adapter:) + @log_tags = [] + @filter_parameters = [] + @subscription_adapter = subscription_adapter + end + + def pubsub_adapter + @subscription_adapter + end + end + + def initialize(subscription_adapter: SuccessAdapter) + @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + @config = FakeConfiguration.new(subscription_adapter: subscription_adapter) + @mutex = Monitor.new + end + + def pubsub + @pubsub ||= @config.subscription_adapter.new(self) + end + + def executor + self + end + + def post + yield + end + + def event_loop + @event_loop ||= ActionCable::Connection::StreamEventLoop.new.tap do |loop| + loop.instance_variable_set(:@executor, Concurrent.global_io_executor) + end + end + + def worker_pool + @worker_pool ||= ActionCable::Server::Worker.new(max_size: 5) + end + end +end diff --git a/spec/dummy/test/controllers/dashboard/application_controller_test.rb b/spec/dummy/test/controllers/dashboard/application_controller_test.rb new file mode 100644 index 00000000000..ba20b65bef6 --- /dev/null +++ b/spec/dummy/test/controllers/dashboard/application_controller_test.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +require "test_helper" + +class DashboardApplicationControllerTest < ActionDispatch::IntegrationTest + def test_it_calls_on_load_hook + assert_equal true, GraphQL::Dashboard::ApplicationController.hook_was_called? + end +end diff --git a/spec/dummy/test/controllers/dashboard/detailed_traces/traces_controller_test.rb b/spec/dummy/test/controllers/dashboard/detailed_traces/traces_controller_test.rb new file mode 100644 index 00000000000..c6c8ea2cf8e --- /dev/null +++ b/spec/dummy/test/controllers/dashboard/detailed_traces/traces_controller_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true +require "test_helper" + +class DashboardTracesControllerTest < ActionDispatch::IntegrationTest + def teardown + DummySchema.detailed_trace.delete_all_traces + end + + def test_it_renders_not_installed + get graphql_dashboard.detailed_traces_traces_path, params: { schema: "NotInstalledSchema" } + assert_includes response.body, CGI::escapeHTML("Detailed traces aren't installed yet") + assert_includes response.body, "NotInstalledSchema" + end + + def test_it_renders_blank_state + get graphql_dashboard.detailed_traces_traces_path + assert_includes response.body, "No traces saved yet." + assert_includes response.body, "DummySchema" + end + + def test_it_renders_trace_listing_with_pagination + 20.times do |n| + sleep 0.05 + DummySchema.execute("query Query#{n} { str }", context: { profile: true }) + end + assert_equal 20, DummySchema.detailed_trace.traces.size + + get graphql_dashboard.detailed_traces_traces_path, params: { last: 10 } + + assert_includes response.body, "Query19" + assert_includes response.body, "Query10" + refute_includes response.body, "Query9" + last_trace = DummySchema.detailed_trace.traces[9] + last_ts = last_trace.begin_ms + assert_includes response.body, "#{Time.at(last_ts / 1000.0).strftime("%Y-%m-%d %H:%M:%S.%L")}" + assert_includes response.body, "Previous >" + get graphql_dashboard.detailed_traces_traces_path, params: { last: 10, before: last_ts } + assert_includes response.body, "Query9" + assert_includes response.body, "Query0" + refute_includes response.body, "Query10" + very_last_trace = DummySchema.detailed_trace.traces.last + very_last_ts = very_last_trace.begin_ms + very_last_td = "#{Time.at(very_last_ts / 1000.0).strftime("%Y-%m-%d %H:%M:%S.%L")}" + assert_includes response.body, very_last_td + very_last_previous_link = "Previous >" + assert_includes response.body, very_last_previous_link + + # Go beyond last trace: + get graphql_dashboard.detailed_traces_traces_path, params: { last: 11, before: last_ts } + assert_includes response.body, very_last_td + refute_includes response.body, very_last_previous_link + end + + def test_it_deletes_one_trace + DummySchema.execute("{ str }", context: { profile: true }) + assert_equal 1, DummySchema.detailed_trace.traces.size + id = DummySchema.detailed_trace.traces.first.id + delete graphql_dashboard.detailed_traces_trace_path(id) + assert_equal 0, DummySchema.detailed_trace.traces.size + end + + def test_it_deletes_all_traces + DummySchema.execute("{ str }", context: { profile: true }) + assert_equal 1, DummySchema.detailed_trace.traces.size + delete graphql_dashboard.delete_all_detailed_traces_traces_path + assert_equal 0, DummySchema.detailed_trace.traces.size + end +end diff --git a/spec/dummy/test/controllers/dashboard/landings_controller_test.rb b/spec/dummy/test/controllers/dashboard/landings_controller_test.rb new file mode 100644 index 00000000000..f10f66d3f01 --- /dev/null +++ b/spec/dummy/test/controllers/dashboard/landings_controller_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +require "test_helper" + +class DashboardLandingsControllerTest < ActionDispatch::IntegrationTest + def test_it_doesnt_load_autoloads_files + result = `BUNDLE_GEMFILE=#{ENV["BUNDLE_GEMFILE"]} ruby ./test_autoloads.rb` + assert_includes result, "No autoloaded constants were found during the boot process." + end + + def test_it_shows_a_landing_page_with_local_static_asset_links + get graphql_dashboard.root_path + assert_includes response.body, "Welcome to the GraphQL-Ruby Dashboard" + assert_includes response.body, '', "it doesn't use config.asset_host" + end + + def test_it_shows_version_and_schema_info + get graphql_dashboard.root_path + assert_includes response.body, "GraphQL-Ruby v#{GraphQL::VERSION}" + assert_includes response.body, "DummySchema" + get graphql_dashboard.root_path, params: { schema: "NotInstalledSchema" } + assert_includes response.body, "NotInstalledSchema" + end +end diff --git a/spec/dummy/test/controllers/dashboard/limiters/limiters_controller_test.rb b/spec/dummy/test/controllers/dashboard/limiters/limiters_controller_test.rb new file mode 100644 index 00000000000..75752dac2b7 --- /dev/null +++ b/spec/dummy/test/controllers/dashboard/limiters/limiters_controller_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +require "test_helper" + +if defined?(GraphQL::Pro) + class DashboardLimitersLimitersControllerTest < ActionDispatch::IntegrationTest + def test_it_checks_installed + get graphql_dashboard.limiters_limiter_path("runtime", { schema: "GraphQL::Schema" }) + assert_includes response.body, CGI::escapeHTML("Rate limiters aren't installed on this schema yet.") + refute_includes response.headers["Content-Security-Policy"], "nonce-" + end + + def test_it_shows_limiters + Redis.new(db: DummySchema::DB_NUMBER).flushdb + + 3.times do + DummySchema.execute("{ sleep(seconds: 0.02) }", context: { limiter_key: "client-1" }).to_h + end + 4.times do + DummySchema.execute("{ sleep(seconds: 0.110) }", context: { limiter_key: "client-2" }).to_h + end + + get graphql_dashboard.limiters_limiter_path("runtime") + assert_includes response.body, "4" + assert_includes response.body, "3" + assert_includes response.body, "Disable Soft Limiting" + assert_includes response.headers["Content-Security-Policy"], "nonce-" + + patch graphql_dashboard.limiters_limiter_path("runtime") + get graphql_dashboard.limiters_limiter_path("runtime") + assert_includes response.body, "Enable Soft Limiting" + + get graphql_dashboard.limiters_limiter_path("active_operations") + assert_includes response.body, "It looks like this limiter isn't installed yet." + + end + end +end diff --git a/spec/dummy/test/controllers/dashboard/operation_store/clients_controller_test.rb b/spec/dummy/test/controllers/dashboard/operation_store/clients_controller_test.rb new file mode 100644 index 00000000000..1bfea866d44 --- /dev/null +++ b/spec/dummy/test/controllers/dashboard/operation_store/clients_controller_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true +require "test_helper" + +if defined?(GraphQL::Pro) + class DashboardOperationStoreClientsControllerTest < ActionDispatch::IntegrationTest + def test_it_manages_clients + assert_equal 0, DummySchema.operation_store.all_clients(page: 1, per_page: 1).total_count + get graphql_dashboard.operation_store_clients_path + assert_includes response.body, "0 Clients" + assert_includes response.body, "To get started, create" + + get graphql_dashboard.new_operation_store_client_path + assert_includes response.body, "New Client" + + post graphql_dashboard.operation_store_clients_path, params: { + client: { + name: "client-1", + secret: "abcdefedcba" + } + } + + get graphql_dashboard.operation_store_clients_path + assert_includes response.body, "1 Client" + + get graphql_dashboard.edit_operation_store_client_path(name: "client-1") + assert_includes response.body, "abcdefedcba" + + patch graphql_dashboard.operation_store_client_path(name: "client-1"), params: { client: { secret: "123456789" } } + get graphql_dashboard.edit_operation_store_client_path(name: "client-1") + assert_includes response.body, "123456789" + + delete graphql_dashboard.operation_store_client_path(name: "client-1") + assert_equal 0, DummySchema.operation_store.all_clients(page: 1, per_page: 1).total_count + ensure + DummySchema.operation_store.delete_client("client-1") + end + + def test_it_paginates + 5.times do |i| + DummySchema.operation_store.upsert_client("client-#{i}", "abcdef") + end + get graphql_dashboard.operation_store_clients_path(per_page: 2) + assert_includes response.body, "5 Clients" + assert_includes response.body, "?page=2&per_page=2" + assert_includes response.body, "disabled>« prev" + + get graphql_dashboard.operation_store_clients_path(per_page: 2, page: 2) + assert_includes response.body, "?page=1&per_page=2" + assert_includes response.body, "?page=3&per_page=2" + + get graphql_dashboard.operation_store_clients_path(per_page: 2, page: 3) + assert_includes response.body, "disabled>next »" + assert_includes response.body, "?page=2&per_page=2" + ensure + 5.times do |i| + DummySchema.operation_store.delete_client("client-#{i}") + end + end + + def test_it_checks_installed + get graphql_dashboard.new_operation_store_client_path, params: { schema: GraphQL::Schema } + assert_includes response.body, "isn't installed for this schema yet" + end + end +end diff --git a/spec/dummy/test/controllers/dashboard/operation_store/index_entries_controller_test.rb b/spec/dummy/test/controllers/dashboard/operation_store/index_entries_controller_test.rb new file mode 100644 index 00000000000..ed4ce016e14 --- /dev/null +++ b/spec/dummy/test/controllers/dashboard/operation_store/index_entries_controller_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +require "test_helper" + +if defined?(GraphQL::Pro) + class DashboardOperationStoreIndexEntriesControllerTest < ActionDispatch::IntegrationTest + def test_it_shows_entries + DummySchema.operation_store.upsert_client("client-1", "abcdef") + DummySchema.operation_store.add(body: "query GetTypename { __type(name: \"Query\") { name @skip(if: true) } }", operation_alias: "GetTypename", client_name: "client-1") + + get graphql_dashboard.operation_store_index_entries_path + assert_includes response.body, "Query.__type.name" + assert_includes response.body, "7 entries" + + get graphql_dashboard.operation_store_index_entries_path(q: "Query") + assert_includes response.body, "3 results" + assert_includes response.body, ">Query" + assert_includes response.body, ">Query.__type" + assert_includes response.body, ">Query.__type.name" + + get graphql_dashboard.operation_store_index_entries_path(q: "Query", per_page: 1, page: 2) + assert_includes response.body, "3 results" + refute_includes response.body, ">Query" + assert_includes response.body, ">Query.__type" + refute_includes response.body, ">Query.__type.name" + + get graphql_dashboard.operation_store_index_entry_path(name: "Query.__type.name") + assert_includes response.body, "GetTypename" + ensure + DummySchema.operation_store.delete_client("client-1") + end + end +end diff --git a/spec/dummy/test/controllers/dashboard/operation_store/operations_controller_test.rb b/spec/dummy/test/controllers/dashboard/operation_store/operations_controller_test.rb new file mode 100644 index 00000000000..fc458ccc2d3 --- /dev/null +++ b/spec/dummy/test/controllers/dashboard/operation_store/operations_controller_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true +require "test_helper" + +if defined?(GraphQL::Pro) + class DashboardOperationStoreOperationsControllerTest < ActionDispatch::IntegrationTest + def teardown + DummySchema.operation_store.delete_client("client-1") + DummySchema.operation_store.delete_client("client-2") + super + end + def test_it_lists_shows_and_archives_operations + get graphql_dashboard.operation_store_operations_path + assert_includes response.body, "Add your first stored operations with" + + get graphql_dashboard.operation_store_operations_path(client_name: "client-5000") + assert_includes response.body, "Add your first stored operations with" + + get graphql_dashboard.archived_operation_store_operations_path + assert_includes response.body, "Archived operations will appear here." + + get graphql_dashboard.archived_operation_store_client_operations_path(client_name: "client-5000") + assert_includes response.body, "Archived operations will appear here." + + os = DummySchema.operation_store + os.upsert_client("client-1", "abcdef") + os.add(body: "query GetTypename { __typename }", operation_alias: "GetTypename", client_name: "client-1") + os.add(body: "query GetAliasedTypename { t: __typename }", operation_alias: "get-aliased-typename", client_name: "client-1") + + os.upsert_client("client-2", "abcdef") + os.add(body: "query GetTypename { __typename }", operation_alias: "GetTypename2", client_name: "client-2") + + get graphql_dashboard.operation_store_operations_path + assert_includes response.body, "2 Active" + assert_includes response.body, "GetTypename" + assert_includes response.body, "GetAliasedTypename" + + get graphql_dashboard.operation_store_operations_path(sort_by: "name", order_dir: "asc", per_page: 1) + refute_includes response.body, "GetTypename" + assert_includes response.body, "GetAliasedTypename" + + get graphql_dashboard.operation_store_operations_path(sort_by: "name", order_dir: "desc", per_page: 1) + assert_includes response.body, "GetTypename" + refute_includes response.body, "GetAliasedTypename" + + get graphql_dashboard.operation_store_operations_path(client_name: "client-2") + assert_includes response.body, "1 Active" + assert_includes response.body, "GetTypename" + refute_includes response.body, "GetAliasedTypename" + + get graphql_dashboard.operation_store_operation_path(digest: "4cd12cc333c91f78e8f781933ecc783d") + assert_includes response.body, "GetAliasedTypename" + assert_includes response.body, "client-1" + assert_includes response.body, "Query.__typename" + + post graphql_dashboard.archive_operation_store_client_operations_path(client_name: "client-1", operation_aliases: ["get-aliased-typename"]) + post graphql_dashboard.archive_operation_store_operations_path(digests: ["b161214b11847649e7f36cc50e1257a1"]) + + get graphql_dashboard.operation_store_operations_path + assert_includes response.body, "0 Active" + assert_includes response.body, "2 Archived" + + get graphql_dashboard.archived_operation_store_operations_path + assert_includes response.body, "2 Archived" + assert_includes response.body, "0 Active" + + get graphql_dashboard.operation_store_operations_path(client_name: "client-2") + assert_includes response.body, "0 Active" + assert_includes response.body, "1 Archived" + end + + def test_it_checks_installed + get graphql_dashboard.new_operation_store_client_path, params: { schema: GraphQL::Schema } + assert_includes response.body, "isn't installed for this schema yet" + end + end +end diff --git a/spec/dummy/test/controllers/dashboard/statics_controller_test.rb b/spec/dummy/test/controllers/dashboard/statics_controller_test.rb new file mode 100644 index 00000000000..6aa37d972f2 --- /dev/null +++ b/spec/dummy/test/controllers/dashboard/statics_controller_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true +require "test_helper" + +class DashboardStaticsControllerTest < ActionDispatch::IntegrationTest + def test_it_serves_assets + get graphql_dashboard.static_path("dashboard.css") + assert_includes response.body, "#header-icon {" + assert_equal response.headers["Cache-Control"], "max-age=31556952, public" + end + + def test_it_doesnt_trigger_csrf_failure + original_forgery_protection = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + get graphql_dashboard.static_path("dashboard.js") + assert_equal 200, response.status + ensure + ActionController::Base.allow_forgery_protection = original_forgery_protection + end + + def test_it_responds_404_for_others + get graphql_dashboard.static_path("other.rb") + assert_equal 404, response.status + + assert_raises ActionController::UrlGenerationError do + graphql_dashboard.static_path("invalid~char.js") + end + + get graphql_dashboard.static_path("invalid-char.js").sub("-char", "~char") + assert_equal 404, response.status + end +end diff --git a/spec/dummy/test/controllers/dashboard/subscriptions/topics_controller_test.rb b/spec/dummy/test/controllers/dashboard/subscriptions/topics_controller_test.rb new file mode 100644 index 00000000000..27143ba0c89 --- /dev/null +++ b/spec/dummy/test/controllers/dashboard/subscriptions/topics_controller_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true +require "test_helper" +require "ostruct" # TODO use a real class in RedisBackend + +if defined?(GraphQL::Pro) + class DashboardSubscriptionsTopicsControllerTest < ActionDispatch::IntegrationTest + def test_it_checks_installed + get graphql_dashboard.subscriptions_topics_path, params: { schema: GraphQL::Schema } + assert_includes response.body, "GraphQL-Pro Subscriptions aren't installed on this schema yet." + end + + def test_it_renders_empty_state_and_not_found_states + get graphql_dashboard.subscriptions_topics_path + assert_includes response.body, "There aren't any subscriptions right now." + get graphql_dashboard.subscriptions_topic_path(":something:") + assert_includes response.body, ":something:" + assert_includes response.body, "Last triggered: none" + assert_includes response.body, "0 Subscriptions" + get graphql_dashboard.subscriptions_subscription_path("abcd-efg") + assert_includes response.body, "abcd-efg" + assert_includes response.body, "This subscription was not found or is no longer active." + end + + def test_it_lists_topics_and_shows_detail + DummySchema.subscriptions.clear + _res1 = DummySchema.execute("subscription { message(channel: \"cats\") }") + res2 = DummySchema.execute("subscription { message(channel: \"dogs\") }") + DummySchema.subscriptions.trigger(:message, { channel: "dogs"}, "Woof!") + get graphql_dashboard.subscriptions_topics_path + assert_includes response.body, ":message:channel:cats" + assert_includes response.body, ":message:channel:dogs" + assert_includes response.body, Time.now.strftime("%Y-%m-%d %H:%M:%S") + + get graphql_dashboard.subscriptions_topic_path(":message:channel:dogs") + assert_includes response.body, res2.context[:subscription_id] + assert_includes response.body, Time.now.strftime("%Y-%m-%d %H:%M:%S") + + get graphql_dashboard.subscriptions_subscription_path(res2.context[:subscription_id]) + assert_includes response.body, res2.context[:subscription_id] + assert_includes response.body, CGI::escapeHTML('subscription { message(channel: "dogs") }') + + post graphql_dashboard.subscriptions_clear_all_path + get graphql_dashboard.subscriptions_topics_path + refute_includes response.body, ":message:" + ensure + DummySchema.subscriptions.clear + end + end +end diff --git a/spec/dummy/test/system/action_cable_subscription_test.rb b/spec/dummy/test/system/action_cable_subscription_test.rb index cb31fae08ba..a7ce09ba3ca 100644 --- a/spec/dummy/test/system/action_cable_subscription_test.rb +++ b/spec/dummy/test/system/action_cable_subscription_test.rb @@ -2,6 +2,9 @@ require "application_system_test_case" class ActionCableSubscriptionsTest < ApplicationSystemTestCase + setup do + ActionCable.server.config.logger = Logger.new(STDOUT) + end # This test covers a lot of ground! test "it handles subscriptions" do # Load the page and let the subscriptions happen @@ -67,8 +70,9 @@ def detect_update_values(possibility_1, possibility_2) test "it only re-runs queries once for subscriptions with matching fingerprints" do + GraphqlChannel::CounterIncremented.reset_call_count visit "/" - using_wait_time 30 do + using_wait_time 10 do sleep 1 # Make 3 subscriptions to the same payload click_on("Subscribe with fingerprint 1") @@ -124,4 +128,48 @@ def detect_update_values(possibility_1, possibility_2) refute_selector "#fingerprint-updates-1-update-1-value-#{fingerprint_1_value_2 + 2}" end end + + test "it unsubscribes from the server" do + GraphqlChannel::CounterIncremented.reset_call_count + visit "/" + using_wait_time 10 do + sleep 1 + # Establish the connection + click_on("Subscribe with fingerprint 1") + debug_assert_selector "#fingerprint-updates-1-connected-1" + # Trigger once + click_on("Trigger with fingerprint 1") + debug_assert_selector "#fingerprint-updates-1-update-1-value-1" + + # Server unsubscribe + click_on("Server-side unsubscribe with fingerprint 1") + # Subsequent updates should fail + click_on("Trigger with fingerprint 1") + refute_selector "#fingerprint-updates-1-update-2-value-2" + + # The client has only 2 connections (from the initial 2) + assert_text "Remaining ActionCable subscriptions: 2" + end + end + + test "it unsubscribes with a message" do + GraphqlChannel::CounterIncremented.reset_call_count + visit "/" + using_wait_time 10 do + sleep 1 + # Establish the connection + click_on("Subscribe with fingerprint 1") + debug_assert_selector "#fingerprint-updates-1-connected-1" + # Trigger once + click_on("Trigger with fingerprint 1") + debug_assert_selector "#fingerprint-updates-1-update-1-value-1" + + # Server unsubscribe + click_on("Unsubscribe with message with fingerprint 1") + # Magic value from unsubscribe hook: + debug_assert_selector "#fingerprint-updates-1-update-1-value-9999" + # The client has only 2 connections (from the initial 2) + assert_text "Remaining ActionCable subscriptions: 2" + end + end end diff --git a/spec/dummy/test/test_helper.rb b/spec/dummy/test/test_helper.rb index cc6cf85762e..f007cbb0870 100644 --- a/spec/dummy/test/test_helper.rb +++ b/spec/dummy/test/test_helper.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true -TESTING_INTERPRETER = true require File.expand_path('../../config/environment', __FILE__) +# Load mounted route helpers before ActionDispatch::IntegrationTest includes them. +Rails.application.reload_routes_unless_loaded require 'rails/test_help' diff --git a/spec/dummy/test_autoloads.rb b/spec/dummy/test_autoloads.rb new file mode 100644 index 00000000000..7fbf6ea9b0e --- /dev/null +++ b/spec/dummy/test_autoloads.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Extracted and adapted from this talk from Ben Sheldon: +# `An ok compromise. Faster development by designing for the Rails Autoloader` +# Youtube video link: https://youtu.be/9-PWz9nbrT8?si=Lw7qsF2_VmBperId&t=1487 + +require_relative "./config/application" + +autoloaded_constants = [] + +Rails.autoloaders.each do |loader| + loader.on_load do |cpath, _value, _abspath| + autoloaded_constants << [cpath, caller] + end +end + +Rails.application.initialize! + +autoloaded_constants.each do |x| + x[1] = Rails.backtrace_cleaner.clean(x[1]).first +end + +allow_listed_constants = [ + 'ActionText::ContentHelper', + 'ActionText::TagHelper', +] + +puts +if !autoloaded_constants.reject! { _1.first.in?(allow_listed_constants) }.nil? + puts + puts "ERROR: Autoloaded constants were referenced during during boot." + puts + puts "These files/constants were autoloaded during the boot process, which will result in" \ + "inconsistent behavior and will slow down and may break development mode. " \ + "Remove references to these constants from code loaded at boot. " + puts + puts + w = autoloaded_constants.map { _1.first.length }.max + autoloaded_constants.each do |name, location| + puts "`#{name.ljust(w)}` referenced by #{location}" + end + + exit 1 +else + puts "SUCCESS! No autoloaded constants were found during the boot process." + exit 0 +end diff --git a/spec/fixtures/cop/.rubocop.yml b/spec/fixtures/cop/.rubocop.yml new file mode 100644 index 00000000000..a8c7733435a --- /dev/null +++ b/spec/fixtures/cop/.rubocop.yml @@ -0,0 +1,29 @@ +require: + - graphql/rubocop + +AllCops: + TargetRubyVersion: 2.4 + DisabledByDefault: true + +GraphQL/DefaultNullTrue: + Enabled: true + +GraphQL/DefaultRequiredTrue: + Enabled: true + +GraphQL/FieldTypeInBlock: + Enabled: true + Include: + - field_type.rb + - small_field_type.rb + - field_type_autocorrect.rb + - field_type_array.rb + - field_type_array_autocorrect.rb + - field_type_interface.rb + - field_type_interface_autocorrect.rb + +GraphQL/RootTypesInBlock: + Enabled: true + Include: + - root_types.rb + - root_types_autocorrect.rb diff --git a/spec/fixtures/cop/field_type.rb b/spec/fixtures/cop/field_type.rb new file mode 100644 index 00000000000..815d0ab6a8b --- /dev/null +++ b/spec/fixtures/cop/field_type.rb @@ -0,0 +1,18 @@ +class Types::Query + field :current_account, Types::Account, null: false, description: "The account of the current viewer" + + field :find_account, Types::Account do + argument :id, ID + end + + # Don't modify these: + field :current_time, String, description: "The current time in the viewer's timezone" + field :current_time, Integer, description: "The current time in the viewer's timezone" + field :current_time, Int, description: "The current time in the viewer's timezone" + field :current_time, Float, description: "The current time in the viewer's timezone" + field :current_time, Boolean, description: "The current time in the viewer's timezone" + + field(:all_accounts, [Types::Account, null: false]) { + argument :active, Boolean, default_value: false + } +end diff --git a/spec/fixtures/cop/field_type_array.rb b/spec/fixtures/cop/field_type_array.rb new file mode 100644 index 00000000000..9a7cee1bac4 --- /dev/null +++ b/spec/fixtures/cop/field_type_array.rb @@ -0,0 +1,6 @@ +class Types::FooType < Types::BaseObject + field :other, [String] + field :bar, [Thing], null: false do + argument :baz, String + end +end diff --git a/spec/fixtures/cop/field_type_array_corrected.rb b/spec/fixtures/cop/field_type_array_corrected.rb new file mode 100644 index 00000000000..9458e81f3b7 --- /dev/null +++ b/spec/fixtures/cop/field_type_array_corrected.rb @@ -0,0 +1,7 @@ +class Types::FooType < Types::BaseObject + field :other, [String] + field :bar, null: false do + type [Thing] + argument :baz, String + end +end diff --git a/spec/fixtures/cop/field_type_corrected.rb b/spec/fixtures/cop/field_type_corrected.rb new file mode 100644 index 00000000000..bd4e66cde79 --- /dev/null +++ b/spec/fixtures/cop/field_type_corrected.rb @@ -0,0 +1,22 @@ +class Types::Query + field :current_account, null: false, description: "The account of the current viewer" do + type Types::Account + end + + field :find_account do + type Types::Account + argument :id, ID + end + + # Don't modify these: + field :current_time, String, description: "The current time in the viewer's timezone" + field :current_time, Integer, description: "The current time in the viewer's timezone" + field :current_time, Int, description: "The current time in the viewer's timezone" + field :current_time, Float, description: "The current time in the viewer's timezone" + field :current_time, Boolean, description: "The current time in the viewer's timezone" + + field(:all_accounts) { + type [Types::Account, null: false] + argument :active, Boolean, default_value: false + } +end diff --git a/spec/fixtures/cop/field_type_interface.rb b/spec/fixtures/cop/field_type_interface.rb new file mode 100644 index 00000000000..8f2e8833209 --- /dev/null +++ b/spec/fixtures/cop/field_type_interface.rb @@ -0,0 +1,5 @@ +module Types::FooType + include Types::BaseInterface + + field :thing, Thing +end diff --git a/spec/fixtures/cop/field_type_interface_corrected.rb b/spec/fixtures/cop/field_type_interface_corrected.rb new file mode 100644 index 00000000000..5bcf7251b9f --- /dev/null +++ b/spec/fixtures/cop/field_type_interface_corrected.rb @@ -0,0 +1,7 @@ +module Types::FooType + include Types::BaseInterface + + field :thing do + type Thing + end +end diff --git a/spec/fixtures/cop/null_true.rb b/spec/fixtures/cop/null_true.rb new file mode 100644 index 00000000000..08e67091694 --- /dev/null +++ b/spec/fixtures/cop/null_true.rb @@ -0,0 +1,13 @@ +class Types::Something < Types::BaseObject + field :name, String, null: true + + field :other_name, String, + null: true, + description: "Here's a description" + + field :described, [String, null: true], null: true, description: "Something" + + field :ok_field, String + + field :also_ok_field, String, null: false +end diff --git a/spec/fixtures/cop/null_true_corrected.rb b/spec/fixtures/cop/null_true_corrected.rb new file mode 100644 index 00000000000..e5933dcb206 --- /dev/null +++ b/spec/fixtures/cop/null_true_corrected.rb @@ -0,0 +1,12 @@ +class Types::Something < Types::BaseObject + field :name, String + + field :other_name, String, + description: "Here's a description" + + field :described, [String, null: true], description: "Something" + + field :ok_field, String + + field :also_ok_field, String, null: false +end diff --git a/spec/fixtures/cop/required_true.rb b/spec/fixtures/cop/required_true.rb new file mode 100644 index 00000000000..eb47a0f918a --- /dev/null +++ b/spec/fixtures/cop/required_true.rb @@ -0,0 +1,20 @@ +class Types::Something < Types::BaseObject + field :name, String do + argument :id_1, ID, required: true + + argument :id_2, + ID, + required: true, + description: "Described" + + argument :id_3, ID, other_config: { something: false, required: true }, required: true, description: "Something" + + argument :id_4, ID, required: false + + argument :id_5, ID + end + + field :name2, String do |f| + f.argument(:id_1, ID, required: true) + end +end diff --git a/spec/fixtures/cop/required_true_corrected.rb b/spec/fixtures/cop/required_true_corrected.rb new file mode 100644 index 00000000000..938d1267f67 --- /dev/null +++ b/spec/fixtures/cop/required_true_corrected.rb @@ -0,0 +1,19 @@ +class Types::Something < Types::BaseObject + field :name, String do + argument :id_1, ID + + argument :id_2, + ID, + description: "Described" + + argument :id_3, ID, other_config: { something: false, required: true }, description: "Something" + + argument :id_4, ID, required: false + + argument :id_5, ID + end + + field :name2, String do |f| + f.argument(:id_1, ID) + end +end diff --git a/spec/fixtures/cop/root_types.rb b/spec/fixtures/cop/root_types.rb new file mode 100644 index 00000000000..2d873fc4901 --- /dev/null +++ b/spec/fixtures/cop/root_types.rb @@ -0,0 +1,5 @@ +class MyAppSchema < GraphQL::Schema + query Types::Query + mutation Types::Mutation + subscription Types::Subscription +end diff --git a/spec/fixtures/cop/root_types_corrected.rb b/spec/fixtures/cop/root_types_corrected.rb new file mode 100644 index 00000000000..d4d1796c63a --- /dev/null +++ b/spec/fixtures/cop/root_types_corrected.rb @@ -0,0 +1,5 @@ +class MyAppSchema < GraphQL::Schema + query { Types::Query } + mutation { Types::Mutation } + subscription { Types::Subscription } +end diff --git a/spec/fixtures/cop/small_field_type.rb b/spec/fixtures/cop/small_field_type.rb new file mode 100644 index 00000000000..eda3b3008ec --- /dev/null +++ b/spec/fixtures/cop/small_field_type.rb @@ -0,0 +1,3 @@ +class Types::Admin::FooType < Types::FooType + field :bar, Types::BarType +end diff --git a/spec/fixtures/eager_module/eager_class.rb b/spec/fixtures/eager_module/eager_class.rb new file mode 100644 index 00000000000..95d713fa727 --- /dev/null +++ b/spec/fixtures/eager_module/eager_class.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module EagerModule + module EagerClass + end +end diff --git a/spec/fixtures/eager_module/nested_eager_module.rb b/spec/fixtures/eager_module/nested_eager_module.rb new file mode 100644 index 00000000000..2a2b613bf46 --- /dev/null +++ b/spec/fixtures/eager_module/nested_eager_module.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +module EagerModule + module NestedEagerModule + extend GraphQL::Autoload + autoload(:NestedEagerClass, "fixtures/eager_module/nested_eager_module/nested_eager_class") + end +end diff --git a/spec/fixtures/eager_module/nested_eager_module/nested_eager_class.rb b/spec/fixtures/eager_module/nested_eager_module/nested_eager_class.rb new file mode 100644 index 00000000000..214ac3bf4b6 --- /dev/null +++ b/spec/fixtures/eager_module/nested_eager_module/nested_eager_class.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +module EagerModule + module NestedEagerModule + class NestedEagerClass + end + end +end diff --git a/spec/fixtures/eager_module/other_eager_class.rb b/spec/fixtures/eager_module/other_eager_class.rb new file mode 100644 index 00000000000..e2ee5d53588 --- /dev/null +++ b/spec/fixtures/eager_module/other_eager_class.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module EagerModule + module OtherEagerClass + end +end diff --git a/spec/fixtures/lazy_module/lazy_class.rb b/spec/fixtures/lazy_module/lazy_class.rb new file mode 100644 index 00000000000..27facc5e632 --- /dev/null +++ b/spec/fixtures/lazy_module/lazy_class.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module LazyModule + module LazyClass + end +end diff --git a/spec/fixtures/unicode_escapes/query1.graphql b/spec/fixtures/unicode_escapes/query1.graphql new file mode 100644 index 00000000000..684fd7148e3 --- /dev/null +++ b/spec/fixtures/unicode_escapes/query1.graphql @@ -0,0 +1,6 @@ +{ + example1: getString(string: "\u0064") # should return "d" + example2: getString(string: "\\u0064") # should return "\\u0064" + # example3: getString(string: "\u006") # validation error + example4: getString(string: "\\u006") # should return "\\u006" +} diff --git a/spec/fixtures/unicode_escapes/query2.graphql b/spec/fixtures/unicode_escapes/query2.graphql new file mode 100644 index 00000000000..87de1742610 --- /dev/null +++ b/spec/fixtures/unicode_escapes/query2.graphql @@ -0,0 +1,6 @@ +query bug2 { + example1: getString(string: """\a""") # should be "\\a" + example2: getString(string: """\u006""") # should be "\\u006" + example3: getString(string: """\n""") # should be "\\n" + example4: getString(string: """\u0064""") # should be "\\u0064" +} diff --git a/spec/fixtures/upgrader/account.original.rb b/spec/fixtures/upgrader/account.original.rb deleted file mode 100644 index 51a51e28f83..00000000000 --- a/spec/fixtures/upgrader/account.original.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Unions - Account = GraphQL::UnionType.define do - name "Account" - description "Users and organizations." - visibility :internal - - possible_types [ - Objects::User, - Objects::Organization, - Objects::Bot - ] - - resolve_type ->(obj, ctx) { :stand_in } - end - end -end diff --git a/spec/fixtures/upgrader/account.transformed.rb b/spec/fixtures/upgrader/account.transformed.rb deleted file mode 100644 index 9e35da7708b..00000000000 --- a/spec/fixtures/upgrader/account.transformed.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Unions - class Account < Platform::Unions::Base - description "Users and organizations." - visibility :internal - - possible_types( - Objects::User, - Objects::Organization, - Objects::Bot, - ) - - def self.resolve_type(obj, ctx) - :stand_in - end - end - end -end diff --git a/spec/fixtures/upgrader/blame_range.original.rb b/spec/fixtures/upgrader/blame_range.original.rb deleted file mode 100644 index 7f8672ace90..00000000000 --- a/spec/fixtures/upgrader/blame_range.original.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Objects - BlameRange = GraphQL::ObjectType.define do - name "BlameRange" - description "Represents a range of information from a Git blame." - - scopeless_tokens_as_minimum - - - interfaces [ - Interfaces::A, - Interfaces::B, - ] - - field :startingLine, !types.Int do - description "The starting line for the range" - - resolve ->(range, args, context) { - range.lines.first[:lineno] - } - end - - field :endingLine, !types.Int do - description "The ending line for the range" - - resolve ->(range, args, context) { - range.lines.first[:lineno] + (range.lines.length - 1) - } - end - - field :commit, -> { !Objects::Commit } do - description "Identifies the line author" - end - - field :age, !types.Int do - description "Identifies the recency of the change, from 1 (new) to 10 (old). This is calculated as a 2-quantile and determines the length of distance between the median age of all the changes in the file and the recency of the current range's change." - property :scale - end - end - end -end diff --git a/spec/fixtures/upgrader/blame_range.transformed.rb b/spec/fixtures/upgrader/blame_range.transformed.rb deleted file mode 100644 index 4a6ee118c25..00000000000 --- a/spec/fixtures/upgrader/blame_range.transformed.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Objects - class BlameRange < Platform::Objects::Base - description "Represents a range of information from a Git blame." - - scopeless_tokens_as_minimum - - implements Interfaces::A - implements Interfaces::B - - field :starting_line, Integer, description: "The starting line for the range", null: false - - def starting_line - object.lines.first[:lineno] - end - - field :ending_line, Integer, description: "The ending line for the range", null: false - - def ending_line - object.lines.first[:lineno] + (object.lines.length - 1) - end - - field :commit, Objects::Commit, description: "Identifies the line author", null: false - - field :age, Integer, method: :scale, description: "Identifies the recency of the change, from 1 (new) to 10 (old). This is calculated as a 2-quantile and determines the length of distance between the median age of all the changes in the file and the recency of the current range's change.", null: false - end - end -end diff --git a/spec/fixtures/upgrader/date_time.original.rb b/spec/fixtures/upgrader/date_time.original.rb deleted file mode 100644 index 9762fe22828..00000000000 --- a/spec/fixtures/upgrader/date_time.original.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Scalars - DateTime = GraphQL::ScalarType.define do - name "DateTime" - description "An ISO-8601 encoded UTC date string." - - # rubocop:disable Layout/SpaceInLambdaLiteral - coerce_input -> (value, context) do - begin - Time.iso8601(value) - rescue ArgumentError, ::TypeError - end - end - # rubocop:enable Layout/SpaceInLambdaLiteral - - coerce_result ->(value, context) do - return nil unless value - value.utc.iso8601 - end - end - end -end diff --git a/spec/fixtures/upgrader/date_time.transformed.rb b/spec/fixtures/upgrader/date_time.transformed.rb deleted file mode 100644 index 6ac89735ad6..00000000000 --- a/spec/fixtures/upgrader/date_time.transformed.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Scalars - class DateTime < Platform::Scalars::Base - description "An ISO-8601 encoded UTC date string." - - # rubocop:disable Layout/SpaceInLambdaLiteral - def self.coerce_input(value, context) - begin - Time.iso8601(value) - rescue ArgumentError, ::TypeError - end - end - # rubocop:enable Layout/SpaceInLambdaLiteral - - def self.coerce_result(value, context) - return nil unless value - value.utc.iso8601 - end - end - end -end diff --git a/spec/fixtures/upgrader/delete_project.original.rb b/spec/fixtures/upgrader/delete_project.original.rb deleted file mode 100644 index 14233291a9e..00000000000 --- a/spec/fixtures/upgrader/delete_project.original.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Mutations - DeleteProject = GraphQL::Relay::Mutation.define do - name "DeleteProject" - description "Deletes a project." - - minimum_accepted_scopes ["public_repo"] - - input_field :projectId, !types.ID, "The Project ID to update." - return_field :owner, !Interfaces::ProjectOwner, "The repository or organization the project was removed from." - - resolve ->(root_obj, inputs, context) do - project = Platform::Helpers::NodeIdentification.typed_object_from_id( - [Objects::Project], inputs[:projectId], context - ) - - context[:permission].can_modify?("DeleteProject", project).sync - context[:abilities].authorize_content(:project, :destroy, owner: project.owner) - - project.enqueue_delete(actor: context[:viewer]) - - { owner: project.owner } - end - end - end -end diff --git a/spec/fixtures/upgrader/delete_project.transformed.rb b/spec/fixtures/upgrader/delete_project.transformed.rb deleted file mode 100644 index d470dacd303..00000000000 --- a/spec/fixtures/upgrader/delete_project.transformed.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Mutations - class DeleteProject < Mutations::BaseMutation - description "Deletes a project." - - minimum_accepted_scopes ["public_repo"] - - argument :project_id, ID, "The Project ID to update.", required: true - field :owner, Interfaces::ProjectOwner, "The repository or organization the project was removed from.", null: false - - def resolve(**inputs) - project = Platform::Helpers::NodeIdentification.typed_object_from_id( - [Objects::Project], inputs[:project_id], context - ) - - context[:permission].can_modify?("DeleteProject", project).sync - context[:abilities].authorize_content(:project, :destroy, owner: project.owner) - - project.enqueue_delete(actor: context[:viewer]) - - { owner: project.owner } - end - end - end -end diff --git a/spec/fixtures/upgrader/gist_order_field.original.rb b/spec/fixtures/upgrader/gist_order_field.original.rb deleted file mode 100644 index a88e54f786d..00000000000 --- a/spec/fixtures/upgrader/gist_order_field.original.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Enums - GistOrderField = GraphQL::EnumType.define do - name "GistOrderField" - description "Properties by which gist connections can be ordered." - - value "CREATED_AT", "Order gists by creation time", value: "created_at" - value "UPDATED_AT", "Order gists by update time", value: "updated_at" - value "PUSHED_AT", "Order gists by push time", value: "pushed_at" - end - end -end diff --git a/spec/fixtures/upgrader/gist_order_field.transformed.rb b/spec/fixtures/upgrader/gist_order_field.transformed.rb deleted file mode 100644 index 55828b247f6..00000000000 --- a/spec/fixtures/upgrader/gist_order_field.transformed.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Enums - class GistOrderField < Platform::Enums::Base - description "Properties by which gist connections can be ordered." - - value "CREATED_AT", "Order gists by creation time", value: "created_at" - value "UPDATED_AT", "Order gists by update time", value: "updated_at" - value "PUSHED_AT", "Order gists by push time", value: "pushed_at" - end - end -end diff --git a/spec/fixtures/upgrader/increment_count.original.rb b/spec/fixtures/upgrader/increment_count.original.rb deleted file mode 100644 index b40e3f95d69..00000000000 --- a/spec/fixtures/upgrader/increment_count.original.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Mutations - IncrementThing = GraphQL::Relay::Mutation.define do - name "IncrementThing" - description "increments the thing by 1." - visibility :internal - minimum_accepted_scopes ["repo"] - - input_field(:thingId, - !types.ID, - "Thing ID to log.", - option: :setting) - - return_field( - :thingId, - !types.ID, - "Thing ID to log." - ) - - resolve ->(root_obj, inputs, context) do - if some_early_check - return { thingId: "000" } - end - - # These shouldn't be modified: - { abcDef: 1 } - some_method do { xyzAbc: 1 } end - - thing = Platform::Helpers::NodeIdentification.typed_object_from_id(Objects::Thing, inputs[:thingId], context) - raise Errors::Validation.new("Thing not found.") unless thing - - ThingActivity.track(thing.id, Time.now.change(min: 0, sec: 0)) - - - if random_condition - { thingId: thing.global_relay_id } - elsif other_random_thing - { :thingId => "abc" } - elsif something_else - method_with_block { - { thingId: "pqr" } - } - elsif yet_another_thing - begin - { thingId: "987" } - rescue - { thingId: "789" } - end - else - return { - thingId: "xyz" - } - end - end - end - end -end diff --git a/spec/fixtures/upgrader/increment_count.transformed.rb b/spec/fixtures/upgrader/increment_count.transformed.rb deleted file mode 100644 index d84c7515d9f..00000000000 --- a/spec/fixtures/upgrader/increment_count.transformed.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Mutations - class IncrementThing < Mutations::BaseMutation - description "increments the thing by 1." - visibility :internal - minimum_accepted_scopes ["repo"] - - argument :thing_id, ID, "Thing ID to log.", option: :setting, required: true - - field :thing_id, ID, "Thing ID to log.", null: false - - def resolve(**inputs) - if some_early_check - return { thing_id: "000" } - end - - # These shouldn't be modified: - { abcDef: 1 } - some_method do { xyzAbc: 1 } end - - thing = Platform::Helpers::NodeIdentification.typed_object_from_id(Objects::Thing, inputs[:thing_id], context) - raise Errors::Validation.new("Thing not found.") unless thing - - ThingActivity.track(thing.id, Time.now.change(min: 0, sec: 0)) - - if random_condition - { thing_id: thing.global_relay_id } - elsif other_random_thing - { :thing_id => "abc" } - elsif something_else - method_with_block { - { thing_id: "pqr" } - } - elsif yet_another_thing - begin - { thing_id: "987" } - rescue - { thing_id: "789" } - end - else - return { - thing_id: "xyz" - } - end - end - end - end -end diff --git a/spec/fixtures/upgrader/mutation.original.rb b/spec/fixtures/upgrader/mutation.original.rb deleted file mode 100644 index 650814e506f..00000000000 --- a/spec/fixtures/upgrader/mutation.original.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true -module Platform - module Mutations - Echo = GraphQL::Relay::Mutation.define do - name 'EchoMutation' - - input_field :message, types.String - - field :data, types.String - - resolve ->(_obj, inputs, _ctx) { - { data: inputs[:message] } - } - end - - Repeat = GraphQL::Relay::Mutation.define do - name 'RepeatMutation' - - input_field :message, types.String - - field :data, types.String - - resolve ->(_obj, inputs, _ctx) { - { data: inputs[:message] } - } - end - end -end diff --git a/spec/fixtures/upgrader/mutation.transformed.rb b/spec/fixtures/upgrader/mutation.transformed.rb deleted file mode 100644 index f7a6ee6952b..00000000000 --- a/spec/fixtures/upgrader/mutation.transformed.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true -module Platform - module Mutations - class Echo < Mutations::BaseMutation - graphql_name 'EchoMutation' - - argument :message, String, required: false - - field :data, String, null: true - - def resolve(**inputs) - { data: inputs[:message] } - end - end - - class Repeat < Mutations::BaseMutation - graphql_name 'RepeatMutation' - - argument :message, String, required: false - - field :data, String, null: true - - def resolve(**inputs) - { data: inputs[:message] } - end - end - end -end diff --git a/spec/fixtures/upgrader/photo.original.rb b/spec/fixtures/upgrader/photo.original.rb deleted file mode 100644 index 7db93f52467..00000000000 --- a/spec/fixtures/upgrader/photo.original.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true -module Platform - module Objects - Photo = GraphQL::ObjectType.define do - field(:caption, types.String) do - resolve(->(obj, _args, _ctx) { obj.caption }) - end - end - end -end diff --git a/spec/fixtures/upgrader/photo.transformed.rb b/spec/fixtures/upgrader/photo.transformed.rb deleted file mode 100644 index b3b04fe3ce5..00000000000 --- a/spec/fixtures/upgrader/photo.transformed.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true -module Platform - module Objects - class Photo < Platform::Objects::Base - field :caption, String, null: true - - def caption - object.caption - end - end - end -end diff --git a/spec/fixtures/upgrader/release_order.original.rb b/spec/fixtures/upgrader/release_order.original.rb deleted file mode 100644 index b86c5043906..00000000000 --- a/spec/fixtures/upgrader/release_order.original.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Inputs - ReleaseOrder = GraphQL::InputObjectType.define do - name "ReleaseOrder" - description "Ways in which lists of releases can be ordered upon return." - - input_field :field, types[!Enums::ReleaseOrderField], <<-MD - The field in which to order releases by. - MD - input_field :direction, !Enums::OrderDirection, "The direction in which to order releases by the specified field." - end - end -end diff --git a/spec/fixtures/upgrader/release_order.transformed.rb b/spec/fixtures/upgrader/release_order.transformed.rb deleted file mode 100644 index 113329e70d2..00000000000 --- a/spec/fixtures/upgrader/release_order.transformed.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Inputs - class ReleaseOrder < Platform::Inputs::Base - description "Ways in which lists of releases can be ordered upon return." - - argument :field, [Enums::ReleaseOrderField], <<-MD, required: false - The field in which to order releases by. - MD - argument :direction, Enums::OrderDirection, "The direction in which to order releases by the specified field.", required: true - end - end -end diff --git a/spec/fixtures/upgrader/starrable.original.rb b/spec/fixtures/upgrader/starrable.original.rb deleted file mode 100644 index 97c8379efc1..00000000000 --- a/spec/fixtures/upgrader/starrable.original.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Interfaces - Starrable = GraphQL::InterfaceType.define do - name "Starrable" - description "Things that can be starred." - - global_id_field :id - - field :viewerHasStarred, !types.Boolean do - argument :preceedsConnectionMethod, types.Boolean - description "Returns a boolean indicating whether the viewing user has starred this starrable." - - resolve ->(object, arguments, context) do - if context[:viewer] - ->(test_inner_proc) do - context[:viewer].starred?(object) - end - else - false - end - end - end - - connection :stargazers, -> { !Connections::Stargazer } do - description "A list of users who have starred this starrable." - - argument :orderBy, Inputs::StarOrder, "Order for connection" - - resolve ->(object, arguments, context) do - scope = case object - when Repository - object.stars - when Gist - GistStar.where(gist_id: object.id) - end - - table = scope.table_name - if order_by = arguments["orderBy"] - scope = scope.order("#{table}.#{order_by["field"]} #{order_by["direction"]}") - end - - scope - end - end - end - end -end diff --git a/spec/fixtures/upgrader/starrable.transformed.rb b/spec/fixtures/upgrader/starrable.transformed.rb deleted file mode 100644 index f4bf3df4d73..00000000000 --- a/spec/fixtures/upgrader/starrable.transformed.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Interfaces - module Starrable - include Platform::Interfaces::Base - description "Things that can be starred." - - global_id_field :id - - field :viewer_has_starred, Boolean, description: "Returns a boolean indicating whether the viewing user has starred this starrable.", null: false do - argument :preceeds_connection_method, Boolean, required: false - end - - def viewer_has_starred(**arguments) - if context[:viewer] - ->(test_inner_proc) do - context[:viewer].starred?(object) - end - else - false - end - end - - field :stargazers, Connections::Stargazer, description: "A list of users who have starred this starrable.", null: false, connection: true do - argument :order_by, Inputs::StarOrder, "Order for connection", required: false - end - - def stargazers(**arguments) - scope = case object - when Repository - object.stars - when Gist - GistStar.where(gist_id: object.id) - end - - table = scope.table_name - if order_by = arguments[:order_by] - scope = scope.order("#{table}.#{order_by["field"]} #{order_by["direction"]}") - end - - scope - end - end - end -end diff --git a/spec/fixtures/upgrader/subscribable.original.rb b/spec/fixtures/upgrader/subscribable.original.rb deleted file mode 100644 index 7e4bb132744..00000000000 --- a/spec/fixtures/upgrader/subscribable.original.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Interfaces - Subscribable = GraphQL::InterfaceType.define do - name "Subscribable" - description "Entities that can be subscribed to for web and email notifications." - - field :id, !GraphQL::DEPRECATED_ID_TYPE, property: :global_relay_id - - field :viewerSubscription, -> { !Enums::SubscriptionState } do - description "Identifies if the viewer is watching, not watching, or ignoring the subscribable entity." - - resolve ->(subscribable, arguments, context) do - if context[:viewer].nil? - return "unsubscribed" - end - - subscription_status_response = subscribable.async_subscription_status(context[:viewer]).sync - - if subscription_status_response.failed? - error = Platform::Errors::ServiceUnavailable.new("Subscriptions are currently unavailable. Please try again later.") - error.ast_node = context.irep_node.ast_node - error.path = context.path - context.errors << error - return "unavailable" - end - - subscription = subscription_status_response.value - if subscription.included? - "unsubscribed" - elsif subscription.subscribed? - "subscribed" - elsif subscription.ignored? - "ignored" - end - end - end - - field :viewerCanSubscribe, !types.Boolean do - description "Check if the viewer is able to change their subscription status for the repository." - - resolve ->(subscribable, arguments, context) do - return false if context[:viewer].nil? - - subscribable.async_subscription_status(context[:viewer]).then(&:success?) - end - end - - connection :issues, function: Platform::Functions::Issues.new, description: "A list of issues associated with the milestone." - connection :files, -> { !Connections.define(PackageFile) }, description: "List of files associated with this registry package version" - field :enabled, !types.Boolean, "Whether enabled for this project", property: :enabled? - end - end -end diff --git a/spec/fixtures/upgrader/subscribable.transformed.rb b/spec/fixtures/upgrader/subscribable.transformed.rb deleted file mode 100644 index 6b378d4f87a..00000000000 --- a/spec/fixtures/upgrader/subscribable.transformed.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Interfaces - module Subscribable - include Platform::Interfaces::Base - description "Entities that can be subscribed to for web and email notifications." - - field :id, GraphQL::DEPRECATED_ID_TYPE, method: :global_relay_id, null: false - - field :viewer_subscription, Enums::SubscriptionState, description: "Identifies if the viewer is watching, not watching, or ignoring the subscribable entity.", null: false - - def viewer_subscription - if context[:viewer].nil? - return "unsubscribed" - end - - subscription_status_response = object.async_subscription_status(context[:viewer]).sync - - if subscription_status_response.failed? - error = Platform::Errors::ServiceUnavailable.new("Subscriptions are currently unavailable. Please try again later.") - error.ast_node = context.irep_node.ast_node - error.path = context.path - context.errors << error - return "unavailable" - end - - subscription = subscription_status_response.value - if subscription.included? - "unsubscribed" - elsif subscription.subscribed? - "subscribed" - elsif subscription.ignored? - "ignored" - end - end - - field :viewer_can_subscribe, Boolean, description: "Check if the viewer is able to change their subscription status for the repository.", null: false - - def viewer_can_subscribe - return false if context[:viewer].nil? - - object.async_subscription_status(context[:viewer]).then(&:success?) - end - - field :issues, function: Platform::Functions::Issues.new, description: "A list of issues associated with the milestone.", connection: true - field :files, Connections.define(PackageFile), description: "List of files associated with this registry package version", null: false, connection: true - field :enabled, Boolean, "Whether enabled for this project", method: :enabled?, null: false - end - end -end diff --git a/spec/fixtures/upgrader/type_x.original.rb b/spec/fixtures/upgrader/type_x.original.rb deleted file mode 100644 index 95dd5623be9..00000000000 --- a/spec/fixtures/upgrader/type_x.original.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Objects - X = define_active_record_type(-> { ::X }) do - name "X" - description "An x on a y." - visibility :internal - minimum_accepted_scopes ["z"] - - global_id_field :id - interfaces [GraphQL::Relay::Node.interface] - - field :f1, !Objects::O1, "The x being y." - field :f2, !Enums::E1, "x for the y.", - property: :field_2 - field :f3, Enums::E2, "x for y." - field :details, types.String, "Details." - - field :f4, !Objects::O2, "x as a y inside the z." do - argument :a1, !Inputs::I1 - - resolve ->(obj_x, arguments, context) do - Class1.new( - a: Class2.new( - b: obj_x.b_1, - c: obj_x.c_1 - ), - d: Class3.new( - b: obj_x.b_2, - c: obj_x.c_3, - ) - ) - end - end - - field :f5, -> { !types.String } do - description "The thing" - property :custom_property - visibility :custom_value - end - - field :f6, -> { !types.String } do - description "The thing" - property :custom_property - visibility :custom_value - end - - field :f7, field: SomeField - field :f8, function: SomeFunction - field :f9, types[Objects::O2] - field :fieldField, types.String, hash_key: "fieldField" - field :fieldField2, types.String, property: :field_field2 - - field :f10, types.String do - resolve ->(obj, _, _) do - obj.something do |_| - xyz_obj.obj - obj.f10 - end - end - end - end - end -end diff --git a/spec/fixtures/upgrader/type_x.transformed.rb b/spec/fixtures/upgrader/type_x.transformed.rb deleted file mode 100644 index d0f69d17f26..00000000000 --- a/spec/fixtures/upgrader/type_x.transformed.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -module Platform - module Objects - class X < Platform::Objects::Base - model_name "X" - description "An x on a y." - visibility :internal - minimum_accepted_scopes ["z"] - - global_id_field :id - implements GraphQL::Relay::Node.interface - - field :f1, Objects::O1, "The x being y.", null: false - field :f2, Enums::E1, "x for the y.", method: :field_2, null: false - field :f3, Enums::E2, "x for y.", null: true - field :details, String, "Details.", null: true - - field :f4, Objects::O2, "x as a y inside the z.", null: false do - argument :a1, Inputs::I1, required: true - end - - def f4(**arguments) - Class1.new( - a: Class2.new( - b: object.b_1, - c: object.c_1 - ), - d: Class3.new( - b: object.b_2, - c: object.c_3, - ) - ) - end - - field :f5, String, visibility: :custom_value, method: :custom_property, description: "The thing", null: false - - field :f6, String, visibility: :custom_value, method: :custom_property, description: "The thing", null: false - - field :f7, field: SomeField - field :f8, function: SomeFunction - field :f9, [Objects::O2, null: true], null: true - field :field_field, String, hash_key: "fieldField", null: true - field :field_field2, String, null: true - - field :f10, String, null: true - - def f10 - object.something do |_| - xyz_obj.obj - object.f10 - end - end - end - end -end diff --git a/spec/graphql/analysis/ast/field_usage_spec.rb b/spec/graphql/analysis/ast/field_usage_spec.rb deleted file mode 100644 index 2faf2c5ff8c..00000000000 --- a/spec/graphql/analysis/ast/field_usage_spec.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true -require "spec_helper" - -describe GraphQL::Analysis::AST::FieldUsage do - let(:result) { GraphQL::Analysis::AST.analyze_query(query, [GraphQL::Analysis::AST::FieldUsage]).first } - let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables) } - let(:variables) { {} } - - describe "query with deprecated fields" do - let(:query_string) {%| - query { - cheese(id: 1) { - id - fatContent - } - } - |} - - it "keeps track of used fields" do - assert_equal ['Cheese.id', 'Cheese.fatContent', 'Query.cheese'], result[:used_fields] - end - - it "keeps track of deprecated fields" do - assert_equal ['Cheese.fatContent'], result[:used_deprecated_fields] - end - end - - describe "query with deprecated fields used more than once" do - let(:query_string) {%| - query { - cheese1: cheese(id: 1) { - id - fatContent - } - - cheese2: cheese(id: 2) { - id - fatContent - } - } - |} - - it "omits duplicate usage of a field" do - assert_equal ['Cheese.id', 'Cheese.fatContent', 'Query.cheese'], result[:used_fields] - end - - it "omits duplicate usage of a deprecated field" do - assert_equal ['Cheese.fatContent'], result[:used_deprecated_fields] - end - end - - describe "query with deprecated fields in a fragment" do - let(:query_string) {%| - query { - cheese(id: 1) { - id - ...CheeseSelections - } - } - fragment CheeseSelections on Cheese { - fatContent - } - |} - - it "keeps track of fields used in the fragment" do - assert_equal ['Cheese.id', 'Cheese.fatContent', 'Query.cheese'], result[:used_fields] - end - - it "keeps track of deprecated fields used in the fragment" do - assert_equal ['Cheese.fatContent'], result[:used_deprecated_fields] - end - end - - describe "query with deprecated fields in an inline fragment" do - let(:query_string) {%| - query { - cheese(id: 1) { - id - ... on Cheese { - fatContent - } - } - } - |} - - it "keeps track of fields used in the fragment" do - assert_equal ['Cheese.id', 'Cheese.fatContent', 'Query.cheese'], result[:used_fields] - end - - it "keeps track of deprecated fields used in the fragment" do - assert_equal ['Cheese.fatContent'], result[:used_deprecated_fields] - end - end -end diff --git a/spec/graphql/analysis/ast/max_query_complexity_spec.rb b/spec/graphql/analysis/ast/max_query_complexity_spec.rb deleted file mode 100644 index 5b6952fdea4..00000000000 --- a/spec/graphql/analysis/ast/max_query_complexity_spec.rb +++ /dev/null @@ -1,148 +0,0 @@ -# frozen_string_literal: true -require "spec_helper" - -describe GraphQL::Analysis::AST::MaxQueryComplexity do - let(:schema) { Class.new(Dummy::Schema) } - let(:query_string) {%| - { - a: cheese(id: 1) { id } - b: cheese(id: 1) { id } - c: cheese(id: 1) { id } - d: cheese(id: 1) { id } - e: cheese(id: 1) { id } - } - |} - let(:query) { GraphQL::Query.new(schema, query_string, variables: {}, max_complexity: max_complexity) } - let(:result) { - GraphQL::Analysis::AST.analyze_query(query, [GraphQL::Analysis::AST::MaxQueryComplexity]).first - } - - - describe "when a query goes over max complexity" do - let(:max_complexity) { 9 } - - it "returns an error" do - assert_equal GraphQL::AnalysisError, result.class - assert_equal "Query has complexity of 10, which exceeds max complexity of 9", result.message - end - end - - describe "when there is no max complexity" do - let(:max_complexity) { nil } - - it "doesn't error" do - assert_nil result - end - end - - describe "when the query is less than the max complexity" do - let(:max_complexity) { 99 } - - it "doesn't error" do - assert_nil result - end - end - - describe "when max_complexity is decreased at query-level" do - before do - schema.max_complexity(100) - end - - let(:max_complexity) { 7 } - - it "is applied" do - assert_equal GraphQL::AnalysisError, result.class - assert_equal "Query has complexity of 10, which exceeds max complexity of 7", result.message - end - end - - describe "when max_complexity is increased at query-level" do - before do - schema.max_complexity(1) - end - - let(:max_complexity) { 10 } - - it "doesn't error" do - assert_nil result - end - end - - describe "when max_complexity is nil at query-level" do - let(:max_complexity) { nil } - - before do - schema.max_complexity(1) - end - - it "is applied" do - assert_nil result - end - end - - describe "when used with the max_depth plugin" do - let(:schema) do - Class.new(GraphQL::Schema) do - query Dummy::DairyAppQuery - - max_depth 3 - max_complexity 1 - end - end - - let(:query_string) {%| - { - a: cheese(id: 1) { ...cheeseFields } - b: cheese(id: 1) { ...cheeseFields } - c: cheese(id: 1) { ...cheeseFields } - d: cheese(id: 1) { ...cheeseFields } - e: cheese(id: 1) { ...cheeseFields } - } - - fragment cheeseFields on Cheese { id } - |} - let(:result) { schema.execute(query_string) } - - it "returns a complexity error" do - assert_equal "Query has complexity of 10, which exceeds max complexity of 1", result["errors"].first["message"] - end - end - - describe "across a multiplex" do - before do - schema.analysis_engine = GraphQL::Analysis::AST - end - - let(:queries) { - 5.times.map { |n| - GraphQL::Query.new(schema, "{ cheese(id: #{n}) { id } }", variables: {}) - } - } - - let(:max_complexity) { 9 } - let(:multiplex) { GraphQL::Execution::Multiplex.new(schema: schema, queries: queries, context: {}, max_complexity: max_complexity) } - let(:analyze_multiplex) { - GraphQL::Analysis::AST.analyze_multiplex(multiplex, [GraphQL::Analysis::AST::MaxQueryComplexity]) - } - - it "returns errors for all queries" do - analyze_multiplex - err_msg = "Query has complexity of 10, which exceeds max complexity of 9" - queries.each do |query| - assert_equal err_msg, query.analysis_errors[0].message - end - end - - describe "with a local override" do - let(:max_complexity) { 10 } - - it "uses the override" do - analyze_multiplex - - queries.each do |query| - assert query.analysis_errors.empty? - end - end - end - end -end diff --git a/spec/graphql/analysis/ast/max_query_depth_spec.rb b/spec/graphql/analysis/ast/max_query_depth_spec.rb deleted file mode 100644 index 8815bdb0eff..00000000000 --- a/spec/graphql/analysis/ast/max_query_depth_spec.rb +++ /dev/null @@ -1,154 +0,0 @@ -# frozen_string_literal: true -require "spec_helper" - -describe GraphQL::Analysis::AST::MaxQueryDepth do - let(:schema) { - schema = Class.new(Dummy::Schema) - schema.analysis_engine = GraphQL::Analysis::AST - schema - } - let(:query_string) { " - { - cheese(id: 1) { - similarCheese(source: SHEEP) { - similarCheese(source: SHEEP) { - similarCheese(source: SHEEP) { - similarCheese(source: SHEEP) { - similarCheese(source: SHEEP) { - id - } - } - } - } - } - } - } - "} - let(:max_depth) { nil } - let(:query) { - # Don't override `schema.max_depth` with `nil` - options = max_depth ? { max_depth: max_depth } : {} - GraphQL::Query.new( - schema, - query_string, - variables: {}, - **options - ) - } - let(:result) { - GraphQL::Analysis::AST.analyze_query(query, [GraphQL::Analysis::AST::MaxQueryDepth]).first - } - let(:multiplex) { - GraphQL::Execution::Multiplex.new( - schema: schema, - queries: [query.dup, query.dup], - context: {}, - max_complexity: nil - ) - } - let(:multiplex_result) { - GraphQL::Analysis::AST.analyze_multiplex(multiplex, [GraphQL::Analysis::AST::MaxQueryDepth]).first - } - - describe "when the query is deeper than max depth" do - let(:max_depth) { 5 } - - it "adds an error message for a too-deep query" do - assert_equal "Query has depth of 7, which exceeds max depth of 5", result.message - end - end - - describe "when a multiplex queries is deeper than max depth" do - before do - schema.max_depth = 5 - end - - it "adds an error message for a too-deep query on from multiplex analyzer" do - assert_equal "Query has depth of 7, which exceeds max depth of 5", multiplex_result.message - end - end - - describe "when the query specifies a different max_depth" do - let(:max_depth) { 100 } - - it "obeys that max_depth" do - assert_nil result - end - end - - describe "When the query is not deeper than max_depth" do - before do - schema.max_depth = 100 - end - - it "doesn't add an error" do - assert_nil result - end - end - - describe "when the max depth isn't set" do - before do - schema.max_depth = nil - end - - it "doesn't add an error message" do - assert_nil result - end - end - - describe "when a fragment exceeds max depth" do - before do - schema.max_depth = 4 - end - - let(:query_string) { " - { - cheese(id: 1) { - ...moreFields - } - } - - fragment moreFields on Cheese { - similarCheese(source: SHEEP) { - similarCheese(source: SHEEP) { - similarCheese(source: SHEEP) { - ...evenMoreFields - } - } - } - } - - fragment evenMoreFields on Cheese { - similarCheese(source: SHEEP) { - similarCheese(source: SHEEP) { - id - } - } - } - "} - - it "adds an error message for a too-deep query" do - assert_equal "Query has depth of 7, which exceeds max depth of 4", result.message - end - end - - describe "when the query would cause a stack error" do - let(:query_string) { - str = "query { cheese(id: 1) { ".dup - n = 10_000 - n.times { str << "similarCheese(source: SHEEP) { " } - str << "id " - n.times { str << "} " } - str << "} }" - str - } - - it "returns an error" do - assert_equal ["This query is too large to execute."], query.result["errors"].map { |err| err["message"] } - - # Make sure `Schema.execute` works too - execute_result = schema.execute(query_string) - assert_equal ["This query is too large to execute."], execute_result["errors"].map { |err| err["message"] } - end - end -end diff --git a/spec/graphql/analysis/ast/query_complexity_spec.rb b/spec/graphql/analysis/ast/query_complexity_spec.rb deleted file mode 100644 index ab66a0ef5d2..00000000000 --- a/spec/graphql/analysis/ast/query_complexity_spec.rb +++ /dev/null @@ -1,400 +0,0 @@ -# frozen_string_literal: true -require "spec_helper" - -describe GraphQL::Analysis::AST::QueryComplexity do - let(:schema) { Dummy::Schema } - let(:reduce_result) { GraphQL::Analysis::AST.analyze_query(query, [GraphQL::Analysis::AST::QueryComplexity]) } - let(:reduce_multiplex_result) { - GraphQL::Analysis::AST.analyze_multiplex(multiplex, [GraphQL::Analysis::AST::QueryComplexity]) - } - let(:variables) { {} } - let(:query) { GraphQL::Query.new(schema, query_string, variables: variables) } - let(:multiplex) { - GraphQL::Execution::Multiplex.new( - schema: schema, - queries: [query.dup, query.dup], - context: {}, - max_complexity: 10 - ) - } - - describe "simple queries" do - let(:query_string) {%| - query cheeses($isSkipped: Boolean = false){ - # complexity of 3 - cheese1: cheese(id: 1) { - id - flavor - } - - # complexity of 4 - cheese2: cheese(id: 2) @skip(if: $isSkipped) { - similarCheese(source: SHEEP) { - ... on Cheese { - similarCheese(source: SHEEP) { - id - } - } - } - } - } - |} - - it "sums the complexity" do - complexities = reduce_result.first - assert_equal 7, complexities - end - - describe "when skipped by directives" do - let(:variables) { { "isSkipped" => true } } - it "doesn't include skipped fields" do - complexity = reduce_result.first - assert_equal 3, complexity - end - end - end - - describe "query with fragments" do - let(:query_string) {%| - { - # complexity of 3 - cheese1: cheese(id: 1) { - id - flavor - } - - # complexity of 7 - cheese2: cheese(id: 2) { - ... cheeseFields1 - ... cheeseFields2 - } - } - - fragment cheeseFields1 on Cheese { - similarCow: similarCheese(source: COW) { - id - ... cheeseFields2 - } - } - - fragment cheeseFields2 on Cheese { - similarSheep: similarCheese(source: SHEEP) { - id - } - } - |} - - it "counts all fragment usages, not the definitions" do - complexity = reduce_result.first - assert_equal 10, complexity - end - - describe "mutually exclusive types" do - let(:query_string) {%| - { - favoriteEdible { - # 1 for everybody - fatContent - - # 1 for everybody - ... on Edible { - origin - } - - # 1 for honey, aspartame - ... on Sweetener { - sweetness - } - - # 2 for milk - ... milkFields - # 1 for cheese - ... cheeseFields - # 1 for honey - ... honeyFields - # 1 for milk + cheese - ... dairyProductFields - } - } - - fragment milkFields on Milk { - id - source - } - - fragment cheeseFields on Cheese { - source - } - - fragment honeyFields on Honey { - flowerType - } - - fragment dairyProductFields on DairyProduct { - ... on Cheese { - flavor - } - - ... on Milk { - flavors - } - } - |} - - it "gets the max among options" do - complexity = reduce_result.first - assert_equal 6, complexity - end - end - - - describe "when there are no selections on any object types" do - let(:query_string) {%| - { - # 1 for everybody - favoriteEdible { - # 1 for everybody - fatContent - - # 1 for everybody - ... on Edible { origin } - - # 1 for honey, aspartame - ... on Sweetener { sweetness } - } - } - |} - - it "gets the max among interface types" do - complexity = reduce_result.first - assert_equal 4, complexity - end - end - - describe "redundant fields" do - let(:query_string) {%| - { - favoriteEdible { - fatContent - # this is executed separately and counts separately: - aliasedFatContent: fatContent - - ... on Edible { - fatContent - } - - ... edibleFields - } - } - - fragment edibleFields on Edible { - fatContent - } - |} - - it "only counts them once" do - complexity = reduce_result.first - assert_equal 3, complexity - end - end - - describe "redundant fields not within a fragment" do - let(:query_string) {%| - { - cheese { - id - } - - cheese { - id - } - } - |} - - it "only counts them once" do - complexity = reduce_result.first - assert_equal 2, complexity - end - end - end - - describe "relay types" do - let(:query) { GraphQL::Query.new(StarWars::Schema, query_string) } - let(:query_string) {%| - { - rebels { - ships { - edges { - node { - id - } - } - pageInfo { - hasNextPage - } - } - } - } - |} - - it "gets the complexity" do - complexity = reduce_result.first - assert_equal 7, complexity - end - end - - describe "calucation complexity for a multiplex" do - let(:query_string) {%| - query cheeses { - cheese(id: 1) { - id - flavor - source - } - } - |} - - - it "sums complexity for both queries" do - complexity = reduce_multiplex_result.first - assert_equal 8, complexity - end - - describe "abstract type" do - let(:query_string) {%| - query Edible { - allEdible { - origin - fatContent - } - } - |} - it "sums complexity for both queries" do - complexity = reduce_multiplex_result.first - assert_equal 6, complexity - end - end - end - - describe "custom complexities" do - class CustomComplexitySchema < GraphQL::Schema - module ComplexityInterface - include GraphQL::Schema::Interface - field :value, Int, null: true - end - - class SingleComplexity < GraphQL::Schema::Object - field :value, Int, null: true, complexity: 0.1 - field :complexity, SingleComplexity, null: true do - argument :int_value, Int, required: false - complexity(->(ctx, args, child_complexity) { args[:int_value] + child_complexity }) - end - implements ComplexityInterface - end - - class DoubleComplexity < GraphQL::Schema::Object - field :value, Int, null: true, complexity: 4 - implements ComplexityInterface - end - - class Query < GraphQL::Schema::Object - field :complexity, SingleComplexity, null: true do - argument :int_value, Int, required: false - complexity ->(ctx, args, child_complexity) { args[:int_value] + child_complexity } - end - - field :inner_complexity, ComplexityInterface, null: true do - argument :value, Int, required: false - end - end - - query(Query) - orphan_types(DoubleComplexity) - end - - let(:query) { GraphQL::Query.new(complexity_schema, query_string) } - let(:complexity_schema) { CustomComplexitySchema } - let(:query_string) {%| - { - a: complexity(intValue: 3) { value } - b: complexity(intValue: 6) { - value - complexity(intValue: 1) { - value - } - } - } - |} - - it "sums the complexity" do - complexity = reduce_result.first - # 10 from `complexity`, `0.3` from `value` - assert_equal 10.3, complexity - end - - describe "same field on multiple types" do - let(:query_string) {%| - { - innerComplexity(intValue: 2) { - ... on SingleComplexity { value } - ... on DoubleComplexity { value } - } - } - |} - - it "picks them max for those fields" do - complexity = reduce_result.first - # 1 for innerComplexity + 4 for DoubleComplexity.value - assert_equal 5, complexity - end - end - end - - describe "field_complexity hook" do - class CustomComplexityAnalyzer < GraphQL::Analysis::AST::QueryComplexity - def initialize(query) - super - @field_complexities_by_query = {} - end - - def result - super - @field_complexities_by_query[@query] - end - - private - - def field_complexity(scoped_type_complexity, max_complexity:, child_complexity:) - @field_complexities_by_query[scoped_type_complexity.query] ||= {} - @field_complexities_by_query[scoped_type_complexity.query][scoped_type_complexity.response_path] = { - max_complexity: max_complexity, - child_complexity: child_complexity, - } - end - end - - let(:reduce_result) { GraphQL::Analysis::AST.analyze_query(query, [CustomComplexityAnalyzer]) } - - let(:query_string) {%| - { - cheese { - id - } - - cheese { - id - flavor - } - } - |} - it "gets called for each field with complexity data" do - field_complexities = reduce_result.first - - assert_equal({ - ['cheese', 'id'] => { max_complexity: 1, child_complexity: nil }, - ['cheese', 'flavor'] => { max_complexity: 1, child_complexity: nil }, - ['cheese'] => { max_complexity: 3, child_complexity: 2 }, - }, field_complexities) - end - end -end diff --git a/spec/graphql/analysis/ast_spec.rb b/spec/graphql/analysis/ast_spec.rb deleted file mode 100644 index 698837f2786..00000000000 --- a/spec/graphql/analysis/ast_spec.rb +++ /dev/null @@ -1,294 +0,0 @@ -# frozen_string_literal: true -require "spec_helper" - -describe GraphQL::Analysis::AST do - class AstTypeCollector < GraphQL::Analysis::AST::Analyzer - def initialize(query) - super - @types = [] - end - - def on_enter_operation_definition(node, parent, visitor) - @types << visitor.type_definition - end - - def on_enter_field(memo, node, visitor) - @types << visitor.field_definition.type.unwrap - end - - def result - @types - end - end - - class AstNodeCounter < GraphQL::Analysis::AST::Analyzer - def initialize(query) - super - @nodes = Hash.new { |h,k| h[k] = 0 } - end - - def on_enter_abstract_node(node, parent, _visitor) - @nodes[node.class] += 1 - end - - def result - @nodes - end - end - - class AstConditionalAnalyzer < GraphQL::Analysis::AST::Analyzer - def initialize(query) - super - @i_have_been_called = false - end - - def analyze? - !!query.context[:analyze] - end - - def on_operation_definition(node, parent, visitor) - @i_have_been_called = true - end - - def result - @i_have_been_called - end - end - - class AstErrorAnalyzer < GraphQL::Analysis::AST::Analyzer - def result - GraphQL::AnalysisError.new("An Error!") - end - end - - class AstPreviousField < GraphQL::Analysis::AST::Analyzer - def on_enter_field(node, parent, visitor) - @previous_field = visitor.previous_field_definition - end - - def result - @previous_field - end - end - - class AstArguments < GraphQL::Analysis::AST::Analyzer - def on_enter_argument(node, parent, visitor) - @argument = visitor.argument_definition - @previous_argument = visitor.previous_argument_definition - end - - def result - [@argument, @previous_argument] - end - end - - describe "using the AST analysis engine" do - let(:schema) do - query_type = Class.new(GraphQL::Schema::Object) do - graphql_name 'Query' - - field :foobar, Integer, null: false - - def foobar - 1337 - end - end - - Class.new(GraphQL::Schema) do - query query_type - query_analyzer AstErrorAnalyzer - end - end - - let(:query_string) {%| - query { - foobar - } - |} - - let(:query) { GraphQL::Query.new(schema, query_string, variables: {}) } - - it "runs the AST analyzers correctly" do - res = query.result - refute res.key?("data") - assert_equal ["An Error!"], res["errors"].map { |e| e["message"] } - end - - it "skips rewrite" do - # Try running the query: - query.result - # But the validation step doesn't build an irep_node tree - assert_nil query.irep_selection - end - - describe "when validate: false" do - let(:query) { GraphQL::Query.new(schema, query_string, validate: false) } - it "Skips rewrite" do - # Try running the query: - query.result - # But the validation step doesn't build an irep_node tree - assert_nil query.irep_selection - end - end - end - - describe ".analyze_query" do - let(:analyzers) { [AstTypeCollector, AstNodeCounter] } - let(:reduce_result) { GraphQL::Analysis::AST.analyze_query(query, analyzers) } - let(:variables) { {} } - let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables) } - let(:query_string) {%| - { - cheese(id: 1) { - id - flavor - } - } - |} - - describe "without a valid operation" do - let(:query_string) {%| - # A comment - # is an invalid operation - # Should break - |} - - it "bails early when there is no selected operation to be executed" do - assert_equal 2, reduce_result.size - end - end - - describe "conditional analysis" do - let(:analyzers) { [AstTypeCollector, AstConditionalAnalyzer] } - - describe "when analyze? returns false" do - let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables, context: { analyze: false }) } - - it "does not run the analyzer" do - # Only type_collector ran - assert_equal 1, reduce_result.size - end - end - - describe "when analyze? returns true" do - let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables, context: { analyze: true }) } - - it "it runs the analyzer" do - # Both analyzers ran - assert_equal 2, reduce_result.size - end - end - - describe "Visitor#previous_field_definition" do - let(:analyzers) { [AstPreviousField] } - let(:query) { GraphQL::Query.new(Dummy::Schema, "{ __schema { types { name } } }") } - - it "it runs the analyzer" do - prev_field = reduce_result.first - assert_equal "__Schema.types", prev_field.path - end - end - - describe "Visitor#argument_definition" do - let(:analyzers) { [AstArguments] } - let(:query) do - GraphQL::Query.new( - Dummy::Schema, - '{ searchDairy(product: [{ source: "SHEEP" }]) { ... on Cheese { id } } }' - ) - end - - it "it runs the analyzer" do - argument, prev_argument = reduce_result.first - assert_equal "DairyProductInput.source", argument.path - assert_equal "Query.searchDairy.product", prev_argument.path - end - end - end - - it "calls the defined analyzers" do - collected_types, node_counts = reduce_result - expected_visited_types = [ - Dummy::DairyAppQuery, - Dummy::Cheese, - GraphQL::Types::Int, - GraphQL::Types::String - ] - assert_equal expected_visited_types, collected_types - - expected_node_counts = { - GraphQL::Language::Nodes::OperationDefinition => 1, - GraphQL::Language::Nodes::Field => 3, - GraphQL::Language::Nodes::Argument => 1 - } - - assert_equal expected_node_counts, node_counts - end - - describe "tracing" do - let(:query_string) { "{ t: __typename }"} - - it "emits traces" do - traces = TestTracing.with_trace do - ctx = { tracers: [TestTracing] } - Dummy::Schema.execute(query_string, context: ctx) - end - - # The query_trace is on the list _first_ because it finished first - _lex, _parse, _validate, query_trace, multiplex_trace, *_rest = traces - - assert_equal "analyze_multiplex", multiplex_trace[:key] - assert_instance_of GraphQL::Execution::Multiplex, multiplex_trace[:multiplex] - - assert_equal "analyze_query", query_trace[:key] - assert_instance_of GraphQL::Query, query_trace[:query] - end - end - - class AstConnectionCounter < GraphQL::Analysis::AST::Analyzer - def initialize(query) - super - @fields = 0 - @connections = 0 - end - - def on_enter_field(node, parent, visitor) - if visitor.field_definition.connection? - @connections += 1 - else - @fields += 1 - end - end - - def result - { - fields: @fields, - connections: @connections - } - end - end - - describe "when processing fields" do - let(:analyzers) { [AstConnectionCounter] } - let(:reduce_result) { GraphQL::Analysis::AST.analyze_query(query, analyzers) } - let(:query) { GraphQL::Query.new(StarWars::Schema, query_string, variables: variables) } - let(:query_string) {%| - query getBases { - empire { - basesByName(first: 30) { edges { cursor } } - bases(first: 30) { edges { cursor } } - } - } - |} - - it "knows which fields are connections" do - connection_counts = reduce_result.first - expected_connection_counts = { - :fields => 5, - :connections => 2 - } - assert_equal expected_connection_counts, connection_counts - end - end - end -end diff --git a/spec/graphql/analysis/field_usage_spec.rb b/spec/graphql/analysis/field_usage_spec.rb new file mode 100644 index 00000000000..2d82b7a7ef2 --- /dev/null +++ b/spec/graphql/analysis/field_usage_spec.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Analysis::FieldUsage do + let(:result) { GraphQL::Analysis.analyze_query(query, [GraphQL::Analysis::FieldUsage]).first } + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables) } + let(:variables) { {} } + + describe "query with deprecated fields" do + let(:query_string) {%| + query { + cheese(id: 1) { + id + fatContent + } + } + |} + + it "keeps track of used fields" do + assert_equal ['Cheese.id', 'Cheese.fatContent', 'Query.cheese'], result[:used_fields] + end + + it "keeps track of deprecated fields" do + assert_equal ['Cheese.fatContent'], result[:used_deprecated_fields] + end + end + + describe "query with deprecated fields used more than once" do + let(:query_string) {%| + query { + cheese1: cheese(id: 1) { + id + fatContent + } + + cheese2: cheese(id: 2) { + id + fatContent + } + } + |} + + it "omits duplicate usage of a field" do + assert_equal ['Cheese.id', 'Cheese.fatContent', 'Query.cheese'], result[:used_fields] + end + + it "omits duplicate usage of a deprecated field" do + assert_equal ['Cheese.fatContent'], result[:used_deprecated_fields] + end + end + + describe "query with deprecated fields in a fragment" do + let(:query_string) {%| + query { + cheese(id: 1) { + id + ...CheeseSelections + } + } + fragment CheeseSelections on Cheese { + fatContent + } + |} + + it "keeps track of fields used in the fragment" do + assert_equal ['Cheese.id', 'Cheese.fatContent', 'Query.cheese'], result[:used_fields] + end + + it "keeps track of deprecated fields used in the fragment" do + assert_equal ['Cheese.fatContent'], result[:used_deprecated_fields] + end + end + + describe "query with deprecated fields in an inline fragment" do + let(:query_string) {%| + query { + cheese(id: 1) { + id + ... on Cheese { + fatContent + } + } + } + |} + + it "keeps track of fields used in the fragment" do + assert_equal ['Cheese.id', 'Cheese.fatContent', 'Query.cheese'], result[:used_fields] + end + + it "keeps track of deprecated fields used in the fragment" do + assert_equal ['Cheese.fatContent'], result[:used_deprecated_fields] + end + end + + describe "query with deprecated arguments" do + let(:query_string) {%| + query { + fromSource(oldSource: "deprecated") { + id + } + } + |} + + it "keeps track of deprecated arguments" do + assert_equal ['Query.fromSource.oldSource'], result[:used_deprecated_arguments] + end + end + + describe "query with deprecated arguments used more than once" do + let(:query_string) {%| + query { + fromSource(oldSource: "deprecated1") { + id + } + + fromSource(oldSource: "deprecated2") { + id + } + } + |} + + it "omits duplicate usage of a deprecated argument" do + assert_equal ['Query.fromSource.oldSource'], result[:used_deprecated_arguments] + end + end + + describe "query with deprecated arguments nested in an array argument" do + let(:query_string) {%| + query { + searchDairy(product: [{ oldSource: "deprecated" }]) { + __typename + } + } + |} + + it "keeps track of nested deprecated arguments" do + assert_equal ['DairyProductInput.oldSource'], result[:used_deprecated_arguments] + end + end + + describe "query with deprecated enum argument" do + let(:query_string) {%| + query { + fromSource(source: YAK) { + id + } + } + |} + + it "keeps track of deprecated arguments" do + assert_equal ['DairyAnimal.YAK'], result[:used_deprecated_enum_values] + end + + describe "tracks non-null/list enums" do + let(:query_string) {%| + query { + cheese(id: 1) { + similarCheese(source: [YAK]) { + id + } + } + } + |} + + it "keeps track of deprecated arguments" do + assert_equal ['DairyAnimal.YAK'], result[:used_deprecated_enum_values] + end + end + end + + describe "query with an array argument sent as null" do + let(:query_string) {%| + query { + searchDairy(product: null) { + __typename + } + } + |} + + it "tolerates null for array argument" do + result + end + end + + describe "query with an input object sent in as null" do + let(:query_string) {%| + query { + cheese(id: 1) { + id + dairyProduct(input: null) { + __typename + } + } + } + |} + + it "tolerates null for object argument" do + result + end + end + + describe "query with deprecated arguments nested in an argument" do + let(:query_string) {%| + query { + searchDairy(singleProduct: { oldSource: "deprecated" }) { + __typename + } + } + |} + + it "keeps track of nested deprecated arguments" do + assert_equal ['DairyProductInput.oldSource'], result[:used_deprecated_arguments] + end + end + + describe "query with arguments nested in a deprecated argument" do + let(:query_string) {%| + query { + searchDairy(oldProduct: [{ source: "sheep" }]) { + __typename + } + } + |} + + it "keeps track of top-level deprecated arguments" do + assert_equal ['Query.searchDairy.oldProduct'], result[:used_deprecated_arguments] + end + end + + describe "query with scalar arguments nested in a deprecated argument" do + let(:query_string) {%| + query { + searchDairy(productIds: ["123"]) { + __typename + } + } + |} + + it "keeps track of top-level deprecated arguments" do + assert_equal ['Query.searchDairy.productIds'], result[:used_deprecated_arguments] + end + end + + + describe "mutation with deprecated argument" do + let(:query_string) {%| + mutation { + pushValue(deprecatedTestInput: { oldSource: "deprecated" }) + } + |} + + it "keeps track of nested deprecated arguments" do + assert_equal ['DairyProductInput.oldSource'], result[:used_deprecated_arguments] + end + end + + describe "mutation with deprecated arguments with prepared values" do + let(:query_string) {%| + mutation { + pushValue(preparedTestInput: { deprecatedDate: "2020-10-10" }) + } + |} + + it "keeps track of nested deprecated arguments" do + assert_equal ['PreparedDateInput.deprecatedDate'], result[:used_deprecated_arguments] + end + end + + describe "when an argument prepare raises a GraphQL::ExecutionError" do + class ArgumentErrorFieldUsageSchema < GraphQL::Schema + class FieldUsage < GraphQL::Analysis::FieldUsage + def result + values = super + query.context[:field_usage] = values + nil + end + end + + class Query < GraphQL::Schema::Object + field :f, Int do + argument :i, Int, prepare: ->(*) { raise GraphQL::ExecutionError.new("boom!") } + end + end + + query(Query) + query_analyzer(FieldUsage) + end + + it "skips analysis of those arguments" do + res = ArgumentErrorFieldUsageSchema.execute("{ f(i: 1) }") + assert_equal ["boom!"], res["errors"].map { |e| e["message"] } + assert_equal({used_fields: ["Query.f"], used_deprecated_arguments: [], used_deprecated_fields: [], used_deprecated_enum_values: []}, res.context[:field_usage]) + end + end +end diff --git a/spec/graphql/analysis/max_query_complexity_spec.rb b/spec/graphql/analysis/max_query_complexity_spec.rb index 189beddfd5e..4044627428b 100644 --- a/spec/graphql/analysis/max_query_complexity_spec.rb +++ b/spec/graphql/analysis/max_query_complexity_spec.rb @@ -3,7 +3,6 @@ describe GraphQL::Analysis::MaxQueryComplexity do let(:schema) { Class.new(Dummy::Schema) } - let(:result) { schema.execute(query_string) } let(:query_string) {%| { a: cheese(id: 1) { id } @@ -13,29 +12,34 @@ e: cheese(id: 1) { id } } |} + let(:query) { GraphQL::Query.new(schema, query_string, variables: {}, max_complexity: max_complexity) } + let(:result) { + GraphQL::Analysis.analyze_query(query, [GraphQL::Analysis::MaxQueryComplexity]).first + } + describe "when a query goes over max complexity" do - before do - schema.max_complexity(9) - end + let(:max_complexity) { 9 } it "returns an error" do - assert_equal "Query has complexity of 10, which exceeds max complexity of 9", result["errors"][0]["message"] + assert_equal GraphQL::AnalysisError, result.class + assert_equal "Query has complexity of 10, which exceeds max complexity of 9", result.message end end describe "when there is no max complexity" do + let(:max_complexity) { nil } + it "doesn't error" do - assert_nil result["errors"] + assert_nil result end end describe "when the query is less than the max complexity" do - before do - schema.max_complexity(99) - end + let(:max_complexity) { 99 } + it "doesn't error" do - assert_nil result["errors"] + assert_nil result end end @@ -43,10 +47,12 @@ before do schema.max_complexity(100) end - let(:result) {schema.execute(query_string, max_complexity: 7) } + + let(:max_complexity) { 7 } it "is applied" do - assert_equal "Query has complexity of 10, which exceeds max complexity of 7", result["errors"][0]["message"] + assert_equal GraphQL::AnalysisError, result.class + assert_equal "Query has complexity of 10, which exceeds max complexity of 7", result.message end end @@ -54,49 +60,168 @@ before do schema.max_complexity(1) end - let(:result) {schema.execute(query_string, max_complexity: 10) } + + let(:max_complexity) { 10 } it "doesn't error" do - assert_nil result["errors"] + assert_nil result end end - describe "when max_complexity is nil query-level" do + describe "when max_complexity is nil at query-level" do + let(:max_complexity) { nil } + before do schema.max_complexity(1) end - let(:result) {schema.execute(query_string, max_complexity: nil) } it "is applied" do - assert_nil result["errors"] + assert_nil result + end + end + + describe "when used with the max_depth plugin" do + let(:schema) do + Class.new(GraphQL::Schema) do + query Dummy::DairyAppQuery + + max_depth 3 + max_complexity 1 + end + end + + let(:query_string) {%| + { + a: cheese(id: 1) { ...cheeseFields } + b: cheese(id: 1) { ...cheeseFields } + c: cheese(id: 1) { ...cheeseFields } + d: cheese(id: 1) { ...cheeseFields } + e: cheese(id: 1) { ...cheeseFields } + } + + fragment cheeseFields on Cheese { id } + |} + let(:result) { schema.execute(query_string) } + + it "returns a complexity error" do + assert_equal "Query has complexity of 10, which exceeds max complexity of 1", result["errors"].first["message"] + end + end + + describe "count_introspection_fields: false" do + let(:schema) { Class.new(Dummy::Schema) { max_complexity(5) } } + let(:skip_introspection_schema) { Class.new(Dummy::Schema) do + max_complexity 5, count_introspection_fields: false + end + } + + it "skips introspection fields when configured" do + query_string = "{ c1: cheese(id: 1) { id __typename } c2: cheese(id: 2) { id __typename } }" + res = schema.execute(query_string) + expected_msg = "Query has complexity of 6, which exceeds max complexity of 5" + assert_equal [expected_msg], res["errors"].map { |e| e["message"]} + + res2 = skip_introspection_schema.execute(query_string) + assert_equal 2, res2["data"].size + refute res2.key?("errors") end end describe "across a multiplex" do before do - schema.max_complexity(9) + schema.analysis_engine = GraphQL::Analysis::AST end - let(:queries) { 5.times.map { |n| { query: "{ cheese(id: #{n}) { id } }" } } } + let(:queries) { + 5.times.map { |n| + GraphQL::Query.new(schema, "{ cheese(id: #{n}) { id } }", variables: {}) + } + } + + let(:max_complexity) { 9 } + let(:multiplex) { GraphQL::Execution::Multiplex.new(schema: schema, queries: queries, context: {}, max_complexity: max_complexity) } + let(:analyze_multiplex) { + GraphQL::Analysis.analyze_multiplex(multiplex, [GraphQL::Analysis::MaxQueryComplexity]) + } it "returns errors for all queries" do - results = schema.multiplex(queries) - assert_equal 5, results.length + analyze_multiplex err_msg = "Query has complexity of 10, which exceeds max complexity of 9" - results.each do |res| - assert_equal err_msg, res["errors"][0]["message"] + queries.each do |query| + assert_equal err_msg, query.analysis_errors[0].message end end describe "with a local override" do + let(:max_complexity) { 10 } + it "uses the override" do - results = schema.multiplex(queries, max_complexity: 10) - assert_equal 5, results.length - results.each do |res| - assert_equal true, res.key?("data") - assert_equal false, res.key?("errors") + analyze_multiplex + + queries.each do |query| + assert query.analysis_errors.empty? + end + end + end + end + + describe "when an argument is unauthorized by type" do + class AuthorizedTypeSchema < GraphQL::Schema + class Thing < GraphQL::Schema::Object + def self.authorized?(obj, ctx) + !!ctx[:authorized] && super + end + field :name, String, hash_key: :name + end + + class Query < GraphQL::Schema::Object + field :things, Thing.connection_type, resolve_static: true do + argument :thing_id, ID, loads: Thing + end + + def self.things(context, thing:) + [thing] + end + + def things(thing:) + self.class.things(context, thing: thing) end end + + query(Query) + def self.resolve_type(abs_type, object, ctx) + Thing + end + + def self.object_from_id(id, ctx) + if id == "13" + raise GraphQL::ExecutionError, "No Thing ##{id}" + else + { name: "Loaded thing #{id}" } + end + end + + def self.unauthorized_object(err) + raise GraphQL::ExecutionError, "Unauthorized Object: #{err.object[:name].inspect}" + end + + default_max_page_size 30 + max_complexity 10 + end + + it "when the arg is unauthorized, returns an authorization error, not a complexity error" do + query_str = "{ things(thingId: \"123\", first: 1) { nodes { name } } }" + res = AuthorizedTypeSchema.execute(query_str, context: { authorized: true }) + assert_equal "Loaded thing 123", res["data"]["things"]["nodes"].first["name"] + + res2 = AuthorizedTypeSchema.execute(query_str) + assert_equal ["Unauthorized Object: \"Loaded thing 123\""], res2["errors"].map { |e| e["message"] } + end + + it "returns the right error when the loaded object raises an error" do + query_str = "{ things(thingId: \"13\", first: 1) { nodes { name } } }" + res = AuthorizedTypeSchema.execute(query_str, context: { authorized: true }) + assert_equal ["No Thing #13"], res["errors"].map { |e| e["message"] } end end end diff --git a/spec/graphql/analysis/max_query_depth_spec.rb b/spec/graphql/analysis/max_query_depth_spec.rb index 3881fc49ab4..de14b63a18c 100644 --- a/spec/graphql/analysis/max_query_depth_spec.rb +++ b/spec/graphql/analysis/max_query_depth_spec.rb @@ -2,8 +2,11 @@ require "spec_helper" describe GraphQL::Analysis::MaxQueryDepth do - let(:schema) { Class.new(Dummy::Schema) } - let(:result) { schema.execute(query_string) } + let(:schema) { + schema = Class.new(Dummy::Schema) + schema.analysis_engine = GraphQL::Analysis::AST + schema + } let(:query_string) { " { cheese(id: 1) { @@ -21,53 +24,81 @@ } } "} + let(:max_depth) { nil } + let(:query) { + # Don't override `schema.max_depth` with `nil` + options = max_depth ? { max_depth: max_depth } : {} + GraphQL::Query.new( + schema, + query_string, + variables: {}, + **options + ) + } + let(:result) { + GraphQL::Analysis.analyze_query(query, [GraphQL::Analysis::MaxQueryDepth]).first + } + let(:multiplex) { + GraphQL::Execution::Multiplex.new( + schema: schema, + queries: [query.dup, query.dup], + context: {}, + max_complexity: nil + ) + } + let(:multiplex_result) { + GraphQL::Analysis.analyze_multiplex(multiplex, [GraphQL::Analysis::MaxQueryDepth]).first + } describe "when the query is deeper than max depth" do + let(:max_depth) { 5 } + it "adds an error message for a too-deep query" do - assert_equal "Query has depth of 7, which exceeds max depth of 5", result["errors"][0]["message"] + assert_equal "Query has depth of 7, which exceeds max depth of 5", result.message end end - describe "when the query specifies a different max_depth" do - let(:result) { schema.execute(query_string, max_depth: 100) } + describe "when a multiplex queries is deeper than max depth" do + before do + schema.max_depth = 5 + end - it "obeys that max_depth" do - assert_nil result["errors"] + it "adds an error message for a too-deep query on from multiplex analyzer" do + assert_equal "Query has depth of 7, which exceeds max depth of 5", multiplex_result.message end end - describe "when the query specifies a nil max_depth" do - let(:result) { schema.execute(query_string, max_depth: nil) } + describe "when the query specifies a different max_depth" do + let(:max_depth) { 100 } it "obeys that max_depth" do - assert_nil result["errors"] + assert_nil result end end describe "When the query is not deeper than max_depth" do before do - schema.max_depth(100) + schema.max_depth = 100 end it "doesn't add an error" do - assert_nil result["errors"] + assert_nil result end end describe "when the max depth isn't set" do before do - # Yuck - Can't override GraphQL::Schema.max_depth to return nil if it has already been set - schema.define_singleton_method(:max_depth) { |*| nil } + schema.max_depth = nil end it "doesn't add an error message" do - assert_nil result["errors"] + assert_nil result end end describe "when a fragment exceeds max depth" do before do - schema.max_depth(4) + schema.max_depth = 4 end let(:query_string) { " @@ -97,7 +128,52 @@ "} it "adds an error message for a too-deep query" do - assert_equal 1, result["errors"].length + assert_equal "Query has depth of 7, which exceeds max depth of 4", result.message + end + end + + describe "when the query would cause a stack error" do + let(:query_string) { + str = "query { cheese(id: 1) { ".dup + n = 10_000 + n.times { str << "similarCheese(source: SHEEP) { " } + str << "id " + n.times { str << "} " } + str << "} }" + str + } + + it "returns an error" do + assert_equal ["This query is too large to execute."], query.result["errors"].map { |err| err["message"] } + + # Make sure `Schema.execute` works too + execute_result = schema.execute(query_string) + assert_equal ["This query is too large to execute."], execute_result["errors"].map { |err| err["message"] } end end + + it "counts introspection fields by default, but can be set to skip" do + schema.max_depth = 3 + query_str = <<-GRAPHQL + { + __type(name: \"Abc\") { + fields { + type { + ofType { + name + } + } + } + } + } + GRAPHQL + + result = schema.execute(query_str) + assert_equal ["Query has depth of 5, which exceeds max depth of 3"], result["errors"].map { |e| e["message"] } + + schema.max_depth(3, count_introspection_fields: false) + + result = schema.execute(query_str) + assert_equal({ "__type" => nil }, result["data"]) + end end diff --git a/spec/graphql/analysis/query_complexity_spec.rb b/spec/graphql/analysis/query_complexity_spec.rb new file mode 100644 index 00000000000..52c86ff49d3 --- /dev/null +++ b/spec/graphql/analysis/query_complexity_spec.rb @@ -0,0 +1,996 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Analysis::QueryComplexity do + let(:schema) { Class.new(Dummy::Schema) { complexity_cost_calculation_mode(:future) } } + let(:reduce_result) { GraphQL::Analysis.analyze_query(query, [GraphQL::Analysis::QueryComplexity]) } + let(:reduce_multiplex_result) { + GraphQL::Analysis.analyze_multiplex(multiplex, [GraphQL::Analysis::QueryComplexity]) + } + let(:variables) { {} } + let(:query_context) { {} } + let(:query) { GraphQL::Query.new(schema, query_string, context: query_context, variables: variables) } + let(:multiplex) { + GraphQL::Execution::Multiplex.new( + schema: schema, + queries: [query.dup, query.dup], + context: {}, + max_complexity: 10 + ) + } + + describe "simple queries" do + let(:query_string) {%| + query cheeses { + # complexity of 3 + cheese1: cheese(id: 1) { + id + flavor + __typename + } + + # complexity of 4 + cheese2: cheese(id: 2) { + similarCheese(source: SHEEP) { + ... on Cheese { + similarCheese(source: SHEEP) { + id + } + } + } + } + } + |} + + it "sums the complexity" do + complexities = reduce_result.first + assert_equal 8, complexities + end + end + + describe "with skip/include" do + let(:query_string) {%| + query cheeses($skip: Boolean = false, $include: Boolean = true) { + fields: cheese(id: 1) { + flavor + origin @skip(if: $skip) + source @include(if: $include) + } + inlineFragments: cheese(id: 1) { + ...on Cheese { flavor } + ...on Cheese @skip(if: $skip) { origin } + ...on Cheese @include(if: $include) { source } + } + fragmentSpreads: cheese(id: 1) { + ...Flavorful + ...Original @skip(if: $skip) + ...Sourced @include(if: $include) + } + } + fragment Flavorful on Cheese { flavor } + fragment Original on Cheese { origin } + fragment Sourced on Cheese { source } + |} + + it "sums up all included complexities" do + assert_equal 12, reduce_result.first + end + + describe "when skipped by directives" do + let(:variables) { { "skip" => true, "include" => false } } + it "doesn't include skipped fields and fragments" do + assert_equal 6, reduce_result.first + end + end + end + + describe "query with fragments" do + let(:query_string) {%| + { + # complexity of 3 + cheese1: cheese(id: 1) { + id + flavor + } + + # complexity of 7 + cheese2: cheese(id: 2) { + ... cheeseFields1 + ... cheeseFields2 + } + } + + fragment cheeseFields1 on Cheese { + similarCow: similarCheese(source: COW) { + id + ... cheeseFields2 + } + } + + fragment cheeseFields2 on Cheese { + similarSheep: similarCheese(source: SHEEP) { + id + } + } + |} + + it "counts all fragment usages, not the definitions" do + complexity = reduce_result.first + assert_equal 10, complexity + end + + describe "mutually exclusive types" do + let(:query_string) {%| + { + favoriteEdible { + # 1 for everybody + fatContent + + # 1 for everybody + ... on Edible { + origin + } + + # 1 for honey, aspartame + ... on Sweetener { + sweetness + } + + # 2 for milk + ... milkFields + # 1 for cheese + ... cheeseFields + # 1 for honey + ... honeyFields + # 1 for milk + cheese + ... dairyProductFields + } + } + + fragment milkFields on Milk { + id + source + } + + fragment cheeseFields on Cheese { + source + } + + fragment honeyFields on Honey { + flowerType + } + + fragment dairyProductFields on DairyProduct { + ... on Cheese { + flavor + } + + ... on Milk { + flavors + } + } + |} + + it "gets the max among options" do + complexity = reduce_result.first + assert_equal 6, complexity + end + end + + + describe "when there are no selections on any object types" do + let(:query_string) {%| + { + # 1 for everybody + favoriteEdible { + # 1 for everybody + fatContent + + # 1 for everybody + ... on Edible { origin } + + # 1 for honey, aspartame + ... on Sweetener { sweetness } + } + } + |} + + it "gets the max among interface types" do + complexity = reduce_result.first + assert_equal 4, complexity + end + end + + describe "redundant fields" do + let(:query_string) {%| + { + favoriteEdible { + fatContent + # this is executed separately and counts separately: + aliasedFatContent: fatContent + + ... on Edible { + fatContent + } + + ... edibleFields + } + } + + fragment edibleFields on Edible { + fatContent + } + |} + + it "only counts them once" do + complexity = reduce_result.first + assert_equal 3, complexity + end + end + + describe "redundant fields not within a fragment" do + let(:query_string) {%| + { + cheese { + id + } + + cheese { + id + } + } + |} + + it "only counts them once" do + complexity = reduce_result.first + assert_equal 2, complexity + end + end + end + + describe "relay types" do + let(:schema) { Class.new(StarWars::Schema) { complexity_cost_calculation_mode(:future) } } + let(:query) { GraphQL::Query.new(schema, query_string) } + let(:query_string) {%| + { + rebels { + ships(first: 1) { + edges { + node { + id + } + } + nodes { + id + } + pageInfo { + hasNextPage + } + } + } + } + |} + + it "gets the complexity" do + complexity = reduce_result.first + expected_complexity = 1 + # rebels + 1 + # ships + 1 + # edges + 1 + # nodes + 1 + 1 + # pageInfo, hasNextPage + 1 + 1 + 1 # node, id, id + assert_equal expected_complexity, complexity + end + + describe "first/last" do + let(:query_string) {%| + { + rebels { + s1: ships(first: 5) { + edges { + node { + id + } + } + pageInfo { + hasNextPage + } + } + + s2: ships(last: 3) { + nodes { id } + } + } + } + |} + + it "uses first/last for calculating complexity" do + complexity = reduce_result.first + + expected_complexity = ( + 1 + # rebels + (1 + 1 + (5 * 2) + 2) + # s1 + (1 + 1 + (3 * 1) + 0) # s2 + ) + assert_equal expected_complexity, complexity + end + end + + describe "Field-level max_page_size" do + let(:query_string) {%| + { + rebels { + ships { + nodes { id } + } + } + } + |} + + it "uses field max_page_size" do + complexity = reduce_result.first + assert_equal 1 + 1 + 1 + (1000 * 1), complexity + end + end + + describe "Schema-level default_max_page_size" do + let(:query_string) {%| + { + rebels { + bases { + nodes { id } + totalCount + } + } + } + |} + + it "uses schema default_max_page_size" do + complexity = reduce_result.first + assert_equal 1 + 1 + 1 + (3 * 1) + 1, complexity + end + end + + describe "Field-level default_page_size" do + let(:query_string) {%| + { + rebels { + shipsWithDefaultPageSize { + nodes { id } + } + } + } + |} + + it "uses field default_page_size" do + complexity = reduce_result.first + assert_equal 1 + 1 + 1 + (500 * 1), complexity + end + end + + describe "Schema-level default_page_size" do + let(:schema) { Class.new(StarWars::SchemaWithDefaultPageSize) { complexity_cost_calculation_mode(:future) } } + let(:query) { GraphQL::Query.new(schema, query_string) } + let(:query_string) {%| + { + rebels { + bases { + nodes { id } + totalCount + } + } + } + |} + + it "uses schema default_page_size" do + complexity = reduce_result.first + assert_equal 1 + 1 + 1 + (2 * 1) + 1, complexity + end + end + end + + describe "calculation complexity for a multiplex" do + let(:query_string) {%| + query cheeses { + cheese(id: 1) { + id + flavor + source + } + } + |} + + + it "sums complexity for both queries" do + complexity = reduce_multiplex_result.first + assert_equal 8, complexity + end + + describe "abstract type" do + let(:query_string) {%| + query Edible { + allEdible { + origin + fatContent + } + } + |} + it "sums complexity for both queries" do + complexity = reduce_multiplex_result.first + assert_equal 6, complexity + end + end + end + + describe "custom complexities" do + class CustomComplexitySchema < GraphQL::Schema + module ComplexityInterface + include GraphQL::Schema::Interface + field :value, Int + end + + class SingleComplexity < GraphQL::Schema::Object + field :value, Int, complexity: 0.1 + field :complexity, SingleComplexity do + argument :int_value, Int, required: false + complexity(->(ctx, args, child_complexity) { args[:int_value] + child_complexity }) + end + implements ComplexityInterface + end + + class DoubleComplexity < GraphQL::Schema::Object + field :value, Int, complexity: 4 + implements ComplexityInterface + end + + class Query < GraphQL::Schema::Object + field :complexity, SingleComplexity do + argument :int_value, Int, required: false, prepare: ->(val, ctx) { + if ctx[:raise_prepare_error] + raise GraphQL::ExecutionError, "Boom" + else + val + end + } + complexity ->(ctx, args, child_complexity) { args[:int_value] + child_complexity } + end + + def complexity(int_value:) + { value: int_value } + end + + field :inner_complexity, ComplexityInterface do + argument :value, Int, required: false + end + end + + query(Query) + orphan_types(DoubleComplexity) + complexity_cost_calculation_mode(:future) + + module CustomIntrospection + class DynamicFields < GraphQL::Introspection::DynamicFields + field :__typename, String, complexity: 100 + end + + class EntryPoints < GraphQL::Introspection::EntryPoints + class CustomIntrospectionField < GraphQL::Schema::Field + def calculate_complexity(query:, nodes:, child_complexity:) + child_complexity + 0.6 + end + end + field_class CustomIntrospectionField + field :__schema, GraphQL::Schema::LateBoundType.new("__Schema") + end + end + + introspection(CustomIntrospection) + end + + let(:query) { GraphQL::Query.new(complexity_schema, query_string, context: query_context) } + let(:complexity_schema) { CustomComplexitySchema } + let(:query_string) {%| + { + a: complexity(intValue: 3) { value } + b: complexity(intValue: 6) { + value + complexity(intValue: 1) { + value + } + } + } + |} + + it "sums the complexity" do + complexity = reduce_result.first + # 10 from `complexity`, `0.3` from `value` + assert_equal 10.3, complexity + end + + describe "introspection" do + let(:query_string) { "{ __typename __schema { queryType } }"} + + it "does custom complexity for introspection" do + complexity = reduce_result.first + # 100 + 1 + 0.6 + assert_equal 101.6, complexity + end + end + + describe "same field on multiple types" do + let(:query_string) {%| + { + innerComplexity(intValue: 2) { + ... on SingleComplexity { value } + ... on DoubleComplexity { value } + } + } + |} + + it "picks them max for those fields" do + complexity = reduce_result.first + # 1 for innerComplexity + 4 for DoubleComplexity.value + assert_equal 5, complexity + end + end + + describe "when prepare raises an error" do + let(:query_string) { "{ complexity(intValue: 3) { value } }"} + let(:query_context) { { raise_prepare_error: true } } + + it "handles it nicely" do + result = query.result + assert_equal ["Boom"], result["errors"].map { |e| e["message"] } + complexity = reduce_result.first + assert_equal 0.1, complexity + end + end + end + + describe "custom complexities by complexity_for(...)" do + class CustomComplexityByMethodSchema < GraphQL::Schema + module ComplexityInterface + include GraphQL::Schema::Interface + field :value, Int + end + + class SingleComplexity < GraphQL::Schema::Object + field :value, Int, complexity: 0.1 + field :complexity, SingleComplexity do + argument :int_value, Int, required: false + + def complexity_for(query:, child_complexity:, lookahead:) + lookahead.arguments[:int_value] + child_complexity + end + end + implements ComplexityInterface + end + + class ComplexityFourField < GraphQL::Schema::Field + def complexity_for(query:, lookahead:, child_complexity:) + 4 + end + end + + class DoubleComplexity < GraphQL::Schema::Object + field_class ComplexityFourField + field :value, Int + implements ComplexityInterface + end + + class Thing < GraphQL::Schema::Object + field :name, String + end + + class CustomThingConnection < GraphQL::Types::Relay::BaseConnection + edge_type Thing.edge_type + field :something_special, String, complexity: 3 + end + + class Query < GraphQL::Schema::Object + field :complexity, SingleComplexity do + argument :int_value, Int, required: false + def complexity_for(query:, child_complexity:, lookahead:) + lookahead.arguments[:int_value] + child_complexity + end + end + + field :inner_complexity, ComplexityInterface do + argument :value, Int, required: false + end + + field :things, Thing.connection_type, max_page_size: 100 do + argument :count, Int, validates: { numericality: { less_than: 50 } } + end + + def things(count:) + count.times.map {|t| {name: t.to_s}} + end + + class ThingsCustom < GraphQL::Schema::Resolver + type CustomThingConnection, null: false + complexity 100 + + def resolve + 5.times { |t| { name: "Thing #{t}" } } + end + end + + field :things_custom, resolver: ThingsCustom + end + + query(Query) + orphan_types(DoubleComplexity) + complexity_cost_calculation_mode(:future) + end + + let(:query) { GraphQL::Query.new(complexity_schema, query_string) } + let(:complexity_schema) { CustomComplexityByMethodSchema } + let(:query_string) {%| + { + a: complexity(intValue: 3) { value } + b: complexity(intValue: 6) { + value + complexity(intValue: 1) { + value + } + } + } + |} + + it "inherits complexity_cost_calculation_mode" do + schema = Class.new(CustomComplexityByMethodSchema) + assert_equal CustomComplexityByMethodSchema.complexity_cost_calculation_mode, schema.complexity_cost_calculation_mode + end + + it "sums the complexity" do + complexity = reduce_result.first + # 10 from `complexity`, `0.3` from `value` + assert_equal 10.3, complexity + end + + describe "same field on multiple types" do + let(:query_string) {%| + { + innerComplexity(value: 2) { + ... on SingleComplexity { value } + ... on DoubleComplexity { value } + } + } + |} + + it "picks them max for those fields" do + complexity = reduce_result.first + # 1 for innerComplexity + 4 for DoubleComplexity.value + assert_equal 5, complexity + end + end + + describe "when the query fails validation" do + let(:query_string) {%| + { + things(count: 200, first: 5) { + nodes { name } + } + } + |} + it "handles the error" do + res = GraphQL::Query.new(complexity_schema, query_string).result + assert_equal ["count must be less than 50"], res["errors"].map { |e| e["message"] } + assert_equal [], reduce_result, "It doesn't finish calculation" + end + end + + describe "when connection fields have custom complexity" do + let(:query_string) { "{ thingsCustom(first: 2) { somethingSpecial nodes { name } } }"} + + it "uses the custom configured value" do + complexity = reduce_result.first + assert_equal 106, complexity + end + end + end + + describe "field_complexity hook" do + class CustomComplexityAnalyzer < GraphQL::Analysis::QueryComplexity + def initialize(query) + super + @field_complexities_by_query = {} + end + + def result + super + @field_complexities_by_query[@query] + end + + private + + def field_complexity(scoped_type_complexity, max_complexity:, child_complexity:) + @field_complexities_by_query[scoped_type_complexity.query] ||= {} + @field_complexities_by_query[scoped_type_complexity.query][scoped_type_complexity.response_path] = { + max_complexity: max_complexity, + child_complexity: child_complexity, + } + end + end + + let(:reduce_result) { GraphQL::Analysis.analyze_query(query, [CustomComplexityAnalyzer]) } + + let(:query_string) {%| + { + cheese { + id + } + + cheese { + id + flavor + } + } + |} + it "gets called for each field with complexity data" do + field_complexities = reduce_result.first + + assert_equal({ + ['cheese', 'id'] => { max_complexity: 1, child_complexity: 0 }, + ['cheese', 'flavor'] => { max_complexity: 1, child_complexity: 0 }, + ['cheese'] => { max_complexity: 3, child_complexity: 2 }, + }, field_complexities) + end + end + + describe "maximum of possible scopes regardless of selection order" do + class MaxOfPossibleScopes < GraphQL::Schema + class Cheese < GraphQL::Schema::Object + field :kind, String + end + + module Producer + include GraphQL::Schema::Interface + field :cheese, Cheese, complexity: 5 + field :name, String, complexity: 5 + end + + class Farm < GraphQL::Schema::Object + implements Producer + field :cheese, Cheese, complexity: 10 + field :name, String, complexity: 10 + end + + class Entity < GraphQL::Schema::Union + possible_types Farm + end + + class Query < GraphQL::Schema::Object + field :entity, Entity, fallback_value: nil + end + + def self.resolve_type + Farm + end + + def self.cost(query_string_or_query) + query = if query_string_or_query.is_a?(String) + GraphQL::Query.new(self, query_string_or_query) + else + query_string_or_query + end + + GraphQL::Analysis::AST.analyze_query( + query, + [GraphQL::Analysis::AST::QueryComplexity], + ).first + end + + query(Query) + end + + describe "in :future mode" do + let(:schema) { Class.new(MaxOfPossibleScopes) { complexity_cost_calculation_mode(:future) }} + it "uses maximum of merged composite fields, regardless of selection order" do + a = schema.cost(%| + { + entity { + ...on Producer { cheese { kind } } + ...on Farm { cheese { kind } } + } + } + |) + + b = schema.cost(%| + { + entity { + ...on Farm { cheese { kind } } + ...on Producer { cheese { kind } } + } + } + |) + + assert_equal 0, a - b + end + + it "uses maximum of merged leaf fields, regardless of selection order" do + a = schema.cost(%| + { + entity { + ...on Producer { name } + ...on Farm { name } + } + } + |) + + b = schema.cost(%| + { + entity { + ...on Farm { name } + ...on Producer { name } + } + } + |) + + assert_equal 0, a - b + end + end + + describe "in :legacy mode" do + let(:schema) { Class.new(MaxOfPossibleScopes) { complexity_cost_calculation_mode(:legacy) }} + it "uses the last of merged composite fields" do + a = schema.cost(%| + { + entity { + ...on Producer { cheese { kind } } + ...on Farm { cheese { kind } } + } + } + |) + + b = schema.cost(%| + { + entity { + ...on Farm { cheese { kind } } + ...on Producer { cheese { kind } } + } + } + |) + + assert_equal 5, a - b + end + + it "uses the last-occurring leaf field" do + a = schema.cost(%| + { + entity { + ...on Producer { name } + ...on Farm { name } + } + } + |) + + b = schema.cost(%| + { + entity { + ...on Farm { name } + ...on Producer { name } + } + } + |) + + assert_equal 5, a - b + end + end + + describe "In dynamic mode with :compare" do + let(:schema) { + Class.new(MaxOfPossibleScopes) do + def self.complexity_cost_calculation_mode_for(context) + :compare + end + + def self.legacy_complexity_cost_calculation_mismatch(query, future_cpx, legacy_cpx) + query.context.response_extensions["complexity_warning"] = { + "current" => legacy_cpx, + "future" => future_cpx + } + 1003 + end + end + } + it "calls the handler and uses the returned value" do + query = GraphQL::Query.new(schema, %| + { + entity { + ...on Producer { cheese { kind } } + ...on Farm { cheese { kind } } + } + } + |) + a = schema.cost(query) + assert_equal 12, a + refute query.result.to_h.key?("extensions") + + queryb = GraphQL::Query.new(schema, %| + { + entity { + ...on Farm { cheese { kind } } + ...on Producer { cheese { kind } } + } + } + |) + b = schema.cost(queryb) + assert_equal 1003, b + assert_equal({"complexity_warning" => {"current" => 7, "future" => 12}}, queryb.result.to_h["extensions"]) + end + + it "calls the custom handler when leaf fields don't match" do + a = schema.cost(%| + { + entity { + ...on Producer { name } + ...on Farm { name } + } + } + |) + assert_equal 11, a + + b = schema.cost(%| + { + entity { + ...on Farm { name } + ...on Producer { name } + } + } + |) + assert_equal 1003, b + end + end + + describe "without a mode setting" do + it "warns, and invalid mismatched scope types will still compute without error" do + cost = nil + + stdout, _stderr = capture_io do + cost = MaxOfPossibleScopes.cost(%| + { + entity { + ...on Farm { cheese { kind } } + ...on Producer { cheese: name } + } + } + |) + end + + assert_equal 12, cost + + assert_includes stdout, "GraphQL-Ruby's complexity cost system is getting some \"breaking fixes\" in a future version. See the migration notes at https://graphql-ruby.org/api-doc/#{GraphQL::VERSION}/GraphQL/Schema.html#complexity_cost_calculation_mode_for-class_method + +To opt into the future behavior, configure your schema (MaxOfPossibleScopes) with: + + complexity_cost_calculation_mode(:future) # or `:legacy`, `:compare`" + end + + it "uses legacy mode" do + cost = nil + assert_nil MaxOfPossibleScopes.complexity_cost_calculation_mode + stdout, _stderr = capture_io do + cost = MaxOfPossibleScopes.cost(%| + { + entity { + ...on Farm { name } + ...on Producer { name } + } + } + |) + end + puts stdout + + assert_equal 6, cost + + assert_includes stdout, "GraphQL-Ruby's complexity cost system is getting some \"breaking fixes\" in a future version. See the migration notes at https://graphql-ruby.org/api-doc/#{GraphQL::VERSION}/GraphQL/Schema.html#complexity_cost_calculation_mode_for-class_method + +To opt into the future behavior, configure your schema (MaxOfPossibleScopes) with: + + complexity_cost_calculation_mode(:future) # or `:legacy`, `:compare`" + end + end + end +end diff --git a/spec/graphql/analysis/ast/query_depth_spec.rb b/spec/graphql/analysis/query_depth_spec.rb similarity index 93% rename from spec/graphql/analysis/ast/query_depth_spec.rb rename to spec/graphql/analysis/query_depth_spec.rb index 9018b9a7282..bb840c32502 100644 --- a/spec/graphql/analysis/ast/query_depth_spec.rb +++ b/spec/graphql/analysis/query_depth_spec.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true require "spec_helper" -describe GraphQL::Analysis::AST::QueryDepth do - let(:result) { GraphQL::Analysis::AST.analyze_query(query, [GraphQL::Analysis::AST::QueryDepth]) } +describe GraphQL::Analysis::QueryDepth do + let(:result) { GraphQL::Analysis.analyze_query(query, [GraphQL::Analysis::QueryDepth]) } let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables) } let(:variables) { {} } diff --git a/spec/graphql/analysis_spec.rb b/spec/graphql/analysis_spec.rb new file mode 100644 index 00000000000..d167aac8f9f --- /dev/null +++ b/spec/graphql/analysis_spec.rb @@ -0,0 +1,721 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Analysis do + class AstTypeCollector < GraphQL::Analysis::Analyzer + def initialize(query) + super + @types = [] + end + + def on_enter_operation_definition(node, parent, visitor) + @types << visitor.type_definition + end + + def on_enter_field(memo, node, visitor) + @types << visitor.field_definition.type.unwrap + end + + def result + @types + end + end + + class AstNodeCounter < GraphQL::Analysis::Analyzer + def initialize(query) + super + @nodes = Hash.new { |h,k| h[k] = 0 } + end + + def on_enter_abstract_node(node, parent, _visitor) + @nodes[node.class] += 1 + end + + alias :on_enter_operation_definition :on_enter_abstract_node + alias :on_enter_field :on_enter_abstract_node + alias :on_enter_argument :on_enter_abstract_node + + def result + @nodes + end + end + + class AstConditionalAnalyzer < GraphQL::Analysis::Analyzer + def initialize(query) + super + @i_have_been_called = false + end + + def analyze? + !!query.context[:analyze] + end + + def on_operation_definition(node, parent, visitor) + @i_have_been_called = true + end + + def result + @i_have_been_called + end + end + + class AstPrecomputedAnalyzer < GraphQL::Analysis::Analyzer + def initialize(query) + super + @i_have_been_visited = false + end + + def visit? + query.context[:precomputed_result].nil? + end + + def on_enter_field(node, parent, visitor) + @i_have_been_visited = true + end + + def result + return query.context[:precomputed_result], @i_have_been_visited + end + end + + class AstErrorAnalyzer < GraphQL::Analysis::Analyzer + def result + GraphQL::AnalysisError.new("An Error!") + end + end + + class AstPreviousField < GraphQL::Analysis::Analyzer + def on_enter_field(node, parent, visitor) + @previous_field = visitor.previous_field_definition + end + + def result + @previous_field + end + end + + class AstArguments < GraphQL::Analysis::Analyzer + def on_enter_argument(node, parent, visitor) + @argument = visitor.argument_definition + @previous_argument = visitor.previous_argument_definition + end + + def result + [@argument, @previous_argument] + end + end + + class AstSkipInclude < GraphQL::Analysis::Analyzer + def initialize(query) + super + @included = [] + end + + def on_enter_field(node, parent, visitor) + @included << "enter #{node.name}" unless visitor.skipping? + end + + def on_leave_field(node, parent, visitor) + @included << "leave #{node.name}" unless visitor.skipping? + end + + def on_enter_inline_fragment(node, parent, visitor) + @included << "enter ...on #{node.type.name}" unless visitor.skipping? + end + + def on_leave_inline_fragment(node, parent, visitor) + @included << "leave ...on #{node.type.name}" unless visitor.skipping? + end + + def on_enter_fragment_spread(node, parent, visitor) + @included << "enter ...#{node.name}" unless visitor.skipping? + end + + def on_leave_fragment_spread(node, parent, visitor) + @included << "leave ...#{node.name}" unless visitor.skipping? + end + + def result + @included + end + end + + describe "skip and include behaviors" do + let(:reduce_result) { GraphQL::Analysis.analyze_query(query, [AstSkipInclude]) } + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string) } + let(:query_string) {%|{}|} + + describe "for fields" do + let(:query_string) {%| + { + cheese { + flavor + origin @skip(if: true) + source @include(if: false) + } + cheese @skip(if: true) { flavor } + cheese @include(if: false) { flavor } + } + |} + + it "tracks inclusions" do + expected = [ + "enter cheese", + "enter flavor", + "leave flavor", + "leave cheese", + ] + assert_equal expected, reduce_result.first + end + end + + describe "for inline fragments" do + let(:query_string) {%| + { + cheese { + ...on Cheese @skip(if: true) { origin } + ...on Cheese { flavor } + ...on Cheese @include(if: false) { source } + } + } + |} + + it "tracks inclusions" do + expected = [ + "enter cheese", + "enter ...on Cheese", + "enter flavor", + "leave flavor", + "leave ...on Cheese", + "leave cheese", + ] + assert_equal expected, reduce_result.first + end + end + + describe "for fragment spreads" do + let(:query_string) {%| + { + cheese { + ...Original @skip(if: true) + ...Flavorful + ...Sourced @include(if: false) + } + } + fragment Flavorful on Cheese { flavor } + fragment Original on Cheese { origin } + fragment Sourced on Cheese { source } + |} + + it "tracks inclusions" do + expected = [ + "enter cheese", + "enter ...Flavorful", + "enter flavor", + "leave flavor", + "leave ...Flavorful", + "leave cheese", + ] + assert_equal expected, reduce_result.first + end + end + end + + describe "using the AST analysis engine" do + let(:schema) do + query_type = Class.new(GraphQL::Schema::Object) do + graphql_name 'Query' + + field :foobar, Integer, null: false + + def foobar + 1337 + end + end + + Class.new(GraphQL::Schema) do + query query_type + query_analyzer AstErrorAnalyzer + end + end + + let(:query_string) {%| + query { + foobar + } + |} + + let(:query) { GraphQL::Query.new(schema, query_string, variables: {}) } + + it "runs the AST analyzers correctly" do + res = query.result + refute res.key?("data") + assert_equal ["An Error!"], res["errors"].map { |e| e["message"] } + end + end + + describe ".analyze_query" do + let(:analyzers) { [AstTypeCollector, AstNodeCounter] } + let(:reduce_result) { GraphQL::Analysis.analyze_query(query, analyzers) } + let(:variables) { {} } + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables) } + let(:query_string) {%| + { + cheese(id: 1) { + id + flavor + } + } + |} + + describe "without a valid operation" do + let(:query_string) {%| + # A comment + # is an invalid operation + # Should break + |} + + it "bails early when there is no selected operation to be executed" do + assert_equal 2, reduce_result.size + end + end + + describe "conditional analysis" do + let(:analyzers) { [AstTypeCollector, AstConditionalAnalyzer] } + + describe "when analyze? returns false" do + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables, context: { analyze: false }) } + + it "does not run the analyzer" do + # Only type_collector ran + assert_equal 1, reduce_result.size + end + end + + describe "when analyze? returns true" do + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables, context: { analyze: true }) } + + it "it runs the analyzer" do + # Both analyzers ran + assert_equal 2, reduce_result.size + end + end + + describe "Visitor#previous_field_definition" do + let(:analyzers) { [AstPreviousField] } + let(:query) { GraphQL::Query.new(Dummy::Schema, "{ __schema { types { name } } }") } + + it "it runs the analyzer" do + prev_field = reduce_result.first + assert_equal "__Schema.types", prev_field.path + end + end + + describe "Visitor#argument_definition" do + let(:analyzers) { [AstArguments] } + let(:query) do + GraphQL::Query.new( + Dummy::Schema, + '{ searchDairy(product: [{ source: "SHEEP" }]) { ... on Cheese { id } } }' + ) + end + + it "it runs the analyzer" do + argument, prev_argument = reduce_result.first + assert_equal "DairyProductInput.source", argument.path + assert_equal "Query.searchDairy.product", prev_argument.path + end + end + end + + describe "precomputed analysis" do + let(:analyzers) { [AstPrecomputedAnalyzer] } + + describe "when visit? returns true" do + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables, context: {}) } + + it "runs the analyzer with visitation" do + assert_equal [nil, true], reduce_result.first + end + end + + describe "when visit? returns false" do + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables, context: { precomputed_result: 23 }) } + + it "runs the analyzer without visitation" do + assert_equal [23, false], reduce_result.first + end + end + end + + it "calls the defined analyzers" do + collected_types, node_counts = reduce_result + expected_visited_types = [ + Dummy::DairyAppQuery, + Dummy::Cheese, + GraphQL::Types::Int, + GraphQL::Types::String + ] + assert_equal expected_visited_types, collected_types + + expected_node_counts = { + GraphQL::Language::Nodes::OperationDefinition => 1, + GraphQL::Language::Nodes::Field => 3, + GraphQL::Language::Nodes::Argument => 1 + } + + assert_equal expected_node_counts, node_counts + end + + class FinishedSchema < GraphQL::Schema + class FinishedAnalyzer < GraphQL::Analysis::Analyzer + def on_enter_field(node, parent, visitor) + if query.context[:force_prepare] + visitor.arguments_for(node, visitor.field_definition) + end + end + + def result + query.context[:analysis_finished] = true + end + end + + class Query < GraphQL::Schema::Object + field :f1, Int, resolve_static: true do + argument :arg, String, prepare: ->(val, ctx) { + ctx[:analysis_finished] ? val.to_i : raise("Prepared too soon!") + } + end + def self.f1(context, arg:) + arg + end + + def f1(arg:) + self.class.f1(context, arg: arg) + end + end + + query(Query) + + query_analyzer(FinishedAnalyzer) + end + + it "doesn't call prepare hooks by default" do + res = FinishedSchema.execute("{ f1(arg: \"5\") }") + assert_equal 5, res["data"]["f1"] + err = assert_raises RuntimeError do + FinishedSchema.execute("{ f1(arg: \"5\") }", context: { force_prepare: true }) + end + assert_equal "Prepared too soon!", err.message + end + + describe "tracing" do + let(:query_string) { "{ t: __typename }"} + + it "emits traces" do + traces = TestTracing.with_trace do + ctx = { tracers: [TestTracing] } + Dummy::Schema.execute(query_string, context: ctx) + end + + # The query_trace is on the list _first_ because it finished first + if USING_C_PARSER + _lex, _parse, _validate, query_trace, multiplex_trace, *_rest = traces + else + _parse, _validate, query_trace, multiplex_trace, *_rest = traces + end + + assert_equal "analyze_multiplex", multiplex_trace[:key] + assert_instance_of GraphQL::Execution::Multiplex, multiplex_trace[:multiplex] + + assert_equal "analyze_query", query_trace[:key] + assert_instance_of GraphQL::Query, query_trace[:query] + end + end + + class AstConnectionCounter < GraphQL::Analysis::Analyzer + def initialize(query) + super + @fields = 0 + @connections = 0 + end + + def on_enter_field(node, parent, visitor) + if visitor.field_definition.connection? + @connections += 1 + else + @fields += 1 + end + end + + def result + { + fields: @fields, + connections: @connections + } + end + end + + describe "when processing fields" do + let(:analyzers) { [AstConnectionCounter] } + let(:reduce_result) { GraphQL::Analysis.analyze_query(query, analyzers) } + let(:query) { GraphQL::Query.new(StarWars::Schema, query_string, variables: variables) } + let(:query_string) {%| + query getBases { + empire { + basesByName(first: 30) { edges { cursor } } + bases(first: 30) { edges { cursor } } + } + } + |} + + it "knows which fields are connections" do + connection_counts = reduce_result.first + expected_connection_counts = { + :fields => 5, + :connections => 2 + } + assert_equal expected_connection_counts, connection_counts + end + end + end + + describe "Detecting all-introspection queries" do + class AllIntrospectionSchema < GraphQL::Schema + class Query < GraphQL::Schema::Object + field :int, Int + end + query(Query) + end + + class AllIntrospectionAnalyzer < GraphQL::Analysis::Analyzer + def initialize(query) + @is_introspection = true + super + end + + def on_enter_field(node, parent, visitor) + @is_introspection &= (visitor.field_definition.introspection? || ((owner = visitor.field_definition.owner) && owner.introspection?)) + end + + def result + @is_introspection + end + end + + def is_introspection?(query_str) + query = GraphQL::Query.new(AllIntrospectionSchema, query_str) + result = GraphQL::Analysis.analyze_query(query, [AllIntrospectionAnalyzer]) + result.first + end + + it "returns true for queries containing only introspection types and fields" do + assert is_introspection?("{ __typename }") + refute is_introspection?("{ int }") + assert is_introspection?(GraphQL::Introspection::INTROSPECTION_QUERY) + assert is_introspection?("{ __type(name: \"Something\") { fields { name } } }") + refute is_introspection?("{ int __type(name: \"Thing\") { name } }") + end + end + + describe "when there's a hidden field" do + class HiddenAnalyzedFieldSchema < GraphQL::Schema + use GraphQL::Schema::Warden if ADD_WARDEN + class DoNothingAnalyzer < GraphQL::Analysis::Analyzer + def on_enter_field(node, parent, visitor) + @result ||= [] + @result << [node.name, visitor.field_definition.class] + super + end + + attr_reader :result + end + class BaseField < GraphQL::Schema::Field + def initialize(*args, visible: true, **kwargs, &block) + @visible = visible + super(*args, **kwargs, &block) + end + + def visible?(context) + return @visible + end + end + + class BaseObject < GraphQL::Schema::Object + field_class BaseField + end + + class Article < BaseObject + field :title, String, null: false + end + + class Query < BaseObject + field :article, String, visible: false, resolve_static: true do |f| + f.argument(:id, Integer) + end + + def self.article(context, id:) + { title: "hello world" } + end + + def article(id:) + self.class.article(context, id: id) + end + end + + query Query + end + + it "uses nil for the field definition" do + gql = <<~GQL + { + article(id: 1) { + title + } + } + GQL + + query = GraphQL::Query.new(HiddenAnalyzedFieldSchema, gql) + result = GraphQL::Analysis.analyze_query(query, [HiddenAnalyzedFieldSchema::DoNothingAnalyzer]) + assert_equal [[["article", NilClass], ["title", NilClass]]], result + end + end + + + describe ".validate_timeout" do + class AnalysisTimeoutSchema < GraphQL::Schema + class SlowAnalyzer < GraphQL::Analysis::Analyzer + def initialize(...) + super + if query.context[:initialize_sleep] + sleep 0.6 + end + end + + def on_enter_field(node, parent, visitor) + if node.name != "__typename" + sleep 0.1 + end + super + end + + def on_enter_directive(...) + sleep 0.1 + super + end + + def on_enter_argument(...) + sleep 0.1 + super + end + + def on_enter_inline_fragment(...) + sleep 0.1 + super + end + + def on_enter_fragment_spread(node, _parent, _visitor) + if !node.name.include?("NoSleep") + sleep 0.1 + end + super + end + + def result + nil + end + end + + class Query < GraphQL::Schema::Object + field :f1, Int, resolve_static: true do + argument :a, String, required: false + argument :b, String, required: false + argument :c, String, required: false + argument :d, String, required: false + argument :e, String, required: false + argument :f, String, required: false + end + + def self.f1(context, **kwargs) + context[:int] ||= 0 + context[:int] += 1 + end + + def f1(...) + self.class.f1(context) + end + end + + class Nothing < GraphQL::Schema::Directive + locations(GraphQL::Schema::Directive::FIELD) + repeatable(true) + + def self.resolve_field(...); end + end + directive(Nothing) + + query(Query) + query_analyzer(SlowAnalyzer) + validate_timeout 0.5 + end + + it "covers analysis too" do + res = AnalysisTimeoutSchema.execute("{ f1: f1 f2: f1 }") + assert_equal({ "f1" => 1, "f2" => 2}, res["data"]) + + res2 = AnalysisTimeoutSchema.execute("{ f1: f1, f2: f1, f3: f1, f4: f1, f5: f1, f6: f1}") + assert_equal ["Timeout on validation of query"], res2["errors"].map { |e| e["message"]} + end + + it "covers directives" do + res = AnalysisTimeoutSchema.execute("{ f1 @nothing @nothing }") + assert_equal({ "f1" => 1 }, res["data"]) + + res2 = AnalysisTimeoutSchema.execute("{ f1 @nothing @nothing @nothing @nothing @nothing }") + assert_equal ["Timeout on validation of query"], res2["errors"].map { |e| e["message"]} + end + + it "covers arguments" do + res = AnalysisTimeoutSchema.execute("{ f1(a: \"a\", b: \"b\")}") + assert_equal({ "f1" => 1 }, res["data"]) + + res2 = AnalysisTimeoutSchema.execute('{ f1(a: "a", b: "b", c: "c", d: "d", e: "e", f: "f") }') + assert_equal ["Timeout on validation of query"], res2["errors"].map { |e| e["message"]} + end + + it "covers inline fragments" do + res = AnalysisTimeoutSchema.execute("{ ... { f1 } ... { f1 } }") + assert_equal({ "f1" => 1 }, res["data"]) + + res2 = AnalysisTimeoutSchema.execute("{ ... { f1 } ... { f1 } ... { f1 } ... { f1 } ... { f1 } ... { f1 } }") + assert_equal ["Timeout on validation of query"], res2["errors"].map { |e| e["message"]} + end + + it "covers operation definitions" do + res = AnalysisTimeoutSchema.execute('query Q1 { __typename }', operation_name: "Q1") + assert_equal({ "__typename" => "Query" }, res["data"]) + + res = AnalysisTimeoutSchema.execute('query Q1 { __typename }', operation_name: "Q1", context: { initialize_sleep: true }) + assert_equal({ "__typename" => "Query" }, res["data"]) + end + + it "covers fragment spreads" do + res = AnalysisTimeoutSchema.execute("{ ...F } fragment F on Query { f1 }") + assert_equal({ "f1" => 1 }, res["data"]) + + res2 = AnalysisTimeoutSchema.execute('{ ...F ...F ...F ...F ...F ...F } fragment F on Query { f1 }') + assert_equal ["Timeout on validation of query"], res2["errors"].map { |e| e["message"]} + end + + it "can be ignored" do + no_timeout_schema = Class.new(AnalysisTimeoutSchema) do + validate_timeout(nil) + end + res = no_timeout_schema.execute("{ f1 @nothing @nothing @nothing @nothing @nothing }") + assert_equal({ "f1" => 1 }, res["data"]) + + res2 = no_timeout_schema.execute('{ ...F ...F ...F ...F ...F ...F } fragment F on Query { f1 }') + assert_equal({ "f1" => 1 }, res2["data"]) + end + end +end diff --git a/spec/graphql/authorization_spec.rb b/spec/graphql/authorization_spec.rb index 01b1836e7e8..985fd00d217 100644 --- a/spec/graphql/authorization_spec.rb +++ b/spec/graphql/authorization_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "spec_helper" -describe GraphQL::Authorization do +describe "GraphQL::Authorization" do module AuthTest class Box attr_reader :value @@ -15,10 +15,6 @@ def visible?(context) super && (context[:hide] ? @name != "hidden" : true) end - def accessible?(context) - super && (context[:hide] ? @name != "inaccessible" : true) - end - def authorized?(parent_object, value, context) super && parent_object != :hide2 end @@ -35,35 +31,21 @@ class BaseInputObject < GraphQL::Schema::InputObject end class BaseField < GraphQL::Schema::Field - def initialize(*args, edge_class: nil, **kwargs, &block) - @edge_class = edge_class - super(*args, **kwargs, &block) - end - - def to_graphql - field_defn = super - if @edge_class - field_defn.edge_class = @edge_class - end - field_defn - end - argument_class BaseArgument + def visible?(context) super && (context[:hide] ? @name != "hidden" : true) end - def accessible?(context) - super && (context[:hide] ? @name != "inaccessible" : true) - end - def authorized?(object, args, context) if object == :raise raise GraphQL::UnauthorizedFieldError.new("raised authorized field error", object: object) end - return Box.new(value: context[:lazy_field_authorized]) if context.key?(:lazy_field_authorized) - - super && object != :hide && object != :replace + if context.key?(:lazy_field_authorized) + Box.new(value: context[:lazy_field_authorized]) + else + super && object != :hide && object != :replace + end end end @@ -84,6 +66,10 @@ def initialize(*args, role: nil, **kwargs) def visible?(context) super && (context[:hide] ? @role != :hidden : true) end + + def authorized?(context) + super && (context[:authorized] ? true : @role != :unauthorized) + end end class BaseEnum < GraphQL::Schema::Enum @@ -93,18 +79,25 @@ class BaseEnum < GraphQL::Schema::Enum module HiddenInterface include BaseInterface - def self.visible?(ctx) - super && !ctx[:hide] - end + definition_methods do + def visible?(ctx) + super && !ctx[:hide] + end - def self.resolve_type(obj, ctx) - HiddenObject + def resolve_type(obj, ctx) + HiddenObject + end end end module HiddenDefaultInterface - include BaseInterface - # visible? will call the super method + if GraphQL::Schema.use_visibility_profile? + include HiddenInterface + else + # Warden will detect no possible types + include BaseInterface + end + def self.resolve_type(obj, ctx) HiddenObject end @@ -117,7 +110,7 @@ def self.visible?(ctx) super && !ctx[:hide] end - field :some_field, String, null: true + field :some_field, String end class RelayObject < BaseObject @@ -125,49 +118,11 @@ def self.visible?(ctx) super && !ctx[:hidden_relay] end - def self.accessible?(ctx) - super && !ctx[:inaccessible_relay] - end - def self.authorized?(_val, ctx) super && !ctx[:unauthorized_relay] end - field :some_field, String, null: true - end - - # TODO test default behavior for abstract types, - # that they check their concrete types - module InaccessibleInterface - include BaseInterface - - def self.accessible?(ctx) - super && !ctx[:hide] - end - - def self.resolve_type(obj, ctx) - InaccessibleObject - end - end - - module InaccessibleDefaultInterface - include BaseInterface - # accessible? will call the super method - def self.resolve_type(obj, ctx) - InaccessibleObject - end - - field :some_field, String, null: true - end - - class InaccessibleObject < BaseObject - implements InaccessibleInterface - implements InaccessibleDefaultInterface - def self.accessible?(ctx) - super && !ctx[:hide] - end - - field :some_field, String, null: true + field :some_field, String end class UnauthorizedObject < BaseObject @@ -240,7 +195,7 @@ class IntegerObjectConnection < GraphQL::Types::Relay::BaseConnection # but if its replacement value is used, it gives `replaced => true` class Replaceable def replacement - { replaced: true } + OpenStruct.new(replaced: true) end def replaced @@ -259,7 +214,7 @@ def self.authorized?(obj, ctx) class LandscapeFeature < BaseEnum value "MOUNTAIN" value "STREAM", role: :unauthorized - value "FIELD", role: :inaccessible + value "FIELD" value "TAR_PIT", role: :hidden end @@ -269,11 +224,10 @@ def self.authorized?(obj, ctx) end field :hidden, Integer, null: false - field :unauthorized, Integer, null: true, method: :itself - field :int2, Integer, null: true do + field :unauthorized, Integer, method: :itself + field :int2, Integer, resolve_legacy_instance_method: true do argument :int, Integer, required: false argument :hidden, Integer, required: false - argument :inaccessible, Integer, required: false argument :unauthorized, Integer, required: false end @@ -281,7 +235,7 @@ def int2(**args) args[:unauthorized] || 1 end - field :landscape_feature, LandscapeFeature, null: false do + field :landscape_feature, LandscapeFeature, resolve_legacy_instance_method: true, null: false do argument :string, String, required: false argument :enum, LandscapeFeature, required: false end @@ -290,7 +244,7 @@ def landscape_feature(string: nil, enum: nil) string || enum end - field :landscape_features, [LandscapeFeature], null: false do + field :landscape_features, [LandscapeFeature], null: false, resolve_legacy_instance_method: true do argument :strings, [String], required: false argument :enums, [LandscapeFeature], required: false end @@ -300,22 +254,15 @@ def landscape_features(strings: [], enums: []) end def empty_array; []; end - field :hidden_object, HiddenObject, null: false, resolver_method: :itself - field :hidden_interface, HiddenInterface, null: false, resolver_method: :itself - field :hidden_default_interface, HiddenDefaultInterface, null: false, resolver_method: :itself - field :hidden_connection, RelayObject.connection_type, null: :false, resolver_method: :empty_array - field :hidden_edge, RelayObject.edge_type, null: :false, resolver_method: :edge_object - - field :inaccessible, Integer, null: false, method: :object_id - field :inaccessible_object, InaccessibleObject, null: false, resolver_method: :itself - field :inaccessible_interface, InaccessibleInterface, null: false, resolver_method: :itself - field :inaccessible_default_interface, InaccessibleDefaultInterface, null: false, resolver_method: :itself - field :inaccessible_connection, RelayObject.connection_type, null: :false, resolver_method: :empty_array - field :inaccessible_edge, RelayObject.edge_type, null: :false, resolver_method: :edge_object - - field :unauthorized_object, UnauthorizedObject, null: true, resolver_method: :itself - field :unauthorized_connection, RelayObject.connection_type, null: false, resolver_method: :array_with_item - field :unauthorized_edge, RelayObject.edge_type, null: false, resolver_method: :edge_object + field :hidden_object, HiddenObject, null: false, resolver_method: :itself, resolve_legacy_instance_method: :itself + field :hidden_interface, HiddenInterface, null: false, resolver_method: :itself, resolve_legacy_instance_method: :itself + field :hidden_default_interface, HiddenDefaultInterface, null: false, resolver_method: :itself, resolve_legacy_instance_method: :itself + field :hidden_connection, RelayObject.connection_type, null: :false, resolver_method: :empty_array, resolve_legacy_instance_method: :empty_array + field :hidden_edge, RelayObject.edge_type, null: :false, resolver_method: :edge_object, resolve_legacy_instance_method: :edge_object + + field :unauthorized_object, UnauthorizedObject, resolver_method: :itself, resolve_legacy_instance_method: :itself + field :unauthorized_connection, RelayObject.connection_type, null: false, resolver_method: :array_with_item, resolve_legacy_instance_method: :array_with_item + field :unauthorized_edge, RelayObject.edge_type, null: false, resolver_method: :edge_object, resolve_legacy_instance_method: :edge_object def edge_object OpenStruct.new(node: 100) @@ -325,45 +272,45 @@ def array_with_item [1] end - field :unauthorized_lazy_box, UnauthorizedBox, null: true do - argument :value, String, required: true + field :unauthorized_lazy_box, UnauthorizedBox, resolve_legacy_instance_method: true do + argument :value, String end def unauthorized_lazy_box(value:) # Make it extra nested, just for good measure. Box.new(value: Box.new(value: value)) end - field :unauthorized_list_items, [UnauthorizedObject], null: true + field :unauthorized_list_items, [UnauthorizedObject], resolve_legacy_instance_method: true def unauthorized_list_items [self, self] end - field :unauthorized_lazy_check_box, UnauthorizedCheckBox, null: true, resolver_method: :unauthorized_lazy_box do - argument :value, String, required: true + field :unauthorized_lazy_check_box, UnauthorizedCheckBox, resolver_method: :unauthorized_lazy_box, resolve_legacy_instance_method: :unauthorized_lazy_box do + argument :value, String end - field :unauthorized_interface, UnauthorizedInterface, null: true, resolver_method: :unauthorized_lazy_box do - argument :value, String, required: true + field :unauthorized_interface, UnauthorizedInterface, resolver_method: :unauthorized_lazy_box, resolve_legacy_instance_method: :unauthorized_lazy_box do + argument :value, String end - field :unauthorized_lazy_list_interface, [UnauthorizedInterface, null: true], null: true + field :unauthorized_lazy_list_interface, [UnauthorizedInterface, null: true], resolve_legacy_instance_method: true def unauthorized_lazy_list_interface ["z", Box.new(value: Box.new(value: "z2")), "a", Box.new(value: "a")] end - field :integers, IntegerObjectConnection, null: false + field :integers, IntegerObjectConnection, null: false, resolve_legacy_instance_method: true def integers [1,2,3] end - field :lazy_integers, IntegerObjectConnection, null: false + field :lazy_integers, IntegerObjectConnection, null: false, resolve_legacy_instance_method: true def lazy_integers Box.new(value: Box.new(value: [1,2,3])) end - field :replaced_object, ReplacedObject, null: false + field :replaced_object, ReplacedObject, null: false, resolve_legacy_instance_method: true def replaced_object Replaceable.new end @@ -380,13 +327,7 @@ def self.visible?(ctx) super && !ctx[:hidden_mutation] end - field :some_return_field, String, null: true - end - - class DoInaccessibleStuff < GraphQL::Schema::RelayClassicMutation - def self.accessible?(ctx) - super && (ctx[:inaccessible_mutation] ? false : true) - end + field :some_return_field, String end class DoUnauthorizedStuff < GraphQL::Schema::RelayClassicMutation @@ -398,7 +339,6 @@ def self.authorized?(obj, ctx) class Mutation < BaseObject field :do_hidden_stuff, mutation: DoHiddenStuff field :do_hidden_stuff2, mutation: DoHiddenStuff2 - field :do_inaccessible_stuff, mutation: DoInaccessibleStuff field :do_unauthorized_stuff, mutation: DoUnauthorizedStuff end @@ -407,13 +347,15 @@ class Nothing < GraphQL::Schema::Directive def self.visible?(ctx) !!ctx[:show_nothing_directive] end + + def self.resolve_field(...); end end class Schema < GraphQL::Schema query(Query) mutation(Mutation) directive(Nothing) - + use GraphQL::Schema::Warden if ADD_WARDEN lazy_resolve(Box, :value) def self.unauthorized_object(err) @@ -427,13 +369,11 @@ def self.unauthorized_object(err) raise GraphQL::ExecutionError, "Unauthorized #{err.type.graphql_name}: #{err.object.inspect}" end end - - # use GraphQL::Backtrace end class SchemaWithFieldHook < GraphQL::Schema query(Query) - + use GraphQL::Schema::Warden if ADD_WARDEN lazy_resolve(Box, :value) def self.unauthorized_field(err) @@ -452,6 +392,7 @@ def auth_execute(*args, **kwargs) AuthTest::Schema.execute(*args, **kwargs) end + describe "applying the visible? method" do it "works in queries" do res = auth_execute(" { int int2 } ", context: { hide: true }) @@ -467,7 +408,7 @@ def auth_execute(*args, **kwargs) error_queries.each do |name, q| hidden_res = auth_execute(q, context: { hide: true}) - assert_equal ["Field '#{name}' doesn't exist on type 'Query'"], hidden_res["errors"].map { |e| e["message"] } + assert_equal ["Field '#{name}' doesn't exist on type 'Query'#{name == "hiddenDefaultInterface" ? "" : " (Did you mean `hiddenConnection`?)"}"], hidden_res["errors"].map { |e| e["message"] } visible_res = auth_execute(q) # Both fields exist; the interface resolves to the object type, though @@ -527,7 +468,7 @@ def auth_execute(*args, **kwargs) assert_equal "RelayObjectEdge", visible_res["data"]["hiddenEdge"]["__typename"] end - it "treats hidden enum values as non-existant, even in lists" do + it "treats hidden enum values as non-existent, even in lists" do hidden_res_1 = auth_execute <<-GRAPHQL, context: { hide: true } { landscapeFeature(enum: TAR_PIT) @@ -544,7 +485,7 @@ def auth_execute(*args, **kwargs) assert_equal ["Argument 'enums' on Field 'landscapeFeatures' has an invalid value ([STREAM, TAR_PIT]). Expected type '[LandscapeFeature!]'."], hidden_res_2["errors"].map { |e| e["message"] } - success_res = auth_execute <<-GRAPHQL, context: { hide: false } + success_res = auth_execute <<-GRAPHQL, context: { hide: false, authorized: true } { landscapeFeature(enum: TAR_PIT) landscapeFeatures(enums: [STREAM, TAR_PIT]) @@ -574,6 +515,33 @@ def auth_execute(*args, **kwargs) end end + it "rejects incoming unauthorized enum values" do + res = auth_execute <<-GRAPHQL, context: { } + { + landscapeFeature(enum: STREAM) + } + GRAPHQL + + assert_equal ["Unauthorized LandscapeFeature: \"STREAM\""], res["errors"].map { |e| e["message"] } + end + + it "rejects outgoing unauthorized enum values" do + err = assert_raises(AuthTest::LandscapeFeature::UnresolvedValueError) do + auth_execute <<-GRAPHQL, context: { } + { + landscapeFeature(string: "STREAM") + } + GRAPHQL + end + + # This switches on `context[:current_path]` which isn't implemented by exec-next (yet?) + expected_message = if_exec_next( + "Resolving Query.landscapeFeature: `\"STREAM\"` was returned for `LandscapeFeature`, but this value was unauthorized. Update the field or resolver to return a different value in this case (or return `nil`).", + "`Query.landscapeFeature` returned `\"STREAM\"` at `landscapeFeature`, but this value was unauthorized. Update the field or resolver to return a different value in this case (or return `nil`)." + ) + assert_equal expected_message, err.message + end + it "works in introspection" do res = auth_execute <<-GRAPHQL, context: { hide: true, hidden_mutation: true } { @@ -592,7 +560,7 @@ def auth_execute(*args, **kwargs) query_field_names = res["data"]["query"]["fields"].map { |f| f["name"] } refute_includes query_field_names, "int" int2_arg_names = res["data"]["query"]["fields"].find { |f| f["name"] == "int2" }["args"].map { |a| a["name"] } - assert_equal ["int", "inaccessible", "unauthorized"], int2_arg_names + assert_equal ["int", "unauthorized"], int2_arg_names assert_nil res["data"]["hiddenObject"] assert_nil res["data"]["hiddenInterface"] @@ -602,12 +570,33 @@ def auth_execute(*args, **kwargs) end it "works when printing the SDL" do - full_sdl = AuthTest::Schema.to_definition - restricted_sdl = AuthTest::Schema.to_definition(context: { hide: true, hidden_mutation: true, hidden_relay: true }) - assert_includes full_sdl, 'Hidden' - assert_includes full_sdl, 'hidden' - refute_includes restricted_sdl, 'Hidden' - refute_includes restricted_sdl, 'hidden' + full_sdl_lines = AuthTest::Schema.to_definition.split("\n") + restricted_sdl_lines = AuthTest::Schema.to_definition(context: { hide: true, hidden_mutation: true, hidden_relay: true }).split("\n") + expected_hidden_lines = [ + "Autogenerated return type of DoHiddenStuff2.", + "type DoHiddenStuff2Payload {", + "Autogenerated input type of DoHiddenStuff", + "input DoHiddenStuffInput {", + "Autogenerated return type of DoHiddenStuff.", + "type DoHiddenStuffPayload {", + "interface HiddenDefaultInterface", + "interface HiddenInterface", + "type HiddenObject implements HiddenDefaultInterface & HiddenInterface {", + " doHiddenStuff(", + " Parameters for DoHiddenStuff", + " input: DoHiddenStuffInput!", + " ): DoHiddenStuffPayload", + " doHiddenStuff2: DoHiddenStuff2Payload", + " hidden: Int!", + " hiddenConnection(", + " hiddenDefaultInterface: HiddenDefaultInterface!", + " hiddenEdge: RelayObjectEdge", + " hiddenInterface: HiddenInterface!", + " hiddenObject: HiddenObject!", + " int2(hidden: Int, int: Int, unauthorized: Int): Int" + ] + assert_equal expected_hidden_lines, full_sdl_lines.select { |l| l.include?("Hidden") || l.include?("hidden") } + assert_equal [], restricted_sdl_lines.select { |l| l.include?("Hidden") || l.include?("hidden") } end it "works with directives" do @@ -674,6 +663,7 @@ def auth_execute(*args, **kwargs) query = "{ unauthorized }" response = AuthTest::SchemaWithFieldHook.execute(query, root_value: 34, context: { lazy_field_authorized: false }) assert_nil response["data"].fetch("unauthorized") + assert_equal ["Unauthorized field unauthorized on Query: 34"], response["errors"].map { |e| e["message"] } end end @@ -762,23 +752,6 @@ def auth_execute(*args, **kwargs) unauthorized_res = auth_execute(query, context: { unauthorized_relay: true }) conn = unauthorized_res["data"].fetch("unauthorizedConnection") assert_equal "RelayObjectConnection", conn.fetch("__typename") - # This is tricky: the previous behavior was to replace the _whole_ - # list with `nil`. This was due to an implementation detail: - # The list field's return value (an array of integers) was wrapped - # _before_ returning, and during this wrapping, a cascading error - # caused the entire field to be nilled out. - # - # In the interpreter, each list item is contained and the error doesn't propagate - # up to the whole list. - # - # Originally, I thought that this was a _feature_ that obscured list entries. - # But really, look at the test below: you don't get this "feature" if - # you use `edges { node }`, so it can't be relied on in any way. - # - # All that to say, in the interpreter, `nodes` and `edges { node }` behave - # the same. - # - # TODO revisit the docs for this. failed_nodes_value = [nil] assert_equal failed_nodes_value, conn.fetch("nodes") assert_equal [{"node" => nil, "__typename" => "RelayObjectEdge"}], conn.fetch("edges") @@ -930,8 +903,11 @@ def auth_execute(*args, **kwargs) assert_equal "Query", res["data"]["__typename"] unauth_res = auth_execute(query, context: { query_unauthorized: true }) - assert_nil unauth_res["data"] - assert_equal [{"message"=>"Unauthorized Query: nil"}], unauth_res["errors"] + + assert_equal({ + "errors" => [if_exec_next({"message"=>"Unauthorized Query: nil", "path" => [] }, {"message"=>"Unauthorized Query: nil"})], + "data" => nil, + }, unauth_res.to_h) end describe "when the object authorization raises an UnauthorizedFieldError" do @@ -951,7 +927,7 @@ def self.authorized?(obj, ctx) false end - field :int, Integer, null: false + field :int, Integer, null: false, resolve_legacy_instance_method: true def int 1 @@ -962,20 +938,35 @@ def int it "works out-of-the-box" do res = FalseSchema.execute("{ int }") - assert_nil res.fetch("data") + if TESTING_EXEC_NEXT + refute res.key?("data") + else + assert_nil res.fetch("data") + end refute res.key?("errors") end end describe "overriding authorized_new" do class AuthorizedNewOverrideSchema < GraphQL::Schema - class LogTracer + module LogTrace def trace(key, data) - if (c = data[:context]) || ((q = data[:query]) && (c = q.context)) + if ((q = data[:query]) && (c = q.context)) c[:log] << key end yield end + ["parse", "lex", "validate", + "analyze_query", "analyze_multiplex", + "execute_query", "execute_multiplex", + "execute_field", "execute_field_lazy", + "authorized", "authorized_lazy", + "resolve_type", "resolve_type_lazy", + "execute_query_lazy"].each do |method_name| + define_method(method_name) do |**data, &block| + trace(method_name, data, &block) + end + end end module CustomIntrospection @@ -996,10 +987,11 @@ def int; 1; end query(Query) introspection(CustomIntrospection) - tracer(LogTracer.new) + trace_with(LogTrace) end it "avoids calls to Object.authorized?" do + exec_next_WONTFIX("Doesn't work this way with exec-next") log = [] res = AuthorizedNewOverrideSchema.execute("{ __typename int }", context: { log: log }) assert_equal "Query", res["data"]["__typename"] diff --git a/spec/graphql/autoload_spec.rb b/spec/graphql/autoload_spec.rb new file mode 100644 index 00000000000..3be0e6c9cd3 --- /dev/null +++ b/spec/graphql/autoload_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "spec_helper" +require "open3" + +describe GraphQL::Autoload do + module LazyModule + extend GraphQL::Autoload + autoload(:LazyClass, "fixtures/lazy_module/lazy_class") + end + + module EagerModule + extend GraphQL::Autoload + autoload(:EagerClass, "fixtures/eager_module/eager_class") + autoload(:OtherEagerClass, "fixtures/eager_module/other_eager_class") + autoload(:NestedEagerModule, "fixtures/eager_module/nested_eager_module") + + def self.eager_load! + super + + NestedEagerModule.eager_load! + end + end + + describe "#autoload" do + it "sets autoload" do + assert LazyModule.const_defined?(:LazyClass) + assert_equal("fixtures/lazy_module/lazy_class", LazyModule.autoload?(:LazyClass)) + LazyModule::LazyClass + assert_nil(LazyModule.autoload?(:LazyClass)) + end + end + + describe "#eager_load!" do + it "eagerly loads autoload entries" do + assert EagerModule.autoload?(:EagerClass) + assert EagerModule.autoload?(:OtherEagerClass) + assert EagerModule.autoload?(:NestedEagerModule) + + EagerModule.eager_load! + + assert_nil(EagerModule.autoload?(:EagerClass)) + assert_nil(EagerModule.autoload?(:OtherEagerClass)) + assert_nil(EagerModule.autoload?(:NestedEagerModule)) + assert_nil(EagerModule::NestedEagerModule.autoload?(:NestedEagerClass)) + assert EagerModule::NestedEagerModule::NestedEagerClass + end + end + + describe "loading nested files in the repo" do + it "can load them individually" do + files_to_load = Dir.glob("lib/**/tracing/*.rb") + assert_equal 29, files_to_load.size, "It found all the expected files" + files_to_load.each do |file| + require_path = file.sub("lib/", "").sub(".rb", "") + stderr_and_stdout, _status = Open3.capture2e("ruby -Ilib -e 'require \"#{require_path}\"'") + assert_equal "", stderr_and_stdout, "It loads #{require_path.inspect} in isolation" + stderr_and_stdout, _status = Open3.capture2e("ruby -Ilib -e 'require \"graphql\"; require \"#{require_path}\"'") + assert_equal "", stderr_and_stdout, "It loads #{require_path.inspect} after loading graphql" + end + end + end +end diff --git a/spec/graphql/backtrace_spec.rb b/spec/graphql/backtrace_spec.rb index 8fc34820b71..c5a8f502c44 100644 --- a/spec/graphql/backtrace_spec.rb +++ b/spec/graphql/backtrace_spec.rb @@ -2,13 +2,16 @@ require "spec_helper" describe GraphQL::Backtrace do + def skip_with_exec_next + exec_next_TODO("Not supported with exec-next because of dependence on context[:current_path]") + end class LazyError def raise_err raise "Lazy Boom" end end - class ErrorAnalyzer < GraphQL::Analysis::AST::Analyzer + class ErrorAnalyzer < GraphQL::Analysis::Analyzer def on_enter_operation_definition(node, parent_node, visitor) if node.name == "raiseError" raise GraphQL::AnalysisError, "this should not be wrapped by a backtrace, but instead, returned to the client" @@ -24,11 +27,13 @@ class NilInspectObject def inspect; nil; end end - class ErrorInstrumentation - def self.before_query(_query) + module ErrorTrace + def initialize(required_arg:, **_rest) + super(**_rest) end - def self.after_query(query) + def execute_multiplex(multiplex:) + super raise "Instrumentation Boom" end end @@ -45,6 +50,7 @@ def self.after_query(query) "name" => Proc.new { |obj| obj[:name] == :boom ? raise("Boom!") : obj[:name] }, "listField" => Proc.new { :not_a_list }, "raiseField" => Proc.new { |o, a| raise("This is broken: #{a[:message]}") }, + "executionError" => Proc.new { raise GraphQL::ExecutionError, "Client-facing error" } }, "ThingWrapper" => { "thing" => Proc.new { |obj| obj[:thing] }, @@ -67,6 +73,7 @@ def self.after_query(query) name: String listField: [OtherThing] raiseField(message: String!): Int + executionError: Int } type ThingWrapper { @@ -93,6 +100,7 @@ def self.after_query(query) describe "GraphQL backtrace helpers" do it "raises a TracedError when enabled" do + skip_with_exec_next assert_raises(GraphQL::Backtrace::TracedError) { backtrace_schema.execute("query BrokenList { field1 { listField { strField } } }") } @@ -103,12 +111,18 @@ def self.after_query(query) end it "works for objects inside lists" do + skip_with_exec_next assert_raises(GraphQL::Backtrace::TracedError) do backtrace_schema.execute("{ nestedList { thing { name } } }") end end + it "doesn't wrap GraphQL::ExecutionError" do + assert_equal ["Client-facing error"], backtrace_schema.execute("{ field1 { executionError } }")["errors"].map { |e| e["message"] } + end + it "annotates crashes from user code" do + skip_with_exec_next err = assert_raises(GraphQL::Backtrace::TracedError) { backtrace_schema.execute <<-GRAPHQL, root_value: "Root" query($msg: String = \"Boom\") { @@ -124,8 +138,8 @@ def self.after_query(query) b = err.cause.backtrace assert_backtrace_includes(b, file: "backtrace_spec.rb", method: "block") assert_backtrace_includes(b, file: "field.rb", method: "resolve") - assert_backtrace_includes(b, file: "runtime.rb", method: "evaluate_selections") - assert_backtrace_includes(b, file: "interpreter.rb", method: "begin_query") + assert_backtrace_includes(b, file: "runtime.rb", method: "evaluate_selection") + assert_backtrace_includes(b, file: "interpreter.rb", method: "run_all") # GraphQL backtrace is present expected_graphql_backtrace = [ @@ -135,22 +149,62 @@ def self.after_query(query) ] assert_equal expected_graphql_backtrace, err.graphql_backtrace + hash_inspect = { message: "Boom" }.inspect + # The message includes the GraphQL context + rendered_table = [ + 'Loc | Field | Object | ' + "Arguments".ljust(hash_inspect.size) + ' | Result', + '3:13 | Thing.raiseField as boomError | :something | ' + hash_inspect + ' | #', + '2:11 | Query.field1 | "Root" | ' + "{}".ljust(hash_inspect.size) + ' | {}', + '1:9 | query | "Root" | ' + {"msg" => "Boom"}.inspect.ljust(hash_inspect.size) + ' | {field1: {...}}', + ].join("\n") + + assert_includes err.message, "\n" + rendered_table + # The message includes the original error message + assert_includes err.message, "This is broken: Boom" + assert_includes err.message, "spec/graphql/backtrace_spec.rb:52", "It includes the original backtrace" + assert_includes err.message, "more lines" + end + + it "annotates crashes from user code when using inline fragments" do + skip_with_exec_next + err = assert_raises(GraphQL::Backtrace::TracedError) { + backtrace_schema.execute <<-GRAPHQL, root_value: "Root" + query($msg: String = \"Boom\") { + field1 { + ... on Thing { + boomError: raiseField(message: $msg) + } + } + } + GRAPHQL + } + + # GraphQL backtrace is present + expected_graphql_backtrace = [ + "4:15: Thing.raiseField as boomError", + "2:11: Query.field1", + "1:9: query", + ] + assert_equal expected_graphql_backtrace, err.graphql_backtrace + + hash_inspect = { message: "Boom" }.inspect # The message includes the GraphQL context rendered_table = [ - 'Loc | Field | Object | Arguments | Result', - '3:13 | Thing.raiseField as boomError | :something | {:message=>"Boom"} | #', - '2:11 | Query.field1 | "Root" | {} | {}', - '1:9 | query | "Root" | {"msg"=>"Boom"} | {field1: {...}}', + 'Loc | Field | Object | ' + "Arguments".ljust(hash_inspect.size) + ' | Result', + '4:15 | Thing.raiseField as boomError | :something | ' + hash_inspect + ' | #', + '2:11 | Query.field1 | "Root" | ' + "{}".ljust(hash_inspect.size) + ' | {}', + '1:9 | query | "Root" | ' + {"msg" => "Boom"}.inspect.ljust(hash_inspect.size) + ' | {field1: {...}}', ].join("\n") assert_includes err.message, "\n" + rendered_table # The message includes the original error message assert_includes err.message, "This is broken: Boom" - assert_includes err.message, "spec/graphql/backtrace_spec.rb:47", "It includes the original backtrace" + assert_includes err.message, "spec/graphql/backtrace_spec.rb:52", "It includes the original backtrace" assert_includes err.message, "more lines" end it "annotates errors from Query#result" do + skip_with_exec_next query_str = "query StrField { field2 { strField } __typename }" context = { backtrace: true } query = GraphQL::Query.new(schema, query_str, context: context) @@ -161,6 +215,7 @@ def self.after_query(query) end it "annotates errors inside lazy resolution" do + skip_with_exec_next # Test context-based flag err = assert_raises(GraphQL::Backtrace::TracedError) { schema.execute("query StrField { field2 { strField } __typename }", context: { backtrace: true }) @@ -169,7 +224,7 @@ def self.after_query(query) b = err.cause.backtrace assert_backtrace_includes(b, file: "backtrace_spec.rb", method: "raise_err") assert_backtrace_includes(b, file: "schema.rb", method: "sync_lazy") - assert_backtrace_includes(b, file: "interpreter.rb", method: "sync_lazies") + assert_backtrace_includes(b, file: "interpreter.rb", method: "run_all") expected_graphql_backtrace = [ "1:27: OtherThing.strField", @@ -194,24 +249,30 @@ def self.after_query(query) end it "always stringifies the #inspect response" do + skip_with_exec_next # test the schema plugin err = assert_raises(GraphQL::Backtrace::TracedError) { backtrace_schema.execute("query { nilInspect { raiseField(message: \"pop!\") } }") } + hash_inspect = {message: "pop!"}.inspect # `=>` on Ruby < 3.4 rendered_table = [ - 'Loc | Field | Object | Arguments | Result', - '1:22 | Thing.raiseField | | {:message=>"pop!"} | #', - '1:9 | Query.nilInspect | nil | {} | {}', - '1:1 | query | nil | {} | {nilInspect: {...}}', + 'Loc | Field | Object | ' + "Arguments".ljust(hash_inspect.size) + ' | Result', + '1:22 | Thing.raiseField | | ' + hash_inspect + ' | #', + '1:9 | Query.nilInspect | nil | ' + "{}".ljust(hash_inspect.size) + ' | {}', + '1:1 | query | nil | ' + "{}".ljust(hash_inspect.size) + ' | {nilInspect: {...}}', + '', + '' ].join("\n") - assert_includes(err.message, rendered_table) + table = err.message.split("GraphQL Backtrace:\n").last + assert_equal rendered_table, table end it "raises original exception instead of a TracedError when error does not occur during resolving" do + skip_with_exec_next instrumentation_schema = Class.new(schema) do - instrument(:query, ErrorInstrumentation) + trace_with(ErrorTrace, required_arg: true) end assert_raises(RuntimeError) { @@ -223,7 +284,13 @@ def self.after_query(query) # This will get brittle when execution code moves between files # but I'm not sure how to be sure that the backtrace contains the right stuff! def assert_backtrace_includes(backtrace, file:, method:) - includes_tag = backtrace.any? { |s| s.include?(file) && s.include?("`" + method) } + includes_tag = if RUBY_VERSION < "3.4" + backtrace.any? { |s| s.include?(file) && s.include?("`" + method) } + elsif method == "block" + backtrace.any? { |s| s.include?(file) && s.include?("'block") } + else + backtrace.any? { |s| s.include?(file) && s.include?("#{method}'") } + end assert includes_tag, "Backtrace should include #{file} inside method #{method}\n\n#{backtrace.join("\n")}" end @@ -233,18 +300,18 @@ def assert_backtrace_includes(backtrace, file:, method:) end it "works with stand-alone analysis" do - example_analyzer = Class.new(GraphQL::Analysis::AST::Analyzer) do + example_analyzer = Class.new(GraphQL::Analysis::Analyzer) do def result :finished end end query = GraphQL::Query.new(backtrace_schema, "{ __typename }") - result = GraphQL::Analysis::AST.analyze_query(query, [example_analyzer]) + result = GraphQL::Analysis.analyze_query(query, [example_analyzer]) assert_equal [:finished], result end it "works with multiplex analysis" do - example_analyzer = Class.new(GraphQL::Analysis::AST::Analyzer) do + example_analyzer = Class.new(GraphQL::Analysis::Analyzer) do def result :finished end @@ -256,7 +323,7 @@ def result context: {}, max_complexity: nil, ) - result = GraphQL::Analysis::AST.analyze_multiplex(multiplex, [example_analyzer]) + result = GraphQL::Analysis.analyze_multiplex(multiplex, [example_analyzer]) assert_equal [:finished], result end @@ -271,6 +338,81 @@ def result {"data" => { "__typename" => "Query" }}, ] - assert_equal expected_res, res + assert_graphql_equal expected_res, res + end + + it "includes other trace modules when backtrace is active" do + custom_trace = Module.new + schema = Class.new(GraphQL::Schema) do + trace_with(custom_trace) + end + query = GraphQL::Query.new(schema, "{ __typename }", context: { backtrace: true }) + assert_includes query.current_trace.class.ancestors, custom_trace + end + + describe "When validators are used" do + class ValidatorBacktraceSchema < GraphQL::Schema + class Query < GraphQL::Schema::Object + field :greeting, String, resolve_static: true do + argument :name, String, validates: { length: { minimum: 5 }} + end + + def self.greeting(context, name:) + "Hello, #{name}!" + end + + def greeting(name:) + self.class.greeting(context, name: name) + end + end + + query(Query) + use GraphQL::Backtrace + end + + it "works properly" do + assert_equal "Hello, Albert!", ValidatorBacktraceSchema.execute("{ greeting(name: \"Albert\") }")["data"]["greeting"] + assert_equal ["name is too short (minimum is 5)"], ValidatorBacktraceSchema.execute("{ greeting(name: \"Tim\") }")["errors"].map { |e| e["message"] } + end + end + + + describe "when prepare fails as in https://github.com/rmosolgo/graphql-ruby/issues/5627" do + class BacktracePrepareErrorSchema < GraphQL::Schema + class CreateComment < GraphQL::Schema::RelayClassicMutation + argument :author_id, String, as: :author, prepare: :prepare_author + argument :body, String + + field :comment_id, String + + def self.prepare_author(id, _ctx) + raise "Author #{id} not found" + end + + def resolve(author:, body:) + { comment_id: "new-comment" } + end + end + + class Mutation < GraphQL::Schema::Object + field :create_comment, mutation: CreateComment + end + + use GraphQL::Backtrace + mutation Mutation + end + + it "works" do + skip_with_exec_next + assert_raises GraphQL::Backtrace::TracedError do + BacktracePrepareErrorSchema.execute <<~GRAPHQL + mutation { + createComment(input: { authorId: "unknown", body: "hello" }) { + commentId + } + } + GRAPHQL + end + end end end diff --git a/spec/graphql/cop/default_null_true_spec.rb b/spec/graphql/cop/default_null_true_spec.rb new file mode 100644 index 00000000000..7f5cdf280a9 --- /dev/null +++ b/spec/graphql/cop/default_null_true_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe "GraphQL::Cop::DefaultNullTrue" do + include RubocopTestHelpers + + it "finds and autocorrects `null: true` field configurations" do + result = run_rubocop_on("spec/fixtures/cop/null_true.rb") + assert_equal 3, rubocop_errors(result) + + assert_includes result, <<-RUBY + field :name, String, null: true + ^^^^^^^^^^ + RUBY + + assert_includes result, <<-RUBY + null: true, + ^^^^^^^^^^ + RUBY + + assert_includes result, <<-RUBY + field :described, [String, null: true], null: true, description: "Something" + ^^^^^^^^^^ + RUBY + + assert_rubocop_autocorrects_all("spec/fixtures/cop/null_true.rb") + end +end diff --git a/spec/graphql/cop/default_required_true_spec.rb b/spec/graphql/cop/default_required_true_spec.rb new file mode 100644 index 00000000000..e72367c2a85 --- /dev/null +++ b/spec/graphql/cop/default_required_true_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe "GraphQL::Cop::DefaultRequiredTrue" do + include RubocopTestHelpers + + it "finds and autocorrects `required: true` argument configurations" do + result = run_rubocop_on("spec/fixtures/cop/required_true.rb") + assert_equal 4, rubocop_errors(result) + + assert_includes result, <<-RUBY + argument :id_1, ID, required: true + ^^^^^^^^^^^^^^ + RUBY + + assert_includes result, <<-RUBY + required: true, + ^^^^^^^^^^^^^^ + RUBY + + assert_includes result, <<-RUBY + argument :id_3, ID, other_config: { something: false, required: true }, required: true, description: \"Something\" + ^^^^^^^^^^^^^^ + RUBY + + assert_includes result, <<-RUBY + f.argument(:id_1, ID, required: true) + ^^^^^^^^^^^^^^ + RUBY + + assert_rubocop_autocorrects_all("spec/fixtures/cop/required_true.rb") + end +end diff --git a/spec/graphql/cop/field_type_in_block_spec.rb b/spec/graphql/cop/field_type_in_block_spec.rb new file mode 100644 index 00000000000..f0e2fc22233 --- /dev/null +++ b/spec/graphql/cop/field_type_in_block_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe "GraphQL::Cop::FieldTypeInBlock" do + include RubocopTestHelpers + + it "finds and autocorrects field corrections with inline types" do + result = run_rubocop_on("spec/fixtures/cop/field_type.rb") + assert_equal 3, rubocop_errors(result) + + assert_includes result, <<-RUBY + field :current_account, Types::Account, null: false, description: "The account of the current viewer" + ^^^^^^^^^^^^^^ + RUBY + + assert_includes result, <<-RUBY + field :find_account, Types::Account do + ^^^^^^^^^^^^^^ + RUBY + + assert_includes result, <<-RUBY + field(:all_accounts, [Types::Account, null: false]) { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RUBY + + assert_rubocop_autocorrects_all("spec/fixtures/cop/field_type.rb") + end + + it "works on small classes" do + result = run_rubocop_on("spec/fixtures/cop/small_field_type.rb") + assert_equal 1, rubocop_errors(result) + end + + it "works with array types" do + result = run_rubocop_on("spec/fixtures/cop/field_type_array.rb") + assert_equal 1, rubocop_errors(result) + + assert_includes result, <<-RUBY + field :bar, [Thing], null: false do + ^^^^^^^ + RUBY + + assert_rubocop_autocorrects_all("spec/fixtures/cop/field_type_array.rb") + end + + it "Works with interfaces" do + result = run_rubocop_on("spec/fixtures/cop/field_type_interface.rb") + assert_equal 1, rubocop_errors(result) + + assert_includes result, <<-RUBY + field :thing, Thing + ^^^^^ + RUBY + + assert_rubocop_autocorrects_all("spec/fixtures/cop/field_type_interface.rb") + end +end diff --git a/spec/graphql/cop/root_types_in_block_spec.rb b/spec/graphql/cop/root_types_in_block_spec.rb new file mode 100644 index 00000000000..b27d6ea71f4 --- /dev/null +++ b/spec/graphql/cop/root_types_in_block_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe "GraphQL::Cop::RootTypesInBlock" do + include RubocopTestHelpers + + it "finds and autocorrects field corrections with inline types" do + result = run_rubocop_on("spec/fixtures/cop/root_types.rb") + assert_equal 3, rubocop_errors(result) + + assert_includes result, <<-RUBY + query Types::Query + ^^^^^^^^^^^^^^^^^^ + RUBY + + assert_includes result, <<-RUBY + mutation Types::Mutation + ^^^^^^^^^^^^^^^^^^^^^^^^ + RUBY + + assert_includes result, <<-RUBY + subscription Types::Subscription + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RUBY + + assert_rubocop_autocorrects_all("spec/fixtures/cop/root_types.rb") + end +end diff --git a/spec/graphql/current_spec.rb b/spec/graphql/current_spec.rb new file mode 100644 index 00000000000..415a7e1b811 --- /dev/null +++ b/spec/graphql/current_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Current do + describe "when no query is running" do + it "returns nil for things" do + assert_nil GraphQL::Current.operation_name + assert_nil GraphQL::Current.field + assert_nil GraphQL::Current.dataloader_source_class + end + end + + describe "in queries" do + class CurrentSchema < GraphQL::Schema + class ThingSource < GraphQL::Dataloader::Source + def initialize(context) + @context = context + end + + def fetch(names) + @context[:current_operation_name] << GraphQL::Current.operation_name + @context[:current_source] << GraphQL::Current.dataloader_source_class + names + end + end + class Thing < GraphQL::Schema::Object + field :name, String, resolve_static: :get_name + + def self.get_name(context) + context[:current_field] << GraphQL::Current.field.path + context.dataload(ThingSource, context, "thing") + end + + def name + self.class.get_name(context) + end + end + class Query < GraphQL::Schema::Object + field :thing, Thing, resolve_static: true + + def self.thing(context) + context[:current_field] << GraphQL::Current.field.path + :thing + end + + def thing + self.class.thing(context) + end + end + + query(Query) + use GraphQL::Dataloader + end + + it "returns execution information" do + ctx = { + current_field: [], + current_source: [], + current_operation_name: [] + } + + res = CurrentSchema.execute("query GetThingName { thing { name } }", context: ctx) + assert_equal "thing", res["data"]["thing"]["name"] + + assert_equal ["GetThingName"], ctx[:current_operation_name] + assert_equal [CurrentSchema::ThingSource], ctx[:current_source] + assert_equal ["Query.thing", "Thing.name"], ctx[:current_field] + end + end +end diff --git a/spec/graphql/dataloader/active_record_association_source_spec.rb b/spec/graphql/dataloader/active_record_association_source_spec.rb new file mode 100644 index 00000000000..84d46f78555 --- /dev/null +++ b/spec/graphql/dataloader/active_record_association_source_spec.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Dataloader::ActiveRecordAssociationSource do + if testing_rails? + include VulfpeckSchemaHelpers + it "works with different scopes on the same object at runtime" do + query_str = <<~GRAPHQL + { + band(name: "Vulfpeck") { + allAlbums: albums { + name + } + unscopedAlbums: albums(unscoped: true) { + name + } + reverseAlbums: albums(reverse: true) { + name + } + countryAlbums: albums(genre: "country") { + name + } + } + } + GRAPHQL + + result = exec_query(query_str) + assert_equal ["Mit Peck", "My First Car"], result["data"]["band"]["allAlbums"].map { |a| a["name"] } + assert_equal ["Mit Peck", "My First Car"], result["data"]["band"]["unscopedAlbums"].map { |a| a["name"] } + assert_equal ["My First Car", "Mit Peck"], result["data"]["band"]["reverseAlbums"].map { |a| a["name"] } + assert_equal [], result["data"]["band"]["countryAlbums"] + end + + it "works with field shorthands" do + exec_next_only("Only exec-next uses these configs") + result = exec_query <<-GRAPHQL + { + band(name: "Vulfpeck") { + allAlbums { + name + band { name } + } + } + } + GRAPHQL + + assert_equal ["Mit Peck", "My First Car"], result["data"]["band"]["allAlbums"].map { |a| a["name"] } + assert_equal ["Vulfpeck", "Vulfpeck"], result["data"]["band"]["allAlbums"].map { |a| a["band"]["name"] } + end + + it_dataloads "queries for associated records when the association isn't already loaded" do |d| + my_first_car = ::Album.find(2) + homey = ::Album.find(4) + log = with_active_record_log(colorize: false) do + vulfpeck, chon = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :band).load_all([my_first_car, homey]) + assert_equal "Vulfpeck", vulfpeck.name + assert_equal "Chon", chon.name + end + + assert_includes log, '[["id", 1], ["id", 3]]' + + toms_story = ::Album.find(3) + log = with_active_record_log(colorize: false) do + vulfpeck, chon, toms_story_band = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :band).load_all([my_first_car, homey, toms_story]) + assert_equal "Vulfpeck", vulfpeck.name + assert_equal "Chon", chon.name + assert_equal "Tom's Story", toms_story_band.name + end + + assert_includes log, '[["id", 2]]' + end + + it_dataloads "doesn't load records that are already cached by ActiveRecordSource" do |d| + d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load_all([1,2,3]) + + my_first_car = ::Album.find(2) + homey = ::Album.find(4) + toms_story = ::Album.find(3) + + log = with_active_record_log(colorize: false) do + vulfpeck, chon, toms_story_band = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :band).load_all([my_first_car, homey, toms_story]) + assert_equal "Vulfpeck", vulfpeck.name + assert_equal "Chon", chon.name + assert_equal "Tom's Story", toms_story_band.name + end + + assert_equal "", log + end + + it_dataloads "warms the cache for ActiveRecordSource" do |d| + my_first_car = ::Album.find(2) + homey = ::Album.find(4) + toms_story = ::Album.find(3) + d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :band).load_all([my_first_car, homey, toms_story]) + + log = with_active_record_log(colorize: false) do + d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load_all([1,2,3]) + end + + assert_equal "", log + end + + it_dataloads "doesn't warm the cache when a scope is given" do |d| + my_first_car = ::Album.find(2) + homey = ::Album.find(4) + summerteeth = ::Album.find(6) + results = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :band, ::Band.country).load_all([my_first_car, homey, summerteeth]) + assert_equal [nil, nil, ::Band.find(4)], results + + log = with_active_record_log(colorize: false) do + d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load_all([1,2,4]) + end + + assert_includes log, "SELECT \"bands\".* FROM \"bands\" WHERE \"bands\".\"id\" IN (?, ?, ?) [[\"id\", 1], [\"id\", 2], [\"id\", 4]]" + end + + it_dataloads "doesn't pause when the association is already loaded" do |d| + source = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :band) + assert_equal 0, source.results.size + assert_equal 0, source.pending.size + + my_first_car = ::Album.find(2) + vulfpeck = my_first_car.band + + vulfpeck2 = source.load(my_first_car) + + assert_equal vulfpeck, vulfpeck2 + + assert_equal 0, source.results.size + assert_equal 0, source.pending.size + + my_first_car.reload + vulfpeck3 = source.load(my_first_car) + assert_equal vulfpeck, vulfpeck3 + + assert_equal 1, source.results.size + assert_equal 0, source.pending.size + end + + it_dataloads "raises an error with a non-existent association" do |d| + my_first_car = ::Album.find(2) + source = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :tour_bus) + assert_raises ActiveRecord::AssociationNotFoundError do + source.load(my_first_car) + end + end + + it_dataloads "works with polymorphic associations" do |d| + wilco = ::Band.find(4) + vulfpeck = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :thing).load(wilco) + assert_equal ::Band.find(1), vulfpeck + end + + it_dataloads "works with collection associations" do |d| + wilco = ::Band.find(4) + chon = ::Band.find(3) + albums_by_band = nil + log = with_active_record_log(colorize: false) do + albums_by_band = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :albums).load_all([wilco, chon]) + end + + assert_equal [[6], [4, 5]], albums_by_band.map { |al| al.map(&:id) } + assert_includes log, 'SELECT "albums".* FROM "albums" WHERE "albums"."band_id" IN (?, ?) [["band_id", 4], ["band_id", 3]]' + + albums = nil + log = with_active_record_log(colorize: false) do + albums = d.with(GraphQL::Dataloader::ActiveRecordSource, Album).load_all([3,4,5,6]) + end + + assert_equal [3,4,5,6], albums.map(&:id) + assert_includes log, 'WHERE "albums"."id" = ? [["id", 3]]' + end + + it_dataloads "works with collection associations with scope" do |d| + wilco = ::Band.find(4) + chon = ::Band.find(3) + albums_by_band = nil + one_month_ago = nil + log = with_active_record_log(colorize: false) do + one_month_ago = 1.month.ago.end_of_day + albums_by_band_1 = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :albums, Album.where("created_at >= ?", one_month_ago)).request(wilco) + albums_by_band_2 = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :albums, Album.where("created_at >= ?", one_month_ago)).request(chon) + albums_by_band_3 = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :albums, Album.where("created_at <= ?", one_month_ago)).request(wilco) + albums_by_band = [albums_by_band_1.load, albums_by_band_2.load, albums_by_band_3.load] + end + + assert_equal [[6], [4, 5], []], albums_by_band.map { |al| al.map(&:id) } + expected_log = if Rails::VERSION::STRING > "8" + 'SELECT "albums".* FROM "albums" WHERE (created_at >= ?) AND "albums"."band_id" IN (?, ?)' + else + 'SELECT "albums".* FROM "albums" WHERE (created_at >= ' + one_month_ago.utc.strftime("'%Y-%m-%d %H:%M:%S.%6N'") + ') AND "albums"."band_id" IN (?, ?)' + end + + assert_includes log, expected_log + + albums = nil + log = with_active_record_log(colorize: false) do + albums = d.with(GraphQL::Dataloader::ActiveRecordSource, Album).load_all([3,4,5,6]) + end + + assert_equal [3,4,5,6], albums.map(&:id) + assert_includes log, 'WHERE "albums"."id" IN (?, ?, ?, ?) [["id", 3], ["id", 4], ["id", 5], ["id", 6]]' + end + + if Rails::VERSION::STRING > "7.1" # not supported in <7.1 + it_dataloads "loads with composite primary keys and warms the cache" do |d| + my_first_car = ::Album.find(2) + homey = ::Album.find(4) + log = with_active_record_log(colorize: false) do + vulfpeck, chon = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :composite_band).load_all([my_first_car, homey]) + assert_equal "Vulfpeck", vulfpeck.name + assert_equal "Chon", chon.name + end + + assert_includes log, '[["name", "Vulfpeck"], ["name", "Chon"], ["genre", 0]]' + + + log = with_active_record_log(colorize: false) do + d.with(GraphQL::Dataloader::ActiveRecordSource, CompositeBand).load_all([["Vulfpeck", "rock"], ["Chon", :rock]]) + end + + assert_equal "", log + end + end + end +end diff --git a/spec/graphql/dataloader/active_record_source_spec.rb b/spec/graphql/dataloader/active_record_source_spec.rb new file mode 100644 index 00000000000..0284262fc32 --- /dev/null +++ b/spec/graphql/dataloader/active_record_source_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Dataloader::ActiveRecordSource do + if testing_rails? + include VulfpeckSchemaHelpers + it "works with field config shorthands" do + exec_next_only("Only exec-next uses these configs") + query_str = "{ rootBand { name } }" + assert_equal "Wilco", exec_query(query_str, root_value: OpenStruct.new(band_name: "Wilco"))["data"]["rootBand"]["name"] + assert_equal "Chon", exec_query(query_str, root_value: OpenStruct.new(band_name: "Chon"))["data"]["rootBand"]["name"] + end + + describe "finding by ID" do + it_dataloads "loads once, then returns from a cache when available" do |d| + log = with_active_record_log(colorize: false) do + r1 = d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load(1) + assert_equal "Vulfpeck", r1.name + end + + assert_includes log, 'SELECT "bands".* FROM "bands" WHERE "bands"."id" = ? [["id", 1]]' + + log = with_active_record_log(colorize: false) do + r1 = d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load(1) + assert_equal "Vulfpeck", r1.name + end + + assert_equal "", log + + log = with_active_record_log(colorize: false) do + records = d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load_all([1, 99, 2, 3]) + assert_equal ["Vulfpeck", nil, "Tom's Story", "Chon"], records.map { |r| r&.name } + end + + assert_includes log, '[["id", 99], ["id", 2], ["id", 3]]' + end + + it_dataloads "casts load values to the column type" do |d| + log = with_active_record_log(colorize: false) do + r1 = d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load("1") + assert_equal "Vulfpeck", r1.name + end + + assert_includes log, 'SELECT "bands".* FROM "bands" WHERE "bands"."id" = ? [["id", 1]]' + + log = with_active_record_log(colorize: false) do + d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load(1) + end + + assert_equal "", log + + log = with_active_record_log(colorize: false) do + d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load("1") + end + + assert_equal "", log + end + end + + describe "finding by other columns" do + it_dataloads "uses the alternative primary key" do |d| + log = with_active_record_log(colorize: false) do + r1 = d.with(GraphQL::Dataloader::ActiveRecordSource, AlternativeBand).load("Vulfpeck") + assert_equal "Vulfpeck", r1.name + if Rails::VERSION::STRING > "8" + assert_equal 1, r1["id"] + else + assert_equal 1, r1._read_attribute("id") + end + end + + assert_includes log, 'SELECT "bands".* FROM "bands" WHERE "bands"."name" = ? [["name", "Vulfpeck"]]' + end + + if Rails::VERSION::STRING > "7.1" # not supported in <7.1 + it_dataloads "uses composite primary keys" do |d| + log = with_active_record_log(colorize: false) do + r1 = d.with(GraphQL::Dataloader::ActiveRecordSource, CompositeBand).load(["Chon", :rock]) + assert_equal "Chon", r1.name + assert_equal ["Chon", "rock"], r1.id + if Rails::VERSION::STRING > "8" + assert_equal 3, r1["id"] + else + assert_equal 3, r1._read_attribute("id") + end + end + + assert_includes log, 'SELECT "bands".* FROM "bands" WHERE "bands"."name" = ? AND "bands"."genre" = ? [["name", "Chon"], ["genre", 0]]' + end + end + + it_dataloads "uses specified find_by columns" do |d| + log = with_active_record_log(colorize: false) do + r1 = d.with(GraphQL::Dataloader::ActiveRecordSource, Band, find_by: :name).load("Chon") + assert_equal "Chon", r1.name + assert_equal 3, r1.id + end + + assert_includes log, 'SELECT "bands".* FROM "bands" WHERE "bands"."name" = ? [["name", "Chon"]]' + end + end + + describe "warming the cache" do + it_dataloads "can receive passed-in objects with a class" do |d| + d.with(GraphQL::Dataloader::ActiveRecordSource, Band).merge({ 100 => Band.find(3) }) + log = with_active_record_log(colorize: false) do + band3 = d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load(100) + assert_equal "Chon", band3.name + assert_equal 3, band3.id + end + + assert_equal "", log + end + + it_dataloads "can infer class of passed-in objects" do |d| + d.merge_records([Band.find(3), Album.find(4)]) + log = with_active_record_log(colorize: false) do + band3 = d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load(3) + assert_equal "Chon", band3.name + + album4 = d.with(GraphQL::Dataloader::ActiveRecordSource, Album).load(4) + assert_equal "Homey", album4.name + end + assert_equal "", log + end + end + end +end diff --git a/spec/graphql/dataloader/async_dataloader_spec.rb b/spec/graphql/dataloader/async_dataloader_spec.rb new file mode 100644 index 00000000000..9eaad318a99 --- /dev/null +++ b/spec/graphql/dataloader/async_dataloader_spec.rb @@ -0,0 +1,553 @@ +# frozen_string_literal: true +require "spec_helper" +if RUBY_VERSION >= "3.2.0" + require "async" + describe GraphQL::Dataloader::AsyncDataloader do + class AsyncSchema < GraphQL::Schema + class SleepSource < GraphQL::Dataloader::Source + def initialize(tag = nil) + @tag = tag + end + + def fetch(keys) + max_sleep = keys.max + # t1 = Time.now + # puts "----- SleepSource => #{max_sleep} (from: #{keys})" + sleep(max_sleep) + # puts "----- SleepSource done #{max_sleep} after #{Time.now - t1}" + keys.map { |_k| max_sleep } + end + end + + class WaitForSource < GraphQL::Dataloader::Source + def initialize(tag) + @tag = tag + end + + def fetch(waits) + max_wait = waits.max + # puts "[#{Time.now.to_f}] Waiting #{max_wait} for #{@tag}" + `sleep #{max_wait}` + # puts "[#{Time.now.to_f}] Finished for #{@tag}" + waits.map { |_w| @tag } + end + end + + class KeyWaitForSource < GraphQL::Dataloader::Source + class << self + attr_accessor :fetches + def reset + @fetches = [] + end + end + + def initialize(wait) + @wait = wait + end + + def fetch(keys) + self.class.fetches << keys + sleep(@wait) + keys + end + end + + class FiberLocalContextSource < GraphQL::Dataloader::Source + def fetch(keys) + keys.map { |key| Thread.current[key] } + end + end + + class Sleeper < GraphQL::Schema::Object + field :sleeper, Sleeper, null: false, resolve_static: true do + argument :duration, Float + end + + def self.sleeper(context, duration:) + context[:key_i] ||= 0 + new_key = context[:key_i] += 1 + context.dataloader.with(SleepSource, new_key).load(duration) + duration + end + + def sleeper(duration:) + self.class.sleeper(context, duration: duration) + end + + field :duration, Float, null: false, resolve_each: true + def self.duration(object, context); object; end + def duration; object; end + end + + class Waiter < GraphQL::Schema::Object + field :wait_for, Waiter, null: false, resolve_batch: true do + argument :tag, String + argument :wait, Float + end + + def self.wait_for(objects, context, tag:, wait:) + context.dataload_all(WaitForSource, tag, Array.new(objects.size, wait)) + end + + def wait_for(tag:, wait:) + dataloader.with(WaitForSource, tag).load(wait) + end + + field :tag, String, null: false, resolve_each: true + def self.tag(object, context) + object + end + + def tag + self.class.tag(object, context) + end + end + + class Query < GraphQL::Schema::Object + field :sleep, Float, null: false, resolve_static: true do + argument :duration, Float + end + + field :sleeper, Sleeper, null: false, resolver_method: :sleep, resolve_static: :sleep do + argument :duration, Float + end + + def self.sleep(context, duration:) + context[:key_i] ||= 0 + new_key = context[:key_i] += 1 + context.dataloader.with(SleepSource, new_key).load(duration) + duration + end + + def sleep(duration:) + self.class.sleep(context, duration: duration) + end + + field :wait_for, Waiter, null: false, resolve_batch: true do + argument :tag, String + argument :wait, Float + end + + def self.wait_for(objects, context, tag:, wait:) + context.dataload_all(WaitForSource, tag, Array.new(objects.size, wait)) + end + + def wait_for(tag:, wait:) + dataloader.with(WaitForSource, tag).load(wait) + end + + class ListWaiter < GraphQL::Schema::Object + field :waiter, Waiter, resolve_batch: true + + def self.waiter(objects, context) + reqs = objects.map { |obj| context.dataloader.with(KeyWaitForSource, obj[:wait]).request(obj[:tag]) } + reqs.map(&:load) + end + + def waiter + dataloader.with(KeyWaitForSource, object[:wait]).load(object[:tag]) + end + end + + field :list_waiters, [ListWaiter], resolve_static: true do + argument :wait, Float + argument :tags, [String] + end + + def self.list_waiters(context, wait:, tags:) + Kernel.sleep(0.1) + tags.map { |t| { tag: t, wait: wait }} + end + + def list_waiters(wait:, tags:) + self.class.list_waiters(context, wait: wait, tags: tags) + end + + field :fiber_local_context, String, resolve_batch: true do + argument :key, String + end + def self.fiber_local_context(objects, context, key:) + context.dataload_all(FiberLocalContextSource, Array.new(objects.size, key)) + end + + def fiber_local_context(key:) + dataloader.with(FiberLocalContextSource).load(key) + end + end + + query(Query) + use GraphQL::Dataloader::AsyncDataloader + end + + module AsyncDataloaderAssertions + ASYNC_DATALOADER_OVERHEAD_ALLOWANCE = 0.07 + + def self.included(child_class) + child_class.class_eval do + it "works with sources" do + dataloader = GraphQL::Dataloader::AsyncDataloader.new + r1 = dataloader.with(AsyncSchema::SleepSource, :s1).request(0.1) + r2 = dataloader.with(AsyncSchema::SleepSource, :s2).request(0.2) + r3 = dataloader.with(AsyncSchema::SleepSource, :s3).request(0.3) + + v1 = nil + dataloader.append_job { + v1 = r1.load + } + started_at = Time.now + dataloader.run + ended_at = Time.now + assert_equal 0.1, v1 + started_at_2 = Time.now + # These should take no time at all since they're already resolved + v2 = r2.load + v3 = r3.load + ended_at_2 = Time.now + + assert_equal 0.2, v2 + assert_equal 0.3, v3 + assert_in_delta 0.0, started_at_2 - ended_at_2, ASYNC_DATALOADER_OVERHEAD_ALLOWANCE, "Already-loaded values returned instantly" + + assert_in_delta 0.3, ended_at - started_at, ASYNC_DATALOADER_OVERHEAD_ALLOWANCE, "IO ran in parallel" + end + + it "works with GraphQL" do + started_at = Time.now + res = @schema.execute("{ s1: sleep(duration: 0.1) s2: sleep(duration: 0.2) s3: sleep(duration: 0.3) }") + ended_at = Time.now + assert_equal({"s1"=>0.1, "s2"=>0.2, "s3"=>0.3}, res["data"]) + assert_in_delta 0.3, ended_at - started_at, ASYNC_DATALOADER_OVERHEAD_ALLOWANCE, "IO ran in parallel" + end + + it "runs fields by depth" do + query_str = <<-GRAPHQL + { + s1: sleeper(duration: 0.1) { + sleeper(duration: 0.1) { + sleeper(duration: 0.1) { + duration + } + } + } + s2: sleeper(duration: 0.2) { + sleeper(duration: 0.1) { + duration + } + } + s3: sleeper(duration: 0.3) { + duration + } + } + GRAPHQL + started_at = Time.now + res = @schema.execute(query_str) + ended_at = Time.now + + expected_data = { + "s1" => { "sleeper" => { "sleeper" => { "duration" => 0.1 } } }, + "s2" => { "sleeper" => { "duration" => 0.1 } }, + "s3" => { "duration" => 0.3 } + } + assert_graphql_equal expected_data, res["data"] + assert_in_delta 0.5, ended_at - started_at, ASYNC_DATALOADER_OVERHEAD_ALLOWANCE, "Each depth ran in parallel" + end + + it "runs dataloaders in parallel across branches" do + query_str = <<-GRAPHQL + { + w1: waitFor(tag: "a", wait: 0.2) { + waitFor(tag: "b", wait: 0.2) { + waitFor(tag: "c", wait: 0.2) { + tag + } + } + } + # After the first, these are returned eagerly from cache + w2: waitFor(tag: "a", wait: 0.2) { + waitFor(tag: "a", wait: 0.2) { + waitFor(tag: "a", wait: 0.2) { + tag + } + } + } + w3: waitFor(tag: "a", wait: 0.2) { + waitFor(tag: "b", wait: 0.2) { + waitFor(tag: "d", wait: 0.2) { + tag + } + } + } + w4: waitFor(tag: "e", wait: 0.6) { + tag + } + } + GRAPHQL + started_at = Time.now + res = @schema.execute(query_str) + ended_at = Time.now + + expected_data = { + "w1" => { "waitFor" => { "waitFor" => { "tag" => "c" } } }, + "w2" => { "waitFor" => { "waitFor" => { "tag" => "a" } } }, + "w3" => { "waitFor" => { "waitFor" => { "tag" => "d" } } }, + "w4" => { "tag" => "e" } + } + assert_graphql_equal expected_data, res["data"] + # We've basically got two options here: + # - Put all jobs in the same queue (fields and sources), but then you don't get predictable batching. + # - Work one-layer-at-a-time, but then layers can get stuck behind one another. That's what's implemented here. + assert_in_delta 1.0, ended_at - started_at, ASYNC_DATALOADER_OVERHEAD_ALLOWANCE, "Sources were executed in parallel" + end + + it "groups across list items" do + query_str = <<-GRAPHQL + { + listWaiters(wait: 0.2, tags: ["a", "b", "c"]) { + waiter { + tag + } + } + } + GRAPHQL + + t1 = Time.now + result = @schema.execute(query_str) + t2 = Time.now + assert_equal ["a", "b", "c"], result["data"]["listWaiters"].map { |lw| lw["waiter"]["tag"]} + assert_equal [["a", "b", "c"]], AsyncSchema::KeyWaitForSource.fetches, "All keys were fetched at once" + # The field itself waits 0.1 + assert_in_delta 0.3, t2 - t1, ASYNC_DATALOADER_OVERHEAD_ALLOWANCE, "Wait was parallel" + end + + it 'copies fiber-local variables over to sources' do + key = 'arbitrary_context' + value = 'test' + Thread.current[key] = value + query_str = <<-GRAPHQL + { + fiberLocalContext(key: "#{key}") + } + GRAPHQL + + result = @schema.execute(query_str) + assert_equal value, result['data']['fiberLocalContext'] + end + end + end + end + + describe "with async" do + before do + @schema = AsyncSchema + AsyncSchema::KeyWaitForSource.reset + end + include AsyncDataloaderAssertions + end + + describe "with perfetto trace turned on" do + class TraceAsyncSchema < AsyncSchema + trace_with GraphQL::Tracing::PerfettoTrace + use GraphQL::Dataloader::AsyncDataloader + end + + before do + @schema = TraceAsyncSchema + AsyncSchema::KeyWaitForSource.reset + end + + include AsyncDataloaderAssertions + include PerfettoSnapshot + + it "produces a trace" do + query_str = <<-GRAPHQL + { + s1: sleeper(duration: 0.1) { + sleeper(duration: 0.1) { + sleeper(duration: 0.1) { + duration + } + } + } + s2: sleeper(duration: 0.2) { + sleeper(duration: 0.1) { + duration + } + } + s3: sleeper(duration: 0.3) { + duration + } + } + GRAPHQL + res = @schema.execute(query_str) + if ENV["DUMP_PERFETTO"] + res.context.query.current_trace.write(file: "perfetto.dump") + end + + json = res.context.query.current_trace.write(file: nil, debug_json: true) + data = JSON.parse(json) + + check_snapshot(data, if_exec_next("example-next.json", "example.json")) + end + end + + if testing_rails? && ISOLATION_LEVEL_FIBER + describe "with activerecord" do + class ActiveRecordAsyncSchema < GraphQL::Schema + class Author < GraphQL::Schema::Object + field :name, String + end + + class Book < GraphQL::Schema::Object + field :title, String + field :author, Author + end + + Author.field(:books, Book.connection_type) + + class Query < GraphQL::Schema::Object + field :book, Book, resolve_static: true do + argument :title, String + end + + def self.book(context, title:) + context.dataload_record(::Book, title, find_by: :title) + end + + def book(title:) + self.class.book(context, title: title) + end + + field :author, Author, resolve_static: true do + argument :name, String + end + + def self.author(context, name:) + context.dataload_record(::Author, name, find_by: :name) + end + + def author(name:) + self.class.author(context, name: name) + end + end + + query(Query) + use GraphQL::Dataloader::AsyncDataloader + end + + it "works with repeated queries" do + query_str = <<~GRAPHQL + { + author(name: "William Shakespeare") { name } + b1: book(title: "A Midsummer Night's Dream") { title author { name } } + b2: book(title: "Hamlet") { title author { name } } + } + GRAPHQL + + results = [] + # Emit the warning about buffer being experimental: + ActiveRecordAsyncSchema.execute(query_str) + 10.times do + stdout, stderr = capture_io do + result = ActiveRecordAsyncSchema.execute(query_str) + results << [ + result["data"]["author"]["name"], + result["data"]["b2"]["title"] + ] + end + assert_equal "", stderr, "Nothing to stderr (like warnings from Task errors)" + assert_equal "", stdout, "Nothing to stdout" + rescue + :failed + end + + assert_equal Array.new(10, ["William Shakespeare", "Hamlet"]), results + end + end + end + + describe "stress test" do + RNG = Random.new(20260724) + MAX_ITERATIONS = 2000 + WEDGE_AFTER = 10 + class SlowSource < GraphQL::Dataloader::Source + def initialize(delay) + @delay = delay + end + + def fetch(keys) + sleep(@delay) + keys.map { |k| "v#{k}" } + end + end + + it "works" do + # Standalone reproduction of graphql-ruby#5671 (AsyncDataloader hang / ClosedQueueError) + $stdout.sync = true + + GraphQL::Dataloader::AsyncDataloader.install_graphql_methods + + + iter_started_at = nil + iter = nil + iter_finished = false + main = Thread.current + + watchdog = Thread.new do + loop do + sleep 1 + started = iter_started_at + break if iter_finished + next unless started + if Process.clock_gettime(Process::CLOCK_MONOTONIC) - started > WEDGE_AFTER + puts "\n=== WEDGE: iteration #{iter} hung for >#{WEDGE_AFTER}s ===" + puts "--- main thread backtrace ---" + puts((main.backtrace || []).first(15)) + assert false, "Failed" + end + end + end + + MAX_ITERATIONS.times do |i| + iter = i + iter_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + n_jobs = 4 + RNG.rand(12) + seed_delay = RNG.rand(0.002) + + begin + dl = GraphQL::Dataloader::AsyncDataloader.new + n_jobs.times do |j| + dl.append_job do + # Everyone snoozes on the shared source first: + dl.with(SlowSource, seed_delay).load(j % 3) + case j % 3 + when 0 + # finishes in its first resumed slice - pure CPU, no yields. + when 1 + # stays "running" across the generation boundary via non-dataloader IO, + # then needs another source round: + sleep(RNG.rand(0.002)) + dl.with(SlowSource, RNG.rand(0.001)).load(100 + j) + when 2 + # a second wave of snoozers to force more generations: + dl.with(SlowSource, RNG.rand(0.001)).load(200 + (j % 2)) + sleep(RNG.rand(0.001)) + dl.with(SlowSource, RNG.rand(0.001)).load(300 + j) + end + end + end + dl.run + print "," if (i + 1) % 50 == 0 + rescue => e + puts "\n=== ERROR at iteration #{i}: #{e.class}: #{e.message} ===" + puts e.backtrace.first(12) + exit!(1) + end + iter_started_at = nil + end + iter_finished = true + assert "completed #{MAX_ITERATIONS} iterations cleanly" + assert watchdog.join + end + end + end +end diff --git a/spec/graphql/dataloader/nonblocking_dataloader_spec.rb b/spec/graphql/dataloader/nonblocking_dataloader_spec.rb new file mode 100644 index 00000000000..a30e7e3fcd3 --- /dev/null +++ b/spec/graphql/dataloader/nonblocking_dataloader_spec.rb @@ -0,0 +1,270 @@ +# frozen_string_literal: true +require "spec_helper" + +if Fiber.respond_to?(:scheduler) # Ruby 3+ + describe "GraphQL::Dataloader::NonblockingDataloader" do + class NonblockingSchema < GraphQL::Schema + class SleepSource < GraphQL::Dataloader::Source + def fetch(keys) + max_sleep = keys.max + # t1 = Time.now + # puts "----- SleepSource => #{max_sleep} " + sleep(max_sleep) + # puts "----- SleepSource done #{max_sleep} after #{Time.now - t1}" + keys.map { |_k| max_sleep } + end + end + + class WaitForSource < GraphQL::Dataloader::Source + def initialize(tag) + @tag = tag + end + + def fetch(waits) + max_wait = waits.max + # puts "[#{Time.now.to_f}] Waiting #{max_wait} for #{@tag}" + `sleep #{max_wait}` + # puts "[#{Time.now.to_f}] Finished for #{@tag}" + waits.map { |_w| @tag } + end + end + + class Sleeper < GraphQL::Schema::Object + field :sleeper, Sleeper, null: false, resolver_method: :sleep, resolve_static: :sleep do + argument :duration, Float + end + + def self.sleep(context, duration:) + `sleep #{duration}` + duration + end + + def sleep(duration:) + self.class.sleep(context, duration: duration) + end + + field :duration, Float, null: false, resolve_each: true + def self.duration(object, context); object; end + + def duration; self.class.duration(object, context); end + end + + class Waiter < GraphQL::Schema::Object + field :wait_for, Waiter, null: false, resolve_batch: true do + argument :tag, String + argument :wait, Float + end + + def self.wait_for(objects, context, tag:, wait:) + context.dataload_all(WaitForSource, tag, Array.new(objects.size, wait)) + end + + def wait_for(tag:, wait:) + dataloader.with(WaitForSource, tag).load(wait) + end + + field :tag, String, null: false, resolve_each: true + def self.tag(object, context) + object + end + + def tag + self.class.tag(object, context) + end + end + + class Query < GraphQL::Schema::Object + field :sleep, Float, null: false, resolve_static: true do + argument :duration, Float + end + + field :sleeper, Sleeper, null: false, resolver_method: :sleep, resolve_static: :sleep do + argument :duration, Float + end + + def self.sleep(context, duration:) + `sleep #{duration}` + duration + end + + def sleep(duration:) + self.class.sleep(context, duration: duration) + end + + field :wait_for, Waiter, null: false, resolve_batch: true do + argument :tag, String + argument :wait, Float + end + + def self.wait_for(objects, context, tag:, wait:) + context.dataload_all(WaitForSource, tag, Array.new(objects.size, wait)) + end + + + def wait_for(tag:, wait:) + dataloader.with(WaitForSource, tag).load(wait) + end + end + + query(Query) + use GraphQL::Dataloader, nonblocking: true + end + + def with_scheduler + Fiber.set_scheduler(scheduler_class.new) + yield + ensure + Fiber.set_scheduler(nil) + end + + module NonblockingDataloaderAssertions + def self.included(child_class) + child_class.class_eval do + + it "runs IO in parallel by default" do + dataloader = GraphQL::Dataloader.new(nonblocking: true) + results = {} + dataloader.append_job { sleep(0.1); results[:a] = 1 } + dataloader.append_job { sleep(0.2); results[:b] = 2 } + dataloader.append_job { sleep(0.3); results[:c] = 3 } + + assert_equal({}, results, "Nothing ran yet") + started_at = Time.now + with_scheduler { dataloader.run } + ended_at = Time.now + + assert_equal({ a: 1, b: 2, c: 3 }, results, "All the jobs ran") + assert_in_delta 0.3, ended_at - started_at, 0.06, "IO ran in parallel" + end + + it "works with sources" do + dataloader = GraphQL::Dataloader.new(nonblocking: true) + r1 = dataloader.with(NonblockingSchema::SleepSource).request(0.1) + r2 = dataloader.with(NonblockingSchema::SleepSource).request(0.2) + r3 = dataloader.with(NonblockingSchema::SleepSource).request(0.3) + + v1 = nil + dataloader.append_job { + v1 = r1.load + } + started_at = Time.now + with_scheduler { dataloader.run } + ended_at = Time.now + assert_equal 0.3, v1 + started_at_2 = Time.now + # These should take no time at all since they're already resolved + v2 = r2.load + v3 = r3.load + ended_at_2 = Time.now + + assert_equal 0.3, v2 + assert_equal 0.3, v3 + assert_in_delta 0.0, started_at_2 - ended_at_2, 0.06, "Already-loaded values returned instantly" + + assert_in_delta 0.3, ended_at - started_at, 0.06, "IO ran in parallel" + end + + it "works with GraphQL" do + started_at = Time.now + res = with_scheduler { + NonblockingSchema.execute("{ s1: sleep(duration: 0.1) s2: sleep(duration: 0.2) s3: sleep(duration: 0.3) }") + } + ended_at = Time.now + assert_equal({"s1"=>0.1, "s2"=>0.2, "s3"=>0.3}, res["data"]) + assert_in_delta 0.3, ended_at - started_at, 0.06, "IO ran in parallel" + end + + it "nested fields don't wait for slower higher-level fields" do + query_str = <<-GRAPHQL + { + s1: sleeper(duration: 0.1) { + sleeper(duration: 0.1) { + sleeper(duration: 0.1) { + duration + } + } + } + s2: sleeper(duration: 0.2) { + sleeper(duration: 0.1) { + duration + } + } + s3: sleeper(duration: 0.3) { + duration + } + } + GRAPHQL + started_at = Time.now + res = with_scheduler { + NonblockingSchema.execute(query_str) + } + ended_at = Time.now + + expected_data = { + "s1" => { "sleeper" => { "sleeper" => { "duration" => 0.1 } } }, + "s2" => { "sleeper" => { "duration" => 0.1 } }, + "s3" => { "duration" => 0.3 } + } + assert_graphql_equal expected_data, res["data"] + assert_in_delta 0.3, ended_at - started_at, 0.06, "Fields ran without any waiting" + end + + it "runs dataloaders in parallel across branches" do + query_str = <<-GRAPHQL + { + w1: waitFor(tag: "a", wait: 0.2) { + waitFor(tag: "b", wait: 0.2) { + waitFor(tag: "c", wait: 0.2) { + tag + } + } + } + # After the first, these are returned eagerly from cache + w2: waitFor(tag: "a", wait: 0.2) { + waitFor(tag: "a", wait: 0.2) { + waitFor(tag: "a", wait: 0.2) { + tag + } + } + } + w3: waitFor(tag: "a", wait: 0.2) { + waitFor(tag: "b", wait: 0.2) { + waitFor(tag: "d", wait: 0.2) { + tag + } + } + } + w4: waitFor(tag: "e", wait: 0.6) { + tag + } + } + GRAPHQL + started_at = Time.now + res = with_scheduler do + NonblockingSchema.execute(query_str) + end + ended_at = Time.now + + expected_data = { + "w1" => { "waitFor" => { "waitFor" => { "tag" => "c" } } }, + "w2" => { "waitFor" => { "waitFor" => { "tag" => "a" } } }, + "w3" => { "waitFor" => { "waitFor" => { "tag" => "d" } } }, + "w4" => { "tag" => "e" } + } + assert_graphql_equal expected_data, res["data"] + # We've basically got two options here: + # - Put all jobs in the same queue (fields and sources), but then you don't get predictable batching. + # - Work one-layer-at-a-time, but then layers can get stuck behind one another. That's what's implemented here. + assert_in_delta 1.0, ended_at - started_at, 0.5, "Sources were executed in parallel" + end + end + end + end + + + describe "With the toy scheduler from Ruby's tests" do + let(:scheduler_class) { ::DummyScheduler } + include NonblockingDataloaderAssertions + end + end +end diff --git a/spec/graphql/dataloader/null_dataloader_spec.rb b/spec/graphql/dataloader/null_dataloader_spec.rb new file mode 100644 index 00000000000..f8dd600846c --- /dev/null +++ b/spec/graphql/dataloader/null_dataloader_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +require "spec_helper" + +describe "GraphQL NullDataloader" do + it "can run_isolated with previously-captured blocks that register lazies" do + dl = GraphQL::Dataloader::NullDataloader.new + result = 0 + dl.run_isolated { + lazy = GraphQL::Execution::Lazy.new { result = 100 } + dl.lazy_at_depth(1, lazy) + } + assert_equal 100, result + end +end diff --git a/spec/graphql/dataloader/snapshots/example-next.json b/spec/graphql/dataloader/snapshots/example-next.json new file mode 100644 index 00000000000..88136f7a0ef --- /dev/null +++ b/spec/graphql/dataloader/snapshots/example-next.json @@ -0,0 +1,2194 @@ +{ + "packet": [ + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "previousPacketDropped": true, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Main Thread", + "childOrdering": "CHRONOLOGICAL" + }, + "firstPacketOnSequence": true + }, + { + "trustedPacketSequenceId": 101010101010, + "internedData": { + "eventCategories": [ + { + "iid": "10101010101010", + "name": "Dataloader" + }, + { + "iid": "10101010101010", + "name": "Field Execution" + }, + { + "iid": "10101010101010", + "name": "ActiveSupport::Notifications" + }, + { + "iid": "10101010101010", + "name": "Authorized" + }, + { + "iid": "10101010101010", + "name": "Resolve Type" + }, + { + "iid": "10101010101010", + "name": "Debug Inspect" + } + ], + "eventNames": [ + { + "iid": "10101010101010", + "name": "GraphQL::Tracing::DetailedTrace#inspect_object" + } + ], + "debugAnnotationNames": [ + { + "iid": "10101010101010", + "name": "object" + }, + { + "iid": "10101010101010", + "name": "result" + }, + { + "iid": "10101010101010", + "name": "arguments" + }, + { + "iid": "10101010101010", + "name": "fetch keys" + }, + { + "iid": "10101010101010", + "name": "inspect instance of" + }, + { + "iid": "10101010101010", + "name": "inspecting for" + } + ], + "debugAnnotationStringValues": [ + { + "iid": "10101010101010", + "str": "KG5pbCk=\n" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Main Fiber", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Allocated Objects", + "parentUuid": "10101010101010", + "counter": {} + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_COUNTER", + "trackUuid": "10101010101010", + "counterValue": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Active Fibers", + "parentUuid": "10101010101010", + "counter": {} + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_COUNTER", + "trackUuid": "10101010101010", + "counterValue": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Resolved Fields", + "parentUuid": "10101010101010", + "counter": {} + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_COUNTER", + "trackUuid": "10101010101010", + "counterValue": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Parse", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "boolValue": true, + "name": "valid?" + }, + { + "nameIid": "10101010101010", + "boolValue": true, + "name": "validate?" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Validate\n", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "internedData": { + "debugAnnotationNames": [ + { + "iid": "10101010101010", + "name": "validate?" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "stringValue": "query {\n s1: sleeper(duration: 0.1) {\n sleeper(duration: 0.1) {\n sleeper(duration: 0.1) {\n duration\n }\n }\n }\n s2: sleeper(duration: 0.2) {\n sleeper(duration: 0.1) {\n duration\n }\n }\n s3: sleeper(duration: 0.3) {\n duration\n }\n}", + "name": "query_string" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Multiplex" + }, + "internedData": { + "debugAnnotationNames": [ + { + "iid": "10101010101010", + "name": "valid?" + }, + { + "iid": "10101010101010", + "name": "query_string" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "name": "analyzers" + }, + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "analyzers_count" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Analysis\n", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "internedData": { + "debugAnnotationNames": [ + { + "iid": "10101010101010", + "name": "analyzers_count" + }, + { + "iid": "10101010101010", + "name": "analyzers" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_COUNTER", + "trackUuid": "10101010101010", + "counterValue": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Dataloader Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Execution Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Exec Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Query.sleeper", + "flowIds": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Yield" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Execution Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Exec Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Query.sleeper", + "flowIds": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Yield" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Execution Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Exec Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Query.sleeper", + "flowIds": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Yield" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Source Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Source Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "@tag" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "fetch keys" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "flowIds": [ + "10101010101010" + ], + "name": "AsyncSchema::SleepSource" + }, + "internedData": { + "eventNames": [ + { + "iid": "10101010101010", + "name": "AsyncSchema::SleepSource" + } + ], + "debugAnnotationNames": [ + { + "iid": "10101010101010", + "name": "10101010101010" + }, + { + "iid": "10101010101010", + "name": "@tag" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Source Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Source Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "@tag" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "fetch keys" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "flowIds": [ + "10101010101010" + ], + "name": "AsyncSchema::SleepSource" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Source Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Source Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "@tag" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "fetch keys" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "flowIds": [ + "10101010101010" + ], + "name": "AsyncSchema::SleepSource" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Resume" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "dictEntries": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "stringValueIid": "10101010101010" + } + ], + "name": "object" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Query.sleeper", + "flowIds": [ + "10101010101010" + ] + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "internedData": { + "debugAnnotationNames": [ + { + "iid": "10101010101010", + "name": "duration\n" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Sleeper.sleeper", + "flowIds": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Yield" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Resume" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "dictEntries": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "stringValueIid": "10101010101010" + } + ], + "name": "object" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Query.sleeper", + "flowIds": [ + "10101010101010" + ] + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Sleeper.sleeper", + "flowIds": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Yield" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Resume" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "dictEntries": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "stringValueIid": "10101010101010" + } + ], + "name": "object" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Query.sleeper", + "flowIds": [ + "10101010101010" + ] + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "object" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Sleeper.duration", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Source Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Source Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "@tag" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "fetch keys" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "flowIds": [ + "10101010101010" + ], + "name": "AsyncSchema::SleepSource" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Source Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Source Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "@tag" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "fetch keys" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "flowIds": [ + "10101010101010" + ], + "name": "AsyncSchema::SleepSource" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Resume" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "dictEntries": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "object" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Sleeper.sleeper", + "flowIds": [ + "10101010101010" + ] + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Sleeper.sleeper", + "flowIds": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Yield" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Resume" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "dictEntries": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "object" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Sleeper.sleeper", + "flowIds": [ + "10101010101010" + ] + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "object" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Sleeper.duration", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Source Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Source Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "@tag" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "fetch keys" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "flowIds": [ + "10101010101010" + ], + "name": "AsyncSchema::SleepSource" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Resume" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "dictEntries": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "object" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Sleeper.sleeper", + "flowIds": [ + "10101010101010" + ] + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "object" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Sleeper.duration", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_COUNTER", + "trackUuid": "10101010101010", + "counterValue": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + } + ] +} \ No newline at end of file diff --git a/spec/graphql/dataloader/snapshots/example.json b/spec/graphql/dataloader/snapshots/example.json new file mode 100644 index 00000000000..f2d2d2ea4b6 --- /dev/null +++ b/spec/graphql/dataloader/snapshots/example.json @@ -0,0 +1,2446 @@ +{ + "packet": [ + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "previousPacketDropped": true, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Main Thread", + "childOrdering": "CHRONOLOGICAL" + }, + "firstPacketOnSequence": true + }, + { + "trustedPacketSequenceId": 101010101010, + "internedData": { + "eventCategories": [ + { + "iid": "10101010101010", + "name": "Dataloader" + }, + { + "iid": "10101010101010", + "name": "Field Execution" + }, + { + "iid": "10101010101010", + "name": "ActiveSupport::Notifications" + }, + { + "iid": "10101010101010", + "name": "Authorized" + }, + { + "iid": "10101010101010", + "name": "Resolve Type" + }, + { + "iid": "10101010101010", + "name": "Debug Inspect" + } + ], + "eventNames": [ + { + "iid": "10101010101010", + "name": "GraphQL::Tracing::DetailedTrace#inspect_object" + } + ], + "debugAnnotationNames": [ + { + "iid": "10101010101010", + "name": "object" + }, + { + "iid": "10101010101010", + "name": "result" + }, + { + "iid": "10101010101010", + "name": "arguments" + }, + { + "iid": "10101010101010", + "name": "fetch keys" + }, + { + "iid": "10101010101010", + "name": "inspect instance of" + }, + { + "iid": "10101010101010", + "name": "inspecting for" + } + ], + "debugAnnotationStringValues": [ + { + "iid": "10101010101010", + "str": "KG5pbCk=\n" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Main Fiber", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Allocated Objects", + "parentUuid": "10101010101010", + "counter": {} + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_COUNTER", + "trackUuid": "10101010101010", + "counterValue": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Active Fibers", + "parentUuid": "10101010101010", + "counter": {} + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_COUNTER", + "trackUuid": "10101010101010", + "counterValue": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Resolved Fields", + "parentUuid": "10101010101010", + "counter": {} + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_COUNTER", + "trackUuid": "10101010101010", + "counterValue": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Parse", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "boolValue": true, + "name": "valid?" + }, + { + "nameIid": "10101010101010", + "boolValue": true, + "name": "validate?" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Validate\n", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "internedData": { + "debugAnnotationNames": [ + { + "iid": "10101010101010", + "name": "validate?" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "stringValue": "query {\n s1: sleeper(duration: 0.1) {\n sleeper(duration: 0.1) {\n sleeper(duration: 0.1) {\n duration\n }\n }\n }\n s2: sleeper(duration: 0.2) {\n sleeper(duration: 0.1) {\n duration\n }\n }\n s3: sleeper(duration: 0.3) {\n duration\n }\n}", + "name": "query_string" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "Multiplex" + }, + "internedData": { + "debugAnnotationNames": [ + { + "iid": "10101010101010", + "name": "valid?" + }, + { + "iid": "10101010101010", + "name": "query_string" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "name": "analyzers" + }, + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "analyzers_count" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Analysis\n", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "internedData": { + "debugAnnotationNames": [ + { + "iid": "10101010101010", + "name": "analyzers_count" + }, + { + "iid": "10101010101010", + "name": "analyzers" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_COUNTER", + "trackUuid": "10101010101010", + "counterValue": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Dataloader Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Execution Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Exec Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Authorized" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "boolValue": true, + "name": "authorized?" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "name": "Authorize: Query" + }, + "internedData": { + "eventNames": [ + { + "iid": "10101010101010", + "name": "Authorize: Query" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "s1", + "flowIds": [ + "10101010101010" + ] + }, + "internedData": { + "debugAnnotationNames": [ + { + "iid": "10101010101010", + "name": "authorized?" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Yield" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Execution Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Exec Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "s2", + "flowIds": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Yield" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Execution Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Exec Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "s3", + "flowIds": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Yield" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Source Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Source Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "@tag" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "fetch keys" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "flowIds": [ + "10101010101010" + ], + "name": "AsyncSchema::SleepSource" + }, + "internedData": { + "eventNames": [ + { + "iid": "10101010101010", + "name": "AsyncSchema::SleepSource" + } + ], + "debugAnnotationNames": [ + { + "iid": "10101010101010", + "name": "10101010101010" + }, + { + "iid": "10101010101010", + "name": "@tag" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Source Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Source Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "@tag" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "fetch keys" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "flowIds": [ + "10101010101010" + ], + "name": "AsyncSchema::SleepSource" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Source Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Source Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "@tag" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "fetch keys" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "flowIds": [ + "10101010101010" + ], + "name": "AsyncSchema::SleepSource" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Resume" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "dictEntries": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "stringValueIid": "10101010101010", + "name": "object", + "stringValue": null + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "s1", + "flowIds": [ + "10101010101010" + ] + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "internedData": { + "debugAnnotationNames": [ + { + "iid": "10101010101010", + "name": "duration\n" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Authorized" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "boolValue": true, + "name": "authorized?" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "name": "Authorize: Sleeper" + }, + "internedData": { + "eventNames": [ + { + "iid": "10101010101010", + "name": "Authorize: Sleeper" + } + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "s1.sleeper", + "flowIds": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Yield" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Resume" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "dictEntries": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "stringValueIid": "10101010101010", + "name": "object", + "stringValue": null + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "s2", + "flowIds": [ + "10101010101010" + ] + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Authorized" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "boolValue": true, + "name": "authorized?" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "name": "Authorize: Sleeper" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "s2.sleeper", + "flowIds": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Yield" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Resume" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "dictEntries": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "stringValueIid": "10101010101010", + "name": "object", + "stringValue": null + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "s3", + "flowIds": [ + "10101010101010" + ] + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Authorized" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "boolValue": true, + "name": "authorized?" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "name": "Authorize: Sleeper" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "object" + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "s3.duration", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Source Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Source Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "@tag" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "fetch keys" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "flowIds": [ + "10101010101010" + ], + "name": "AsyncSchema::SleepSource" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Source Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Source Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "@tag" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "fetch keys" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "flowIds": [ + "10101010101010" + ], + "name": "AsyncSchema::SleepSource" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Resume" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "dictEntries": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "object" + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "s1.sleeper", + "flowIds": [ + "10101010101010" + ] + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Authorized" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "boolValue": true, + "name": "authorized?" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "name": "Authorize: Sleeper" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "s1.sleeper.sleeper", + "flowIds": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Yield" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Resume" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "dictEntries": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "object" + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "s2.sleeper", + "flowIds": [ + "10101010101010" + ] + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Authorized" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "boolValue": true, + "name": "authorized?" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "name": "Authorize: Sleeper" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "object" + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "s2.sleeper.duration", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "name": "Create Source Fiber", + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "trustedPacketSequenceId": 101010101010, + "sequenceFlags": 101010101010, + "trackDescriptor": { + "uuid": "10101010101010", + "name": "Source Fiber #1010", + "parentUuid": "10101010101010", + "childOrdering": "CHRONOLOGICAL" + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "intValue": "10101010101010", + "name": "@tag" + }, + { + "nameIid": "10101010101010", + "arrayValues": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "fetch keys" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "flowIds": [ + "10101010101010" + ], + "name": "AsyncSchema::SleepSource" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "name": "Fiber Resume" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "dictEntries": [ + { + "nameIid": "10101010101010", + "doubleValue": 101010101010 + } + ], + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "object" + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "name": "s1.sleeper.sleeper", + "flowIds": [ + "10101010101010" + ] + } + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Authorized" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "boolValue": true, + "name": "authorized?" + } + ], + "type": "TYPE_SLICE_BEGIN", + "nameIid": "10101010101010", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ], + "name": "Authorize: Sleeper" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Field Execution" + ], + "categoryIids": [ + "10101010101010" + ], + "debugAnnotations": [ + { + "nameIid": "10101010101010", + "name": "arguments" + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "object" + }, + { + "nameIid": "10101010101010", + "doubleValue": 101010101010, + "name": "result" + } + ], + "type": "TYPE_SLICE_BEGIN", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "s1.sleeper.sleeper.duration", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010", + "10101010101010" + ], + "extraCounterTrackUuids": [ + "10101010101010", + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "categories": [ + "Dataloader" + ], + "categoryIids": [ + "10101010101010" + ], + "type": "TYPE_INSTANT", + "trackUuid": "10101010101010", + "extraCounterValues": [ + "10101010101010" + ], + "name": "Fiber Exit", + "extraCounterTrackUuids": [ + "10101010101010" + ] + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_COUNTER", + "trackUuid": "10101010101010", + "counterValue": "10101010101010" + }, + "sequenceFlags": 101010101010 + }, + { + "timestamp": "10101010101010", + "trustedPacketSequenceId": 101010101010, + "trackEvent": { + "type": "TYPE_SLICE_END", + "trackUuid": "10101010101010" + }, + "sequenceFlags": 101010101010 + } + ] +} \ No newline at end of file diff --git a/spec/graphql/dataloader/source_spec.rb b/spec/graphql/dataloader/source_spec.rb new file mode 100644 index 00000000000..be7b747f7d9 --- /dev/null +++ b/spec/graphql/dataloader/source_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Dataloader::Source do + class FailsToLoadSource < GraphQL::Dataloader::Source + def fetch(keys) + dataloader.with(FailsToLoadSource).load_all(keys) + end + end + + if testing_rails? + describe "with field configuration shorthands" do + include VulfpeckSchemaHelpers + it "calls the configured source" do + exec_next_only("Only exec-next uses these configs") + result = exec_query("{ bandsCount albumsCount }") + assert_equal 4, result["data"]["bandsCount"] + assert_equal 6, result["data"]["albumsCount"] + end + end + end + + it "raises an error when it tries too many times to sync" do + dl = GraphQL::Dataloader.new + dl.append_job { dl.with(FailsToLoadSource).load(1) } + err = assert_raises RuntimeError do + dl.run + end + expected_message = "FailsToLoadSource#sync tried 1000 times to load pending keys ([1]), but they still weren't loaded. There is likely a circular dependency." + assert_equal expected_message, err.message + + dl = GraphQL::Dataloader.new(fiber_limit: 10000) + dl.append_job { dl.with(FailsToLoadSource).load(1) } + err = assert_raises RuntimeError do + dl.run + end + expected_message = "FailsToLoadSource#sync tried 1000 times to load pending keys ([1]), but they still weren't loaded. There is likely a circular dependency or `fiber_limit: 10000` is set too low." + assert_equal expected_message, err.message + end + + it "is pending when waiting for false and nil" do + dl = GraphQL::Dataloader.new + dl.with(FailsToLoadSource).request(nil) + + source_cache = dl.instance_variable_get(:@source_cache) + source_cache_for_source = source_cache[FailsToLoadSource] + + # The value of this changed in Ruby 3.3.3, see https://bugs.ruby-lang.org/issues/20180 + # In previous versions, it was `[{}]`, but now it's `[]` + empty_execution_next_key = [*[]] + source_inst = source_cache_for_source[empty_execution_next_key] + assert_instance_of FailsToLoadSource, source_inst, "The cache includes a pending source (#{source_cache_for_source.inspect})" + assert source_inst.pending? + end + + class CustomKeySource < GraphQL::Dataloader::Source + def result_key_for(record) + record[:id] + end + + def fetch(records) + records.map { |r| r[:value] * 10 } + end + end + + it "uses a custom key when configured" do + values = nil + + GraphQL::Dataloader.with_dataloading do |dl| + first_req = dl.with(CustomKeySource).request({ id: 1, value: 10 }) + second_rec = dl.with(CustomKeySource).request({ id: 2, value: 20 }) + third_rec = dl.with(CustomKeySource).request({id: 1, value: 30 }) + + values = [ + first_req.load, + second_rec.load, + third_rec.load + ] + end + + # There wasn't a `300` because the third requested value was de-duped to the first one. + assert_equal [100, 200, 100], values + end + + class NoDataloaderSchema < GraphQL::Schema + class ThingSource < GraphQL::Dataloader::Source + def fetch(ids) + ids.map { |id| { name: "Thing-#{id}" } } + end + end + + class Thing < GraphQL::Schema::Object + field :name, String + end + + class Query < GraphQL::Schema::Object + field :thing, Thing, resolve_batch: true do + argument :id, ID + end + + def self.thing(objects, context, id:) + context.dataload_all(ThingSource, Array.new(objects.size, id)) + end + + def thing(id:) + context.dataload(ThingSource, id) + end + end + query(Query) + end + + it "raises an error when used without a dataloader" do + err = assert_raises GraphQL::Error do + NoDataloaderSchema.execute("{ thing(id: 1) { name } }") + end + + expected_message = exec_next_error_message "Query.thing", "GraphQL::Dataloader is not running -- add `use GraphQL::Dataloader` to your schema to use Dataloader sources." + assert_equal expected_message, err.message + end +end diff --git a/spec/graphql/dataloader_spec.rb b/spec/graphql/dataloader_spec.rb index 48f8190f26e..6b29fad14a3 100644 --- a/spec/graphql/dataloader_spec.rb +++ b/spec/graphql/dataloader_spec.rb @@ -1,7 +1,24 @@ # frozen_string_literal: true require "spec_helper" +require "fiber" + +if defined?(Console) && defined?(Async) + Console.logger.disable(Async::Task) +end describe GraphQL::Dataloader do + class BatchedCallsCounter + def initialize + @count = 0 + end + + def increment + @count += 1 + end + + attr_reader :count + end + class FiberSchema < GraphQL::Schema module Database extend self @@ -45,6 +62,33 @@ def fetch(keys) end end + class ToString < GraphQL::Dataloader::Source + def fetch(keys) + keys.map(&:to_s) + end + end + + class PendingCheckSource < GraphQL::Dataloader::Source + class << self + attr_accessor :pending_checks + end + + self.pending_checks = 0 + + def initialize(batch_key) + @batch_key = batch_key + end + + def fetch(keys) + keys + end + + def pending? + self.class.pending_checks += 1 + super + end + end + class NestedDataObject < GraphQL::Dataloader::Source def fetch(ids) @dataloader.with(DataObject).load_all(ids) @@ -67,6 +111,22 @@ def fetch(keys) end end + class CustomBatchKeySource < GraphQL::Dataloader::Source + def initialize(batch_key) + @batch_key = batch_key + end + + def self.batch_key_for(batch_key) + Database.log << [:batch_key_for, batch_key] + # Ignore it altogether + :all_the_same + end + + def fetch(keys) + Database.mget(keys) + end + end + class KeywordArgumentSource < GraphQL::Dataloader::Source def initialize(column:) @column = column @@ -81,34 +141,79 @@ def fetch(keys) end end - module Ingredient + class AuthorizedSource < GraphQL::Dataloader::Source + def initialize(counter) + @counter = counter + end + + def fetch(recipes) + @counter&.increment + recipes.map { true } + end + end + + class ErrorSource < GraphQL::Dataloader::Source + def fetch(ids) + raise GraphQL::Error, "Source error on: #{ids.inspect}" + end + end + + class BaseField < GraphQL::Schema::Field + end + + class BaseObject < GraphQL::Schema::Object + field_class(BaseField) + end + + module BaseInterface include GraphQL::Schema::Interface - field :name, String, null: false - field :id, ID, null: false + field_class(BaseField) end - class Grain < GraphQL::Schema::Object + module Ingredient + include BaseInterface + field :name, String, null: false, hash_key: :name + field :id, ID, null: false, hash_key: :id + + field :name_by_scoped_context, String, resolve_legacy_instance_method: true + + def name_by_scoped_context + context[:ingredient_name] + end + end + + class Grain < BaseObject implements Ingredient end - class LeaveningAgent < GraphQL::Schema::Object + class LeaveningAgent < BaseObject implements Ingredient end - class Dairy < GraphQL::Schema::Object + class Dairy < BaseObject implements Ingredient end - class Recipe < GraphQL::Schema::Object - field :name, String, null: false - field :ingredients, [Ingredient], null: false + class Recipe < BaseObject + def self.authorized?(obj, ctx) + ctx.dataloader.with(AuthorizedSource, ctx[:batched_calls_counter]).load(obj) + end + + field :name, String, null: false, hash_key: :name + field :ingredients, [Ingredient], null: false, resolve_batch: true + + def self.ingredients(objects, context) + objects + .map { |obj| context.dataloader.with(DataObject).request_all(obj[:ingredient_ids]) } + .map(&:load) + end def ingredients ingredients = dataloader.with(DataObject).load_all(object[:ingredient_ids]) ingredients end - field :slow_ingredients, [Ingredient], null: false + field :slow_ingredients, [Ingredient], null: false, resolve_legacy_instance_method: true def slow_ingredients # Use `object[:id]` here to force two different instances of the loader in the test @@ -116,75 +221,122 @@ def slow_ingredients end end - class Query < GraphQL::Schema::Object - field :recipes, [Recipe], null: false + class Cookbook < BaseObject + field :featured_recipe, Recipe, resolve_legacy_instance_method: true - def recipes + def featured_recipe + -> { Database.mget([object[:featured_recipe]]).first } + end + end + + class Query < BaseObject + field :recipes, [Recipe], null: false, resolve_static: true + + def self.recipes(context) Database.mget(["5", "6"]) end - field :ingredient, Ingredient, null: true do - argument :id, ID, required: true + def recipes + self.class.recipes(context) + end + + field :ingredient, Ingredient, resolve_legacy_instance_method: true do + argument :id, ID end def ingredient(id:) dataloader.with(DataObject).load(id) end - field :ingredient_by_name, Ingredient, null: true do - argument :name, String, required: true + field :ingredient_by_name, Ingredient, resolve_legacy_instance_method: true do + argument :name, String end def ingredient_by_name(name:) - dataloader.with(DataObject, :name).load(name) + ing = dataloader.with(DataObject, :name).load(name) + context.scoped_set!(:ingredient_name, "Scoped:#{name}") + ing end - field :nested_ingredient, Ingredient, null: true do - argument :id, ID, required: true + field :nested_ingredient, Ingredient, resolve_legacy_instance_method: true do + argument :id, ID end def nested_ingredient(id:) dataloader.with(NestedDataObject).load(id) end - field :slow_recipe, Recipe, null: true do - argument :id, ID, required: true + field :slow_recipe, Recipe, resolve_legacy_instance_method: true do + argument :id, ID end def slow_recipe(id:) dataloader.with(SlowDataObject, id).load(id) end - field :recipe, Recipe, null: true do - argument :id, ID, required: true, loads: Recipe, as: :recipe + field :recipe, Recipe, resolve_legacy_instance_method: true do + argument :id, ID, loads: Recipe, as: :recipe end def recipe(recipe:) recipe end - field :key_ingredient, Ingredient, null: true do - argument :id, ID, required: true + field :recipe_by_id_using_load, Recipe, resolve_legacy_instance_method: true do + argument :id, ID, required: false + end + + def recipe_by_id_using_load(id:) + dataloader.with(DataObject).load(id) + end + + field :recipes_by_id_using_load_all, [Recipe], resolve_legacy_instance_method: true do + argument :ids, [ID, null: true] + end + + def recipes_by_id_using_load_all(ids:) + dataloader.with(DataObject).load_all(ids) + end + + field :recipes_by_id, [Recipe], resolve_static: true do + argument :ids, [ID], loads: Recipe, as: :recipes + end + + def self.recipes_by_id(context, recipes:) + recipes + end + + def recipes_by_id(recipes:) + recipes + end + + field :key_ingredient, Ingredient, resolve_legacy_instance_method: true do + argument :id, ID end def key_ingredient(id:) dataloader.with(KeywordArgumentSource, column: :id).load(id) end - field :recipe_ingredient, Ingredient, null: true do - argument :recipe_id, ID, required: true - argument :ingredient_number, Int, required: true + class RecipeIngredientInput < GraphQL::Schema::InputObject + argument :id, ID + argument :ingredient_number, Int + end + + field :recipe_ingredient, Ingredient, resolve_legacy_instance_method: true do + argument :recipe, RecipeIngredientInput end - def recipe_ingredient(recipe_id:, ingredient_number:) - recipe = dataloader.with(DataObject).load(recipe_id) - ingredient_id = recipe[:ingredient_ids][ingredient_number - 1] + def recipe_ingredient(recipe:) + recipe_object = dataloader.with(DataObject).load(recipe[:id]) + ingredient_idx = recipe[:ingredient_number] - 1 + ingredient_id = recipe_object[:ingredient_ids][ingredient_idx] dataloader.with(DataObject).load(ingredient_id) end - field :common_ingredients, [Ingredient], null: true do - argument :recipe_1_id, ID, required: true - argument :recipe_2_id, ID, required: true + field :common_ingredients, [Ingredient], resolve_legacy_instance_method: true do + argument :recipe_1_id, ID + argument :recipe_2_id, ID end def common_ingredients(recipe_1_id:, recipe_2_id:) @@ -196,309 +348,177 @@ def common_ingredients(recipe_1_id:, recipe_2_id:) dataloader.with(DataObject).load_all(common_ids) end - field :common_ingredients_with_load, [Ingredient], null: false do - argument :recipe_1_id, ID, required: true, loads: Recipe - argument :recipe_2_id, ID, required: true, loads: Recipe + field :common_ingredients_with_load, [Ingredient], null: false, resolve_batch: true do + argument :recipe_1_id, ID, loads: Recipe + argument :recipe_2_id, ID, loads: Recipe end - def common_ingredients_with_load(recipe_1:, recipe_2:) + def self.common_ingredients_with_load(objects, context, recipe_1:, recipe_2:) common_ids = recipe_1[:ingredient_ids] & recipe_2[:ingredient_ids] - dataloader.with(DataObject).load_all(common_ids) + results = context.dataloader.with(DataObject).load_all(common_ids) + Array.new(objects.size, results) end - field :common_ingredients_from_input_object, [Ingredient], null: false do + def common_ingredients_with_load(recipe_1:, recipe_2:) + self.class.common_ingredients_with_load([object], context, recipe_1: recipe_1, recipe_2: recipe_2).first + end + + field :common_ingredients_from_input_object, [Ingredient], null: false, resolve_batch: true do class CommonIngredientsInput < GraphQL::Schema::InputObject - argument :recipe_1_id, ID, required: true, loads: Recipe - argument :recipe_2_id, ID, required: true, loads: Recipe + argument :recipe_1_id, ID, loads: Recipe + argument :recipe_2_id, ID, loads: Recipe end - argument :input, CommonIngredientsInput, required: true + argument :input, CommonIngredientsInput end - def common_ingredients_from_input_object(input:) + self.class.common_ingredients_from_input_object([object], context, input: input).first + end + + def self.common_ingredients_from_input_object(objects, context, input:) recipe_1 = input[:recipe_1] recipe_2 = input[:recipe_2] common_ids = recipe_1[:ingredient_ids] & recipe_2[:ingredient_ids] - dataloader.with(DataObject).load_all(common_ids) + results = context.dataloader.with(DataObject).load_all(common_ids) + Array.new(objects.size, results) end - end - query(Query) + field :ingredient_with_custom_batch_key, Ingredient, resolve_legacy_instance_method: true do + argument :id, ID + argument :batch_key, String + end - def self.object_from_id(id, ctx) - if ctx[:use_request] - ctx.dataloader.with(DataObject).request(id) - else - ctx.dataloader.with(DataObject).load(id) + def ingredient_with_custom_batch_key(id:, batch_key:) + dataloader.with(CustomBatchKeySource, batch_key).load(id) end - end - def self.resolve_type(type, obj, ctx) - get_type(obj[:type]) - end + field :recursive_ingredient_name, String, resolve_legacy_instance_method: true do + argument :id, ID + end - orphan_types(Grain, Dairy, Recipe, LeaveningAgent) - use GraphQL::Dataloader - end + def recursive_ingredient_name(id:) + res = context.schema.execute("{ ingredient(id: #{id}) { name } }") + res["data"]["ingredient"]["name"] + end - def database_log - FiberSchema::Database.log - end + field :test_error, String, resolve_legacy_instance_method: true do + argument :source, Boolean, required: false, default_value: false + end - before do - database_log.clear - end + def test_error(source:) + if source + dataloader.with(ErrorSource).load(1) + else + raise GraphQL::Error, "Field error" + end + end - it "Works with request(...)" do - res = FiberSchema.execute <<-GRAPHQL - { - commonIngredients(recipe1Id: 5, recipe2Id: 6) { - name - } - } - GRAPHQL - - expected_data = { - "data" => { - "commonIngredients" => [ - { "name" => "Corn" }, - { "name" => "Butter" }, + class LookaheadInput < GraphQL::Schema::InputObject + argument :id, ID + argument :batch_key, String + end + + field :lookahead_ingredient, Ingredient, extras: [:lookahead], resolve_legacy_instance_method: true do + argument :input, LookaheadInput + end + + def lookahead_ingredient(input:, lookahead:) + lookahead.arguments # forces a dataloader.run_isolated call + dataloader.with(CustomBatchKeySource, input[:batch_key]).load(input[:id]) + end + + field :cookbooks, [Cookbook], resolve_legacy_instance_method: true + + def cookbooks + [ + { featured_recipe: "5" }, + { featured_recipe: "6" }, ] - } - } - assert_equal expected_data, res - assert_equal [[:mget, ["5", "6"]], [:mget, ["2", "3"]]], database_log - end + end + end + + query(Query) - it "batch-loads" do - res = FiberSchema.execute <<-GRAPHQL - { - i1: ingredient(id: 1) { id name } - i2: ingredient(id: 2) { name } - r1: recipe(id: 5) { - ingredients { name } + class Mutation1 < GraphQL::Schema::Mutation + argument :argument_1, String, prepare: ->(val, ctx) { + raise FieldTestError } - ri1: recipeIngredient(recipeId: 6, ingredientNumber: 3) { - name + field :value, String, hash_key: :value + def resolve(argument_1:) + { value: argument_1 } + end + end + + class Mutation2 < GraphQL::Schema::Mutation + argument :argument_2, String, prepare: ->(val, ctx) { + raise FieldTestError } - } - GRAPHQL - - expected_data = { - "i1" => { "id" => "1", "name" => "Wheat" }, - "i2" => { "name" => "Corn" }, - "r1" => { - "ingredients" => [ - { "name" => "Wheat" }, - { "name" => "Corn" }, - { "name" => "Butter" }, - { "name" => "Baking Soda" }, - ], - }, - "ri1" => { - "name" => "Cheese", - }, - } - assert_equal(expected_data, res["data"]) - - expected_log = [ - [:mget, [ - "1", "2", # The first 2 ingredients - "5", # The first recipe - "6", # recipeIngredient recipeId - ]], - [:mget, [ - "3", "4", # The two unfetched ingredients the first recipe - "7", # recipeIngredient ingredient_id - ]], - ] - assert_equal expected_log, database_log - end + field :value, String, hash_key: :value + def resolve(argument_2:) + { value: argument_2 } + end + end - it "caches and batch-loads across a multiplex" do - context = {} - result = FiberSchema.multiplex([ - { query: "{ i1: ingredient(id: 1) { name } i2: ingredient(id: 2) { name } }", }, - { query: "{ i2: ingredient(id: 2) { name } r1: recipe(id: 5) { ingredients { name } } }", }, - { query: "{ i1: ingredient(id: 1) { name } ri1: recipeIngredient(recipeId: 5, ingredientNumber: 2) { name } }", }, - ], context: context) - - expected_result = [ - {"data"=>{"i1"=>{"name"=>"Wheat"}, "i2"=>{"name"=>"Corn"}}}, - {"data"=>{"i2"=>{"name"=>"Corn"}, "r1"=>{"ingredients"=>[{"name"=>"Wheat"}, {"name"=>"Corn"}, {"name"=>"Butter"}, {"name"=>"Baking Soda"}]}}}, - {"data"=>{"i1"=>{"name"=>"Wheat"}, "ri1"=>{"name"=>"Corn"}}}, - ] - assert_equal expected_result, result - expected_log = [ - [:mget, ["1", "2", "5"]], - [:mget, ["3", "4"]], - ] - assert_equal expected_log, database_log - end + class Mutation3 < GraphQL::Schema::Mutation + argument :label, String + type String - it "works with calls within sources" do - res = FiberSchema.execute <<-GRAPHQL - { - i1: nestedIngredient(id: 1) { name } - i2: nestedIngredient(id: 2) { name } - } - GRAPHQL - - expected_data = { "i1" => { "name" => "Wheat" }, "i2" => { "name" => "Corn" } } - assert_equal expected_data, res["data"] - assert_equal [[:mget, ["1", "2"]]], database_log - end + def resolve(label:) + log = context[:mutation_log] ||= [] + log << "begin #{label}" + dataloader.with(DataObject).load(1) + log << "end #{label}" + label + end + end - it "works with batch parameters" do - res = FiberSchema.execute <<-GRAPHQL - { - i1: ingredientByName(name: "Butter") { id } - i2: ingredientByName(name: "Corn") { id } - i3: ingredientByName(name: "Gummi Bears") { id } - } - GRAPHQL - - expected_data = { - "i1" => { "id" => "3" }, - "i2" => { "id" => "2" }, - "i3" => nil, - } - assert_equal expected_data, res["data"] - assert_equal [[:find_by, :name, ["Butter", "Corn", "Gummi Bears"]]], database_log - end + class GetCache < GraphQL::Schema::Mutation + type String + def resolve + dataloader.with(ToString).load(1) + end + end - it "works with manual parallelism" do - start = Time.now.to_f - FiberSchema.execute <<-GRAPHQL - { - i1: slowRecipe(id: 5) { slowIngredients { name } } - i2: slowRecipe(id: 6) { slowIngredients { name } } - } - GRAPHQL - finish = Time.now.to_f - - # Each load slept for 0.5 second, so sequentially, this would have been 2s sequentially - assert_in_delta 1, finish - start, 0.1, "Load threads are executed in parallel" - expected_log = [ - # These were separated because of different recipe IDs: - [:mget, ["5"]], - [:mget, ["6"]], - # These were cached separately because of different recipe IDs: - [:mget, ["2", "3", "7"]], - [:mget, ["1", "2", "3", "4"]], - ] - # Sort them because threads may have returned in slightly different order - assert_equal expected_log.sort, database_log.sort - end + class Mutation < BaseObject + field :mutation_1, mutation: Mutation1 + field :mutation_2, mutation: Mutation2 + field :mutation_3, mutation: Mutation3 + field :set_cache, String, resolve_legacy_instance_method: true do + argument :input, String + end - it "Works with multiple-field selections and __typename" do - query_str = <<-GRAPHQL - { - ingredient(id: 1) { - __typename - name - } - } - GRAPHQL - - res = FiberSchema.execute(query_str) - expected_data = { - "ingredient" => { - "__typename" => "Grain", - "name" => "Wheat", - } - } - assert_equal expected_data, res["data"] - end + def set_cache(input:) + dataloader.with(ToString).merge({ 1 => input }) + input + end - it "Works when the parent field didn't yield" do - query_str = <<-GRAPHQL - { - recipes { - ingredients { - name - } - } - } - GRAPHQL - - res = FiberSchema.execute(query_str) - expected_data = { - "recipes" =>[ - { "ingredients" => [ - {"name"=>"Wheat"}, - {"name"=>"Corn"}, - {"name"=>"Butter"}, - {"name"=>"Baking Soda"} - ]}, - { "ingredients" => [ - {"name"=>"Corn"}, - {"name"=>"Butter"}, - {"name"=>"Cheese"} - ]}, - ] - } - assert_equal expected_data, res["data"] + field :get_cache, mutation: GetCache + end - expected_log = [ - [:mget, ["5", "6"]], - [:mget, ["1", "2", "3", "4", "7"]], - ] - assert_equal expected_log, database_log - end + mutation(Mutation) - it "loads arguments in batches, even with request" do - query_str = <<-GRAPHQL - { - commonIngredientsWithLoad(recipe1Id: 5, recipe2Id: 6) { - name - } - } - GRAPHQL - - res = FiberSchema.execute(query_str) - expected_data = { - "commonIngredientsWithLoad" => [ - {"name"=>"Corn"}, - {"name"=>"Butter"}, - ] - } - assert_equal expected_data, res["data"] + def self.object_from_id(id, ctx) + ctx.dataloader.with(DataObject).load(id) + end - expected_log = [ - [:mget, ["5", "6"]], - [:mget, ["2", "3"]], - ] - assert_equal expected_log, database_log + def self.resolve_type(type, obj, ctx) + get_type(obj[:type]) + end - # Run the same test, but using `.request` from object_from_id - database_log.clear - res2 = FiberSchema.execute(query_str, context: { use_request: true }) - assert_equal expected_data, res2["data"] - assert_equal expected_log, database_log - end + orphan_types(Grain, Dairy, Recipe, LeaveningAgent) + use GraphQL::Dataloader + lazy_resolve Proc, :call - it "works with sources that use keyword arguments in the initializer" do - query_str = <<-GRAPHQL - { - keyIngredient(id: 1) { - __typename - name - } - } - GRAPHQL - - res = FiberSchema.execute(query_str) - expected_data = { - "keyIngredient" => { - "__typename" => "Grain", - "name" => "Wheat", - } - } - assert_equal expected_data, res["data"] + class FieldTestError < StandardError; end + + rescue_from(FieldTestError) do |err, obj, args, ctx, field| + errs = ctx[:errors] ||= [] + errs << "FieldTestError @ #{ctx[:current_path] || "--"}, #{field.path} / #{ctx[:current_field]&.path || "--"}" + nil + end end - class UsageAnalyzer < GraphQL::Analysis::AST::Analyzer + class UsageAnalyzer < GraphQL::Analysis::Analyzer def initialize(query) @query = query @fields = Set.new @@ -519,89 +539,832 @@ def result end end - it "Works with analyzing arguments with `loads:`, even with .request" do - query_str = <<-GRAPHQL - { - commonIngredientsWithLoad(recipe1Id: 5, recipe2Id: 6) { - name - } - } - GRAPHQL - query = GraphQL::Query.new(FiberSchema, query_str) - results = GraphQL::Analysis::AST.analyze_query(query, [UsageAnalyzer]) - expected_results = [ - ["commonIngredientsWithLoad", [:recipe_1, :recipe_2]], - ["name", []], - ] - assert_equal expected_results, results.first.to_a + def database_log + FiberSchema::Database.log + end - query2 = GraphQL::Query.new(FiberSchema, query_str, context: { use_request: true }) - result2 = GraphQL::Analysis::AST.analyze_query(query2, [UsageAnalyzer]) - assert_equal expected_results, result2.first.to_a + before do + database_log.clear end - it "Works with input objects, load and request" do - query_str = <<-GRAPHQL - { - commonIngredientsFromInputObject(input: { recipe1Id: 5, recipe2Id: 6 }) { - name - } - } - GRAPHQL - res = FiberSchema.execute(query_str) - expected_data = { - "commonIngredientsFromInputObject" => [ - {"name"=>"Corn"}, - {"name"=>"Butter"}, + ALL_FIBERS = [] + + + class PartsSchema < GraphQL::Schema + class FieldSource < GraphQL::Dataloader::Source + DATA = [ + {"id" => 1, "name" => "a"}, + {"id" => 2, "name" => "b"}, + {"id" => 3, "name" => "c"}, + {"id" => 4, "name" => "d"}, ] - } - assert_equal expected_data, res["data"] + def fetch(fields) + @previously_fetched ||= Set.new + fields.each do |f| + if !@previously_fetched.add?(f) + raise "Duplicate fetch for #{f.inspect}" + end + end + Array.new(fields.size, DATA) + end + end - expected_log = [ - [:mget, ["5", "6"]], - [:mget, ["2", "3"]], - ] - assert_equal expected_log, database_log + class StringFilter < GraphQL::Schema::InputObject + argument :equal_to_any_of, [String] + end + class ComponentFilter < GraphQL::Schema::InputObject + argument :name, StringFilter + end - # Run the same test, but using `.request` from object_from_id - database_log.clear - res2 = FiberSchema.execute(query_str, context: { use_request: true }) - assert_equal expected_data, res2["data"] - assert_equal expected_log, database_log + class FetchObjects < GraphQL::Schema::Resolver + argument :filter, ComponentFilter, required: false + def resolve(**_kwargs) + context.dataloader.with(FieldSource).load("#{field.path}/#{object&.fetch("id")}") + end + end + + class Component < GraphQL::Schema::Object + field :name, String, hash_key: "name" + end + + class Part < GraphQL::Schema::Object + field :components, [Component], resolver: FetchObjects + end + + class Manufacturer < GraphQL::Schema::Object + field :parts, [Part], resolver: FetchObjects + end + + class Query < GraphQL::Schema::Object + field :manufacturers, [Manufacturer], resolver: FetchObjects + end + + query(Query) + use GraphQL::Dataloader end - it "Works with input objects using variables, load and request" do - query_str = <<-GRAPHQL - query($input: CommonIngredientsInput!) { - commonIngredientsFromInputObject(input: $input) { - name - } - } - GRAPHQL - res = FiberSchema.execute(query_str, variables: { input: { recipe1Id: 5, recipe2Id: 6 }}) - expected_data = { - "commonIngredientsFromInputObject" => [ - {"name"=>"Corn"}, - {"name"=>"Butter"}, - ] - } - assert_equal expected_data, res["data"] + module DataloaderAssertions + module FiberCounting + class << self + attr_accessor :starting_count, :last_spawn_count, :last_max_count, :graphql_fiber_ids - expected_log = [ - [:mget, ["5", "6"]], - [:mget, ["2", "3"]], - ] - assert_equal expected_log, database_log + def current_count + count_active - starting_count + end + def count_active + GC.start + fibers = ObjectSpace.each_object(Fiber) + countable_ids = fibers.map(&:object_id) & graphql_fiber_ids.to_a + countable_ids.count + end - # Run the same test, but using `.request` from object_from_id - database_log.clear - res2 = FiberSchema.execute(query_str, context: { use_request: true }, variables: { input: { recipe1Id: 5, recipe2Id: 6 }}) - assert_equal expected_data, res2["data"] - assert_equal expected_log, database_log + def update_counts + FiberCounting.last_spawn_count += 1 + current_count = FiberCounting.current_count + if current_count > FiberCounting.last_max_count + FiberCounting.last_max_count = current_count + end + end + end + + def initialize(*args, **kwargs, &block) + super + FiberCounting.graphql_fiber_ids = Set.new + FiberCounting.starting_count = FiberCounting.count_active + FiberCounting.last_max_count = 0 + FiberCounting.last_spawn_count = 0 + end + + def set_fiber_variables(vars) + super + FiberCounting.graphql_fiber_ids.add(Fiber.current.object_id) + FiberCounting.update_counts + end + + def cleanup_fiber + FiberCounting.graphql_fiber_ids.delete(Fiber.current.object_id) + end + end + + def self.included(child_class) + child_class.class_eval do + let(:schema) { make_schema_from(FiberSchema) } + let(:parts_schema) { make_schema_from(PartsSchema) } + + def exec_query(query_string, schema: self.schema, context: nil, variables: nil) + schema.execute(query_string, context: context, variables: variables) + end + + it "Works with request(...)" do + res = exec_query <<-GRAPHQL + { + commonIngredients(recipe1Id: 5, recipe2Id: 6) { + name + } + } + GRAPHQL + + expected_data = { + "data" => { + "commonIngredients" => [ + { "name" => "Corn" }, + { "name" => "Butter" }, + ] + } + } + assert_graphql_equal expected_data, res + assert_equal [[:mget, ["5", "6"]], [:mget, ["2", "3"]]], database_log + end + + it "runs mutations sequentially" do + res = exec_query <<-GRAPHQL + mutation { + first: mutation3(label: "first") + second: mutation3(label: "second") + } + GRAPHQL + + assert_equal({ "first" => "first", "second" => "second" }, res["data"]) + assert_equal ["begin first", "end first", "begin second", "end second"], res.context[:mutation_log] + end + + it "clears the cache between mutations" do + res = exec_query <<-GRAPHQL + mutation { + setCache(input: "Salad") + getCache + } + GRAPHQL + + assert_equal({"setCache" => "Salad", "getCache" => "1"}, res["data"]) + end + + it "batch-loads" do + res = exec_query <<-GRAPHQL + { + i1: ingredient(id: 1) { id name } + i2: ingredient(id: 2) { name } + __typename + r1: recipe(id: 5) { + # This loads Ingredients 3 and 4 + ingredients { name } + } + # This loads Ingredient 7 + ri1: recipeIngredient(recipe: { id: 6, ingredientNumber: 3 }) { + name + } + } + GRAPHQL + + expected_data = { + "i1" => { "id" => "1", "name" => "Wheat" }, + "i2" => { "name" => "Corn" }, + "__typename" => "Query", + "r1" => { + "ingredients" => [ + { "name" => "Wheat" }, + { "name" => "Corn" }, + { "name" => "Butter" }, + { "name" => "Baking Soda" }, + ], + }, + "ri1" => { + "name" => "Cheese", + }, + } + assert_graphql_equal(expected_data, res["data"]) + + expected_log = [ + [:mget, [ + "1", "2", # The first 2 ingredients + "5", # The first recipe + "6", # recipeIngredient recipeId + ]], + [:mget, [ + "7", # recipeIngredient ingredient_id + ]], + [:mget, [ + "3", "4", # The two unfetched ingredients the first recipe + ]], + ] + assert_equal expected_log, database_log + end + + it "caches and batch-loads across a multiplex" do + context = {} + result = schema.multiplex([ + { query: "{ i1: ingredient(id: 1) { name } i2: ingredient(id: 2) { name } }", }, + { query: "{ i2: ingredient(id: 2) { name } r1: recipe(id: 5) { ingredients { name } } }", }, + { query: "{ i1: ingredient(id: 1) { name } ri1: recipeIngredient(recipe: { id: 5, ingredientNumber: 2 }) { name } }", }, + ], context: context) + + expected_result = [ + {"data"=>{"i1"=>{"name"=>"Wheat"}, "i2"=>{"name"=>"Corn"}}}, + {"data"=>{"i2"=>{"name"=>"Corn"}, "r1"=>{"ingredients"=>[{"name"=>"Wheat"}, {"name"=>"Corn"}, {"name"=>"Butter"}, {"name"=>"Baking Soda"}]}}}, + {"data"=>{"i1"=>{"name"=>"Wheat"}, "ri1"=>{"name"=>"Corn"}}}, + ] + assert_graphql_equal expected_result, result + expected_log = [ + [:mget, ["1", "2", "5"]], + [:mget, ["3", "4"]], + ] + assert_equal expected_log, database_log + end + + it "works with calls within sources" do + res = exec_query <<-GRAPHQL + { + i1: nestedIngredient(id: 1) { name } + i2: nestedIngredient(id: 2) { name } + } + GRAPHQL + + expected_data = { "i1" => { "name" => "Wheat" }, "i2" => { "name" => "Corn" } } + assert_graphql_equal expected_data, res["data"] + assert_equal [[:mget, ["1", "2"]]], database_log + end + + it "works with batch parameters" do + res = exec_query <<-GRAPHQL + { + i1: ingredientByName(name: "Butter") { id } + i2: ingredientByName(name: "Corn") { id } + i3: ingredientByName(name: "Gummi Bears") { id } + } + GRAPHQL + + expected_data = { + "i1" => { "id" => "3" }, + "i2" => { "id" => "2" }, + "i3" => nil, + } + assert_graphql_equal expected_data, res["data"] + assert_equal [[:find_by, :name, ["Butter", "Corn", "Gummi Bears"]]], database_log + end + + it "works with manual parallelism" do + start = Time.now.to_f + exec_query <<-GRAPHQL + { + i1: slowRecipe(id: 5) { slowIngredients { name } } + i2: slowRecipe(id: 6) { slowIngredients { name } } + } + GRAPHQL + finish = Time.now.to_f + + # For some reason Async adds some overhead to this manual parallelism. + # But who cares, you wouldn't use Thread#join in that case + delta = schema.dataloader_class == GraphQL::Dataloader ? 0.1 : 0.5 + # Each load slept for 0.5 second, so sequentially, this would have been 2s sequentially + assert_in_delta 1, finish - start, delta, "Load threads are executed in parallel" + expected_log = [ + # These were separated because of different recipe IDs: + [:mget, ["5"]], + [:mget, ["6"]], + # These were cached separately because of different recipe IDs: + [:mget, ["2", "3", "7"]], + [:mget, ["1", "2", "3", "4"]], + ] + # Sort them because threads may have returned in slightly different order + assert_equal expected_log.sort, database_log.sort + end + + it "Works with multiple-field selections and __typename" do + query_str = <<-GRAPHQL + { + ingredient(id: 1) { + __typename + name + } + } + GRAPHQL + + res = exec_query(query_str) + expected_data = { + "ingredient" => { + "__typename" => "Grain", + "name" => "Wheat", + } + } + assert_graphql_equal expected_data, res["data"] + end + + it "Works when the parent field didn't yield" do + query_str = <<-GRAPHQL + { + recipes { + ingredients { + name + } + } + } + GRAPHQL + + res = exec_query(query_str) + expected_data = { + "recipes" =>[ + { "ingredients" => [ + {"name"=>"Wheat"}, + {"name"=>"Corn"}, + {"name"=>"Butter"}, + {"name"=>"Baking Soda"} + ]}, + { "ingredients" => [ + {"name"=>"Corn"}, + {"name"=>"Butter"}, + {"name"=>"Cheese"} + ]}, + ] + } + assert_graphql_equal expected_data, res["data"] + + expected_log = [ + [:mget, ["5", "6"]], + [:mget, ["1", "2", "3", "4", "7"]], + ] + assert_equal expected_log, database_log + end + + it "loads arguments in batches, even with request" do + query_str = <<-GRAPHQL + { + commonIngredientsWithLoad(recipe1Id: 5, recipe2Id: 6) { + name + } + } + GRAPHQL + + res = exec_query(query_str) + expected_data = { + "commonIngredientsWithLoad" => [ + {"name"=>"Corn"}, + {"name"=>"Butter"}, + ] + } + assert_graphql_equal expected_data, res["data"] + + expected_log = [ + [:mget, ["5", "6"]], + [:mget, ["2", "3"]], + ] + assert_equal expected_log, database_log + end + + it "works with sources that use keyword arguments in the initializer" do + query_str = <<-GRAPHQL + { + keyIngredient(id: 1) { + __typename + name + } + } + GRAPHQL + + res = exec_query(query_str) + expected_data = { + "keyIngredient" => { + "__typename" => "Grain", + "name" => "Wheat", + } + } + assert_graphql_equal expected_data, res["data"] + end + + it "Works with analyzing arguments with `loads:`, even with .request" do + query_str = <<-GRAPHQL + { + commonIngredientsWithLoad(recipe1Id: 5, recipe2Id: 6) { + name + } + } + GRAPHQL + query = GraphQL::Query.new(schema, query_str) + results = GraphQL::Analysis.analyze_query(query, [UsageAnalyzer]) + expected_results = [ + ["commonIngredientsWithLoad", [:recipe_1, :recipe_2]], + ["name", []], + ] + normalized_results = results.first.to_a + normalized_results.each do |key, values| + values.sort! + end + assert_equal expected_results, results.first.to_a + end + + it "Works with input objects, load and request" do + query_str = <<-GRAPHQL + { + commonIngredientsFromInputObject(input: { recipe1Id: 5, recipe2Id: 6 }) { + name + } + } + GRAPHQL + res = exec_query(query_str) + expected_data = { + "commonIngredientsFromInputObject" => [ + {"name"=>"Corn"}, + {"name"=>"Butter"}, + ] + } + assert_graphql_equal expected_data, res["data"] + + expected_log = [ + [:mget, ["5", "6"]], + [:mget, ["2", "3"]], + ] + assert_equal expected_log, database_log + end + + it "works with side-by-side top level arguments when one is a list" do + exec_next_only("Only supported in Execution::Next") + query_str = "{ r1: recipe(id: 5) { name } recipesById(ids: [6]) { name } }" + context = { batched_calls_counter: BatchedCallsCounter.new } + result = exec_query(query_str, context: context) + assert_graphql_equal({ "r1" => {"name" => "Cornbread" }, "recipesById" => [ { "name" => "Grits"}]}, result["data"]) + assert_equal 1, context[:batched_calls_counter].count + expected_log = [[:mget, ["5", "6"]]] + assert_equal expected_log, database_log + end + + it "batches calls in .authorized?" do + query_str = "{ r1: recipe(id: 5) { name } r2: recipe(id: 6) { name } }" + context = { batched_calls_counter: BatchedCallsCounter.new } + exec_query(query_str, context: context) + assert_equal 1, context[:batched_calls_counter].count + + query_str = "{ recipes { name } }" + context = { batched_calls_counter: BatchedCallsCounter.new } + exec_query(query_str, context: context) + assert_equal 1, context[:batched_calls_counter].count + + query_str = "{ recipesById(ids: [5, 6]) { name } }" + context = { batched_calls_counter: BatchedCallsCounter.new } + exec_query(query_str, context: context) + assert_equal 1, context[:batched_calls_counter].count + end + + it "batches nested object calls in .authorized? after using lazy_resolve" do + query_str = "{ cookbooks { featuredRecipe { name } } }" + context = { batched_calls_counter: BatchedCallsCounter.new } + result = exec_query(query_str, context: context) + assert_equal ["Cornbread", "Grits"], result["data"]["cookbooks"].map { |c| c["featuredRecipe"]["name"] } + refute result.key?("errors") + assert_equal 1, context[:batched_calls_counter].count + end + + it "works when passing nil into source" do + query_str = <<-GRAPHQL + query($id: ID) { + recipe: recipeByIdUsingLoad(id: $id) { + name + } + } + GRAPHQL + res = exec_query(query_str, variables: { id: nil }) + expected_data = { "recipe" => nil } + assert_graphql_equal expected_data, res["data"] + + query_str = <<-GRAPHQL + query($ids: [ID]!) { + recipes: recipesByIdUsingLoadAll(ids: $ids) { + name + } + } + GRAPHQL + res = exec_query(query_str, variables: { ids: [nil] }) + expected_data = { "recipes" => nil } + assert_graphql_equal expected_data, res["data"] + end + + it "Works with input objects using variables, load and request" do + query_str = <<-GRAPHQL + query($input: CommonIngredientsInput!) { + commonIngredientsFromInputObject(input: $input) { + name + } + } + GRAPHQL + res = exec_query(query_str, variables: { input: { recipe1Id: 5, recipe2Id: 6 }}) + expected_data = { + "commonIngredientsFromInputObject" => [ + {"name"=>"Corn"}, + {"name"=>"Butter"}, + ] + } + assert_graphql_equal expected_data, res["data"] + + expected_log = [ + [:mget, ["5", "6"]], + [:mget, ["2", "3"]], + ] + assert_equal expected_log, database_log + end + + it "supports general usage" do + a = b = c = nil + + res = schema.dataloader_class.with_dataloading { |dataloader| + dataloader.append_job { + a = dataloader.with(FiberSchema::DataObject).load("1") + } + + dataloader.append_job { + b = dataloader.with(FiberSchema::DataObject).load("1") + } + + dataloader.append_job { + r1 = dataloader.with(FiberSchema::DataObject).request("2") + r2 = dataloader.with(FiberSchema::DataObject).request("3") + c = [ + r1.load, + r2.load + ] + } + + :finished + } + + assert_equal :finished, res + assert_equal [[:mget, ["1", "2", "3"]]], database_log + assert_equal "Wheat", a[:name] + assert_equal "Wheat", b[:name] + assert_equal ["Corn", "Butter"], c.map { |d| d[:name] } + end + + it "works with scoped context" do + query_str = <<-GRAPHQL + { + i1: ingredientByName(name: "Corn") { nameByScopedContext } + i2: ingredientByName(name: "Wheat") { nameByScopedContext } + i3: ingredientByName(name: "Butter") { nameByScopedContext } + } + GRAPHQL + + expected_data = { + "i1" => { "nameByScopedContext" => "Scoped:Corn" }, + "i2" => { "nameByScopedContext" => "Scoped:Wheat" }, + "i3" => { "nameByScopedContext" => "Scoped:Butter" }, + } + result = exec_query(query_str) + assert_graphql_equal expected_data, result["data"] + end + + it "works when the schema calls itself" do + result = exec_query("{ recursiveIngredientName(id: 1) }") + assert_equal "Wheat", result["data"]["recursiveIngredientName"] + end + + it "works empty" do + dl = schema.dataloader_class.new + dl.run + assert "it finished" + end + + + it "uses .batch_key_for in source classes" do + query_str = <<-GRAPHQL + { + i1: ingredientWithCustomBatchKey(id: 1, batchKey: "abc") { name } + i2: ingredientWithCustomBatchKey(id: 2, batchKey: "def") { name } + i3: ingredientWithCustomBatchKey(id: 3, batchKey: "ghi") { name } + } + GRAPHQL + + res = exec_query(query_str) + expected_data = { "i1" => { "name" => "Wheat" }, "i2" => { "name" => "Corn" }, "i3" => { "name" => "Butter" } } + assert_graphql_equal expected_data, res["data"] + expected_log = [ + # Each batch key is given to the source class: + [:batch_key_for, "abc"], + [:batch_key_for, "def"], + [:batch_key_for, "ghi"], + # But since they return the same value, + # all keys are fetched in the same call: + [:mget, ["1", "2", "3"]] + ] + assert_equal expected_log, database_log + end + + it "uses cached values from .merge" do + query_str = "{ ingredient(id: 1) { id name } }" + assert_equal "Wheat", exec_query(query_str)["data"]["ingredient"]["name"] + assert_equal [[:mget, ["1"]]], database_log + database_log.clear + + dataloader = schema.dataloader_class.new + data_source = dataloader.with(FiberSchema::DataObject) + data_source.merge({ "1" => { name: "Kamut", id: "1", type: "Grain" } }) + assert_equal "Kamut", data_source.load("1")[:name] + res = exec_query(query_str, context: { dataloader: dataloader }) + assert_equal [], database_log + assert_equal "Kamut", res["data"]["ingredient"]["name"] + end + + it "raises errors from fields" do + err = assert_raises GraphQL::Error do + exec_query("{ testError }") + end + expected_message = exec_next_error_message("Query.testError", "Field error") + assert_equal expected_message, err.message + end + + it "raises errors from sources" do + err = assert_raises GraphQL::Error do + exec_query("{ testError(source: true) }") + end + expected_message = exec_next_error_message "Query.testError", "Source error on: [1]" + assert_equal expected_message, err.message + end + + it "works with very very large queries" do + query_str = "{".dup + fields = 1100 + fields.times do |i| + query_str << "\n field#{i}: lookaheadIngredient(input: { id: 1, batchKey: \"key-#{i}\"}) { name }" + end + query_str << "\n}" + GC.start + GC.disable + old_fibers = [] + ObjectSpace.each_object(Fiber) do |f| + old_fibers << f + end + res = exec_query(query_str) + assert_equal fields, res["data"].keys.size + skip("Doesn't work after Ractor.new (https://bugs.ruby-lang.org/issues/19387)") if RUN_RACTOR_TESTS + all_fibers = [] + ObjectSpace.each_object(Fiber) do |f| + all_fibers << f + end + new_fibers = all_fibers - old_fibers + if new_fibers.any?(&:alive?) + message = "Alive fibers:\n\n".dup + new_fibers.select(&:alive?).each do |f| + message << " - #{f.inspect}\n" + f.backtrace.each do |line| + message << " #{line}\n" + end + end + puts message + end + assert_equal [false], new_fibers.map(&:alive?).uniq + ensure + GC.enable + end + + it "doesn't perform duplicate source fetches" do + query = <<~QUERY + query { + manufacturers { + parts { + components(filter: {name: {equalToAnyOf: ["c1", "c2", "c3"]}}) { + name + } + } + } + } + QUERY + response = parts_schema.execute(query).to_h + assert_equal [4, 4, 4, 4], response["data"]["manufacturers"].map { |parts_obj| parts_obj["parts"].size } + end + + describe "fiber_limit" do + def assert_last_max_count(expected_last_max_count, message = nil) + diff = FiberCounting.last_max_count - expected_last_max_count + case diff + when 1 + # TODO why does this happen sometimes? + warn "AsyncDataloader had +#{diff} last_max_count (expected: #{expected_last_max_count}, actual: #{FiberCounting.last_max_count}) at #{caller(1, 1).first}" + assert_equal (expected_last_max_count + diff), FiberCounting.last_max_count, message + else + assert_equal expected_last_max_count, FiberCounting.last_max_count, message + end + end + + it "respects a configured fiber_limit" do + skip("Doesn't work after Ractor.new (https://bugs.ruby-lang.org/issues/19387)") if RUN_RACTOR_TESTS + query_str = <<-GRAPHQL + { + recipes { + ingredients { + name + } + } + nestedIngredient(id: 2) { + name + } + keyIngredient(id: 4) { + name + } + commonIngredientsWithLoad(recipe1Id: 5, recipe2Id: 6) { + name + } + } + GRAPHQL + + fiber_counting_dataloader_class = Class.new(schema.dataloader_class) + is_async = fiber_counting_dataloader_class < GraphQL::Dataloader::AsyncDataloader + fiber_counting_dataloader_class.include(FiberCounting) + + res = exec_query(query_str, context: { dataloader: fiber_counting_dataloader_class.new }) + assert_nil res.context.dataloader.fiber_limit + assert_equal (is_async ? 12 : 10), FiberCounting.last_spawn_count + assert_last_max_count(9, "No limit works as expected") + + extra_shortlived_jobs_fibers = is_async ? (if_exec_next(-1, -1)) : 0 + res = schema.execute(query_str, context: { dataloader: fiber_counting_dataloader_class.new(fiber_limit: 4) }) + assert_equal 4, res.context.dataloader.fiber_limit + assert_equal if_exec_next(11, 12) + extra_shortlived_jobs_fibers, FiberCounting.last_spawn_count + assert_last_max_count(4 + (is_async ? 1 : 0), "Limit of 4 works as expected") + + extra_shortlived_jobs_fibers = is_async ? 3 : 0 + res = schema.execute(query_str, context: { dataloader: fiber_counting_dataloader_class.new(fiber_limit: 6) }) + assert_equal 6, res.context.dataloader.fiber_limit + assert_equal 8 + extra_shortlived_jobs_fibers, FiberCounting.last_spawn_count + assert_last_max_count(6, "Limit of 6 works as expected") + end + + it "accepts a default fiber_limit config" do + skip("Doesn't work after Ractor.new (https://bugs.ruby-lang.org/issues/19387)") if RUN_RACTOR_TESTS + + schema = Class.new(FiberSchema) do + use GraphQL::Dataloader, fiber_limit: 4 + end + query_str = <<-GRAPHQL + { + recipes { + ingredients { + name + } + } + nestedIngredient(id: 2) { + name + } + keyIngredient(id: 4) { + name + } + commonIngredientsWithLoad(recipe1Id: 5, recipe2Id: 6) { + name + } + } + GRAPHQL + res = exec_query(query_str, schema: schema) + assert_equal 4, res.context.dataloader.fiber_limit + assert_nil res["errors"] + end + + it "requires at least three fibers" do + dl = GraphQL::Dataloader.new(fiber_limit: 2) + err = assert_raises ArgumentError do + dl.run + end + assert_equal "Dataloader fiber limit is too low (2), it must be at least 4", err.message + end + end + end + end + end + + def make_schema_from(schema) + schema + end + + include DataloaderAssertions + + if RUBY_VERSION >= "3.1.1" + require "async" + describe "AsyncDataloader" do + def make_schema_from(schema) + Class.new(schema) { + use GraphQL::Dataloader::AsyncDataloader + } + end + + include DataloaderAssertions + end end + if Fiber.respond_to?(:scheduler) + describe "nonblocking: true" do + def make_schema_from(schema) + Class.new(schema) do + use GraphQL::Dataloader, nonblocking: true + end + end + + before do + Fiber.set_scheduler(::DummyScheduler.new) + end + + after do + Fiber.set_scheduler(nil) + end + + include DataloaderAssertions + end + end describe "example from #3314" do module Example @@ -616,17 +1379,16 @@ def fetch(ids) end class QueryType < GraphQL::Schema::Object - field :foo, Example::FooType, null: true do + field :foo, Example::FooType, resolve_static: true do argument :foo_id, GraphQL::Types::ID, required: false, loads: Example::FooType - argument :use_load, GraphQL::Types::Boolean, required: false, default_value: false end - def foo(use_load: false, foo: nil) - if use_load - dataloader.with(Example::FooSource).load("load") - else - dataloader.with(Example::FooSource).request("request") - end + def self.foo(context, foo: nil) + context.dataload(Example::FooSource, "load") + end + + def foo(foo: nil) + self.class.foo(context, foo: foo) end end @@ -635,7 +1397,11 @@ class Schema < GraphQL::Schema use GraphQL::Dataloader def self.object_from_id(id, ctx) - ctx.dataloader.with(Example::FooSource).request(id) + ctx.dataloader.with(Example::FooSource).load(id) + end + + def self.resolve_type(type, obj, ctx) + type end end end @@ -643,11 +1409,7 @@ def self.object_from_id(id, ctx) it "loads properly" do result = Example::Schema.execute(<<-GRAPHQL) { - foo(useLoad: false, fooId: "Other") { - __typename - id - } - fooWithLoad: foo(useLoad: true, fooId: "Other") { + fooWithLoad: foo(fooId: "Other") { __typename id } @@ -656,12 +1418,11 @@ def self.object_from_id(id, ctx) # This should not have a Lazy in it expected_result = { "data" => { - "foo" => { "id" => "request", "__typename" => "Foo" }, "fooWithLoad" => { "id" => "load", "__typename" => "Foo" }, } } - assert_equal expected_result, result.to_h + assert_graphql_equal expected_result, result.to_h end end @@ -673,27 +1434,43 @@ def fetch(_) end class Query < GraphQL::Schema::Object - field :load, String, null: false - field :load_all, String, null: false - field :request, String, null: false - field :request_all, String, null: false + field :load, String, null: false, resolve_static: true + field :load_all, String, null: false, resolve_static: true + field :request, String, null: false, resolve_static: true + field :request_all, String, null: false, resolve_static: true + + def self.load(context) + context.dataload(ErrorObject, 123) + end def load - dataloader.with(ErrorObject).load(123) + self.class.load(context) + end + + def self.load_all(context) + context.dataload_all(ErrorObject, [123]) end def load_all - dataloader.with(ErrorObject).load_all([123]) + self.class.load_all(context) + end + + def self.request(context) + req = context.dataloader.with(ErrorObject).request(123) + req.load end def request - req = dataloader.with(ErrorObject).request(123) + self.class.request(context) + end + + def self.request_all(context) + req = context.dataloader.with(ErrorObject).request_all([123]) req.load end def request_all - req = dataloader.with(ErrorObject).request_all([123]) - req.load + self.class.request_all(context) end end @@ -718,10 +1495,23 @@ def request_all "Nope (FiberErrorSchema::Query.requestAll, nil, {})", ] - assert_equal(nil, res["data"]) + assert_nil(res["data"]) assert_equal(expected_errors, context[:errors].sort) end + it "has proper context[:current_field]" do + res = FiberSchema.execute("mutation { mutation1(argument1: \"abc\") { __typename } mutation2(argument2: \"def\") { __typename } }") + assert_equal({"mutation1"=>{ "__typename" => "Mutation1Payload" }, "mutation2"=>{ "__typename" => "Mutation2Payload"} }, res["data"]) + expected_errors = if_exec_next( + # No context[:current_...] values: + ["FieldTestError @ --, Mutation.mutation1 / --", "FieldTestError @ --, Mutation.mutation2 / --"], + [ + "FieldTestError @ [\"mutation1\"], Mutation.mutation1 / Mutation.mutation1", + "FieldTestError @ [\"mutation2\"], Mutation.mutation2 / Mutation.mutation2", + ]) + assert_equal expected_errors, res.context[:errors] + end + it "passes along throws" do value = catch(:hello) do dataloader = GraphQL::Dataloader.new @@ -734,6 +1524,49 @@ def request_all assert :world, value end + it "tracks pending sources without scanning the entire source cache" do + dataloader = GraphQL::Dataloader.new + 100.times do |idx| + dataloader.with(FiberSchema::PendingCheckSource, idx) + end + + FiberSchema::PendingCheckSource.pending_checks = 0 + dataloader.append_job do + dataloader.with(FiberSchema::PendingCheckSource, 0).load(1) + end + dataloader.run + + assert_operator FiberSchema::PendingCheckSource.pending_checks, :<, 100 + end + + class CanaryDataloader < GraphQL::Dataloader::NullDataloader + end + + it "uses context[:dataloader] when given" do + res = Class.new(GraphQL::Schema) do + query_type = Class.new(GraphQL::Schema::Object) do + graphql_name "Query" + end + query(query_type) + end.execute("{ __typename }") + assert_instance_of GraphQL::Dataloader::NullDataloader, res.context.dataloader + res = FiberSchema.execute("{ __typename }") + assert_instance_of GraphQL::Dataloader, res.context.dataloader + refute res.context.dataloader.nonblocking? + res = FiberSchema.execute("{ __typename }", context: { dataloader: CanaryDataloader.new } ) + assert_instance_of CanaryDataloader, res.context.dataloader + + if Fiber.respond_to?(:scheduler) + Fiber.set_scheduler(::DummyScheduler.new) + res = FiberSchema.execute("{ __typename }", context: { dataloader: GraphQL::Dataloader.new(nonblocking: true) }) + assert res.context.dataloader.nonblocking? + + res = FiberSchema.multiplex([{ query: "{ __typename }" }], context: { dataloader: GraphQL::Dataloader.new(nonblocking: true) }) + assert res[0].context.dataloader.nonblocking? + Fiber.set_scheduler(nil) + end + end + describe "#run_isolated" do module RunIsolated class CountSource < GraphQL::Dataloader::Source @@ -768,6 +1601,48 @@ def fetch(ids) dl.run assert_equal({ a: 1, b: 2, c: 3, d: 4, e: 3 }, result) end + + it "restores pending sources from the outer queue" do + dl = GraphQL::Dataloader.new + result = {} + outer_request = dl.with(RunIsolated::CountSource).request(1) + + dl.run_isolated { + result[:isolated] = dl.with(RunIsolated::CountSource).load(2) + } + + dl.append_job { + result[:outer] = outer_request.load + } + dl.run + + assert_equal({ isolated: 1, outer: 2 }, result) + end + + it "shares a cache" do + dl = GraphQL::Dataloader.new + result = {} + dl.run_isolated { + _r1 = dl.with(RunIsolated::CountSource).request(1) + _r2 = dl.with(RunIsolated::CountSource).request(2) + r3 = dl.with(RunIsolated::CountSource).request(3) + # Run all three of the above requests: + result[:a] = r3.load + } + + dl.append_job { + # This should return cached from above + result[:b] = dl.with(RunIsolated::CountSource).load(1) + } + dl.append_job { + # This one is run by itself + result[:c] = dl.with(RunIsolated::CountSource).load(4) + } + + assert_equal({ a: 3 }, result) + dl.run + assert_equal({ a: 3, b: 3, c: 4 }, result) + end end describe "thread local variables" do @@ -784,12 +1659,16 @@ def fetch(keys) end class QueryType < GraphQL::Schema::Object - field :thread_var, ThreadVariable::Type, null: true do - argument :key, GraphQL::Types::String, required: true + field :thread_var, ThreadVariable::Type, resolve_static: true do + argument :key, GraphQL::Types::String + end + + def self.thread_var(context, key:) + context.dataload(ThreadVariable::Source, key) end def thread_var(key:) - dataloader.with(ThreadVariable::Source).load(key) + self.class.thread_var(context, key: key) end end @@ -817,7 +1696,180 @@ class Schema < GraphQL::Schema } } - assert_equal expected_result, result.to_h + assert_graphql_equal expected_result, result.to_h + end + end + + describe "thread-local variables with custom dataloader" do + module CustomThreadVariable + class Type < GraphQL::Schema::Object + field :key, String, null: false + field :value, String, null: false + end + + class CustomDataloader < GraphQL::Dataloader + def get_fiber_variables + { test_thread_var: "bazbarfoo" } + end + end + + class Source < GraphQL::Dataloader::Source + def fetch(keys) + keys.map { |key| OpenStruct.new(key: key, value: Thread.current[key.to_sym]) } + end + end + + class QueryType < GraphQL::Schema::Object + field :thread_var, CustomThreadVariable::Type, resolve_static: true do + argument :key, GraphQL::Types::String + end + + def self.thread_var(context, key:) + context.dataload(CustomThreadVariable::Source, key) + end + + def thread_var(key:) + self.class.thread_var(context, key: key) + end + end + + class Schema < GraphQL::Schema + query CustomThreadVariable::QueryType + use CustomDataloader + end + end + + it "sets the parent thread locals in the execution fiber" do + result = CustomThreadVariable::Schema.execute(<<-GRAPHQL) + { + threadVar(key: "test_thread_var") { + key + value + } + } + GRAPHQL + + expected_result = { + "data" => { + "threadVar" => { "key" => "test_thread_var", "value" => "bazbarfoo" } + } + } + + assert_graphql_equal expected_result, result.to_h + end + end + + describe "dataloader calls from inside sources" do + class NestedDataloaderCallsSchema < GraphQL::Schema + class Echo < GraphQL::Dataloader::Source + def fetch(keys) + keys + end + end + + class Nested < GraphQL::Dataloader::Source + def fetch(keys) + dataloader.with(Echo).load_all(keys) + end + end + + class Nested2 < GraphQL::Dataloader::Source + def fetch(keys) + dataloader.with(Nested).load_all(keys) + end + end + + class QueryType < GraphQL::Schema::Object + field :nested, String, resolve_static: true + field :nested2, String, resolve_static: true + + def self.nested(context) + context.dataload(Nested, "nested") + end + + def nested + dataloader.with(Nested).load("nested") + end + + def self.nested2(context) + context.dataload(Nested, "nested2") + end + + def nested2 + dataloader.with(Nested2).load("nested2") + end + end + + query QueryType + use GraphQL::Dataloader + end + end + + it "loads data from inside source methods" do + assert_equal({ "data" => { "nested" => "nested" } }, NestedDataloaderCallsSchema.execute("{ nested }")) + assert_equal({ "data" => { "nested2" => "nested2" } }, NestedDataloaderCallsSchema.execute("{ nested2 }")) + assert_equal({ "data" => { "nested" => "nested", "nested2" => "nested2" } }, NestedDataloaderCallsSchema.execute("{ nested nested2 }")) + end + + describe "with lazy authorization hooks" do + class LazyAuthHookSchema < GraphQL::Schema + class Source < ::GraphQL::Dataloader::Source + def fetch(ids) + return ids.map {|i| i * 2} + end + end + + class BarType < GraphQL::Schema::Object + field :id, Integer, method: :itself + + def self.authorized?(object, context) + -> { true } + end + end + + class FooType < GraphQL::Schema::Object + field :dataloader_value, BarType, resolve_static: true + + def self.authorized?(object, context) + -> { true } + end + + def self.dataloader_value(context) + context.dataload(Source, 1) + end + + def dataloader_value + self.class.dataloader_value(context) + end + end + + class QueryType < GraphQL::Schema::Object + field :foo, FooType, resolve_static: true + + def self.foo(context) + {} + end + + def foo; {}; end + end + + use GraphQL::Dataloader + query QueryType + lazy_resolve Proc, :call + end + + it "resolves everything" do + dataloader_query = """ + query { + foo { + dataloaderValue { + id + } + } + } + """ + dataloader_result = LazyAuthHookSchema.execute(dataloader_query) + assert_equal 2, dataloader_result["data"]["foo"]["dataloaderValue"]["id"] end end end diff --git a/spec/graphql/define/instance_definable_spec.rb b/spec/graphql/define/instance_definable_spec.rb deleted file mode 100644 index 1c02a4a2931..00000000000 --- a/spec/graphql/define/instance_definable_spec.rb +++ /dev/null @@ -1,203 +0,0 @@ -# frozen_string_literal: true -require "date" -require "spec_helper" - -module Garden - module DefinePlantBetween - def self.call(plant, plant_range) - plant.start_planting_on = plant_range.begin - plant.end_planting_on = plant_range.end - end - end - - class Vegetable - include GraphQL::Define::InstanceDefinable - attr_accessor :name, :start_planting_on, :end_planting_on - ensure_defined(:name, :start_planting_on, :end_planting_on) - accepts_definitions :name, plant_between: DefinePlantBetween, has_leaves: GraphQL::Define.assign_metadata_key(:has_leaves), color: GraphQL::Define.assign_metadata_key(:color) - - # definition added later: - attr_accessor :height - ensure_defined(:height) - - def color - metadata[:color] - end - end -end - -describe GraphQL::Define::InstanceDefinable do - describe "extending definitions" do - before do - Garden::Vegetable.accepts_definitions(:height) - end - - after do - Garden::Vegetable.own_dictionary.delete(:height) - end - - it "accepts after-the-fact definitions" do - corn = Garden::Vegetable.define do - name "Corn" - height 8 - end - - assert_equal "Corn", corn.name - assert_equal 8, corn.height - end - end - - describe "applying custom definitions" do - it "uses custom callables" do - tomato = Garden::Vegetable.define do - name "Tomato" - plant_between Date.new(2000, 4, 20)..Date.new(2000, 6, 1) - end - - assert_equal "Tomato", tomato.name - assert_equal Date.new(2000, 4, 20), tomato.start_planting_on - assert_equal Date.new(2000, 6, 1), tomato.end_planting_on - end - - it "accepts bare definitions" do - radish = Garden::Vegetable.define do - name "Radish" - has_leaves - end - assert_equal true, radish.metadata[:has_leaves] - end - end - - describe ".define with keywords" do - it "applies definitions from keywords" do - okra = Garden::Vegetable.define(name: "Okra", plant_between: Date.new(2000, 5, 1)..Date.new(2000, 7, 1)) - assert_equal "Okra", okra.name - assert_equal Date.new(2000, 5, 1), okra.start_planting_on - assert_equal Date.new(2000, 7, 1), okra.end_planting_on - end - end - - describe "#define" do - it "applies new definitions to an object" do - okra = Garden::Vegetable.define(name: "Okra", plant_between: Date.new(2000, 5, 1)..Date.new(2000, 7, 1)) - assert_equal "Okra", okra.name - okra.define(name: "Gumbo") - assert_equal "Gumbo", okra.name - okra.define { name "Okra" } - assert_equal "Okra", okra.name - end - - describe "errors in define blocks" do - it "preserves the definition block to try again" do - magic_number = 12 - - radish = Garden::Vegetable.define { - name "Pre-error" - magic_number += 1 - if magic_number == 13 - raise "👻" - end - name "Radish" - } - - # The first call triggers an error: - assert_raises(RuntimeError) { radish.name } - # Calling definintion-dependent method should re-run the block, - # not leave old values around: - assert_equal "Radish", radish.name - end - end - end - - describe "#redefine" do - it "re-runs definitions without modifying the original object" do - arugula = Garden::Vegetable.define(name: "Arugula", color: :green) - - red_arugula = arugula.redefine(color: :red) - renamed_red_arugula = red_arugula.redefine do - name "Renamed Red Arugula" - end - - assert_equal :green, arugula.color - assert_equal "Arugula", arugula.name - - assert_equal :red, red_arugula.color - assert_equal "Arugula", red_arugula.name - - assert_equal :red, renamed_red_arugula.color - assert_equal "Renamed Red Arugula", renamed_red_arugula.name - end - - it "can be chained several times" do - arugula_1 = Garden::Vegetable.define(name: "Arugula") { color :green } - arugula_2 = arugula_1.redefine { color :red } - arugula_3 = arugula_2.redefine { plant_between(1..3) } - assert_equal ["Arugula", :green], [arugula_1.name, arugula_1.color] - assert_equal ["Arugula", :red], [arugula_2.name, arugula_2.color] - assert_equal ["Arugula", :red], [arugula_3.name, arugula_3.color] - end - end - - describe "#metadata" do - it "gets values from definitions" do - arugula = Garden::Vegetable.define(name: "Arugula", color: :green) - assert_equal :green, arugula.metadata[:color] - end - end - - describe "#use" do - class TestPlugin - attr_reader :target - - def use(defn) - @target = defn.target - defn.name('Arugula') - end - end - - module TestPluginWithKwargs - extend self - - def use(defn, name:) - defn.name(name) - end - end - - it "sends a message to the specified plugin's :use method with access to the proxy object and target object" do - plugin = TestPlugin.new - - arugula = Garden::Vegetable.define do - use plugin - end - - assert_equal 'Arugula', arugula.name - assert_equal arugula, plugin.target - end - - it "passes kwargs to plugin's `use` method" do - arugula = Garden::Vegetable.define do - use TestPluginWithKwargs, name: 'Arugula' - end - - assert_equal 'Arugula', arugula.name - end - end - - describe "typos" do - it "provides the right class name, method name and line number" do - err = assert_raises(GraphQL::Define::NoDefinitionError) { - beet = Garden::Vegetable.define { - name "Beet" - nonsense :Blah - } - beet.name - } - assert_includes err.message, "Garden::Vegetable" - assert_includes err.message, "nonsense" - first_backtrace = err.backtrace.first - # This is the offset from the assertion to the `nonsense` call, - # it might change when this test changes: - assert_includes first_backtrace, "#{__LINE__ - 9}" - end - end -end diff --git a/spec/graphql/deprecation_spec.rb b/spec/graphql/deprecation_spec.rb deleted file mode 100644 index 04768c2fbdd..00000000000 --- a/spec/graphql/deprecation_spec.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -describe GraphQL::Deprecation do - if defined?(ActiveSupport) - it "uses ActiveSupport::Deprecation.warn when it's available" do - ActiveSupport::Deprecation.stub :warn, :was_warned do - assert_equal :was_warned, GraphQL::Deprecation.warn("abcd") - end - end - else - it "falls back to Kernel.warn" do - Kernel.stub :warn, :was_kernel_warned do - assert_equal :was_kernel_warned, GraphQL::Deprecation.warn("abcd") - end - end - end -end diff --git a/spec/graphql/directive/skip_directive_spec.rb b/spec/graphql/directive/skip_directive_spec.rb deleted file mode 100644 index a9d9cf81d0d..00000000000 --- a/spec/graphql/directive/skip_directive_spec.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true -require "spec_helper" - -describe GraphQL::Directive::SkipDirective do - let(:directive) { GraphQL::Directive::SkipDirective } - it "is a default directive" do - assert directive.default_directive? - end -end diff --git a/spec/graphql/directive_spec.rb b/spec/graphql/directive_spec.rb index 2010ad5fd87..771a661916e 100644 --- a/spec/graphql/directive_spec.rb +++ b/spec/graphql/directive_spec.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true require "spec_helper" -describe GraphQL::Directive do +describe "GraphQL::Directive" do let(:variables) { {"t" => true, "f" => false} } - let(:result) { Dummy::Schema.execute(query_string, variables: variables) } + let(:result) { + Dummy::Schema.execute(query_string, variables: variables) + } describe "on fields" do let(:query_string) { %|query directives($t: Boolean!, $f: Boolean!) { cheese(id: 1) { @@ -48,6 +50,24 @@ end end + describe "when directive argument variable is explicitly null" do + let(:query_string) { <<-GRAPHQL + query($f: Boolean = false) { + cheese(id: 1) { + includeFlavor: flavor @include(if: $f) + skipFlavor: flavor @skip(if: $f) + } + } + GRAPHQL + } + let(:variables) { {"f" => nil} } + + it "returns the coercion error without raising" do + expected = "`null` is not a valid input for `Boolean!`, please provide a value for this argument." + assert_equal [expected], result["errors"].map { |e| e["message"] }.uniq + end + end + describe "when directive uses argument with default value" do describe "with false" do let(:query_string) { <<-GRAPHQL @@ -275,21 +295,4 @@ end end end - - describe "defining a directive" do - let(:directive) { - GraphQL::Directive.define do - arguments [GraphQL::Argument.define(name: 'skip')] - end - } - - it "can accept an array of arguments" do - assert_equal 1, directive.arguments.length - assert_equal 'skip', directive.arguments.first.name - end - - it "is not default" do - assert_equal false, directive.default_directive? - end - end end diff --git a/spec/graphql/execution/breadth_runtime_spec.rb b/spec/graphql/execution/breadth_runtime_spec.rb new file mode 100644 index 00000000000..79a8e04d857 --- /dev/null +++ b/spec/graphql/execution/breadth_runtime_spec.rb @@ -0,0 +1,338 @@ +# frozen_string_literal: true +require "spec_helper" + +describe "GraphQL::Execution::Interpreter for breadth-first execution" do + # A breadth-first interpreter uses the following runtime interface: + # - evaluate_selection(result_key, ast_nodes, selections_result) + # - exit_with_inner_result? + class SimpleBreadthRuntime < GraphQL::Execution::Interpreter::Runtime + class BreadthObject < GraphQL::Execution::Interpreter::Runtime::GraphQLResultHash + attr_accessor :breadth_index + attr_accessor :results_by_key + + def collect_result(result_name, result_value) + results_by_key[result_name][breadth_index] = result_value + true + end + end + + def initialize(query:) + query.multiplex = GraphQL::Execution::Multiplex.new( + schema: query.schema, + queries: [query], + context: query.context, + max_complexity: nil, + ) + + super(query: query) + @breadth_results_by_key = {} + end + + def run(&block) + query.current_trace.execute_multiplex(multiplex: query.multiplex) do + query.current_trace.execute_query(query: query, &block) + end + ensure + delete_all_interpreter_context + end + + def evaluate_breadth_selection(objects, parent_type, node) + result_key = node.alias || node.name + @breadth_results_by_key[result_key] = Array.new(objects.size) + + objects.each_with_index do |object, index| + app_value = parent_type.wrap(object, query.context) + breadth_object = BreadthObject.new(nil, parent_type, app_value, nil, false, node.selections, false, node, nil, nil) + breadth_object.ordered_result_keys = [] + breadth_object.breadth_index = index + breadth_object.results_by_key = @breadth_results_by_key + + state = get_current_runtime_state + state.current_result_name = nil + state.current_result = breadth_object + @dataloader.append_job { evaluate_selection(result_key, node, breadth_object) } + end + + @dataloader.run + + @breadth_results_by_key[result_key] + end + end + + class PassthroughLoader < GraphQL::Batch::Loader + def perform(objects) + objects.each { |obj| fulfill(obj, obj) } + end + end + + class SimpleHashBatchLoader < GraphQL::Batch::Loader + def initialize(key) + super() + @key = key + end + + def perform(objects) + objects.each { |obj| fulfill(obj, obj.fetch(@key)) } + end + end + + class UpcaseExtension < GraphQL::Schema::FieldExtension + def after_resolve(value:, **rest) + value&.upcase + end + end + + class RangeInput < GraphQL::Schema::InputObject + argument :min, Int + argument :max, Int + + def prepare + min..max + end + end + + class BreadthBaseField < GraphQL::Schema::Field + def authorized?(obj, args, ctx) + if !ctx[:field_auth].nil? + ctx[:field_auth] + elsif !ctx[:lazy_field_auth].nil? + PassthroughLoader.load(ctx[:lazy_field_auth]) + elsif !ctx[:field_auth_with_error].nil? + raise GraphQL::ExecutionError, "Not authorized" unless ctx[:field_auth_with_error] + else + true + end + end + end + + class BreadthBaseObject < GraphQL::Schema::Object + field_class BreadthBaseField + end + + class BreadthTestQuery < BreadthBaseObject + field :foo, String + + def foo + object[:foo] + end + + field :lazy_foo, String + + def lazy_foo + SimpleHashBatchLoader.for(:foo).load(object) + end + + field :maybe_lazy_foo, String + + def maybe_lazy_foo + if object[:foo] == "beep" + SimpleHashBatchLoader.for(:foo).load(object) + else + object[:foo] + end + end + + field :nested_lazy_foo, String + + def nested_lazy_foo + PassthroughLoader + .load(object) + .then { |obj| SimpleHashBatchLoader.for(:foo).load(obj) } + .then { |str| str } + end + + field :upcase_foo, String, extensions: [UpcaseExtension] + + def upcase_foo + object[:foo] + end + + field :lazy_upcase_foo, String, extensions: [UpcaseExtension] + + def lazy_upcase_foo + SimpleHashBatchLoader.for(:foo).load(object) + end + + field :go_boom, String + + def go_boom + raise GraphQL::ExecutionError, "boom" + end + + field :args, String do |f| + f.argument :a, String + f.argument :b, String + end + + def args(a:, b:) + "#{a}#{b}" + end + + field :valid_args, String do |f| + f.argument :a, String, validates: { length: { is: 1 } } + end + + def valid_args(a:) + a + end + + field :range, String do |f| + f.argument :input, RangeInput + end + + def range(input:) + "#{input.min}-#{input.max}" + end + + field :extras, String, extras: [:lookahead] + + def extras(lookahead:) + lookahead.field.name + end + + # uses default resolver... + field :fizz, String + end + + class BreadthTestSchema < GraphQL::Schema + use(GraphQL::Batch) + query BreadthTestQuery + end + + SCHEMA_FROM_DEF = GraphQL::Schema.from_definition( + %|type Query { a: String }|, + default_resolve: { + "Query" => { "a" => ->(obj, _args, _ctx) { obj["a"] } }, + }, + ) + + OBJECTS = [{ foo: "fizz" }, { foo: "buzz" }, { foo: "beep" }, { foo: "boom" }].freeze + EXPECTED_RESULTS = ["fizz", "buzz", "beep", "boom"].freeze + + def test_maps_sync_results + result = map_breadth_objects(OBJECTS, "{ foo }") + assert_equal EXPECTED_RESULTS, result + end + + def test_maps_lazy_results + result = map_breadth_objects(OBJECTS, "{ lazyFoo }") + assert_equal EXPECTED_RESULTS, result + end + + def test_maps_sometimes_lazy_results + result = map_breadth_objects(OBJECTS, "{ maybeLazyFoo }") + assert_equal EXPECTED_RESULTS, result + end + + def test_maps_nested_lazy_results + result = map_breadth_objects(OBJECTS, "{ nestedLazyFoo }") + assert_equal EXPECTED_RESULTS, result + end + + def test_maps_field_extension_results + result = map_breadth_objects(OBJECTS, "{ upcaseFoo }") + assert_equal ["FIZZ", "BUZZ", "BEEP", "BOOM"], result + end + + def test_maps_lazy_field_extension_results + result = map_breadth_objects(OBJECTS, "{ lazyUpcaseFoo }") + assert_equal ["FIZZ", "BUZZ", "BEEP", "BOOM"], result + end + + def test_maps_fields_with_authorization + context = { field_auth: false } + result = map_breadth_objects(OBJECTS, "{ foo }", context: context) + assert_equal [nil, nil, nil, nil], result + end + + def test_maps_fields_with_lazy_authorization + context = { lazy_field_auth: false } + result = map_breadth_objects(OBJECTS, "{ foo }", context: context) + assert result.all? { |r| r.is_a?(GraphQL::UnauthorizedFieldError) } + end + + def test_maps_fields_with_authorization_errors + context = { field_auth_with_error: false } + result = map_breadth_objects(OBJECTS, "{ foo }", context: context) + assert result.all? { |r| r.is_a?(GraphQL::ExecutionError) } + end + + def test_maps_field_errors + result = map_breadth_objects(OBJECTS, "{ goBoom }") + assert result.all? { |r| r.is_a?(GraphQL::ExecutionError) } + assert_equal ["boom", "boom", "boom", "boom"], result.map(&:message) + end + + def test_maps_basic_arguments + doc = %|{ args(a:"fizz", b:"buzz") }| + result = map_breadth_objects([{}], doc) + assert_equal ["fizzbuzz"], result + end + + def test_maps_basic_arguments_with_variables + doc = %|query($b: String) { args(a:"fizz", b: $b) }| + result = map_breadth_objects([{}], doc, variables: { b: "buzz" }) + assert_equal ["fizzbuzz"], result + end + + def test_maps_invalidated_arguments + doc = %|query { validArgs(a: "boo") }| + result = map_breadth_objects([{}], doc) + assert result.first.is_a?(GraphQL::ExecutionError) + assert_equal "a is the wrong length (should be 1)", result.first.message + end + + def test_maps_prepared_input_object + doc = %|{ range(input: { min: 1, max: 2 }) }| + result = map_breadth_objects([{}], doc) + assert_equal ["1-2"], result + end + + def test_maps_prepared_input_object_with_variables + doc = %|query($b: Int) { range(input: { min: 1, max: $b }) }| + result = map_breadth_objects([{}], doc, variables: { b: 2 }) + assert_equal ["1-2"], result + end + + def test_maps_extras_arguments + result = map_breadth_objects([{}], "{ extras }") + assert_equal ["extras"], result + end + + def test_uses_default_resolver_for_hash_keys + result = map_breadth_objects([{ fizz: "buzz" }], "{ fizz }") + assert_equal ["buzz"], result + end + + def test_uses_default_resolver_for_method_calls + entity = Struct.new(:fizz) + result = map_breadth_objects([entity.new("buzz")], "{ fizz }") + assert_equal ["buzz"], result + end + + def test_maps_schemas_from_definition + objects = [{ "a" => "1" }, { "a" => "2" }] + result = map_breadth_objects(objects, "{ a }", schema: SCHEMA_FROM_DEF) + assert_equal ["1", "2"], result + end + + def test_maps_results_with_multiple_nodes + result = map_breadth_objects(OBJECTS, "{ foo foo }") + assert_equal EXPECTED_RESULTS, result + end + + private + + def map_breadth_objects(objects, doc, schema: BreadthTestSchema, variables: {}, context: {}) + query = GraphQL::Query.new( + schema, + document: GraphQL.parse(doc), + variables: variables, + context: context, + ) + + node = query.document.definitions.first.selections.first + runtime = SimpleBreadthRuntime.new(query: query) + runtime.run { runtime.evaluate_breadth_selection(objects, schema.query, node) } + end +end diff --git a/spec/graphql/execution/errors_spec.rb b/spec/graphql/execution/errors_spec.rb index eb345111419..ce6cf8af5f2 100644 --- a/spec/graphql/execution/errors_spec.rb +++ b/spec/graphql/execution/errors_spec.rb @@ -14,6 +14,7 @@ class ErrorsTestSchema < ParentErrorsTestSchema ErrorD = ParentErrorsTestSchema::ErrorD class ErrorA < RuntimeError; end class ErrorB < RuntimeError; end + class ErrorC < RuntimeError attr_reader :value def initialize(value:) @@ -50,6 +51,11 @@ class ErrorBGrandchildClass < ErrorBChildClass; end err.value end + class ErrorList < Array + def each + raise ErrorB + end + end class Thing < GraphQL::Schema::Object def self.authorized?(obj, ctx) @@ -59,14 +65,18 @@ def self.authorized?(obj, ctx) true end - field :string, String, null: false - def string + field :string, String, null: false, resolve_static: true + def self.string(context) "a string" end + + def string + self.class.string(context) + end end class ValuesInput < GraphQL::Schema::InputObject - argument :value, Int, required: true, loads: Thing + argument :value, Int, loads: Thing def self.object_from_id(type, value, ctx) if value == 1 @@ -88,74 +98,129 @@ def self.coerce_input(value, ctx) end class Query < GraphQL::Schema::Object - field :f1, Int, null: true do + field :f1, Int, resolve_static: true do argument :a1, Int, required: false end - def f1(a1: nil) + def self.f1(context, a1: nil) raise ErrorA, "f1 broke" end - field :f2, Int, null: true - def f2 + def f1(a1: nil) + self.class.f1(context, a1: a1) + end + + field :f2, Int, resolve_static: true + def self.f2(context) -> { raise ErrorA, "f2 broke" } end - field :f3, Int, null: true + def f2 + self.class.f2(context) + end - def f3 + field :f3, Int, resolve_static: true + + def self.f3(context) raise ErrorB end - field :f4, Int, null: false - def f4 + def f3 + self.class.f3(context) + end + + field :f4, Int, null: false, resolve_static: true + def self.f4(context) raise ErrorC.new(value: 20) end - field :f5, Int, null: true - def f5 + def f4 + self.class.f4(context) + end + + field :f5, Int, resolve_static: true + def self.f5(context) raise ErrorASubclass, "raised subclass" end - field :f6, Int, null: true - def f6 + def f5 + self.class.f5(context) + end + + field :f6, Int, resolve_static: true + def self.f6(context) -> { raise ErrorB } end - field :f7, String, null: true - def f7 + def f6 + self.class.f6(context) + end + + field :f7, String, resolve_static: true + def self.f7(context) raise ErrorBGrandchildClass end - field :f8, String, null: true do - argument :input, PickyString, required: true + def f7 + self.class.f7(context) end - def f8(input:) + field :f8, String, resolve_static: true do + argument :input, PickyString + end + + def self.f8(context, input:) input end - field :f9, String, null: true do - argument :thing_id, ID, required: true, loads: Thing + def f8(input:) + self.class.f8(context, input: input) end - def f9(thing:) + field :f9, String, resolve_static: true do + argument :thing_id, ID, loads: Thing + end + + def self.f9(context, thing:) thing[:id] end - field :thing, Thing, null: true - def thing + def f9(thing:) + self.class.f9(context, thing: thing) + end + + field :thing, Thing, resolve_static: true + def self.thing(context) :thing end - field :input_field, Int, null: true do - argument :values, ValuesInput, required: true, method_access: false + def thing + self.class.thing(context) end - field :non_nullable_array, [String], null: false - def non_nullable_array + field :input_field, Int do + argument :values, ValuesInput + end + + field :non_nullable_array, [String], null: false, resolve_static: true + + def self.non_nullable_array(context) [nil] end + + def non_nullable_array + self.class.non_nullable_array(context) + end + + field :error_in_each, [Int], resolve_static: true + + def self.error_in_each(context) + ErrorList.new + end + + def error_in_each + self.class.error_in_each(context) + end end query(Query) @@ -174,23 +239,12 @@ def self.resolve_type(type, obj, ctx) end end - class ErrorsTestSchemaWithoutInterpreter < GraphQL::Schema - class Query < GraphQL::Schema::Object - field :non_nullable_array, [String], null: false - def non_nullable_array - [nil] - end - end - - query(Query) - end - describe "rescue_from handling" do it "can replace values with `nil`" do ctx = { errors: [] } res = ErrorsTestSchema.execute "{ f1(a1: 1) }", context: ctx, root_value: :abc assert_equal({ "data" => { "f1" => nil } }, res) - assert_equal ["f1 broke (ErrorsTestSchema::Query.f1, :abc, {:a1=>1})"], ctx[:errors] + assert_equal ["f1 broke (ErrorsTestSchema::Query.f1, #{if_exec_next("nil", ":abc")}, #{{a1: 1}.inspect})"], ctx[:errors] end it "rescues errors from lazy code" do @@ -266,7 +320,7 @@ def non_nullable_array it "rescues them" do context = { authorized: false } res = ErrorsTestSchema.execute(" { thing { string } } ", context: context) - assert_equal ["ErrorD on nil at Query.thing({})"], res["errors"].map { |e| e["message"] } + assert_equal ["ErrorD on #{if_exec_next(":thing", "nil")} at Query.thing({})"], res["errors"].map { |e| e["message"] } end end @@ -276,7 +330,7 @@ def non_nullable_array res = ErrorsTestSchema.execute(" { inputField(values: { value: 2 }) } ", root_value: :root, context: context) # It would be better to have the arguments here, but since this error was raised during _creation_ of keywords, # so the runtime arguments aren't available now. - assert_equal ["ErrorD on :root at Query.inputField()"], res["errors"].map { |e| e["message"] } + assert_equal ["ErrorD on #{if_exec_next("nil", ":root")} at Query.inputField()"], res["errors"].map { |e| e["message"] } end it "rescues them from variable values" do @@ -292,21 +346,23 @@ def non_nullable_array end describe "errors raised in non_nullable_array loads" do - it "outputs the appropriate error message when using non-interpreter schema" do - res = ErrorsTestSchemaWithoutInterpreter.execute("{ nonNullableArray }") - expected_error = { - "message" => "Cannot return null for non-nullable field Query.nonNullableArray" - } - assert_equal({ "data" => nil, "errors" => [expected_error] }, res) - end - it "outputs the appropriate error message when using interpreter schema" do res = ErrorsTestSchema.execute("{ nonNullableArray }") expected_error = { - "message" => "Cannot return null for non-nullable field Query.nonNullableArray" + "message" => "Cannot return null for non-nullable element of type 'String!' for Query.nonNullableArray", + "path" => ["nonNullableArray", 0], + "locations" => [{ "line" => 1, "column" => 3 }] } assert_equal({ "data" => nil, "errors" => [expected_error] }, res) end end + + describe "when .each on a list type raises an error" do + it "rescues it properly" do + res = ErrorsTestSchema.execute("{ __typename errorInEach }") + expected_error = { "message" => "boom!", "locations"=>[{"line"=>1, "column"=>14}], "path"=>["errorInEach"] } + assert_equal({ "data" => { "__typename" => "Query", "errorInEach" => nil }, "errors" => [expected_error] }, res) + end + end end end diff --git a/spec/graphql/execution/finalize_spec.rb b/spec/graphql/execution/finalize_spec.rb new file mode 100644 index 00000000000..23ebdebabe4 --- /dev/null +++ b/spec/graphql/execution/finalize_spec.rb @@ -0,0 +1,784 @@ +# frozen_string_literal: true +require "spec_helper" + +class ExecutionFinalizeTest < Minitest::Test + class HashKeyResolver + def initialize(key) + @key = key + end + + def call(obj, ctx) + obj[@key] + end + end + RESOLVE_TYPE = ->(abs_type, obj, ctx) { ctx.types.get_type("Test") } + TEST_RESOLVERS = { + "Node" => { + "id" => HashKeyResolver.new("id"), + "__type__" => ->(obj, ctx) { ctx.types.type(obj["__typename__"]) }, + }, + "Test" => { + "id" => HashKeyResolver.new("id"), + "req" => HashKeyResolver.new("req"), + "opt" => HashKeyResolver.new("opt"), + }, + "Query" => { + "node" => HashKeyResolver.new("node"), + "test" => HashKeyResolver.new("test"), + "reqField" => HashKeyResolver.new("reqField"), + "anotherField" => HashKeyResolver.new("anotherField"), + }, + }.freeze + + module DefaultResolve + def self.resolve_type(abs_t, obj, ctx) + ctx.types.type("Test") + end + + def self.call(object_type, field_definition, object, arguments, context) + TEST_RESOLVERS.fetch(object_type.graphql_name).fetch(field_definition.graphql_name).call(object, context) + end + end + + def exec_test(schema_str, query_str, data) + schema = GraphQL::Schema.from_definition(schema_str, default_resolve: DefaultResolve) + schema.use(GraphQL::Execution::Next) + schema.execute_next(query_str, root_value: data) + end + + def test_basic_object_structure + schema = "type Test { req: String! opt: String } type Query { test: Test }" + source = { + "test" => { + "req" => "yes", + "opt" => nil + } + } + expected = { + "data" => { + "test" => { + "req" => "yes", + "opt" => nil + } + } + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_errors_render_above_data_in_result + schema = "type Test { req: String! opt: String } type Query { test: Test }" + source = { "test" => { "req" => nil } } + + assert_equal ["errors", "data"], exec_test(schema, "{ test { req } }", source).keys + end + + def test_bubbles_null_for_single_object_scopes + schema = "type Test { req: String! opt: String } type Query { test: Test }" + source = { + "test" => { + "req" => nil, + "opt" => "yes" + }, + } + expected = { + "data" => { + "test" => nil, + }, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.req", + "path" => ["test", "req"], + "locations" => [{ "line" => 1, "column" => 10 }], + }], + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_bubbles_null_for_nested_non_null_object_scopes + schema = "type Test { req: String! opt: String } type Query { test: Test! }" + source = { + "test" => { + "req" => nil, + "opt" => "yes" + } + } + expected = { + "data" => nil, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.req", + "path" => ["test", "req"], + "locations" => [{ "line" => 1, "column" => 10 }], + }], + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_basic_list_structure + schema = "type Test { req: String! opt: String } type Query { test: [Test] }" + source = { + "test" => [ + { "req" => "yes", "opt" => nil }, + { "req" => "yes", "opt" => "yes" }, + ], + } + expected = { + "data" => { + "test" => [ + { "req" => "yes", "opt" => nil }, + { "req" => "yes", "opt" => "yes" }, + ], + }, + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_bubbles_null_for_list_elements + schema = "type Test { req: String! opt: String } type Query { test: [Test] }" + source = { + "test" => [ + { "req" => "yes", "opt" => nil }, + { "req" => nil, "opt" => "yes" }, + ], + } + expected = { + "data" => { + "test" => [ + { "req" => "yes", "opt" => nil }, + nil, + ], + }, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.req", + "path" => ["test", 1, "req"], + "locations" => [{ "line" => 1, "column" => 10 }], + }], + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_bubbles_null_for_required_list_elements + schema = "type Test { req: String! opt: String } type Query { test: [Test!] }" + source = { + "test" => [ + { "req" => "yes", "opt" => nil }, + { "req" => nil, "opt" => "yes" }, + ] + } + expected = { + "data" => { + "test" => nil, + }, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.req", + "path" => ["test", 1, "req"], + "locations" => [{ "line" => 1, "column" => 10 }], + }], + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_bubbles_null_for_required_lists + schema = "type Test { req: String! opt: String } type Query { test: [Test!]! }" + source = { + "test" => [ + { "req" => "yes", "opt" => nil }, + { "req" => nil, "opt" => "yes" }, + ], + } + expected = { + "data" => nil, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.req", + "path" => ["test", 1, "req"], + "locations" => [{ "line" => 1, "column" => 10 }], + }], + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_basic_nested_list_structure + schema = "type Test { req: String! opt: String } type Query { test: [[Test]] }" + source = { + "test" => [ + [{ "req" => "yes", "opt" => nil }], + [{ "req" => "yes", "opt" => "yes" }], + ], + } + expected = { + "data" => { + "test" => [ + [{ "req" => "yes", "opt" => nil }], + [{ "req" => "yes", "opt" => "yes" }], + ], + }, + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_bubbles_null_for_nested_list_elements + schema = "type Test { req: String! opt: String } type Query { test: [[Test]] }" + source = { + "test" => [ + [{ "req" => "yes", "opt" => nil }], + [{ "req" => nil, "opt" => "yes" }], + ], + } + expected = { + "data" => { + "test" => [ + [{ "req" => "yes", "opt" => nil }], + [nil], + ], + }, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.req", + "path" => ["test", 1, 0, "req"], + "locations" => [{ "line" => 1, "column" => 10 }], + }], + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_bubbles_null_for_nested_required_list_elements + schema = "type Test { req: String! opt: String } type Query { test: [[Test!]] }" + source = { + "test" => [ + [{ "req" => "yes", "opt" => nil }], + [{ "req" => nil, "opt" => "yes" }], + ], + } + expected = { + "data" => { + "test" => [ + [{ "req" => "yes", "opt" => nil }], + nil, + ], + }, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.req", + "path" => ["test", 1, 0, "req"], + "locations" => [{ "line" => 1, "column" => 10 }], + }], + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_bubbles_null_for_inner_required_lists + schema = "type Test { req: String! opt: String } type Query { test: [[Test!]!] }" + source = { + "test" => [ + [{ "req" => "yes", "opt" => nil }], + [{ "req" => nil, "opt" => "yes" }], + ], + } + expected = { + "data" => { + "test" => nil, + }, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.req", + "path" => ["test", 1, 0, "req"], + "locations" => [{ "line" => 1, "column" => 10 }], + }], + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_bubbles_null_through_nested_required_list_scopes + schema = "type Test { req: String! opt: String } type Query { test: [[Test!]!]! }" + source = { + "test" => [ + [{ "req" => "yes", "opt" => nil }], + [{ "req" => nil, "opt" => "yes" }], + ], + } + expected = { + "data" => nil, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.req", + "path" => ["test", 1, 0, "req"], + "locations" => [{ "line" => 1, "column" => 10 }], + }], + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_bubble_through_inline_fragment + schema = "type Test { req: String! opt: String } type Query { test: Test }" + query = "{ test { ... on Test { req opt } } }" + source = { + "test" => { + "req" => nil, + "opt" => nil + }, + } + expected = { + "data" => { + "test" => nil, + }, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.req", + "path" => ["test", "req"], + "locations" => [{ "line" => 1, "column" => 24 }], + }], + } + + assert_equal expected, exec_test(schema, query, source) + end + + def test_bubble_through_fragment_spreads + schema = "type Test { req: String! opt: String } type Query { test: Test }" + query = "{ test { ...F } } fragment F on Test { req opt }" + source = { + "test" => { + "req" => nil, + "opt" => nil + }, + } + expected = { + "data" => { + "test" => nil, + }, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.req", + "path" => ["test", "req"], + "locations" => [{ "line" => 1, "column" => 40 }], + }], + } + + assert_equal expected, exec_test(schema, query, source) + end + + def test_bubbles_null_through_fragment_spread_with_sibling_field + schema = "type Test { req: String! opt: String } type Query { test: Test }" + query = "{ test { ...F opt } } fragment F on Test { req }" + source = { + "test" => { + "req" => nil, + "opt" => "yes" + }, + } + expected = { + "data" => { + "test" => nil, + }, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.req", + "path" => ["test", "req"], + "locations" => [{ "line" => 1, "column" => 44 }], + }], + } + + assert_equal expected, exec_test(schema, query, source) + end + + def test_bubbles_null_through_inline_fragment_with_sibling_field + schema = "type Test { req: String! opt: String } type Query { test: Test }" + query = "{ test { ... on Test { req } opt } }" + source = { + "test" => { + "req" => nil, + "opt" => "yes" + }, + } + expected = { + "data" => { + "test" => nil, + }, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.req", + "path" => ["test", "req"], + "locations" => [{ "line" => 1, "column" => 24 }], + }], + } + + assert_equal expected, exec_test(schema, query, source) + end + + def test_inline_errors_in_null_positions_report + schema = "type Test { req: String! opt: String } type Query { test: [Test] }" + source = { + "test" => [ + { "req" => "yes", "opt" => nil }, + { "req" => "yes", "opt" => GraphQL::ExecutionError.new("Not okay!") }, + ], + } + expected = { + "data" => { + "test" => [ + { "req" => "yes", "opt" => nil }, + { "req" => "yes", "opt" => nil }, + ], + }, + "errors" => [{ + "message" => "Not okay!", + "locations" => [{ "line" => 1, "column" => 14 }], + "path" => ["test", 1, "opt"], + }], + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + # Reset this object so that node detection will work during the next execution: + source["test"][1]["opt"].ast_node = nil + + inline_fragment_errors = [{ + "message" => "Not okay!", + "locations" => [{ "line" => 1, "column" => 28 }], + "path" => ["test", 1, "opt"], + }] + + result = exec_test(schema, "{ ...on Query { test { req opt } } }", source) + assert_equal expected["data"], result["data"] + assert_equal inline_fragment_errors, result["errors"] + + fragment_errors = [{ + "message" => "Not okay!", + "locations" => [{ "line" => 1, "column" => 59 }], + "path" => ["test", 1, "opt"], + }] + # Reset this object so that node detection will work during the next execution: + source["test"][1]["opt"].ast_node = nil + result = exec_test(schema, "{ ...Selection } fragment Selection on Query { test { req opt } }", source) + assert_equal expected["data"], result["data"] + assert_equal fragment_errors, result["errors"] + end + + def test_abstract_fragments_on_concrete_results_interpret_type + schema = %| + interface Node { + id: ID! + } + type Test implements Node { + id: ID! + } + type Query { + node: Node + test: Test + } + | + + query = %| + query { + test { + ... on Node { id } + ... NodeAttrs + } + } + fragment NodeAttrs on Node { id } + | + + source = { + "test" => {}, + } + + expected = { + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.id", + "locations" => [{ "line" => 4, "column" => 25 }, { "line" => 8, "column" => 36 }], + "path" => ["test", "id"], + }], + "data" => { "test" => nil }, + } + + assert_equal expected, exec_test(schema, query, source) + end + + def test_concrete_fragments_on_abstract_results_interpret_type + schema = %| + interface Node { + id: ID! + } + type Test implements Node { + id: ID! + } + type Query { + node: Node + test: Test + } + | + + query = %| + query { + node { + ... on Test { id } + ... TestAttrs + } + } + fragment TestAttrs on Test { id } + | + + source = { + "node" => { "__typename__" => "Test" }, + } + + expected = { + "errors" => [{ + "message" => "Cannot return null for non-nullable field Test.id", + "locations" => [{ "line" => 4, "column" => 25 }, { "line" => 8, "column" => 36 }], + "path" => ["node", "id"], + }], + "data" => { "node" => nil }, + } + + assert_equal expected, exec_test(schema, query, source) + end + + def test_inline_errors_in_non_null_positions_report_and_propagate + schema = "type Test { req: String! opt: String } type Query { test: [Test] }" + source = { + "test" => [ + { "req" => "yes", "opt" => nil }, + { "req" => GraphQL::ExecutionError.new("Not okay!"), "opt" => nil }, + ], + } + expected = { + "data" => { + "test" => [ + { "req" => "yes", "opt" => nil }, + nil, + ], + }, + "errors" => [{ + "message" => "Not okay!", + "locations" => [{ "line" => 1, "column" => 10 }], + "path" => ["test", 1, "req"], + }], + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_multiple_offenses_for_null_position_report_all_instances + schema = "type Test { req: String! opt: String } type Query { test: [Test] }" + source = { + "test" => [ + { "req" => "yes", "opt" => nil }, + { "req" => "yes", "opt" => GraphQL::ExecutionError.new("Not okay!") }, + { "req" => "yes", "opt" => GraphQL::ExecutionError.new("Not okay!") }, + ], + } + expected = { + "errors" => [{ + "message" => "Not okay!", + "locations" => [{ "line" => 1, "column" => 14 }], + "path" => ["test", 1, "opt"], + }, { + "message" => "Not okay!", + "locations" => [{ "line" => 1, "column" => 14 }], + "path" => ["test", 2, "opt"], + }], + "data" => { + "test" => [ + { "req" => "yes", "opt" => nil }, + { "req" => "yes", "opt" => nil }, + { "req" => "yes", "opt" => nil }, + ], + }, + } + + assert_graphql_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_multiple_offenses_for_non_null_position_without_intersecting_propagation_report_all_instances + schema = "type Test { req: String! opt: String } type Query { test: [Test] }" + source = { + "test" => [ + { "req" => "yes", "opt" => nil }, + { "req" => GraphQL::ExecutionError.new("Not okay!"), "opt" => "yes" }, + { "req" => GraphQL::ExecutionError.new("Not okay!"), "opt" => "yes" }, + ], + } + expected = { + "data" => { + "test" => [ + { "req" => "yes", "opt" => nil }, + nil, + nil, + ], + }, + "errors" => [{ + "message" => "Not okay!", + "locations" => [{ "line" => 1, "column" => 10 }], + "path" => ["test", 1, "req"], + }, { + "message" => "Not okay!", + "locations" => [{ "line" => 1, "column" => 10 }], + "path" => ["test", 2, "req"], + }], + } + + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_multiple_offenses_for_non_null_position_with_intersecting_propagation_report_first_instance + schema = "type Test { req: String! opt: String } type Query { test: [Test!] }" + source = { + "test" => [ + { "req" => "yes", "opt" => nil }, + { "req" => GraphQL::ExecutionError.new("first"), "opt" => "yes" }, + { "req" => GraphQL::ExecutionError.new("second"), "opt" => "yes" }, + ], + } + expected = { + "data" => { + "test" => nil, + }, + "errors" => [{ + "message" => "first", + "locations" => [{ "line" => 1, "column" => 10 }], + "path" => ["test", 1, "req"], + },{ + "message" => "second", + "locations" => [{ "line" => 1, "column" => 10 }], + "path" => ["test", 2, "req"], + }], + } + + # The original Shopify spec only expected the _first_ error to be present, + # because of how the query would be terminated when an error was encountered. + # We might change this in the future to only return a single error. + # See: https://github.com/rmosolgo/graphql-ruby/pull/5509#discussion_r2756873801 + assert_equal expected, exec_test(schema, "{ test { req opt } }", source) + end + + def test_multiple_locations_for_duplicate_field_selections + schema = "type Query { reqField: String! }" + source = { + "reqField" => nil, + } + + query = <<~GRAPHQL + { + reqField + reqField + } + GRAPHQL + + expected = { + "data" => nil, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Query.reqField", + "path" => ["reqField"], + "locations" => [ + { "line" => 2, "column" => 3 }, + { "line" => 3, "column" => 3 }, + ], + }], + } + + assert_equal expected, exec_test(schema, query, source) + end + + def test_multiple_locations_with_fragments + schema = "type Query { reqField: String! anotherField: String }" + source = { + "reqField" => nil, + "anotherField" => "value", + } + + query = <<~GRAPHQL + { + reqField + ...Fields + } + + fragment Fields on Query { + reqField + anotherField + } + GRAPHQL + + expected = { + "data" => nil, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Query.reqField", + "path" => ["reqField"], + "locations" => [ + { "line" => 2, "column" => 3 }, + { "line" => 7, "column" => 3 }, + ], + }], + } + + assert_equal expected, exec_test(schema, query, source) + end + + def test_multiple_locations_with_inline_fragments + schema = "type Query { reqField: String! }" + source = { + "reqField" => nil, + } + + query = <<~GRAPHQL + { + reqField + ... on Query { + reqField + } + } + GRAPHQL + + expected = { + "data" => nil, + "errors" => [{ + "message" => "Cannot return null for non-nullable field Query.reqField", + "path" => ["reqField"], + "locations" => [ + { "line" => 2, "column" => 3 }, + { "line" => 4, "column" => 5 }, + ], + }], + } + + assert_equal expected, exec_test(schema, query, source) + end + + def test_formats_errors_with_extensions + schema = "type Query { test: String! }" + source = { + "test" => GraphQL::ExecutionError.new("Not okay!", extensions: { + "code" => "TEST", + reason: "sorry", + }) + } + expected = { + "data" => nil, + "errors" => [{ + "message" => "Not okay!", + "locations" => [{ "line" => 1, "column" => 3 }], + "extensions" => { "code" => "TEST", "reason" => "sorry" }, + "path" => ["test"], + }], + } + + assert_equal expected, exec_test(schema, "{ test }", source) + end + + def test_formats_error_message_for_non_null_list_items + schema = "type Test { req: String! } type Query { test: [Test!]! }" + source = { + "test" => [nil], + } + expected = { + "data" => nil, + "errors" => [{ + "message" => "Cannot return null for non-nullable element of type 'Test!' for Query.test", + "path" => ["test", 0], + "locations" => [{ "line" => 1, "column" => 3 }], + }], + } + + assert_equal expected, exec_test(schema, "{ test { req } }", source) + end +end diff --git a/spec/graphql/execution/input_values_spec.rb b/spec/graphql/execution/input_values_spec.rb new file mode 100644 index 00000000000..ead23b3c828 --- /dev/null +++ b/spec/graphql/execution/input_values_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true +require "spec_helper" + +class ExecutionInputValuesTest < Minitest::Test + class TestSchema < GraphQL::Schema + class TestStatus < GraphQL::Schema::Enum + value :ACTIVE + value :INACTIVE + end + + class TestInput < GraphQL::Schema::InputObject + argument :string, String, required: false + argument :float, Float, required: false + argument :int, Int, required: false + argument :enum, TestStatus, required: false + end + + class Mutation < GraphQL::Schema::Object + field :test_input, Boolean do + argument :input, TestInput, required: false + end + + field :test_list_input, Boolean do + argument :input, [TestInput, null: true], required: false + end + end + + mutation(Mutation) + query(Mutation) # Just to have something + end + + class DummyRunner + def add_step(s); end + def schema; TestSchema; end + end + + def get_input_values(query_string: nil, variables_string: nil, variables: nil) + query_string ||= "query#{variables_string ? "(#{variables_string})" : ""} { __typename }" + query = GraphQL::Query.new(TestSchema, query_string, validate: false, variables: variables) + GraphQL::Execution::InputValues.new(query, DummyRunner.new) + end + + def get_argument_nodes(arg_string) + GraphQL.parse("query @something(#{arg_string}) { t }").definitions.first.directives.first.arguments + end + + def test_coerce_variable_values_empty_inputs_returns_empty + input = get_input_values + assert_equal({}, input.variable_values) + end + + def test_it_works_with_simple_scalars + input = get_input_values(variables_string: "$name: String, $count: Int, $average: Float, $isOk: Boolean", variables: { "name" => "hello", "count" => 1, "average" => 3.4, "isOk" => false }) + assert_equal({ "name" => "hello", "count" => 1, "average" => 3.4, "isOk" => false }, input.variable_values) + + with_defaults_str = "$name: String = \"def\", $count: Int = 10, $average: Float = 300.4, $isOk: Boolean = true" + + input = get_input_values(variables_string: with_defaults_str, variables: { "name" => "hello", "count" => 1, "average" => 3.4, "isOk" => false }) + assert_equal({ "name" => "hello", "count" => 1, "average" => 3.4, "isOk" => false }, input.variable_values) + + input = get_input_values(variables_string: with_defaults_str) + assert_equal({ "name" => "def", "count" => 10, "average" => 300.4, "isOk" => true }, input.variable_values) + end + + def test_it_produces_argument_values_for_simple_scalars + vs = "$if: Boolean = false" + input = get_input_values(variables_string: vs) + assert_equal_input( { if: false }, input.argument_values(GraphQL::Schema::Directive::Skip, get_argument_nodes("if: $if"), nil)) + assert_equal_input( { if: true }, input.argument_values(GraphQL::Schema::Directive::Skip, get_argument_nodes("if: true"), nil)) + end + + def test_it_produces_argument_values_for_input_objects + input = get_input_values + assert_equal_input( {input: { string: "a", enum: "ACTIVE" } }, input.argument_values(TestSchema.find("Mutation.testInput"), get_argument_nodes("input: { string: \"a\", enum: ACTIVE }"), nil)) + end + + def assert_equal_input(expected_ruby_hash, graphql_input, path = []) + if path.empty? && graphql_input.is_a?(Array) && graphql_input.last.nil? && expected_ruby_hash.is_a?(Hash) + graphql_input = graphql_input.first # ignore the `nil` errors in the multiple return + end + case expected_ruby_hash + when Array + assert_instance_of Array, graphql_input, "Matches at `#{path.join(".")}`" + expected_ruby_hash.each_with_index do |next_expected, idx| + assert_equal_input(next_expected, graphql_input[idx], path + [idx]) + end + when Hash + if path.empty? + assert_instance_of Hash, graphql_input, "Matches at `#{path.join(".")}`" + else + assert_kind_of GraphQL::Schema::InputObject, graphql_input, "Matches at `#{path.join(".")}`" + graphql_input = graphql_input.to_h + end + expected_ruby_hash.each do |k, v| + assert_equal_input(v, graphql_input[k], path + [k]) + end + else + assert_equal expected_ruby_hash, graphql_input, "Matches at `#{path.join(".")}`" + end + end + + def test_it_works_with_arrays_of_input_objects + input = get_input_values(variables_string: "$string: String = \"abc\", $string2: String, $input: TestInput!", variables: { string2: "xyz", input: { string: "nested" }}) + assert_equal_input({input: [{}]}, input.argument_values(TestSchema.find("Mutation.testListInput"), get_argument_nodes("input: { string: $s }"), nil)) + assert_equal_input({input: [{ string: "Str" }]}, input.argument_values(TestSchema.find("Mutation.testListInput"), get_argument_nodes("input: { string: \"Str\" }"), nil)) + assert_equal_input({input: [{ string: "abc" }]}, input.argument_values(TestSchema.find("Mutation.testListInput"), get_argument_nodes("input: { string: $string }"), nil)) + assert_equal_input({input: [{ string: "xyz" }]}, input.argument_values(TestSchema.find("Mutation.testListInput"), get_argument_nodes("input: { string: $string2 }"), nil)) + assert_equal_input({input: [{ string: "nested" }]}, input.argument_values(TestSchema.find("Mutation.testListInput"), get_argument_nodes("input: $input"), nil)) + assert_equal_input({input: [{}, {string: "Str"}, {string: "abc"}, {string: "xyz"}, {string: "nested"}]}, input.argument_values(TestSchema.find("Mutation.testListInput"), get_argument_nodes("input: [{string: $s}, {string: \"Str\"}, {string: $string }, { string: $string2 }, $input]"), nil)) + end +end diff --git a/spec/graphql/execution/instrumentation_spec.rb b/spec/graphql/execution/instrumentation_spec.rb index da9dc8f7e74..3bd87b874c6 100644 --- a/spec/graphql/execution/instrumentation_spec.rb +++ b/spec/graphql/execution/instrumentation_spec.rb @@ -13,72 +13,72 @@ def initialize(key) end end - class LogInstrumenter - def before_query(unit_of_work) - run_hook(unit_of_work, "begin") - end - - def after_query(unit_of_work) - run_hook(unit_of_work, "end") - end - - alias :before_multiplex :before_query - alias :after_multiplex :after_query - - private - - def run_hook(unit_of_work, event_name) - unit_of_work.context[log_key(event_name)] = true - if unit_of_work.context[raise_key(event_name)] - raise InstrumenterError.new(log_key(event_name)) + module LogInstrumenter + def self.generate(context_key_sym) + hook_method = :"#{context_key_sym}_run_hook" + mod = Module.new + + mod.define_method(:execute_query) do |query:, &block| + public_send(hook_method, query, "begin") + result = nil + begin + result = super(query: query, &block) + ensure + public_send(hook_method, query, "end") + end + result end - end - def log_key(event_name) - context_key("did_#{event_name}") - end + mod.define_method(:execute_multiplex) do |multiplex:, &block| + public_send(hook_method, multiplex, "begin") + result = nil + begin + result = super(multiplex: multiplex, &block) + ensure + public_send(hook_method, multiplex, "end") + end + result + end - def raise_key(event_name) - context_key("should_raise_#{event_name}") - end + mod.define_method(hook_method) do |unit_of_work, event_name| + log_key = :"#{context_key_sym}_did_#{event_name}" + error_key = :"#{context_key_sym}_should_raise_#{event_name}" + unit_of_work.context[log_key] = true + if unit_of_work.context[error_key] + raise InstrumenterError.new(log_key) + end + end - def context_key(suffix) - prefix = self.class.name.sub("Instrumenter", "").downcase - :"#{prefix}_instrumenter_#{suffix}" + mod end end - class FirstInstrumenter < LogInstrumenter; end - class SecondInstrumenter < LogInstrumenter; end - - class ExecutionErrorInstrumenter - def before_query(query) + module ExecutionErrorTrace + def execute_query(query:) if query.context[:raise_execution_error] - raise GraphQL::ExecutionError, "Raised from instrumenter before_query" + raise GraphQL::ExecutionError, "Raised from trace execute_query" end - end - - def after_query(query) + super end end # This is how you might add queries from a persisted query backend - class QueryStringInstrumenter - def before_query(query) - if query.context[:extra_query_string] && query.query_string.nil? - query.query_string = query.context[:extra_query_string] + module QueryStringTrace + def execute_multiplex(multiplex:) + multiplex.queries.each do |query| + if query.context[:extra_query_string] && query.query_string.nil? + query.query_string = query.context[:extra_query_string] + end end - end - - def after_query(query) + super end end let(:query_type) { Class.new(GraphQL::Schema::Object) do graphql_name "Query" - field :int, Integer, null: true do + field :int, Integer do argument :value, Integer, required: false end @@ -92,10 +92,10 @@ def int(value:) spec = self Class.new(GraphQL::Schema) do query(spec.query_type) - instrument(:query, FirstInstrumenter.new) - instrument(:query, SecondInstrumenter.new) - instrument(:query, ExecutionErrorInstrumenter.new) - instrument(:query, QueryStringInstrumenter.new) + trace_with(LogInstrumenter.generate(:second_instrumenter)) + trace_with(LogInstrumenter.generate(:first_instrumenter)) + trace_with(ExecutionErrorTrace) + trace_with(QueryStringTrace) end } @@ -125,11 +125,22 @@ def int(value:) assert context[:second_instrumenter_did_end] end - it "rescues execution errors from before_query" do + it "rescues execution errors from execute_query" do context = {raise_execution_error: true} res = schema.execute(" { int(value: 2) } ", context: context) - assert_equal "Raised from instrumenter before_query", res["errors"].first["message"] - refute res.key?("data"), "The query doesn't run" + + assert_equal({ + "data" => nil, + "errors" => [ + { + "message" => "Raised from trace execute_query", + **if_exec_next({ + "locations" => [{"line" => 1, "column" => 2}], + "path" => [], + }, {}) + }, + ] + }, res.to_h) end it "can assign a query string there" do @@ -141,9 +152,9 @@ def int(value:) describe "within a multiplex" do let(:multiplex_schema) { - schema.redefine { - instrument(:multiplex, FirstInstrumenter.new) - instrument(:multiplex, SecondInstrumenter.new) + Class.new(schema) { + trace_with(LogInstrumenter.generate(:second_instrumenter)) + trace_with(LogInstrumenter.generate(:first_instrumenter)) } } @@ -166,8 +177,9 @@ def int(value:) assert multiplex_ctx[:second_instrumenter_did_begin] refute multiplex_ctx[:second_instrumenter_did_end] # No query instrumentation was run at all - assert_equal 0, query_1_ctx.size - assert_equal 0, query_2_ctx.size + expected_ctx_size = GraphQL::Schema.use_visibility_profile? ? 1 : 0 + assert_equal expected_ctx_size, query_1_ctx.size + assert_equal expected_ctx_size, query_2_ctx.size end it "does full and partial query runs" do diff --git a/spec/graphql/execution/interpreter/arguments_spec.rb b/spec/graphql/execution/interpreter/arguments_spec.rb index 500710dfa05..3552ab44232 100644 --- a/spec/graphql/execution/interpreter/arguments_spec.rb +++ b/spec/graphql/execution/interpreter/arguments_spec.rb @@ -10,7 +10,7 @@ class SearchParams < GraphQL::Schema::InputObject class Query < GraphQL::Schema::Object field :search, [String], null: false do argument :params, SearchParams, required: false - argument :limit, Int, required: true + argument :limit, Int end end diff --git a/spec/graphql/execution/interpreter_spec.rb b/spec/graphql/execution/interpreter_spec.rb index f0f727c8e26..6520f454520 100644 --- a/spec/graphql/execution/interpreter_spec.rb +++ b/spec/graphql/execution/interpreter_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "spec_helper" require_relative "../subscriptions_spec" - describe GraphQL::Execution::Interpreter do module InterpreterTest class Box @@ -19,11 +18,23 @@ def value end end - class Expansion < GraphQL::Schema::Object + class BaseField < GraphQL::Schema::Field + end + + class BaseObject < GraphQL::Schema::Object + field_class(BaseField) + end + + module BaseInterface + include GraphQL::Schema::Interface + field_class(BaseField) + end + + class Expansion < BaseObject field :sym, String, null: false - field :lazy_sym, String, null: false + field :lazy_sym, String, null: false, resolve_legacy_instance_method: true field :name, String, null: false - field :cards, ["InterpreterTest::Card"], null: false + field :cards, ["InterpreterTest::Card"], null: false, resolve_legacy_instance_method: true def self.authorized?(expansion, ctx) if expansion.sym == "NOPE" @@ -41,29 +52,25 @@ def lazy_sym Box.new(value: object.sym) end - field :null_union_field_test, Integer, null: false - def null_union_field_test - 1 - end - - field :always_cached_value, Integer, null: false + field :always_cached_value, Integer, null: false, resolve_legacy_instance_method: true def always_cached_value raise "should never be called" end end - class Card < GraphQL::Schema::Object + class Card < BaseObject field :name, String, null: false field :colors, "[InterpreterTest::Color]", null: false - field :expansion, Expansion, null: false + field :expansion, Expansion, null: false, resolve_legacy_instance_method: true def expansion Query::EXPANSIONS.find { |e| e.sym == @object.expansion_sym } end - field :null_union_field_test, Integer, null: true - def null_union_field_test - nil + field :parent_class_name, String, null: false, extras: [:parent], resolve_legacy_instance_method: true + + def parent_class_name(parent:) + parent.class.name end end @@ -83,14 +90,14 @@ def self.resolve_type(obj, ctx) end end - class FieldCounter < GraphQL::Schema::Object + class FieldCounter < BaseObject implements GraphQL::Types::Relay::Node - field :field_counter, FieldCounter, null: false + field :field_counter, FieldCounter, null: false, resolve_legacy_instance_method: true def field_counter; self.class.generate_tag(context); end - field :calls, Integer, null: false do - argument :expected, Integer, required: true + field :calls, Integer, null: false, resolve_legacy_instance_method: true do + argument :expected, Integer end def calls(expected:) @@ -137,66 +144,69 @@ def interpreter_context_for(key) base_ctx_value = context[key] interpreter_ctx_value = context.namespace(:interpreter)[key] if base_ctx_value != interpreter_ctx_value - raise "Context mismatch for #{key} -> #{base_ctx_value} / intepreter: #{interpreter_ctx_value}" + raise "Context mismatch for #{key} -> #{base_ctx_value} / interpreter: #{interpreter_ctx_value}" else base_ctx_value end end end - class Query < GraphQL::Schema::Object + class Query < BaseObject # Try a root-level authorized hook that returns a lazy value def self.authorized?(obj, ctx) Box.new(value: true) end - field :card, Card, null: true do - argument :name, String, required: true + field :card, Card, resolve_legacy_instance_method: true do + argument :name, String end def card(name:) Box.new(value: CARDS.find { |c| c.name == name }) end - field :expansion, Expansion, null: true do - argument :sym, String, required: true + field :expansion, Expansion, resolve_legacy_instance_method: true do + argument :sym, String end def expansion(sym:) EXPANSIONS.find { |e| e.sym == sym } end - field :expansion_raw, Expansion, null: false + field :expansion_raw, Expansion, null: false, resolve_legacy_instance_method: true def expansion_raw raw_value(sym: "RAW", name: "Raw expansion", always_cached_value: 42) end - field :expansion_mixed, [Expansion], null: false + field :expansion_mixed, [Expansion], null: false, resolve_legacy_instance_method: true def expansion_mixed expansions + [expansion_raw] end - field :expansions, [Expansion], null: false + field :expansions, [Expansion], null: false, resolve_legacy_instance_method: true def expansions EXPANSIONS end + class ExpansionData < OpenStruct + end + CARDS = [ OpenStruct.new(name: "Dark Confidant", colors: ["BLACK"], expansion_sym: "RAV"), ] EXPANSIONS = [ - OpenStruct.new(name: "Ravnica, City of Guilds", sym: "RAV"), + ExpansionData.new(name: "Ravnica, City of Guilds", sym: "RAV"), # This data has an error, for testing null propagation - OpenStruct.new(name: nil, sym: "XYZ"), + ExpansionData.new(name: nil, sym: "XYZ"), # This is not allowed by .authorized?, - OpenStruct.new(name: nil, sym: "NOPE"), + ExpansionData.new(name: nil, sym: "NOPE"), ] - field :find, [Entity], null: false do - argument :id, [ID], required: true + field :find, [Entity], null: false, resolve_legacy_instance_method: true do + argument :id, [ID] end def find(id:) @@ -206,45 +216,79 @@ def find(id:) end end - field :find_many, [Entity, null: true], null: false do - argument :ids, [ID], required: true + field :find_many, [Entity, null: true], null: false, resolve_legacy_instance_method: true do + argument :ids, [ID] end def find_many(ids:) find(id: ids).map { |e| Box.new(value: e) } end - field :field_counter, FieldCounter, null: false + field :field_counter, FieldCounter, null: false, resolve_legacy_instance_method: true def field_counter; FieldCounter.generate_tag(context) ; end - add_field(GraphQL::Types::Relay::NodeField) - add_field(GraphQL::Types::Relay::NodesField) + include GraphQL::Types::Relay::HasNodeField + include GraphQL::Types::Relay::HasNodesField + + class NestedQueryResult < BaseObject + field :result, String + field :current_path, [String] + end + + field :nested_query, NestedQueryResult, resolve_legacy_instance_method: true do + argument :query, String + end + + def nested_query(query:) + result = context.schema.multiplex([{query: query}], context: { allow_pending_thread_state: true }).first + { + result: JSON.dump(result), + current_path: context[:current_path], + } + end end - class Counter < GraphQL::Schema::Object - field :value, Integer, null: false - field :lazy_value, Integer, null: false + class Counter < BaseObject + field :value, Integer, null: false, resolve_each: true + + def self.value(object, context) + object[:counter].value + end + + def value + self.class.value(object, context) + end + + field :lazy_value, Integer, null: false, resolve_legacy_instance_method: true def lazy_value - Box.new { object.value } + Box.new { object[:counter].value } end - field :increment, Counter, null: false + field :incremented_value, Integer, hash_key: :incremented_value + + field :increment, Counter, null: false, resolve_legacy_instance_method: true def increment - object.value += 1 - object + counter = object[:counter] + v = counter.value += 1 + { + counter: counter, + incremented_value: v, + } end end - - class Mutation < GraphQL::Schema::Object - field :increment_counter, Counter, null: false + class Mutation < BaseObject + field :increment_counter, Counter, null: false, resolve_legacy_instance_method: true def increment_counter counter = context[:counter] - counter.value += 1 - counter + v = counter.value += 1 + { + counter: counter, + incremented_value: v + } end end @@ -252,17 +296,55 @@ class Schema < GraphQL::Schema query(Query) mutation(Mutation) lazy_resolve(Box, :value) + use GraphQL::Schema::AlwaysVisible def self.object_from_id(id, ctx) OpenStruct.new(id: id) end + def self.id_from_object(obj, type, ctx) + obj.id + end + def self.resolve_type(type, obj, ctx) FieldCounter end + + class EnsureArgsAreObject + def self.trace(event, data) + case event + when "execute_field", "execute_field_lazy" + args = data[:query].context[:current_arguments] + if !args.is_a?(GraphQL::Execution::Interpreter::Arguments) + raise "Expected arguments object, got #{args.class}: #{args.inspect}" + end + end + yield + end + end + tracer EnsureArgsAreObject + + module EnsureThreadCleanedUp + def execute_multiplex(multiplex:) + res = super + runtime_info = Fiber[:__graphql_runtime_info] + if !runtime_info.nil? && runtime_info != {} + if !multiplex.context[:allow_pending_thread_state] + # `nestedQuery` can allow this + raise "Query did not clean up runtime state, found: #{runtime_info.inspect}" + end + end + res + end + end + trace_with(EnsureThreadCleanedUp) end end + def exec_query(...) + InterpreterTest::Schema.execute(...) + end + it "runs a query" do query_string = <<-GRAPHQL query($expansion: String!, $id1: ID!, $id2: ID!){ @@ -299,7 +381,7 @@ def self.resolve_type(type, obj, ctx) GRAPHQL vars = {expansion: "RAV", id1: "Dark Confidant", id2: "RAV"} - result = InterpreterTest::Schema.execute(query_string, variables: vars) + result = exec_query(query_string, variables: vars) assert_equal ["BLACK"], result["data"]["card"]["colors"] assert_equal "Ravnica, City of Guilds", result["data"]["card"]["expansion"]["name"] assert_equal [{"name" => "Dark Confidant"}], result["data"]["card"]["expansion"]["cards"] @@ -309,34 +391,44 @@ def self.resolve_type(type, obj, ctx) {"__typename" => "Expansion", "sym" => "RAV"}, ] assert_equal expected_abstract_list, result["data"]["find"] + assert_nil Fiber[:__graphql_runtime_info] + end + + it "runs a nested query and maintains proper state" do + exec_next_TODO "requires context[:current_path]" + query_str = "query($queryStr: String!) { nestedQuery(query: $queryStr) { result currentPath } }" + result = exec_query(query_str, variables: { queryStr: "{ __typename }" }) + assert_equal '{"data":{"__typename":"Query"}}', result["data"]["nestedQuery"]["result"] + assert_equal ["nestedQuery"], result["data"]["nestedQuery"]["currentPath"] + assert_nil Fiber[:__graphql_runtime_info] end it "runs mutation roots atomically and sequentially" do query_str = <<-GRAPHQL mutation { i1: incrementCounter { value lazyValue - i2: increment { value lazyValue } - i3: increment { value lazyValue } + i2: increment { value incrementedValue lazyValue } + i3: increment { value incrementedValue lazyValue } } - i4: incrementCounter { value lazyValue } - i5: incrementCounter { value lazyValue } + i4: incrementCounter { value incrementedValue lazyValue } + i5: incrementCounter { value incrementedValue lazyValue } } GRAPHQL - result = InterpreterTest::Schema.execute(query_str, context: { counter: OpenStruct.new(value: 0) }) + result = exec_query(query_str, context: { counter: OpenStruct.new(value: 0) }) expected_data = { "i1" => { "value" => 1, # All of these get `3` as lazy value. They're resolved together, # since they aren't _root_ mutation fields. "lazyValue" => 3, - "i2" => { "value" => 2, "lazyValue" => 3 }, - "i3" => { "value" => 3, "lazyValue" => 3 }, + "i2" => { "value" => 2, "incrementedValue" => 2, "lazyValue" => 3 }, + "i3" => { "value" => 3, "incrementedValue" => 3, "lazyValue" => 3 }, }, - "i4" => { "value" => 4, "lazyValue" => 4}, - "i5" => { "value" => 5, "lazyValue" => 5}, + "i4" => { "value" => 4, "incrementedValue" => 4, "lazyValue" => 4}, + "i5" => { "value" => 5, "incrementedValue" => 5, "lazyValue" => 5}, } - assert_equal expected_data, result["data"] + assert_graphql_equal expected_data, result["data"] end it "runs skip and include" do @@ -352,26 +444,20 @@ def self.resolve_type(type, obj, ctx) GRAPHQL vars = {truthy: true, falsey: false} - result = InterpreterTest::Schema.execute(query_str, variables: vars) + result = exec_query(query_str, variables: vars) expected_data = { "exp2" => {"name" => "Ravnica, City of Guilds"}, "exp3" => {"name" => "Ravnica, City of Guilds"}, "exp5" => {"name" => "Ravnica, City of Guilds"}, } - assert_equal expected_data, result["data"] - end - - describe "temporary interpreter flag" do - it "is set" do - # This can be removed later, just a sanity check during migration - res = InterpreterTest::Schema.execute("{ __typename }") - assert_equal true, res.context.interpreter? - end + assert_graphql_equal expected_data, result["data"] + assert_nil Fiber[:__graphql_runtime_info] end describe "runtime info in context" do it "is available" do - res = InterpreterTest::Schema.execute <<-GRAPHQL + exec_next_TODO "requires runtime info" + res = exec_query <<-GRAPHQL { fieldCounter { runtimeInfo(a: 1, b: 2) @@ -390,6 +476,15 @@ def self.resolve_type(type, obj, ctx) end end + describe "when a field is missing and validate: false" do + it "raises a useful error" do + err = assert_raises GraphQL::Error do + exec_query("{ nonsense }", validate: false) + end + assert_equal "No field definition found for Query.nonsense (at [1, 3])", err.message + end + end + describe "null propagation" do it "propagates nulls" do query_str = <<-GRAPHQL @@ -402,11 +497,25 @@ def self.resolve_type(type, obj, ctx) } GRAPHQL - res = InterpreterTest::Schema.execute(query_str) + res = exec_query(query_str) # Although the expansion was found, its name of `nil` # propagated to here assert_nil res["data"].fetch("expansion") assert_equal ["Cannot return null for non-nullable field Expansion.name"], res["errors"].map { |e| e["message"] } + assert_nil Fiber[:__graphql_runtime_info] + end + + it "places errors ahead of data in the response" do + query_str = <<-GRAPHQL + { + expansion(sym: "XYZ") { + name + } + } + GRAPHQL + + res = exec_query(query_str) + assert_equal ["errors", "data"], res.keys end it "propagates nulls in lists" do @@ -420,13 +529,13 @@ def self.resolve_type(type, obj, ctx) } GRAPHQL - res = InterpreterTest::Schema.execute(query_str) + res = exec_query(query_str) # A null in one of the list items removed the whole list assert_nil(res["data"]) end it "works with unions that fail .authorized?" do - res = InterpreterTest::Schema.execute <<-GRAPHQL + res = exec_query <<-GRAPHQL { find(id: "NOPE") { ... on Expansion { @@ -435,11 +544,12 @@ def self.resolve_type(type, obj, ctx) } } GRAPHQL - assert_equal ["Cannot return null for non-nullable field Query.find"], res["errors"].map { |e| e["message"] } + + assert_equal ["Cannot return null for non-nullable element of type 'Entity!' for Query.find"], res["errors"].map { |e| e["message"] } end it "works with lists of unions" do - res = InterpreterTest::Schema.execute <<-GRAPHQL + res = exec_query <<-GRAPHQL { findMany(ids: ["RAV", "NOPE", "BOGUS"]) { ... on Expansion { @@ -451,30 +561,13 @@ def self.resolve_type(type, obj, ctx) assert_equal 3, res["data"]["findMany"].size assert_equal "RAV", res["data"]["findMany"][0]["sym"] - assert_equal nil, res["data"]["findMany"][1] - assert_equal nil, res["data"]["findMany"][2] + assert_nil res["data"]["findMany"][1] + assert_nil res["data"]["findMany"][2] assert_equal false, res.key?("errors") assert_equal Hash, res["data"].class assert_equal Array, res["data"]["findMany"].class end - - it "works with union lists that have members of different kinds, with different nullabilities" do - res = InterpreterTest::Schema.execute <<-GRAPHQL - { - findMany(ids: ["RAV", "Dark Confidant"]) { - ... on Expansion { - nullUnionFieldTest - } - ... on Card { - nullUnionFieldTest - } - } - } - GRAPHQL - - assert_equal [1, nil], res["data"]["findMany"].map { |f| f["nullUnionFieldTest"] } - end end describe "duplicated fields" do @@ -502,17 +595,17 @@ def self.resolve_type(type, obj, ctx) GRAPHQL # It will raise an error if it doesn't match the expectation - res = InterpreterTest::Schema.execute(query_str, context: { calls: 0 }) + res = exec_query(query_str, context: { calls: 0 }) assert_equal 3, res["data"]["fieldCounter"]["fieldCounter"]["c3"] end end describe "backwards compatibility" do it "handles a legacy nodes field" do - res = InterpreterTest::Schema.execute('{ node(id: "abc") { id } }') + res = exec_query('{ node(id: "abc") { id } }') assert_equal "abc", res["data"]["node"]["id"] - res = InterpreterTest::Schema.execute('{ nodes(ids: ["abc", "xyz"]) { id } }') + res = exec_query('{ nodes(ids: ["abc", "xyz"]) { id } }') assert_equal ["abc", "xyz"], res["data"]["nodes"].map { |n| n["id"] } end end @@ -529,7 +622,7 @@ def self.resolve_type(type, obj, ctx) } GRAPHQL - res = InterpreterTest::Schema.execute(query_str) + res = exec_query(query_str) assert_equal({ sym: "RAW", name: "Raw expansion", always_cached_value: 42 }, res["data"]["expansionRaw"]) end end @@ -546,7 +639,7 @@ def self.resolve_type(type, obj, ctx) } GRAPHQL - res = InterpreterTest::Schema.execute(query_str) + res = exec_query(query_str) assert_equal({ sym: "RAW", name: "Raw expansion", always_cached_value: 42 }, res["data"]["expansionRaw"]) end end @@ -557,18 +650,18 @@ class Query < GraphQL::Schema::Object def self.authorized?(obj, ctx) -> { true } end - field :skip, String, null: true + field :skip, String, resolve_legacy_instance_method: true def skip context.skip end - field :lazy_skip, String, null: true + field :lazy_skip, String, resolve_legacy_instance_method: true def lazy_skip -> { context.skip } end - field :mixed_skips, [String], null: true + field :mixed_skips, [String], resolve_legacy_instance_method: true def mixed_skips [ "a", @@ -581,7 +674,7 @@ def mixed_skips end class NothingSubscription < GraphQL::Schema::Subscription - field :nothing, String, null: true + field :nothing, String, hash_key: :nothing def authorized?(*) -> { true } end @@ -624,6 +717,114 @@ class Subscription < GraphQL::Schema::Object end end + describe "GraphQL::ExecutionErrors from connection fields" do + module ConnectionErrorTest + class BaseField < GraphQL::Schema::Field + def authorized?(obj, args, ctx) + ctx[:authorized_calls] ||= 0 + ctx[:authorized_calls] += 1 + raise GraphQL::ExecutionError, "#{name} is not authorized" + end + end + + class BaseConnection < GraphQL::Types::Relay::BaseConnection + node_nullable(false) + edge_nullable(false) + edges_nullable(false) + end + + class BaseEdge < GraphQL::Types::Relay::BaseEdge + node_nullable(false) + end + + class Thing < GraphQL::Schema::Object + field_class BaseField + connection_type_class BaseConnection + edge_type_class BaseEdge + field :title, String, null: false, hash_key: :title + field :body, String, null: false, hash_key: :body + end + + class Query < GraphQL::Schema::Object + field :things, Thing.connection_type, resolve_static: true + field :other_things, Thing.connection_type, resolve_static: :things + field :non_null_things, Thing.connection_type, null: false, resolve_static: :things + field :non_null_other_things, Thing.connection_type, null: false, resolve_static: :things + + def self.things(context) + [{title: "a"}, {title: "b"}, {title: "c"}] + end + + def things + self.class.things(context) + end + + def other_things + self.class.things(context) + end + + def non_null_other_things + self.class.things(context) + end + + def non_null_things + self.class.things(context) + end + + field :thing, Thing, null: false, resolve_static: true + + def self.thing(context) + { + title: "a", + body: "b", + } + end + + def thing + self.class.things(context) + end + end + + class Schema < GraphQL::Schema + query Query + end + end + + it "works on different branches" do + res = ConnectionErrorTest::Schema.execute("{ things { nodes { title } } otherThings { nodes { title } } }") + assert_equal({ "things" => nil, "otherThings" => nil }, res["data"]) + assert_equal [["things", "nodes", 0, "title"], ["otherThings", "nodes", 0, "title"]], res["errors"].map { |e| e["path"] } + assert_equal 2, res.context[:authorized_calls] + end + + it "Does non-null propagation across branches" do + res = ConnectionErrorTest::Schema.execute("{ nonNullThings { nodes { title } } nonNullOtherThings { nodes { title } } }") + assert_nil res.fetch("data") + expected_error_paths = if_exec_next([ + ["nonNullThings", "nodes", 0, "title"] + ], [ + ["nonNullThings", "nodes", 0, "title"], + ["nonNullOtherThings", "nodes", 0, "title"] + ]) + assert_equal expected_error_paths, res["errors"].map { |e| e["path"] } + assert_equal if_exec_next(1, 2), res.context[:authorized_calls] + end + + it "returns only 1 error and stops resolving fields after that" do + res = ConnectionErrorTest::Schema.execute("{ things { nodes { title } } }") + assert_equal [["things", "nodes", 0, "title"]], res["errors"].map { |e| e["path"] } + assert_equal 1, res.context[:authorized_calls] + + res = ConnectionErrorTest::Schema.execute("{ things { edges { node { title } } } }") + assert_equal [["things", "edges", 0, "node", "title"]], res["errors"].map { |e| e["path"] } + assert_equal 1, res.context[:authorized_calls] + + res = ConnectionErrorTest::Schema.execute("{ thing { title body } }") + assert_equal [["thing", "title"]], res["errors"].map { |e| e["path"] } + assert_equal 1, res.context[:authorized_calls] + end + end + describe "GraphQL::ExecutionErrors from non-null list fields" do module ListErrorTest class BaseField < GraphQL::Schema::Field @@ -634,15 +835,19 @@ def authorized?(*) class Thing < GraphQL::Schema::Object field_class BaseField - field :title, String, null: false + field :title, String, null: false, hash_key: :title end class Query < GraphQL::Schema::Object - field :things, [Thing], null: false + field :things, [Thing], null: false, resolve_static: true - def things + def self.things(context) [{title: "a"}, {title: "b"}, {title: "c"}] end + + def things + self.class.things(context) + end end class Schema < GraphQL::Schema @@ -665,31 +870,47 @@ module Iface end class Txn < GraphQL::Schema::Object - field :fails, String, null: false + field :fails, String, null: false, resolve_static: true - def fails + def self.fails(context) raise GraphQL::ExecutionError, "boom" end + + def fails + self.class.fails(context) + end end class Concrete < GraphQL::Schema::Object implements Iface - field :txn, Txn, null: true + field :txn, Txn, resolve_static: true + + def self.txn(context) + {} + end def txn {} end - field :msg, String, null: true + field :msg, String, resolve_static: true - def msg + def self.msg(context) "THIS SHOULD SHOW UP" end + + def msg + self.class.msg(context) + end end class Query < GraphQL::Schema::Object - field :iface, Iface, null: true + field :iface, Iface, resolve_static: true + + def self.iface(context) + {} + end def iface {} @@ -720,9 +941,6 @@ def self.resolve_type(type, obj, ctx) result = RaisedErrorSchema.execute(querystring) expected_result = { - "data" => { - "iface" => { "txn" => nil, "msg" => "THIS SHOULD SHOW UP" }, - }, "errors" => [ { "message"=>"boom", @@ -730,8 +948,250 @@ def self.resolve_type(type, obj, ctx) "path"=>["iface", "txn", "fails"] }, ], + "data" => { + "iface" => { "txn" => nil, "msg" => "THIS SHOULD SHOW UP" }, + }, + } + assert_graphql_equal expected_result, result.to_h + end + end + + it "supports extras: [:parent]" do + exec_next_WONTFIX "Not possible in batching" + + query_str = <<-GRAPHQL + { + card(name: "Dark Confidant") { + parentClassName + } + expansion(sym: "RAV") { + cards { + parentClassName + } + } + } + GRAPHQL + res = exec_query(query_str, context: { calls: 0 }) + + assert_equal "NilClass", res["data"]["card"].fetch("parentClassName") + assert_equal "InterpreterTest::Query::ExpansionData", res["data"]["expansion"]["cards"].first["parentClassName"] + end + + describe "fragment used twice in different ways" do + class FragmentBugSchema < GraphQL::Schema + class ProductVariant < GraphQL::Schema::Object + field :product, "FragmentBugSchema::Product", hash_key: :product + end + + class Product < GraphQL::Schema::Object + field :id, ID, hash_key: :id + field :variants, [ProductVariant], resolve_static: true + + def self.variants(context) + [{ product: { id: "1" } }] + end + + def variants + self.class.variants(context) + end + end + + class Query < GraphQL::Schema::Object + field :variant, ProductVariant, resolve_static: true + + def self.variant(context) + { product: { id: "1" } } + end + + def variant + self.class.variant(context) + end + end + + query(Query) + end + + it "executes successfully" do + query_str = <<-GRAPHQL + { + variant { + ...variantFields + ... on ProductVariant { + product { + variants { + ...variantFields + } + } + } + } + } + + fragment variantFields on ProductVariant { + product { + id + } + } + GRAPHQL + + res = FragmentBugSchema.execute(query_str).to_h + + expected_result = { "variant" => { "product" => { "id" => "1", "variants" => [ { "product" => { "id" => "1" } } ] } } } + assert_equal(expected_result, res["data"]) + end + end + + describe "multiplex queries" do + def exec_multiplex(...) + InterpreterTest::Schema.multiplex(...) + end + + it "runs multiplex queries" do + result = exec_multiplex([ + { + query: "query Card($name: String!) { card(name: $name) { colors } }", + variables: { name: "Dark Confidant" }, + operation_name: "Card" + }, + { + query: "query Expansion($expansion: String!) { expansion(sym: $expansion) { cards { name } } }", + variables: { expansion: "RAV" }, + operation_name: "Expansion" + } + ]) + + assert_equal ["BLACK"], result[0]["data"]["card"]["colors"] + assert_equal [{"name" => "Dark Confidant"}], result[1]["data"]["expansion"]["cards"] + assert_nil Fiber[:__graphql_runtime_info] + end + end + + describe "when execution raises SystemStackError" do + class StackErrorSchema < GraphQL::Schema + class Query < GraphQL::Schema::Object + field :crash, String + + def crash + raise SystemStackError, "stack level too deep" + end + end + + query(Query) + + def self.query_stack_error(query, err) + query.context[:stack_error] = err + super + end + end + + it "returns a query error" do + result = GraphQL::Execution::Interpreter.run_all( + StackErrorSchema, + [{ query: "{ crash }" }] + ).first + + assert_instance_of SystemStackError, result.context[:stack_error] + assert_equal \ + [{ "message" => "This query is too large to execute." }], + result["errors"] + end + end + + describe "list items with dataloader and current_path usage" do + class ListBugExampleSchema < GraphQL::Schema + class PathTest < GraphQL::Schema::Directive + locations(GraphQL::Schema::Directive::INLINE_FRAGMENT) + + def self.resolve(object, arguments, context) + context[:test_paths] ||= [] + context[:test_paths] << context[:current_path] + super + end + end + + class DataloadedSource < GraphQL::Dataloader::Source + def fetch(objects) + objects.map(&:dataloaded) + end + end + + class DataloadedType < GraphQL::Schema::Object + field :int, Integer + end + + class SiblingType < GraphQL::Schema::Object + field :dataloaded, DataloadedType, null: false + + def dataloaded + dataload(DataloadedSource, object) + end + end + + + class ChildType < GraphQL::Schema::Object + field :int, Integer, null: false + end + + class ParentType < GraphQL::Schema::Object + field :children, [ChildType], null: false + end + + class Query < GraphQL::Schema::Object + field :parent, ParentType do + argument :name, String + end + + def parent(name:) + object.parent + end + + field :siblings, [SiblingType], null: false + end + + query(Query) + use GraphQL::Dataloader + directive PathTest + end + + it "correctly provides current_type at selections-level" do + exec_next_TODO("No context[:current_type] in exec-next") + query_str = <<~GRAPHQL + query { + parent(name: "ABC") { + children { + ... @pathTest { + int + } + } + } + siblings { + dataloaded { + int + } + } } - assert_equal expected_result, result.to_h + GRAPHQL + + root_value = OpenStruct.new( + parent: OpenStruct.new( + children: [ + OpenStruct.new(int: 1), + ] + ), + siblings: [ + OpenStruct.new(dataloaded: OpenStruct.new(int: 2)), + ] + ) + + result = ListBugExampleSchema.execute(query_str, root_value: root_value) + expected_result = { + "data" => { + "parent" => {"children" => [{"int" => 1}]}, + "siblings" => [{"dataloaded" => {"int" => 2}}] + } + } + + assert_graphql_equal expected_result, result + assert_equal [["parent", "children", 0]], result.context[:test_paths] end end end diff --git a/spec/graphql/execution/lazy_spec.rb b/spec/graphql/execution/lazy_spec.rb index 59850da3453..3221792e4ba 100644 --- a/spec/graphql/execution/lazy_spec.rb +++ b/spec/graphql/execution/lazy_spec.rb @@ -4,6 +4,10 @@ describe GraphQL::Execution::Lazy do include LazyHelpers + before do + LazyHelpers::SumAll.all.clear + end + describe "resolving" do it "calls value handlers" do res = run_query('{ int(value: 2, plus: 1) }') @@ -74,7 +78,7 @@ ], } - assert_equal expected_data, res["data"] + assert_graphql_equal expected_data, res["data"] end [ @@ -101,7 +105,7 @@ end end - it "Handles fields that return nil" do + it "Handles fields that return nil and batches lazy resultion across depths when possible" do values = [ LazyHelpers::MAGIC_NUMBER_THAT_RETURNS_NIL, LazyHelpers::MAGIC_NUMBER_WITH_LAZY_AUTHORIZED_HOOK, @@ -136,7 +140,7 @@ } }| - assert_equal(nil, res["data"]) + assert_nil(res["data"]) assert_equal 1, res["errors"].length end @@ -176,7 +180,7 @@ "b" => nil, "c" => { "value" => 3 }, } - assert_equal expected_data, res["data"] + assert_graphql_equal expected_data, res["data"] expected_errors = [{ "message"=>"13 is unlucky", @@ -241,4 +245,119 @@ class SubWrapper < LazyHelpers::Wrapper; end assert_equal(:value, map.get(s)) end end + + describe "Interface.resolve_type" do + class LazyResolveTypeSchema < GraphQL::Schema + class Loader + LOG = [] + DATA = { + 1 => { versionable: 3 }, + 2 => { versionable: 4 }, + 3 => { foo: "foo" }, + 4 => { bar: "bar" }, + } + + def initialize(loading_key) + @loading_key = loading_key + @loading_ids = Set.new + @loaded = {} + end + + def self.for(context, loading_key) + l_cache = context[:loader_cache] ||= Hash.new { |h, k| h[k] = Loader.new(k) } + l_cache[loading_key] + end + + def load(id) + @loading_ids.add(id) + -> { + resolve + result = @loaded.fetch(id) + if block_given? + yield(result) + else + result + end + } + end + + def resolve + if !@loading_ids.empty? + Loader::LOG << [@loading_key, @loading_ids.to_a] + @loading_ids.to_a.each do |id| + @loaded[id] = DATA[id] + end + @loading_ids.clear + end + end + end + + module Version + include GraphQL::Schema::Interface + + def self.resolve_type(obj, ctx) + Loader.for(ctx, :versionable).load(obj[:versionable]) do |versionable| + [(versionable[:foo] ? FooVersionable : BarVersionable), versionable] + end + end + end + + class FooVersionable < GraphQL::Schema::Object + implements Version + field :foo, String, hash_key: :foo + end + + class BarVersionable < GraphQL::Schema::Object + implements Version + field :bar, String, hash_key: :bar + end + + class VersionReference < GraphQL::Schema::Object + field :version, Version, resolve_each: true + + def self.version(object, context) + Loader.for(context, :version).load(object[:version]) + end + end + class Query < GraphQL::Schema::Object + field :version_references, [VersionReference], resolve_static: true + + def self.version_references(context) + [{ version: 1 }, { version: 2 }] + end + end + + lazy_resolve(Proc, :call) + query(Query) + orphan_types FooVersionable, BarVersionable + use GraphQL::Execution::Next + end + + it "resolves lazies efficiently" do + LazyResolveTypeSchema::Loader::LOG.clear + query_str = " { + versionReferences { + version { + ... on FooVersionable { foo } + ... on BarVersionable { bar } + } + } + }" + + res = LazyResolveTypeSchema.execute_next(query_str) + expected_data = { + "versionReferences" => [ + {"version" => {"foo" => "foo"}}, + {"version" => {"bar" => "bar"}} + ] + } + + assert_equal expected_data, res["data"] + expected_log = [ + [:version, [1, 2]], + [:versionable, [3, 4]] + ] + assert_equal expected_log, LazyResolveTypeSchema::Loader::LOG + end + end end diff --git a/spec/graphql/execution/lookahead_spec.rb b/spec/graphql/execution/lookahead_spec.rb index a91ee809e30..cc0e2eeaab5 100644 --- a/spec/graphql/execution/lookahead_spec.rb +++ b/spec/graphql/execution/lookahead_spec.rb @@ -31,57 +31,98 @@ class BirdSpecies < GraphQL::Schema::Object field :name, String, null: false field :id, ID, null: false, method: :name field :is_waterfowl, Boolean, null: false - field :similar_species, [BirdSpecies], null: false + field :similar_species, [BirdSpecies], null: false, resolve_each: true - def similar_species + def self.similar_species(object, context) object.similar_species_names.map { |n| DATA.find_by_name(n) } end - field :genus, BirdGenus, null: false, + def similar_species + self.class.similar_species(object, context) + end + + field :genus, BirdGenus, null: false, resolve_each: true, extras: [:lookahead] - def genus(lookahead:) + def self.genus(object, context, lookahead:) if lookahead.selects?(:latin_name) context[:lookahead_latin_name] += 1 end object.genus end + + def genus(lookahead:) + self.class.genus(object, context, lookahead: lookahead) + end + end + + class PlantSpecies < GraphQL::Schema::Object + implements Node + field :name, String, null: false + field :id, ID, null: false, method: :name + field :is_edible, Boolean, null: false + end + + class Species < GraphQL::Schema::Union + possible_types BirdSpecies, PlantSpecies end class Query < GraphQL::Schema::Object - field :find_bird_species, BirdSpecies, null: true do - argument :by_name, String, required: true + field :find_bird_species, BirdSpecies, resolve_static: true do + argument :by_name, String end - def find_bird_species(by_name:) + def self.find_bird_species(context, by_name:) DATA.find_by_name(by_name) end - field :node, Node, null: true do - argument :id, ID, required: true + def find_bird_species(by_name:) + self.class.find_bird_species(context, by_name: by_name) + end + + field :node, Node, resolve_static: true do + argument :id, ID end - def node(id:) + def self.node(context, id:) if (node = DATA.find_by_name(id)) node else DATA.map { |d| d.genus }.select { |g| g.name == id } end end - end - class LookaheadInstrumenter - def self.before_query(query) - query.context[:root_lookahead_selections] = query.lookahead.selections + def node(id:) + self.class.node(context, id: id) + end + + field :species, Species, resolve_static: true do + argument :id, ID end - def self.after_query(q) + def self.species(context, id:) + DATA.find_by_name(id) + end + + def species(id:) + self.class.species(context, id: id) + end + end + + module LookaheadInstrumenter + def execute_query(query:) + query.context[:root_lookahead_selections] = query.lookahead.selections + super end end class Schema < GraphQL::Schema query(Query) - instrument :query, LookaheadInstrumenter + trace_with LookaheadInstrumenter + end + + class AlwaysVisibleSchema < Schema + use GraphQL::Schema::AlwaysVisible end end @@ -99,8 +140,9 @@ class Schema < GraphQL::Schema } GRAPHQL } + let(:schema) { LookaheadTest::Schema } let(:query) { - GraphQL::Query.new(LookaheadTest::Schema, document: document, variables: { name: "Cardinal" }) + GraphQL::Query.new(schema, document: document, variables: { name: "Cardinal" }) } it "has a good test setup" do @@ -120,6 +162,61 @@ class Schema < GraphQL::Schema assert_equal true, query.lookahead.selects?("__typename") end + it "uses null lookahead when no operation is selected" do + query = GraphQL::Query.new(schema, document: document, variables: { name: "Cardinal" }, operation_name: "Invalid") + assert_selection_is_null query.lookahead + end + + describe "with a NullWarden" do + let(:schema) { LookaheadTest::AlwaysVisibleSchema } + + it "works" do + lookahead = query.lookahead.selection("findBirdSpecies") + assert_equal true, lookahead.selects?("similarSpecies") + assert_equal true, lookahead.selects?(:similar_species) + assert_equal false, lookahead.selects?("isWaterfowl") + assert_equal false, lookahead.selects?(:is_waterfowl) + end + end + + describe "on unions" do + let(:document) { + GraphQL.parse <<-GRAPHQL + { + species(id: "Cardinal") { + ... on BirdSpecies { + name + isWaterfowl + } + ... on PlantSpecies { + name + isEdible + } + } + } + GRAPHQL + } + + it "works" do + lookahead = query.lookahead.selection(:species) + assert lookahead.selects?(:name) + assert_equal [:name, :is_waterfowl, :name, :is_edible], lookahead.selections.map(&:name) + end + + it "works with different selected types" do + lookahead = query.lookahead.selection(:species) + # Both have `name` + assert lookahead.selects?(:name, selected_type: LookaheadTest::BirdSpecies) + assert lookahead.selects?(:name, selected_type: LookaheadTest::PlantSpecies) + # Only birds have `isWaterfowl` + assert lookahead.selects?(:is_waterfowl, selected_type: LookaheadTest::BirdSpecies) + refute lookahead.selects?(:is_waterfowl, selected_type: LookaheadTest::PlantSpecies) + # Only plants have `isEdible` + refute lookahead.selects?(:is_edible, selected_type: LookaheadTest::BirdSpecies) + assert lookahead.selects?(:is_edible, selected_type: LookaheadTest::PlantSpecies) + end + end + describe "fields on interfaces" do let(:document) { GraphQL.parse <<-GRAPHQL @@ -278,6 +375,50 @@ class Schema < GraphQL::Schema assert res.key?("errors") assert_equal 0, context[:lookahead_latin_name] end + + describe "When there is an argument error" do + class NestedArgumentErrorSchema < GraphQL::Schema + class Data < GraphQL::Schema::Object + field :echo, String, resolve_static: true do + argument :input, String + end + + def self.echo(context, input:) + input + end + + def echo(input:) + self.class.echo(context, input: input) + end + end + + class Query < GraphQL::Schema::Object + field :data, Data, extras: [:lookahead], resolve_static: true + + def self.data(context, lookahead:) + context[:args_class] = lookahead.selection(:echo).arguments.class + {} + end + + def data(lookahead:) + self.class.data(context, lookahead: lookahead) + end + end + + query(Query) + end + + it "uses empty arguments" do + query_str = "query getEcho($input: String = null) { data { echo(input: $input) } }" + res = NestedArgumentErrorSchema.execute(query_str, variables: {}) + assert_equal ["`null` is not a valid input for `String!`, please provide a value for this argument."], res["errors"].map { |err| err["message"] } + assert_equal Hash, res.context[:args_class] + + good_res = NestedArgumentErrorSchema.execute("{ data { echo(input: \"Hello\") } }") + assert_equal "Hello", good_res["data"]["data"]["echo"] + assert_equal Hash, good_res.context[:args_class] + end + end end describe '#selections' do @@ -409,4 +550,345 @@ def query(doc = document) assert_equal false, lookahead.selects?(:name) end end + + def assert_selection_exists(selection) + assert GraphQL::Execution::Lookahead::NULL_LOOKAHEAD != selection + end + + def assert_selection_is_null(selection) + assert_equal GraphQL::Execution::Lookahead::NULL_LOOKAHEAD, selection + end + + describe "#selection" do + let(:document) { + GraphQL.parse <<-GRAPHQL + query { + findBirdSpecies(byName: "Laughing Gull") { + name + similarSpecies { + likesWater: isWaterfowl + } + } + } + GRAPHQL + } + + def query(doc = document) + GraphQL::Query.new(LookaheadTest::Schema, document: doc) + end + + it "returns selection by field name" do + ast_node = document.definitions.first.selections.first + field = LookaheadTest::Query.fields["findBirdSpecies"] + lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], field: field) + assert_selection_exists lookahead.selection("similarSpecies") + end + + describe "when same field is selected twice" do + let(:document) { + GraphQL.parse <<-GRAPHQL + query { + gull: findBirdSpecies(byName: "Laughing Gull") { + name + } + + tanager: findBirdSpecies(byName: "Scarlet Tanager") { + name + } + } + GRAPHQL + } + + let(:graphql_query) do + GraphQL::Query.new(LookaheadTest::Schema, document: document) + end + + it "returns lookahead with two ast_nodes" do + assert_equal 2, graphql_query.lookahead.selection("findBirdSpecies").ast_nodes.length + end + end + + describe "when query has alias" do + let(:document) { + GraphQL.parse <<-GRAPHQL + query { + findBirdSpecies(byName: "Laughing Gull") { + name + similar: similarSpecies { + likesWater: isWaterfowl + } + } + } + GRAPHQL + } + + let(:graphql_query) do + GraphQL::Query.new(LookaheadTest::Schema, document: document) + end + + let(:species_lookahead) do + graphql_query.lookahead.selection("findBirdSpecies") + end + + it "returns selection when field name is passed" do + assert_selection_exists species_lookahead.selection("similarSpecies") + end + + it "returns null when alias name is passed" do + assert_selection_is_null species_lookahead.selection("similar") + end + + describe "when alias has arguments" do + let(:document) { + GraphQL.parse <<-GRAPHQL + query { + gull: findBirdSpecies(byName: "Laughing Gull") { + name + } + } + GRAPHQL + } + + it "returns selection when field name is passed" do + assert_selection_exists graphql_query.lookahead.selection("findBirdSpecies") + end + + it "returns null when alias name is passed" do + assert_selection_is_null graphql_query.lookahead.selection("gull") + end + + describe "when same field is selected twice" do + let(:document) { + GraphQL.parse <<-GRAPHQL + query { + gull: findBirdSpecies(byName: "Laughing Gull") { + name + } + + tanager: findBirdSpecies(byName: "Scarlet Tanager") { + name + } + } + GRAPHQL + } + + it "returns null when alias name is passed" do + assert_selection_is_null graphql_query.lookahead.selection("gull") + assert_selection_is_null graphql_query.lookahead.selection("tanager") + end + end + end + end + end + + describe "#alias_selection" do + let(:document) { + GraphQL.parse <<-GRAPHQL + query { + findBirdSpecies(byName: "Laughing Gull") { + name + similar: similarSpecies { + likesWater: isWaterfowl + } + } + } + GRAPHQL + } + + def query(doc = document) + GraphQL::Query.new(LookaheadTest::Schema, document: doc) + end + + let(:graphql_query) do + GraphQL::Query.new(LookaheadTest::Schema, document: document) + end + + let(:species_lookahead) do + graphql_query.lookahead.selection("findBirdSpecies") + end + + describe "when alias name is passed" do + it "returns selection" do + assert_selection_exists species_lookahead.alias_selection("similar") + end + + it "returns true from selects_alias?" do + assert true, species_lookahead.selects_alias?("similar") + end + + describe "when the aliased field is deeply nested" do + it "not finds the deeply-nested alias" do + assert_equal [:name, :similar_species], species_lookahead.selections.map(&:name) + assert_equal false, species_lookahead.selects_alias?("likesWater") + end + end + end + + describe "when the same field is executed with the same arguments but different aliases" do + let(:document) { + GraphQL.parse <<-GRAPHQL + query { + egret: findBirdSpecies(byName: "Great Egret") { + isWaterfowl + } + otherEgret: findBirdSpecies(byName: "Great Egret") { + name + } + findBirdSpecies(byName: "Great Egret") { + __typename + } + } + GRAPHQL + } + + it "distinguishes between the aliased fields" do + lookahead = query.lookahead + assert_equal [:is_waterfowl], lookahead.alias_selection("egret").selections.map(&:name) + assert_equal [:name], lookahead.alias_selection("otherEgret").selections.map(&:name) + assert_equal [], lookahead.alias_selection("findBirdSpecies").selections.map(&:name) + end + + it "filters aliased fields by arguments" do + lookahead = query.lookahead + # No `arguments:` performs no filtering + assert_equal [:is_waterfowl], lookahead.alias_selection("egret").selections.map(&:name) + # Matching arguments filters to the expected field: + assert_equal [:is_waterfowl], lookahead.alias_selection("egret", arguments: {by_name: "Great Egret"}).selections.map(&:name) + # Empty `arguments:` matches nothing: + assert_equal [], lookahead.alias_selection("egret", arguments: {}).selections.map(&:name) + # Mismatching `arguments:` filters to nothing: + assert_equal [], lookahead.alias_selection("egret", arguments: {by_name: "Macaw"}).selections.map(&:name) + end + end + + describe "when field name is passed" do + it "returns null_lookahead" do + assert_selection_is_null species_lookahead.alias_selection("similarSpecies") + end + + it "returns false from selects_alias?" do + assert_equal false, species_lookahead.selects_alias?("similarSpecies") + end + end + + describe "when alias is inside fragment" do + let(:document) { + GraphQL.parse <<-GRAPHQL + fragment BirdSpeciesFragment on BirdSpecies { + name + similar: similarSpecies { + likesWater: isWaterfowl + } + } + + query { + findBirdSpecies(byName: "Laughing Gull") { + ...BirdSpeciesFragment + } + } + GRAPHQL + } + + it "returns selection" do + assert_selection_exists species_lookahead.alias_selection("similar") + end + + it "returns true from selects_alias?" do + assert true, species_lookahead.selects_alias?("similar") + end + + describe "when fragment name is wrong" do + let(:document) { + GraphQL.parse <<-GRAPHQL + query { + findBirdSpecies(byName: "Laughing Gull") { + ...WrongFragment + } + } + GRAPHQL + } + + it "raises error" do + assert_raises(RuntimeError) { + species_lookahead.selects_alias?("similar") + } + end + end + end + + describe "when alias is inside inline fragment" do + let(:document) { + GraphQL.parse <<-GRAPHQL + query { + findBirdSpecies(byName: "Laughing Gull") { + ...on BirdSpecies { + name + similar: similarSpecies { + likesWater: isWaterfowl + } + } + } + } + GRAPHQL + } + + it "returns selection" do + assert_selection_exists species_lookahead.alias_selection("similar") + end + + it "returns true from selects_alias?" do + assert true, species_lookahead.selects_alias?("similar") + end + end + + describe "when alias has arguments" do + let(:document) { + GraphQL.parse <<-GRAPHQL + query { + gull: findBirdSpecies(byName: "Laughing Gull") { + name + } + } + GRAPHQL + } + + it "returns selection" do + assert_selection_exists graphql_query.lookahead.alias_selection("gull") + end + + it "returns true from selects_alias?" do + assert true, graphql_query.lookahead.selects_alias?("gull") + end + + describe "when same field is selected twice" do + let(:document) { + GraphQL.parse <<-GRAPHQL + query { + gull: findBirdSpecies(byName: "Laughing Gull") { + name + } + + tanager: findBirdSpecies(byName: "Scarlet Tanager") { + name + } + } + GRAPHQL + } + + it "returns selection when alias name is passed" do + graphql_query.lookahead.alias_selection("gull", arguments: { by_name: "Laughing Gull" }).tap do |selection| + assert_selection_exists selection + assert_equal({ by_name: "Laughing Gull" }, selection.arguments) + assert_equal 1, selection.ast_nodes.length + end + + graphql_query.lookahead.alias_selection("tanager", arguments: { by_name: "Scarlet Tanager" }).tap do |selection| + assert_selection_exists selection + assert_equal({ by_name: "Scarlet Tanager" }, selection.arguments) + assert_equal 1, selection.ast_nodes.length + end + end + end + end + end end diff --git a/spec/graphql/execution/multiplex_spec.rb b/spec/graphql/execution/multiplex_spec.rb index 6e8940768db..bf555116787 100644 --- a/spec/graphql/execution/multiplex_spec.rb +++ b/spec/graphql/execution/multiplex_spec.rb @@ -50,7 +50,7 @@ def multiplex(*a, **kw) ] res = multiplex(queries) - assert_equal expected_data, res + assert_graphql_equal expected_data, res end it "returns responses in the same order as their respective requests" do @@ -105,16 +105,22 @@ def multiplex(*a, **kw) "data"=>{"success"=>{"value"=>2}} }, { - "data"=>{"runtimeError"=>nil}, "errors"=>[{ "message"=>"13 is unlucky", "locations"=>[{"line"=>1, "column"=>4}], "path"=>["runtimeError"] - }] + }], + "data"=>{"runtimeError"=>nil}, }, { + "errors"=>[ + { + "message"=>"Cannot return null for non-nullable field LazySum.nestedSum", + "path"=>["invalidNestedNull", "nullableNestedSum", "nestedSum"], + "locations"=>[{"line"=>5, "column"=>11}], + }, + ], "data"=>{"invalidNestedNull"=>{"value" => 2,"nullableNestedSum" => nil}}, - "errors"=>[{"message"=>"Cannot return null for non-nullable field LazySum.nestedSum"}], }, { "errors" => [{ @@ -132,7 +138,7 @@ def multiplex(*a, **kw) {query: q3}, {query: q4}, ]) - assert_equal expected_res, res.map(&:to_h) + assert_graphql_equal expected_res, res.map(&:to_h) end end @@ -171,55 +177,71 @@ def multiplex(*a, **kw) end end - describe "after_query when errors are raised" do - class InspectQueryInstrumentation - class << self - attr_reader :last_json - def before_query(query) - end + describe "execute_query when errors are raised" do + module InspectQueryInstrumentation + def execute_multiplex(multiplex:) + super + ensure + InspectQueryInstrumentation.last_json = multiplex.queries.first.result.to_json + end - def after_query(query) - @last_json = query.result.to_json - end + class << self + attr_accessor :last_json end end class InspectSchema < GraphQL::Schema class Query < GraphQL::Schema::Object - field :raise_execution_error, String, null: true + field :raise_execution_error, String, resolve_static: true - def raise_execution_error + def self.raise_execution_error(context) raise GraphQL::ExecutionError, "Whoops" end - field :raise_error, String, null: true + def raise_execution_error + self.class.raise_execution_error(context) + end - def raise_error + field :raise_error, String, resolve_static: true + + def self.raise_error(context) raise GraphQL::Error, "Crash" end - field :raise_syntax_error, String, null: true + def raise_error + self.class.raise_error(context) + end - def raise_syntax_error + field :raise_syntax_error, String, resolve_static: true + + def self.raise_syntax_error(context) raise SyntaxError end - field :raise_exception, String, null: true + def raise_syntax_error + self.class.raise_syntax_error(context) + end - def raise_exception + field :raise_exception, String, resolve_static: true + + def self.raise_exception(context) raise Exception end + + def raise_exception + self.class.raise_exception(context) + end end query(Query) - instrument(:query, InspectQueryInstrumentation) + trace_with(InspectQueryInstrumentation) end unhandled_err_json = '{}' it "can access the query results" do InspectSchema.execute("{ raiseExecutionError }") - handled_err_json = '{"data":{"raiseExecutionError":null},"errors":[{"message":"Whoops","locations":[{"line":1,"column":3}],"path":["raiseExecutionError"]}]}' + handled_err_json = '{"errors":[{"message":"Whoops","locations":[{"line":1,"column":3}],"path":["raiseExecutionError"]}],"data":{"raiseExecutionError":null}}' assert_equal handled_err_json, InspectQueryInstrumentation.last_json @@ -242,4 +264,43 @@ def raise_exception assert_equal unhandled_err_json, InspectQueryInstrumentation.last_json end end + + describe "context[:trace]" do + class MultiplexTraceSchema < GraphQL::Schema + class Query < GraphQL::Schema::Object + field :int, Integer, resolve_static: true + def self.int(context); 1; end + + def int; 1; end + end + + class Trace < GraphQL::Tracing::Trace + def execute_multiplex(multiplex:) + @execute_multiplex_count ||= 0 + @execute_multiplex_count += 1 + super + end + + def execute_query(query:) + @execute_query_count ||= 0 + @execute_query_count += 1 + super + end + + attr_reader :execute_multiplex_count, :execute_query_count + end + + query(Query) + end + + it "uses it instead of making a new trace" do + query_str = "{ int }" + trace_instance = MultiplexTraceSchema::Trace.new + res = MultiplexTraceSchema.multiplex([{query: query_str}, {query: query_str}], context: { trace: trace_instance }) + assert_equal [1, 1], res.map { |r| r["data"]["int"]} + + assert_equal 1, trace_instance.execute_multiplex_count + assert_equal 2, trace_instance.execute_query_count + end + end end diff --git a/spec/graphql/execution/next_spec.rb b/spec/graphql/execution/next_spec.rb new file mode 100644 index 00000000000..3b5e6b49242 --- /dev/null +++ b/spec/graphql/execution/next_spec.rb @@ -0,0 +1,386 @@ +# frozen_string_literal: true +require "spec_helper" + +describe "Next Execution" do + class NextExecutionSchema < GraphQL::Schema + CLEAN_DATA = [ + OpenStruct.new(name: "Legumes", grows_in: ["SPRING", "🌻", "FALL"], species: [OpenStruct.new(name: "Snow Pea")]), + OpenStruct.new(name: "Nightshades", grows_in: ["🌻"], species: [OpenStruct.new(name: "Tomato")]), + OpenStruct.new(name: "Curcurbits", grows_in: ["🌻"], species: [OpenStruct.new(name: "Cucumber")]) + ] + + DATA = [] + + class Season < GraphQL::Schema::Enum + value "WINTER" + value "SPRING" + value "SUMMER", value: "🌻" + value "FALL" + end + + module Nameable + include GraphQL::Schema::Interface + field :name, String + end + + class PlantSpecies < GraphQL::Schema::Object + implements Nameable + field :poisonous, Boolean, resolve_static: :all_poisonous + + def self.all_poisonous(_ctx) + false + end + + field :family, "NextExecutionSchema::PlantFamily", resolve_each: :resolve_family + + def self.resolve_family(object, context) + DATA.find { |f| f.species.include?(object) } + end + + field :grows_in, [Season], resolve_each: :resolve_grows_in + + def self.resolve_grows_in(object, context) + object.grows_in || [] + end + end + + class PlantFamily < GraphQL::Schema::Object + implements Nameable + field :name, String, null: false + field :grows_in, [Season] + field :species, [PlantSpecies] + field :plant_count, Integer, resolve_each: :resolve_plant_count + + def self.resolve_plant_count(objects, context) + objects.species.length.to_f # let it be coerced to int + end + + field :first_species_name, String, dig: [:species, 0, :name] + end + + class Thing < GraphQL::Schema::Union + possible_types(PlantFamily, PlantSpecies) + end + + class Query < GraphQL::Schema::Object + field :families, [PlantFamily], resolve_static: :resolve_families + field :nullable_families, [PlantFamily, null: true], resolve_static: :resolve_families + + def self.resolve_families(_ctx) + DATA + end + + field :str, String, resolve_batch: :all_str + + def self.all_str(objects, context) + objects.map { |obj| obj.class.name } + end + + field :find_species, PlantSpecies, resolve_static: :all_find_species do + argument :name, String + end + + def self.all_find_species(context, name:) + species = nil + DATA.each do |f| + if (species = f.species.find { |s| s.name == name }) + break + end + end + species + end + + field :all_things, [Thing], resolve_static: :resolve_all_things + + def self.resolve_all_things(_ctx) + DATA + DATA.map(&:species).flatten + end + end + + class Mutation < GraphQL::Schema::Object + class CreatePlantInput < GraphQL::Schema::InputObject + argument :name, String + argument :family, String + argument :grows_in, [Season], default_value: ["🌻"] + end + + field :create_plant, PlantSpecies, resolve_static: :resolve_create_plant do + argument :input, CreatePlantInput + end + + def self.resolve_create_plant( _ctx, input:) + name = input[:name] + family = input[:family] + grows_in = input[:grows_in] + family_obj = DATA.find { |f| f.name == family} + species_obj = OpenStruct.new(name: name, grows_in: grows_in ) + family_obj.species << species_obj + species_obj + end + end + + query(Query) + mutation(Mutation) + use GraphQL::Execution::Next + + def self.resolve_type(abs_type, obj, ctx) + if obj.respond_to?(:grows_in) + PlantFamily + else + PlantSpecies + end + end + end + + + def run_next(...) + NextExecutionSchema.execute_next(...) + end + + before do + NextExecutionSchema::DATA.clear + NextExecutionSchema::DATA.concat(Marshal.load(Marshal.dump(NextExecutionSchema::CLEAN_DATA))) + end + + it "runs a query" do + result = run_next(" + query TestNext($name: String!) { + str + families { + ... on Nameable { name } + ... on PlantFamily { growsIn } + } + families { species { name } } + t: findSpecies(name: $name) { ...SpeciesInfo ... NameableInfo } + c: findSpecies(name: \"Cucumber\") { name ...SpeciesInfo } + x: findSpecies(name: \"Blue Rasperry\") { name } + allThings { + __typename + ... on Nameable { name } + ... on PlantFamily { growsIn } + } + } + + fragment SpeciesInfo on PlantSpecies { + poisonous + } + + fragment NameableInfo on Nameable { + name + } + ", root_value: "Abc", variables: { "name" => "Tomato" }) + expected_result = { + "data" => { + "str" => "String", + "families" => [ + {"name" => "Legumes", "growsIn" => ["SPRING", "SUMMER", "FALL"], "species" => [{"name" => "Snow Pea"}]}, + {"name" => "Nightshades", "growsIn" => ["SUMMER"], "species" => [{"name" => "Tomato"}]}, + {"name" => "Curcurbits", "growsIn" => ["SUMMER"], "species" => [{"name" => "Cucumber"}]} + ], + "t" => { "poisonous" => false, "name" => "Tomato" }, + "c" => { "name" => "Cucumber", "poisonous" => false }, + "x" => nil, + "allThings" => [ + {"__typename" => "PlantFamily", "name" => "Legumes", "growsIn" => ["SPRING", "SUMMER", "FALL"]}, + {"__typename" => "PlantFamily", "name" => "Nightshades", "growsIn" => ["SUMMER"]}, + {"__typename" => "PlantFamily", "name" => "Curcurbits", "growsIn" => ["SUMMER"]}, + {"__typename" => "PlantSpecies", "name" => "Snow Pea"}, + {"__typename" => "PlantSpecies", "name" => "Tomato"}, + {"__typename" => "PlantSpecies", "name" => "Cucumber"}, + ] + } + } + assert_graphql_equal(expected_result, result) + end + + it "runs mutations in isolation" do + result = run_next <<~GRAPHQL + mutation TestSequence { + p1: createPlant(input: { name: "Eggplant", family: "Nightshades", growsIn: [SUMMER] }) { growsIn family { plantCount } } + p2: createPlant(input: { name: "Ground Cherry", family: "Nightshades" }) { growsIn family { plantCount } } + p3: createPlant(input: { name: "Potato", family: "Nightshades", growsIn: [SPRING, SUMMER] }) { growsIn family { plantCount } } + } + GRAPHQL + + expected_result = { "data" => { + "p1" => { "growsIn" => ["SUMMER"], "family" => { "plantCount" => 2 }}, + "p2" => { "growsIn" => ["SUMMER"], "family" => { "plantCount" => 3 }}, + "p3" => { "growsIn" => ["SPRING", "SUMMER"], "family" => { "plantCount" => 4 }} + } } + assert_graphql_equal(expected_result, result) + end + + it "runs introspection" do + result = run_next(GraphQL::Introspection::INTROSPECTION_QUERY) + new_schema = GraphQL::Schema.from_introspection(result) + assert_equal NextExecutionSchema.to_definition, new_schema.to_definition + end + + it "skips and includes" do + result = run_next <<~GRAPHQL + { + c1: findSpecies(name: "Cucumber") @skip(if: true) { name } + c2: findSpecies(name: "Cucumber") @include(if: false) { name } + c3: findSpecies(name: "Cucumber") @skip(if: false) { name } + c4: findSpecies(name: "Cucumber") @include(if: true) { name } + } + GRAPHQL + + expected_result = { "data" => { + "c3" => {"name" => "Cucumber"}, + "c4" => {"name" => "Cucumber"} + } } + assert_equal expected_result, result + end + + it "runs dig" do + result = run_next("{ families { firstSpeciesName } }") + expected_result = { + "data" => { + "families" => [ + {"firstSpeciesName" => "Snow Pea"}, + {"firstSpeciesName" => "Tomato"}, + {"firstSpeciesName" => "Cucumber"} + ] + } + } + assert_graphql_equal(expected_result, result) + end + + it "runs a query by name" do + result = run_next <<~GRAPHQL, operation_name: "A" + query A { a: __typename } + query B { b: __typename } + GRAPHQL + assert_equal({ "a" => "Query" }, result["data"]) + end + + it "does scalar coercion" do + result = run_next <<~GRAPHQL, variables: { input: { name: :Zucchini, family: "Curcurbits", grows_in: "🌻" }} + mutation TestCoerce($input: CreatePlantInput!) { + createPlant(input: $input) { + name + growsIn + family { name } + } + } + GRAPHQL + + expected_result = {"errors" => + [{"message" => + "Variable $input of type CreatePlantInput! was provided invalid value for name (Could not coerce value \"Zucchini\" to String), grows_in (Field is not defined on CreatePlantInput)", + "locations" => [{"line" => 1, "column" => 21}], + "extensions" => + {"value" => + {"name" => :Zucchini, "family" => "Curcurbits", "grows_in" => "🌻"}, + "problems" => + [{"path" => ["name"], + "explanation" => "Could not coerce value \"Zucchini\" to String"}, + {"path" => ["grows_in"], + "explanation" => "Field is not defined on CreatePlantInput"}]}}]} + assert_equal expected_result, result + end + + it "propagates nulls in lists" do + NextExecutionSchema::DATA << nil + result = run_next <<~GRAPHQL + { + families { name } + nullableFamilies { name } + } + GRAPHQL + + expected_result = { + "errors" => [ + { + "message" => "Cannot return null for non-nullable element of type 'PlantFamily' for Query.families", + "locations" => [{"line" => 2, "column" => 3}], + "path" => ["families", 3] + } + ], + "data" => { + "families" => nil, + "nullableFamilies" => [ + { "name" => "Legumes" }, + { "name" => "Nightshades" }, + { "name" => "Curcurbits" }, + nil, + ] + } + } + assert_graphql_equal expected_result, result.to_h + end + + it "propages nulls in objects" do + NextExecutionSchema::DATA << OpenStruct.new( + name: nil, + species: [OpenStruct.new(name: "Artichoke")] + ) + + result = run_next <<-GRAPHQL + { + findSpecies(name: "Artichoke") { + name + family { name } + } + } + GRAPHQL + + expected_result = { + "errors" => [{ + "message" => "Cannot return null for non-nullable field PlantFamily.name", + "locations" => [{"line" => 4, "column" => 20}], + "path" => ["findSpecies", "family", "name"] + }], + "data" => { + "findSpecies" => { + "name" => "Artichoke", + "family" => nil, + } + }, + } + assert_graphql_equal expected_result, result + end + + it "propagates nested nulls in objects in lists" do + NextExecutionSchema::DATA << OpenStruct.new( + name: nil, + species: [OpenStruct.new(name: "Artichoke")] + ) + + result = run_next <<-GRAPHQL + { + families { + ...FamilyInfo + } + } + + fragment FamilyInfo on PlantFamily { + species { + family { + ... on Nameable { name } + } + } + } + GRAPHQL + + expected_result = { + "errors" => [ + { + "message" => "Cannot return null for non-nullable field PlantFamily.name", + "locations" => [{"line" => 10, "column" => 31}], + "path" => ["families", 3, "species", 0, "family", "name"] + } + ], + "data" => { + "families" => [ + {"species" => [{"family" => {"name" => "Legumes"}}]}, + {"species" => [{"family" => {"name" => "Nightshades"}}]}, + {"species" => [{"family" => {"name" => "Curcurbits"}}]}, + {"species" => [{"family" => nil}]} + ] + }, + } + assert_graphql_equal expected_result, result + end +end diff --git a/spec/graphql/execution/selections_step_spec.rb b/spec/graphql/execution/selections_step_spec.rb new file mode 100644 index 00000000000..7174b789e99 --- /dev/null +++ b/spec/graphql/execution/selections_step_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Execution::SelectionsStep do + class SelectionsStepRunner + attr_reader :steps + + def initialize(groups) + @groups = groups + @steps = [] + end + + def gather_selections(_parent_type, _selections, _step, _query, all_selections, _prototype_result, into:) + all_selections.replace(@groups) + end + + def runtime_directives + GraphQL::EmptyObjects::EMPTY_HASH + end + + def add_step(step) + @steps << step + end + end + + it "enqueues each field step once across selection groups" do + first_step = Object.new + second_step = Object.new + inline_fragment = GraphQL.parse("{ ... @include(if: true) { __typename } }").definitions.first.selections.first + runner = SelectionsStepRunner.new([ + { "first" => first_step }, + { "first" => nil }, + { __node: inline_fragment, "second" => second_step }, + { "second" => nil }, + ]) + step = GraphQL::Execution::SelectionsStep.new( + parent_type: nil, + field_resolve_step: nil, + selections: [], + objects: [], + results: [{}], + runner: runner, + query: Object.new, + path: [], + clobber: false, + ) + + step.call + + assert_equal [first_step, second_step], runner.steps + end +end diff --git a/spec/graphql/execution/typecast_spec.rb b/spec/graphql/execution/typecast_spec.rb deleted file mode 100644 index 8125676a161..00000000000 --- a/spec/graphql/execution/typecast_spec.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true -require "spec_helper" - -describe GraphQL::Execution::Typecast do - describe ".subtype?" do - def subtype?(*args) - GraphQL::Execution::Typecast.subtype?(*args) - end - - it "counts the same type as a subtype" do - assert subtype?(Dummy::Milk.graphql_definition, Dummy::Milk.graphql_definition) - assert !subtype?(Dummy::Milk.graphql_definition, Dummy::Cheese.graphql_definition) - assert subtype?(Dummy::Milk.graphql_definition.to_list_type.to_non_null_type, Dummy::Milk.graphql_definition.to_list_type.to_non_null_type) - end - - it "counts member types as subtypes" do - assert subtype?(Dummy::Edible.graphql_definition, Dummy::Cheese.graphql_definition) - assert subtype?(Dummy::Edible.graphql_definition, Dummy::Milk.graphql_definition) - assert subtype?(Dummy::DairyProduct.graphql_definition, Dummy::Milk.graphql_definition) - assert subtype?(Dummy::DairyProduct.graphql_definition, Dummy::Cheese.graphql_definition) - - assert !subtype?(Dummy::DairyAppQuery.graphql_definition, Dummy::DairyProduct.graphql_definition) - assert !subtype?(Dummy::Cheese.graphql_definition, Dummy::DairyProduct.graphql_definition) - assert !subtype?(Dummy::Edible.graphql_definition, Dummy::DairyProduct.graphql_definition) - assert !subtype?(Dummy::Edible.graphql_definition, GraphQL::DEPRECATED_STRING_TYPE) - assert !subtype?(Dummy::Edible.graphql_definition, Dummy::DairyProductInput.graphql_definition) - end - - it "counts lists as subtypes if their inner types are subtypes" do - assert subtype?(Dummy::Edible.graphql_definition.to_list_type, Dummy::Milk.graphql_definition.to_list_type) - assert subtype?(Dummy::DairyProduct.graphql_definition.to_list_type, Dummy::Milk.graphql_definition.to_list_type) - assert !subtype?(Dummy::Cheese.graphql_definition.to_list_type, Dummy::DairyProduct.graphql_definition.to_list_type) - assert !subtype?(Dummy::Edible.graphql_definition.to_list_type, Dummy::DairyProduct.graphql_definition.to_list_type) - assert !subtype?(Dummy::Edible.graphql_definition.to_list_type, GraphQL::DEPRECATED_STRING_TYPE.to_list_type) - end - - it "counts non-null types as subtypes of nullable parent types" do - assert subtype?(Dummy::Milk.graphql_definition, Dummy::Milk.graphql_definition.to_non_null_type) - assert subtype?(Dummy::Edible.graphql_definition, Dummy::Milk.graphql_definition.to_non_null_type) - assert subtype?(Dummy::Edible.graphql_definition.to_non_null_type, Dummy::Milk.graphql_definition.to_non_null_type) - assert subtype?( - GraphQL::DEPRECATED_STRING_TYPE.to_non_null_type.to_list_type, - GraphQL::DEPRECATED_STRING_TYPE.to_non_null_type.to_list_type.to_non_null_type, - ) - end - end -end diff --git a/spec/graphql/execution_error_spec.rb b/spec/graphql/execution_error_spec.rb index 5b730c71270..4d02ce1bd84 100644 --- a/spec/graphql/execution_error_spec.rb +++ b/spec/graphql/execution_error_spec.rb @@ -56,15 +56,31 @@ it "the error is inserted into the errors key and the rest of the query is fulfilled" do expected_result = { "data"=>{ + "dairy" => { + "milks" => [ + { + "source" => "COW", + "executionError" => nil, + "allDairy" => [ + { "__typename" => "Cheese" }, + { "__typename" => "Cheese" }, + { "__typename" => "Cheese" }, + { "__typename" => "Milk", "origin" => "Antiquity", "executionError" => nil } + ] + } + ] + }, + "executionError" => nil, + "valueWithExecutionError" => 0, "cheese"=>{ "id" => 1, + "flavor" => "Brie", "error1"=> nil, "error2"=> nil, "nonError"=> { "id" => 3, "flavor" => "Manchego", }, - "flavor" => "Brie", }, "allDairy" => [ { "flavor" => "Brie" }, @@ -78,22 +94,6 @@ { "__typename" => "Cheese" }, { "__typename" => "Milk" } ], - "dairy" => { - "milks" => [ - { - "source" => "COW", - "executionError" => nil, - "allDairy" => [ - { "__typename" => "Cheese" }, - { "__typename" => "Cheese" }, - { "__typename" => "Cheese" }, - { "__typename" => "Milk", "origin" => "Antiquity", "executionError" => nil } - ] - } - ] - }, - "executionError" => nil, - "valueWithExecutionError" => 0 }, "errors"=>[ { @@ -116,16 +116,6 @@ "locations"=>[{"line"=>31, "column"=>9}], "path"=>["dairy", "milks", 0, "executionError"] }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>22, "column"=>9}], - "path"=>["allDairy", 3, "executionError"] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>36, "column"=>13}], - "path"=>["dairy", "milks", 0, "allDairy", 3, "executionError"] - }, { "message"=>"No cheeses are made from Yak milk!", "locations"=>[{"line"=>5, "column"=>7}], @@ -136,6 +126,16 @@ "locations"=>[{"line"=>8, "column"=>7}], "path"=>["cheese", "error2"] }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>22, "column"=>9}], + "path"=>["allDairy", 3, "executionError"] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>36, "column"=>13}], + "path"=>["dairy", "milks", 0, "allDairy", 3, "executionError"] + }, ] } assert_equal(expected_result, result.to_h) @@ -210,6 +210,10 @@ # This is extracted from the test above -- it kept breaking # when working on dataloader, so I isolated it to keep an eye # on the minimal reproduction + # + # It's `def self.authorized?` is lazy, and it requires + # _both_ a lazy resolution and a dataloader run + # in order to resolve properly. expected_result = { "data"=>{ "cheese"=>{ @@ -340,7 +344,7 @@ let(:query_string) { %|{ multipleErrorsOnNonNullableListField} |} it "the errors are inserted into the errors key and the data is nil even for a NonNullable field" do expected_result = { - "data"=>nil, + "data"=>{"multipleErrorsOnNonNullableListField"=>[nil, nil]}, "errors"=> [{"message"=>"The first error message for a field defined to return a list of strings.", "locations"=>[{"line"=>1, "column"=>3}], @@ -353,4 +357,248 @@ end end end + + it "supports arrays containing only execution errors for list fields" do + schema = GraphQL::Schema.from_definition <<-GRAPHQL + type Query { + testArray: [String]! + } + GRAPHQL + + root_value = OpenStruct.new(testArray: [GraphQL::ExecutionError.new("boom!"), GraphQL::ExecutionError.new("bang!"), "OK"]) + result = schema.execute("{ testArray }", root_value: root_value) + assert_equal({ "testArray" => [nil, nil, "OK"]}, result["data"]) + expected_errors = [ + { + "message"=>"boom!", + "locations"=>[{"line"=>1, "column"=>3}], + "path"=>["testArray", 0] + }, + { + "message"=>"bang!", + "locations"=>[{"line"=>1, "column"=>3}], + "path"=>["testArray", 1] + } + ] + assert_equal(expected_errors, result["errors"]) + + root_value_errors_only = OpenStruct.new(testArray: [GraphQL::ExecutionError.new("zing!"), GraphQL::ExecutionError.new("fizz!")]) + result = schema.execute("{ testArray }", root_value: root_value_errors_only) + assert_equal({ "testArray" => [nil, nil] }, result["data"]) + expected_errors = [ + { + "message"=>"zing!", + "locations"=>[{"line"=>1, "column"=>3}], + "path"=>["testArray", 0] + }, + { + "message"=>"fizz!", + "locations"=>[{"line"=>1, "column"=>3}], + "path"=>["testArray", 1] + } + ] + assert_equal(expected_errors, result["errors"]) + end + + describe "when ExecutionError is raised in resolve_type" do + let(:schema) do + test_type = Class.new(GraphQL::Schema::Object) do + graphql_name "Test" + field :dummy, GraphQL::Types::Boolean + end + + test_union = Class.new(GraphQL::Schema::Union) do + graphql_name "TestUnion" + possible_types test_type + end + + query_type = Class.new(GraphQL::Schema::Object) do + graphql_name "Query" + + field :test, test_union, resolve_static: true + define_method(:test) do + 1 + end + + define_singleton_method(:test) do |ctx| + 1 + end + end + + Class.new(GraphQL::Schema) do + query query_type + + define_singleton_method(:resolve_type) do |abstract_type, obj, ctx| + raise GraphQL::ExecutionError.new("resolve_type") + end + end + end + + it "return execution error with location and path" do + query = "{ test { ...on Test { dummy } } }" + result = schema.execute(query) + expected_result = { + "errors"=>[ + { + "message"=>"resolve_type", + "locations"=>[{"line"=>1, "column"=>3}], + "path"=>["test"] + } + ], + "data"=>{"test"=>nil} + } + assert_equal(expected_result, result.to_h) + end + + describe "when using DataLoaders" do + let(:schema) do + test_type = Class.new(GraphQL::Schema::Object) do + graphql_name "Test" + field :dummy, GraphQL::Types::Boolean + end + + test_union = Class.new(GraphQL::Schema::Union) do + graphql_name "TestUnion" + possible_types test_type + end + + query_type = Class.new(GraphQL::Schema::Object) do + graphql_name "Query" + + field :test, test_union, resolve_static: true + define_method(:test) do + 1 + end + + define_singleton_method(:test) do |ctx| + 1 + end + end + + Class.new(GraphQL::Schema) do + query query_type + use GraphQL::Dataloader + + define_singleton_method(:resolve_type) do |abstract_type, obj, ctx| + raise GraphQL::ExecutionError.new("resolve_type") + end + end + end + + it "return execution error with location and path" do + query = "{ test { ...on Test { dummy } } }" + result = schema.execute(query) + expected_result = { + "errors"=>[ + { + "message"=>"resolve_type", + "locations"=>[{"line"=>1, "column"=>3}], + "path"=>["test"] + } + ], + "data"=>{"test"=>nil} + } + assert_equal(expected_result, result.to_h) + end + end + end + + describe "when using DataLoaders" do + let(:schema) do + item_error_loader = Class.new(GraphQL::Dataloader::Source) do + def fetch(keys) + keys.map { |key| GraphQL::ExecutionError.new("Error for #{key}") } + end + end + + query_type = Class.new(GraphQL::Schema::Object) do + graphql_name "Query" + field :item, String, resolve_static: true do + argument :key, String + end + define_method(:item) do |key:| + dataloader.with(item_error_loader).load(key) + end + + define_singleton_method(:item) do |ctx, key:| + ctx.dataloader.with(item_error_loader).load(key) + end + end + + Class.new(GraphQL::Schema) do + query query_type + use GraphQL::Dataloader + end + end + + let(:result) { schema.execute(query_string) } + + describe "when querying for unique items" do + let(:query_string) { + <<-GRAPHQL + query { + query0: item(key: "a") + query1: item(key: "b") + } + GRAPHQL + } + + it "returns unique execution errors locations and paths" do + expected_result = { + "data" => { + "query0" => nil, + "query1" => nil + }, + "errors" => [ + { + "message" => "Error for a", + "locations" => [{"line" => 2, "column" => 13}], + "path" => ["query0"] + }, + { + "message" => "Error for b", + "locations" => [{"line" => 3, "column" => 13}], + "path" => ["query1"] + } + ] + } + + assert_equal(expected_result, result.to_h) + end + end + + describe "when querying for duplicate items" do + let(:query_string) { + <<-GRAPHQL + query { + query0: item(key: "a") + query1: item(key: "a") + } + GRAPHQL + } + + it "returns execution errors for duplicate items" do + expected_result = { + "data" => { + "query0" => nil, + "query1" => nil + }, + "errors" => [ + { + "message" => "Error for a", + "locations" => [{"line" => 2, "column" => 13}], + "path" => ["query0"] + }, + { + "message" => "Error for a", + "locations" => [{"line" => 3, "column" => 13}], + "path" => ["query1"] + } + ] + } + + assert_equal(expected_result, result.to_h) + end + end + end end diff --git a/spec/graphql/introspection/directive_type_spec.rb b/spec/graphql/introspection/directive_type_spec.rb index c12f221812c..2df958f741a 100644 --- a/spec/graphql/introspection/directive_type_spec.rb +++ b/spec/graphql/introspection/directive_type_spec.rb @@ -9,6 +9,7 @@ name, args { name, type { kind, name, ofType { name } } }, locations + isRepeatable # Deprecated fields: onField onFragment @@ -27,7 +28,7 @@ end end - let(:schema) { Class.new(Dummy::Schema) } + let(:schema) { Class.new(Dummy::Schema) { directive(Class.new(GraphQL::Schema::Directive) { graphql_name("doStuff"); repeatable(true) })}} let(:result) { schema.execute(query_string) } before do schema.max_depth(100) @@ -37,32 +38,73 @@ expected = { "data" => { "__schema" => { "directives" => [ + { + "name" => "deprecated", + "args" => [ + {"name"=>"reason", "type"=>{"kind"=>"SCALAR", "name"=>"String", "ofType"=>nil}} + ], + "locations"=>["FIELD_DEFINITION", "ENUM_VALUE", "ARGUMENT_DEFINITION", "INPUT_FIELD_DEFINITION"], + "isRepeatable" => false, + "onField" => false, + "onFragment" => false, + "onOperation" => false, + }, + { + "name"=>"directiveForVariableDefinition", + "args"=>[], + "locations"=>["VARIABLE_DEFINITION"], + "isRepeatable"=>false, + "onField"=>false, + "onFragment"=>false, + "onOperation"=>false, + }, + { + "name"=>"doStuff", + "args"=>[], + "locations"=>[], + "isRepeatable"=>true, + "onField"=>false, + "onFragment"=>false, + "onOperation"=>false, + }, { "name" => "include", "args" => [ {"name"=>"if", "type"=>{"kind"=>"NON_NULL", "name"=>nil, "ofType"=>{"name"=>"Boolean"}}} ], "locations"=>["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], + "isRepeatable" => false, "onField" => true, "onFragment" => true, "onOperation" => false, }, + { + "name" => "oneOf", + "args" => [], + "locations"=>["INPUT_OBJECT"], + "isRepeatable" => false, + "onField" => false, + "onFragment" => false, + "onOperation" => false, + }, { "name" => "skip", "args" => [ {"name"=>"if", "type"=>{"kind"=>"NON_NULL", "name"=>nil, "ofType"=>{"name"=>"Boolean"}}} ], "locations"=>["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], + "isRepeatable" => false, "onField" => true, "onFragment" => true, "onOperation" => false, }, { - "name" => "deprecated", + "name" => "specifiedBy", "args" => [ - {"name"=>"reason", "type"=>{"kind"=>"SCALAR", "name"=>"String", "ofType"=>nil}} + {"name"=>"url", "type"=>{"kind"=>"NON_NULL", "name"=>nil, "ofType"=>{"name"=>"String"}}} ], - "locations"=>["FIELD_DEFINITION", "ENUM_VALUE", "ARGUMENT_DEFINITION", "INPUT_FIELD_DEFINITION"], + "locations"=>["SCALAR"], + "isRepeatable" => false, "onField" => false, "onFragment" => false, "onOperation" => false, @@ -70,7 +112,7 @@ ] } }} - assert_equal(expected, result) + assert_equal(expected, result.to_h) end it "hides deprecated arguments by default" do @@ -80,8 +122,8 @@ __schema { directives { name - args { - name + args { + name } } } @@ -102,7 +144,7 @@ __schema { directives { name - args(includeDeprecated: true) { + args(includeDeprecated: true) { name isDeprecated deprecationReason diff --git a/spec/graphql/introspection/entry_points_spec.rb b/spec/graphql/introspection/entry_points_spec.rb index 08b84d97183..2ef503d2a62 100644 --- a/spec/graphql/introspection/entry_points_spec.rb +++ b/spec/graphql/introspection/entry_points_spec.rb @@ -33,6 +33,7 @@ def self.visible?(context) Class.new(GraphQL::Schema) do query query_type + use GraphQL::Schema::Warden if ADD_WARDEN end end diff --git a/spec/graphql/introspection/introspection_query_spec.rb b/spec/graphql/introspection/introspection_query_spec.rb index dd82968ac76..d2523ca139c 100644 --- a/spec/graphql/introspection/introspection_query_spec.rb +++ b/spec/graphql/introspection/introspection_query_spec.rb @@ -45,4 +45,18 @@ GraphQL::Schema::Loader.load(result) } end + + it "doesn't contain blank lines" do + int_query = GraphQL::Introspection.query + refute_includes int_query, "\n\n" + + int_query_with_options = GraphQL::Introspection.query( + include_deprecated_args: true, + include_schema_description: true, + include_is_repeatable: true, + include_specified_by_url: true, + include_is_one_of: true + ) + refute_includes int_query_with_options, "\n\n" + end end diff --git a/spec/graphql/introspection/schema_type_spec.rb b/spec/graphql/introspection/schema_type_spec.rb index a7771b231ec..234bb056c92 100644 --- a/spec/graphql/introspection/schema_type_spec.rb +++ b/spec/graphql/introspection/schema_type_spec.rb @@ -2,10 +2,11 @@ require "spec_helper" describe GraphQL::Introspection::SchemaType do - let(:schema) { Class.new(Dummy::Schema) } + let(:schema) { Class.new(Dummy::Schema) { description("Cool schema") }} let(:query_string) {%| query getSchema { __schema { + description types { name } queryType { fields { name }} mutationType { fields { name }} @@ -17,6 +18,7 @@ it "exposes the schema" do expected = { "data" => { "__schema" => { + "description" => "Cool schema", "types" => schema.types.values.sort_by(&:graphql_name).map { |t| t.graphql_name.nil? ? (p t; raise("no name for #{t}")) : {"name" => t.graphql_name} }, "queryType"=>{ "fields"=>[ @@ -30,11 +32,13 @@ {"name"=>"dairy"}, {"name"=>"deepNonNull"}, {"name"=>"error"}, + {"name"=>"exampleBeverage"}, {"name"=>"executionError"}, {"name"=>"executionErrorWithExtensions"}, {"name"=>"executionErrorWithOptions"}, {"name"=>"favoriteEdible"}, {"name"=>"fromSource"}, + {"name"=>"hugeInteger"}, {"name"=>"maybeNull"}, {"name"=>"milk"}, {"name"=>"multipleErrorsOnNonNullableField"}, @@ -108,13 +112,14 @@ def self.visible?(context) field :visible, visible_type, null: false field :with_invisible_args, String, null: false do argument :invisible, invisible_input_type, required: false - argument :visible, visible_input_type, required: true + argument :visible, visible_input_type end end Class.new(GraphQL::Schema) do query query_type orphan_types invisible_orphan_type + use GraphQL::Schema::Warden if ADD_WARDEN end end @@ -175,6 +180,7 @@ def self.visible?(context) end Class.new(GraphQL::Schema) do + use GraphQL::Schema::Visibility, preload: false query query_type directives invisible_directive, visible_directive end @@ -189,7 +195,7 @@ def self.visible?(context) |} it "only returns visible directives" do - expected_dirs = ['deprecated', 'include', 'skip', 'visibleDirective'] + expected_dirs = ['deprecated', 'include', 'skip', 'oneOf', 'specifiedBy', 'visibleDirective'] directives = result['data']['__schema']['directives'].map { |dir| dir.fetch('name') } assert_equal(expected_dirs.sort, directives.sort) end diff --git a/spec/graphql/introspection/type_type_spec.rb b/spec/graphql/introspection/type_type_spec.rb index 3aa45d1f62c..ca1c9431fed 100644 --- a/spec/graphql/introspection/type_type_spec.rb +++ b/spec/graphql/introspection/type_type_spec.rb @@ -8,12 +8,14 @@ milkType: __type(name: "Milk") { interfaces { name }, fields { type { kind, name, ofType { name } } } } dairyAnimal: __type(name: "DairyAnimal") { name, kind, enumValues(includeDeprecated: false) { name, isDeprecated } } dairyProduct: __type(name: "DairyProduct") { name, kind, possibleTypes { name } } - animalProduct: __type(name: "AnimalProduct") { name, kind, possibleTypes { name }, fields { name } } + animalProduct: __type(name: "AnimalProduct") { name, kind, specifiedByURL, possibleTypes { name }, fields { name } } missingType: __type(name: "NotAType") { name } + timeType: __type(name: "Time") { specifiedByURL } } |} let(:result) { Dummy::Schema.execute(query_string, context: {}, variables: {"cheeseId" => 2}) } let(:cheese_fields) {[ + {"name"=>"dairyProduct", "isDeprecated" => false, "type"=>{"kind"=>"UNION", "name"=>"DairyProduct", "ofType"=>nil}}, {"name"=>"deeplyNullableCheese", "isDeprecated" => false, "type"=>{ "kind" => "OBJECT", "name" => "Cheese", "ofType" => nil}}, {"name"=>"flavor", "isDeprecated" => false, "type" => { "kind" => "NON_NULL", "name" => nil, "ofType" => { "name" => "String"}}}, {"name"=>"id", "isDeprecated" => false, "type" => { "kind" => "NON_NULL", "name" => nil, "ofType" => { "name" => "Int"}}}, @@ -25,6 +27,7 @@ ]} let(:dairy_animals) {[ + {"name"=>"NONE", "isDeprecated"=> false }, {"name"=>"COW", "isDeprecated"=> false }, {"name"=>"DONKEY", "isDeprecated"=> false }, {"name"=>"GOAT", "isDeprecated"=> false }, @@ -40,9 +43,9 @@ }, "milkType"=>{ "interfaces"=>[ + {"name"=>"AnimalProduct"}, {"name"=>"Edible"}, {"name"=>"EdibleAsMilk"}, - {"name"=>"AnimalProduct"}, {"name"=>"LocalProduct"}, ], "fields"=>[ @@ -69,12 +72,14 @@ "animalProduct" => { "name"=>"AnimalProduct", "kind"=>"INTERFACE", + "specifiedByURL" => nil, "possibleTypes"=>[{"name"=>"Cheese"}, {"name"=>"Honey"}, {"name"=>"Milk"}], "fields"=>[ {"name"=>"source"}, ] }, "missingType" => nil, + "timeType" => { "specifiedByURL" => "https://time.graphql"} }} assert_equal(expected, result.to_h) end @@ -108,8 +113,8 @@ it "hides deprecated field arguments by default" do result = Dummy::Schema.execute <<-GRAPHQL { - __type(name: "Query") { - fields { + __type(name: "Query") { + fields { name args { name @@ -129,8 +134,8 @@ it "can expose deprecated field arguments" do result = Dummy::Schema.execute <<-GRAPHQL { - __type(name: "Query") { - fields { + __type(name: "Query") { + fields { name args(includeDeprecated: true) { name @@ -179,8 +184,8 @@ it "can expose deprecated input fields" do result = Dummy::Schema.execute <<-GRAPHQL { - __type(name: "DairyProductInput") { - inputFields(includeDeprecated: true) { + __type(name: "DairyProductInput") { + inputFields(includeDeprecated: true) { name isDeprecated deprecationReason @@ -224,7 +229,7 @@ field_result = type_result["fields"].find { |f| f["name"] == "bases" } all_arg_names = ["after", "before", "first", "last", "nameIncludes", "complexOrder"] returned_arg_names = field_result["args"].map { |a| a["name"] } - assert_equal all_arg_names, returned_arg_names + assert_equal all_arg_names.sort, returned_arg_names.sort end end end diff --git a/spec/graphql/invalid_null_error_spec.rb b/spec/graphql/invalid_null_error_spec.rb new file mode 100644 index 00000000000..6c1b9c7885e --- /dev/null +++ b/spec/graphql/invalid_null_error_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +require "spec_helper" + +describe "GraphQL::InvalidNullError" do + it "can be inspected" do + assert_equal "GraphQL::InvalidNullError", GraphQL::InvalidNullError.inspect + end +end diff --git a/spec/graphql/language/block_string_spec.rb b/spec/graphql/language/block_string_spec.rb index e330b221b8a..05ded99d711 100644 --- a/spec/graphql/language/block_string_spec.rb +++ b/spec/graphql/language/block_string_spec.rb @@ -63,6 +63,18 @@ def trim_whitespace(str) # Doesn't crash when the string is only a newline "\n", "" + ], + [ + # Removes long blank lines + " \n \n + Hello, + World! + + Yours, + GraphQL. + + \n \n", + "Hello,\n World!\n\nYours,\n GraphQL." ] ] diff --git a/spec/graphql/language/clexer_spec.rb b/spec/graphql/language/clexer_spec.rb new file mode 100644 index 00000000000..e97a428a901 --- /dev/null +++ b/spec/graphql/language/clexer_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true +require "spec_helper" +require_relative "./lexer_examples" + +if defined?(GraphQL::CParser::Lexer) + describe GraphQL::CParser::Lexer do + subject { GraphQL::CParser::Lexer } + + def assert_bad_unicode(string, _message = nil) + assert_equal :BAD_UNICODE_ESCAPE, subject.tokenize(string).first[0] + end + + it "makes tokens like the other lexer" do + str = "{ f1(type: \"str\") ...F2 }\nfragment F2 on SomeType { f2 }" + tokens = GraphQL.scan_with_c(str).map { |t| [*t.first(4), t[3].encoding] } + old_tokens = GraphQL.scan_with_ruby(str).map { |t| [*t, t[3].encoding] } + + assert_equal [ + [:LCURLY, 1, 1, "{", Encoding::UTF_8], + [:IDENTIFIER, 1, 3, "f1", Encoding::UTF_8], + [:LPAREN, 1, 5, "(", Encoding::UTF_8], + [:TYPE, 1, 6, "type", Encoding::UTF_8], + [:COLON, 1, 10, ":", Encoding::UTF_8], + [:STRING, 1, 12, "str", Encoding::UTF_8], + [:RPAREN, 1, 17, ")", Encoding::UTF_8], + [:ELLIPSIS, 1, 19, "...", Encoding::UTF_8], + [:IDENTIFIER, 1, 22, "F2", Encoding::UTF_8], + [:RCURLY, 1, 25, "}", Encoding::UTF_8], + [:FRAGMENT, 2, 1, "fragment", Encoding::UTF_8], + [:IDENTIFIER, 2, 10, "F2", Encoding::UTF_8], + [:ON, 2, 13, "on", Encoding::UTF_8], + [:IDENTIFIER, 2, 16, "SomeType", Encoding::UTF_8], + [:LCURLY, 2, 25, "{", Encoding::UTF_8], + [:IDENTIFIER, 2, 27, "f2", Encoding::UTF_8], + [:RCURLY, 2, 30, "}", Encoding::UTF_8] + ], tokens + assert_equal(old_tokens, tokens) + end + + it "makes frozen strings when using SchemaParser" do + str = "type Query { f1: Int }" + schema_ast = GraphQL::CParser::SchemaParser.new(str, nil, GraphQL::Tracing::NullTrace, nil).result + default_ast = GraphQL::CParser::Parser.new(str, nil, GraphQL::Tracing::NullTrace, nil).result + + # Equivalent ASTs: + assert_equal schema_ast, default_ast + + # But this one is frozen: + assert_equal "Query", schema_ast.definitions.first.name + assert schema_ast.definitions.first.name.frozen? + + # And this one isn't: + assert_equal "Query", default_ast.definitions.first.name + refute default_ast.definitions.first.name.frozen? + end + + it "exposes tokens_count" do + str = "type Query { f1: Int }" + parser = GraphQL::CParser::Parser.new(str, nil, GraphQL::Tracing::NullTrace, nil) + + assert_equal 7, parser.tokens_count + end + + include LexerExamples + end +end diff --git a/spec/graphql/language/definition_slice_spec.rb b/spec/graphql/language/definition_slice_spec.rb index a1e5a547cc9..0ac9edf845f 100644 --- a/spec/graphql/language/definition_slice_spec.rb +++ b/spec/graphql/language/definition_slice_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" describe GraphQL::Language::DefinitionSlice do - let(:document) { GraphQL::Language::Parser.parse(query_string) } + let(:document) { GraphQL.parse(query_string) } describe "anonymous query with no dependencies" do let(:query_string) {%| diff --git a/spec/graphql/language/document_from_schema_definition_spec.rb b/spec/graphql/language/document_from_schema_definition_spec.rb index 32de5601a2e..e6e8d566170 100644 --- a/spec/graphql/language/document_from_schema_definition_spec.rb +++ b/spec/graphql/language/document_from_schema_definition_spec.rb @@ -84,11 +84,11 @@ class Secret < GraphQL::Schema::Directive end class Query < GraphQL::Schema::Object - field :i, Int, null: true do + field :i, Int do directive Secret end - field :ssn, String, null: true do + field :ssn, String do directive Secret, top: true end end @@ -100,7 +100,7 @@ class LangEnum < GraphQL::Schema::Enum end locations GraphQL::Schema::Directive::FIELD - argument :lang, LangEnum, required: true + argument :lang, LangEnum end query(Query) @@ -112,6 +112,47 @@ class LangEnum < GraphQL::Schema::Enum end end + describe "when it has an enum_value with an adjacent custom directive" do + let(:schema_idl) { <<-GRAPHQL +directive @customEnumValueDirective(fakeArgument: String!) on ENUM_VALUE + +enum FakeEnum { + VALUE1 + VALUE2 @customEnumValueDirective(fakeArgument: "Value1 is better...") +} + +type Query { + fakeQueryField: FakeEnum! +} + GRAPHQL + } + + class EnumValueDirectiveSchema < GraphQL::Schema + class CustomEnumValueDirective < GraphQL::Schema::Directive + locations GraphQL::Schema::Directive::ENUM_VALUE + + argument :fake_argument, String + end + + class FakeEnum < GraphQL::Schema::Enum + value "VALUE1" + value "VALUE2" do + directive CustomEnumValueDirective, fake_argument: "Value1 is better..." + end + end + + class Query < GraphQL::Schema::Object + field :fake_query_field, FakeEnum, null: false + end + + query(Query) + end + + it "dumps the custom directive definition to the IDL" do + assert_equal schema_idl, EnumValueDirectiveSchema.to_definition + end + end + describe "when printing and schema respects root name conventions" do let(:schema_idl) { <<-GRAPHQL type Query { @@ -281,7 +322,7 @@ class LangEnum < GraphQL::Schema::Enum end end - describe "with an except filter" do + describe "with a visibility check" do let(:expected_idl) { <<-GRAPHQL type QueryType { foo: Foo @@ -323,11 +364,23 @@ class LangEnum < GraphQL::Schema::Enum GRAPHQL } + let(:schema) { + Class.new(GraphQL::Schema.from_definition(schema_idl)) do + def self.visible?(m, ctx) + m.graphql_name != "Type" + end + end + } + let(:document) { - subject.new( - schema, - except: ->(m, _ctx) { m.is_a?(GraphQL::BaseType) && m.name == "Type" } - ).document + doc_schema = Class.new(schema) do + use GraphQL::Schema::Visibility + def self.visible?(m, _ctx) + m.respond_to?(:graphql_name) && m.graphql_name != "Type" + end + end + + subject.new(doc_schema).document } it "returns the IDL minus the filtered members" do @@ -374,11 +427,22 @@ class LangEnum < GraphQL::Schema::Enum GRAPHQL } + let(:schema) { + Class.new(GraphQL::Schema.from_definition(schema_idl)) do + def self.visible?(m, ctx) + !(m.respond_to?(:kind) && m.kind.scalar? && m.name == "CustomScalar") + end + end + } + let(:document) { - subject.new( - schema, - only: ->(m, _ctx) { !(m.is_a?(GraphQL::ScalarType) && m.name == "CustomScalar") } - ).document + doc_schema = Class.new(schema) do + def self.visible?(m, _ctx) + !(m.respond_to?(:kind) && m.kind.scalar? && m.name == "CustomScalar") + end + end + + subject.new(doc_schema).document } it "returns the IDL minus the filtered members" do @@ -827,4 +891,31 @@ def equivalent_node?(expected, node) expected == node end end + + describe "custom SDL directives" do + class CustomSDLDirectiveSchema < GraphQL::Schema + class CustomThing < GraphQL::Schema::Directive + locations(FIELD_DEFINITION) + argument :stuff, String + end + + directive CustomThing + + class Query < GraphQL::Schema::Object + field :f, Int, directives: { CustomThing => { stuff: "ok" } } + end + query(Query) + end + + it "prints them out" do + expected_str = <<~GRAPHQL + directive @customThing(stuff: String!) on FIELD_DEFINITION + + type Query { + f: Int @customThing(stuff: "ok") + } + GRAPHQL + assert_equal expected_str, CustomSDLDirectiveSchema.to_definition + end + end end diff --git a/spec/graphql/language/equality_spec.rb b/spec/graphql/language/equality_spec.rb index 0c742426d8e..62a44ed489f 100644 --- a/spec/graphql/language/equality_spec.rb +++ b/spec/graphql/language/equality_spec.rb @@ -3,8 +3,8 @@ describe GraphQL::Language::Nodes::AbstractNode do describe ".eql?" do - let(:document1) { GraphQL::Language::Parser.parse(query_string1) } - let(:document2) { GraphQL::Language::Parser.parse(query_string2) } + let(:document1) { GraphQL.parse(query_string1) } + let(:document2) { GraphQL.parse(query_string2) } describe "large identical document" do let(:query_string1) {%| diff --git a/spec/graphql/language/generation_spec.rb b/spec/graphql/language/generation_spec.rb index f4c70e25c88..7f887b7be55 100644 --- a/spec/graphql/language/generation_spec.rb +++ b/spec/graphql/language/generation_spec.rb @@ -10,7 +10,7 @@ let(:custom_printer_class) { Class.new(GraphQL::Language::Printer) { def print_field_definition(print_field_definition) - "