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,339 @@
1
+ require 'active_support/core_ext/string/inflections'
2
+
3
+ module ForestAdminDatasourceGraphqlHasura
4
+ module Introspection
5
+ # Converts introspected tables into Forest Admin field schemas.
6
+ #
7
+ # Collections are named after the Rails class name, because the Forest
8
+ # serializer resolves the target of a PolymorphicManyToOne from the raw value
9
+ # of the type column — both namings have to match.
10
+ class SchemaConverter
11
+ ColumnSchema = ForestAdminDatasourceToolkit::Schema::ColumnSchema
12
+ Relations = ForestAdminDatasourceToolkit::Schema::Relations
13
+ Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
14
+
15
+ BASE_OPERATORS = [
16
+ Operators::EQUAL, Operators::NOT_EQUAL, Operators::PRESENT, Operators::MISSING,
17
+ Operators::IN, Operators::NOT_IN
18
+ ].freeze
19
+
20
+ # PRESENT is deliberately absent: on a text column an empty string is not
21
+ # "present", so the toolkit derives it from NOT_IN as `NotIn [nil, '']`.
22
+ STRING_OPERATORS = [
23
+ Operators::EQUAL, Operators::NOT_EQUAL, Operators::MISSING, Operators::IN, Operators::NOT_IN,
24
+ Operators::CONTAINS, Operators::NOT_CONTAINS, Operators::I_CONTAINS, Operators::NOT_I_CONTAINS,
25
+ Operators::STARTS_WITH, Operators::I_STARTS_WITH, Operators::ENDS_WITH, Operators::I_ENDS_WITH,
26
+ Operators::LIKE, Operators::I_LIKE
27
+ ].freeze
28
+
29
+ COMPARABLE_OPERATORS = (BASE_OPERATORS + [Operators::GREATER_THAN, Operators::LESS_THAN,
30
+ Operators::GREATER_THAN_OR_EQUAL,
31
+ Operators::LESS_THAN_OR_EQUAL]).freeze
32
+
33
+ DATE_OPERATORS = (COMPARABLE_OPERATORS + [Operators::BEFORE, Operators::AFTER]).freeze
34
+
35
+ # Hasura's array comparison expressions have no pattern matching, and
36
+ # `IncludesAll` has no equivalent the filter converter can emit.
37
+ ARRAY_OPERATORS = [Operators::EQUAL, Operators::NOT_EQUAL, Operators::PRESENT,
38
+ Operators::MISSING].freeze
39
+
40
+ def initialize(tables, configuration)
41
+ @tables = tables
42
+ # Two indexes rather than one merged map: a table's type_name may equal
43
+ # another table's root field name (crossed custom_root_fields renames),
44
+ # and each lookup knows which spelling it holds.
45
+ @tables_by_root = tables.to_h { |table| [table.name, table] }
46
+ @tables_by_type = tables.to_h { |table| [table.type_name, table] }
47
+ @configuration = configuration
48
+ end
49
+
50
+ # Rails stores the class name derived from the Postgres table, which the
51
+ # GraphQL type name follows — not the root field, which custom_root_fields
52
+ # can rename freely. Callers pass root field names, so only the root index
53
+ # is consulted; type_values accepts either name.
54
+ def rails_class_name_of(table_name)
55
+ table = @tables_by_root[table_name]
56
+
57
+ @configuration.type_values[table_name] ||
58
+ (table && @configuration.type_values[table.type_name]) ||
59
+ (table&.type_name || table_name).classify
60
+ end
61
+
62
+ # 'Banking::Account' -> 'Banking__Account'
63
+ def collection_name_of(table_name)
64
+ rails_class_name_of(table_name).gsub('::', '__')
65
+ end
66
+
67
+ def build_fields(table)
68
+ fields = {}
69
+ composite_key = table.primary_key.size > 1
70
+
71
+ table.columns.each do |column|
72
+ fields[column.name] = convert_column(column, composite_key: composite_key)
73
+ end
74
+
75
+ add_polymorphics(table, fields)
76
+ add_relationships(table, fields)
77
+ add_reverse_polymorphics(table, fields)
78
+
79
+ fields
80
+ end
81
+
82
+ private
83
+
84
+ # A relationship references the GraphQL type; the root-field spelling is
85
+ # accepted as a fallback, and on a collision the type interpretation wins.
86
+ def resolve_table(reference)
87
+ @tables_by_type[reference] || @tables_by_root[reference]
88
+ end
89
+
90
+ # A single-column key is database-generated in the schemas Hasura fronts
91
+ # (serial, uuid default), and the writes persist explicit nils — which
92
+ # would override that default — so it stays read-only. A composite key is
93
+ # application-assigned (a join table has no default to fall back on):
94
+ # keeping it read-only would make the table impossible to create through.
95
+ def convert_column(column, composite_key:)
96
+ read_only = column.is_primary_key && !composite_key
97
+
98
+ ColumnSchema.new(
99
+ column_type: column.is_array ? [column.type] : column.type,
100
+ filter_operators: operators_for(column),
101
+ is_primary_key: column.is_primary_key,
102
+ is_read_only: read_only,
103
+ is_sortable: !column.is_array,
104
+ # The capabilities route publishes this flag, so anything but the
105
+ # foreign keys of mark_groupable_foreign_keys would have the UI offer a
106
+ # group-by that grouped_aggregate then rejects.
107
+ is_groupable: false,
108
+ default_value: nil,
109
+ validation: column.nullable || read_only ? [] : [{ operator: Operators::PRESENT }]
110
+ )
111
+ end
112
+
113
+ def add_polymorphics(table, fields)
114
+ table.polymorphics.each do |polymorphic|
115
+ # A physical column of that name wins: replacing it would drop it from
116
+ # the schema, leaving it neither readable nor writable.
117
+ if fields.key?(polymorphic.name)
118
+ ForestAdminDatasourceGraphqlHasura.logger.warn(
119
+ "[forest_admin_datasource_graphql_hasura] '#{table.name}' has a column named " \
120
+ "'#{polymorphic.name}', so its polymorphic association is not exposed. Rename either the " \
121
+ 'column or the association to surface both.'
122
+ )
123
+ next
124
+ end
125
+
126
+ fields[polymorphic.name] = convert_polymorphic(polymorphic)
127
+ lock_discriminators(table, polymorphic, fields)
128
+ end
129
+ end
130
+
131
+ # The widget drives the discriminator columns, so they are read-only and
132
+ # cannot carry a Present validation the user could never satisfy. The
133
+ # exception is a discriminator belonging to a composite primary key (a
134
+ # Rails taggings table): locking it would make the row impossible to
135
+ # create, which the composite-key carve-out of convert_column exists to
136
+ # prevent.
137
+ def lock_discriminators(table, polymorphic, fields)
138
+ composite = table.primary_key.size > 1
139
+
140
+ [polymorphic.foreign_key, polymorphic.type_field].each do |column|
141
+ field = fields[column]
142
+ next if field.nil?
143
+ next if composite && table.primary_key.include?(column)
144
+
145
+ field.is_read_only = true
146
+ field.validation = []
147
+ end
148
+ end
149
+
150
+ def convert_polymorphic(polymorphic)
151
+ Relations::PolymorphicManyToOneSchema.new(
152
+ foreign_key: polymorphic.foreign_key,
153
+ foreign_key_type_field: polymorphic.type_field,
154
+ foreign_collections: polymorphic.targets.keys.map { |type_value| type_value.gsub('::', '__') },
155
+ foreign_key_targets: polymorphic.targets.to_h do |type_value, target|
156
+ [type_value.gsub('::', '__'), target[:primary_key]]
157
+ end
158
+ )
159
+ end
160
+
161
+ def add_relationships(table, fields)
162
+ table.relationships.each do |relationship|
163
+ name, schema = convert_relationship(table, relationship)
164
+ next if schema.nil? || shadowed_relationship?(table, name, fields)
165
+
166
+ fields[name] = schema
167
+ end
168
+ end
169
+
170
+ def shadowed_relationship?(table, name, fields)
171
+ return false unless fields.key?(name)
172
+
173
+ ForestAdminDatasourceGraphqlHasura.logger.warn(
174
+ "[forest_admin_datasource_graphql_hasura] Relationship '#{name}' on '#{table.name}' " \
175
+ 'shares its name with another field, which wins; rename one of them to surface both.'
176
+ )
177
+ true
178
+ end
179
+
180
+ def convert_relationship(table, relationship)
181
+ if relationship.kind == :object
182
+ convert_object_relationship(table, relationship)
183
+ else
184
+ convert_array_relationship(table, relationship)
185
+ end
186
+ end
187
+
188
+ def convert_object_relationship(table, relationship)
189
+ remote = resolve_table(relationship.remote_table)
190
+
191
+ # A relation towards a table the datasource does not expose (excluded, or
192
+ # dropped for want of a primary key) breaks schema generation at boot.
193
+ unless remote
194
+ skip_relationship(table, relationship, "target table '#{relationship.remote_table}' is not exposed")
195
+
196
+ return [relationship.name, nil]
197
+ end
198
+
199
+ return [relationship.name, nil] unless single_column_mapping?(table, relationship)
200
+
201
+ foreign_key = relationship.mapping&.keys&.first || "#{relationship.name}_id"
202
+
203
+ unless table.columns.any? { |column| column.name == foreign_key }
204
+ skip_relationship(table, relationship, "foreign key '#{foreign_key}' does not exist")
205
+
206
+ return [relationship.name, nil]
207
+ end
208
+
209
+ [relationship.name, Relations::ManyToOneSchema.new(
210
+ foreign_collection: collection_name_of(remote.name),
211
+ foreign_key: foreign_key,
212
+ foreign_key_target: relationship.mapping&.values&.first || primary_key_of(remote)
213
+ )]
214
+ end
215
+
216
+ def convert_array_relationship(table, relationship)
217
+ remote = resolve_table(relationship.remote_table)
218
+ return [relationship.name, nil] unless remote
219
+ return [relationship.name, nil] if covered_by_reverse_polymorphic?(table, relationship, remote)
220
+ return [relationship.name, nil] unless single_column_mapping?(table, relationship)
221
+
222
+ # The conventional foreign key follows the underlying table (type_name),
223
+ # not a root field custom_root_fields may have renamed.
224
+ origin_key = relationship.mapping&.values&.first || "#{table.type_name.singularize}_id"
225
+
226
+ unless remote.columns.any? { |column| column.name == origin_key }
227
+ skip_relationship(table, relationship, "origin key '#{origin_key}' does not exist on " \
228
+ "'#{relationship.remote_table}'")
229
+
230
+ return [relationship.name, nil]
231
+ end
232
+
233
+ [relationship.name, Relations::OneToManySchema.new(
234
+ foreign_collection: collection_name_of(remote.name),
235
+ origin_key: origin_key,
236
+ origin_key_target: relationship.mapping&.keys&.first || primary_key_of(table)
237
+ )]
238
+ end
239
+
240
+ # Requires a known column mapping: without the Hasura metadata, a
241
+ # same-named array relationship may well be a regular has_many, and
242
+ # replacing it would silently list the wrong records.
243
+ def covered_by_reverse_polymorphic?(table, relationship, remote)
244
+ return false if relationship.mapping.nil?
245
+
246
+ remote.polymorphics.any? { |polymorphic| reverse_of?(relationship, table, polymorphic) }
247
+ end
248
+
249
+ # Both ends have to line up: an array relationship joining the polymorphic
250
+ # foreign key to another local column (`{ 'external_id' => 'commentable_id' }`)
251
+ # is a different relationship, and the PolymorphicOneToMany that would
252
+ # replace it queries by the primary key instead.
253
+ def reverse_of?(relationship, table, polymorphic)
254
+ this_class_name = rails_class_name_of(table.name)
255
+ target = polymorphic.targets[this_class_name]
256
+ return false if target.nil?
257
+ return false unless relationship.mapping&.values&.first == polymorphic.foreign_key
258
+
259
+ local_key = relationship.mapping.keys.first
260
+
261
+ local_key.nil? || local_key == target[:primary_key]
262
+ end
263
+
264
+ def single_column_mapping?(table, relationship)
265
+ return true if relationship.mapping.nil? || relationship.mapping.size <= 1
266
+
267
+ skip_relationship(table, relationship,
268
+ 'its Hasura column mapping spans several columns, which Forest Admin ' \
269
+ 'relations cannot express')
270
+ false
271
+ end
272
+
273
+ # One PolymorphicOneToMany per polymorphic belongs_to targeting this table,
274
+ # named after the matching Hasura array relationship when there is one.
275
+ def add_reverse_polymorphics(table, fields)
276
+ this_class_name = rails_class_name_of(table.name)
277
+
278
+ @tables.each do |child|
279
+ child.polymorphics.each do |polymorphic|
280
+ next unless polymorphic.targets.key?(this_class_name)
281
+
282
+ name = reverse_polymorphic_name(table, child, polymorphic, fields)
283
+ next unless name
284
+
285
+ fields[name] = Relations::PolymorphicOneToManySchema.new(
286
+ foreign_collection: collection_name_of(child.name),
287
+ origin_key: polymorphic.foreign_key,
288
+ origin_key_target: polymorphic.targets[this_class_name][:primary_key],
289
+ origin_type_field: polymorphic.type_field,
290
+ origin_type_value: this_class_name
291
+ )
292
+ end
293
+ end
294
+ end
295
+
296
+ def reverse_polymorphic_name(table, child, polymorphic, fields)
297
+ array_relationship = table.relationships.find do |rel|
298
+ rel.kind == :array && resolve_table(rel.remote_table) == child && reverse_of?(rel, table, polymorphic)
299
+ end
300
+
301
+ candidates = [array_relationship&.name, child.name, "#{child.name}_#{polymorphic.name}"].compact.uniq
302
+ name = candidates.find { |candidate| !fields.key?(candidate) }
303
+
304
+ unless name
305
+ ForestAdminDatasourceGraphqlHasura.logger.warn(
306
+ '[forest_admin_datasource_graphql_hasura] Cannot expose the reverse of ' \
307
+ "'#{child.name}.#{polymorphic.name}' on '#{table.name}': the names #{candidates.join(", ")} " \
308
+ 'are already taken. Rename the conflicting field or declare a Hasura array relationship.'
309
+ )
310
+ end
311
+
312
+ name
313
+ end
314
+
315
+ def primary_key_of(table)
316
+ table.primary_key.first || 'id'
317
+ end
318
+
319
+ def operators_for(column)
320
+ return ARRAY_OPERATORS if column.is_array
321
+
322
+ case column.type
323
+ when 'String' then column.is_text ? STRING_OPERATORS : BASE_OPERATORS
324
+ when 'Number' then COMPARABLE_OPERATORS
325
+ when 'Date', 'Dateonly', 'Time' then DATE_OPERATORS
326
+ else BASE_OPERATORS
327
+ end
328
+ end
329
+
330
+ def skip_relationship(table, relationship, reason)
331
+ ForestAdminDatasourceGraphqlHasura.logger.warn(
332
+ "[forest_admin_datasource_graphql_hasura] Skipping relationship '#{relationship.name}' " \
333
+ "on '#{table.name}': #{reason}. Declare it in the Hasura metadata or through the " \
334
+ "'polymorphic_relations' option."
335
+ )
336
+ end
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,24 @@
1
+ module ForestAdminDatasourceGraphqlHasura
2
+ module Introspection
3
+ # name is the root field records are queried through; type_name is the
4
+ # GraphQL OBJECT type, which relationships reference. They only differ when
5
+ # the Hasura metadata customizes the root fields. root_fields resolves the
6
+ # other operation roots: { aggregate:, insert:, update:, delete: }, custom
7
+ # names applied when the metadata declares them, derived otherwise.
8
+ Table = Struct.new(:name, :type_name, :columns, :primary_key, :relationships, :polymorphics,
9
+ :root_fields, keyword_init: true)
10
+
11
+ Column = Struct.new(:name, :type, :graphql_type, :nullable, :is_primary_key, :is_array, :is_text,
12
+ keyword_init: true)
13
+
14
+ # kind is :object or :array. mapping is { local_column => remote_column },
15
+ # where a nil side stands for the primary key of that table, and the whole
16
+ # hash is nil when the Hasura metadata was unreachable. manual tells a
17
+ # `manual_configuration` relationship from a foreign-key-constraint one.
18
+ Relationship = Struct.new(:name, :kind, :remote_table, :mapping, :manual, keyword_init: true)
19
+
20
+ # targets maps the value stored in the type column to its table:
21
+ # { 'Transfer' => { table: 'transfers', hasura_field: 'transfer', primary_key: 'id' } }
22
+ Polymorphic = Struct.new(:name, :foreign_key, :type_field, :targets, keyword_init: true)
23
+ end
24
+ end