Skip to content

Commit 98fac03

Browse files
committed
Support proto2 syntax and custom proto file headers
`proto_schema_artifacts` gains two options: - `syntax: :proto2` emits a proto2 file instead of the default proto3 (labeling every field `optional` or `repeated`). This is useful when the generated messages need to reference proto2 types — `protoc` forbids a proto3 message from referencing a proto2 enum. - `headers:` injects file-level lines (such as `option` declarations) verbatim as a contiguous section after the `package` declaration, so language-specific options can be set without baking any particular convention into the gem.
1 parent 1537869 commit 98fac03

10 files changed

Lines changed: 211 additions & 12 deletions

File tree

elasticgraph-proto_ingestion/README.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# ElasticGraph::ProtoIngestion
22

33
An ElasticGraph extension that supports ingesting Protocol Buffer data into ElasticGraph.
4-
Currently it generates `proto3` Protocol Buffers schema artifacts from ElasticGraph schemas.
4+
Currently it generates Protocol Buffers schema artifacts from ElasticGraph schemas: it emits
5+
`proto3` by default and can emit `proto2`, and supports arbitrary file-level headers (such
6+
as `option` declarations).
57

68
## Dependency Diagram
79

@@ -78,6 +80,54 @@ After running `bundle exec rake schema_artifacts:dump`, ElasticGraph will genera
7880

7981
## Schema Definition Options
8082

83+
### Protobuf Syntax (`proto2` / `proto3`)
84+
85+
`proto_schema_artifacts` emits `proto3` by default. Pass `syntax: :proto2` to emit a proto2 file
86+
instead (every field is then labeled `optional` or `repeated`). This is useful when the generated
87+
messages need to reference proto2 types — for example, `protoc` forbids a `proto3` message from
88+
referencing a `proto2` enum:
89+
90+
```ruby
91+
# in config/schema/protobuf.rb
92+
93+
ElasticGraph.define_schema do |schema|
94+
schema.proto_schema_artifacts package_name: "myapp.events.v1", syntax: :proto2
95+
end
96+
```
97+
98+
### Custom Headers
99+
100+
Pass `headers:` an array of strings to inject file-level lines (such as `option` declarations)
101+
verbatim, as a contiguous section immediately after the `package` declaration. This lets you set
102+
language-specific options without the gem baking in any particular convention:
103+
104+
```ruby
105+
# in config/schema/protobuf.rb
106+
107+
ElasticGraph.define_schema do |schema|
108+
schema.proto_schema_artifacts(
109+
package_name: "myapp.events.v1",
110+
headers: [
111+
%(option java_package = "com.myapp.events";),
112+
"option java_multiple_files = true;"
113+
]
114+
)
115+
end
116+
```
117+
118+
produces:
119+
120+
```text
121+
syntax = "proto3";
122+
123+
package myapp.events.v1;
124+
125+
option java_package = "com.myapp.events";
126+
option java_multiple_files = true;
127+
128+
// ...messages...
129+
```
130+
81131
### Custom Scalar Types
82132

83133
Built-in ElasticGraph scalar types are automatically mapped to proto scalar types.

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,42 @@ def self.extended(api)
6060
# Configures protobuf artifact generation behavior.
6161
#
6262
# @param package_name [String] proto package name to emit
63+
# @param syntax [Symbol] `:proto3` (default) or `:proto2`
64+
# @param headers [Array<String>] file-level header lines (e.g. `option` declarations) rendered
65+
# verbatim after the `package` declaration
6366
# @return [void]
6467
#
6568
# @example Set the proto package name
6669
# ElasticGraph.define_schema do |schema|
6770
# schema.proto_schema_artifacts package_name: "myapp.events.v1"
6871
# end
69-
def proto_schema_artifacts(package_name: "elasticgraph")
72+
#
73+
# @example Emit proto2 with custom file-level options
74+
# ElasticGraph.define_schema do |schema|
75+
# schema.proto_schema_artifacts(
76+
# package_name: "myapp.events.v1",
77+
# syntax: :proto2,
78+
# headers: [
79+
# %(option java_package = "com.myapp.events";),
80+
# "option java_multiple_files = true;"
81+
# ]
82+
# )
83+
# end
84+
def proto_schema_artifacts(package_name: "elasticgraph", syntax: :proto3, headers: [])
7085
if !package_name.is_a?(String) || package_name.empty?
7186
raise Errors::SchemaError, "`package_name` must be a non-empty String"
7287
end
88+
unless Schema::SUPPORTED_SYNTAXES.include?(syntax.to_s)
89+
raise Errors::SchemaError, "`syntax` must be one of #{Schema::SUPPORTED_SYNTAXES.inspect}, got: #{syntax.inspect}"
90+
end
91+
if !headers.is_a?(Array) || headers.any? { |header| !header.is_a?(String) }
92+
raise Errors::SchemaError, "`headers` must be an Array of Strings"
93+
end
7394

