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,260 @@
|
|
|
1
|
+
module ForestAdminDatasourceGraphqlHasura
|
|
2
|
+
module Query
|
|
3
|
+
# Builds Hasura GraphQL operations (queries and mutations) with variables.
|
|
4
|
+
# All methods return { query:, variables: }.
|
|
5
|
+
#
|
|
6
|
+
# names is { root:, base:, aggregate:, insert:, update:, delete: }: `root`
|
|
7
|
+
# is the select root field, `base` (the GraphQL type name) is what the
|
|
8
|
+
# generated type names derive from — `<base>_bool_exp`,
|
|
9
|
+
# `<base>_insert_input`… — and the operation roots carry their resolved
|
|
10
|
+
# names, custom_root_fields applied when the metadata declares them.
|
|
11
|
+
class QueryBuilder
|
|
12
|
+
class << self
|
|
13
|
+
# selection holds resolved GraphQL fields, nested relations included
|
|
14
|
+
# ("membership { id full_name }").
|
|
15
|
+
def list(names, filter, selection)
|
|
16
|
+
args = []
|
|
17
|
+
var_defs = []
|
|
18
|
+
variables = {}
|
|
19
|
+
|
|
20
|
+
where = FilterConverter.convert(filter.condition_tree)
|
|
21
|
+
|
|
22
|
+
if where
|
|
23
|
+
var_defs << "$where: #{names[:base]}_bool_exp"
|
|
24
|
+
args << 'where: $where'
|
|
25
|
+
variables['where'] = where
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
add_sort(names, filter, args, var_defs, variables)
|
|
29
|
+
add_pagination(filter, args, var_defs, variables)
|
|
30
|
+
|
|
31
|
+
query = <<~GRAPHQL
|
|
32
|
+
query List#{camelize(names[:root])}#{wrap(var_defs)} {
|
|
33
|
+
#{names[:root]}#{wrap(args)} {
|
|
34
|
+
#{selection.join("\n ")}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
GRAPHQL
|
|
38
|
+
|
|
39
|
+
{ query: query, variables: variables }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def create(names, records, selection)
|
|
43
|
+
query = <<~GRAPHQL
|
|
44
|
+
mutation Insert#{camelize(names[:base])}($objects: [#{names[:base]}_insert_input!]!) {
|
|
45
|
+
#{names[:insert]}(objects: $objects) {
|
|
46
|
+
returning {
|
|
47
|
+
#{selection.join("\n ")}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
GRAPHQL
|
|
52
|
+
|
|
53
|
+
{ query: query, variables: { 'objects' => records.map { |record| stringify_keys(record) } } }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def update(names, filter, patch)
|
|
57
|
+
where = FilterConverter.convert(filter.condition_tree)
|
|
58
|
+
|
|
59
|
+
# Backstop behind the collection guard: `{}` is vacuously true for
|
|
60
|
+
# Hasura, so a filterless update would rewrite the whole table.
|
|
61
|
+
if where.nil?
|
|
62
|
+
raise ForestAdminDatasourceToolkit::Exceptions::ForestException,
|
|
63
|
+
"Refusing to update every row of '#{names[:root]}': the filter carries no condition."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
query = <<~GRAPHQL
|
|
67
|
+
mutation Update#{camelize(names[:base])}($where: #{names[:base]}_bool_exp!, $set: #{names[:base]}_set_input!) {
|
|
68
|
+
#{names[:update]}(where: $where, _set: $set) {
|
|
69
|
+
affected_rows
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
GRAPHQL
|
|
73
|
+
|
|
74
|
+
{ query: query, variables: { 'where' => where, 'set' => stringify_keys(patch) } }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def delete(names, filter)
|
|
78
|
+
query = <<~GRAPHQL
|
|
79
|
+
mutation Delete#{camelize(names[:base])}($where: #{names[:base]}_bool_exp!) {
|
|
80
|
+
#{names[:delete]}(where: $where) {
|
|
81
|
+
affected_rows
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
GRAPHQL
|
|
85
|
+
|
|
86
|
+
# `{}` (match all) is deliberate here: a bulk delete with "select all"
|
|
87
|
+
# legitimately carries no condition, and wiping is the requested semantic.
|
|
88
|
+
{ query: query, variables: { 'where' => FilterConverter.convert(filter.condition_tree) || {} } }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# extra_where is a raw bool_exp and-combined with the converted filter
|
|
92
|
+
# (the null-bucket query adds `{ fk => { _is_null => true } }`).
|
|
93
|
+
def aggregate(names, filter, aggregation, extra_where: nil)
|
|
94
|
+
args = []
|
|
95
|
+
var_defs = []
|
|
96
|
+
variables = {}
|
|
97
|
+
|
|
98
|
+
where = combine(FilterConverter.convert(filter.condition_tree), extra_where)
|
|
99
|
+
|
|
100
|
+
if where
|
|
101
|
+
var_defs << "$where: #{names[:base]}_bool_exp"
|
|
102
|
+
args << 'where: $where'
|
|
103
|
+
variables['where'] = where
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
query = <<~GRAPHQL
|
|
107
|
+
query Aggregate#{camelize(names[:base])}#{wrap(var_defs)} {
|
|
108
|
+
#{names[:aggregate]}#{wrap(args)} {
|
|
109
|
+
aggregate {
|
|
110
|
+
#{aggregation_selection(aggregation)}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
GRAPHQL
|
|
115
|
+
|
|
116
|
+
{ query: query, variables: variables }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# relation is { parent_table:, parent_field:, relation_name:, parent_order_fields: },
|
|
120
|
+
# page is { limit:, offset: }. Parents are ordered by their primary key so
|
|
121
|
+
# offset pagination is stable, and filtered by the chart's predicate through
|
|
122
|
+
# the relationship, so the pages only walk parents owning at least one
|
|
123
|
+
# matching child row.
|
|
124
|
+
def grouped_aggregate(names, relation, filter, aggregation, page)
|
|
125
|
+
args = []
|
|
126
|
+
var_defs = ['$parentLimit: Int', '$parentOffset: Int']
|
|
127
|
+
variables = { 'parentLimit' => page[:limit], 'parentOffset' => page[:offset] }
|
|
128
|
+
parent_args = ['limit: $parentLimit', 'offset: $parentOffset', parent_order(relation)]
|
|
129
|
+
|
|
130
|
+
where = FilterConverter.convert(filter.condition_tree)
|
|
131
|
+
|
|
132
|
+
if where
|
|
133
|
+
var_defs << "$where: #{names[:base]}_bool_exp"
|
|
134
|
+
args << 'where: $where'
|
|
135
|
+
parent_args << "where: { #{relation[:relation_name]}: $where }"
|
|
136
|
+
variables['where'] = where
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
query = <<~GRAPHQL
|
|
140
|
+
query Aggregate#{camelize(relation[:parent_table])}#{wrap(var_defs)} {
|
|
141
|
+
#{relation[:parent_table]}#{wrap(parent_args)} {
|
|
142
|
+
#{relation[:parent_field]}
|
|
143
|
+
#{relation[:relation_name]}_aggregate#{wrap(args)} {
|
|
144
|
+
aggregate {
|
|
145
|
+
#{aggregation_selection(aggregation)}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
GRAPHQL
|
|
151
|
+
|
|
152
|
+
{ query: query, variables: variables }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Distinct values of `column` among rows without a matching parent — the
|
|
156
|
+
# dangling foreign keys a grouped chart must keep as groups of their own.
|
|
157
|
+
# distinct_on requires the matching order_by.
|
|
158
|
+
def orphan_keys(names, filter, column, relation_name, limit)
|
|
159
|
+
where = combine(
|
|
160
|
+
FilterConverter.convert(filter.condition_tree),
|
|
161
|
+
{ '_and' => [{ '_not' => { relation_name => {} } }, { column => { '_is_null' => false } }] }
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
query = <<~GRAPHQL
|
|
165
|
+
query OrphanKeys#{camelize(names[:root])}($where: #{names[:base]}_bool_exp, $limit: Int) {
|
|
166
|
+
#{names[:root]}(where: $where, distinct_on: [#{column}], order_by: [{ #{column}: asc }], limit: $limit) {
|
|
167
|
+
#{column}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
GRAPHQL
|
|
171
|
+
|
|
172
|
+
{ query: query, variables: { 'where' => where, 'limit' => limit } }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# row_count tells a group with no rows at all (SQL grouping omits it)
|
|
176
|
+
# from one whose rows exist but hold NULL in the aggregated column
|
|
177
|
+
# (SQL keeps it, at zero for a count and at NULL otherwise).
|
|
178
|
+
def aggregation_selection(aggregation)
|
|
179
|
+
"#{operation_selection(aggregation)}#{avg_merge_selection(aggregation)}\nrow_count: count"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def operation_selection(aggregation)
|
|
183
|
+
if aggregation.operation == 'Count'
|
|
184
|
+
aggregation.field ? "count(columns: #{aggregation.field})" : 'count'
|
|
185
|
+
else
|
|
186
|
+
"#{aggregation.operation.downcase} { #{aggregation.field} }"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# An average cannot be merged across parent rows sharing a group value;
|
|
191
|
+
# its sum and non-null count can, weighting it exactly.
|
|
192
|
+
def avg_merge_selection(aggregation)
|
|
193
|
+
return '' unless aggregation.operation == 'Avg'
|
|
194
|
+
|
|
195
|
+
"\navg_sum: sum { #{aggregation.field} }\navg_count: count(columns: #{aggregation.field})"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def add_sort(names, filter, args, var_defs, variables)
|
|
201
|
+
return unless filter.respond_to?(:sort) && filter.sort&.any?
|
|
202
|
+
|
|
203
|
+
var_defs << "$orderBy: [#{names[:base]}_order_by!]"
|
|
204
|
+
args << 'order_by: $orderBy'
|
|
205
|
+
variables['orderBy'] = convert_sort(filter.sort)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def add_pagination(filter, args, var_defs, variables)
|
|
209
|
+
page = filter.respond_to?(:page) ? filter.page : nil
|
|
210
|
+
|
|
211
|
+
if page&.limit
|
|
212
|
+
var_defs << '$limit: Int'
|
|
213
|
+
args << 'limit: $limit'
|
|
214
|
+
variables['limit'] = page.limit
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
return unless page&.offset&.positive?
|
|
218
|
+
|
|
219
|
+
var_defs << '$offset: Int'
|
|
220
|
+
args << 'offset: $offset'
|
|
221
|
+
variables['offset'] = page.offset
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def convert_sort(sort)
|
|
225
|
+
sort.map do |clause|
|
|
226
|
+
direction = clause[:ascending] ? 'asc' : 'desc'
|
|
227
|
+
parts = clause[:field].split(':')
|
|
228
|
+
|
|
229
|
+
parts.reverse.reduce(direction) { |memo, part| { part => memo } }
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Values are kept as submitted, nil included: a column left empty has to
|
|
234
|
+
# be written as null rather than fall back to its database default. The
|
|
235
|
+
# caller already restricted the keys to real columns.
|
|
236
|
+
def stringify_keys(record)
|
|
237
|
+
record.to_h { |key, value| [key.to_s, value] }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def parent_order(relation)
|
|
241
|
+
"order_by: [#{relation[:parent_order_fields].map { |field| "{ #{field}: asc }" }.join(", ")}]"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def combine(where, extra)
|
|
245
|
+
return where if extra.nil?
|
|
246
|
+
|
|
247
|
+
where ? { '_and' => [where, extra] } : extra
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def wrap(parts)
|
|
251
|
+
parts.empty? ? '' : "(#{parts.join(", ")})"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def camelize(name)
|
|
255
|
+
name.split('_').map(&:capitalize).join
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require_relative 'forest_admin_datasource_graphql_hasura/version'
|
|
2
|
+
require 'logger'
|
|
3
|
+
require 'set'
|
|
4
|
+
require 'zeitwerk'
|
|
5
|
+
require 'forest_admin_datasource_toolkit'
|
|
6
|
+
|
|
7
|
+
loader = Zeitwerk::Loader.for_gem
|
|
8
|
+
loader.ignore("#{__dir__}/forest_admin_datasource_graphql_hasura/introspection/structures.rb")
|
|
9
|
+
loader.setup
|
|
10
|
+
|
|
11
|
+
require_relative 'forest_admin_datasource_graphql_hasura/introspection/structures'
|
|
12
|
+
|
|
13
|
+
module ForestAdminDatasourceGraphqlHasura
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
class ConfigurationError < Error; end
|
|
16
|
+
|
|
17
|
+
# Inherits from the toolkit exception so the agent's error translator surfaces
|
|
18
|
+
# the actual message with a 400 instead of an opaque 500 "Unexpected error".
|
|
19
|
+
# Reserved for errors Hasura itself returns; transport failures are not the
|
|
20
|
+
# user's doing and raise TransportError instead.
|
|
21
|
+
class GraphqlError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end
|
|
22
|
+
|
|
23
|
+
# An unreachable endpoint is an infrastructure incident, not a client mistake:
|
|
24
|
+
# 503 keeps HTTP monitoring truthful, while inheriting the toolkit exception
|
|
25
|
+
# still lets the error translator surface the actionable message.
|
|
26
|
+
class TransportError < ForestAdminDatasourceToolkit::Exceptions::ForestException
|
|
27
|
+
def status
|
|
28
|
+
503
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class IntrospectionError < Error; end
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
attr_writer :logger
|
|
36
|
+
|
|
37
|
+
def logger
|
|
38
|
+
@logger ||= default_logger
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def default_logger
|
|
44
|
+
return Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
45
|
+
|
|
46
|
+
Logger.new($stderr).tap { |l| l.progname = 'forest_admin_datasource_graphql_hasura' }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: forest_admin_datasource_graphql_hasura
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.37.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Forest Admin
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-06 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activesupport
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '6.1'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '6.1'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: zeitwerk
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.3'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.3'
|
|
41
|
+
description: Surface tables exposed by a Hasura GraphQL API as Forest Admin collections,
|
|
42
|
+
including Rails-style polymorphic associations.
|
|
43
|
+
email:
|
|
44
|
+
- contact@forestadmin.com
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- ".rspec"
|
|
50
|
+
- LICENSE
|
|
51
|
+
- README.md
|
|
52
|
+
- Rakefile
|
|
53
|
+
- forest_admin_datasource_graphql_hasura.gemspec
|
|
54
|
+
- lib/forest_admin_datasource_graphql_hasura.rb
|
|
55
|
+
- lib/forest_admin_datasource_graphql_hasura/client.rb
|
|
56
|
+
- lib/forest_admin_datasource_graphql_hasura/collection.rb
|
|
57
|
+
- lib/forest_admin_datasource_graphql_hasura/configuration.rb
|
|
58
|
+
- lib/forest_admin_datasource_graphql_hasura/datasource.rb
|
|
59
|
+
- lib/forest_admin_datasource_graphql_hasura/introspection/introspector.rb
|
|
60
|
+
- lib/forest_admin_datasource_graphql_hasura/introspection/polymorphism_detector.rb
|
|
61
|
+
- lib/forest_admin_datasource_graphql_hasura/introspection/schema_converter.rb
|
|
62
|
+
- lib/forest_admin_datasource_graphql_hasura/introspection/structures.rb
|
|
63
|
+
- lib/forest_admin_datasource_graphql_hasura/query/aggregator.rb
|
|
64
|
+
- lib/forest_admin_datasource_graphql_hasura/query/filter_converter.rb
|
|
65
|
+
- lib/forest_admin_datasource_graphql_hasura/query/query_builder.rb
|
|
66
|
+
- lib/forest_admin_datasource_graphql_hasura/version.rb
|
|
67
|
+
homepage: https://www.forestadmin.com
|
|
68
|
+
licenses:
|
|
69
|
+
- GPL-3.0
|
|
70
|
+
metadata:
|
|
71
|
+
homepage_uri: https://www.forestadmin.com
|
|
72
|
+
source_code_uri: https://github.com/ForestAdmin/agent-ruby
|
|
73
|
+
changelog_uri: https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md
|
|
74
|
+
rubygems_mfa_required: 'false'
|
|
75
|
+
post_install_message:
|
|
76
|
+
rdoc_options: []
|
|
77
|
+
require_paths:
|
|
78
|
+
- lib
|
|
79
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: 3.0.0
|
|
84
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
requirements: []
|
|
90
|
+
rubygems_version: 3.4.20
|
|
91
|
+
signing_key:
|
|
92
|
+
specification_version: 4
|
|
93
|
+
summary: Hasura GraphQL datasource for Forest Admin Ruby agent.
|
|
94
|
+
test_files: []
|