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..6458bc528 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).
@@ -118,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'
@@ -139,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'
@@ -254,6 +257,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 +299,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 +357,11 @@ 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_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/.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/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
new file mode 100644
index 000000000..e3d79ce9b
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/README.md
@@ -0,0 +1,121 @@
+# 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`; 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 |
+| `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. 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) 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
+ 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` 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 —
+ 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.
+
+## 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..fe3ce0229
--- /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/ validation/ .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..8f62c4b79
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura.rb
@@ -0,0 +1,49 @@
+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".
+ # 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
+ 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..3f4db3946
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/client.rb
@@ -0,0 +1,117 @@
+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 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
+ ].freeze
+
+ def execute(query, variables = {})
+ body = JSON.generate({ query: query, variables: 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)
+
+ if payload['errors']&.any?
+ messages = payload['errors'].map { |e| e['message'] }.join('; ')
+ raise GraphqlError, messages
+ end
+
+ # 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
+
+ # 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. 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?
+ 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 metadata_fallback("the metadata endpoint answered HTTP #{response.code}") unless
+ response.is_a?(Net::HTTPSuccess)
+
+ parse_metadata(response.body)
+ rescue *TRANSPORT_ERRORS => e
+ metadata_fallback("the metadata endpoint is not reachable (#{e.class})")
+ end
+
+ 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)
+ ForestAdminDatasourceGraphqlHasura.logger.warn(
+ "[forest_admin_datasource_graphql_hasura] Hasura metadata unavailable: #{reason}; " \
+ 'falling back to configuration and naming conventions.'
+ )
+ nil
+ end
+
+ 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
+ http.write_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..4f126002e
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/collection.rb
@@ -0,0 +1,180 @@
+module ForestAdminDatasourceGraphqlHasura
+ class Collection < ForestAdminDatasourceToolkit::Collection
+ ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException
+ Projection = ForestAdminDatasourceToolkit::Components::Query::Projection
+
+ PLACEHOLDER_REFERENCE = { '*' => nil }.freeze
+
+ 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
+ # 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
+
+ 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(@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(@names, [writable_columns(data)], column_names)
+ returning = execute(:create, operation).dig(@names[:insert], 'returning')
+
+ raise GraphqlError, "No record returned by #{@names[:insert]}" 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(@names, filter, writable_columns(data))
+ execute(:update, operation)
+ end
+
+ def delete(_caller, filter)
+ operation = Query::QueryBuilder.delete(@names, filter)
+ execute(:delete, operation)
+ end
+
+ 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)
+ @client.execute(operation[:query], operation[:variables])
+ rescue GraphqlError, TransportError => e
+ raise e.class, "GraphQL #{operation_name} failed on '#{name}': #{e.message}"
+ 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). Protected: called on
+ # the target collection to resolve nested selections.
+ 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.build_selection(relation_projection)
+ selection << "#{relation_name} { #{nested.join(" ")} }"
+ end
+ end
+
+ 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
+
+ # 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
+ @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
+
+ 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
+
+ # 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(nested, field, relation_projection)
+ target = datasource.get_collection(field.foreign_collection)
+
+ case nested
+ when Hash then target.materialize_polymorphics(nested, relation_projection)
+ when Array then nested.each { |row| target.materialize_polymorphics(row, relation_projection) }
+ end
+ 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
+
+ # 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..db4b5e219
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/configuration.rb
@@ -0,0 +1,72 @@
+module ForestAdminDatasourceGraphqlHasura
+ class Configuration
+ # 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' }.
+ 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
+ # 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[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
+
+ # 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
+ 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
new file mode 100644
index 000000000..220bdcb10
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/datasource.rb
@@ -0,0 +1,81 @@
+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
+ 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|
+ 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
+
+ # `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
+ # 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/introspector.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb
new file mode 100644
index 000000000..a67bbccd4
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb
@@ -0,0 +1,427 @@
+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
+
+ def initialize(client, configuration)
+ @client = client
+ @configuration = configuration
+ end
+
+ # @return [Array]
+ def introspect
+ types, query_fields = introspection_payload
+ 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)
+
+ parse_tables(query_fields)
+ end
+
+ 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.
+ # Only shape errors degrade: anything else is a bug that must fail loudly.
+ def safe_relationship_mappings(metadata)
+ parse_relationship_mappings(metadata)
+ 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."
+ )
+ @custom_root_fields = {}
+ {}
+ end
+
+ 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)
+ table_info = table['table']
+ 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] }
+
+ relationships.each do |(rel, kind)|
+ entry = relationship_mapping(rel, kind)
+ next if entry.nil?
+
+ # 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')
+ 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']
+
+ 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)
+
+ 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
+ # 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']
+ return nil unless using.is_a?(Hash)
+
+ 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
+
+ # 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', 'ByPk')
+
+ type_name = base_type_name(field['type'])
+ pk_fields = (field['args'] || []).map { |arg| arg['name'] }
+ memo[type_name] = pk_fields if pk_fields.any?
+ end
+ 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']
+ # 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?
+
+ # 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
+
+ # 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 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/, '')
+
+ base != name && root_names.include?(base)
+ end
+
+ def parse_table(table_name, type)
+ 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'])) }
+
+ Table.new(
+ name: table_name,
+ type_name: type['name'],
+ columns: columns,
+ primary_key: resolve_primary_key(type['name'], columns),
+ relationships: relations.map { |field| parse_relationship(table_name, field) },
+ 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`.
+ 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)
+ entry = @relationship_mappings["#{table_name}.#{field["name"]}"]
+
+ Relationship.new(
+ name: field['name'],
+ kind: array_type?(field['type']) ? :array : :object,
+ remote_table: base_type_name(field['type']),
+ 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 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(type_name, columns)
+ known = @primary_keys[type_name]
+
+ if known
+ columns.each { |column| column.is_primary_key = known.include?(column.name) }
+
+ return known
+ 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
+
+ # 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',
+ # 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',
+ '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/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..f5e35bddf
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb
@@ -0,0 +1,146 @@
+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)
+ # 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] })
+
+ 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 = configured_relations(table).keys
+
+ detected = names.filter_map do |name|
+ base = name.delete_suffix('_type')
+ # 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
+ 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)
+ configured_tables = configured_relations(table)[base]
+ foreign_key = "#{base}_id"
+
+ 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]
+ next unless target_table
+ next if ambiguous_branch?(table, base, target_table.name, relationships)
+
+ relationship = relationships.first
+ 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'
+ }
+ 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, 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]
+
+ 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
+ # whenever an unrelated `_type` enum sits next to `_id`.
+ 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] ||
+ table.type_name.classify
+ 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..57ef66115
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb
@@ -0,0 +1,339 @@
+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
+ # 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. 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_root[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'
+ def collection_name_of(table_name)
+ rails_class_name_of(table_name).gsub('::', '__')
+ end
+
+ def build_fields(table)
+ fields = {}
+ composite_key = table.primary_key.size > 1
+
+ table.columns.each do |column|
+ fields[column.name] = convert_column(column, composite_key: composite_key)
+ end
+
+ add_polymorphics(table, fields)
+ add_relationships(table, fields)
+ add_reverse_polymorphics(table, fields)
+
+ fields
+ end
+
+ 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
+ # 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: 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 || read_only ? [] : [{ operator: Operators::PRESENT }]
+ )
+ 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)
+ 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
+
+ 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 add_relationships(table, fields)
+ table.relationships.each do |relationship|
+ name, schema = convert_relationship(table, relationship)
+ 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)
+ else
+ convert_array_relationship(table, relationship)
+ end
+ end
+
+ def convert_object_relationship(table, relationship)
+ 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.
+ 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(remote.name),
+ foreign_key: foreign_key,
+ foreign_key_target: relationship.mapping&.values&.first || primary_key_of(remote)
+ )]
+ end
+
+ def convert_array_relationship(table, relationship)
+ 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)
+
+ # 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 " \
+ "'#{relationship.remote_table}'")
+
+ return [relationship.name, nil]
+ end
+
+ [relationship.name, Relations::OneToManySchema.new(
+ foreign_collection: collection_name_of(remote.name),
+ 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?
+
+ 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)
+ 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)
+ 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 && resolve_table(rel.remote_table) == child && reverse_of?(rel, table, polymorphic)
+ 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 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..4457c8bf4
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb
@@ -0,0 +1,24 @@
+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. 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,
+ :root_fields, 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/aggregator.rb b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb
new file mode 100644
index 000000000..dfba8d2c7
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb
@@ -0,0 +1,431 @@
+require 'time'
+
+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 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
+
+ # 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
+
+ def run(filter, aggregation, limit)
+ validate(aggregation)
+ @date_field = date_field?(aggregation)
+
+ if aggregation.groups.nil? || aggregation.groups.empty?
+ simple(filter, aggregation)
+ else
+ grouped(filter, aggregation, limit)
+ end
+ end
+
+ private
+
+ def name = @collection.name
+ def names = @collection.names
+ 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, column_only: true) 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
+ raise ForestException,
+ "Grouping on several fields is not supported by the GraphQL datasource (collection '#{name}')."
+ end
+
+ groups.each do |group|
+ if group[:operation]
+ raise ForestException,
+ "Date grouping is not supported by the GraphQL datasource (collection '#{name}')."
+ end
+
+ # 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
+
+ # 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
+ raise ForestException, "Invalid aggregation field '#{field}' on collection '#{name}'."
+ end
+
+ *relations, last = path
+ collection = relations.reduce(@collection) { |current, part| collection_through(current, part, field) }
+ target = collection.schema[:fields][last]
+
+ raise ForestException, "Field '#{field}' not found on collection '#{collection.name}'." if target.nil?
+
+ 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)
+ 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(names, filter, aggregation)
+ 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.
+ [{ 'value' => extract_value(data, aggregation), 'group' => {} }]
+ end
+
+ 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_orphan_groups(values, relation, filter, aggregation)
+
+ 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
+
+ def fetch_parent_rows(relation, filter, aggregation)
+ rows = []
+ offset = 0
+
+ loop do
+ operation = QueryBuilder.grouped_aggregate(names, relation, filter, aggregation,
+ { limit: PARENT_PAGE, offset: offset })
+ page = @collection.execute(:aggregate, operation)[relation[:parent_table]] || []
+ rows.concat(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|
+ data = row.dig("#{relation[:relation_name]}_aggregate", 'aggregate')
+ next if childless?(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
+ end
+
+ # 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]
+
+ 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(names, filter, aggregation, extra_where: extra_where)
+ data = @collection.execute(:aggregate, operation).dig(names[:aggregate], 'aggregate')
+ return if childless?(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)
+ 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
+ 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
+ # the primary key, as leaderboard charts do — and SQL would return them as a
+ # 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 '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
+ 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
+
+ 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)
+ left = numeric(current)
+ right = numeric(value)
+
+ left + right
+ end
+
+ def numeric(value)
+ case value
+ when Integer, Float then value
+ 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)`,
+ # 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
+
+ # 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_name, 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,
+ 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)
+ }
+ 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]
+ .select { |_, field| field.respond_to?(:is_primary_key) && field.is_primary_key }
+ .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_name, field, field.foreign_key] if field&.type == 'ManyToOne'
+
+ name, relation = fields.find { |_, f| f.type == 'ManyToOne' && f.foreign_key == field_name }
+
+ [name, relation, 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
+
+ # 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. 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, '']
+ when String then comparable_string(value)
+ # 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. 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 = @date_field ? time_value(value) : nil
+
+ instant ? [0, instant, ''] : [1, 0.0, value]
+ end
+
+ def time_value(value)
+ Time.parse(value).to_f
+ rescue ArgumentError, TypeError
+ 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?
+
+ 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
+
+ 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/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..0d2945397
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/filter_converter.rb
@@ -0,0 +1,117 @@
+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
+ convert_branch(condition_tree)
+ when Nodes::ConditionTreeLeaf
+ convert_leaf(condition_tree)
+ else
+ raise GraphqlError, "Unsupported condition tree node: #{condition_tree.class}"
+ end
+ end
+
+ 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.
+ 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..9a703efcc
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb
@@ -0,0 +1,260 @@
+module ForestAdminDatasourceGraphqlHasura
+ module Query
+ # Builds Hasura GraphQL operations (queries and mutations) with variables.
+ # All methods return { query:, variables: }.
+ #
+ # 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
+ # ("membership { id full_name }").
+ def list(names, filter, selection)
+ args = []
+ var_defs = []
+ variables = {}
+
+ where = FilterConverter.convert(filter.condition_tree)
+
+ if where
+ var_defs << "$where: #{names[:base]}_bool_exp"
+ args << 'where: $where'
+ variables['where'] = where
+ end
+
+ add_sort(names, filter, args, var_defs, variables)
+ add_pagination(filter, args, var_defs, variables)
+
+ query = <<~GRAPHQL
+ query List#{camelize(names[:root])}#{wrap(var_defs)} {
+ #{names[:root]}#{wrap(args)} {
+ #{selection.join("\n ")}
+ }
+ }
+ GRAPHQL
+
+ { query: query, variables: variables }
+ end
+
+ def create(names, records, selection)
+ query = <<~GRAPHQL
+ mutation Insert#{camelize(names[:base])}($objects: [#{names[:base]}_insert_input!]!) {
+ #{names[:insert]}(objects: $objects) {
+ returning {
+ #{selection.join("\n ")}
+ }
+ }
+ }
+ GRAPHQL
+
+ { query: query, variables: { 'objects' => records.map { |record| stringify_keys(record) } } }
+ end
+
+ 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 '#{names[:root]}': the filter carries no condition."
+ end
+
+ query = <<~GRAPHQL
+ mutation Update#{camelize(names[:base])}($where: #{names[:base]}_bool_exp!, $set: #{names[:base]}_set_input!) {
+ #{names[:update]}(where: $where, _set: $set) {
+ affected_rows
+ }
+ }
+ GRAPHQL
+
+ { query: query, variables: { 'where' => where, 'set' => stringify_keys(patch) } }
+ end
+
+ def delete(names, filter)
+ query = <<~GRAPHQL
+ mutation Delete#{camelize(names[:base])}($where: #{names[:base]}_bool_exp!) {
+ #{names[:delete]}(where: $where) {
+ affected_rows
+ }
+ }
+ 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
+
+ # extra_where is a raw bool_exp and-combined with the converted filter
+ # (the null-bucket query adds `{ fk => { _is_null => true } }`).
+ def aggregate(names, filter, aggregation, extra_where: nil)
+ args = []
+ var_defs = []
+ variables = {}
+
+ where = combine(FilterConverter.convert(filter.condition_tree), extra_where)
+
+ if where
+ var_defs << "$where: #{names[:base]}_bool_exp"
+ args << 'where: $where'
+ variables['where'] = where
+ end
+
+ query = <<~GRAPHQL
+ query Aggregate#{camelize(names[:base])}#{wrap(var_defs)} {
+ #{names[:aggregate]}#{wrap(args)} {
+ aggregate {
+ #{aggregation_selection(aggregation)}
+ }
+ }
+ }
+ GRAPHQL
+
+ { query: query, variables: variables }
+ end
+
+ # 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(names, relation, filter, aggregation, page)
+ args = []
+ 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: #{names[:base]}_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]}#{wrap(parent_args)} {
+ #{relation[:parent_field]}
+ #{relation[:relation_name]}_aggregate#{wrap(args)} {
+ aggregate {
+ #{aggregation_selection(aggregation)}
+ }
+ }
+ }
+ }
+ GRAPHQL
+
+ { 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(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(names[:root])}($where: #{names[:base]}_bool_exp, $limit: Int) {
+ #{names[:root]}(where: $where, distinct_on: [#{column}], order_by: [{ #{column}: asc }], limit: $limit) {
+ #{column}
+ }
+ }
+ GRAPHQL
+
+ { 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_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'
+
+ "\navg_sum: sum { #{aggregation.field} }\navg_count: count(columns: #{aggregation.field})"
+ end
+
+ private
+
+ def add_sort(names, filter, args, var_defs, variables)
+ return unless filter.respond_to?(:sort) && filter.sort&.any?
+
+ var_defs << "$orderBy: [#{names[:base]}_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
+
+ # 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 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
+
+ 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..aef3fc5df
--- /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"
+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
new file mode 100644
index 000000000..1bd28d60f
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/client_spec.rb
@@ -0,0 +1,165 @@
+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
+
+ 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
+
+ # 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 }))
+
+ expect { client.execute('query { ok }') }.to raise_error(TransportError, /no data/)
+ end
+ 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)
+ first.headers['Authorization'] = 'leak'
+
+ 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,
+ polymorphic_relations: { 'comments' => ['commentable'] })
+ end.to raise_error(ConfigurationError, /polymorphic_relations/)
+ 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
+
+ # 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
+ 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/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..fc14483da
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/collection_spec.rb
@@ -0,0 +1,661 @@
+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 graphql_requests
+ requests = []
+ expect(WebMock).to(have_requested(:post, BankingSchema::GRAPHQL_URI).at_least_once.with do |req|
+ requests << JSON.parse(req.body) unless req.body.include?('IntrospectSchema')
+ true
+ end)
+ requests
+ end
+
+ def last_graphql_request
+ graphql_requests.last
+ 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
+
+ # 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
+ 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' } }] }
+ )
+
+ 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
+
+ # `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' => [] })
+
+ 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
+
+ # 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
+ 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 } })
+
+ 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 '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
+
+ # 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
+
+ # 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)
+
+ # 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
+
+ # 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
+
+ # `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(
+ {
+ '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
+ 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
+
+ # 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 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
+ 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 } } })
+
+ 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 '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 '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|
+ { '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
+
+ # 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 } } }
+ )
+
+ 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
+
+ # 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
+
+ # `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' }])
+
+ 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..2b9580106
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/datasource_spec.rb
@@ -0,0 +1,145 @@
+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)
+ # 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
+
+ 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
+
+ # 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,
+ 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..f49b4b1bf
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/introspection/introspector_detection_spec.rb
@@ -0,0 +1,1028 @@
+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
+
+ # 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',
+ 'fields' => [field('transfer_id', scalar('bigint')), field('total', scalar('bigint'))] }],
+ [list_query('transfer_stats')]
+ )
+ stub_metadata([])
+
+ expect(build_datasource.collections).to be_empty
+ end
+
+ # 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_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')] }
+ ]
+ )
+
+ 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
+
+ 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 '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.
+ 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
+ def stub_people_schema(select_config: 'people', extra_roots: {})
+ 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' => select_config }.merge(extra_roots) },
+ '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).
+ 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('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
+
+ 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
+ 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
+ # 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 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(
+ [
+ {
+ '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(
+ [
+ {
+ '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 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/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
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..c1db49ee5
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/spec/forest_admin_datasource_graphql_hasura/query/filter_converter_spec.rb
@@ -0,0 +1,111 @@
+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
+
+ # 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/)
+ 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..b0a4f2287
--- /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 90
+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..50849e45f
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/spec/support/banking_schema.rb
@@ -0,0 +1,218 @@
+# 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('metadata', scalar('jsonb')),
+ 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
+
+ # 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(*responses)
+ 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..6dbe8c53f
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/validation/init.sql
@@ -0,0 +1,99 @@
+-- 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),
+ -- 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),
+ ('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..4ef0bb426
--- /dev/null
+++ b/packages/forest_admin_datasource_graphql_hasura/validation/validate.rb
@@ -0,0 +1,431 @@
+# 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
+
+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
+ 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']] }
+ # 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 =="
+
+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']] }
+
+ # 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
+ 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 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:*'))
+
+ 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 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)
+ )
+
+ begin
+ unreachable.execute('query { __typename }')
+ raise 'expected a transport failure'
+ 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
+
+failures = RESULTS.count { |(_, status, _)| status == :fail }
+puts "\n#{RESULTS.size} scenarios, #{failures} failure(s)"
+exit(failures.zero? ? 0 : 1)