Skip to content

Commit d516c31

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 74011e5 commit d516c31

9 files changed

Lines changed: 176 additions & 48 deletions

File tree

elasticgraph-proto_ingestion/README.md

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,22 @@ ElasticGraph.define_schema do |schema|
9595
end
9696
```
9797

98+
A custom scalar can also map to an externally defined proto type by passing `import:` with the
99+
proto file that defines it, and `comment:` documents the expected format on each generated field
100+
(useful when the proto type is wider than the ElasticGraph type):
101+
102+
```ruby
103+
# in config/schema/phone_number.rb
104+
105+
ElasticGraph.define_schema do |schema|
106+
schema.scalar_type "PhoneNumber" do |t|
107+
t.mapping type: "keyword"
108+
t.json_schema type: "string"
109+
t.protobuf type: "string", comment: "E.164 phone number"
110+
end
111+
end
112+
```
113+
98114
### Stable Field Numbers
99115

100116
`schema_artifacts:dump` automatically reads and writes `proto_field_numbers.yaml`
@@ -138,23 +154,30 @@ enums:
138154

139155
The generated `schema.proto` uses these built-in scalar mappings:
140156

141-
| ElasticGraph Type | Protobuf Type |
142-
|-------------------|------------|
143-
| `Boolean` | `bool` |
144-
| `Cursor` | `string` |
145-
| `Date` | `string` |
146-
| `DateTime` | `string` |
147-
| `Float` | `double` |
148-
| `ID` | `string` |
149-
| `Int` | `int32` |
150-
| `JsonSafeLong` | `int64` |
151-
| `LocalTime` | `string` |
152-
| `LongString` | `int64` |
153-
| `String` | `string` |
154-
| `TimeZone` | `string` |
155-
| `Untyped` | `string` |
157+
| ElasticGraph Type | Protobuf Type |
158+
|-------------------|-----------------------------|
159+
| `Boolean` | `bool` |
160+
| `Cursor` | `string` |
161+
| `Date` | `string` |
162+
| `DateTime` | `google.protobuf.Timestamp` |
163+
| `Float` | `double` |
164+
| `ID` | `string` |
165+
| `Int` | `int32` |
166+
| `JsonSafeLong` | `int64` |
167+
| `LocalTime` | `string` |
168+
| `LongString` | `int64` |
169+
| `String` | `string` |
170+
| `TimeZone` | `string` |
171+
| `Untyped` | `string` |
156172

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

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

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,21 @@ module SchemaDefinition
2121
# Module designed to be extended onto an {ElasticGraph::SchemaDefinition::API} instance
2222
# to enable protobuf schema artifact generation.
2323
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"
24+
# Maps built-in ElasticGraph scalar types to their `protobuf` configuration options.
25+
PROTO_OPTIONS_BY_BUILT_IN_SCALAR_TYPE = {
26+
"Boolean" => {type: "bool"},
27+
"Cursor" => {type: "string"},
28+
"Date" => {type: "string", comment: %(ISO 8601 date, e.g. "2024-11-25")},
29+
"DateTime" => {type: "google.protobuf.Timestamp", import: "google/protobuf/timestamp.proto"},
30+
"Float" => {type: "double"},
31+
"ID" => {type: "string"},
32+
"Int" => {type: "int32"},
33+
"JsonSafeLong" => {type: "int64"},
34+
"LocalTime" => {type: "string", comment: %(ISO 8601 local time, e.g. "14:23:12")},
35+
"LongString" => {type: "int64"},
36+
"String" => {type: "string"},
37+
"TimeZone" => {type: "string", comment: %(IANA time zone identifier, e.g. "America/Los_Angeles")},
38+
"Untyped" => {type: "string"}
3939
}.freeze
4040

4141
# Wires up the protobuf extensions when this module is extended onto an API instance.
@@ -51,7 +51,8 @@ def self.extended(api)
5151
if type.is_a?(SchemaElements::ScalarTypeExtension)
5252
# Use the reverted (original) type name so that built-in scalars renamed via
5353
# `type_name_overrides` still resolve to their proto types.
54-
type.protobuf type: PROTO_TYPES_BY_BUILT_IN_SCALAR_TYPE.fetch(type.type_ref.with_reverted_override.name)
54+
options = PROTO_OPTIONS_BY_BUILT_IN_SCALAR_TYPE.fetch(type.type_ref.with_reverted_override.name)
55+
type.protobuf(type: options.fetch(:type), import: options[:import], comment: options[:comment])
5556
end
5657
end
5758
end

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

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def initialize(
8282
@proto_enum_value_numbers_by_enum = normalize_proto_enum_value_number_mappings(proto_field_number_mappings)
8383
@used_field_numbers_by_message = {}
8484
@used_enum_value_numbers_by_enum = {}
85+
@imports = ::Set.new
8586
@message_definitions_by_name = {}
8687
@enum_definitions_by_name = {}
8788
@type_name_by_message_name = {}
@@ -100,6 +101,7 @@ def to_proto
100101
sections = [
101102
%(syntax = "proto3";),
102103
"package #{@package_name};",
104+
*render_imports,
103105
render_definitions
104106
]
105107

@@ -154,8 +156,12 @@ def register_type(type)
154156
when SchemaElements::ObjectInterfaceAndUnionExtension
155157
register_message(type)
156158
when SchemaElements::ScalarTypeExtension
157-
# Scalars don't get their own proto definition. Resolving their proto field type below
159+
# Scalars don't get their own proto definition, but scalars mapped to an externally
160+
# defined proto type require its import. Resolving their proto field type below also
158161
# surfaces a clear error during registration when a custom scalar is not configured.
162+
if (import = type.protobuf_import)
163+
@imports << import
164+
end
159165
else
160166
raise Errors::SchemaError, "Type `#{type.name}` cannot be converted to proto."
161167
end
@@ -177,7 +183,7 @@ def register_message(type)
177183
.filter_map(&:to_indexing_field)
178184
.map do |field|
179185
field_name = Identifier.field_name(field.name)
180-
repeated, field_type = proto_field_type_for(
186+
repeated, field_type, type_comment = proto_field_type_for(
181187
field.type,
182188
context_type_name: type.name,
183189
context_field_name: field.name
@@ -189,19 +195,16 @@ def register_message(type)
189195
name_in_index: field.name_in_index
190196
)
191197

192-
comment =
193-
if field_name == field.name
194-
nil
195-
else
196-
"source name: #{field.name}"
197-
end
198+
comment_parts = [] # : ::Array[::String]
199+
comment_parts << "source name: #{field.name}" unless field_name == field.name
200+
comment_parts << type_comment if type_comment
198201

199202
FieldDefinition.new(
200203
name: field_name,
201204
type: field_type,
202205
field_number: field_number,
203206
repeated: repeated,
204-
comment: comment
207+
comment: comment_parts.empty? ? nil : comment_parts.join("; ")
205208
)
206209
end
207210

@@ -352,7 +355,9 @@ def proto_field_type_for(type_ref, context_type_name:, context_field_name:)
352355
raise Errors::SchemaError, "Type `#{base_type_ref.unwrapped_name}` cannot be resolved for proto generation."
353356
end
354357

355-
[list_depth == 1, register_type(resolved)]
358+
base_type_name = register_type(resolved)
359+
base_type_comment = (SchemaElements::ScalarTypeExtension === resolved) ? resolved.protobuf_comment : nil
360+
[list_depth == 1, base_type_name, base_type_comment]
356361
end
357362

358363
def list_depth_and_base_type(type_ref)
@@ -367,6 +372,12 @@ def list_depth_and_base_type(type_ref)
367372
[list_depth, current]
368373
end
369374

375+
# Renders `import` statements for referenced external proto files as a single contiguous
376+
# section. Returns `[]` when no external types are referenced.
377+
def render_imports
378+
return [] if @imports.empty?
379+
[@imports.sort.map { |import| %(import "#{import}";) }.join("\n")]
380+
end
370381
def render_definitions
371382
rendered_enums = @enum_definitions_by_name.values.sort_by(&:name).map { |definition| render_enum(definition) }
372383
rendered_messages = @message_definitions_by_name.values.sort_by(&:name).map { |definition| render_message(definition) }

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,28 @@ module ScalarTypeExtension
1818
# @dynamic protobuf_type
1919
attr_reader :protobuf_type
2020

21-
# Configures the protobuf type for this scalar type.
21+
# Proto file to import for the configured protobuf type, if it is an externally defined type.
22+
# @dynamic protobuf_import
23+
attr_reader :protobuf_import
24+
25+
# Comment rendered on generated proto fields of this scalar type (e.g. to document a string format).
26+
# @dynamic protobuf_comment
27+
attr_reader :protobuf_comment
28+
29+
# Configures the protobuf type for this scalar type. In addition to proto scalar types,
30+
# an externally defined message type can be used by passing `import:` with the proto file
31+
# that defines it (e.g. a well-known type such as `google.protobuf.Timestamp`). When the
32+
# proto type is wider than the ElasticGraph type (such as a `string`-typed `Date`),
33+
# `comment:` documents the expected format on each generated field.
2234
#
23-
# @param type [String] protobuf scalar type name
35+
# @param type [String] protobuf type name
36+
# @param import [String, nil] proto file to import for the type, when it is not a proto scalar
37+
# @param comment [String, nil] comment rendered on generated fields of this type
2438
# @return [void]
25-
def protobuf(type:)
39+
def protobuf(type:, import: nil, comment: nil)
2640
@protobuf_type = type
41+
@protobuf_import = import
42+
@protobuf_comment = comment
2743
end
2844

2945
# Returns this scalar's proto field type.

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
@@ -2,7 +2,7 @@ module ElasticGraph
22
module ProtoIngestion
33
module SchemaDefinition
44
module APIExtension: ::ElasticGraph::SchemaDefinition::API
5-
PROTO_TYPES_BY_BUILT_IN_SCALAR_TYPE: ::Hash[::String, ::String]
5+
PROTO_OPTIONS_BY_BUILT_IN_SCALAR_TYPE: ::Hash[::String, ::Hash[::Symbol, ::String]]
66

77
def self.extended: (::ElasticGraph::SchemaDefinition::API & APIExtension) -> void
88
def proto_schema_artifacts: (?package_name: ::String) -> void

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ module ElasticGraph
6363
@proto_enum_value_numbers_by_enum: ::Hash[::String, ::Hash[::String, ::Integer]]
6464
@used_field_numbers_by_message: ::Hash[::String, ::Set[::Integer]]
6565
@used_enum_value_numbers_by_enum: ::Hash[::String, ::Set[::Integer]]
66+
@imports: ::Set[::String]
6667
@message_definitions_by_name: ::Hash[::String, MessageDefinition]
6768
@enum_definitions_by_name: ::Hash[::String, EnumDefinition]
6869
@type_name_by_message_name: ::Hash[::String, ::String]
@@ -103,10 +104,11 @@ module ElasticGraph
103104
::ElasticGraph::SchemaDefinition::SchemaElements::TypeReference,
104105
context_type_name: ::String,
105106
context_field_name: ::String
106-
) -> [bool, ::String]
107+
) -> [bool, ::String, ::String?]
107108
def list_depth_and_base_type: (
108109
::ElasticGraph::SchemaDefinition::SchemaElements::TypeReference
109110
) -> [::Integer, ::ElasticGraph::SchemaDefinition::SchemaElements::TypeReference]
111+
def render_imports: () -> ::Array[::String]
110112
def render_definitions: () -> ::String
111113
def proto_enum_value_name: (::String, ::String) -> ::String
112114
def proto_zero_enum_value_name: (::String) -> ::String

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ module ElasticGraph
44
module SchemaElements
55
module ScalarTypeExtension: ::ElasticGraph::SchemaDefinition::SchemaElements::ScalarType
66
attr_reader protobuf_type: ::String?
7+
attr_reader protobuf_import: ::String?
8+
attr_reader protobuf_comment: ::String?
79

