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,431 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
|
|
3
|
+
module ForestAdminDatasourceGraphqlHasura
|
|
4
|
+
module Query
|
|
5
|
+
# Runs Forest aggregations against Hasura on behalf of a collection.
|
|
6
|
+
#
|
|
7
|
+
# Hasura exposes GROUP BY only through a nested `<relation>_aggregate` on a
|
|
8
|
+
# parent object, so a grouped aggregation goes through the parent table and is
|
|
9
|
+
# reduced here rather than by the database.
|
|
10
|
+
class Aggregator
|
|
11
|
+
ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException
|
|
12
|
+
|
|
13
|
+
# Parent rows are reduced in Ruby, so they are paginated by PARENT_PAGE and
|
|
14
|
+
# capped at MAX_PARENT_ROWS: past the cap the chart fails with a clear error
|
|
15
|
+
# rather than silently charting a subset.
|
|
16
|
+
PARENT_PAGE = 1000
|
|
17
|
+
MAX_PARENT_ROWS = 10_000
|
|
18
|
+
|
|
19
|
+
# At most this many distinct dangling foreign keys get a group of their
|
|
20
|
+
# own; beyond that the data is corrupt enough to deserve an error.
|
|
21
|
+
DANGLING_KEYS_LIMIT = 100
|
|
22
|
+
|
|
23
|
+
def initialize(collection)
|
|
24
|
+
@collection = collection
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run(filter, aggregation, limit)
|
|
28
|
+
validate(aggregation)
|
|
29
|
+
@date_field = date_field?(aggregation)
|
|
30
|
+
|
|
31
|
+
if aggregation.groups.nil? || aggregation.groups.empty?
|
|
32
|
+
simple(filter, aggregation)
|
|
33
|
+
else
|
|
34
|
+
grouped(filter, aggregation, limit)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def name = @collection.name
|
|
41
|
+
def names = @collection.names
|
|
42
|
+
def datasource = @collection.datasource
|
|
43
|
+
def fields = @collection.schema[:fields]
|
|
44
|
+
|
|
45
|
+
# Aggregation fields are interpolated into the GraphQL document and are the
|
|
46
|
+
# one path the agent does not validate upstream (the charts route passes the
|
|
47
|
+
# request's `aggregateFieldName` straight through).
|
|
48
|
+
def validate(aggregation)
|
|
49
|
+
validate_field(aggregation.field, column_only: true) if aggregation.field
|
|
50
|
+
|
|
51
|
+
# Without a field, `sum { }` would be an empty GraphQL selection set.
|
|
52
|
+
if aggregation.field.nil? && aggregation.operation != 'Count'
|
|
53
|
+
raise ForestException,
|
|
54
|
+
"#{aggregation.operation} requires a field on collection '#{name}'."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
groups = aggregation.groups || []
|
|
58
|
+
|
|
59
|
+
if groups.size > 1
|
|
60
|
+
raise ForestException,
|
|
61
|
+
"Grouping on several fields is not supported by the GraphQL datasource (collection '#{name}')."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
groups.each do |group|
|
|
65
|
+
if group[:operation]
|
|
66
|
+
raise ForestException,
|
|
67
|
+
"Date grouping is not supported by the GraphQL datasource (collection '#{name}')."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A two-segment path must end on a column: a relation as the leaf
|
|
71
|
+
# selection would be invalid GraphQL.
|
|
72
|
+
validate_field(group[:field], allow_relation: true,
|
|
73
|
+
column_only: group[:field].to_s.include?(':'))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# column_only rejects a relation as the aggregated field (`sum { membership }`
|
|
78
|
+
# is not valid GraphQL); a group field may end on a ManyToOne, which stands
|
|
79
|
+
# for its foreign key.
|
|
80
|
+
def validate_field(field, allow_relation: false, column_only: false)
|
|
81
|
+
path = field.to_s.split(':')
|
|
82
|
+
|
|
83
|
+
unless (allow_relation && path.size <= 2) || path.size == 1
|
|
84
|
+
raise ForestException, "Invalid aggregation field '#{field}' on collection '#{name}'."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
*relations, last = path
|
|
88
|
+
collection = relations.reduce(@collection) { |current, part| collection_through(current, part, field) }
|
|
89
|
+
target = collection.schema[:fields][last]
|
|
90
|
+
|
|
91
|
+
raise ForestException, "Field '#{field}' not found on collection '#{collection.name}'." if target.nil?
|
|
92
|
+
|
|
93
|
+
return unless column_only && target.type != 'Column'
|
|
94
|
+
|
|
95
|
+
raise ForestException,
|
|
96
|
+
"Cannot aggregate on '#{field}': it is a relation, not a column (collection '#{name}')."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def collection_through(collection, relation_name, field)
|
|
100
|
+
schema = collection.schema[:fields][relation_name]
|
|
101
|
+
raise ForestException, "Field '#{field}' not found on collection '#{collection.name}'." if schema.nil?
|
|
102
|
+
|
|
103
|
+
unless schema.type == 'ManyToOne'
|
|
104
|
+
raise ForestException, "Cannot aggregate through '#{relation_name}' on collection '#{collection.name}'."
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
datasource.get_collection(schema.foreign_collection)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def simple(filter, aggregation)
|
|
111
|
+
operation = QueryBuilder.aggregate(names, filter, aggregation)
|
|
112
|
+
data = @collection.execute(:aggregate, operation).dig(names[:aggregate], 'aggregate')
|
|
113
|
+
|
|
114
|
+
# One row even when the aggregate is null: the charts route reads
|
|
115
|
+
# `result[0]['value']` unguarded.
|
|
116
|
+
[{ 'value' => extract_value(data, aggregation), 'group' => {} }]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def grouped(filter, aggregation, limit)
|
|
120
|
+
group_field = aggregation.groups.first[:field]
|
|
121
|
+
relation = find_group_relation(group_field)
|
|
122
|
+
values = collect_groups(fetch_parent_rows(relation, filter, aggregation), relation, aggregation)
|
|
123
|
+
add_orphan_groups(values, relation, filter, aggregation)
|
|
124
|
+
|
|
125
|
+
rows = values.map do |key, value|
|
|
126
|
+
{ 'value' => finalize_value(value, aggregation), 'group' => { group_field => key } }
|
|
127
|
+
end
|
|
128
|
+
results = rows.sort_by { |row| comparable(row['value']) }.reverse
|
|
129
|
+
|
|
130
|
+
limit ? results.first(limit) : results
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def fetch_parent_rows(relation, filter, aggregation)
|
|
134
|
+
rows = []
|
|
135
|
+
offset = 0
|
|
136
|
+
|
|
137
|
+
loop do
|
|
138
|
+
operation = QueryBuilder.grouped_aggregate(names, relation, filter, aggregation,
|
|
139
|
+
{ limit: PARENT_PAGE, offset: offset })
|
|
140
|
+
page = @collection.execute(:aggregate, operation)[relation[:parent_table]] || []
|
|
141
|
+
rows.concat(page)
|
|
142
|
+
|
|
143
|
+
# The strict comparison lets exactly MAX_PARENT_ROWS through (a final
|
|
144
|
+
# empty page then closes the walk); anything beyond fails, even on a
|
|
145
|
+
# partial page, so the cap the README documents is the cap enforced.
|
|
146
|
+
if rows.size > MAX_PARENT_ROWS
|
|
147
|
+
raise ForestException,
|
|
148
|
+
"Grouped aggregation on '#{name}' spans more than #{MAX_PARENT_ROWS} " \
|
|
149
|
+
"'#{relation[:parent_table]}' rows; narrow the chart filter."
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
return rows if page.size < PARENT_PAGE
|
|
153
|
+
|
|
154
|
+
offset += PARENT_PAGE
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def collect_groups(rows, relation, aggregation)
|
|
159
|
+
rows.each_with_object({}) do |row, memo|
|
|
160
|
+
data = row.dig("#{relation[:relation_name]}_aggregate", 'aggregate')
|
|
161
|
+
next if childless?(data, aggregation)
|
|
162
|
+
|
|
163
|
+
value = group_value(data, aggregation)
|
|
164
|
+
key = row[relation[:parent_field]]
|
|
165
|
+
memo[key] = memo.key?(key) ? merge_values(memo[key], value, aggregation) : value
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Rows without a matching parent are invisible to the parent-table detour.
|
|
170
|
+
# Grouped by a parent column, they are the NULL group of a LEFT JOIN — nil
|
|
171
|
+
# and dangling foreign keys alike (`_not: { relation: {} }` selects both).
|
|
172
|
+
# Grouped by the foreign key itself, SQL keeps each dangling key as a group
|
|
173
|
+
# of its own: those keys are enumerated and aggregated one by one, and only
|
|
174
|
+
# truly NULL keys fall into the nil bucket.
|
|
175
|
+
def add_orphan_groups(values, relation, filter, aggregation)
|
|
176
|
+
return unless relation[:orphans_possible]
|
|
177
|
+
|
|
178
|
+
if relation[:fk_grouping]
|
|
179
|
+
dangling_keys(relation, filter).each do |key|
|
|
180
|
+
add_orphan_group(values, key, filter, aggregation, { relation[:foreign_key] => { '_eq' => key } })
|
|
181
|
+
end
|
|
182
|
+
add_orphan_group(values, nil, filter, aggregation,
|
|
183
|
+
{ relation[:foreign_key] => { '_is_null' => true } })
|
|
184
|
+
else
|
|
185
|
+
# The nil key can pre-exist (a parent whose grouped column is null):
|
|
186
|
+
# both are the NULL group of a LEFT JOIN, so they merge.
|
|
187
|
+
add_orphan_group(values, nil, filter, aggregation,
|
|
188
|
+
{ '_not' => { relation[:child_relation_name] => {} } })
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def add_orphan_group(values, key, filter, aggregation, extra_where)
|
|
193
|
+
operation = QueryBuilder.aggregate(names, filter, aggregation, extra_where: extra_where)
|
|
194
|
+
data = @collection.execute(:aggregate, operation).dig(names[:aggregate], 'aggregate')
|
|
195
|
+
return if childless?(data, aggregation)
|
|
196
|
+
|
|
197
|
+
value = group_value(data, aggregation)
|
|
198
|
+
values[key] = values.key?(key) ? merge_values(values[key], value, aggregation) : value
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# An average is carried as its sum and non-null count while groups merge —
|
|
202
|
+
# SQL AVG over the union weights by count, which the averages themselves
|
|
203
|
+
# cannot express — and divided once the groups are final.
|
|
204
|
+
def group_value(data, aggregation)
|
|
205
|
+
return extract_value(data, aggregation) unless aggregation.operation == 'Avg' && data.key?('avg_sum')
|
|
206
|
+
|
|
207
|
+
raw_sum = data.dig('avg_sum', aggregation.field)
|
|
208
|
+
|
|
209
|
+
{ sum: raw_sum.nil? ? nil : numeric(raw_sum), count: data['avg_count'].to_i }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def finalize_value(value, aggregation)
|
|
213
|
+
return value unless aggregation.operation == 'Avg' && value.is_a?(Hash)
|
|
214
|
+
|
|
215
|
+
value[:count].zero? ? nil : numeric(value[:sum] || 0).fdiv(value[:count])
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# One extra key is requested so that exactly DANGLING_KEYS_LIMIT distinct
|
|
219
|
+
# values complete instead of tripping the guard.
|
|
220
|
+
def dangling_keys(relation, filter)
|
|
221
|
+
operation = QueryBuilder.orphan_keys(names, filter, relation[:foreign_key],
|
|
222
|
+
relation[:child_relation_name], DANGLING_KEYS_LIMIT + 1)
|
|
223
|
+
rows = @collection.execute(:aggregate, operation)[names[:root]] || []
|
|
224
|
+
keys = rows.map { |row| row[relation[:foreign_key]] }
|
|
225
|
+
|
|
226
|
+
if keys.size > DANGLING_KEYS_LIMIT
|
|
227
|
+
raise ForestException,
|
|
228
|
+
"Grouped aggregation on '#{name}': more than #{DANGLING_KEYS_LIMIT} distinct " \
|
|
229
|
+
"'#{relation[:foreign_key]}' values reference no parent row; clean the data up " \
|
|
230
|
+
'or narrow the chart filter.'
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
keys
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Two parent rows can share a group value — grouping by a name rather than by
|
|
237
|
+
# the primary key, as leaderboard charts do — and SQL would return them as a
|
|
238
|
+
# single group. A NULL side is ignored, as SQL aggregates ignore NULLs, and
|
|
239
|
+
# two NULL sides stay NULL.
|
|
240
|
+
def merge_values(current, value, aggregation)
|
|
241
|
+
return current if value.nil?
|
|
242
|
+
return value if current.nil?
|
|
243
|
+
|
|
244
|
+
case aggregation.operation
|
|
245
|
+
when 'Count', 'Sum' then add(current, value)
|
|
246
|
+
when 'Avg' then merge_averages(current, value, aggregation)
|
|
247
|
+
when 'Max' then (comparable(value) <=> comparable(current)).positive? ? value : current
|
|
248
|
+
when 'Min' then (comparable(value) <=> comparable(current)).negative? ? value : current
|
|
249
|
+
else
|
|
250
|
+
raise ForestException,
|
|
251
|
+
"#{aggregation.operation} cannot be grouped on '#{name}' by a value several parent rows " \
|
|
252
|
+
'share: the result would not be exact. Group on the foreign key instead.'
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def merge_averages(current, value, aggregation)
|
|
257
|
+
unless current.is_a?(Hash) && value.is_a?(Hash)
|
|
258
|
+
raise ForestException,
|
|
259
|
+
"#{aggregation.operation} cannot be grouped on '#{name}' by a value several parent rows " \
|
|
260
|
+
'share: the result would not be exact. Group on the foreign key instead.'
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
{ sum: add(current[:sum] || 0, value[:sum] || 0), count: current[:count] + value[:count] }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Hasura sends bigint and numeric as JSON strings to keep a precision a Float
|
|
267
|
+
# would lose, so whole numbers are added as Integers, which Ruby does not cap.
|
|
268
|
+
def add(current, value)
|
|
269
|
+
left = numeric(current)
|
|
270
|
+
right = numeric(value)
|
|
271
|
+
|
|
272
|
+
left + right
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def numeric(value)
|
|
276
|
+
case value
|
|
277
|
+
when Integer, Float then value
|
|
278
|
+
when String then parse_number(value)
|
|
279
|
+
else 0
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Charting 0 in place of a value the wire format hid would be silently
|
|
284
|
+
# wrong data; an unparseable aggregate deserves an error.
|
|
285
|
+
def parse_number(value)
|
|
286
|
+
return value.to_i if value.match?(/\A-?\d+\z/)
|
|
287
|
+
|
|
288
|
+
Float(value, exception: false) ||
|
|
289
|
+
raise(ForestException, "Non-numeric aggregate value #{value.inspect} on collection '#{name}'.")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# A group with no rows at all is what SQL grouping leaves out. The
|
|
293
|
+
# `row_count` alias tells it from a group whose rows exist but hold NULL in
|
|
294
|
+
# the aggregated column — SQL keeps that one: a zero `count(columns: x)`,
|
|
295
|
+
# a NULL Sum/Max/Min. The value-based fallback covers a response missing
|
|
296
|
+
# the alias.
|
|
297
|
+
def childless?(data, aggregation)
|
|
298
|
+
return true if data.nil?
|
|
299
|
+
return data['row_count'].to_i.zero? if data.key?('row_count')
|
|
300
|
+
|
|
301
|
+
value = extract_value(data, aggregation)
|
|
302
|
+
value.nil? || (aggregation.operation == 'Count' && aggregation.field.nil? && value.to_i.zero?)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Accepts a foreign key (`membership_id`) or a path through a ManyToOne
|
|
306
|
+
# (`membership:full_name`, what leaderboard charts request).
|
|
307
|
+
def find_group_relation(group_field)
|
|
308
|
+
field_name, parent_column = group_field.split(':')
|
|
309
|
+
relation_name, relation, foreign_key = resolve_group_relation(field_name)
|
|
310
|
+
reverse = relation && reverse_relation_name(relation, foreign_key)
|
|
311
|
+
|
|
312
|
+
unless reverse
|
|
313
|
+
raise ForestException,
|
|
314
|
+
"Group by '#{group_field}' is not supported: the GraphQL datasource groups through a " \
|
|
315
|
+
"foreign key whose reverse relationship is declared in Hasura (collection '#{name}')."
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
parent = datasource.get_collection(relation.foreign_collection)
|
|
319
|
+
|
|
320
|
+
{
|
|
321
|
+
parent_table: parent.table_name,
|
|
322
|
+
parent_field: parent_column || relation.foreign_key_target,
|
|
323
|
+
relation_name: reverse,
|
|
324
|
+
child_relation_name: relation_name,
|
|
325
|
+
foreign_key: foreign_key,
|
|
326
|
+
fk_grouping: parent_column.nil?,
|
|
327
|
+
parent_order_fields: primary_keys_of(parent),
|
|
328
|
+
orphans_possible: orphans_possible?(relation_name, relation)
|
|
329
|
+
}
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# A NOT NULL foreign key backed by a real constraint cannot reference a
|
|
333
|
+
# missing parent, so the orphan query would be a wasted round trip. A
|
|
334
|
+
# manual relationship (or one whose backing is unknown) can dangle even
|
|
335
|
+
# on a NOT NULL column.
|
|
336
|
+
def orphans_possible?(relation_name, relation)
|
|
337
|
+
foreign_key = fields[relation.foreign_key]
|
|
338
|
+
nullable = foreign_key.nil? || foreign_key.validation.empty?
|
|
339
|
+
|
|
340
|
+
nullable || !@collection.constraint_backed?(relation_name)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Offset pagination needs a stable order, which only the primary key gives.
|
|
344
|
+
def primary_keys_of(collection)
|
|
345
|
+
collection.schema[:fields]
|
|
346
|
+
.select { |_, field| field.respond_to?(:is_primary_key) && field.is_primary_key }
|
|
347
|
+
.keys
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Returns [relation_name, relation_schema, foreign_key]. The relation name
|
|
351
|
+
# is also the Hasura object relationship on the child table, which the
|
|
352
|
+
# orphan query of add_null_group negates.
|
|
353
|
+
def resolve_group_relation(field_name)
|
|
354
|
+
field = fields[field_name]
|
|
355
|
+
return [field_name, field, field.foreign_key] if field&.type == 'ManyToOne'
|
|
356
|
+
|
|
357
|
+
name, relation = fields.find { |_, f| f.type == 'ManyToOne' && f.foreign_key == field_name }
|
|
358
|
+
|
|
359
|
+
[name, relation, field_name]
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def reverse_relation_name(relation, foreign_key)
|
|
363
|
+
parent = datasource.get_collection(relation.foreign_collection)
|
|
364
|
+
|
|
365
|
+
parent.schema[:fields].each do |relation_name, field|
|
|
366
|
+
next unless field.type == 'OneToMany' &&
|
|
367
|
+
field.foreign_collection == name &&
|
|
368
|
+
field.origin_key == foreign_key
|
|
369
|
+
|
|
370
|
+
return relation_name
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
nil
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Aggregate values are not necessarily numbers: Hasura sends bigint and
|
|
377
|
+
# numeric as strings, and Max/Min aggregate dates as well as text. The tuple
|
|
378
|
+
# orders numbers and instants together, then text lexically, and stays
|
|
379
|
+
# comparable across rows so `sort_by` and Max/Min agree. Whole numbers are
|
|
380
|
+
# kept as Integers — Ruby compares them with Floats exactly — because a
|
|
381
|
+
# bigint rounded through a Float would tie with its neighbours.
|
|
382
|
+
def comparable(value)
|
|
383
|
+
case value
|
|
384
|
+
when Numeric then [0, value, '']
|
|
385
|
+
when String then comparable_string(value)
|
|
386
|
+
# NULL groups (rows whose aggregated column is all NULL) sort last.
|
|
387
|
+
else [-1, 0.0, '']
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Strings reaching here belong to non-numeric columns — numeric ones were
|
|
392
|
+
# normalized at extraction. Date columns order as instants (offsets make
|
|
393
|
+
# lexical ordering lie); anything else orders lexically, like SQL collates,
|
|
394
|
+
# even when a text value happens to look like a date or a number.
|
|
395
|
+
def comparable_string(value)
|
|
396
|
+
instant = @date_field ? time_value(value) : nil
|
|
397
|
+
|
|
398
|
+
instant ? [0, instant, ''] : [1, 0.0, value]
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def time_value(value)
|
|
402
|
+
Time.parse(value).to_f
|
|
403
|
+
rescue ArgumentError, TypeError
|
|
404
|
+
nil
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Hasura serializes bigint and numeric aggregates as JSON strings; a chart
|
|
408
|
+
# value must be a number, and one merged in Ruby must not differ in type
|
|
409
|
+
# from one straight off the wire, so numeric columns are normalized here.
|
|
410
|
+
def extract_value(data, aggregation)
|
|
411
|
+
return nil if data.nil?
|
|
412
|
+
|
|
413
|
+
value = if aggregation.operation == 'Count'
|
|
414
|
+
data['count']
|
|
415
|
+
else
|
|
416
|
+
data.dig(aggregation.operation.downcase, aggregation.field)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
value.is_a?(String) && number_field?(aggregation) ? numeric(value) : value
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def number_field?(aggregation)
|
|
423
|
+
aggregation.field && fields[aggregation.field]&.column_type == 'Number'
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def date_field?(aggregation)
|
|
427
|
+
aggregation.field && %w[Date Dateonly Time].include?(fields[aggregation.field]&.column_type)
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module ForestAdminDatasourceGraphqlHasura
|
|
2
|
+
module Query
|
|
3
|
+
# Converts a Forest Admin condition tree into a Hasura `_bool_exp` hash.
|
|
4
|
+
class FilterConverter
|
|
5
|
+
Nodes = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes
|
|
6
|
+
Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
|
|
7
|
+
|
|
8
|
+
def self.convert(condition_tree)
|
|
9
|
+
new.convert(condition_tree)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def convert(condition_tree)
|
|
13
|
+
return nil if condition_tree.nil?
|
|
14
|
+
|
|
15
|
+
case condition_tree
|
|
16
|
+
when Nodes::ConditionTreeBranch
|
|
17
|
+
convert_branch(condition_tree)
|
|
18
|
+
when Nodes::ConditionTreeLeaf
|
|
19
|
+
convert_leaf(condition_tree)
|
|
20
|
+
else
|
|
21
|
+
raise GraphqlError, "Unsupported condition tree node: #{condition_tree.class}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# A branch that matches every row converts to nil rather than to an empty
|
|
28
|
+
# `_and`, which Hasura reads as vacuously true: the mutation guards treat
|
|
29
|
+
# nil as "no filter" and refuse to run, whereas `{ _and: [] }` would slip
|
|
30
|
+
# through and touch the whole table.
|
|
31
|
+
def convert_branch(branch)
|
|
32
|
+
conditions = branch.conditions.map { |condition| convert(condition) }
|
|
33
|
+
|
|
34
|
+
if branch.aggregator == 'And'
|
|
35
|
+
kept = conditions.compact
|
|
36
|
+
|
|
37
|
+
kept.empty? ? nil : { '_and' => kept }
|
|
38
|
+
else
|
|
39
|
+
# A nil among the alternatives matches everything, so does the union.
|
|
40
|
+
conditions.include?(nil) ? nil : { '_or' => conditions }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# `_and`/`_or` only exist at bool_exp level, never inside a comparison
|
|
45
|
+
# expression, so an operator needing two comparisons on the same field is
|
|
46
|
+
# nested first and combined after.
|
|
47
|
+
def convert_leaf(leaf)
|
|
48
|
+
path = leaf.field.split(':')
|
|
49
|
+
expression = operator_expression(leaf.operator, leaf.value)
|
|
50
|
+
|
|
51
|
+
if expression.is_a?(Array)
|
|
52
|
+
aggregator, comparisons = expression
|
|
53
|
+
|
|
54
|
+
{ aggregator => comparisons.map { |comparison| nest(path, comparison) } }
|
|
55
|
+
else
|
|
56
|
+
nest(path, expression)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def nest(path, comparison)
|
|
61
|
+
path.reverse.reduce(comparison) { |memo, part| { part => memo } }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def operator_expression(operator, value)
|
|
65
|
+
case operator
|
|
66
|
+
when Operators::EQUAL then value.nil? ? { '_is_null' => true } : { '_eq' => value }
|
|
67
|
+
when Operators::NOT_EQUAL then value.nil? ? { '_is_null' => false } : { '_neq' => value }
|
|
68
|
+
when Operators::GREATER_THAN, Operators::AFTER then { '_gt' => value }
|
|
69
|
+
when Operators::LESS_THAN, Operators::BEFORE then { '_lt' => value }
|
|
70
|
+
when Operators::GREATER_THAN_OR_EQUAL then { '_gte' => value }
|
|
71
|
+
when Operators::LESS_THAN_OR_EQUAL then { '_lte' => value }
|
|
72
|
+
when Operators::IN then in_expression(value)
|
|
73
|
+
when Operators::NOT_IN then not_in_expression(value)
|
|
74
|
+
when Operators::PRESENT then { '_is_null' => false }
|
|
75
|
+
when Operators::MISSING, Operators::BLANK then { '_is_null' => true }
|
|
76
|
+
when Operators::LIKE then { '_like' => value }
|
|
77
|
+
when Operators::I_LIKE then { '_ilike' => value }
|
|
78
|
+
# Case-insensitive like the ActiveRecord datasource, whose `matches`
|
|
79
|
+
# compiles to ILIKE on Postgres.
|
|
80
|
+
when Operators::CONTAINS, Operators::I_CONTAINS then { '_ilike' => "%#{escape_pattern(value)}%" }
|
|
81
|
+
when Operators::NOT_CONTAINS, Operators::NOT_I_CONTAINS then { '_nilike' => "%#{escape_pattern(value)}%" }
|
|
82
|
+
when Operators::STARTS_WITH, Operators::I_STARTS_WITH then { '_ilike' => "#{escape_pattern(value)}%" }
|
|
83
|
+
when Operators::ENDS_WITH, Operators::I_ENDS_WITH then { '_ilike' => "%#{escape_pattern(value)}" }
|
|
84
|
+
else
|
|
85
|
+
raise GraphqlError, "Unsupported operator: #{operator}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# `IN (NULL, ...)` never matches NULL rows in Postgres. The toolkit relies
|
|
90
|
+
# on this shape to emulate Blank/Present on text columns.
|
|
91
|
+
def in_expression(value)
|
|
92
|
+
values = Array(value)
|
|
93
|
+
return { '_in' => values } unless values.include?(nil)
|
|
94
|
+
|
|
95
|
+
others = values.compact
|
|
96
|
+
return { '_is_null' => true } if others.empty?
|
|
97
|
+
|
|
98
|
+
['_or', [{ '_is_null' => true }, { '_in' => others }]]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def not_in_expression(value)
|
|
102
|
+
values = Array(value)
|
|
103
|
+
return { '_nin' => values } unless values.include?(nil)
|
|
104
|
+
|
|
105
|
+
others = values.compact
|
|
106
|
+
return { '_is_null' => false } if others.empty?
|
|
107
|
+
|
|
108
|
+
['_and', [{ '_is_null' => false }, { '_nin' => others }]]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Someone searching "100%" means the literal string, not "contains 100".
|
|
112
|
+
def escape_pattern(value)
|
|
113
|
+
value.to_s.gsub(/[\\%_]/) { |match| "\\#{match}" }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|