Skip to content

Commit d64ce6d

Browse files
committed
Map DateTime to google.protobuf.Timestamp and document temporal string formats
`DateTime` fields now use the well-known `google.protobuf.Timestamp` type (with `google/protobuf/timestamp.proto` imported automatically) instead of `string`. A Timestamp cannot be malformed the way a string can, and proto consumers get language-native timestamp types. Note that a Timestamp is a UTC instant, so a publisher's original UTC offset is not preserved; `t.protobuf type: "string"` remains available to override. To support this, `t.protobuf` gains two options usable by any scalar: - `import:` maps a scalar to an externally defined proto type, emitting the needed `import` statement in `schema.proto`. - `comment:` documents the expected format on each generated field, used by the built-in `string`-typed temporal scalars (`Date`, `LocalTime`, `TimeZone`) whose proto type is wider than the ElasticGraph type (e.g. `// ISO 8601 date`). Values are still validated at ingestion time, just as with JSON ingestion.
1 parent bbe754f commit d64ce6d

9 files changed

Lines changed: 230 additions & 43 deletions

File tree

elasticgraph-proto_ingestion/README.md

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -94,27 +94,93 @@ ElasticGraph.define_schema do |schema|
9494
end
9595
```
9696

97+
A custom scalar can also map to an externally defined proto type by passing `import:` with the
98+
proto file that defines it, and `comment:` documents the expected format on each generated field
99+
(useful when the proto type is wider than the ElasticGraph type):
100+
101+
```ruby
102+
# in config/schema/phone_number.rb
103+
104+
ElasticGraph.define_schema do |schema|
105+
schema.scalar_type "PhoneNumber" do |t|
106+
t.mapping type: "keyword"
107+
t.json_schema type: "string"
108+
t.protobuf type: "string", comment: "E.164 phone number"
109+
end
110+
end
111+
```
112+
113+
### Stable Field Numbers
114+
115+
`schema_artifacts:dump` automatically reads and writes `proto_field_numbers.yaml`
116+
in the schema artifacts directory. Existing numbers stay fixed even if field order
117+
changes, and new fields get the next available numbers. Field numbers follow protobuf's
118+
rules: they must be between 1 and 536,870,911, and the protobuf-reserved 19000-19999
119+
range is never allocated and is rejected in mappings.
120+
121+
Alternatives inside generated interface and union `oneof` blocks use the same stable
122+
message-field mappings, so adding or removing a concrete subtype does not renumber the
123+
remaining alternatives.
124+
125+
`schema.proto` always uses the public GraphQL field names. When a field uses a
126+
different `name_in_index`, the sidecar YAML stores that override privately:
127+
128+
```yaml
129+
messages:
130+
Widget:
131+
fields:
132+
id: 1
133+
display_name:
134+
field_number: 2
135+
name_in_index: displayName
136+
```
137+
138+
If a field is renamed with `field.renamed_from`, `elasticgraph-proto_ingestion` reuses the
139+
existing field number under the new public field name.
140+
141+
### Stable Enum Value Numbers
142+
143+
Enum value numbers are pinned the same way, in an `enums` section of the sidecar. Existing
144+
values keep their numbers when other values are added or removed, new values get the next
145+
available numbers, and removed values keep their numbers reserved so they are never reused
146+
(number `0` is always the generated `*_UNSPECIFIED` value):
147+
148+
```yaml
149+
enums:
150+
WidgetColor:
151+
values:
152+
RED: 1
153+
BLUE: 2
154+
```
155+
97156
## Type Mappings
98157

99158
The generated `schema.proto` uses these built-in scalar mappings:
100159

101-
| ElasticGraph Type | Protobuf Type |
102-
|-------------------|------------|
103-
| `Boolean` | `bool` |
104-
| `Cursor` | `string` |
105-
| `Date` | `string` |
106-
| `DateTime` | `string` |
107-
| `Float` | `double` |
108-
| `ID` | `string` |
109-
| `Int` | `int32` |
110-
| `JsonSafeLong` | `int64` |
111-
| `LocalTime` | `string` |
112-
| `LongString` | `int64` |
113-
| `String` | `string` |
114-
| `TimeZone` | `string` |
115-
| `Untyped` | `string` |
160+
| ElasticGraph Type | Protobuf Type |
161+
|-------------------|-----------------------------|
162+
| `Boolean` | `bool` |
163+
| `Cursor` | `string` |
164+
| `Date` | `string` |
165+
| `DateTime` | `google.protobuf.Timestamp` |
166+
| `Float` | `double` |
167+
| `ID` | `string` |
168+
| `Int` | `int32` |
169+
| `JsonSafeLong` | `int64` |
170+
| `LocalTime` | `string` |
171+
| `LongString` | `int64` |
172+
| `String` | `string` |
173+
| `TimeZone` | `string` |
174+
| `Untyped` | `string` |
116175

117176
Additionally:
177+
- `DateTime` uses the [well-known `Timestamp` type](https://protobuf.dev/reference/protobuf/google.protobuf/#timestamp);
178+
`schema.proto` imports `google/protobuf/timestamp.proto` automatically. Note that a `Timestamp`
179+
is a UTC instant, so a publisher's original UTC offset is not preserved.
180+
- `string`-typed temporal scalars (`Date`, `LocalTime`, `TimeZone`) are wider than the
181+
ElasticGraph types they carry, so generated fields of these types document the expected format
182+
in a comment (e.g. `// ISO 8601 date, e.g. "2024-11-25"`). Values are validated when events
183+
are ingested, just as with JSON ingestion.
118184
- List types become `repeated` fields.
119185
- Lists of lists (e.g. `[[Float!]!]!`) are not supported because Protocol Buffers cannot represent
120186
them directly. Schema artifact generation raises an error identifying the unsupported field.

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def to_proto
4545
sections = [
4646
%(syntax = "proto3";),
4747
"package #{@package_name};",
48+
*render_imports(types),
4849
render_definitions(types)
4950
]
5051

