Replies: 4 comments 5 replies
-
|
@ashkan25 do you propose any config updates to allow enforcement of the input/return enum split? How do you envision the separation being maintained over time? |
Beta Was this translation helpful? Give feedback.
-
|
@myronmarston this looks fine to me. Any concerns or comments? |
Beta Was this translation helpful? Give feedback.
-
|
Thanks for the well-articulated proposal, @ashkan25! I didn't really understand what problem you were trying to solve when I first saw #1093 but now I understand it well. However, I have concerns about adding this to ElasticGraph itself, and I think there's a better path: implementing this entirely as an extension. Why I'm Not in Favor of the Proposed Core ChangesThis is a niche migration problem, not a general-purpose feature. It only affects projects that used The surface area is broad. The proposal touches It can be implemented as an extension instead. EG's extension system is powerful enough to handle this without any core changes. The migration logic can live in a standalone gem (even an internal one), and when the migration is complete, the gem is simply removed, which leaves zero residual complexity in EG core. How to Implement This as an ExtensionThe extension has two parts: (1) schema definition changes to generate the transitional types/fields, and (2) a query-time hook to make the new filter field work. Part 1: Schema Definition ExtensionThis follows the same patterns as API ExtensionRegisters the factory and state extensions, and exposes a method on the API to register transitioning enums: module EnumTransition
module SchemaDefinition
module APIExtension
def self.extended(api)
api.state.extend(StateExtension)
api.factory.extend(FactoryExtension)
end
# Register enums as "in transition" — generates transitional input
# enums and companion filter fields to enable gradual client migration.
def transitioning_enums(*enum_names)
enum_names.each { |name| state.transitioning_enums << name.to_s }
end
end
end
endUsage in schema.extend EnumTransition::SchemaDefinition::APIExtension
schema.transitioning_enums "Color", "Size"State ExtensionStores the set of transitioning enum names on the schema definition state: module EnumTransition
module SchemaDefinition
module StateExtension
def transitioning_enums
@transitioning_enums ||= Set.new
end
end
end
endFactory ExtensionOverrides module EnumTransition
module SchemaDefinition
module FactoryExtension
def new_enum_type(name, &block)
super(name) do |enum_type|
enum_type.extend(EnumTypeExtension) if @state.transitioning_enums.include?(name)
block&.call(enum_type)
end
end
end
end
endEnum Type ExtensionOverrides module EnumTransition
module SchemaDefinition
module EnumTypeExtension
def derived_graphql_types
transition_input_name = compute_transition_input_name
register_transition_filter_field(transition_input_name)
input_enum = build_transition_input_enum(transition_input_name)
[input_enum] + super
end
private
def compute_transition_input_name
# The "natural" input name without the type_name_override that
# collapsed input/output into one type.
"#{name}Input" # Or derive via TypeNamer format if customized.
end
def register_transition_filter_field(transition_input_name)
# Derive the filter type name for THIS (output) enum.
# E.g., for "Color" with standard naming, the filter type is "ColorFilterInput".
filter_type_name = type_ref
.as_static_derived_type(:filter_input)
.name
# Add equalToAnyOfInput field to the existing filter type.
# customize_derived_types just registers a block — it runs later
# when Results applies customizations, so calling it here is fine.
customize_derived_types(filter_type_name) do |filter_type|
filter_type.field "equalToAnyOfInput", "[#{transition_input_name}!]" do |f|
f.documentation "Transitional filter field. Behaves identically to `equalToAnyOf` " \
"but accepts `#{transition_input_name}` values. Use this to migrate to the new " \
"input enum type before the type_name_override is removed."
end
end
end
def build_transition_input_enum(transition_input_name)
schema_def_state.factory.new_enum_type(transition_input_name) do |t|
t.for_output = false
t.graphql_only true
t.documentation doc_comment
directives.each { |dir| dir.duplicate_on(t) }
values_by_name.each { |_, val| val.duplicate_on(t) }
end
end
end
end
endPart 2: GraphQL Extension (Query-Time Rewriting)The schema now has a GraphQL ExtensionOverrides module EnumTransition
module GraphQLExtension
def filter_args_translator
@filter_args_translator ||= super.tap do |translator|
translator.extend(FilterArgsTranslatorExtension)
end
end
end
endThis GraphQL extension can be configured via Filter Args Translator ExtensionRewrites module EnumTransition
module FilterArgsTranslatorExtension
FIELD_REWRITES = {
"equalToAnyOfInput" => "equalToAnyOf"
# Add more rewrites here if needed for other filter operators
}.freeze
def translate_filter_args(field:, args:)
if (filter_hash = args[filter_arg_name])
rewritten = deep_rewrite_filter_keys(filter_hash)
super(field: field, args: args.merge(filter_arg_name => rewritten))
else
super
end
end
private
def deep_rewrite_filter_keys(obj)
case obj
when ::Hash
obj.to_h do |key, value|
rewritten_key = FIELD_REWRITES.fetch(key, key)
[rewritten_key, deep_rewrite_filter_keys(value)]
end
when ::Array
obj.map { |item| deep_rewrite_filter_keys(item) }
else
obj
end
end
end
endWhy This Works End-to-End
Migration WorkflowThe migration workflow stays essentially the same as the proposal, but powered by an extension:
Note Claude generated these code snippets for me and I haven't tried them so they probably don't quite work right but it should get you started. If you run into any problems with this approach, we can work together to improve EG such that it enables an extension like this--but I doubt we need any changes to EG core for this to work. @ashkan25 / @BrianSigafoos-SQ / @jwondrusch -- what do y'all think about this approach? |
Beta Was this translation helpful? Give feedback.
-
|
Thanks for the detailed extension approach, Myron! This makes a lot of sense to me and I agree it's a better fit as an extension rather than core changes.
I put this through Claude Code and it did flag one potential blocker: Do you have thoughts on how to handle that? A few options I can think of:
Happy to go whichever direction makes the most sense for the project. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Proposal: Gradual Enum Migration When
type_name_overridesCollapses Input Enum NamesContext
How Enum Migration Normally Works
ElasticGraph separates input and output enum types by default. For example, defining a
Colorenum produces bothColor(used in output positions) andColorInput(used in filter input positions):This separation enables a safe, 3-stage migration workflow when adding a new enum value:
ColorInputenum only (schema change, no client impact)Coloroutput enumThis works because clients' filter variables reference
ColorInput, which can evolve independently fromColor.The Problem:
type_name_overridesCollapses the SeparationSome schemas use
type_name_overridesto collapse input enum names onto their output counterparts:With this override active, both input and output positions use
Color. There is no separateColorInputtype:This is a cleaner API for clients, but it breaks the 3-stage migration. Removing the override to restore the separate types is a breaking change — it simultaneously:
ColorInputtype[Color!]to[ColorInput!][Color!](GraphQL type checking rejects the mismatch)There is no transition period. The change is all-or-nothing.
Why This Is Hard to Work Around
The
type_name_overridessystem is deeply woven into three code paths, which together prevent bothColorandColorInputfrom coexisting while the override is active:EnumType#as_input— callsto_final_form, which collapses"ColorInput"back to"Color", soas_inputreturnsself(no input enum is created)Field#type— callsto_final_form(as_input:), which applies the InputEnum format and then the override immediately collapses it backEnumType#initialize— callsto_final_formon the type ref, so even constructing an enum named"ColorInput"registers it under"Color"The net effect is that removing the override is a single atomic change that simultaneously affects every filter field across the schema — there's no way to migrate clients incrementally.
Options Considered
Option 1: Coordinated Big-Bang Migration
Remove the override and migrate all clients simultaneously.
This requires no framework changes, but it means coordinating every client of the affected schema to update their variable types at the exact same time. For schemas with many clients, this is impractical — a single missed client breaks on deploy.
Option 2: Gateway-Level Transforms
Use router-level request transforms (e.g., Apollo Router) to rewrite variable types at the gateway.
Router transforms operate on the serialized request, not the type system. GraphQL variable type checking happens before transforms run, so the type mismatch still causes validation failures. This doesn't actually solve the problem.
Option 3: Post-Process Generated SDL
Generate the schema normally, then inject the transition types as raw SDL after the fact.
The injected types wouldn't flow through ElasticGraph's schema definition pipeline — no runtime metadata, no filter interpreter registration, no shard routing optimization. An
equalToAnyOfInputfield injected this way would be unrecognized byFilterNodeInterpreterat runtime (classified as a:sub_fieldinstead of an:operator), breaking queries.Option 4: First-Class
enums_in_transitionConfiguration (Proposed)Add a configuration option that temporarily generates a transition input enum and a companion filter field, while keeping the existing override active. This is the approach I'm proposing below.
Proposal
I propose adding an
enums_in_transitionconfiguration option that allows schemas usingtype_name_overridesto gradually migrate their clients before removing the override.Configuration
enums_in_transitionaccepts a list of enum type names (the output/collapsed names):Note
The names in
enums_in_transitionare validated against registered enum types after schema definition is complete. Misspelled names produce a warning withDidYouMeansuggestions, consistent with howtype_name_overridesandenum_value_overrides_by_typeare validated.Schema Changes
For each enum in the list, the generated schema gains two additions:
A transition input enum type:
The transition enum mirrors all values, documentation, and directives from the output enum.
An
equalToAnyOfInputfilter field:The new field appears on both the standard filter type and the list element filter type.
Runtime Behavior
equalToAnyOfInputis processed identically toequalToAnyOfat query time:FilterNodeInterpreter— sametermsquery, same null handling, sameidsquery optimization for ID fieldsRoutingPickerextracts routing values from both operatorsIndexExpressionBuilderuses both operators for rollover index expression pruningColor.REDandColorInput.REDboth produce the string"RED"viato_datastore_value— the Elasticsearch queries are identicalMigration Workflow
Here's the full lifecycle for migrating away from a collapsed enum override:
Phase 1 — Add the enum to
enums_in_transition. The schema now includesColorInputandequalToAnyOfInput. No client impact — all existing queries continue to work.Phase 2 — Clients migrate at their own pace from:
to:
Phase 3 — Once all clients have migrated, remove the
type_name_overridesentry forColor. TheequalToAnyOffield now naturally uses[ColorInput!]. Clients still usingequalToAnyOfInputare unaffected.Phase 4 (optional) — Clients migrate back from
equalToAnyOfInputtoequalToAnyOf, since both now accept[ColorInput!].Phase 5 — Remove the enum from
enums_in_transition. The transitionequalToAnyOfInputfield and the explicitly-generatedColorInputenum are removed from the schema.ColorInputcontinues to exist as EG's normal input enum.Implementation Details
The implementation requires two internal bypass mechanisms, both defaulting to
false(zero behavior change for all existing schemas):skip_name_overrideonEnumTypeconstructorWhen
true, the type ref is used as-is without callingto_final_form. This allows constructing aColorInputenum that isn't collapsed toColorby the override. This flag is only used internally by the transition enum builder — it is not exposed to schema definition users.type_already_finalonFieldWhen
true, thetypeandtype_for_derived_typesmethods return the original type reference without callingto_final_form. This allows theequalToAnyOfInputfilter field to reference[ColorInput!]exactly, withoutto_final_formrewriting it. Again, only used internally.New schema element:
equalToAnyOfInputAdded to
SchemaElementNamesso it participates in the standard name generation pipeline (respects casing configuration, can be overridden viaschema_element_name_overrides). The corresponding filter operator is registered inFilterNodeInterpreter, sharing the same lambda asequalToAnyOf.Note
equalToAnyOfInputis intentionally a new, distinct schema element name rather than an alias forequalToAnyOf. This keeps the two fields independently addressable in the schema and avoids any ambiguity during the transition period.What Doesn't Change
equalToAnyOf: [Color!]continues to work exactly as before"RED"is"RED"regardless of which enum type it came fromenum_types_by_nameenums_in_transitiondefaults to[], so all existing schemas are completely unaffectedtype_name_overrides— the feature is only meaningful when overrides have collapsed input enum namesProof of Concept
A draft implementation is available at #1093. It includes:
State,API,RakeTasks,EnumType,Field,Factory,ScalarType)FilterNodeInterpreter,RoutingPicker,IndexExpressionBuilder)DidYouMeansuggestionscamelCaseandsnake_caseconfigurationsAll existing tests pass with no regressions.
Consequences
type_name_overridesto collapse enum names will be able to migrate away from those overrides gradually, without breaking clients.enums_in_transition), one new schema element (equalToAnyOfInput), and two internal flags (skip_name_override,type_already_final). All are inert when not explicitly activated.enums_in_transitionconfiguration is designed to be temporary — once the override is removed and clients have migrated, it can be cleaned up. The feature is a bridge, not a permanent addition to the schema.Beta Was this translation helpful? Give feedback.
All reactions