Skip to content

Commit 653a94f

Browse files
committed
Add elasticgraph-proto_ingestion schema artifacts
Fills in the `elasticgraph-proto_ingestion` gem with the Protocol Buffers schema artifact generation logic. Running `schema_artifacts:dump` emits: - `schema.proto` — the generated Protobuf schema for the indexed types - `proto_field_numbers.yaml` — a sidecar that reserves field numbers and enum value numbers so they stay wire-stable as the schema evolves (including across field renames, and reserving removed values so numbers are not reused) Capabilities: - Maps built-in ElasticGraph scalars to proto types, with `proto_field` to configure custom scalars. - Generates messages for object/interface/union types and enums (with a zero-valued `*_UNSPECIFIED` entry), escaping proto reserved words and wrapping nested lists so the output stays valid. - `proto_enum_mappings` reuses enum values already maintained elsewhere. The generator emits `proto3` by default and can emit `proto2` via `syntax: :proto2` (which labels every field `optional`/`repeated`). Arbitrary file-level headers (e.g. `option` declarations) can be injected verbatim via `headers:`, so language-specific options can be set without baking any particular convention into the gem.
1 parent cc66e8b commit 653a94f

33 files changed

Lines changed: 3785 additions & 38 deletions

elasticgraph-proto_ingestion/README.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# ElasticGraph::ProtoIngestion
22

33
An ElasticGraph extension that supports ingesting Protocol Buffer data into ElasticGraph.
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).
47

58
## Dependency Diagram
69

