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