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,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
|