Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 69 additions & 10 deletions lib/proto_plugin/field_descriptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<K, V>` 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.
Expand All @@ -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).
Expand Down Expand Up @@ -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
6 changes: 5 additions & 1 deletion lib/proto_plugin/message_descriptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,16 @@ def enums

# The messages defined as children of this message.
#
# Synthetic map-entry types (generated by `protoc` to back `map<K, V>`
# fields) are excluded, as they are an implementation detail rather than
# user-declared messages. Use {FieldDescriptor#map?} to work with maps.
#
# @return [Array<MessageDescriptor>]
#
# @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
Expand Down
Binary file modified test/fixtures/blog.cgr
Binary file not shown.
Binary file modified test/fixtures/blog.fds
Binary file not shown.
10 changes: 10 additions & 0 deletions test/fixtures/blog/comment.proto
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,13 @@ message CommentEvent {
bool deleted = 4;
}
}

message CommentDigest {
map<string, uint64> counts_by_user = 1;

repeated string labels = 2;

Comment.Status status = 3;

map<uint64, Comment> comments_by_id = 4;
}
43 changes: 43 additions & 0 deletions test/proto_plugin/field_descriptor_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
Loading