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,85 @@
|
|
|
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
|
+
module Resolvers
|
|
12
|
+
# Responsible for taking raw GraphQL query arguments and transforming
|
|
13
|
+
# them into a DatastoreQuery object.
|
|
14
|
+
class QueryAdapter
|
|
15
|
+
def initialize(datastore_query_builder:, datastore_query_adapters:)
|
|
16
|
+
@datastore_query_builder = datastore_query_builder
|
|
17
|
+
@datastore_query_adapters = datastore_query_adapters
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build_query_from(field:, args:, lookahead:, context: {})
|
|
21
|
+
monotonic_clock_deadline = context[:monotonic_clock_deadline]
|
|
22
|
+
|
|
23
|
+
# Building an `DatastoreQuery` is not cheap; we do a lot of work to:
|
|
24
|
+
#
|
|
25
|
+
# 1) Convert the `args` to their schema form.
|
|
26
|
+
# 2) Reduce over our different query builders into a final `Query` object
|
|
27
|
+
# 3) ...and those individual query builders often do a lot of work (traversing lookaheads, etc).
|
|
28
|
+
#
|
|
29
|
+
# So it is beneficial to avoid re-creating the exact same `DatastoreQuery` object when
|
|
30
|
+
# we are resolving the same field in the context of a different object. For example,
|
|
31
|
+
# consider a query like:
|
|
32
|
+
#
|
|
33
|
+
# query {
|
|
34
|
+
# widgets {
|
|
35
|
+
# components {
|
|
36
|
+
# id
|
|
37
|
+
# parts {
|
|
38
|
+
# id
|
|
39
|
+
# }
|
|
40
|
+
# }
|
|
41
|
+
# }
|
|
42
|
+
# }
|
|
43
|
+
#
|
|
44
|
+
# Here `components` and `parts` are nested relation fields. If we load 50 of each collection,
|
|
45
|
+
# this `build_query_from` method will be called 50 times for the `Widget.components` field,
|
|
46
|
+
# and 2500 times (50 * 50) for the `Component.parts` field...but for a given field, the
|
|
47
|
+
# built `DatastoreQuery` will be exactly the same.
|
|
48
|
+
#
|
|
49
|
+
# Therefore, it is beneficial to memoize the `DatastoreQuery` to avoid re-doing the same work
|
|
50
|
+
# over and over again, provided we can do so safely.
|
|
51
|
+
#
|
|
52
|
+
# `context` is a hash-like `GraphQL::Query::Context` object. Each executed query gets its own
|
|
53
|
+
# instance, so we can safely cache things in it and trust that it will not "leak" to another
|
|
54
|
+
# query execution. We carefully build a cache key below to ensure that we only ever reuse
|
|
55
|
+
# the same `DatastoreQuery` in a situation that would produce the exact same `DatastoreQuery`.
|
|
56
|
+
context[:datastore_query_cache] ||= {}
|
|
57
|
+
context[:datastore_query_cache][cache_key_for(field, args, lookahead)] ||=
|
|
58
|
+
build_new_query_from(field, args, lookahead, context, monotonic_clock_deadline)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def build_new_query_from(field, args, lookahead, context, monotonic_clock_deadline)
|
|
64
|
+
unwrapped_type = field.type.unwrap_fully
|
|
65
|
+
|
|
66
|
+
initial_query = @datastore_query_builder.new_query(
|
|
67
|
+
search_index_definitions: unwrapped_type.search_index_definitions,
|
|
68
|
+
monotonic_clock_deadline: monotonic_clock_deadline
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@datastore_query_adapters.reduce(initial_query) do |query, adapter|
|
|
72
|
+
adapter.call(query: query, field: field, args: args, lookahead: lookahead, context: context)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def cache_key_for(field, args, lookahead)
|
|
77
|
+
# Unfortunately, `Lookahead` does not define `==` according to its internal state,
|
|
78
|
+
# so `l1 == l2` with the same internal state returns false. So we have to pull
|
|
79
|
+
# out its individual state fields in the cache key for our caching to work here.
|
|
80
|
+
[field, args, lookahead.ast_nodes, lookahead.field, lookahead.owner_type]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Use of this source code is governed by an MIT-style
|
|
4
|
+
# license that can be found in the LICENSE file or at
|
|
5
|
+
# https://opensource.org/licenses/MIT.
|
|
6
|
+
#
|
|
7
|
+
# frozen_string_literal: true
|
|
8
|
+
|
|
9
|
+
require "graphql/dataloader/source"
|
|
10
|
+
|
|
11
|
+
module ElasticGraph
|
|
12
|
+
class GraphQL
|
|
13
|
+
module Resolvers
|
|
14
|
+
# Provides a way to avoid N+1 query problems by batching up multiple
|
|
15
|
+
# datastore queries into one `msearch` call. In general, it is recommended
|
|
16
|
+
# that you use this from any resolver that needs to query the datastore, to
|
|
17
|
+
# maximize our ability to combine multiple datastore requests. Importantly,
|
|
18
|
+
# this should never be instantiated directly; instead use the `execute` method from below.
|
|
19
|
+
class QuerySource < ::GraphQL::Dataloader::Source
|
|
20
|
+
def initialize(datastore_router, query_tracker)
|
|
21
|
+
@datastore_router = datastore_router
|
|
22
|
+
@query_tracker = query_tracker
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fetch(queries)
|
|
26
|
+
responses_by_query = @datastore_router.msearch(queries, query_tracker: @query_tracker)
|
|
27
|
+
@query_tracker.record_datastore_queries_for_single_request(queries)
|
|
28
|
+
queries.map { |q| responses_by_query[q] }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.execute_many(queries, for_context:)
|
|
32
|
+
datastore_router = for_context.fetch(:datastore_search_router)
|
|
33
|
+
query_tracker = for_context.fetch(:elastic_graph_query_tracker)
|
|
34
|
+
dataloader = for_context.dataloader
|
|
35
|
+
|
|
36
|
+
responses = dataloader.with(self, datastore_router, query_tracker).load_all(queries)
|
|
37
|
+
queries.zip(responses).to_h
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.execute_one(query, for_context:)
|
|
41
|
+
execute_many([query], for_context: for_context).fetch(query)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
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/resolvable_value"
|
|
10
|
+
require "forwardable"
|
|
11
|
+
|
|
12
|
+
module ElasticGraph
|
|
13
|
+
class GraphQL
|
|
14
|
+
module Resolvers
|
|
15
|
+
module RelayConnection
|
|
16
|
+
# Relay connection adapter for an array. Implemented primarily by `GraphQL::Relay::ArrayConnection`;
|
|
17
|
+
# here we just adapt it to the ElasticGraph internal resolver interface.
|
|
18
|
+
class ArrayAdapter < ResolvableValue.new(:graphql_impl)
|
|
19
|
+
# `ResolvableValue.new` provides the following methods:
|
|
20
|
+
# @dynamic initialize, graphql_impl, schema_element_names
|
|
21
|
+
|
|
22
|
+
# `def_delegators` provides the following methods:
|
|
23
|
+
# @dynamic start_cursor, end_cursor, has_next_page, has_previous_page
|
|
24
|
+
extend Forwardable
|
|
25
|
+
def_delegators :graphql_impl, :start_cursor, :end_cursor, :has_next_page, :has_previous_page
|
|
26
|
+
|
|
27
|
+
def self.build(nodes, args, schema_element_names, context)
|
|
28
|
+
# ElasticGraph supports any schema elements (like a `first` argument) being renamed,
|
|
29
|
+
# but `GraphQL::Relay::ArrayConnection` would not understand a renamed argument.
|
|
30
|
+
# Here we map the args back to the canonical relay args so `ArrayConnection` can
|
|
31
|
+
# understand them.
|
|
32
|
+
relay_args = [:first, :after, :last, :before].to_h do |arg_name|
|
|
33
|
+
[arg_name, args[schema_element_names.public_send(arg_name)]]
|
|
34
|
+
end.compact
|
|
35
|
+
|
|
36
|
+
graphql_impl = ::GraphQL::Pagination::ArrayConnection.new(nodes, context: context, **relay_args)
|
|
37
|
+
new(schema_element_names, graphql_impl)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def total_edge_count
|
|
41
|
+
graphql_impl.nodes.size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def page_info
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def edges
|
|
49
|
+
@edges ||= graphql_impl.nodes.map do |node|
|
|
50
|
+
Edge.new(schema_element_names, graphql_impl, node)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def nodes
|
|
55
|
+
@nodes ||= graphql_impl.nodes
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Simple edge implementation for a node object.
|
|
59
|
+
class Edge < ResolvableValue.new(:graphql_impl, :node)
|
|
60
|
+
# `ResolvableValue.new` provides the following methods:
|
|
61
|
+
# @dynamic initialize, graphql_impl, schema_element_names, node
|
|
62
|
+
|
|
63
|
+
def cursor
|
|
64
|
+
graphql_impl.cursor_for(node)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
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/page_info"
|
|
10
|
+
require "elastic_graph/graphql/resolvers/resolvable_value"
|
|
11
|
+
|
|
12
|
+
module ElasticGraph
|
|
13
|
+
class GraphQL
|
|
14
|
+
module Resolvers
|
|
15
|
+
module RelayConnection
|
|
16
|
+
class GenericAdapter < ResolvableValue.new(
|
|
17
|
+
# Array of nodes for this page of data, before paginator truncation has been added.
|
|
18
|
+
:raw_nodes,
|
|
19
|
+
# The paginator that's being used.
|
|
20
|
+
:paginator,
|
|
21
|
+
# Lambda that is used to convert a node to a sort value during truncation.
|
|
22
|
+
:to_sort_value,
|
|
23
|
+
# Gets an optional count of total edges.
|
|
24
|
+
:get_total_edge_count
|
|
25
|
+
)
|
|
26
|
+
# @dynamic initialize, with, schema_element_names, raw_nodes, paginator, to_sort_value, get_total_edge_count
|
|
27
|
+
|
|
28
|
+
def page_info
|
|
29
|
+
@page_info ||= PageInfo.new(
|
|
30
|
+
schema_element_names: schema_element_names,
|
|
31
|
+
before_truncation_nodes: before_truncation_nodes,
|
|
32
|
+
edges: edges,
|
|
33
|
+
paginator: paginator
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def total_edge_count
|
|
38
|
+
get_total_edge_count.call
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def edges
|
|
42
|
+
@edges ||= nodes.map { |node| Edge.new(schema_element_names, node) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def nodes
|
|
46
|
+
@nodes ||= paginator.truncate_items(before_truncation_nodes, &to_sort_value)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def before_truncation_nodes
|
|
52
|
+
@before_truncation_nodes ||= paginator.restore_intended_item_order(raw_nodes)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Implements an `Edge` as per the relay spec:
|
|
56
|
+
# https://facebook.github.io/relay/graphql/connections.htm#sec-Edge-Types
|
|
57
|
+
class Edge < ResolvableValue.new(:node)
|
|
58
|
+
# @dynamic initialize, node
|
|
59
|
+
def cursor = node.cursor.encode
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
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/resolvable_value"
|
|
10
|
+
|
|
11
|
+
module ElasticGraph
|
|
12
|
+
class GraphQL
|
|
13
|
+
module Resolvers
|
|
14
|
+
module RelayConnection
|
|
15
|
+
# Provides the `PageInfo` field values required by the relay spec.
|
|
16
|
+
#
|
|
17
|
+
# The relay connections spec defines an algorithm behind `hasPreviousPage` and `hasNextPage`:
|
|
18
|
+
# https://facebook.github.io/relay/graphql/connections.htm#sec-undefined.PageInfo.Fields
|
|
19
|
+
#
|
|
20
|
+
# However, it has a couple bugs as currently written (https://github.com/facebook/relay/issues/2787),
|
|
21
|
+
# so we have implemented our own algorithm instead. It would be nice to calculate `hasPreviousPage`
|
|
22
|
+
# and `hasNextPage` on-demand in a resolver, so we do not spend any effort on it if the client has
|
|
23
|
+
# not requested those fields, but it is quite hard to calculate them after the fact: we need to know
|
|
24
|
+
# whether we removed any leading or trailing items while processing the list to accurately answer
|
|
25
|
+
# the question, "do we have a page before or after the one we are returning?".
|
|
26
|
+
#
|
|
27
|
+
# Note: it's not clear what values `hasPreviousPage` and `hasNextPage` should have when we are returning
|
|
28
|
+
# a blank page (the client isn't being returned any cursors to continue paginating from!). This logic,
|
|
29
|
+
# as written, will normally cause both fields to be `true` (our request of `size: size + 1` will get us
|
|
30
|
+
# a list of 1 document, which will then be removed, causing `items.first` and `items.last` to
|
|
31
|
+
# both change to `nil`). However, if the datastore returns an empty list to us than `false` will be returned
|
|
32
|
+
# for one or both fields, based on the presence or absence of the `before`/`after` cursors in the pagination
|
|
33
|
+
# arguments. Regardless, given that it's not clear what the correct value is, we are just doing the
|
|
34
|
+
# least-effort thing and not putting any special handling for this case in place.
|
|
35
|
+
class PageInfo < ResolvableValue.new(
|
|
36
|
+
# The array of nodes for this page before we applied necessary truncation.
|
|
37
|
+
:before_truncation_nodes,
|
|
38
|
+
# The array of edges for this page.
|
|
39
|
+
:edges,
|
|
40
|
+
# The paginator built from the field arguments.
|
|
41
|
+
:paginator
|
|
42
|
+
)
|
|
43
|
+
# @dynamic initialize, with, before_truncation_nodes, edges, paginator
|
|
44
|
+
|
|
45
|
+
def start_cursor
|
|
46
|
+
edges.first&.cursor
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def end_cursor
|
|
50
|
+
edges.last&.cursor
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def has_previous_page
|
|
54
|
+
# If we dropped the first node during truncation then it means we removed some leading docs, indicating a previous page.
|
|
55
|
+
return true if edges.first&.node != before_truncation_nodes.first
|
|
56
|
+
|
|
57
|
+
# Nothing exists both before and after the same cursor, and there is therefore no page before that set of results.
|
|
58
|
+
return false if paginator.before == paginator.after
|
|
59
|
+
|
|
60
|
+
# If an `after` cursor was passed then there is definitely at least one doc before the page we are
|
|
61
|
+
# returning (the one matching the cursor), assuming the client did not construct a cursor by hand
|
|
62
|
+
# (which we do not support).
|
|
63
|
+
!!paginator.after
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def has_next_page
|
|
67
|
+
# If we dropped the last node during truncation then it means we removed some trailing docs, indicating a next page.
|
|
68
|
+
return true if edges.last&.node != before_truncation_nodes.last
|
|
69
|
+
|
|
70
|
+
# Nothing exists both before and after the same cursor, and there is therefore no page after that set of results.
|
|
71
|
+
return false if paginator.before == paginator.after
|
|
72
|
+
|
|
73
|
+
# If a `before` cursor was passed then there is definitely at least one doc after the page we are
|
|
74
|
+
# returning (the one matching the cursor), assuming the client did not construct a cursor by hand
|
|
75
|
+
# (which we do not support).
|
|
76
|
+
!!paginator.before
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
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/generic_adapter"
|
|
10
|
+
|
|
11
|
+
module ElasticGraph
|
|
12
|
+
class GraphQL
|
|
13
|
+
module Resolvers
|
|
14
|
+
module RelayConnection
|
|
15
|
+
# Adapts an `DatastoreResponse::SearchResponse` to what the graphql gem expects for a relay connection.
|
|
16
|
+
class SearchResponseAdapterBuilder
|
|
17
|
+
def self.build_from(schema_element_names:, search_response:, query:)
|
|
18
|
+
document_paginator = query.document_paginator
|
|
19
|
+
|
|
20
|
+
GenericAdapter.new(
|
|
21
|
+
schema_element_names: schema_element_names,
|
|
22
|
+
raw_nodes: search_response.to_a,
|
|
23
|
+
paginator: document_paginator.paginator,
|
|
24
|
+
get_total_edge_count: -> { search_response.total_document_count },
|
|
25
|
+
to_sort_value: ->(document, decoded_cursor) do
|
|
26
|
+
(_ = document).sort.zip(decoded_cursor.sort_values.values, document_paginator.sort).map do |from_document, from_cursor, sort_clause|
|
|
27
|
+
DatastoreQuery::Paginator::SortValue.new(
|
|
28
|
+
from_item: from_document,
|
|
29
|
+
from_cursor: from_cursor,
|
|
30
|
+
sort_direction: sort_clause.values.first.fetch("order").to_sym
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
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/aggregation/resolvers/relay_connection_builder"
|
|
10
|
+
require "elastic_graph/graphql/resolvers/relay_connection/search_response_adapter_builder"
|
|
11
|
+
|
|
12
|
+
module ElasticGraph
|
|
13
|
+
class GraphQL
|
|
14
|
+
module Resolvers
|
|
15
|
+
# Defines resolver logic related to relay connections. The relay connections spec is here:
|
|
16
|
+
# https://facebook.github.io/relay/graphql/connections.htm
|
|
17
|
+
module RelayConnection
|
|
18
|
+
# Conditionally wraps the given search response in the appropriate relay connection adapter, if needed.
|
|
19
|
+
def self.maybe_wrap(search_response, field:, context:, lookahead:, query:)
|
|
20
|
+
return search_response unless field.type.relay_connection?
|
|
21
|
+
|
|
22
|
+
schema_element_names = context.fetch(:schema_element_names)
|
|
23
|
+
|
|
24
|
+
unless field.type.unwrap_fully.indexed_aggregation?
|
|
25
|
+
return SearchResponseAdapterBuilder.build_from(
|
|
26
|
+
schema_element_names: schema_element_names,
|
|
27
|
+
search_response: search_response,
|
|
28
|
+
query: query
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
agg_name = lookahead.ast_nodes.first&.alias || lookahead.name
|
|
33
|
+
Aggregation::Resolvers::RelayConnectionBuilder.build_from_search_response(
|
|
34
|
+
schema_element_names: schema_element_names,
|
|
35
|
+
search_response: search_response,
|
|
36
|
+
query: query.aggregations.fetch(agg_name)
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Use of this source code is governed by an MIT-style
|
|
4
|
+
# license that can be found in the LICENSE file or at
|
|
5
|
+
# https://opensource.org/licenses/MIT.
|
|
6
|
+
#
|
|
7
|
+
# frozen_string_literal: true
|
|
8
|
+
|
|
9
|
+
require "elastic_graph/error"
|
|
10
|
+
require "elastic_graph/support/memoizable_data"
|
|
11
|
+
|
|
12
|
+
module ElasticGraph
|
|
13
|
+
class GraphQL
|
|
14
|
+
module Resolvers
|
|
15
|
+
# A class builder that is just like `Data` and also adapts itself to our
|
|
16
|
+
# resolver interface. Can resolve any field that is defined in `schema_element_names`
|
|
17
|
+
# and also has a corresponding method definition.
|
|
18
|
+
module ResolvableValue
|
|
19
|
+
# `MemoizableData.define` provides the following methods:
|
|
20
|
+
# @dynamic schema_element_names
|
|
21
|
+
|
|
22
|
+
def self.new(*fields, &block)
|
|
23
|
+
Support::MemoizableData.define(:schema_element_names, *fields) do
|
|
24
|
+
# @implements ResolvableValueClass
|
|
25
|
+
include ResolvableValue
|
|
26
|
+
# @type var block: (^() -> void)?
|
|
27
|
+
class_exec(&block) if block
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def resolve(field:, object:, context:, args:, lookahead:)
|
|
32
|
+
method_name = canonical_name_for(field.name, "Field")
|
|
33
|
+
public_send(method_name, **args_to_canonical_form(args))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def can_resolve?(field:, object:)
|
|
37
|
+
method_name = schema_element_names.canonical_name_for(field.name)
|
|
38
|
+
!!method_name && respond_to?(method_name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def args_to_canonical_form(args)
|
|
44
|
+
args.to_h do |key, value|
|
|
45
|
+
[canonical_name_for(key, "Argument"), value]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def canonical_name_for(name, element_type)
|
|
50
|
+
schema_element_names.canonical_name_for(name) ||
|
|
51
|
+
raise(SchemaError, "#{element_type} `#{name}` is not a defined schema element")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
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/decoded_cursor"
|
|
10
|
+
|
|
11
|
+
module ElasticGraph
|
|
12
|
+
class GraphQL
|
|
13
|
+
module ScalarCoercionAdapters
|
|
14
|
+
class Cursor
|
|
15
|
+
def self.coerce_input(value, ctx)
|
|
16
|
+
case value
|
|
17
|
+
when DecodedCursor
|
|
18
|
+
value
|
|
19
|
+
when ::String
|
|
20
|
+
DecodedCursor.try_decode(value)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.coerce_result(value, ctx)
|
|
25
|
+
case value
|
|
26
|
+
when DecodedCursor
|
|
27
|
+
value.encode
|
|
28
|
+
when ::String
|
|
29
|
+
value if DecodedCursor.try_decode(value)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
module ScalarCoercionAdapters
|
|
12
|
+
class Date
|
|
13
|
+
def self.coerce_input(value, ctx)
|
|
14
|
+
return value if value.nil?
|
|
15
|
+
|
|
16
|
+
# `::Date.iso8601` will happily parse a time ISO8601 string like `2021-11-10T12:30:00Z`
|
|
17
|
+
# but for simplicity we only want to support a Date string (like `2021-11-10`),
|
|
18
|
+
# so we detect that case here.
|
|
19
|
+
raise ::ArgumentError if value.is_a?(::String) && value.include?(":")
|
|
20
|
+
|
|
21
|
+
date = ::Date.iso8601(value)
|
|
22
|
+
|
|
23
|
+
# Verify we have a 4 digit year. The datastore `strict_date_time` format se use only supports 4 digit years:
|
|
24
|
+
#
|
|
25
|
+
# > Most of the below formats have a `strict` companion format, which means that year, month and day parts of the
|
|
26
|
+
# > week must use respectively 4, 2 and 2 digits exactly, potentially prepending zeros.
|
|
27
|
+
#
|
|
28
|
+
# https://www.elastic.co/guide/en/elasticsearch/reference/7.10/mapping-date-format.html#built-in-date-formats
|
|
29
|
+
raise_coercion_error(value) if date.year < 1000 || date.year > 9999
|
|
30
|
+
|
|
31
|
+
# We ultimately wind up passing input args to the datastore as our GraphQL engine receives
|
|
32
|
+
# them (it doesn't do any formatting of Date args to what the datastore needs) so we do
|
|
33
|
+
# that here instead. We have configured the datastore to expect Dates in `strict_date`
|
|
34
|
+
# format, so here we convert it to that format (which is just ISO8601 format). Ultimately,
|
|
35
|
+
# that means that this method just "roundtrips" the input string back to a string, but it
|
|
36
|
+
# validates the string is formatted correctly and returns a string in the exact format we
|
|
37
|
+
# need for the datastore. Also, we technically don't have to do this; ISO8601 format is
|
|
38
|
+
# the format that `Date` objects are serialized as in JSON, anyway. But we _have_ to do this
|
|
39
|
+
# for `DateTime` objects so we also do it here for parity/consistency.
|
|
40
|
+
date.iso8601
|
|
41
|
+
rescue ArgumentError, ::TypeError
|
|
42
|
+
raise_coercion_error(value)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.coerce_result(value, ctx)
|
|
46
|
+
case value
|
|
47
|
+
when ::Date
|
|
48
|
+
value.iso8601
|
|
49
|
+
when ::String
|
|
50
|
+
::Date.iso8601(value).iso8601
|
|
51
|
+
end
|
|
52
|
+
rescue ::ArgumentError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private_class_method def self.raise_coercion_error(value)
|
|
57
|
+
raise ::GraphQL::CoercionError,
|
|
58
|
+
"Could not coerce value #{value.inspect} to Date: must be formatted " \
|
|
59
|
+
"as an ISO8601 Date string (example: #{::Date.today.iso8601.inspect})."
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
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 "time"
|
|
10
|
+
|
|
11
|
+
module ElasticGraph
|
|
12
|
+
class GraphQL
|
|
13
|
+
module ScalarCoercionAdapters
|
|
14
|
+
class DateTime
|
|
15
|
+
PRECISION = 3 # millisecond precision
|
|
16
|
+
|
|
17
|
+
def self.coerce_input(value, ctx)
|
|
18
|
+
return value if value.nil?
|
|
19
|
+
|
|
20
|
+
time = ::Time.iso8601(value)
|
|
21
|
+
|
|
22
|
+
# Verify we do not have more than 4 digits for the year. The datastore `strict_date_time` format we use only supports 4 digit years:
|
|
23
|
+
#
|
|
24
|
+
# > Most of the below formats have a `strict` companion format, which means that year, month and day parts of the
|
|
25
|
+
# > week must use respectively 4, 2 and 2 digits exactly, potentially prepending zeros.
|
|
26
|
+
#
|
|
27
|
+
# https://www.elastic.co/guide/en/elasticsearch/reference/8.12/mapping-date-format.html#built-in-date-formats
|
|
28
|
+
raise_coercion_error(value) if time.year > 9999
|
|
29
|
+
|
|
30
|
+
# We ultimately wind up passing input args to the datastore as our GraphQL engine receives
|
|
31
|
+
# them (it doesn't do any formatting of DateTime args to what the datastore needs) so we do
|
|
32
|
+
# that here instead. We have configured the datastore to expect DateTimes in `strict_date_time`
|
|
33
|
+
# format, so here we convert it to that format (which is just ISO8601 format). Ultimately,
|
|
34
|
+
# that means that this method just "roundtrips" the input string back to a string, but it validates
|
|
35
|
+
# the string is formatted correctly and returns a string in the exact format we need for the datastore.
|
|
36
|
+
time.iso8601(PRECISION)
|
|
37
|
+
rescue ::ArgumentError, ::TypeError
|
|
38
|
+
raise_coercion_error(value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.coerce_result(value, ctx)
|
|
42
|
+
case value
|
|
43
|
+
when ::Time
|
|
44
|
+
value.iso8601(PRECISION)
|
|
45
|
+
when ::String
|
|
46
|
+
::Time.iso8601(value).iso8601(PRECISION)
|
|
47
|
+
end
|
|
48
|
+
rescue ::ArgumentError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private_class_method def self.raise_coercion_error(value)
|
|
53
|
+
raise ::GraphQL::CoercionError,
|
|
54
|
+
"Could not coerce value #{value.inspect} to DateTime: must be formatted as an ISO8601 " \
|
|
55
|
+
"DateTime string with a 4 digit year (example: #{::Time.now.getutc.iso8601.inspect})."
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|