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