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,124 @@
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
+ # Query adapter that populates the `requested_fields` attribute of an `DatastoreQuery` in
13
+ # order to limit what fields we fetch from the datastore to only those that we actually
14
+ # need to satisfy the GraphQL query. This results in more efficient datastore queries,
15
+ # similar to doing `SELECT f1, f2, ...` instead of `SELECT *` for a SQL query.
16
+ class RequestedFields
17
+ def initialize(schema)
18
+ @schema = schema
19
+ end
20
+
21
+ def call(field:, query:, lookahead:, args:, context:)
22
+ return query if field.type.unwrap_fully.indexed_aggregation?
23
+
24
+ attributes = query_attributes_for(field: field, lookahead: lookahead)
25
+ query.merge_with(**attributes)
26
+ end
27
+
28
+ def query_attributes_for(field:, lookahead:)
29
+ attributes =
30
+ if field.type.relay_connection?
31
+ {
32
+ individual_docs_needed: pagination_fields_need_individual_docs?(lookahead),
33
+ requested_fields: requested_fields_under(relay_connection_node_from(lookahead))
34
+ }
35
+ else
36
+ {
37
+ requested_fields: requested_fields_under(lookahead)
38
+ }
39
+ end
40
+
41
+ attributes.merge(total_document_count_needed: query_needs_total_document_count?(lookahead))
42
+ end
43
+
44
+ private
45
+
46
+ # Identifies the fields we need to fetch from the datastore by looking for the fields
47
+ # under the given `node`.
48
+ #
49
+ # For nested relation fields, it is important that we start with this method, instead of
50
+ # `requested_fields_for`, because they need to be treated differently if we are building
51
+ # an `DatastoreQuery` for the nested relation field, or for a parent type. When we determine
52
+ # requested fields for a nested relation field, we need to look at its child fields, and we
53
+ # can ignore its foreign key; but when we are determining requested fields for a parent type,
54
+ # we need to identify the foreign key to request from the datastore, without recursing into
55
+ # its children.
56
+ def requested_fields_under(node, path_prefix: "")
57
+ fields = node.selections.flat_map do |child|
58
+ requested_fields_for(child, path_prefix: path_prefix)
59
+ end
60
+
61
+ fields << "#{path_prefix}__typename" if field_for(node.field)&.type&.abstract?
62
+ fields
63
+ end
64
+
65
+ # Identifies the fields we need to fetch from the datastore for the given node,
66
+ # and recursing into the fields under it as needed.
67
+ def requested_fields_for(node, path_prefix:)
68
+ return [] if graphql_dynamic_field?(node)
69
+
70
+ # @type var field: Schema::Field
71
+ field = _ = field_for(node.field)
72
+
73
+ if field.type.embedded_object?
74
+ requested_fields_under(node, path_prefix: "#{path_prefix}#{field.name_in_index}.")
75
+ else
76
+ field.index_field_names_for_resolution.map do |name|
77
+ "#{path_prefix}#{name}"
78
+ end
79
+ end
80
+ end
81
+
82
+ def field_for(field)
83
+ return nil unless field
84
+ @schema.field_named(field.owner.graphql_name, field.name)
85
+ end
86
+
87
+ def pagination_fields_need_individual_docs?(lookahead)
88
+ # If the client wants cursors, we need to request docs from the datastore so we get back the sort values
89
+ # for each node, which we can then encode into a cursor.
90
+ return true if lookahead.selection(@schema.element_names.edges).selects?(@schema.element_names.cursor)
91
+
92
+ # Most subfields of `page_info` also require us to fetch documents from the datastore. For example,
93
+ # we cannot compute `has_next_page` or `has_previous_page` correctly if we do not fetch a full page
94
+ # of documents from the datastore.
95
+ lookahead.selection(@schema.element_names.page_info).selections.any?
96
+ end
97
+
98
+ def relay_connection_node_from(lookahead)
99
+ node = lookahead.selection(@schema.element_names.nodes)
100
+ return node if node.selected?
101
+
102
+ lookahead
103
+ .selection(@schema.element_names.edges)
104
+ .selection(@schema.element_names.node)
105
+ end
106
+
107
+ # total_hits_count is needed when the connection explicitly specifies `total_edge_count` to
108
+ # be returned in the part of the GraphQL query we are processing. Note that the aggregation
109
+ # query adapter can also set it to true based on its needs.
110
+ def query_needs_total_document_count?(lookahead)
111
+ # If total edge count is explicitly specified in page_info, we have to return the total count
112
+ lookahead.selects?(@schema.element_names.total_edge_count)
113
+ end
114
+
115
+ def graphql_dynamic_field?(node)
116
+ # As per https://spec.graphql.org/October2021/#sec-Objects,
117
+ # > All fields defined within an Object type must not have a name which begins with "__"
118
+ # > (two underscores), as this is used exclusively by GraphQL’s introspection system.
119
+ node.field.name.start_with?("__")
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,32 @@
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
+ Sort = Data.define(:order_by_arg_name) do
14
+ # @implements Sort
15
+ def call(query:, args:, field:, lookahead:, context:)
16
+ sort_clauses = field.sort_clauses_for(args[order_by_arg_name])
17
+
18
+ if sort_clauses.empty?
19
+ # When there are multiple search index definitions, we just need to pick one as the
20
+ # source of the default sort clauses. It doesn't really matter which (if the client
21
+ # really cared, they would have provided an `order_by` argument...) but we want our
22
+ # logic to be consistent and deterministic, so we just use the alphabetically first
23
+ # index here.
24
+ sort_clauses = (_ = query.search_index_definitions.min_by(&:name)).default_sort_clauses
25
+ end
26
+
27
+ query.merge_with(sort: sort_clauses)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,60 @@
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/hash_util"
11
+ require "graphql"
12
+
13
+ module ElasticGraph
14
+ class GraphQL
15
+ # Class used to track details of what happens during a single GraphQL query for the purposes of logging.
16
+ # Here we use `Struct` instead of `Data` specifically because it is designed to be mutable.
17
+ class QueryDetailsTracker < Struct.new(
18
+ :hidden_types,
19
+ :shard_routing_values,
20
+ :search_index_expressions,
21
+ :query_counts_per_datastore_request,
22
+ :datastore_query_server_duration_ms,
23
+ :datastore_query_client_duration_ms,
24
+ :mutex
25
+ )
26
+ def self.empty
27
+ new(
28
+ hidden_types: ::Set.new,
29
+ shard_routing_values: ::Set.new,
30
+ search_index_expressions: ::Set.new,
31
+ query_counts_per_datastore_request: [],
32
+ datastore_query_server_duration_ms: 0,
33
+ datastore_query_client_duration_ms: 0,
34
+ mutex: ::Thread::Mutex.new
35
+ )
36
+ end
37
+
38
+ def record_datastore_queries_for_single_request(queries)
39
+ mutex.synchronize do
40
+ shard_routing_values.merge(queries.flat_map { |q| q.shard_routing_values || [] })
41
+ search_index_expressions.merge(queries.map(&:search_index_expression))
42
+ query_counts_per_datastore_request << queries.size
43
+ end
44
+ end
45
+
46
+ def record_hidden_type(type)
47
+ mutex.synchronize do
48
+ hidden_types << type
49
+ end
50
+ end
51
+
52
+ def record_datastore_query_duration_ms(client:, server:)
53
+ mutex.synchronize do
54
+ self.datastore_query_client_duration_ms += client
55
+ self.datastore_query_server_duration_ms += server if server
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,200 @@
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/graphql/query_details_tracker"
11
+ require "elastic_graph/support/hash_util"
12
+ require "graphql"
13
+
14
+ module ElasticGraph
15
+ class GraphQL
16
+ # Responsible for executing queries.
17
+ class QueryExecutor
18
+ # @dynamic schema
19
+ attr_reader :schema
20
+
21
+ def initialize(schema:, monotonic_clock:, logger:, slow_query_threshold_ms:, datastore_search_router:)
22
+ @schema = schema
23
+ @monotonic_clock = monotonic_clock
24
+ @logger = logger
25
+ @slow_query_threshold_ms = slow_query_threshold_ms
26
+ @datastore_search_router = datastore_search_router
27
+ end
28
+
29
+ # Executes the given `query_string` using the provided `variables`.
30
+ #
31
+ # `timeout_in_ms` can be provided to limit how long the query runs for. If the timeout
32
+ # is exceeded, `RequestExceededDeadlineError` will be raised. Note that `timeout_in_ms`
33
+ # does not provide an absolute guarantee that the query will take no longer than the
34
+ # provided value; it is only used to halt datastore queries. In process computation
35
+ # can make the total query time exceeded the specified timeout.
36
+ #
37
+ # `context` is merged into the context hash passed to the resolvers.
38
+ def execute(
39
+ query_string,
40
+ client: Client::ANONYMOUS,
41
+ variables: {},
42
+ timeout_in_ms: nil,
43
+ operation_name: nil,
44
+ context: {},
45
+ start_time_in_ms: @monotonic_clock.now_in_ms
46
+ )
47
+ # Before executing the query, prune any null-valued variable fields. This means we
48
+ # treat `foo: null` the same as if `foo` was unmentioned. With certain clients (e.g.
49
+ # code-gen'd clients in a statically typed language), it is non-trivial to avoid
50
+ # mentioning variable fields they aren't using. It makes it easier to evolve the
51
+ # schema if we ignore null-valued fields rather than potentially returning an error
52
+ # due to a null-valued field referencing an undefined schema element.
53
+ variables = ElasticGraph::Support::HashUtil.recursively_prune_nils_from(variables)
54
+
55
+ query_tracker = QueryDetailsTracker.empty
56
+
57
+ query, result = build_and_execute_query(
58
+ query_string: query_string,
59
+ variables: variables,
60
+ operation_name: operation_name,
61
+ client: client,
62
+ context: context.merge({
63
+ monotonic_clock_deadline: timeout_in_ms&.+(start_time_in_ms),
64
+ elastic_graph_schema: @schema,
65
+ schema_element_names: @schema.element_names,
66
+ elastic_graph_query_tracker: query_tracker,
67
+ datastore_search_router: @datastore_search_router
68
+ }.compact)
69
+ )
70
+
71
+ unless result.to_h.fetch("errors", []).empty?
72
+ @logger.error <<~EOS
73
+ Query #{query.operation_name}[1] for client #{client.description} resulted in errors[2].
74
+
75
+ [1] #{full_description_of(query)}
76
+
77
+ [2] #{::JSON.pretty_generate(result.to_h.fetch("errors"))}
78
+ EOS
79
+ end
80
+
81
+ unless query_tracker.hidden_types.empty?
82
+ @logger.warn "#{query_tracker.hidden_types.size} GraphQL types were hidden from the schema due to their backing indices being inaccessible: #{query_tracker.hidden_types.sort.join(", ")}"
83
+ end
84
+
85
+ duration = @monotonic_clock.now_in_ms - start_time_in_ms
86
+
87
+ # Note: I also wanted to log the sanitized query if `result` has `errors`, but `GraphQL::Query#sanitized_query`
88
+ # returns `nil` on an invalid query, and I don't want to risk leaking PII by logging the raw query string, so
89
+ # we don't log any form of the query in that case.
90
+ if duration > @slow_query_threshold_ms
91
+ @logger.warn "Query #{query.operation_name} for client #{client.description} with shard routing values " \
92
+ "#{query_tracker.shard_routing_values.sort.inspect} and search index expressions #{query_tracker.search_index_expressions.sort.inspect} took longer " \
93
+ "(#{duration} ms) than the configured slow query threshold (#{@slow_query_threshold_ms} ms). " \
94
+ "Sanitized query:\n\n#{query.sanitized_query_string}"
95
+ end
96
+
97
+ unless client == Client::ELASTICGRAPH_INTERNAL
98
+ @logger.info({
99
+ "message_type" => "ElasticGraphQueryExecutorQueryDuration",
100
+ "client" => client.name,
101
+ "query_fingerprint" => fingerprint_for(query),
102
+ "query_name" => query.operation_name,
103
+ "duration_ms" => duration,
104
+ # Here we log how long the datastore queries took according to what the datastore itself reported.
105
+ "datastore_server_duration_ms" => query_tracker.datastore_query_server_duration_ms,
106
+ # Here we log an estimate for how much overhead ElasticGraph added on top of how long the datastore took.
107
+ # This is based on the duration, excluding how long the datastore calls took from the client side
108
+ # (e.g. accounting for network latency, serialization time, etc)
109
+ "elasticgraph_overhead_ms" => duration - query_tracker.datastore_query_client_duration_ms,
110
+ # According to https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html#metric-filters-extract-json,
111
+ # > Value nodes can be strings or numbers...If a property selector points to an array or object, the metric filter won't match the log format.
112
+ # So, to allow flexibility to deal with cloud watch metric filters, we coerce these values to a string here.
113
+ "unique_shard_routing_values" => query_tracker.shard_routing_values.sort.join(", "),
114
+ # We also include the count of shard routing values, to make it easier to search logs
115
+ # for the case of no shard routing values.
116
+ "unique_shard_routing_value_count" => query_tracker.shard_routing_values.count,
117
+ "unique_search_index_expressions" => query_tracker.search_index_expressions.sort.join(", "),
118
+ # Indicates how many requests we sent to the datastore to satisfy the GraphQL query.
119
+ "datastore_request_count" => query_tracker.query_counts_per_datastore_request.size,
120
+ # Indicates how many individual datastore queries there were. One datastore request
121
+ # can contain many queries (since we use `msearch`), so these counts can be different.
122
+ "datastore_query_count" => query_tracker.query_counts_per_datastore_request.sum,
123
+ "over_slow_threshold" => (duration > @slow_query_threshold_ms).to_s,
124
+ "slo_result" => slo_result_for(query, duration)
125
+ })
126
+ end
127
+
128
+ result
129
+ end
130
+
131
+ private
132
+
133
+ # Note: this is designed so that `elasticgraph-query_registry` can hook into this method. It needs to be able
134
+ # to override how the query is built and executed.
135
+ def build_and_execute_query(query_string:, variables:, operation_name:, context:, client:)
136
+ query = ::GraphQL::Query.new(
137
+ @schema.graphql_schema,
138
+ query_string,
139
+ variables: variables,
140
+ operation_name: operation_name,
141
+ context: context
142
+ )
143
+
144
+ [query, execute_query(query, client: client)]
145
+ end
146
+
147
+ # Executes the given query, providing some extra logging if an exception occurs.
148
+ def execute_query(query, client:)
149
+ # Log the query before starting to execute it, in case there's a lambda timeout, in which case
150
+ # we won't get any other logged messages for the query.
151
+ @logger.info "Starting to execute query #{fingerprint_for(query)} for client #{client.description}."
152
+
153
+ query.result
154
+ rescue => ex
155
+ @logger.error <<~EOS
156
+ Query #{query.operation_name}[1] for client #{client.description} failed with an exception[2].
157
+
158
+ [1] #{full_description_of(query)}
159
+
160
+ [2] #{ex.class}: #{ex.message}
161
+ EOS
162
+
163
+ raise ex
164
+ end
165
+
166
+ # Returns a string that describes the query as completely as we can.
167
+ # Note that `query.sanitized_query_string` is quite complete, but can be nil in
168
+ # certain situations (such as when the query string itself is invalid!); we include
169
+ # the fingerprint to make sure that we at least have some identification information
170
+ # about the query.
171
+ def full_description_of(query)
172
+ "#{fingerprint_for(query)} #{query.sanitized_query_string}"
173
+ end
174
+
175
+ def fingerprint_for(query)
176
+ query.query_string ? query.fingerprint : "(no query string)"
177
+ end
178
+
179
+ def slo_result_for(query, duration)
180
+ latency_slo = directives_from_query_operation(query)
181
+ .dig(schema.element_names.eg_latency_slo, schema.element_names.ms)
182
+
183
+ if latency_slo.nil?
184
+ nil
185
+ elsif duration <= latency_slo
186
+ "good"
187
+ else
188
+ "bad"
189
+ end
190
+ end
191
+
192
+ def directives_from_query_operation(query)
193
+ query.selected_operation&.directives&.to_h do |dir|
194
+ arguments = dir.arguments.to_h { |arg| [arg.name, arg.value] }
195
+ [dir.name, arguments]
196
+ end || {}
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,49 @@
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/datastore_response/document"
10
+ require "elastic_graph/graphql/resolvers/relay_connection/array_adapter"
11
+ require "elastic_graph/support/hash_util"
12
+
13
+ module ElasticGraph
14
+ class GraphQL
15
+ module Resolvers
16
+ # Responsible for fetching a single field value from a document.
17
+ class GetRecordFieldValue
18
+ def initialize(schema_element_names:)
19
+ @schema_element_names = schema_element_names
20
+ end
21
+
22
+ def can_resolve?(field:, object:)
23
+ object.is_a?(DatastoreResponse::Document) || object.is_a?(::Hash)
24
+ end
25
+
26
+ def resolve(field:, object:, args:, context:, lookahead:)
27
+ field_name = field.name_in_index.to_s
28
+ data =
29
+ case object
30
+ when DatastoreResponse::Document
31
+ object.payload
32
+ else
33
+ object
34
+ end
35
+
36
+ value = Support::HashUtil.fetch_value_at_path(data, field_name) do
37
+ field.type.list? ? [] : nil
38
+ end
39
+
40
+ if field.type.relay_connection?
41
+ RelayConnection::ArrayAdapter.build(value, args, @schema_element_names, context)
42
+ else
43
+ value
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,114 @@
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/resolvers/query_adapter"
10
+
11
+ module ElasticGraph
12
+ class GraphQL
13
+ module Resolvers
14
+ # Adapts the GraphQL gem's resolver interface to the interface implemented by
15
+ # our resolvers. Responsible for routing a resolution request to the appropriate
16
+ # resolver.
17
+ class GraphQLAdapter
18
+ def initialize(schema:, datastore_query_builder:, datastore_query_adapters:, runtime_metadata:, resolvers:)
19
+ @schema = schema
20
+ @query_adapter = QueryAdapter.new(
21
+ datastore_query_builder: datastore_query_builder,
22
+ datastore_query_adapters: datastore_query_adapters
23
+ )
24
+
25
+ @resolvers = resolvers
26
+
27
+ scalar_types_by_name = runtime_metadata.scalar_types_by_name
28
+ @coercion_adapters_by_scalar_type_name = ::Hash.new do |hash, name|
29
+ scalar_types_by_name.fetch(name).load_coercion_adapter.extension_class
30
+ end
31
+ end
32
+
33
+ # To be a valid resolver, we must implement `call`, accepting the 5 arguments listed here.
34
+ #
35
+ # See https://graphql-ruby.org/api-doc/1.9.6/GraphQL/Schema.html#from_definition-class_method
36
+ # (specifically, the `default_resolve` argument) for the API documentation.
37
+ def call(parent_type, field, object, args, context)
38
+ schema_field = @schema.field_named(parent_type.graphql_name, field.name)
39
+
40
+ # Extract the `:lookahead` extra that we have configured all fields to provide.
41
+ # See https://graphql-ruby.org/api-doc/1.10.8/GraphQL/Execution/Lookahead.html for more info.
42
+ # It is not a "real" arg in the schema and breaks `args_to_schema_form` when we call that
43
+ # so we need to peel it off here.
44
+ lookahead = args[:lookahead]
45
+ # Convert args to the form they were defined in the schema, undoing the normalization
46
+ # the GraphQL gem does to convert them to Ruby keyword args form.
47
+ args = schema_field.args_to_schema_form(args.except(:lookahead))
48
+
49
+ resolver = resolver_for(schema_field, object) do
50
+ raise <<~ERROR
51
+ No resolver yet implemented for this case.
52
+
53
+ parent_type: #{schema_field.parent_type}
54
+
55
+ field: #{schema_field}
56
+
57
+ obj: #{object.inspect}
58
+
59
+ args: #{args.inspect}
60
+
61
+ ctx: #{context.inspect}
62
+ ERROR
63
+ end
64
+
65
+ result = resolver.resolve(field: schema_field, object: object, args: args, context: context, lookahead: lookahead) do
66
+ @query_adapter.build_query_from(field: schema_field, args: args, lookahead: lookahead, context: context)
67
+ end
68
+
69
+ # Give the field a chance to coerce the result before returning it. Initially, this is only used to deal with
70
+ # enum value overrides (e.g. so that if `DayOfWeek.MONDAY` has been overridden to `DayOfWeek.MON`, we can coerce
71
+ # a `MONDAY` value being returned by a painless script to `MON`), but this is designed to be general purpose
72
+ # and we may use it for other coercions in the future.
73
+ #
74
+ # Note that coercion of scalar values is handled by the `coerce_result` callback below.
75
+ schema_field.coerce_result(result)
76
+ end
77
+
78
+ # In order to support unions and interfaces, we must implement `resolve_type`.
79
+ def resolve_type(supertype, object, context)
80
+ # If `__typename` is available, use that to resolve. It should be available on any embedded abstract types...
81
+ # (See `Inventor` in `config/schema.graphql` for an example of this kind of type union.)
82
+ if (typename = object["__typename"])
83
+ @schema.graphql_schema.possible_types(supertype).find { |t| t.graphql_name == typename }
84
+ else
85
+ # ...otherwise infer the type based on what index the object came from. This is the case
86
+ # with unions/interfaces of individually indexed types.
87
+ # (See `Part` in `config/schema/widgets.rb` for an example of this kind of type union.)
88
+ @schema.document_type_stored_in(object.index_definition_name).graphql_type
89
+ end
90
+ end
91
+
92
+ def coerce_input(type, value, ctx)
93
+ scalar_coercion_adapter_for(type).coerce_input(value, ctx)
94
+ end
95
+
96
+ def coerce_result(type, value, ctx)
97
+ scalar_coercion_adapter_for(type).coerce_result(value, ctx)
98
+ end
99
+
100
+ private
101
+
102
+ def scalar_coercion_adapter_for(type)
103
+ @coercion_adapters_by_scalar_type_name[type.graphql_name]
104
+ end
105
+
106
+ def resolver_for(field, object)
107
+ return object if object.respond_to?(:can_resolve?) && object.can_resolve?(field: field, object: object)
108
+ resolver = @resolvers.find { |r| r.can_resolve?(field: field, object: object) }
109
+ resolver || yield
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,29 @@
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/resolvers/query_source"
10
+ require "elastic_graph/graphql/resolvers/relay_connection"
11
+
12
+ module ElasticGraph
13
+ class GraphQL
14
+ module Resolvers
15
+ # Responsible for fetching a a list of records of a particular type
16
+ class ListRecords
17
+ def can_resolve?(field:, object:)
18
+ field.parent_type.name == :Query && field.type.collection?
19
+ end
20
+
21
+ def resolve(field:, context:, lookahead:, **)
22
+ query = yield
23
+ response = QuerySource.execute_one(query, for_context: context)
24
+ RelayConnection.maybe_wrap(response, field: field, context: context, lookahead: lookahead, query: query)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,74 @@
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/resolvers/relay_connection"
10
+
11
+ module ElasticGraph
12
+ class GraphQL
13
+ module Resolvers
14
+ # Responsible for loading nested relationships that are stored as separate documents
15
+ # in the datastore. We use `QuerySource` for the datastore queries to avoid
16
+ # the N+1 query problem (giving us one datastore query per layer of our graph).
17
+ #
18
+ # Most of the logic for this lives in ElasticGraph::Schema::RelationJoin.
19
+ class NestedRelationships
20
+ def initialize(schema_element_names:, logger:)
21
+ @schema_element_names = schema_element_names
22
+ @logger = logger
23
+ end
24
+
25
+ def can_resolve?(field:, object:)
26
+ !!field.relation_join
27
+ end
28
+
29
+ def resolve(object:, field:, context:, lookahead:, **)
30
+ log_warning = ->(**options) { log_field_problem_warning(field: field, **options) }
31
+ join = field.relation_join
32
+ id_or_ids = join.extract_id_or_ids_from(object, log_warning)
33
+ filters = [
34
+ build_filter(join.filter_id_field_name, nil, join.foreign_key_nested_paths, Array(id_or_ids)),
35
+ join.additional_filter
36
+ ].reject(&:empty?)
37
+ query = yield.merge_with(filters: filters)
38
+
39
+ response =
40
+ case id_or_ids
41
+ when nil, []
42
+ join.blank_value
43
+ else
44
+ initial_response = QuerySource.execute_one(query, for_context: context)
45
+ join.normalize_documents(initial_response) do |problem|
46
+ log_warning.call(document: {"id" => id_or_ids}, problem: "got #{problem} from the datastore search query")
47
+ end
48
+ end
49
+
50
+ RelayConnection.maybe_wrap(response, field: field, context: context, lookahead: lookahead, query: query)
51
+ end
52
+
53
+ private
54
+
55
+ def log_field_problem_warning(field:, document:, problem:)
56
+ id = document.fetch("id", "<no id>")
57
+ @logger.warn "#{field.parent_type.name}(id: #{id}).#{field.name} had a problem: #{problem}"
58
+ end
59
+
60
+ def build_filter(path, previous_nested_path, nested_paths, ids)
61
+ if nested_paths.empty?
62
+ path = path.delete_prefix("#{previous_nested_path}.") if previous_nested_path
63
+ {path => {@schema_element_names.equal_to_any_of => ids}}
64
+ else
65
+ next_nested_path, *rest_nested_paths = nested_paths
66
+ sub_filter = build_filter(path, next_nested_path, rest_nested_paths, ids)
67
+ next_nested_path = next_nested_path.delete_prefix("#{previous_nested_path}.") if previous_nested_path
68
+ {next_nested_path => {@schema_element_names.any_satisfy => sub_filter}}
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end