Skip to content

Commit 182fa35

Browse files
jwilsclaude
andauthored
Add returnable: false field option (#1108)
* Prototype: fetchable: false field option to exclude fields from _source Adds a new `fetchable: false` option for field definitions that allows fields to remain filterable, sortable, groupable, and aggregatable while being excluded from the GraphQL output type and from `_source` in the datastore mapping. Changes: - Field struct: new `:fetchable` member with `fetchable?` predicate (defaults true) - fields_sdl: filters out non-fetchable fields from output type SDL - Index#mappings: emits `_source.excludes` for non-fetchable field paths - TypeWithSubfields#non_fetchable_field_paths: collects paths using indexing_fields_by_name_in_index to avoid interface/union recursion cycles - to_filter_field: resets fetchable to nil so filter fields still appear in filter input types Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Rename fetchable field option to returnable --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 35f5ded commit 182fa35

6 files changed

Lines changed: 179 additions & 3 deletions

File tree

elasticgraph-schema_definition/lib/elastic_graph/schema_definition/indexing/index.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,12 @@ def mappings
326326
# made against the wrong shard.
327327
hash["_routing"] = {"required" => true} if uses_custom_routing?
328328
hash["_size"] = {"enabled" => true} if schema_def_state.index_document_sizes?
329+
330+
# Exclude non-returnable fields from `_source` to save storage. These fields are still
331+
# indexed (in the inverted index and/or doc_values) for filtering, sorting, and aggregation,
332+
# but their values are not stored in the compressed `_source` blob.
333+
source_excludes = indexed_type.source_excludes_paths
334+
hash["_source"] = {"excludes" => source_excludes} if source_excludes.any?
329335
end
330336
end
331337

elasticgraph-schema_definition/lib/elastic_graph/schema_definition/mixins/has_indices.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,28 @@ def fields_with_sources
292292
indexing_fields_by_name_in_index.values.reject { |f| f.source.nil? }
293293
end
294294

295+
# Returns the list of `_source.excludes` paths for non-returnable fields.
296+
#
297+
# Uses `indexing_fields_by_name_in_index` for traversal (same as
298+
# `index_field_runtime_metadata_tuples`) to avoid infinite recursion
299+
# through interface/union subtype cycles.
300+
#
301+
# @private
302+
def source_excludes_paths(path_prefix: "")
303+
indexing_fields_by_name_in_index.flat_map do |name, field|
304+
path = path_prefix + name
305+
object_type = field.type.fully_unwrapped.as_object_type
306+
307+
if !field.returnable?
308+
[object_type ? "#{path}.*" : path]
309+
elsif object_type
310+
object_type.source_excludes_paths(path_prefix: "#{path}.")
311+
else
312+
[]
313+
end
314+
end
315+
end
316+
295317
private
296318

297319
def initialize_has_indices

elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/field.rb

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ module SchemaElements
7373
# @private
7474
# @!attribute [rw] highlightable
7575
# @private
76+
# @!attribute [rw] returnable
77+
# @private
7678
# @!attribute [rw] source
7779
# @private
7880
# @!attribute [rw] runtime_field_script
@@ -91,7 +93,7 @@ class Field < Struct.new(
9193
:name, :original_type, :parent_type, :original_type_for_derived_types, :schema_def_state, :accuracy_confidence,
9294
:filter_customizations, :grouped_by_customizations, :highlights_customizations, :sub_aggregations_customizations,
9395
:aggregated_values_customizations, :sort_order_enum_value_customizations, :args,
94-
:sortable, :filterable, :aggregatable, :groupable, :highlightable,
96+
:sortable, :filterable, :aggregatable, :groupable, :highlightable, :returnable,
9597
:graphql_only, :source, :runtime_field_script, :relationship, :singular_name,
9698
:computation_detail, :non_nullable_in_json_schema, :as_input,
9799
:name_in_index, :resolver
@@ -106,7 +108,7 @@ def initialize(
106108
name:, type:, parent_type:, schema_def_state:,
107109
accuracy_confidence: :high, name_in_index: name,
108110
type_for_derived_types: nil, graphql_only: nil, singular: nil,
109-
sortable: nil, filterable: nil, aggregatable: nil, groupable: nil, highlightable: nil,
111+
sortable: nil, filterable: nil, aggregatable: nil, groupable: nil, highlightable: nil, returnable: nil,
110112
as_input: false, resolver: nil
111113
)
112114
type_ref = schema_def_state.type_ref(type)
@@ -129,6 +131,7 @@ def initialize(
129131
aggregatable: aggregatable,
130132
groupable: groupable,
131133
highlightable: highlightable,
134+
returnable: returnable,
132135
graphql_only: graphql_only,
133136
source: nil,
134137
runtime_field_script: nil,
@@ -743,6 +746,16 @@ def highlightable?
743746
type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:highlightable?)
744747
end
745748

749+
# Indicates if this field is returnable in GraphQL query responses. When `false`, the field will
750+
# still be available for filtering, sorting, grouping, and aggregation, but will not appear in the
751+
# GraphQL output type and its data will be excluded from `_source` in the datastore for storage savings.
752+
#
753+
# @return [Boolean] true if this field's data can be returned (default: true)
754+
def returnable?
755+
return true if returnable.nil?
756+
returnable
757+
end
758+
746759
# Defines an argument on the field.
747760
#
748761
# @note ElasticGraph takes care of defining arguments for all the query features it supports, so there is generally no need to use
@@ -892,7 +905,10 @@ def to_filter_field(parent_type:, for_single_value: !type_for_derived_types.list
892905
parent_type: parent_type,
893906
name_in_index: name_in_index,
894907
type_for_derived_types: nil,
895-
resolver: nil
908+
resolver: nil,
909+
# Filter fields should always appear in their parent input type's SDL regardless
910+
# of the source field's returnability.
911+
returnable: true
896912
)
897913

898914
schema_def_state.factory.new_field(**params).tap do |f|

elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/type_with_subfields.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ def name
137137
# ElasticGraph will infer field sortability based on the field's GraphQL type and mapping type.
138138
# @option options [Boolean] highlightable force-enables or disables the ability to request search highlights for this field. When
139139
# not provided, ElasticGraph will infer field highlightable based on the field's mapping type.
140+
# @option options [Boolean] returnable when set to `false`, the field will not appear in the GraphQL output type and its data
141+
# will be excluded from `_source` in the datastore for storage savings. The field will still be available for filtering,
142+
# sorting, grouping, and aggregation. Defaults to `true`.
140143
# @yield [Field] the field for further customization
141144
# @return [void]
142145
#
@@ -531,6 +534,7 @@ def index_field_runtime_metadata_tuples(
531534

532535
def fields_sdl(&arg_selector)
533536
graphql_fields_by_name.values
537+
.select(&:returnable?)
534538
.map { |f| f.to_sdl(&arg_selector) }
535539
.flat_map { |sdl| sdl.split("\n") }
536540
.join("\n ")

elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/datastore_config/index_mappings/miscellaneous_spec.rb

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,101 @@ module SchemaDefinition
117117
})
118118
end
119119

120+
it "adds `name_in_index` to `_source.excludes` for `returnable: false` fields" do
121+
mapping = index_mapping_for "my_type" do |s|
122+
s.object_type "MyType" do |t|
123+
t.field "id", "ID"
124+
t.field "name", "String"
125+
t.field "internal_code_gql", "String", name_in_index: "internal_code", returnable: false
126+
t.index "my_type"
127+
end
128+
end
129+
130+
expect(mapping.dig("_source", "excludes")).to contain_exactly("internal_code")
131+
# The field should still appear in properties (it's indexed, just not in _source)
132+
expect(mapping.dig("properties", "internal_code")).to eq({"type" => "keyword"})
133+
end
134+
135+
it "adds `.*` to `_source.excludes` for `returnable: false` object fields" do
136+
mapping = index_mapping_for "my_type" do |s|
137+
s.object_type "InternalMetadata" do |t|
138+
t.field "internal_code", "String"
139+
end
140+
141+
s.object_type "MyType" do |t|
142+
t.field "id", "ID"
143+
t.field "internal_metadata", "InternalMetadata", returnable: false
144+
t.index "my_type"
145+
end
146+
end
147+
148+
expect(mapping).to include("_source" => {"excludes" => ["internal_metadata.*"]})
149+
expect(mapping.dig("properties", "internal_metadata", "properties", "internal_code")).to eq({"type" => "keyword"})
150+
end
151+
152+
it "adds `returnable: false` indexing-only fields to `_source.excludes` but not `graphql_only` fields" do
153+
mapping = index_mapping_for "my_type" do |s|
154+
s.object_type "MyType" do |t|
155+
t.field "id", "ID"
156+
t.field "name", "String"
157+
t.field "legacy_name", "String", graphql_only: true, name_in_index: "name", returnable: false
158+
t.field "internal_code", "String", indexing_only: true, returnable: false
159+
t.index "my_type"
160+
end
161+
end
162+
163+
expect(mapping.dig("_source", "excludes")).to contain_exactly("internal_code")
164+
expect(mapping.fetch("properties")).to include(
165+
"name" => {"type" => "keyword"},
166+
"internal_code" => {"type" => "keyword"}
167+
)
168+
expect(mapping.fetch("properties")).not_to include("legacy_name")
169+
end
170+
171+
it "adds full indexed paths to `_source.excludes` for `returnable: false` fields under nested mappings" do
172+
mapping = index_mapping_for "my_type" do |s|
173+
s.object_type "Parent" do |t|
174+
t.field "child", "String", name_in_index: "child_in_index", returnable: false
175+
end
176+
177+
s.object_type "Grandparent" do |t|
178+
t.field "parent", "Parent!", name_in_index: "parent_in_index"
179+
end
180+
181+
s.object_type "MyType" do |t|
182+
t.field "id", "ID!"
183+
t.field "grandparents", "[Grandparent!]!", name_in_index: "grandparents_in_index" do |f|
184+
f.mapping type: "nested"
185+
end
186+
t.index "my_type"
187+
end
188+
end
189+
190+
expect(mapping.dig("_source", "excludes")).to contain_exactly("grandparents_in_index.parent_in_index.child_in_index")
191+
expect(mapping.dig("properties", "grandparents_in_index")).to include(
192+
"type" => "nested",
193+
"properties" => {
194+
"parent_in_index" => {
195+
"properties" => {
196+
"child_in_index" => {"type" => "keyword"}
197+
}
198+
}
199+
}
200+
)
201+
end
202+
203+
it "does not add `_source` config when all fields are returnable" do
204+
mapping = index_mapping_for "my_type" do |s|
205+
s.object_type "MyType" do |t|
206+
t.field "id", "ID"
207+
t.field "name", "String"
208+
t.index "my_type"
209+
end
210+
end
211+
212+
expect(mapping).not_to have_key("_source")
213+
end
214+
120215
it "keeps `source_from` fields in the mapping so that indexed documents support the field even though it comes from an alternate source" do
121216
mapping = index_mapping_for "components" do |s|
122217
s.object_type "Widget" do |t|

elasticgraph-schema_definition/spec/unit/elastic_graph/schema_definition/graphql_schema/object_type_spec.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,39 @@ module SchemaDefinition
643643
end
644644
end
645645

646+
it "excludes `returnable: false` fields from the output type but keeps them in filter, sort, grouped_by, aggregated_values, and highlights types" do
647+
result = define_schema do |api|
648+
api.object_type "Widget" do |t|
649+
t.field "id", "ID"
650+
t.field "name", "String"
651+
t.field "internal_code", "String", returnable: false
652+
t.index "widgets"
653+
end
654+
end
655+
656+
expect(type_def_from(result, "Widget")).to eq(<<~EOS.strip)
657+
type Widget {
658+
id: ID
659+
name: String
660+
}
661+
EOS
662+
663+
# returnable: false field should still appear in filter input
664+
expect(filter_type_from(result, "Widget")).to include("internal_code: StringFilterInput")
665+
666+
# returnable: false field should still appear in sort order
667+
expect(sort_order_type_from(result, "Widget")).to include("internal_code_ASC")
668+
669+
# returnable: false field should still appear in grouped_by
670+
expect(grouped_by_type_from(result, "Widget")).to include("internal_code: String")
671+
672+
# returnable: false field should still appear in aggregated_values
673+
expect(aggregated_values_type_from(result, "Widget")).to include("internal_code: NonNumericAggregatedValues")
674+
675+
# returnable: false field should still appear in highlights
676+
expect(highlights_type_from(result, "Widget")).to include("internal_code: [String!]!")
677+
end
678+
646679
def object_type(name, *args, pre_def: nil, include_docs: true, &block)
647680
result = define_schema do |api|
648681
pre_def&.call(api)

0 commit comments

Comments
 (0)