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