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.
@@ -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