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,120 @@
|
|
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 "base64"
|
10
|
+
require "elastic_graph/constants"
|
11
|
+
require "elastic_graph/error"
|
12
|
+
require "elastic_graph/support/memoizable_data"
|
13
|
+
require "json"
|
14
|
+
|
15
|
+
module ElasticGraph
|
16
|
+
class GraphQL
|
17
|
+
# Provides the in-memory representation of a cursor after it has been decoded, as a simple hash of sort values.
|
18
|
+
#
|
19
|
+
# The datastore's `search_after` pagination uses an array of values (which represent values of the fields you are
|
20
|
+
# sorting by). A cursor returned when we applied one sort is generally not valid when we apply a completely
|
21
|
+
# different sort. To ensure we can detect this, the encoder encodes a hash of sort fields and values, ensuring
|
22
|
+
# each value in the cursor is properly labeled with what field it came from. This allows us
|
23
|
+
# to detect situations where the client uses a cursor with a completely different sort applied, while
|
24
|
+
# allowing some minor variation in the sort. The following are still allowed:
|
25
|
+
#
|
26
|
+
# - Changing the direction of the sort (from `asc` to `desc` or vice-versa)
|
27
|
+
# - Re-ordering the sort (e.g. changing from `[amount_money_DESC, created_at_ASC]`
|
28
|
+
# to `[created_at_ASC, amount_money_DESC]`
|
29
|
+
# - Removing fields from the sort (e.g. changing from `[amount_money_DESC, created_at_ASC]`
|
30
|
+
# to `[amount_money_DESC]`) -- but adding fields is not allowed
|
31
|
+
#
|
32
|
+
# While we don't necessarily recommend clients change these things between pagination requests (the
|
33
|
+
# behavior may be surprising to the user), there is no ambiguity in how to support them, and we do not
|
34
|
+
# feel like it makes sense to restrict it at this point.
|
35
|
+
class DecodedCursor < Support::MemoizableData.define(:sort_values)
|
36
|
+
# Methods provided by `MemoizableData.define`:
|
37
|
+
# @dynamic initialize, sort_values
|
38
|
+
|
39
|
+
# Tries to decode the given string cursor, returning `nil` if it is invalid.
|
40
|
+
def self.try_decode(string)
|
41
|
+
decode!(string)
|
42
|
+
rescue InvalidCursorError
|
43
|
+
nil
|
44
|
+
end
|
45
|
+
|
46
|
+
# Tries to decode the given string cursor, raising an `InvalidCursorError` if it's invalid.
|
47
|
+
def self.decode!(string)
|
48
|
+
return SINGLETON if string == SINGLETON_CURSOR
|
49
|
+
json = ::Base64.urlsafe_decode64(string)
|
50
|
+
new(::JSON.parse(json))
|
51
|
+
rescue ::ArgumentError, ::JSON::ParserError
|
52
|
+
raise InvalidCursorError, "`#{string}` is an invalid cursor."
|
53
|
+
end
|
54
|
+
|
55
|
+
# Encodes the cursor to a string using JSON and Base64 encoding.
|
56
|
+
def encode
|
57
|
+
@encode ||= begin
|
58
|
+
json = ::JSON.fast_generate(sort_values)
|
59
|
+
::Base64.urlsafe_encode64(json, padding: false)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# A special cursor instance for when we need a cursor but have only a static collection of a single
|
64
|
+
# element without any sort of key we can encode.
|
65
|
+
SINGLETON = new({}).tap do |sc|
|
66
|
+
# Ensure the special string value is returned even though our `sort_values` are empty.
|
67
|
+
def sc.encode
|
68
|
+
SINGLETON_CURSOR
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Used to build decoded cursor values for the given `sort_fields`.
|
73
|
+
class Factory < Data.define(:sort_fields)
|
74
|
+
# Methods provided by `Data.define`:
|
75
|
+
# @dynamic initialize, sort_fields
|
76
|
+
|
77
|
+
# Builds a factory from a list like:
|
78
|
+
# `[{ 'amount_money.amount' => 'asc' }, { 'created_at' => 'desc' }]`.
|
79
|
+
def self.from_sort_list(sort_list)
|
80
|
+
sort_fields = sort_list.map do |hash|
|
81
|
+
if hash.values.any? { |v| !v.is_a?(::Hash) } || hash.values.flat_map(&:keys) != ["order"]
|
82
|
+
raise InvalidSortFieldsError,
|
83
|
+
"Given `sort_list` contained an invalid entry. Each must be a flat hash with one entry. Got: #{sort_list.inspect}"
|
84
|
+
end
|
85
|
+
|
86
|
+
# Steep thinks it could be `nil` because `hash.keys` could be empty, but we raise an error above in
|
87
|
+
# that case, so we know this will wind up being a `String`. `_` here silences Steep's type check error.
|
88
|
+
_ = hash.keys.first
|
89
|
+
end
|
90
|
+
|
91
|
+
if sort_fields.uniq.size < sort_fields.size
|
92
|
+
raise InvalidSortFieldsError,
|
93
|
+
"Given `sort_list` contains a duplicate field, which the CursorEncoder cannot handler. " \
|
94
|
+
"The caller is responsible for de-duplicating the sort list fist. Got: #{sort_list.inspect}"
|
95
|
+
end
|
96
|
+
|
97
|
+
new(sort_fields)
|
98
|
+
end
|
99
|
+
|
100
|
+
def build(sort_values)
|
101
|
+
unless sort_values.size == sort_fields.size
|
102
|
+
raise CursorEncodingError,
|
103
|
+
"size of sort values (#{sort_values.inspect}) does not match the " \
|
104
|
+
"size of sort fields (#{sort_fields.inspect})"
|
105
|
+
end
|
106
|
+
|
107
|
+
DecodedCursor.new(sort_fields.zip(sort_values).to_h)
|
108
|
+
end
|
109
|
+
|
110
|
+
alias_method :to_s, :inspect
|
111
|
+
|
112
|
+
module Null
|
113
|
+
def self.build(sort_values)
|
114
|
+
DecodedCursor.new(sort_values.map(&:to_s).zip(sort_values).to_h)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,45 @@
|
|
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 Filtering
|
12
|
+
# BooleanQuery is an internal class for composing a datastore query:
|
13
|
+
# https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html
|
14
|
+
#
|
15
|
+
# It is composed of:
|
16
|
+
# 1) The occurrence type (:must, :filter, :should, or :must_not)
|
17
|
+
# 2) A list of query clauses evaluated by the given occurrence type
|
18
|
+
# 3) An optional flag indicating whether the occurrence should be negated
|
19
|
+
class BooleanQuery < ::Data.define(:occurrence, :clauses)
|
20
|
+
def self.must(*clauses)
|
21
|
+
new(:must, clauses)
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.filter(*clauses)
|
25
|
+
new(:filter, clauses)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.should(*clauses)
|
29
|
+
new(:should, clauses)
|
30
|
+
end
|
31
|
+
|
32
|
+
def merge_into(bool_node)
|
33
|
+
bool_node[occurrence].concat(clauses)
|
34
|
+
end
|
35
|
+
|
36
|
+
# For `any_of: []` we need a way to force the datastore to match no documents, but
|
37
|
+
# I haven't found any sort of literal `false` we can pass in the compound expression
|
38
|
+
# or even a literal `1 = 0` as is sometimes used in SQL. Instead, we use this for that
|
39
|
+
# case.
|
40
|
+
empty_array = [] # : ::Array[untyped]
|
41
|
+
ALWAYS_FALSE_FILTER = filter({ids: {values: empty_array}})
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,81 @@
|
|
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/constants"
|
10
|
+
|
11
|
+
module ElasticGraph
|
12
|
+
class GraphQL
|
13
|
+
module Filtering
|
14
|
+
# Tracks state related to field paths as we traverse our filtering data structure in order to translate
|
15
|
+
# it to its Elasticsearch/OpenSearch form.
|
16
|
+
#
|
17
|
+
# Instances of this class are immutable--callers must use the provided APIs (`+`, `counts_path`, `nested`)
|
18
|
+
# to get back new instances with state changes applied.
|
19
|
+
FieldPath = ::Data.define(
|
20
|
+
# The path from the overall document root.
|
21
|
+
:from_root,
|
22
|
+
# The path from the current parent document. Usually `from_parent` and `from_root` are the same,
|
23
|
+
# but they'll be different when we encounter a list field indexed using the `nested` mapping type.
|
24
|
+
# When we're traversing a subfield of a `nested` field, `from_root` will contain the full path from
|
25
|
+
# the original, overall document root, while `from_parent` will contain the path from the current
|
26
|
+
# nested document's root.
|
27
|
+
:from_parent
|
28
|
+
) do
|
29
|
+
# @implements FieldPath
|
30
|
+
|
31
|
+
# Builds an empty instance.
|
32
|
+
def self.empty
|
33
|
+
new([], [])
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.of(parts)
|
37
|
+
new(parts, parts)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Used when we encounter a `nested` field to restart the `from_parent` path (while preserving the `from_root` path).
|
41
|
+
def nested
|
42
|
+
FieldPath.new(from_root, [])
|
43
|
+
end
|
44
|
+
|
45
|
+
# Creates a new instance with `sub_path` appended.
|
46
|
+
def +(other)
|
47
|
+
FieldPath.new(from_root + [other], from_parent + [other])
|
48
|
+
end
|
49
|
+
|
50
|
+
# Converts the current paths to what they need to be to be able to query our hidden `__counts` field (which
|
51
|
+
# is a map containing the counts of elements of every list field on the document). The `__counts` field
|
52
|
+
# sits a the root of every document (for both an overall root document and a `nested` document). Here's an
|
53
|
+
# example (which assumes `seasons` and `seasons.players` fields which are both `nested` and an `awards` field
|
54
|
+
# which is a list of strings). Given a filter like this:
|
55
|
+
#
|
56
|
+
# filter: {seasons: {any_satisfy: {players: {any_satisfy: {results: {awards: {count: {gt: 1}}}}}}}}
|
57
|
+
#
|
58
|
+
# ...after processing the `awards` key, our `FieldPath` will be:
|
59
|
+
#
|
60
|
+
# FieldPath.new(["seasons", "players", "results", "awards"], ["results", "awards"])
|
61
|
+
#
|
62
|
+
# When we then reach the `count` sub field and `counts_path` is called on it, the following will be returned:
|
63
|
+
#
|
64
|
+
# FieldPath.new(["seasons", "players", LIST_COUNTS_FIELD, "results|awards"], [LIST_COUNTS_FIELD, "results|awards"])
|
65
|
+
#
|
66
|
+
# This gives us what we want:
|
67
|
+
# - The path from the root is `seasons.players.__counts.results|awards`.
|
68
|
+
# - The path from the (nested) parent is `__counts.results|awards`.
|
69
|
+
#
|
70
|
+
# Note that our `__counts` field is a flat map which uses `|` (the `LIST_COUNTS_FIELD_PATH_KEY_SEPARATOR` character)
|
71
|
+
# to separate its parts (hence, it's `results|awards` instead of `results.awards`).
|
72
|
+
def counts_path
|
73
|
+
from_root_to_parent_of_counts_field = from_root[0...-from_parent.size] # : ::Array[::String]
|
74
|
+
counts_sub_field = [LIST_COUNTS_FIELD, from_parent.join(LIST_COUNTS_FIELD_PATH_KEY_SEPARATOR)]
|
75
|
+
|
76
|
+
FieldPath.new(from_root_to_parent_of_counts_field + counts_sub_field, counts_sub_field)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,58 @@
|
|
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 Filtering
|
12
|
+
# Responsible for translating a `filter` expression from GraphQL field names to the internal
|
13
|
+
# `name_in_index` of each field. This is necessary so that when a field is defined with
|
14
|
+
# an alternate `name_in_index`, the query against the index uses that name even while
|
15
|
+
# the name in the GraphQL schema is different.
|
16
|
+
#
|
17
|
+
# In addition, we translate the enum value names to enum value objects, so that any runtime
|
18
|
+
# metadata associated with that enum value is available to our `FilterInterpreter`.
|
19
|
+
class FilterArgsTranslator < ::Data.define(:filter_arg_name)
|
20
|
+
def initialize(schema_element_names:)
|
21
|
+
super(filter_arg_name: schema_element_names.filter)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Translates the `filter` expression from the given `args` and `field` into their equivalent
|
25
|
+
# form using the `name_in_index` for any fields that are named differently in the index
|
26
|
+
# vs GraphQL.
|
27
|
+
def translate_filter_args(field:, args:)
|
28
|
+
return nil unless (filter_hash = args[filter_arg_name])
|
29
|
+
filter_type = field.schema.type_from(field.graphql_field.arguments[filter_arg_name].type)
|
30
|
+
convert(filter_type, filter_hash)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def convert(parent_type, filter_object)
|
36
|
+
case filter_object
|
37
|
+
when ::Hash
|
38
|
+
filter_object.to_h do |key, value|
|
39
|
+
field = parent_type.field_named(key)
|
40
|
+
[field.name_in_index.to_s, convert(field.type.unwrap_fully, value)]
|
41
|
+
end
|
42
|
+
when ::Array
|
43
|
+
filter_object.map { |value| convert(parent_type, value) }
|
44
|
+
when nil
|
45
|
+
nil
|
46
|
+
else
|
47
|
+
if parent_type.enum?
|
48
|
+
# Replace the name of an enum value with the value itself.
|
49
|
+
parent_type.enum_value_named(filter_object)
|
50
|
+
else
|
51
|
+
filter_object
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|