diff --git a/lib/proto_plugin/field_descriptor.rb b/lib/proto_plugin/field_descriptor.rb index 20159ca..207b767 100644 --- a/lib/proto_plugin/field_descriptor.rb +++ b/lib/proto_plugin/field_descriptor.rb @@ -41,23 +41,52 @@ def file # Resolves the message or enum descriptor referenced by this field. # # Only message, enum, and group fields reference another type. For scalar - # fields (or when the referenced type was not included in the request), - # `nil` is returned. + # and map fields (or when the referenced type was not included in the + # request), `nil` is returned. For a map field, inspect {#key} and {#value} + # instead. # # @return [MessageDescriptor] if the field is a message or group type # @return [EnumDescriptor] if the field is an enum type - # @return [nil] if the field is a scalar type or the type was not found + # @return [nil] if the field is a scalar or map type, or the type was not found def type_descriptor - return if scalar? + return unless message? || enum? || group? @context.type_by_proto_name(type_name) end - # Returns true if the field is a message type. + # Returns true if the field is a `map` field. + # + # A map is represented on the wire as a repeated message of synthetic + # entries. This detects that representation so callers can treat maps + # distinctly from repeated message fields. + # + # @return [Boolean] + def map? + !map_entry.nil? + end + + # The map key field, for a map field. + # + # @return [FieldDescriptor] the synthetic entry's key field (number 1) + # @return [nil] if the field is not a map + def key + map_entry&.fields&.find { |f| f.number == 1 } + end + + # The map value field, for a map field. + # + # @return [FieldDescriptor] the synthetic entry's value field (number 2) + # @return [nil] if the field is not a map + def value + map_entry&.fields&.find { |f| f.number == 2 } + end + + # Returns true if the field is a message type. Map fields are excluded; use + # {#map?} to detect those. # # @return [Boolean] def message? - type == :TYPE_MESSAGE + type == :TYPE_MESSAGE && !map? end # Returns true if the field is an enum type. @@ -74,18 +103,20 @@ def group? type == :TYPE_GROUP end - # Returns true if the field is a scalar type (i.e. not a message, enum, or group). + # Returns true if the field is a scalar type (i.e. not a message, enum, + # group, or map). # # @return [Boolean] def scalar? - !message? && !enum? && !group? + !map? && !message? && !enum? && !group? end - # Returns true if the field has the `repeated` label. + # Returns true if the field has the `repeated` label. Map fields are + # excluded; use {#map?} to detect those. # # @return [Boolean] def repeated? - label == :LABEL_REPEATED + repeated_label? && !map? end # Returns true if the field has the `required` label (proto2 only). @@ -132,5 +163,33 @@ def oneof message.oneofs[descriptor.oneof_index] end + + private + + def repeated_label? + label == :LABEL_REPEATED + end + + # The synthetic map-entry message backing a map field, if this is one. + # + # A map field is a repeated message whose type is a nested message flagged + # with the `map_entry` option. The entry is resolved directly from the + # containing message's raw `nested_type` (rather than the context) because + # synthetic entries are intentionally excluded from {MessageDescriptor#messages} + # and therefore from the context's type index. + # + # @return [MessageDescriptor] + # @return [nil] if the field is not a map + def map_entry + return @map_entry if defined?(@map_entry) + + @map_entry = if repeated_label? && type == :TYPE_MESSAGE + name = type_name.split(".").last + proto = message.descriptor.nested_type.find do |n| + n.name == name && n.options&.map_entry + end + MessageDescriptor.new(proto, message, @context) if proto + end + end end end diff --git a/lib/proto_plugin/message_descriptor.rb b/lib/proto_plugin/message_descriptor.rb index 23c0814..f2d3636 100644 --- a/lib/proto_plugin/message_descriptor.rb +++ b/lib/proto_plugin/message_descriptor.rb @@ -78,12 +78,16 @@ def enums # The messages defined as children of this message. # + # Synthetic map-entry types (generated by `protoc` to back `map` + # fields) are excluded, as they are an implementation detail rather than + # user-declared messages. Use {FieldDescriptor#map?} to work with maps. + # # @return [Array] # # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto#L140 # Google::Protobuf::DescriptorProto#nested_type def messages - @nested_messages ||= @descriptor.nested_type.map do |m| + @nested_messages ||= @descriptor.nested_type.reject { |m| m.options&.map_entry }.map do |m| MessageDescriptor.new(m, self, @context) end end diff --git a/test/fixtures/blog.cgr b/test/fixtures/blog.cgr index a591532..fea96d6 100644 Binary files a/test/fixtures/blog.cgr and b/test/fixtures/blog.cgr differ diff --git a/test/fixtures/blog.fds b/test/fixtures/blog.fds index 8fcdff2..5598f21 100644 Binary files a/test/fixtures/blog.fds and b/test/fixtures/blog.fds differ diff --git a/test/fixtures/blog/comment.proto b/test/fixtures/blog/comment.proto index 7f2dd5b..881842e 100644 --- a/test/fixtures/blog/comment.proto +++ b/test/fixtures/blog/comment.proto @@ -34,3 +34,13 @@ message CommentEvent { bool deleted = 4; } } + +message CommentDigest { + map counts_by_user = 1; + + repeated string labels = 2; + + Comment.Status status = 3; + + map comments_by_id = 4; +} diff --git a/test/proto_plugin/field_descriptor_test.rb b/test/proto_plugin/field_descriptor_test.rb index fa3326d..0f9902e 100644 --- a/test/proto_plugin/field_descriptor_test.rb +++ b/test/proto_plugin/field_descriptor_test.rb @@ -86,6 +86,49 @@ def test_proto3_optional refute(@fields["title"].proto3_optional?) end + def test_scalar_map + digest = @context.type_by_proto_name(".proto_plugin.fixtures.CommentDigest") + counts = digest.fields.find { |f| f.name == "counts_by_user" } + + assert(counts.map?) + + # A map is neither a repeated, message, nor scalar field. + refute(counts.repeated?) + refute(counts.message?) + refute(counts.scalar?) + assert_nil(counts.type_descriptor) + + assert_equal("key", counts.key.name) + assert(counts.key.scalar?) + assert_equal("value", counts.value.name) + assert(counts.value.scalar?) + end + + def test_message_valued_map + digest = @context.type_by_proto_name(".proto_plugin.fixtures.CommentDigest") + comments = digest.fields.find { |f| f.name == "comments_by_id" } + + assert(comments.map?) + assert(comments.value.message?) + assert_equal("ProtoPlugin::Fixtures::Comment", comments.value.type_descriptor.full_name) + end + + def test_repeated_scalar_is_not_a_map + digest = @context.type_by_proto_name(".proto_plugin.fixtures.CommentDigest") + labels = digest.fields.find { |f| f.name == "labels" } + + refute(labels.map?) + assert(labels.repeated?) + assert(labels.scalar?) + assert_nil(labels.key) + assert_nil(labels.value) + end + + def test_synthetic_map_entries_excluded_from_messages + digest = @context.type_by_proto_name(".proto_plugin.fixtures.CommentDigest") + assert_empty(digest.messages) + end + def test_oneof_membership event = @context.type_by_proto_name(".proto_plugin.fixtures.CommentEvent") fields = event.fields.each_with_object({}) do |field, hash|