@@ -103,6 +104,14 @@ def render_definitions(types)
103104
.join("\n\n")
104105
end
105106

107+
def render_imports(types)
108+
imports = types.filter_map do |type|
109+
type.protobuf_import if type.respond_to?(:protobuf_import)
110+
end.uniq.sort
111+
112+
imports.empty? ? [] : [imports.map { |import| %(import "#{import}";) }.join("\n")]
113+
end
114+
106115
def validate_unique_enum_value_prefixes(types)
107116
enum_type_by_prefix = {} # : ::Hash[::String, untyped]
108117

elasticgraph-proto_ingestion/lib/elastic_graph/proto_ingestion/schema_definition/schema_elements/object_interface_and_union_extension.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def render_proto_message(schema, message_name, package_name)
7474
fields = proto_fields
7575
documentation = ProtoDocumentation.comment_lines_for(doc_comment).map { |line| "#{line}\n" }.join
7676
field_definitions = fields.map do |schema_field, field|
77-
repeated, field_type = proto_field_type_for(
77+
repeated, field_type, type_comment = proto_field_type_for(
7878
field.type,
7979
package_name: package_name,
8080
context_field_name: field.name
@@ -87,6 +87,7 @@ def render_proto_message(schema, message_name, package_name)
8787
)
8888
label = "repeated " if repeated
8989
line = " #{label}#{field_type} #{schema_field.name} = #{field_number};"
90+
line += " // #{type_comment}" if type_comment
9091
field_documentation = ProtoDocumentation
9192
.comment_lines_for(schema_field.doc_comment, indent: " ")
9293
.map { |comment_line| "#{comment_line}\n" }
@@ -152,7 +153,8 @@ def proto_field_type_for(type_ref, package_name:, context_field_name:)
152153
end
153154

154155
proto_type = _ = base_type_ref.resolved
155-
[list_depth == 1, proto_type.proto_type_reference(package_name)]
156+
type_comment = (ScalarTypeExtension === proto_type) ? proto_type.protobuf_comment : nil
157+
[list_depth == 1, proto_type.proto_type_reference(package_name), type_comment]
156158
end
157159
end
158160
end

elasticgraph-proto_ingestion/lib/elastic_graph/proto_ingestion/schema_definition/schema_elements/scalar_type_extension.rb

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,33 +14,45 @@ module SchemaDefinition
1414
module SchemaElements
1515
# Extends ScalarType with proto field type conversion.
1616
module ScalarTypeExtension
17-
# Default protobuf types applied to ElasticGraph's built-in scalar types as they are constructed.
18-
BUILT_IN_SCALAR_PROTO_TYPES_BY_NAME = {
19-
"Boolean" => "bool",
20-
"Cursor" => "string",
21-
"Date" => "string",
22-
"DateTime" => "string",
23-
"Float" => "double",
24-
"ID" => "string",
25-
"Int" => "int32",
26-
"JsonSafeLong" => "int64",
27-
"LocalTime" => "string",
28-
"LongString" => "int64",
29-
"String" => "string",
30-
"TimeZone" => "string",
31-
"Untyped" => "string"
17+
# Default protobuf options applied to ElasticGraph's built-in scalar types as they are constructed.
18+
BUILT_IN_SCALAR_PROTO_OPTIONS_BY_NAME = {
19+
"Boolean" => {type: "bool"},
20+
"Cursor" => {type: "string"},
21+
"Date" => {type: "string", comment: %(ISO 8601 date, e.g. "2024-11-25")},
22+
"DateTime" => {type: "google.protobuf.Timestamp", import: "google/protobuf/timestamp.proto"},
23+
"Float" => {type: "double"},
24+
"ID" => {type: "string"},
25+
"Int" => {type: "int32"},
26+
"JsonSafeLong" => {type: "int64"},
27+
"LocalTime" => {type: "string", comment: %(ISO 8601 local time, e.g. "14:23:12")},
28+
"LongString" => {type: "int64"},
29+
"String" => {type: "string"},
30+
"TimeZone" => {type: "string", comment: %(IANA time zone identifier, e.g. "America/Los_Angeles")},
31+
"Untyped" => {type: "string"}
3232
}.freeze
3333

3434
# Configured protobuf type (e.g. string, int64, bool).
3535
# @dynamic protobuf_type
3636
attr_reader :protobuf_type
3737

38+
# Proto file to import for the configured protobuf type, if it is externally defined.
39+
# @dynamic protobuf_import
40+
attr_reader :protobuf_import
41+
42+
# Comment rendered on generated proto fields of this scalar type.
43+
# @dynamic protobuf_comment
44+
attr_reader :protobuf_comment
45+
3846
# Configures the protobuf type for this scalar type.
3947
#
40-
# @param type [String] protobuf scalar type name
48+
# @param type [String] protobuf type name
49+
# @param import [String, nil] proto file to import for an externally defined type
50+
# @param comment [String, nil] comment rendered on generated fields of this type
4151
# @return [void]
42-
def protobuf(type:)
52+
def protobuf(type:, import: nil, comment: nil)
4353
@protobuf_type = type
54+
@protobuf_import = import
55+
@protobuf_comment = comment
4456
end
4557

4658
# Applies any built-in protobuf type, yields for further configuration, and validates the result.
@@ -50,8 +62,12 @@ def protobuf(type:)
5062
# @raise [Errors::SchemaError] when a protobuf type is missing
5163
def initialize_proto_extension
5264
original_name = type_ref.with_reverted_override.name
53-
if (proto_type = BUILT_IN_SCALAR_PROTO_TYPES_BY_NAME[original_name])
54-
protobuf type: proto_type
65+
if (proto_options = BUILT_IN_SCALAR_PROTO_OPTIONS_BY_NAME[original_name])
66+
protobuf(
67+
type: proto_options.fetch(:type),
68+
import: proto_options[:import],
69+
comment: proto_options[:comment]
70+
)
5571
end
5672

5773
yield

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ module ElasticGraph
2929

3030
def proto_types: () -> ::Array[untyped]
3131
def render_definitions: (::Array[untyped] types) -> ::String
32+
def render_imports: (::Array[untyped] types) -> ::Array[::String]
3233
def validate_unique_enum_value_prefixes: (::Array[untyped] types) -> void
3334
def previous_field_names_for: (::String, ::String) -> ::Array[::String]
3435
def previous_field_names_by_type_name_and_field_name: () -> ::Hash[::String, ::Hash[::String, ::Array[::String]]]

elasticgraph-proto_ingestion/sig/elastic_graph/proto_ingestion/schema_definition/schema_elements/object_interface_and_union_extension.rbs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ module ElasticGraph
2828
::ElasticGraph::SchemaDefinition::SchemaElements::TypeReference,
2929
package_name: ::String,
3030
context_field_name: ::String
31-
) -> [bool, ::String]
31+
) -> [bool, ::String, ::String?]
3232
end
3333
end
3434
end

