Skip to content

Commit cd2b49f

Browse files
jwilsclaude
andcommitted
Reference external proto enum types
Some schemas already have canonical protobuf enum definitions, and generating duplicate enums in `schema.proto` makes consumers deal with parallel types. - Add `proto_external_types` to import and reference existing proto enum types - Validate external enum references against exactly one option-free `proto_enum_mappings` source - Render `import` statements for referenced proto files Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 653a94f commit cd2b49f

13 files changed

Lines changed: 467 additions & 2 deletions

File tree

elasticgraph-proto_ingestion/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,36 @@ end
163163
When a mapping exists for an enum, `elasticgraph-proto_ingestion` uses the mapped proto enum(s)
164164
as the source of enum values (respecting `exclusions`, `expected_extras`, and `name_transform`).
165165

166+
### Referencing Existing Protobuf Types
167+
168+
For enums that exactly match a canonical proto enum, you can import and reference
169+
the existing proto type instead of generating a duplicate local enum:
170+
171+
```ruby
172+
# in config/schema/protobuf.rb
173+
174+
ElasticGraph.define_schema do |schema|
175+
if defined?(Squareup::Connect::V2::Resources::Card::Type)
176+
schema.proto_enum_mappings(
177+
"CardType" => {
178+
Squareup::Connect::V2::Resources::Card::Type => {}
179+
}
180+
)
181+
182+
schema.proto_external_types(
183+
"CardType" => {
184+
proto: "squareup.connect.v2.resources.Card.Type",
185+
import: "squareup/connect/v2/resources/card.proto"
186+
}
187+
)
188+
end
189+
end
190+
```
191+
192+
External type references currently support enums only. The matching
193+
`proto_enum_mappings` entry must have exactly one source and no transform options;
194+
otherwise the enum stays generated locally so value curation remains explicit.
195+
166196
### Stable Field Numbers
167197

168198
`schema_artifacts:dump` automatically reads and writes `proto_field_numbers.yaml`

elasticgraph-proto_ingestion/lib/elastic_graph/proto_ingestion/schema_definition/api_extension.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,26 @@ def proto_enum_mappings(proto_enums_by_graphql_enum)
108108
nil
109109
end
110110

111+
# Registers GraphQL types that should be referenced from existing proto files instead of
112+
# generated locally in `schema.proto`.
113+
#
114+
# @param proto_external_types [Hash] map of GraphQL type name to `proto` and `import` values
115+
# @return [void]
116+
#
117+
# @example Reference an external enum type
118+
# ElasticGraph.define_schema do |schema|
119+
# schema.proto_external_types(
120+
# "CardType" => {
121+
# proto: "squareup.connect.v2.resources.Card.Type",
122+
# import: "squareup/connect/v2/resources/card.proto"
123+
# }
124+
# )
125+
# end
126+
def proto_external_types(proto_external_types)
127+
protobuf_state.proto_external_types = proto_external_types
128+
nil
129+
end
130+
111131
# Configures proto field-number mappings directly from a hash.
112132
# Useful for tests and advanced use cases where mappings are sourced outside artifacts.
113133
# When artifacts are dumped, mappings from the existing `proto_field_numbers.yaml` artifact

elasticgraph-proto_ingestion/lib/elastic_graph/proto_ingestion/schema_definition/identifier.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ def message_name(name)
5959
# @return [String]
6060
alias_method :enum_value_name, :message_name
6161

62+
# Builds a reference to an externally-defined protobuf type. Unlike generated local
63+
# identifiers, this preserves dotted fully-qualified names verbatim.
64+
#
65+
# @param name [#to_s]
66+
# @return [String]
67+
def external_type_name(name)
68+
name.to_s
69+
end
70+
6271
# Escapes protobuf reserved keywords by suffixing them with an underscore.
6372
#
6473
# @param identifier [String]

elasticgraph-proto_ingestion/lib/elastic_graph/proto_ingestion/schema_definition/results_extension.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def protobuf_schema_generator
5252
state: state,
5353
package_name: state.proto_schema_package_name,
5454
proto_enums_by_graphql_enum: state.proto_enums_by_graphql_enum,
55+
proto_external_types: state.proto_external_types,
5556
proto_field_number_mappings: state.proto_field_number_mappings,
5657
syntax: state.proto_schema_syntax,
5758
headers: state.proto_schema_headers

elasticgraph-proto_ingestion/lib/elastic_graph/proto_ingestion/schema_definition/schema.rb

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,29 @@ class Schema
6060
# @!attribute [r] name_in_index
6161
# @return [String]
6262
FieldNumberMapping = ::Data.define(:field_number, :name_in_index)
63+
# Internal representation of an externally-defined protobuf type.
64+
#
65+
# @!attribute [r] fqn
66+
# @return [String]
67+
# @!attribute [r] import
68+
# @return [String]
69+
ExternalTypeDefinition = ::Data.define(:fqn, :import)
6370

