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