Browse Source

Merge pull request #217 from seriyps/configurable-hstore

Add `return` and `nulls` options to hstore codec
Sergey Prokhorov 5 years ago
parent
commit
c965d6cc56
4 changed files with 104 additions and 32 deletions
  1. 8 2
      README.md
  2. 1 2
      rebar.config
  3. 58 27
      src/datatypes/epgsql_codec_hstore.erl
  4. 37 1
      test/epgsql_SUITE.erl

+ 8 - 2
README.md

@@ -465,8 +465,8 @@ PG type       | Representation
   record      | `{int2, time, text, ...}` (decode only)
   record      | `{int2, time, text, ...}` (decode only)
   point       |  `{10.2, 100.12}`
   point       |  `{10.2, 100.12}`
   int4range   | `[1,5)`
   int4range   | `[1,5)`
-  hstore      | `{[ {binary(), binary() \| null} ]}`
-  json/jsonb  | `<<"{ \"key\": [ 1, 1.0, true, \"string\" ] }">>` (see below for codec details)
+  hstore      | `{[ {binary(), binary() \| null} ]}` (configurable)
+  json/jsonb  | `<<"{ \"key\": [ 1, 1.0, true, \"string\" ] }">>` (configurable)
   uuid        | `<<"123e4567-e89b-12d3-a456-426655440000">>`
   uuid        | `<<"123e4567-e89b-12d3-a456-426655440000">>`
   inet        | `inet:ip_address()`
   inet        | `inet:ip_address()`
   cidr        | `{ip_address(), Mask :: 0..32}`
   cidr        | `{ip_address(), Mask :: 0..32}`
@@ -487,6 +487,12 @@ and `plus_infinity`
 `tsrange`, `tstzrange`, `daterange` are range types for `timestamp`, `timestamptz` and `date`
 `tsrange`, `tstzrange`, `daterange` are range types for `timestamp`, `timestamptz` and `date`
 respectively. They can return `empty` atom as the result from a database if bounds are equal
 respectively. They can return `empty` atom as the result from a database if bounds are equal
 
 
+`hstore` type can take map or jiffy-style objects as input. Output can be tuned by
+providing `return :: map | jiffy | proplist` option to choose the format to which
+hstore should be decoded. `nulls :: [atom(), ...]` option can be used to select the
+terms which should be interpreted as SQL `NULL` - semantics is the same as
+for `connect/1` `nulls` option.
+
 `json` and `jsonb` types can optionally use a custom JSON encoding/decoding module to accept
 `json` and `jsonb` types can optionally use a custom JSON encoding/decoding module to accept
 and return erlang-formatted JSON. The module must implement the callbacks in `epgsql_codec_json`,
 and return erlang-formatted JSON. The module must implement the callbacks in `epgsql_codec_json`,
 which most popular open source JSON parsers will already, and you can specify it in the codec
 which most popular open source JSON parsers will already, and you can specify it in the codec

+ 1 - 2
rebar.config

@@ -1,7 +1,5 @@
 %% -*- mode: erlang -*-
 %% -*- mode: erlang -*-
 
 
-{eunit_opts, [verbose]}.
-
 {cover_enabled, true}.
 {cover_enabled, true}.
 
 
 {edoc_opts, [{preprocess, true}]}.
 {edoc_opts, [{preprocess, true}]}.