6471
# Protobuf syntaxes this generator can emit.
6572
SUPPORTED_SYNTAXES = %w[proto2 proto3].freeze
6673

6774
# @param state [ElasticGraph::SchemaDefinition::State]
6875
# @param package_name [String]
6976
# @param proto_enums_by_graphql_enum [Hash]
77+
# @param proto_external_types [Hash]
7078
# @param proto_field_number_mappings [Hash]
7179
# @param syntax [Symbol, String] `:proto3` (default) or `:proto2`; validated by {APIExtension#proto_schema_artifacts}
7280
# @param headers [Array<String>] file-level header lines (e.g. `option` declarations) rendered verbatim
7381
def initialize(
7482
state:,
7583
package_name:,
7684
proto_enums_by_graphql_enum:,
85+
proto_external_types: {},
7786
proto_field_number_mappings: {},
7887
syntax: :proto3,
7988
headers: []
@@ -83,10 +92,13 @@ def initialize(
8392
@state = state
8493
@package_name = Identifier.package_name(package_name)
8594
@proto_enums_by_graphql_enum = normalize_proto_enum_mappings(proto_enums_by_graphql_enum)
95+
@proto_external_types_by_type_name = normalize_proto_external_types(proto_external_types)
8696
@proto_field_number_mappings_by_message = normalize_proto_field_number_mappings(proto_field_number_mappings)
8797
@proto_enum_value_numbers_by_enum = normalize_proto_enum_value_number_mappings(proto_field_number_mappings)
8898
@used_field_numbers_by_message = {}
8999
@used_enum_value_numbers_by_enum = {}
100+
@imports = ::Set.new
101+
@registered_external_type_names = ::Set.new
90102
@message_definitions_by_name = {}
91103
@enum_definitions_by_name = {}
92104
@generated_message_definitions_by_name = {}
@@ -108,8 +120,9 @@ def to_proto
108120
%(syntax = "#{@syntax}";),
109121
"package #{@package_name};",
110122
*render_headers,
123+
*render_imports,
111124
render_definitions
112-
]
125+
].reject(&:empty?)
113126

114127
sections.join("\n\n") + "\n"
115128
end
@@ -156,6 +169,11 @@ def indexed_types
156169

157170
# Registers the type's proto definition (if it needs one) and returns its proto field type name.
158171
def register_type(type)
172+
if (external_type = @proto_external_types_by_type_name[type.name.to_s])
173+
register_external_type(type, external_type)
174+
return external_type.fqn
175+
end
176+
159177
case type
160178
when SchemaElements::EnumTypeExtension
161179
register_enum(type)
@@ -171,6 +189,55 @@ def register_type(type)
171189
type.to_proto_field_type
172190
end
173191

192+
def register_external_type(type, external_type)
193+
type_name = type.name.to_s
194+
195+
case type
196+
when SchemaElements::EnumTypeExtension
197+
unless @registered_external_type_names.include?(type_name)
198+
validate_external_enum_type(type)
199+
@registered_external_type_names << type_name
200+
end
201+
202+
@imports << external_type.import
203+
else
204+
raise Errors::SchemaError, "External proto type `#{type.name}` cannot be referenced yet. " \
205+
"Only enum types are supported by `proto_external_types` in this release."
206+
end
207+
end
208+
209+
def validate_external_enum_type(enum_type)
210+
enum_type_name = enum_type.name.to_s
211+
mapping_entries = @proto_enums_by_graphql_enum[enum_type_name]
212+
if mapping_entries.nil? || mapping_entries.empty?
213+
raise Errors::SchemaError, "External proto enum `#{enum_type_name}` must also configure " \
214+
"`proto_enum_mappings` with exactly one untransformed source so its values can be verified."
215+
end
216+
217+
unless mapping_entries.size == 1
218+
raise Errors::SchemaError, "External proto enum `#{enum_type_name}` must use exactly one " \
219+
"`proto_enum_mappings` source; multi-source enum mappings cannot be safely referenced externally."
220+
end
221+
222+
proto_type, options = mapping_entries.first
223+
options_are_empty = options.nil? || (options.is_a?(Hash) && options.empty?)
224+
unless options_are_empty
225+
raise Errors::SchemaError, "External proto enum `#{enum_type_name}` must use an empty " \
226+
"`proto_enum_mappings` options hash; transformed, excluded, or extra values must stay generated locally."
227+
end
228+
229+
proto_value_names = enum_value_names_from_proto_mapping(
230+
enum_type_name: enum_type_name,
231+
proto_type: proto_type,
232+
options: {}
233+
).uniq.sort
234+
eg_value_names = enum_type.values_by_name.keys.map(&:to_s).uniq.sort
235+
return if proto_value_names == eg_value_names
236+
237+
raise Errors::SchemaError, "External proto enum `#{enum_type_name}` values do not match the ElasticGraph enum values. " \
238+
"External values: #{proto_value_names.join(", ")}. ElasticGraph values: #{eg_value_names.join(", ")}."
239+
end
240+
174241
def register_message(type)
175242
message_name = Identifier.message_name(type.name)
176243
check_message_name_collision(message_name, type.name)
@@ -464,6 +531,10 @@ def name_taken?(name)
464531
@enum_definitions_by_name.key?(name)
465532
end
466533

