defmodule Protobuf.DSL do
@doc """
Define a field for the message.
Corresponds to Protobuf declarations such as:
string query = 1;
or more generally
<type> <name> = <field_number>;
## Arguments
* `name` — must be an atom representing the name of the field.
* `field_number` — must be an integer representing the field number.
* `options` - a keyword list of options, see below.
## Options
* `:proto3_optional` - boolean representing whether the field is optional with the `proto3`
syntax. See [the
documentation](https://developers.google.com/protocol-buffers/docs/proto3#oneof)
* `:type` - atom representing the type of the field.
* `:repeated` - boolean representing whether the field is repeated.
* `:optional` - boolean representing whether the field is optional.
* `:required` - boolean representing whether the field is required.
* `:enum` - boolean representing whether the field is a possible value of an enum.
* `:map` - boolean representing whether the field is a part of a map.
* `:default` - the default value of the field. Must be a valid Elixir term at compile time,
that can be encoded with Protobuf and matches with the type of the field.
* `:packed` - boolean representing whether the field is packed.
* `:deprecated` - boolean representing whether the field is deprecated.
* `:json_name` - if present, specifies the name of the field when using the JSON mapping (see
`Protobuf.JSON`). If not present, the default mapping will be used.
"""
defmacro field(name, field_number, options \\ []) do
quote bind_quoted: [name: name, fnum: field_number, options: options] do
if not is_atom(name) do
raise ArgumentError, "expected atom as the field name, got: #{inspect(name)}"
end
if not is_integer(fnum) do
raise ArgumentError,
"expected integer as the field number, got: #{inspect(fnum)}"
end
if not Keyword.keyword?(options) do
raise ArgumentError, "expected a keyword list as the options, got: #{inspect(options)}"
end
@fields {name, fnum, options}
end
end
@doc """
Define oneof in the message module.
"""
defmacro oneof(name, index) do
quote do
@oneofs {unquote(name), unquote(index)}
end
end
@doc """
Define "extend" for a message(the first argument module).
"""
defmacro extend(mod, name, fnum, options) do
quote do
@extends {unquote(mod), unquote(name), unquote(fnum), unquote(options)}
end
end
@doc """
Define extensions range in the message module to allow extensions for this module.
Extension ranges are defined as a list of tuples `{start, end}`, where each tuple is
an **exclusive** range starting and `start` and ending at `end` (the equivalent
of `start..end-1` in Elixir).
To simulate the Protobuf `max` keyword, you can use `Protobuf.Extension.max/0`.
## Examples
These Protobuf definition:
```protobuf
message Foo {
extensions 1, 10 to 20, 100 to max;
}
```
Would be translated in Elixir to:
extensions [{1, 2}, {10, 21}, {100, Protobuf.Extension.max()}]
"""
defmacro extensions(ranges) do
quote do
ranges = unquote(ranges)
if not is_list(ranges) do
raise ArgumentError, "expected a list of ranges, got: #{inspect(ranges)}"
end
Enum.each(ranges, fn
value when not is_tuple(value) or tuple_size(value) != 2 ->
raise ArgumentError, "expected a range, got: #{inspect(value)}"
{left, right} when not is_integer(left) or not is_integer(right) ->
raise ArgumentError, "expected an integer range, got: #{inspect({left, right})}"
{left, right} when left >= right ->
raise ArgumentError, "expected an ordered range, got: #{inspect({left, right})}"
other ->
:ok
end)
@extensions unquote(ranges)
end
end
alias Protobuf.FieldProps
alias Protobuf.MessageProps
alias Protobuf.Wire
# Registered as the @before_compile callback for modules that call "use Protobuf".
defmacro __before_compile__(env) do
fields = Module.get_attribute(env.module, :fields)
options = Module.get_attribute(env.module, :options)
oneofs = Module.get_attribute(env.module, :oneofs)
extensions = Module.get_attribute(env.module, :extensions)
extension_props =
Module.get_attribute(env.module, :extends)
|> gen_extension_props()
msg_props = generate_message_props(fields, oneofs, extensions, options)
defines_t_type? = Module.defines_type?(env.module, {:t, 0})
defines_defstruct? = Module.defines?(env.module, {:__struct__, 1})
quote do
@spec __message_props__() :: Protobuf.MessageProps.t()
def __message_props__ do
unquote(Macro.escape(msg_props))
end
cond do
# If this is an enum and "@type t()" is already called, it's fine because it's likely
# the old code generated by this library.
unquote(msg_props.enum?) and unquote(defines_t_type?) ->
IO.warn("""
Since v0.10.0 of the :protobuf library, the t/0 type in Protobuf enum modules \
is automatically generated by "use Protobuf". Remove your explicit definition or \
regenerate the files with a newer version of the protoc-gen-elixir escript. \
This warning will become an error in version 0.11.0+ of this library.
""")
# If both "defstruct" and "@type t()" are called, it's probably okay because it's the code
# we used to generated before from this library, but we want to get rid of it, so we warn.
unquote(defines_defstruct?) and unquote(defines_t_type?) ->
IO.warn("""
Since v0.10.0 of the :protobuf library, the t/0 type and the struct are automatically \
generated for modules that call "use Protobuf" if they are Protobuf enums or messages. \
Remove your explicit definition of both of these or regenerate the files with the \
latest version of the protoc-gen-elixir plugin. This warning will become an error \
in version 0.11.0+ of the :protobuf library.\
""")
# If users defined only "defstruct" OR "@type t()", it means either they didn't generate
# the code through this library or they modified the generated files. In either case,
# let's raise here since we could have inconsistencies between the user-defined spec/type
# and our type/spec, respectively.
unquote(not msg_props.enum?) and (unquote(defines_defstruct?) or unquote(defines_t_type?)) ->
what = if unquote(defines_defstruct?), do: "defstruct/1", else: "@type t() :: ..."
raise """
since v0.10.0 of the :protobuf library, the t/0 type and the struct are automatically \
generated for modules that call "use Protobuf" if they are Protobuf enums or messages. \
This was the case for #{inspect(unquote(__MODULE__))}, where you call #{what}. \
This could cause inconsistencies with the type or struct generated by the library. \
You can either:
* make sure that you define both the t/0 type as well as the struct, but that will
become an error in later versions of the Protobuf library
* remove both the t/0 type definition as well as the struct definition and let the
library define both
* regenerate the file from the Protobuf source definition with the latest version
of the protoc-gen-elixir plugin, which won't generate the struct or the t/0 type
definition
"""
# Newest version of this library generate both the t/0 type as well as the struct.
true ->
unquote(def_t_typespec(msg_props, extension_props))
unquote(gen_defstruct(msg_props))
end
unquote(msg_props.enum? && Protobuf.DSL.Enum.quoted_enum_functions(msg_props))
if unquote(Macro.escape(extension_props)) != nil do
def __protobuf_info__(:extension_props) do
unquote(Macro.escape(extension_props))
end
end
if unquote(Macro.escape(extensions)) do
unquote(def_extension_functions())
end
end
end
defp def_t_typespec(%MessageProps{enum?: true} = props, _extension_props) do
quote do
@type t() :: unquote(Protobuf.DSL.Typespecs.quoted_enum_typespec(props))
end
end
defp def_t_typespec(%MessageProps{} = props, _extension_props = nil) do
quote do
@type t() :: unquote(Protobuf.DSL.Typespecs.quoted_message_typespec(props))
end
end
defp def_t_typespec(_props, _extension_props) do
nil
end
defp def_extension_functions() do
quote do
def put_extension(%{} = map, extension_mod, field, value) do
Protobuf.Extension.put(__MODULE__, map, extension_mod, field, value)
end
def get_extension(struct, extension_mod, field, default \\ nil) do
Protobuf.Extension.get(struct, extension_mod, field, default)
end
end
end
defp generate_message_props(fields, oneofs, extensions, options) do
syntax = Keyword.get(options, :syntax, :proto2)
field_props =
Map.new(fields, fn {name, fnum, opts} -> {fnum, field_props(syntax, name, fnum, opts)} end)
# The "reverse" of field props, that is, a map from atom name to field number.
# We calculate this from "fields" instead of just reversing "field_props" because
# enum fields can have aliases, that is, names that have the same integer tag. "field_props"
# is a map with field tags as keys, so fields with the same "field_tags" have been
# squashed together.
field_tags = Map.new(fields, fn {name, fnum, _opts} -> {name, fnum} end)
repeated_fields =
for {_fnum, %FieldProps{repeated?: true, name_atom: name}} <- field_props,
do: name
embedded_fields =
for {_fnum, %FieldProps{embedded?: true, map?: false, name_atom: name}} <- field_props,
do: name
%MessageProps{
tags_map: Map.new(fields, fn {_, fnum, _} -> {fnum, fnum} end),
ordered_tags: field_props |> Map.keys() |> Enum.sort(),
field_props: field_props,
field_tags: field_tags,
repeated_fields: repeated_fields,
embedded_fields: embedded_fields,
syntax: syntax,
oneof: Enum.reverse(oneofs),
enum?: Keyword.get(options, :enum) == true,
map?: Keyword.get(options, :map) == true,
extension_range: extensions
}
end
defp gen_extension_props([_ | _] = extends) do
extensions =
Map.new(extends, fn {extendee, name_atom, fnum, opts} ->
# Only proto2 has extensions
props = field_props(:proto2, name_atom, fnum, opts)
props = %Protobuf.Extension.Props.Extension{
extendee: extendee,
field_props: props
}
{{extendee, fnum}, props}
end)
name_to_tag =
Map.new(extends, fn {extendee, name_atom, fnum, _opts} ->
{{extendee, name_atom}, {extendee, fnum}}
end)
%Protobuf.Extension.Props{extensions: extensions, name_to_tag: name_to_tag}
end
defp gen_extension_props(_) do
nil
end
defp field_props(syntax, name, fnum, opts) do
%FieldProps{
fnum: fnum,
name: Atom.to_string(name),
name_atom: name
}
|> parse_field_opts_to_field_props(opts)
|> verify_no_default_in_proto3(syntax)
|> wrap_enum_type()
|> cal_label(syntax)
|> cal_json_name()
|> cal_embedded()
|> cal_packed(syntax)
|> cal_repeated()
|> cal_encoded_fnum()
end
defp parse_field_opts_to_field_props(%FieldProps{} = props, opts) do
Enum.reduce(opts, props, fn
{:optional, optional?}, acc ->
%FieldProps{acc | optional?: optional?}
{:proto3_optional, proto3_optional?}, acc ->
%FieldProps{acc | proto3_optional?: proto3_optional?}
{:required, required?}, acc ->
%FieldProps{acc | required?: required?}
{:enum, enum?}, acc ->
%FieldProps{acc | enum?: enum?}
{:map, map?}, acc ->
%FieldProps{acc | map?: map?}
{:repeated, repeated?}, acc ->
%FieldProps{acc | repeated?: repeated?}
{:embedded, embedded}, acc ->
%FieldProps{acc | embedded?: embedded}
{:deprecated, deprecated?}, acc ->
%FieldProps{acc | deprecated?: deprecated?}
{:packed, packed?}, acc ->
%FieldProps{acc | packed?: packed?}
{:type, type}, acc ->
%FieldProps{acc | type: type}
{:default, default}, acc ->
%FieldProps{acc | default: default}
{:oneof, oneof}, acc ->
%FieldProps{acc | oneof: oneof}
{:json_name, json_name}, acc ->
%FieldProps{acc | json_name: json_name}
end)
end
defp cal_label(%FieldProps{} = props, :proto3) do
if props.required? do
raise Protobuf.InvalidError, message: "required can't be used in proto3"
else
%FieldProps{props | optional?: true}
end
end
defp cal_label(props, _syntax), do: props
defp wrap_enum_type(%FieldProps{enum?: true, type: type} = props) do
%FieldProps{props | type: {:enum, type}, wire_type: Wire.wire_type({:enum, type})}
end
defp wrap_enum_type(%FieldProps{type: type} = props) do
%FieldProps{props | wire_type: Wire.wire_type(type)}
end
# The compiler always emits a json name, but we omit it in the DSL when it
# matches the name, to keep it uncluttered. Now we infer it back from name.
defp cal_json_name(%FieldProps{json_name: name} = props) when is_binary(name), do: props
defp cal_json_name(props), do: %FieldProps{props | json_name: props.name}
defp verify_no_default_in_proto3(%FieldProps{} = props, syntax) do
if syntax == :proto3 and not is_nil(props.default) do
raise Protobuf.InvalidError, message: "default can't be used in proto3"
else
props
end
end
defp cal_embedded(%FieldProps{type: type, enum?: false} = props) when is_atom(type) do
case to_string(type) do
"Elixir." <> _ -> %FieldProps{props | embedded?: true}
_ -> props
end
end
defp cal_embedded(props), do: props
defp cal_packed(%FieldProps{packed?: true, repeated?: repeated?} = props, _syntax) do
cond do
props.embedded? -> raise ":packed can't be used with :embedded field"
repeated? -> %FieldProps{props | packed?: true}
true -> raise ":packed must be used with :repeated"
end
end
defp cal_packed(%FieldProps{packed?: false} = props, _syntax) do
props
end
defp cal_packed(%FieldProps{type: type, repeated?: true} = props, :proto3) do
packed? = (props.enum? or not props.embedded?) and type_numeric?(type)
%FieldProps{props | packed?: packed?}
end
defp cal_packed(props, _syntax), do: %FieldProps{props | packed?: false}
defp cal_repeated(%FieldProps{map?: true} = props), do: %FieldProps{props | repeated?: false}
defp cal_repeated(%FieldProps{repeated?: true, oneof: oneof}) when not is_nil(oneof),
do: raise(":oneof can't be used with repeated")
defp cal_repeated(props), do: props
defp cal_encoded_fnum(%FieldProps{fnum: fnum, packed?: true} = props) do
encoded_fnum = Protobuf.Encoder.encode_fnum(fnum, Wire.wire_type(:bytes))
%FieldProps{props | encoded_fnum: encoded_fnum}
end
defp cal_encoded_fnum(%FieldProps{fnum: fnum, wire_type: wire_type} = props) do
encoded_fnum = Protobuf.Encoder.encode_fnum(fnum, wire_type)
%FieldProps{props | encoded_fnum: encoded_fnum}
end
defp gen_defstruct(%MessageProps{} = message_props) do
regular_fields =
for {_fnum, %FieldProps{oneof: oneof, proto3_optional?: p3o} = prop} <- message_props.field_props,
oneof == nil or p3o,
do: {prop.name_atom, field_default(message_props.syntax, prop)}
oneof_fields =
for {name_atom, _fnum} <- message_props.oneof,
do: {name_atom, _struct_default = nil}
extension_fields =
if message_props.extension_range do
[{:__pb_extensions__, _default = %{}}]
else
[]
end
unknown_fields = {:__unknown_fields__, _default = []}
struct_fields = regular_fields ++ oneof_fields ++ extension_fields
struct_fields = if Protobuf.Compat.is_compat?() do
struct_fields
else
struct_fields ++ [unknown_fields]
end
quote do
defstruct unquote(Macro.escape(struct_fields))
end
end
defp type_numeric?(:int32), do: true
defp type_numeric?(:int64), do: true
defp type_numeric?(:uint32), do: true
defp type_numeric?(:uint64), do: true
defp type_numeric?(:sint32), do: true
defp type_numeric?(:sint64), do: true
defp type_numeric?(:bool), do: true
defp type_numeric?({:enum, _}), do: true
defp type_numeric?(:fixed32), do: true
defp type_numeric?(:sfixed32), do: true
defp type_numeric?(:fixed64), do: true
defp type_numeric?(:sfixed64), do: true
defp type_numeric?(:float), do: true
defp type_numeric?(:double), do: true
defp type_numeric?(_), do: false
# Used by Protobuf.Decoder
@doc false
def field_default(syntax, field_props)
def field_default(_syntax, %FieldProps{default: default}) when not is_nil(default), do: default
def field_default(_syntax, %FieldProps{repeated?: true}), do: []
def field_default(_syntax, %FieldProps{map?: true}), do: %{}
def field_default(:proto3, %FieldProps{proto3_optional?: true}), do: nil
def field_default(:proto3, props), do: type_default(props.type)
def field_default(_syntax, _props), do: nil
defp type_default(:int32), do: 0
defp type_default(:int64), do: 0
defp type_default(:uint32), do: 0
defp type_default(:uint64), do: 0
defp type_default(:sint32), do: 0
defp type_default(:sint64), do: 0
defp type_default(:bool), do: false
defp type_default({:enum, mod}), do: mod.key(0)
defp type_default(:fixed32), do: 0
defp type_default(:sfixed32), do: 0
defp type_default(:fixed64), do: 0
defp type_default(:sfixed64), do: 0
defp type_default(:float), do: 0.0
defp type_default(:double), do: 0.0
defp type_default(:bytes), do: <<>>
defp type_default(:string), do: ""
defp type_default(:message), do: nil
defp type_default(:group), do: nil
defp type_default(_), do: nil
end