elasticgraph-graphql 0.18.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +3 -0
  4. data/elasticgraph-graphql.gemspec +23 -0
  5. data/lib/elastic_graph/graphql/aggregation/composite_grouping_adapter.rb +79 -0
  6. data/lib/elastic_graph/graphql/aggregation/computation.rb +39 -0
  7. data/lib/elastic_graph/graphql/aggregation/date_histogram_grouping.rb +83 -0
  8. data/lib/elastic_graph/graphql/aggregation/field_path_encoder.rb +47 -0
  9. data/lib/elastic_graph/graphql/aggregation/field_term_grouping.rb +26 -0
  10. data/lib/elastic_graph/graphql/aggregation/key.rb +87 -0
  11. data/lib/elastic_graph/graphql/aggregation/nested_sub_aggregation.rb +37 -0
  12. data/lib/elastic_graph/graphql/aggregation/non_composite_grouping_adapter.rb +129 -0
  13. data/lib/elastic_graph/graphql/aggregation/path_segment.rb +31 -0
  14. data/lib/elastic_graph/graphql/aggregation/query.rb +172 -0
  15. data/lib/elastic_graph/graphql/aggregation/query_adapter.rb +345 -0
  16. data/lib/elastic_graph/graphql/aggregation/query_optimizer.rb +187 -0
  17. data/lib/elastic_graph/graphql/aggregation/resolvers/aggregated_values.rb +41 -0
  18. data/lib/elastic_graph/graphql/aggregation/resolvers/count_detail.rb +44 -0
  19. data/lib/elastic_graph/graphql/aggregation/resolvers/grouped_by.rb +30 -0
  20. data/lib/elastic_graph/graphql/aggregation/resolvers/node.rb +64 -0
  21. data/lib/elastic_graph/graphql/aggregation/resolvers/relay_connection_builder.rb +83 -0
  22. data/lib/elastic_graph/graphql/aggregation/resolvers/sub_aggregations.rb +82 -0
  23. data/lib/elastic_graph/graphql/aggregation/script_term_grouping.rb +32 -0
  24. data/lib/elastic_graph/graphql/aggregation/term_grouping.rb +118 -0
  25. data/lib/elastic_graph/graphql/client.rb +43 -0
  26. data/lib/elastic_graph/graphql/config.rb +81 -0
  27. data/lib/elastic_graph/graphql/datastore_query/document_paginator.rb +100 -0
  28. data/lib/elastic_graph/graphql/datastore_query/index_expression_builder.rb +142 -0
  29. data/lib/elastic_graph/graphql/datastore_query/paginator.rb +199 -0
  30. data/lib/elastic_graph/graphql/datastore_query/routing_picker.rb +239 -0
  31. data/lib/elastic_graph/graphql/datastore_query.rb +372 -0
  32. data/lib/elastic_graph/graphql/datastore_response/document.rb +78 -0
  33. data/lib/elastic_graph/graphql/datastore_response/search_response.rb +79 -0
  34. data/lib/elastic_graph/graphql/datastore_search_router.rb +151 -0
  35. data/lib/elastic_graph/graphql/decoded_cursor.rb +120 -0
  36. data/lib/elastic_graph/graphql/filtering/boolean_query.rb +45 -0
  37. data/lib/elastic_graph/graphql/filtering/field_path.rb +81 -0
  38. data/lib/elastic_graph/graphql/filtering/filter_args_translator.rb +58 -0
  39. data/lib/elastic_graph/graphql/filtering/filter_interpreter.rb +526 -0
  40. data/lib/elastic_graph/graphql/filtering/filter_value_set_extractor.rb +148 -0
  41. data/lib/elastic_graph/graphql/filtering/range_query.rb +56 -0
  42. data/lib/elastic_graph/graphql/http_endpoint.rb +229 -0
  43. data/lib/elastic_graph/graphql/monkey_patches/schema_field.rb +56 -0
  44. data/lib/elastic_graph/graphql/monkey_patches/schema_object.rb +48 -0
  45. data/lib/elastic_graph/graphql/query_adapter/filters.rb +161 -0
  46. data/lib/elastic_graph/graphql/query_adapter/pagination.rb +27 -0
  47. data/lib/elastic_graph/graphql/query_adapter/requested_fields.rb +124 -0
  48. data/lib/elastic_graph/graphql/query_adapter/sort.rb +32 -0
  49. data/lib/elastic_graph/graphql/query_details_tracker.rb +60 -0
  50. data/lib/elastic_graph/graphql/query_executor.rb +200 -0
  51. data/lib/elastic_graph/graphql/resolvers/get_record_field_value.rb +49 -0
  52. data/lib/elastic_graph/graphql/resolvers/graphql_adapter.rb +114 -0
  53. data/lib/elastic_graph/graphql/resolvers/list_records.rb +29 -0
  54. data/lib/elastic_graph/graphql/resolvers/nested_relationships.rb +74 -0
  55. data/lib/elastic_graph/graphql/resolvers/query_adapter.rb +85 -0
  56. data/lib/elastic_graph/graphql/resolvers/query_source.rb +46 -0
  57. data/lib/elastic_graph/graphql/resolvers/relay_connection/array_adapter.rb +71 -0
  58. data/lib/elastic_graph/graphql/resolvers/relay_connection/generic_adapter.rb +65 -0
  59. data/lib/elastic_graph/graphql/resolvers/relay_connection/page_info.rb +82 -0
  60. data/lib/elastic_graph/graphql/resolvers/relay_connection/search_response_adapter_builder.rb +40 -0
  61. data/lib/elastic_graph/graphql/resolvers/relay_connection.rb +42 -0
  62. data/lib/elastic_graph/graphql/resolvers/resolvable_value.rb +56 -0
  63. data/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb +35 -0
  64. data/lib/elastic_graph/graphql/scalar_coercion_adapters/date.rb +64 -0
  65. data/lib/elastic_graph/graphql/scalar_coercion_adapters/date_time.rb +60 -0
  66. data/lib/elastic_graph/graphql/scalar_coercion_adapters/local_time.rb +30 -0
  67. data/lib/elastic_graph/graphql/scalar_coercion_adapters/longs.rb +47 -0
  68. data/lib/elastic_graph/graphql/scalar_coercion_adapters/no_op.rb +24 -0
  69. data/lib/elastic_graph/graphql/scalar_coercion_adapters/time_zone.rb +44 -0
  70. data/lib/elastic_graph/graphql/scalar_coercion_adapters/untyped.rb +32 -0
  71. data/lib/elastic_graph/graphql/scalar_coercion_adapters/valid_time_zones.rb +634 -0
  72. data/lib/elastic_graph/graphql/schema/arguments.rb +78 -0
  73. data/lib/elastic_graph/graphql/schema/enum_value.rb +30 -0
  74. data/lib/elastic_graph/graphql/schema/field.rb +147 -0
  75. data/lib/elastic_graph/graphql/schema/relation_join.rb +103 -0
  76. data/lib/elastic_graph/graphql/schema/type.rb +263 -0
  77. data/lib/elastic_graph/graphql/schema.rb +164 -0
  78. data/lib/elastic_graph/graphql.rb +253 -0
  79. data/script/dump_time_zones +81 -0
  80. data/script/dump_time_zones.java +17 -0
  81. 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