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