@@ -15,3 +18,209 @@ graph LR;
1518
elasticgraph-proto_ingestion --> elasticgraph-support;
1619
class elasticgraph-support otherEgGemStyle;
1720
```
21+
22+
## Usage
23+
24+
First, add `elasticgraph-proto_ingestion` to your `Gemfile`, alongside the other ElasticGraph gems:
25+
26+
```diff
27+
diff --git a/Gemfile b/Gemfile
28+
index 4a5ef1e..5c16c2b 100644
29+
--- a/Gemfile
30+
+++ b/Gemfile
31+
@@ -8,6 +8,7 @@ gem "elasticgraph-query_registry", *elasticgraph_details
32+
33+
# Can be elasticgraph-elasticsearch or elasticgraph-opensearch based on the datastore you want to use.
34+
gem "elasticgraph-opensearch", *elasticgraph_details
35+
+gem "elasticgraph-proto_ingestion", *elasticgraph_details
36+
37+
gem "httpx", "~> 1.3"
38+
39+
```
40+
41+
Next, update your `Rakefile` so that `ElasticGraph::ProtoIngestion::SchemaDefinition::APIExtension` is
42+
included in the schema-definition extension modules:
43+
44+
```diff
45+
diff --git a/Rakefile b/Rakefile
46+
index 2943335..26633c3 100644
47+
--- a/Rakefile
48+
+++ b/Rakefile
49+
@@ -3,5 +3,6 @@
50+
require "elastic_graph/json_ingestion/schema_definition/api_extension"
51+
require "elastic_graph/local/rake_tasks"
52+
+require "elastic_graph/proto_ingestion/schema_definition/api_extension"
53+
require "elastic_graph/query_registry/rake_tasks"
54+
require "rspec/core/rake_task"
55+
require "standard/rake"
56+
@@ -16,6 +17,7 @@ ElasticGraph::Local::RakeTasks.new(
57+
# Determines casing of field names. Can be either `:camelCase` or `:snake_case`.
58+
tasks.schema_element_name_form = :camelCase
59+
tasks.schema_definition_extension_modules << ElasticGraph::JSONIngestion::SchemaDefinition::APIExtension
60+
+ tasks.schema_definition_extension_modules << ElasticGraph::ProtoIngestion::SchemaDefinition::APIExtension
61+
62+
# Customizes the names of fields generated by ElasticGraph.
63+
tasks.schema_element_name_overrides = {
64+
```
65+
66+
Then opt into proto generation from your schema definition:
67+
68+
```ruby
69+
# in config/schema/protobuf.rb
70+
71+
ElasticGraph.define_schema do |schema|
72+
schema.proto_schema_artifacts package_name: "myapp.events.v1"
73+
end
74+
```
75+
76+
After running `bundle exec rake schema_artifacts:dump`, ElasticGraph will generate:
77+
78+
- `schema.proto`
79+
- `proto_field_numbers.yaml`
80+
81+
## Schema Definition Options
82+
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+
131+
### Custom Scalar Types
132+
133+
Built-in ElasticGraph scalar types are automatically mapped to proto scalar types.
134+
For custom scalar types, use `proto_field` to define the proto scalar type:
135+
136+
```ruby
137+
# in config/schema/money.rb
138+
139+
ElasticGraph.define_schema do |schema|
140+
schema.scalar_type "Money" do |t|
141+
t.mapping type: "long"
142+
t.json_schema type: "integer"
143+
t.proto_field type: "int64"
144+
end
145+
end
146+
```
147+
148+
### Sourcing Enum Values From Existing Protobuf Mappings
149+
150+
If your project already maintains GraphQL-to-proto enum mappings (for example in tests),
151+
you can reuse them for proto schema generation:
152+
153+
```ruby
154+
# in config/schema/proto_enum_mappings.rb
155+
156+
ElasticGraph.define_schema do |schema|
157+
schema.proto_enum_mappings(
158+
SalesEg::ProtoEnumMappings::PROTO_ENUMS_BY_GRAPHQL_ENUM
159+
) if defined?(SalesEg::ProtoEnumMappings)
160+
end
161+
```
162+
163+
When a mapping exists for an enum, `elasticgraph-proto_ingestion` uses the mapped proto enum(s)
164+
as the source of enum values (respecting `exclusions`, `expected_extras`, and `name_transform`).
165+
166+
### Stable Field Numbers
167+
168+
`schema_artifacts:dump` automatically reads and writes `proto_field_numbers.yaml`
169+
in the schema artifacts directory. Existing numbers stay fixed even if field order
170+
changes, and new fields get the next available numbers.
171+
172+
`schema.proto` always uses the public GraphQL field names. When a field uses a
173+
different `name_in_index`, the sidecar YAML stores that override privately:
174+
175+
```yaml
176+
messages:
177+
Widget:
178+
fields:
179+
id: 1
180+
display_name:
181+
field_number: 2
182+
name_in_index: displayName
183+
```
184+
185+
If a field is renamed with `field.renamed_from`, `elasticgraph-proto_ingestion` reuses the
186+
existing field number under the new public field name.
187+
188+
### Stable Enum Value Numbers
189+
190+
Enum value numbers are pinned the same way, in an `enums` section of the sidecar. Existing
191+
values keep their numbers when other values are added or removed, new values get the next
192+
available numbers, and removed values keep their numbers reserved so they are never reused
193+
(number `0` is always the generated `*_UNSPECIFIED` value):
194+
195+
```yaml
196+
enums:
197+
WidgetColor:
198+
values:
199+
RED: 1
200+
BLUE: 2
201+
```
202+
203+
## Type Mappings
204+
205+
The generated `schema.proto` uses these built-in scalar mappings:
206+
207+
| ElasticGraph Type | Protobuf Type |
208+
|-------------------|------------|
209+
| `Boolean` | `bool` |
210+
| `Cursor` | `string` |
211+
| `Date` | `string` |
212+
| `DateTime` | `string` |
213+
| `Float` | `double` |
214+
| `ID` | `string` |
215+
| `Int` | `int32` |
216+
| `JsonSafeLong` | `int64` |
217+
| `LocalTime` | `string` |
218+
| `LongString` | `int64` |
219+
| `String` | `string` |
220+
| `TimeZone` | `string` |
221+
| `Untyped` | `string` |
222+
223+
Additionally:
224+
- List types become `repeated` fields.
225+
- Nested list types generate wrapper messages so the output remains valid `proto3`.
226+
- Enum types generate `enum` definitions whose values are prefixed with the enum type name in `UPPER_SNAKE_CASE`, including a zero-valued `*_UNSPECIFIED` entry.

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

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
#
77
# frozen_string_literal: true
88

9+
require "elastic_graph/errors"
910
require "elastic_graph/proto_ingestion"
11+
require "elastic_graph/proto_ingestion/schema_definition/factory_extension"
12+
require "elastic_graph/proto_ingestion/schema_definition/schema"
13+
require "elastic_graph/proto_ingestion/schema_definition/state_extension"
1014

1115
module ElasticGraph
1216
module ProtoIngestion
@@ -16,10 +20,114 @@ module ProtoIngestion
1620
module SchemaDefinition
1721
# Module designed to be extended onto an {ElasticGraph::SchemaDefinition::API} instance
1822
# to enable protobuf schema artifact generation.
19-
#
20-
# @note The protobuf schema artifact generation logic has not been implemented yet, so
21-
# extending this module is currently a no-op.
2223
module APIExtension
24+
# Maps built-in ElasticGraph scalar types to proto field types.
25+
PROTO_TYPES_BY_BUILT_IN_SCALAR_TYPE = {
26+
"Boolean" => "bool",
27+
"Cursor" => "string",
28+
"Date" => "string",
29+
"DateTime" => "string",
30+
"Float" => "double",
31+
"ID" => "string",
32+
"Int" => "int32",
33+
"JsonSafeLong" => "int64",
34+
"LocalTime" => "string",
35+
"LongString" => "int64",
36+
"String" => "string",
37+
"TimeZone" => "string",
38+
"Untyped" => "string"
39+
}.freeze
40+
41+
# Wires up the protobuf extensions when this module is extended onto an API instance.
42+
#
43+
# @param api [ElasticGraph::SchemaDefinition::API] the API instance to extend
44+
# @return [void]
45+
# @api private
46+
def self.extended(api)
47+
api.state.extend(StateExtension)
48+
api.factory.extend(FactoryExtension)
49+
50+
api.on_built_in_types do |type|
51+
if type.is_a?(SchemaElements::ScalarTypeExtension)
52+
# Use the reverted (original) type name so that built-in scalars renamed via
53+
# `type_name_overrides` still resolve to their proto field types.
54+
type.proto_field type: PROTO_TYPES_BY_BUILT_IN_SCALAR_TYPE.fetch(type.type_ref.with_reverted_override.name)
55+
end
56+
end
57+
end
58+
59+
# Configures protobuf artifact generation behavior.
60+
#
61+
# @param package_name [String] proto package name to emit
62+
# @param syntax [Symbol] `:proto3` (default) or `:proto2`
63+
# @param headers [Array<String>] file-level header lines (e.g. `option` declarations) rendered
64+
# verbatim after the `package` declaration
65+
# @return [void]
66+
#
67+
# @example Set the proto package name
68+
# ElasticGraph.define_schema do |schema|
69+
# schema.proto_schema_artifacts package_name: "myapp.events.v1"
70+
# end
71+
#
72+
# @example Emit proto2 with custom file-level options
73+
# ElasticGraph.define_schema do |schema|
74+
# schema.proto_schema_artifacts(
75+
# package_name: "myapp.events.v1",
76+
# syntax: :proto2,
77+
# headers: [
78+
# %(option java_package = "com.myapp.events";),
79+
# "option java_multiple_files = true;"
80+
# ]
81+
# )
82+
# end
83+
def proto_schema_artifacts(package_name: "elasticgraph", syntax: :proto3, headers: [])
84+
if !package_name.is_a?(String) || package_name.empty?
85+
raise Errors::SchemaError, "`package_name` must be a non-empty String"
86+
end
87+
unless Schema::SUPPORTED_SYNTAXES.include?(syntax.to_s)
88+
raise Errors::SchemaError, "`syntax` must be one of #{Schema::SUPPORTED_SYNTAXES.inspect}, got: #{syntax.inspect}"
89+
end
90+
if !headers.is_a?(Array) || headers.any? { |header| !header.is_a?(String) }
91+
raise Errors::SchemaError, "`headers` must be an Array of Strings"
92+
end
93+
94+
protobuf_state.proto_schema_package_name = package_name
95+
protobuf_state.proto_schema_syntax = syntax
96+
protobuf_state.proto_schema_headers = headers
97+
nil
98+
end
99+
100+
# Registers mappings from GraphQL enum names to protobuf enum classes and transform options.
101+
# This is intended to support reusing enum mappings already maintained by applications
102+
# (for example in schema/proto consistency tests).
103+
#
104+
# @param proto_enums_by_graphql_enum [Hash]
105+
# @return [void]
106+
def proto_enum_mappings(proto_enums_by_graphql_enum)
107+
protobuf_state.proto_enums_by_graphql_enum = proto_enums_by_graphql_enum
108+
nil
109+
end
110+
111+
# Configures proto field-number mappings directly from a hash.
112+
# Useful for tests and advanced use cases where mappings are sourced outside artifacts.
113+
# When artifacts are dumped, mappings from the existing `proto_field_numbers.yaml` artifact
114+
# are loaded automatically; this method does not need to be called in that case.
115+
#
116+
# @param proto_field_number_mappings [Hash]
117+
# @return [void]
118+
def configure_proto_field_number_mappings(proto_field_number_mappings)
119+
protobuf_state.proto_field_number_mappings = proto_field_number_mappings
120+
nil
121+
end
122+
123+
private
124+
125+
# Returns the API's `state` narrowed to include this gem's `StateExtension`. Centralizes
126+
# the Steep cast that's needed because Steep can't see the `extend(StateExtension)` applied
127+
# at runtime in `extended`.
128+
def protobuf_state
129+
state # : ElasticGraph::SchemaDefinition::State & StateExtension
130+
end
23131
end
24132
end
25133
end

0 commit comments

Comments
 (0)