74-
proto_ingestion_state.package_name = package_name
95+
ingestion_state = proto_ingestion_state
96+
ingestion_state.package_name = package_name
97+
ingestion_state.syntax = syntax
98+
ingestion_state.headers = headers
7599
nil
76100
end
77101

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@ module SchemaDefinition
1515
class ProtoIngestionState
1616
# @dynamic package_name, package_name=
1717
# @dynamic field_number_mappings, field_number_mappings=
18-
attr_accessor :package_name, :field_number_mappings
18+
# @dynamic syntax, syntax=
19+
# @dynamic headers, headers=
20+
attr_accessor :package_name, :field_number_mappings, :syntax, :headers
1921

2022
def initialize
2123
@package_name = "elasticgraph"
2224
@field_number_mappings = {}
25+
@syntax = :proto3
26+
@headers = []
2327
end
2428
end
2529
end

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ def protobuf_schema_generator
4747
Schema.new(
4848
state: extension_state,
4949
package_name: ingestion_state.package_name,
50-
proto_field_number_mappings: ingestion_state.field_number_mappings
50+
proto_field_number_mappings: ingestion_state.field_number_mappings,
51+
syntax: ingestion_state.syntax,
52+
headers: ingestion_state.headers
5153
)
5254
end
5355
end

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

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
module ElasticGraph
1616
module ProtoIngestion
1717
module SchemaDefinition
18-
# Builds a `proto3` schema string from an ElasticGraph schema definition.
18+
# Builds a `proto2` or `proto3` schema string from an ElasticGraph schema definition.
1919
class Schema
2020
# Internal representation of a protobuf field definition.
2121
#
@@ -61,14 +61,23 @@ class Schema
6161
# @return [String]
6262
FieldNumberMapping = ::Data.define(:field_number, :name_in_index)
6363

64+
# Protobuf syntaxes this generator can emit.
65+
SUPPORTED_SYNTAXES = %w[proto2 proto3].freeze
66+
6467
# @param state [ElasticGraph::SchemaDefinition::State]
6568
# @param package_name [String]
6669
# @param proto_field_number_mappings [Hash]
70+
# @param syntax [Symbol, String] `:proto3` (default) or `:proto2`; validated by {APIExtension#proto_schema_artifacts}
71+
# @param headers [Array<String>] file-level header lines (e.g. `option` declarations) rendered verbatim
6772
def initialize(
6873
state:,
6974
package_name:,
70-
proto_field_number_mappings: {}
75+
proto_field_number_mappings: {},
76+
syntax: :proto3,
77+
headers: []
7178
)
79+
@syntax = syntax.to_s
80+
@headers = headers
7281
@state = state
7382
@package_name = Identifier.package_name(package_name)
7483
@proto_field_number_mappings_by_message = normalize_proto_field_number_mappings(proto_field_number_mappings)
@@ -84,7 +93,7 @@ def initialize(
8493
@type_name_by_enum_name = {}
8594
end
8695

87-
# Renders the schema as a valid `proto3` file.
96+
# Renders the schema as a valid `proto2` or `proto3` file.
8897
#
8998
# @return [String]
9099
def to_proto
@@ -94,8 +103,9 @@ def to_proto
94103
root_types.each { |type| register_type(type) }
95104

96105
sections = [
97-
%(syntax = "proto3";),
106+
%(syntax = "#{@syntax}";),
98107
"package #{@package_name};",
108+
*render_headers,
99109
*render_imports,
100110
render_definitions
101111
]
@@ -418,6 +428,13 @@ def name_taken?(name)
418428
@enum_definitions_by_name.key?(name)
419429
end
420430

431+
# Renders the custom header lines as a single contiguous section (so they are not
432+
# blank-line separated). Returns `[]` when no headers were configured.
433+
def render_headers
434+
return [] if @headers.empty?
435+
[@headers.join("\n")]
436+
end
437+
421438
# Renders `import` statements for referenced external proto files as a single contiguous
422439
# section. Returns `[]` when no external types are referenced.
423440
def render_imports
@@ -464,7 +481,13 @@ def render_message(message_definition)
464481
lines << " // No indexed fields were defined for this type."
465482
else
466483
message_definition.fields.each do |field|
467-
label = field.repeated ? "repeated " : ""
484+
# proto2 requires an explicit label on every field; proto3 only uses `repeated`.
485+
label =
486+
if @syntax == "proto2"
487+
field.repeated ? "repeated " : "optional "
488+
else
489+
field.repeated ? "repeated " : ""
490+
end
468491
line = " #{label}#{field.type} #{field.name} = #{field.field_number};"
469492
line += " // #{field.comment}" if field.comment
470493
lines << line

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module ElasticGraph
55
PROTO_OPTIONS_BY_BUILT_IN_SCALAR_TYPE: ::Hash[::String, ::Hash[::Symbol, ::String]]
66

77
def self.extended: (::ElasticGraph::SchemaDefinition::API & APIExtension) -> void
8-
def proto_schema_artifacts: (?package_name: ::String) -> void
8+
def proto_schema_artifacts: (?package_name: ::String, ?syntax: ::Symbol, ?headers: ::Array[::String]) -> void
99
def configure_proto_field_number_mappings: (untyped) -> void
1010

1111
private

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ module ElasticGraph
44
class ProtoIngestionState
55
attr_accessor package_name: ::String
66
attr_accessor field_number_mappings: untyped
7+
attr_accessor syntax: (::Symbol | ::String)
8+
attr_accessor headers: ::Array[::String]
79

810
def initialize: () -> void
911
end

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ module ElasticGraph
5353

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

56+
SUPPORTED_SYNTAXES: ::Array[::String]
57+
58+
@syntax: ::String
59+
@headers: ::Array[::String]
5660
@state: ::ElasticGraph::SchemaDefinition::State
5761
@package_name: ::String
5862
@proto_field_number_mappings_by_message: ::Hash[::String, fieldNumberMappingsByFieldName]
@@ -71,7 +75,9 @@ module ElasticGraph
7175
def initialize: (
7276
state: ::ElasticGraph::SchemaDefinition::State,
7377
package_name: ::String,
74-
?proto_field_number_mappings: untyped
78+
?proto_field_number_mappings: untyped,
79+
?syntax: (::Symbol | ::String),
80+
?headers: ::Array[::String]
7581
) -> void
7682

7783
def to_proto: () -> ::String
@@ -113,6 +119,7 @@ module ElasticGraph
113119
) -> ::String
114120
def unique_generated_message_name: (::String) -> ::String
115121
def name_taken?: (::String) -> bool
122+
def render_headers: () -> ::Array[::String]
116123
def render_imports: () -> ::Array[::String]
117124
def render_definitions: () -> ::String
118125
def proto_enum_value_name: (::String, ::String) -> ::String

