Skip to content

Commit ee20f41

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 0a476c1 commit ee20f41

10 files changed

Lines changed: 180 additions & 42 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: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def initialize(
4646
@proto_enum_value_numbers_by_enum = normalize_proto_enum_value_number_mappings(proto_field_number_mappings)
4747
@used_field_numbers_by_message = {}
4848
@used_enum_value_numbers_by_enum = {}
49+
@imports = ::Set.new
4950
@definitions_by_name = {}
5051
@definition_kind_by_name = {}
5152
@type_name_by_proto_name = {}
@@ -63,6 +64,7 @@ def to_proto
6364
sections = [
6465
%(syntax = "proto3";),
6566
"package #{@package_name};",
67+
*render_imports,
6668
render_definitions
6769
]
6870

@@ -138,7 +140,9 @@ def proto_field_type_for(type_ref, context_type_name:, context_field_name:)
138140
raise Errors::SchemaError, "Type `#{base_type_ref.unwrapped_name}` cannot be resolved for proto generation."
139141
end
140142

141-
[list_depth == 1, register_type(resolved)]
143+
base_type_name = register_type(resolved)
144+
base_type_comment = (SchemaElements::ScalarTypeExtension === resolved) ? resolved.protobuf_comment : nil
145+
[list_depth == 1, base_type_name, base_type_comment]
142146
end
143147

144148
# Returns the stable protobuf number for a message field.
@@ -189,6 +193,13 @@ def enum_value_numbers_for(enum_name, value_names)
189193
end
190194
end
191195

196+
# Registers a proto file import required by a referenced external type.
197+
#
198+
# @api private
199+
def register_import(import)
200+
@imports << import if import
201+
end
202+
192203
private
193204

194205
def indexed_types
@@ -270,6 +281,11 @@ def render_definitions
270281
.join("\n\n")
271282
end
272283

284+
def render_imports
285+
return [] if @imports.empty?
286+
[@imports.sort.map { |import| %(import "#{import}";) }.join("\n")]
287+
end
288+
273289
def normalize_proto_field_number_mappings(raw_mappings)
274290
return {} if raw_mappings.nil?
275291
unless raw_mappings.is_a?(Hash)

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def render_proto_message(schema, message_name)
4747
else
4848
fields.each do |field|
4949
field_name = Identifier.field_name(field.name)
50-
repeated, field_type = schema.proto_field_type_for(field.type, context_type_name: name, context_field_name: field.name)
50+
repeated, field_type, type_comment = schema.proto_field_type_for(field.type, context_type_name: name, context_field_name: field.name)
5151
field_number = schema.field_number_for(
5252
message_name: message_name,
5353
type_name: name,
@@ -56,7 +56,10 @@ def render_proto_message(schema, message_name)
5656
)
5757
label = "repeated " if repeated
5858
line = " #{label}#{field_type} #{field_name} = #{field_number};"
59-
line += " // source name: #{field.name}" if field_name != field.name
59+
comment_parts = [] # : ::Array[::String]
60+
comment_parts << "source name: #{field.name}" if field_name != field.name
61+
comment_parts << type_comment if type_comment
62+
line += " // #{comment_parts.join("; ")}" if comment_parts.any?
6063
lines << line
6164
end
6265
end

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,36 @@ 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.
3046
#
3147
# @return [String]
3248
# @raise [Errors::SchemaError] when missing
33-
def to_proto(_schema = nil)
49+
def to_proto(schema = nil)
50+
schema&.register_import(protobuf_import)
3451
protobuf_type ||
3552
raise(Errors::SchemaError, "Protobuf type not configured for scalar type `#{name}`. " \
3653
'To proceed, call `protobuf type: "TYPE"` on the scalar type definition.')

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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ module ElasticGraph
2121
@proto_enum_value_numbers_by_enum: ::Hash[::String, ::Hash[::String, ::Integer]]
2222
@used_field_numbers_by_message: ::Hash[::String, ::Set[::Integer]]
2323
@used_enum_value_numbers_by_enum: ::Hash[::String, ::Set[::Integer]]
24+
@imports: ::Set[::String]
2425
@definitions_by_name: ::Hash[::String, ::String?]
2526
@definition_kind_by_name: ::Hash[::String, ::Symbol]
2627
@type_name_by_proto_name: ::Hash[::String, ::String]
@@ -42,11 +43,12 @@ module ElasticGraph
4243
name_in_index: ::String
4344
) -> ::Integer
4445
def enum_value_numbers_for: (::String, ::Array[::String]) -> ::Hash[::String, ::Integer]
46+
def register_import: (::String?) -> void
4547
def proto_field_type_for: (
4648
::ElasticGraph::SchemaDefinition::SchemaElements::TypeReference,
4749
context_type_name: ::String,
4850
context_field_name: ::String
49-
) -> [bool, ::String]
51+
) -> [bool, ::String, ::String?]
5052

5153
private
5254

@@ -65,6 +67,7 @@ module ElasticGraph
6567
::ElasticGraph::SchemaDefinition::SchemaElements::TypeReference
6668
) -> [::Integer, ::ElasticGraph::SchemaDefinition::SchemaElements::TypeReference]
6769
def render_definitions: () -> ::String
70+
def render_imports: () -> ::Array[::String]
6871
def normalize_proto_field_number_mappings: (untyped) -> ::Hash[::String, fieldNumberMappingsByFieldName]
6972
def normalize_proto_enum_value_number_mappings: (untyped) -> ::Hash[::String, ::Hash[::String, ::Integer]]
7073
def normalize_field_number_mapping_entry: (::String, ::String, untyped) -> [::Integer, ::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
def to_proto: (?Schema?) -> ::String
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)