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,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,3 @@
1
+ module ForestAdminDatasourceGraphqlHasura
2
+ VERSION = "1.37.0"
3
+ 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: []