Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions elasticgraph-protobuf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,36 @@ end
When a mapping exists for an enum, `elasticgraph-protobuf` uses the mapped proto enum(s)
as the source of enum values (respecting `exclusions`, `expected_extras`, and `name_transform`).

### Referencing Existing Protobuf Types

For enums that exactly match a canonical proto enum, you can import and reference
the existing proto type instead of generating a duplicate local enum:

```ruby
# in config/schema/protobuf.rb

ElasticGraph.define_schema do |schema|
if defined?(Squareup::Connect::V2::Resources::Card::Type)
schema.proto_enum_mappings(
"CardType" => {
Squareup::Connect::V2::Resources::Card::Type => {}
}
)

schema.proto_external_types(
"CardType" => {
proto: "squareup.connect.v2.resources.Card.Type",
import: "squareup/connect/v2/resources/card.proto"
}
)
end
end
```

External type references currently support enums only. The matching
`proto_enum_mappings` entry must have exactly one source and no transform options;
otherwise the enum stays generated locally so value curation remains explicit.

### Stable Field Numbers

`schema_artifacts:dump` automatically reads and writes `proto_field_numbers.yaml`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,26 @@ def proto_enum_mappings(proto_enums_by_graphql_enum)
nil
end

# Registers GraphQL types that should be referenced from existing proto files instead of
# generated locally in `schema.proto`.
#
# @param proto_external_types [Hash] map of GraphQL type name to `proto` and `import` values
# @return [void]
#
# @example Reference an external enum type
# ElasticGraph.define_schema do |schema|
# schema.proto_external_types(
# "CardType" => {
# proto: "squareup.connect.v2.resources.Card.Type",
# import: "squareup/connect/v2/resources/card.proto"
# }
# )
# end
def proto_external_types(proto_external_types)
protobuf_state.proto_external_types = proto_external_types
nil
end

# Configures proto field-number mappings directly from a hash.
# Useful for tests and advanced use cases where mappings are sourced outside artifacts.
# When artifacts are dumped, mappings from the existing `proto_field_numbers.yaml` artifact
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ def self.enum_value_name(name)
escape_keyword(name.to_s)
end

# Builds a reference to an externally-defined protobuf type. Unlike generated local
# identifiers, this preserves dotted fully-qualified names verbatim.
#
# @param name [#to_s]
# @return [String]
def self.external_type_name(name)
name.to_s
end

# Escapes protobuf reserved keywords by suffixing them with an underscore.
#
# @param identifier [String]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def protobuf_schema_generator
state: state,
package_name: state.proto_schema_package_name,
proto_enums_by_graphql_enum: state.proto_enums_by_graphql_enum,
proto_external_types: state.proto_external_types,
proto_field_number_mappings: state.proto_field_number_mappings,
syntax: state.proto_schema_syntax,
headers: state.proto_schema_headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,29 @@ class Schema
# @!attribute [r] name_in_index
# @return [String]
FieldNumberMapping = ::Data.define(:field_number, :name_in_index)
# Internal representation of an externally-defined protobuf type.
#
# @!attribute [r] fqn
# @return [String]
# @!attribute [r] import
# @return [String]
ExternalTypeDefinition = ::Data.define(:fqn, :import)

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

# @param state [ElasticGraph::SchemaDefinition::State]
# @param package_name [String]
# @param proto_enums_by_graphql_enum [Hash]
# @param proto_external_types [Hash]
# @param proto_field_number_mappings [Hash]
# @param syntax [Symbol, String] `:proto3` (default) or `:proto2`; validated by {APIExtension#proto_schema_artifacts}
# @param headers [Array<String>] file-level header lines (e.g. `option` declarations) rendered verbatim
def initialize(
state:,
package_name:,
proto_enums_by_graphql_enum:,
proto_external_types: {},
proto_field_number_mappings: {},
syntax: :proto3,
headers: []
Expand All @@ -83,7 +92,10 @@ def initialize(
@state = state
@package_name = Identifier.package_name(package_name)
@proto_enums_by_graphql_enum = normalize_proto_enum_mappings(proto_enums_by_graphql_enum)
@proto_external_types_by_type_name = normalize_proto_external_types(proto_external_types)
@proto_field_number_mappings_by_message = normalize_proto_field_number_mappings(proto_field_number_mappings)
@imports = ::Set.new
@registered_external_type_names = ::Set.new
@message_definitions_by_name = {}
@enum_definitions_by_name = {}
@generated_message_definitions_by_name = {}
Expand All @@ -105,8 +117,9 @@ def to_proto
%(syntax = "#{@syntax}";),
"package #{@package_name};",
*render_headers,
*render_imports,
render_definitions
]
].reject(&:empty?)

sections.join("\n\n") + "\n"
end
Expand Down Expand Up @@ -146,6 +159,11 @@ def indexed_types

# Registers the type's proto definition (if it needs one) and returns its proto field type name.
def register_type(type)
if type.respond_to?(:name) && (external_type = @proto_external_types_by_type_name[type.name.to_s])
register_external_type(type, external_type)
return external_type.fqn
end