elasticgraph-proto_ingestion/sig/elastic_graph/proto_ingestion/schema_definition/schema_elements/scalar_type_extension.rbs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ module ElasticGraph
33
module SchemaDefinition
44
module SchemaElements
55
module ScalarTypeExtension: ::ElasticGraph::SchemaDefinition::SchemaElements::ScalarType
6-
BUILT_IN_SCALAR_PROTO_TYPES_BY_NAME: ::Hash[::String, ::String]
6+
BUILT_IN_SCALAR_PROTO_OPTIONS_BY_NAME: ::Hash[::String, ::Hash[::Symbol, ::String]]
77

88
attr_reader protobuf_type: ::String?
9+
attr_reader protobuf_import: ::String?
10+
attr_reader protobuf_comment: ::String?
911

10-
def protobuf: (type: ::String) -> void
12+
def protobuf: (type: ::String, ?import: ::String?, ?comment: ::String?) -> void
1113
def initialize_proto_extension: () { () -> void } -> void
1214
def proto_name: () -> ::String
1315
def proto_type_reference: (::String package_name) -> ::String

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,22 @@ module SchemaDefinition
3232
end
3333

3434
it "maps every built-in scalar to a proto field type" do
35+
built_in_scalar_options = SchemaElements::ScalarTypeExtension::BUILT_IN_SCALAR_PROTO_OPTIONS_BY_NAME
3536
field_types = []
3637
proto = define_proto_schema do |s|
3738
s.object_type "Widget" do |t|
38-
SchemaElements::ScalarTypeExtension::BUILT_IN_SCALAR_PROTO_TYPES_BY_NAME.each_key do |type_name|
39+
built_in_scalar_options.each_key do |type_name|
3940
t.field type_name.downcase, type_name
4041
end
4142
field_types = t.graphql_fields_by_name.values.map { |field| field.type.name }
4243
t.index "widgets"
4344
end
4445
end
4546

