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