elasticgraph-graphql 0.18.0.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/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
|