elasticgraph-graphql 0.18.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +3 -0
  4. data/elasticgraph-graphql.gemspec +23 -0
  5. data/lib/elastic_graph/graphql/aggregation/composite_grouping_adapter.rb +79 -0
  6. data/lib/elastic_graph/graphql/aggregation/computation.rb +39 -0
  7. data/lib/elastic_graph/graphql/aggregation/date_histogram_grouping.rb +83 -0
  8. data/lib/elastic_graph/graphql/aggregation/field_path_encoder.rb +47 -0
  9. data/lib/elastic_graph/graphql/aggregation/field_term_grouping.rb +26 -0
  10. data/lib/elastic_graph/graphql/aggregation/key.rb +87 -0
  11. data/lib/elastic_graph/graphql/aggregation/nested_sub_aggregation.rb +37 -0
  12. data/lib/elastic_graph/graphql/aggregation/non_composite_grouping_adapter.rb +129 -0
  13. data/lib/elastic_graph/graphql/aggregation/path_segment.rb +31 -0
  14. data/lib/elastic_graph/graphql/aggregation/query.rb +172 -0
  15. data/lib/elastic_graph/graphql/aggregation/query_adapter.rb +345 -0
  16. data/lib/elastic_graph/graphql/aggregation/query_optimizer.rb +187 -0
  17. data/lib/elastic_graph/graphql/aggregation/resolvers/aggregated_values.rb +41 -0
  18. data/lib/elastic_graph/graphql/aggregation/resolvers/count_detail.rb +44 -0
  19. data/lib/elastic_graph/graphql/aggregation/resolvers/grouped_by.rb +30 -0
  20. data/lib/elastic_graph/graphql/aggregation/resolvers/node.rb +64 -0
  21. data/lib/elastic_graph/graphql/aggregation/resolvers/relay_connection_builder.rb +83 -0
  22. data/lib/elastic_graph/graphql/aggregation/resolvers/sub_aggregations.rb +82 -0
  23. data/lib/elastic_graph/graphql/aggregation/script_term_grouping.rb +32 -0
  24. data/lib/elastic_graph/graphql/aggregation/term_grouping.rb +118 -0
  25. data/lib/elastic_graph/graphql/client.rb +43 -0
  26. data/lib/elastic_graph/graphql/config.rb +81 -0
  27. data/lib/elastic_graph/graphql/datastore_query/document_paginator.rb +100 -0
  28. data/lib/elastic_graph/graphql/datastore_query/index_expression_builder.rb +142 -0
  29. data/lib/elastic_graph/graphql/datastore_query/paginator.rb +199 -0
  30. data/lib/elastic_graph/graphql/datastore_query/routing_picker.rb +239 -0
  31. data/lib/elastic_graph/graphql/datastore_query.rb +372 -0
  32. data/lib/elastic_graph/graphql/datastore_response/document.rb +78 -0
  33. data/lib/elastic_graph/graphql/datastore_response/search_response.rb +79 -0
  34. data/lib/elastic_graph/graphql/datastore_search_router.rb +151 -0
  35. data/lib/elastic_graph/graphql/decoded_cursor.rb +120 -0
  36. data/lib/elastic_graph/graphql/filtering/boolean_query.rb +45 -0
  37. data/lib/elastic_graph/graphql/filtering/field_path.rb +81 -0
  38. data/lib/elastic_graph/graphql/filtering/filter_args_translator.rb +58 -0
  39. data/lib/elastic_graph/graphql/filtering/filter_interpreter.rb +526 -0
  40. data/lib/elastic_graph/graphql/filtering/filter_value_set_extractor.rb +148 -0
  41. data/lib/elastic_graph/graphql/filtering/range_query.rb +56 -0
  42. data/lib/elastic_graph/graphql/http_endpoint.rb +229 -0
  43. data/lib/elastic_graph/graphql/monkey_patches/schema_field.rb +56 -0
  44. data/lib/elastic_graph/graphql/monkey_patches/schema_object.rb +48 -0
  45. data/lib/elastic_graph/graphql/query_adapter/filters.rb +161 -0
  46. data/lib/elastic_graph/graphql/query_adapter/pagination.rb +27 -0
  47. data/lib/elastic_graph/graphql/query_adapter/requested_fields.rb +124 -0
  48. data/lib/elastic_graph/graphql/query_adapter/sort.rb +32 -0
  49. data/lib/elastic_graph/graphql/query_details_tracker.rb +60 -0
  50. data/lib/elastic_graph/graphql/query_executor.rb +200 -0
  51. data/lib/elastic_graph/graphql/resolvers/get_record_field_value.rb +49 -0
  52. data/lib/elastic_graph/graphql/resolvers/graphql_adapter.rb +114 -0
  53. data/lib/elastic_graph/graphql/resolvers/list_records.rb +29 -0
  54. data/lib/elastic_graph/graphql/resolvers/nested_relationships.rb +74 -0
  55. data/lib/elastic_graph/graphql/resolvers/query_adapter.rb +85 -0
  56. data/lib/elastic_graph/graphql/resolvers/query_source.rb +46 -0
  57. data/lib/elastic_graph/graphql/resolvers/relay_connection/array_adapter.rb +71 -0
  58. data/lib/elastic_graph/graphql/resolvers/relay_connection/generic_adapter.rb +65 -0
  59. data/lib/elastic_graph/graphql/resolvers/relay_connection/page_info.rb +82 -0
  60. data/lib/elastic_graph/graphql/resolvers/relay_connection/search_response_adapter_builder.rb +40 -0
  61. data/lib/elastic_graph/graphql/resolvers/relay_connection.rb +42 -0
  62. data/lib/elastic_graph/graphql/resolvers/resolvable_value.rb +56 -0
  63. data/lib/elastic_graph/graphql/scalar_coercion_adapters/cursor.rb +35 -0
  64. data/lib/elastic_graph/graphql/scalar_coercion_adapters/date.rb +64 -0
  65. data/lib/elastic_graph/graphql/scalar_coercion_adapters/date_time.rb +60 -0
  66. data/lib/elastic_graph/graphql/scalar_coercion_adapters/local_time.rb +30 -0
  67. data/lib/elastic_graph/graphql/scalar_coercion_adapters/longs.rb +47 -0
  68. data/lib/elastic_graph/graphql/scalar_coercion_adapters/no_op.rb +24 -0
  69. data/lib/elastic_graph/graphql/scalar_coercion_adapters/time_zone.rb +44 -0
  70. data/lib/elastic_graph/graphql/scalar_coercion_adapters/untyped.rb +32 -0
  71. data/lib/elastic_graph/graphql/scalar_coercion_adapters/valid_time_zones.rb +634 -0
  72. data/lib/elastic_graph/graphql/schema/arguments.rb +78 -0
  73. data/lib/elastic_graph/graphql/schema/enum_value.rb +30 -0
  74. data/lib/elastic_graph/graphql/schema/field.rb +147 -0
  75. data/lib/elastic_graph/graphql/schema/relation_join.rb +103 -0
  76. data/lib/elastic_graph/graphql/schema/type.rb +263 -0
  77. data/lib/elastic_graph/graphql/schema.rb +164 -0
  78. data/lib/elastic_graph/graphql.rb +253 -0
  79. data/script/dump_time_zones +81 -0
  80. data/script/dump_time_zones.java +17 -0
  81. 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