534+
def render_imports
535+
@imports.sort.map { |import| "import \"#{import}\";" }
536+
end
537+
467538
# Renders the custom header lines as a single contiguous section (so they are not
468539
# blank-line separated). Returns `[]` when no headers were configured.
469540
def render_headers
@@ -579,6 +650,46 @@ def normalize_proto_enum_mappings(raw_mappings)
579650
normalized
580651
end
581652

653+
def normalize_proto_external_types(raw_mappings)
654+
normalized = {} # : ::Hash[::String, ExternalTypeDefinition]
655+
return normalized if raw_mappings.nil?
656+
657+
unless raw_mappings.is_a?(Hash)
658+
raise Errors::SchemaError, "External proto type mappings must be a Hash, got: #{raw_mappings.class}."
659+
end
660+
661+
raw_mappings.each do |type_name, mapping|
662+
unless mapping.is_a?(Hash)
663+
raise Errors::SchemaError, "External proto type mapping for `#{type_name}` must be a Hash."
664+
end
665+
666+
proto_type_name = fetch_external_type_mapping_value(type_name, mapping, :proto)
667+
import = fetch_external_type_mapping_value(type_name, mapping, :import)
668+
669+
normalized[type_name.to_s] = ExternalTypeDefinition.new(
670+
fqn: Identifier.external_type_name(proto_type_name),
671+
import: import
672+
)
673+
end
674+
675+
normalized
676+
end
677+
678+
def fetch_external_type_mapping_value(type_name, mapping, key)
679+
value =
680+
if mapping.key?(key)
681+
mapping.fetch(key)
682+
elsif mapping.key?(key.to_s)
683+
mapping.fetch(key.to_s)
684+
end
685+
686+
if value.is_a?(String) && !value.empty?
687+
value
688+
else
689+
raise Errors::SchemaError, "External proto type mapping for `#{type_name}` must include a non-empty `#{key}` String."
690+
end
691+
end
692+
582693
def normalize_proto_field_number_mappings(raw_mappings)
583694
return {} if raw_mappings.nil?
584695
unless raw_mappings.is_a?(Hash)

elasticgraph-proto_ingestion/lib/elastic_graph/proto_ingestion/schema_definition/state_extension.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,17 @@ module SchemaDefinition
1515
module StateExtension
1616
# @dynamic proto_schema_package_name, proto_schema_package_name=
1717
# @dynamic proto_enums_by_graphql_enum, proto_enums_by_graphql_enum=
18+
# @dynamic proto_external_types, proto_external_types=
1819
# @dynamic proto_field_number_mappings, proto_field_number_mappings=
1920
# @dynamic proto_schema_syntax, proto_schema_syntax=
2021
# @dynamic proto_schema_headers, proto_schema_headers=
21-
attr_accessor :proto_schema_package_name, :proto_enums_by_graphql_enum,
22+
attr_accessor :proto_schema_package_name, :proto_enums_by_graphql_enum, :proto_external_types,
2223
:proto_field_number_mappings, :proto_schema_syntax, :proto_schema_headers
2324

2425
def self.extended(state)
2526
state.proto_schema_package_name = "elasticgraph"
2627
state.proto_enums_by_graphql_enum = {}
28+
state.proto_external_types = {}
2729
state.proto_field_number_mappings = {}
2830
state.proto_schema_syntax = :proto3
2931
state.proto_schema_headers = []

elasticgraph-proto_ingestion/sig/elastic_graph/proto_ingestion/schema_definition/api_extension.rbs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module ElasticGraph
77
def self.extended: (::ElasticGraph::SchemaDefinition::API & APIExtension) -> void
88
def proto_schema_artifacts: (?package_name: ::String, ?syntax: ::Symbol, ?headers: ::Array[::String]) -> void
99
def proto_enum_mappings: (untyped) -> void
10+
def proto_external_types: (untyped) -> void
1011
def configure_proto_field_number_mappings: (untyped) -> void
1112

1213
private

elasticgraph-proto_ingestion/sig/elastic_graph/proto_ingestion/schema_definition/identifier.rbs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ module ElasticGraph
1010
def self.enum_name: (::String) -> ::String
1111
def self.field_name: (::String) -> ::String
1212
def self.enum_value_name: (::String) -> ::String
13+
def self.external_type_name: (::String) -> ::String
1314
def self.escape_keyword: (::String) -> ::String
1415
end
1516
end