@@ -29,6 +27,7 @@
      rules =>
      rules =>
          [{elvis_style, line_length, #{limit => 120}},
          [{elvis_style, line_length, #{limit => 120}},
           {elvis_style, god_modules, #{limit => 41}},
           {elvis_style, god_modules, #{limit => 41}},
+          {elvis_style, dont_repeat_yourself, #{min_complexity => 11}},
           {elvis_style, state_record_and_type, disable} % epgsql_sock
           {elvis_style, state_record_and_type, disable} % epgsql_sock
          ]}
          ]}
   ]
   ]

+ 58 - 27
src/datatypes/epgsql_codec_hstore.erl

@@ -1,6 +1,11 @@
 %%% @doc
 %%% @doc
 %%% Codec for `hstore' type.
 %%% Codec for `hstore' type.
 %%%
 %%%
+%%% Hstore codec can take a jiffy-style object or map() as input.
+%%% Output format can be changed by providing `return' option. See {@link return_format()}.
+%%% Values of hstore can be `NULL'. NULL representation can be changed by providing
+%%% `nulls' option, semantics is similar to {@link epgsql:connect_opts()} `nulls' option.
+%%%
 %%% XXX: hstore is not a part of postgresql builtin datatypes, it's in contrib.
 %%% XXX: hstore is not a part of postgresql builtin datatypes, it's in contrib.
 %%% It should be enabled in postgresql by command `CREATE EXTENSION hstore'.
 %%% It should be enabled in postgresql by command `CREATE EXTENSION hstore'.
 %%% <ul>
 %%% <ul>
@@ -17,43 +22,71 @@
 
 
 -include("protocol.hrl").
 -include("protocol.hrl").
 
 
--export_type([data/0]).
+-export_type([data/0, options/0, return_format/0]).
 
 
 -type data() :: data_in() | data_out().
 -type data() :: data_in() | data_out().
 
 
 -type key_in() :: list() | binary() | atom() | integer() | float().
 -type key_in() :: list() | binary() | atom() | integer() | float().
-%% jiffy-style maps
--type data_in() :: { [{key_in(), binary()}] }.
--type data_out() :: { [{Key :: binary(), Value :: binary()}] }.
+-type data_in() :: { [{key_in(), binary()}] } |
+                   #{key_in() => binary() | atom()}.
+-type data_out() :: { [{Key :: binary(), Value :: binary()}] } |      % jiffy
+                    [{Key :: binary(), Value :: binary() | atom()}] | % proplist
+                    #{binary() => binary() | atom()}.                 % map
+
+-type return_format() :: map | jiffy | proplist.
+-type options() :: #{return => return_format(),
+                     nulls => [atom(), ...]}.
+
+-record(st,
+        {return :: return_format(),
+         nulls :: [atom(), ...]}).
 
 
 -dialyzer([{nowarn_function, [encode/3]}, no_improper_lists]).
 -dialyzer([{nowarn_function, [encode/3]}, no_improper_lists]).
 
 
-%% TODO: option for output format: proplist | jiffy-object | map
-init(_, _) -> [].
+init(Opts0, _) ->
+    Opts = epgsql:to_map(Opts0),
+    #st{return = maps:get(return, Opts, jiffy),
+        nulls = maps:get(nulls, Opts, [null, undefined])}.
 
 
 names() ->
 names() ->
     [hstore].
     [hstore].
 
 
