elasticgraph-graphql 0.18.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +3 -0
- data/elasticgraph-graphql.gemspec +23 -0
- data/lib/elastic_graph/graphql/aggregation/composite_grouping_adapter.rb +79 -0
- data/lib/elastic_graph/graphql/aggregation/computation.rb +39 -0
- data/lib/elastic_graph/graphql/aggregation/date_histogram_grouping.rb +83 -0
- data/lib/elastic_graph/graphql/aggregation/field_path_encoder.rb +47 -0
- data/lib/elastic_graph/graphql/aggregation/field_term_grouping.rb +26 -0
- data/lib/elastic_graph/graphql/aggregation/key.rb +87 -0
- data/lib/elastic_graph/graphql/aggregation/nested_sub_aggregation.rb +37 -0
- data/lib/elastic_graph/graphql/aggregation/non_composite_grouping_adapter.rb +129 -0
- data/lib/elastic_graph/graphql/aggregation/path_segment.rb +31 -0
- data/lib/elastic_graph/graphql/aggregation/query.rb +172 -0
- data/lib/elastic_graph/graphql/aggregation/query_adapter.rb +345 -0
- data/lib/elastic_graph/graphql/aggregation/query_optimizer.rb +187 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/aggregated_values.rb +41 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/count_detail.rb +44 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/grouped_by.rb +30 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/node.rb +64 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/relay_connection_builder.rb +83 -0
- data/lib/elastic_graph/graphql/aggregation/resolvers/sub_aggregations.rb +82 -0
- data/lib/elastic_graph/graphql/aggregation/script_term_grouping.rb +32 -0
- data/lib/elastic_graph/graphql/aggregation/term_grouping.rb +118 -0
- data/lib/elastic_graph/graphql/client.rb +43 -0
- data/lib/elastic_graph/graphql/config.rb +81 -0
- data/lib/elastic_graph/graphql/datastore_query/document_paginator.rb +100 -0
- data/lib/elastic_graph/graphql/datastore_query/index_expression_builder.rb +142 -0
- data/lib/elastic_graph/graphql/datastore_query/paginator.rb +199 -0
- data/lib/elastic_graph/graphql/datastore_query/routing_picker.rb +239 -0
- data/lib/elastic_graph/graphql/datastore_query.rb +372 -0
- data/lib/elastic_graph/graphql/datastore_response/document.rb +78 -0
- data/lib/elastic_graph/graphql/datastore_response/search_response.rb +79 -0
- data/lib/elastic_graph/graphql/datastore_search_router.rb +151 -0
- data/lib/elastic_graph/graphql/decoded_cursor.rb +120 -0
- data/lib/elastic_graph/graphql/filtering/boolean_query.rb +45 -0
- data/lib/elastic_graph/graphql/filtering/field_path.rb +81 -0
- data/lib/elastic_graph/graphql/filtering/filter_args_translator.rb +58 -0
- data/lib/elastic_graph/graphql/filtering/filter_interpreter.rb +526 -0
- data/lib/elastic_graph/graphql/filtering/filter_value_set_extractor.rb +148 -0
- data/lib/elastic_graph/graphql/filtering/range_query.rb +56 -0
- data/lib/elastic_graph/graphql/http_endpoint.rb +229 -0
- data/lib/elastic_graph/graphql/monkey_patches/schema_field.rb +56 -0
- data/lib/elastic_graph/graphql/monkey_patches/schema_object.rb +48 -0
- data/lib/elastic_graph/graphql/query_adapter/filters.rb +161 -0
- data/lib/elastic_graph/graphql/query_adapter/pagination.rb +27 -0
- data/lib/elastic_graph/graphql/query_adapter/requested_fields.rb +124 -0
- data/lib/elastic_graph/graphql/query_adapter/sort.rb +32 -0
- data/lib/elastic_graph/graphql/query_details_tracker.rb +60 -0
- data/lib/elastic_graph/graphql/query_executor.rb +200 -0
- data/lib/elastic_graph/graphql/resolvers/get_record_field_value.rb +49 -0
- data/lib/elastic_graph/graphql/resolvers/graphql_adapter.rb +114 -0
- data/lib/elastic_graph/graphql/resolvers/list_records.rb +29 -0
- data/lib/elastic_graph/graphql/resolvers/nested_relationships.rb +74 -0
- data/lib/elastic_graph/graphql/resolvers/query_adapter.rb +85 -0
- data/lib/elastic_graph/graphql/resolvers/query_source.rb +46 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/array_adapter.rb +71 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/generic_adapter.rb +65 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/page_info.rb +82 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection/search_response_adapter_builder.rb +40 -0
- data/lib/elastic_graph/graphql/resolvers/relay_connection.rb +42 -0
- data/lib/elastic_graph/graphql/resolvers/resolvable_value.rb +56 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb +35 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/date.rb +64 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/date_time.rb +60 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/local_time.rb +30 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/longs.rb +47 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/no_op.rb +24 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/time_zone.rb +44 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/untyped.rb +32 -0
- data/lib/elastic_graph/graphql/scalar_coercion_adapters/valid_time_zones.rb +634 -0
- data/lib/elastic_graph/graphql/schema/arguments.rb +78 -0
- data/lib/elastic_graph/graphql/schema/enum_value.rb +30 -0
- data/lib/elastic_graph/graphql/schema/field.rb +147 -0
- data/lib/elastic_graph/graphql/schema/relation_join.rb +103 -0
- data/lib/elastic_graph/graphql/schema/type.rb +263 -0
- data/lib/elastic_graph/graphql/schema.rb +164 -0
- data/lib/elastic_graph/graphql.rb +253 -0
- data/script/dump_time_zones +81 -0
- data/script/dump_time_zones.java +17 -0
- metadata +503 -0
@@ -0,0 +1,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
|