8-
def protobuf: (type: ::String) -> void
10+
def protobuf: (type: ::String, ?import: ::String?, ?comment: ::String?) -> void
911
def to_proto_field_type: () -> ::String
1012
end
1113
end

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ module SchemaDefinition
2929

3030
expect(proto_schema_from(results)).to include(
3131
"package sales.v1;",
32+
'import "google/protobuf/timestamp.proto";',
3233
"string id = 1;",
3334
"int32 count = 2;",
3435
"double cost = 3;",
3536
"bool active = 4;",
36-
"string created_at = 5;",
37+
"google.protobuf.Timestamp created_at = 5;",
3738
"int64 size_bytes = 6;"
3839
)
3940
end

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,78 @@ module SchemaDefinition
135135
expect(proto_schema_from(results)).to include("fixed64 occurred_at = 2;")
136136
end
137137

138+
it "maps `DateTime` fields to `google.protobuf.Timestamp`, importing its proto file once" do
139+
results = define_proto_schema do |s|
140+
s.object_type "Event" do |t|
141+
t.field "id", "ID"
142+
t.field "created_at", "DateTime"
143+
t.field "updated_at", "DateTime"
144+
t.index "events"
145+
end
146+
end
147+
148+
expect(proto_schema_from(results)).to eq(<<~PROTO)
149+
syntax = "proto3";
150+
151+
package elasticgraph;
152+
153+
import "google/protobuf/timestamp.proto";
154+
155+
message Event {
156+
string id = 1;
157+
google.protobuf.Timestamp created_at = 2;
158+
google.protobuf.Timestamp updated_at = 3;
159+
}
160+
PROTO
161+
end
162+
163+
it "renders format comments on fields whose scalar type documents one" do
164+
results = define_proto_schema do |s|
165+
s.object_type "Person" do |t|
166+
t.field "id", "ID"
167+
t.field "birth_date", "Date"
168+
t.index "people"
169+
end
170+
end
171+
172+
expect(proto_schema_from(results)).to include(
173+
%(string birth_date = 2; // ISO 8601 date, e.g. "2024-11-25")
174+
)
175+
end
176+
177+
it "combines source name and format comments on a single field" do
178+
results = define_proto_schema do |s|
179+
s.object_type "Person" do |t|
180+
t.field "id", "ID"
181+
t.field "option", "Date"
182+
t.index "people"
183+
end
184+
end
185+
186+
expect(proto_schema_from(results)).to include(
187+
%(string option_ = 2; // source name: option; ISO 8601 date, e.g. "2024-11-25")
188+
)
189+
end
190+
191+
it "supports `import:` and `comment:` on custom scalar types" do
192+
results = define_proto_schema do |s|
193+
s.scalar_type "Money" do |t|
194+
t.mapping type: "keyword"
195+
t.protobuf type: "myapp.types.Money", import: "myapp/types/money.proto", comment: "amount + currency"
196+
end
197+
198+
s.object_type "Order" do |t|
199+
t.field "id", "ID"
200+
t.field "total", "Money"
201+
t.index "orders"
202+
end
203+
end
204+
205+
generated = proto_schema_from(results)
206+
expect(generated).to include('import "myapp/types/money.proto";')
207+
expect(generated).to include("myapp.types.Money total = 2; // amount + currency")
208+
end
209+
138210
it "can assign field numbers from configured mappings" do
139211
results = define_proto_schema do |s|
140212
s.configure_proto_field_number_mappings(

0 commit comments

Comments
 (0)