From acffa510828b622b5cba344045f04b2834a02574 Mon Sep 17 00:00:00 2001 From: myronmarston-toast Date: Wed, 5 Aug 2026 00:00:34 -0700 Subject: [PATCH] Replace ComputationDetail with a function adapter registry Aggregated value functions were treated uniformly only because every one of them takes no arguments and returns a flat {"value" => ...} response. A function needing a request argument and returning a nested response has nowhere for that knowledge to live, so it would leak as `if function == :percentiles` conditionals across the clause builder, the resolver, and the empty-bucket builder. Each function now routes through an adapter owning all of its datastore-specific behavior: datastore aggregation name, GraphQL argument extraction, extra clause options, reading the value out of the response, and fabricating a response for a bucket the datastore omitted. Adding a function becomes a registry entry plus one adapter. Decoupling the metadata name from the datastore aggregation name is what lets the two diverge. Fields resolve the name to an adapter once at boot (fields are built once and cached), so an unregistered name fails at boot rather than mid-query, and the registry has one query-time reader. Arguments flow in two phases because argument names are customizable via schema element names, which the computation value object cannot see when building a clause: query building calls extract_args at the boundary, storing a canonically-keyed hash; clause building later calls the pure clause_options. Threading element names onto the computation instead would pollute its identity, which is used to dedupe computations in a Set. The empty-bucket value moves from per-field metadata into the registry. It is fully derivable from the function (verified across all 45 aggregated value fields), so per-field storage was redundant and let a schema-definition author pair a function with a wrong empty value. The adapter returns the entire fabricated response rather than a bare value the builder must wrap, removing a read-path/write-path asymmetry. The argument plumbing ships now even though no function uses it yet: the point of this prep work is that the framework is ready, and deferring it would leave the follow-up changing the adapter interface itself. `computes :sum` reads as part of the field DSL, which is otherwise unprefixed. It validates against the registry at dump time, mirroring how elasticgraph-schema_definition already depends on elasticgraph-graphql to validate scalar coercion adapters; a mistyped name is a plausible slip. Prep refactor #2 of 2 for PR #1327; the percentile function itself is not added here. Closes #1330. --- config/schema/artifacts/runtime_metadata.yaml | 129 ++++++------------ .../runtime_metadata.yaml | 129 ++++++------------ .../graphql/aggregation/computation.rb | 14 +- .../graphql/aggregation/function_adapter.rb | 53 +++++++ .../graphql/aggregation/query_adapter.rb | 5 +- .../resolvers/aggregated_values.rb | 3 +- .../resolvers/relay_connection_builder.rb | 2 +- .../lib/elastic_graph/graphql/schema/field.rb | 12 +- .../graphql/aggregation/computation.rbs | 9 +- .../graphql/aggregation/function_adapter.rbs | 38 ++++++ .../elastic_graph/graphql/schema/field.rbs | 2 +- .../spec/support/aggregations_helpers.rb | 10 +- .../graphql/aggregation/computation_spec.rb | 25 ++++ .../aggregation/function_adapter_spec.rb | 70 ++++++++++ .../graphql/schema/field_spec.rb | 18 +-- .../runtime_metadata/computation_detail.rb | 36 ----- .../runtime_metadata/graphql_field.rb | 18 +-- .../runtime_metadata/computation_detail.rbs | 32 ----- .../runtime_metadata/graphql_field.rbs | 17 +-- .../runtime_metadata/graphql_field_spec.rb | 17 +-- .../runtime_metadata/object_type_spec.rb | 2 +- .../runtime_metadata/schema_spec.rb | 16 +-- .../elasticgraph-schema_definition.gemspec | 2 +- .../schema_definition/factory.rb | 2 +- .../schema_elements/built_in_types.rb | 22 +-- .../schema_elements/field.rb | 24 ++-- .../schema_elements/field.rbs | 4 +- .../for_built_in_types_spec.rb | 65 ++++----- .../graphql_fields_by_name_spec.rb | 28 ++++ .../spec_support/runtime_metadata_support.rb | 11 +- 30 files changed, 418 insertions(+), 397 deletions(-) create mode 100644 elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/function_adapter.rb create mode 100644 elasticgraph-graphql/sig/elastic_graph/graphql/aggregation/function_adapter.rbs create mode 100644 elasticgraph-graphql/spec/unit/elastic_graph/graphql/aggregation/function_adapter_spec.rb delete mode 100644 elasticgraph-schema_artifacts/lib/elastic_graph/schema_artifacts/runtime_metadata/computation_detail.rb delete mode 100644 elasticgraph-schema_artifacts/sig/elastic_graph/schema_artifacts/runtime_metadata/computation_detail.rbs diff --git a/config/schema/artifacts/runtime_metadata.yaml b/config/schema/artifacts/runtime_metadata.yaml index 0a5647e6a..f615e096a 100644 --- a/config/schema/artifacts/runtime_metadata.yaml +++ b/config/schema/artifacts/runtime_metadata.yaml @@ -4026,24 +4026,19 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead graphql_only_return_type: true @@ -4065,24 +4060,19 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead graphql_only_return_type: true @@ -4416,30 +4406,23 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead approximate_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead graphql_only_return_type: true @@ -4683,36 +4666,27 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead approximate_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead exact_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead graphql_only_return_type: true @@ -4845,36 +4819,27 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead approximate_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead exact_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead graphql_only_return_type: true @@ -4886,24 +4851,19 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead graphql_only_return_type: true @@ -4911,46 +4871,35 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead approximate_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead approximate_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead approximate_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead exact_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead graphql_only_return_type: true @@ -6085,9 +6034,7 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead graphql_only_return_type: true diff --git a/config/schema/artifacts_with_apollo/runtime_metadata.yaml b/config/schema/artifacts_with_apollo/runtime_metadata.yaml index f61c15ec7..3bdc412ac 100644 --- a/config/schema/artifacts_with_apollo/runtime_metadata.yaml +++ b/config/schema/artifacts_with_apollo/runtime_metadata.yaml @@ -4128,24 +4128,19 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead graphql_only_return_type: true @@ -4167,24 +4162,19 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead graphql_only_return_type: true @@ -4518,30 +4508,23 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead approximate_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead graphql_only_return_type: true @@ -4785,36 +4768,27 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead approximate_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead exact_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead graphql_only_return_type: true @@ -4947,36 +4921,27 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead approximate_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead exact_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead graphql_only_return_type: true @@ -4988,24 +4953,19 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead graphql_only_return_type: true @@ -5013,46 +4973,35 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_avg: - computation_detail: - function: avg + computation_function: avg resolver: name: object_with_lookahead approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead approximate_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead approximate_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead approximate_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead exact_max: - computation_detail: - function: max + computation_function: max resolver: name: object_with_lookahead exact_min: - computation_detail: - function: min + computation_function: min resolver: name: object_with_lookahead exact_sum: - computation_detail: - empty_bucket_value: 0 - function: sum + computation_function: sum resolver: name: object_with_lookahead graphql_only_return_type: true @@ -6208,9 +6157,7 @@ object_types_by_name: elasticgraph_category: scalar_aggregated_values graphql_fields_by_name: approximate_distinct_value_count: - computation_detail: - empty_bucket_value: 0 - function: cardinality + computation_function: cardinality resolver: name: object_with_lookahead graphql_only_return_type: true diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/computation.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/computation.rb index d577a390c..49bd7a1e8 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/computation.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/computation.rb @@ -19,7 +19,16 @@ module Aggregation # https://www.elastic.co/guide/en/elasticsearch/reference/7.12/search-aggregations-metrics-max-aggregation.html # https://www.elastic.co/guide/en/elasticsearch/reference/7.12/search-aggregations-metrics-min-aggregation.html # https://www.elastic.co/guide/en/elasticsearch/reference/7.12/search-aggregations-metrics-sum-aggregation.html - Computation = ::Data.define(:source_field_path, :leaf, :detail) do + Computation = ::Data.define( + :source_field_path, + # The path segment for the computation function itself (e.g. `exactMin`), potentially aliased. + :leaf, + # The adapter that owns this function's datastore-specific behavior. + :function_adapter, + # The canonically-keyed GraphQL arguments passed to the function, as produced by + # `function_adapter.extract_args` at query building time. + :function_args + ) do # @implements Computation def key(aggregation_name:) @@ -32,7 +41,8 @@ def key(aggregation_name:) def clause encoded_path = FieldPathEncoder.join(source_field_path.filter_map(&:name_in_index)) - {detail.function.to_s => {"field" => encoded_path}} + clause_body = {"field" => encoded_path}.merge(function_adapter.clause_options(function_args)) + {function_adapter.datastore_function_name => clause_body} end end end diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/function_adapter.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/function_adapter.rb new file mode 100644 index 000000000..b91d469fc --- /dev/null +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/function_adapter.rb @@ -0,0 +1,53 @@ +# Copyright 2024 - 2026 Block, Inc. +# +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. +# +# frozen_string_literal: true + +module ElasticGraph + class GraphQL + module Aggregation + # Namespace for the adapters that own the datastore-specific behavior of each aggregated + # value function (`sum`, `avg`, etc). A function's adapter knows the datastore aggregation + # it maps to, how to translate its GraphQL arguments into the aggregation clause, how to + # locate its value in the datastore response, and what response to fabricate for a bucket + # the datastore omitted. + # + # @private + module FunctionAdapter + # Adapter for metric aggregations that take no arguments and return a flat + # `{"value" => ...}` response, which describes all currently supported functions. + # + # @private + class SimpleMetric < ::Data.define(:datastore_function_name, :empty_bucket_value) + def extract_args(args, element_names) + {} + end + + def clause_options(function_args) + {} + end + + def extract_result(raw) + raw + end + + def empty_bucket_result + {"value" => empty_bucket_value} + end + end + + # The registered adapters, keyed by the function name used in runtime metadata. + BY_NAME = { + avg: SimpleMetric.new(datastore_function_name: "avg", empty_bucket_value: nil), + cardinality: SimpleMetric.new(datastore_function_name: "cardinality", empty_bucket_value: 0), + max: SimpleMetric.new(datastore_function_name: "max", empty_bucket_value: nil), + min: SimpleMetric.new(datastore_function_name: "min", empty_bucket_value: nil), + sum: SimpleMetric.new(datastore_function_name: "sum", empty_bucket_value: 0) + }.freeze + end + end + end +end diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/query_adapter.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/query_adapter.rb index fe62a4bf2..924d425bd 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/query_adapter.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/query_adapter.rb @@ -188,12 +188,13 @@ def build_computations_from(node_node, from_field_path: []) get_children_nodes(node).map do |fn_node| computed_field = field_from_node(fn_node) - computation_detail = computed_field.computation_detail # : SchemaArtifacts::RuntimeMetadata::ComputationDetail + function_adapter = computed_field.function_adapter # : FunctionAdapter::adapter Aggregation::Computation.new( source_field_path: field_path, leaf: PathSegment.for(field: computed_field, lookahead: fn_node), - detail: computation_detail + function_adapter: function_adapter, + function_args: function_adapter.extract_args(computed_field.args_to_schema_form(fn_node.arguments), element_names) ) end end diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/resolvers/aggregated_values.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/resolvers/aggregated_values.rb index 2df1cdb53..51d583c9e 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/resolvers/aggregated_values.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/resolvers/aggregated_values.rb @@ -24,7 +24,8 @@ def resolve(field:, object:, args:, context:, lookahead:) function_name: PathSegment.for(field: field, lookahead: lookahead).name_in_graphql_query ) - result = Support::HashUtil.verbose_fetch(bucket, key.encode) + function_adapter = field.function_adapter # : FunctionAdapter::adapter + result = function_adapter.extract_result(Support::HashUtil.verbose_fetch(bucket, key.encode)) # Aggregated value results always have a `value` key; in addition, for `date` field, they also have a `value_as_string`. # In that case, `value` is a number (e.g. ms since epoch) whereas `value_as_string` is a formatted value. ElasticGraph diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/resolvers/relay_connection_builder.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/resolvers/relay_connection_builder.rb index a89aa35c3..3d35ceece 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/resolvers/relay_connection_builder.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/resolvers/relay_connection_builder.rb @@ -69,7 +69,7 @@ def self.build_from_buckets(query:, parent_queries:, schema:, field_path: [], &b } empty_bucket_computations = query.computations.to_h do |computation| - [computation.key(aggregation_name: query.name), {"value" => computation.detail.empty_bucket_value}] + [computation.key(aggregation_name: query.name), computation.function_adapter.empty_bucket_result] end defaults diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/schema/field.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/schema/field.rb index 8bcba35eb..2069b6268 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/schema/field.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/schema/field.rb @@ -7,6 +7,7 @@ # frozen_string_literal: true require "elastic_graph/errors" +require "elastic_graph/graphql/aggregation/function_adapter" require "elastic_graph/graphql/schema/relation_join" require "elastic_graph/graphql/schema/arguments" @@ -18,7 +19,12 @@ class Field # The type in which the field resides. attr_reader :parent_type - attr_reader :schema, :schema_element_names, :graphql_field, :name_in_index, :relation, :computation_detail, :resolver + # The adapter for the aggregation function this field computes, or nil for a field that + # computes none. Resolved from the registry once here (fields are built once and cached) + # so that an unregistered function name fails at boot rather than mid-query. + attr_reader :function_adapter + + attr_reader :schema, :schema_element_names, :graphql_field, :name_in_index, :relation, :resolver def initialize(schema, parent_type, graphql_field, runtime_metadata, resolvers_needing_lookahead) @schema = schema @@ -26,7 +32,9 @@ def initialize(schema, parent_type, graphql_field, runtime_metadata, resolvers_n @parent_type = parent_type @graphql_field = graphql_field @relation = runtime_metadata&.relation - @computation_detail = runtime_metadata&.computation_detail + @function_adapter = runtime_metadata&.computation_function&.then do |function| + Aggregation::FunctionAdapter::BY_NAME.fetch(function) + end @resolver = runtime_metadata&.resolver @name_in_index = runtime_metadata&.name_in_index || name @graphql_field.extras([:lookahead]) if resolvers_needing_lookahead.include?(@resolver&.name) diff --git a/elasticgraph-graphql/sig/elastic_graph/graphql/aggregation/computation.rbs b/elasticgraph-graphql/sig/elastic_graph/graphql/aggregation/computation.rbs index 50825a14e..d103ea41a 100644 --- a/elasticgraph-graphql/sig/elastic_graph/graphql/aggregation/computation.rbs +++ b/elasticgraph-graphql/sig/elastic_graph/graphql/aggregation/computation.rbs @@ -4,7 +4,8 @@ module ElasticGraph class Computation attr_reader source_field_path: fieldPath attr_reader leaf: PathSegment - attr_reader detail: SchemaArtifacts::RuntimeMetadata::ComputationDetail + attr_reader function_adapter: FunctionAdapter::adapter + attr_reader function_args: FunctionAdapter::functionArgs def key: (aggregation_name: ::String) -> ::String def clause: () -> ::Hash[::String, untyped] @@ -12,13 +13,15 @@ module ElasticGraph def initialize : ( source_field_path: fieldPath, leaf: PathSegment, - detail: SchemaArtifacts::RuntimeMetadata::ComputationDetail + function_adapter: FunctionAdapter::adapter, + function_args: FunctionAdapter::functionArgs ) -> void def with: ( ?source_field_path: fieldPath, ?leaf: PathSegment, - ?detail: SchemaArtifacts::RuntimeMetadata::ComputationDetail + ?function_adapter: FunctionAdapter::adapter, + ?function_args: FunctionAdapter::functionArgs ) -> instance end end diff --git a/elasticgraph-graphql/sig/elastic_graph/graphql/aggregation/function_adapter.rbs b/elasticgraph-graphql/sig/elastic_graph/graphql/aggregation/function_adapter.rbs new file mode 100644 index 000000000..0c3af1a2a --- /dev/null +++ b/elasticgraph-graphql/sig/elastic_graph/graphql/aggregation/function_adapter.rbs @@ -0,0 +1,38 @@ +module ElasticGraph + class GraphQL + module Aggregation + module FunctionAdapter + # Adapters need not all be data class instances. A function whose behavior is not a + # parameterization of anything is registered as a singleton module instead, and joins + # this union as a `singleton(...)` member. + type adapter = SimpleMetric + + type functionArgs = ::Hash[::Symbol, untyped] + + class SimpleMetricSupertype < Data + attr_reader datastore_function_name: ::String + attr_reader empty_bucket_value: ::Numeric? + + def initialize: ( + datastore_function_name: ::String, + empty_bucket_value: ::Numeric? + ) -> void + + def self.new: ( + datastore_function_name: ::String, + empty_bucket_value: ::Numeric? + ) -> SimpleMetric + end + + class SimpleMetric < SimpleMetricSupertype + def extract_args: (::Hash[::String, untyped], SchemaArtifacts::RuntimeMetadata::SchemaElementNames) -> functionArgs + def clause_options: (functionArgs) -> ::Hash[::String, untyped] + def extract_result: (::Hash[::String, untyped]) -> ::Hash[::String, untyped] + def empty_bucket_result: () -> ::Hash[::String, untyped] + end + + BY_NAME: ::Hash[::Symbol, adapter] + end + end + end +end diff --git a/elasticgraph-graphql/sig/elastic_graph/graphql/schema/field.rbs b/elasticgraph-graphql/sig/elastic_graph/graphql/schema/field.rbs index da85b12d1..c8fa27fd3 100644 --- a/elasticgraph-graphql/sig/elastic_graph/graphql/schema/field.rbs +++ b/elasticgraph-graphql/sig/elastic_graph/graphql/schema/field.rbs @@ -10,7 +10,7 @@ module ElasticGraph attr_reader parent_type: Type attr_reader type: Type attr_reader graphql_field: ::GraphQL::Schema::Field - attr_reader computation_detail: SchemaArtifacts::RuntimeMetadata::ComputationDetail? + attr_reader function_adapter: Aggregation::FunctionAdapter::adapter? attr_reader relation: SchemaArtifacts::RuntimeMetadata::Relation def aggregated?: () -> bool diff --git a/elasticgraph-graphql/spec/support/aggregations_helpers.rb b/elasticgraph-graphql/spec/support/aggregations_helpers.rb index 90156c2cc..882b59bbb 100644 --- a/elasticgraph-graphql/spec/support/aggregations_helpers.rb +++ b/elasticgraph-graphql/spec/support/aggregations_helpers.rb @@ -10,27 +10,25 @@ require "elastic_graph/graphql/aggregation/computation" require "elastic_graph/graphql/aggregation/date_histogram_grouping" require "elastic_graph/graphql/aggregation/field_term_grouping" +require "elastic_graph/graphql/aggregation/function_adapter" require "elastic_graph/graphql/aggregation/key" require "elastic_graph/graphql/aggregation/nested_sub_aggregation" require "elastic_graph/graphql/aggregation/non_composite_grouping_adapter" require "elastic_graph/graphql/aggregation/path_segment" require "elastic_graph/graphql/aggregation/query" require "elastic_graph/graphql/aggregation/script_term_grouping" -require "elastic_graph/schema_artifacts/runtime_metadata/computation_detail" require "elastic_graph/schema_artifacts/runtime_metadata/schema_element_names" module ElasticGraph module AggregationsHelpers - def computation_of(*field_names_in_index, function, computed_field_name: function.to_s, leaf_alias: computed_field_name, field_names_in_graphql_query: field_names_in_index) + def computation_of(*field_names_in_index, function, computed_field_name: function.to_s, leaf_alias: computed_field_name, field_names_in_graphql_query: field_names_in_index, function_args: {}) source_field_path = build_field_path(names_in_index: field_names_in_index, names_in_graphql_query: field_names_in_graphql_query) GraphQL::Aggregation::Computation.new( source_field_path: source_field_path, leaf: GraphQL::Aggregation::PathSegment.new(name_in_graphql_query: leaf_alias, name_in_index: computed_field_name), - detail: SchemaArtifacts::RuntimeMetadata::ComputationDetail.new( - function: function, - empty_bucket_value: (function == :sum || function == :cardinality) ? 0 : nil - ) + function_adapter: GraphQL::Aggregation::FunctionAdapter::BY_NAME.fetch(function), + function_args: function_args ) end diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/aggregation/computation_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/aggregation/computation_spec.rb index 2304f891a..ce5eb8f24 100644 --- a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/aggregation/computation_spec.rb +++ b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/aggregation/computation_spec.rb @@ -67,6 +67,31 @@ module Aggregation expect(computation.clause).to eq({"avg" => {"field" => "foo.bar"}}) end + + it "keys the clause off the adapter's datastore function name and merges the adapter's clause options alongside `field`" do + # A fake adapter for a function whose datastore name differs from its ElasticGraph function name + # and which turns its args into clause options. None of the registered functions do either, so + # there's no real adapter to exercise this part of the interface with. + adapter = Object.new + class << adapter + def datastore_function_name + "some_datastore_fn" + end + + def clause_options(function_args) + {"percents" => function_args.fetch(:percents)} + end + end + + computation = Computation.new( + source_field_path: [PathSegment.new(name_in_graphql_query: "foo", name_in_index: "foo")], + leaf: PathSegment.new(name_in_graphql_query: "myFn", name_in_index: "some_fn"), + function_adapter: adapter, + function_args: {percents: [25, 75]} + ) + + expect(computation.clause).to eq({"some_datastore_fn" => {"field" => "foo", "percents" => [25, 75]}}) + end end end end diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/aggregation/function_adapter_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/aggregation/function_adapter_spec.rb new file mode 100644 index 000000000..854057bee --- /dev/null +++ b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/aggregation/function_adapter_spec.rb @@ -0,0 +1,70 @@ +# Copyright 2024 - 2026 Block, Inc. +# +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. +# +# frozen_string_literal: true + +require "elastic_graph/graphql/aggregation/function_adapter" +require "elastic_graph/schema_artifacts/runtime_metadata/schema_element_names" + +module ElasticGraph + class GraphQL + module Aggregation + RSpec.describe FunctionAdapter do + describe "BY_NAME" do + it "registers an adapter for each supported aggregated value function" do + expect(FunctionAdapter::BY_NAME.keys).to contain_exactly(:avg, :cardinality, :max, :min, :sum) + end + + it "maps each function to its datastore aggregation name" do + expect(FunctionAdapter::BY_NAME.transform_values(&:datastore_function_name)).to eq( + avg: "avg", + cardinality: "cardinality", + max: "max", + min: "min", + sum: "sum" + ) + end + + it "fabricates the empty bucket response each function returns when the datastore has no documents to aggregate" do + expect(FunctionAdapter::BY_NAME.transform_values(&:empty_bucket_result)).to eq( + avg: {"value" => nil}, + cardinality: {"value" => 0}, + max: {"value" => nil}, + min: {"value" => nil}, + sum: {"value" => 0} + ) + end + end + + describe FunctionAdapter::SimpleMetric do + let(:element_names) { SchemaArtifacts::RuntimeMetadata::SchemaElementNames.new(form: :snake_case, overrides: {}) } + let(:adapter) { FunctionAdapter::SimpleMetric.new(datastore_function_name: "avg", empty_bucket_value: nil) } + + it "exposes the datastore function name it was configured with" do + expect(adapter.datastore_function_name).to eq "avg" + end + + it "extracts no args, since these functions take none" do + expect(adapter.extract_args({"some_arg" => 3}, element_names)).to eq({}) + end + + it "contributes no extra clause options beyond the `field` the clause builder provides" do + expect(adapter.clause_options({})).to eq({}) + end + + it "returns the raw datastore response as the value hash, since these functions return a flat response" do + expect(adapter.extract_result({"value" => 3.7})).to eq({"value" => 3.7}) + end + + it "fabricates an empty bucket response using the configured empty bucket value" do + expect(adapter.empty_bucket_result).to eq({"value" => nil}) + expect(FunctionAdapter::SimpleMetric.new(datastore_function_name: "sum", empty_bucket_value: 0).empty_bucket_result).to eq({"value" => 0}) + end + end + end + end + end +end diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/schema/field_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/schema/field_spec.rb index 583f57021..bdbf8238d 100644 --- a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/schema/field_spec.rb +++ b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/schema/field_spec.rb @@ -193,22 +193,18 @@ class Schema end end - describe "#computation_detail" do - it "returns the aggregation function from an aggregated values field" do - field = define_schema do |s| + describe "#function_adapter" do + it "resolves an aggregated values field's function name to its registered adapter, and is nil for a field that computes nothing" do + schema = define_schema do |s| s.object_type "Photo" do |t| t.field "id", "ID!" t.field "some_field", "Int" t.index "photos" end - end.field_named("IntAggregatedValues", "exact_sum") - - expect(field.computation_detail).to eq( - SchemaArtifacts::RuntimeMetadata::ComputationDetail.new( - function: :sum, - empty_bucket_value: 0 - ) - ) + end + + expect(schema.field_named("IntAggregatedValues", "exact_sum").function_adapter).to be Aggregation::FunctionAdapter::BY_NAME.fetch(:sum) + expect(schema.field_named("Photo", "some_field").function_adapter).to be nil end end diff --git a/elasticgraph-schema_artifacts/lib/elastic_graph/schema_artifacts/runtime_metadata/computation_detail.rb b/elasticgraph-schema_artifacts/lib/elastic_graph/schema_artifacts/runtime_metadata/computation_detail.rb deleted file mode 100644 index 0e013abe5..000000000 --- a/elasticgraph-schema_artifacts/lib/elastic_graph/schema_artifacts/runtime_metadata/computation_detail.rb +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 2024 - 2026 Block, Inc. -# -# Use of this source code is governed by an MIT-style -# license that can be found in the LICENSE file or at -# https://opensource.org/licenses/MIT. -# -# frozen_string_literal: true - -module ElasticGraph - module SchemaArtifacts - module RuntimeMetadata - # Details about our aggregation functions. - # - # @private - class ComputationDetail < ::Data.define(:empty_bucket_value, :function) - FUNCTION = "function" - EMPTY_BUCKET_VALUE = "empty_bucket_value" - - def self.from_hash(hash) - new( - empty_bucket_value: hash[EMPTY_BUCKET_VALUE], - function: hash.fetch(FUNCTION).to_sym - ) - end - - def to_dumpable_hash - { - # Keys here are ordered alphabetically; please keep them that way. - EMPTY_BUCKET_VALUE => empty_bucket_value, - FUNCTION => function.to_s - } - end - end - end - end -end diff --git a/elasticgraph-schema_artifacts/lib/elastic_graph/schema_artifacts/runtime_metadata/graphql_field.rb b/elasticgraph-schema_artifacts/lib/elastic_graph/schema_artifacts/runtime_metadata/graphql_field.rb index ed7f43ee5..3afea7903 100644 --- a/elasticgraph-schema_artifacts/lib/elastic_graph/schema_artifacts/runtime_metadata/graphql_field.rb +++ b/elasticgraph-schema_artifacts/lib/elastic_graph/schema_artifacts/runtime_metadata/graphql_field.rb @@ -6,7 +6,6 @@ # # frozen_string_literal: true -require "elastic_graph/schema_artifacts/runtime_metadata/computation_detail" require "elastic_graph/schema_artifacts/runtime_metadata/configured_graphql_resolver" require "elastic_graph/schema_artifacts/runtime_metadata/relation" @@ -14,18 +13,18 @@ module ElasticGraph module SchemaArtifacts module RuntimeMetadata # @private - class GraphQLField < ::Data.define(:name_in_index, :relation, :computation_detail, :resolver) + class GraphQLField < ::Data.define(:name_in_index, :relation, :computation_function, :resolver) EMPTY = new(nil, nil, nil, nil) NAME_IN_INDEX = "name_in_index" RELATION = "relation" - AGGREGATION_DETAIL = "computation_detail" + COMPUTATION_FUNCTION = "computation_function" RESOLVER = "resolver" def self.from_hash(hash) new( name_in_index: hash[NAME_IN_INDEX], relation: hash[RELATION]&.then { |rel_hash| Relation.from_hash(rel_hash) }, - computation_detail: hash[AGGREGATION_DETAIL]&.then { |agg_hash| ComputationDetail.from_hash(agg_hash) }, + computation_function: hash[COMPUTATION_FUNCTION]&.to_sym, resolver: hash[RESOLVER]&.then { |res_hash| ConfiguredGraphQLResolver.from_hash(res_hash) } ) end @@ -33,7 +32,7 @@ def self.from_hash(hash) def to_dumpable_hash { # Keys here are ordered alphabetically; please keep them that way. - AGGREGATION_DETAIL => computation_detail&.to_dumpable_hash, + COMPUTATION_FUNCTION => computation_function&.to_s, NAME_IN_INDEX => name_in_index, RELATION => relation&.to_dumpable_hash, RESOLVER => resolver&.to_dumpable_hash @@ -45,18 +44,11 @@ def to_dumpable_hash # included in the dumped runtime metadata. def needed?(name_in_graphql) !!relation || - !!computation_detail || + !!computation_function || name_in_index&.!=(name_in_graphql) || !resolver.nil? || false end - - def with_computation_detail(empty_bucket_value:, function:) - with(computation_detail: ComputationDetail.new( - empty_bucket_value: empty_bucket_value, - function: function - )) - end end end end diff --git a/elasticgraph-schema_artifacts/sig/elastic_graph/schema_artifacts/runtime_metadata/computation_detail.rbs b/elasticgraph-schema_artifacts/sig/elastic_graph/schema_artifacts/runtime_metadata/computation_detail.rbs deleted file mode 100644 index ef5970796..000000000 --- a/elasticgraph-schema_artifacts/sig/elastic_graph/schema_artifacts/runtime_metadata/computation_detail.rbs +++ /dev/null @@ -1,32 +0,0 @@ -module ElasticGraph - module SchemaArtifacts - module RuntimeMetadata - class ComputationDetailSupertype - attr_reader function: ::Symbol - attr_reader empty_bucket_value: ::Numeric? - - def initialize: ( - function: ::Symbol, - empty_bucket_value: ::Numeric? - ) -> void - - def with: ( - ?function: ::Symbol, - ?empty_bucket_value: ::Numeric? - ) -> instance - - def self.new: - (function: ::Symbol, empty_bucket_value: ::Numeric?) -> instance - | (::Symbol, ::Numeric?) -> instance - end - - class ComputationDetail < ComputationDetailSupertype - FUNCTION: "function" - EMPTY_BUCKET_VALUE: "empty_bucket_value" - - def self.from_hash: (::Hash[::String, untyped]) -> ComputationDetail - def to_dumpable_hash: () -> ::Hash[::String, untyped] - end - end - end -end diff --git a/elasticgraph-schema_artifacts/sig/elastic_graph/schema_artifacts/runtime_metadata/graphql_field.rbs b/elasticgraph-schema_artifacts/sig/elastic_graph/schema_artifacts/runtime_metadata/graphql_field.rbs index 06c954c27..aa261c6ed 100644 --- a/elasticgraph-schema_artifacts/sig/elastic_graph/schema_artifacts/runtime_metadata/graphql_field.rbs +++ b/elasticgraph-schema_artifacts/sig/elastic_graph/schema_artifacts/runtime_metadata/graphql_field.rbs @@ -4,43 +4,38 @@ module ElasticGraph class GraphQLFieldSupertype attr_reader name_in_index: ::String? attr_reader relation: Relation? - attr_reader computation_detail: ComputationDetail? + attr_reader computation_function: ::Symbol? attr_reader resolver: ConfiguredGraphQLResolver? def initialize: ( name_in_index: ::String?, relation: Relation?, - computation_detail: ComputationDetail?, + computation_function: ::Symbol?, resolver: ConfiguredGraphQLResolver? ) -> void def with: ( ?name_in_index: ::String?, ?relation: Relation?, - ?computation_detail: ComputationDetail?, + ?computation_function: ::Symbol?, ?resolver: ConfiguredGraphQLResolver? ) -> instance def self.new: - (name_in_index: ::String?, relation: Relation?, computation_detail: ComputationDetail?, resolver: ConfiguredGraphQLResolver?) -> instance - | (::String?, Relation?, ComputationDetail?, ConfiguredGraphQLResolver?) -> instance + (name_in_index: ::String?, relation: Relation?, computation_function: ::Symbol?, resolver: ConfiguredGraphQLResolver?) -> instance + | (::String?, Relation?, ::Symbol?, ConfiguredGraphQLResolver?) -> instance end class GraphQLField < GraphQLFieldSupertype EMPTY: GraphQLField NAME_IN_INDEX: "name_in_index" RELATION: "relation" - AGGREGATION_DETAIL: "computation_detail" + COMPUTATION_FUNCTION: "computation_function" RESOLVER: "resolver" def self.from_hash: (::Hash[::String, untyped]) -> GraphQLField def to_dumpable_hash: () -> ::Hash[::String, untyped] def needed?: (::String) -> bool - - def with_computation_detail: ( - empty_bucket_value: ::Numeric?, - function: ::Symbol - ) -> GraphQLField end end end diff --git a/elasticgraph-schema_artifacts/spec/unit/elastic_graph/schema_artifacts/runtime_metadata/graphql_field_spec.rb b/elasticgraph-schema_artifacts/spec/unit/elastic_graph/schema_artifacts/runtime_metadata/graphql_field_spec.rb index 1d1403d5d..ab5ccff7c 100644 --- a/elasticgraph-schema_artifacts/spec/unit/elastic_graph/schema_artifacts/runtime_metadata/graphql_field_spec.rb +++ b/elasticgraph-schema_artifacts/spec/unit/elastic_graph/schema_artifacts/runtime_metadata/graphql_field_spec.rb @@ -19,30 +19,23 @@ module RuntimeMetadata field = GraphQLField.from_hash({}) expect(field).to eq GraphQLField.new( - computation_detail: nil, + computation_function: nil, name_in_index: nil, relation: nil, resolver: nil ) end - it "offers `with_computation_detail` updating aggregation detail" do + it "round-trips `computation_function` through the dumped form, symbolizing it on load" do field = GraphQLField.new( - computation_detail: nil, + computation_function: :sum, name_in_index: nil, relation: nil, resolver: configured_graphql_resolver(:self) ) - updated = field.with_computation_detail( - empty_bucket_value: 0, - function: :sum - ) - - expect(updated.computation_detail).to eq(ComputationDetail.new( - empty_bucket_value: 0, - function: :sum - )) + expect(field.to_dumpable_hash).to include("computation_function" => "sum") + expect(GraphQLField.from_hash(field.to_dumpable_hash).computation_function).to eq :sum end it "exposes `resolver` as nil when it is unset" do diff --git a/elasticgraph-schema_artifacts/spec/unit/elastic_graph/schema_artifacts/runtime_metadata/object_type_spec.rb b/elasticgraph-schema_artifacts/spec/unit/elastic_graph/schema_artifacts/runtime_metadata/object_type_spec.rb index d2c845018..44b191a5a 100644 --- a/elasticgraph-schema_artifacts/spec/unit/elastic_graph/schema_artifacts/runtime_metadata/object_type_spec.rb +++ b/elasticgraph-schema_artifacts/spec/unit/elastic_graph/schema_artifacts/runtime_metadata/object_type_spec.rb @@ -72,7 +72,7 @@ module RuntimeMetadata "foo1" => graphql_field_with(name_in_index: "foo1"), "foo2" => graphql_field_with(name_in_index: "foo2_in_index"), "foo3" => graphql_field_with(name_in_index: "foo3", relation: relation_with), - "foo4" => graphql_field_with(name_in_index: "foo4", computation_detail: computation_detail_with), + "foo4" => graphql_field_with(name_in_index: "foo4", computation_function: :sum), "foo5" => graphql_field_with(resolver: :other, name_in_index: "foo5"), "foo6" => graphql_field_with(name_in_index: nil) } diff --git a/elasticgraph-schema_artifacts/spec/unit/elastic_graph/schema_artifacts/runtime_metadata/schema_spec.rb b/elasticgraph-schema_artifacts/spec/unit/elastic_graph/schema_artifacts/runtime_metadata/schema_spec.rb index 058254673..cd1709d4a 100644 --- a/elasticgraph-schema_artifacts/spec/unit/elastic_graph/schema_artifacts/runtime_metadata/schema_spec.rb +++ b/elasticgraph-schema_artifacts/spec/unit/elastic_graph/schema_artifacts/runtime_metadata/schema_spec.rb @@ -63,13 +63,13 @@ module RuntimeMetadata ], graphql_fields_by_name: { "name_graphql" => GraphQLField.new( - computation_detail: nil, + computation_function: nil, name_in_index: "name_index", relation: nil, resolver: ConfiguredGraphQLResolver.new(:self, {arg1: 17}) ), "parent" => GraphQLField.new( - computation_detail: nil, + computation_function: nil, name_in_index: "parent", relation: Relation.new( foreign_key: "grandparents.parents.some_id", @@ -80,10 +80,7 @@ module RuntimeMetadata resolver: ConfiguredGraphQLResolver.new(:self, {}) ), "sum" => GraphQLField.new( - computation_detail: ComputationDetail.new( - empty_bucket_value: 0, - function: :sum - ), + computation_function: :sum, name_in_index: "sum", relation: nil, resolver: ConfiguredGraphQLResolver.new(:self, {}) @@ -212,10 +209,7 @@ module RuntimeMetadata "resolver" => {"name" => "self"} }, "sum" => { - "computation_detail" => { - "empty_bucket_value" => 0, - "function" => "sum" - }, + "computation_function" => "sum", "resolver" => {"name" => "self"} } }, @@ -348,7 +342,7 @@ module RuntimeMetadata "FieldsByGraphQLOnly" => object_type_with(graphql_fields_by_name: { "name_graphql" => GraphQLField.new( name_in_index: "name_index", - computation_detail: nil, + computation_function: nil, relation: nil, resolver: ConfiguredGraphQLResolver.new(:self, {}) ) diff --git a/elasticgraph-schema_definition/elasticgraph-schema_definition.gemspec b/elasticgraph-schema_definition/elasticgraph-schema_definition.gemspec index 2b48234c4..251ec6405 100644 --- a/elasticgraph-schema_definition/elasticgraph-schema_definition.gemspec +++ b/elasticgraph-schema_definition/elasticgraph-schema_definition.gemspec @@ -41,7 +41,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = [">= 3.4", "< 4.1"] - spec.add_dependency "elasticgraph-graphql", ElasticGraph::VERSION # needed since we validate that scalar `coerce_with` options are valid (which loads scalar coercion adapters) + spec.add_dependency "elasticgraph-graphql", ElasticGraph::VERSION # needed since we validate that scalar `coerce_with` options and aggregation function names are valid (which loads scalar coercion adapters and the aggregation function adapter registry) spec.add_dependency "elasticgraph-indexer", ElasticGraph::VERSION # needed since we validate that scalar `prepare_for_indexing_with` options are valid (which loads indexing preparer adapters) spec.add_dependency "elasticgraph-schema_artifacts", ElasticGraph::VERSION spec.add_dependency "elasticgraph-support", ElasticGraph::VERSION diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/factory.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/factory.rb index b32611fac..8c4c7735f 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/factory.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/factory.rb @@ -370,7 +370,7 @@ def new_aggregated_values_type_for_index_leaf_type(index_leaf_type) it usually differs from the true distinct value count by less than 7%. EOS - f.runtime_metadata_computation_detail empty_bucket_value: 0, function: :cardinality + f.computes :cardinality end yield type diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb index 783f05be7..adfe0106f 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/built_in_types.rb @@ -681,7 +681,7 @@ def register_standard_graphql_scalars t.customize_aggregated_values_type do |avt| # not nullable, since sum(empty_set) == 0 avt.field names.approximate_sum, "Float!", graphql_only: true do |f| - f.runtime_metadata_computation_detail empty_bucket_value: 0, function: :sum + f.computes :sum f.documentation <<~EOS The sum of the field values within this grouping. @@ -701,7 +701,7 @@ def register_standard_graphql_scalars end avt.field names.approximate_avg, "Float", graphql_only: true do |f| - f.runtime_metadata_computation_detail empty_bucket_value: nil, function: :avg + f.computes :avg f.documentation <<~EOS The average (mean) of the field values within this grouping. @@ -1004,7 +1004,7 @@ def register_custom_elastic_graph_scalars t.customize_aggregated_values_type do |avt| # not nullable, since sum(empty_set) == 0 avt.field names.approximate_sum, "Float!", graphql_only: true do |f| - f.runtime_metadata_computation_detail empty_bucket_value: 0, function: :sum + f.computes :sum f.documentation <<~EOS The (approximate) sum of the field values within this grouping. @@ -1016,7 +1016,7 @@ def register_custom_elastic_graph_scalars end avt.field names.exact_sum, "JsonSafeLong", graphql_only: true do |f| - f.runtime_metadata_computation_detail empty_bucket_value: 0, function: :sum + f.computes :sum f.documentation <<~EOS The exact sum of the field values within this grouping, if it fits in a `JsonSafeLong`. @@ -1045,7 +1045,7 @@ def register_custom_elastic_graph_scalars names.exact_max => [:max, "maximum", names.approximate_max, "largest"] }.each do |exact_name, (func, full_name, approx_name, adjective)| avt.field approx_name, "LongString", graphql_only: true do |f| - f.runtime_metadata_computation_detail empty_bucket_value: nil, function: func + f.computes func f.documentation <<~EOS The #{full_name} of the field values within this grouping. @@ -1060,7 +1060,7 @@ def register_custom_elastic_graph_scalars end avt.field names.approximate_avg, "Float", graphql_only: true do |f| - f.runtime_metadata_computation_detail empty_bucket_value: nil, function: :avg + f.computes :avg f.documentation <<~EOS The average (mean) of the field values within this grouping. @@ -1532,7 +1532,7 @@ def define_integral_aggregated_values_for(scalar_type, long_type: "JsonSafeLong" scalar_type.customize_aggregated_values_type do |t| # not nullable, since sum(empty_set) == 0 t.field names.approximate_sum, "Float!", graphql_only: true do |f| - f.runtime_metadata_computation_detail empty_bucket_value: 0, function: :sum + f.computes :sum f.documentation <<~EOS The (approximate) sum of the field values within this grouping. @@ -1544,7 +1544,7 @@ def define_integral_aggregated_values_for(scalar_type, long_type: "JsonSafeLong" end t.field names.exact_sum, long_type, graphql_only: true do |f| - f.runtime_metadata_computation_detail empty_bucket_value: 0, function: :sum + f.computes :sum f.documentation <<~EOS The exact sum of the field values within this grouping, if it fits in a `#{long_type}`. @@ -1563,7 +1563,7 @@ def define_integral_aggregated_values_for(scalar_type, long_type: "JsonSafeLong" end t.field names.approximate_avg, "Float", graphql_only: true do |f| - f.runtime_metadata_computation_detail empty_bucket_value: nil, function: :avg + f.computes :avg f.documentation <<~EOS The average (mean) of the field values within this grouping. @@ -1580,7 +1580,7 @@ def define_exact_min_max_and_approx_avg_on_aggregated_values(aggregated_values_t define_exact_min_and_max_on_aggregated_values(aggregated_values_type, scalar_type, &block) aggregated_values_type.field names.approximate_avg, scalar_type, graphql_only: true do |f| - f.runtime_metadata_computation_detail empty_bucket_value: nil, function: :avg + f.computes :avg f.documentation <<~EOS The average (mean) of the field values within this grouping. @@ -1597,7 +1597,7 @@ def define_exact_min_and_max_on_aggregated_values(aggregated_values_type, scalar discussion = yield(adjective: adjective, full_name: full_name) aggregated_values_type.field name, scalar_type, graphql_only: true do |f| - f.runtime_metadata_computation_detail empty_bucket_value: nil, function: func + f.computes func f.documentation ["The #{full_name} of the field values within this grouping.", discussion].compact.join("\n\n") end diff --git a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb index 856c2eea4..fda7ebc63 100644 --- a/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb +++ b/elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb @@ -8,6 +8,7 @@ require "delegate" require "elastic_graph/constants" +require "elastic_graph/graphql/aggregation/function_adapter" require "elastic_graph/schema_artifacts/runtime_metadata/configured_graphql_resolver" require "elastic_graph/schema_definition/indexing/field" require "elastic_graph/schema_definition/indexing/field_reference" @@ -83,7 +84,7 @@ module SchemaElements # @private # @!attribute [rw] singular_name # @private - # @!attribute [rw] computation_detail + # @!attribute [rw] computation_function # @private # @!attribute [rw] as_input # @private @@ -93,7 +94,7 @@ class Field < Struct.new( :aggregated_values_customizations, :sort_order_enum_value_customizations, :args, :sortable, :filterable, :aggregatable, :groupable, :highlightable, :returnable, :graphql_only, :source, :runtime_field_script, :relationship, :singular_name, - :computation_detail, :as_input, + :computation_function, :as_input, :name_in_index, :resolver ) include Mixins::HasDocumentation @@ -1091,24 +1092,27 @@ def nested? mapping_type == "nested" end - # Records the `ComputationDetail` that should be on the `runtime_metadata_graphql_field`. + # Records the aggregation function this field computes, which the query engine resolves to a + # {ElasticGraph::GraphQL::Aggregation::FunctionAdapter} when it builds the field. # # @private - def runtime_metadata_computation_detail(empty_bucket_value:, function:) - self.computation_detail = SchemaArtifacts::RuntimeMetadata::ComputationDetail.new( - empty_bucket_value: empty_bucket_value, - function: function - ) + def computes(function) + unless GraphQL::Aggregation::FunctionAdapter::BY_NAME.key?(function) + raise Errors::SchemaError, "`#{parent_type.name}.#{name}` has an unregistered aggregation function: `#{function}`. " \ + "Valid functions are: #{GraphQL::Aggregation::FunctionAdapter::BY_NAME.keys.map(&:inspect).join(", ")}." + end + + self.computation_function = function end - # Lazily creates and returns a GraphQLField using the field's {#name_in_index}, {#computation_detail}, + # Lazily creates and returns a GraphQLField using the field's {#name_in_index}, {#computation_function}, # and {#relationship}. # # @private def runtime_metadata_graphql_field SchemaArtifacts::RuntimeMetadata::GraphQLField.new( name_in_index: name_in_index, - computation_detail: computation_detail, + computation_function: computation_function, relation: relationship&.runtime_metadata, resolver: resolver ) diff --git a/elasticgraph-schema_definition/sig/elastic_graph/schema_definition/schema_elements/field.rbs b/elasticgraph-schema_definition/sig/elastic_graph/schema_definition/schema_elements/field.rbs index e1b34cdc6..bd7b0bab5 100644 --- a/elasticgraph-schema_definition/sig/elastic_graph/schema_definition/schema_elements/field.rbs +++ b/elasticgraph-schema_definition/sig/elastic_graph/schema_definition/schema_elements/field.rbs @@ -18,7 +18,7 @@ module ElasticGraph attr_reader parent_type: indexableType attr_reader schema_def_state: State attr_reader accuracy_confidence: accuracyConfidence - attr_accessor computation_detail: SchemaArtifacts::RuntimeMetadata::ComputationDetail + attr_accessor computation_function: ::Symbol? attr_reader filter_customizations: ::Array[^(Field) -> void] attr_reader sort_order_enum_value_customizations: ::Array[^(SortOrderEnumValue) -> void] attr_reader source: FieldSource? @@ -93,7 +93,7 @@ module ElasticGraph def nested?: () -> bool - def runtime_metadata_computation_detail: (empty_bucket_value: ::Numeric?, function: ::Symbol) -> void + def computes: (::Symbol) -> void def runtime_metadata_graphql_field: () -> SchemaArtifacts::RuntimeMetadata::GraphQLField def backing_indexing_field: () -> Field? diff --git a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/runtime_metadata/object_types_by_name/for_built_in_types_spec.rb b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/runtime_metadata/object_types_by_name/for_built_in_types_spec.rb index b37136130..3dcf40f26 100644 --- a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/runtime_metadata/object_types_by_name/for_built_in_types_spec.rb +++ b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/runtime_metadata/object_types_by_name/for_built_in_types_spec.rb @@ -24,13 +24,13 @@ module SchemaDefinition end expect(metadata.elasticgraph_category).to eq :scalar_aggregated_values - expect(metadata.graphql_fields_by_name.transform_values(&:computation_detail)).to eq( - "approximate_avg" => agg_detail_of(:avg, nil), - "approximate_sum" => agg_detail_of(:sum, 0), - "exact_sum" => agg_detail_of(:sum, 0), - "exact_max" => agg_detail_of(:max, nil), - "exact_min" => agg_detail_of(:min, nil), - "approximate_distinct_value_count" => agg_detail_of(:cardinality, 0) + expect(metadata.graphql_fields_by_name.transform_values(&:computation_function)).to eq( + "approximate_avg" => :avg, + "approximate_sum" => :sum, + "exact_sum" => :sum, + "exact_max" => :max, + "exact_min" => :min, + "approximate_distinct_value_count" => :cardinality ) end @@ -44,12 +44,12 @@ module SchemaDefinition end expect(metadata.elasticgraph_category).to eq :scalar_aggregated_values - expect(metadata.graphql_fields_by_name.transform_values(&:computation_detail)).to eq( - "approximate_avg" => agg_detail_of(:avg, nil), - "approximate_sum" => agg_detail_of(:sum, 0), - "exact_max" => agg_detail_of(:max, nil), - "exact_min" => agg_detail_of(:min, nil), - "approximate_distinct_value_count" => agg_detail_of(:cardinality, 0) + expect(metadata.graphql_fields_by_name.transform_values(&:computation_function)).to eq( + "approximate_avg" => :avg, + "approximate_sum" => :sum, + "exact_max" => :max, + "exact_min" => :min, + "approximate_distinct_value_count" => :cardinality ) end @@ -63,13 +63,13 @@ module SchemaDefinition end expect(metadata.elasticgraph_category).to eq :scalar_aggregated_values - expect(metadata.graphql_fields_by_name.transform_values(&:computation_detail)).to eq( - "approximate_avg" => agg_detail_of(:avg, nil), - "approximate_sum" => agg_detail_of(:sum, 0), - "exact_sum" => agg_detail_of(:sum, 0), - "exact_max" => agg_detail_of(:max, nil), - "exact_min" => agg_detail_of(:min, nil), - "approximate_distinct_value_count" => agg_detail_of(:cardinality, 0) + expect(metadata.graphql_fields_by_name.transform_values(&:computation_function)).to eq( + "approximate_avg" => :avg, + "approximate_sum" => :sum, + "exact_sum" => :sum, + "exact_max" => :max, + "exact_min" => :min, + "approximate_distinct_value_count" => :cardinality ) end @@ -83,15 +83,15 @@ module SchemaDefinition end expect(metadata.elasticgraph_category).to eq :scalar_aggregated_values - expect(metadata.graphql_fields_by_name.transform_values(&:computation_detail)).to eq( - "approximate_avg" => agg_detail_of(:avg, nil), - "approximate_sum" => agg_detail_of(:sum, 0), - "exact_sum" => agg_detail_of(:sum, 0), - "approximate_max" => agg_detail_of(:max, nil), - "exact_max" => agg_detail_of(:max, nil), - "approximate_min" => agg_detail_of(:min, nil), - "exact_min" => agg_detail_of(:min, nil), - "approximate_distinct_value_count" => agg_detail_of(:cardinality, 0) + expect(metadata.graphql_fields_by_name.transform_values(&:computation_function)).to eq( + "approximate_avg" => :avg, + "approximate_sum" => :sum, + "exact_sum" => :sum, + "approximate_max" => :max, + "exact_max" => :max, + "approximate_min" => :min, + "exact_min" => :min, + "approximate_distinct_value_count" => :cardinality ) end end @@ -122,13 +122,6 @@ module SchemaDefinition end end - def agg_detail_of(function, empty_bucket_value) - SchemaArtifacts::RuntimeMetadata::ComputationDetail.new( - function: function, - empty_bucket_value: empty_bucket_value - ) - end - prepend Module.new { def object_type_metadata_for(...) super(...).tap do |metadata| diff --git a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/runtime_metadata/object_types_by_name/graphql_fields_by_name_spec.rb b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/runtime_metadata/object_types_by_name/graphql_fields_by_name_spec.rb index 4750cfabc..f2d2dc0cf 100644 --- a/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/runtime_metadata/object_types_by_name/graphql_fields_by_name_spec.rb +++ b/elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/runtime_metadata/object_types_by_name/graphql_fields_by_name_spec.rb @@ -87,6 +87,34 @@ module SchemaDefinition }) end + it "dumps the aggregation function recorded by `computes`" do + metadata = object_type_metadata_for "Widget" do |s| + s.object_type "Widget" do |t| + t.field "id", "ID" + t.field "total", "Int", graphql_only: true do |f| + f.computes :sum + end + end + end + + expect(metadata.graphql_fields_by_name.fetch("total").computation_function).to eq :sum + end + + it "raises an error when `computes` is given an unregistered function name, since the query engine would have no adapter for it" do + expect { + object_type_metadata_for "Widget" do |s| + s.object_type "Widget" do |t| + t.field "id", "ID" + t.field "total", "Int", graphql_only: true do |f| + f.computes :not_a_real_function + end + end + end + }.to raise_error Errors::SchemaError, a_string_including( + "`Widget.total` has an unregistered aggregation function: `not_a_real_function`." + ) + end + it "raises an error when a custom `Query` field lacks a resolver, as it won't be resolvable by elasticgraph-graphql" do expect { object_type_metadata_for "Query" do |s| diff --git a/spec_support/lib/elastic_graph/spec_support/runtime_metadata_support.rb b/spec_support/lib/elastic_graph/spec_support/runtime_metadata_support.rb index 33aee9181..401642705 100644 --- a/spec_support/lib/elastic_graph/spec_support/runtime_metadata_support.rb +++ b/spec_support/lib/elastic_graph/spec_support/runtime_metadata_support.rb @@ -102,13 +102,6 @@ def normal_indexing_update_target_with( ) end - def computation_detail_with(empty_bucket_value: 0, function: :sum) - ComputationDetail.new( - empty_bucket_value: empty_bucket_value, - function: function - ) - end - def dynamic_param_with(source_path: "some_field", cardinality: :one) DynamicParam.new(source_path: source_path, cardinality: cardinality) end @@ -162,9 +155,9 @@ def relation_with(foreign_key: "some_id", direction: :asc, additional_filter: {} Relation.new(foreign_key: foreign_key, direction: direction, additional_filter: additional_filter, foreign_key_nested_paths: foreign_key_nested_paths) end - def graphql_field_with(name_in_index: "name_index", relation: nil, computation_detail: nil, resolver: nil) + def graphql_field_with(name_in_index: "name_index", relation: nil, computation_function: nil, resolver: nil) GraphQLField.new( - computation_detail: computation_detail, + computation_function: computation_function, name_in_index: name_in_index, relation: relation, resolver: resolver