elasticgraph-proto_ingestion/spec/unit/elastic_graph/proto_ingestion/schema_definition/api_extension_spec.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,28 @@ module SchemaDefinition
5252
end
5353
}.to raise_error(Errors::SchemaError, a_string_including("`package_name` must be a non-empty String"))
5454
end
55+
56+
it "requires `syntax` to be a supported protobuf syntax" do
57+
expect {
58+
define_proto_schema do |s|
59+
s.proto_schema_artifacts syntax: :proto1
60+
end
61+
}.to raise_error(Errors::SchemaError, a_string_including("`syntax` must be one of"))
62+
end
63+
64+
it "requires `headers` to be an Array of Strings" do
65+
expect {
66+
define_proto_schema do |s|
67+
s.proto_schema_artifacts headers: %(option java_package = "com.example";)
68+
end
69+
}.to raise_error(Errors::SchemaError, a_string_including("`headers` must be an Array of Strings"))
70+
71+
expect {
72+
define_proto_schema do |s|
73+
s.proto_schema_artifacts headers: [:not_a_string]
74+
end
75+
}.to raise_error(Errors::SchemaError, a_string_including("`headers` must be an Array of Strings"))
76+
end
5577
end
5678
end
5779
end

elasticgraph-proto_ingestion/spec/unit/elastic_graph/proto_ingestion/schema_definition/schema_spec.rb

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,71 @@ module SchemaDefinition
5757
PROTO
5858
end
5959

60+
it "emits proto2 syntax with an explicit label on every field when `syntax: :proto2`" do
61+
results = define_proto_schema do |s|
62+
s.proto_schema_artifacts syntax: :proto2
63+
64+
s.enum_type "Status" do |t|
65+
t.values "ACTIVE", "INACTIVE"
66+
end
67+
68+
s.object_type "Account" do |t|
69+
t.field "id", "ID"
70+
t.field "status", "Status"
71+
t.field "tags", "[String!]!"
72+
t.index "accounts"
73+
end
74+
end
75+
76+
expect(proto_schema_from(results)).to eq(<<~PROTO)
77+
syntax = "proto2";
78+
79+
package elasticgraph;
80+
81+
enum Status {
82+
STATUS_UNSPECIFIED = 0;
83+
STATUS_ACTIVE = 1;
84+
STATUS_INACTIVE = 2;
85+
}
86+
87+
message Account {
88+
optional string id = 1;
89+
optional Status status = 2;
90+
repeated string tags = 3;
91+
}
92+
PROTO
93+
end
94+
95+
it "renders custom `headers` verbatim as a contiguous section after the package declaration" do
96+
results = define_proto_schema do |s|
97+
s.proto_schema_artifacts(
98+
package_name: "myapp.events.v1",
99+
headers: [
100+
%(option java_package = "com.myapp.events";),
101+
"option java_multiple_files = true;"
102+
]
103+
)
104+
105+
s.object_type "Account" do |t|
106+
t.field "id", "ID"
107+
t.index "accounts"
108+
end
109+
end
110+
111+
expect(proto_schema_from(results)).to eq(<<~PROTO)
112+
syntax = "proto3";
113+
114+
package myapp.events.v1;
115+
116+
option java_package = "com.myapp.events";
117+
option java_multiple_files = true;
118+
119+
message Account {
120+
string id = 1;
121+
}
122+
PROTO
123+
end
124+
60125
it "generates a single message for an indexed abstract type, covering its subtypes' fields" do
61126
results = define_proto_schema do |s|
62127
s.object_type "Car" do |t|

0 commit comments

Comments
 (0)