Skip to content

Commit 07364a8

Browse files
jwilsclaude
andcommitted
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>
1 parent 35f5ded commit 07364a8

5 files changed

Lines changed: 110 additions & 3 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,14 @@ 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-fetchable 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+
if indexed_type.respond_to?(:non_fetchable_field_paths)
334+
source_excludes = indexed_type.non_fetchable_field_paths
335+
hash["_source"] = {"excludes" => source_excludes} if source_excludes.any?
336+
end
329337
end
330338
end
331339

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ class Field < Struct.new(
9191
:name, :original_type, :parent_type, :original_type_for_derived_types, :schema_def_state, :accuracy_confidence,
9292
:filter_customizations, :grouped_by_customizations, :highlights_customizations, :sub_aggregations_customizations,
9393
:aggregated_values_customizations, :sort_order_enum_value_customizations, :args,
94-
:sortable, :filterable, :aggregatable, :groupable, :highlightable,
94+
:sortable, :filterable, :aggregatable, :groupable, :highlightable, :fetchable,
9595
:graphql_only, :source, :runtime_field_script, :relationship, :singular_name,
9696
:computation_detail, :non_nullable_in_json_schema, :as_input,
9797
:name_in_index, :resolver
@@ -106,7 +106,7 @@ def initialize(
106106
name:, type:, parent_type:, schema_def_state:,
107107
accuracy_confidence: :high, name_in_index: name,
108108
type_for_derived_types: nil, graphql_only: nil, singular: nil,
109-
sortable: nil, filterable: nil, aggregatable: nil, groupable: nil, highlightable: nil,
109+
sortable: nil, filterable: nil, aggregatable: nil, groupable: nil, highlightable: nil, fetchable: nil,
110110
as_input: false, resolver: nil
111111
)
112112
type_ref = schema_def_state.type_ref(type)
@@ -129,6 +129,7 @@ def initialize(
129129
aggregatable: aggregatable,
130130
groupable: groupable,
131131
highlightable: highlightable,
132+
fetchable: fetchable,
132133
graphql_only: graphql_only,
133134
source: nil,
134135
runtime_field_script: nil,
@@ -743,6 +744,16 @@ def highlightable?
743744
type_for_derived_types.fully_unwrapped.as_object_type&.supports?(&:highlightable?)
744745
end
745746

747+
# Indicates if this field is fetchable in GraphQL query responses. When `false`, the field will
748+
# still be available for filtering, sorting, grouping, and aggregation, but will not appear in the
749+
# GraphQL output type and its data will be excluded from `_source` in the datastore for storage savings.
750+
#
751+
# @return [Boolean] true if this field's data can be fetched (default: true)
752+
def fetchable?
753+
return true if fetchable.nil?
754+
fetchable
755+
end
756+
746757
# Defines an argument on the field.
747758
#
748759
# @note ElasticGraph takes care of defining arguments for all the query features it supports, so there is generally no need to use
@@ -892,7 +903,10 @@ def to_filter_field(parent_type:, for_single_value: !type_for_derived_types.list
892903
parent_type: parent_type,
893904
name_in_index: name_in_index,
894905
type_for_derived_types: nil,
895-
resolver: nil
906+
resolver: nil,
907+
# Filter fields should always appear in their parent input type's SDL regardless
908+
# of the source field's fetchability.
909+
fetchable: nil
896910
)
897911

898912
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: 25 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] fetchable 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
#
@@ -482,6 +485,27 @@ def to_indexing_field_type
482485
)
483486
end
484487

488+
# Returns the list of field paths (in dotted notation) for fields that have `fetchable: false`.
489+
# These paths are used to populate `_source.excludes` in the datastore mapping so that
490+
# non-fetchable field data is not stored in `_source`, saving storage space.
491+
#
492+
# Uses `indexing_fields_by_name_in_index` for traversal (same as `index_field_runtime_metadata_tuples`)
493+
# to avoid infinite recursion through interface/union subtype cycles.
494+
#
495+
# @private
496+
def non_fetchable_field_paths(path_prefix: "")
497+
indexing_fields_by_name_in_index.flat_map do |name, field|
498+
path = path_prefix + name
499+
if !field.fetchable?
500+
[path]
501+
elsif (object_type = field.type.fully_unwrapped.as_object_type) && object_type.respond_to?(:non_fetchable_field_paths)
502+
object_type.non_fetchable_field_paths(path_prefix: "#{path}.")
503+
else
504+
[]
505+
end
506+
end
507+
end
508+
485509
# @private
486510
def current_sources
487511
indexing_fields_by_name_in_index.values.flat_map do |field|
@@ -531,6 +555,7 @@ def index_field_runtime_metadata_tuples(
531555

532556
def fields_sdl(&arg_selector)
533557
graphql_fields_by_name.values
558+
.select(&:fetchable?)
534559
.map { |f| f.to_sdl(&arg_selector) }
535560
.flat_map { |sdl| sdl.split("\n") }
536561
.join("\n ")

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

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

120+
it "includes `_source.excludes` for `fetchable: 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", "String", fetchable: false
126+
t.index "my_type"
127+
end
128+
end
129+
130+
expect(mapping).to include("_source" => {"excludes" => ["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 "does not include `_source` config when all fields are fetchable" do
136+
mapping = index_mapping_for "my_type" do |s|
137+
s.object_type "MyType" do |t|
138+
t.field "id", "ID"
139+
t.field "name", "String"
140+
t.index "my_type"
141+
end
142+
end
143+
144+
expect(mapping).not_to have_key("_source")
145+
end
146+
120147
it "keeps `source_from` fields in the mapping so that indexed documents support the field even though it comes from an alternate source" do
121148
mapping = index_mapping_for "components" do |s|
122149
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 `fetchable: 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", fetchable: 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+
# fetchable: false field should still appear in filter input
664+
expect(filter_type_from(result, "Widget")).to include("internal_code: StringFilterInput")
665+
666+
# fetchable: false field should still appear in sort order
667+
expect(sort_order_type_from(result, "Widget")).to include("internal_code_ASC")
668+
669+
# fetchable: false field should still appear in grouped_by
670+
expect(grouped_by_type_from(result, "Widget")).to include("internal_code: String")
671+
672+
# fetchable: false field should still appear in aggregated_values
673+
expect(aggregated_values_type_from(result, "Widget")).to include("internal_code: NonNumericAggregatedValues")
674+
675+
# fetchable: 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)