-encode({Hstore}, hstore, _) when is_list(Hstore) ->
-    Size = length(Hstore),
-    %% TODO: construct improper list when support for Erl 17 will be dropped
-    Body = [[encode_key(K), encode_value(V)]
-           || {K, V} <- Hstore],
-    [<<Size:?int32>> | Body].
+encode({KV}, hstore, #st{nulls = Nulls}) when is_list(KV) ->
+    Size = length(KV),
+    encode_kv(KV, Size, Nulls);
+encode(Map, hstore, #st{nulls = Nulls}) when is_map(Map) ->
+    Size = map_size(Map),
+    encode_kv(maps:to_list(Map), Size, Nulls).
+
+decode(<<Size:?int32, Elements/binary>>, hstore, #st{return = Return, nulls = Nulls}) ->
+    KV = do_decode(Size, Elements, hd(Nulls)),
+    case Return of
+        jiffy ->
+            {KV};
+        map ->
+            maps:from_list(KV);
+        proplist ->
+            KV
+    end.
 
 
-decode(<<Size:?int32, Elements/binary>>, hstore, _) ->
-    {do_decode(Size, Elements)}.
+decode_text(V, _, _) -> V.
 
 
+%% Internal
+
+encode_kv(KV, Size, Nulls) ->
+    %% TODO: construct improper list when support for Erl 17 will be dropped
+    Body = [[encode_key(K), encode_value(V, Nulls)]
+           || {K, V} <- KV],
+    [<<Size:?int32>> | Body].
 
 
 encode_key(K) ->
 encode_key(K) ->
     encode_string(K).
     encode_string(K).
 
 
-encode_value(null) ->
-    <<-1:?int32>>;
-encode_value(undefined) ->
-    <<-1:?int32>>;
-encode_value(V) ->
-    encode_string(V).
+encode_value(V, Nulls) ->
+    case lists:member(V, Nulls) of
+        true -> <<-1:?int32>>;
+        false -> encode_string(V)
+    end.
 
 
 encode_string(Str) when is_binary(Str) ->
 encode_string(Str) when is_binary(Str) ->
     <<(byte_size(Str)):?int32, Str/binary>>;
     <<(byte_size(Str)):?int32, Str/binary>>;
@@ -68,11 +101,9 @@ encode_string(Str) when is_float(Str) ->
     %% encode_string(erlang:float_to_binary(Str)).
     %% encode_string(erlang:float_to_binary(Str)).
 
 
 
 
-do_decode(0, _) -> [];
-do_decode(N, <<KeyLen:?int32, Key:KeyLen/binary, -1:?int32, Rest/binary>>) ->
-    [{Key, null} | do_decode(N - 1, Rest)];
+do_decode(0, _, _) -> [];
+do_decode(N, <<KeyLen:?int32, Key:KeyLen/binary, -1:?int32, Rest/binary>>, Null) ->
+    [{Key, Null} | do_decode(N - 1, Rest, Null)];
 do_decode(N, <<KeyLen:?int32, Key:KeyLen/binary,
 do_decode(N, <<KeyLen:?int32, Key:KeyLen/binary,
-               ValLen:?int32, Value:ValLen/binary, Rest/binary>>) ->
-    [{Key, Value} | do_decode(N - 1, Rest)].
-
-decode_text(V, _, _) -> V.
+               ValLen:?int32, Value:ValLen/binary, Rest/binary>>, Null) ->
+    [{Key, Value} | do_decode(N - 1, Rest, Null)].

+ 37 - 1
test/epgsql_SUITE.erl

@@ -900,6 +900,7 @@ misc_type(Config) ->
     check_type(Config, bytea, "E'\001\002'", <<1,2>>, [<<>>, <<0,128,255>>]).
     check_type(Config, bytea, "E'\001\002'", <<1,2>>, [<<>>, <<0,128,255>>]).
 
 
 hstore_type(Config) ->
 hstore_type(Config) ->
+    Module = ?config(module, Config),
     Values = [
     Values = [
         {[]},
         {[]},
         {[{null, null}]},
         {[{null, null}]},
@@ -915,7 +916,42 @@ hstore_type(Config) ->
     check_type(Config, hstore, "''", {[]}, []),
     check_type(Config, hstore, "''", {[]}, []),
     check_type(Config, hstore,
     check_type(Config, hstore,
                "'a => 1, b => 2.0, c => null'",
                "'a => 1, b => 2.0, c => null'",
-               {[{<<"a">>, <<"1">>}, {<<"b">>, <<"2.0">>}, {<<"c">>, null}]}, Values).
+               {[{<<"a">>, <<"1">>}, {<<"b">>, <<"2.0">>}, {<<"c">>, null}]}, Values),
+    epgsql_ct:with_connection(
+      Config,
+      fun(C) ->
+              %% Maps as input
+              [begin
+                   {ok, _, [{Res}]} = Module:equery(C, "select $1::hstore", [maps:from_list(KV)]),
+                   ?assert(compare(hstore, Res, Jiffy))
+               end || {KV} = Jiffy <- Values],
+              %% Maps as output
+              {ok, [hstore]} = epgsql:update_type_cache(
+                                 C, [{epgsql_codec_hstore, #{return => map}}]),
+              [begin
+                   {ok, _, [{Res}]} = Module:equery(C, "select $1::hstore", [maps:from_list(KV)]),
+                   HstoreMap = maps:from_list([{format_hstore_key(K), format_hstore_value(V)} || {K, V} <- KV]),
+                   ?assertEqual(HstoreMap, Res)
+               end || {KV} <- Values],
+              %% Proplist as output
+              {ok, [hstore]} = epgsql:update_type_cache(
+                                 C, [{epgsql_codec_hstore, #{return => proplist}}]),
+              [begin
+                   {ok, _, [{Res}]} = Module:equery(C, "select $1::hstore", [Jiffy]),
+                   HstoreProplist = [{format_hstore_key(K), format_hstore_value(V)} || {K, V} <- KV],
+                   ?assertEqual(lists:sort(HstoreProplist), lists:sort(Res))
+               end || {KV} = Jiffy <- Values],
+              %% Custom nulls
+              Nulls = [nil, 'NULL', aaaa],
+              {ok, [hstore]} = epgsql:update_type_cache(
+                                 C, [{epgsql_codec_hstore, #{return => map,
+                                                             nulls => Nulls}}]),
+              K = <<"k">>,
+              [begin
+                   {ok, _, [{Res}]} = Module:equery(C, "select $1::hstore", [#{K => V}]),
+                   ?assertEqual(#{K => nil}, Res)
+               end || V <- Nulls]
+      end).
 
 
 net_type(Config) ->
 net_type(Config) ->
     check_type(Config, cidr, "'127.0.0.1/32'", {{127,0,0,1}, 32}, [{{127,0,0,1}, 32}, {{0,0,0,0,0,0,0,1}, 128}]),
     check_type(Config, cidr, "'127.0.0.1/32'", {{127,0,0,1}, 32}, [{{127,0,0,1}, 32}, {{0,0,0,0,0,0,0,1}, 128}]),