forest_admin_datasource_graphql_hasura 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # Forest Admin — Hasura GraphQL datasource
2
+
3
+ Surface tables exposed by a [Hasura](https://hasura.io) GraphQL API as Forest Admin collections,
4
+ including **Rails-style polymorphic associations** (`belongs_to :commentable, polymorphic: true`).
5
+
6
+ ## Installation
7
+
8
+ ```ruby
9
+ # Gemfile
10
+ gem 'forest_admin_datasource_graphql_hasura'
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ruby
16
+ ForestAdminRails::Agent.instance.add_datasource(
17
+ ForestAdminDatasourceGraphqlHasura::Datasource.new(
18
+ uri: 'https://my-instance.hasura.app/v1/graphql',
19
+ headers: { 'x-hasura-admin-secret' => ENV['HASURA_ADMIN_SECRET'] }
20
+ )
21
+ )
22
+ ```
23
+
24
+ Collections are named after the Rails class name derived from the table name
25
+ (`transfers` → `Transfer`). This matches the values stored in Rails `*_type` columns, which is
26
+ what makes polymorphic relations resolvable by the Forest Admin frontend.
27
+
28
+ ## Polymorphic associations
29
+
30
+ Rails represents `belongs_to :commentable, polymorphic: true` with two columns
31
+ (`commentable_type`, `commentable_id`). Hasura cannot express the type condition, so teams
32
+ declare one manual object relationship per target, joining on `commentable_id` alone.
33
+
34
+ This datasource detects the pattern (a `<base>_type`/`<base>_id` column pair whose object
35
+ relationships all join on `<base>_id`) and emits:
36
+
37
+ - a `PolymorphicManyToOne` (`Comment.commentable`) instead of the ambiguous per-target
38
+ relations — the Forest UI shows the native polymorphic widget;
39
+ - a `PolymorphicOneToMany` on each target (`Transfer.comments`, filtered on
40
+ `commentable_type = 'Transfer'`), so related data never leaks records of another type.
41
+
42
+ The detection uses the Hasura metadata API (`/v1/metadata`, derived from `uri`). When that
43
+ endpoint is not reachable (common in production), declare the associations explicitly:
44
+
45
+ ```ruby
46
+ ForestAdminDatasourceGraphqlHasura::Datasource.new(
47
+ uri: '...',
48
+ polymorphic_relations: { 'comments' => { 'commentable' => %w[transfers cards] } }
49
+ )
50
+ ```
51
+
52
+ For namespaced models, override the type value stored by Rails:
53
+
54
+ ```ruby
55
+ type_values: { 'bank_accounts' => 'Banking::Account' }
56
+ ```
57
+
58
+ ## Options
59
+
60
+ | Option | Description |
61
+ | --- | --- |
62
+ | `uri` | Hasura GraphQL endpoint (required) |
63
+ | `headers` | HTTP headers, e.g. admin secret or JWT |
64
+ | `metadata_uri` | Metadata endpoint (default: `uri` with `/v1/graphql` → `/v1/metadata`; not derived — and detection skipped — when `uri` has no `/v1/graphql` segment) |
65
+ | `included_tables` / `excluded_tables` | Allow/deny lists of table names |
66
+ | `polymorphic_relations` | Explicit polymorphic declarations (see above) |
67
+ | `type_values` | Table → Rails class name overrides |
68
+ | `timeout` | HTTP timeout in seconds (default 30) |
69
+
70
+ ## Requirements and limitations
71
+
72
+ - **A polymorphic association is only detected from a Hasura `manual_configuration`
73
+ relationship** joining on the polymorphic foreign key (or from `polymorphic_relations`).
74
+ A relationship backed by a real foreign key constraint is treated as a plain belongs_to,
75
+ so a business enum named `<something>_type` sitting next to a `<something>_id` foreign key
76
+ is left alone.
77
+ - **Grouped aggregations** (charts) work on a foreign key, or on a `<relation>:<column>`
78
+ path through a ManyToOne (leaderboard charts) whose reverse relationship is declared in
79
+ Hasura: Hasura exposes GROUP BY only through nested `<relation>_aggregate` fields. A
80
+ foreign key without a declared reverse relationship is advertised as non-groupable, like
81
+ every other column, and date truncation is not supported. Rows whose foreign key is NULL
82
+ form a bucket of their own, as SQL grouping would. Dangling foreign keys (possible on a
83
+ constraint-less relationship) keep a group per value when grouping by the foreign key (up
84
+ to 100 distinct dangling values, then a clear error) and fall into the NULL bucket when
85
+ grouping through a parent column, as a LEFT JOIN would. Parent rows are filtered by the
86
+ chart's predicate and paginated by 1000; a chart spanning more than 10 000 parent rows
87
+ fails with a clear error rather than returning partial numbers.
88
+ - **Tables without a primary key** (typically untracked views) are skipped: Forest cannot
89
+ address their records.
90
+ - Filtering and sorting through a polymorphic relation is not possible (a Forest Admin
91
+ limitation shared with the ActiveRecord datasource).
92
+ - Pattern operators (`contains`, `starts with`…) are only offered on genuine text columns.
93
+ Postgres enums and custom Hasura scalars get equality and nullity operators, because
94
+ their Hasura comparison expressions have no `_like`/`_ilike`. Text matching is
95
+ case-insensitive, like the ActiveRecord datasource.
96
+ - Nested creates/updates are out of scope: mutations write scalar columns (including
97
+ `jsonb`), never related records.
98
+ - A `*_type` value matching no exposed collection (a legacy STI subclass name, an excluded
99
+ target) leaves the reference empty and logs a warning, rather than failing the page.
100
+ - `bytea` and `money` columns are surfaced as text (Hasura returns them hex-encoded and in
101
+ Postgres money form respectively).
102
+ - Customized root fields (`custom_root_fields`, `custom_name`) and the `graphql-default`
103
+ naming convention are followed for introspection, metadata matching and query generation —
104
+ renamed mutation and aggregate roots included, as long as the metadata API is reachable to
105
+ declare them (unreachable metadata falls back to the derived names). One gap: Rails
106
+ polymorphism *detection* relies on snake_case `<base>_type`/`<base>_id` column pairs, so
107
+ camelized columns need the `polymorphic_relations` option.
108
+ - Errors Hasura returns (a permission rule, an invalid value) surface as HTTP 400 with the
109
+ original message; an unreachable endpoint (timeout, DNS, TLS, non-2xx response) surfaces
110
+ as HTTP 503, so infrastructure incidents stay visible to monitoring.
111
+
112
+ ## Validating against a real instance
113
+
114
+ `validation/` holds a Postgres + Hasura stack seeded with a Rails-like schema and an
115
+ end-to-end script covering the scenarios above:
116
+
117
+ ```bash
118
+ docker compose -f validation/docker-compose.yml up -d
119
+ bash validation/setup_hasura.sh
120
+ BUNDLE_GEMFILE=Gemfile-test bundle exec ruby validation/validate.rb
121
+ ```
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -0,0 +1,35 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)
3
+
4
+ require_relative 'lib/forest_admin_datasource_graphql_hasura/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'forest_admin_datasource_graphql_hasura'
8
+ spec.version = ForestAdminDatasourceGraphqlHasura::VERSION
9
+ spec.authors = ['Forest Admin']
10
+ spec.email = ['contact@forestadmin.com']
11
+ spec.homepage = 'https://www.forestadmin.com'
12
+ spec.summary = 'Hasura GraphQL datasource for Forest Admin Ruby agent.'
13
+ spec.description = 'Surface tables exposed by a Hasura GraphQL API as Forest Admin collections, ' \
14
+ 'including Rails-style polymorphic associations.'
15
+ spec.license = 'GPL-3.0'
16
+ spec.required_ruby_version = '>= 3.0.0'
17
+
18
+ spec.metadata['homepage_uri'] = spec.homepage
19
+ spec.metadata['source_code_uri'] = 'https://github.com/ForestAdmin/agent-ruby'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md'
21
+ spec.metadata['rubygems_mfa_required'] = 'false'
22
+
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ (File.expand_path(f) == __FILE__) ||
26
+ f.start_with?(*%w[bin/ test/ spec/ features/ validation/ .git .circleci appveyor Gemfile])
27
+ end
28
+ end
29
+ spec.bindir = 'exe'
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ['lib']
32
+
33
+ spec.add_dependency 'activesupport', '>= 6.1'
34
+ spec.add_dependency 'zeitwerk', '~> 2.3'
35
+ end
@@ -0,0 +1,117 @@
1
+ require 'json'
2
+ require 'openssl'
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ module ForestAdminDatasourceGraphqlHasura
7
+ # GraphQL-over-HTTP client for Hasura (queries, mutations and the metadata
8
+ # API), on Net::HTTP so the gem needs no HTTP dependency.
9
+ class Client
10
+ def initialize(configuration)
11
+ @configuration = configuration
12
+ end
13
+
14
+ # Wrapped in TransportError (503) so they reach the user as an actionable
15
+ # message without masquerading as a client mistake: errors Hasura itself
16
+ # returns are the only ones raised as GraphqlError (400).
17
+ TRANSPORT_ERRORS = [
18
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Net::HTTPBadResponse, IOError, SocketError,
19
+ SystemCallError, OpenSSL::SSL::SSLError, JSON::ParserError
20
+ ].freeze
21
+
22
+ def execute(query, variables = {})
23
+ body = JSON.generate({ query: query, variables: variables })
24
+ response = post(@configuration.uri, body)
25
+
26
+ raise TransportError, "GraphQL endpoint returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
27
+ # A 204 is a success with a nil body, which JSON.parse would turn into an
28
+ # unwrapped TypeError.
29
+ raise TransportError, 'GraphQL endpoint returned an empty body' if response.body.nil? || response.body.empty?
30
+
31
+ payload = JSON.parse(response.body)
32
+ raise TransportError, 'GraphQL endpoint returned an unexpected body' unless payload.is_a?(Hash)
33
+
34
+ if payload['errors']&.any?
35
+ messages = payload['errors'].map { |e| e['message'] }.join('; ')
36
+ raise GraphqlError, messages
37
+ end
38
+
39
+ # A 200 with neither data nor errors is malformed, and letting a nil out
40
+ # would crash the caller with an opaque NoMethodError.
41
+ data = payload['data']
42
+ raise TransportError, 'GraphQL endpoint returned no data' if data.nil?
43
+
44
+ data
45
+ rescue *TRANSPORT_ERRORS => e
46
+ raise TransportError, "Could not reach the GraphQL endpoint (#{e.class}): #{e.message}"
47
+ end
48
+
49
+ # Returns nil when the endpoint is unreachable or forbidden, which is common
50
+ # in production: introspection then falls back to the configuration and to
51
+ # naming conventions. Every fallback branch warns — losing the metadata
52
+ # silently disables polymorphism detection and custom root field
53
+ # resolution, and each cause deserves something to grep for.
54
+ def fetch_metadata
55
+ if @configuration.metadata_uri.nil?
56
+ return metadata_fallback("no metadata endpoint could be derived from uri (no '/v1/graphql' " \
57
+ "segment); set the 'metadata_uri' option")
58
+ end
59
+
60
+ body = JSON.generate({ type: 'export_metadata', version: 2, args: {} })
61
+ response = post(@configuration.metadata_uri, body)
62
+
63
+ return metadata_fallback("the metadata endpoint answered HTTP #{response.code}") unless
64
+ response.is_a?(Net::HTTPSuccess)
65
+
66
+ parse_metadata(response.body)
67
+ rescue *TRANSPORT_ERRORS => e
68
+ metadata_fallback("the metadata endpoint is not reachable (#{e.class})")
69
+ end
70
+
71
+ private
72
+
73
+ # Guards mirror Client#execute: a 204 passes the HTTPSuccess check with a
74
+ # nil body, and a JSON body need not be an object. Parse errors are caught
75
+ # here rather than by the transport rescue, whose "not reachable" message
76
+ # would be misleading — the endpoint did answer.
77
+ def parse_metadata(body)
78
+ return metadata_fallback('the metadata endpoint returned an empty body') if body.nil? || body.empty?
79
+
80
+ payload = JSON.parse(body)
81
+ return metadata_fallback("the metadata response is not a JSON object (#{payload.class})") unless
82
+ payload.is_a?(Hash)
83
+
84
+ metadata = payload['metadata'] || payload
85
+ return metadata if metadata.is_a?(Hash) && metadata['sources']
86
+
87
+ shape = metadata.is_a?(Hash) ? "top-level keys: #{metadata.keys.first(5).join(", ")}" : metadata.class
88
+ metadata_fallback("the metadata response carries no sources (#{shape})")
89
+ rescue JSON::ParserError
90
+ metadata_fallback('the metadata response is not valid JSON')
91
+ end
92
+
93
+ def metadata_fallback(reason)
94
+ ForestAdminDatasourceGraphqlHasura.logger.warn(
95
+ "[forest_admin_datasource_graphql_hasura] Hasura metadata unavailable: #{reason}; " \
96
+ 'falling back to configuration and naming conventions.'
97
+ )
98
+ nil
99
+ end
100
+
101
+ def post(url, body)
102
+ uri = URI.parse(url)
103
+ http = Net::HTTP.new(uri.host, uri.port)
104
+ http.use_ssl = uri.scheme == 'https'
105
+ http.read_timeout = @configuration.timeout
106
+ http.open_timeout = @configuration.timeout
107
+ http.write_timeout = @configuration.timeout
108
+
109
+ request = Net::HTTP::Post.new(uri.request_uri)
110
+ request['Content-Type'] = 'application/json'
111
+ @configuration.headers.each { |key, value| request[key] = value }
112
+ request.body = body
113
+
114
+ http.request(request)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,180 @@
1
+ module ForestAdminDatasourceGraphqlHasura
2
+ class Collection < ForestAdminDatasourceToolkit::Collection
3
+ ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException
4
+ Projection = ForestAdminDatasourceToolkit::Components::Query::Projection
5
+
6
+ PLACEHOLDER_REFERENCE = { '*' => nil }.freeze
7
+
8
+ attr_reader :table_name, :names
9
+
10
+ def initialize(datasource, table, client, converter)
11
+ super(datasource, converter.collection_name_of(table.name))
12
+
13
+ @table = table
14
+ @table_name = table.name
15
+ # root is the select root field; base (the GraphQL type name) is what
16
+ # generated type names derive from; the operation roots carry their own
17
+ # resolved names, custom or derived. See Query::QueryBuilder.
18
+ @names = { root: table.name, base: table.type_name }.merge(table.root_fields || {})
19
+ @client = client
20
+ @converter = converter
21
+
22
+ add_fields(converter.build_fields(table))
23
+ enable_count
24
+ schema[:aggregation_capabilities][:supported_date_operations] = []
25
+ end
26
+
27
+ def list(_caller, filter, projection)
28
+ selection = build_selection(projection)
29
+ operation = Query::QueryBuilder.list(@names, filter, selection)
30
+ records = execute(:list, operation)[@names[:root]] || []
31
+
32
+ records.map { |record| materialize_polymorphics(record, projection) }
33
+ end
34
+
35
+ def create(_caller, data)
36
+ operation = Query::QueryBuilder.create(@names, [writable_columns(data)], column_names)
37
+ returning = execute(:create, operation).dig(@names[:insert], 'returning')
38
+
39
+ raise GraphqlError, "No record returned by #{@names[:insert]}" if returning.nil? || returning.empty?
40
+
41
+ returning.first
42
+ end
43
+
44
+ def update(_caller, filter, data)
45
+ if empty_condition?(filter)
46
+ raise ForestException,
47
+ "Refusing to update every row of '#{name}': the filter carries no condition."
48
+ end
49
+
50
+ operation = Query::QueryBuilder.update(@names, filter, writable_columns(data))
51
+ execute(:update, operation)
52
+ end
53
+
54
+ def delete(_caller, filter)
55
+ operation = Query::QueryBuilder.delete(@names, filter)
56
+ execute(:delete, operation)
57
+ end
58
+
59
+ def aggregate(_caller, filter, aggregation, limit = nil)
60
+ Query::Aggregator.new(self).run(filter, aggregation, limit)
61
+ end
62
+
63
+ # Whether a relationship rests on a real foreign key constraint — only the
64
+ # Hasura metadata knows, and only introspection saw it. False when manual
65
+ # or when the metadata was unreachable (constraint unproven).
66
+ def constraint_backed?(relation_name)
67
+ @table.relationships.any? { |rel| rel.name == relation_name && rel.manual == false }
68
+ end
69
+
70
+ # Wraps every Hasura call so the failing operation is named in the error,
71
+ # keeping the class (GraphqlError or TransportError) and thus the status.
72
+ def execute(operation_name, operation)
73
+ @client.execute(operation[:query], operation[:variables])
74
+ rescue GraphqlError, TransportError => e
75
+ raise e.class, "GraphQL #{operation_name} failed on '#{name}': #{e.message}"
76
+ end
77
+
78
+ protected
79
+
80
+ # A PolymorphicManyToOne cannot be joined by Hasura, so its discriminator
81
+ # columns are selected instead and the relation is rebuilt by the serializer
82
+ # from those two values (see materialize_polymorphics). Protected: called on
83
+ # the target collection to resolve nested selections.
84
+ def build_selection(projection)
85
+ selection = projection.columns.reject { |column| column == '*' }
86
+
87
+ projection.relations.each do |relation_name, relation_projection|
88
+ field = schema[:fields][relation_name]
89
+ next if field.nil?
90
+
91
+ if field.type == 'PolymorphicManyToOne'
92
+ selection << field.foreign_key
93
+ selection << field.foreign_key_type_field
94
+ else
95
+ target = datasource.get_collection(field.foreign_collection)
96
+ nested = target.build_selection(relation_projection)
97
+ selection << "#{relation_name} { #{nested.join(" ")} }"
98
+ end
99
+ end
100
+
101
+ selection = selection.uniq
102
+ # An empty projection is a valid toolkit input, but `table { }` is not
103
+ # valid GraphQL: fall back to the primary key.
104
+ selection.empty? ? Array(@table.primary_key.first || column_names.first) : selection
105
+ end
106
+
107
+ # The serializer reads the reference off the discriminator columns, so the
108
+ # relation key only carries a placeholder — which has to be non-empty, since
109
+ # an empty hash drops the relation from the JSON:API payload. A type matching
110
+ # no exposed collection stays unresolved: the serializer looks the collection
111
+ # up by that raw value and would fail the whole page. Nested records walk
112
+ # down to their own collection, mirroring build_selection: a projection can
113
+ # reach a polymorphic relation through an ordinary one. Protected, like
114
+ # build_selection, so target collections can be delegated to.
115
+ def materialize_polymorphics(record, projection)
116
+ projection.relations.each do |relation_name, relation_projection|
117
+ field = schema[:fields][relation_name]
118
+ next if field.nil?
119
+
120
+ if field.type == 'PolymorphicManyToOne'
121
+ materialize_placeholder(record, relation_name, field)
122
+ else
123
+ materialize_nested(record[relation_name], field, relation_projection)
124
+ end
125
+ end
126
+
127
+ record
128
+ end
129
+
130
+ private
131
+
132
+ def column_names
133
+ @column_names ||= @table.columns.map(&:name)
134
+ end
135
+
136
+ # Selects on the schema rather than on the value type, so that a `jsonb`
137
+ # column — whose value is a hash, like a relation payload would be — is kept.
138
+ def writable_columns(data)
139
+ data.select { |key, _| column_names.include?(key.to_s) }
140
+ end
141
+
142
+ def materialize_placeholder(record, relation_name, field)
143
+ type_value = record[field.foreign_key_type_field]
144
+ resolvable = type_value && field.foreign_key_targets.key?(type_value.to_s.gsub('::', '__'))
145
+ warn_unknown_type(relation_name, type_value) if type_value && !resolvable
146
+
147
+ # An explicit nil check: false is a legitimate key value on a boolean
148
+ # primary key, absent is not.
149
+ record[relation_name] = resolvable && !record[field.foreign_key].nil? ? PLACEHOLDER_REFERENCE : nil
150
+ end
151
+
152
+ def materialize_nested(nested, field, relation_projection)
153
+ target = datasource.get_collection(field.foreign_collection)
154
+
155
+ case nested
156
+ when Hash then target.materialize_polymorphics(nested, relation_projection)
157
+ when Array then nested.each { |row| target.materialize_polymorphics(row, relation_projection) }
158
+ end
159
+ end
160
+
161
+ def warn_unknown_type(relation_name, type_value)
162
+ @warned_types ||= Set.new
163
+ return unless @warned_types.add?("#{relation_name}/#{type_value}")
164
+
165
+ ForestAdminDatasourceGraphqlHasura.logger.warn(
166
+ "[forest_admin_datasource_graphql_hasura] '#{name}.#{relation_name}' references the type " \
167
+ "'#{type_value}', which matches no exposed collection; those references are shown empty. " \
168
+ "Use the 'type_values' option if the Rails class name differs from the table name."
169
+ )
170
+ end
171
+
172
+ # Only guards update: a bulk delete with "select all" legitimately carries no
173
+ # condition, and wiping is then the requested semantic.
174
+ def empty_condition?(filter)
175
+ condition = Query::FilterConverter.convert(filter&.condition_tree)
176
+
177
+ condition.nil? || condition.empty?
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,72 @@
1
+ module ForestAdminDatasourceGraphqlHasura
2
+ class Configuration
3
+ # polymorphic_relations declares associations explicitly when the metadata API
4
+ # is unreachable: { 'comments' => { 'commentable' => %w[transfers cards] } }.
5
+ # type_values overrides the Rails class name a table maps to, for those that
6
+ # `classify` gets wrong: { 'bank_accounts' => 'Banking::Account' }.
7
+ DEFAULTS = {
8
+ headers: {},
9
+ metadata_uri: nil,
10
+ included_tables: nil,
11
+ excluded_tables: [],
12
+ polymorphic_relations: {},
13
+ type_values: {},
14
+ timeout: 30
15
+ }.freeze
16
+
17
+ attr_reader :uri, :headers, :metadata_uri, :included_tables, :excluded_tables,
18
+ :polymorphic_relations, :type_values, :timeout
19
+
20
+ def initialize(uri:, **options)
21
+ raise ConfigurationError, 'uri is required' if uri.nil? || uri.empty?
22
+
23
+ unknown = options.keys - DEFAULTS.keys
24
+ raise ConfigurationError, "Unknown option(s): #{unknown.join(", ")}" if unknown.any?
25
+
26
+ @uri = uri
27
+ # An explicit nil means "the default" (`headers: nil` must not crash every
28
+ # request), and `default.dup` keeps the DEFAULTS hashes and arrays from
29
+ # being shared — and mutated — across Configuration instances.
30
+ DEFAULTS.each do |option, default|
31
+ instance_variable_set("@#{option}", options[option].nil? ? default.dup : options[option])
32
+ end
33
+ validate_polymorphic_relations
34
+ validate_table_lists
35
+ # Only derivable from the conventional endpoint path: substituting on any
36
+ # other uri would silently post metadata commands to the GraphQL endpoint.
37
+ @metadata_uri ||= uri.include?('/v1/graphql') ? uri.sub('/v1/graphql', '/v1/metadata') : nil
38
+ end
39
+
40
+ # Accepts every spelling a table goes by (root field and type name): an
41
+ # exclusion must hold under renaming, or it would silently re-expose data.
42
+ def table_allowed?(*table_names)
43
+ return false if table_names.any? { |name| excluded_tables.include?(name) }
44
+ return (table_names & included_tables).any? unless included_tables.nil?
45
+
46
+ true
47
+ end
48
+
49
+ private
50
+
51
+ # A String by mistake (`included_tables: "users"`) would silently become a
52
+ # substring check instead of a name match.
53
+ def validate_table_lists
54
+ return if excluded_tables.is_a?(Array) && (included_tables.nil? || included_tables.is_a?(Array))
55
+
56
+ raise ConfigurationError, 'included_tables and excluded_tables must be arrays of table names'
57
+ end
58
+
59
+ # A misshapen declaration would otherwise crash deep inside introspection as
60
+ # an opaque NoMethodError instead of naming the option.
61
+ def validate_polymorphic_relations
62
+ valid = polymorphic_relations.is_a?(Hash) && polymorphic_relations.all? do |_, bases|
63
+ bases.is_a?(Hash) && bases.all? { |_, targets| targets.is_a?(Array) }
64
+ end
65
+
66
+ return if valid
67
+
68
+ raise ConfigurationError,
69
+ "polymorphic_relations must be { 'table' => { 'association' => ['target', ...] } }"
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,81 @@
1
+ module ForestAdminDatasourceGraphqlHasura
2
+ class Datasource < ForestAdminDatasourceToolkit::Datasource
3
+ attr_reader :client, :configuration
4
+
5
+ def initialize(uri:, **options)
6
+ super()
7
+
8
+ @configuration = Configuration.new(uri: uri, **options)
9
+ @client = Client.new(@configuration)
10
+
11
+ register_collections
12
+ end
13
+
14
+ private
15
+
16
+ def register_collections
17
+ tables = Introspection::Introspector.new(@client, @configuration).introspect
18
+ tables = deduplicate_collection_names(tables)
19
+ # Detection runs on the surviving tables only, so a polymorphic target
20
+ # can never carry the primary key of a table dedup dropped.
21
+ Introspection::PolymorphismDetector.new(@configuration).detect(tables)
22
+ converter = Introspection::SchemaConverter.new(tables, @configuration)
23
+
24
+ tables.each do |table|
25
+ add_collection(Collection.new(self, table, @client, converter))
26
+ end
27
+
28
+ mark_groupable_foreign_keys
29
+
30
+ ForestAdminDatasourceGraphqlHasura.logger.info(
31
+ "[forest_admin_datasource_graphql_hasura] #{tables.size} collections registered " \
32
+ "(#{tables.sum { |table| table.polymorphics.size }} polymorphic relations detected)."
33
+ )
34
+ end
35
+
36
+ # `user_status` and `user_statuses` both classify to `UserStatus`, and the
37
+ # toolkit refuses a duplicate collection name with an error that names
38
+ # neither table: keep the first (alphabetically, for determinism) and say
39
+ # which tables collided and how to fix it.
40
+ def deduplicate_collection_names(tables)
41
+ converter = Introspection::SchemaConverter.new(tables, @configuration)
42
+
43
+ tables.group_by { |table| converter.collection_name_of(table.name) }.flat_map do |name, group|
44
+ next group.first if group.size == 1
45
+
46
+ kept, *dropped = group.sort_by(&:name)
47
+ ForestAdminDatasourceGraphqlHasura.logger.warn(
48
+ "[forest_admin_datasource_graphql_hasura] Tables #{group.map(&:name).sort.join(", ")} all map " \
49
+ "to the collection name '#{name}'; only '#{kept.name}' is exposed " \
50
+ "(#{dropped.map(&:name).join(", ")} skipped). Disambiguate with the 'type_values' option."
51
+ )
52
+ kept
53
+ end
54
+ end
55
+
56
+ # The capabilities route publishes is_groupable, and grouping goes through the
57
+ # parent's nested `<relation>_aggregate`, which only exists when Hasura
58
+ # declares the reverse array relationship: marking a foreign key without one
59
+ # would have the UI offer a group-by that the aggregator then rejects.
60
+ def mark_groupable_foreign_keys
61
+ collections.each_value do |collection|
62
+ collection.schema[:fields].each_value do |field|
63
+ next unless field.type == 'ManyToOne' && reverse_declared?(collection, field)
64
+
65
+ foreign_key = collection.schema[:fields][field.foreign_key]
66
+ foreign_key.is_groupable = true if foreign_key.respond_to?(:is_groupable=)
67
+ end
68
+ end
69
+ end
70
+
71
+ def reverse_declared?(collection, relation)
72
+ parent = get_collection(relation.foreign_collection)
73
+
74
+ parent.schema[:fields].each_value.any? do |field|
75
+ field.type == 'OneToMany' &&
76
+ field.foreign_collection == collection.name &&
77
+ field.origin_key == relation.foreign_key
78
+ end
79
+ end
80
+ end
81
+ end