Browse Source

Merge pull request #197 from UmanShahzad/uman/configurable-json-codec

Allow configuring a json module with options for encode/decode in the json codec
Sergey Prokhorov 5 years ago
parent
commit
3cfe73a4df
4 changed files with 144 additions and 50 deletions
  1. 52 21
      README.md
  2. 34 4
      src/datatypes/epgsql_codec_json.erl
  3. 42 25
      test/epgsql_SUITE.erl
  4. 16 0
      test/epgsql_fake_json_mod.erl

+ 52 - 21
README.md

@@ -79,7 +79,9 @@ connect(Opts) -> {ok, Connection :: epgsql:connection()} | {error, Reason :: epg
 
 
 connect(Host, Username, Password, Opts) -> {ok, C} | {error, Reason}.
 connect(Host, Username, Password, Opts) -> {ok, C} | {error, Reason}.
 ```
 ```
+
 example:
 example:
+
 ```erlang
 ```erlang
 {ok, C} = epgsql:connect("localhost", "username", "psss", #{
 {ok, C} = epgsql:connect("localhost", "username", "psss", #{
     database => "test_db",
     database => "test_db",
@@ -146,7 +148,9 @@ Asynchronous connect example (applies to **epgsqli** too):
 %% @doc runs simple `SqlQuery' via given `Connection'
 %% @doc runs simple `SqlQuery' via given `Connection'
 squery(Connection, SqlQuery) -> ...
 squery(Connection, SqlQuery) -> ...
 ```
 ```
+
 examples:
 examples:
+
 ```erlang
 ```erlang
 epgsql:squery(C, "insert into account (name) values  ('alice'), ('bob')").
 epgsql:squery(C, "insert into account (name) values  ('alice'), ('bob')").
 > {ok,2}
 > {ok,2}
@@ -237,6 +241,7 @@ end.
 {ok, Count, Columns, Rows} = epgsql:equery(C, "insert ... returning ...", [Parameters]).
 {ok, Count, Columns, Rows} = epgsql:equery(C, "insert ... returning ...", [Parameters]).
 {error, Error}             = epgsql:equery(C, "invalid SQL", [Parameters]).
 {error, Error}             = epgsql:equery(C, "invalid SQL", [Parameters]).
 ```
 ```
+
 `Parameters` - optional list of values to be bound to `$1`, `$2`, `$3`, etc.
 `Parameters` - optional list of values to be bound to `$1`, `$2`, `$3`, etc.
 
 
 The extended query protocol combines parse, bind, and execute using
 The extended query protocol combines parse, bind, and execute using
@@ -276,17 +281,20 @@ end.
 squery including final `{C, Ref, done}`.
 squery including final `{C, Ref, done}`.
 
 
 ### Prepared Query
 ### Prepared Query
+
 ```erlang
 ```erlang
 {ok, Columns, Rows}        = epgsql:prepared_query(C, StatementName, [Parameters]).
 {ok, Columns, Rows}        = epgsql:prepared_query(C, StatementName, [Parameters]).
 {ok, Count}                = epgsql:prepared_query(C, StatementName, [Parameters]).
 {ok, Count}                = epgsql:prepared_query(C, StatementName, [Parameters]).
 {ok, Count, Columns, Rows} = epgsql:prepared_query(C, StatementName, [Parameters]).
 {ok, Count, Columns, Rows} = epgsql:prepared_query(C, StatementName, [Parameters]).
 {error, Error}             = epgsql:prepared_query(C, "non_existent_query", [Parameters]).
 {error, Error}             = epgsql:prepared_query(C, "non_existent_query", [Parameters]).
 ```
 ```
+
 `Parameters` - optional list of values to be bound to `$1`, `$2`, `$3`, etc.
 `Parameters` - optional list of values to be bound to `$1`, `$2`, `$3`, etc.
 `StatementName` - name of query given with ```erlang epgsql:parse(C, StatementName, "select ...", []).```
 `StatementName` - name of query given with ```erlang epgsql:parse(C, StatementName, "select ...", []).```
 
 
 With prepared query one can parse a query giving it a name with `epgsql:parse` on start and reuse the name
 With prepared query one can parse a query giving it a name with `epgsql:parse` on start and reuse the name
 for all further queries with different parameters.
 for all further queries with different parameters.
+
 ```erlang
 ```erlang
 epgsql:parse(C, "inc", "select $1+1", []).
 epgsql:parse(C, "inc", "select $1+1", []).
 epgsql:prepared_query(C, "inc", [4]).
 epgsql:prepared_query(C, "inc", [4]).
@@ -322,11 +330,13 @@ squery including final `{C, Ref, done}`.
 For valid type names see `pgsql_types.erl`.
 For valid type names see `pgsql_types.erl`.
 
 
 `epgsqla:parse/2` sends `{C, Ref, {ok, Statement} | {error, Reason}}`.
 `epgsqla:parse/2` sends `{C, Ref, {ok, Statement} | {error, Reason}}`.
+
 `epgsqli:parse/2` sends:
 `epgsqli:parse/2` sends:
- - `{C, Ref, {types, Types}}`
- - `{C, Ref, {columns, Columns}}`
- - `{C, Ref, no_data}` if statement will not return rows
- - `{C, Ref, {error, Reason}}`
+
+- `{C, Ref, {types, Types}}`
+- `{C, Ref, {columns, Columns}}`
+- `{C, Ref, no_data}` if statement will not return rows
+- `{C, Ref, {error, Reason}}`
 
 
 ```erlang
 ```erlang
 ok = epgsql:bind(C, Statement, [PortalName], ParameterValues).
 ok = epgsql:bind(C, Statement, [PortalName], ParameterValues).
@@ -351,6 +361,7 @@ both `epgsqla:bind/3` and `epgsqli:bind/3` send `{C, Ref, ok | {error, Reason}}`
 return value of `epgsql:execute/3`.
 return value of `epgsql:execute/3`.
 
 
 `epgsqli:execute/3` sends
 `epgsqli:execute/3` sends
+
 - `{C, Ref, {data, Row}}`
 - `{C, Ref, {data, Row}}`
 - `{C, Ref, {error, Reason}}`
 - `{C, Ref, {error, Reason}}`
 - `{C, Ref, suspended}` partial result was sent, more rows are available
 - `{C, Ref, suspended}` partial result was sent, more rows are available
@@ -367,7 +378,6 @@ All epgsql functions return `{error, Error}` when an error occurs.
 
 
 `epgsqla`/`epgsqli` modules' `close` and `sync` functions send `{C, Ref, ok}`.
 `epgsqla`/`epgsqli` modules' `close` and `sync` functions send `{C, Ref, ok}`.
 
 
-
 ### Batch execution
 ### Batch execution
 
 
 Batch execution is `bind` + `execute` for several prepared statements.
 Batch execution is `bind` + `execute` for several prepared statements.
@@ -389,7 +399,9 @@ example:
 ```
 ```
 
 
 `epgsqla:execute_batch/3` sends `{C, Ref, Results}`
 `epgsqla:execute_batch/3` sends `{C, Ref, Results}`
+
 `epgsqli:execute_batch/3` sends
 `epgsqli:execute_batch/3` sends
+
 - `{C, Ref, {data, Row}}`
 - `{C, Ref, {data, Row}}`
 - `{C, Ref, {error, Reason}}`
 - `{C, Ref, {error, Reason}}`
 - `{C, Ref, {complete, {_Type, Count}}}`
 - `{C, Ref, {complete, {_Type, Count}}}`
@@ -422,7 +434,7 @@ PG type       | Representation
   point       |  `{10.2, 100.12}`
   point       |  `{10.2, 100.12}`
   int4range   | `[1,5)`
   int4range   | `[1,5)`
   hstore      | `{[ {binary(), binary() \| null} ]}`
   hstore      | `{[ {binary(), binary() \| null} ]}`
-  json/jsonb  | `<<"{ \"key\": [ 1, 1.0, true, \"string\" ] }">>`
+  json/jsonb  | `<<"{ \"key\": [ 1, 1.0, true, \"string\" ] }">>` (see below for codec details)
   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}`
@@ -432,14 +444,31 @@ PG type       | Representation
   tstzrange   | `{{Hour, Minute, Second.Microsecond}, {Hour, Minute, Second.Microsecond}}`
   tstzrange   | `{{Hour, Minute, Second.Microsecond}, {Hour, Minute, Second.Microsecond}}`
   daterange   | `{{Year, Month, Day}, {Year, Month, Day}}`
   daterange   | `{{Year, Month, Day}, {Year, Month, Day}}`
 
 
-  `timestamp` and `timestamptz` parameters can take `erlang:now()` format: `{MegaSeconds, Seconds, MicroSeconds}`
+`timestamp` and `timestamptz` parameters can take `erlang:now()` format: `{MegaSeconds, Seconds, MicroSeconds}`
+
+`int4range` is a range type for ints that obeys inclusive/exclusive semantics,
+bracket and parentheses respectively. Additionally, infinities are represented by the atoms `minus_infinity`
+and `plus_infinity`
+
+`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
+
+`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`,
+which most popular open source JSON parsers will already, and you can specify it in the codec
+configuration like this:
+
+```erlang
+{epgsql_codec_json, JsonMod}
+
+% With options
+{epgsql_codec_json, JsonMod, EncodeOpts, DecodeOpts}
 
 
-  `int4range` is a range type for ints that obeys inclusive/exclusive semantics,
-  bracket and parentheses respectively. Additionally, infinities are represented by the atoms `minus_infinity`
-  and `plus_infinity`
+% Real world example using jiffy to return a map on decode
+{epgsql_codec_json, {jiffy, [], [return_maps]}}
+```
 
 
-  `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
+Note that the decoded terms will be message-passed to the receiving process (i.e. copied), which may exhibit a performance hit if decoding large terms very frequently.
 
 
 ## Errors
 ## Errors
 
 
@@ -482,6 +511,7 @@ Message formats:
 ```erlang
 ```erlang
 {epgsql, Connection, {notification, Channel, Pid, Payload}}
 {epgsql, Connection, {notification, Channel, Pid, Payload}}
 ```
 ```
+
 - `Connection`  - connection the notification occurred on
 - `Connection`  - connection the notification occurred on
 - `Channel`  - channel the notification occurred on
 - `Channel`  - channel the notification occurred on
 - `Pid`  - database session pid that sent notification
 - `Pid`  - database session pid that sent notification
@@ -490,6 +520,7 @@ Message formats:
 ```erlang
 ```erlang
 {epgsql, Connection, {notice, Error}}
 {epgsql, Connection, {notice, Error}}
 ```
 ```
+
 - `Connection`  - connection the notice occurred on
 - `Connection`  - connection the notice occurred on
 - `Error`       - an `#error{}` record, see `epgsql.hrl`
 - `Error`       - an `#error{}` record, see `epgsql.hrl`
 
 
@@ -510,7 +541,9 @@ Executes a function in a PostgreSQL transaction. It executes `BEGIN` prior to ex
 `ROLLBACK` if the function raises an exception and `COMMIT` if the function returns without an error.
 `ROLLBACK` if the function raises an exception and `COMMIT` if the function returns without an error.
 If it is successful, it returns the result of the function. The failure case may differ, depending on
 If it is successful, it returns the result of the function. The failure case may differ, depending on
 the options passed.
 the options passed.
+
 Options (proplist or map):
 Options (proplist or map):
+
 - `reraise` (default `true`): when set to true, the original exception will be re-thrown after rollback,
 - `reraise` (default `true`): when set to true, the original exception will be re-thrown after rollback,
   otherwise `{rollback, ErrorReason}` will be returned
   otherwise `{rollback, ErrorReason}` will be returned
 - `ensure_committed` (default `false`): even when the callback returns without exception,
 - `ensure_committed` (default `false`): even when the callback returns without exception,
@@ -522,7 +555,6 @@ Options (proplist or map):
   appending them to `"BEGIN "` string. Eg `{begin_opts, "ISOLATION LEVEL SERIALIZABLE"}`.
   appending them to `"BEGIN "` string. Eg `{begin_opts, "ISOLATION LEVEL SERIALIZABLE"}`.
   Beware of SQL injection! The value of `begin_opts` is not escaped!
   Beware of SQL injection! The value of `begin_opts` is not escaped!
 
 
-
 ### Command status
 ### Command status
 
 
 `epgsql{a,i}:get_cmd_status(C) -> undefined | atom() | {atom(), integer()}`
 `epgsql{a,i}:get_cmd_status(C) -> undefined | atom() | {atom(), integer()}`
@@ -571,10 +603,10 @@ See [pluggable_types.md](pluggable_types.md)
 epgsql is a community driven effort - we welcome contributions!
 epgsql is a community driven effort - we welcome contributions!
 Here's how to create a patch that's easy to integrate:
 Here's how to create a patch that's easy to integrate:
 
 
-* Create a new branch for the proposed fix.
-* Make sure it includes a test and documentation, if appropriate.
-* Open a pull request against the `devel` branch of epgsql.
-* Passing build in travis
+- Create a new branch for the proposed fix.
+- Make sure it includes a test and documentation, if appropriate.
+- Open a pull request against the `devel` branch of epgsql.
+- Passing build in travis
 
 
 ## Test Setup
 ## Test Setup
 
 
@@ -582,11 +614,10 @@ In order to run the epgsql tests, you will need to install local
 Postgres database.
 Postgres database.
 
 
 NOTE: you will need the postgis and hstore extensions to run these
 NOTE: you will need the postgis and hstore extensions to run these
-tests!  On Ubuntu, you can install them with a command like this:
-
-1.  apt-get install postgresql-9.3-postgis-2.1 postgresql-contrib
+tests! On Ubuntu, you can install them with a command like this:
 
 
-2. `make test` # Runs the tests
+1. `apt-get install postgresql-9.3-postgis-2.1 postgresql-contrib`
+1. `make test` # Runs the tests
 
 
 NOTE 2: It's possible to run tests on exact postgres version by changing $PATH like
 NOTE 2: It's possible to run tests on exact postgres version by changing $PATH like
 
 

+ 34 - 4
src/datatypes/epgsql_codec_json.erl

@@ -11,24 +11,54 @@
 
 
 -export([init/2, names/0, encode/3, decode/3, decode_text/3]).
 -export([init/2, names/0, encode/3, decode/3, decode_text/3]).
 
 
--export_type([data/0]).
+-export_type([data/0, json_mod/0]).
 
 
 -type data() :: binary().
 -type data() :: binary().
+-type json_mod()::module() | {module(), EncodeOpts::any(), DecodeOpts::any()}.
 
 
 -define(JSONB_VERSION_1, 1).
 -define(JSONB_VERSION_1, 1).
 
 
-%% TODO: JSON encode/decode `fun Mod:Name/1` / `{Mod, Name}` as option.
-%% Shall not pass `fun(_) -> .. end`, because of hot code upgrade problems.
-init(_, _) -> [].
+-optional_callbacks([encode/2, decode/2]).
+
+%% Encode erlang-formatted JSON, e.g. a map or a proplist,
+%% and return a valid JSON iolist or binary string.
+-callback encode(ErlJson::term()) -> EncodedJson::iodata().
+-callback encode(ErlJson::term(), EncodeOpts::term()) -> EncodedJson::iodata().
+
+%% Decode JSON binary string into erlang-formatted JSON.
+-callback decode(EncodedJson::binary()) -> ErlJson::term().
+-callback decode(EncodedJson::binary(), DecodeOpts::term()) -> ErlJson::term().
+
+%% JsonMod shall be a module that implements the callbacks defined by this module;
+%% encode/1, decode/1, and optionally the option-accepting variants.
+-spec init(JsonMod::json_mod(), epgsql_sock:pg_sock()) -> epgsql_codec:codec_state().
+init(JsonMod, _) ->
+    JsonMod.
 
 
 names() ->
 names() ->
     [json, jsonb].
     [json, jsonb].
 
 
+encode(ErlJson, json, JsonMod) when is_atom(JsonMod) ->
+    JsonMod:encode(ErlJson);
+encode(ErlJson, json, {JsonMod, EncodeOpts, _}) when is_atom(JsonMod) ->
+    JsonMod:encode(ErlJson, EncodeOpts);
+encode(ErlJson, jsonb, JsonMod) when is_atom(JsonMod) ->
+    [<<?JSONB_VERSION_1:8>> | JsonMod:encode(ErlJson)];
+encode(ErlJson, jsonb, {JsonMod, EncodeOpts, _}) when is_atom(JsonMod) ->
+    [<<?JSONB_VERSION_1:8>> | JsonMod:encode(ErlJson, EncodeOpts)];
 encode(Bin, json, _) ->
 encode(Bin, json, _) ->
     Bin;
     Bin;
 encode(Bin, jsonb, _) ->
 encode(Bin, jsonb, _) ->
     [<<?JSONB_VERSION_1:8>> | Bin].
     [<<?JSONB_VERSION_1:8>> | Bin].
 
 
+decode(Bin, json, JsonMod) when is_atom(JsonMod) ->
+    JsonMod:decode(Bin);
+decode(Bin, json, {JsonMod, _, DecodeOpts}) when is_atom(JsonMod) ->
+    JsonMod:decode(Bin, DecodeOpts);
+decode(<<?JSONB_VERSION_1:8, Bin/binary>>, jsonb, JsonMod) when is_atom(JsonMod) ->
+    JsonMod:decode(Bin);
+decode(<<?JSONB_VERSION_1:8, Bin/binary>>, jsonb, {JsonMod, _, DecodeOpts}) when is_atom(JsonMod) ->
+    JsonMod:decode(Bin, DecodeOpts);
 decode(Bin, json, _) ->
 decode(Bin, json, _) ->
     Bin;
     Bin;
 decode(<<?JSONB_VERSION_1:8, Bin/binary>>, jsonb, _) ->
 decode(<<?JSONB_VERSION_1:8, Bin/binary>>, jsonb, _) ->

+ 42 - 25
test/epgsql_SUITE.erl

@@ -15,7 +15,7 @@
     end_per_suite/1
     end_per_suite/1
 ]).
 ]).
 
 
