From c86efb3b9708cb707206a8a7742628a35103688a Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Tue, 4 Aug 2026 19:15:25 +0200 Subject: [PATCH 01/26] feat(datasource graphql hasura): add Hasura datasource with Rails polymorphism support Introspects a Hasura GraphQL API and exposes its tables as collections named after their Rails class names. Rails-style polymorphic associations (a `_type`/`_id` column pair) are detected from the Hasura metadata or from an explicit configuration, and emitted as PolymorphicManyToOne and PolymorphicOneToMany relations, so the Forest UI gets the native polymorphic widget and related data is always filtered by type. Only a `manual_configuration` relationship can be one branch of a polymorphic association: one backed by a foreign key constraint is monomorphic, and accepting it would absorb a legitimate belongs_to whenever an unrelated `_type` enum column sits next to `_id`. Also handles the cases a real instance surfaced: foreign keys referencing a non-id primary key, multi-column mappings, targets that are not exposed, table names tracked in several Postgres schemas, tables without a primary key, name collisions on reverse relations, Postgres enums and array columns, aggregates returned as JSON strings, and a blocked metadata endpoint. `validation/` holds a Postgres + Hasura stack and an end-to-end script covering those scenarios (31 checks), next to the RSpec suite. --- .github/workflows/build.yml | 4 +- .releaserc.js | 7 +- .rubocop.yml | 7 + .../.gitignore | 5 + .../.rspec | 3 + .../Gemfile | 16 + .../Gemfile-test | 19 + .../README.md | 105 +++++ .../Rakefile | 6 + ...st_admin_datasource_graphql_hasura.gemspec | 35 ++ .../forest_admin_datasource_graphql_hasura.rb | 38 ++ .../client.rb | 77 ++++ .../collection.rb | 293 +++++++++++++ .../configuration.rb | 33 ++ .../datasource.rb | 30 ++ .../introspection/introspector.rb | 374 +++++++++++++++++ .../introspection/schema_converter.rb | 262 ++++++++++++ .../introspection/structures.rb | 18 + .../query/filter_converter.rb | 101 +++++ .../query/query_builder.rb | 201 +++++++++ .../version.rb | 3 + .../collection_spec.rb | 186 +++++++++ .../datasource_spec.rb | 131 ++++++ .../introspector_detection_spec.rb | 273 ++++++++++++ .../query/filter_converter_spec.rb | 88 ++++ .../spec/spec_helper.rb | 39 ++ .../spec/support/banking_schema.rb | 210 ++++++++++ .../validation/docker-compose.yml | 29 ++ .../validation/init.sql | 97 +++++ .../validation/setup_hasura.sh | 67 +++ .../validation/validate.rb | 393 ++++++++++++++++++ 31 files changed, 3147 insertions(+), 3 deletions(-) create mode 100644 packages/forest_admin_datasource_graphql_hasura/.gitignore create mode 100644 packages/forest_admin_datasource_graphql_hasura/.rspec create mode 100644 packages/forest_admin_datasource_graphql_hasura/Gemfile create mode 100644 packages/forest_admin_datasource_graphql_hasura/Gemfile-test create mode 100644 packages/forest_admin_datasource_graphql_hasura/README.md create mode 100644 packages/forest_admin_datasource_graphql_hasura/Rakefile create mode 100644 packages/forest_admin_datasource_graphql_hasura/forest_admin_datasource_graphql_hasura.gemspec create mode 100644 packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/filter_converter.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/version.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/datasource_spec.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/query/filter_converter_spec.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/spec/spec_helper.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/spec/support/banking_schema.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/validation/docker-compose.yml create mode 100644 packages/forest_admin_datasource_graphql_hasura/validation/init.sql create mode 100644 packages/forest_admin_datasource_graphql_hasura/validation/setup_hasura.sh create mode 100644 packages/forest_admin_datasource_graphql_hasura/validation/validate.rb diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1251d6065..d00482310 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,6 +33,7 @@ jobs: - forest_admin_datasource_zendesk - forest_admin_datasource_snowflake - forest_admin_datasource_mambu_payments + - forest_admin_datasource_graphql_hasura steps: - name: Checkout @@ -76,6 +77,7 @@ jobs: - forest_admin_datasource_zendesk - forest_admin_datasource_snowflake - forest_admin_datasource_mambu_payments + - forest_admin_datasource_graphql_hasura services: mongodb: image: mongo:latest @@ -143,7 +145,7 @@ jobs: with: verbose: true oidc: true - files: ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_active_record/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_customizer/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_toolkit/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mongoid/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_rpc_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_rpc/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_zendesk/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_snowflake/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mambu_payments/coverage.json + files: ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_active_record/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_customizer/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_toolkit/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mongoid/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_rpc_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_rpc/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_zendesk/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_snowflake/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mambu_payments/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_graphql_hasura/coverage.json deploy: name: Release package diff --git a/.releaserc.js b/.releaserc.js index 6ba231515..76e57c8f0 100644 --- a/.releaserc.js +++ b/.releaserc.js @@ -30,7 +30,8 @@ module.exports = { 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/version.rb; '+ 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb; '+ 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/version.rb; '+ - 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb; ', + 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb; '+ + 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/version.rb; ', successCmd: '( cd packages/forest_admin_agent && gem build && gem push forest_admin_agent-*.gem );' + '( cd packages/forest_admin_datasource_active_record && gem build && gem push forest_admin_datasource_active_record-*.gem );' + @@ -43,7 +44,8 @@ module.exports = { '( cd packages/forest_admin_datasource_rpc && gem build && gem push forest_admin_datasource_rpc-*.gem );' + '( cd packages/forest_admin_datasource_zendesk && gem build && gem push forest_admin_datasource_zendesk-*.gem );' + '( cd packages/forest_admin_datasource_snowflake && gem build && gem push forest_admin_datasource_snowflake-*.gem );' + - '( cd packages/forest_admin_datasource_mambu_payments && gem build && gem push forest_admin_datasource_mambu_payments-*.gem );' , + '( cd packages/forest_admin_datasource_mambu_payments && gem build && gem push forest_admin_datasource_mambu_payments-*.gem );' + + '( cd packages/forest_admin_datasource_graphql_hasura && gem build && gem push forest_admin_datasource_graphql_hasura-*.gem );' , }, ], [ @@ -65,6 +67,7 @@ module.exports = { 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb', 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/version.rb', 'packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb', + 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/version.rb', 'package.json' ], }, diff --git a/.rubocop.yml b/.rubocop.yml index cfb997da3..77e881423 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -41,6 +41,7 @@ Gemspec/RequireMFA: - 'packages/forest_admin_datasource_zendesk/forest_admin_datasource_zendesk.gemspec' - 'packages/forest_admin_datasource_snowflake/forest_admin_datasource_snowflake.gemspec' - 'packages/forest_admin_datasource_mambu_payments/forest_admin_datasource_mambu_payments.gemspec' + - 'packages/forest_admin_datasource_graphql_hasura/forest_admin_datasource_graphql_hasura.gemspec' # Offense count: 1 # This cop supports unsafe autocorrection (--autocorrect-all). @@ -254,6 +255,7 @@ Naming/PredicatePrefix: Metrics/ParameterLists: Exclude: + - 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb' - 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/base_collection.rb' - 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/datasource.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/routes/query_handler.rb' @@ -295,6 +297,7 @@ Metrics/MethodLength: CountAsOne: ['array', 'hash', 'method_call'] Max: 20 Exclude: + - 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/filter_converter.rb' - 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/collection.rb' - 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/utils/query.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/auth/oauth2/forest_provider.rb' @@ -352,6 +355,10 @@ Metrics/BlockLength: Metrics/ClassLength: Exclude: + - 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb' + - 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb' + - 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb' + - 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb' - 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/base_collection.rb' - 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/collection.rb' - 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/datasource.rb' diff --git a/packages/forest_admin_datasource_graphql_hasura/.gitignore b/packages/forest_admin_datasource_graphql_hasura/.gitignore new file mode 100644 index 000000000..2afcd317c --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/.gitignore @@ -0,0 +1,5 @@ +/coverage/ +/pkg/ +/tmp/ +Gemfile.lock +Gemfile-test.lock diff --git a/packages/forest_admin_datasource_graphql_hasura/.rspec b/packages/forest_admin_datasource_graphql_hasura/.rspec new file mode 100644 index 000000000..34c5164d9 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/packages/forest_admin_datasource_graphql_hasura/Gemfile b/packages/forest_admin_datasource_graphql_hasura/Gemfile new file mode 100644 index 000000000..c229ff1d5 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/Gemfile @@ -0,0 +1,16 @@ +source 'https://rubygems.org' + +gemspec + +gem 'forest_admin_datasource_customizer' +gem 'forest_admin_datasource_toolkit' +gem 'rake', '~> 13.0' +gem 'rubocop', '1.86.1' +gem 'rubocop-performance', '1.26.1' +gem 'rubocop-rspec', '3.9.0' + +group :development, :test do + gem 'rspec', '~> 3.0' + gem 'simplecov', '~> 0.22', require: false + gem 'webmock', '~> 3.0' +end diff --git a/packages/forest_admin_datasource_graphql_hasura/Gemfile-test b/packages/forest_admin_datasource_graphql_hasura/Gemfile-test new file mode 100644 index 000000000..a4b3cb3c5 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/Gemfile-test @@ -0,0 +1,19 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in forest_admin_datasource_graphql_hasura.gemspec +gemspec + +gem 'rake', '~> 13.0' +gem 'rubocop', '1.86.1' +gem 'rubocop-performance', '1.26.1' +gem 'rubocop-rspec', '3.9.0' + +group :development, :test do + gem 'forest_admin_datasource_customizer', path: '../forest_admin_datasource_customizer' + gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit' + gem 'rspec', '~> 3.0' + gem 'simplecov', '~> 0.22', require: false + gem 'simplecov-html', '~> 0.12.3' + gem 'simplecov_json_formatter', '~> 0.1.4' + gem 'webmock', '~> 3.0' +end diff --git a/packages/forest_admin_datasource_graphql_hasura/README.md b/packages/forest_admin_datasource_graphql_hasura/README.md new file mode 100644 index 000000000..ba5f3901c --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/README.md @@ -0,0 +1,105 @@ +# Forest Admin — Hasura GraphQL datasource + +Surface tables exposed by a [Hasura](https://hasura.io) GraphQL API as Forest Admin collections, +including **Rails-style polymorphic associations** (`belongs_to :commentable, polymorphic: true`). + +## Installation + +```ruby +# Gemfile +gem 'forest_admin_datasource_graphql_hasura' +``` + +## Usage + +```ruby +ForestAdminRails::Agent.instance.add_datasource( + ForestAdminDatasourceGraphqlHasura::Datasource.new( + uri: 'https://my-instance.hasura.app/v1/graphql', + headers: { 'x-hasura-admin-secret' => ENV['HASURA_ADMIN_SECRET'] } + ) +) +``` + +Collections are named after the Rails class name derived from the table name +(`transfers` → `Transfer`). This matches the values stored in Rails `*_type` columns, which is +what makes polymorphic relations resolvable by the Forest Admin frontend. + +## Polymorphic associations + +Rails represents `belongs_to :commentable, polymorphic: true` with two columns +(`commentable_type`, `commentable_id`). Hasura cannot express the type condition, so teams +declare one manual object relationship per target, joining on `commentable_id` alone. + +This datasource detects the pattern (a `_type`/`_id` column pair whose object +relationships all join on `_id`) and emits: + +- a `PolymorphicManyToOne` (`Comment.commentable`) instead of the ambiguous per-target + relations — the Forest UI shows the native polymorphic widget; +- a `PolymorphicOneToMany` on each target (`Transfer.comments`, filtered on + `commentable_type = 'Transfer'`), so related data never leaks records of another type. + +The detection uses the Hasura metadata API (`/v1/metadata`, derived from `uri`). When that +endpoint is not reachable (common in production), declare the associations explicitly: + +```ruby +ForestAdminDatasourceGraphqlHasura::Datasource.new( + uri: '...', + polymorphic_relations: { 'comments' => { 'commentable' => %w[transfers cards] } } +) +``` + +For namespaced models, override the type value stored by Rails: + +```ruby +type_values: { 'bank_accounts' => 'Banking::Account' } +``` + +## Options + +| Option | Description | +| --- | --- | +| `uri` | Hasura GraphQL endpoint (required) | +| `headers` | HTTP headers, e.g. admin secret or JWT | +| `metadata_uri` | Metadata endpoint (default: `uri` with `/v1/graphql` → `/v1/metadata`) | +| `included_tables` / `excluded_tables` | Allow/deny lists of table names | +| `polymorphic_relations` | Explicit polymorphic declarations (see above) | +| `type_values` | Table → Rails class name overrides | +| `timeout` | HTTP timeout in seconds (default 30) | + +## Requirements and limitations + +- **A polymorphic association is only detected from a Hasura `manual_configuration` + relationship** joining on the polymorphic foreign key (or from `polymorphic_relations`). + A relationship backed by a real foreign key constraint is treated as a plain belongs_to, + so a business enum named `_type` sitting next to a `_id` foreign key + is left alone. +- **Grouped aggregations** (charts) work on a foreign key, or on a `:` + path through a ManyToOne (leaderboard charts) whose reverse relationship is declared in + Hasura: Hasura exposes GROUP BY only through nested `_aggregate` fields. Other + columns are advertised as non-groupable, and date truncation is not supported. Grouped + aggregation reads at most 1000 parent rows and logs a warning beyond that. +- **Tables without a primary key** (typically untracked views) are skipped: Forest cannot + address their records. +- Filtering and sorting through a polymorphic relation is not possible (a Forest Admin + limitation shared with the ActiveRecord datasource). +- Pattern operators (`contains`, `starts with`…) are only offered on genuine text columns. + Postgres enums and custom Hasura scalars get equality and nullity operators, because + their Hasura comparison expressions have no `_like`/`_ilike`. Text matching is + case-insensitive, like the ActiveRecord datasource. +- Nested creates/updates are out of scope: mutations write scalar columns (including + `jsonb`), never related records. +- A `*_type` value matching no exposed collection (a legacy STI subclass name, an excluded + target) leaves the reference empty and logs a warning, rather than failing the page. +- `bytea` columns are surfaced as text (Hasura returns them hex-encoded). + +## Validating against a real instance + +`validation/` holds a Postgres + Hasura stack seeded with a Rails-like schema and an +end-to-end script covering the scenarios above: + +```bash +docker compose -f validation/docker-compose.yml up -d +bash validation/setup_hasura.sh +BUNDLE_GEMFILE=Gemfile-test bundle exec ruby validation/validate.rb +``` diff --git a/packages/forest_admin_datasource_graphql_hasura/Rakefile b/packages/forest_admin_datasource_graphql_hasura/Rakefile new file mode 100644 index 000000000..4c774a2bf --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/Rakefile @@ -0,0 +1,6 @@ +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/packages/forest_admin_datasource_graphql_hasura/forest_admin_datasource_graphql_hasura.gemspec b/packages/forest_admin_datasource_graphql_hasura/forest_admin_datasource_graphql_hasura.gemspec new file mode 100644 index 000000000..a82330304 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/forest_admin_datasource_graphql_hasura.gemspec @@ -0,0 +1,35 @@ +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) + +require_relative 'lib/forest_admin_datasource_graphql_hasura/version' + +Gem::Specification.new do |spec| + spec.name = 'forest_admin_datasource_graphql_hasura' + spec.version = ForestAdminDatasourceGraphqlHasura::VERSION + spec.authors = ['Forest Admin'] + spec.email = ['contact@forestadmin.com'] + spec.homepage = 'https://www.forestadmin.com' + spec.summary = 'Hasura GraphQL datasource for Forest Admin Ruby agent.' + spec.description = 'Surface tables exposed by a Hasura GraphQL API as Forest Admin collections, ' \ + 'including Rails-style polymorphic associations.' + spec.license = 'GPL-3.0' + spec.required_ruby_version = '>= 3.0.0' + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = 'https://github.com/ForestAdmin/agent-ruby' + spec.metadata['changelog_uri'] = 'https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md' + spec.metadata['rubygems_mfa_required'] = 'false' + + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + (File.expand_path(f) == __FILE__) || + f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile]) + end + end + spec.bindir = 'exe' + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] + + spec.add_dependency 'activesupport', '>= 6.1' + spec.add_dependency 'zeitwerk', '~> 2.3' +end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura.rb new file mode 100644 index 000000000..d086332e2 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura.rb @@ -0,0 +1,38 @@ +require_relative 'forest_admin_datasource_graphql_hasura/version' +require 'logger' +require 'set' +require 'zeitwerk' +require 'forest_admin_datasource_toolkit' + +loader = Zeitwerk::Loader.for_gem +loader.ignore("#{__dir__}/forest_admin_datasource_graphql_hasura/introspection/structures.rb") +loader.setup + +require_relative 'forest_admin_datasource_graphql_hasura/introspection/structures' + +module ForestAdminDatasourceGraphqlHasura + class Error < StandardError; end + class ConfigurationError < Error; end + + # Inherits from the toolkit exception so the agent's error translator surfaces + # the actual message with a 400 instead of an opaque 500 "Unexpected error". + class GraphqlError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end + + class IntrospectionError < Error; end + + class << self + attr_writer :logger + + def logger + @logger ||= default_logger + end + + private + + def default_logger + return Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger + + Logger.new($stderr).tap { |l| l.progname = 'forest_admin_datasource_graphql_hasura' } + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb new file mode 100644 index 000000000..4259c4b01 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb @@ -0,0 +1,77 @@ +require 'json' +require 'openssl' +require 'net/http' +require 'uri' + +module ForestAdminDatasourceGraphqlHasura + # GraphQL-over-HTTP client for Hasura (queries, mutations and the metadata + # API), on Net::HTTP so the gem needs no HTTP dependency. + class Client + def initialize(configuration) + @configuration = configuration + end + + # Wrapped in GraphqlError so they reach the user as an actionable message + # instead of an opaque 500. + TRANSPORT_ERRORS = [ + Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse, IOError, SocketError, SystemCallError, + OpenSSL::SSL::SSLError, JSON::ParserError + ].freeze + + def execute(query, variables = {}) + body = JSON.generate({ query: query, variables: variables }) + response = post(@configuration.uri, body) + + raise GraphqlError, "GraphQL endpoint returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) + + payload = JSON.parse(response.body) + + if payload['errors']&.any? + messages = payload['errors'].map { |e| e['message'] }.join('; ') + raise GraphqlError, messages + end + + payload['data'] + rescue *TRANSPORT_ERRORS => e + raise GraphqlError, "Could not reach the GraphQL endpoint (#{e.class}): #{e.message}" + end + + # Returns nil when the endpoint is unreachable or forbidden, which is common + # in production: introspection then falls back to the configuration and to + # naming conventions. + def fetch_metadata + body = JSON.generate({ type: 'export_metadata', version: 2, args: {} }) + response = post(@configuration.metadata_uri, body) + + return nil unless response.is_a?(Net::HTTPSuccess) + + payload = JSON.parse(response.body) + metadata = payload['metadata'] || payload + + metadata['sources'] ? metadata : nil + rescue StandardError => e + ForestAdminDatasourceGraphqlHasura.logger.info( + "[forest_admin_datasource_graphql_hasura] Hasura metadata API not available (#{e.class}); " \ + 'falling back to configuration and naming conventions.' + ) + nil + end + + private + + def post(url, body) + uri = URI.parse(url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + http.read_timeout = @configuration.timeout + http.open_timeout = @configuration.timeout + + request = Net::HTTP::Post.new(uri.request_uri) + request['Content-Type'] = 'application/json' + @configuration.headers.each { |key, value| request[key] = value } + request.body = body + + http.request(request) + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb new file mode 100644 index 000000000..ece9273d6 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb @@ -0,0 +1,293 @@ +module ForestAdminDatasourceGraphqlHasura + class Collection < ForestAdminDatasourceToolkit::Collection + ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException + Projection = ForestAdminDatasourceToolkit::Components::Query::Projection + + GROUPED_AGGREGATE_PARENT_LIMIT = 1000 + PLACEHOLDER_REFERENCE = { '*' => nil }.freeze + + attr_reader :table_name + + def initialize(datasource, table, client, converter) + super(datasource, converter.collection_name_of(table.name)) + + @table = table + @table_name = table.name + @client = client + @converter = converter + + add_fields(converter.build_fields(table)) + enable_count + schema[:aggregation_capabilities][:supported_date_operations] = [] + end + + def list(_caller, filter, projection) + selection = build_selection(projection) + operation = Query::QueryBuilder.list(@table_name, filter, selection) + records = execute(:list, operation)[@table_name] || [] + + records.map { |record| materialize_polymorphics(record, projection) } + end + + def create(_caller, data) + operation = Query::QueryBuilder.create(@table_name, [writable_columns(data)], column_names) + returning = execute(:create, operation).dig("insert_#{@table_name}", 'returning') + + raise GraphqlError, "No record returned by insert_#{@table_name}" if returning.nil? || returning.empty? + + returning.first + end + + def update(_caller, filter, data) + if empty_condition?(filter) + raise ForestException, + "Refusing to update every row of '#{name}': the filter carries no condition." + end + + operation = Query::QueryBuilder.update(@table_name, filter, writable_columns(data)) + execute(:update, operation) + end + + def delete(_caller, filter) + operation = Query::QueryBuilder.delete(@table_name, filter) + execute(:delete, operation) + end + + def aggregate(_caller, filter, aggregation, limit = nil) + validate_aggregation(aggregation) + + if aggregation.groups.nil? || aggregation.groups.empty? + simple_aggregate(filter, aggregation) + else + grouped_aggregate(filter, aggregation, limit) + end + end + + private + + def column_names + @column_names ||= @table.columns.map(&:name) + end + + # Selects on the schema rather than on the value type, so that a `jsonb` + # column — whose value is a hash, like a relation payload would be — is kept. + def writable_columns(data) + data.select { |key, _| column_names.include?(key.to_s) } + end + + # Aggregation fields are interpolated into the GraphQL document and are the + # one path the agent does not validate upstream (the charts route passes the + # request's `aggregateFieldName` straight through). + def validate_aggregation(aggregation) + validate_aggregation_field(aggregation.field) if aggregation.field + + (aggregation.groups || []).each do |group| + if group[:operation] + raise ForestException, + "Date grouping is not supported by the GraphQL datasource (collection '#{name}')." + end + + validate_aggregation_field(group[:field], allow_relation: true) + end + end + + def validate_aggregation_field(field, allow_relation: false) + path = field.to_s.split(':') + + unless (allow_relation && path.size <= 2) || path.size == 1 + raise ForestException, "Invalid aggregation field '#{field}' on collection '#{name}'." + end + + collection = self + path.each_with_index do |part, index| + schema = collection.schema[:fields][part] + raise ForestException, "Field '#{field}' not found on collection '#{collection.name}'." if schema.nil? + next if index == path.size - 1 + + unless schema.type == 'ManyToOne' + raise ForestException, "Cannot aggregate through '#{part}' on collection '#{collection.name}'." + end + + collection = datasource.get_collection(schema.foreign_collection) + end + end + + def execute(operation_name, operation) + @client.execute(operation[:query], operation[:variables]) + rescue GraphqlError => e + raise GraphqlError, "GraphQL #{operation_name} failed on '#{name}': #{e.message}" + end + + # A PolymorphicManyToOne cannot be joined by Hasura, so its discriminator + # columns are selected instead and the relation is rebuilt by the serializer + # from those two values (see materialize_polymorphics). + def build_selection(projection) + selection = projection.columns.reject { |column| column == '*' } + + projection.relations.each do |relation_name, relation_projection| + field = schema[:fields][relation_name] + next if field.nil? + + if field.type == 'PolymorphicManyToOne' + selection << field.foreign_key + selection << field.foreign_key_type_field + else + target = datasource.get_collection(field.foreign_collection) + nested = target.send(:build_selection, relation_projection) + selection << "#{relation_name} { #{nested.join(" ")} }" + end + end + + selection.uniq + end + + # The serializer reads the reference off the discriminator columns, so the + # relation key only carries a placeholder — which has to be non-empty, since + # an empty hash drops the relation from the JSON:API payload. A type matching + # no exposed collection stays unresolved: the serializer looks the collection + # up by that raw value and would fail the whole page. + def materialize_polymorphics(record, projection) + projection.relations.each_key do |relation_name| + field = schema[:fields][relation_name] + next unless field&.type == 'PolymorphicManyToOne' + + type_value = record[field.foreign_key_type_field] + resolvable = type_value && field.foreign_key_targets.key?(type_value.to_s.gsub('::', '__')) + warn_unknown_type(relation_name, type_value) if type_value && !resolvable + + record[relation_name] = resolvable && record[field.foreign_key] ? PLACEHOLDER_REFERENCE : nil + end + + record + end + + def warn_unknown_type(relation_name, type_value) + @warned_types ||= Set.new + return unless @warned_types.add?("#{relation_name}/#{type_value}") + + ForestAdminDatasourceGraphqlHasura.logger.warn( + "[forest_admin_datasource_graphql_hasura] '#{name}.#{relation_name}' references the type " \ + "'#{type_value}', which matches no exposed collection; those references are shown empty. " \ + "Use the 'type_values' option if the Rails class name differs from the table name." + ) + end + + def simple_aggregate(filter, aggregation) + operation = Query::QueryBuilder.aggregate(@table_name, filter, aggregation) + data = execute(:aggregate, operation).dig("#{@table_name}_aggregate", 'aggregate') + + # One row even when the aggregate is null: the charts route reads + # `result[0]['value']` unguarded. + [{ 'value' => extract_aggregate_value(data, aggregation), 'group' => {} }] + end + + # Hasura exposes GROUP BY only through a nested `_aggregate` on a + # parent object, hence the detour through the parent table and the reduction + # in Ruby. + def grouped_aggregate(filter, aggregation, limit) + group_field = aggregation.groups.first[:field] + relation = find_group_relation(group_field) + + operation = Query::QueryBuilder.grouped_aggregate( + @table_name, relation, filter, aggregation, GROUPED_AGGREGATE_PARENT_LIMIT + ) + + rows = execute(:aggregate, operation)[relation[:parent_table]] || [] + warn_truncated_groups(relation[:parent_table]) if rows.size >= GROUPED_AGGREGATE_PARENT_LIMIT + + results = rows.filter_map do |row| + value = extract_aggregate_value(row.dig("#{relation[:relation_name]}_aggregate", 'aggregate'), aggregation) + next if childless_parent?(value, aggregation) + + { 'value' => value, 'group' => { group_field => row[relation[:parent_field]] } } + end + + results = results.sort_by { |row| sortable_value(row['value']) }.reverse + limit ? results.first(limit) : results + end + + # A `Sum` adding up to zero is a real group, unlike a parent that simply has + # no child row — which SQL grouping would not return either. + def childless_parent?(value, aggregation) + value.nil? || (aggregation.operation == 'Count' && value.to_i.zero?) + end + + # Accepts a foreign key (`membership_id`) or a path through a ManyToOne + # (`membership:full_name`, what leaderboard charts request). + def find_group_relation(group_field) + field_name, parent_column = group_field.split(':') + field = schema[:fields][field_name] + + relation, foreign_key = + if field&.type == 'ManyToOne' + [field, field.foreign_key] + else + [schema[:fields].values.find { |f| f.type == 'ManyToOne' && f.foreign_key == field_name }, field_name] + end + + if relation + parent = datasource.get_collection(relation.foreign_collection) + reverse = reverse_relation_name(parent, foreign_key) + + if reverse + return { + parent_table: parent.table_name, + parent_field: parent_column || relation.foreign_key_target, + relation_name: reverse + } + end + end + + raise ForestException, + "Group by '#{group_field}' is not supported: the GraphQL datasource groups through a " \ + "foreign key whose reverse relationship is declared in Hasura (collection '#{name}')." + end + + def reverse_relation_name(parent, foreign_key) + parent.schema[:fields].each do |relation_name, field| + next unless field.type == 'OneToMany' && + field.foreign_collection == name && + field.origin_key == foreign_key + + return relation_name + end + + nil + end + + def warn_truncated_groups(parent_table) + ForestAdminDatasourceGraphqlHasura.logger.warn( + "[forest_admin_datasource_graphql_hasura] Grouped aggregation on '#{name}' stopped after " \ + "#{GROUPED_AGGREGATE_PARENT_LIMIT} '#{parent_table}' rows; the result may be incomplete." + ) + end + + # Hasura returns bigint/numeric/money as JSON strings to preserve precision, + # and Max/Min may aggregate dates. + def sortable_value(value) + case value + when Numeric then value + when String then Float(value, exception: false) || 0 + else 0 + end + end + + def extract_aggregate_value(data, aggregation) + return nil if data.nil? + + if aggregation.operation == 'Count' + data['count'] + else + data.dig(aggregation.operation.downcase, aggregation.field) + end + end + + # Only guards update: a bulk delete with "select all" legitimately carries no + # condition, and wiping is then the requested semantic. + def empty_condition?(filter) + condition = Query::FilterConverter.convert(filter&.condition_tree) + + condition.nil? || condition.empty? + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb new file mode 100644 index 000000000..edfd82e96 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb @@ -0,0 +1,33 @@ +module ForestAdminDatasourceGraphqlHasura + class Configuration + DEFAULT_TIMEOUT = 30 + + attr_reader :uri, :headers, :metadata_uri, :included_tables, :excluded_tables, + :polymorphic_relations, :type_values, :timeout + + # polymorphic_relations declares associations explicitly when the metadata API + # is unreachable: { 'comments' => { 'commentable' => %w[transfers cards] } }. + # type_values overrides the Rails class name a table maps to, for those that + # `classify` gets wrong: { 'bank_accounts' => 'Banking::Account' }. + def initialize(uri:, headers: {}, metadata_uri: nil, included_tables: nil, excluded_tables: [], + polymorphic_relations: {}, type_values: {}, timeout: DEFAULT_TIMEOUT) + raise ConfigurationError, 'uri is required' if uri.nil? || uri.empty? + + @uri = uri + @headers = headers + @metadata_uri = metadata_uri || uri.sub('/v1/graphql', '/v1/metadata') + @included_tables = included_tables + @excluded_tables = excluded_tables + @polymorphic_relations = polymorphic_relations + @type_values = type_values + @timeout = timeout + end + + def table_allowed?(table_name) + return false if excluded_tables.include?(table_name) + return included_tables.include?(table_name) unless included_tables.nil? + + true + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb new file mode 100644 index 000000000..46075c37b --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb @@ -0,0 +1,30 @@ +module ForestAdminDatasourceGraphqlHasura + class Datasource < ForestAdminDatasourceToolkit::Datasource + attr_reader :client, :configuration + + def initialize(uri:, **options) + super() + + @configuration = Configuration.new(uri: uri, **options) + @client = Client.new(@configuration) + + register_collections + end + + private + + def register_collections + tables = Introspection::Introspector.new(@client, @configuration).introspect + converter = Introspection::SchemaConverter.new(tables, @configuration) + + tables.each do |table| + add_collection(Collection.new(self, table, @client, converter)) + end + + ForestAdminDatasourceGraphqlHasura.logger.info( + "[forest_admin_datasource_graphql_hasura] #{tables.size} collections registered " \ + "(#{tables.sum { |table| table.polymorphics.size }} polymorphic relations detected)." + ) + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb new file mode 100644 index 000000000..2ba5bb6a9 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb @@ -0,0 +1,374 @@ +require 'active_support/core_ext/string/inflections' + +module ForestAdminDatasourceGraphqlHasura + module Introspection + # Builds the datasource structure from the GraphQL introspection query + # (tables, columns, relationship fields) and the Hasura metadata API + # (relationship column mappings), when the latter is reachable. + class Introspector + INTROSPECTION_QUERY = <<~GRAPHQL.freeze + query IntrospectSchema { + __schema { + types { + name + kind + fields { + name + type { ...TypeRef } + } + enumValues { name } + } + queryType { + name + fields { + name + type { ...TypeRef } + args { + name + type { name kind ofType { name kind } } + } + } + } + } + } + fragment TypeRef on __Type { + name + kind + ofType { + name + kind + ofType { + name + kind + ofType { name kind } + } + } + } + GRAPHQL + + SCALAR_TYPES = %w[ + Int Float String Boolean ID + uuid timestamptz timestamp date time timetz jsonb json numeric bigint smallint + integer real double_precision text varchar char bpchar bytea inet cidr macaddr + money interval bit xml citext + _text _int4 _uuid _jsonb + ].to_set.freeze + + # A Postgres enum, or any scalar Hasura exposes under a custom name + # (`macaddr`, a domain type…), displays as a string but has no + # `_like`/`_ilike` in its comparison expression. + TEXT_TYPES = %w[String ID text varchar char bpchar citext bytea].to_set.freeze + + EXCLUDED_PREFIXES = %w[__ hdb_ pg_ information_schema].freeze + EXCLUDED_SUFFIXES = %w[_aggregate _by_pk _stream _connection].freeze + + def initialize(client, configuration) + @client = client + @configuration = configuration + end + + # @return [Array] + def introspect + schema = @client.execute(INTROSPECTION_QUERY) + raise IntrospectionError, 'Introspection query returned no schema' unless schema&.key?('__schema') + + metadata = @client.fetch_metadata + + @type_map = build_type_map(schema['__schema']['types']) + @relationship_mappings = metadata ? parse_relationship_mappings(metadata) : {} + @primary_keys = parse_primary_keys(schema['__schema']['queryType']['fields']) + + tables = parse_tables(schema['__schema']['queryType']['fields']) + detect_polymorphism(tables) + + tables + end + + private + + def build_type_map(types) + types.each_with_object({}) { |type, memo| memo[type['name']] = type if type['name'] } + end + + # Keyed by GraphQL root field, which Hasura derives from the table name, + # prefixed by the schema outside of `public` — hence the two spellings. A + # name claimed by two schemas is dropped rather than guessed: inheriting + # another schema's mapping would silently produce wrong foreign keys. + def parse_relationship_mappings(metadata) + mappings = {} + ambiguous = Set.new + + metadata['sources'].each do |source| + source['tables'].each do |table| + collect_table_mappings(table, mappings, ambiguous) + end + end + + ambiguous.each do |key| + table_name = key.split('.').first + ForestAdminDatasourceGraphqlHasura.logger.warn( + "[forest_admin_datasource_graphql_hasura] Table name '#{table_name}' is tracked in several " \ + 'Postgres schemas; its relationship metadata is ambiguous and therefore ignored.' + ) + mappings.delete(key) + end + + mappings + end + + def collect_table_mappings(table, mappings, ambiguous) + schema_name = table.dig('table', 'schema') + table_name = table.dig('table', 'name') + prefixed = schema_name.nil? || schema_name == 'public' ? nil : "#{schema_name}_#{table_name}" + + relationships = (table['object_relationships'] || []).map { |rel| [rel, :object] } + + (table['array_relationships'] || []).map { |rel| [rel, :array] } + + relationships.each do |(rel, kind)| + entry = relationship_mapping(rel, kind) + next if entry.nil? + + [table_name, prefixed].compact.each do |name| + key = "#{name}.#{rel["name"]}" + ambiguous << key if mappings.key?(key) && mappings[key] != entry + mappings[key] = entry + end + end + end + + # A nil column stands for the primary key of that table: a foreign key + # constraint may reference any unique column, and which one is only + # resolvable once the tables are parsed. + def relationship_mapping(rel, kind) + using = rel['using'] + constraint = using['foreign_key_constraint_on'] + manual = using['manual_configuration'] + + if constraint + mapping = kind == :object ? { constraint => nil } : { nil => constraint['column'] } + + { mapping: mapping, manual: false } + elsif manual + { mapping: manual['column_mapping'], manual: true } + end + end + + def parse_primary_keys(query_fields) + query_fields.each_with_object({}) do |field, memo| + next unless field['name'].end_with?('_by_pk') + + table_name = field['name'].delete_suffix('_by_pk') + pk_fields = (field['args'] || []).map { |arg| arg['name'] } + memo[table_name] = pk_fields if pk_fields.any? + end + end + + def parse_tables(query_fields) + query_fields.filter_map do |field| + table_name = field['name'] + next if skip_table?(table_name) + + type = @type_map[base_type_name(field['type'])] + next unless type && type['kind'] == 'OBJECT' + + table = parse_table(table_name, type) + next table unless table.primary_key.empty? + + # Forest cannot address a record without a primary key: ids, detail view + # and every write would fail. + ForestAdminDatasourceGraphqlHasura.logger.warn( + "[forest_admin_datasource_graphql_hasura] Skipping table '#{table_name}': no primary key found. " \ + 'Expose one in Hasura (a tracked primary key or an `id` column) to surface it in Forest Admin.' + ) + nil + end + end + + def skip_table?(name) + EXCLUDED_PREFIXES.any? { |prefix| name.start_with?(prefix) } || + EXCLUDED_SUFFIXES.any? { |suffix| name.end_with?(suffix) } || + !@configuration.table_allowed?(name) + end + + def parse_table(table_name, type) + columns = [] + relationships = [] + + (type['fields'] || []).each do |field| + next if field['name'].start_with?('__') + # Hasura companion fields of array relationships, not relations + next if field['name'].end_with?('_aggregate') + + type_name = base_type_name(field['type']) + + if scalar?(type_name) + columns << parse_column(field, type_name) + else + relationships << parse_relationship(table_name, field, type_name) + end + end + + Table.new( + name: table_name, + columns: columns, + primary_key: resolve_primary_key(table_name, columns), + relationships: relationships.compact, + polymorphics: [] + ) + end + + def parse_relationship(table_name, field, remote_type_name) + entry = @relationship_mappings["#{table_name}.#{field["name"]}"] + + Relationship.new( + name: field['name'], + kind: array_type?(field['type']) ? :array : :object, + remote_table: remote_type_name, + mapping: entry&.fetch(:mapping), + manual: entry ? entry[:manual] : nil + ) + end + + def parse_column(field, type_name) + # Postgres array columns are exposed by Hasura as custom scalars named + # after the element type (`_text`, `_int4`), not as GraphQL lists. + is_array = array_type?(field['type']) || type_name.start_with?('_') + + Column.new( + name: field['name'], + type: map_column_type(type_name), + graphql_type: type_name, + nullable: field['type']['kind'] != 'NON_NULL', + is_primary_key: false, + is_array: is_array, + is_text: TEXT_TYPES.include?(type_name) + ) + end + + # Hasura only generates a `_by_pk` query for tables that have a primary + # key. Anything else is left without one: guessing a composite key out of + # the `*_id` columns produced wrong record ids. + def resolve_primary_key(table_name, columns) + known = @primary_keys[table_name] + + if known + columns.each { |column| column.is_primary_key = known.include?(column.name) } + + return known + end + + id_column = columns.find { |column| column.name == 'id' } + + if id_column + id_column.is_primary_key = true + + return ['id'] + end + + [] + end + + # Fills `polymorphics` and removes the per-target object relationships it + # absorbs. + def detect_polymorphism(tables) + tables_by_name = tables.to_h { |table| [table.name, table] } + + tables.each do |table| + polymorphic_bases(table).each do |base| + targets = polymorphic_targets(table, base, tables_by_name) + next if targets.empty? + + table.polymorphics << Polymorphic.new( + name: base, + foreign_key: "#{base}_id", + type_field: "#{base}_type", + targets: targets + ) + + consumed = targets.values.filter_map { |target| target[:hasura_field] } + table.relationships.reject! { |rel| consumed.include?(rel.name) } + end + end + end + + def polymorphic_bases(table) + names = table.columns.map(&:name) + configured = @configuration.polymorphic_relations[table.name]&.keys || [] + + detected = names.filter_map do |name| + base = name.delete_suffix('_type') + base if name.end_with?('_type') && names.include?("#{base}_id") + end + + (detected + configured).uniq + end + + def polymorphic_targets(table, base, tables_by_name) + configured_tables = @configuration.polymorphic_relations.dig(table.name, base) + foreign_key = "#{base}_id" + + candidates = table.relationships.select do |rel| + next false unless rel.kind == :object + next configured_tables.include?(rel.remote_table) if configured_tables + + # A relationship backed by a real foreign key constraint is monomorphic + # by definition: accepting one here would absorb a legitimate belongs_to + # whenever an unrelated `_type` enum sits next to `_id`. + rel.manual && rel.mapping&.keys == [foreign_key] + end + + candidates.each_with_object({}) do |rel, memo| + target_table = tables_by_name[rel.remote_table] + next unless target_table + + type_value = @configuration.type_values[rel.remote_table] || rel.remote_table.classify + memo[type_value] = { + table: rel.remote_table, + hasura_field: rel.name, + primary_key: rel.mapping&.values&.first || target_table.primary_key.first || 'id' + } + end + end + + def scalar?(type_name) + return true if SCALAR_TYPES.include?(type_name) + + type = @type_map[type_name] + type ? %w[SCALAR ENUM].include?(type['kind']) : false + end + + def array_type?(type_ref) + return false if type_ref.nil? + return true if type_ref['kind'] == 'LIST' + + array_type?(type_ref['ofType']) + end + + def base_type_name(type_ref) + return 'Unknown' if type_ref.nil? + + type_ref['name'] || base_type_name(type_ref['ofType']) + end + + def map_column_type(graphql_type) + { + 'Int' => 'Number', 'Float' => 'Number', 'numeric' => 'Number', 'bigint' => 'Number', + 'smallint' => 'Number', 'integer' => 'Number', 'real' => 'Number', + 'double_precision' => 'Number', 'money' => 'Number', + 'String' => 'String', 'text' => 'String', 'varchar' => 'String', 'char' => 'String', + 'bpchar' => 'String', 'citext' => 'String', 'inet' => 'String', 'ID' => 'String', + 'Boolean' => 'Boolean', + 'uuid' => 'Uuid', + 'timestamptz' => 'Date', 'timestamp' => 'Date', + 'date' => 'Dateonly', + 'time' => 'Time', 'timetz' => 'Time', + 'jsonb' => 'Json', 'json' => 'Json', + # Text rather than Binary: Hasura returns bytea hex-encoded, and the + # agent's binary decorator would hand back raw bytes, which a JSON + # mutation body cannot carry. + 'bytea' => 'String' + }.fetch(graphql_type, 'String') + end + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb new file mode 100644 index 000000000..937bb4618 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb @@ -0,0 +1,262 @@ +require 'active_support/core_ext/string/inflections' + +module ForestAdminDatasourceGraphqlHasura + module Introspection + # Converts introspected tables into Forest Admin field schemas. + # + # Collections are named after the Rails class name, because the Forest + # serializer resolves the target of a PolymorphicManyToOne from the raw value + # of the type column — both namings have to match. + class SchemaConverter + ColumnSchema = ForestAdminDatasourceToolkit::Schema::ColumnSchema + Relations = ForestAdminDatasourceToolkit::Schema::Relations + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + + BASE_OPERATORS = [ + Operators::EQUAL, Operators::NOT_EQUAL, Operators::PRESENT, Operators::MISSING, + Operators::IN, Operators::NOT_IN + ].freeze + + # PRESENT is deliberately absent: on a text column an empty string is not + # "present", so the toolkit derives it from NOT_IN as `NotIn [nil, '']`. + STRING_OPERATORS = [ + Operators::EQUAL, Operators::NOT_EQUAL, Operators::MISSING, Operators::IN, Operators::NOT_IN, + Operators::CONTAINS, Operators::NOT_CONTAINS, Operators::I_CONTAINS, Operators::NOT_I_CONTAINS, + Operators::STARTS_WITH, Operators::I_STARTS_WITH, Operators::ENDS_WITH, Operators::I_ENDS_WITH, + Operators::LIKE, Operators::I_LIKE + ].freeze + + COMPARABLE_OPERATORS = (BASE_OPERATORS + [Operators::GREATER_THAN, Operators::LESS_THAN, + Operators::GREATER_THAN_OR_EQUAL, + Operators::LESS_THAN_OR_EQUAL]).freeze + + DATE_OPERATORS = (COMPARABLE_OPERATORS + [Operators::BEFORE, Operators::AFTER]).freeze + + # Hasura's array comparison expressions have no pattern matching, and + # `IncludesAll` has no equivalent the filter converter can emit. + ARRAY_OPERATORS = [Operators::EQUAL, Operators::NOT_EQUAL, Operators::PRESENT, + Operators::MISSING].freeze + + def initialize(tables, configuration) + @tables = tables + @tables_by_name = tables.to_h { |table| [table.name, table] } + @configuration = configuration + end + + def rails_class_name_of(table_name) + @configuration.type_values[table_name] || table_name.classify + end + + # 'Banking::Account' -> 'Banking__Account' + def collection_name_of(table_name) + rails_class_name_of(table_name).gsub('::', '__') + end + + def build_fields(table) + fields = {} + + table.columns.each { |column| fields[column.name] = convert_column(column) } + + table.polymorphics.each do |polymorphic| + fields[polymorphic.name] = convert_polymorphic(polymorphic) + fields[polymorphic.foreign_key]&.is_read_only = true + fields[polymorphic.type_field]&.is_read_only = true + end + + table.relationships.each do |relationship| + name, schema = convert_relationship(table, relationship) + fields[name] = schema if schema && !fields.key?(name) + end + + add_reverse_polymorphics(table, fields) + mark_groupable_foreign_keys(fields) + + fields + end + + private + + def convert_column(column) + ColumnSchema.new( + column_type: column.is_array ? [column.type] : column.type, + filter_operators: operators_for(column), + is_primary_key: column.is_primary_key, + is_read_only: column.is_primary_key, + is_sortable: !column.is_array, + # The capabilities route publishes this flag, so anything but the + # foreign keys of mark_groupable_foreign_keys would have the UI offer a + # group-by that grouped_aggregate then rejects. + is_groupable: false, + default_value: nil, + validation: column.nullable || column.is_primary_key ? [] : [{ operator: Operators::PRESENT }] + ) + end + + def convert_polymorphic(polymorphic) + Relations::PolymorphicManyToOneSchema.new( + foreign_key: polymorphic.foreign_key, + foreign_key_type_field: polymorphic.type_field, + foreign_collections: polymorphic.targets.keys.map { |type_value| type_value.gsub('::', '__') }, + foreign_key_targets: polymorphic.targets.to_h do |type_value, target| + [type_value.gsub('::', '__'), target[:primary_key]] + end + ) + end + + def convert_relationship(table, relationship) + if relationship.kind == :object + convert_object_relationship(table, relationship) + else + convert_array_relationship(table, relationship) + end + end + + def convert_object_relationship(table, relationship) + remote = @tables_by_name[relationship.remote_table] + + # A relation towards a table the datasource does not expose (excluded, or + # dropped for want of a primary key) breaks schema generation at boot. + unless remote + skip_relationship(table, relationship, "target table '#{relationship.remote_table}' is not exposed") + + return [relationship.name, nil] + end + + return [relationship.name, nil] unless single_column_mapping?(table, relationship) + + foreign_key = relationship.mapping&.keys&.first || "#{relationship.name}_id" + + unless table.columns.any? { |column| column.name == foreign_key } + skip_relationship(table, relationship, "foreign key '#{foreign_key}' does not exist") + + return [relationship.name, nil] + end + + [relationship.name, Relations::ManyToOneSchema.new( + foreign_collection: collection_name_of(relationship.remote_table), + foreign_key: foreign_key, + foreign_key_target: relationship.mapping&.values&.first || primary_key_of(remote) + )] + end + + def convert_array_relationship(table, relationship) + remote = @tables_by_name[relationship.remote_table] + return [relationship.name, nil] unless remote + return [relationship.name, nil] if covered_by_reverse_polymorphic?(table, relationship, remote) + return [relationship.name, nil] unless single_column_mapping?(table, relationship) + + origin_key = relationship.mapping&.values&.first || "#{table.name.singularize}_id" + + unless remote.columns.any? { |column| column.name == origin_key } + skip_relationship(table, relationship, "origin key '#{origin_key}' does not exist on " \ + "'#{relationship.remote_table}'") + + return [relationship.name, nil] + end + + [relationship.name, Relations::OneToManySchema.new( + foreign_collection: collection_name_of(relationship.remote_table), + origin_key: origin_key, + origin_key_target: relationship.mapping&.keys&.first || primary_key_of(table) + )] + end + + # Requires a known column mapping: without the Hasura metadata, a + # same-named array relationship may well be a regular has_many, and + # replacing it would silently list the wrong records. + def covered_by_reverse_polymorphic?(table, relationship, remote) + return false if relationship.mapping.nil? + + this_class_name = rails_class_name_of(table.name) + remote.polymorphics.any? do |polymorphic| + polymorphic.targets.key?(this_class_name) && + relationship.mapping.values.first == polymorphic.foreign_key + end + end + + def single_column_mapping?(table, relationship) + return true if relationship.mapping.nil? || relationship.mapping.size <= 1 + + skip_relationship(table, relationship, + 'its Hasura column mapping spans several columns, which Forest Admin ' \ + 'relations cannot express') + false + end + + # One PolymorphicOneToMany per polymorphic belongs_to targeting this table, + # named after the matching Hasura array relationship when there is one. + def add_reverse_polymorphics(table, fields) + this_class_name = rails_class_name_of(table.name) + + @tables.each do |child| + child.polymorphics.each do |polymorphic| + next unless polymorphic.targets.key?(this_class_name) + + name = reverse_polymorphic_name(table, child, polymorphic, fields) + next unless name + + fields[name] = Relations::PolymorphicOneToManySchema.new( + foreign_collection: collection_name_of(child.name), + origin_key: polymorphic.foreign_key, + origin_key_target: polymorphic.targets[this_class_name][:primary_key], + origin_type_field: polymorphic.type_field, + origin_type_value: this_class_name + ) + end + end + end + + def reverse_polymorphic_name(table, child, polymorphic, fields) + array_relationship = table.relationships.find do |rel| + rel.kind == :array && rel.remote_table == child.name && + rel.mapping&.values&.first == polymorphic.foreign_key + end + + candidates = [array_relationship&.name, child.name, "#{child.name}_#{polymorphic.name}"].compact.uniq + name = candidates.find { |candidate| !fields.key?(candidate) } + + unless name + ForestAdminDatasourceGraphqlHasura.logger.warn( + '[forest_admin_datasource_graphql_hasura] Cannot expose the reverse of ' \ + "'#{child.name}.#{polymorphic.name}' on '#{table.name}': the names #{candidates.join(", ")} " \ + 'are already taken. Rename the conflicting field or declare a Hasura array relationship.' + ) + end + + name + end + + def mark_groupable_foreign_keys(fields) + fields.each_value do |field| + next unless field.type == 'ManyToOne' + + foreign_key_field = fields[field.foreign_key] + foreign_key_field.is_groupable = true if foreign_key_field.respond_to?(:is_groupable=) + end + end + + def primary_key_of(table) + table.primary_key.first || 'id' + end + + def operators_for(column) + return ARRAY_OPERATORS if column.is_array + + case column.type + when 'String' then column.is_text ? STRING_OPERATORS : BASE_OPERATORS + when 'Number' then COMPARABLE_OPERATORS + when 'Date', 'Dateonly', 'Time' then DATE_OPERATORS + else BASE_OPERATORS + end + end + + def skip_relationship(table, relationship, reason) + ForestAdminDatasourceGraphqlHasura.logger.warn( + "[forest_admin_datasource_graphql_hasura] Skipping relationship '#{relationship.name}' " \ + "on '#{table.name}': #{reason}. Declare it in the Hasura metadata or through the " \ + "'polymorphic_relations' option." + ) + end + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb new file mode 100644 index 000000000..d37ada971 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb @@ -0,0 +1,18 @@ +module ForestAdminDatasourceGraphqlHasura + module Introspection + Table = Struct.new(:name, :columns, :primary_key, :relationships, :polymorphics, keyword_init: true) + + Column = Struct.new(:name, :type, :graphql_type, :nullable, :is_primary_key, :is_array, :is_text, + keyword_init: true) + + # kind is :object or :array. mapping is { local_column => remote_column }, + # where a nil side stands for the primary key of that table, and the whole + # hash is nil when the Hasura metadata was unreachable. manual tells a + # `manual_configuration` relationship from a foreign-key-constraint one. + Relationship = Struct.new(:name, :kind, :remote_table, :mapping, :manual, keyword_init: true) + + # targets maps the value stored in the type column to its table: + # { 'Transfer' => { table: 'transfers', hasura_field: 'transfer', primary_key: 'id' } } + Polymorphic = Struct.new(:name, :foreign_key, :type_field, :targets, keyword_init: true) + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/filter_converter.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/filter_converter.rb new file mode 100644 index 000000000..bd0ae9bd4 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/filter_converter.rb @@ -0,0 +1,101 @@ +module ForestAdminDatasourceGraphqlHasura + module Query + # Converts a Forest Admin condition tree into a Hasura `_bool_exp` hash. + class FilterConverter + Nodes = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + + def self.convert(condition_tree) + new.convert(condition_tree) + end + + def convert(condition_tree) + return nil if condition_tree.nil? + + case condition_tree + when Nodes::ConditionTreeBranch + aggregator = condition_tree.aggregator == 'And' ? '_and' : '_or' + { aggregator => condition_tree.conditions.map { |condition| convert(condition) } } + when Nodes::ConditionTreeLeaf + convert_leaf(condition_tree) + else + raise GraphqlError, "Unsupported condition tree node: #{condition_tree.class}" + end + end + + private + + # `_and`/`_or` only exist at bool_exp level, never inside a comparison + # expression, so an operator needing two comparisons on the same field is + # nested first and combined after. + def convert_leaf(leaf) + path = leaf.field.split(':') + expression = operator_expression(leaf.operator, leaf.value) + + if expression.is_a?(Array) + aggregator, comparisons = expression + + { aggregator => comparisons.map { |comparison| nest(path, comparison) } } + else + nest(path, expression) + end + end + + def nest(path, comparison) + path.reverse.reduce(comparison) { |memo, part| { part => memo } } + end + + def operator_expression(operator, value) + case operator + when Operators::EQUAL then value.nil? ? { '_is_null' => true } : { '_eq' => value } + when Operators::NOT_EQUAL then value.nil? ? { '_is_null' => false } : { '_neq' => value } + when Operators::GREATER_THAN, Operators::AFTER then { '_gt' => value } + when Operators::LESS_THAN, Operators::BEFORE then { '_lt' => value } + when Operators::GREATER_THAN_OR_EQUAL then { '_gte' => value } + when Operators::LESS_THAN_OR_EQUAL then { '_lte' => value } + when Operators::IN then in_expression(value) + when Operators::NOT_IN then not_in_expression(value) + when Operators::PRESENT then { '_is_null' => false } + when Operators::MISSING, Operators::BLANK then { '_is_null' => true } + when Operators::LIKE then { '_like' => value } + when Operators::I_LIKE then { '_ilike' => value } + # Case-insensitive like the ActiveRecord datasource, whose `matches` + # compiles to ILIKE on Postgres. + when Operators::CONTAINS, Operators::I_CONTAINS then { '_ilike' => "%#{escape_pattern(value)}%" } + when Operators::NOT_CONTAINS, Operators::NOT_I_CONTAINS then { '_nilike' => "%#{escape_pattern(value)}%" } + when Operators::STARTS_WITH, Operators::I_STARTS_WITH then { '_ilike' => "#{escape_pattern(value)}%" } + when Operators::ENDS_WITH, Operators::I_ENDS_WITH then { '_ilike' => "%#{escape_pattern(value)}" } + else + raise GraphqlError, "Unsupported operator: #{operator}" + end + end + + # `IN (NULL, ...)` never matches NULL rows in Postgres. The toolkit relies + # on this shape to emulate Blank/Present on text columns. + def in_expression(value) + values = Array(value) + return { '_in' => values } unless values.include?(nil) + + others = values.compact + return { '_is_null' => true } if others.empty? + + ['_or', [{ '_is_null' => true }, { '_in' => others }]] + end + + def not_in_expression(value) + values = Array(value) + return { '_nin' => values } unless values.include?(nil) + + others = values.compact + return { '_is_null' => false } if others.empty? + + ['_and', [{ '_is_null' => false }, { '_nin' => others }]] + end + + # Someone searching "100%" means the literal string, not "contains 100". + def escape_pattern(value) + value.to_s.gsub(/[\\%_]/) { |match| "\\#{match}" } + end + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb new file mode 100644 index 000000000..751e7c441 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb @@ -0,0 +1,201 @@ +module ForestAdminDatasourceGraphqlHasura + module Query + # Builds Hasura GraphQL operations (queries and mutations) with variables. + # All methods return { query:, variables: }. + class QueryBuilder + class << self + # selection holds resolved GraphQL fields, nested relations included + # ("membership { id full_name }"). + def list(table, filter, selection) + args = [] + var_defs = [] + variables = {} + + where = FilterConverter.convert(filter.condition_tree) + + if where + var_defs << "$where: #{table}_bool_exp" + args << 'where: $where' + variables['where'] = where + end + + add_sort(table, filter, args, var_defs, variables) + add_pagination(filter, args, var_defs, variables) + + query = <<~GRAPHQL + query List#{camelize(table)}#{wrap(var_defs)} { + #{table}#{wrap(args)} { + #{selection.join("\n ")} + } + } + GRAPHQL + + { query: query, variables: variables } + end + + def create(table, records, selection) + query = <<~GRAPHQL + mutation Insert#{camelize(table)}($objects: [#{table}_insert_input!]!) { + insert_#{table}(objects: $objects) { + returning { + #{selection.join("\n ")} + } + } + } + GRAPHQL + + { query: query, variables: { 'objects' => records.map { |record| clean_record(record) } } } + end + + def update(table, filter, patch) + query = <<~GRAPHQL + mutation Update#{camelize(table)}($where: #{table}_bool_exp!, $set: #{table}_set_input!) { + update_#{table}(where: $where, _set: $set) { + affected_rows + } + } + GRAPHQL + + { + query: query, + variables: { + 'where' => FilterConverter.convert(filter.condition_tree) || {}, + 'set' => clean_record(patch, keep_nil: true) + } + } + end + + def delete(table, filter) + query = <<~GRAPHQL + mutation Delete#{camelize(table)}($where: #{table}_bool_exp!) { + delete_#{table}(where: $where) { + affected_rows + } + } + GRAPHQL + + { query: query, variables: { 'where' => FilterConverter.convert(filter.condition_tree) || {} } } + end + + def aggregate(table, filter, aggregation) + args = [] + var_defs = [] + variables = {} + + where = FilterConverter.convert(filter.condition_tree) + + if where + var_defs << "$where: #{table}_bool_exp" + args << 'where: $where' + variables['where'] = where + end + + query = <<~GRAPHQL + query Aggregate#{camelize(table)}#{wrap(var_defs)} { + #{table}_aggregate#{wrap(args)} { + aggregate { + #{aggregation_selection(aggregation)} + } + } + } + GRAPHQL + + { query: query, variables: variables } + end + + # relation is { parent_table:, parent_field:, relation_name: }. + def grouped_aggregate(child_table, relation, filter, aggregation, parent_limit) + args = [] + var_defs = ['$parentLimit: Int'] + variables = { 'parentLimit' => parent_limit } + + where = FilterConverter.convert(filter.condition_tree) + + if where + var_defs << "$where: #{child_table}_bool_exp" + args << 'where: $where' + variables['where'] = where + end + + query = <<~GRAPHQL + query Aggregate#{camelize(relation[:parent_table])}#{wrap(var_defs)} { + #{relation[:parent_table]}(limit: $parentLimit) { + #{relation[:parent_field]} + #{relation[:relation_name]}_aggregate#{wrap(args)} { + aggregate { + #{aggregation_selection(aggregation)} + } + } + } + } + GRAPHQL + + { query: query, variables: variables } + end + + def aggregation_selection(aggregation) + operation = aggregation.operation + + if operation == 'Count' + aggregation.field ? "count(columns: #{aggregation.field})" : 'count' + else + "#{operation.downcase} { #{aggregation.field} }" + end + end + + private + + def add_sort(table, filter, args, var_defs, variables) + return unless filter.respond_to?(:sort) && filter.sort&.any? + + var_defs << "$orderBy: [#{table}_order_by!]" + args << 'order_by: $orderBy' + variables['orderBy'] = convert_sort(filter.sort) + end + + def add_pagination(filter, args, var_defs, variables) + page = filter.respond_to?(:page) ? filter.page : nil + + if page&.limit + var_defs << '$limit: Int' + args << 'limit: $limit' + variables['limit'] = page.limit + end + + return unless page&.offset&.positive? + + var_defs << '$offset: Int' + args << 'offset: $offset' + variables['offset'] = page.offset + end + + def convert_sort(sort) + sort.map do |clause| + direction = clause[:ascending] ? 'asc' : 'desc' + parts = clause[:field].split(':') + + parts.reverse.reduce(direction) { |memo, part| { part => memo } } + end + end + + # Nils are dropped on insert so database defaults apply, and kept on + # update so a field can be cleared. + def clean_record(record, keep_nil: false) + record.each_with_object({}) do |(key, value), memo| + next if value.nil? && !keep_nil + + memo[key.to_s] = value + end + end + + def wrap(parts) + parts.empty? ? '' : "(#{parts.join(", ")})" + end + + def camelize(name) + name.split('_').map(&:capitalize).join + end + end + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/version.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/version.rb new file mode 100644 index 000000000..aba24225d --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/version.rb @@ -0,0 +1,3 @@ +module ForestAdminDatasourceGraphqlHasura + VERSION = '1.0.0'.freeze +end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb new file mode 100644 index 000000000..c3956ee99 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb @@ -0,0 +1,186 @@ +require 'spec_helper' + +RSpec.describe ForestAdminDatasourceGraphqlHasura::Collection do + let(:datasource) { BankingSchema.build_datasource } + let(:comments) { datasource.get_collection('Comment') } + let(:caller) { nil } + + def toolkit_query + ForestAdminDatasourceToolkit::Components::Query + end + + def projection(*fields) + toolkit_query::Projection.new(fields) + end + + def filter(**options) + toolkit_query::Filter.new(**options) + end + + def leaf(field, operator, value = nil) + toolkit_query::ConditionTree::Nodes::ConditionTreeLeaf.new(field, operator, value) + end + + def branch(aggregator, conditions) + toolkit_query::ConditionTree::Nodes::ConditionTreeBranch.new(aggregator, conditions) + end + + def operators + toolkit_query::ConditionTree::Operators + end + + def last_graphql_request + request = nil + expect(WebMock).to(have_requested(:post, BankingSchema::GRAPHQL_URI).at_least_once.with do |req| + request = JSON.parse(req.body) unless req.body.include?('IntrospectSchema') + true + end) + request + end + + describe '#list' do + it 'selects the discriminator columns instead of joining the polymorphic targets' do + BankingSchema.stub_graphql_data({ 'comments' => [] }) + + comments.list(caller, filter, projection('id', 'body', 'commentable_type', 'commentable:*')) + + query = last_graphql_request['query'] + expect(query).to include('commentable_id') + expect(query).to include('commentable_type') + expect(query).not_to include('transfer {') + expect(query).not_to include('card {') + end + + it 'materializes the polymorphic relation as a phantom record' do + BankingSchema.stub_graphql_data( + { + 'comments' => [ + { 'id' => 1, 'body' => 'ok', 'commentable_type' => 'Transfer', 'commentable_id' => 42 }, + { 'id' => 2, 'body' => 'orphan', 'commentable_type' => nil, 'commentable_id' => nil } + ] + } + ) + + records = comments.list(caller, filter, projection('id', 'body', 'commentable_type', 'commentable:*')) + + expect(records[0]['commentable']).to eq({ '*' => nil }) + expect(records[0]['commentable_id']).to eq(42) + expect(records[0]['commentable_type']).to eq('Transfer') + expect(records[1]['commentable']).to be_nil + end + + it 'resolves regular relations through Hasura nested selections' do + BankingSchema.stub_graphql_data( + { 'comments' => [{ 'id' => 1, 'membership' => { 'full_name' => 'Jane' } }] } + ) + + records = comments.list(caller, filter, projection('id', 'membership:full_name')) + + expect(last_graphql_request['query']).to include('membership { full_name }') + expect(records[0]['membership']).to eq({ 'full_name' => 'Jane' }) + end + + it 'converts the related-data filter of a PolymorphicOneToMany into a flat bool_exp' do + BankingSchema.stub_graphql_data({ 'comments' => [] }) + + condition_tree = branch('And', [ + leaf('commentable_id', operators::EQUAL, 42), + leaf('commentable_type', operators::EQUAL, 'Transfer') + ]) + comments.list(caller, filter(condition_tree: condition_tree), projection('id')) + + expect(last_graphql_request['variables']['where']).to eq( + '_and' => [ + { 'commentable_id' => { '_eq' => 42 } }, + { 'commentable_type' => { '_eq' => 'Transfer' } } + ] + ) + end + + it 'applies sort and pagination' do + BankingSchema.stub_graphql_data({ 'comments' => [] }) + + list_filter = filter( + sort: toolkit_query::Sort.new([{ field: 'created_at', ascending: false }]), + page: toolkit_query::Page.new(offset: 10, limit: 5) + ) + comments.list(caller, list_filter, projection('id')) + + variables = last_graphql_request['variables'] + expect(variables['orderBy']).to eq([{ 'created_at' => 'desc' }]) + expect(variables['limit']).to eq(5) + expect(variables['offset']).to eq(10) + end + end + + describe '#create' do + it 'inserts through Hasura and returns the created record' do + BankingSchema.stub_graphql_data( + { 'insert_comments' => { 'returning' => [{ 'id' => 7, 'body' => 'hello' }] } } + ) + + record = comments.create(caller, { 'body' => 'hello', 'membership_id' => 1 }) + + expect(record).to eq({ 'id' => 7, 'body' => 'hello' }) + request = last_graphql_request + expect(request['query']).to include('insert_comments(objects: $objects)') + expect(request['variables']['objects']).to eq([{ 'body' => 'hello', 'membership_id' => 1 }]) + end + end + + describe '#update' do + it 'updates through Hasura with the converted filter' do + BankingSchema.stub_graphql_data({ 'update_comments' => { 'affected_rows' => 1 } }) + + comments.update(caller, filter(condition_tree: leaf('id', operators::EQUAL, 7)), { 'body' => 'edited' }) + + request = last_graphql_request + expect(request['variables']['where']).to eq({ 'id' => { '_eq' => 7 } }) + expect(request['variables']['set']).to eq({ 'body' => 'edited' }) + end + end + + describe '#delete' do + it 'deletes through Hasura with the converted filter' do + BankingSchema.stub_graphql_data({ 'delete_comments' => { 'affected_rows' => 1 } }) + + comments.delete(caller, filter(condition_tree: leaf('id', operators::IN, [1, 2]))) + + expect(last_graphql_request['variables']['where']).to eq({ 'id' => { '_in' => [1, 2] } }) + end + end + + describe '#aggregate' do + it 'runs simple counts against
_aggregate' do + BankingSchema.stub_graphql_data({ 'comments_aggregate' => { 'aggregate' => { 'count' => 12 } } }) + + aggregation = toolkit_query::Aggregation.new(operation: 'Count') + result = comments.aggregate(caller, filter, aggregation) + + expect(result).to eq([{ 'value' => 12, 'group' => {} }]) + end + + it 'groups on a foreign key through the parent nested aggregate' do + BankingSchema.stub_graphql_data( + { + 'memberships' => [ + { 'id' => 1, 'comments_aggregate' => { 'aggregate' => { 'count' => 3 } } }, + { 'id' => 2, 'comments_aggregate' => { 'aggregate' => { 'count' => 0 } } } + ] + } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Count', groups: [{ field: 'membership_id' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result).to eq([{ 'value' => 3, 'group' => { 'membership_id' => 1 } }]) + end + + it 'rejects grouping on the polymorphic foreign key with a clear error' do + aggregation = toolkit_query::Aggregation.new(operation: 'Count', groups: [{ field: 'commentable_id' }]) + + expect { comments.aggregate(caller, filter, aggregation) } + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /not supported/) + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/datasource_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/datasource_spec.rb new file mode 100644 index 000000000..72d226cc1 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/datasource_spec.rb @@ -0,0 +1,131 @@ +require 'spec_helper' + +module ForestAdminDatasourceGraphqlHasura + RSpec.describe Datasource do + describe 'Rails polymorphism' do + context 'with the Hasura metadata API available' do + subject(:datasource) { BankingSchema.build_datasource } + + it 'registers the collections under their Rails class names' do + expect(datasource.collections.keys).to contain_exactly('Comment', 'Transfer', 'Card', 'Membership') + end + + it 'emits a PolymorphicManyToOne for the commentable association' do + field = datasource.get_collection('Comment').schema[:fields]['commentable'] + + expect(field.type).to eq('PolymorphicManyToOne') + expect(field.foreign_key).to eq('commentable_id') + expect(field.foreign_key_type_field).to eq('commentable_type') + expect(field.foreign_collections).to contain_exactly('Transfer', 'Card') + expect(field.foreign_key_targets).to eq({ 'Transfer' => 'id', 'Card' => 'id' }) + end + + it 'does not expose the per-target Hasura relationships as ManyToOne' do + fields = datasource.get_collection('Comment').schema[:fields] + + expect(fields).not_to have_key('transfer') + expect(fields).not_to have_key('card') + end + + it 'ignores the Hasura _aggregate companion fields' do + fields = datasource.get_collection('Transfer').schema[:fields] + + expect(fields).not_to have_key('comments_aggregate') + end + + it 'marks the polymorphic discriminator columns as read-only' do + fields = datasource.get_collection('Comment').schema[:fields] + + expect(fields['commentable_id'].is_read_only).to be(true) + expect(fields['commentable_type'].is_read_only).to be(true) + end + + it 'emits a matching PolymorphicOneToMany on each target' do + transfer_comments = datasource.get_collection('Transfer').schema[:fields]['comments'] + card_comments = datasource.get_collection('Card').schema[:fields]['comments'] + + expect(transfer_comments.type).to eq('PolymorphicOneToMany') + expect(transfer_comments.foreign_collection).to eq('Comment') + expect(transfer_comments.origin_key).to eq('commentable_id') + expect(transfer_comments.origin_key_target).to eq('id') + expect(transfer_comments.origin_type_field).to eq('commentable_type') + expect(transfer_comments.origin_type_value).to eq('Transfer') + expect(card_comments.origin_type_value).to eq('Card') + end + + it 'pairs both sides so the toolkit resolves the inverse relation' do + inverse = ForestAdminDatasourceToolkit::Utils::Collection.get_inverse_relation( + datasource.get_collection('Transfer'), 'comments' + ) + + expect(inverse).to eq('commentable') + end + + it 'keeps the regular belongs_to as a plain ManyToOne' do + field = datasource.get_collection('Comment').schema[:fields]['membership'] + + expect(field.type).to eq('ManyToOne') + expect(field.foreign_collection).to eq('Membership') + expect(field.foreign_key).to eq('membership_id') + expect(field.foreign_key_target).to eq('id') + end + + it 'detects primary keys from the _by_pk queries' do + fields = datasource.get_collection('Comment').schema[:fields] + + expect(fields['id'].is_primary_key).to be(true) + expect(fields['body'].is_primary_key).to be(false) + end + end + + context 'when the metadata API is blocked (production setup)' do + it 'does not crash and keeps the discriminator columns as plain fields' do + datasource = BankingSchema.build_datasource(metadata_blocked: true) + fields = datasource.get_collection('Comment').schema[:fields] + + expect(fields['commentable_type'].type).to eq('Column') + expect(fields['commentable_id'].type).to eq('Column') + expect(fields).not_to have_key('commentable') + end + + it 'skips relationships whose foreign key cannot be inferred instead of crashing' do + datasource = BankingSchema.build_datasource(metadata_blocked: true) + fields = datasource.get_collection('Comment').schema[:fields] + + expect(fields).not_to have_key('transfer') + expect(fields).not_to have_key('card') + end + + it 'still emits the polymorphic relations when declared in the configuration' do + datasource = BankingSchema.build_datasource( + metadata_blocked: true, + polymorphic_relations: { 'comments' => { 'commentable' => %w[transfers cards] } } + ) + + commentable = datasource.get_collection('Comment').schema[:fields]['commentable'] + transfer_comments = datasource.get_collection('Transfer').schema[:fields]['comments'] + + expect(commentable.type).to eq('PolymorphicManyToOne') + expect(commentable.foreign_collections).to contain_exactly('Transfer', 'Card') + expect(transfer_comments.type).to eq('PolymorphicOneToMany') + expect(transfer_comments.origin_type_value).to eq('Transfer') + end + end + + context 'with namespaced Rails models' do + it 'uses the configured type value and formats the collection name' do + datasource = BankingSchema.build_datasource(type_values: { 'transfers' => 'Banking::Transfer' }) + + expect(datasource.collections.keys).to include('Banking__Transfer') + + commentable = datasource.get_collection('Comment').schema[:fields]['commentable'] + expect(commentable.foreign_collections).to contain_exactly('Banking__Transfer', 'Card') + expect(commentable.foreign_key_targets).to include('Banking__Transfer' => 'id') + + comments = datasource.get_collection('Banking__Transfer').schema[:fields]['comments'] + expect(comments.origin_type_value).to eq('Banking::Transfer') + end + end + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb new file mode 100644 index 000000000..aa1b0916c --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb @@ -0,0 +1,273 @@ +require 'spec_helper' + +RSpec.describe ForestAdminDatasourceGraphqlHasura::Introspection::Introspector do + def scalar(name) = { 'name' => name, 'kind' => 'SCALAR' } + def enum(name) = { 'name' => name, 'kind' => 'ENUM' } + def non_null(of_type) = { 'name' => nil, 'kind' => 'NON_NULL', 'ofType' => of_type } + def list_of(of_type) = { 'name' => nil, 'kind' => 'LIST', 'ofType' => of_type } + def object(name) = { 'name' => name, 'kind' => 'OBJECT' } + def field(name, type) = { 'name' => name, 'type' => type } + + def list_query(table) = field(table, non_null(list_of(non_null(object(table))))) + + def by_pk_query(table, pk_names = ['id']) + { + 'name' => "#{table}_by_pk", + 'type' => object(table), + 'args' => pk_names.map { |name| { 'name' => name, 'type' => non_null(scalar('bigint')) } } + } + end + + def fk_object_rel(name, column) + { 'name' => name, 'using' => { 'foreign_key_constraint_on' => column } } + end + + def manual_object_rel(name, remote_table, mapping, schema: 'public') + { + 'name' => name, + 'using' => { + 'manual_configuration' => { + 'remote_table' => { 'schema' => schema, 'name' => remote_table }, + 'column_mapping' => mapping + } + } + } + end + + def stub_schema(types, query_fields) + WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI) + .with { |request| request.body.include?('IntrospectSchema') } + .to_return( + status: 200, + body: JSON.generate({ 'data' => { '__schema' => { + 'types' => types, + 'queryType' => { 'name' => 'query_root', 'fields' => query_fields } + } } }), + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_metadata(tables) + WebMock.stub_request(:post, BankingSchema::METADATA_URI) + .to_return( + status: 200, + body: JSON.generate({ 'metadata' => { 'version' => 3, 'sources' => [ + { 'name' => 'default', 'kind' => 'postgres', 'tables' => tables } + ] } }), + headers: { 'Content-Type' => 'application/json' } + ) + end + + def build_datasource(**options) + ForestAdminDatasourceGraphqlHasura::Datasource.new(uri: BankingSchema::GRAPHQL_URI, **options) + end + + describe 'a business enum sitting next to a real foreign key' do + before do + stub_schema( + [ + { + 'name' => 'payments', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('account_type', non_null(scalar('String'))), + field('account_id', non_null(scalar('bigint'))), + field('account', object('accounts')) + ] + }, + { + 'name' => 'accounts', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('iban', scalar('String')), + field('payments', non_null(list_of(non_null(object('payments'))))) + ] + } + ], + [list_query('payments'), by_pk_query('payments'), list_query('accounts'), by_pk_query('accounts')] + ) + + stub_metadata( + [ + { + 'table' => { 'schema' => 'public', 'name' => 'payments' }, + 'object_relationships' => [fk_object_rel('account', 'account_id')] + }, + { + 'table' => { 'schema' => 'public', 'name' => 'accounts' }, + 'array_relationships' => [ + { 'name' => 'payments', + 'using' => { 'foreign_key_constraint_on' => { + 'column' => 'account_id', 'table' => { 'schema' => 'public', 'name' => 'payments' } + } } } + ] + } + ] + ) + end + + it 'does not turn a real foreign key next to a _type enum into a polymorphic relation' do + fields = build_datasource.get_collection('Payment').schema[:fields] + + expect(fields['account'].type).to eq('ManyToOne') + expect(fields['account'].foreign_key).to eq('account_id') + expect(fields['account_type'].is_read_only).to be(false) + end + + it 'keeps the reverse has_many intact instead of filtering it by a phantom type' do + comments = build_datasource.get_collection('Account').schema[:fields]['payments'] + + expect(comments.type).to eq('OneToMany') + expect(comments.origin_key).to eq('account_id') + end + end + + describe 'schema and mapping edge cases' do + it 'resolves a foreign key that references a non-id primary key' do + stub_schema( + [ + { + 'name' => 'transfers', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('beneficiary_reference', scalar('String')), + field('beneficiary', object('beneficiaries')) + ] + }, + { + 'name' => 'beneficiaries', 'kind' => 'OBJECT', + 'fields' => [field('reference', non_null(scalar('String'))), field('name', scalar('String'))] + } + ], + [ + list_query('transfers'), by_pk_query('transfers'), + list_query('beneficiaries'), by_pk_query('beneficiaries', ['reference']) + ] + ) + stub_metadata( + [{ 'table' => { 'schema' => 'public', 'name' => 'transfers' }, + 'object_relationships' => [fk_object_rel('beneficiary', 'beneficiary_reference')] }] + ) + + field = build_datasource.get_collection('Transfer').schema[:fields]['beneficiary'] + + expect(field.foreign_key).to eq('beneficiary_reference') + expect(field.foreign_key_target).to eq('reference') + end + + it 'skips a relationship whose column mapping spans several columns' do + stub_schema( + [ + { + 'name' => 'transfers', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('tenant_id', non_null(scalar('bigint'))), + field('account_id', non_null(scalar('bigint'))), + field('account', object('accounts')) + ] + }, + { 'name' => 'accounts', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + [list_query('transfers'), by_pk_query('transfers'), list_query('accounts'), by_pk_query('accounts')] + ) + stub_metadata( + [{ 'table' => { 'schema' => 'public', 'name' => 'transfers' }, + 'object_relationships' => [ + manual_object_rel('account', 'accounts', { 'tenant_id' => 'tenant_id', 'account_id' => 'id' }) + ] }] + ) + + expect(build_datasource.get_collection('Transfer').schema[:fields]).not_to have_key('account') + end + + it 'skips a table with no detectable primary key instead of exposing a broken collection' do + stub_schema( + [{ 'name' => 'transfer_stats', 'kind' => 'OBJECT', + 'fields' => [field('transfer_id', scalar('bigint')), field('total', scalar('bigint'))] }], + [list_query('transfer_stats')] + ) + stub_metadata([]) + + expect(build_datasource.collections).to be_empty + end + + it 'ignores relationship metadata of a table name tracked in several schemas' do + stub_schema( + [ + { + 'name' => 'transfers', 'kind' => 'OBJECT', + 'fields' => [field('id', non_null(scalar('bigint'))), field('account', object('accounts'))] + }, + { 'name' => 'accounts', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + [list_query('transfers'), by_pk_query('transfers'), list_query('accounts'), by_pk_query('accounts')] + ) + stub_metadata( + [ + { 'table' => { 'schema' => 'public', 'name' => 'transfers' }, + 'object_relationships' => [fk_object_rel('account', 'account_id')] }, + { 'table' => { 'schema' => 'banking', 'name' => 'transfers' }, + 'object_relationships' => [fk_object_rel('account', 'other_account_id')] } + ] + ) + + # Falls back to the naming convention, which finds no `account_id` column. + expect(build_datasource.get_collection('Transfer').schema[:fields]).not_to have_key('account') + end + end + + describe 'operators advertised per column type' do + before do + stub_schema( + [ + { + 'name' => 'cards', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('status', non_null(enum('card_status'))), + field('tags', scalar('_text')), + field('label', scalar('String')) + ] + }, + { 'name' => 'card_status', 'kind' => 'ENUM', 'enumValues' => [{ 'name' => 'active' }] } + ], + [list_query('cards'), by_pk_query('cards')] + ) + stub_metadata([]) + end + + let(:fields) { build_datasource.get_collection('Card').schema[:fields] } + + # Hasura enum comparison expressions have no _like/_ilike. + it 'does not advertise pattern operators on enum columns' do + expect(fields['status'].filter_operators).to include('equal', 'in') + expect(fields['status'].filter_operators).not_to include('contains', 'i_contains', 'like') + end + + it 'advertises only translatable operators on array columns' do + expect(fields['tags'].filter_operators).not_to include('includes_all', 'contains') + expect(fields['tags'].column_type).to eq(['String']) + end + + it 'lets the toolkit emulate Present on text columns' do + expect(fields['label'].filter_operators).to include('not_in') + expect(fields['label'].filter_operators).not_to include('present') + end + + it 'marks columns as non-groupable except the foreign keys grouping supports' do + expect(fields['status'].is_groupable).to be(false) + expect(fields['label'].is_groupable).to be(false) + end + end + + describe 'groupable foreign keys' do + it 'marks a ManyToOne foreign key as groupable' do + datasource = BankingSchema.build_datasource + fields = datasource.get_collection('Comment').schema[:fields] + + expect(fields['membership_id'].is_groupable).to be(true) + expect(fields['body'].is_groupable).to be(false) + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/query/filter_converter_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/query/filter_converter_spec.rb new file mode 100644 index 000000000..0c11e3dfc --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/query/filter_converter_spec.rb @@ -0,0 +1,88 @@ +require 'spec_helper' + +RSpec.describe ForestAdminDatasourceGraphqlHasura::Query::FilterConverter do + def nodes + ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes + end + + def operators + ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + end + + def leaf(field, operator, value = nil) + nodes::ConditionTreeLeaf.new(field, operator, value) + end + + it 'returns nil for a nil tree' do + expect(described_class.convert(nil)).to be_nil + end + + it 'converts comparison operators' do + expect(described_class.convert(leaf('a', operators::EQUAL, 1))).to eq({ 'a' => { '_eq' => 1 } }) + expect(described_class.convert(leaf('a', operators::NOT_EQUAL, 1))).to eq({ 'a' => { '_neq' => 1 } }) + expect(described_class.convert(leaf('a', operators::GREATER_THAN, 1))).to eq({ 'a' => { '_gt' => 1 } }) + expect(described_class.convert(leaf('a', operators::LESS_THAN, 1))).to eq({ 'a' => { '_lt' => 1 } }) + expect(described_class.convert(leaf('a', operators::IN, [1, 2]))).to eq({ 'a' => { '_in' => [1, 2] } }) + expect(described_class.convert(leaf('a', operators::NOT_IN, [1]))).to eq({ 'a' => { '_nin' => [1] } }) + end + + it 'converts null-checking operators' do + expect(described_class.convert(leaf('a', operators::EQUAL))).to eq({ 'a' => { '_is_null' => true } }) + expect(described_class.convert(leaf('a', operators::PRESENT))).to eq({ 'a' => { '_is_null' => false } }) + expect(described_class.convert(leaf('a', operators::MISSING))).to eq({ 'a' => { '_is_null' => true } }) + end + + it 'converts string operators to case-insensitive like patterns' do + expect(described_class.convert(leaf('a', operators::CONTAINS, 'x'))).to eq({ 'a' => { '_ilike' => '%x%' } }) + expect(described_class.convert(leaf('a', operators::I_CONTAINS, 'x'))).to eq({ 'a' => { '_ilike' => '%x%' } }) + expect(described_class.convert(leaf('a', operators::STARTS_WITH, 'x'))).to eq({ 'a' => { '_ilike' => 'x%' } }) + expect(described_class.convert(leaf('a', operators::ENDS_WITH, 'x'))).to eq({ 'a' => { '_ilike' => '%x' } }) + expect(described_class.convert(leaf('a', operators::NOT_CONTAINS, 'x'))).to eq({ 'a' => { '_nilike' => '%x%' } }) + end + + it 'escapes LIKE wildcards so a literal % or _ is searched' do + expect(described_class.convert(leaf('a', operators::CONTAINS, '100%'))) + .to eq({ 'a' => { '_ilike' => '%100\\%%' } }) + expect(described_class.convert(leaf('a', operators::CONTAINS, 'a_b'))) + .to eq({ 'a' => { '_ilike' => '%a\\_b%' } }) + expect(described_class.convert(leaf('a', operators::CONTAINS, 'c:\\x'))) + .to eq({ 'a' => { '_ilike' => '%c:\\\\x%' } }) + end + + # A `String_comparison_exp` has no `_and`/`_or` field, hence the combination + # one level up. + it 'converts In/NotIn containing nil into explicit null checks combined at bool_exp level' do + expect(described_class.convert(leaf('a', operators::IN, [nil, '']))) + .to eq({ '_or' => [{ 'a' => { '_is_null' => true } }, { 'a' => { '_in' => [''] } }] }) + expect(described_class.convert(leaf('a', operators::NOT_IN, [nil, '']))) + .to eq({ '_and' => [{ 'a' => { '_is_null' => false } }, { 'a' => { '_nin' => [''] } }] }) + expect(described_class.convert(leaf('a', operators::IN, [nil]))).to eq({ 'a' => { '_is_null' => true } }) + expect(described_class.convert(leaf('a', operators::NOT_IN, [nil]))).to eq({ 'a' => { '_is_null' => false } }) + end + + it 'keeps the relation path on both sides of a null-aware In through a relation' do + expect(described_class.convert(leaf('membership:full_name', operators::IN, [nil, '']))) + .to eq({ '_or' => [ + { 'membership' => { 'full_name' => { '_is_null' => true } } }, + { 'membership' => { 'full_name' => { '_in' => [''] } } } + ] }) + end + + it 'converts nested relation paths to nested bool_exps' do + expect(described_class.convert(leaf('membership:full_name', operators::EQUAL, 'Jane'))) + .to eq({ 'membership' => { 'full_name' => { '_eq' => 'Jane' } } }) + end + + it 'converts And/Or branches' do + tree = nodes::ConditionTreeBranch.new('Or', [leaf('a', operators::EQUAL, 1), leaf('b', operators::EQUAL, 2)]) + + expect(described_class.convert(tree)).to eq( + '_or' => [{ 'a' => { '_eq' => 1 } }, { 'b' => { '_eq' => 2 } }] + ) + end + + it 'raises on unsupported operators' do + expect { described_class.convert(leaf('a', operators::LONGER_THAN, 3)) } + .to raise_error(ForestAdminDatasourceGraphqlHasura::GraphqlError, /Unsupported operator/) + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/spec_helper.rb b/packages/forest_admin_datasource_graphql_hasura/spec/spec_helper.rb new file mode 100644 index 000000000..7474742ec --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/spec/spec_helper.rb @@ -0,0 +1,39 @@ +require 'simplecov' +begin + require 'simplecov_json_formatter' + require 'simplecov-html' + SimpleCov.formatters = [SimpleCov::Formatter::JSONFormatter, SimpleCov::Formatter::HTMLFormatter] +rescue LoadError + # Local Gemfile run without the CI formatters; default text output is fine. +end + +SimpleCov.start do + add_filter '/spec/' + enable_coverage :branch + minimum_coverage 80 +end + +SimpleCov.coverage_dir 'coverage' + +require 'webmock/rspec' +require 'forest_admin_datasource_toolkit' +require 'forest_admin_datasource_graphql_hasura' + +Dir[File.join(__dir__, 'support', '**', '*.rb')].each { |file| require file } + +WebMock.disable_net_connect!(allow_localhost: true) + +RSpec.configure do |config| + config.expect_with :rspec do |c| + c.syntax = :expect + end + config.mock_with :rspec do |m| + m.verify_partial_doubles = true + end + config.disable_monkey_patching! + config.warnings = false + config.order = :random + Kernel.srand config.seed + + config.before { WebMock.reset! } +end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/support/banking_schema.rb b/packages/forest_admin_datasource_graphql_hasura/spec/support/banking_schema.rb new file mode 100644 index 000000000..394452962 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/spec/support/banking_schema.rb @@ -0,0 +1,210 @@ +# Simulated Rails banking schema exposed through Hasura: +# +# class Comment < ApplicationRecord +# belongs_to :commentable, polymorphic: true # commentable_type + commentable_id +# belongs_to :membership +# end +# class Transfer < ApplicationRecord; has_many :comments, as: :commentable; end +# class Card < ApplicationRecord; has_many :comments, as: :commentable; end +module BankingSchema + GRAPHQL_URI = 'http://hasura.test/v1/graphql'.freeze + METADATA_URI = 'http://hasura.test/v1/metadata'.freeze + + module_function + + def scalar(name) + { 'name' => name, 'kind' => 'SCALAR' } + end + + def non_null(of_type) + { 'name' => nil, 'kind' => 'NON_NULL', 'ofType' => of_type } + end + + def list_of(of_type) + { 'name' => nil, 'kind' => 'LIST', 'ofType' => of_type } + end + + def object(name) + { 'name' => name, 'kind' => 'OBJECT' } + end + + def field(name, type) + { 'name' => name, 'type' => type } + end + + def list_query_field(table) + field(table, non_null(list_of(non_null(object(table))))) + end + + def by_pk_query_field(table) + { + 'name' => "#{table}_by_pk", + 'type' => object(table), + 'args' => [{ 'name' => 'id', 'type' => non_null(scalar('bigint')) }] + } + end + + def introspection_response + { + 'data' => { + '__schema' => { + 'types' => [ + { + 'name' => 'comments', + 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('body', scalar('String')), + field('commentable_type', non_null(scalar('String'))), + field('commentable_id', non_null(scalar('bigint'))), + field('membership_id', scalar('bigint')), + field('created_at', non_null(scalar('timestamptz'))), + field('membership', object('memberships')), + field('transfer', object('transfers')), + field('card', object('cards')) + ] + }, + { + 'name' => 'transfers', + 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('amount_cents', non_null(scalar('bigint'))), + field('status', scalar('String')), + field('comments', non_null(list_of(non_null(object('comments'))))), + field('comments_aggregate', non_null(object('comments_aggregate'))) + ] + }, + { + 'name' => 'cards', + 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('last4', scalar('String')), + field('comments', non_null(list_of(non_null(object('comments'))))) + ] + }, + { + 'name' => 'memberships', + 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('full_name', scalar('String')), + field('comments', non_null(list_of(non_null(object('comments'))))) + ] + } + ], + 'queryType' => { + 'name' => 'query_root', + 'fields' => [ + list_query_field('comments'), by_pk_query_field('comments'), + list_query_field('transfers'), by_pk_query_field('transfers'), + list_query_field('cards'), by_pk_query_field('cards'), + list_query_field('memberships'), by_pk_query_field('memberships') + ] + } + } + } + } + end + + def manual_object_relationship(name, remote_table, column_mapping) + { + 'name' => name, + 'using' => { + 'manual_configuration' => { + 'remote_table' => { 'schema' => 'public', 'name' => remote_table }, + 'column_mapping' => column_mapping + } + } + } + end + + def manual_array_relationship(name, remote_table, column_mapping) + manual_object_relationship(name, remote_table, column_mapping) + end + + # All a Rails team can declare in Hasura for a polymorphic belongs_to: one + # manual relationship per target, joining on the foreign key alone, since a + # column_mapping cannot carry the type condition. + def metadata_response + { + 'metadata' => { + 'version' => 3, + 'sources' => [ + { + 'name' => 'default', + 'kind' => 'postgres', + 'tables' => [ + { + 'table' => { 'schema' => 'public', 'name' => 'comments' }, + 'object_relationships' => [ + { 'name' => 'membership', 'using' => { 'foreign_key_constraint_on' => 'membership_id' } }, + manual_object_relationship('transfer', 'transfers', { 'commentable_id' => 'id' }), + manual_object_relationship('card', 'cards', { 'commentable_id' => 'id' }) + ] + }, + { + 'table' => { 'schema' => 'public', 'name' => 'transfers' }, + 'array_relationships' => [ + manual_array_relationship('comments', 'comments', { 'id' => 'commentable_id' }) + ] + }, + { + 'table' => { 'schema' => 'public', 'name' => 'cards' }, + 'array_relationships' => [ + manual_array_relationship('comments', 'comments', { 'id' => 'commentable_id' }) + ] + }, + { + 'table' => { 'schema' => 'public', 'name' => 'memberships' }, + 'array_relationships' => [ + { + 'name' => 'comments', + 'using' => { + 'foreign_key_constraint_on' => { + 'column' => 'membership_id', + 'table' => { 'schema' => 'public', 'name' => 'comments' } + } + } + } + ] + } + ] + } + ] + } + } + end + + def stub_introspection + WebMock::API.stub_request(:post, GRAPHQL_URI) + .with { |request| request.body.include?('IntrospectSchema') } + .to_return(status: 200, body: JSON.generate(introspection_response), + headers: { 'Content-Type' => 'application/json' }) + end + + def stub_metadata(available: true) + if available + WebMock::API.stub_request(:post, METADATA_URI) + .to_return(status: 200, body: JSON.generate(metadata_response), + headers: { 'Content-Type' => 'application/json' }) + else + WebMock::API.stub_request(:post, METADATA_URI).to_return(status: 403, body: '{}') + end + end + + def stub_graphql_data(data) + WebMock::API.stub_request(:post, GRAPHQL_URI) + .with { |request| !request.body.include?('IntrospectSchema') } + .to_return(status: 200, body: JSON.generate({ 'data' => data }), + headers: { 'Content-Type' => 'application/json' }) + end + + def build_datasource(**options) + stub_introspection + stub_metadata(available: !options.delete(:metadata_blocked)) + + ForestAdminDatasourceGraphqlHasura::Datasource.new(uri: GRAPHQL_URI, **options) + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/validation/docker-compose.yml b/packages/forest_admin_datasource_graphql_hasura/validation/docker-compose.yml new file mode 100644 index 000000000..780502a94 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/validation/docker-compose.yml @@ -0,0 +1,29 @@ +# Local validation stack: Postgres seeded with a Rails-like banking schema + Hasura. +# Usage: docker compose up -d, then ruby validate.rb +services: + postgres: + image: postgres:15 + ports: + - '55432:5432' + environment: + POSTGRES_PASSWORD: postgrespassword + POSTGRES_DB: banking + volumes: + - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres'] + interval: 2s + timeout: 3s + retries: 30 + + hasura: + image: hasura/graphql-engine:v2.44.0 + ports: + - '58080:8080' + depends_on: + postgres: + condition: service_healthy + environment: + HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/banking + HASURA_GRAPHQL_ENABLE_CONSOLE: 'false' + HASURA_GRAPHQL_ADMIN_SECRET: hasura-validation-secret diff --git a/packages/forest_admin_datasource_graphql_hasura/validation/init.sql b/packages/forest_admin_datasource_graphql_hasura/validation/init.sql new file mode 100644 index 000000000..6669fadfa --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/validation/init.sql @@ -0,0 +1,97 @@ +-- Rails-like banking schema covering the supported polymorphism scenarios: +-- * comments.commentable -> Transfer | Card (multi-target, bigint PKs) +-- * attachments.attachable -> Transfer | Banking::BankAccount (namespaced model) +-- * attachments.author -> Membership (single-target polymorphic) +-- * attachments has a uuid primary key +-- * cards uses STI (type column, one legacy row stores the subclass name) +-- * card_memberships is a join table with a composite primary key +-- * dangling and null polymorphic references + +CREATE TABLE memberships ( + id bigserial PRIMARY KEY, + full_name text NOT NULL +); + +CREATE TYPE card_status AS ENUM ('active', 'blocked'); + +CREATE TABLE transfers ( + id bigserial PRIMARY KEY, + amount_cents bigint NOT NULL, + status varchar, + tags text[], + -- The false-positive trap: a business enum column named like a polymorphic + -- discriminator, sitting next to a real foreign key. + beneficiary_type varchar, + beneficiary_id bigint REFERENCES memberships(id), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE cards ( + id bigserial PRIMARY KEY, + last4 varchar, + status card_status NOT NULL DEFAULT 'active', + type varchar NOT NULL DEFAULT 'Card', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE bank_accounts ( + id bigserial PRIMARY KEY, + iban text NOT NULL +); + +CREATE TABLE comments ( + id bigserial PRIMARY KEY, + body text, + metadata jsonb, + commentable_type varchar, + commentable_id bigint, + membership_id bigint REFERENCES memberships(id), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE attachments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + file_name text NOT NULL, + attachable_type varchar NOT NULL, + attachable_id bigint NOT NULL, + author_type varchar, + author_id bigint +); + +CREATE TABLE card_memberships ( + card_id bigint NOT NULL REFERENCES cards(id), + membership_id bigint NOT NULL REFERENCES memberships(id), + PRIMARY KEY (card_id, membership_id) +); + +-- A view without a primary key: must be skipped, not exposed broken. +CREATE VIEW transfer_stats AS + SELECT beneficiary_id, sum(amount_cents) AS total_cents FROM transfers GROUP BY beneficiary_id; + +INSERT INTO memberships (full_name) VALUES ('Jane Doe'), ('John Smith'); +INSERT INTO transfers (amount_cents, status, tags, beneficiary_type, beneficiary_id) VALUES + (125000, 'completed', ARRAY['urgent', 'sepa'], 'internal', 1), + (9900, 'pending', NULL, 'external', 2); +INSERT INTO cards (last4, status, type) VALUES ('4242', 'active', 'Card'), ('9999', 'blocked', 'FlashCard'); +INSERT INTO bank_accounts (iban) VALUES ('FR7630006000011234567890189'); + +-- The faux-join trap: Transfer#1 and Card#1 both have comments. +INSERT INTO comments (body, metadata, commentable_type, commentable_id, membership_id) VALUES + ('on transfer 1', '{"source": "api"}', 'Transfer', 1, 1), + ('on card 1', NULL, 'Card', 1, 1), + ('second on transfer 1', NULL, 'Transfer', 1, 2), + ('legacy sti row', NULL, 'FlashCard', 2, 1), + ('dangling target', NULL, 'Transfer', 999, 2), + ('no target', NULL, NULL, NULL, 1), + -- Empty string vs NULL: "is present" and "is blank" must not overlap. + ('', NULL, 'Transfer', 2, 1), + (NULL, NULL, 'Transfer', 2, 1), + -- Literal wildcards: a "contains 100%" search must not match these both. + ('discount 100% applied', NULL, 'Transfer', 2, 1), + ('discount 1000 applied', NULL, 'Transfer', 2, 1); + +INSERT INTO attachments (file_name, attachable_type, attachable_id, author_type, author_id) VALUES + ('invoice.pdf', 'Transfer', 1, 'Membership', 1), + ('rib.pdf', 'Banking::BankAccount', 1, 'Membership', 2); + +INSERT INTO card_memberships (card_id, membership_id) VALUES (1, 1), (1, 2); diff --git a/packages/forest_admin_datasource_graphql_hasura/validation/setup_hasura.sh b/packages/forest_admin_datasource_graphql_hasura/validation/setup_hasura.sh new file mode 100644 index 000000000..17f856552 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/validation/setup_hasura.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Tracks the tables and declares the relationships a Rails team would actually +# configure in Hasura (FK-based where a real FK exists, manual per polymorphic +# target elsewhere — the type condition cannot be expressed). +set -euo pipefail + +HASURA_URL="${HASURA_URL:-http://localhost:58080}" +ADMIN_SECRET="${ADMIN_SECRET:-hasura-validation-secret}" + +metadata() { + local body="$1" + local response + response=$(curl -sS -X POST "$HASURA_URL/v1/metadata" \ + -H "x-hasura-admin-secret: $ADMIN_SECRET" \ + -H 'Content-Type: application/json' \ + -d "$body") + if echo "$response" | grep -q '"error"'; then + echo "FAILED: $body" >&2 + echo "$response" >&2 + exit 1 + fi +} + +for table in memberships transfers cards bank_accounts comments attachments card_memberships transfer_stats; do + metadata "{\"type\":\"pg_track_table\",\"args\":{\"source\":\"default\",\"table\":{\"schema\":\"public\",\"name\":\"$table\"}}}" +done + +object_rel_fk() { # table name fk_column + metadata "{\"type\":\"pg_create_object_relationship\",\"args\":{\"source\":\"default\",\"table\":{\"schema\":\"public\",\"name\":\"$1\"},\"name\":\"$2\",\"using\":{\"foreign_key_constraint_on\":\"$3\"}}}" +} + +object_rel_manual() { # table name remote_table local_col remote_col + metadata "{\"type\":\"pg_create_object_relationship\",\"args\":{\"source\":\"default\",\"table\":{\"schema\":\"public\",\"name\":\"$1\"},\"name\":\"$2\",\"using\":{\"manual_configuration\":{\"remote_table\":{\"schema\":\"public\",\"name\":\"$3\"},\"column_mapping\":{\"$4\":\"$5\"}}}}}" +} + +array_rel_fk() { # table name remote_table fk_column + metadata "{\"type\":\"pg_create_array_relationship\",\"args\":{\"source\":\"default\",\"table\":{\"schema\":\"public\",\"name\":\"$1\"},\"name\":\"$2\",\"using\":{\"foreign_key_constraint_on\":{\"table\":{\"schema\":\"public\",\"name\":\"$3\"},\"column\":\"$4\"}}}}" +} + +array_rel_manual() { # table name remote_table local_col remote_col + metadata "{\"type\":\"pg_create_array_relationship\",\"args\":{\"source\":\"default\",\"table\":{\"schema\":\"public\",\"name\":\"$1\"},\"name\":\"$2\",\"using\":{\"manual_configuration\":{\"remote_table\":{\"schema\":\"public\",\"name\":\"$3\"},\"column_mapping\":{\"$4\":\"$5\"}}}}}" +} + +# transfers.beneficiary: a real FK sitting next to the `beneficiary_type` enum — +# must stay a plain ManyToOne, never be absorbed into a polymorphic relation. +object_rel_fk transfers beneficiary beneficiary_id +array_rel_fk memberships transfers transfers beneficiary_id + +# comments: real FK + one manual relationship per polymorphic target +object_rel_fk comments membership membership_id +object_rel_manual comments transfer transfers commentable_id id +object_rel_manual comments card cards commentable_id id + +# attachments: two polymorphic belongs_to (attachable multi-target, author single-target) +object_rel_manual attachments transfer transfers attachable_id id +object_rel_manual attachments bank_account bank_accounts attachable_id id +object_rel_manual attachments membership memberships author_id id + +# reverse sides +array_rel_fk memberships comments comments membership_id +array_rel_manual memberships attachments attachments id author_id +array_rel_manual transfers comments comments id commentable_id +array_rel_manual transfers attachments attachments id attachable_id +array_rel_manual cards comments comments id commentable_id +array_rel_manual bank_accounts attachments attachments id attachable_id + +echo 'Hasura metadata configured.' diff --git a/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb b/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb new file mode 100644 index 000000000..177a4286b --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb @@ -0,0 +1,393 @@ +# End-to-end validation against a real Hasura instance (see docker-compose.yml). +# +# docker compose -f validation/docker-compose.yml up -d +# bash validation/setup_hasura.sh +# BUNDLE_GEMFILE=Gemfile-test bundle exec ruby validation/validate.rb +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) + +require 'forest_admin_datasource_toolkit' +require 'forest_admin_datasource_customizer' +require 'forest_admin_datasource_graphql_hasura' + +URI_GRAPHQL = ENV.fetch('HASURA_URL', 'http://localhost:58080/v1/graphql') +HEADERS = { 'x-hasura-admin-secret' => ENV.fetch('ADMIN_SECRET', 'hasura-validation-secret') }.freeze + +Query = ForestAdminDatasourceToolkit::Components::Query +Nodes = Query::ConditionTree::Nodes +Operators = Query::ConditionTree::Operators + +RESULTS = [] # rubocop:disable Style/MutableConstant -- scenario accumulator + +def scenario(name) + yield + RESULTS << [name, :pass, nil] + puts " \e[32mPASS\e[0m #{name}" +rescue StandardError => e + RESULTS << [name, :fail, e] + puts " \e[31mFAIL\e[0m #{name}\n #{e.class}: #{e.message.lines.first&.strip}" +end + +def assert(condition, message) + raise "assertion failed: #{message}" unless condition +end + +def assert_equal(expected, actual, message) + raise "#{message}\n expected: #{expected.inspect}\n actual: #{actual.inspect}" unless expected == actual +end + +def projection(*fields) = Query::Projection.new(fields) +def filter(**options) = Query::Filter.new(**options) +def leaf(field, operator, value = nil) = Nodes::ConditionTreeLeaf.new(field, operator, value) +def branch(aggregator, conditions) = Nodes::ConditionTreeBranch.new(aggregator, conditions) + +puts "\n== Introspection (metadata API available) ==" + +datasource = ForestAdminDatasourceGraphqlHasura::Datasource.new( + uri: URI_GRAPHQL, + headers: HEADERS, + type_values: { 'bank_accounts' => 'Banking::BankAccount' } +) + +scenario 'collections are registered under Rails class names' do + expected = %w[Attachment Banking__BankAccount Card CardMembership Comment Membership Transfer] + assert_equal expected, datasource.collections.keys.sort, 'collection names' +end + +scenario 'comments.commentable is a PolymorphicManyToOne (Transfer | Card)' do + field = datasource.get_collection('Comment').schema[:fields]['commentable'] + assert_equal 'PolymorphicManyToOne', field.type, 'relation type' + assert_equal %w[Card Transfer], field.foreign_collections.sort, 'targets' + assert_equal({ 'Transfer' => 'id', 'Card' => 'id' }, field.foreign_key_targets, 'targets pks') + fields = datasource.get_collection('Comment').schema[:fields] + assert !fields.key?('transfer') && !fields.key?('card'), 'per-target relations must be absorbed' +end + +scenario 'attachments has two polymorphic belongs_to (attachable multi-target namespaced, author single-target)' do + fields = datasource.get_collection('Attachment').schema[:fields] + assert_equal 'PolymorphicManyToOne', fields['attachable'].type, 'attachable type' + assert_equal %w[Banking__BankAccount Transfer], fields['attachable'].foreign_collections.sort, 'attachable targets' + assert_equal 'PolymorphicManyToOne', fields['author'].type, 'author type' + assert_equal %w[Membership], fields['author'].foreign_collections, 'author targets' +end + +scenario 'uuid and composite primary keys are detected' do + attachment_fields = datasource.get_collection('Attachment').schema[:fields] + assert_equal 'Uuid', attachment_fields['id'].column_type, 'attachments.id type' + assert attachment_fields['id'].is_primary_key, 'attachments.id must be pk' + + join_fields = datasource.get_collection('CardMembership').schema[:fields] + assert join_fields['card_id'].is_primary_key && join_fields['membership_id'].is_primary_key, + 'card_memberships composite pk' +end + +scenario 'reverse PolymorphicOneToMany are emitted with the raw stored type value' do + transfers = datasource.get_collection('Transfer').schema[:fields] + assert_equal 'PolymorphicOneToMany', transfers['comments'].type, 'Transfer.comments' + assert_equal 'Transfer', transfers['comments'].origin_type_value, 'Transfer.comments type value' + assert_equal 'PolymorphicOneToMany', transfers['attachments'].type, 'Transfer.attachments' + + bank_accounts = datasource.get_collection('Banking__BankAccount').schema[:fields] + assert_equal 'Banking::BankAccount', bank_accounts['attachments'].origin_type_value, + 'namespaced raw type value' + + memberships = datasource.get_collection('Membership').schema[:fields] + assert_equal 'PolymorphicOneToMany', memberships['attachments'].type, 'Membership.attachments (author)' + assert_equal 'OneToMany', memberships['comments'].type, 'Membership.comments stays a plain OneToMany' +end + +scenario 'toolkit pairs both sides (inverseOf)' do + inverse = ForestAdminDatasourceToolkit::Utils::Collection.get_inverse_relation( + datasource.get_collection('Transfer'), 'comments' + ) + assert_equal 'commentable', inverse, 'inverse relation' +end + +puts "\n== Runtime: list ==" + +comments = datasource.get_collection('Comment') + +scenario 'list with commentable:* returns phantom records built from the discriminator columns' do + records = comments.list(nil, filter, projection('id', 'body', 'commentable_type', 'commentable:*')) + by_body = records.to_h { |record| [record['body'], record] } + + assert_equal({ '*' => nil }, by_body['on transfer 1']['commentable'], 'phantom for transfer comment') + assert_equal 1, by_body['on transfer 1']['commentable_id'], 'fk selected even if not projected' + assert_equal({ '*' => nil }, by_body['dangling target']['commentable'], 'dangling id still yields a phantom') + assert by_body['no target']['commentable'].nil?, 'null reference yields nil' +end + +scenario 'the faux-join trap is dead: related data of Transfer#1 excludes Card#1 comments' do + condition = branch('And', [ + leaf('commentable_id', Operators::EQUAL, 1), + leaf('commentable_type', Operators::EQUAL, 'Transfer') + ]) + records = comments.list(nil, filter(condition_tree: condition), projection('id', 'body')) + + assert_equal ['on transfer 1', 'second on transfer 1'], records.map { |r| r['body'] }.sort, 'transfer comments' +end + +scenario 'regular ManyToOne joins through Hasura' do + records = comments.list( + nil, + filter(condition_tree: leaf('body', Operators::EQUAL, 'on card 1')), + projection('id', 'membership:full_name') + ) + assert_equal 'Jane Doe', records.first&.dig('membership', 'full_name'), 'joined membership' +end + +scenario 'sort, pagination and operators' do + records = comments.list( + nil, + filter( + condition_tree: leaf('body', Operators::I_CONTAINS, 'transfer'), + sort: Query::Sort.new([{ field: 'id', ascending: false }]), + page: Query::Page.new(offset: 0, limit: 1) + ), + projection('id', 'body') + ) + assert_equal ['second on transfer 1'], records.map { |r| r['body'] }, 'filtered+sorted+paged' +end + +scenario 'uuid-pk collection lists with its polymorphic relations' do + attachments = datasource.get_collection('Attachment') + records = attachments.list(nil, filter, projection('id', 'file_name', 'attachable:*', 'author:*')) + rib = records.find { |r| r['file_name'] == 'rib.pdf' } + + assert_equal 'Banking::BankAccount', rib['attachable_type'], 'namespaced raw type in record' + assert_equal({ '*' => nil }, rib['attachable'], 'attachable phantom') + assert_equal({ '*' => nil }, rib['author'], 'author phantom') +end + +puts "\n== Runtime: writes ==" + +created_id = nil + +scenario 'create / update / delete a comment' do + record = comments.create(nil, { + 'body' => 'validation temp', + 'commentable_type' => 'Transfer', + 'commentable_id' => 2, + 'membership_id' => 1 + }) + created_id = record['id'] + assert created_id, 'created id returned' + + comments.update(nil, filter(condition_tree: leaf('id', Operators::EQUAL, created_id)), { 'body' => 'edited' }) + after = comments.list(nil, filter(condition_tree: leaf('id', Operators::EQUAL, created_id)), + projection('id', 'body')) + assert_equal 'edited', after.first&.fetch('body'), 'update applied' + + comments.delete(nil, filter(condition_tree: leaf('id', Operators::EQUAL, created_id))) + gone = comments.list(nil, filter(condition_tree: leaf('id', Operators::EQUAL, created_id)), projection('id')) + assert gone.empty?, 'record deleted' +end + +puts "\n== Runtime: aggregates ==" + +scenario 'simple count with filter' do + aggregation = Query::Aggregation.new(operation: 'Count') + condition = leaf('commentable_type', Operators::EQUAL, 'Transfer') + result = comments.aggregate(nil, filter(condition_tree: condition), aggregation) + assert_equal 7, result.first&.fetch('value'), 'transfer-typed comments count' +end + +scenario 'count grouped by foreign key (chart use case)' do + aggregation = Query::Aggregation.new(operation: 'Count', groups: [{ field: 'membership_id' }]) + result = comments.aggregate(nil, filter, aggregation) + grouped = result.to_h { |row| [row['group']['membership_id'], row['value']] } + assert_equal({ 1 => 8, 2 => 2 }, grouped, 'counts per membership') +end + +puts "\n== Config modes ==" + +scenario 'metadata API blocked: polymorphic_relations configuration takes over' do + blocked = ForestAdminDatasourceGraphqlHasura::Datasource.new( + uri: URI_GRAPHQL, + headers: HEADERS, + metadata_uri: 'http://localhost:58080/v1/metadata-blocked', + type_values: { 'bank_accounts' => 'Banking::BankAccount' }, + polymorphic_relations: { + 'comments' => { 'commentable' => %w[transfers cards] }, + 'attachments' => { 'attachable' => %w[transfers bank_accounts], 'author' => %w[memberships] } + } + ) + + commentable = blocked.get_collection('Comment').schema[:fields]['commentable'] + assert_equal 'PolymorphicManyToOne', commentable.type, 'commentable via config' + assert_equal %w[Card Transfer], commentable.foreign_collections.sort, 'targets via config' + + records = blocked.get_collection('Comment').list(nil, filter, projection('id', 'body', 'commentable:*')) + assert records.any? { |r| r['commentable'] == { '*' => nil } }, 'phantoms still materialized' +end + +scenario 'STI legacy rows: subclass name stored in the type column is surfaced as-is' do + records = comments.list( + nil, + filter(condition_tree: leaf('body', Operators::EQUAL, 'legacy sti row')), + projection('id', 'body', 'commentable_type', 'commentable:*') + ) + # Rails stores base_class.name since 6.1, so only legacy rows hold a subclass + # name — which matches no collection and stays unresolved. + assert_equal 'FlashCard', records.first&.fetch('commentable_type'), 'raw legacy value kept' +end + +puts "\n== Adversarial-review regressions ==" + +scenario 'a real FK next to a _type enum stays a plain ManyToOne' do + fields = datasource.get_collection('Transfer').schema[:fields] + + assert_equal 'ManyToOne', fields['beneficiary'].type, 'beneficiary relation type' + assert_equal 'beneficiary_id', fields['beneficiary'].foreign_key, 'beneficiary fk' + assert_equal false, fields['beneficiary_type'].is_read_only, 'business enum stays writable' + assert_equal 'OneToMany', datasource.get_collection('Membership').schema[:fields]['transfers'].type, + 'reverse has_many stays a plain OneToMany' +end + +scenario 'the reverse has_many of that FK returns rows (not filtered by a phantom type)' do + transfers = datasource.get_collection('Transfer') + records = transfers.list(nil, filter(condition_tree: leaf('beneficiary_id', Operators::EQUAL, 1)), + projection('id', 'amount_cents')) + + assert_equal 1, records.size, 'transfers of membership 1' +end + +scenario 'a view without a primary key is not exposed' do + assert !datasource.collections.key?('TransferStat'), 'PK-less view must be skipped' +end + +scenario 'enum columns do not advertise pattern operators, array columns stay filterable-safe' do + card_fields = datasource.get_collection('Card').schema[:fields] + transfer_fields = datasource.get_collection('Transfer').schema[:fields] + + assert !card_fields['status'].filter_operators.include?('i_contains'), 'no ilike on a PG enum' + assert !transfer_fields['tags'].filter_operators.include?('includes_all'), 'no unsupported array operator' + assert_equal ['String'], transfer_fields['tags'].column_type, 'PG array detected' +end + +scenario 'Sum over zero rows returns a row (charts read result[0])' do + transfers = datasource.get_collection('Transfer') + aggregation = Query::Aggregation.new(operation: 'Sum', field: 'amount_cents') + result = transfers.aggregate(nil, filter(condition_tree: leaf('id', Operators::EQUAL, 99_999)), aggregation) + + assert_equal 1, result.size, 'exactly one row' + assert result.first['value'].nil?, 'null value, which the charts route turns into 0' +end + +scenario 'an aggregation field that is not a column is rejected, not interpolated' do + hostile = 'amount_cents } } } cards { id } dummy: transfers_aggregate { aggregate { sum { amount_cents' + aggregation = Query::Aggregation.new(operation: 'Sum', field: hostile) + + begin + datasource.get_collection('Transfer').aggregate(nil, filter, aggregation) + raise 'expected the hostile aggregation field to be rejected' + rescue ForestAdminDatasourceToolkit::Exceptions::ForestException => e + assert e.message.match?(/not found|Invalid aggregation field/), "unexpected message: #{e.message}" + end +end + +scenario 'leaderboard grouping through a relation path works' do + aggregation = Query::Aggregation.new(operation: 'Count', groups: [{ field: 'membership:full_name' }]) + result = comments.aggregate(nil, filter, aggregation) + grouped = result.to_h { |row| [row['group']['membership:full_name'], row['value']] } + + assert_equal({ 'Jane Doe' => 8, 'John Smith' => 2 }, grouped, 'counts per membership name') +end + +scenario 'Sum grouped by FK handles bigint values returned as JSON strings' do + transfers = datasource.get_collection('Transfer') + aggregation = Query::Aggregation.new(operation: 'Sum', field: 'amount_cents', + groups: [{ field: 'beneficiary_id' }]) + result = transfers.aggregate(nil, filter, aggregation) + + assert_equal 2, result.size, 'one row per beneficiary' + # Highest first, whatever the wire type of the bigint. + assert_equal 1, result.first['group']['beneficiary_id'], 'sorted by value desc' +end + +# The operator-equivalence decorator always sits above a datasource in a running +# agent; only it is instantiated here, as the full stack pulls in the agent gem. +scenario 'Present and Blank do not overlap on a text column (through operator equivalence)' do + decorated = ForestAdminDatasourceCustomizer::Decorators::OperatorsEquivalence:: + OperatorsEquivalenceCollectionDecorator.new(comments, datasource) + + agent_caller = ForestAdminDatasourceToolkit::Components::Caller.new( + id: 1, email: 'validation@forestadmin.com', first_name: 'V', last_name: 'A', team: 'ops', + rendering_id: 1, tags: {}, timezone: 'Europe/Paris', permission_level: 'admin' + ) + + present = decorated.list(agent_caller, filter(condition_tree: leaf('body', Operators::PRESENT)), + projection('id', 'body')) + blank = decorated.list(agent_caller, filter(condition_tree: leaf('body', Operators::BLANK)), + projection('id', 'body')) + + assert present.none? { |r| r['body'].nil? || r['body'].empty? }, 'present excludes NULL and empty string' + assert blank.any? { |r| r['body'].nil? }, 'blank includes NULL rows' + assert blank.any? { |r| r['body'] == '' }, 'blank includes empty strings' + assert (present.map { |r| r['id'] } & blank.map { |r| r['id'] }).empty?, 'no overlap' +end + +scenario 'Contains searches a literal % instead of treating it as a wildcard' do + records = comments.list(nil, filter(condition_tree: leaf('body', Operators::CONTAINS, 'discount 100%')), + projection('id', 'body')) + + assert_equal ['discount 100% applied'], records.map { |r| r['body'] }, 'only the literal match' +end + +scenario 'Contains is case-insensitive, like the other datasources' do + records = comments.list(nil, filter(condition_tree: leaf('body', Operators::CONTAINS, 'ON TRANSFER')), + projection('id', 'body')) + + assert records.size >= 2, "expected case-insensitive matches, got #{records.size}" +end + +scenario 'a jsonb column survives create and update' do + record = comments.create(nil, { + 'body' => 'jsonb probe', + 'metadata' => { 'source' => 'validation', 'nested' => { 'ok' => true } }, + 'commentable_type' => 'Transfer', + 'commentable_id' => 1 + }) + probe_id = record['id'] + + comments.update(nil, filter(condition_tree: leaf('id', Operators::EQUAL, probe_id)), + { 'metadata' => { 'source' => 'edited' } }) + after = comments.list(nil, filter(condition_tree: leaf('id', Operators::EQUAL, probe_id)), + projection('id', 'metadata')) + + assert_equal({ 'source' => 'edited' }, after.first&.fetch('metadata'), 'jsonb patch applied') +ensure + comments.delete(nil, filter(condition_tree: leaf('id', Operators::EQUAL, probe_id))) if probe_id +end + +scenario 'an unknown polymorphic type value is left unresolved instead of breaking the page' do + records = comments.list(nil, filter(condition_tree: leaf('body', Operators::EQUAL, 'legacy sti row')), + projection('id', 'body', 'commentable_type', 'commentable:*')) + + assert_equal 'FlashCard', records.first['commentable_type'], 'raw value kept' + assert records.first['commentable'].nil?, 'no phantom for a type matching no collection' +end + +scenario 'update refuses to run without any condition' do + comments.update(nil, filter, { 'body' => 'mass update' }) + raise 'expected an unfiltered update to be refused' +rescue ForestAdminDatasourceToolkit::Exceptions::ForestException => e + assert e.message.include?('Refusing'), "unexpected message: #{e.message}" +end + +scenario 'a transport failure surfaces as a Forest validation error, not an opaque crash' do + unreachable = ForestAdminDatasourceGraphqlHasura::Client.new( + ForestAdminDatasourceGraphqlHasura::Configuration.new(uri: 'http://127.0.0.1:1/v1/graphql', timeout: 2) + ) + + begin + unreachable.execute('query { __typename }') + raise 'expected a transport failure' + rescue ForestAdminDatasourceGraphqlHasura::GraphqlError => e + assert e.is_a?(ForestAdminDatasourceToolkit::Exceptions::ForestException), 'must be a ForestException' + assert e.message.include?('Could not reach'), "unexpected message: #{e.message}" + end +end + +failures = RESULTS.count { |(_, status, _)| status == :fail } +puts "\n#{RESULTS.size} scenarios, #{failures} failure(s)" +exit(failures.zero? ? 0 : 1) From e3b911af5b04b245a377a1e7f49375f629706698 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Tue, 4 Aug 2026 19:33:53 +0200 Subject: [PATCH 02/26] fix(datasource graphql hasura): address review findings - persist an explicit nil on insert instead of dropping the key, which let the column default win over a value the user cleared (the ActiveRecord datasource writes null here); create and update now behave the same way - require the local key of an array relationship to be the primary key the polymorphic association targets before treating it as the reverse side, so a relationship mapped on another column is no longer replaced by one querying the primary key - keep a physical column that shares its name with a polymorphic association, and skip the association with a warning rather than shadowing the column - set write_timeout alongside the read and open ones, and translate Net::WriteTimeout like the other transport failures Also splits the functions flagged as too complex (parse_table, parse_tables, polymorphic_targets, validate_aggregation_field) along their natural seams. --- .../client.rb | 5 +- .../collection.rb | 25 +++-- .../introspection/introspector.rb | 49 +++++----- .../introspection/schema_converter.rb | 47 ++++++--- .../query/query_builder.rb | 17 ++-- .../collection_spec.rb | 23 +++++ .../introspector_detection_spec.rb | 95 +++++++++++++++++++ .../spec/support/banking_schema.rb | 1 + .../validation/validate.rb | 10 ++ 9 files changed, 213 insertions(+), 59 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb index 4259c4b01..41c8ada15 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb @@ -14,8 +14,8 @@ def initialize(configuration) # Wrapped in GraphqlError so they reach the user as an actionable message # instead of an opaque 500. TRANSPORT_ERRORS = [ - Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse, IOError, SocketError, SystemCallError, - OpenSSL::SSL::SSLError, JSON::ParserError + Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Net::HTTPBadResponse, IOError, SocketError, + SystemCallError, OpenSSL::SSL::SSLError, JSON::ParserError ].freeze def execute(query, variables = {}) @@ -65,6 +65,7 @@ def post(url, body) http.use_ssl = uri.scheme == 'https' http.read_timeout = @configuration.timeout http.open_timeout = @configuration.timeout + http.write_timeout = @configuration.timeout request = Net::HTTP::Post.new(uri.request_uri) request['Content-Type'] = 'application/json' diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb index ece9273d6..9a6d4e55c 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb @@ -98,18 +98,23 @@ def validate_aggregation_field(field, allow_relation: false) raise ForestException, "Invalid aggregation field '#{field}' on collection '#{name}'." end - collection = self - path.each_with_index do |part, index| - schema = collection.schema[:fields][part] - raise ForestException, "Field '#{field}' not found on collection '#{collection.name}'." if schema.nil? - next if index == path.size - 1 - - unless schema.type == 'ManyToOne' - raise ForestException, "Cannot aggregate through '#{part}' on collection '#{collection.name}'." - end + *relations, last = path + collection = relations.reduce(self) { |current, part| collection_through(current, part, field) } + + return unless collection.schema[:fields][last].nil? + + raise ForestException, "Field '#{field}' not found on collection '#{collection.name}'." + end + + def collection_through(collection, relation_name, field) + schema = collection.schema[:fields][relation_name] + raise ForestException, "Field '#{field}' not found on collection '#{collection.name}'." if schema.nil? - collection = datasource.get_collection(schema.foreign_collection) + unless schema.type == 'ManyToOne' + raise ForestException, "Cannot aggregate through '#{relation_name}' on collection '#{collection.name}'." end + + datasource.get_collection(schema.foreign_collection) end def execute(operation_name, operation) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb index 2ba5bb6a9..a45cdae66 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb @@ -191,39 +191,32 @@ def skip_table?(name) end def parse_table(table_name, type) - columns = [] - relationships = [] - - (type['fields'] || []).each do |field| - next if field['name'].start_with?('__') - # Hasura companion fields of array relationships, not relations - next if field['name'].end_with?('_aggregate') - - type_name = base_type_name(field['type']) - - if scalar?(type_name) - columns << parse_column(field, type_name) - else - relationships << parse_relationship(table_name, field, type_name) - end - end + fields = (type['fields'] || []).reject { |field| companion_field?(field['name']) } + scalars, relations = fields.partition { |field| scalar?(base_type_name(field['type'])) } + columns = scalars.map { |field| parse_column(field, base_type_name(field['type'])) } Table.new( name: table_name, columns: columns, primary_key: resolve_primary_key(table_name, columns), - relationships: relationships.compact, + relationships: relations.map { |field| parse_relationship(table_name, field) }, polymorphics: [] ) end - def parse_relationship(table_name, field, remote_type_name) + # Introspection metadata and the `_aggregate` fields Hasura adds + # next to every array relationship — neither are columns or relations. + def companion_field?(name) + name.start_with?('__') || name.end_with?('_aggregate') + end + + def parse_relationship(table_name, field) entry = @relationship_mappings["#{table_name}.#{field["name"]}"] Relationship.new( name: field['name'], kind: array_type?(field['type']) ? :array : :object, - remote_table: remote_type_name, + remote_table: base_type_name(field['type']), mapping: entry&.fetch(:mapping), manual: entry ? entry[:manual] : nil ) @@ -308,13 +301,7 @@ def polymorphic_targets(table, base, tables_by_name) foreign_key = "#{base}_id" candidates = table.relationships.select do |rel| - next false unless rel.kind == :object - next configured_tables.include?(rel.remote_table) if configured_tables - - # A relationship backed by a real foreign key constraint is monomorphic - # by definition: accepting one here would absorb a legitimate belongs_to - # whenever an unrelated `_type` enum sits next to `_id`. - rel.manual && rel.mapping&.keys == [foreign_key] + polymorphic_branch?(rel, foreign_key, configured_tables) end candidates.each_with_object({}) do |rel, memo| @@ -330,6 +317,16 @@ def polymorphic_targets(table, base, tables_by_name) end end + def polymorphic_branch?(relationship, foreign_key, configured_tables) + return false unless relationship.kind == :object + return configured_tables.include?(relationship.remote_table) if configured_tables + + # A relationship backed by a real foreign key constraint is monomorphic by + # definition: accepting one here would absorb a legitimate belongs_to + # whenever an unrelated `_type` enum sits next to `_id`. + relationship.manual && relationship.mapping&.keys == [foreign_key] + end + def scalar?(type_name) return true if SCALAR_TYPES.include?(type_name) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb index 937bb4618..493c1c63b 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb @@ -57,11 +57,7 @@ def build_fields(table) table.columns.each { |column| fields[column.name] = convert_column(column) } - table.polymorphics.each do |polymorphic| - fields[polymorphic.name] = convert_polymorphic(polymorphic) - fields[polymorphic.foreign_key]&.is_read_only = true - fields[polymorphic.type_field]&.is_read_only = true - end + add_polymorphics(table, fields) table.relationships.each do |relationship| name, schema = convert_relationship(table, relationship) @@ -92,6 +88,25 @@ def convert_column(column) ) end + def add_polymorphics(table, fields) + table.polymorphics.each do |polymorphic| + # A physical column of that name wins: replacing it would drop it from + # the schema, leaving it neither readable nor writable. + if fields.key?(polymorphic.name) + ForestAdminDatasourceGraphqlHasura.logger.warn( + "[forest_admin_datasource_graphql_hasura] '#{table.name}' has a column named " \ + "'#{polymorphic.name}', so its polymorphic association is not exposed. Rename either the " \ + 'column or the association to surface both.' + ) + next + end + + fields[polymorphic.name] = convert_polymorphic(polymorphic) + fields[polymorphic.foreign_key]&.is_read_only = true + fields[polymorphic.type_field]&.is_read_only = true + end + end + def convert_polymorphic(polymorphic) Relations::PolymorphicManyToOneSchema.new( foreign_key: polymorphic.foreign_key, @@ -167,11 +182,22 @@ def convert_array_relationship(table, relationship) def covered_by_reverse_polymorphic?(table, relationship, remote) return false if relationship.mapping.nil? + remote.polymorphics.any? { |polymorphic| reverse_of?(relationship, table, polymorphic) } + end + + # Both ends have to line up: an array relationship joining the polymorphic + # foreign key to another local column (`{ 'external_id' => 'commentable_id' }`) + # is a different relationship, and the PolymorphicOneToMany that would + # replace it queries by the primary key instead. + def reverse_of?(relationship, table, polymorphic) this_class_name = rails_class_name_of(table.name) - remote.polymorphics.any? do |polymorphic| - polymorphic.targets.key?(this_class_name) && - relationship.mapping.values.first == polymorphic.foreign_key - end + target = polymorphic.targets[this_class_name] + return false if target.nil? + return false unless relationship.mapping&.values&.first == polymorphic.foreign_key + + local_key = relationship.mapping.keys.first + + local_key.nil? || local_key == target[:primary_key] end def single_column_mapping?(table, relationship) @@ -208,8 +234,7 @@ def add_reverse_polymorphics(table, fields) def reverse_polymorphic_name(table, child, polymorphic, fields) array_relationship = table.relationships.find do |rel| - rel.kind == :array && rel.remote_table == child.name && - rel.mapping&.values&.first == polymorphic.foreign_key + rel.kind == :array && rel.remote_table == child.name && reverse_of?(rel, table, polymorphic) end candidates = [array_relationship&.name, child.name, "#{child.name}_#{polymorphic.name}"].compact.uniq diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb index 751e7c441..9cff95ae5 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb @@ -44,7 +44,7 @@ def create(table, records, selection) } GRAPHQL - { query: query, variables: { 'objects' => records.map { |record| clean_record(record) } } } + { query: query, variables: { 'objects' => records.map { |record| stringify_keys(record) } } } end def update(table, filter, patch) @@ -60,7 +60,7 @@ def update(table, filter, patch) query: query, variables: { 'where' => FilterConverter.convert(filter.condition_tree) || {}, - 'set' => clean_record(patch, keep_nil: true) + 'set' => stringify_keys(patch) } } end @@ -178,14 +178,11 @@ def convert_sort(sort) end end - # Nils are dropped on insert so database defaults apply, and kept on - # update so a field can be cleared. - def clean_record(record, keep_nil: false) - record.each_with_object({}) do |(key, value), memo| - next if value.nil? && !keep_nil - - memo[key.to_s] = value - end + # Values are kept as submitted, nil included: a column left empty has to + # be written as null rather than fall back to its database default. The + # caller already restricted the keys to real columns. + def stringify_keys(record) + record.to_h { |key, value| [key.to_s, value] } end def wrap(parts) diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb index c3956ee99..d9eda4569 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb @@ -126,6 +126,29 @@ def last_graphql_request expect(request['query']).to include('insert_comments(objects: $objects)') expect(request['variables']['objects']).to eq([{ 'body' => 'hello', 'membership_id' => 1 }]) end + + # A column left empty must be written as null, not fall back to its database + # default — same as the ActiveRecord datasource. + it 'inserts an explicit nil instead of dropping the column' do + BankingSchema.stub_graphql_data( + { 'insert_comments' => { 'returning' => [{ 'id' => 7, 'body' => nil }] } } + ) + + comments.create(caller, { 'body' => nil, 'membership_id' => 1 }) + + expect(last_graphql_request['variables']['objects']).to eq([{ 'body' => nil, 'membership_id' => 1 }]) + end + + it 'keeps a jsonb value on insert' do + BankingSchema.stub_graphql_data( + { 'insert_comments' => { 'returning' => [{ 'id' => 7 }] } } + ) + + comments.create(caller, { 'body' => 'x', 'metadata' => { 'source' => 'api' } }) + + expect(last_graphql_request['variables']['objects']) + .to eq([{ 'body' => 'x', 'metadata' => { 'source' => 'api' } }]) + end end describe '#update' do diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb index aa1b0916c..cf7f929ed 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb @@ -217,6 +217,101 @@ def build_datasource(**options) end end + describe 'reverse polymorphic relations' do + before do + stub_schema( + [ + { + 'name' => 'comments', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('commentable_type', non_null(scalar('String'))), + field('commentable_id', non_null(scalar('bigint'))), + field('transfer', object('transfers')) + ] + }, + { + 'name' => 'transfers', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('external_id', non_null(scalar('bigint'))), + field('comments', non_null(list_of(non_null(object('comments'))))) + ] + } + ], + [list_query('comments'), by_pk_query('comments'), list_query('transfers'), by_pk_query('transfers')] + ) + end + + # The array relationship joins `external_id`, while the polymorphic + # association targets `id`: it is a different relationship, so it must not be + # replaced by a PolymorphicOneToMany querying by `id`. + it 'does not absorb an array relationship whose local key is not the targeted primary key' do + stub_metadata( + [ + { 'table' => { 'schema' => 'public', 'name' => 'comments' }, + 'object_relationships' => [manual_object_rel('transfer', 'transfers', { 'commentable_id' => 'id' })] }, + { 'table' => { 'schema' => 'public', 'name' => 'transfers' }, + 'array_relationships' => [ + manual_object_rel('comments', 'comments', { 'external_id' => 'commentable_id' }) + ] } + ] + ) + + fields = build_datasource.get_collection('Transfer').schema[:fields] + + expect(fields['comments'].type).to eq('OneToMany') + expect(fields['comments'].origin_key_target).to eq('external_id') + expect(fields['transfers']).to be_nil + end + + it 'absorbs the array relationship that does join the targeted primary key' do + stub_metadata( + [ + { 'table' => { 'schema' => 'public', 'name' => 'comments' }, + 'object_relationships' => [manual_object_rel('transfer', 'transfers', { 'commentable_id' => 'id' })] }, + { 'table' => { 'schema' => 'public', 'name' => 'transfers' }, + 'array_relationships' => [manual_object_rel('comments', 'comments', { 'id' => 'commentable_id' })] } + ] + ) + + field = build_datasource.get_collection('Transfer').schema[:fields]['comments'] + + expect(field.type).to eq('PolymorphicOneToMany') + expect(field.origin_key_target).to eq('id') + end + end + + describe 'a column named like the polymorphic association' do + it 'keeps the physical column and skips the association' do + stub_schema( + [ + { + 'name' => 'comments', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('commentable', scalar('String')), + field('commentable_type', non_null(scalar('String'))), + field('commentable_id', non_null(scalar('bigint'))), + field('transfer', object('transfers')) + ] + }, + { 'name' => 'transfers', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + [list_query('comments'), by_pk_query('comments'), list_query('transfers'), by_pk_query('transfers')] + ) + stub_metadata( + [{ 'table' => { 'schema' => 'public', 'name' => 'comments' }, + 'object_relationships' => [manual_object_rel('transfer', 'transfers', { 'commentable_id' => 'id' })] }] + ) + + fields = build_datasource.get_collection('Comment').schema[:fields] + + expect(fields['commentable'].type).to eq('Column') + expect(fields['commentable_type'].is_read_only).to be(false) + end + end + describe 'operators advertised per column type' do before do stub_schema( diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/support/banking_schema.rb b/packages/forest_admin_datasource_graphql_hasura/spec/support/banking_schema.rb index 394452962..caa6626b6 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/support/banking_schema.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/support/banking_schema.rb @@ -55,6 +55,7 @@ def introspection_response 'fields' => [ field('id', non_null(scalar('bigint'))), field('body', scalar('String')), + field('metadata', scalar('jsonb')), field('commentable_type', non_null(scalar('String'))), field('commentable_id', non_null(scalar('bigint'))), field('membership_id', scalar('bigint')), diff --git a/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb b/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb index 177a4286b..f680e118a 100644 --- a/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb +++ b/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb @@ -359,6 +359,16 @@ def branch(aggregator, conditions) = Nodes::ConditionTreeBranch.new(aggregator, comments.delete(nil, filter(condition_tree: leaf('id', Operators::EQUAL, probe_id))) if probe_id end +scenario 'an explicit nil is persisted as null rather than falling back to the column default' do + cards = datasource.get_collection('Card') + record = cards.create(nil, { 'last4' => nil, 'type' => 'Card' }) + probe_id = record['id'] + + assert record['last4'].nil?, 'last4 stored as null' +ensure + cards.delete(nil, filter(condition_tree: leaf('id', Operators::EQUAL, probe_id))) if probe_id +end + scenario 'an unknown polymorphic type value is left unresolved instead of breaking the page' do records = comments.list(nil, filter(condition_tree: leaf('body', Operators::EQUAL, 'legacy sti row')), projection('id', 'body', 'commentable_type', 'commentable:*')) From 62de6a3b3337a1668b49067d656490f90deb8b4d Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 09:35:44 +0200 Subject: [PATCH 03/26] refactor(datasource graphql hasura): split aggregation and polymorphism detection out The collection carried the whole aggregation pipeline (validation, the parent-table detour Hasura forces on grouped aggregates, value coercion) and the introspector carried the polymorphism detection. Both now live in classes of their own, Query::Aggregator and Introspection::PolymorphismDetector, leaving the collection to its CRUD surface and the introspector to reading the schema. Configuration takes its options as a keyword hash validated against the known list, so an unknown option is reported by name. --- .rubocop.yml | 1 + .../collection.rb | 172 +--------------- .../configuration.rb | 33 ++-- .../introspection/introspector.rb | 68 +------ .../introspection/polymorphism_detector.rb | 86 ++++++++ .../query/aggregator.rb | 187 ++++++++++++++++++ 6 files changed, 301 insertions(+), 246 deletions(-) create mode 100644 packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb create mode 100644 packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb diff --git a/.rubocop.yml b/.rubocop.yml index 77e881423..685b5cc26 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -359,6 +359,7 @@ Metrics/ClassLength: - 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb' - 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb' - 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb' + - 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb' - 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/base_collection.rb' - 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/collection.rb' - 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/datasource.rb' diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb index 9a6d4e55c..e5f5bfa4b 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb @@ -3,7 +3,6 @@ class Collection < ForestAdminDatasourceToolkit::Collection ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException Projection = ForestAdminDatasourceToolkit::Components::Query::Projection - GROUPED_AGGREGATE_PARENT_LIMIT = 1000 PLACEHOLDER_REFERENCE = { '*' => nil }.freeze attr_reader :table_name @@ -54,13 +53,14 @@ def delete(_caller, filter) end def aggregate(_caller, filter, aggregation, limit = nil) - validate_aggregation(aggregation) + Query::Aggregator.new(self).run(filter, aggregation, limit) + end - if aggregation.groups.nil? || aggregation.groups.empty? - simple_aggregate(filter, aggregation) - else - grouped_aggregate(filter, aggregation, limit) - end + # Wraps every Hasura call so the failing operation is named in the error. + def execute(operation_name, operation) + @client.execute(operation[:query], operation[:variables]) + rescue GraphqlError => e + raise GraphqlError, "GraphQL #{operation_name} failed on '#{name}': #{e.message}" end private @@ -75,54 +75,6 @@ def writable_columns(data) data.select { |key, _| column_names.include?(key.to_s) } end - # Aggregation fields are interpolated into the GraphQL document and are the - # one path the agent does not validate upstream (the charts route passes the - # request's `aggregateFieldName` straight through). - def validate_aggregation(aggregation) - validate_aggregation_field(aggregation.field) if aggregation.field - - (aggregation.groups || []).each do |group| - if group[:operation] - raise ForestException, - "Date grouping is not supported by the GraphQL datasource (collection '#{name}')." - end - - validate_aggregation_field(group[:field], allow_relation: true) - end - end - - def validate_aggregation_field(field, allow_relation: false) - path = field.to_s.split(':') - - unless (allow_relation && path.size <= 2) || path.size == 1 - raise ForestException, "Invalid aggregation field '#{field}' on collection '#{name}'." - end - - *relations, last = path - collection = relations.reduce(self) { |current, part| collection_through(current, part, field) } - - return unless collection.schema[:fields][last].nil? - - raise ForestException, "Field '#{field}' not found on collection '#{collection.name}'." - end - - def collection_through(collection, relation_name, field) - schema = collection.schema[:fields][relation_name] - raise ForestException, "Field '#{field}' not found on collection '#{collection.name}'." if schema.nil? - - unless schema.type == 'ManyToOne' - raise ForestException, "Cannot aggregate through '#{relation_name}' on collection '#{collection.name}'." - end - - datasource.get_collection(schema.foreign_collection) - end - - def execute(operation_name, operation) - @client.execute(operation[:query], operation[:variables]) - rescue GraphqlError => e - raise GraphqlError, "GraphQL #{operation_name} failed on '#{name}': #{e.message}" - end - # A PolymorphicManyToOne cannot be joined by Hasura, so its discriminator # columns are selected instead and the relation is rebuilt by the serializer # from those two values (see materialize_polymorphics). @@ -177,116 +129,6 @@ def warn_unknown_type(relation_name, type_value) ) end - def simple_aggregate(filter, aggregation) - operation = Query::QueryBuilder.aggregate(@table_name, filter, aggregation) - data = execute(:aggregate, operation).dig("#{@table_name}_aggregate", 'aggregate') - - # One row even when the aggregate is null: the charts route reads - # `result[0]['value']` unguarded. - [{ 'value' => extract_aggregate_value(data, aggregation), 'group' => {} }] - end - - # Hasura exposes GROUP BY only through a nested `_aggregate` on a - # parent object, hence the detour through the parent table and the reduction - # in Ruby. - def grouped_aggregate(filter, aggregation, limit) - group_field = aggregation.groups.first[:field] - relation = find_group_relation(group_field) - - operation = Query::QueryBuilder.grouped_aggregate( - @table_name, relation, filter, aggregation, GROUPED_AGGREGATE_PARENT_LIMIT - ) - - rows = execute(:aggregate, operation)[relation[:parent_table]] || [] - warn_truncated_groups(relation[:parent_table]) if rows.size >= GROUPED_AGGREGATE_PARENT_LIMIT - - results = rows.filter_map do |row| - value = extract_aggregate_value(row.dig("#{relation[:relation_name]}_aggregate", 'aggregate'), aggregation) - next if childless_parent?(value, aggregation) - - { 'value' => value, 'group' => { group_field => row[relation[:parent_field]] } } - end - - results = results.sort_by { |row| sortable_value(row['value']) }.reverse - limit ? results.first(limit) : results - end - - # A `Sum` adding up to zero is a real group, unlike a parent that simply has - # no child row — which SQL grouping would not return either. - def childless_parent?(value, aggregation) - value.nil? || (aggregation.operation == 'Count' && value.to_i.zero?) - end - - # Accepts a foreign key (`membership_id`) or a path through a ManyToOne - # (`membership:full_name`, what leaderboard charts request). - def find_group_relation(group_field) - field_name, parent_column = group_field.split(':') - field = schema[:fields][field_name] - - relation, foreign_key = - if field&.type == 'ManyToOne' - [field, field.foreign_key] - else - [schema[:fields].values.find { |f| f.type == 'ManyToOne' && f.foreign_key == field_name }, field_name] - end - - if relation - parent = datasource.get_collection(relation.foreign_collection) - reverse = reverse_relation_name(parent, foreign_key) - - if reverse - return { - parent_table: parent.table_name, - parent_field: parent_column || relation.foreign_key_target, - relation_name: reverse - } - end - end - - raise ForestException, - "Group by '#{group_field}' is not supported: the GraphQL datasource groups through a " \ - "foreign key whose reverse relationship is declared in Hasura (collection '#{name}')." - end - - def reverse_relation_name(parent, foreign_key) - parent.schema[:fields].each do |relation_name, field| - next unless field.type == 'OneToMany' && - field.foreign_collection == name && - field.origin_key == foreign_key - - return relation_name - end - - nil - end - - def warn_truncated_groups(parent_table) - ForestAdminDatasourceGraphqlHasura.logger.warn( - "[forest_admin_datasource_graphql_hasura] Grouped aggregation on '#{name}' stopped after " \ - "#{GROUPED_AGGREGATE_PARENT_LIMIT} '#{parent_table}' rows; the result may be incomplete." - ) - end - - # Hasura returns bigint/numeric/money as JSON strings to preserve precision, - # and Max/Min may aggregate dates. - def sortable_value(value) - case value - when Numeric then value - when String then Float(value, exception: false) || 0 - else 0 - end - end - - def extract_aggregate_value(data, aggregation) - return nil if data.nil? - - if aggregation.operation == 'Count' - data['count'] - else - data.dig(aggregation.operation.downcase, aggregation.field) - end - end - # Only guards update: a bulk delete with "select all" legitimately carries no # condition, and wiping is then the requested semantic. def empty_condition?(filter) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb index edfd82e96..03190806a 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb @@ -1,26 +1,31 @@ module ForestAdminDatasourceGraphqlHasura class Configuration - DEFAULT_TIMEOUT = 30 - - attr_reader :uri, :headers, :metadata_uri, :included_tables, :excluded_tables, - :polymorphic_relations, :type_values, :timeout - # polymorphic_relations declares associations explicitly when the metadata API # is unreachable: { 'comments' => { 'commentable' => %w[transfers cards] } }. # type_values overrides the Rails class name a table maps to, for those that # `classify` gets wrong: { 'bank_accounts' => 'Banking::Account' }. - def initialize(uri:, headers: {}, metadata_uri: nil, included_tables: nil, excluded_tables: [], - polymorphic_relations: {}, type_values: {}, timeout: DEFAULT_TIMEOUT) + DEFAULTS = { + headers: {}, + metadata_uri: nil, + included_tables: nil, + excluded_tables: [], + polymorphic_relations: {}, + type_values: {}, + timeout: 30 + }.freeze + + attr_reader :uri, :headers, :metadata_uri, :included_tables, :excluded_tables, + :polymorphic_relations, :type_values, :timeout + + def initialize(uri:, **options) raise ConfigurationError, 'uri is required' if uri.nil? || uri.empty? + unknown = options.keys - DEFAULTS.keys + raise ConfigurationError, "Unknown option(s): #{unknown.join(", ")}" if unknown.any? + @uri = uri - @headers = headers - @metadata_uri = metadata_uri || uri.sub('/v1/graphql', '/v1/metadata') - @included_tables = included_tables - @excluded_tables = excluded_tables - @polymorphic_relations = polymorphic_relations - @type_values = type_values - @timeout = timeout + DEFAULTS.each { |option, default| instance_variable_set("@#{option}", options.fetch(option, default)) } + @metadata_uri ||= uri.sub('/v1/graphql', '/v1/metadata') end def table_allowed?(table_name) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb index a45cdae66..5ea0b105a 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb @@ -79,7 +79,7 @@ def introspect @primary_keys = parse_primary_keys(schema['__schema']['queryType']['fields']) tables = parse_tables(schema['__schema']['queryType']['fields']) - detect_polymorphism(tables) + PolymorphismDetector.new(@configuration).detect(tables) tables end @@ -261,72 +261,6 @@ def resolve_primary_key(table_name, columns) [] end - # Fills `polymorphics` and removes the per-target object relationships it - # absorbs. - def detect_polymorphism(tables) - tables_by_name = tables.to_h { |table| [table.name, table] } - - tables.each do |table| - polymorphic_bases(table).each do |base| - targets = polymorphic_targets(table, base, tables_by_name) - next if targets.empty? - - table.polymorphics << Polymorphic.new( - name: base, - foreign_key: "#{base}_id", - type_field: "#{base}_type", - targets: targets - ) - - consumed = targets.values.filter_map { |target| target[:hasura_field] } - table.relationships.reject! { |rel| consumed.include?(rel.name) } - end - end - end - - def polymorphic_bases(table) - names = table.columns.map(&:name) - configured = @configuration.polymorphic_relations[table.name]&.keys || [] - - detected = names.filter_map do |name| - base = name.delete_suffix('_type') - base if name.end_with?('_type') && names.include?("#{base}_id") - end - - (detected + configured).uniq - end - - def polymorphic_targets(table, base, tables_by_name) - configured_tables = @configuration.polymorphic_relations.dig(table.name, base) - foreign_key = "#{base}_id" - - candidates = table.relationships.select do |rel| - polymorphic_branch?(rel, foreign_key, configured_tables) - end - - candidates.each_with_object({}) do |rel, memo| - target_table = tables_by_name[rel.remote_table] - next unless target_table - - type_value = @configuration.type_values[rel.remote_table] || rel.remote_table.classify - memo[type_value] = { - table: rel.remote_table, - hasura_field: rel.name, - primary_key: rel.mapping&.values&.first || target_table.primary_key.first || 'id' - } - end - end - - def polymorphic_branch?(relationship, foreign_key, configured_tables) - return false unless relationship.kind == :object - return configured_tables.include?(relationship.remote_table) if configured_tables - - # A relationship backed by a real foreign key constraint is monomorphic by - # definition: accepting one here would absorb a legitimate belongs_to - # whenever an unrelated `_type` enum sits next to `_id`. - relationship.manual && relationship.mapping&.keys == [foreign_key] - end - def scalar?(type_name) return true if SCALAR_TYPES.include?(type_name) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb new file mode 100644 index 000000000..336b07cb7 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb @@ -0,0 +1,86 @@ +require 'active_support/core_ext/string/inflections' + +module ForestAdminDatasourceGraphqlHasura + module Introspection + # Recognises Rails polymorphic belongs_to associations among introspected + # tables, from a `_type`/`_id` column pair backed by one Hasura + # relationship per target, or from the `polymorphic_relations` configuration + # when the metadata API is unreachable. + class PolymorphismDetector + def initialize(configuration) + @configuration = configuration + end + + # Fills `polymorphics` on each table and removes the per-target object + # relationships it absorbs. + def detect(tables) + tables_by_name = tables.to_h { |table| [table.name, table] } + + tables.each do |table| + bases_of(table).each { |base| absorb(table, base, tables_by_name) } + end + end + + private + + def absorb(table, base, tables_by_name) + targets = targets_of(table, base, tables_by_name) + return if targets.empty? + + table.polymorphics << Polymorphic.new( + name: base, + foreign_key: "#{base}_id", + type_field: "#{base}_type", + targets: targets + ) + + consumed = targets.values.filter_map { |target| target[:hasura_field] } + table.relationships.reject! { |rel| consumed.include?(rel.name) } + end + + def bases_of(table) + names = table.columns.map(&:name) + configured = @configuration.polymorphic_relations[table.name]&.keys || [] + + detected = names.filter_map do |name| + base = name.delete_suffix('_type') + base if name.end_with?('_type') && names.include?("#{base}_id") + end + + (detected + configured).uniq + end + + def targets_of(table, base, tables_by_name) + configured_tables = @configuration.polymorphic_relations.dig(table.name, base) + foreign_key = "#{base}_id" + + candidates = table.relationships.select { |rel| branch?(rel, foreign_key, configured_tables) } + + candidates.each_with_object({}) do |rel, memo| + target_table = tables_by_name[rel.remote_table] + next unless target_table + + memo[class_name_of(rel.remote_table)] = { + table: rel.remote_table, + hasura_field: rel.name, + primary_key: rel.mapping&.values&.first || target_table.primary_key.first || 'id' + } + end + end + + def branch?(relationship, foreign_key, configured_tables) + return false unless relationship.kind == :object + return configured_tables.include?(relationship.remote_table) if configured_tables + + # A relationship backed by a real foreign key constraint is monomorphic by + # definition: accepting one here would absorb a legitimate belongs_to + # whenever an unrelated `_type` enum sits next to `_id`. + relationship.manual && relationship.mapping&.keys == [foreign_key] + end + + def class_name_of(table_name) + @configuration.type_values[table_name] || table_name.classify + end + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb new file mode 100644 index 000000000..4859ea72e --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb @@ -0,0 +1,187 @@ +module ForestAdminDatasourceGraphqlHasura + module Query + # Runs Forest aggregations against Hasura on behalf of a collection. + # + # Hasura exposes GROUP BY only through a nested `_aggregate` on a + # parent object, so a grouped aggregation goes through the parent table and is + # reduced here rather than by the database. + class Aggregator + ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException + + # Parent rows are read in one page because they are reduced in Ruby; beyond + # that the chart would be truncated anyway. + PARENT_LIMIT = 1000 + + def initialize(collection) + @collection = collection + end + + def run(filter, aggregation, limit) + validate(aggregation) + + if aggregation.groups.nil? || aggregation.groups.empty? + simple(filter, aggregation) + else + grouped(filter, aggregation, limit) + end + end + + private + + def name = @collection.name + def table_name = @collection.table_name + def datasource = @collection.datasource + def fields = @collection.schema[:fields] + + # Aggregation fields are interpolated into the GraphQL document and are the + # one path the agent does not validate upstream (the charts route passes the + # request's `aggregateFieldName` straight through). + def validate(aggregation) + validate_field(aggregation.field) if aggregation.field + + (aggregation.groups || []).each do |group| + if group[:operation] + raise ForestException, + "Date grouping is not supported by the GraphQL datasource (collection '#{name}')." + end + + validate_field(group[:field], allow_relation: true) + end + end + + def validate_field(field, allow_relation: false) + path = field.to_s.split(':') + + unless (allow_relation && path.size <= 2) || path.size == 1 + raise ForestException, "Invalid aggregation field '#{field}' on collection '#{name}'." + end + + *relations, last = path + collection = relations.reduce(@collection) { |current, part| collection_through(current, part, field) } + + return unless collection.schema[:fields][last].nil? + + raise ForestException, "Field '#{field}' not found on collection '#{collection.name}'." + end + + def collection_through(collection, relation_name, field) + schema = collection.schema[:fields][relation_name] + raise ForestException, "Field '#{field}' not found on collection '#{collection.name}'." if schema.nil? + + unless schema.type == 'ManyToOne' + raise ForestException, "Cannot aggregate through '#{relation_name}' on collection '#{collection.name}'." + end + + datasource.get_collection(schema.foreign_collection) + end + + def simple(filter, aggregation) + operation = QueryBuilder.aggregate(table_name, filter, aggregation) + data = @collection.execute(:aggregate, operation).dig("#{table_name}_aggregate", 'aggregate') + + # One row even when the aggregate is null: the charts route reads + # `result[0]['value']` unguarded. + [{ 'value' => extract_value(data, aggregation), 'group' => {} }] + end + + def grouped(filter, aggregation, limit) + group_field = aggregation.groups.first[:field] + relation = find_group_relation(group_field) + operation = QueryBuilder.grouped_aggregate(table_name, relation, filter, aggregation, PARENT_LIMIT) + + rows = @collection.execute(:aggregate, operation)[relation[:parent_table]] || [] + warn_truncated(relation[:parent_table]) if rows.size >= PARENT_LIMIT + + results = collect_groups(rows, relation, aggregation, group_field) + limit ? results.first(limit) : results + end + + def collect_groups(rows, relation, aggregation, group_field) + results = rows.filter_map do |row| + value = extract_value(row.dig("#{relation[:relation_name]}_aggregate", 'aggregate'), aggregation) + next if childless_parent?(value, aggregation) + + { 'value' => value, 'group' => { group_field => row[relation[:parent_field]] } } + end + + results.sort_by { |row| sortable_value(row['value']) }.reverse + end + + # A `Sum` adding up to zero is a real group, unlike a parent that simply has + # no child row — which SQL grouping would not return either. + def childless_parent?(value, aggregation) + value.nil? || (aggregation.operation == 'Count' && value.to_i.zero?) + end + + # Accepts a foreign key (`membership_id`) or a path through a ManyToOne + # (`membership:full_name`, what leaderboard charts request). + def find_group_relation(group_field) + field_name, parent_column = group_field.split(':') + relation, foreign_key = resolve_group_relation(field_name) + reverse = relation && reverse_relation_name(relation, foreign_key) + + unless reverse + raise ForestException, + "Group by '#{group_field}' is not supported: the GraphQL datasource groups through a " \ + "foreign key whose reverse relationship is declared in Hasura (collection '#{name}')." + end + + parent = datasource.get_collection(relation.foreign_collection) + + { + parent_table: parent.table_name, + parent_field: parent_column || relation.foreign_key_target, + relation_name: reverse + } + end + + def resolve_group_relation(field_name) + field = fields[field_name] + return [field, field.foreign_key] if field&.type == 'ManyToOne' + + [fields.values.find { |f| f.type == 'ManyToOne' && f.foreign_key == field_name }, field_name] + end + + def reverse_relation_name(relation, foreign_key) + parent = datasource.get_collection(relation.foreign_collection) + + parent.schema[:fields].each do |relation_name, field| + next unless field.type == 'OneToMany' && + field.foreign_collection == name && + field.origin_key == foreign_key + + return relation_name + end + + nil + end + + def warn_truncated(parent_table) + ForestAdminDatasourceGraphqlHasura.logger.warn( + "[forest_admin_datasource_graphql_hasura] Grouped aggregation on '#{name}' stopped after " \ + "#{PARENT_LIMIT} '#{parent_table}' rows; the result may be incomplete." + ) + end + + # Hasura returns bigint/numeric/money as JSON strings to preserve precision, + # and Max/Min may aggregate dates. + def sortable_value(value) + case value + when Numeric then value + when String then Float(value, exception: false) || 0 + else 0 + end + end + + def extract_value(data, aggregation) + return nil if data.nil? + + if aggregation.operation == 'Count' + data['count'] + else + data.dig(aggregation.operation.downcase, aggregation.field) + end + end + end + end +end From d4580c3585bc3eacd01b4aebcf374f3340744419 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 09:51:19 +0200 Subject: [PATCH 04/26] fix(datasource graphql hasura): address the remaining review findings - a condition tree that matches every row converts to nil instead of an empty `_and`, which Hasura reads as vacuously true and which slipped past the mutation guard, so an update could have touched a whole table - an object relationship is only taken for a polymorphic branch when its mapping uses the expected foreign key, including when its target is configured: a table can hold both an ordinary relationship and a branch towards the same target - primary keys come from `_by_pk` only; an `id` column on a view or a tracked function carries no uniqueness to address records by - an explicit allow-list wins over the built-in system-table prefixes - a scalar column named like a `_aggregate` companion field is kept - Postgres arrays take the type of their element (`_int4` reads as Number) - grouping on several fields is rejected rather than silently honouring the first - parent rows sharing a group value are merged, as SQL grouping would - a zero `count(columns: field)` is kept: rows exist, they all hold null - Max/Min over dates order by instant instead of collapsing to zero --- .../introspection/introspector.rb | 39 ++++++++------ .../introspection/polymorphism_detector.rb | 6 ++- .../query/aggregator.rb | 52 +++++++++++++++---- .../query/filter_converter.rb | 20 ++++++- .../collection_spec.rb | 48 +++++++++++++++++ .../introspector_detection_spec.rb | 50 ++++++++++++++++++ .../query/filter_converter_spec.rb | 23 ++++++++ 7 files changed, 210 insertions(+), 28 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb index 5ea0b105a..d8c47d3b4 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb @@ -185,13 +185,17 @@ def parse_tables(query_fields) end def skip_table?(name) + # An explicitly allow-listed table wins over the built-in exclusions, so a + # legitimate table whose name starts like a system one stays reachable. + return false if @configuration.included_tables&.include?(name) + EXCLUDED_PREFIXES.any? { |prefix| name.start_with?(prefix) } || EXCLUDED_SUFFIXES.any? { |suffix| name.end_with?(suffix) } || !@configuration.table_allowed?(name) end def parse_table(table_name, type) - fields = (type['fields'] || []).reject { |field| companion_field?(field['name']) } + fields = (type['fields'] || []).reject { |field| companion_field?(field) } scalars, relations = fields.partition { |field| scalar?(base_type_name(field['type'])) } columns = scalars.map { |field| parse_column(field, base_type_name(field['type'])) } @@ -204,10 +208,13 @@ def parse_table(table_name, type) ) end - # Introspection metadata and the `_aggregate` fields Hasura adds - # next to every array relationship — neither are columns or relations. - def companion_field?(name) - name.start_with?('__') || name.end_with?('_aggregate') + # Introspection metadata and the `_aggregate` objects Hasura adds + # next to every array relationship. The suffix alone is not enough: a scalar + # column may legitimately be named `total_aggregate`. + def companion_field?(field) + return true if field['name'].start_with?('__') + + field['name'].end_with?('_aggregate') && !scalar?(base_type_name(field['type'])) end def parse_relationship(table_name, field) @@ -238,9 +245,10 @@ def parse_column(field, type_name) ) end - # Hasura only generates a `_by_pk` query for tables that have a primary - # key. Anything else is left without one: guessing a composite key out of - # the `*_id` columns produced wrong record ids. + # Hasura only generates a `_by_pk` query for a tracked table that has a + # primary key, which makes it the one trustworthy signal. Inferring a key + # from an `id` column would address records of a view — or of a tracked + # function — through a column that carries no uniqueness. def resolve_primary_key(table_name, columns) known = @primary_keys[table_name] @@ -250,14 +258,6 @@ def resolve_primary_key(table_name, columns) return known end - id_column = columns.find { |column| column.name == 'id' } - - if id_column - id_column.is_primary_key = true - - return ['id'] - end - [] end @@ -281,11 +281,18 @@ def base_type_name(type_ref) type_ref['name'] || base_type_name(type_ref['ofType']) end + # Hasura names a Postgres array scalar after its element type, prefixed with + # an underscore (`_int4`), so the element type drives the mapping. def map_column_type(graphql_type) + graphql_type = graphql_type.delete_prefix('_') + { 'Int' => 'Number', 'Float' => 'Number', 'numeric' => 'Number', 'bigint' => 'Number', 'smallint' => 'Number', 'integer' => 'Number', 'real' => 'Number', 'double_precision' => 'Number', 'money' => 'Number', + # Internal Postgres names, which is how Hasura names array element types + 'int2' => 'Number', 'int4' => 'Number', 'int8' => 'Number', + 'float4' => 'Number', 'float8' => 'Number', 'bool' => 'Boolean', 'String' => 'String', 'text' => 'String', 'varchar' => 'String', 'char' => 'String', 'bpchar' => 'String', 'citext' => 'String', 'inet' => 'String', 'ID' => 'String', 'Boolean' => 'Boolean', diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb index 336b07cb7..66973a1e3 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb @@ -70,12 +70,16 @@ def targets_of(table, base, tables_by_name) def branch?(relationship, foreign_key, configured_tables) return false unless relationship.kind == :object + # A known mapping is checked even when the target is configured: a table + # may hold both an ordinary relationship and a polymorphic branch towards + # the same target, and they must not be mistaken for one another. + return false unless relationship.mapping.nil? || relationship.mapping.keys == [foreign_key] return configured_tables.include?(relationship.remote_table) if configured_tables # A relationship backed by a real foreign key constraint is monomorphic by # definition: accepting one here would absorb a legitimate belongs_to # whenever an unrelated `_type` enum sits next to `_id`. - relationship.manual && relationship.mapping&.keys == [foreign_key] + relationship.manual end def class_name_of(table_name) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb index 4859ea72e..3854a3d63 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb @@ -1,3 +1,5 @@ +require 'time' + module ForestAdminDatasourceGraphqlHasura module Query # Runs Forest aggregations against Hasura on behalf of a collection. @@ -38,8 +40,14 @@ def fields = @collection.schema[:fields] # request's `aggregateFieldName` straight through). def validate(aggregation) validate_field(aggregation.field) if aggregation.field + groups = aggregation.groups || [] + + if groups.size > 1 + raise ForestException, + "Grouping on several fields is not supported by the GraphQL datasource (collection '#{name}')." + end - (aggregation.groups || []).each do |group| + groups.each do |group| if group[:operation] raise ForestException, "Date grouping is not supported by the GraphQL datasource (collection '#{name}')." @@ -97,20 +105,39 @@ def grouped(filter, aggregation, limit) end def collect_groups(rows, relation, aggregation, group_field) - results = rows.filter_map do |row| + values = rows.each_with_object({}) do |row, memo| value = extract_value(row.dig("#{relation[:relation_name]}_aggregate", 'aggregate'), aggregation) next if childless_parent?(value, aggregation) - { 'value' => value, 'group' => { group_field => row[relation[:parent_field]] } } + key = row[relation[:parent_field]] + memo[key] = memo.key?(key) ? merge_values(memo[key], value, aggregation) : value end - results.sort_by { |row| sortable_value(row['value']) }.reverse + values + .map { |key, value| { 'value' => value, 'group' => { group_field => key } } } + .sort_by { |row| sortable_value(row['value']) } + .reverse end - # A `Sum` adding up to zero is a real group, unlike a parent that simply has - # no child row — which SQL grouping would not return either. + # Two parent rows can share a group value — grouping by a name rather than by + # the primary key, as leaderboard charts do — and SQL would return them as a + # single group. + def merge_values(current, value, aggregation) + case aggregation.operation + when 'Count', 'Sum' then sortable_value(current) + sortable_value(value) + when 'Max' then sortable_value(value) > sortable_value(current) ? value : current + when 'Min' then sortable_value(value) < sortable_value(current) ? value : current + else + raise ForestException, + "#{aggregation.operation} cannot be grouped on '#{name}' by a value several parent rows " \ + 'share: the result would not be exact. Group on the foreign key instead.' + end + end + + # A parent with no child row is what SQL grouping would leave out. A zero + # `count(columns: field)` is different: rows exist, they just all hold null. def childless_parent?(value, aggregation) - value.nil? || (aggregation.operation == 'Count' && value.to_i.zero?) + value.nil? || (aggregation.operation == 'Count' && aggregation.field.nil? && value.to_i.zero?) end # Accepts a foreign key (`membership_id`) or a path through a ManyToOne @@ -164,15 +191,22 @@ def warn_truncated(parent_table) end # Hasura returns bigint/numeric/money as JSON strings to preserve precision, - # and Max/Min may aggregate dates. + # and Max/Min aggregate dates, which have to order by instant rather than + # collapse to zero. def sortable_value(value) case value when Numeric then value - when String then Float(value, exception: false) || 0 + when String then Float(value, exception: false) || time_value(value) else 0 end end + def time_value(value) + Time.parse(value).to_f + rescue ArgumentError, TypeError + 0 + end + def extract_value(data, aggregation) return nil if data.nil? diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/filter_converter.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/filter_converter.rb index bd0ae9bd4..0d2945397 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/filter_converter.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/filter_converter.rb @@ -14,8 +14,7 @@ def convert(condition_tree) case condition_tree when Nodes::ConditionTreeBranch - aggregator = condition_tree.aggregator == 'And' ? '_and' : '_or' - { aggregator => condition_tree.conditions.map { |condition| convert(condition) } } + convert_branch(condition_tree) when Nodes::ConditionTreeLeaf convert_leaf(condition_tree) else @@ -25,6 +24,23 @@ def convert(condition_tree) private + # A branch that matches every row converts to nil rather than to an empty + # `_and`, which Hasura reads as vacuously true: the mutation guards treat + # nil as "no filter" and refuse to run, whereas `{ _and: [] }` would slip + # through and touch the whole table. + def convert_branch(branch) + conditions = branch.conditions.map { |condition| convert(condition) } + + if branch.aggregator == 'And' + kept = conditions.compact + + kept.empty? ? nil : { '_and' => kept } + else + # A nil among the alternatives matches everything, so does the union. + conditions.include?(nil) ? nil : { '_or' => conditions } + end + end + # `_and`/`_or` only exist at bool_exp level, never inside a comparison # expression, so an operator needing two comparisons on the same field is # nested first and combined after. diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb index d9eda4569..5aad2abd2 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb @@ -152,6 +152,13 @@ def last_graphql_request end describe '#update' do + it 'refuses a filter whose condition tree matches everything' do + empty_branch = branch('And', []) + + expect { comments.update(caller, filter(condition_tree: empty_branch), { 'body' => 'x' }) } + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /Refusing/) + end + it 'updates through Hasura with the converted filter' do BankingSchema.stub_graphql_data({ 'update_comments' => { 'affected_rows' => 1 } }) @@ -174,6 +181,47 @@ def last_graphql_request end describe '#aggregate' do + it 'rejects grouping on several fields rather than honouring only the first' do + aggregation = toolkit_query::Aggregation.new( + operation: 'Count', + groups: [{ field: 'membership_id' }, { field: 'commentable_type' }] + ) + + expect { comments.aggregate(caller, filter, aggregation) } + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /several fields/) + end + + it 'merges parent rows that share the same group value' do + BankingSchema.stub_graphql_data( + { + 'memberships' => [ + { 'full_name' => 'Jane', 'comments_aggregate' => { 'aggregate' => { 'count' => 3 } } }, + { 'full_name' => 'Jane', 'comments_aggregate' => { 'aggregate' => { 'count' => 2 } } } + ] + } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Count', + groups: [{ field: 'membership:full_name' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result).to eq([{ 'value' => 5, 'group' => { 'membership:full_name' => 'Jane' } }]) + end + + # `count(columns: x)` returns zero when rows exist but every value is null, + # which is not the same as a parent without children. + it 'keeps a zero count on a specific column' do + BankingSchema.stub_graphql_data( + { 'memberships' => [{ 'id' => 1, 'comments_aggregate' => { 'aggregate' => { 'count' => 0 } } }] } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Count', field: 'body', + groups: [{ field: 'membership_id' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result).to eq([{ 'value' => 0, 'group' => { 'membership_id' => 1 } }]) + end + it 'runs simple counts against
_aggregate' do BankingSchema.stub_graphql_data({ 'comments_aggregate' => { 'aggregate' => { 'count' => 12 } } }) diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb index cf7f929ed..297969979 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb @@ -181,6 +181,56 @@ def build_datasource(**options) expect(build_datasource.get_collection('Transfer').schema[:fields]).not_to have_key('account') end + # Hasura only generates `_by_pk` for a real primary key, so an `id` column on a + # view or a tracked function carries no uniqueness to address records by. + it 'does not infer a primary key from an id column without a _by_pk query' do + stub_schema( + [{ 'name' => 'transfer_views', 'kind' => 'OBJECT', + 'fields' => [field('id', scalar('bigint')), field('total', scalar('bigint'))] }], + [list_query('transfer_views')] + ) + stub_metadata([]) + + expect(build_datasource.collections).to be_empty + end + + it 'keeps a scalar column whose name ends with _aggregate' do + stub_schema( + [{ 'name' => 'transfers', 'kind' => 'OBJECT', + 'fields' => [field('id', non_null(scalar('bigint'))), field('total_aggregate', scalar('bigint'))] }], + [list_query('transfers'), by_pk_query('transfers')] + ) + stub_metadata([]) + + fields = build_datasource.get_collection('Transfer').schema[:fields] + + expect(fields['total_aggregate'].type).to eq('Column') + end + + it 'types a Postgres array after its element type' do + stub_schema( + [{ 'name' => 'transfers', 'kind' => 'OBJECT', + 'fields' => [field('id', non_null(scalar('bigint'))), field('scores', scalar('_int4'))] }], + [list_query('transfers'), by_pk_query('transfers')] + ) + stub_metadata([]) + + expect(build_datasource.get_collection('Transfer').schema[:fields]['scores'].column_type).to eq(['Number']) + end + + it 'lets an explicit allow-list restore a table the built-in prefixes exclude' do + stub_schema( + [{ 'name' => 'pg_stat_activity', 'kind' => 'OBJECT', + 'fields' => [field('id', non_null(scalar('bigint')))] }], + [list_query('pg_stat_activity'), by_pk_query('pg_stat_activity')] + ) + stub_metadata([]) + + datasource = build_datasource(included_tables: ['pg_stat_activity']) + + expect(datasource.collections.keys).to eq(['PgStatActivity']) + end + it 'skips a table with no detectable primary key instead of exposing a broken collection' do stub_schema( [{ 'name' => 'transfer_stats', 'kind' => 'OBJECT', diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/query/filter_converter_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/query/filter_converter_spec.rb index 0c11e3dfc..c1db49ee5 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/query/filter_converter_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/query/filter_converter_spec.rb @@ -81,6 +81,29 @@ def leaf(field, operator, value = nil) ) end + # Hasura reads an empty `_and` as vacuously true, which would let a mutation + # through the empty-filter guard and touch every row. + it 'converts a branch that matches everything to nil rather than an empty _and' do + expect(described_class.convert(nodes::ConditionTreeBranch.new('And', []))).to be_nil + expect(described_class.convert(nodes::ConditionTreeBranch.new('Or', [ + nodes::ConditionTreeBranch.new('And', []), + leaf('a', operators::EQUAL, 1) + ]))).to be_nil + end + + it 'keeps an empty Or, which matches nothing' do + expect(described_class.convert(nodes::ConditionTreeBranch.new('Or', []))).to eq({ '_or' => [] }) + end + + it 'drops a match-everything branch nested in an And' do + tree = nodes::ConditionTreeBranch.new('And', [ + nodes::ConditionTreeBranch.new('And', []), + leaf('a', operators::EQUAL, 1) + ]) + + expect(described_class.convert(tree)).to eq({ '_and' => [{ 'a' => { '_eq' => 1 } }] }) + end + it 'raises on unsupported operators' do expect { described_class.convert(leaf('a', operators::LONGER_THAN, 3)) } .to raise_error(ForestAdminDatasourceGraphqlHasura::GraphqlError, /Unsupported operator/) From fe45ab2baee5d36a3e5a248d70742426a5f6c847 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 10:00:01 +0200 Subject: [PATCH 05/26] fix(datasource graphql hasura): merge grouped values exactly Merging parent rows that share a group value, added in the previous commit, went through a float conversion: a Sum over bigint lost precision past 2^53, and a Max or Min over text compared two zeros and kept whichever row came first. Whole numbers are now added as Integers, which Ruby does not cap, and Max/Min compare through a tuple that orders numbers and instants together, then text lexically. Sorting reuses that same tuple, so the order and the merge agree. --- .../query/aggregator.rb | 50 ++++++++++++++----- .../collection_spec.rb | 36 +++++++++++++ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb index 3854a3d63..e3bee001e 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb @@ -115,7 +115,7 @@ def collect_groups(rows, relation, aggregation, group_field) values .map { |key, value| { 'value' => value, 'group' => { group_field => key } } } - .sort_by { |row| sortable_value(row['value']) } + .sort_by { |row| comparable(row['value']) } .reverse end @@ -124,9 +124,9 @@ def collect_groups(rows, relation, aggregation, group_field) # single group. def merge_values(current, value, aggregation) case aggregation.operation - when 'Count', 'Sum' then sortable_value(current) + sortable_value(value) - when 'Max' then sortable_value(value) > sortable_value(current) ? value : current - when 'Min' then sortable_value(value) < sortable_value(current) ? value : current + when 'Count', 'Sum' then add(current, value) + when 'Max' then (comparable(value) <=> comparable(current)).positive? ? value : current + when 'Min' then (comparable(value) <=> comparable(current)).negative? ? value : current else raise ForestException, "#{aggregation.operation} cannot be grouped on '#{name}' by a value several parent rows " \ @@ -134,6 +134,23 @@ def merge_values(current, value, aggregation) end end + # Hasura sends bigint and numeric as JSON strings to keep a precision a Float + # would lose, so whole numbers are added as Integers, which Ruby does not cap. + def add(current, value) + left = numeric(current) + right = numeric(value) + + left + right + end + + def numeric(value) + case value + when Integer, Float then value + when String then value.match?(/\A-?\d+\z/) ? value.to_i : Float(value, exception: false) || 0 + else 0 + end + end + # A parent with no child row is what SQL grouping would leave out. A zero # `count(columns: field)` is different: rows exist, they just all hold null. def childless_parent?(value, aggregation) @@ -190,21 +207,30 @@ def warn_truncated(parent_table) ) end - # Hasura returns bigint/numeric/money as JSON strings to preserve precision, - # and Max/Min aggregate dates, which have to order by instant rather than - # collapse to zero. - def sortable_value(value) + # Aggregate values are not necessarily numbers: Hasura sends bigint and + # numeric as strings, and Max/Min aggregate dates as well as text. The tuple + # orders numbers and instants together, then text lexically, and stays + # comparable across rows so `sort_by` and Max/Min agree. + def comparable(value) case value - when Numeric then value - when String then Float(value, exception: false) || time_value(value) - else 0 + when Numeric then [0, value.to_f, ''] + when String then comparable_string(value) + else [2, 0.0, ''] end end + def comparable_string(value) + number = Float(value, exception: false) + return [0, number, ''] if number + + instant = time_value(value) + instant ? [0, instant, ''] : [1, 0.0, value] + end + def time_value(value) Time.parse(value).to_f rescue ArgumentError, TypeError - 0 + nil end def extract_value(data, aggregation) diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb index 5aad2abd2..4b23d05d4 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb @@ -208,6 +208,42 @@ def last_graphql_request expect(result).to eq([{ 'value' => 5, 'group' => { 'membership:full_name' => 'Jane' } }]) end + # Hasura sends bigint as a string precisely because a Float would round it. + it 'merges large integer sums without losing precision' do + BankingSchema.stub_graphql_data( + { + 'memberships' => [ + { 'full_name' => 'Jane', + 'comments_aggregate' => { 'aggregate' => { 'sum' => { 'id' => '9007199254740993' } } } }, + { 'full_name' => 'Jane', 'comments_aggregate' => { 'aggregate' => { 'sum' => { 'id' => '2' } } } } + ] + } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Sum', field: 'id', + groups: [{ field: 'membership:full_name' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result.first['value']).to eq(9_007_199_254_740_995) + end + + it 'merges a Max over text lexically instead of keeping the first row' do + BankingSchema.stub_graphql_data( + { + 'memberships' => [ + { 'full_name' => 'Jane', 'comments_aggregate' => { 'aggregate' => { 'max' => { 'body' => 'apple' } } } }, + { 'full_name' => 'Jane', 'comments_aggregate' => { 'aggregate' => { 'max' => { 'body' => 'pear' } } } } + ] + } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Max', field: 'body', + groups: [{ field: 'membership:full_name' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result.first['value']).to eq('pear') + end + # `count(columns: x)` returns zero when rows exist but every value is null, # which is not the same as a parent without children. it 'keeps a zero count on a specific column' do From 1035de71a5ffaab2630d576a1c1c93d4f43f71e1 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 15:38:52 +0200 Subject: [PATCH 06/26] fix(datasource graphql hasura): make grouped aggregations exact and honest at scale The parent-table detour read the first 1000 parents in arbitrary order, unfiltered, and logged a warning nobody charting sees: past 1000 parents the chart was silently wrong. Parents are now filtered by the chart's predicate through the relationship, ordered by their primary key and paginated; past 10 000 parent rows the chart fails with a clear error instead of returning a subset. Rows whose foreign key is NULL were invisible to the detour and fell out of every bucket, where SQL grouping gives them one of their own: they are now aggregated apart and merged in as the nil group. A foreign key was advertised as groupable whether or not Hasura declares the reverse array relationship the grouping query needs, so the UI could offer a group-by that the aggregator then rejects. The marking now happens once all collections are registered, and only where the reverse relationship exists. Co-Authored-By: Claude Fable 5 --- .../datasource.rb | 27 ++++++ .../introspection/schema_converter.rb | 10 --- .../query/aggregator.rb | 83 ++++++++++++++----- .../query/query_builder.rb | 32 +++++-- .../collection_spec.rb | 76 ++++++++++++++++- .../introspector_detection_spec.rb | 31 ++++++- .../spec/support/banking_schema.rb | 13 ++- 7 files changed, 225 insertions(+), 47 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb index 46075c37b..57eb458b6 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb @@ -21,10 +21,37 @@ def register_collections add_collection(Collection.new(self, table, @client, converter)) end + mark_groupable_foreign_keys + ForestAdminDatasourceGraphqlHasura.logger.info( "[forest_admin_datasource_graphql_hasura] #{tables.size} collections registered " \ "(#{tables.sum { |table| table.polymorphics.size }} polymorphic relations detected)." ) end + + # The capabilities route publishes is_groupable, and grouping goes through the + # parent's nested `_aggregate`, which only exists when Hasura + # declares the reverse array relationship: marking a foreign key without one + # would have the UI offer a group-by that the aggregator then rejects. + def mark_groupable_foreign_keys + collections.each_value do |collection| + collection.schema[:fields].each_value do |field| + next unless field.type == 'ManyToOne' && reverse_declared?(collection, field) + + foreign_key = collection.schema[:fields][field.foreign_key] + foreign_key.is_groupable = true if foreign_key.respond_to?(:is_groupable=) + end + end + end + + def reverse_declared?(collection, relation) + parent = get_collection(relation.foreign_collection) + + parent.schema[:fields].each_value.any? do |field| + field.type == 'OneToMany' && + field.foreign_collection == collection.name && + field.origin_key == relation.foreign_key + end + end end end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb index 493c1c63b..426e8329f 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb @@ -65,7 +65,6 @@ def build_fields(table) end add_reverse_polymorphics(table, fields) - mark_groupable_foreign_keys(fields) fields end @@ -251,15 +250,6 @@ def reverse_polymorphic_name(table, child, polymorphic, fields) name end - def mark_groupable_foreign_keys(fields) - fields.each_value do |field| - next unless field.type == 'ManyToOne' - - foreign_key_field = fields[field.foreign_key] - foreign_key_field.is_groupable = true if foreign_key_field.respond_to?(:is_groupable=) - end - end - def primary_key_of(table) table.primary_key.first || 'id' end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb index e3bee001e..18dc06c4c 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb @@ -10,9 +10,11 @@ module Query class Aggregator ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException - # Parent rows are read in one page because they are reduced in Ruby; beyond - # that the chart would be truncated anyway. - PARENT_LIMIT = 1000 + # Parent rows are reduced in Ruby, so they are paginated by PARENT_PAGE and + # capped at MAX_PARENT_ROWS: past the cap the chart fails with a clear error + # rather than silently charting a subset. + PARENT_PAGE = 1000 + MAX_PARENT_ROWS = 10_000 def initialize(collection) @collection = collection @@ -95,28 +97,63 @@ def simple(filter, aggregation) def grouped(filter, aggregation, limit) group_field = aggregation.groups.first[:field] relation = find_group_relation(group_field) - operation = QueryBuilder.grouped_aggregate(table_name, relation, filter, aggregation, PARENT_LIMIT) + values = collect_groups(fetch_parent_rows(relation, filter, aggregation), relation, aggregation) + add_null_group(values, relation, filter, aggregation) - rows = @collection.execute(:aggregate, operation)[relation[:parent_table]] || [] - warn_truncated(relation[:parent_table]) if rows.size >= PARENT_LIMIT - - results = collect_groups(rows, relation, aggregation, group_field) + results = values + .map { |key, value| { 'value' => value, 'group' => { group_field => key } } } + .sort_by { |row| comparable(row['value']) } + .reverse limit ? results.first(limit) : results end - def collect_groups(rows, relation, aggregation, group_field) - values = rows.each_with_object({}) do |row, memo| + def fetch_parent_rows(relation, filter, aggregation) + rows = [] + offset = 0 + + loop do + operation = QueryBuilder.grouped_aggregate(table_name, relation, filter, aggregation, + { limit: PARENT_PAGE, offset: offset }) + page = @collection.execute(:aggregate, operation)[relation[:parent_table]] || [] + rows.concat(page) + + return rows if page.size < PARENT_PAGE + + if rows.size >= MAX_PARENT_ROWS + raise ForestException, + "Grouped aggregation on '#{name}' spans more than #{MAX_PARENT_ROWS} " \ + "'#{relation[:parent_table]}' rows; narrow the chart filter." + end + + offset += PARENT_PAGE + end + end + + def collect_groups(rows, relation, aggregation) + rows.each_with_object({}) do |row, memo| value = extract_value(row.dig("#{relation[:relation_name]}_aggregate", 'aggregate'), aggregation) next if childless_parent?(value, aggregation) key = row[relation[:parent_field]] memo[key] = memo.key?(key) ? merge_values(memo[key], value, aggregation) : value end + end + + # SQL grouping puts rows whose foreign key is NULL in a bucket of their own; + # the parent-table detour cannot see them, so they are aggregated apart. The + # nil key can pre-exist (a parent whose grouped column is null): both are the + # NULL group of a LEFT JOIN, so they merge. + def add_null_group(values, relation, filter, aggregation) + foreign_key = fields[relation[:foreign_key]] + return if foreign_key.nil? || foreign_key.validation.any? # non-nullable: no orphan rows + + operation = QueryBuilder.aggregate(table_name, filter, aggregation, + extra_where: { relation[:foreign_key] => { '_is_null' => true } }) + data = @collection.execute(:aggregate, operation).dig("#{table_name}_aggregate", 'aggregate') + value = extract_value(data, aggregation) + return if childless_parent?(value, aggregation) - values - .map { |key, value| { 'value' => value, 'group' => { group_field => key } } } - .sort_by { |row| comparable(row['value']) } - .reverse + values[nil] = values.key?(nil) ? merge_values(values[nil], value, aggregation) : value end # Two parent rows can share a group value — grouping by a name rather than by @@ -175,10 +212,19 @@ def find_group_relation(group_field) { parent_table: parent.table_name, parent_field: parent_column || relation.foreign_key_target, - relation_name: reverse + relation_name: reverse, + foreign_key: foreign_key, + parent_order_fields: primary_keys_of(parent) } end + # Offset pagination needs a stable order, which only the primary key gives. + def primary_keys_of(collection) + collection.schema[:fields] + .select { |_, field| field.respond_to?(:is_primary_key) && field.is_primary_key } + .keys + end + def resolve_group_relation(field_name) field = fields[field_name] return [field, field.foreign_key] if field&.type == 'ManyToOne' @@ -200,13 +246,6 @@ def reverse_relation_name(relation, foreign_key) nil end - def warn_truncated(parent_table) - ForestAdminDatasourceGraphqlHasura.logger.warn( - "[forest_admin_datasource_graphql_hasura] Grouped aggregation on '#{name}' stopped after " \ - "#{PARENT_LIMIT} '#{parent_table}' rows; the result may be incomplete." - ) - end - # Aggregate values are not necessarily numbers: Hasura sends bigint and # numeric as strings, and Max/Min aggregate dates as well as text. The tuple # orders numbers and instants together, then text lexically, and stays diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb index 9cff95ae5..00728e727 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb @@ -77,12 +77,14 @@ def delete(table, filter) { query: query, variables: { 'where' => FilterConverter.convert(filter.condition_tree) || {} } } end - def aggregate(table, filter, aggregation) + # extra_where is a raw bool_exp and-combined with the converted filter + # (the null-bucket query adds `{ fk => { _is_null => true } }`). + def aggregate(table, filter, aggregation, extra_where: nil) args = [] var_defs = [] variables = {} - where = FilterConverter.convert(filter.condition_tree) + where = combine(FilterConverter.convert(filter.condition_tree), extra_where) if where var_defs << "$where: #{table}_bool_exp" @@ -103,23 +105,29 @@ def aggregate(table, filter, aggregation) { query: query, variables: variables } end - # relation is { parent_table:, parent_field:, relation_name: }. - def grouped_aggregate(child_table, relation, filter, aggregation, parent_limit) + # relation is { parent_table:, parent_field:, relation_name:, parent_order_fields: }, + # page is { limit:, offset: }. Parents are ordered by their primary key so + # offset pagination is stable, and filtered by the chart's predicate through + # the relationship, so the pages only walk parents owning at least one + # matching child row. + def grouped_aggregate(child_table, relation, filter, aggregation, page) args = [] - var_defs = ['$parentLimit: Int'] - variables = { 'parentLimit' => parent_limit } + var_defs = ['$parentLimit: Int', '$parentOffset: Int'] + variables = { 'parentLimit' => page[:limit], 'parentOffset' => page[:offset] } + parent_args = ['limit: $parentLimit', 'offset: $parentOffset', parent_order(relation)] where = FilterConverter.convert(filter.condition_tree) if where var_defs << "$where: #{child_table}_bool_exp" args << 'where: $where' + parent_args << "where: { #{relation[:relation_name]}: $where }" variables['where'] = where end query = <<~GRAPHQL query Aggregate#{camelize(relation[:parent_table])}#{wrap(var_defs)} { - #{relation[:parent_table]}(limit: $parentLimit) { + #{relation[:parent_table]}#{wrap(parent_args)} { #{relation[:parent_field]} #{relation[:relation_name]}_aggregate#{wrap(args)} { aggregate { @@ -185,6 +193,16 @@ def stringify_keys(record) record.to_h { |key, value| [key.to_s, value] } end + def parent_order(relation) + "order_by: [#{relation[:parent_order_fields].map { |field| "{ #{field}: asc }" }.join(", ")}]" + end + + def combine(where, extra) + return where if extra.nil? + + where ? { '_and' => [where, extra] } : extra + end + def wrap(parts) parts.empty? ? '' : "(#{parts.join(", ")})" end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb index 4b23d05d4..57067faac 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb @@ -29,13 +29,17 @@ def operators toolkit_query::ConditionTree::Operators end - def last_graphql_request - request = nil + def graphql_requests + requests = [] expect(WebMock).to(have_requested(:post, BankingSchema::GRAPHQL_URI).at_least_once.with do |req| - request = JSON.parse(req.body) unless req.body.include?('IntrospectSchema') + requests << JSON.parse(req.body) unless req.body.include?('IntrospectSchema') true end) - request + requests + end + + def last_graphql_request + graphql_requests.last end describe '#list' do @@ -283,6 +287,70 @@ def last_graphql_request expect(result).to eq([{ 'value' => 3, 'group' => { 'membership_id' => 1 } }]) end + it 'filters and orders the parent rows through the relationship predicate' do + BankingSchema.stub_graphql_data({ 'memberships' => [] }) + + aggregation = toolkit_query::Aggregation.new(operation: 'Count', groups: [{ field: 'membership_id' }]) + condition_tree = leaf('created_at', operators::GREATER_THAN, '2026-01-01') + comments.aggregate(caller, filter(condition_tree: condition_tree), aggregation) + + parents_request = graphql_requests.find { |request| request['query'].include?('memberships(') } + expect(parents_request['query']).to include('where: { comments: $where }') + expect(parents_request['query']).to include('order_by: [{ id: asc }]') + expect(parents_request['variables']['where']).to eq({ 'created_at' => { '_gt' => '2026-01-01' } }) + end + + it 'paginates the parent rows instead of stopping at the first page' do + full_page = { + 'memberships' => (1..1000).map do |id| + { 'id' => id, 'comments_aggregate' => { 'aggregate' => { 'count' => 1 } } } + end + } + last_page = { 'memberships' => [{ 'id' => 2000, 'comments_aggregate' => { 'aggregate' => { 'count' => 5 } } }] } + no_orphans = { 'comments_aggregate' => { 'aggregate' => { 'count' => 0 } } } + BankingSchema.stub_graphql_data(full_page, last_page, no_orphans) + + aggregation = toolkit_query::Aggregation.new(operation: 'Count', groups: [{ field: 'membership_id' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result.size).to eq(1001) + expect(result.first).to eq({ 'value' => 5, 'group' => { 'membership_id' => 2000 } }) + offsets = graphql_requests.filter_map { |request| request.dig('variables', 'parentOffset') } + expect(offsets).to eq([0, 1000]) + end + + it 'fails clearly instead of charting a subset when the parent rows exceed the cap' do + full_page = { + 'memberships' => (1..1000).map do |id| + { 'id' => id, 'comments_aggregate' => { 'aggregate' => { 'count' => 1 } } } + end + } + BankingSchema.stub_graphql_data(*Array.new(10, full_page)) + + aggregation = toolkit_query::Aggregation.new(operation: 'Count', groups: [{ field: 'membership_id' }]) + + expect { comments.aggregate(caller, filter, aggregation) } + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /more than 10000/) + end + + # SQL grouping would put comments whose membership_id is NULL in their own + # bucket; the parent-table detour cannot see them. + it 'adds a bucket for the rows whose foreign key is null' do + BankingSchema.stub_graphql_data( + { 'memberships' => [{ 'id' => 1, 'comments_aggregate' => { 'aggregate' => { 'count' => 3 } } }] }, + { 'comments_aggregate' => { 'aggregate' => { 'count' => 2 } } } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Count', groups: [{ field: 'membership_id' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result).to eq([ + { 'value' => 3, 'group' => { 'membership_id' => 1 } }, + { 'value' => 2, 'group' => { 'membership_id' => nil } } + ]) + expect(last_graphql_request['variables']['where']).to eq({ 'membership_id' => { '_is_null' => true } }) + end + it 'rejects grouping on the polymorphic foreign key with a clear error' do aggregation = toolkit_query::Aggregation.new(operation: 'Count', groups: [{ field: 'commentable_id' }]) diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb index 297969979..3003512c6 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb @@ -407,12 +407,41 @@ def build_datasource(**options) end describe 'groupable foreign keys' do - it 'marks a ManyToOne foreign key as groupable' do + it 'marks a ManyToOne foreign key as groupable when its reverse relationship is declared' do datasource = BankingSchema.build_datasource fields = datasource.get_collection('Comment').schema[:fields] expect(fields['membership_id'].is_groupable).to be(true) expect(fields['body'].is_groupable).to be(false) end + + # Grouping goes through the parent's nested `_aggregate`, which only + # exists when Hasura declares the reverse array relationship: advertising the + # foreign key would offer a group-by that the aggregator then rejects. + it 'does not mark a foreign key groupable when no reverse relationship is declared' do + stub_schema( + [ + { + 'name' => 'payments', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('account_id', non_null(scalar('bigint'))), + field('account', object('accounts')) + ] + }, + { 'name' => 'accounts', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + [list_query('payments'), by_pk_query('payments'), list_query('accounts'), by_pk_query('accounts')] + ) + stub_metadata( + [{ 'table' => { 'schema' => 'public', 'name' => 'payments' }, + 'object_relationships' => [fk_object_rel('account', 'account_id')] }] + ) + + fields = build_datasource.get_collection('Payment').schema[:fields] + + expect(fields['account'].type).to eq('ManyToOne') + expect(fields['account_id'].is_groupable).to be(false) + end end end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/support/banking_schema.rb b/packages/forest_admin_datasource_graphql_hasura/spec/support/banking_schema.rb index caa6626b6..50849e45f 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/support/banking_schema.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/support/banking_schema.rb @@ -195,11 +195,18 @@ def stub_metadata(available: true) end end - def stub_graphql_data(data) + # Each argument is one response; WebMock repeats the last one when more + # requests come in (a grouped aggregation issues parent pages, then the + # null-bucket aggregate). + def stub_graphql_data(*data) + responses = data.map do |body| + { status: 200, body: JSON.generate({ 'data' => body }), + headers: { 'Content-Type' => 'application/json' } } + end + WebMock::API.stub_request(:post, GRAPHQL_URI) .with { |request| !request.body.include?('IntrospectSchema') } - .to_return(status: 200, body: JSON.generate({ 'data' => data }), - headers: { 'Content-Type' => 'application/json' }) + .to_return(*responses) end def build_datasource(**options) From 8e518a90d1d6304ef6b5ec12e1d1c54c6fdf7fc7 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 15:39:08 +0200 Subject: [PATCH 07/26] fix(datasource graphql hasura): separate transport failures from Hasura errors and harden the configuration Every failure surfaced as a 400 ValidationError, dressing a downed Hasura up as a client mistake and hiding it from 5xx-based monitoring. Errors Hasura itself returns keep the 400; an unreachable endpoint (timeout, DNS, TLS, non-2xx, invalid body) now raises TransportError, a ForestException carrying a 503 status, so the message stays actionable and the incident stays visible. The client gains its own spec, which the transport paths never had. Two configuration traps are closed. A polymorphic relation declared on a table missing its _type/_id column pair emitted a relation towards columns that do not exist; it is now skipped with a warning naming the missing columns. And when the uri carries no '/v1/graphql' segment, no metadata endpoint is derived anymore: substituting on such a uri silently posted metadata commands to the GraphQL endpoint itself. Co-Authored-By: Claude Fable 5 --- .../forest_admin_datasource_graphql_hasura.rb | 11 +++ .../client.rb | 17 +++- .../collection.rb | 36 +++---- .../configuration.rb | 4 +- .../introspection/polymorphism_detector.rb | 15 ++- .../client_spec.rb | 93 +++++++++++++++++++ .../datasource_spec.rb | 11 +++ 7 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura.rb index d086332e2..8f62c4b79 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura.rb @@ -16,8 +16,19 @@ class ConfigurationError < Error; end # Inherits from the toolkit exception so the agent's error translator surfaces # the actual message with a 400 instead of an opaque 500 "Unexpected error". + # Reserved for errors Hasura itself returns; transport failures are not the + # user's doing and raise TransportError instead. class GraphqlError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end + # An unreachable endpoint is an infrastructure incident, not a client mistake: + # 503 keeps HTTP monitoring truthful, while inheriting the toolkit exception + # still lets the error translator surface the actionable message. + class TransportError < ForestAdminDatasourceToolkit::Exceptions::ForestException + def status + 503 + end + end + class IntrospectionError < Error; end class << self diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb index 41c8ada15..be7d3e912 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb @@ -11,8 +11,9 @@ def initialize(configuration) @configuration = configuration end - # Wrapped in GraphqlError so they reach the user as an actionable message - # instead of an opaque 500. + # Wrapped in TransportError (503) so they reach the user as an actionable + # message without masquerading as a client mistake: errors Hasura itself + # returns are the only ones raised as GraphqlError (400). TRANSPORT_ERRORS = [ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Net::HTTPBadResponse, IOError, SocketError, SystemCallError, OpenSSL::SSL::SSLError, JSON::ParserError @@ -22,7 +23,7 @@ def execute(query, variables = {}) body = JSON.generate({ query: query, variables: variables }) response = post(@configuration.uri, body) - raise GraphqlError, "GraphQL endpoint returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) + raise TransportError, "GraphQL endpoint returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) payload = JSON.parse(response.body) @@ -33,13 +34,21 @@ def execute(query, variables = {}) payload['data'] rescue *TRANSPORT_ERRORS => e - raise GraphqlError, "Could not reach the GraphQL endpoint (#{e.class}): #{e.message}" + raise TransportError, "Could not reach the GraphQL endpoint (#{e.class}): #{e.message}" end # Returns nil when the endpoint is unreachable or forbidden, which is common # in production: introspection then falls back to the configuration and to # naming conventions. def fetch_metadata + if @configuration.metadata_uri.nil? + ForestAdminDatasourceGraphqlHasura.logger.info( + '[forest_admin_datasource_graphql_hasura] No metadata endpoint could be derived from uri ' \ + "(no '/v1/graphql' segment); set the 'metadata_uri' option to enable relationship detection." + ) + return nil + end + body = JSON.generate({ type: 'export_metadata', version: 2, args: {} }) response = post(@configuration.metadata_uri, body) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb index e5f5bfa4b..9cdd8242a 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb @@ -56,28 +56,20 @@ def aggregate(_caller, filter, aggregation, limit = nil) Query::Aggregator.new(self).run(filter, aggregation, limit) end - # Wraps every Hasura call so the failing operation is named in the error. + # Wraps every Hasura call so the failing operation is named in the error, + # keeping the class (GraphqlError or TransportError) and thus the status. def execute(operation_name, operation) @client.execute(operation[:query], operation[:variables]) - rescue GraphqlError => e - raise GraphqlError, "GraphQL #{operation_name} failed on '#{name}': #{e.message}" + rescue GraphqlError, TransportError => e + raise e.class, "GraphQL #{operation_name} failed on '#{name}': #{e.message}" end - private - - def column_names - @column_names ||= @table.columns.map(&:name) - end - - # Selects on the schema rather than on the value type, so that a `jsonb` - # column — whose value is a hash, like a relation payload would be — is kept. - def writable_columns(data) - data.select { |key, _| column_names.include?(key.to_s) } - end + protected # A PolymorphicManyToOne cannot be joined by Hasura, so its discriminator # columns are selected instead and the relation is rebuilt by the serializer - # from those two values (see materialize_polymorphics). + # from those two values (see materialize_polymorphics). Protected: called on + # the target collection to resolve nested selections. def build_selection(projection) selection = projection.columns.reject { |column| column == '*' } @@ -90,7 +82,7 @@ def build_selection(projection) selection << field.foreign_key_type_field else target = datasource.get_collection(field.foreign_collection) - nested = target.send(:build_selection, relation_projection) + nested = target.build_selection(relation_projection) selection << "#{relation_name} { #{nested.join(" ")} }" end end @@ -98,6 +90,18 @@ def build_selection(projection) selection.uniq end + private + + def column_names + @column_names ||= @table.columns.map(&:name) + end + + # Selects on the schema rather than on the value type, so that a `jsonb` + # column — whose value is a hash, like a relation payload would be — is kept. + def writable_columns(data) + data.select { |key, _| column_names.include?(key.to_s) } + end + # The serializer reads the reference off the discriminator columns, so the # relation key only carries a placeholder — which has to be non-empty, since # an empty hash drops the relation from the JSON:API payload. A type matching diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb index 03190806a..816dc5933 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb @@ -25,7 +25,9 @@ def initialize(uri:, **options) @uri = uri DEFAULTS.each { |option, default| instance_variable_set("@#{option}", options.fetch(option, default)) } - @metadata_uri ||= uri.sub('/v1/graphql', '/v1/metadata') + # Only derivable from the conventional endpoint path: substituting on any + # other uri would silently post metadata commands to the GraphQL endpoint. + @metadata_uri ||= uri.include?('/v1/graphql') ? uri.sub('/v1/graphql', '/v1/metadata') : nil end def table_allowed?(table_name) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb index 66973a1e3..e6ffce465 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb @@ -47,7 +47,20 @@ def bases_of(table) base if name.end_with?('_type') && names.include?("#{base}_id") end - (detected + configured).uniq + (detected + configured.select { |base| discriminators?(table, names, base) }).uniq + end + + # A configured association without its column pair would emit a relation + # referencing columns that do not exist, breaking the collection at boot. + def discriminators?(table, names, base) + missing = ["#{base}_type", "#{base}_id"].reject { |column| names.include?(column) } + return true if missing.empty? + + ForestAdminDatasourceGraphqlHasura.logger.warn( + '[forest_admin_datasource_graphql_hasura] Ignoring the configured polymorphic relation ' \ + "'#{table.name}.#{base}': column(s) #{missing.join(", ")} not found on '#{table.name}'." + ) + false end def targets_of(table, base, tables_by_name) diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb new file mode 100644 index 000000000..52db3599f --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb @@ -0,0 +1,93 @@ +require 'spec_helper' + +module ForestAdminDatasourceGraphqlHasura + RSpec.describe Client do + let(:configuration) { Configuration.new(uri: BankingSchema::GRAPHQL_URI) } + let(:client) { described_class.new(configuration) } + + describe '#execute' do + it 'returns the data payload' do + WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI) + .to_return(status: 200, body: JSON.generate({ 'data' => { 'ok' => 1 } })) + + expect(client.execute('query { ok }')).to eq({ 'ok' => 1 }) + end + + it 'sends the configured headers' do + configuration = Configuration.new(uri: BankingSchema::GRAPHQL_URI, + headers: { 'x-hasura-admin-secret' => 's3cret' }) + stub = WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI) + .with(headers: { 'x-hasura-admin-secret' => 's3cret' }) + .to_return(status: 200, body: JSON.generate({ 'data' => {} })) + + described_class.new(configuration).execute('query { ok }') + + expect(stub).to have_been_requested + end + + # Errors Hasura itself returns are the user's to act on: 400. + it 'raises GraphqlError carrying every message Hasura returns' do + WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI) + .to_return(status: 200, + body: JSON.generate({ 'errors' => [{ 'message' => 'permission denied' }, + { 'message' => 'field unknown' }] })) + + expect { client.execute('query { ok }') } + .to raise_error(GraphqlError, 'permission denied; field unknown') + end + + # Infrastructure failures are not client mistakes: 503, message kept. + it 'raises TransportError with a 503 status on a non-2xx response' do + WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI).to_return(status: 502, body: 'bad gateway') + + expect { client.execute('query { ok }') }.to raise_error(TransportError) do |error| + expect(error.status).to eq(503) + expect(error.message).to include('HTTP 502') + end + end + + it 'raises TransportError on a timeout' do + WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI).to_timeout + + expect { client.execute('query { ok }') }.to raise_error(TransportError, /Could not reach/) + end + + it 'raises TransportError on a connection failure' do + WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI).to_raise(SocketError.new('getaddrinfo failed')) + + expect { client.execute('query { ok }') } + .to raise_error(TransportError, /getaddrinfo failed/) + end + + it 'raises TransportError on a body that is not JSON' do + WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI).to_return(status: 200, body: 'oops') + + expect { client.execute('query { ok }') }.to raise_error(TransportError, /Could not reach/) + end + end + + describe '#fetch_metadata' do + it 'returns the metadata when the endpoint answers' do + WebMock.stub_request(:post, BankingSchema::METADATA_URI) + .to_return(status: 200, body: JSON.generate({ 'metadata' => { 'sources' => [] } })) + + expect(client.fetch_metadata).to eq({ 'sources' => [] }) + end + + it 'returns nil when the endpoint is forbidden' do + WebMock.stub_request(:post, BankingSchema::METADATA_URI).to_return(status: 403, body: '{}') + + expect(client.fetch_metadata).to be_nil + end + + # An uri without the conventional segment yields no derivable metadata + # endpoint: introspection must not post metadata commands to GraphQL. + it 'skips the call entirely when no metadata endpoint could be derived' do + configuration = Configuration.new(uri: 'http://hasura.test/custom-graphql') + + expect(described_class.new(configuration).fetch_metadata).to be_nil + expect(WebMock).not_to have_requested(:post, 'http://hasura.test/custom-graphql') + end + end + end +end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/datasource_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/datasource_spec.rb index 72d226cc1..2013a7b69 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/datasource_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/datasource_spec.rb @@ -96,6 +96,17 @@ module ForestAdminDatasourceGraphqlHasura expect(fields).not_to have_key('card') end + # A configured association without its column pair would emit a relation + # referencing columns that do not exist and break the collection. + it 'ignores a configured polymorphic relation whose discriminator columns are missing' do + datasource = BankingSchema.build_datasource( + metadata_blocked: true, + polymorphic_relations: { 'transfers' => { 'ownable' => %w[cards] } } + ) + + expect(datasource.get_collection('Transfer').schema[:fields]).not_to have_key('ownable') + end + it 'still emits the polymorphic relations when declared in the configuration' do datasource = BankingSchema.build_datasource( metadata_blocked: true, From ed80af3708047c7cd6fa8ea2cc097676bd37c08d Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 15:39:21 +0200 Subject: [PATCH 08/26] chore(datasource graphql hasura): align packaging and test conventions - exclude validation/ from the built gem: the Docker stack, seed SQL and setup script were shipping to every client - add the LICENSE file the gemspec announces, like the other packages - raise minimum_coverage to the repo-wide 90 (actual coverage is 97%) - update the README to the new grouped-aggregation, error and metadata derivation behaviours Co-Authored-By: Claude Fable 5 --- .../LICENSE | 674 ++++++++++++++++++ .../README.md | 14 +- ...st_admin_datasource_graphql_hasura.gemspec | 2 +- .../spec/spec_helper.rb | 2 +- 4 files changed, 686 insertions(+), 6 deletions(-) create mode 100644 packages/forest_admin_datasource_graphql_hasura/LICENSE diff --git a/packages/forest_admin_datasource_graphql_hasura/LICENSE b/packages/forest_admin_datasource_graphql_hasura/LICENSE new file mode 100644 index 000000000..9cecc1d46 --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + 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 . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/packages/forest_admin_datasource_graphql_hasura/README.md b/packages/forest_admin_datasource_graphql_hasura/README.md index ba5f3901c..12a47ff10 100644 --- a/packages/forest_admin_datasource_graphql_hasura/README.md +++ b/packages/forest_admin_datasource_graphql_hasura/README.md @@ -61,7 +61,7 @@ type_values: { 'bank_accounts' => 'Banking::Account' } | --- | --- | | `uri` | Hasura GraphQL endpoint (required) | | `headers` | HTTP headers, e.g. admin secret or JWT | -| `metadata_uri` | Metadata endpoint (default: `uri` with `/v1/graphql` → `/v1/metadata`) | +| `metadata_uri` | Metadata endpoint (default: `uri` with `/v1/graphql` → `/v1/metadata`; not derived — and detection skipped — when `uri` has no `/v1/graphql` segment) | | `included_tables` / `excluded_tables` | Allow/deny lists of table names | | `polymorphic_relations` | Explicit polymorphic declarations (see above) | | `type_values` | Table → Rails class name overrides | @@ -76,9 +76,12 @@ type_values: { 'bank_accounts' => 'Banking::Account' } is left alone. - **Grouped aggregations** (charts) work on a foreign key, or on a `:` path through a ManyToOne (leaderboard charts) whose reverse relationship is declared in - Hasura: Hasura exposes GROUP BY only through nested `_aggregate` fields. Other - columns are advertised as non-groupable, and date truncation is not supported. Grouped - aggregation reads at most 1000 parent rows and logs a warning beyond that. + Hasura: Hasura exposes GROUP BY only through nested `_aggregate` fields. A + foreign key without a declared reverse relationship is advertised as non-groupable, like + every other column, and date truncation is not supported. Rows whose foreign key is NULL + form a bucket of their own, as SQL grouping would. Parent rows are filtered by the + chart's predicate and paginated by 1000; a chart spanning more than 10 000 parent rows + fails with a clear error rather than returning partial numbers. - **Tables without a primary key** (typically untracked views) are skipped: Forest cannot address their records. - Filtering and sorting through a polymorphic relation is not possible (a Forest Admin @@ -92,6 +95,9 @@ type_values: { 'bank_accounts' => 'Banking::Account' } - A `*_type` value matching no exposed collection (a legacy STI subclass name, an excluded target) leaves the reference empty and logs a warning, rather than failing the page. - `bytea` columns are surfaced as text (Hasura returns them hex-encoded). +- Errors Hasura returns (a permission rule, an invalid value) surface as HTTP 400 with the + original message; an unreachable endpoint (timeout, DNS, TLS, non-2xx response) surfaces + as HTTP 503, so infrastructure incidents stay visible to monitoring. ## Validating against a real instance diff --git a/packages/forest_admin_datasource_graphql_hasura/forest_admin_datasource_graphql_hasura.gemspec b/packages/forest_admin_datasource_graphql_hasura/forest_admin_datasource_graphql_hasura.gemspec index a82330304..fe3ce0229 100644 --- a/packages/forest_admin_datasource_graphql_hasura/forest_admin_datasource_graphql_hasura.gemspec +++ b/packages/forest_admin_datasource_graphql_hasura/forest_admin_datasource_graphql_hasura.gemspec @@ -23,7 +23,7 @@ Gem::Specification.new do |spec| spec.files = Dir.chdir(__dir__) do `git ls-files -z`.split("\x0").reject do |f| (File.expand_path(f) == __FILE__) || - f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile]) + f.start_with?(*%w[bin/ test/ spec/ features/ validation/ .git .circleci appveyor Gemfile]) end end spec.bindir = 'exe' diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/spec_helper.rb b/packages/forest_admin_datasource_graphql_hasura/spec/spec_helper.rb index 7474742ec..b0a4f2287 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/spec_helper.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/spec_helper.rb @@ -10,7 +10,7 @@ SimpleCov.start do add_filter '/spec/' enable_coverage :branch - minimum_coverage 80 + minimum_coverage 90 end SimpleCov.coverage_dir 'coverage' From 1189745d3b3b2eaac0cf2d09c0ad01dba6edba4a Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 15:46:34 +0200 Subject: [PATCH 09/26] test(datasource graphql hasura): cover the NULL foreign key bucket end to end Seeds an orphan comment (membership_id NULL) and asserts that grouped charts give it the bucket SQL grouping would, on the foreign key, through a leaderboard relation path, and under a chart filter (which also exercises the parent-side relationship predicate against a real Hasura). Also realigns the transport-failure scenario with the previous commit: it still rescued GraphqlError where the client now raises TransportError, and it asserts the 503 status. Run against the Docker stack: 33 scenarios, 0 failure. Co-Authored-By: Claude Fable 5 --- .../validation/init.sql | 4 +++- .../validation/validate.rb | 20 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/validation/init.sql b/packages/forest_admin_datasource_graphql_hasura/validation/init.sql index 6669fadfa..6dbe8c53f 100644 --- a/packages/forest_admin_datasource_graphql_hasura/validation/init.sql +++ b/packages/forest_admin_datasource_graphql_hasura/validation/init.sql @@ -88,7 +88,9 @@ INSERT INTO comments (body, metadata, commentable_type, commentable_id, membersh (NULL, NULL, 'Transfer', 2, 1), -- Literal wildcards: a "contains 100%" search must not match these both. ('discount 100% applied', NULL, 'Transfer', 2, 1), - ('discount 1000 applied', NULL, 'Transfer', 2, 1); + ('discount 1000 applied', NULL, 'Transfer', 2, 1), + -- NULL foreign key: grouped charts must give it the bucket SQL grouping would. + ('orphan comment', NULL, 'Card', 1, NULL); INSERT INTO attachments (file_name, attachable_type, attachable_id, author_type, author_id) VALUES ('invoice.pdf', 'Transfer', 1, 'Membership', 1), diff --git a/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb b/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb index f680e118a..5073590da 100644 --- a/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb +++ b/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb @@ -195,7 +195,17 @@ def branch(aggregator, conditions) = Nodes::ConditionTreeBranch.new(aggregator, aggregation = Query::Aggregation.new(operation: 'Count', groups: [{ field: 'membership_id' }]) result = comments.aggregate(nil, filter, aggregation) grouped = result.to_h { |row| [row['group']['membership_id'], row['value']] } - assert_equal({ 1 => 8, 2 => 2 }, grouped, 'counts per membership') + # The orphan comment (membership_id NULL) gets the bucket SQL grouping would give it. + assert_equal({ 1 => 8, 2 => 2, nil => 1 }, grouped, 'counts per membership, orphans in their own bucket') +end + +scenario 'the NULL bucket respects the chart filter' do + aggregation = Query::Aggregation.new(operation: 'Count', groups: [{ field: 'membership_id' }]) + condition = leaf('commentable_type', Operators::EQUAL, 'Card') + result = comments.aggregate(nil, filter(condition_tree: condition), aggregation) + grouped = result.to_h { |row| [row['group']['membership_id'], row['value']] } + + assert_equal({ 1 => 1, nil => 1 }, grouped, 'card comments per membership, filtered orphan included') end puts "\n== Config modes ==" @@ -290,7 +300,8 @@ def branch(aggregator, conditions) = Nodes::ConditionTreeBranch.new(aggregator, result = comments.aggregate(nil, filter, aggregation) grouped = result.to_h { |row| [row['group']['membership:full_name'], row['value']] } - assert_equal({ 'Jane Doe' => 8, 'John Smith' => 2 }, grouped, 'counts per membership name') + # A LEFT JOIN groups the orphan comment under a NULL name. + assert_equal({ 'Jane Doe' => 8, 'John Smith' => 2, nil => 1 }, grouped, 'counts per membership name') end scenario 'Sum grouped by FK handles bigint values returned as JSON strings' do @@ -384,7 +395,7 @@ def branch(aggregator, conditions) = Nodes::ConditionTreeBranch.new(aggregator, assert e.message.include?('Refusing'), "unexpected message: #{e.message}" end -scenario 'a transport failure surfaces as a Forest validation error, not an opaque crash' do +scenario 'a transport failure surfaces as a 503 with an actionable message, not a client mistake' do unreachable = ForestAdminDatasourceGraphqlHasura::Client.new( ForestAdminDatasourceGraphqlHasura::Configuration.new(uri: 'http://127.0.0.1:1/v1/graphql', timeout: 2) ) @@ -392,8 +403,9 @@ def branch(aggregator, conditions) = Nodes::ConditionTreeBranch.new(aggregator, begin unreachable.execute('query { __typename }') raise 'expected a transport failure' - rescue ForestAdminDatasourceGraphqlHasura::GraphqlError => e + rescue ForestAdminDatasourceGraphqlHasura::TransportError => e assert e.is_a?(ForestAdminDatasourceToolkit::Exceptions::ForestException), 'must be a ForestException' + assert_equal 503, e.status, 'transport failures carry a 503, not a 400' assert e.message.include?('Could not reach'), "unexpected message: #{e.message}" end end From 9390fa1b41f737ae36015e299c3fcb8dac75fa93 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 15:59:36 +0200 Subject: [PATCH 10/26] fix(datasource graphql hasura): address the Macroscope findings - an aggregation spanning exactly the 10 000-parent cap completed the walk in theory but raised in practice: the strict comparison lets a final partial or empty page close the pagination, and the error now fires only when a full page lands past the cap - Max/Min merging and result ordering compared numbers through Float, so two bigints rounding to the same double tied and kept whichever row came first; whole numbers now stay Integers, which Ruby compares with Floats exactly - a projection selecting nothing (a valid toolkit input) generated `table { }`, which is not valid GraphQL: the selection falls back to the primary key Co-Authored-By: Claude Fable 5 --- .../collection.rb | 5 +- .../query/aggregator.rb | 12 +++-- .../collection_spec.rb | 47 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb index 9cdd8242a..30f05e205 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb @@ -87,7 +87,10 @@ def build_selection(projection) end end - selection.uniq + selection = selection.uniq + # An empty projection is a valid toolkit input, but `table { }` is not + # valid GraphQL: fall back to the primary key. + selection.empty? ? Array(@table.primary_key.first || column_names.first) : selection end private diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb index 18dc06c4c..3a595780a 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb @@ -117,9 +117,11 @@ def fetch_parent_rows(relation, filter, aggregation) page = @collection.execute(:aggregate, operation)[relation[:parent_table]] || [] rows.concat(page) + # A partial page means the walk is complete, however close to the cap: + # the strict comparison lets exactly MAX_PARENT_ROWS rows through. return rows if page.size < PARENT_PAGE - if rows.size >= MAX_PARENT_ROWS + if rows.size > MAX_PARENT_ROWS raise ForestException, "Grouped aggregation on '#{name}' spans more than #{MAX_PARENT_ROWS} " \ "'#{relation[:parent_table]}' rows; narrow the chart filter." @@ -249,16 +251,20 @@ def reverse_relation_name(relation, foreign_key) # Aggregate values are not necessarily numbers: Hasura sends bigint and # numeric as strings, and Max/Min aggregate dates as well as text. The tuple # orders numbers and instants together, then text lexically, and stays - # comparable across rows so `sort_by` and Max/Min agree. + # comparable across rows so `sort_by` and Max/Min agree. Whole numbers are + # kept as Integers — Ruby compares them with Floats exactly — because a + # bigint rounded through a Float would tie with its neighbours. def comparable(value) case value - when Numeric then [0, value.to_f, ''] + when Numeric then [0, value, ''] when String then comparable_string(value) else [2, 0.0, ''] end end def comparable_string(value) + return [0, value.to_i, ''] if value.match?(/\A-?\d+\z/) + number = Float(value, exception: false) return [0, number, ''] if number diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb index 57067faac..54a87fbba 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb @@ -101,6 +101,15 @@ def last_graphql_request ) end + # `comments { }` is not valid GraphQL. + it 'falls back to the primary key when the projection selects nothing' do + BankingSchema.stub_graphql_data({ 'comments' => [] }) + + comments.list(caller, filter, projection) + + expect(last_graphql_request['query']).to match(/comments\s*\{\s*id\s*\}/) + end + it 'applies sort and pagination' do BankingSchema.stub_graphql_data({ 'comments' => [] }) @@ -231,6 +240,27 @@ def last_graphql_request expect(result.first['value']).to eq(9_007_199_254_740_995) end + # Two bigints that round to the same Float must not tie: the comparison has + # to stay exact, or Max keeps whichever row came first. + it 'merges a Max over bigints without float rounding ties' do + BankingSchema.stub_graphql_data( + { + 'memberships' => [ + { 'full_name' => 'Jane', + 'comments_aggregate' => { 'aggregate' => { 'max' => { 'id' => '9007199254740992' } } } }, + { 'full_name' => 'Jane', + 'comments_aggregate' => { 'aggregate' => { 'max' => { 'id' => '9007199254740993' } } } } + ] + } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Max', field: 'id', + groups: [{ field: 'membership:full_name' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result.first['value']).to eq('9007199254740993') + end + it 'merges a Max over text lexically instead of keeping the first row' do BankingSchema.stub_graphql_data( { @@ -319,6 +349,23 @@ def last_graphql_request expect(offsets).to eq([0, 1000]) end + it 'completes an aggregation spanning exactly the parent cap' do + full_page = { + 'memberships' => (1..1000).map do |id| + { 'id' => id, 'comments_aggregate' => { 'aggregate' => { 'count' => 1 } } } + end + } + empty_page = { 'memberships' => [] } + BankingSchema.stub_graphql_data(*Array.new(10, full_page), empty_page) + + aggregation = toolkit_query::Aggregation.new(operation: 'Count', groups: [{ field: 'membership_id' }]) + result = comments.aggregate(caller, filter, aggregation) + + # The ten identical pages merge into one group per id, each summing to 10. + expect(result.size).to eq(1000) + expect(result.first['value']).to eq(10) + end + it 'fails clearly instead of charting a subset when the parent rows exceed the cap' do full_page = { 'memberships' => (1..1000).map do |id| From 486c7c56aa9f5f3f904d99e1dad39c19043eab06 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 16:06:24 +0200 Subject: [PATCH 11/26] fix(datasource graphql hasura): count dangling foreign keys in the null bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orphan query only matched a NULL foreign key, so a child row whose key references no parent — possible on a constraint-less relationship — escaped both the parent walk and the null bucket and vanished from grouped charts. The query now negates the relationship itself (`_not: { relation: {} }`), which is how Hasura selects rows without a matching parent: NULL and dangling keys land in the LEFT JOIN's NULL group. SQL would keep a dangling key as a group of its own when grouping by the foreign key, but Hasura cannot enumerate those keys; counted under nil beats dropped. This also removes the non-nullable shortcut: a NOT NULL column can still dangle without a constraint, so the orphan query always runs. Validated against the Docker stack: 33 scenarios, 0 failure. Co-Authored-By: Claude Fable 5 --- .../README.md | 3 +- .../query/aggregator.rb | 30 +++++++++++-------- .../collection_spec.rb | 9 +++--- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/README.md b/packages/forest_admin_datasource_graphql_hasura/README.md index 12a47ff10..e180c8f0d 100644 --- a/packages/forest_admin_datasource_graphql_hasura/README.md +++ b/packages/forest_admin_datasource_graphql_hasura/README.md @@ -79,7 +79,8 @@ type_values: { 'bank_accounts' => 'Banking::Account' } Hasura: Hasura exposes GROUP BY only through nested `_aggregate` fields. A foreign key without a declared reverse relationship is advertised as non-groupable, like every other column, and date truncation is not supported. Rows whose foreign key is NULL - form a bucket of their own, as SQL grouping would. Parent rows are filtered by the + form a bucket of their own, as SQL grouping would — dangling foreign keys (possible on a + constraint-less relationship) join that bucket rather than being dropped. Parent rows are filtered by the chart's predicate and paginated by 1000; a chart spanning more than 10 000 parent rows fails with a clear error rather than returning partial numbers. - **Tables without a primary key** (typically untracked views) are skipped: Forest cannot diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb index 3a595780a..9bdf41194 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb @@ -141,16 +141,17 @@ def collect_groups(rows, relation, aggregation) end end - # SQL grouping puts rows whose foreign key is NULL in a bucket of their own; - # the parent-table detour cannot see them, so they are aggregated apart. The - # nil key can pre-exist (a parent whose grouped column is null): both are the - # NULL group of a LEFT JOIN, so they merge. + # Rows without a matching parent — a NULL foreign key, or a dangling one on + # a constraint-less relationship — are invisible to the parent-table detour: + # `_not: { relation: {} }` is how Hasura selects them, and a LEFT JOIN would + # put them in its NULL group. When grouping by the foreign key itself, SQL + # would keep a dangling key as a group of its own; Hasura cannot enumerate + # those keys, so they land in the nil bucket too, rather than being dropped. + # The nil key can pre-exist (a parent whose grouped column is null): both + # are the NULL group of a LEFT JOIN, so they merge. def add_null_group(values, relation, filter, aggregation) - foreign_key = fields[relation[:foreign_key]] - return if foreign_key.nil? || foreign_key.validation.any? # non-nullable: no orphan rows - operation = QueryBuilder.aggregate(table_name, filter, aggregation, - extra_where: { relation[:foreign_key] => { '_is_null' => true } }) + extra_where: { '_not' => { relation[:child_relation_name] => {} } }) data = @collection.execute(:aggregate, operation).dig("#{table_name}_aggregate", 'aggregate') value = extract_value(data, aggregation) return if childless_parent?(value, aggregation) @@ -200,7 +201,7 @@ def childless_parent?(value, aggregation) # (`membership:full_name`, what leaderboard charts request). def find_group_relation(group_field) field_name, parent_column = group_field.split(':') - relation, foreign_key = resolve_group_relation(field_name) + relation_name, relation, foreign_key = resolve_group_relation(field_name) reverse = relation && reverse_relation_name(relation, foreign_key) unless reverse @@ -215,7 +216,7 @@ def find_group_relation(group_field) parent_table: parent.table_name, parent_field: parent_column || relation.foreign_key_target, relation_name: reverse, - foreign_key: foreign_key, + child_relation_name: relation_name, parent_order_fields: primary_keys_of(parent) } end @@ -227,11 +228,16 @@ def primary_keys_of(collection) .keys end + # Returns [relation_name, relation_schema, foreign_key]. The relation name + # is also the Hasura object relationship on the child table, which the + # orphan query of add_null_group negates. def resolve_group_relation(field_name) field = fields[field_name] - return [field, field.foreign_key] if field&.type == 'ManyToOne' + return [field_name, field, field.foreign_key] if field&.type == 'ManyToOne' + + name, relation = fields.find { |_, f| f.type == 'ManyToOne' && f.foreign_key == field_name } - [fields.values.find { |f| f.type == 'ManyToOne' && f.foreign_key == field_name }, field_name] + [name, relation, field_name] end def reverse_relation_name(relation, foreign_key) diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb index 54a87fbba..d4099e75c 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb @@ -380,9 +380,10 @@ def last_graphql_request .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /more than 10000/) end - # SQL grouping would put comments whose membership_id is NULL in their own - # bucket; the parent-table detour cannot see them. - it 'adds a bucket for the rows whose foreign key is null' do + # SQL grouping would put comments without a matching membership — NULL or + # dangling foreign key — in the LEFT JOIN's NULL bucket; the parent-table + # detour cannot see them, so they are caught by negating the relationship. + it 'adds a bucket for the rows without a matching parent' do BankingSchema.stub_graphql_data( { 'memberships' => [{ 'id' => 1, 'comments_aggregate' => { 'aggregate' => { 'count' => 3 } } }] }, { 'comments_aggregate' => { 'aggregate' => { 'count' => 2 } } } @@ -395,7 +396,7 @@ def last_graphql_request { 'value' => 3, 'group' => { 'membership_id' => 1 } }, { 'value' => 2, 'group' => { 'membership_id' => nil } } ]) - expect(last_graphql_request['variables']['where']).to eq({ 'membership_id' => { '_is_null' => true } }) + expect(last_graphql_request['variables']['where']).to eq({ '_not' => { 'membership' => {} } }) end it 'rejects grouping on the polymorphic foreign key with a clear error' do From 27e7998cd3cd58ef55be8ab2abe62ec7cc84916c Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 16:26:15 +0200 Subject: [PATCH 12/26] fix(datasource graphql hasura): make aggregation results exact, typed and honestly capped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by an adversarial pass over the aggregation pipeline: - a zero count(columns: x) could not tell "no rows" (SQL omits the group) from "rows whose x is all NULL" (SQL keeps it at zero): a row_count alias now rides along every aggregate selection, which also stops parents without any child from surfacing as spurious zero groups, and keeps all-NULL Sum/Max/Min groups instead of dropping them - aggregate values of numeric columns are normalized to numbers at extraction: one chart no longer mixes 1500 and "1500" depending on whether a group was merged in Ruby, and text columns compare lexically again ("9" beats "10", as SQL collates) since only genuine text reaches the tuple - the 10 000-parent cap is enforced even when the overflowing page is partial, matching what the README promises; exactly 10 000 still completes - Sum/Avg/Max/Min without a field are rejected by name instead of emitting an empty GraphQL selection set - QueryBuilder.update refuses a filter that converts to no condition instead of defaulting to {}, which Hasura reads as match-all — a backstop behind the collection guard; delete keeps {} deliberately (bulk "select all") - the orphan-bucket query is skipped when no orphan can exist (NOT NULL foreign key backed by a real constraint) Co-Authored-By: Claude Fable 5 --- .../collection.rb | 7 ++ .../query/aggregator.rb | 91 ++++++++++++++----- .../query/query_builder.rb | 34 ++++--- .../collection_spec.rb | 90 +++++++++++++++++- .../query/aggregator_spec.rb | 89 ++++++++++++++++++ 5 files changed, 274 insertions(+), 37 deletions(-) create mode 100644 packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/query/aggregator_spec.rb diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb index 30f05e205..de20d8f7f 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb @@ -56,6 +56,13 @@ def aggregate(_caller, filter, aggregation, limit = nil) Query::Aggregator.new(self).run(filter, aggregation, limit) end + # Whether a relationship rests on a real foreign key constraint — only the + # Hasura metadata knows, and only introspection saw it. False when manual + # or when the metadata was unreachable (constraint unproven). + def constraint_backed?(relation_name) + @table.relationships.any? { |rel| rel.name == relation_name && rel.manual == false } + end + # Wraps every Hasura call so the failing operation is named in the error, # keeping the class (GraphqlError or TransportError) and thus the status. def execute(operation_name, operation) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb index 9bdf41194..853a9bc21 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb @@ -42,6 +42,13 @@ def fields = @collection.schema[:fields] # request's `aggregateFieldName` straight through). def validate(aggregation) validate_field(aggregation.field) if aggregation.field + + # Without a field, `sum { }` would be an empty GraphQL selection set. + if aggregation.field.nil? && aggregation.operation != 'Count' + raise ForestException, + "#{aggregation.operation} requires a field on collection '#{name}'." + end + groups = aggregation.groups || [] if groups.size > 1 @@ -117,25 +124,27 @@ def fetch_parent_rows(relation, filter, aggregation) page = @collection.execute(:aggregate, operation)[relation[:parent_table]] || [] rows.concat(page) - # A partial page means the walk is complete, however close to the cap: - # the strict comparison lets exactly MAX_PARENT_ROWS rows through. - return rows if page.size < PARENT_PAGE - + # The strict comparison lets exactly MAX_PARENT_ROWS through (a final + # empty page then closes the walk); anything beyond fails, even on a + # partial page, so the cap the README documents is the cap enforced. if rows.size > MAX_PARENT_ROWS raise ForestException, "Grouped aggregation on '#{name}' spans more than #{MAX_PARENT_ROWS} " \ "'#{relation[:parent_table]}' rows; narrow the chart filter." end + return rows if page.size < PARENT_PAGE + offset += PARENT_PAGE end end def collect_groups(rows, relation, aggregation) rows.each_with_object({}) do |row, memo| - value = extract_value(row.dig("#{relation[:relation_name]}_aggregate", 'aggregate'), aggregation) - next if childless_parent?(value, aggregation) + data = row.dig("#{relation[:relation_name]}_aggregate", 'aggregate') + next if childless?(data, aggregation) + value = extract_value(data, aggregation) key = row[relation[:parent_field]] memo[key] = memo.key?(key) ? merge_values(memo[key], value, aggregation) : value end @@ -150,19 +159,25 @@ def collect_groups(rows, relation, aggregation) # The nil key can pre-exist (a parent whose grouped column is null): both # are the NULL group of a LEFT JOIN, so they merge. def add_null_group(values, relation, filter, aggregation) + return unless relation[:orphans_possible] + operation = QueryBuilder.aggregate(table_name, filter, aggregation, extra_where: { '_not' => { relation[:child_relation_name] => {} } }) data = @collection.execute(:aggregate, operation).dig("#{table_name}_aggregate", 'aggregate') - value = extract_value(data, aggregation) - return if childless_parent?(value, aggregation) + return if childless?(data, aggregation) + value = extract_value(data, aggregation) values[nil] = values.key?(nil) ? merge_values(values[nil], value, aggregation) : value end # Two parent rows can share a group value — grouping by a name rather than by # the primary key, as leaderboard charts do — and SQL would return them as a - # single group. + # single group. A NULL side is ignored, as SQL aggregates ignore NULLs, and + # two NULL sides stay NULL. def merge_values(current, value, aggregation) + return current if value.nil? + return value if current.nil? + case aggregation.operation when 'Count', 'Sum' then add(current, value) when 'Max' then (comparable(value) <=> comparable(current)).positive? ? value : current @@ -191,9 +206,16 @@ def numeric(value) end end - # A parent with no child row is what SQL grouping would leave out. A zero - # `count(columns: field)` is different: rows exist, they just all hold null. - def childless_parent?(value, aggregation) + # A group with no rows at all is what SQL grouping leaves out. The + # `row_count` alias tells it from a group whose rows exist but hold NULL in + # the aggregated column — SQL keeps that one: a zero `count(columns: x)`, + # a NULL Sum/Max/Min. The value-based fallback covers a response missing + # the alias. + def childless?(data, aggregation) + return true if data.nil? + return data['row_count'].to_i.zero? if data.key?('row_count') + + value = extract_value(data, aggregation) value.nil? || (aggregation.operation == 'Count' && aggregation.field.nil? && value.to_i.zero?) end @@ -217,10 +239,22 @@ def find_group_relation(group_field) parent_field: parent_column || relation.foreign_key_target, relation_name: reverse, child_relation_name: relation_name, - parent_order_fields: primary_keys_of(parent) + parent_order_fields: primary_keys_of(parent), + orphans_possible: orphans_possible?(relation_name, relation) } end + # A NOT NULL foreign key backed by a real constraint cannot reference a + # missing parent, so the orphan query would be a wasted round trip. A + # manual relationship (or one whose backing is unknown) can dangle even + # on a NOT NULL column. + def orphans_possible?(relation_name, relation) + foreign_key = fields[relation.foreign_key] + nullable = foreign_key.nil? || foreign_key.validation.empty? + + nullable || !@collection.constraint_backed?(relation_name) + end + # Offset pagination needs a stable order, which only the primary key gives. def primary_keys_of(collection) collection.schema[:fields] @@ -264,17 +298,17 @@ def comparable(value) case value when Numeric then [0, value, ''] when String then comparable_string(value) - else [2, 0.0, ''] + # NULL groups (rows whose aggregated column is all NULL) sort last. + else [-1, 0.0, ''] end end + # Strings reaching here belong to non-numeric columns — numeric ones were + # normalized at extraction. Dates order as instants, text lexically, like + # SQL would (a digit-only text value must not compare numerically). def comparable_string(value) - return [0, value.to_i, ''] if value.match?(/\A-?\d+\z/) - - number = Float(value, exception: false) - return [0, number, ''] if number - instant = time_value(value) + instant ? [0, instant, ''] : [1, 0.0, value] end @@ -284,14 +318,23 @@ def time_value(value) nil end + # Hasura serializes bigint and numeric aggregates as JSON strings; a chart + # value must be a number, and one merged in Ruby must not differ in type + # from one straight off the wire, so numeric columns are normalized here. def extract_value(data, aggregation) return nil if data.nil? - if aggregation.operation == 'Count' - data['count'] - else - data.dig(aggregation.operation.downcase, aggregation.field) - end + value = if aggregation.operation == 'Count' + data['count'] + else + data.dig(aggregation.operation.downcase, aggregation.field) + end + + value.is_a?(String) && number_field?(aggregation) ? numeric(value) : value + end + + def number_field?(aggregation) + aggregation.field && fields[aggregation.field]&.column_type == 'Number' end end end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb index 00728e727..d3c46ba4f 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb @@ -48,6 +48,15 @@ def create(table, records, selection) end def update(table, filter, patch) + where = FilterConverter.convert(filter.condition_tree) + + # Backstop behind the collection guard: `{}` is vacuously true for + # Hasura, so a filterless update would rewrite the whole table. + if where.nil? + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + "Refusing to update every row of '#{table}': the filter carries no condition." + end + query = <<~GRAPHQL mutation Update#{camelize(table)}($where: #{table}_bool_exp!, $set: #{table}_set_input!) { update_#{table}(where: $where, _set: $set) { @@ -56,13 +65,7 @@ def update(table, filter, patch) } GRAPHQL - { - query: query, - variables: { - 'where' => FilterConverter.convert(filter.condition_tree) || {}, - 'set' => stringify_keys(patch) - } - } + { query: query, variables: { 'where' => where, 'set' => stringify_keys(patch) } } end def delete(table, filter) @@ -74,6 +77,8 @@ def delete(table, filter) } GRAPHQL + # `{}` (match all) is deliberate here: a bulk delete with "select all" + # legitimately carries no condition, and wiping is the requested semantic. { query: query, variables: { 'where' => FilterConverter.convert(filter.condition_tree) || {} } } end @@ -144,11 +149,16 @@ def grouped_aggregate(child_table, relation, filter, aggregation, page) def aggregation_selection(aggregation) operation = aggregation.operation - if operation == 'Count' - aggregation.field ? "count(columns: #{aggregation.field})" : 'count' - else - "#{operation.downcase} { #{aggregation.field} }" - end + selection = if operation == 'Count' + aggregation.field ? "count(columns: #{aggregation.field})" : 'count' + else + "#{operation.downcase} { #{aggregation.field} }" + end + + # row_count tells a group with no rows at all (SQL grouping omits it) + # from one whose rows exist but hold NULL in the aggregated column + # (SQL keeps it, at zero for a count and at NULL otherwise). + "#{selection}\nrow_count: count" end private diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb index d4099e75c..4e852f153 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb @@ -258,7 +258,8 @@ def last_graphql_request groups: [{ field: 'membership:full_name' }]) result = comments.aggregate(caller, filter, aggregation) - expect(result.first['value']).to eq('9007199254740993') + # Normalized to a number: a chart value should not be a string. + expect(result.first['value']).to eq(9_007_199_254_740_993) end it 'merges a Max over text lexically instead of keeping the first row' do @@ -292,6 +293,68 @@ def last_graphql_request expect(result).to eq([{ 'value' => 0, 'group' => { 'membership_id' => 1 } }]) end + # SQL keeps a group whose rows exist with the column all NULL (count 0, Sum + # NULL), and omits a group with no rows: `row_count` tells them apart. + it 'drops a parent without rows but keeps one whose aggregated column is all null' do + BankingSchema.stub_graphql_data( + { + 'memberships' => [ + { 'id' => 1, 'comments_aggregate' => { 'aggregate' => { 'count' => 0, 'row_count' => 0 } } }, + { 'id' => 2, 'comments_aggregate' => { 'aggregate' => { 'count' => 0, 'row_count' => 3 } } } + ] + } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Count', field: 'body', + groups: [{ field: 'membership_id' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result).to eq([{ 'value' => 0, 'group' => { 'membership_id' => 2 } }]) + end + + it 'keeps a null Sum group when its rows exist, sorted last' do + BankingSchema.stub_graphql_data( + { + 'memberships' => [ + { 'id' => 1, + 'comments_aggregate' => { 'aggregate' => { 'sum' => { 'id' => nil }, 'row_count' => 2 } } }, + { 'id' => 2, + 'comments_aggregate' => { 'aggregate' => { 'sum' => { 'id' => '7' }, 'row_count' => 1 } } } + ] + } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Sum', field: 'id', + groups: [{ field: 'membership_id' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result).to eq([ + { 'value' => 7, 'group' => { 'membership_id' => 2 } }, + { 'value' => nil, 'group' => { 'membership_id' => 1 } } + ]) + end + + # SQL Max ignores NULLs: a parent row whose values are all NULL must not win + # the merge against a real value. + it 'ignores null values when merging parents that share a group value' do + BankingSchema.stub_graphql_data( + { + 'memberships' => [ + { 'full_name' => 'Jane', + 'comments_aggregate' => { 'aggregate' => { 'max' => { 'body' => nil }, 'row_count' => 2 } } }, + { 'full_name' => 'Jane', + 'comments_aggregate' => { 'aggregate' => { 'max' => { 'body' => 'pear' }, 'row_count' => 1 } } } + ] + } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Max', field: 'body', + groups: [{ field: 'membership:full_name' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result.first['value']).to eq('pear') + end + it 'runs simple counts against
_aggregate' do BankingSchema.stub_graphql_data({ 'comments_aggregate' => { 'aggregate' => { 'count' => 12 } } }) @@ -399,6 +462,31 @@ def last_graphql_request expect(last_graphql_request['variables']['where']).to eq({ '_not' => { 'membership' => {} } }) end + # `sum { }` would be an empty GraphQL selection set. + it 'rejects a Sum without a field with a clear error' do + aggregation = toolkit_query::Aggregation.new(operation: 'Sum') + + expect { comments.aggregate(caller, filter, aggregation) } + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /requires a field/) + end + + it 'enforces the cap even when the overflowing page is partial' do + full_page = { + 'memberships' => (1..1000).map do |id| + { 'id' => id, 'comments_aggregate' => { 'aggregate' => { 'count' => 1 } } } + end + } + partial_page = { + 'memberships' => [{ 'id' => 10_500, 'comments_aggregate' => { 'aggregate' => { 'count' => 1 } } }] + } + BankingSchema.stub_graphql_data(*Array.new(10, full_page), partial_page) + + aggregation = toolkit_query::Aggregation.new(operation: 'Count', groups: [{ field: 'membership_id' }]) + + expect { comments.aggregate(caller, filter, aggregation) } + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /more than 10000/) + end + it 'rejects grouping on the polymorphic foreign key with a clear error' do aggregation = toolkit_query::Aggregation.new(operation: 'Count', groups: [{ field: 'commentable_id' }]) diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/query/aggregator_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/query/aggregator_spec.rb new file mode 100644 index 000000000..7ad5cdeeb --- /dev/null +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/query/aggregator_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +RSpec.describe ForestAdminDatasourceGraphqlHasura::Query::Aggregator do + def scalar(name) = { 'name' => name, 'kind' => 'SCALAR' } + def non_null(of_type) = { 'name' => nil, 'kind' => 'NON_NULL', 'ofType' => of_type } + def list_of(of_type) = { 'name' => nil, 'kind' => 'LIST', 'ofType' => of_type } + def object(name) = { 'name' => name, 'kind' => 'OBJECT' } + def field(name, type) = { 'name' => name, 'type' => type } + def list_query(table) = field(table, non_null(list_of(non_null(object(table))))) + + def by_pk_query(table) + { 'name' => "#{table}_by_pk", 'type' => object(table), + 'args' => [{ 'name' => 'id', 'type' => non_null(scalar('bigint')) }] } + end + + # payments.account_id is NOT NULL and its relationship rests on a real + # foreign key constraint: no orphan row can exist. + def stub_constrained_schema + WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI) + .with { |request| request.body.include?('IntrospectSchema') } + .to_return( + status: 200, + body: JSON.generate( + { 'data' => { '__schema' => { + 'types' => [ + { 'name' => 'payments', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('account_id', non_null(scalar('bigint'))), + field('account', object('accounts')) + ] }, + { 'name' => 'accounts', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('payments', non_null(list_of(non_null(object('payments'))))) + ] } + ], + 'queryType' => { + 'name' => 'query_root', + 'fields' => [list_query('payments'), by_pk_query('payments'), + list_query('accounts'), by_pk_query('accounts')] + } + } } } + ), + headers: { 'Content-Type' => 'application/json' } + ) + + WebMock.stub_request(:post, BankingSchema::METADATA_URI) + .to_return( + status: 200, + body: JSON.generate( + { 'metadata' => { 'version' => 3, 'sources' => [ + { 'name' => 'default', 'kind' => 'postgres', 'tables' => [ + { 'table' => { 'schema' => 'public', 'name' => 'payments' }, + 'object_relationships' => [ + { 'name' => 'account', 'using' => { 'foreign_key_constraint_on' => 'account_id' } } + ] }, + { 'table' => { 'schema' => 'public', 'name' => 'accounts' }, + 'array_relationships' => [ + { 'name' => 'payments', + 'using' => { 'foreign_key_constraint_on' => { + 'column' => 'account_id', 'table' => { 'schema' => 'public', 'name' => 'payments' } + } } } + ] } + ] } + ] } } + ), + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'skips the orphan query when the foreign key is NOT NULL and constraint-backed' do + stub_constrained_schema + datasource = ForestAdminDatasourceGraphqlHasura::Datasource.new(uri: BankingSchema::GRAPHQL_URI) + BankingSchema.stub_graphql_data( + { 'accounts' => [{ 'id' => 1, 'payments_aggregate' => { 'aggregate' => { 'count' => 2, 'row_count' => 2 } } }] } + ) + + aggregation = ForestAdminDatasourceToolkit::Components::Query::Aggregation.new( + operation: 'Count', groups: [{ field: 'account_id' }] + ) + result = datasource.get_collection('Payment') + .aggregate(nil, ForestAdminDatasourceToolkit::Components::Query::Filter.new, aggregation) + + expect(result).to eq([{ 'value' => 2, 'group' => { 'account_id' => 1 } }]) + expect(WebMock).to have_requested(:post, BankingSchema::GRAPHQL_URI) + .with { |request| !request.body.include?('IntrospectSchema') }.once + end +end From 10d39b8573a97dd1d77eaff7707b7f19b171a3e2 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 16:26:15 +0200 Subject: [PATCH 13/26] fix(datasource graphql hasura): survive hostile introspection and configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by an adversarial pass over introspection and configuration: - with the metadata unreachable, a configured polymorphic target reachable through two object relationships absorbed one of them arbitrarily — it could be a plain belongs_to, silently deleted; the target is now skipped with a warning naming both relationships - a 200 introspection response with a null or partial __schema crashed boot with NoMethodError; it now raises IntrospectionError suggesting that introspection may be disabled, and a malformed metadata entry (legacy string table form, relationship without using) degrades to the naming conventions like an unreachable endpoint - non-public schema mappings no longer claim the bare table name: the bare GraphQL field can only be the public table, and the alias could invalidate a legitimate public mapping as ambiguous - two tables classifying to the same collection name (user_status and user_statuses) crashed boot deep in the toolkit; the first is kept and the warning names the tables and the type_values remedy - a table listed in both included_tables and excluded_tables was exposed; the exclusion now always wins, as the Configuration API says - a 200 GraphQL body that is not an object, or carries neither data nor errors, raises TransportError instead of leaking nil into the collection - Configuration instances no longer share the DEFAULTS objects (mutating one datasource's headers leaked into every other), and a misshapen polymorphic_relations raises ConfigurationError instead of crashing introspection Co-Authored-By: Claude Fable 5 --- .../client.rb | 8 +- .../configuration.rb | 22 +++- .../datasource.rb | 21 ++++ .../introspection/introspector.rb | 73 ++++++++---- .../introspection/polymorphism_detector.rb | 29 ++++- .../client_spec.rb | 30 +++++ .../introspector_detection_spec.rb | 107 +++++++++++++++++- 7 files changed, 259 insertions(+), 31 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb index be7d3e912..7abb72cda 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb @@ -26,13 +26,19 @@ def execute(query, variables = {}) raise TransportError, "GraphQL endpoint returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) payload = JSON.parse(response.body) + raise TransportError, 'GraphQL endpoint returned an unexpected body' unless payload.is_a?(Hash) if payload['errors']&.any? messages = payload['errors'].map { |e| e['message'] }.join('; ') raise GraphqlError, messages end - payload['data'] + # A 200 with neither data nor errors is malformed, and letting a nil out + # would crash the caller with an opaque NoMethodError. + data = payload['data'] + raise TransportError, 'GraphQL endpoint returned no data' if data.nil? + + data rescue *TRANSPORT_ERRORS => e raise TransportError, "Could not reach the GraphQL endpoint (#{e.class}): #{e.message}" end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb index 816dc5933..f4349c8a9 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb @@ -24,7 +24,12 @@ def initialize(uri:, **options) raise ConfigurationError, "Unknown option(s): #{unknown.join(", ")}" if unknown.any? @uri = uri - DEFAULTS.each { |option, default| instance_variable_set("@#{option}", options.fetch(option, default)) } + # `default.dup` keeps the DEFAULTS hashes and arrays from being shared — + # and mutated — across Configuration instances. + DEFAULTS.each do |option, default| + instance_variable_set("@#{option}", options.key?(option) ? options[option] : default.dup) + end + validate_polymorphic_relations # Only derivable from the conventional endpoint path: substituting on any # other uri would silently post metadata commands to the GraphQL endpoint. @metadata_uri ||= uri.include?('/v1/graphql') ? uri.sub('/v1/graphql', '/v1/metadata') : nil @@ -36,5 +41,20 @@ def table_allowed?(table_name) true end + + private + + # A misshapen declaration would otherwise crash deep inside introspection as + # an opaque NoMethodError instead of naming the option. + def validate_polymorphic_relations + valid = polymorphic_relations.is_a?(Hash) && polymorphic_relations.all? do |_, bases| + bases.is_a?(Hash) && bases.all? { |_, targets| targets.is_a?(Array) } + end + + return if valid + + raise ConfigurationError, + "polymorphic_relations must be { 'table' => { 'association' => ['target', ...] } }" + end end end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb index 57eb458b6..ca8233fbd 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb @@ -15,6 +15,7 @@ def initialize(uri:, **options) def register_collections tables = Introspection::Introspector.new(@client, @configuration).introspect + tables = deduplicate_collection_names(tables) converter = Introspection::SchemaConverter.new(tables, @configuration) tables.each do |table| @@ -29,6 +30,26 @@ def register_collections ) end + # `user_status` and `user_statuses` both classify to `UserStatus`, and the + # toolkit refuses a duplicate collection name with an error that names + # neither table: keep the first (alphabetically, for determinism) and say + # which tables collided and how to fix it. + def deduplicate_collection_names(tables) + converter = Introspection::SchemaConverter.new(tables, @configuration) + + tables.group_by { |table| converter.collection_name_of(table.name) }.flat_map do |name, group| + next group.first if group.size == 1 + + kept, *dropped = group.sort_by(&:name) + ForestAdminDatasourceGraphqlHasura.logger.warn( + "[forest_admin_datasource_graphql_hasura] Tables #{group.map(&:name).sort.join(", ")} all map " \ + "to the collection name '#{name}'; only '#{kept.name}' is exposed " \ + "(#{dropped.map(&:name).join(", ")} skipped). Disambiguate with the 'type_values' option." + ) + kept + end + end + # The capabilities route publishes is_groupable, and grouping goes through the # parent's nested `_aggregate`, which only exists when Hasura # declares the reverse array relationship: marking a foreign key without one diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb index d8c47d3b4..0b4b1bd94 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb @@ -69,16 +69,14 @@ def initialize(client, configuration) # @return [Array
] def introspect - schema = @client.execute(INTROSPECTION_QUERY) - raise IntrospectionError, 'Introspection query returned no schema' unless schema&.key?('__schema') - + types, query_fields = introspection_payload metadata = @client.fetch_metadata - @type_map = build_type_map(schema['__schema']['types']) - @relationship_mappings = metadata ? parse_relationship_mappings(metadata) : {} - @primary_keys = parse_primary_keys(schema['__schema']['queryType']['fields']) + @type_map = build_type_map(types) + @relationship_mappings = metadata ? safe_relationship_mappings(metadata) : {} + @primary_keys = parse_primary_keys(query_fields) - tables = parse_tables(schema['__schema']['queryType']['fields']) + tables = parse_tables(query_fields) PolymorphismDetector.new(@configuration).detect(tables) tables @@ -86,6 +84,35 @@ def introspect private + # A gateway can answer 200 with `__schema: null` or partial objects when + # introspection is disabled: better a named error than a NoMethodError. + def introspection_payload + response = @client.execute(INTROSPECTION_QUERY) + schema = response.is_a?(Hash) ? response['__schema'] : nil + types = schema.is_a?(Hash) ? schema['types'] : nil + query_fields = schema.is_a?(Hash) ? schema.dig('queryType', 'fields') : nil + + unless types.is_a?(Array) && query_fields.is_a?(Array) + raise IntrospectionError, + 'The introspection response carries no usable schema: is GraphQL introspection ' \ + 'enabled on this endpoint?' + end + + [types, query_fields] + end + + # The metadata is optional by design; one malformed entry must degrade to + # the same fallback as an unreachable endpoint, not crash the boot. + def safe_relationship_mappings(metadata) + parse_relationship_mappings(metadata) + rescue StandardError => e + ForestAdminDatasourceGraphqlHasura.logger.warn( + '[forest_admin_datasource_graphql_hasura] Hasura metadata could not be parsed ' \ + "(#{e.class}: #{e.message}); falling back to configuration and naming conventions." + ) + {} + end + def build_type_map(types) types.each_with_object({}) { |type, memo| memo[type['name']] = type if type['name'] } end @@ -98,8 +125,8 @@ def parse_relationship_mappings(metadata) mappings = {} ambiguous = Set.new - metadata['sources'].each do |source| - source['tables'].each do |table| + (metadata['sources'] || []).each do |source| + (source['tables'] || []).each do |table| collect_table_mappings(table, mappings, ambiguous) end end @@ -117,9 +144,15 @@ def parse_relationship_mappings(metadata) end def collect_table_mappings(table, mappings, ambiguous) - schema_name = table.dig('table', 'schema') - table_name = table.dig('table', 'name') - prefixed = schema_name.nil? || schema_name == 'public' ? nil : "#{schema_name}_#{table_name}" + table_info = table['table'] + return unless table_info.is_a?(Hash) + + schema_name = table_info['schema'] + table_name = table_info['name'] + # Hasura derives the root field from the table name, prefixed by the + # schema outside of `public`: a bare name can only be the public table, + # so a non-public one must not claim it. + exposed = schema_name.nil? || schema_name == 'public' ? table_name : "#{schema_name}_#{table_name}" relationships = (table['object_relationships'] || []).map { |rel| [rel, :object] } + (table['array_relationships'] || []).map { |rel| [rel, :array] } @@ -128,11 +161,9 @@ def collect_table_mappings(table, mappings, ambiguous) entry = relationship_mapping(rel, kind) next if entry.nil? - [table_name, prefixed].compact.each do |name| - key = "#{name}.#{rel["name"]}" - ambiguous << key if mappings.key?(key) && mappings[key] != entry - mappings[key] = entry - end + key = "#{exposed}.#{rel["name"]}" + ambiguous << key if mappings.key?(key) && mappings[key] != entry + mappings[key] = entry end end @@ -141,6 +172,8 @@ def collect_table_mappings(table, mappings, ambiguous) # resolvable once the tables are parsed. def relationship_mapping(rel, kind) using = rel['using'] + return nil unless using.is_a?(Hash) + constraint = using['foreign_key_constraint_on'] manual = using['manual_configuration'] @@ -185,8 +218,10 @@ def parse_tables(query_fields) end def skip_table?(name) - # An explicitly allow-listed table wins over the built-in exclusions, so a - # legitimate table whose name starts like a system one stays reachable. + # An explicit exclusion always wins; then an explicit allow-list wins over + # the built-in exclusions, so a legitimate table whose name starts like a + # system one stays reachable. + return true if @configuration.excluded_tables.include?(name) return false if @configuration.included_tables&.include?(name) EXCLUDED_PREFIXES.any? { |prefix| name.start_with?(prefix) } || diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb index e6ffce465..04900cbff 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb @@ -69,18 +69,35 @@ def targets_of(table, base, tables_by_name) candidates = table.relationships.select { |rel| branch?(rel, foreign_key, configured_tables) } - candidates.each_with_object({}) do |rel, memo| - target_table = tables_by_name[rel.remote_table] + candidates.group_by(&:remote_table).each_with_object({}) do |(remote_table, relationships), memo| + target_table = tables_by_name[remote_table] next unless target_table + next if ambiguous_branch?(table, base, remote_table, relationships) - memo[class_name_of(rel.remote_table)] = { - table: rel.remote_table, - hasura_field: rel.name, - primary_key: rel.mapping&.values&.first || target_table.primary_key.first || 'id' + relationship = relationships.first + memo[class_name_of(remote_table)] = { + table: remote_table, + hasura_field: relationship.name, + primary_key: relationship.mapping&.values&.first || target_table.primary_key.first || 'id' } end end + # Without the Hasura metadata every mapping is unknown, so two object + # relationships towards the same configured target are indistinguishable: + # one may be a plain belongs_to, and absorbing it would silently delete a + # legitimate relation. Refuse to guess. + def ambiguous_branch?(table, base, remote_table, relationships) + return false if relationships.size == 1 + + ForestAdminDatasourceGraphqlHasura.logger.warn( + "[forest_admin_datasource_graphql_hasura] '#{table.name}.#{base}' cannot absorb a branch " \ + "towards '#{remote_table}': the relationships #{relationships.map(&:name).join(", ")} are " \ + 'equally plausible and one may be a plain belongs_to. That target is skipped.' + ) + true + end + def branch?(relationship, foreign_key, configured_tables) return false unless relationship.kind == :object # A known mapping is checked even when the target is configured: a table diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb index 52db3599f..5ea93ae78 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb @@ -64,6 +64,36 @@ module ForestAdminDatasourceGraphqlHasura expect { client.execute('query { ok }') }.to raise_error(TransportError, /Could not reach/) end + + it 'raises TransportError on a JSON body that is not an object' do + WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI).to_return(status: 200, body: '[]') + + expect { client.execute('query { ok }') }.to raise_error(TransportError, /unexpected body/) + end + + it 'raises TransportError on a 200 carrying neither data nor errors' do + WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI) + .to_return(status: 200, body: JSON.generate({ 'data' => nil })) + + expect { client.execute('query { ok }') }.to raise_error(TransportError, /no data/) + end + end + + describe 'configuration' do + it 'does not share default objects across instances' do + first = Configuration.new(uri: BankingSchema::GRAPHQL_URI) + second = Configuration.new(uri: BankingSchema::GRAPHQL_URI) + first.headers['Authorization'] = 'leak' + + expect(second.headers).to eq({}) + end + + it 'rejects a misshapen polymorphic_relations declaration by name' do + expect do + Configuration.new(uri: BankingSchema::GRAPHQL_URI, + polymorphic_relations: { 'comments' => ['commentable'] }) + end.to raise_error(ConfigurationError, /polymorphic_relations/) + end end describe '#fetch_metadata' do diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb index 3003512c6..69c306db7 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb @@ -242,12 +242,19 @@ def build_datasource(**options) expect(build_datasource.collections).to be_empty end - it 'ignores relationship metadata of a table name tracked in several schemas' do + # The bare GraphQL field `transfers` can only be `public.transfers`; the + # `banking.transfers` metadata belongs to the `banking_transfers` field and + # must not shadow or invalidate the public mapping. + it 'keeps the public mapping when another schema tracks the same table name' do stub_schema( [ { 'name' => 'transfers', 'kind' => 'OBJECT', - 'fields' => [field('id', non_null(scalar('bigint'))), field('account', object('accounts'))] + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('account_id', non_null(scalar('bigint'))), + field('account', object('accounts')) + ] }, { 'name' => 'accounts', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } ], @@ -262,8 +269,69 @@ def build_datasource(**options) ] ) - # Falls back to the naming convention, which finds no `account_id` column. - expect(build_datasource.get_collection('Transfer').schema[:fields]).not_to have_key('account') + account = build_datasource.get_collection('Transfer').schema[:fields]['account'] + + expect(account.type).to eq('ManyToOne') + expect(account.foreign_key).to eq('account_id') + end + + it 'survives malformed metadata entries by falling back to naming conventions' do + stub_schema( + [{ 'name' => 'transfers', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] }], + [list_query('transfers'), by_pk_query('transfers')] + ) + stub_metadata( + [ + 'legacy-string-table-form', + { 'table' => { 'schema' => 'public', 'name' => 'transfers' }, + 'object_relationships' => [{ 'name' => 'broken' }] } + ] + ) + + expect(build_datasource.collections.keys).to eq(['Transfer']) + end + + it 'raises a named error when introspection returns no usable schema' do + WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI) + .with { |request| request.body.include?('IntrospectSchema') } + .to_return(status: 200, body: JSON.generate({ 'data' => { '__schema' => nil } }), + headers: { 'Content-Type' => 'application/json' }) + stub_metadata([]) + + expect { build_datasource } + .to raise_error(ForestAdminDatasourceGraphqlHasura::IntrospectionError, /introspection enabled/) + end + + it 'lets an explicit exclusion win over the allow-list' do + stub_schema( + [ + { 'name' => 'transfers', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] }, + { 'name' => 'cards', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + [list_query('transfers'), by_pk_query('transfers'), list_query('cards'), by_pk_query('cards')] + ) + stub_metadata([]) + + datasource = build_datasource(included_tables: %w[transfers cards], excluded_tables: ['cards']) + + expect(datasource.collections.keys).to eq(['Transfer']) + end + + it 'keeps one collection and warns when two tables classify to the same name' do + stub_schema( + [ + { 'name' => 'user_status', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] }, + { 'name' => 'user_statuses', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + [list_query('user_status'), by_pk_query('user_status'), + list_query('user_statuses'), by_pk_query('user_statuses')] + ) + stub_metadata([]) + + datasource = build_datasource + + expect(datasource.collections.keys).to eq(['UserStatus']) + expect(datasource.get_collection('UserStatus').table_name).to eq('user_status') end end @@ -332,6 +400,37 @@ def build_datasource(**options) end end + describe 'configured polymorphism without the Hasura metadata' do + # With every mapping unknown, two relationships towards the same configured + # target are indistinguishable — one may be a plain belongs_to whose + # absorption would silently delete it. + it 'refuses to guess between two relationships towards the same target' do + stub_schema( + [ + { + 'name' => 'comments', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('commentable_type', non_null(scalar('String'))), + field('commentable_id', non_null(scalar('bigint'))), + field('transfer', object('transfers')), + field('reviewed_transfer', object('transfers')) + ] + }, + { 'name' => 'transfers', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + [list_query('comments'), by_pk_query('comments'), list_query('transfers'), by_pk_query('transfers')] + ) + WebMock.stub_request(:post, BankingSchema::METADATA_URI).to_return(status: 403, body: '{}') + + datasource = build_datasource( + polymorphic_relations: { 'comments' => { 'commentable' => %w[transfers] } } + ) + + expect(datasource.get_collection('Comment').schema[:fields]).not_to have_key('commentable') + end + end + describe 'a column named like the polymorphic association' do it 'keeps the physical column and skips the association' do stub_schema( From 5fe667d416170f58a49ccb16cb8c54db4c0f2581 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 16:44:46 +0200 Subject: [PATCH 14/26] fix(datasource graphql hasura): follow customized Hasura root fields and close review edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Macroscope findings on customized root fields ran deeper than reported: relationships reference the GraphQL type name, while tables were indexed by their root field name — identical under default naming, different as soon as the metadata renames a select root field. Table now carries both names, lookups accept either, and foreign collections resolve through the table: - metadata mappings are keyed by the root field the metadata declares (custom_root_fields.select, then custom_name, then the derived name) - primary keys are correlated through the OBJECT type the _by_pk field returns instead of unsuffixing its name Also from the same batch: - Time.parse ordering is reserved for date columns: a text value that looks like a date ("3 Feb 2020") compares lexically, the way SQL collates text - a relation passed as the aggregated field is rejected by name instead of emitting `sum { membership }` and failing at the GraphQL layer - an explicit nil option (`headers: nil`) means the default instead of crashing every request - a success response with an empty body (a 204) raises TransportError instead of an unwrapped TypeError Co-Authored-By: Claude Fable 5 --- .../client.rb | 3 ++ .../configuration.rb | 7 +-- .../introspection/introspector.rb | 37 +++++++++----- .../introspection/polymorphism_detector.rb | 9 ++-- .../introspection/schema_converter.rb | 9 ++-- .../introspection/structures.rb | 6 ++- .../query/aggregator.rb | 27 ++++++++--- .../client_spec.rb | 16 +++++++ .../collection_spec.rb | 48 +++++++++++++++++++ .../introspector_detection_spec.rb | 41 ++++++++++++++++ 10 files changed, 175 insertions(+), 28 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb index 7abb72cda..bacd35916 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb @@ -24,6 +24,9 @@ def execute(query, variables = {}) response = post(@configuration.uri, body) raise TransportError, "GraphQL endpoint returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) + # A 204 is a success with a nil body, which JSON.parse would turn into an + # unwrapped TypeError. + raise TransportError, 'GraphQL endpoint returned an empty body' if response.body.nil? || response.body.empty? payload = JSON.parse(response.body) raise TransportError, 'GraphQL endpoint returned an unexpected body' unless payload.is_a?(Hash) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb index f4349c8a9..0b91b33db 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb @@ -24,10 +24,11 @@ def initialize(uri:, **options) raise ConfigurationError, "Unknown option(s): #{unknown.join(", ")}" if unknown.any? @uri = uri - # `default.dup` keeps the DEFAULTS hashes and arrays from being shared — - # and mutated — across Configuration instances. + # An explicit nil means "the default" (`headers: nil` must not crash every + # request), and `default.dup` keeps the DEFAULTS hashes and arrays from + # being shared — and mutated — across Configuration instances. DEFAULTS.each do |option, default| - instance_variable_set("@#{option}", options.key?(option) ? options[option] : default.dup) + instance_variable_set("@#{option}", options[option].nil? ? default.dup : options[option]) end validate_polymorphic_relations # Only derivable from the conventional endpoint path: substituting on any diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb index 0b4b1bd94..e2833bf1e 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb @@ -147,12 +147,7 @@ def collect_table_mappings(table, mappings, ambiguous) table_info = table['table'] return unless table_info.is_a?(Hash) - schema_name = table_info['schema'] - table_name = table_info['name'] - # Hasura derives the root field from the table name, prefixed by the - # schema outside of `public`: a bare name can only be the public table, - # so a non-public one must not claim it. - exposed = schema_name.nil? || schema_name == 'public' ? table_name : "#{schema_name}_#{table_name}" + exposed = exposed_root_field(table, table_info) relationships = (table['object_relationships'] || []).map { |rel| [rel, :object] } + (table['array_relationships'] || []).map { |rel| [rel, :array] } @@ -167,6 +162,22 @@ def collect_table_mappings(table, mappings, ambiguous) end end + # The mapping key has to be the root field the introspection query will + # show. Hasura derives it from the table name — prefixed by the schema + # outside of `public`, so a bare name can only be the public table — + # unless the metadata customizes it (`custom_root_fields.select` wins + # over `custom_name`, which replaces the derived name). + def exposed_root_field(table, table_info) + custom = table.dig('configuration', 'custom_root_fields', 'select') || + table.dig('configuration', 'custom_name') + return custom if custom + + schema_name = table_info['schema'] + table_name = table_info['name'] + + schema_name.nil? || schema_name == 'public' ? table_name : "#{schema_name}_#{table_name}" + end + # A nil column stands for the primary key of that table: a foreign key # constraint may reference any unique column, and which one is only # resolvable once the tables are parsed. @@ -186,13 +197,16 @@ def relationship_mapping(rel, kind) end end + # Keyed by the GraphQL OBJECT type the field returns, which the list root + # field shares whatever the root fields are renamed to — deriving a table + # name from the `_by_pk` spelling would miss a customized select field. def parse_primary_keys(query_fields) query_fields.each_with_object({}) do |field, memo| next unless field['name'].end_with?('_by_pk') - table_name = field['name'].delete_suffix('_by_pk') + type_name = base_type_name(field['type']) pk_fields = (field['args'] || []).map { |arg| arg['name'] } - memo[table_name] = pk_fields if pk_fields.any? + memo[type_name] = pk_fields if pk_fields.any? end end @@ -236,8 +250,9 @@ def parse_table(table_name, type) Table.new( name: table_name, + type_name: type['name'], columns: columns, - primary_key: resolve_primary_key(table_name, columns), + primary_key: resolve_primary_key(type['name'], columns), relationships: relations.map { |field| parse_relationship(table_name, field) }, polymorphics: [] ) @@ -284,8 +299,8 @@ def parse_column(field, type_name) # primary key, which makes it the one trustworthy signal. Inferring a key # from an `id` column would address records of a view — or of a tracked # function — through a column that carries no uniqueness. - def resolve_primary_key(table_name, columns) - known = @primary_keys[table_name] + def resolve_primary_key(type_name, columns) + known = @primary_keys[type_name] if known columns.each { |column| column.is_primary_key = known.include?(column.name) } diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb index 04900cbff..f39c4b2c9 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb @@ -14,7 +14,10 @@ def initialize(configuration) # Fills `polymorphics` on each table and removes the per-target object # relationships it absorbs. def detect(tables) + # Relationships reference the GraphQL type name, which only differs from + # the root field name when the Hasura metadata customizes root fields. tables_by_name = tables.to_h { |table| [table.name, table] } + .merge(tables.to_h { |table| [table.type_name, table] }) tables.each do |table| bases_of(table).each { |base| absorb(table, base, tables_by_name) } @@ -72,11 +75,11 @@ def targets_of(table, base, tables_by_name) candidates.group_by(&:remote_table).each_with_object({}) do |(remote_table, relationships), memo| target_table = tables_by_name[remote_table] next unless target_table - next if ambiguous_branch?(table, base, remote_table, relationships) + next if ambiguous_branch?(table, base, target_table.name, relationships) relationship = relationships.first - memo[class_name_of(remote_table)] = { - table: remote_table, + memo[class_name_of(target_table.name)] = { + table: target_table.name, hasura_field: relationship.name, primary_key: relationship.mapping&.values&.first || target_table.primary_key.first || 'id' } diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb index 426e8329f..3b2ddadc7 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb @@ -39,7 +39,10 @@ class SchemaConverter def initialize(tables, configuration) @tables = tables + # Relationships reference the GraphQL type name, which only differs from + # the root field name when the Hasura metadata customizes root fields. @tables_by_name = tables.to_h { |table| [table.name, table] } + .merge(tables.to_h { |table| [table.type_name, table] }) @configuration = configuration end @@ -147,7 +150,7 @@ def convert_object_relationship(table, relationship) end [relationship.name, Relations::ManyToOneSchema.new( - foreign_collection: collection_name_of(relationship.remote_table), + foreign_collection: collection_name_of(remote.name), foreign_key: foreign_key, foreign_key_target: relationship.mapping&.values&.first || primary_key_of(remote) )] @@ -169,7 +172,7 @@ def convert_array_relationship(table, relationship) end [relationship.name, Relations::OneToManySchema.new( - foreign_collection: collection_name_of(relationship.remote_table), + foreign_collection: collection_name_of(remote.name), origin_key: origin_key, origin_key_target: relationship.mapping&.keys&.first || primary_key_of(table) )] @@ -233,7 +236,7 @@ def add_reverse_polymorphics(table, fields) def reverse_polymorphic_name(table, child, polymorphic, fields) array_relationship = table.relationships.find do |rel| - rel.kind == :array && rel.remote_table == child.name && reverse_of?(rel, table, polymorphic) + rel.kind == :array && @tables_by_name[rel.remote_table] == child && reverse_of?(rel, table, polymorphic) end candidates = [array_relationship&.name, child.name, "#{child.name}_#{polymorphic.name}"].compact.uniq diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb index d37ada971..9990da5ae 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb @@ -1,6 +1,10 @@ module ForestAdminDatasourceGraphqlHasura module Introspection - Table = Struct.new(:name, :columns, :primary_key, :relationships, :polymorphics, keyword_init: true) + # name is the root field records are queried through; type_name is the + # GraphQL OBJECT type, which relationships reference. They only differ when + # the Hasura metadata customizes the root fields. + Table = Struct.new(:name, :type_name, :columns, :primary_key, :relationships, :polymorphics, + keyword_init: true) Column = Struct.new(:name, :type, :graphql_type, :nullable, :is_primary_key, :is_array, :is_text, keyword_init: true) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb index 853a9bc21..67bf33b16 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb @@ -22,6 +22,7 @@ def initialize(collection) def run(filter, aggregation, limit) validate(aggregation) + @date_field = date_field?(aggregation) if aggregation.groups.nil? || aggregation.groups.empty? simple(filter, aggregation) @@ -41,7 +42,7 @@ def fields = @collection.schema[:fields] # one path the agent does not validate upstream (the charts route passes the # request's `aggregateFieldName` straight through). def validate(aggregation) - validate_field(aggregation.field) if aggregation.field + validate_field(aggregation.field, column_only: true) if aggregation.field # Without a field, `sum { }` would be an empty GraphQL selection set. if aggregation.field.nil? && aggregation.operation != 'Count' @@ -66,7 +67,10 @@ def validate(aggregation) end end - def validate_field(field, allow_relation: false) + # column_only rejects a relation as the aggregated field (`sum { membership }` + # is not valid GraphQL); a group field may end on a ManyToOne, which stands + # for its foreign key. + def validate_field(field, allow_relation: false, column_only: false) path = field.to_s.split(':') unless (allow_relation && path.size <= 2) || path.size == 1 @@ -75,10 +79,14 @@ def validate_field(field, allow_relation: false) *relations, last = path collection = relations.reduce(@collection) { |current, part| collection_through(current, part, field) } + target = collection.schema[:fields][last] - return unless collection.schema[:fields][last].nil? + raise ForestException, "Field '#{field}' not found on collection '#{collection.name}'." if target.nil? - raise ForestException, "Field '#{field}' not found on collection '#{collection.name}'." + return unless column_only && target.type != 'Column' + + raise ForestException, + "Cannot aggregate on '#{field}': it is a relation, not a column (collection '#{name}')." end def collection_through(collection, relation_name, field) @@ -304,10 +312,11 @@ def comparable(value) end # Strings reaching here belong to non-numeric columns — numeric ones were - # normalized at extraction. Dates order as instants, text lexically, like - # SQL would (a digit-only text value must not compare numerically). + # normalized at extraction. Date columns order as instants (offsets make + # lexical ordering lie); anything else orders lexically, like SQL collates, + # even when a text value happens to look like a date or a number. def comparable_string(value) - instant = time_value(value) + instant = @date_field ? time_value(value) : nil instant ? [0, instant, ''] : [1, 0.0, value] end @@ -336,6 +345,10 @@ def extract_value(data, aggregation) def number_field?(aggregation) aggregation.field && fields[aggregation.field]&.column_type == 'Number' end + + def date_field?(aggregation) + aggregation.field && %w[Date Dateonly Time].include?(fields[aggregation.field]&.column_type) + end end end end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb index 5ea93ae78..d2615e244 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb @@ -71,6 +71,14 @@ module ForestAdminDatasourceGraphqlHasura expect { client.execute('query { ok }') }.to raise_error(TransportError, /unexpected body/) end + # A 204 passes the Net::HTTPSuccess check with a nil body, which + # JSON.parse would turn into an unwrapped TypeError. + it 'raises TransportError on an empty body' do + WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI).to_return(status: 204, body: nil) + + expect { client.execute('query { ok }') }.to raise_error(TransportError, /empty body/) + end + it 'raises TransportError on a 200 carrying neither data nor errors' do WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI) .to_return(status: 200, body: JSON.generate({ 'data' => nil })) @@ -80,6 +88,14 @@ module ForestAdminDatasourceGraphqlHasura end describe 'configuration' do + it 'treats an explicit nil option as the default' do + configuration = Configuration.new(uri: BankingSchema::GRAPHQL_URI, headers: nil) + WebMock.stub_request(:post, BankingSchema::GRAPHQL_URI) + .to_return(status: 200, body: JSON.generate({ 'data' => {} })) + + expect(described_class.new(configuration).execute('query { ok }')).to eq({}) + end + it 'does not share default objects across instances' do first = Configuration.new(uri: BankingSchema::GRAPHQL_URI) second = Configuration.new(uri: BankingSchema::GRAPHQL_URI) diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb index 4e852f153..acd746ccc 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb @@ -262,6 +262,54 @@ def last_graphql_request expect(result.first['value']).to eq(9_007_199_254_740_993) end + # A text value that happens to parse as a date must still compare lexically, + # the way SQL collates a text column. + it 'merges date-looking text lexically, not chronologically' do + BankingSchema.stub_graphql_data( + { + 'memberships' => [ + { 'full_name' => 'Jane', + 'comments_aggregate' => { 'aggregate' => { 'max' => { 'body' => '2 Jan 2021' } } } }, + { 'full_name' => 'Jane', + 'comments_aggregate' => { 'aggregate' => { 'max' => { 'body' => '3 Feb 2020' } } } } + ] + } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Max', field: 'body', + groups: [{ field: 'membership:full_name' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result.first['value']).to eq('3 Feb 2020') + end + + # On a real date column, lexical ordering lies as soon as offsets differ. + it 'merges a Max over a date column by instant' do + BankingSchema.stub_graphql_data( + { + 'memberships' => [ + { 'full_name' => 'Jane', + 'comments_aggregate' => { 'aggregate' => { 'max' => { 'created_at' => '2026-08-05T23:00:00+00:00' } } } }, + { 'full_name' => 'Jane', + 'comments_aggregate' => { 'aggregate' => { 'max' => { 'created_at' => '2026-08-06T00:30:00+02:00' } } } } + ] + } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Max', field: 'created_at', + groups: [{ field: 'membership:full_name' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result.first['value']).to eq('2026-08-05T23:00:00+00:00') + end + + it 'rejects a relation as the aggregated field with a clear error' do + aggregation = toolkit_query::Aggregation.new(operation: 'Sum', field: 'membership') + + expect { comments.aggregate(caller, filter, aggregation) } + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /not a column/) + end + it 'merges a Max over text lexically instead of keeping the first row' do BankingSchema.stub_graphql_data( { diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb index 69c306db7..5dd84922d 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb @@ -400,6 +400,47 @@ def build_datasource(**options) end end + describe 'customized root fields' do + # The root select field is renamed to `people`, but relationships and the + # `_by_pk` query keep referencing the `person_table` GraphQL type: metadata + # and primary keys must follow what the schema actually exposes. + it 'applies the metadata and detects the primary key through the GraphQL type' do + stub_schema( + [ + { + 'name' => 'person_table', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('best_friend_ref', scalar('bigint')), + field('best_friend', object('person_table')) + ] + } + ], + [ + field('people', non_null(list_of(non_null(object('person_table'))))), + { 'name' => 'person_table_by_pk', 'type' => object('person_table'), + 'args' => [{ 'name' => 'id', 'type' => non_null(scalar('bigint')) }] } + ] + ) + stub_metadata( + [ + { 'table' => { 'schema' => 'public', 'name' => 'person_table' }, + 'configuration' => { 'custom_root_fields' => { 'select' => 'people' } }, + 'object_relationships' => [ + manual_object_rel('best_friend', 'person_table', { 'best_friend_ref' => 'id' }) + ] } + ] + ) + + fields = build_datasource.get_collection('Person').schema[:fields] + + expect(fields['id'].is_primary_key).to be(true) + expect(fields['best_friend'].type).to eq('ManyToOne') + expect(fields['best_friend'].foreign_key).to eq('best_friend_ref') + expect(fields['best_friend'].foreign_collection).to eq('Person') + end + end + describe 'configured polymorphism without the Hasura metadata' do # With every mapping unknown, two relationships towards the same configured # target are indistinguishable — one may be a plain belongs_to whose From 71341521437c18c1f3d62e64a37b1117901e5a48 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 16:55:44 +0200 Subject: [PATCH 15/26] fix(datasource graphql hasura): give dangling keys exact groups and finish the custom-root-fields follow-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grouping by the foreign key, SQL keeps each dangling value as a group of its own; the nil-bucket compromise merged them. Hasura has no GROUP BY but it has distinct_on: the dangling keys are enumerated (capped at 100 distinct values, then a clear error — data that corrupt deserves one) and aggregated one by one, and only truly NULL keys fall into the nil bucket. Grouping through a parent column keeps the single negated-relationship aggregate: NULL and dangling keys alike are the NULL group of a LEFT JOIN there. The custom-root-fields work also had three stragglers still reading the root field where the underlying type is the truth: the Rails class name (a select field renamed to all_transfers made collection AllTransfer, breaking the polymorphic type match), the conventional origin key of an array relationship, and the polymorphic_relations target comparison — type_values and the configuration now accept either name. Co-Authored-By: Claude Fable 5 --- .../README.md | 6 +- .../introspection/polymorphism_detector.rb | 24 ++++++-- .../introspection/schema_converter.rb | 13 +++- .../query/aggregator.rb | 60 +++++++++++++++---- .../query/query_builder.rb | 20 +++++++ .../collection_spec.rb | 47 +++++++++++++-- .../introspector_detection_spec.rb | 6 +- 7 files changed, 147 insertions(+), 29 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/README.md b/packages/forest_admin_datasource_graphql_hasura/README.md index e180c8f0d..ec52d9909 100644 --- a/packages/forest_admin_datasource_graphql_hasura/README.md +++ b/packages/forest_admin_datasource_graphql_hasura/README.md @@ -79,8 +79,10 @@ type_values: { 'bank_accounts' => 'Banking::Account' } Hasura: Hasura exposes GROUP BY only through nested `_aggregate` fields. A foreign key without a declared reverse relationship is advertised as non-groupable, like every other column, and date truncation is not supported. Rows whose foreign key is NULL - form a bucket of their own, as SQL grouping would — dangling foreign keys (possible on a - constraint-less relationship) join that bucket rather than being dropped. Parent rows are filtered by the + form a bucket of their own, as SQL grouping would. Dangling foreign keys (possible on a + constraint-less relationship) keep a group per value when grouping by the foreign key (up + to 100 distinct dangling values, then a clear error) and fall into the NULL bucket when + grouping through a parent column, as a LEFT JOIN would. Parent rows are filtered by the chart's predicate and paginated by 1000; a chart spanning more than 10 000 parent rows fails with a clear error rather than returning partial numbers. - **Tables without a primary key** (typically untracked views) are skipped: Forest cannot diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb index f39c4b2c9..c1dc871ff 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb @@ -70,7 +70,9 @@ def targets_of(table, base, tables_by_name) configured_tables = @configuration.polymorphic_relations.dig(table.name, base) foreign_key = "#{base}_id" - candidates = table.relationships.select { |rel| branch?(rel, foreign_key, configured_tables) } + candidates = table.relationships.select do |rel| + branch?(rel, foreign_key, configured_tables, tables_by_name) + end candidates.group_by(&:remote_table).each_with_object({}) do |(remote_table, relationships), memo| target_table = tables_by_name[remote_table] @@ -78,7 +80,7 @@ def targets_of(table, base, tables_by_name) next if ambiguous_branch?(table, base, target_table.name, relationships) relationship = relationships.first - memo[class_name_of(target_table.name)] = { + memo[class_name_of(target_table)] = { table: target_table.name, hasura_field: relationship.name, primary_key: relationship.mapping&.values&.first || target_table.primary_key.first || 'id' @@ -101,13 +103,21 @@ def ambiguous_branch?(table, base, remote_table, relationships) true end - def branch?(relationship, foreign_key, configured_tables) + def branch?(relationship, foreign_key, configured_tables, tables_by_name) return false unless relationship.kind == :object # A known mapping is checked even when the target is configured: a table # may hold both an ordinary relationship and a polymorphic branch towards # the same target, and they must not be mistaken for one another. return false unless relationship.mapping.nil? || relationship.mapping.keys == [foreign_key] - return configured_tables.include?(relationship.remote_table) if configured_tables + + if configured_tables + # The configuration names tables; the relationship carries the GraphQL + # type name, which custom_root_fields can decouple from the root field. + target = tables_by_name[relationship.remote_table] + + # & rather than intersect?, which needs Ruby >= 3.1. + return (configured_tables & [target&.name, target&.type_name].compact).any? + end # A relationship backed by a real foreign key constraint is monomorphic by # definition: accepting one here would absorb a legitimate belongs_to @@ -115,8 +125,10 @@ def branch?(relationship, foreign_key, configured_tables) relationship.manual end - def class_name_of(table_name) - @configuration.type_values[table_name] || table_name.classify + def class_name_of(table) + @configuration.type_values[table.name] || + @configuration.type_values[table.type_name] || + table.type_name.classify end end end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb index 3b2ddadc7..4903968f1 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb @@ -46,8 +46,15 @@ def initialize(tables, configuration) @configuration = configuration end + # Rails stores the class name derived from the Postgres table, which the + # GraphQL type name follows — not the root field, which custom_root_fields + # can rename freely. type_values accepts either name. def rails_class_name_of(table_name) - @configuration.type_values[table_name] || table_name.classify + table = @tables_by_name[table_name] + + @configuration.type_values[table_name] || + (table && @configuration.type_values[table.type_name]) || + (table&.type_name || table_name).classify end # 'Banking::Account' -> 'Banking__Account' @@ -162,7 +169,9 @@ def convert_array_relationship(table, relationship) return [relationship.name, nil] if covered_by_reverse_polymorphic?(table, relationship, remote) return [relationship.name, nil] unless single_column_mapping?(table, relationship) - origin_key = relationship.mapping&.values&.first || "#{table.name.singularize}_id" + # The conventional foreign key follows the underlying table (type_name), + # not a root field custom_root_fields may have renamed. + origin_key = relationship.mapping&.values&.first || "#{table.type_name.singularize}_id" unless remote.columns.any? { |column| column.name == origin_key } skip_relationship(table, relationship, "origin key '#{origin_key}' does not exist on " \ diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb index 67bf33b16..5fdd1b334 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb @@ -16,6 +16,10 @@ class Aggregator PARENT_PAGE = 1000 MAX_PARENT_ROWS = 10_000 + # At most this many distinct dangling foreign keys get a group of their + # own; beyond that the data is corrupt enough to deserve an error. + DANGLING_KEYS_LIMIT = 100 + def initialize(collection) @collection = collection end @@ -113,7 +117,7 @@ def grouped(filter, aggregation, limit) group_field = aggregation.groups.first[:field] relation = find_group_relation(group_field) values = collect_groups(fetch_parent_rows(relation, filter, aggregation), relation, aggregation) - add_null_group(values, relation, filter, aggregation) + add_orphan_groups(values, relation, filter, aggregation) results = values .map { |key, value| { 'value' => value, 'group' => { group_field => key } } } @@ -158,24 +162,52 @@ def collect_groups(rows, relation, aggregation) end end - # Rows without a matching parent — a NULL foreign key, or a dangling one on - # a constraint-less relationship — are invisible to the parent-table detour: - # `_not: { relation: {} }` is how Hasura selects them, and a LEFT JOIN would - # put them in its NULL group. When grouping by the foreign key itself, SQL - # would keep a dangling key as a group of its own; Hasura cannot enumerate - # those keys, so they land in the nil bucket too, rather than being dropped. - # The nil key can pre-exist (a parent whose grouped column is null): both - # are the NULL group of a LEFT JOIN, so they merge. - def add_null_group(values, relation, filter, aggregation) + # Rows without a matching parent are invisible to the parent-table detour. + # Grouped by a parent column, they are the NULL group of a LEFT JOIN — nil + # and dangling foreign keys alike (`_not: { relation: {} }` selects both). + # Grouped by the foreign key itself, SQL keeps each dangling key as a group + # of its own: those keys are enumerated and aggregated one by one, and only + # truly NULL keys fall into the nil bucket. + def add_orphan_groups(values, relation, filter, aggregation) return unless relation[:orphans_possible] - operation = QueryBuilder.aggregate(table_name, filter, aggregation, - extra_where: { '_not' => { relation[:child_relation_name] => {} } }) + if relation[:fk_grouping] + dangling_keys(relation, filter).each do |key| + add_orphan_group(values, key, filter, aggregation, { relation[:foreign_key] => { '_eq' => key } }) + end + add_orphan_group(values, nil, filter, aggregation, + { relation[:foreign_key] => { '_is_null' => true } }) + else + # The nil key can pre-exist (a parent whose grouped column is null): + # both are the NULL group of a LEFT JOIN, so they merge. + add_orphan_group(values, nil, filter, aggregation, + { '_not' => { relation[:child_relation_name] => {} } }) + end + end + + def add_orphan_group(values, key, filter, aggregation, extra_where) + operation = QueryBuilder.aggregate(table_name, filter, aggregation, extra_where: extra_where) data = @collection.execute(:aggregate, operation).dig("#{table_name}_aggregate", 'aggregate') return if childless?(data, aggregation) value = extract_value(data, aggregation) - values[nil] = values.key?(nil) ? merge_values(values[nil], value, aggregation) : value + values[key] = values.key?(key) ? merge_values(values[key], value, aggregation) : value + end + + def dangling_keys(relation, filter) + operation = QueryBuilder.orphan_keys(table_name, filter, relation[:foreign_key], + relation[:child_relation_name], DANGLING_KEYS_LIMIT) + rows = @collection.execute(:aggregate, operation)[table_name] || [] + keys = rows.map { |row| row[relation[:foreign_key]] } + + if keys.size >= DANGLING_KEYS_LIMIT + raise ForestException, + "Grouped aggregation on '#{name}': more than #{DANGLING_KEYS_LIMIT} distinct " \ + "'#{relation[:foreign_key]}' values reference no parent row; clean the data up " \ + 'or narrow the chart filter.' + end + + keys end # Two parent rows can share a group value — grouping by a name rather than by @@ -247,6 +279,8 @@ def find_group_relation(group_field) parent_field: parent_column || relation.foreign_key_target, relation_name: reverse, child_relation_name: relation_name, + foreign_key: foreign_key, + fk_grouping: parent_column.nil?, parent_order_fields: primary_keys_of(parent), orphans_possible: orphans_possible?(relation_name, relation) } diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb index d3c46ba4f..b67d74b02 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb @@ -146,6 +146,26 @@ def grouped_aggregate(child_table, relation, filter, aggregation, page) { query: query, variables: variables } end + # Distinct values of `column` among rows without a matching parent — the + # dangling foreign keys a grouped chart must keep as groups of their own. + # distinct_on requires the matching order_by. + def orphan_keys(table, filter, column, relation_name, limit) + where = combine( + FilterConverter.convert(filter.condition_tree), + { '_and' => [{ '_not' => { relation_name => {} } }, { column => { '_is_null' => false } }] } + ) + + query = <<~GRAPHQL + query OrphanKeys#{camelize(table)}($where: #{table}_bool_exp, $limit: Int) { + #{table}(where: $where, distinct_on: [#{column}], order_by: [{ #{column}: asc }], limit: $limit) { + #{column} + } + } + GRAPHQL + + { query: query, variables: { 'where' => where, 'limit' => limit } } + end + def aggregation_selection(aggregation) operation = aggregation.operation diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb index acd746ccc..fff39ffb3 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb @@ -491,12 +491,12 @@ def last_graphql_request .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /more than 10000/) end - # SQL grouping would put comments without a matching membership — NULL or - # dangling foreign key — in the LEFT JOIN's NULL bucket; the parent-table - # detour cannot see them, so they are caught by negating the relationship. - it 'adds a bucket for the rows without a matching parent' do + # Grouping by the foreign key, SQL gives rows whose key is NULL a bucket of + # their own; the parent-table detour cannot see them. + it 'adds a bucket for the rows whose foreign key is null' do BankingSchema.stub_graphql_data( { 'memberships' => [{ 'id' => 1, 'comments_aggregate' => { 'aggregate' => { 'count' => 3 } } }] }, + { 'comments' => [] }, { 'comments_aggregate' => { 'aggregate' => { 'count' => 2 } } } ) @@ -507,6 +507,45 @@ def last_graphql_request { 'value' => 3, 'group' => { 'membership_id' => 1 } }, { 'value' => 2, 'group' => { 'membership_id' => nil } } ]) + expect(last_graphql_request['variables']['where']).to eq({ 'membership_id' => { '_is_null' => true } }) + end + + # SQL keeps a dangling key (a value referencing no parent row) as a group of + # its own when grouping by the foreign key — not merged into the NULL bucket. + it 'keeps each dangling foreign key as its own group' do + BankingSchema.stub_graphql_data( + { 'memberships' => [{ 'id' => 1, 'comments_aggregate' => { 'aggregate' => { 'count' => 3 } } }] }, + { 'comments' => [{ 'membership_id' => 42 }] }, + { 'comments_aggregate' => { 'aggregate' => { 'count' => 5 } } }, + { 'comments_aggregate' => { 'aggregate' => { 'count' => 0, 'row_count' => 0 } } } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Count', groups: [{ field: 'membership_id' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result).to eq([ + { 'value' => 5, 'group' => { 'membership_id' => 42 } }, + { 'value' => 3, 'group' => { 'membership_id' => 1 } } + ]) + requests = graphql_requests + expect(requests[1]['query']).to include('distinct_on: [membership_id]') + expect(requests[2]['variables']['where']).to eq({ 'membership_id' => { '_eq' => 42 } }) + end + + # Grouping by a parent column, NULL and dangling keys alike are the NULL + # group of a LEFT JOIN: one negated-relationship aggregate covers both. + it 'adds a single null bucket when grouping through a parent column' do + BankingSchema.stub_graphql_data( + { 'memberships' => [{ 'full_name' => 'Jane', + 'comments_aggregate' => { 'aggregate' => { 'count' => 3 } } }] }, + { 'comments_aggregate' => { 'aggregate' => { 'count' => 2 } } } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Count', + groups: [{ field: 'membership:full_name' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result).to include({ 'value' => 2, 'group' => { 'membership:full_name' => nil } }) expect(last_graphql_request['variables']['where']).to eq({ '_not' => { 'membership' => {} } }) end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb index 5dd84922d..4dd8a4006 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb @@ -432,12 +432,14 @@ def build_datasource(**options) ] ) - fields = build_datasource.get_collection('Person').schema[:fields] + # The Rails class name follows the underlying type (person_table), not + # the renamed root field (people). + fields = build_datasource.get_collection('PersonTable').schema[:fields] expect(fields['id'].is_primary_key).to be(true) expect(fields['best_friend'].type).to eq('ManyToOne') expect(fields['best_friend'].foreign_key).to eq('best_friend_ref') - expect(fields['best_friend'].foreign_collection).to eq('Person') + expect(fields['best_friend'].foreign_collection).to eq('PersonTable') end end From e25c8722f68db13607b49b0ea641edf591383159 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 17:03:10 +0200 Subject: [PATCH 16/26] fix(datasource graphql hasura): keep composite primary keys writable Every primary-key column was read-only, which made a composite-key join table impossible to create through Forest Admin: its key is assigned by the application and has no default to fall back on. Composite keys are now writable and required; a single-column key stays read-only, because it is database-generated (serial, uuid) and the explicit-nil write semantics would override that default with NULL. The validation suite now creates and deletes a card_memberships row for real. Co-Authored-By: Claude Fable 5 --- .../introspection/schema_converter.rb | 18 ++++++++++++---- .../datasource_spec.rb | 3 +++ .../introspector_detection_spec.rb | 21 +++++++++++++++++++ .../validation/validate.rb | 16 ++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb index 4903968f1..332605c81 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb @@ -64,8 +64,11 @@ def collection_name_of(table_name) def build_fields(table) fields = {} + composite_key = table.primary_key.size > 1 - table.columns.each { |column| fields[column.name] = convert_column(column) } + table.columns.each do |column| + fields[column.name] = convert_column(column, composite_key: composite_key) + end add_polymorphics(table, fields) @@ -81,19 +84,26 @@ def build_fields(table) private - def convert_column(column) + # A single-column key is database-generated in the schemas Hasura fronts + # (serial, uuid default), and the writes persist explicit nils — which + # would override that default — so it stays read-only. A composite key is + # application-assigned (a join table has no default to fall back on): + # keeping it read-only would make the table impossible to create through. + def convert_column(column, composite_key:) + read_only = column.is_primary_key && !composite_key + ColumnSchema.new( column_type: column.is_array ? [column.type] : column.type, filter_operators: operators_for(column), is_primary_key: column.is_primary_key, - is_read_only: column.is_primary_key, + is_read_only: read_only, is_sortable: !column.is_array, # The capabilities route publishes this flag, so anything but the # foreign keys of mark_groupable_foreign_keys would have the UI offer a # group-by that grouped_aggregate then rejects. is_groupable: false, default_value: nil, - validation: column.nullable || column.is_primary_key ? [] : [{ operator: Operators::PRESENT }] + validation: column.nullable || read_only ? [] : [{ operator: Operators::PRESENT }] ) end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/datasource_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/datasource_spec.rb index 2013a7b69..2b9580106 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/datasource_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/datasource_spec.rb @@ -75,6 +75,9 @@ module ForestAdminDatasourceGraphqlHasura expect(fields['id'].is_primary_key).to be(true) expect(fields['body'].is_primary_key).to be(false) + # Single-column keys are database-generated: writable, our explicit-nil + # writes would override their default. + expect(fields['id'].is_read_only).to be(true) end end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb index 4dd8a4006..d8a3575b5 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb @@ -400,6 +400,27 @@ def build_datasource(**options) end end + describe 'composite primary keys' do + # A join table's key is application-assigned: read-only key columns would + # make the table impossible to create through Forest Admin. + it 'keeps composite key columns writable and required' do + stub_schema( + [{ 'name' => 'card_memberships', 'kind' => 'OBJECT', + 'fields' => [field('card_id', non_null(scalar('bigint'))), + field('membership_id', non_null(scalar('bigint')))] }], + [list_query('card_memberships'), by_pk_query('card_memberships', %w[card_id membership_id])] + ) + stub_metadata([]) + + fields = build_datasource.get_collection('CardMembership').schema[:fields] + + expect(fields['card_id'].is_read_only).to be(false) + expect(fields['membership_id'].is_read_only).to be(false) + expect(fields['card_id'].validation) + .to eq([{ operator: ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators::PRESENT }]) + end + end + describe 'customized root fields' do # The root select field is renamed to `people`, but relationships and the # `_by_pk` query keep referencing the `person_table` GraphQL type: metadata diff --git a/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb b/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb index 5073590da..4ef0bb426 100644 --- a/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb +++ b/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb @@ -182,6 +182,22 @@ def branch(aggregator, conditions) = Nodes::ConditionTreeBranch.new(aggregator, assert gone.empty?, 'record deleted' end +scenario 'a composite-key join table can be created and deleted' do + join = datasource.get_collection('CardMembership') + fields = join.schema[:fields] + assert_equal false, fields['card_id'].is_read_only, 'application-assigned keys must stay writable' + + join.create(nil, { 'card_id' => 2, 'membership_id' => 1 }) + condition = branch('And', [ + leaf('card_id', Operators::EQUAL, 2), + leaf('membership_id', Operators::EQUAL, 1) + ]) + created = join.list(nil, filter(condition_tree: condition), projection('card_id', 'membership_id')) + assert_equal 1, created.size, 'join row created' +ensure + join.delete(nil, filter(condition_tree: condition)) if condition +end + puts "\n== Runtime: aggregates ==" scenario 'simple count with filter' do From 520ac2977d45d26a08c10942ae2a47aee09778e7 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 17:42:17 +0200 Subject: [PATCH 17/26] fix(datasource graphql hasura): derive generated GraphQL names from the type, not the select root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every generated name —
_bool_exp, insert_
,
_aggregate,
_order_by — was derived from the select root field. Hasura derives them all from the table (or custom_name): with a renamed select root, a filtered list referenced a bool_exp type that does not exist and every mutation targeted a root that does not exist. QueryBuilder now takes both names and uses the root only where the root belongs. From the same adversarial pass: - money columns are no longer Number: Hasura serializes them as Postgres money text ("$1,100.00"), which numeric() flattened to 0 — a chart showing 0 with no error; they surface as text, and an aggregate value that cannot be read as a number now raises instead of charting 0 - dangling-key enumeration requests one key past the cap, so exactly 100 distinct values complete instead of tripping a false "more than 100" - a group path ending on a relation (membership:comments) is rejected by name instead of emitting an invalid leaf selection Co-Authored-By: Claude Fable 5 --- .../collection.rb | 19 +++--- .../query/aggregator.rb | 38 ++++++++---- .../query/query_builder.rb | 58 ++++++++++--------- .../collection_spec.rb | 22 +++++++ 4 files changed, 91 insertions(+), 46 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb index de20d8f7f..da692ade3 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb @@ -5,13 +5,16 @@ class Collection < ForestAdminDatasourceToolkit::Collection PLACEHOLDER_REFERENCE = { '*' => nil }.freeze - attr_reader :table_name + attr_reader :table_name, :names def initialize(datasource, table, client, converter) super(datasource, converter.collection_name_of(table.name)) @table = table @table_name = table.name + # root is the select root field; base (the GraphQL type name) is what + # every other generated name derives from. See Query::QueryBuilder. + @names = { root: table.name, base: table.type_name } @client = client @converter = converter @@ -22,17 +25,17 @@ def initialize(datasource, table, client, converter) def list(_caller, filter, projection) selection = build_selection(projection) - operation = Query::QueryBuilder.list(@table_name, filter, selection) - records = execute(:list, operation)[@table_name] || [] + operation = Query::QueryBuilder.list(@names, filter, selection) + records = execute(:list, operation)[@names[:root]] || [] records.map { |record| materialize_polymorphics(record, projection) } end def create(_caller, data) - operation = Query::QueryBuilder.create(@table_name, [writable_columns(data)], column_names) - returning = execute(:create, operation).dig("insert_#{@table_name}", 'returning') + operation = Query::QueryBuilder.create(@names, [writable_columns(data)], column_names) + returning = execute(:create, operation).dig("insert_#{@names[:base]}", 'returning') - raise GraphqlError, "No record returned by insert_#{@table_name}" if returning.nil? || returning.empty? + raise GraphqlError, "No record returned by insert_#{@names[:base]}" if returning.nil? || returning.empty? returning.first end @@ -43,12 +46,12 @@ def update(_caller, filter, data) "Refusing to update every row of '#{name}': the filter carries no condition." end - operation = Query::QueryBuilder.update(@table_name, filter, writable_columns(data)) + operation = Query::QueryBuilder.update(@names, filter, writable_columns(data)) execute(:update, operation) end def delete(_caller, filter) - operation = Query::QueryBuilder.delete(@table_name, filter) + operation = Query::QueryBuilder.delete(@names, filter) execute(:delete, operation) end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb index 5fdd1b334..972038708 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb @@ -38,7 +38,7 @@ def run(filter, aggregation, limit) private def name = @collection.name - def table_name = @collection.table_name + def names = @collection.names def datasource = @collection.datasource def fields = @collection.schema[:fields] @@ -67,7 +67,10 @@ def validate(aggregation) "Date grouping is not supported by the GraphQL datasource (collection '#{name}')." end - validate_field(group[:field], allow_relation: true) + # A two-segment path must end on a column: a relation as the leaf + # selection would be invalid GraphQL. + validate_field(group[:field], allow_relation: true, + column_only: group[:field].to_s.include?(':')) end end @@ -105,8 +108,8 @@ def collection_through(collection, relation_name, field) end def simple(filter, aggregation) - operation = QueryBuilder.aggregate(table_name, filter, aggregation) - data = @collection.execute(:aggregate, operation).dig("#{table_name}_aggregate", 'aggregate') + operation = QueryBuilder.aggregate(names, filter, aggregation) + data = @collection.execute(:aggregate, operation).dig("#{names[:base]}_aggregate", 'aggregate') # One row even when the aggregate is null: the charts route reads # `result[0]['value']` unguarded. @@ -131,7 +134,7 @@ def fetch_parent_rows(relation, filter, aggregation) offset = 0 loop do - operation = QueryBuilder.grouped_aggregate(table_name, relation, filter, aggregation, + operation = QueryBuilder.grouped_aggregate(names, relation, filter, aggregation, { limit: PARENT_PAGE, offset: offset }) page = @collection.execute(:aggregate, operation)[relation[:parent_table]] || [] rows.concat(page) @@ -186,21 +189,23 @@ def add_orphan_groups(values, relation, filter, aggregation) end def add_orphan_group(values, key, filter, aggregation, extra_where) - operation = QueryBuilder.aggregate(table_name, filter, aggregation, extra_where: extra_where) - data = @collection.execute(:aggregate, operation).dig("#{table_name}_aggregate", 'aggregate') + operation = QueryBuilder.aggregate(names, filter, aggregation, extra_where: extra_where) + data = @collection.execute(:aggregate, operation).dig("#{names[:base]}_aggregate", 'aggregate') return if childless?(data, aggregation) value = extract_value(data, aggregation) values[key] = values.key?(key) ? merge_values(values[key], value, aggregation) : value end + # One extra key is requested so that exactly DANGLING_KEYS_LIMIT distinct + # values complete instead of tripping the guard. def dangling_keys(relation, filter) - operation = QueryBuilder.orphan_keys(table_name, filter, relation[:foreign_key], - relation[:child_relation_name], DANGLING_KEYS_LIMIT) - rows = @collection.execute(:aggregate, operation)[table_name] || [] + operation = QueryBuilder.orphan_keys(names, filter, relation[:foreign_key], + relation[:child_relation_name], DANGLING_KEYS_LIMIT + 1) + rows = @collection.execute(:aggregate, operation)[names[:root]] || [] keys = rows.map { |row| row[relation[:foreign_key]] } - if keys.size >= DANGLING_KEYS_LIMIT + if keys.size > DANGLING_KEYS_LIMIT raise ForestException, "Grouped aggregation on '#{name}': more than #{DANGLING_KEYS_LIMIT} distinct " \ "'#{relation[:foreign_key]}' values reference no parent row; clean the data up " \ @@ -241,11 +246,20 @@ def add(current, value) def numeric(value) case value when Integer, Float then value - when String then value.match?(/\A-?\d+\z/) ? value.to_i : Float(value, exception: false) || 0 + when String then parse_number(value) else 0 end end + # Charting 0 in place of a value the wire format hid would be silently + # wrong data; an unparseable aggregate deserves an error. + def parse_number(value) + return value.to_i if value.match?(/\A-?\d+\z/) + + Float(value, exception: false) || + raise(ForestException, "Non-numeric aggregate value #{value.inspect} on collection '#{name}'.") + end + # A group with no rows at all is what SQL grouping leaves out. The # `row_count` alias tells it from a group whose rows exist but hold NULL in # the aggregated column — SQL keeps that one: a zero `count(columns: x)`, diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb index b67d74b02..5232e973f 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb @@ -2,11 +2,17 @@ module ForestAdminDatasourceGraphqlHasura module Query # Builds Hasura GraphQL operations (queries and mutations) with variables. # All methods return { query:, variables: }. + # + # names is { root:, base: }: `root` is the select root field (which + # custom_root_fields can rename freely), `base` is what every other + # generated name derives from — `_bool_exp`, `insert_`, + # `_aggregate`… — namely the GraphQL type name. They only differ when + # the Hasura metadata renames the select root field. class QueryBuilder class << self # selection holds resolved GraphQL fields, nested relations included # ("membership { id full_name }"). - def list(table, filter, selection) + def list(names, filter, selection) args = [] var_defs = [] variables = {} @@ -14,17 +20,17 @@ def list(table, filter, selection) where = FilterConverter.convert(filter.condition_tree) if where - var_defs << "$where: #{table}_bool_exp" + var_defs << "$where: #{names[:base]}_bool_exp" args << 'where: $where' variables['where'] = where end - add_sort(table, filter, args, var_defs, variables) + add_sort(names, filter, args, var_defs, variables) add_pagination(filter, args, var_defs, variables) query = <<~GRAPHQL - query List#{camelize(table)}#{wrap(var_defs)} { - #{table}#{wrap(args)} { + query List#{camelize(names[:root])}#{wrap(var_defs)} { + #{names[:root]}#{wrap(args)} { #{selection.join("\n ")} } } @@ -33,10 +39,10 @@ def list(table, filter, selection) { query: query, variables: variables } end - def create(table, records, selection) + def create(names, records, selection) query = <<~GRAPHQL - mutation Insert#{camelize(table)}($objects: [#{table}_insert_input!]!) { - insert_#{table}(objects: $objects) { + mutation Insert#{camelize(names[:base])}($objects: [#{names[:base]}_insert_input!]!) { + insert_#{names[:base]}(objects: $objects) { returning { #{selection.join("\n ")} } @@ -47,19 +53,19 @@ def create(table, records, selection) { query: query, variables: { 'objects' => records.map { |record| stringify_keys(record) } } } end - def update(table, filter, patch) + def update(names, filter, patch) where = FilterConverter.convert(filter.condition_tree) # Backstop behind the collection guard: `{}` is vacuously true for # Hasura, so a filterless update would rewrite the whole table. if where.nil? raise ForestAdminDatasourceToolkit::Exceptions::ForestException, - "Refusing to update every row of '#{table}': the filter carries no condition." + "Refusing to update every row of '#{names[:root]}': the filter carries no condition." end query = <<~GRAPHQL - mutation Update#{camelize(table)}($where: #{table}_bool_exp!, $set: #{table}_set_input!) { - update_#{table}(where: $where, _set: $set) { + mutation Update#{camelize(names[:base])}($where: #{names[:base]}_bool_exp!, $set: #{names[:base]}_set_input!) { + update_#{names[:base]}(where: $where, _set: $set) { affected_rows } } @@ -68,10 +74,10 @@ def update(table, filter, patch) { query: query, variables: { 'where' => where, 'set' => stringify_keys(patch) } } end - def delete(table, filter) + def delete(names, filter) query = <<~GRAPHQL - mutation Delete#{camelize(table)}($where: #{table}_bool_exp!) { - delete_#{table}(where: $where) { + mutation Delete#{camelize(names[:base])}($where: #{names[:base]}_bool_exp!) { + delete_#{names[:base]}(where: $where) { affected_rows } } @@ -84,7 +90,7 @@ def delete(table, filter) # extra_where is a raw bool_exp and-combined with the converted filter # (the null-bucket query adds `{ fk => { _is_null => true } }`). - def aggregate(table, filter, aggregation, extra_where: nil) + def aggregate(names, filter, aggregation, extra_where: nil) args = [] var_defs = [] variables = {} @@ -92,14 +98,14 @@ def aggregate(table, filter, aggregation, extra_where: nil) where = combine(FilterConverter.convert(filter.condition_tree), extra_where) if where - var_defs << "$where: #{table}_bool_exp" + var_defs << "$where: #{names[:base]}_bool_exp" args << 'where: $where' variables['where'] = where end query = <<~GRAPHQL - query Aggregate#{camelize(table)}#{wrap(var_defs)} { - #{table}_aggregate#{wrap(args)} { + query Aggregate#{camelize(names[:base])}#{wrap(var_defs)} { + #{names[:base]}_aggregate#{wrap(args)} { aggregate { #{aggregation_selection(aggregation)} } @@ -115,7 +121,7 @@ def aggregate(table, filter, aggregation, extra_where: nil) # offset pagination is stable, and filtered by the chart's predicate through # the relationship, so the pages only walk parents owning at least one # matching child row. - def grouped_aggregate(child_table, relation, filter, aggregation, page) + def grouped_aggregate(names, relation, filter, aggregation, page) args = [] var_defs = ['$parentLimit: Int', '$parentOffset: Int'] variables = { 'parentLimit' => page[:limit], 'parentOffset' => page[:offset] } @@ -124,7 +130,7 @@ def grouped_aggregate(child_table, relation, filter, aggregation, page) where = FilterConverter.convert(filter.condition_tree) if where - var_defs << "$where: #{child_table}_bool_exp" + var_defs << "$where: #{names[:base]}_bool_exp" args << 'where: $where' parent_args << "where: { #{relation[:relation_name]}: $where }" variables['where'] = where @@ -149,15 +155,15 @@ def grouped_aggregate(child_table, relation, filter, aggregation, page) # Distinct values of `column` among rows without a matching parent — the # dangling foreign keys a grouped chart must keep as groups of their own. # distinct_on requires the matching order_by. - def orphan_keys(table, filter, column, relation_name, limit) + def orphan_keys(names, filter, column, relation_name, limit) where = combine( FilterConverter.convert(filter.condition_tree), { '_and' => [{ '_not' => { relation_name => {} } }, { column => { '_is_null' => false } }] } ) query = <<~GRAPHQL - query OrphanKeys#{camelize(table)}($where: #{table}_bool_exp, $limit: Int) { - #{table}(where: $where, distinct_on: [#{column}], order_by: [{ #{column}: asc }], limit: $limit) { + query OrphanKeys#{camelize(names[:root])}($where: #{names[:base]}_bool_exp, $limit: Int) { + #{names[:root]}(where: $where, distinct_on: [#{column}], order_by: [{ #{column}: asc }], limit: $limit) { #{column} } } @@ -183,10 +189,10 @@ def aggregation_selection(aggregation) private - def add_sort(table, filter, args, var_defs, variables) + def add_sort(names, filter, args, var_defs, variables) return unless filter.respond_to?(:sort) && filter.sort&.any? - var_defs << "$orderBy: [#{table}_order_by!]" + var_defs << "$orderBy: [#{names[:base]}_order_by!]" args << 'order_by: $orderBy' variables['orderBy'] = convert_sort(filter.sort) end diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb index fff39ffb3..e8777f06c 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb @@ -310,6 +310,28 @@ def last_graphql_request .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /not a column/) end + # `membership:comments` would emit a relation as a leaf selection. + it 'rejects a group path ending on a relation with a clear error' do + aggregation = toolkit_query::Aggregation.new(operation: 'Count', + groups: [{ field: 'membership:comments' }]) + + expect { comments.aggregate(caller, filter, aggregation) } + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /not a column/) + end + + # Charting 0 in place of a value the wire format hid would be silently + # wrong data. + it 'raises on an aggregate value that cannot be read as a number' do + BankingSchema.stub_graphql_data( + { 'comments_aggregate' => { 'aggregate' => { 'sum' => { 'id' => 'NaN' }, 'row_count' => 2 } } } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Sum', field: 'id') + + expect { comments.aggregate(caller, filter, aggregation) } + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /Non-numeric/) + end + it 'merges a Max over text lexically instead of keeping the first row' do BankingSchema.stub_graphql_data( { From e2653abb1e90ec8bc1a4764bb94b94b82281ad3a Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 17:42:17 +0200 Subject: [PATCH 18/26] fix(datasource graphql hasura): harden introspection against renames, collisions and composite keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crossed custom_root_fields renames could make one table's type name shadow another table's root field in the shared lookup map, silently mixing their class names, collections and polymorphic pairings: the converter now keeps a root index and a type index and each lookup uses the spelling it holds - the polymorphic discriminators were locked read-only even when they belong to a composite primary key (Rails taggings), recreating the impossible-to- create table the composite-key carve-out had just fixed — and their Present validation now goes away with the lock, instead of demanding a value the user cannot type in - polymorphism detection runs after collection-name deduplication, so a polymorphic target can never carry the primary key of a dropped table - custom_root_fields.select in its object form ({ name:, comment: }) no longer derails the metadata keying, a custom-named select_by_pk root is a lookup rather than a second unlistable collection, and the graphql-default naming convention is matched by registering each mapping under both the Postgres and the camelized spellings, columns included - polymorphic_relations accepts the type name like type_values does, and a relationship whose name collides with an existing field logs a warning instead of vanishing silently Co-Authored-By: Claude Fable 5 --- .../README.md | 8 +- .../datasource.rb | 3 + .../introspection/introspector.rb | 50 +++-- .../introspection/polymorphism_detector.rb | 17 +- .../introspection/schema_converter.rb | 60 ++++-- .../introspector_detection_spec.rb | 200 +++++++++++++++++- 6 files changed, 303 insertions(+), 35 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/README.md b/packages/forest_admin_datasource_graphql_hasura/README.md index ec52d9909..ae3ddaa4f 100644 --- a/packages/forest_admin_datasource_graphql_hasura/README.md +++ b/packages/forest_admin_datasource_graphql_hasura/README.md @@ -97,7 +97,13 @@ type_values: { 'bank_accounts' => 'Banking::Account' } `jsonb`), never related records. - A `*_type` value matching no exposed collection (a legacy STI subclass name, an excluded target) leaves the reference empty and logs a warning, rather than failing the page. -- `bytea` columns are surfaced as text (Hasura returns them hex-encoded). +- `bytea` and `money` columns are surfaced as text (Hasura returns them hex-encoded and in + Postgres money form respectively). +- Customized root fields (`custom_root_fields`, `custom_name`) and the `graphql-default` + naming convention are followed for introspection, metadata matching and query generation. + One gap: Rails polymorphism *detection* relies on snake_case `_type`/`_id` + column pairs, so camelized columns need the `polymorphic_relations` option. Renamed + aggregate or mutation root fields (`select_aggregate`, `insert`, …) are not supported. - Errors Hasura returns (a permission rule, an invalid value) surface as HTTP 400 with the original message; an unreachable endpoint (timeout, DNS, TLS, non-2xx response) surfaces as HTTP 503, so infrastructure incidents stay visible to monitoring. diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb index ca8233fbd..220bdcb10 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb @@ -16,6 +16,9 @@ def initialize(uri:, **options) def register_collections tables = Introspection::Introspector.new(@client, @configuration).introspect tables = deduplicate_collection_names(tables) + # Detection runs on the surviving tables only, so a polymorphic target + # can never carry the primary key of a table dedup dropped. + Introspection::PolymorphismDetector.new(@configuration).detect(tables) converter = Introspection::SchemaConverter.new(tables, @configuration) tables.each do |table| diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb index e2833bf1e..8dee3b6e7 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb @@ -60,7 +60,9 @@ class Introspector TEXT_TYPES = %w[String ID text varchar char bpchar citext bytea].to_set.freeze EXCLUDED_PREFIXES = %w[__ hdb_ pg_ information_schema].freeze - EXCLUDED_SUFFIXES = %w[_aggregate _by_pk _stream _connection].freeze + # Camel variants cover the graphql-default naming convention; a genuine + # Postgres table name is lowercase and cannot end with them. + EXCLUDED_SUFFIXES = %w[_aggregate _by_pk _stream _connection Aggregate ByPk Stream Connection].freeze def initialize(client, configuration) @client = client @@ -76,10 +78,7 @@ def introspect @relationship_mappings = metadata ? safe_relationship_mappings(metadata) : {} @primary_keys = parse_primary_keys(query_fields) - tables = parse_tables(query_fields) - PolymorphismDetector.new(@configuration).detect(tables) - - tables + parse_tables(query_fields) end private @@ -156,21 +155,40 @@ def collect_table_mappings(table, mappings, ambiguous) entry = relationship_mapping(rel, kind) next if entry.nil? - key = "#{exposed}.#{rel["name"]}" - ambiguous << key if mappings.key?(key) && mappings[key] != entry - mappings[key] = entry + # The graphql-default naming convention camelizes root fields, + # relationship fields and columns, while the metadata keeps the + # Postgres spellings; registering both makes the lookup match — and + # carry column names in — whichever spelling introspection exposes. + # When both spellings coincide the snake entry stands: a wrong-case + # mapping degrades to a skipped relationship, never a wrong one. + snake_key = "#{exposed}.#{rel["name"]}" + camel_key = "#{exposed.camelize(:lower)}.#{rel["name"].camelize(:lower)}" + register_mapping(mappings, ambiguous, snake_key, entry) + register_mapping(mappings, ambiguous, camel_key, camelized_entry(entry)) unless camel_key == snake_key end end + def register_mapping(mappings, ambiguous, key, entry) + ambiguous << key if mappings.key?(key) && mappings[key] != entry + mappings[key] = entry + end + + def camelized_entry(entry) + mapping = entry[:mapping]&.to_h { |local, remote| [local&.camelize(:lower), remote&.camelize(:lower)] } + + { mapping: mapping, manual: entry[:manual] } + end + # The mapping key has to be the root field the introspection query will # show. Hasura derives it from the table name — prefixed by the schema # outside of `public`, so a bare name can only be the public table — # unless the metadata customizes it (`custom_root_fields.select` wins # over `custom_name`, which replaces the derived name). def exposed_root_field(table, table_info) - custom = table.dig('configuration', 'custom_root_fields', 'select') || - table.dig('configuration', 'custom_name') - return custom if custom + custom = table.dig('configuration', 'custom_root_fields', 'select') + custom = custom['name'] if custom.is_a?(Hash) + custom ||= table.dig('configuration', 'custom_name') + return custom if custom.is_a?(String) schema_name = table_info['schema'] table_name = table_info['name'] @@ -202,7 +220,7 @@ def relationship_mapping(rel, kind) # name from the `_by_pk` spelling would miss a customized select field. def parse_primary_keys(query_fields) query_fields.each_with_object({}) do |field, memo| - next unless field['name'].end_with?('_by_pk') + next unless field['name'].end_with?('_by_pk', 'ByPk') type_name = base_type_name(field['type']) pk_fields = (field['args'] || []).map { |arg| arg['name'] } @@ -214,6 +232,9 @@ def parse_tables(query_fields) query_fields.filter_map do |field| table_name = field['name'] next if skip_table?(table_name) + # Only the select root returns a list; a custom-named select_by_pk + # root returns the bare object and is not a table. + next unless array_type?(field['type']) type = @type_map[base_type_name(field['type'])] next unless type && type['kind'] == 'OBJECT' @@ -339,7 +360,10 @@ def map_column_type(graphql_type) { 'Int' => 'Number', 'Float' => 'Number', 'numeric' => 'Number', 'bigint' => 'Number', 'smallint' => 'Number', 'integer' => 'Number', 'real' => 'Number', - 'double_precision' => 'Number', 'money' => 'Number', + 'double_precision' => 'Number', + # Text, not Number: Hasura serializes money in its Postgres text form + # ("$1,100.00"), which no numeric aggregation can consume. + 'money' => 'String', # Internal Postgres names, which is how Hasura names array element types 'int2' => 'Number', 'int4' => 'Number', 'int8' => 'Number', 'float4' => 'Number', 'float8' => 'Number', 'bool' => 'Boolean', diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb index c1dc871ff..0a56c9781 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb @@ -14,8 +14,9 @@ def initialize(configuration) # Fills `polymorphics` on each table and removes the per-target object # relationships it absorbs. def detect(tables) - # Relationships reference the GraphQL type name, which only differs from - # the root field name when the Hasura metadata customizes root fields. + # Relationships reference the GraphQL type name; the merge order makes + # the type interpretation win when it collides with another table's + # root field name (crossed custom_root_fields renames). tables_by_name = tables.to_h { |table| [table.name, table] } .merge(tables.to_h { |table| [table.type_name, table] }) @@ -43,7 +44,7 @@ def absorb(table, base, tables_by_name) def bases_of(table) names = table.columns.map(&:name) - configured = @configuration.polymorphic_relations[table.name]&.keys || [] + configured = configured_relations(table).keys detected = names.filter_map do |name| base = name.delete_suffix('_type') @@ -67,7 +68,7 @@ def discriminators?(table, names, base) end def targets_of(table, base, tables_by_name) - configured_tables = @configuration.polymorphic_relations.dig(table.name, base) + configured_tables = configured_relations(table)[base] foreign_key = "#{base}_id" candidates = table.relationships.select do |rel| @@ -125,6 +126,14 @@ def branch?(relationship, foreign_key, configured_tables, tables_by_name) relationship.manual end + # Like type_values, the configuration accepts the root field name or the + # underlying type name. + def configured_relations(table) + @configuration.polymorphic_relations[table.name] || + @configuration.polymorphic_relations[table.type_name] || + {} + end + def class_name_of(table) @configuration.type_values[table.name] || @configuration.type_values[table.type_name] || diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb index 332605c81..5b7688c33 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb @@ -39,18 +39,20 @@ class SchemaConverter def initialize(tables, configuration) @tables = tables - # Relationships reference the GraphQL type name, which only differs from - # the root field name when the Hasura metadata customizes root fields. - @tables_by_name = tables.to_h { |table| [table.name, table] } - .merge(tables.to_h { |table| [table.type_name, table] }) + # Two indexes rather than one merged map: a table's type_name may equal + # another table's root field name (crossed custom_root_fields renames), + # and each lookup knows which spelling it holds. + @tables_by_root = tables.to_h { |table| [table.name, table] } + @tables_by_type = tables.to_h { |table| [table.type_name, table] } @configuration = configuration end # Rails stores the class name derived from the Postgres table, which the # GraphQL type name follows — not the root field, which custom_root_fields - # can rename freely. type_values accepts either name. + # can rename freely. Callers pass root field names, so only the root index + # is consulted; type_values accepts either name. def rails_class_name_of(table_name) - table = @tables_by_name[table_name] + table = @tables_by_root[table_name] @configuration.type_values[table_name] || (table && @configuration.type_values[table.type_name]) || @@ -74,7 +76,17 @@ def build_fields(table) table.relationships.each do |relationship| name, schema = convert_relationship(table, relationship) - fields[name] = schema if schema && !fields.key?(name) + next if schema.nil? + + if fields.key?(name) + ForestAdminDatasourceGraphqlHasura.logger.warn( + "[forest_admin_datasource_graphql_hasura] Relationship '#{name}' on '#{table.name}' " \ + 'shares its name with another field, which wins; rename one of them to surface both.' + ) + next + end + + fields[name] = schema end add_reverse_polymorphics(table, fields) @@ -84,6 +96,12 @@ def build_fields(table) private + # A relationship references the GraphQL type; the root-field spelling is + # accepted as a fallback, and on a collision the type interpretation wins. + def resolve_table(reference) + @tables_by_type[reference] || @tables_by_root[reference] + end + # A single-column key is database-generated in the schemas Hasura fronts # (serial, uuid default), and the writes persist explicit nils — which # would override that default — so it stays read-only. A composite key is @@ -121,8 +139,26 @@ def add_polymorphics(table, fields) end fields[polymorphic.name] = convert_polymorphic(polymorphic) - fields[polymorphic.foreign_key]&.is_read_only = true - fields[polymorphic.type_field]&.is_read_only = true + lock_discriminators(table, polymorphic, fields) + end + end + + # The widget drives the discriminator columns, so they are read-only and + # cannot carry a Present validation the user could never satisfy. The + # exception is a discriminator belonging to a composite primary key (a + # Rails taggings table): locking it would make the row impossible to + # create, which the composite-key carve-out of convert_column exists to + # prevent. + def lock_discriminators(table, polymorphic, fields) + composite = table.primary_key.size > 1 + + [polymorphic.foreign_key, polymorphic.type_field].each do |column| + field = fields[column] + next if field.nil? + next if composite && table.primary_key.include?(column) + + field.is_read_only = true + field.validation = [] end end @@ -146,7 +182,7 @@ def convert_relationship(table, relationship) end def convert_object_relationship(table, relationship) - remote = @tables_by_name[relationship.remote_table] + remote = resolve_table(relationship.remote_table) # A relation towards a table the datasource does not expose (excluded, or # dropped for want of a primary key) breaks schema generation at boot. @@ -174,7 +210,7 @@ def convert_object_relationship(table, relationship) end def convert_array_relationship(table, relationship) - remote = @tables_by_name[relationship.remote_table] + remote = resolve_table(relationship.remote_table) return [relationship.name, nil] unless remote return [relationship.name, nil] if covered_by_reverse_polymorphic?(table, relationship, remote) return [relationship.name, nil] unless single_column_mapping?(table, relationship) @@ -255,7 +291,7 @@ def add_reverse_polymorphics(table, fields) def reverse_polymorphic_name(table, child, polymorphic, fields) array_relationship = table.relationships.find do |rel| - rel.kind == :array && @tables_by_name[rel.remote_table] == child && reverse_of?(rel, table, polymorphic) + rel.kind == :array && resolve_table(rel.remote_table) == child && reverse_of?(rel, table, polymorphic) end candidates = [array_relationship&.name, child.name, "#{child.name}_#{polymorphic.name}"].compact.uniq diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb index d8a3575b5..cb23ede9c 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb @@ -422,10 +422,7 @@ def build_datasource(**options) end describe 'customized root fields' do - # The root select field is renamed to `people`, but relationships and the - # `_by_pk` query keep referencing the `person_table` GraphQL type: metadata - # and primary keys must follow what the schema actually exposes. - it 'applies the metadata and detects the primary key through the GraphQL type' do + def stub_people_schema(select_config: 'people') stub_schema( [ { @@ -446,12 +443,19 @@ def build_datasource(**options) stub_metadata( [ { 'table' => { 'schema' => 'public', 'name' => 'person_table' }, - 'configuration' => { 'custom_root_fields' => { 'select' => 'people' } }, + 'configuration' => { 'custom_root_fields' => { 'select' => select_config } }, 'object_relationships' => [ manual_object_rel('best_friend', 'person_table', { 'best_friend_ref' => 'id' }) ] } ] ) + end + + # The root select field is renamed to `people`, but relationships and the + # `_by_pk` query keep referencing the `person_table` GraphQL type: metadata + # and primary keys must follow what the schema actually exposes. + it 'applies the metadata and detects the primary key through the GraphQL type' do + stub_people_schema # The Rails class name follows the underlying type (person_table), not # the renamed root field (people). @@ -462,6 +466,192 @@ def build_datasource(**options) expect(fields['best_friend'].foreign_key).to eq('best_friend_ref') expect(fields['best_friend'].foreign_collection).to eq('PersonTable') end + + it 'accepts the object form of custom_root_fields.select' do + stub_people_schema(select_config: { 'name' => 'people', 'comment' => 'renamed' }) + + field = build_datasource.get_collection('PersonTable').schema[:fields]['best_friend'] + + expect(field.foreign_key).to eq('best_friend_ref') + end + + # Only the select root field is renamed: every other generated name — + # `_bool_exp`, `insert_`, `_aggregate` — still derives + # from the type. + it 'derives type names and mutation roots from the type, the list root from the field' do + stub_people_schema + collection = build_datasource.get_collection('PersonTable') + + BankingSchema.stub_graphql_data( + { 'people' => [] }, + { 'insert_person_table' => { 'returning' => [{ 'id' => 1 }] } } + ) + + toolkit = ForestAdminDatasourceToolkit::Components::Query + condition = toolkit::ConditionTree::Nodes::ConditionTreeLeaf.new('id', 'equal', 1) + collection.list(nil, toolkit::Filter.new(condition_tree: condition), toolkit::Projection.new(['id'])) + record = collection.create(nil, { 'best_friend_ref' => 1 }) + + expect(record).to eq({ 'id' => 1 }) + queries = [] + expect(WebMock).to(have_requested(:post, BankingSchema::GRAPHQL_URI).at_least_once.with do |req| + queries << JSON.parse(req.body)['query'] unless req.body.include?('IntrospectSchema') + true + end) + expect(queries[0]).to include('$where: person_table_bool_exp') + expect(queries[0]).to include('people(where: $where)') + expect(queries[1]).to include('insert_person_table(objects: $objects)') + end + + # A custom-named select_by_pk root returns the bare object: it is a lookup, + # not a second table, and must not shadow the real collection. + it 'does not mistake a custom-named by_pk root field for a table' do + stub_schema( + [ + { 'name' => 'users', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + [ + list_query('users'), by_pk_query('users'), + { 'name' => 'user', 'type' => object('users'), + 'args' => [{ 'name' => 'id', 'type' => non_null(scalar('bigint')) }] } + ] + ) + stub_metadata([]) + + datasource = build_datasource + + expect(datasource.collections.keys).to eq(['User']) + expect(datasource.get_collection('User').table_name).to eq('users') + end + + # Crossed renames: table A's type name equals table B's root field name. + # Each collection must still classify from its own underlying type. + it 'does not let one table type shadow another table root field' do + stub_schema( + [ + { 'name' => 'accounts', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] }, + { 'name' => 'team_accounts', 'kind' => 'OBJECT', + 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + [ + field('accounts_list', non_null(list_of(non_null(object('accounts'))))), + by_pk_query('accounts'), + field('accounts', non_null(list_of(non_null(object('team_accounts'))))), + by_pk_query('team_accounts') + ] + ) + stub_metadata( + [ + { 'table' => { 'schema' => 'public', 'name' => 'accounts' }, + 'configuration' => { 'custom_root_fields' => { 'select' => 'accounts_list' } } }, + { 'table' => { 'schema' => 'public', 'name' => 'team_accounts' }, + 'configuration' => { 'custom_root_fields' => { 'select' => 'accounts' } } } + ] + ) + + datasource = build_datasource + + expect(datasource.collections.keys.sort).to eq(%w[Account TeamAccount]) + expect(datasource.get_collection('TeamAccount').table_name).to eq('accounts') + expect(datasource.get_collection('Account').table_name).to eq('accounts_list') + end + + # The graphql-default naming convention camelizes root and relationship + # fields; the metadata keying must match either spelling, and ByPk-suffixed + # roots still drive primary key detection. + it 'matches camelized root and relationship fields' do + stub_schema( + [ + { + 'name' => 'userAddresses', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('ownerRef', scalar('bigint')), + field('bestFriend', object('userAddresses')) + ] + } + ], + [ + field('userAddresses', non_null(list_of(non_null(object('userAddresses'))))), + { 'name' => 'userAddressesByPk', 'type' => object('userAddresses'), + 'args' => [{ 'name' => 'id', 'type' => non_null(scalar('bigint')) }] } + ] + ) + stub_metadata( + [ + { 'table' => { 'schema' => 'public', 'name' => 'user_addresses' }, + 'configuration' => { 'custom_root_fields' => { 'select' => 'userAddresses' } }, + 'object_relationships' => [ + manual_object_rel('best_friend', 'user_addresses', { 'owner_ref' => 'id' }) + ] } + ] + ) + + fields = build_datasource.get_collection('UserAddress').schema[:fields] + + expect(fields['id'].is_primary_key).to be(true) + expect(fields['bestFriend'].type).to eq('ManyToOne') + # The mapping columns are translated along with the key. + expect(fields['bestFriend'].foreign_key).to eq('ownerRef') + end + end + + describe 'a polymorphic association inside a composite primary key' do + # Rails taggings: (tag_id, taggable_id, taggable_type) composite key with a + # polymorphic taggable. Locking the discriminators read-only would make the + # table impossible to create through. + it 'keeps the discriminator key members writable' do + stub_schema( + [ + { + 'name' => 'taggings', 'kind' => 'OBJECT', + 'fields' => [ + field('tag_id', non_null(scalar('bigint'))), + field('taggable_type', non_null(scalar('String'))), + field('taggable_id', non_null(scalar('bigint'))), + field('transfer', object('transfers')) + ] + }, + { 'name' => 'transfers', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + [list_query('taggings'), by_pk_query('taggings', %w[tag_id taggable_id taggable_type]), + list_query('transfers'), by_pk_query('transfers')] + ) + stub_metadata( + [{ 'table' => { 'schema' => 'public', 'name' => 'taggings' }, + 'object_relationships' => [manual_object_rel('transfer', 'transfers', { 'taggable_id' => 'id' })] }] + ) + + fields = build_datasource.get_collection('Tagging').schema[:fields] + + expect(fields['taggable'].type).to eq('PolymorphicManyToOne') + expect(fields['taggable_id'].is_read_only).to be(false) + expect(fields['taggable_type'].is_read_only).to be(false) + end + + it 'locks non-key discriminators and clears their validation' do + fields = BankingSchema.build_datasource.get_collection('Comment').schema[:fields] + + expect(fields['commentable_id'].is_read_only).to be(true) + # A read-only field must not demand a value the user cannot type in. + expect(fields['commentable_id'].validation).to eq([]) + expect(fields['commentable_type'].validation).to eq([]) + end + end + + describe 'money columns' do + # Hasura serializes money in its Postgres text form ("$1,100.00"): as a + # Number it would aggregate to silently wrong charts. + it 'surfaces money as text' do + stub_schema( + [{ 'name' => 'invoices', 'kind' => 'OBJECT', + 'fields' => [field('id', non_null(scalar('bigint'))), field('total', scalar('money'))] }], + [list_query('invoices'), by_pk_query('invoices')] + ) + stub_metadata([]) + + expect(build_datasource.get_collection('Invoice').schema[:fields]['total'].column_type).to eq('String') + end end describe 'configured polymorphism without the Hasura metadata' do From 28caa2d05e69019e443c71565ac82fee031c9399 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 17:49:21 +0200 Subject: [PATCH 19/26] fix(datasource graphql hasura): materialize nested polymorphics and ignore a bare _type column build_selection walks relations recursively, but materialization stopped at the top level: a polymorphic reached through an ordinary relation (or through a PolymorphicOneToMany's rows) came back without the placeholder the serializer reads. Nested records now delegate to their own collection, mirroring the selection walk, hashes and arrays alike. A column literally named `_type` next to an `_id` column produced an empty detection base, emitting an unnamed polymorphic association that absorbed whatever relationship joins through `_id`. An empty base is no base. Co-Authored-By: Claude Fable 5 --- .../collection.rb | 51 +++++++++++++------ .../introspection/polymorphism_detector.rb | 4 +- .../collection_spec.rb | 18 +++++++ .../introspector_detection_spec.rb | 29 +++++++++++ 4 files changed, 86 insertions(+), 16 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb index da692ade3..73451f99a 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb @@ -103,6 +103,29 @@ def build_selection(projection) selection.empty? ? Array(@table.primary_key.first || column_names.first) : selection end + # The serializer reads the reference off the discriminator columns, so the + # relation key only carries a placeholder — which has to be non-empty, since + # an empty hash drops the relation from the JSON:API payload. A type matching + # no exposed collection stays unresolved: the serializer looks the collection + # up by that raw value and would fail the whole page. Nested records walk + # down to their own collection, mirroring build_selection: a projection can + # reach a polymorphic relation through an ordinary one. Protected, like + # build_selection, so target collections can be delegated to. + def materialize_polymorphics(record, projection) + projection.relations.each do |relation_name, relation_projection| + field = schema[:fields][relation_name] + next if field.nil? + + if field.type == 'PolymorphicManyToOne' + materialize_placeholder(record, relation_name, field) + else + materialize_nested(record, relation_name, field, relation_projection) + end + end + + record + end + private def column_names @@ -115,24 +138,22 @@ def writable_columns(data) data.select { |key, _| column_names.include?(key.to_s) } end - # The serializer reads the reference off the discriminator columns, so the - # relation key only carries a placeholder — which has to be non-empty, since - # an empty hash drops the relation from the JSON:API payload. A type matching - # no exposed collection stays unresolved: the serializer looks the collection - # up by that raw value and would fail the whole page. - def materialize_polymorphics(record, projection) - projection.relations.each_key do |relation_name| - field = schema[:fields][relation_name] - next unless field&.type == 'PolymorphicManyToOne' + def materialize_placeholder(record, relation_name, field) + type_value = record[field.foreign_key_type_field] + resolvable = type_value && field.foreign_key_targets.key?(type_value.to_s.gsub('::', '__')) + warn_unknown_type(relation_name, type_value) if type_value && !resolvable + + record[relation_name] = resolvable && record[field.foreign_key] ? PLACEHOLDER_REFERENCE : nil + end - type_value = record[field.foreign_key_type_field] - resolvable = type_value && field.foreign_key_targets.key?(type_value.to_s.gsub('::', '__')) - warn_unknown_type(relation_name, type_value) if type_value && !resolvable + def materialize_nested(record, relation_name, field, relation_projection) + nested = record[relation_name] + target = datasource.get_collection(field.foreign_collection) - record[relation_name] = resolvable && record[field.foreign_key] ? PLACEHOLDER_REFERENCE : nil + case nested + when Hash then target.materialize_polymorphics(nested, relation_projection) + when Array then nested.each { |row| target.materialize_polymorphics(row, relation_projection) } end - - record end def warn_unknown_type(relation_name, type_value) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb index 0a56c9781..f5e35bddf 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb @@ -48,7 +48,9 @@ def bases_of(table) detected = names.filter_map do |name| base = name.delete_suffix('_type') - base if name.end_with?('_type') && names.include?("#{base}_id") + # A column literally named `_type` leaves an empty base, which would + # emit an unnamed association absorbing whatever joins through `_id`. + base if name.end_with?('_type') && !base.empty? && names.include?("#{base}_id") end (detected + configured.select { |base| discriminators?(table, names, base) }).uniq diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb index e8777f06c..6ae1e80e5 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb @@ -73,6 +73,24 @@ def last_graphql_request expect(records[1]['commentable']).to be_nil end + # A projection can reach a polymorphic relation through an ordinary one; + # the nested records need their placeholders too. + it 'materializes polymorphics on nested records' do + BankingSchema.stub_graphql_data( + { + 'transfers' => [ + { 'id' => 1, + 'comments' => [{ 'id' => 2, 'commentable_type' => 'Transfer', 'commentable_id' => 1 }] } + ] + } + ) + + transfers = datasource.get_collection('Transfer') + records = transfers.list(caller, filter, projection('id', 'comments:id', 'comments:commentable:*')) + + expect(records[0]['comments'][0]['commentable']).to eq({ '*' => nil }) + end + it 'resolves regular relations through Hasura nested selections' do BankingSchema.stub_graphql_data( { 'comments' => [{ 'id' => 1, 'membership' => { 'full_name' => 'Jane' } }] } diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb index cb23ede9c..feb8b4448 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb @@ -685,6 +685,35 @@ def stub_people_schema(select_config: 'people') end end + describe 'a column literally named _type' do + it 'does not detect an unnamed polymorphic base and keeps the relationship' do + stub_schema( + [ + { + 'name' => 'events', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('_type', scalar('String')), + field('_id', scalar('bigint')), + field('owner', object('owners')) + ] + }, + { 'name' => 'owners', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + [list_query('events'), by_pk_query('events'), list_query('owners'), by_pk_query('owners')] + ) + stub_metadata( + [{ 'table' => { 'schema' => 'public', 'name' => 'events' }, + 'object_relationships' => [manual_object_rel('owner', 'owners', { '_id' => 'id' })] }] + ) + + fields = build_datasource.get_collection('Event').schema[:fields] + + expect(fields).not_to have_key('') + expect(fields['owner'].type).to eq('ManyToOne') + end + end + describe 'a column named like the polymorphic association' do it 'keeps the physical column and skips the association' do stub_schema( From 36355b7606fd25023f87c00c624fb525b1295626 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 17:55:38 +0200 Subject: [PATCH 20/26] feat(datasource graphql hasura): follow renamed mutation and aggregate root fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The select root already followed custom_root_fields; the other operation roots were still derived from the type name, so a renamed insert, update, delete or select_aggregate root broke its operation with an unknown-field error. The metadata that declares those renames is already being read: every custom root field is now recorded and resolved onto the table, and each operation queries its own root — derived names remain the fallback when the metadata is unreachable. Type names (`_bool_exp`, `_insert_input`…) still derive from the type, which custom_root_fields does not touch. Co-Authored-By: Claude Fable 5 --- .../README.md | 9 +++--- .../collection.rb | 9 +++--- .../introspection/introspector.rb | 32 ++++++++++++++++++- .../introspection/structures.rb | 6 ++-- .../query/aggregator.rb | 4 +-- .../query/query_builder.rb | 18 +++++------ .../introspector_detection_spec.rb | 27 ++++++++++++++-- 7 files changed, 81 insertions(+), 24 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/README.md b/packages/forest_admin_datasource_graphql_hasura/README.md index ae3ddaa4f..e3d79ce9b 100644 --- a/packages/forest_admin_datasource_graphql_hasura/README.md +++ b/packages/forest_admin_datasource_graphql_hasura/README.md @@ -100,10 +100,11 @@ type_values: { 'bank_accounts' => 'Banking::Account' } - `bytea` and `money` columns are surfaced as text (Hasura returns them hex-encoded and in Postgres money form respectively). - Customized root fields (`custom_root_fields`, `custom_name`) and the `graphql-default` - naming convention are followed for introspection, metadata matching and query generation. - One gap: Rails polymorphism *detection* relies on snake_case `_type`/`_id` - column pairs, so camelized columns need the `polymorphic_relations` option. Renamed - aggregate or mutation root fields (`select_aggregate`, `insert`, …) are not supported. + naming convention are followed for introspection, metadata matching and query generation — + renamed mutation and aggregate roots included, as long as the metadata API is reachable to + declare them (unreachable metadata falls back to the derived names). One gap: Rails + polymorphism *detection* relies on snake_case `_type`/`_id` column pairs, so + camelized columns need the `polymorphic_relations` option. - Errors Hasura returns (a permission rule, an invalid value) surface as HTTP 400 with the original message; an unreachable endpoint (timeout, DNS, TLS, non-2xx response) surfaces as HTTP 503, so infrastructure incidents stay visible to monitoring. diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb index 73451f99a..ca84ec9a9 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb @@ -13,8 +13,9 @@ def initialize(datasource, table, client, converter) @table = table @table_name = table.name # root is the select root field; base (the GraphQL type name) is what - # every other generated name derives from. See Query::QueryBuilder. - @names = { root: table.name, base: table.type_name } + # generated type names derive from; the operation roots carry their own + # resolved names, custom or derived. See Query::QueryBuilder. + @names = { root: table.name, base: table.type_name }.merge(table.root_fields || {}) @client = client @converter = converter @@ -33,9 +34,9 @@ def list(_caller, filter, projection) def create(_caller, data) operation = Query::QueryBuilder.create(@names, [writable_columns(data)], column_names) - returning = execute(:create, operation).dig("insert_#{@names[:base]}", 'returning') + returning = execute(:create, operation).dig(@names[:insert], 'returning') - raise GraphqlError, "No record returned by insert_#{@names[:base]}" if returning.nil? || returning.empty? + raise GraphqlError, "No record returned by #{@names[:insert]}" if returning.nil? || returning.empty? returning.first end diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb index 8dee3b6e7..1de77f1ae 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb @@ -75,6 +75,7 @@ def introspect metadata = @client.fetch_metadata @type_map = build_type_map(types) + @custom_root_fields = {} @relationship_mappings = metadata ? safe_relationship_mappings(metadata) : {} @primary_keys = parse_primary_keys(query_fields) @@ -109,6 +110,7 @@ def safe_relationship_mappings(metadata) '[forest_admin_datasource_graphql_hasura] Hasura metadata could not be parsed ' \ "(#{e.class}: #{e.message}); falling back to configuration and naming conventions." ) + @custom_root_fields = {} {} end @@ -147,6 +149,8 @@ def collect_table_mappings(table, mappings, ambiguous) return unless table_info.is_a?(Hash) exposed = exposed_root_field(table, table_info) + custom = normalized_root_fields(table) + @custom_root_fields[exposed] = custom if custom.any? relationships = (table['object_relationships'] || []).map { |rel| [rel, :object] } + (table['array_relationships'] || []).map { |rel| [rel, :array] } @@ -196,6 +200,18 @@ def exposed_root_field(table, table_info) schema_name.nil? || schema_name == 'public' ? table_name : "#{schema_name}_#{table_name}" end + # Every custom root field, values flattened to their string form (the + # metadata also allows { name:, comment: } objects). + def normalized_root_fields(table) + config = table.dig('configuration', 'custom_root_fields') + return {} unless config.is_a?(Hash) + + config.each_with_object({}) do |(operation, value), memo| + value = value['name'] if value.is_a?(Hash) + memo[operation] = value if value.is_a?(String) + end + end + # A nil column stands for the primary key of that table: a foreign key # constraint may reference any unique column, and which one is only # resolvable once the tables are parsed. @@ -275,10 +291,24 @@ def parse_table(table_name, type) columns: columns, primary_key: resolve_primary_key(type['name'], columns), relationships: relations.map { |field| parse_relationship(table_name, field) }, - polymorphics: [] + polymorphics: [], + root_fields: resolve_root_fields(table_name, type['name']) ) end + # The operation roots derive from the type name unless the metadata + # renames them — same resolution the select root already gets. + def resolve_root_fields(table_name, type_name) + custom = @custom_root_fields[table_name] || {} + + { + aggregate: custom['select_aggregate'] || "#{type_name}_aggregate", + insert: custom['insert'] || "insert_#{type_name}", + update: custom['update'] || "update_#{type_name}", + delete: custom['delete'] || "delete_#{type_name}" + } + end + # Introspection metadata and the `_aggregate` objects Hasura adds # next to every array relationship. The suffix alone is not enough: a scalar # column may legitimately be named `total_aggregate`. diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb index 9990da5ae..4457c8bf4 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb @@ -2,9 +2,11 @@ module ForestAdminDatasourceGraphqlHasura module Introspection # name is the root field records are queried through; type_name is the # GraphQL OBJECT type, which relationships reference. They only differ when - # the Hasura metadata customizes the root fields. + # the Hasura metadata customizes the root fields. root_fields resolves the + # other operation roots: { aggregate:, insert:, update:, delete: }, custom + # names applied when the metadata declares them, derived otherwise. Table = Struct.new(:name, :type_name, :columns, :primary_key, :relationships, :polymorphics, - keyword_init: true) + :root_fields, keyword_init: true) Column = Struct.new(:name, :type, :graphql_type, :nullable, :is_primary_key, :is_array, :is_text, keyword_init: true) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb index 972038708..796f41fc6 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb @@ -109,7 +109,7 @@ def collection_through(collection, relation_name, field) def simple(filter, aggregation) operation = QueryBuilder.aggregate(names, filter, aggregation) - data = @collection.execute(:aggregate, operation).dig("#{names[:base]}_aggregate", 'aggregate') + data = @collection.execute(:aggregate, operation).dig(names[:aggregate], 'aggregate') # One row even when the aggregate is null: the charts route reads # `result[0]['value']` unguarded. @@ -190,7 +190,7 @@ def add_orphan_groups(values, relation, filter, aggregation) def add_orphan_group(values, key, filter, aggregation, extra_where) operation = QueryBuilder.aggregate(names, filter, aggregation, extra_where: extra_where) - data = @collection.execute(:aggregate, operation).dig("#{names[:base]}_aggregate", 'aggregate') + data = @collection.execute(:aggregate, operation).dig(names[:aggregate], 'aggregate') return if childless?(data, aggregation) value = extract_value(data, aggregation) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb index 5232e973f..3dc62c966 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb @@ -3,11 +3,11 @@ module Query # Builds Hasura GraphQL operations (queries and mutations) with variables. # All methods return { query:, variables: }. # - # names is { root:, base: }: `root` is the select root field (which - # custom_root_fields can rename freely), `base` is what every other - # generated name derives from — `_bool_exp`, `insert_`, - # `_aggregate`… — namely the GraphQL type name. They only differ when - # the Hasura metadata renames the select root field. + # names is { root:, base:, aggregate:, insert:, update:, delete: }: `root` + # is the select root field, `base` (the GraphQL type name) is what the + # generated type names derive from — `_bool_exp`, + # `_insert_input`… — and the operation roots carry their resolved + # names, custom_root_fields applied when the metadata declares them. class QueryBuilder class << self # selection holds resolved GraphQL fields, nested relations included @@ -42,7 +42,7 @@ def list(names, filter, selection) def create(names, records, selection) query = <<~GRAPHQL mutation Insert#{camelize(names[:base])}($objects: [#{names[:base]}_insert_input!]!) { - insert_#{names[:base]}(objects: $objects) { + #{names[:insert]}(objects: $objects) { returning { #{selection.join("\n ")} } @@ -65,7 +65,7 @@ def update(names, filter, patch) query = <<~GRAPHQL mutation Update#{camelize(names[:base])}($where: #{names[:base]}_bool_exp!, $set: #{names[:base]}_set_input!) { - update_#{names[:base]}(where: $where, _set: $set) { + #{names[:update]}(where: $where, _set: $set) { affected_rows } } @@ -77,7 +77,7 @@ def update(names, filter, patch) def delete(names, filter) query = <<~GRAPHQL mutation Delete#{camelize(names[:base])}($where: #{names[:base]}_bool_exp!) { - delete_#{names[:base]}(where: $where) { + #{names[:delete]}(where: $where) { affected_rows } } @@ -105,7 +105,7 @@ def aggregate(names, filter, aggregation, extra_where: nil) query = <<~GRAPHQL query Aggregate#{camelize(names[:base])}#{wrap(var_defs)} { - #{names[:base]}_aggregate#{wrap(args)} { + #{names[:aggregate]}#{wrap(args)} { aggregate { #{aggregation_selection(aggregation)} } diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb index feb8b4448..58fb45286 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb @@ -422,7 +422,7 @@ def build_datasource(**options) end describe 'customized root fields' do - def stub_people_schema(select_config: 'people') + def stub_people_schema(select_config: 'people', extra_roots: {}) stub_schema( [ { @@ -443,7 +443,7 @@ def stub_people_schema(select_config: 'people') stub_metadata( [ { 'table' => { 'schema' => 'public', 'name' => 'person_table' }, - 'configuration' => { 'custom_root_fields' => { 'select' => select_config } }, + 'configuration' => { 'custom_root_fields' => { 'select' => select_config }.merge(extra_roots) }, 'object_relationships' => [ manual_object_rel('best_friend', 'person_table', { 'best_friend_ref' => 'id' }) ] } @@ -503,6 +503,29 @@ def stub_people_schema(select_config: 'people') expect(queries[1]).to include('insert_person_table(objects: $objects)') end + it 'follows renamed mutation and aggregate roots from the metadata' do + stub_people_schema(extra_roots: { 'insert' => 'createPerson', 'select_aggregate' => 'peopleStats' }) + collection = build_datasource.get_collection('PersonTable') + BankingSchema.stub_graphql_data( + { 'createPerson' => { 'returning' => [{ 'id' => 1 }] } }, + { 'peopleStats' => { 'aggregate' => { 'count' => 3, 'row_count' => 3 } } } + ) + + toolkit = ForestAdminDatasourceToolkit::Components::Query + record = collection.create(nil, { 'best_friend_ref' => 1 }) + result = collection.aggregate(nil, toolkit::Filter.new, toolkit::Aggregation.new(operation: 'Count')) + + expect(record).to eq({ 'id' => 1 }) + expect(result).to eq([{ 'value' => 3, 'group' => {} }]) + queries = [] + expect(WebMock).to(have_requested(:post, BankingSchema::GRAPHQL_URI).at_least_once.with do |req| + queries << JSON.parse(req.body)['query'] unless req.body.include?('IntrospectSchema') + true + end) + expect(queries[0]).to include('createPerson(objects: $objects)') + expect(queries[1]).to include('peopleStats') + end + # A custom-named select_by_pk root returns the bare object: it is a lookup, # not a second table, and must not shadow the real collection. it 'does not mistake a custom-named by_pk root field for a table' do From 695b769fc8142a6bc73787f6b731a357214c91f3 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 18:03:00 +0200 Subject: [PATCH 21/26] fix(datasource graphql hasura): merge grouped averages exactly and accept a false key value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avg raised whenever two parent rows shared a group value, failing a valid leaderboard chart. An average cannot be merged from averages, but its sum and non-null count can: both now ride along the aggregate selection, groups merge by adding them — SQL AVG over the union weights by count — and the division happens once the groups are final. The raise remains only for a response missing the aliases. materialize_placeholder gated the phantom on the truthiness of the foreign key, so a false value — legitimate on a boolean primary key — displayed as an empty reference. Only nil means "no reference". Co-Authored-By: Claude Fable 5 --- .../collection.rb | 4 +- .../query/aggregator.rb | 41 ++++++++++++++++--- .../query/query_builder.rb | 7 ++++ .../collection_spec.rb | 37 +++++++++++++++++ 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb index ca84ec9a9..c3306c52a 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb @@ -144,7 +144,9 @@ def materialize_placeholder(record, relation_name, field) resolvable = type_value && field.foreign_key_targets.key?(type_value.to_s.gsub('::', '__')) warn_unknown_type(relation_name, type_value) if type_value && !resolvable - record[relation_name] = resolvable && record[field.foreign_key] ? PLACEHOLDER_REFERENCE : nil + # An explicit nil check: false is a legitimate key value on a boolean + # primary key, absent is not. + record[relation_name] = resolvable && !record[field.foreign_key].nil? ? PLACEHOLDER_REFERENCE : nil end def materialize_nested(record, relation_name, field, relation_projection) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb index 796f41fc6..dfba8d2c7 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb @@ -122,10 +122,11 @@ def grouped(filter, aggregation, limit) values = collect_groups(fetch_parent_rows(relation, filter, aggregation), relation, aggregation) add_orphan_groups(values, relation, filter, aggregation) - results = values - .map { |key, value| { 'value' => value, 'group' => { group_field => key } } } - .sort_by { |row| comparable(row['value']) } - .reverse + rows = values.map do |key, value| + { 'value' => finalize_value(value, aggregation), 'group' => { group_field => key } } + end + results = rows.sort_by { |row| comparable(row['value']) }.reverse + limit ? results.first(limit) : results end @@ -159,7 +160,7 @@ def collect_groups(rows, relation, aggregation) data = row.dig("#{relation[:relation_name]}_aggregate", 'aggregate') next if childless?(data, aggregation) - value = extract_value(data, aggregation) + value = group_value(data, aggregation) key = row[relation[:parent_field]] memo[key] = memo.key?(key) ? merge_values(memo[key], value, aggregation) : value end @@ -193,10 +194,27 @@ def add_orphan_group(values, key, filter, aggregation, extra_where) data = @collection.execute(:aggregate, operation).dig(names[:aggregate], 'aggregate') return if childless?(data, aggregation) - value = extract_value(data, aggregation) + value = group_value(data, aggregation) values[key] = values.key?(key) ? merge_values(values[key], value, aggregation) : value end + # An average is carried as its sum and non-null count while groups merge — + # SQL AVG over the union weights by count, which the averages themselves + # cannot express — and divided once the groups are final. + def group_value(data, aggregation) + return extract_value(data, aggregation) unless aggregation.operation == 'Avg' && data.key?('avg_sum') + + raw_sum = data.dig('avg_sum', aggregation.field) + + { sum: raw_sum.nil? ? nil : numeric(raw_sum), count: data['avg_count'].to_i } + end + + def finalize_value(value, aggregation) + return value unless aggregation.operation == 'Avg' && value.is_a?(Hash) + + value[:count].zero? ? nil : numeric(value[:sum] || 0).fdiv(value[:count]) + end + # One extra key is requested so that exactly DANGLING_KEYS_LIMIT distinct # values complete instead of tripping the guard. def dangling_keys(relation, filter) @@ -225,6 +243,7 @@ def merge_values(current, value, aggregation) case aggregation.operation when 'Count', 'Sum' then add(current, value) + when 'Avg' then merge_averages(current, value, aggregation) when 'Max' then (comparable(value) <=> comparable(current)).positive? ? value : current when 'Min' then (comparable(value) <=> comparable(current)).negative? ? value : current else @@ -234,6 +253,16 @@ def merge_values(current, value, aggregation) end end + def merge_averages(current, value, aggregation) + unless current.is_a?(Hash) && value.is_a?(Hash) + raise ForestException, + "#{aggregation.operation} cannot be grouped on '#{name}' by a value several parent rows " \ + 'share: the result would not be exact. Group on the foreign key instead.' + end + + { sum: add(current[:sum] || 0, value[:sum] || 0), count: current[:count] + value[:count] } + end + # Hasura sends bigint and numeric as JSON strings to keep a precision a Float # would lose, so whole numbers are added as Integers, which Ruby does not cap. def add(current, value) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb index 3dc62c966..ab9608e5a 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb @@ -181,6 +181,13 @@ def aggregation_selection(aggregation) "#{operation.downcase} { #{aggregation.field} }" end + # An average cannot be merged across parent rows sharing a group + # value; its sum and non-null count can, weighting it exactly. + if operation == 'Avg' + selection += "\navg_sum: sum { #{aggregation.field} }" \ + "\navg_count: count(columns: #{aggregation.field})" + end + # row_count tells a group with no rows at all (SQL grouping omits it) # from one whose rows exist but hold NULL in the aggregated column # (SQL keeps it, at zero for a count and at NULL otherwise). diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb index 6ae1e80e5..fc14483da 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb @@ -73,6 +73,18 @@ def last_graphql_request expect(records[1]['commentable']).to be_nil end + # false is a legitimate key value on a boolean primary key; only nil means + # "no reference". + it 'materializes a phantom for a false foreign key value' do + BankingSchema.stub_graphql_data( + { 'comments' => [{ 'id' => 1, 'commentable_type' => 'Transfer', 'commentable_id' => false }] } + ) + + records = comments.list(caller, filter, projection('id', 'commentable:*')) + + expect(records[0]['commentable']).to eq({ '*' => nil }) + end + # A projection can reach a polymorphic relation through an ordinary one; # the nested records need their placeholders too. it 'materializes polymorphics on nested records' do @@ -422,6 +434,31 @@ def last_graphql_request ]) end + # SQL AVG over the union weights by row count: merging (10, 10) with (40) + # gives 20, not the average of averages (25). + it 'merges an Avg over shared groups as a weighted average' do + BankingSchema.stub_graphql_data( + { + 'memberships' => [ + { 'full_name' => 'Jane', + 'comments_aggregate' => { 'aggregate' => { + 'avg' => { 'id' => 10.0 }, 'avg_sum' => { 'id' => '20' }, 'avg_count' => 2, 'row_count' => 2 + } } }, + { 'full_name' => 'Jane', + 'comments_aggregate' => { 'aggregate' => { + 'avg' => { 'id' => 40.0 }, 'avg_sum' => { 'id' => '40' }, 'avg_count' => 1, 'row_count' => 1 + } } } + ] + } + ) + + aggregation = toolkit_query::Aggregation.new(operation: 'Avg', field: 'id', + groups: [{ field: 'membership:full_name' }]) + result = comments.aggregate(caller, filter, aggregation) + + expect(result).to eq([{ 'value' => 20.0, 'group' => { 'membership:full_name' => 'Jane' } }]) + end + # SQL Max ignores NULLs: a parent row whose values are all NULL must not win # the merge against a real value. it 'ignores null values when merging parents that share a group value' do From 19120779136f2fc02773b3fd5643260785a1e90d Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 18:34:00 +0200 Subject: [PATCH 22/26] refactor(datasource graphql hasura): flatten the qlty hotspots worth flattening - build_fields reads as the pipeline it is (columns, polymorphics, relationships, reverses), the shadow-warning loop moving to add_relationships - aggregation_selection splits its operation and Avg-merge parts - materialize_nested receives the nested value rather than digging it out - normalized_root_fields flattens then filters instead of accumulating The remaining qlty annotations are parameter-count and file-size metrics whose fix would be indirection for its own sake; they are addressed in the PR discussion. Co-Authored-By: Claude Fable 5 --- .../collection.rb | 5 ++- .../introspection/introspector.rb | 7 ++-- .../introspection/schema_converter.rb | 34 +++++++++--------- .../query/query_builder.rb | 35 ++++++++++--------- 4 files changed, 41 insertions(+), 40 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb index c3306c52a..4f126002e 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb @@ -120,7 +120,7 @@ def materialize_polymorphics(record, projection) if field.type == 'PolymorphicManyToOne' materialize_placeholder(record, relation_name, field) else - materialize_nested(record, relation_name, field, relation_projection) + materialize_nested(record[relation_name], field, relation_projection) end end @@ -149,8 +149,7 @@ def materialize_placeholder(record, relation_name, field) record[relation_name] = resolvable && !record[field.foreign_key].nil? ? PLACEHOLDER_REFERENCE : nil end - def materialize_nested(record, relation_name, field, relation_projection) - nested = record[relation_name] + def materialize_nested(nested, field, relation_projection) target = datasource.get_collection(field.foreign_collection) case nested diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb index 1de77f1ae..0f158a17b 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb @@ -206,10 +206,9 @@ def normalized_root_fields(table) config = table.dig('configuration', 'custom_root_fields') return {} unless config.is_a?(Hash) - config.each_with_object({}) do |(operation, value), memo| - value = value['name'] if value.is_a?(Hash) - memo[operation] = value if value.is_a?(String) - end + flattened = config.transform_values { |value| value.is_a?(Hash) ? value['name'] : value } + + flattened.select { |_, value| value.is_a?(String) } end # A nil column stands for the primary key of that table: a foreign key diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb index 5b7688c33..c5d523364 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb @@ -73,22 +73,7 @@ def build_fields(table) end add_polymorphics(table, fields) - - table.relationships.each do |relationship| - name, schema = convert_relationship(table, relationship) - next if schema.nil? - - if fields.key?(name) - ForestAdminDatasourceGraphqlHasura.logger.warn( - "[forest_admin_datasource_graphql_hasura] Relationship '#{name}' on '#{table.name}' " \ - 'shares its name with another field, which wins; rename one of them to surface both.' - ) - next - end - - fields[name] = schema - end - + add_relationships(table, fields) add_reverse_polymorphics(table, fields) fields @@ -173,6 +158,23 @@ def convert_polymorphic(polymorphic) ) end + def add_relationships(table, fields) + table.relationships.each do |relationship| + name, schema = convert_relationship(table, relationship) + next if schema.nil? + + if fields.key?(name) + ForestAdminDatasourceGraphqlHasura.logger.warn( + "[forest_admin_datasource_graphql_hasura] Relationship '#{name}' on '#{table.name}' " \ + 'shares its name with another field, which wins; rename one of them to surface both.' + ) + next + end + + fields[name] = schema + end + end + def convert_relationship(table, relationship) if relationship.kind == :object convert_object_relationship(table, relationship) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb index ab9608e5a..9a703efcc 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb @@ -172,26 +172,27 @@ def orphan_keys(names, filter, column, relation_name, limit) { query: query, variables: { 'where' => where, 'limit' => limit } } end + # row_count tells a group with no rows at all (SQL grouping omits it) + # from one whose rows exist but hold NULL in the aggregated column + # (SQL keeps it, at zero for a count and at NULL otherwise). def aggregation_selection(aggregation) - operation = aggregation.operation - - selection = if operation == 'Count' - aggregation.field ? "count(columns: #{aggregation.field})" : 'count' - else - "#{operation.downcase} { #{aggregation.field} }" - end - - # An average cannot be merged across parent rows sharing a group - # value; its sum and non-null count can, weighting it exactly. - if operation == 'Avg' - selection += "\navg_sum: sum { #{aggregation.field} }" \ - "\navg_count: count(columns: #{aggregation.field})" + "#{operation_selection(aggregation)}#{avg_merge_selection(aggregation)}\nrow_count: count" + end + + def operation_selection(aggregation) + if aggregation.operation == 'Count' + aggregation.field ? "count(columns: #{aggregation.field})" : 'count' + else + "#{aggregation.operation.downcase} { #{aggregation.field} }" end + end + + # An average cannot be merged across parent rows sharing a group value; + # its sum and non-null count can, weighting it exactly. + def avg_merge_selection(aggregation) + return '' unless aggregation.operation == 'Avg' - # row_count tells a group with no rows at all (SQL grouping omits it) - # from one whose rows exist but hold NULL in the aggregated column - # (SQL keeps it, at zero for a count and at NULL otherwise). - "#{selection}\nrow_count: count" + "\navg_sum: sum { #{aggregation.field} }\navg_count: count(columns: #{aggregation.field})" end private From 8e20113b3dab9c95af67e8cb3b5df89895929b92 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 5 Aug 2026 18:38:47 +0200 Subject: [PATCH 23/26] refactor(datasource graphql hasura): extract the shadowed-relationship guard The complexity annotation followed the loop into add_relationships; the warn-and-skip guard reads better as shadowed_relationship?, mirroring the detector's ambiguous_branch?. Co-Authored-By: Claude Fable 5 --- .../introspection/schema_converter.rb | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb index c5d523364..57ef66115 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb @@ -161,20 +161,22 @@ def convert_polymorphic(polymorphic) def add_relationships(table, fields) table.relationships.each do |relationship| name, schema = convert_relationship(table, relationship) - next if schema.nil? - - if fields.key?(name) - ForestAdminDatasourceGraphqlHasura.logger.warn( - "[forest_admin_datasource_graphql_hasura] Relationship '#{name}' on '#{table.name}' " \ - 'shares its name with another field, which wins; rename one of them to surface both.' - ) - next - end + next if schema.nil? || shadowed_relationship?(table, name, fields) fields[name] = schema end end + def shadowed_relationship?(table, name, fields) + return false unless fields.key?(name) + + ForestAdminDatasourceGraphqlHasura.logger.warn( + "[forest_admin_datasource_graphql_hasura] Relationship '#{name}' on '#{table.name}' " \ + 'shares its name with another field, which wins; rename one of them to surface both.' + ) + true + end + def convert_relationship(table, relationship) if relationship.kind == :object convert_object_relationship(table, relationship) From 4d9d5fa1f8c8448094a89160d61dab170dce561d Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 6 Aug 2026 12:12:35 +0200 Subject: [PATCH 24/26] fix(datasource graphql hasura): let semantic-release actually bump the version version.rb used single quotes and .freeze, which the release sed (VERSION = ".*", double quotes) would never match: the package would have shipped frozen at 1.0.0 forever. Aligned on the repo convention (double quotes, no .freeze) and added the file to the same rubocop exclusions as every other version.rb. Spotted by @matthv. Co-Authored-By: Claude Fable 5 --- .rubocop.yml | 2 ++ .../lib/forest_admin_datasource_graphql_hasura/version.rb | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.rubocop.yml b/.rubocop.yml index 685b5cc26..6458bc528 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -119,6 +119,7 @@ Style/FrozenStringLiteralComment: Style/MutableConstant: Exclude: - 'lib/agent_ruby/version.rb' + - 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/version.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/version.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/schema_emitter.rb' - 'packages/forest_admin_rails/lib/forest_admin_rails/version.rb' @@ -140,6 +141,7 @@ Style/MutableConstant: Style/StringLiterals: Exclude: - 'agent_ruby.gemspec' + - 'packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/version.rb' - 'Gemfile' - 'Rakefile' - 'bin/console' diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/version.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/version.rb index aba24225d..aef3fc5df 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/version.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/version.rb @@ -1,3 +1,3 @@ module ForestAdminDatasourceGraphqlHasura - VERSION = '1.0.0'.freeze + VERSION = "1.0.0" end From 81e518aa064492cabfc0ee0efb04bba331217b51 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 6 Aug 2026 12:12:35 +0200 Subject: [PATCH 25/26] fix(datasource graphql hasura): address the human review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From @matthv's review: - every metadata fallback in Client#fetch_metadata now warns with its actual cause (HTTP status, missing sources with the top-level keys, transport failure, underivable uri): each one silently disables polymorphism detection in production and deserves something to grep for - the broad rescues are narrowed to what they are meant to absorb — transport errors in the client, shape errors (TypeError, NoMethodError, KeyError) around the metadata parsing — so a genuine bug fails loudly instead of being relabeled "metadata unavailable" - the name-based EXCLUDED_SUFFIXES are gone: a real table named data_stream or bank_connection was silently dropped. The structural list-shape check already rejects _aggregate/_by_pk/_connection roots, and _stream companions are recognized by their base root field existing - excluded_tables/included_tables now match the underlying table name as well as the exposed root field — an exclusion must hold under custom_root_fields renaming, or it silently re-exposes data — and both options are validated as arrays (a String would have become a substring check) - the untested guards get their specs: relationships towards unexposed tables, a metadata mapping genuinely claimed twice, and the three polymorphic name-collision fallbacks including the all-candidates-taken path Co-Authored-By: Claude Fable 5 --- .../client.rb | 38 ++-- .../configuration.rb | 17 +- .../introspection/introspector.rb | 38 ++-- .../client_spec.rb | 7 + .../introspector_detection_spec.rb | 176 ++++++++++++++++++ 5 files changed, 246 insertions(+), 30 deletions(-) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb index bacd35916..47240ce15 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb @@ -48,35 +48,45 @@ def execute(query, variables = {}) # Returns nil when the endpoint is unreachable or forbidden, which is common # in production: introspection then falls back to the configuration and to - # naming conventions. + # naming conventions. Every fallback branch warns — losing the metadata + # silently disables polymorphism detection and custom root field + # resolution, and each cause deserves something to grep for. def fetch_metadata if @configuration.metadata_uri.nil? - ForestAdminDatasourceGraphqlHasura.logger.info( - '[forest_admin_datasource_graphql_hasura] No metadata endpoint could be derived from uri ' \ - "(no '/v1/graphql' segment); set the 'metadata_uri' option to enable relationship detection." - ) - return nil + return metadata_fallback("no metadata endpoint could be derived from uri (no '/v1/graphql' " \ + "segment); set the 'metadata_uri' option") end body = JSON.generate({ type: 'export_metadata', version: 2, args: {} }) response = post(@configuration.metadata_uri, body) - return nil unless response.is_a?(Net::HTTPSuccess) + return metadata_fallback("the metadata endpoint answered HTTP #{response.code}") unless + response.is_a?(Net::HTTPSuccess) - payload = JSON.parse(response.body) + parse_metadata(response.body) + rescue *TRANSPORT_ERRORS => e + metadata_fallback("the metadata endpoint is not reachable (#{e.class})") + end + + private + + def parse_metadata(body) + payload = JSON.parse(body) metadata = payload['metadata'] || payload + return metadata if metadata.is_a?(Hash) && metadata['sources'] + + shape = metadata.is_a?(Hash) ? "top-level keys: #{metadata.keys.first(5).join(", ")}" : metadata.class + metadata_fallback("the metadata response carries no sources (#{shape})") + end - metadata['sources'] ? metadata : nil - rescue StandardError => e - ForestAdminDatasourceGraphqlHasura.logger.info( - "[forest_admin_datasource_graphql_hasura] Hasura metadata API not available (#{e.class}); " \ + def metadata_fallback(reason) + ForestAdminDatasourceGraphqlHasura.logger.warn( + "[forest_admin_datasource_graphql_hasura] Hasura metadata unavailable: #{reason}; " \ 'falling back to configuration and naming conventions.' ) nil end - private - def post(url, body) uri = URI.parse(url) http = Net::HTTP.new(uri.host, uri.port) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb index 0b91b33db..db4b5e219 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb @@ -31,20 +31,31 @@ def initialize(uri:, **options) instance_variable_set("@#{option}", options[option].nil? ? default.dup : options[option]) end validate_polymorphic_relations + validate_table_lists # Only derivable from the conventional endpoint path: substituting on any # other uri would silently post metadata commands to the GraphQL endpoint. @metadata_uri ||= uri.include?('/v1/graphql') ? uri.sub('/v1/graphql', '/v1/metadata') : nil end - def table_allowed?(table_name) - return false if excluded_tables.include?(table_name) - return included_tables.include?(table_name) unless included_tables.nil? + # Accepts every spelling a table goes by (root field and type name): an + # exclusion must hold under renaming, or it would silently re-expose data. + def table_allowed?(*table_names) + return false if table_names.any? { |name| excluded_tables.include?(name) } + return (table_names & included_tables).any? unless included_tables.nil? true end private + # A String by mistake (`included_tables: "users"`) would silently become a + # substring check instead of a name match. + def validate_table_lists + return if excluded_tables.is_a?(Array) && (included_tables.nil? || included_tables.is_a?(Array)) + + raise ConfigurationError, 'included_tables and excluded_tables must be arrays of table names' + end + # A misshapen declaration would otherwise crash deep inside introspection as # an opaque NoMethodError instead of naming the option. def validate_polymorphic_relations diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb index 0f158a17b..a67bbccd4 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb @@ -60,9 +60,6 @@ class Introspector TEXT_TYPES = %w[String ID text varchar char bpchar citext bytea].to_set.freeze EXCLUDED_PREFIXES = %w[__ hdb_ pg_ information_schema].freeze - # Camel variants cover the graphql-default naming convention; a genuine - # Postgres table name is lowercase and cannot end with them. - EXCLUDED_SUFFIXES = %w[_aggregate _by_pk _stream _connection Aggregate ByPk Stream Connection].freeze def initialize(client, configuration) @client = client @@ -103,9 +100,10 @@ def introspection_payload # The metadata is optional by design; one malformed entry must degrade to # the same fallback as an unreachable endpoint, not crash the boot. + # Only shape errors degrade: anything else is a bug that must fail loudly. def safe_relationship_mappings(metadata) parse_relationship_mappings(metadata) - rescue StandardError => e + rescue TypeError, NoMethodError, KeyError => e ForestAdminDatasourceGraphqlHasura.logger.warn( '[forest_admin_datasource_graphql_hasura] Hasura metadata could not be parsed ' \ "(#{e.class}: #{e.message}); falling back to configuration and naming conventions." @@ -244,15 +242,18 @@ def parse_primary_keys(query_fields) end def parse_tables(query_fields) + root_names = query_fields.to_set { |query_field| query_field['name'] } + query_fields.filter_map do |field| table_name = field['name'] - next if skip_table?(table_name) - # Only the select root returns a list; a custom-named select_by_pk - # root returns the bare object and is not a table. + # Only the select root returns a list: _aggregate, _by_pk and Relay + # _connection roots all return bare objects and are rejected here. next unless array_type?(field['type']) type = @type_map[base_type_name(field['type'])] next unless type && type['kind'] == 'OBJECT' + next if skip_table?([table_name, type['name']].uniq) + next if stream_companion?(table_name, root_names) table = parse_table(table_name, type) next table unless table.primary_key.empty? @@ -267,16 +268,27 @@ def parse_tables(query_fields) end end - def skip_table?(name) + # names carries the root field and the underlying type name: an exclusion + # (or inclusion) must hold whichever spelling the user wrote, or renaming + # a table would silently re-expose it. + def skip_table?(names) # An explicit exclusion always wins; then an explicit allow-list wins over # the built-in exclusions, so a legitimate table whose name starts like a # system one stays reachable. - return true if @configuration.excluded_tables.include?(name) - return false if @configuration.included_tables&.include?(name) + return true if names.any? { |name| @configuration.excluded_tables.include?(name) } + return false if @configuration.included_tables && (names & @configuration.included_tables).any? + + names.any? { |name| EXCLUDED_PREFIXES.any? { |prefix| name.start_with?(prefix) } } || + !@configuration.table_allowed?(*names) + end + + # `_stream` returns the same list shape as its select root, + # so the structural check cannot tell them apart; a genuine table named + # `data_stream` has no `data` root field and is kept. + def stream_companion?(name, root_names) + base = name.sub(/(_stream|Stream)\z/, '') - EXCLUDED_PREFIXES.any? { |prefix| name.start_with?(prefix) } || - EXCLUDED_SUFFIXES.any? { |suffix| name.end_with?(suffix) } || - !@configuration.table_allowed?(name) + base != name && root_names.include?(base) end def parse_table(table_name, type) diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb index d2615e244..50d2955c3 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb @@ -104,6 +104,13 @@ module ForestAdminDatasourceGraphqlHasura expect(second.headers).to eq({}) end + # A String would silently become a substring check instead of a name match. + it 'rejects table lists that are not arrays by name' do + expect do + Configuration.new(uri: BankingSchema::GRAPHQL_URI, included_tables: 'users') + end.to raise_error(ConfigurationError, /must be arrays/) + end + it 'rejects a misshapen polymorphic_relations declaration by name' do expect do Configuration.new(uri: BankingSchema::GRAPHQL_URI, diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb index 58fb45286..f49b4b1bf 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb @@ -400,6 +400,182 @@ def build_datasource(**options) end end + describe 'relationships towards unexposed tables' do + # The guard exists specifically so a real production schema (excluded + # tables, PK-less views) cannot crash the boot. + it 'skips object and array relationships whose target is not exposed' do + stub_schema( + [ + { + 'name' => 'transfers', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('account', object('accounts')), + field('items', non_null(list_of(non_null(object('items'))))) + ] + }, + { 'name' => 'accounts', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] }, + { 'name' => 'items', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + # accounts and items have no _by_pk: both are dropped for lack of a + # primary key, leaving transfers' relationships dangling. + [list_query('transfers'), by_pk_query('transfers'), list_query('accounts'), list_query('items')] + ) + stub_metadata([]) + + datasource = build_datasource + + expect(datasource.collections.keys).to eq(['Transfer']) + expect(datasource.get_collection('Transfer').schema[:fields].keys).to eq(['id']) + end + end + + describe 'metadata mapping collisions' do + # Two sources claiming the same exposed root field with different mappings: + # neither can be trusted, so the mapping is dropped and the relationship + # falls back to naming conventions (which find no owner_id here). + it 'drops a mapping genuinely claimed twice with different definitions' do + stub_schema( + [ + { + 'name' => 'transfers', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('owner_ref', scalar('bigint')), + field('other_ref', scalar('bigint')), + field('owner', object('owners')) + ] + }, + { 'name' => 'owners', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + [list_query('transfers'), by_pk_query('transfers'), list_query('owners'), by_pk_query('owners')] + ) + WebMock.stub_request(:post, BankingSchema::METADATA_URI).to_return( + status: 200, + body: JSON.generate( + { 'metadata' => { 'version' => 3, 'sources' => [ + { 'name' => 'a', 'kind' => 'postgres', 'tables' => [ + { 'table' => { 'schema' => 'public', 'name' => 'transfers' }, + 'object_relationships' => [manual_object_rel('owner', 'owners', { 'owner_ref' => 'id' })] } + ] }, + { 'name' => 'b', 'kind' => 'postgres', 'tables' => [ + { 'table' => { 'schema' => 'public', 'name' => 'transfers' }, + 'object_relationships' => [manual_object_rel('owner', 'owners', { 'other_ref' => 'id' })] } + ] } + ] } } + ), + headers: { 'Content-Type' => 'application/json' } + ) + + expect(build_datasource.get_collection('Transfer').schema[:fields]).not_to have_key('owner') + end + end + + describe 'polymorphic name collisions' do + def stub_poly_schema(transfer_fields:, comment_extra_fields: []) + stub_schema( + [ + { + 'name' => 'comments', 'kind' => 'OBJECT', + 'fields' => [ + field('id', non_null(scalar('bigint'))), + field('commentable_type', non_null(scalar('String'))), + field('commentable_id', non_null(scalar('bigint'))), + field('transfer', object('transfers')) + ] + comment_extra_fields + }, + { 'name' => 'transfers', 'kind' => 'OBJECT', + 'fields' => [field('id', non_null(scalar('bigint')))] + transfer_fields } + ], + [list_query('comments'), by_pk_query('comments'), list_query('transfers'), by_pk_query('transfers')] + ) + end + + def poly_metadata(extra_comment_rels: []) + stub_metadata( + [{ 'table' => { 'schema' => 'public', 'name' => 'comments' }, + 'object_relationships' => [ + manual_object_rel('transfer', 'transfers', { 'commentable_id' => 'id' }) + ] + extra_comment_rels }] + ) + end + + # A relationship literally named like the polymorphic association must not + # overwrite it once the association is emitted. + it 'keeps the polymorphic association over a relationship sharing its name' do + stub_poly_schema(transfer_fields: [], + comment_extra_fields: [field('other_ref', scalar('bigint')), + field('commentable', object('transfers'))]) + poly_metadata(extra_comment_rels: [manual_object_rel('commentable', 'transfers', { 'other_ref' => 'id' })]) + + fields = build_datasource.get_collection('Comment').schema[:fields] + + expect(fields['commentable'].type).to eq('PolymorphicManyToOne') + end + + # No declared array relationship, and `comments` taken by a column: the + # reverse falls back to `_`. + it 'falls back to the combined name when the child table name is taken' do + stub_poly_schema(transfer_fields: [field('comments', scalar('String'))]) + poly_metadata + + fields = build_datasource.get_collection('Transfer').schema[:fields] + + expect(fields['comments'].type).to eq('Column') + expect(fields['comments_commentable'].type).to eq('PolymorphicOneToMany') + end + + it 'gives up with a warning when every reverse name candidate is taken' do + stub_poly_schema(transfer_fields: [field('comments', scalar('String')), + field('comments_commentable', scalar('String'))]) + poly_metadata + + fields = build_datasource.get_collection('Transfer').schema[:fields] + + expect(fields['comments'].type).to eq('Column') + expect(fields['comments_commentable'].type).to eq('Column') + expect(fields.values.map(&:type)).not_to include('PolymorphicOneToMany') + end + end + + describe 'table lists under renamed root fields' do + # An exclusion written with the real table name must hold when the select + # root is renamed: re-exposing an excluded table leaks data. + it 'excludes a renamed table by its underlying name' do + stub_schema( + [{ 'name' => 'secrets', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] }], + [field('vault', non_null(list_of(non_null(object('secrets'))))), by_pk_query('secrets')] + ) + stub_metadata( + [{ 'table' => { 'schema' => 'public', 'name' => 'secrets' }, + 'configuration' => { 'custom_root_fields' => { 'select' => 'vault' } } }] + ) + + expect(build_datasource(excluded_tables: ['secrets']).collections).to be_empty + end + end + + describe 'stream root fields' do + # `_stream` shares the select root's list shape; a genuine table + # merely named like one has no matching base root and must be kept. + it 'skips a stream companion but keeps a real table named like one' do + stub_schema( + [ + { 'name' => 'users', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] }, + { 'name' => 'data_stream', 'kind' => 'OBJECT', 'fields' => [field('id', non_null(scalar('bigint')))] } + ], + [ + list_query('users'), by_pk_query('users'), + field('users_stream', non_null(list_of(non_null(object('users'))))), + list_query('data_stream'), by_pk_query('data_stream') + ] + ) + stub_metadata([]) + + expect(build_datasource.collections.keys.sort).to eq(%w[DataStream User]) + end + end + describe 'composite primary keys' do # A join table's key is application-assigned: read-only key columns would # make the table impossible to create through Forest Admin. From 1d7d99df1dc61ea881f58ab1c209cfa669eef278 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Thu, 6 Aug 2026 15:11:20 +0200 Subject: [PATCH 26/26] fix(datasource graphql hasura): guard the metadata parsing the narrowed rescue no longer covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrowing fetch_metadata's rescue removed the net that absorbed a 204 no-content response (nil body into JSON.parse) and a JSON array payload (String index into an Array) — both crashed the boot again. parse_metadata now guards them explicitly, mirroring Client#execute, and catches its own JSON::ParserError so garbage JSON reports "not valid JSON" instead of the transport rescue's misleading "not reachable". Spotted by @matthv. Co-Authored-By: Claude Fable 5 --- .../client.rb | 11 +++++++++++ .../client_spec.rb | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb index 47240ce15..3f4db3946 100644 --- a/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb +++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb @@ -70,13 +70,24 @@ def fetch_metadata private + # Guards mirror Client#execute: a 204 passes the HTTPSuccess check with a + # nil body, and a JSON body need not be an object. Parse errors are caught + # here rather than by the transport rescue, whose "not reachable" message + # would be misleading — the endpoint did answer. def parse_metadata(body) + return metadata_fallback('the metadata endpoint returned an empty body') if body.nil? || body.empty? + payload = JSON.parse(body) + return metadata_fallback("the metadata response is not a JSON object (#{payload.class})") unless + payload.is_a?(Hash) + metadata = payload['metadata'] || payload return metadata if metadata.is_a?(Hash) && metadata['sources'] shape = metadata.is_a?(Hash) ? "top-level keys: #{metadata.keys.first(5).join(", ")}" : metadata.class metadata_fallback("the metadata response carries no sources (#{shape})") + rescue JSON::ParserError + metadata_fallback('the metadata response is not valid JSON') end def metadata_fallback(reason) diff --git a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb index 50d2955c3..1bd28d60f 100644 --- a/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb +++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb @@ -133,6 +133,25 @@ module ForestAdminDatasourceGraphqlHasura expect(client.fetch_metadata).to be_nil end + # Net::HTTPNoContent is a Net::HTTPSuccess whose body is nil. + it 'falls back on a 204 with an empty body instead of crashing the boot' do + WebMock.stub_request(:post, BankingSchema::METADATA_URI).to_return(status: 204, body: nil) + + expect(client.fetch_metadata).to be_nil + end + + it 'falls back on a JSON body that is not an object' do + WebMock.stub_request(:post, BankingSchema::METADATA_URI).to_return(status: 200, body: '[]') + + expect(client.fetch_metadata).to be_nil + end + + it 'falls back on a body that is not valid JSON' do + WebMock.stub_request(:post, BankingSchema::METADATA_URI).to_return(status: 200, body: '') + + expect(client.fetch_metadata).to be_nil + end + # An uri without the conventional segment yields no derivable metadata # endpoint: introspection must not post metadata commands to GraphQL. it 'skips the call entirely when no metadata endpoint could be derived' do