46-
expect(field_types).to match_array(SchemaElements::ScalarTypeExtension::BUILT_IN_SCALAR_PROTO_TYPES_BY_NAME.keys)
47-
SchemaElements::ScalarTypeExtension::BUILT_IN_SCALAR_PROTO_TYPES_BY_NAME.each.with_index(1) do |(type_name, proto_type), field_number|
48-
expect(proto).to include("#{proto_type} #{type_name.downcase} = #{field_number};")
47+
expect(field_types).to match_array(built_in_scalar_options.keys)
48+
expect(proto).to include('import "google/protobuf/timestamp.proto";')
49+
built_in_scalar_options.each.with_index(1) do |(type_name, options), field_number|
50+
expect(proto).to include("#{options.fetch(:type)} #{type_name.downcase} = #{field_number};")
4951
end
5052
end
5153

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,95 @@ module SchemaDefinition
242242
expect(proto_type_def_from(proto, "Event")).to include("int64 occurred_at = 2;")
243243
end
244244

245+
it "maps `DateTime` fields to `google.protobuf.Timestamp`, importing its proto file once" do
246+
proto = define_proto_schema do |s|
247+
s.object_type "Event" do |t|
248+
t.field "id", "ID"
249+
t.field "created_at", "DateTime"
250+
t.field "updated_at", "DateTime"
251+
t.index "events"
252+
end
253+
end
254+
255+
expect(proto).to eq(<<~PROTO)
256+
syntax = "proto3";
257+
258+
package elasticgraph;
259+
260+
import "google/protobuf/timestamp.proto";
261+
262+
message Event {
263+
string id = 1;
264+
google.protobuf.Timestamp created_at = 2;
265+
google.protobuf.Timestamp updated_at = 3;
266+
}
267+
PROTO
268+
end
269+
270+
it "sorts and de-duplicates imports shared by distinct protobuf types" do
271+
proto = define_proto_schema do |s|
272+
s.scalar_type "FirstZType" do |t|
273+
t.mapping type: "keyword"
274+
t.protobuf type: "example.FirstZType", import: "z/types.proto"
275+
end
276+
277+
s.scalar_type "SecondZType" do |t|
278+
t.mapping type: "keyword"
279+
t.protobuf type: "example.SecondZType", import: "z/types.proto"
280+
end
281+
282+
s.scalar_type "AType" do |t|
283+
t.mapping type: "keyword"
284+
t.protobuf type: "example.AType", import: "a/types.proto"
285+
end
286+
287+
s.object_type "Event" do |t|
288+
t.field "id", "ID"
289+
t.field "first_z", "FirstZType"
290+
t.field "second_z", "SecondZType"
291+
t.field "a", "AType"
292+
t.index "events"
293+
end
294+
end
295+
296+
expect(proto.lines.grep(/\Aimport /).map(&:chomp)).to eq([
297+
%(import "a/types.proto";),
298+
%(import "z/types.proto";)
299+
])
300+
end
301+
302+
it "renders format comments on fields whose scalar type documents one" do
303+
proto = define_proto_schema do |s|
304+
s.object_type "Person" do |t|
305+
t.field "id", "ID"
306+
t.field "birth_date", "Date"
307+
t.index "people"
308+
end
309+
end
310+
311+
expect(proto).to include(
312+
%(string birth_date = 2; // ISO 8601 date, e.g. "2024-11-25")
313+
)
314+
end
315+
316+
it "supports `import:` and `comment:` on custom scalar types" do
317+
proto = define_proto_schema do |s|
318+
s.scalar_type "Money" do |t|
319+
t.mapping type: "keyword"
320+
t.protobuf type: "myapp.types.Money", import: "myapp/types/money.proto", comment: "amount + currency"
321+
end
322+
323+
s.object_type "Order" do |t|
324+
t.field "id", "ID"
325+
t.field "total", "Money"
326+
t.index "orders"
327+
end
328+
end
329+
330+
expect(proto).to include('import "myapp/types/money.proto";')
331+
expect(proto).to include("myapp.types.Money total = 2; // amount + currency")
332+
end
333+
245334
it "assigns the lowest unused field number to a new field, rather than the number after the maximum used one" do
246335
# A gap below the maximum used number can only arise from a hand-edited artifact:
247336
# organic schema evolution never creates one, since removed fields keep their numbers

0 commit comments

Comments
 (0)