elasticgraph-proto_ingestion/sig/elastic_graph/proto_ingestion/schema_definition/schema.rbs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ module ElasticGraph
5151
def self.new: (field_number: ::Integer, name_in_index: ::String) -> instance
5252
end
5353

54+
class ExternalTypeDefinition
55+
attr_reader fqn: ::String
56+
attr_reader import: ::String
57+
58+
def self.new: (fqn: ::String, import: ::String) -> instance
59+
end
60+
5461
type fieldNumberMappingsByFieldName = ::Hash[::String, FieldNumberMapping]
5562

5663
SUPPORTED_SYNTAXES: ::Array[::String]
@@ -60,10 +67,13 @@ module ElasticGraph
6067
@state: ::ElasticGraph::SchemaDefinition::State
6168
@package_name: ::String
6269
@proto_enums_by_graphql_enum: ::Hash[::String, untyped]
70+
@proto_external_types_by_type_name: ::Hash[::String, ExternalTypeDefinition]
6371
@proto_field_number_mappings_by_message: ::Hash[::String, fieldNumberMappingsByFieldName]
6472
@proto_enum_value_numbers_by_enum: ::Hash[::String, ::Hash[::String, ::Integer]]
6573
@used_field_numbers_by_message: ::Hash[::String, ::Set[::Integer]]
6674
@used_enum_value_numbers_by_enum: ::Hash[::String, ::Set[::Integer]]
75+
@imports: ::Set[::String]
76+
@registered_external_type_names: ::Set[::String]
6777
@message_definitions_by_name: ::Hash[::String, MessageDefinition]
6878
@enum_definitions_by_name: ::Hash[::String, EnumDefinition]
6979
@generated_message_definitions_by_name: ::Hash[::String, MessageDefinition]
@@ -76,6 +86,7 @@ module ElasticGraph
7686
state: ::ElasticGraph::SchemaDefinition::State,
7787
package_name: ::String,
7888
proto_enums_by_graphql_enum: untyped,
89+
?proto_external_types: untyped,
7990
?proto_field_number_mappings: untyped,
8091
?syntax: (::Symbol | ::String),
8192
?headers: ::Array[::String]
@@ -88,6 +99,8 @@ module ElasticGraph
8899

89100
def indexed_types: () -> ::Array[untyped]
90101
def register_type: (untyped type) -> ::String
102+
def register_external_type: (untyped type, ExternalTypeDefinition) -> void
103+
def validate_external_enum_type: (untyped enum_type) -> void
91104
def register_message: (untyped type) -> void
92105
def register_enum: (untyped enum_type) -> void
93106
def enum_value_names_for: (untyped enum_type) -> ::Array[::String]
@@ -125,6 +138,7 @@ module ElasticGraph
125138
) -> ::String
126139
def unique_generated_message_name: (::String) -> ::String
127140
def name_taken?: (::String) -> bool
141+
def render_imports: () -> ::Array[::String]
128142
def render_headers: () -> ::Array[::String]
129143
def render_definitions: () -> ::String
130144
def proto_enum_value_name: (::String, ::String) -> ::String
@@ -139,6 +153,8 @@ module ElasticGraph
139153
def check_message_name_collision: (::String, ::String) -> void
140154
def check_enum_name_collision: (::String, ::String) -> void
141155
def normalize_proto_enum_mappings: (untyped) -> ::Hash[::String, untyped]
156+
def normalize_proto_external_types: (untyped) -> ::Hash[::String, ExternalTypeDefinition]
157+
def fetch_external_type_mapping_value: (::String | ::Symbol, ::Hash[untyped, untyped], ::Symbol) -> ::String
142158
def normalize_proto_field_number_mappings: (untyped) -> ::Hash[::String, fieldNumberMappingsByFieldName]
143159
def normalize_proto_enum_value_number_mappings: (untyped) -> ::Hash[::String, ::Hash[::String, ::Integer]]
144160
def normalize_field_number_mapping_entry: (::String, ::String, untyped) -> [::Integer, ::String]

elasticgraph-proto_ingestion/sig/elastic_graph/proto_ingestion/schema_definition/state_extension.rbs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module ElasticGraph
44
module StateExtension: ::ElasticGraph::SchemaDefinition::State
55
attr_accessor proto_schema_package_name: ::String
66
attr_accessor proto_enums_by_graphql_enum: untyped
7+
attr_accessor proto_external_types: untyped
78
attr_accessor proto_field_number_mappings: untyped
89
attr_accessor proto_schema_syntax: (::Symbol | ::String)
910
attr_accessor proto_schema_headers: ::Array[::String]

0 commit comments

Comments
 (0)