Source enum values from external proto enums and reference external enum types - #1286
Source enum values from external proto enums and reference external enum types#1286jwils wants to merge 2 commits into
Conversation
723577d to
362dde9
Compare
362dde9 to
b140cc6
Compare
b140cc6 to
61d2c86
Compare
61d2c86 to
06b8a2b
Compare
06b8a2b to
a2e1ff0
Compare
a2e1ff0 to
cd2b49f
Compare
cd2b49f to
f3f5f66
Compare
f3f5f66 to
8cd283f
Compare
dd523aa to
593342e
Compare
593342e to
ec4819c
Compare
ec4819c to
7a122a7
Compare
4d94fd9 to
529dcb5
Compare
170323f to
5f29c5a
Compare
5f29c5a to
6fc64b7
Compare
## Why - continue the pluggable ingestion serializer proposal after pulling JSON Schema into its own gem - revive the earlier protobuf prototype from #1056 on top of the new serializer extension points ## What - fill in the `elasticgraph-proto_ingestion` extension gem with core generation: `schema_artifacts:dump` emits a `proto3` `schema.proto` covering the schema's indexed types - map built-in ElasticGraph scalars to proto types, with `t.protobuf type:` for custom scalars (resolved via `type_ref.with_reverted_override` so built-ins renamed with `type_name_overrides` keep working) - generate messages for object/interface/union types and enums (with a zero-valued `*_UNSPECIFIED` entry), escaping proto reserved words and wrapping lists of lists so the output stays valid - keep `schema.proto` on public GraphQL field names; validate proto package names - hold extension state on a `ProtoIngestionState` container behind a single `proto_ingestion_state` reader (matching #1281) Field and enum value numbers are assigned sequentially in definition order in this PR; the stacked follow-up adds the `proto_field_numbers.yaml` sidecar that keeps them wire-stable across schema evolution. ## Stacked follow-ups 1. this PR — core `schema.proto` generation 2. wire-stable field/enum value numbers via a `proto_field_numbers.yaml` sidecar 3. `syntax: :proto2` support and custom file-level `headers:` 4. #1286 — enum value sourcing from existing proto enums + external proto type references ## Verification - `script/run_gem_specs elasticgraph-proto_ingestion` (100% line + branch coverage at this commit) - `script/type_check`, `script/lint`, `script/spellcheck` - `script/quick_build` green at the stack head (whose tree is identical to the previously reviewed single-PR revision) ## References - #1059 - #1056 - #1079 ## Update — 2026-07-10 - The current stack is #1080 → #1304 → #1306 → #1305 → #1286. - Proto extension state now uses a mutable Struct, and keyword package-name segments are validated without being rewritten. - Lists of lists now raise an actionable schema error instead of generating wrapper messages; this supersedes the earlier wrapping note above. --- ## Update — 2026-07-15 - Interface and union messages now wrap concrete subtype messages in a `oneof`, matching the JSON Schema `oneOf` representation. - Concrete subtype messages omit the redundant `__typename` discriminator. - Proto type rendering is now stateless: the generator selects the reachable type graph up front, then each extended type renders itself without mutating shared traversal state. - No-block extension coverage now completes the definitions so the fixture remains valid under CI GraphQL-schema validation. --- ## Update — 2026-07-19 - Replaced keyword suffixing with fully qualified local message and enum references, preserving source type and field names while disambiguating contextual protobuf words and built-in scalar names. - Removed the now-unnecessary keyword collision tracking and verified a generated schema containing contextual names with `protoc` 35.1.
6fc64b7 to
2b39fb5
Compare
2b39fb5 to
b565b3b
Compare
b565b3b to
9fb4336
Compare
9fb4336 to
0521685
Compare
| end | ||
|
|
||
| doctest.before "ElasticGraph::ProtoIngestion::SchemaDefinition::SchemaElements::EnumTypeExtension#external_proto_enum" do | ||
| extend ::RSpec::Mocks::ExampleMethods |
There was a problem hiding this comment.
It's not safe to just include the RSpec mocks methods--you also have to call its setup/teardown so that it cleans up mocks/stubs properly:
https://rspec.info/features/3-12/rspec-mocks/outside-rspec/any-test-framework/
In particular, your use of stub_const below leaks.
|
|
||
| ElasticGraph.define_schema do |schema| | ||
| schema.enum_type "Currency" do |t| | ||
| t.values "USD", "CAD" |
There was a problem hiding this comment.
Above you say:
you can source an enum's generated proto values from them instead of maintaining the value list in two places.
Based on that, I thought the point of this was to not need to define the values--instead, it would get them from the externally defined proto.
Am I misunderstanding what this is for? Or should the t.values be removed here?
| # Values expected in the generated enum that the proto enum lacks. | ||
| expected_extras: [:LEGACY], | ||
| # Optional transform applied to each proto value name. | ||
| name_transform: ->(name) { name.sub(/\ACURRENCY_/, "") } |
There was a problem hiding this comment.
| name_transform: ->(name) { name.sub(/\ACURRENCY_/, "") } | |
| name_transform: ->(name) { name.delete_prefix("CURRENCY_") } |
(A bit more readable, IMO).
| # Proto values to omit from the generated enum. | ||
| exclusions: [:UNKNOWN_DO_NOT_USE], | ||
| # Values expected in the generated enum that the proto enum lacks. | ||
| expected_extras: [:LEGACY], |
There was a problem hiding this comment.
expected_extras makes since in a test validation context but I'm not sure it makes sense here. If someone wants extra enum values that aren't defined on the proto...can't they just call t.value? Why do we need expected_extras? And what does it even do?
|
|
||
| `name_transform` runs first, and `exclusions` and `expected_extras` then apply to the | ||
| transformed names. In the example above the exclusion is therefore `UNKNOWN_DO_NOT_USE`, the | ||
| name left after the transform strips `CURRENCY_`, and not `CURRENCY_UNKNOWN_DO_NOT_USE`. |
There was a problem hiding this comment.
| name left after the transform strips `CURRENCY_`, and not `CURRENCY_UNKNOWN_DO_NOT_USE`. | |
| name left after the transform strips `CURRENCY_`, rather than `CURRENCY_UNKNOWN_DO_NOT_USE`. |
|
|
||
| A referenced enum must have exactly one option-free source whose values match the | ||
| ElasticGraph enum's values; transformed, curated, or multi-source enums stay generated | ||
| locally so value curation remains explicit. Note that `MyApp::Protos::Currency` from the |
There was a problem hiding this comment.
value curation remains explicit
I don't know what this means.
| ElasticGraph enum's values; transformed, curated, or multi-source enums stay generated | ||
| locally so value curation remains explicit. Note that `MyApp::Protos::Currency` from the | ||
| previous section cannot be referenced this way: its `CURRENCY_`-prefixed names only match | ||
| after a transform, and referenced enums allow no transform. |
There was a problem hiding this comment.
I don't understand this last sentence.
Also, I thought that EG natively generates enum values with a prefix like CURRENCY_. For example, see:
Given that, why is the CURRENCY_ prefixing a problem?
| after a transform, and referenced enums allow no transform. | ||
|
|
||
| The source's enum entries must also expose `.number`. Those numbers are recorded in | ||
| `proto_field_numbers.yaml`, and must agree with any numbers already pinned there — otherwise |
There was a problem hiding this comment.
If we're referencing an existing proto enum, then we should treat it as the canonical source of truth for the enum value numbers. Recording them in proto_field_numbers.yaml feels like it could let them conflict.
| proto_status = ::Class.new do | ||
| def self.enums | ||
| [ | ||
| ::Data.define(:name, :number).new(name: :ACTIVE, number: 1), |
There was a problem hiding this comment.
::Data.define(:name, :number) defines a new class. It's wasteful to define a new class every time we need an object with name and number.
Instead, we should define ProtoEnumValue = ::Data.define(...) somewhere (maybe in support?) and then we can use it from all these places.
| ::Data.define(:name).new(name: :INACTIVE) | ||
| ] | ||
| end | ||
| end |
There was a problem hiding this comment.
Instead of dynamically defining a class for these protos can we define a Data class in support for them? e.g. ProtoEnumType = ::Data.define(:enums).
…prove README wording, extract shared proto enum Data classes
- doctest_helper.rb: Replace stub_const with real module definitions and an
after hook that calls remove_const, eliminating the rspec-mocks lifecycle
from doctests entirely (fixes stub_const leak flagged by Myron)
- README + enum_type_extension.rb + schema_spec.rb: Replace sub(/\ACURRENCY_/, )
with delete_prefix("CURRENCY_") in all 4 copies for consistency
- README: Rewrite "one option-free source" as the concrete
"exactly one external_proto_enum call that passes no exclusions:,
expected_extras:, or name_transform:"
- README: Cut "value curation remains explicit"
- README: Improve wording: "rather than" instead of "and not"
- proto_schema_support.rb: Define ProtoEnumEntry, ProtoEnumEntryNameOnly,
and ProtoEnum as shared Data classes for use across all proto_ingestion specs
- schema_spec.rb + schema_edge_cases_spec.rb: Replace all inline
::Data.define(:name) / ::Data.define(:name, :number) and ::Class.new blocks
with the shared constants, reducing class allocation churn
Why
Some schemas already have canonical protobuf enum definitions. Generating duplicate enums in
schema.protomakes Java consumers deal with parallel types, and maintaining enum value lists in two places invites drift.What
Adds
external_proto_enumon enum types, per the API inversion suggested in review — configuration lives on the enum type itself instead of a global hash imported from application code:.enums), honoringexclusions,expected_extras, andname_transformkeyword args. Multiple sources may be registered and must agree.proto:andimport:goes further —schema.protoimports the named file and references the existing enum type instead of generating a local duplicate. A referenced enum must have exactly one option-free source whose values match the ElasticGraph enum's values, so transformed or curated enums stay generated locally.Because the API lives on enum types and options are real keyword arguments, several things from the previous revision disappear entirely: the global-hash normalization and string/symbol key handling, the extension state field, and two validation error cases that are now impossible by construction (an external reference without a value source, and referencing a non-enum type).
Risk Assessment
Low — only affects the unreleased
elasticgraph-proto_ingestionextension, and both features are opt-in with eager validation.References