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
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
require 'active_support/core_ext/string/inflections'
|
|
2
|
+
|
|
3
|
+
module ForestAdminDatasourceGraphqlHasura
|
|
4
|
+
module Introspection
|
|
5
|
+
# Builds the datasource structure from the GraphQL introspection query
|
|
6
|
+
# (tables, columns, relationship fields) and the Hasura metadata API
|
|
7
|
+
# (relationship column mappings), when the latter is reachable.
|
|
8
|
+
class Introspector
|
|
9
|
+
INTROSPECTION_QUERY = <<~GRAPHQL.freeze
|
|
10
|
+
query IntrospectSchema {
|
|
11
|
+
__schema {
|
|
12
|
+
types {
|
|
13
|
+
name
|
|
14
|
+
kind
|
|
15
|
+
fields {
|
|
16
|
+
name
|
|
17
|
+
type { ...TypeRef }
|
|
18
|
+
}
|
|
19
|
+
enumValues { name }
|
|
20
|
+
}
|
|
21
|
+
queryType {
|
|
22
|
+
name
|
|
23
|
+
fields {
|
|
24
|
+
name
|
|
25
|
+
type { ...TypeRef }
|
|
26
|
+
args {
|
|
27
|
+
name
|
|
28
|
+
type { name kind ofType { name kind } }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
fragment TypeRef on __Type {
|
|
35
|
+
name
|
|
36
|
+
kind
|
|
37
|
+
ofType {
|
|
38
|
+
name
|
|
39
|
+
kind
|
|
40
|
+
ofType {
|
|
41
|
+
name
|
|
42
|
+
kind
|
|
43
|
+
ofType { name kind }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
GRAPHQL
|
|
48
|
+
|
|
49
|
+
SCALAR_TYPES = %w[
|
|
50
|
+
Int Float String Boolean ID
|
|
51
|
+
uuid timestamptz timestamp date time timetz jsonb json numeric bigint smallint
|
|
52
|
+
integer real double_precision text varchar char bpchar bytea inet cidr macaddr
|
|
53
|
+
money interval bit xml citext
|
|
54
|
+
_text _int4 _uuid _jsonb
|
|
55
|
+
].to_set.freeze
|
|
56
|
+
|
|
57
|
+
# A Postgres enum, or any scalar Hasura exposes under a custom name
|
|
58
|
+
# (`macaddr`, a domain type…), displays as a string but has no
|
|
59
|
+
# `_like`/`_ilike` in its comparison expression.
|
|
60
|
+
TEXT_TYPES = %w[String ID text varchar char bpchar citext bytea].to_set.freeze
|
|
61
|
+
|
|
62
|
+
EXCLUDED_PREFIXES = %w[__ hdb_ pg_ information_schema].freeze
|
|
63
|
+
|
|
64
|
+
def initialize(client, configuration)
|
|
65
|
+
@client = client
|
|
66
|
+
@configuration = configuration
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Array<Table>]
|
|
70
|
+
def introspect
|
|
71
|
+
types, query_fields = introspection_payload
|
|
72
|
+
metadata = @client.fetch_metadata
|
|
73
|
+
|
|
74
|
+
@type_map = build_type_map(types)
|
|
75
|
+
@custom_root_fields = {}
|
|
76
|
+
@relationship_mappings = metadata ? safe_relationship_mappings(metadata) : {}
|
|
77
|
+
@primary_keys = parse_primary_keys(query_fields)
|
|
78
|
+
|
|
79
|
+
parse_tables(query_fields)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# A gateway can answer 200 with `__schema: null` or partial objects when
|
|
85
|
+
# introspection is disabled: better a named error than a NoMethodError.
|
|
86
|
+
def introspection_payload
|
|
87
|
+
response = @client.execute(INTROSPECTION_QUERY)
|
|
88
|
+
schema = response.is_a?(Hash) ? response['__schema'] : nil
|
|
89
|
+
types = schema.is_a?(Hash) ? schema['types'] : nil
|
|
90
|
+
query_fields = schema.is_a?(Hash) ? schema.dig('queryType', 'fields') : nil
|
|
91
|
+
|
|
92
|
+
unless types.is_a?(Array) && query_fields.is_a?(Array)
|
|
93
|
+
raise IntrospectionError,
|
|
94
|
+
'The introspection response carries no usable schema: is GraphQL introspection ' \
|
|
95
|
+
'enabled on this endpoint?'
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
[types, query_fields]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The metadata is optional by design; one malformed entry must degrade to
|
|
102
|
+
# the same fallback as an unreachable endpoint, not crash the boot.
|
|
103
|
+
# Only shape errors degrade: anything else is a bug that must fail loudly.
|
|
104
|
+
def safe_relationship_mappings(metadata)
|
|
105
|
+
parse_relationship_mappings(metadata)
|
|
106
|
+
rescue TypeError, NoMethodError, KeyError => e
|
|
107
|
+
ForestAdminDatasourceGraphqlHasura.logger.warn(
|
|
108
|
+
'[forest_admin_datasource_graphql_hasura] Hasura metadata could not be parsed ' \
|
|
109
|
+
"(#{e.class}: #{e.message}); falling back to configuration and naming conventions."
|
|
110
|
+
)
|
|
111
|
+
@custom_root_fields = {}
|
|
112
|
+
{}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_type_map(types)
|
|
116
|
+
types.each_with_object({}) { |type, memo| memo[type['name']] = type if type['name'] }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Keyed by GraphQL root field, which Hasura derives from the table name,
|
|
120
|
+
# prefixed by the schema outside of `public` — hence the two spellings. A
|
|
121
|
+
# name claimed by two schemas is dropped rather than guessed: inheriting
|
|
122
|
+
# another schema's mapping would silently produce wrong foreign keys.
|
|
123
|
+
def parse_relationship_mappings(metadata)
|
|
124
|
+
mappings = {}
|
|
125
|
+
ambiguous = Set.new
|
|
126
|
+
|
|
127
|
+
(metadata['sources'] || []).each do |source|
|
|
128
|
+
(source['tables'] || []).each do |table|
|
|
129
|
+
collect_table_mappings(table, mappings, ambiguous)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
ambiguous.each do |key|
|
|
134
|
+
table_name = key.split('.').first
|
|
135
|
+
ForestAdminDatasourceGraphqlHasura.logger.warn(
|
|
136
|
+
"[forest_admin_datasource_graphql_hasura] Table name '#{table_name}' is tracked in several " \
|
|
137
|
+
'Postgres schemas; its relationship metadata is ambiguous and therefore ignored.'
|
|
138
|
+
)
|
|
139
|
+
mappings.delete(key)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
mappings
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def collect_table_mappings(table, mappings, ambiguous)
|
|
146
|
+
table_info = table['table']
|
|
147
|
+
return unless table_info.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
exposed = exposed_root_field(table, table_info)
|
|
150
|
+
custom = normalized_root_fields(table)
|
|
151
|
+
@custom_root_fields[exposed] = custom if custom.any?
|
|
152
|
+
|
|
153
|
+
relationships = (table['object_relationships'] || []).map { |rel| [rel, :object] } +
|
|
154
|
+
(table['array_relationships'] || []).map { |rel| [rel, :array] }
|
|
155
|
+
|
|
156
|
+
relationships.each do |(rel, kind)|
|
|
157
|
+
entry = relationship_mapping(rel, kind)
|
|
158
|
+
next if entry.nil?
|
|
159
|
+
|
|
160
|
+
# The graphql-default naming convention camelizes root fields,
|
|
161
|
+
# relationship fields and columns, while the metadata keeps the
|
|
162
|
+
# Postgres spellings; registering both makes the lookup match — and
|
|
163
|
+
# carry column names in — whichever spelling introspection exposes.
|
|
164
|
+
# When both spellings coincide the snake entry stands: a wrong-case
|
|
165
|
+
# mapping degrades to a skipped relationship, never a wrong one.
|
|
166
|
+
snake_key = "#{exposed}.#{rel["name"]}"
|
|
167
|
+
camel_key = "#{exposed.camelize(:lower)}.#{rel["name"].camelize(:lower)}"
|
|
168
|
+
register_mapping(mappings, ambiguous, snake_key, entry)
|
|
169
|
+
register_mapping(mappings, ambiguous, camel_key, camelized_entry(entry)) unless camel_key == snake_key
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def register_mapping(mappings, ambiguous, key, entry)
|
|
174
|
+
ambiguous << key if mappings.key?(key) && mappings[key] != entry
|
|
175
|
+
mappings[key] = entry
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def camelized_entry(entry)
|
|
179
|
+
mapping = entry[:mapping]&.to_h { |local, remote| [local&.camelize(:lower), remote&.camelize(:lower)] }
|
|
180
|
+
|
|
181
|
+
{ mapping: mapping, manual: entry[:manual] }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The mapping key has to be the root field the introspection query will
|
|
185
|
+
# show. Hasura derives it from the table name — prefixed by the schema
|
|
186
|
+
# outside of `public`, so a bare name can only be the public table —
|
|
187
|
+
# unless the metadata customizes it (`custom_root_fields.select` wins
|
|
188
|
+
# over `custom_name`, which replaces the derived name).
|
|
189
|
+
def exposed_root_field(table, table_info)
|
|
190
|
+
custom = table.dig('configuration', 'custom_root_fields', 'select')
|
|
191
|
+
custom = custom['name'] if custom.is_a?(Hash)
|
|
192
|
+
custom ||= table.dig('configuration', 'custom_name')
|
|
193
|
+
return custom if custom.is_a?(String)
|
|
194
|
+
|
|
195
|
+
schema_name = table_info['schema']
|
|
196
|
+
table_name = table_info['name']
|
|
197
|
+
|
|
198
|
+
schema_name.nil? || schema_name == 'public' ? table_name : "#{schema_name}_#{table_name}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Every custom root field, values flattened to their string form (the
|
|
202
|
+
# metadata also allows { name:, comment: } objects).
|
|
203
|
+
def normalized_root_fields(table)
|
|
204
|
+
config = table.dig('configuration', 'custom_root_fields')
|
|
205
|
+
return {} unless config.is_a?(Hash)
|
|
206
|
+
|
|
207
|
+
flattened = config.transform_values { |value| value.is_a?(Hash) ? value['name'] : value }
|
|
208
|
+
|
|
209
|
+
flattened.select { |_, value| value.is_a?(String) }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# A nil column stands for the primary key of that table: a foreign key
|
|
213
|
+
# constraint may reference any unique column, and which one is only
|
|
214
|
+
# resolvable once the tables are parsed.
|
|
215
|
+
def relationship_mapping(rel, kind)
|
|
216
|
+
using = rel['using']
|
|
217
|
+
return nil unless using.is_a?(Hash)
|
|
218
|
+
|
|
219
|
+
constraint = using['foreign_key_constraint_on']
|
|
220
|
+
manual = using['manual_configuration']
|
|
221
|
+
|
|
222
|
+
if constraint
|
|
223
|
+
mapping = kind == :object ? { constraint => nil } : { nil => constraint['column'] }
|
|
224
|
+
|
|
225
|
+
{ mapping: mapping, manual: false }
|
|
226
|
+
elsif manual
|
|
227
|
+
{ mapping: manual['column_mapping'], manual: true }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Keyed by the GraphQL OBJECT type the field returns, which the list root
|
|
232
|
+
# field shares whatever the root fields are renamed to — deriving a table
|
|
233
|
+
# name from the `_by_pk` spelling would miss a customized select field.
|
|
234
|
+
def parse_primary_keys(query_fields)
|
|
235
|
+
query_fields.each_with_object({}) do |field, memo|
|
|
236
|
+
next unless field['name'].end_with?('_by_pk', 'ByPk')
|
|
237
|
+
|
|
238
|
+
type_name = base_type_name(field['type'])
|
|
239
|
+
pk_fields = (field['args'] || []).map { |arg| arg['name'] }
|
|
240
|
+
memo[type_name] = pk_fields if pk_fields.any?
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def parse_tables(query_fields)
|
|
245
|
+
root_names = query_fields.to_set { |query_field| query_field['name'] }
|
|
246
|
+
|
|
247
|
+
query_fields.filter_map do |field|
|
|
248
|
+
table_name = field['name']
|
|
249
|
+
# Only the select root returns a list: _aggregate, _by_pk and Relay
|
|
250
|
+
# _connection roots all return bare objects and are rejected here.
|
|
251
|
+
next unless array_type?(field['type'])
|
|
252
|
+
|
|
253
|
+
type = @type_map[base_type_name(field['type'])]
|
|
254
|
+
next unless type && type['kind'] == 'OBJECT'
|
|
255
|
+
next if skip_table?([table_name, type['name']].uniq)
|
|
256
|
+
next if stream_companion?(table_name, root_names)
|
|
257
|
+
|
|
258
|
+
table = parse_table(table_name, type)
|
|
259
|
+
next table unless table.primary_key.empty?
|
|
260
|
+
|
|
261
|
+
# Forest cannot address a record without a primary key: ids, detail view
|
|
262
|
+
# and every write would fail.
|
|
263
|
+
ForestAdminDatasourceGraphqlHasura.logger.warn(
|
|
264
|
+
"[forest_admin_datasource_graphql_hasura] Skipping table '#{table_name}': no primary key found. " \
|
|
265
|
+
'Expose one in Hasura (a tracked primary key or an `id` column) to surface it in Forest Admin.'
|
|
266
|
+
)
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# names carries the root field and the underlying type name: an exclusion
|
|
272
|
+
# (or inclusion) must hold whichever spelling the user wrote, or renaming
|
|
273
|
+
# a table would silently re-expose it.
|
|
274
|
+
def skip_table?(names)
|
|
275
|
+
# An explicit exclusion always wins; then an explicit allow-list wins over
|
|
276
|
+
# the built-in exclusions, so a legitimate table whose name starts like a
|
|
277
|
+
# system one stays reachable.
|
|
278
|
+
return true if names.any? { |name| @configuration.excluded_tables.include?(name) }
|
|
279
|
+
return false if @configuration.included_tables && (names & @configuration.included_tables).any?
|
|
280
|
+
|
|
281
|
+
names.any? { |name| EXCLUDED_PREFIXES.any? { |prefix| name.start_with?(prefix) } } ||
|
|
282
|
+
!@configuration.table_allowed?(*names)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# `<select_root>_stream` returns the same list shape as its select root,
|
|
286
|
+
# so the structural check cannot tell them apart; a genuine table named
|
|
287
|
+
# `data_stream` has no `data` root field and is kept.
|
|
288
|
+
def stream_companion?(name, root_names)
|
|
289
|
+
base = name.sub(/(_stream|Stream)\z/, '')
|
|
290
|
+
|
|
291
|
+
base != name && root_names.include?(base)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def parse_table(table_name, type)
|
|
295
|
+
fields = (type['fields'] || []).reject { |field| companion_field?(field) }
|
|
296
|
+
scalars, relations = fields.partition { |field| scalar?(base_type_name(field['type'])) }
|
|
297
|
+
columns = scalars.map { |field| parse_column(field, base_type_name(field['type'])) }
|
|
298
|
+
|
|
299
|
+
Table.new(
|
|
300
|
+
name: table_name,
|
|
301
|
+
type_name: type['name'],
|
|
302
|
+
columns: columns,
|
|
303
|
+
primary_key: resolve_primary_key(type['name'], columns),
|
|
304
|
+
relationships: relations.map { |field| parse_relationship(table_name, field) },
|
|
305
|
+
polymorphics: [],
|
|
306
|
+
root_fields: resolve_root_fields(table_name, type['name'])
|
|
307
|
+
)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# The operation roots derive from the type name unless the metadata
|
|
311
|
+
# renames them — same resolution the select root already gets.
|
|
312
|
+
def resolve_root_fields(table_name, type_name)
|
|
313
|
+
custom = @custom_root_fields[table_name] || {}
|
|
314
|
+
|
|
315
|
+
{
|
|
316
|
+
aggregate: custom['select_aggregate'] || "#{type_name}_aggregate",
|
|
317
|
+
insert: custom['insert'] || "insert_#{type_name}",
|
|
318
|
+
update: custom['update'] || "update_#{type_name}",
|
|
319
|
+
delete: custom['delete'] || "delete_#{type_name}"
|
|
320
|
+
}
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Introspection metadata and the `<relation>_aggregate` objects Hasura adds
|
|
324
|
+
# next to every array relationship. The suffix alone is not enough: a scalar
|
|
325
|
+
# column may legitimately be named `total_aggregate`.
|
|
326
|
+
def companion_field?(field)
|
|
327
|
+
return true if field['name'].start_with?('__')
|
|
328
|
+
|
|
329
|
+
field['name'].end_with?('_aggregate') && !scalar?(base_type_name(field['type']))
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def parse_relationship(table_name, field)
|
|
333
|
+
entry = @relationship_mappings["#{table_name}.#{field["name"]}"]
|
|
334
|
+
|
|
335
|
+
Relationship.new(
|
|
336
|
+
name: field['name'],
|
|
337
|
+
kind: array_type?(field['type']) ? :array : :object,
|
|
338
|
+
remote_table: base_type_name(field['type']),
|
|
339
|
+
mapping: entry&.fetch(:mapping),
|
|
340
|
+
manual: entry ? entry[:manual] : nil
|
|
341
|
+
)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def parse_column(field, type_name)
|
|
345
|
+
# Postgres array columns are exposed by Hasura as custom scalars named
|
|
346
|
+
# after the element type (`_text`, `_int4`), not as GraphQL lists.
|
|
347
|
+
is_array = array_type?(field['type']) || type_name.start_with?('_')
|
|
348
|
+
|
|
349
|
+
Column.new(
|
|
350
|
+
name: field['name'],
|
|
351
|
+
type: map_column_type(type_name),
|
|
352
|
+
graphql_type: type_name,
|
|
353
|
+
nullable: field['type']['kind'] != 'NON_NULL',
|
|
354
|
+
is_primary_key: false,
|
|
355
|
+
is_array: is_array,
|
|
356
|
+
is_text: TEXT_TYPES.include?(type_name)
|
|
357
|
+
)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Hasura only generates a `_by_pk` query for a tracked table that has a
|
|
361
|
+
# primary key, which makes it the one trustworthy signal. Inferring a key
|
|
362
|
+
# from an `id` column would address records of a view — or of a tracked
|
|
363
|
+
# function — through a column that carries no uniqueness.
|
|
364
|
+
def resolve_primary_key(type_name, columns)
|
|
365
|
+
known = @primary_keys[type_name]
|
|
366
|
+
|
|
367
|
+
if known
|
|
368
|
+
columns.each { |column| column.is_primary_key = known.include?(column.name) }
|
|
369
|
+
|
|
370
|
+
return known
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
[]
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def scalar?(type_name)
|
|
377
|
+
return true if SCALAR_TYPES.include?(type_name)
|
|
378
|
+
|
|
379
|
+
type = @type_map[type_name]
|
|
380
|
+
type ? %w[SCALAR ENUM].include?(type['kind']) : false
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def array_type?(type_ref)
|
|
384
|
+
return false if type_ref.nil?
|
|
385
|
+
return true if type_ref['kind'] == 'LIST'
|
|
386
|
+
|
|
387
|
+
array_type?(type_ref['ofType'])
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def base_type_name(type_ref)
|
|
391
|
+
return 'Unknown' if type_ref.nil?
|
|
392
|
+
|
|
393
|
+
type_ref['name'] || base_type_name(type_ref['ofType'])
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Hasura names a Postgres array scalar after its element type, prefixed with
|
|
397
|
+
# an underscore (`_int4`), so the element type drives the mapping.
|
|
398
|
+
def map_column_type(graphql_type)
|
|
399
|
+
graphql_type = graphql_type.delete_prefix('_')
|
|
400
|
+
|
|
401
|
+
{
|
|
402
|
+
'Int' => 'Number', 'Float' => 'Number', 'numeric' => 'Number', 'bigint' => 'Number',
|
|
403
|
+
'smallint' => 'Number', 'integer' => 'Number', 'real' => 'Number',
|
|
404
|
+
'double_precision' => 'Number',
|
|
405
|
+
# Text, not Number: Hasura serializes money in its Postgres text form
|
|
406
|
+
# ("$1,100.00"), which no numeric aggregation can consume.
|
|
407
|
+
'money' => 'String',
|
|
408
|
+
# Internal Postgres names, which is how Hasura names array element types
|
|
409
|
+
'int2' => 'Number', 'int4' => 'Number', 'int8' => 'Number',
|
|
410
|
+
'float4' => 'Number', 'float8' => 'Number', 'bool' => 'Boolean',
|
|
411
|
+
'String' => 'String', 'text' => 'String', 'varchar' => 'String', 'char' => 'String',
|
|
412
|
+
'bpchar' => 'String', 'citext' => 'String', 'inet' => 'String', 'ID' => 'String',
|
|
413
|
+
'Boolean' => 'Boolean',
|
|
414
|
+
'uuid' => 'Uuid',
|
|
415
|
+
'timestamptz' => 'Date', 'timestamp' => 'Date',
|
|
416
|
+
'date' => 'Dateonly',
|
|
417
|
+
'time' => 'Time', 'timetz' => 'Time',
|
|
418
|
+
'jsonb' => 'Json', 'json' => 'Json',
|
|
419
|
+
# Text rather than Binary: Hasura returns bytea hex-encoded, and the
|
|
420
|
+
# agent's binary decorator would hand back raw bytes, which a JSON
|
|
421
|
+
# mutation body cannot carry.
|
|
422
|
+
'bytea' => 'String'
|
|
423
|
+
}.fetch(graphql_type, 'String')
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
require 'active_support/core_ext/string/inflections'
|
|
2
|
+
|
|
3
|
+
module ForestAdminDatasourceGraphqlHasura
|
|
4
|
+
module Introspection
|
|
5
|
+
# Recognises Rails polymorphic belongs_to associations among introspected
|
|
6
|
+
# tables, from a `<base>_type`/`<base>_id` column pair backed by one Hasura
|
|
7
|
+
# relationship per target, or from the `polymorphic_relations` configuration
|
|
8
|
+
# when the metadata API is unreachable.
|
|
9
|
+
class PolymorphismDetector
|
|
10
|
+
def initialize(configuration)
|
|
11
|
+
@configuration = configuration
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Fills `polymorphics` on each table and removes the per-target object
|
|
15
|
+
# relationships it absorbs.
|
|
16
|
+
def detect(tables)
|
|
17
|
+
# Relationships reference the GraphQL type name; the merge order makes
|
|
18
|
+
# the type interpretation win when it collides with another table's
|
|
19
|
+
# root field name (crossed custom_root_fields renames).
|
|
20
|
+
tables_by_name = tables.to_h { |table| [table.name, table] }
|
|
21
|
+
.merge(tables.to_h { |table| [table.type_name, table] })
|
|
22
|
+
|
|
23
|
+
tables.each do |table|
|
|
24
|
+
bases_of(table).each { |base| absorb(table, base, tables_by_name) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def absorb(table, base, tables_by_name)
|
|
31
|
+
targets = targets_of(table, base, tables_by_name)
|
|
32
|
+
return if targets.empty?
|
|
33
|
+
|
|
34
|
+
table.polymorphics << Polymorphic.new(
|
|
35
|
+
name: base,
|
|
36
|
+
foreign_key: "#{base}_id",
|
|
37
|
+
type_field: "#{base}_type",
|
|
38
|
+
targets: targets
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
consumed = targets.values.filter_map { |target| target[:hasura_field] }
|
|
42
|
+
table.relationships.reject! { |rel| consumed.include?(rel.name) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def bases_of(table)
|
|
46
|
+
names = table.columns.map(&:name)
|
|
47
|
+
configured = configured_relations(table).keys
|
|
48
|
+
|
|
49
|
+
detected = names.filter_map do |name|
|
|
50
|
+
base = name.delete_suffix('_type')
|
|
51
|
+
# A column literally named `_type` leaves an empty base, which would
|
|
52
|
+
# emit an unnamed association absorbing whatever joins through `_id`.
|
|
53
|
+
base if name.end_with?('_type') && !base.empty? && names.include?("#{base}_id")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
(detected + configured.select { |base| discriminators?(table, names, base) }).uniq
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A configured association without its column pair would emit a relation
|
|
60
|
+
# referencing columns that do not exist, breaking the collection at boot.
|
|
61
|
+
def discriminators?(table, names, base)
|
|
62
|
+
missing = ["#{base}_type", "#{base}_id"].reject { |column| names.include?(column) }
|
|
63
|
+
return true if missing.empty?
|
|
64
|
+
|
|
65
|
+
ForestAdminDatasourceGraphqlHasura.logger.warn(
|
|
66
|
+
'[forest_admin_datasource_graphql_hasura] Ignoring the configured polymorphic relation ' \
|
|
67
|
+
"'#{table.name}.#{base}': column(s) #{missing.join(", ")} not found on '#{table.name}'."
|
|
68
|
+
)
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def targets_of(table, base, tables_by_name)
|
|
73
|
+
configured_tables = configured_relations(table)[base]
|
|
74
|
+
foreign_key = "#{base}_id"
|
|
75
|
+
|
|
76
|
+
candidates = table.relationships.select do |rel|
|
|
77
|
+
branch?(rel, foreign_key, configured_tables, tables_by_name)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
candidates.group_by(&:remote_table).each_with_object({}) do |(remote_table, relationships), memo|
|
|
81
|
+
target_table = tables_by_name[remote_table]
|
|
82
|
+
next unless target_table
|
|
83
|
+
next if ambiguous_branch?(table, base, target_table.name, relationships)
|
|
84
|
+
|
|
85
|
+
relationship = relationships.first
|
|
86
|
+
memo[class_name_of(target_table)] = {
|
|
87
|
+
table: target_table.name,
|
|
88
|
+
hasura_field: relationship.name,
|
|
89
|
+
primary_key: relationship.mapping&.values&.first || target_table.primary_key.first || 'id'
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Without the Hasura metadata every mapping is unknown, so two object
|
|
95
|
+
# relationships towards the same configured target are indistinguishable:
|
|
96
|
+
# one may be a plain belongs_to, and absorbing it would silently delete a
|
|
97
|
+
# legitimate relation. Refuse to guess.
|
|
98
|
+
def ambiguous_branch?(table, base, remote_table, relationships)
|
|
99
|
+
return false if relationships.size == 1
|
|
100
|
+
|
|
101
|
+
ForestAdminDatasourceGraphqlHasura.logger.warn(
|
|
102
|
+
"[forest_admin_datasource_graphql_hasura] '#{table.name}.#{base}' cannot absorb a branch " \
|
|
103
|
+
"towards '#{remote_table}': the relationships #{relationships.map(&:name).join(", ")} are " \
|
|
104
|
+
'equally plausible and one may be a plain belongs_to. That target is skipped.'
|
|
105
|
+
)
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def branch?(relationship, foreign_key, configured_tables, tables_by_name)
|
|
110
|
+
return false unless relationship.kind == :object
|
|
111
|
+
# A known mapping is checked even when the target is configured: a table
|
|
112
|
+
# may hold both an ordinary relationship and a polymorphic branch towards
|
|
113
|
+
# the same target, and they must not be mistaken for one another.
|
|
114
|
+
return false unless relationship.mapping.nil? || relationship.mapping.keys == [foreign_key]
|
|
115
|
+
|
|
116
|
+
if configured_tables
|
|
117
|
+
# The configuration names tables; the relationship carries the GraphQL
|
|
118
|
+
# type name, which custom_root_fields can decouple from the root field.
|
|
119
|
+
target = tables_by_name[relationship.remote_table]
|
|
120
|
+
|
|
121
|
+
# & rather than intersect?, which needs Ruby >= 3.1.
|
|
122
|
+
return (configured_tables & [target&.name, target&.type_name].compact).any?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A relationship backed by a real foreign key constraint is monomorphic by
|
|
126
|
+
# definition: accepting one here would absorb a legitimate belongs_to
|
|
127
|
+
# whenever an unrelated `<base>_type` enum sits next to `<base>_id`.
|
|
128
|
+
relationship.manual
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Like type_values, the configuration accepts the root field name or the
|
|
132
|
+
# underlying type name.
|
|
133
|
+
def configured_relations(table)
|
|
134
|
+
@configuration.polymorphic_relations[table.name] ||
|
|
135
|
+
@configuration.polymorphic_relations[table.type_name] ||
|
|
136
|
+
{}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def class_name_of(table)
|
|
140
|
+
@configuration.type_values[table.name] ||
|
|
141
|
+
@configuration.type_values[table.type_name] ||
|
|
142
|
+
table.type_name.classify
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|