-module(influxdb_line_encoding). -export([ encode/1 ]). -export_type([ point/0, measurement/0, tags/0, fields/0, timestamp/0 ]). -type point() :: {measurement(), tags(), fields(), timestamp()} | {measurement(), tags(), fields()}. -type measurement() :: key(). -type tags() :: #{key() => iodata() | atom()}. -type fields() :: #{key() => number() | boolean() | iodata() | atom()}. -type key() :: iodata() | atom(). -type timestamp() :: integer(). -spec encode([point()]) -> iodata(). %% @doc encode a list of `Measurement`, `Tags`, `Fields` and `Timestamp` into multiple lines. encode(Measurements) when is_list(Measurements) -> encode_(Measurements, []). encode_([{Measurement, Tags, Fields, Timestamp} | Rest], Acc) -> encode_(Rest, [encode(Measurement, Tags, Fields, Timestamp) | Acc]); encode_([{Measurement, Tags, Fields} | Rest], Acc) -> encode_(Rest, [encode(Measurement, Tags, Fields, undefined) | Acc]); encode_([], Acc) -> lists:reverse(Acc). -spec encode(measurement(), tags(), fields(), timestamp() | undefined) -> iodata(). %% @doc encode `Measurement`, `Tags`, `Fields` and `Timestamp` into a line, including the final line feed. encode(Measurement, Tags, Fields, Timestamp) -> [encode_measurement(Measurement), encode_tags(Tags), $\s, encode_fields(Fields), encode_timestamp(Timestamp), $\n]. % Internals %% @doc encode the measurement name, escaping `,` and ` ` (space). encode_measurement(Measurement) when is_atom(Measurement) -> encode_measurement(atom_to_list(Measurement)); encode_measurement(Measurement) -> escape(Measurement, fun (C) when C =:= $, orelse C =:= $\s -> [$\\, C]; (C) -> C end). %% @doc encode the tags map, escaping `,`, ` ` (space) and `=` in both the tag name and tag value. The encoded tags map %% includes the `,` prefix in the case of the non empty map. encode_tags(Tags) -> encode_tags(lists:ukeysort(1, [{normalize_key(Key), Value} || {Key, Value} <- maps:to_list(Tags)]), []). encode_tags([], Acc) -> lists:reverse(Acc); encode_tags([{Key, Value} | Rest], Acc) -> encode_tags(Rest, [encode_tag_value(Value), $=, encode_key(Key), $, | Acc]). %% @doc encode the fields map, escaping `,`, ` ` (space) and `=` in the field name and using the right encoding for the %% field value based on its type. encode_fields(Fields) -> encode_fields(lists:ukeysort(1, [{normalize_key(Key), Value} || {Key, Value} <- maps:to_list(Fields)]), []). encode_fields([{Key, Value}], Acc) -> lists:reverse(Acc, [encode_key(Key), $=, encode_field_value(Value)]); encode_fields([{Key, Value} | Rest], Acc) -> encode_fields(Rest, [$,, encode_field_value(Value), $=, encode_key(Key) | Acc]). %% @doc encode a timestamp. This includes the prefix ` ` (space) if the value is not undefined. encode_timestamp(undefined) -> []; encode_timestamp(Timestamp) -> [$\s, erlang:integer_to_binary(Timestamp)]. %% @doc convert the key from iodata() | atom() to string(). normalize_key(Data) when is_atom(Data) -> erlang:atom_to_list(Data); normalize_key(Data) -> unicode:characters_to_list(Data). %% @doc encode a tag or field key. encode_key(Data) -> escape(Data, fun (C) when C =:= $, orelse C =:= $= orelse C =:= $\s -> [$\\, C]; (C) -> C end). %% @doc encode a tag value. encode_tag_value(Data) when is_atom(Data) -> encode_tag_value(atom_to_list(Data)); encode_tag_value(Data) -> escape(Data, fun (C) when C =:= $, orelse C =:= $= orelse C =:= $\s -> [$\\, C]; (C) -> C end). %% @doc encode a field value. encode_field_value(Value) when is_integer(Value) -> [erlang:integer_to_binary(Value), $i]; encode_field_value(Value) when is_float(Value) -> erlang:float_to_binary(Value, [{decimals, 10}, compact]); encode_field_value(true) -> <<"t">>; encode_field_value(false) -> <<"f">>; encode_field_value(Value) when is_atom(Value) -> encode_field_value(atom_to_list(Value)); encode_field_value(Value) when is_list(Value) orelse is_binary(Value) -> [$", escape(Value, fun (C) when C =:= $" -> [$\\, C]; (C) -> C end), $"]. %% @doc encode %% @doc internal function used for traversing an iodata structure and escaping characters using the given escape %% function. escape(Data, EscapeFun) when is_list(Data) -> [if is_integer(C) -> EscapeFun(C); is_list(C) orelse is_binary(C) -> escape(C, EscapeFun) end || C <- Data]; escape(Bin, EscapeFun) when is_binary(Bin) -> unicode:characters_to_binary(escape(unicode:characters_to_list(Bin), EscapeFun)). -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -define(label(S), unicode:characters_to_list(io_lib:format("~p", [S]))). encode_measurement_test_() -> Tests = #{ "string" => "string", <<"binary">> => "binary", ["io", <<"l">>, $i, <<"st">>] => "iolist", "spaces🚀️, commas and emoji" => "spaces🚀️\\,\\ commas\\ and\\ emoji", "spaces🚀️ and emoji" => "spaces🚀️\\ and\\ emoji" }, [{?label(Measurement), ?_assertEqual(Encoded, unicode:characters_to_list(encode_measurement(Measurement)))} || {Measurement, Encoded} <- maps:to_list(Tests)]. encode_tags_test_() -> Tests = #{ #{} => "", #{"key" => "value"} => ",key=value", #{"b" => "B", ["a", <<"1">>] => "A"} => ",a1=A,b=B" }, [{?label(Tags), ?_assertEqual(Encoded, unicode:characters_to_list(encode_tags(Tags)))} || {Tags, Encoded} <- maps:to_list(Tests)]. encode_fields_test_() -> Tests = #{ #{"float" => 1.2} => "float=1.2", #{"integer" => 1} => "integer=1i", #{"true" => true} => "true=t", #{"false" => false} => "false=f", #{"string" => "string"} => "string=\"string\"" }, [{?label(Fields), ?_assertEqual(Encoded, unicode:characters_to_list(encode_fields(Fields)))} || {Fields, Encoded} <- maps:to_list(Tests)]. encode_timestamp_test_() -> Now = erlang:system_time(nano_seconds), Tests = #{ undefined => "", Now => [$\s, erlang:integer_to_binary(Now)] }, [{?label(Timestamp), ?_assertEqual(Encoded, encode_timestamp(Timestamp))} || {Timestamp, Encoded} <- maps:to_list(Tests)]. -endif.