case type
when SchemaElements::EnumTypeExtension
register_enum(type)
Expand All @@ -161,6 +179,55 @@ def register_type(type)
type.to_proto_field_type
end

def register_external_type(type, external_type)
type_name = type.name.to_s

case type
when SchemaElements::EnumTypeExtension
unless @registered_external_type_names.include?(type_name)
validate_external_enum_type(type)
@registered_external_type_names << type_name
end

@imports << external_type.import
else
raise Errors::SchemaError, "External proto type `#{type.name}` cannot be referenced yet. " \
"Only enum types are supported by `proto_external_types` in this release."
end
end

def validate_external_enum_type(enum_type)
enum_type_name = enum_type.name.to_s
mapping_entries = @proto_enums_by_graphql_enum[enum_type_name]
if mapping_entries.nil? || mapping_entries.empty?
raise Errors::SchemaError, "External proto enum `#{enum_type_name}` must also configure " \
"`proto_enum_mappings` with exactly one untransformed source so its values can be verified."
end

unless mapping_entries.size == 1
raise Errors::SchemaError, "External proto enum `#{enum_type_name}` must use exactly one " \
"`proto_enum_mappings` source; multi-source enum mappings cannot be safely referenced externally."
end

proto_type, options = mapping_entries.first
options_are_empty = options.nil? || (options.is_a?(Hash) && options.empty?)
unless options_are_empty
raise Errors::SchemaError, "External proto enum `#{enum_type_name}` must use an empty " \
"`proto_enum_mappings` options hash; transformed, excluded, or extra values must stay generated locally."
end

proto_value_names = enum_value_names_from_proto_mapping(
enum_type_name: enum_type_name,
proto_type: proto_type,
options: {}
).uniq.sort
eg_value_names = enum_type.values_by_name.keys.map(&:to_s).uniq.sort
return if proto_value_names == eg_value_names

raise Errors::SchemaError, "External proto enum `#{enum_type_name}` values do not match the ElasticGraph enum values. " \
"External values: #{proto_value_names.join(", ")}. ElasticGraph values: #{eg_value_names.join(", ")}."
end

def register_message(type)
message_name = Identifier.message_name(type.name)
check_message_name_collision(message_name, type.name)
Expand Down Expand Up @@ -428,6 +495,10 @@ def name_taken?(name)
@enum_definitions_by_name.key?(name)
end

def render_imports
@imports.sort.map { |import| "import \"#{import}\";" }
end

# Renders the custom header lines as a single contiguous section (so they are not
# blank-line separated). Returns `[]` when no headers were configured.
def render_headers
Expand Down Expand Up @@ -543,6 +614,46 @@ def normalize_proto_enum_mappings(raw_mappings)
normalized
end

def normalize_proto_external_types(raw_mappings)
normalized = {} # : ::Hash[::String, ExternalTypeDefinition]
return normalized if raw_mappings.nil?

unless raw_mappings.is_a?(Hash)
raise Errors::SchemaError, "External proto type mappings must be a Hash, got: #{raw_mappings.class}."
end

raw_mappings.each do |type_name, mapping|
unless mapping.is_a?(Hash)
raise Errors::SchemaError, "External proto type mapping for `#{type_name}` must be a Hash."
end

proto_type_name = fetch_external_type_mapping_value(type_name, mapping, :proto)
import = fetch_external_type_mapping_value(type_name, mapping, :import)

normalized[type_name.to_s] = ExternalTypeDefinition.new(
fqn: Identifier.external_type_name(proto_type_name),
import: import
)
end

normalized
end

def fetch_external_type_mapping_value(type_name, mapping, key)
value =
if mapping.key?(key)
mapping.fetch(key)
elsif mapping.key?(key.to_s)
mapping.fetch(key.to_s)
end

if value.is_a?(String) && !value.empty?
value
else
raise Errors::SchemaError, "External proto type mapping for `#{type_name}` must include a non-empty `#{key}` String."
end
end

def normalize_proto_field_number_mappings(raw_mappings)
return {} if raw_mappings.nil?
unless raw_mappings.is_a?(Hash)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@ module SchemaDefinition
module StateExtension
# @dynamic proto_schema_package_name, proto_schema_package_name=
# @dynamic proto_enums_by_graphql_enum, proto_enums_by_graphql_enum=
# @dynamic proto_external_types, proto_external_types=
# @dynamic proto_field_number_mappings, proto_field_number_mappings=
# @dynamic proto_schema_syntax, proto_schema_syntax=
# @dynamic proto_schema_headers, proto_schema_headers=
attr_accessor :proto_schema_package_name, :proto_enums_by_graphql_enum,
attr_accessor :proto_schema_package_name, :proto_enums_by_graphql_enum, :proto_external_types,
:proto_field_number_mappings, :proto_schema_syntax, :proto_schema_headers

