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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/LICENSE +674 -0
- data/README.md +121 -0
- data/Rakefile +6 -0
- data/forest_admin_datasource_graphql_hasura.gemspec +35 -0
- data/lib/forest_admin_datasource_graphql_hasura/client.rb +117 -0
- data/lib/forest_admin_datasource_graphql_hasura/collection.rb +180 -0
- data/lib/forest_admin_datasource_graphql_hasura/configuration.rb +72 -0
- data/lib/forest_admin_datasource_graphql_hasura/datasource.rb +81 -0
- data/lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb +427 -0
- data/lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb +146 -0
- data/lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb +339 -0
- data/lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb +24 -0
- data/lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb +431 -0
- data/lib/forest_admin_datasource_graphql_hasura/query/filter_converter.rb +117 -0
- data/lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb +260 -0
- data/lib/forest_admin_datasource_graphql_hasura/version.rb +3 -0
- data/lib/forest_admin_datasource_graphql_hasura.rb +49 -0
- metadata +94 -0
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,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
|