Skip to content

Commit a2b2407

Browse files
committed
Support retrieved_from: :doc_values for direct leaf fields
1 parent 8e9b12f commit a2b2407

30 files changed

Lines changed: 612 additions & 38 deletions

File tree

config/site/src/guides/ai-tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ The [elasticgraph-mcp-server](https://pypi.org/project/elasticgraph-mcp-server/)
4343

4444
### Installation
4545

46-
Install and run the MCP server, for example as a [Goose extension](https://block.github.io/goose/docs/getting-started/using-extensions), using:
46+
Install and run the MCP server, for example as a [Goose extension](https://goose-docs.ai/docs/getting-started/using-extensions/), using:
4747

4848
{% include copyable_code_snippet.html language="shell" code="uvx elasticgraph-mcp-server" %}
4949

elasticgraph-graphql/lib/elastic_graph/graphql/datastore_query.rb

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ def ignored_values_for_routing
303303
def to_datastore_body
304304
@to_datastore_body ||= aggregations_datastore_body
305305
.merge(document_paginator.to_datastore_body)
306-
.merge({highlight: highlight, query: filter_interpreter.build_query(all_filters), _source: source}.compact)
306+
.merge({docvalue_fields: docvalue_fields, highlight: highlight, query: filter_interpreter.build_query(all_filters), _source: source}.compact)
307307
end
308308

309309
def aggregations_datastore_body
@@ -323,13 +323,28 @@ def aggregations_datastore_body
323323
# we only ask for the fields we need to return.
324324
def source
325325
return true if request_all_fields
326-
requested_source_fields = requested_fields - ["id"]
326+
requested_source_fields = requested_fields_for_source - ["id"]
327327
return false if requested_source_fields.empty?
328328
# Merging in requested_fields as _source:{includes:} based on Elasticsearch documentation:
329329
# https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-source-field.html#include-exclude
330330
{includes: requested_source_fields.to_a}
331331
end
332332

333+
def docvalue_fields
334+
requested_docvalue_fields =
335+
if request_all_fields
336+
all_docvalue_fields
337+
else
338+
requested_fields.select do |field_path|
339+
requested_via_doc_values?(field_path)
340+
end
341+
end
342+
343+
return nil if requested_docvalue_fields.empty?
344+
345+
requested_docvalue_fields.to_a
346+
end
347+
333348
def highlight
334349
return nil if !request_all_highlights && requested_highlights.empty?
335350

@@ -343,6 +358,30 @@ def highlight
343358
{fields:, highlight_query:}.compact
344359
end
345360

361+
def requested_fields_for_source
362+
@requested_fields_for_source ||= requested_fields.reject do |field_path|
363+
requested_via_doc_values?(field_path)
364+
end
365+
end
366+
367+
def all_docvalue_fields
368+
@all_docvalue_fields ||= search_index_definitions.flat_map do |index_def|
369+
index_def.fields_by_path.filter_map do |field_path, field|
370+
field_path if field.retrieved_from_doc_values?
371+
end
372+
end.to_set
373+
end
374+
375+
def requested_via_doc_values?(field_path)
376+
return false if field_path == "id"
377+
378+
field_definitions = search_index_definitions.filter_map do |index_def|
379+
index_def.fields_by_path[field_path]
380+
end
381+
382+
field_definitions.any? && field_definitions.all?(&:retrieved_from_doc_values?)
383+
end
384+
346385
# Encapsulates dependencies of `Query`, giving us something we can expose off of `application`
347386
# to build queries when desired.
348387
class Builder < Support::MemoizableData.define(:runtime_metadata, :logger, :filter_interpreter, :filter_node_interpreter, :default_page_size, :max_page_size)

elasticgraph-graphql/lib/elastic_graph/graphql/datastore_response/document.rb

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,21 @@
88

99
require "elastic_graph/graphql/decoded_cursor"
1010
require "elastic_graph/support/memoizable_data"
11+
require "elastic_graph/support/hash_util"
1112
require "forwardable"
1213

1314
module ElasticGraph
1415
class GraphQL
1516
module DatastoreResponse
17+
DOCUMENT_MISSING_VALUE = ::Object.new.freeze
18+
1619
# Represents a document fetched from the datastore. Exposes both the raw metadata
1720
# provided by the datastore and the doc payload itself. In addition, you can treat
1821
# it just like a document hash using `#[]` or `#fetch`.
1922
Document = Support::MemoizableData.define(:raw_data, :payload, :decoded_cursor_factory) do
2023
# @implements Document
2124
extend Forwardable
2225

23-
def_delegators :payload, :[], :fetch
24-
2526
def self.build(raw_data, decoded_cursor_factory: DecodedCursor::Factory::Null)
2627
source = raw_data.fetch("_source") do
2728
{} # : ::Hash[::String, untyped]
@@ -51,6 +52,33 @@ def id
5152
raw_data["_id"]
5253
end
5354

55+
def [](key)
56+
fetch(key) { nil }
57+
end
58+
59+
def fetch(key, default_value = DOCUMENT_MISSING_VALUE)
60+
value = lookup_value_at([key], list: false, missing_value: DOCUMENT_MISSING_VALUE)
61+
return value unless value.equal?(DOCUMENT_MISSING_VALUE)
62+
return yield(key) if block_given?
63+
return default_value unless default_value.equal?(DOCUMENT_MISSING_VALUE)
64+
65+
raise KeyError, "key not found: #{key}"
66+
end
67+
68+
def fetch_value_at(path, list:, default_value: DOCUMENT_MISSING_VALUE)
69+
value = lookup_value_at(path, list: list, missing_value: DOCUMENT_MISSING_VALUE)
70+
return value unless value.equal?(DOCUMENT_MISSING_VALUE)
71+
return yield(path) if block_given?
72+
return default_value unless default_value.equal?(DOCUMENT_MISSING_VALUE)
73+
74+
raise KeyError, "path not found: #{path.join(".")}"
75+
end
76+
77+
def value_at(path, list:)
78+
value = lookup_value_at(path, list: list, missing_value: DOCUMENT_MISSING_VALUE)
79+
value.equal?(DOCUMENT_MISSING_VALUE) ? nil : value
80+
end
81+
5482
def sort
5583
raw_data["sort"]
5684
end
@@ -77,6 +105,18 @@ def to_s
77105
"#<#{self.class.name} #{datastore_path}>"
78106
end
79107
alias_method :inspect, :to_s
108+
109+
private
110+
111+
def lookup_value_at(path, list:, missing_value:)
112+
value = Support::HashUtil.fetch_value_at_path(payload, path) { missing_value }
113+
return value unless value.equal?(missing_value)
114+
115+
field_values = raw_data.fetch("fields", {}).fetch(path.join("."), missing_value)
116+
return missing_value if field_values.equal?(missing_value)
117+
118+
list ? field_values : field_values.first
119+
end
80120
end
81121
end
82122
end

elasticgraph-graphql/lib/elastic_graph/graphql/datastore_response/search_response.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def filter_results(field_path, values, size)
114114
# `id` within `_source`, given it's available as `_id`.
115115
->(hit) { values.include?(hit.fetch("_id")) }
116116
else
117-
->(hit) { values.intersect?(Support::HashUtil.fetch_leaf_values_at_path(hit.fetch("_source"), field_path).to_set) }
117+
->(hit) { values.intersect?(hit_values_at_path(hit, field_path).to_set) }
118118
end
119119

120120
hits = raw_data.fetch("hits").fetch("hits").select(&filter).first(size)
@@ -131,6 +131,15 @@ def docs_description
131131
(documents.size < 3) ? documents.inspect : "[#{documents.first}, ..., #{documents.last}]"
132132
end
133133

134+
def hit_values_at_path(hit, field_path)
135+
Support::HashUtil.fetch_leaf_values_at_path(hit.fetch("_source"), field_path)
136+
rescue KeyError => error
137+
fields = hit.fetch("fields", {})
138+
return fields.fetch(field_path.join(".")) if fields.key?(field_path.join("."))
139+
140+
raise error
141+
end
142+
134143
def total_document_count(default: nil)
135144
super() || default || raise(Errors::CountUnavailableError, "#{__method__} is unavailable; set `query.total_document_count_needed = true` to make it available")
136145
end

elasticgraph-graphql/lib/elastic_graph/graphql/resolvers/get_record_field_value.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ def initialize(elasticgraph_graphql:, config:)
1919
end
2020

2121
def resolve(field:, object:, args:, context:)
22-
data =
22+
value =
2323
case object
2424
when DatastoreResponse::Document
25-
object.payload
25+
object.value_at(field.path_in_index, list: field.type.list?)
2626
else
27-
object
27+
data = object
28+
Support::HashUtil.fetch_value_at_path(data, field.path_in_index) { nil }
2829
end
2930

30-
value = Support::HashUtil.fetch_value_at_path(data, field.path_in_index) { nil }
3131
value = [] if value.nil? && field.type.list?
3232

3333
if field.type.relay_connection?

elasticgraph-graphql/lib/elastic_graph/graphql/schema/relation_join.rb

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# frozen_string_literal: true
88

99
require "elastic_graph/graphql/datastore_response/search_response"
10+
require "elastic_graph/graphql/datastore_response/document"
1011

1112
module ElasticGraph
1213
class GraphQL
@@ -51,10 +52,19 @@ def blank_value
5152

5253
# Extracts a single id or a list of ids from the given document, as required by the relation.
5354
def extract_id_or_ids_from(document, log_warning)
54-
id_or_ids = document.fetch(document_id_field_name) do
55-
log_warning.call(document: document, problem: "#{document_id_field_name} is missing from the document")
56-
blank_value
57-
end
55+
id_or_ids =
56+
case document
57+
when DatastoreResponse::Document
58+
document.fetch_value_at([document_id_field_name], list: id_cardinality == Cardinality::Many) do
59+
log_warning.call(document: document, problem: "#{document_id_field_name} is missing from the document")
60+
blank_value
61+
end
62+
else
63+
document.fetch(document_id_field_name) do
64+
log_warning.call(document: document, problem: "#{document_id_field_name} is missing from the document")
65+
blank_value
66+
end
67+
end
5868

5969
normalize_ids(id_or_ids) do |problem|
6070
log_warning.call(document: document, problem: "#{document_id_field_name}: #{problem}")

elasticgraph-graphql/sig/elastic_graph/graphql/datastore_response/document.rbs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ module ElasticGraph
2929

3030
def []: (::String) -> untyped
3131
def fetch: (::String) -> untyped
32+
| (::String, untyped) -> untyped
33+
| (::String) { (::String) -> untyped } -> untyped
34+
def fetch_value_at: (::Array[::String], list: bool) -> untyped
35+
| (::Array[::String], list: bool, default_value: untyped) -> untyped
36+
| (::Array[::String], list: bool) { (::Array[::String]) -> untyped } -> untyped
37+
def value_at: (::Array[::String], list: bool) -> untyped
3238
def index_name: () -> ::String
3339
def index_definition_name: () -> ::String
3440
def id: () -> ::String

elasticgraph-graphql/sig/elastic_graph/graphql/schema/relation_join.rbs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ module ElasticGraph
5555
def blank_value: () -> (nil | DatastoreResponse::SearchResponse)
5656

5757
def extract_id_or_ids_from: (
58-
::Hash[::String, untyped],
59-
^(document: ::Hash[::String, untyped], problem: ::String) -> void
58+
DatastoreResponse::Document | ::Hash[::String, untyped],
59+
^(document: DatastoreResponse::Document | ::Hash[::String, untyped], problem: ::String) -> void
6060
) -> (nil | ::String | ::Enumerable[::String])
6161

6262
def normalize_documents: (

elasticgraph-graphql/spec/integration/elastic_graph/graphql/datastore_query/requested_fields_spec.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,30 @@ class GraphQL
5050
expect(results.first.payload.keys).to include("name", "id", "options", "the_opts", "tags")
5151
expect(results.first["options"].keys).to include("size", "the_sighs")
5252
end
53+
54+
specify "returns docvalue-backed fields if passed `request_all_fields: true`" do
55+
graphql = build_graphql(schema_definition: lambda do |schema|
56+
schema.object_type "Widget" do |t|
57+
t.field "id", "ID!"
58+
t.field "name", "String"
59+
t.field "internal_code", "String", retrieved_from: :doc_values
60+
t.index "widgets"
61+
end
62+
end)
63+
64+
index_into(graphql, {
65+
"__typename" => "Widget",
66+
"__version" => 1,
67+
"__json_schema_version" => 1,
68+
"id" => "widget-1",
69+
"name" => "Widget 1",
70+
"internal_code" => "INT-001"
71+
})
72+
73+
results = search_datastore(graphql: graphql, request_all_fields: true)
74+
75+
expect(results.first["internal_code"]).to eq "INT-001"
76+
end
5377
end
5478
end
5579
end

elasticgraph-graphql/spec/unit/elastic_graph/graphql/datastore_query/requested_fields_spec.rb

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,88 @@ class GraphQL
4242

4343
expect(datastore_body_of(query)[:_source]).to eq(true)
4444
end
45+
46+
it "still requests doc values when requesting all fields" do
47+
graphql = build_graphql(schema_definition: lambda do |schema|
48+
schema.object_type "Widget" do |t|
49+
t.field "id", "ID!"
50+
t.field "name", "String"
51+
t.field "internal_code", "String", retrieved_from: :doc_values
52+
t.index "widgets"
53+
end
54+
end)
55+
query = graphql.datastore_query_builder.new_query(
56+
search_index_definitions: graphql.datastore_core.index_definitions_by_graphql_type.fetch("Widget"),
57+
request_all_fields: true
58+
)
59+
60+
expect(datastore_body_of(query)[:_source]).to eq true
61+
expect(datastore_body_of(query)[:docvalue_fields]).to contain_exactly("internal_code")
62+
end
63+
64+
it "still requests doc values when requesting all fields from mixed index definitions" do
65+
graphql = build_graphql(schema_definition: lambda do |schema|
66+
schema.object_type "Widget" do |t|
67+
t.field "id", "ID!"
68+
t.field "internal_code", "String", retrieved_from: :doc_values
69+
t.index "widgets"
70+
end
71+
end)
72+
doc_values_index_def = graphql.datastore_core.index_definitions_by_name.fetch("widgets")
73+
source_backed_index_def = doc_values_index_def.with(
74+
fields_by_path: doc_values_index_def.fields_by_path.merge(
75+
"internal_code" => doc_values_index_def.fields_by_path.fetch("internal_code").with(retrieved_from: nil)
76+
)
77+
)
78+
query = graphql.datastore_query_builder.new_query(
79+
search_index_definitions: [doc_values_index_def, source_backed_index_def],
80+
request_all_fields: true
81+
)
82+
83+
expect(datastore_body_of(query)[:_source]).to eq true
84+
expect(datastore_body_of(query)[:docvalue_fields]).to contain_exactly("internal_code")
85+
end
86+
87+
it "requests doc values for fields marked `retrieved_from: :doc_values`" do
88+
graphql = build_graphql(schema_definition: lambda do |schema|
89+
schema.object_type "Widget" do |t|
90+
t.field "id", "ID!"
91+
t.field "name", "String"
92+
t.field "internal_code", "String", retrieved_from: :doc_values
93+
t.index "widgets"
94+
end
95+
end)
96+
query = graphql.datastore_query_builder.new_query(
97+
search_index_definitions: graphql.datastore_core.index_definitions_by_graphql_type.fetch("Widget"),
98+
requested_fields: ["internal_code", "name"]
99+
)
100+
101+
expect(datastore_body_of(query)[:docvalue_fields]).to contain_exactly("internal_code")
102+
expect(datastore_body_of(query)[:_source][:includes]).to contain_exactly("name")
103+
end
104+
105+
it "keeps requesting `_source` when index definitions disagree on how to retrieve a field" do
106+
graphql = build_graphql(schema_definition: lambda do |schema|
107+
schema.object_type "Widget" do |t|
108+
t.field "id", "ID!"
109+
t.field "internal_code", "String", retrieved_from: :doc_values
110+
t.index "widgets"
111+
end
112+
end)
113+
doc_values_index_def = graphql.datastore_core.index_definitions_by_name.fetch("widgets")
114+
source_backed_index_def = doc_values_index_def.with(
115+
fields_by_path: doc_values_index_def.fields_by_path.merge(
116+
"internal_code" => doc_values_index_def.fields_by_path.fetch("internal_code").with(retrieved_from: nil)
117+
)
118+
)
119+
query = graphql.datastore_query_builder.new_query(
120+
search_index_definitions: [doc_values_index_def, source_backed_index_def],
121+
requested_fields: ["internal_code"]
122+
)
123+
124+
expect(datastore_body_of(query)[:docvalue_fields]).to eq nil
125+
expect(datastore_body_of(query)[:_source][:includes]).to contain_exactly("internal_code")
126+
end
45127
end
46128
end
47129
end

0 commit comments

Comments
 (0)