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.
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