elasticgraph-graphql 0.18.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +3 -0
- data/elasticgraph-graphql.gemspec +23 -0
- data/lib/elastic_graph/graphql/aggregation/composite_grouping_adapter.rb +79 -0
- data/lib/elastic_graph/graphql/aggregation/computation.rb +39 -0
- data/lib/elastic_graph/graphql/aggregation/date_histogram_grouping.rb +83 -0
- data/lib/elastic_graph/graphql/aggregation/field_path_encoder.rb +47 -0
- data/lib/elastic_graph/graphql/aggregation/field_term_grouping.rb +26 -0
- data/lib/elastic_graph/graphql/aggregation/key.rb +87 -0
- data/lib/elastic_graph/graphql/aggregation/nested_sub_aggregation.rb +37 -0
- data/lib/elastic_graph/graphql/aggregation/non_composite_grouping_adapter.rb +129 -0
- data/lib/elastic_graph/graphql/aggregation/path_segment.rb +31 -0
- data/lib/elastic_graph/graphql/aggregation/query.rb +172 -0
- data/lib/elastic_graph/graphql/aggregation/query_adapter.rb +345 -0
- data/lib/elastic_graph/graphql/aggregation/query_optimizer.rb +187 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/aggregated_values.rb +41 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/count_detail.rb +44 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/grouped_by.rb +30 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/node.rb +64 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/relay_connection_builder.rb +83 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/sub_aggregations.rb +82 -0
- data/lib/elastic_graph/graphql/aggregation/script_term_grouping.rb +32 -0
- data/lib/elastic_graph/graphql/aggregation/term_grouping.rb +118 -0
- data/lib/elastic_graph/graphql/client.rb +43 -0
- data/lib/elastic_graph/graphql/config.rb +81 -0
- data/lib/elastic_graph/graphql/datastore_query/document_paginator.rb +100 -0
- data/lib/elastic_graph/graphql/datastore_query/index_expression_builder.rb +142 -0
- data/lib/elastic_graph/graphql/datastore_query/paginator.rb +199 -0
- data/lib/elastic_graph/graphql/datastore_query/routing_picker.rb +239 -0
- data/lib/elastic_graph/graphql/datastore_query.rb +372 -0
- data/lib/elastic_graph/graphql/datastore_response/document.rb +78 -0
- data/lib/elastic_graph/graphql/datastore_response/search_response.rb +79 -0
- data/lib/elastic_graph/graphql/datastore_search_router.rb +151 -0
- data/lib/elastic_graph/graphql/decoded_cursor.rb +120 -0
- data/lib/elastic_graph/graphql/filtering/boolean_query.rb +45 -0
- data/lib/elastic_graph/graphql/filtering/field_path.rb +81 -0
- data/lib/elastic_graph/graphql/filtering/filter_args_translator.rb +58 -0
- data/lib/elastic_graph/graphql/filtering/filter_interpreter.rb +526 -0
- data/lib/elastic_graph/graphql/filtering/filter_value_set_extractor.rb +148 -0
- data/lib/elastic_graph/graphql/filtering/range_query.rb +56 -0
- data/lib/elastic_graph/graphql/http_endpoint.rb +229 -0
- data/lib/elastic_graph/graphql/monkey_patches/schema_field.rb +56 -0
- data/lib/elastic_graph/graphql/monkey_patches/schema_object.rb +48 -0
- data/lib/elastic_graph/graphql/query_adapter/filters.rb +161 -0
- data/lib/elastic_graph/graphql/query_adapter/pagination.rb +27 -0
- data/lib/elastic_graph/graphql/query_adapter/requested_fields.rb +124 -0
- data/lib/elastic_graph/graphql/query_adapter/sort.rb +32 -0
- data/lib/elastic_graph/graphql/query_details_tracker.rb +60 -0
- data/lib/elastic_graph/graphql/query_executor.rb +200 -0
- data/lib/elastic_graph/graphql/resolvers/get_record_field_value.rb +49 -0
- data/lib/elastic_graph/graphql/resolvers/graphql_adapter.rb +114 -0
- data/lib/elastic_graph/graphql/resolvers/list_records.rb +29 -0
- data/lib/elastic_graph/graphql/resolvers/nested_relationships.rb +74 -0
- data/lib/elastic_graph/graphql/resolvers/query_adapter.rb +85 -0
- data/lib/elastic_graph/graphql/resolvers/query_source.rb +46 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/array_adapter.rb +71 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/generic_adapter.rb +65 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/page_info.rb +82 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/search_response_adapter_builder.rb +40 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection.rb +42 -0
- data/lib/elastic_graph/graphql/resolvers/resolvable_value.rb +56 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb +35 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/date.rb +64 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/date_time.rb +60 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/local_time.rb +30 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/longs.rb +47 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/no_op.rb +24 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/time_zone.rb +44 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/untyped.rb +32 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/valid_time_zones.rb +634 -0
- data/lib/elastic_graph/graphql/schema/arguments.rb +78 -0
- data/lib/elastic_graph/graphql/schema/enum_value.rb +30 -0
- data/lib/elastic_graph/graphql/schema/field.rb +147 -0
- data/lib/elastic_graph/graphql/schema/relation_join.rb +103 -0
- data/lib/elastic_graph/graphql/schema/type.rb +263 -0
- data/lib/elastic_graph/graphql/schema.rb +164 -0
- data/lib/elastic_graph/graphql.rb +253 -0
- data/script/dump_time_zones +81 -0
- data/script/dump_time_zones.java +17 -0
- metadata +503 -0
@@ -0,0 +1,56 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
require "elastic_graph/support/hash_util"
|
10
|
+
|
11
|
+
module ElasticGraph
|
12
|
+
class GraphQL
|
13
|
+
module Filtering
|
14
|
+
# Alternate `BooleanQuery` implementation for range queries. When we get a filter like this:
|
15
|
+
#
|
16
|
+
# {some_field: {gt: 10, lt: 100}}
|
17
|
+
#
|
18
|
+
# ...we independently build a range query for each predicate. The datastore query structure would look like this:
|
19
|
+
#
|
20
|
+
# {filter: [
|
21
|
+
# {range: {some_field: {gt: 10}}},
|
22
|
+
# {range: {some_field: {lt: 100}}}
|
23
|
+
# ]}
|
24
|
+
#
|
25
|
+
# However, the `range` query allows these be combined, like so:
|
26
|
+
#
|
27
|
+
# {filter: [
|
28
|
+
# {range: {some_field: {gt: 10, lt: 100}}}
|
29
|
+
# ]}
|
30
|
+
#
|
31
|
+
# While we haven't measured it, it's likely to be more efficient (certainly not _less_ efficient!),
|
32
|
+
# and it's essential that we combine them when we are using `any_satisfy`. Consider this filter:
|
33
|
+
#
|
34
|
+
# {some_field: {any_satisfy: {gt: 10, lt: 100}}}
|
35
|
+
#
|
36
|
+
# This should match a document with `some_field: [5, 45, 200]` (since 45 is between 10 and 100),
|
37
|
+
# and not match a document with `some_field: [5, 200]` (since `some_field` has no value between 10 and 100).
|
38
|
+
# However, if we keep the range clauses separate, this document would match, because `some_field` has
|
39
|
+
# a value > 10 and a value < 100 (even though no single value satisfies both parts!). When we combine
|
40
|
+
# the clauses into a single `range` query then the filtering works like we expect.
|
41
|
+
class RangeQuery < ::Data.define(:field_name, :operator, :value)
|
42
|
+
def merge_into(bool_node)
|
43
|
+
existing_range_index = bool_node[:filter].find_index { |clause| clause.dig(:range, field_name) }
|
44
|
+
new_range_clause = {range: {field_name => {operator => value}}}
|
45
|
+
|
46
|
+
if existing_range_index
|
47
|
+
existing_range_clause = bool_node[:filter][existing_range_index]
|
48
|
+
bool_node[:filter][existing_range_index] = Support::HashUtil.deep_merge(existing_range_clause, new_range_clause)
|
49
|
+
else
|
50
|
+
bool_node[:filter] << new_range_clause
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,229 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
require "elastic_graph/graphql/client"
|
10
|
+
require "elastic_graph/support/memoizable_data"
|
11
|
+
require "json"
|
12
|
+
require "uri"
|
13
|
+
|
14
|
+
module ElasticGraph
|
15
|
+
class GraphQL
|
16
|
+
# Handles HTTP concerns for when ElasticGraph is served via HTTP. The logic here
|
17
|
+
# is based on the graphql.org recommendations:
|
18
|
+
#
|
19
|
+
# https://graphql.org/learn/serving-over-http/#http-methods-headers-and-body
|
20
|
+
#
|
21
|
+
# As that recommends, we support queries in 3 different HTTP forms:
|
22
|
+
#
|
23
|
+
# - A standard POST request as application/json with query/operationName/variables in the body.
|
24
|
+
# - A GET request with `query`, `operationName` and `variables` query params in the URL.
|
25
|
+
# - A POST as application/graphql with a query string in the body.
|
26
|
+
#
|
27
|
+
# Note that this is designed to be agnostic to what the calling HTTP context is (for example,
|
28
|
+
# AWS Lambda, or Rails, or Rack...). Instead, this uses simple Request/Response value objects
|
29
|
+
# that the calling context can easily translate to/from to use this in any HTTP context.
|
30
|
+
class HTTPEndpoint
|
31
|
+
APPLICATION_JSON = "application/json"
|
32
|
+
APPLICATION_GRAPHQL = "application/graphql"
|
33
|
+
|
34
|
+
def initialize(query_executor:, monotonic_clock:, client_resolver:)
|
35
|
+
@query_executor = query_executor
|
36
|
+
@monotonic_clock = monotonic_clock
|
37
|
+
@client_resolver = client_resolver
|
38
|
+
end
|
39
|
+
|
40
|
+
# Processes the given HTTP request, returning an HTTP response.
|
41
|
+
#
|
42
|
+
# `max_timeout_in_ms` is not a property of the HTTP request (the
|
43
|
+
# calling application will determine it instead!) so it is a separate argument.
|
44
|
+
#
|
45
|
+
# Note that this method does _not_ convert exceptions to 500 responses. It's up to
|
46
|
+
# the calling application to do that if it wants to (and to determine how much of the
|
47
|
+
# exception to return in the HTTP response...).
|
48
|
+
def process(request, max_timeout_in_ms: nil, start_time_in_ms: @monotonic_clock.now_in_ms)
|
49
|
+
client_or_response = @client_resolver.resolve(request)
|
50
|
+
return client_or_response if client_or_response.is_a?(HTTPResponse)
|
51
|
+
|
52
|
+
with_parsed_request(request, max_timeout_in_ms: max_timeout_in_ms) do |parsed|
|
53
|
+
result = @query_executor.execute(
|
54
|
+
parsed.query_string,
|
55
|
+
variables: parsed.variables,
|
56
|
+
operation_name: parsed.operation_name,
|
57
|
+
client: client_or_response,
|
58
|
+
timeout_in_ms: parsed.timeout_in_ms,
|
59
|
+
context: parsed.context,
|
60
|
+
start_time_in_ms: start_time_in_ms
|
61
|
+
)
|
62
|
+
|
63
|
+
HTTPResponse.json(200, result.to_h)
|
64
|
+
end
|
65
|
+
rescue RequestExceededDeadlineError
|
66
|
+
HTTPResponse.error(504, "Search exceeded requested timeout.")
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
# Helper method that converts `HTTPRequest` to a parsed form we can work with.
|
72
|
+
# If the request can be successfully parsed, a `ParsedRequest` will be yielded;
|
73
|
+
# otherwise an `HTTPResponse` will be returned with an error.
|
74
|
+
def with_parsed_request(request, max_timeout_in_ms:)
|
75
|
+
with_request_params(request) do |params|
|
76
|
+
with_timeout(request, max_timeout_in_ms: max_timeout_in_ms) do |timeout_in_ms|
|
77
|
+
with_context(request) do |context|
|
78
|
+
yield ParsedRequest.new(
|
79
|
+
query_string: params["query"],
|
80
|
+
variables: params["variables"] || {},
|
81
|
+
operation_name: params["operationName"],
|
82
|
+
timeout_in_ms: timeout_in_ms,
|
83
|
+
context: context
|
84
|
+
)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Responsible for handling the 3 types of requests we need to handle:
|
91
|
+
#
|
92
|
+
# - A standard POST request as application/json with query/operationName/variables in the body.
|
93
|
+
# - A GET request with `query`, `operationName` and `variables` query params in the URL.
|
94
|
+
# - A POST as application/graphql with a query string in the body.
|
95
|
+
#
|
96
|
+
# This yields a hash containing the query/operationName/variables if successful; otherwise
|
97
|
+
# it returns an `HTTPResponse` with an error.
|
98
|
+
def with_request_params(request)
|
99
|
+
params =
|
100
|
+
# POST with application/json is the most common form requests take, so we have it as the first branch here.
|
101
|
+
if request.http_method == :post && request.content_type == APPLICATION_JSON
|
102
|
+
begin
|
103
|
+
::JSON.parse(request.body.to_s)
|
104
|
+
rescue ::JSON::ParserError
|
105
|
+
# standard:disable Lint/NoReturnInBeginEndBlocks
|
106
|
+
return HTTPResponse.error(400, "Request body is invalid JSON.")
|
107
|
+
# standard:enable Lint/NoReturnInBeginEndBlocks
|
108
|
+
end
|
109
|
+
|
110
|
+
elsif request.http_method == :post && request.content_type == APPLICATION_GRAPHQL
|
111
|
+
{"query" => request.body}
|
112
|
+
|
113
|
+
elsif request.http_method == :post
|
114
|
+
return HTTPResponse.error(415, "`#{request.content_type}` is not a supported content type. Only `#{APPLICATION_JSON}` and `#{APPLICATION_GRAPHQL}` are supported.")
|
115
|
+
|
116
|
+
elsif request.http_method == :get
|
117
|
+
::URI.decode_www_form(::URI.parse(request.url).query.to_s).to_h.tap do |hash|
|
118
|
+
# Variables must come in as JSON, even if in the URL. express-graphql does it this way,
|
119
|
+
# which is a bit of a canonical implementation, as it is referenced from graphql.org:
|
120
|
+
# https://github.com/graphql/express-graphql/blob/v0.12.0/src/index.ts#L492-L497
|
121
|
+
hash["variables"] &&= ::JSON.parse(hash["variables"])
|
122
|
+
rescue ::JSON::ParserError
|
123
|
+
return HTTPResponse.error(400, "Variables are invalid JSON.")
|
124
|
+
end
|
125
|
+
|
126
|
+
else
|
127
|
+
return HTTPResponse.error(405, "GraphQL only supports GET and POST requests.")
|
128
|
+
end
|
129
|
+
|
130
|
+
# Ignore an empty string operationName.
|
131
|
+
params = params.merge("operationName" => nil) if params["operationName"] && params["operationName"].empty?
|
132
|
+
|
133
|
+
yield params
|
134
|
+
end
|
135
|
+
|
136
|
+
# Responsible for figuring out the timeout, based on a header and a provided max.
|
137
|
+
# If successful, yields the timeout value; otherwise will return an `HTTPResponse` with
|
138
|
+
# an error.
|
139
|
+
def with_timeout(request, max_timeout_in_ms:)
|
140
|
+
requested_timeout_in_ms =
|
141
|
+
if (timeout_in_ms_str = request.normalized_headers[HTTPRequest.normalize_header_name(TIMEOUT_MS_HEADER)])
|
142
|
+
begin
|
143
|
+
Integer(timeout_in_ms_str)
|
144
|
+
rescue ::ArgumentError
|
145
|
+
# standard:disable Lint/NoReturnInBeginEndBlocks
|
146
|
+
return HTTPResponse.error(400, "`#{TIMEOUT_MS_HEADER}` header value of #{timeout_in_ms_str.inspect} is invalid")
|
147
|
+
# standard:enable Lint/NoReturnInBeginEndBlocks
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
yield [max_timeout_in_ms, requested_timeout_in_ms].compact.min
|
152
|
+
end
|
153
|
+
|
154
|
+
# Responsible for determining any `context` values to pass down into the `query_executor`,
|
155
|
+
# which in turn will make the values available to the GraphQL resolvers.
|
156
|
+
#
|
157
|
+
# By default, our only context value is the HTTP request. This method exists to provide an extension
|
158
|
+
# point so that ElasticGraph extensions can add `context` values based on the `request` as desired.
|
159
|
+
#
|
160
|
+
# Extensions can return an `HTTPResponse` with an error if the `request` is invalid according
|
161
|
+
# to their requirements. Otherwise, they must call `super` (to delegate to this and any other
|
162
|
+
# extensions) with a block. In the block, they must merge in their `context` values and then `yield`.
|
163
|
+
def with_context(request)
|
164
|
+
yield({http_request: request})
|
165
|
+
end
|
166
|
+
|
167
|
+
ParsedRequest = Data.define(:query_string, :variables, :operation_name, :timeout_in_ms, :context)
|
168
|
+
end
|
169
|
+
|
170
|
+
# Represents an HTTP request, containing:
|
171
|
+
#
|
172
|
+
# - http_method: a symbol like :get or :post.
|
173
|
+
# - url: a string containing the full URL.
|
174
|
+
# - headers: a hash with string keys and values containing HTTP headers. The headers can
|
175
|
+
# be in any form like `Content-Type`, `content-type`, `CONTENT-TYPE`, `CONTENT_TYPE`, etc.
|
176
|
+
# - body: a string containing the request body, if there was one.
|
177
|
+
HTTPRequest = Support::MemoizableData.define(:http_method, :url, :headers, :body) do
|
178
|
+
# @implements HTTPRequest
|
179
|
+
|
180
|
+
# HTTP headers are intended to be case-insensitive, and different Web frameworks treat them differently.
|
181
|
+
# For example, Rack uppercases them with `_` in place of `-`. With AWS Lambda proxy integrations API
|
182
|
+
# gateway HTTP APIs, header names are lowercased:
|
183
|
+
# https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
|
184
|
+
#
|
185
|
+
# ...but for integration with API gateway REST APIs, header names are provided as-is:
|
186
|
+
# https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
|
187
|
+
#
|
188
|
+
# To be maximally compatible here, this normalizes to uppercase form with dashes in place of underscores.
|
189
|
+
def normalized_headers
|
190
|
+
@normalized_headers ||= headers.transform_keys do |key|
|
191
|
+
HTTPRequest.normalize_header_name(key)
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
def content_type
|
196
|
+
@content_type ||= normalized_headers["CONTENT-TYPE"]
|
197
|
+
end
|
198
|
+
|
199
|
+
def self.normalize_header_name(header)
|
200
|
+
header.upcase.tr("_", "-")
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
# Represents an HTTP response, containing:
|
205
|
+
#
|
206
|
+
# - status_code: an integer like 200.
|
207
|
+
# - headers: a hash with string keys and values containing HTTP response headers.
|
208
|
+
# - body: a string containing the response body.
|
209
|
+
HTTPResponse = Data.define(:status_code, :headers, :body) do
|
210
|
+
# @implements HTTPResponse
|
211
|
+
|
212
|
+
# Helper method for building a JSON response.
|
213
|
+
def self.json(status_code, body)
|
214
|
+
new(status_code, {"Content-Type" => HTTPEndpoint::APPLICATION_JSON}, ::JSON.generate(body))
|
215
|
+
end
|
216
|
+
|
217
|
+
# Helper method for building an error response.
|
218
|
+
def self.error(status_code, message)
|
219
|
+
json(status_code, {"errors" => [{"message" => message}]})
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
# Steep weirdly expects them here...
|
224
|
+
# @dynamic initialize, config, logger, runtime_metadata, graphql_schema_string, datastore_core, clock
|
225
|
+
# @dynamic graphql_http_endpoint, graphql_query_executor, schema, datastore_search_router, filter_interpreter
|
226
|
+
# @dynamic datastore_query_builder, graphql_gem_plugins, graphql_resolvers, datastore_query_adapters, monotonic_clock
|
227
|
+
# @dynamic load_dependencies_eagerly, self.from_parsed_yaml, filter_args_translator, sub_aggregation_grouping_adapter
|
228
|
+
end
|
229
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
require "graphql"
|
10
|
+
|
11
|
+
module ElasticGraph
|
12
|
+
class GraphQL
|
13
|
+
module MonkeyPatches
|
14
|
+
# This module is designed to monkey patch `GraphQL::Schema::Field`, but to do so in a
|
15
|
+
# conservative, safe way:
|
16
|
+
#
|
17
|
+
# - It defines no new methods.
|
18
|
+
# - It delegates to the original implementation with `super` unless we are sure that a type should be hidden.
|
19
|
+
# - It only changes the behavior for ElasticGraph schemas (as indicated by `:elastic_graph_schema` in the `context`).
|
20
|
+
module SchemaFieldVisibilityDecorator
|
21
|
+
def visible?(context)
|
22
|
+
# `DynamicFields` and `EntryPoints` are built-in introspection types that `field_named` below doesn't support:
|
23
|
+
# https://github.com/rmosolgo/graphql-ruby/blob/0df187995c971b399ed7cc1fbdcbd958af6c4ade/lib/graphql/introspection/entry_points.rb
|
24
|
+
# https://github.com/rmosolgo/graphql-ruby/blob/0df187995c971b399ed7cc1fbdcbd958af6c4ade/lib/graphql/introspection/dynamic_fields.rb
|
25
|
+
#
|
26
|
+
# ...so if the owner is one of those we just return `super` here.
|
27
|
+
return super if %w[DynamicFields EntryPoints].include?(owner.graphql_name)
|
28
|
+
|
29
|
+
if context[:elastic_graph_schema]&.field_named(owner.graphql_name, graphql_name)&.hidden_from_queries?
|
30
|
+
return false
|
31
|
+
end
|
32
|
+
|
33
|
+
super
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# As per https://graphql-ruby.org/authorization/visibility.html, the public API
|
41
|
+
# provided by the GraphQL gem to control visibility of object types is to define
|
42
|
+
# a `visible?` instance method on a custom subclass of `GraphQL::Schema::Field`.
|
43
|
+
# However, because we load our schema from an SDL definition rather than defining
|
44
|
+
# classes for each schema type, we don't have a way to register a custom subclass
|
45
|
+
# to be used for fields.
|
46
|
+
#
|
47
|
+
# So, here we solve this a slightly different way: we prepend a module onto
|
48
|
+
# the `GraphQL::Schema::Field class. This allows our module to act like a
|
49
|
+
# decorator and intercept calls to `visible?` so that it can hide types as needed.
|
50
|
+
module GraphQL
|
51
|
+
class Schema
|
52
|
+
class Field
|
53
|
+
prepend ::ElasticGraph::GraphQL::MonkeyPatches::SchemaFieldVisibilityDecorator
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
require "graphql"
|
10
|
+
|
11
|
+
module ElasticGraph
|
12
|
+
class GraphQL
|
13
|
+
module MonkeyPatches
|
14
|
+
# This module is designed to monkey patch `GraphQL::Schema::Object`, but to do so in a
|
15
|
+
# conservative, safe way:
|
16
|
+
#
|
17
|
+
# - It defines no new methods.
|
18
|
+
# - It delegates to the original implementation with `super` unless we are sure that a type should be hidden.
|
19
|
+
# - It only changes the behavior for ElasticGraph schemas (as indicated by `:elastic_graph_schema` in the `context`).
|
20
|
+
module SchemaObjectVisibilityDecorator
|
21
|
+
def visible?(context)
|
22
|
+
if context[:elastic_graph_schema]&.type_named(graphql_name)&.hidden_from_queries?
|
23
|
+
context[:elastic_graph_query_tracker].record_hidden_type(graphql_name)
|
24
|
+
return false
|
25
|
+
end
|
26
|
+
|
27
|
+
super
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# As per https://graphql-ruby.org/authorization/visibility.html, the public API
|
35
|
+
# provided by the GraphQL gem to control visibility of object types is to define
|
36
|
+
# a `visible?` class method on each of your type classes. However, because we load
|
37
|
+
# our schema from an SDL definition rather than defining classes for each schema
|
38
|
+
# type, we don't have a way to define the `visible?` on each of our type classes.
|
39
|
+
#
|
40
|
+
# So, here we solve this a slightly different way: we prepend a module onto
|
41
|
+
# the `GraphQL::Schema::Object` singleton class. This allows our module to
|
42
|
+
# act like a decorator and intercept calls to `visible?` so that it can hide
|
43
|
+
# types as needed. This works because all types must be defined as subclasses
|
44
|
+
# of `GraphQL::Schema::Object`, and in fact the GraphQL gem defined anonymous
|
45
|
+
# subclasses for each type in our SDL schema, as you can see here:
|
46
|
+
#
|
47
|
+
# https://github.com/rmosolgo/graphql-ruby/blob/v1.12.16/lib/graphql/schema/build_from_definition.rb#L312
|
48
|
+
GraphQL::Schema::Object.singleton_class.prepend ElasticGraph::GraphQL::MonkeyPatches::SchemaObjectVisibilityDecorator
|
@@ -0,0 +1,161 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
require "elastic_graph/constants"
|
10
|
+
require "elastic_graph/graphql/filtering/filter_value_set_extractor"
|
11
|
+
require "elastic_graph/support/memoizable_data"
|
12
|
+
|
13
|
+
module ElasticGraph
|
14
|
+
class GraphQL
|
15
|
+
class QueryAdapter
|
16
|
+
class Filters < Support::MemoizableData.define(:schema_element_names, :filter_args_translator)
|
17
|
+
def call(field:, query:, args:, lookahead:, context:)
|
18
|
+
filter_from_args = filter_args_translator.translate_filter_args(field: field, args: args)
|
19
|
+
automatic_filter = build_automatic_filter(filter_from_args: filter_from_args, query: query)
|
20
|
+
filters = [filter_from_args, automatic_filter].compact
|
21
|
+
return query if filters.empty?
|
22
|
+
|
23
|
+
query.merge_with(filters: filters)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def build_automatic_filter(filter_from_args:, query:)
|
29
|
+
# If an incomplete document could be hit by a search with our filters against any of the
|
30
|
+
# index definitions, we must add a filter that will exclude incomplete documents.
|
31
|
+
exclude_incomplete_docs_filter if query
|
32
|
+
.search_index_definitions
|
33
|
+
.any? { |index_def| search_could_hit_incomplete_docs?(index_def, filter_from_args || {}) }
|
34
|
+
end
|
35
|
+
|
36
|
+
def exclude_incomplete_docs_filter
|
37
|
+
{"__sources" => {schema_element_names.equal_to_any_of => [SELF_RELATIONSHIP_NAME]}}
|
38
|
+
end
|
39
|
+
|
40
|
+
# Indicates if a search against the given `index_def` using the given `filter_from_args`
|
41
|
+
# could hit an incomplete document.
|
42
|
+
def search_could_hit_incomplete_docs?(index_def, filter_from_args)
|
43
|
+
# If the index definition doesn't allow any searches to hit incomplete documents, we
|
44
|
+
# can immediately return `false` without checking the filters.
|
45
|
+
return false unless index_def.searches_could_hit_incomplete_docs?
|
46
|
+
|
47
|
+
# ...otherwise, we have to look at how we are filtering. An incomplete document will have `null`
|
48
|
+
# values for all fields with a `SELF_RELATIONSHIP_NAME` source. Therefore, if we filter on a
|
49
|
+
# self-sourced field in a way that excludes documents with a `null` value, the search cannot
|
50
|
+
# hit incomplete documents. However, when in doubt we'd rather return `true` as that's the safer
|
51
|
+
# value to return (no bugs will result from returning `true` when we could have returned `false`,
|
52
|
+
# but the query may not be as efficient as we'd like).
|
53
|
+
#
|
54
|
+
# Here we determine what field paths we need to check (e.g. only those field paths that are against
|
55
|
+
# self-sourced fields).
|
56
|
+
paths_to_check = determine_paths_to_check(filter_from_args, index_def.fields_by_path)
|
57
|
+
|
58
|
+
# If we have no paths to check, then our filters don't exclude incomplete documents and we must return `true`.
|
59
|
+
return true if paths_to_check.empty?
|
60
|
+
|
61
|
+
# Finally, we look over each path. If all our filters allow the search to match documents that have `nil`
|
62
|
+
# at that path, then the search can hit incomplete documents. But if even one path excludes documents
|
63
|
+
# that have a `null` value for the field, we can safely return `false` for a more efficient query.
|
64
|
+
paths_to_check.all? { |path| can_match_nil_values_at?(path, filter_from_args) }
|
65
|
+
end
|
66
|
+
|
67
|
+
# Figures out which field paths we need to check to see if a filter on it could match an incomplete document.
|
68
|
+
# This method returns the set intersection of:
|
69
|
+
#
|
70
|
+
# - The field paths we are filtering on.
|
71
|
+
# - The field paths that are sourced from `SELF_RELATIONSHIP_NAME`.
|
72
|
+
def determine_paths_to_check(filter, index_fields_by_path, parent_path: "")
|
73
|
+
filter.compact.flat_map do |field_name, value|
|
74
|
+
path = parent_path + field_name
|
75
|
+
|
76
|
+
if (index_field = index_fields_by_path[path])
|
77
|
+
# We've recursed down to a field path. We want that path to be returned if the
|
78
|
+
# field is sourced from SELF_RELATIONSHIP_NAME.
|
79
|
+
(index_field.source == SELF_RELATIONSHIP_NAME) ? [path] : []
|
80
|
+
elsif field_name == schema_element_names.any_of
|
81
|
+
# `any_of` represents an OR and the value will be an array, so we have to flat map over it.
|
82
|
+
value.flat_map do |sub_filter|
|
83
|
+
determine_paths_to_check(sub_filter, index_fields_by_path, parent_path: parent_path)
|
84
|
+
end
|
85
|
+
elsif field_name == schema_element_names.not
|
86
|
+
# While `not` represents negation, we don't have to negate anything here because the negation
|
87
|
+
# is handled later (when we use `filter_value_set_extractor`). Here we are just determining the
|
88
|
+
# paths to check. We want to recurse without adding `not` to the `parent_path` since it's not
|
89
|
+
# part of the field path.
|
90
|
+
determine_paths_to_check(value, index_fields_by_path, parent_path: parent_path)
|
91
|
+
else
|
92
|
+
# ...otherwise, `field_name` is a parent field and we need to recurse down through the children.
|
93
|
+
determine_paths_to_check(value, index_fields_by_path, parent_path: "#{path}.")
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Indicates if the given `filter` can match `nil` values at the given `path`. We rely
|
99
|
+
# on `filter_value_set_extractor` to determine it, since it understands the semantics
|
100
|
+
# of `any_of`, `not`, etc.
|
101
|
+
def can_match_nil_values_at?(path, filter)
|
102
|
+
filter_value_set_extractor.extract_filter_value_set([filter], [path]).includes_nil?
|
103
|
+
end
|
104
|
+
|
105
|
+
def filter_value_set_extractor
|
106
|
+
@filter_value_set_extractor ||=
|
107
|
+
Filtering::FilterValueSetExtractor.new(schema_element_names, IncludesNilSet) do |operator, filter_value|
|
108
|
+
if operator == :equal_to_any_of && filter_value.include?(nil)
|
109
|
+
IncludesNilSet
|
110
|
+
else
|
111
|
+
ExcludesNilSet
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Mixin for use with our set implementations that only care about if `nil` is an included value or not.
|
117
|
+
module NilFocusedSet
|
118
|
+
def union(other)
|
119
|
+
(includes_nil? || other.includes_nil?) ? IncludesNilSet : ExcludesNilSet
|
120
|
+
end
|
121
|
+
|
122
|
+
def intersection(other)
|
123
|
+
(includes_nil? && other.includes_nil?) ? IncludesNilSet : ExcludesNilSet
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# A representation of a set that includes `nil`.
|
128
|
+
module IncludesNilSet
|
129
|
+
extend NilFocusedSet
|
130
|
+
|
131
|
+
# Methods provided by `extend NilFocusedSet`
|
132
|
+
# @dynamic self.union, self.intersection
|
133
|
+
|
134
|
+
def self.negate
|
135
|
+
ExcludesNilSet
|
136
|
+
end
|
137
|
+
|
138
|
+
def self.includes_nil?
|
139
|
+
true
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# A representation of a set that excludes `nil`.
|
144
|
+
module ExcludesNilSet
|
145
|
+
extend NilFocusedSet
|
146
|
+
|
147
|
+
# Methods provided by `extend NilFocusedSet`
|
148
|
+
# @dynamic self.union, self.intersection
|
149
|
+
|
150
|
+
def self.negate
|
151
|
+
IncludesNilSet
|
152
|
+
end
|
153
|
+
|
154
|
+
def self.includes_nil?
|
155
|
+
false
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
module ElasticGraph
|
10
|
+
class GraphQL
|
11
|
+
class QueryAdapter
|
12
|
+
# Note: This class is not tested directly but indirectly through specs on `QueryAdapter`
|
13
|
+
Pagination = Data.define(:schema_element_names) do
|
14
|
+
# @implements Pagination
|
15
|
+
def call(query:, args:, lookahead:, field:, context:)
|
16
|
+
return query unless field.type.unwrap_fully.indexed_document?
|
17
|
+
|
18
|
+
document_pagination = [:first, :before, :last, :after].to_h do |key|
|
19
|
+
[key, args[schema_element_names.public_send(key)]]
|
20
|
+
end
|
21
|
+
|
22
|
+
query.merge_with(document_pagination: document_pagination)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|