--compile(export_all).
+-compile([export_all, nowarn_export_all]).
 
 
 modules() ->
 modules() ->
     [
     [
@@ -828,10 +828,24 @@ date_time_type(Config) ->
     end).
     end).
 
 
 json_type(Config) ->
 json_type(Config) ->
-    check_type(Config, json, "'{}'", <<"{}">>,
-               [<<"{}">>, <<"[]">>, <<"1">>, <<"1.0">>, <<"true">>, <<"\"string\"">>, <<"{\"key\": []}">>]),
-    check_type(Config, jsonb, "'{}'", <<"{}">>,
-               [<<"{}">>, <<"[]">>, <<"1">>, <<"1.0">>, <<"true">>, <<"\"string\"">>, <<"{\"key\": []}">>]).
+    Module = ?config(module, Config),
+    epgsql_ct:with_connection(Config, fun(C) ->
+        check_type(Config, json, "'{}'", <<"{}">>,
+                [<<"{}">>, <<"[]">>, <<"1">>, <<"1.0">>, <<"true">>, <<"\"string\"">>, <<"{\"key\": []}">>],
+                get_type_col(json), C),
+        check_type(Config, jsonb, "'{}'", <<"{}">>,
+                [<<"{}">>, <<"[]">>, <<"1">>, <<"1.0">>, <<"true">>, <<"\"string\"">>, <<"{\"key\": []}">>],
+                get_type_col(jsonb), C),
+        epgsql:update_type_cache(C, [{epgsql_codec_json, epgsql_fake_json_mod}]),
+        RowId = "json_type_custom_mod_" ++ atom_to_list(Module),
+        {ok, 1} = Module:equery(C, "insert into test_table2 (c_varchar, c_json, c_jsonb) values ($1, $2, $3)", [RowId, {"{}"}, {"{}"}]),
+        Res = Module:equery(C, "select c_json, c_jsonb from test_table2 where c_varchar = $1", [RowId]),
+        ?assertMatch(
+            {ok, [#column{name = <<"c_json">>, type = json}, #column{name = <<"c_jsonb">>, type = jsonb}],
+                 [{{<<"{}">>}, {<<"{}">>}}]},
+            Res
+        )
+    end).
 
 
 misc_type(Config) ->
 misc_type(Config) ->
     check_type(Config, bool, "true", true, [true, false]),
     check_type(Config, bool, "true", true, [true, false]),
@@ -1241,33 +1255,36 @@ with_transaction(Config) ->
 %% Internal functions
 %% Internal functions
 %% ============================================================================
 %% ============================================================================
 
 
+get_type_col(Type) ->
+    "c_" ++ atom_to_list(Type).
+
 check_type(Config, Type, In, Out, Values) ->
 check_type(Config, Type, In, Out, Values) ->
-    Column = "c_" ++ atom_to_list(Type),
+    Column = get_type_col(Type),
     check_type(Config, Type, In, Out, Values, Column).
     check_type(Config, Type, In, Out, Values, Column).
 
 
 check_type(Config, Type, In, Out, Values, Column) ->
 check_type(Config, Type, In, Out, Values, Column) ->
-    Module = ?config(module, Config),
     epgsql_ct:with_connection(Config, fun(C) ->
     epgsql_ct:with_connection(Config, fun(C) ->
-        Select = io_lib:format("select ~s::~w", [In, Type]),
-        Res = Module:equery(C, Select),
-        ?assertMatch({ok, [#column{type = Type}], [{Out}]}, Res),
-        Sql = io_lib:format("insert into test_table2 (~s) values ($1) returning ~s", [Column, Column]),
-        {ok, #statement{columns = [#column{type = Type}]} = S} = Module:parse(C, Sql),
-        Insert = fun(V) ->
-            ok = Module:bind(C, S, [V]),
-            {ok, 1, [{V2}]} = Module:execute(C, S),
-            case compare(Type, V, V2) of
-                true  -> ok;
-                false ->
-                    error({write_read_compare_failed,
-                           iolist_to_binary(
-                             io_lib:format("~p =/= ~p~n", [V, V2]))})
-            end,
-            ok = Module:sync(C)
-        end,
-        lists:foreach(Insert, [null, undefined | Values])
+        check_type(Config, Type, In, Out, Values, Column, C)
     end).
     end).
 
 
+check_type(Config, Type, In, Out, Values, Column, C) ->
+    Module = ?config(module, Config),
+    Select = io_lib:format("select ~s::~w", [In, Type]),
+    Res = Module:equery(C, Select),
+    ?assertMatch({ok, [#column{type = Type}], [{Out}]}, Res),
+    Sql = io_lib:format("insert into test_table2 (~s) values ($1) returning ~s", [Column, Column]),
+    {ok, #statement{columns = [#column{type = Type}]} = S} = Module:parse(C, Sql),
+    Insert = fun(V) ->
+        ok = Module:bind(C, S, [V]),
+        {ok, 1, [{V2}]} = Module:execute(C, S),
+        case compare(Type, V, V2) of
+            true  -> ok;
+            false -> error({write_read_compare_failed, io_lib:format("~p =/= ~p", [V, V2])})
+        end,
+        ok = Module:sync(C)
+    end,
+    lists:foreach(Insert, [null, undefined | Values]).
+
 compare(_Type, null, null)      -> true;
 compare(_Type, null, null)      -> true;
 compare(_Type, undefined, null) -> true;
 compare(_Type, undefined, null) -> true;
 compare(float4, V1, V2) when is_float(V1) -> abs(V2 - V1) < 0.000001;
 compare(float4, V1, V2) when is_float(V1) -> abs(V2 - V1) < 0.000001;

+ 16 - 0
test/epgsql_fake_json_mod.erl

@@ -0,0 +1,16 @@
+-module(epgsql_fake_json_mod).
+
+-export([encode/1]).
+-export([decode/1]).
+
+encode(ErlJson) ->
+    encode(ErlJson, []).
+
+encode({ErlJson}, _EncodeOpts) ->
+    ErlJson.
+
+decode(EncodedJson) ->
+    decode(EncodedJson, []).
+
+decode(EncodedJson, _DecodeOpts) ->
+    {EncodedJson}.