def self.extended(state)
state.proto_schema_package_name = "elasticgraph"
state.proto_enums_by_graphql_enum = {}
state.proto_external_types = {}
state.proto_field_number_mappings = {}
state.proto_schema_syntax = :proto3
state.proto_schema_headers = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module ElasticGraph
def self.extended: (::ElasticGraph::SchemaDefinition::API & APIExtension) -> void
def proto_schema_artifacts: (?package_name: ::String, ?syntax: ::Symbol, ?headers: ::Array[::String]) -> void
def proto_enum_mappings: (untyped) -> void
def proto_external_types: (untyped) -> void
def configure_proto_field_number_mappings: (untyped) -> void

private
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ module ElasticGraph
def self.enum_name: (::String) -> ::String
def self.field_name: (::String) -> ::String
def self.enum_value_name: (::String) -> ::String
def self.external_type_name: (::String) -> ::String
def self.escape_keyword: (::String) -> ::String
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ module ElasticGraph
def self.new: (field_number: ::Integer, name_in_index: ::String) -> instance
end

class ExternalTypeDefinition
attr_reader fqn: ::String
attr_reader import: ::String

def self.new: (fqn: ::String, import: ::String) -> instance
end

type fieldNumberMappingsByFieldName = ::Hash[::String, FieldNumberMapping]

SUPPORTED_SYNTAXES: ::Array[::String]
Expand All @@ -60,7 +67,10 @@ module ElasticGraph
@state: ::ElasticGraph::SchemaDefinition::State
@package_name: ::String
@proto_enums_by_graphql_enum: ::Hash[::String, untyped]
@proto_external_types_by_type_name: ::Hash[::String, ExternalTypeDefinition]
@proto_field_number_mappings_by_message: ::Hash[::String, fieldNumberMappingsByFieldName]
@imports: ::Set[::String]
@registered_external_type_names: ::Set[::String]
@message_definitions_by_name: ::Hash[::String, MessageDefinition]
@enum_definitions_by_name: ::Hash[::String, EnumDefinition]
@generated_message_definitions_by_name: ::Hash[::String, MessageDefinition]
Expand All @@ -73,6 +83,7 @@ module ElasticGraph
state: ::ElasticGraph::SchemaDefinition::State,
package_name: ::String,
proto_enums_by_graphql_enum: untyped,
?proto_external_types: untyped,
?proto_field_number_mappings: untyped,
?syntax: (::Symbol | ::String),
?headers: ::Array[::String]
Expand All @@ -85,6 +96,8 @@ module ElasticGraph

def indexed_types: () -> ::Array[untyped]
def register_type: (untyped type) -> ::String
def register_external_type: (untyped type, ExternalTypeDefinition) -> void
def validate_external_enum_type: (untyped enum_type) -> void
def register_message: (untyped type) -> void
def register_enum: (untyped enum_type) -> void
def enum_value_names_for: (untyped enum_type) -> ::Array[::String]
Expand Down Expand Up @@ -121,6 +134,7 @@ module ElasticGraph
) -> ::String
def unique_generated_message_name: (::String) -> ::String
def name_taken?: (::String) -> bool
def render_imports: () -> ::Array[::String]
def render_headers: () -> ::Array[::String]
def render_definitions: () -> ::String
def proto_enum_value_name: (::String, ::String) -> ::String
Expand All @@ -135,6 +149,8 @@ module ElasticGraph
def check_message_name_collision: (::String, ::String) -> void
def check_enum_name_collision: (::String, ::String) -> void
def normalize_proto_enum_mappings: (untyped) -> ::Hash[::String, untyped]
def normalize_proto_external_types: (untyped) -> ::Hash[::String, ExternalTypeDefinition]
def fetch_external_type_mapping_value: (::String | ::Symbol, ::Hash[untyped, untyped], ::Symbol) -> ::String
def normalize_proto_field_number_mappings: (untyped) -> ::Hash[::String, fieldNumberMappingsByFieldName]
def normalize_field_number_mapping_entry: (::String, ::String, untyped) -> [::Integer, ::String]
def renamed_public_field_names_by_type_name: () -> ::Hash[::String, ::Hash[::String, ::Array[::String]]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module ElasticGraph
module StateExtension: ::ElasticGraph::SchemaDefinition::State
attr_accessor proto_schema_package_name: ::String
attr_accessor proto_enums_by_graphql_enum: untyped
attr_accessor proto_external_types: untyped
attr_accessor proto_field_number_mappings: untyped
attr_accessor proto_schema_syntax: (::Symbol | ::String)
attr_accessor proto_schema_headers: ::Array[::String]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ module SchemaDefinition
expect(Identifier.field_name("string")).to eq("string_")
expect(Identifier.enum_value_name("stream")).to eq("stream_")
end

it "preserves external dotted type names" do
expect(Identifier.external_type_name("squareup.connect.v2.resources.Card.Type"))
.to eq("squareup.connect.v2.resources.Card.Type")
end
end
end
end
Expand Down
Loading