Current section

Files

Jump to
couchbeam src couchbeam.erl
Raw

src/couchbeam.erl

%%% -*- erlang -*-
%%%
%%% This file is part of couchbeam released under the MIT license.
%%% See the NOTICE for more information.
-module(couchbeam).
-author('Benoit Chesneau').
-include("couchbeam.hrl").
-include_lib("hackney/include/hackney.hrl").
-include_lib("hackney/include/hackney_lib.hrl").
-define(TIMEOUT, infinity).
%% API urls
-export([server_connection/0, server_connection/1,
server_connection/2, server_connection/4,
server_info/1,
get_uuid/1, get_uuids/2,
replicate/2, replicate/3, replicate/4,
all_dbs/1, all_dbs/2, db_exists/2,
create_db/2, create_db/3, create_db/4,
open_db/2, open_db/3,
open_or_create_db/2, open_or_create_db/3, open_or_create_db/4,
delete_db/1, delete_db/2,
db_info/1,
design_info/2, view_cleanup/1,
save_doc/2, save_doc/3, save_doc/4,
doc_exists/2,
open_doc/2, open_doc/3,
stream_doc/1, end_doc_stream/1,
delete_doc/2, delete_doc/3,
save_docs/2, save_docs/3,
delete_docs/2, delete_docs/3,
copy_doc/2, copy_doc/3,
lookup_doc_rev/2, lookup_doc_rev/3,
fetch_attachment/3, fetch_attachment/4, stream_attachment/1, stream_attachment/3,
delete_attachment/3, delete_attachment/4,
put_attachment/4, put_attachment/5, send_attachment/2,
ensure_full_commit/1, ensure_full_commit/2,
compact/1, compact/2,
get_missing_revs/2,
find/3]).
%% Connection handle is now a pid in hackney process-per-connection branch
-opaque doc_stream() :: {pid(), any()}.
-export_type([doc_stream/0]).
-type mp_attachment() :: {Name :: binary(), Bin :: binary()}
| {Name :: binary(), Bin :: binary(), Encoding :: binary()}
| { Name :: binary(), Bin :: binary(), Type :: binary(), Encoding :: binary()}
| { Name :: binary(), {file, Path :: string()}}
| { Name :: binary(), {file, Path :: string()}, Encoding :: binary()}
| { Name :: binary(), Fun :: fun(), Length :: integer()}
| { Name :: binary(), Fun :: fun(), Length :: integer(), Encoding :: binary()}
| { Name :: binary(), Fun :: fun(), Length :: integer(), Type :: binary(), Encoding :: binary()}
| { Name :: binary(), {Fun :: fun(), Acc :: any()}, Length :: integer()}
| { Name :: binary(), {Fun :: fun(), Acc :: any()}, Length :: integer(), Encoding :: binary()}
| { Name :: binary(), {Fun :: fun(), Acc :: any()}, Length :: integer(), Type :: binary(), Encoding :: binary()}.
-type mp_attachments() :: [mp_attachment()].
%% --------------------------------------------------------------------
%% API functins.
%% --------------------------------------------------------------------
%% @doc Create a server for connectiong to a CouchDB node
%% @equiv server_connection("127.0.0.1", 5984, "", [], false)
server_connection() ->
URL = couchbeam_util:binary_env("COUCHDB_URL", "http://127.0.0.1:5984"),
ADMIN = couchbeam_util:binary_env("COUCHDB_ADMIN", "admin"),
PASSWORD = couchbeam_util:binary_env("COUCHDB_PASSWORD", "change_me"),
server_connection(URL, [{basic_auth, {ADMIN, PASSWORD}}]).
server_connection(URL) when is_list(URL) orelse is_binary(URL) ->
server_connection(URL, []).
%% @doc Create a server for connectiong to a CouchDB node
%% @equiv server_connection(Host, Port, "", [])
server_connection(URL, Options0) when is_list(Options0) ->
Options = case hackney_url:property(scheme, hackney_url:parse_url(URL)) of
http -> [{insecure_basic_auth, true} | Options0];
_ -> Options0
end,
#server{url=hackney_url:fix_path(URL), options=Options};
server_connection(Host, Port) when is_integer(Port) ->
server_connection(Host, Port, <<>>, []).
%% @doc Create a server for connectiong to a CouchDB node
%%
%% Connections are made to:
%% ```http://Host:PortPrefix'''
%%
%% If ssl is set https is used.
%%
%% For a description of SSL Options, look in the <a href="http://www.erlang.org/doc/apps/ssl/index.html">ssl</a> manpage.
%%
-spec server_connection(Host::string(), Port::non_neg_integer(), Prefix::binary(), OptionsList::list()) ->
Server::server().
%% OptionsList() = [option()]
%% option() =
%% {is_ssl, boolean()} |
%% {ssl_options, [SSLOpt]} |
%% {pool_name, atom()} |
%% {proxy_host, string()} |
%% {proxy_port, integer()} |
%% {proxy_user, string()} |
%% {proxy_password, string()} |
%% {basic_auth, {username(), password()}} |
%% {cookie, string()} |
%% {oauth, oauthOptions()} |
%% {proxyauth, [proxyauthOpt]}
%%
%% username() = string()
%% password() = string()
%% SSLOpt = term()
%% oauthOptions() = [oauth()]
%% oauth() =
%% {consumer_key, string()} |
%% {token, string()} |
%% {token_secret, string()} |
%% {consumer_secret, string()} |
%% {signature_method, string()}
%%
%% proxyauthOpt = {X-Auth-CouchDB-UserName, username :: string()} |
%% {X-Auth-CouchDB-Roles, roles :: string} | list_of_user_roles_separated_by_a_comma
%% {X-Auth-CouchDB-Token: token :: string()} | authentication token. Optional, but strongly recommended to force token be required to prevent requests from untrusted sources.
server_connection(Host, Port, Prefix, Options)
when is_integer(Port), Port =:= 443 ->
BaseUrl = iolist_to_binary(["https://", Host, ":",
integer_to_list(Port)]),
Url = hackney_url:make_url(BaseUrl, [Prefix], []),
server_connection(Url, Options);
server_connection(Host, Port, Prefix, Options) ->
Scheme = case proplists:get_value(is_ssl, Options) of
true -> "https";
_ -> "http"
end,
BaseUrl = iolist_to_binary([Scheme, "://", Host, ":",
integer_to_list(Port)]),
Url = hackney_url:make_url(BaseUrl, [Prefix], []),
server_connection(Url, Options).
%% @doc Get Information from the server
-spec server_info(server()) -> {ok, map()} | {error, term()}.
server_info(#server{url=Url, options=Opts}) ->
case hackney:get(Url, [], <<>>, Opts) of
{ok, 200, _, Ref} ->
Version = couchbeam_httpc:json_body(Ref),
{ok, Version};
{ok, Status, Headers, Ref} ->
{ok, Body} = hackney:body(Ref),
{error, {bad_response, {Status, Headers, Body}}};
Error ->
Error
end.
%% @doc Get one uuid from the server
-spec get_uuid(server()) -> [binary()].
get_uuid(Server) ->
couchbeam_uuids:get_uuids(Server, 1).
%% @doc Get a list of uuids from the server
-spec get_uuids(server(), integer()) -> [binary()].
get_uuids(Server, Count) ->
couchbeam_uuids:get_uuids(Server, Count).
%% @doc Handle replication. Pass an object containting all informations
%% It allows to pass for example an authentication info
%% ```
%% RepObj = {[
%% {<<"source">>, <<"sourcedb">>},
%% {<<"target">>, <<"targetdb">>},
%% {<<"create_target">>, true}
%% ]}
%% replicate(Server, RepObj).
%% '''
-spec replicate(server(), map()) -> {ok, map()} | {error, term()}.
replicate(#server{}=Server, RepObj) ->
case open_db(Server, <<"_replicator">>) of
{ok, ReplicatorDb} ->
case save_doc(ReplicatorDb, RepObj) of
{ok, Doc} ->
{ok, Doc};
Error ->
Error
end
end.
%% @doc Handle replication.
-spec replicate(server(), binary() | string(), binary() | string()) -> {ok, map()} | {error, term()}.
replicate(Server, Source, Target) ->
replicate(Server, Source, Target, []).
%% @doc handle Replication. Allows to pass options with source and
%% target. Source and target can be either simple URI strings or
%% complex document structures with authentication. Options is a Json object.
%% ex:
%% ```
%% %% Simple URI replication
%% Options = [{<<"create_target">>, true}]},
%% couchbeam:replicate(S, "testdb", "testdb2", Options).
%%
%% %% Complex replication with authentication
%% Source = "http://user:pass@remote.com:5984/db",
%% Target = {[{<<"url">>, <<"http://localhost:5984/target_db">>},
%% {<<"auth">>, {[{<<"basic">>, {[{<<"username">>, <<"user">>},
%% {<<"password">>, <<"pass">>}]}}]}}]},
%% couchbeam:replicate(S, Source, Target, [{<<"continuous">>, true}]).
%% '''
replicate(Server, Source, Target, Options) when is_list(Options) ->
SourceProp = format_replication_endpoint(Source),
TargetProp = format_replication_endpoint(Target),
BaseMap = #{<<"source">> => SourceProp,
<<"target">> => TargetProp},
RepMap = lists:foldl(fun({K, V}, Acc) -> maps:put(K, V, Acc) end, BaseMap, Options),
replicate(Server, RepMap);
replicate(Server, Source, Target, Options) when is_map(Options) ->
SourceProp = format_replication_endpoint(Source),
TargetProp = format_replication_endpoint(Target),
RepMap = Options#{<<"source">> => SourceProp,
<<"target">> => TargetProp},
replicate(Server, RepMap).
%% @doc get list of databases on a CouchDB node
-spec all_dbs(server()) -> {ok, [binary()]} | {error, term()}.
all_dbs(#server{}=Server) -> all_dbs(Server, []).
%% @doc get list of databases on a CouchDB node with optional filter
-spec all_dbs(server(), list()) -> {ok, [binary()]} | {error, term()}.
all_dbs(#server{url=ServerUrl, options=Opts}, Options) ->
Args = couchbeam_view:parse_view_options(Options),
Url = hackney_url:make_url(ServerUrl, <<"_all_dbs">>, Args#view_query_args.options),
Resp = couchbeam_httpc:db_request(get, Url, [], <<>>, Opts, [200]),
case Resp of
{ok, _, _, Ref} ->
AllDbs = couchbeam_httpc:json_body(Ref),
{ok, AllDbs};
Error ->
Error
end.
design_info(#db{server=Server, name=DbName, options=Opts}, DesignName) ->
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server),
[DbName, <<"_design">>, DesignName, <<"_info">>],
[]),
Resp = couchbeam_httpc:db_request(get, Url, [], <<>>, Opts, [200]),
case Resp of
{ok, _, _, Ref} ->
DesignInfo = couchbeam_httpc:json_body(Ref),
{ok, DesignInfo};
Error ->
Error
end.
view_cleanup(#db{server=Server, name=DbName, options=Opts}) ->
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server),
[DbName, <<"_view_cleanup">>],
[]),
Headers = [{<<"Content-Type">>, <<"application/json">>}],
Resp = couchbeam_httpc:db_request(post, Url, Headers, <<>>, Opts, [200, 202]),
case Resp of
{ok, _, _, Ref} ->
catch hackney:skip_body(Ref),
ok;
Error ->
Error
end.
%% @doc test if db with dbname exists on the CouchDB node
-spec db_exists(server(), binary() | string()) -> boolean().
db_exists(#server{url=ServerUrl, options=Opts}, DbName) ->
Url = hackney_url:make_url(ServerUrl, couchbeam_util:dbname(DbName), []),
case couchbeam_httpc:db_request(head, Url, [], <<>>, Opts, [200]) of
{ok, 200, _}->
true;
_Error ->
io:format("db exists error ~p", [_Error]),
false
end.
%% @doc Create a database and a client for connectiong to it.
%% @equiv create_db(Server, DbName, [], [])
create_db(Server, DbName) ->
create_db(Server, DbName, [], []).
%% @doc Create a database and a client for connectiong to it.
%% @equiv create_db(Server, DbName, Options, [])
create_db(Server, DbName, Options) ->
create_db(Server, DbName, Options, []).
%% @doc Create a database and a client for connectiong to it.
%%
%% Connections are made to:
%% ```http://Host:PortPrefix/DbName'''
%%
%% If ssl is set https is used. See server_connections for options.
%% Params is a list of optionnal query argument you want to pass to the
%% db. Useful for bigcouch for example.
%%
-spec create_db(server(), binary() | string(), list(), list()) -> {ok, db()} | {error, term()}.
create_db(#server{url=ServerUrl, options=Opts}=Server, DbName0, Options,
Params) ->
DbName = couchbeam_util:dbname(DbName0),
Options1 = couchbeam_util:propmerge1(Options, Opts),
Url = hackney_url:make_url(ServerUrl, DbName, Params),
Resp = couchbeam_httpc:db_request(put, Url, [], <<>>, Options1, [201]),
case Resp of
{ok, _Status, _Headers, Ref} ->
hackney:skip_body(Ref),
{ok, #db{server=Server, name=DbName, options=Options1}};
{error, precondition_failed} ->
{error, db_exists};
Error ->
Error
end.
%% @doc Create a client for connection to a database
%% @equiv open_db(Server, DbName, [])
open_db(Server, DbName) ->
open_db(Server, DbName, []).
%% @doc Create a client for connection to a database
-spec open_db(server(), binary() | string(), list()) -> {ok, db()}.
open_db(#server{options=Opts}=Server, DbName, Options) ->
Options1 = couchbeam_util:propmerge1(Options, Opts),
{ok, #db{server=Server, name=couchbeam_util:dbname(DbName), options=Options1}}.
%% @doc Create a client for connecting to a database and create the
%% database if needed.
%% @equiv open_or_create_db(Server, DbName, [], [])
open_or_create_db(Server, DbName) ->
open_or_create_db(Server, DbName, [], []).
%% @doc Create a client for connecting to a database and create the
%% database if needed.
%% @equiv open_or_create_db(Server, DbName, Options, [])
open_or_create_db(Server, DbName, Options) ->
open_or_create_db(Server, DbName, Options, []).
%% @doc Create a client for connecting to a database and create the
%% database if needed.
-spec open_or_create_db(server(), binary() | string(), list(), list()) -> {ok, db()} | {error, term()}.
open_or_create_db(#server{url=ServerUrl, options=Opts}=Server, DbName0,
Options, Params) ->
DbName = couchbeam_util:dbname(DbName0),
Url = hackney_url:make_url(ServerUrl, DbName, []),
Opts1 = couchbeam_util:propmerge1(Options, Opts),
Resp = couchbeam_httpc:request(get, Url, [], <<>>, Opts1),
case couchbeam_httpc:db_resp(Resp, [200]) of
{ok, _Status, _Headers, Ref} ->
hackney:skip_body(Ref),
open_db(Server, DbName, Options);
{error, not_found} ->
create_db(Server, DbName, Options, Params);
Error ->
Error
end.
%% @doc delete database
%% @equiv delete_db(Server, DbName)
delete_db(#db{server=Server, name=DbName}) ->
delete_db(Server, DbName).
%% @doc delete database
-spec delete_db(server(), binary() | string()) -> {ok, map()} | {error, term()}.
delete_db(#server{url=ServerUrl, options=Opts}, DbName) ->
Url = hackney_url:make_url(ServerUrl, couchbeam_util:dbname(DbName), []),
Resp = couchbeam_httpc:request(delete, Url, [], <<>>, Opts),
case couchbeam_httpc:db_resp(Resp, [200]) of
{ok, _, _, Ref} ->
{ok, couchbeam_httpc:json_body(Ref)};
Error ->
Error
end.
%% @doc get database info
-spec db_info(db()) -> {ok, map()} | {error, term()}.
db_info(#db{server=Server, name=DbName, options=Opts}) ->
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), couchbeam_util:dbname(DbName), []),
case couchbeam_httpc:db_request(get, Url, [], <<>>, Opts, [200]) of
{ok, _Status, _Headers, Ref} ->
Infos = couchbeam_httpc:json_body(Ref),
{ok, Infos};
{error, not_found} ->
{error, db_not_found};
Error ->
Error
end.
%% @doc test if doc with uuid exists in the given db
-spec doc_exists(db(), binary() | string()) -> boolean().
doc_exists(#db{server=Server, options=Opts}=Db, DocId) ->
DocId1 = couchbeam_util:encode_docid(DocId),
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), couchbeam_httpc:doc_url(Db, DocId1), []),
case couchbeam_httpc:db_request(head, Url, [], <<>>, Opts, [200]) of
{ok, _, _} -> true;
_Error -> false
end.
%% @doc open a document
%% @equiv open_doc(Db, DocId, [])
open_doc(Db, DocId) ->
open_doc(Db, DocId, []).
%% @doc open a document
%% Params is a list of query argument. Have a look in CouchDb API
-spec open_doc(db(), binary() | string(), list()) -> {ok, map()} | {error, term()}.
open_doc(#db{server=Server, options=Opts}=Db, DocId, Params) ->
DocId1 = couchbeam_util:encode_docid(DocId),
%% is there any accepted content-type passed to the params?
{Accept, Params1} = case proplists:get_value(accept, Params) of
undefined -> {any, Params};
A -> {A, proplists:delete(accept, Params)}
end,
%% set the headers with the accepted content-type if needed
Headers = case {Accept, proplists:get_value("attachments", Params)} of
{any, true} ->
%% only use the more efficient method when we get the
%% attachments so we don't use much bandwidth.
[{<<"Accept">>, <<"multipart/related">>}];
{Accept, _} when is_binary(Accept) ->
%% accepted content-type has been forced
[{<<"Accept">>, Accept}];
_ ->
[]
end,
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), couchbeam_httpc:doc_url(Db, DocId1),
Params1),
case couchbeam_httpc:db_request(get, Url, Headers, <<>>, Opts,
[200, 201]) of
{ok, _, RespHeaders, Ref} ->
ParsedHeaders = hackney_headers:from_list(RespHeaders),
case hackney_headers:get_value(<<"content-type">>, ParsedHeaders) of
undefined ->
{ok, couchbeam_httpc:json_body(Ref)};
ContentType ->
case hackney_headers:parse_content_type(ContentType) of
{<<"multipart">>, _, Params} ->
%% we get a multipart request, start to parse it.
{_, Boundary} = lists:keyfind(<<"boundary">>, 1, Params),
InitialState = {Ref, fun() ->
couchbeam_httpc:wait_mp_doc(Ref, Boundary, <<>>)
end},
{ok, {multipart, InitialState}};
_ ->
{ok, couchbeam_httpc:json_body(Ref)}
end
end;
Error ->
Error
end.
%% @doc stream the multipart response of the doc API. Use this function
%% when you get `{ok, {multipart, State}}' from the function
%% `couchbeam:open_doc/3'.
-spec stream_doc(doc_stream()) ->
{doc, doc()}
| {att, Name :: binary(), doc_stream()}
| {att_body, Name :: binary(), Chunk :: binary(), doc_stream()}
| {att_eof, Name :: binary(), doc_stream()}
| eof
| {error, term()}.
stream_doc({_Ref, Cont}) ->
Cont().
%% @doc stop to receive the multipart response of the doc api and close
%% the connection.
-spec end_doc_stream(doc_stream()) -> ok.
end_doc_stream({Ref, _Cont}) ->
hackney:close(Ref).
%% @doc save a document
%% @equiv save_doc(Db, Doc, [])
save_doc(Db, Doc) ->
save_doc(Db, Doc, []).
%% @doc save a *document
%% A document is a Json object like this one:
%%
%% ```{[
%% {<<"_id">>, <<"myid">>},
%% {<<"title">>, <<"test">>}
%% ]}'''
%%
%% Options are arguments passed to the request. This function return a
%% new document with last revision and a docid. If _id isn't specified in
%% document it will be created. Id is created by extracting an uuid from
%% the couchdb node.
%%
-spec save_doc(db(), map(), list()) -> {ok, map()} | {error, term()}.
save_doc(Db, Doc, Options) ->
save_doc(Db, Doc, [], Options).
%% @doc save a *document with all its attacjments
%% A document is a Json object like this one:
%%
%% ```{[
%% {<<"_id">>, <<"myid">>},
%% {<<"title">>, <<"test">>}
%% ]}'''
%%
%% Options are arguments passed to the request. This function return a
%% new document with last revision and a docid. If _id isn't specified in
%% document it will be created. Id is created by extracting an uuid from
%% the couchdb node.
%%
%% If the attachments is not empty, the doc will be sent as multipart.
%% Attachments are passed as a list of the following tuples:
%%
%% - `{Name :: binary(), Bin :: binary()}'
%% - `{Name :: binary(), Bin :: binary(), Encoding :: binary()}'
%% - `{ Name :: binary(), Bin :: binary(), Type :: binary(), Encoding :: binary()}'
%% - `{ Name :: binary(), {file, Path :: string()}}'
%% - `{ Name :: binary(), {file, Path :: string()}, Encoding :: binary()}'
%% - `{ Name :: binary(), Fun :: fun(), Length :: integer()}'
%% - `{ Name :: binary(), Fun :: fun(), Length :: integer(), Encoding :: binary()}'
%% - `{Name :: binary(), Fun :: fun(), Length :: integer(), Type :: binary(), Encoding :: binary()}'
%% - `{ Name :: binary(), {Fun :: fun(), Acc :: any()}, Length :: integer()}'
%% - `{ Name :: binary(), {Fun :: fun(), Acc :: any()}, Length :: integer(), Encoding :: binary()}'
%% - `{ Name :: binary(), {Fun :: fun(), Acc :: any()}, Length :: integer(), Type :: binary(), Encoding :: binary()}.'
%%
%% where `Type` is the content-type of the attachments (detected in other
%% case) and `Encoding` the encoding of the attachments:
%% `<<"identity">>' if normal or `<<"gzip">>' if the attachments is
%% gzipped.
-spec save_doc(Db::db(), doc(), mp_attachments(), Options :: [{binary(), binary() | true}] | binary()) ->
{ok, doc()} | {error, term()}.
save_doc(#db{server=Server, options=Opts}=Db, Doc, Atts, Options) when is_map(Doc) ->
DocId = case maps:get(<<"_id">>, Doc, undefined) of
undefined ->
[Id] = get_uuid(Server),
Id;
DocId1 ->
couchbeam_util:encode_docid(DocId1)
end,
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), couchbeam_httpc:doc_url(Db, DocId),
Options),
case Atts of
[] ->
JsonDoc = couchbeam_ejson:encode(Doc),
Headers = [{<<"Content-Type">>, <<"application/json">>}],
case couchbeam_httpc:db_request(put, Url, Headers, JsonDoc, Opts,
[200, 201, 202]) of
{ok, _, _, Ref} ->
JsonProp = couchbeam_httpc:json_body(Ref),
NewRev = maps:get(<<"rev">>, JsonProp),
NewDocId = maps:get(<<"id">>, JsonProp),
Doc1 = couchbeam_doc:set_value(<<"_rev">>, NewRev,
couchbeam_doc:set_value(<<"_id">>, NewDocId, Doc)),
{ok, Doc1};
Error ->
Error
end;
_ ->
Boundary = couchbeam_uuids:random(),
%% for now couchdb can't received chunked multipart stream
%% so we have to calculate the content-length. It also means
%% that we need to know the size of each attachments. (Which
%% should be expected).
{CLen, JsonDoc, Doc2} = couchbeam_httpc:len_doc_to_mp_stream(Atts, Boundary, Doc),
CType = <<"multipart/related; boundary=\"",
Boundary/binary, "\"" >>,
Headers = [{<<"Content-Type">>, CType},
{<<"Content-Length">>, hackney_bstr:to_binary(CLen)}],
case couchbeam_httpc:request(put, Url, Headers, stream,
Opts) of
{ok, Ref} ->
couchbeam_httpc:send_mp_doc(Atts, Ref, Boundary, JsonDoc, Doc2);
Error ->
Error
end
end.
%% @doc delete a document
%% @equiv delete_doc(Db, Doc, [])
delete_doc(Db, Doc) ->
delete_doc(Db, Doc, []).
%% @doc delete a document
%% if you want to make sure the doc it emptied on delete, use the option
%% {empty_on_delete, true} or pass a doc with just _id and _rev
%% members.
-spec delete_doc(db(), map(), list()) -> {ok, list()} | {error, term()}.
delete_doc(Db, Doc, Options) ->
delete_docs(Db, [Doc], Options).
%% @doc delete a list of documents
%% @equiv delete_docs(Db, Docs, [])
delete_docs(Db, Docs) ->
delete_docs(Db, Docs, []).
%% @doc delete a list of documents
%% if you want to make sure the doc it emptied on delete, use the option
%% {empty_on_delete, true} or pass a doc with just _id and _rev
%% members.
-spec delete_docs(db(), [map()], list()) -> {ok, list()} | {error, term()}.
delete_docs(Db, Docs, Options) ->
Empty = couchbeam_util:get_value("empty_on_delete", Options, false),
{FinalDocs, FinalOptions} = case Empty of
true ->
Docs1 = lists:map(fun(Doc)->
#{<<"_id">> => couchbeam_doc:get_id(Doc),
<<"_rev">> => couchbeam_doc:get_rev(Doc),
<<"_deleted">> => true}
end, Docs),
{Docs1, proplists:delete("all_or_nothing", Options)};
_ ->
Docs1 = lists:map(fun(Doc)->
maps:put(<<"_deleted">>, true, Doc)
end, Docs),
{Docs1, Options}
end,
save_docs(Db, FinalDocs, FinalOptions).
%% @doc save a list of documents
%% @equiv save_docs(Db, Docs, [])
save_docs(Db, Docs) ->
save_docs(Db, Docs, []).
%% @doc save a list of documents
-spec save_docs(db(), [map()], list()) -> {ok, list()} | {error, term()}.
save_docs(#db{server=Server, options=Opts}=Db, Docs, Options) ->
Docs1 = [maybe_docid(Server, Doc) || Doc <- Docs],
Options1 = couchbeam_util:parse_options(Options),
DocOptions = [
{list_to_binary(K), V} || {K, V} <- Options1,
(K =:= "all_or_nothing" orelse K =:= "new_edits") andalso is_boolean(V)
],
Options2 = [
{K, V} || {K, V} <- Options1,
K =/= "all_or_nothing" andalso K =/= "new_edits"
],
DocOptionsMap = maps:from_list(DocOptions),
Body = couchbeam_ejson:encode(maps:put(<<"docs">>, Docs1, DocOptionsMap)),
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server),
[couchbeam_httpc:db_url(Db), <<"_bulk_docs">>],
Options2),
Headers = [{<<"Content-Type">>, <<"application/json">>}],
case couchbeam_httpc:db_request(post, Url, Headers, Body, Opts, [201]) of
{ok, _, _, Ref} ->
{ok, couchbeam_httpc:json_body(Ref)};
Error ->
Error
end.
%% @doc duplicate a document using the doc API
copy_doc(#db{server=Server}=Db, Doc) ->
[DocId] = get_uuid(Server),
copy_doc(Db, Doc, DocId).
%% @doc copy a doc to a destination. If the destination exist it will
%% use the last revision, in other case a new doc is created with the
%% the current doc revision.
copy_doc(Db, Doc, Dest) when is_binary(Dest) ->
Destination = case open_doc(Db, Dest) of
{ok, DestDoc} ->
Rev = couchbeam_doc:get_rev(DestDoc),
{Dest, Rev};
_ ->
{Dest, <<>>}
end,
do_copy(Db, Doc, Destination);
copy_doc(Db, Doc, Props) when is_map(Props) ->
DocId = maps:get(<<"_id">>, Props),
Rev = maps:get(<<"_rev">>, Props, <<>>),
do_copy(Db, Doc, {DocId, Rev}).
do_copy(Db, Doc, Destination) when is_map(Doc) ->
case maps:get(<<"_id">>, Doc, undefined) of
undefined ->
{error, invalid_source};
DocId ->
DocRev = maps:get(<<"_rev">>, Doc, nil),
do_copy(Db, {DocId, DocRev}, Destination)
end;
do_copy(Db, DocId, Destination) when is_binary(DocId) ->
do_copy(Db, {DocId, nil}, Destination);
do_copy(#db{server=Server, options=Opts}=Db, {DocId, DocRev},
{DestId, DestRev}) ->
Destination = case DestRev of
<<>> -> DestId;
_ -> << DestId/binary, "?rev=", DestRev/binary >>
end,
Headers = [{<<"Destination">>, Destination}],
{Headers1, Params} = case {DocRev, DestRev} of
{nil, _} ->
{Headers, []};
{_, <<>>} ->
{[{<<"If-Match">>, DocRev} | Headers], []};
{_, _} ->
{Headers, [{<<"rev">>, DocRev}]}
end,
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), couchbeam_httpc:doc_url(Db, DocId),
Params),
case couchbeam_httpc:db_request(copy, Url, Headers1, <<>>,
Opts, [201]) of
{ok, _, _, Ref} ->
JsonProp = couchbeam_httpc:json_body(Ref),
NewRev = maps:get(<<"rev">>, JsonProp),
NewDocId = maps:get(<<"id">>, JsonProp),
{ok, NewDocId, NewRev};
Error ->
Error
end.
%% @doc get the last revision of the document
lookup_doc_rev(Db, DocId) ->
lookup_doc_rev(Db, DocId, []).
lookup_doc_rev(#db{server=Server, options=Opts}=Db, DocId, Params) ->
DocId1 = couchbeam_util:encode_docid(DocId),
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), couchbeam_httpc:doc_url(Db, DocId1),
Params),
case couchbeam_httpc:db_request(head, Url, [], <<>>, Opts, [200]) of
{ok, _, Headers} ->
HeadersDict = hackney_headers:new(Headers),
Rev = re:replace(hackney_headers:get_value(<<"etag">>, HeadersDict),
<<"\"">>, <<>>, [global, {return, binary}]),
{ok, Rev};
Error ->
Error
end.
%% @doc fetch a document attachment
%% @equiv fetch_attachment(Db, DocId, Name, [])
fetch_attachment(Db, DocId, Name) ->
fetch_attachment(Db, DocId, Name, []).
%% @doc fetch a document attachment
%% Options are
%% <ul>
%% <li>`stream': to start streaming an attachment. the function return
%% `{ok, Ref}' where is a ref to the attachment</li>
%% <li>Other options that can be sent using the REST API</li>
%% </ul>
%%
-spec fetch_attachment(db(), list() | binary(), list() | binary(), list())
-> {ok, binary()}| {ok, atom()} |{error, term()}.
fetch_attachment(#db{server=Server, options=Opts}=Db, DocId, Name, Options0) ->
{Stream, Options} = case couchbeam_util:get_value(stream, Options0) of
undefined ->
{false, Options0};
true ->
{true, proplists:delete(stream, Options0)};
_ ->
{false, proplists:delete(stream, Options0)}
end,
Options1 = couchbeam_util:parse_options(Options),
%% custom headers. Allows us to manage Range.
{Options2, Headers} = case couchbeam_util:get_value("headers", Options1) of
undefined ->
{Options1, []};
Headers1 ->
{proplists:delete("headers", Options1), Headers1}
end,
DocId1 = couchbeam_util:encode_docid(DocId),
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server),
[couchbeam_httpc:db_url(Db), DocId1,
Name],
Options2),
case hackney:get(Url, Headers, <<>>, Opts) of
{ok, 200, _, Ref} when Stream /= true ->
hackney:body(Ref);
{ok, 200, _, Ref} ->
{ok, Ref};
{ok, 404, _, Ref} ->
hackney:skip_body(Ref),
{error, not_found};
{ok, Status, Headers, Ref} ->
{ok, Body} = hackney:body(Ref),
{error, {bad_response, {Status, Headers, Body}}};
Error ->
Error
end.
%% @doc fetch an attachment chunk.
%% Use this function when you pass the `stream' option to the
%% `couchbeam:fetch_attachment/4' function.
%% This function return the following response:
%% <dl>
%% <dt>done</dt>
%% <dd>You got all the attachment</dd>
%% <dt>{ok, binary()}</dt>
%% <dd>Part of the attachment</dd>
%% <dt>{error, term()}</dt>
%% <dd>n error occurred</dd>
%% </dl>
%%
-spec stream_attachment(pid()) -> {ok, binary()}
| done
| {error, term()}.
stream_attachment(Ref) ->
hackney:stream_body(Ref).
%% @doc Start streaming an attachment. Returns a reference that can be
%% passed to stream_attachment/1 to receive chunks.
%% @equiv fetch_attachment(Db, DocId, Name, [stream])
-spec stream_attachment(db(), binary(), binary()) -> {ok, pid()} | {error, term()}.
stream_attachment(Db, DocId, Name) ->
fetch_attachment(Db, DocId, Name, [stream]).
%% @doc put an attachment
%% @equiv put_attachment(Db, DocId, Name, Body, [])
put_attachment(Db, DocId, Name, Body)->
put_attachment(Db, DocId, Name, Body, []).
%% @doc put an attachment
-spec put_attachment(db(), binary() | string(), binary() | string(),
iodata() | fun(), list()) -> {ok, map()} | {error, term()}.
put_attachment(#db{server=Server, options=Opts}=Db, DocId, Name, Body,
Options) ->
QueryArgs = case couchbeam_util:get_value(rev, Options) of
undefined -> [];
Rev -> [{<<"rev">>, couchbeam_util:to_binary(Rev)}]
end,
Headers = couchbeam_util:get_value(headers, Options, []),
FinalHeaders = lists:foldl(fun(Option, Acc) ->
case Option of
{content_length, V} ->
V1 = couchbeam_util:to_binary(V),
[{<<"Content-Length">>, V1}|Acc];
{content_type, V} ->
V1 = couchbeam_util:to_binary(V),
[{<<"Content-Type">>, V1}|Acc];
_ ->
Acc
end
end, Headers, Options),
DocId1 = couchbeam_util:encode_docid(DocId),
AttName = couchbeam_util:encode_att_name(Name),
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db), DocId1,
AttName],
QueryArgs),
case couchbeam_httpc:db_request(put, Url, FinalHeaders, Body, Opts,
[201, 202]) of
{ok, _, _, Ref} ->
JsonBody = couchbeam_httpc:json_body(Ref),
#{<<"ok">> := true} = JsonBody,
{ok, maps:remove(<<"ok">>, JsonBody)};
{ok, Ref} ->
{ok, Ref};
Error ->
Error
end.
%% @doc send an attachment chunk
%% Msg could be Data, eof to stop sending.
send_attachment(Ref, eof) ->
case hackney:finish_send_body(Ref) of
ok ->
Resp = hackney:start_response(Ref),
couchbeam_httpc:reply_att(Resp);
Error ->
Error
end;
send_attachment(Ref, Msg) ->
Reply = hackney:send_body(Ref, Msg),
couchbeam_httpc:reply_att(Reply).
%% @doc delete a document attachment
%% @equiv delete_attachment(Db, Doc, Name, [])
delete_attachment(Db, Doc, Name) ->
delete_attachment(Db, Doc, Name, []).
%% @doc delete a document attachment
-spec delete_attachment(db(), map() | binary() | string(), binary() | string(), list()) ->
{ok, map()} | {error, term()}.
delete_attachment(#db{server=Server, options=Opts}=Db, DocOrDocId, Name,
Options) ->
Options1 = couchbeam_util:parse_options(Options),
{Rev, DocId} = case DocOrDocId of
Props when is_map(Props) ->
Rev1 = maps:get(<<"_rev">>, Props, undefined),
DocId1 = maps:get(<<"_id">>, Props),
{Rev1, DocId1};
DocId1 ->
Rev1 = couchbeam_util:get_value("rev", Options1),
{Rev1, DocId1}
end,
case Rev of
undefined ->
{error, rev_undefined};
_ ->
Options2 = case couchbeam_util:get_value("rev", Options1) of
undefined ->
[{<<"rev">>, couchbeam_util:to_binary(Rev)}|Options1];
_ ->
Options1
end,
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db),
DocId,
Name],
Options2),
case couchbeam_httpc:db_request(delete, Url, [], <<>>, Opts,
[200]) of
{ok, _, _, Ref} ->
Map = couchbeam_httpc:json_body(Ref),
#{<<"ok">> := true} = Map,
{ok, maps:remove(<<"ok">>, Map)};
Error ->
Error
end
end.
%% @doc commit all docs in memory
%% @equiv ensure_full_commit(Db, [])
ensure_full_commit(Db) ->
ensure_full_commit(Db, []).
%% @doc commit all docs in memory
-spec ensure_full_commit(Db::db(), Options::list())
-> {ok, InstancestartTime :: binary()}
| {error, term()}.
ensure_full_commit(#db{server=Server, options=Opts}=Db, Options) ->
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db),
<<"_ensure_full_commit">>],
Options),
Headers = [{<<"Content-Type">>, <<"application/json">>}],
case couchbeam_httpc:db_request(post, Url, Headers, <<>>, Opts, [201]) of
{ok, _, _, Ref} ->
Props = couchbeam_httpc:json_body(Ref),
{ok, maps:get(<<"instance_start_time">>, Props)};
Error ->
Error
end.
%% @doc Compaction compresses the database file by removing unused
%% sections created during updates.
%% See [http://wiki.apache.org/couchdb/Compaction] for more informations
-spec compact(db()) -> ok | {error, term()}.
compact(#db{server=Server, options=Opts}=Db) ->
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db),
<<"_compact">>],
[]),
Headers = [{<<"Content-Type">>, <<"application/json">>}],
case couchbeam_httpc:db_request(post, Url, Headers, <<>>, Opts, [202]) of
{ok, _, _, Ref} ->
hackney:skip_body(Ref),
ok;
Error ->
Error
end.
%% @doc Like compact/1 but this compacts the view index from the
%% current version of the design document.
%% See [http://wiki.apache.org/couchdb/Compaction#View_compaction] for more informations
-spec compact(db(), binary() | string()) -> ok | {error, term()}.
compact(#db{server=Server, options=Opts}=Db, DesignName) ->
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db),
<<"_compact">>,
DesignName], []),
Headers = [{<<"Content-Type">>, <<"application/json">>}],
case couchbeam_httpc:db_request(post, Url, Headers, <<>>, Opts, [202]) of
{ok, _, _, Ref} ->
hackney:skip_body(Ref),
ok;
Error ->
Error
end.
%% @doc get missing revisions
-spec get_missing_revs(#db{}, [{binary(), [binary()]}]) ->
{ok, [{DocId :: binary(), [MissingRev :: binary()], [
PossibleAncestor :: binary()]}]}
| {error, term()}.
get_missing_revs(#db{server=Server, options=Opts}=Db, IdRevs) ->
%% Convert [{DocId, [Revs]}] to map
IdRevsMap = maps:from_list(IdRevs),
Json = couchbeam_ejson:encode(IdRevsMap),
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db),
<<"_revs_diff">>],
[]),
Headers = [{<<"Content-Type">>, <<"application/json">>}],
case couchbeam_httpc:db_request(post, Url, Headers, Json, Opts,
[200]) of
{ok, _, _, Ref} ->
Props = couchbeam_httpc:json_body(Ref),
Res = maps:fold(fun(Id, Result, Acc) ->
MissingRevs = maps:get(<<"missing">>, Result, []),
PossibleAncestors = maps:get(<<"possible_ancestors">>, Result, []),
[{Id, MissingRevs, PossibleAncestors} | Acc]
end, [], Props),
{ok, Res};
Error ->
Error
end.
-spec find(Db::db(), Selector::doc(), Params::list()) -> {ok, Doc::doc()} | {error, any()}.
find(#db{server=Server, options=Opts}=Db, Selector, Params) ->
Url = hackney_url:make_url(couchbeam_httpc:server_url(Server)
,[couchbeam_httpc:db_url(Db), <<"_find">>]
,[]
),
Headers = [{<<"content-type">>, <<"application/json">>}
,{<<"accept">>, <<"application/json">>}
],
BodyJson = maps:put(<<"selector">>, Selector, maps:from_list(Params)),
case couchbeam_httpc:db_request(post, Url, Headers, couchbeam_ejson:encode(BodyJson), Opts, [200, 201]) of
{ok, _, _, Ref} ->
{ok, couchbeam_httpc:json_body(Ref)};
Error ->
Error
end.
%% --------------------------------------------------------------------
%% private functions.
%% --------------------------------------------------------------------
%% add missing docid to a list of documents if needed
maybe_docid(Server, Doc) when is_map(Doc) ->
case maps:get(<<"_id">>, Doc, undefined) of
undefined ->
[DocId] = get_uuid(Server),
maps:put(<<"_id">>, DocId, Doc);
_DocId ->
Doc
end;
maybe_docid(_Server, Doc) ->
Doc.
%% format replication endpoint for source or target
%% supports Db record, simple URI strings and complex document structures
format_replication_endpoint(Props) when is_map(Props) ->
Props;
format_replication_endpoint(<<"http://", _/binary>> = Endpoint)->
couchbeam_util:to_binary(Endpoint);
format_replication_endpoint(<<"https://", _/binary>> = Endpoint)->
couchbeam_util:to_binary(Endpoint);
format_replication_endpoint(#db{server=#server{url=BaseUrl, options=Options}, name=DbName}) ->
case proplists:get_value(basic_auth, Options) of
{User, Password} ->
ServerBaseUrl = hackney_url:parse_url(BaseUrl),
ServerUrl = hackney_url:unparse_url(ServerBaseUrl#hackney_url{user=User, password=Password}),
hackney_url:make_url(ServerUrl, [DbName], []);
_ ->
hackney_url:make_url(BaseUrl, [DbName], [])
end.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
basic_test() ->
couchbeam_mocks:setup(),
try
{ok, _} = application:ensure_all_started(couchbeam),
%% Mock hackney:get for server_info
ServerInfoResponse = #{<<"couchdb">> => <<"Welcome">>,
<<"version">> => <<"3.3.0">>,
<<"uuid">> => <<"test-uuid">>},
Ref = make_ref(),
meck:expect(hackney, get, fun(_Url, _Headers, _Body, _Opts) ->
couchbeam_mocks:set_body(Ref, ServerInfoResponse),
{ok, 200, [], Ref}
end),
Server = couchbeam:server_connection(),
{ok, Data} = couchbeam:server_info(Server),
?assertEqual(<<"Welcome">>, maps:get(<<"couchdb">>, Data)),
?assertEqual(<<"3.3.0">>, maps:get(<<"version">>, Data)),
ok
after
couchbeam_mocks:teardown()
end.
db_test() ->
couchbeam_mocks:setup(),
%% Track created databases
put(mock_dbs, []),
try
{ok, _} = application:ensure_all_started(couchbeam),
%% Mock db_request for database operations
meck:expect(couchbeam_httpc, db_request,
fun(Method, Url, _Headers, _Body, _Options, _Expect) ->
db_mock_handle_request(Method, Url)
end),
%% Mock request for delete_db and open_or_create_db (use request directly)
meck:expect(couchbeam_httpc, request,
fun(delete, Url, _Headers, _Body, _Options) ->
DbName = extract_mock_dbname(Url),
Dbs = get(mock_dbs),
case lists:member(DbName, Dbs) of
true ->
put(mock_dbs, lists:delete(DbName, Dbs)),
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, #{<<"ok">> => true}),
{ok, 200, [], Ref};
false ->
{ok, 404, [], make_ref()}
end;
(get, Url, _Headers, _Body, _Options) ->
%% open_or_create_db uses GET to check if db exists
DbName = extract_mock_dbname(Url),
Dbs = get(mock_dbs),
case lists:member(DbName, Dbs) of
true ->
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, #{<<"db_name">> => DbName}),
{ok, 200, [], Ref};
false ->
{ok, 404, [], make_ref()}
end;
(Method, Url, Headers, Body, Options) ->
meck:passthrough([Method, Url, Headers, Body, Options])
end),
Server = couchbeam:server_connection(),
%% Test db creation
?assertMatch({ok, _}, couchbeam:create_db(Server, "couchbeam_testdb")),
?assertEqual({error, db_exists}, couchbeam:create_db(Server, "couchbeam_testdb")),
?assertMatch({ok, _}, couchbeam:create_db(Server, "couchbeam_testdb2")),
%% Test all_dbs
{ok, AllDbs} = couchbeam:all_dbs(Server),
?assert(is_list(AllDbs)),
?assertEqual(true, lists:member(<<"couchbeam_testdb">>, AllDbs)),
?assertEqual(true, lists:member(<<"couchbeam_testdb2">>, AllDbs)),
%% Test db_exists
?assertEqual(true, couchbeam:db_exists(Server, "couchbeam_testdb")),
?assertEqual(true, couchbeam:db_exists(Server, "couchbeam_testdb2")),
%% Test delete_db
?assertMatch({ok, _}, couchbeam:delete_db(Server, "couchbeam_testdb2")),
{ok, AllDbs1} = couchbeam:all_dbs(Server),
?assertEqual(true, lists:member(<<"couchbeam_testdb">>, AllDbs1)),
?assertEqual(false, lists:member(<<"couchbeam_testdb2">>, AllDbs1)),
%% Test db_exists after delete
?assertEqual(true, couchbeam:db_exists(Server, "couchbeam_testdb")),
?assertEqual(false, couchbeam:db_exists(Server, "couchbeam_testdb2")),
%% Test open_or_create_db
?assertMatch({ok, _}, couchbeam:open_or_create_db(Server, "couchbeam_testdb")),
?assertMatch({ok, _}, couchbeam:open_or_create_db(Server, "couchbeam_testdb2")),
ok
after
erase(mock_dbs),
couchbeam_mocks:teardown()
end.
%% Helper for db_mock_test - handle db_request calls
db_mock_handle_request(put, Url) ->
DbName = extract_mock_dbname(Url),
Dbs = get(mock_dbs),
case lists:member(DbName, Dbs) of
true ->
{error, precondition_failed};
false ->
put(mock_dbs, [DbName | Dbs]),
{ok, 201, [], make_ref()}
end;
db_mock_handle_request(head, Url) ->
DbName = extract_mock_dbname(Url),
Dbs = get(mock_dbs),
case lists:member(DbName, Dbs) of
true -> {ok, 200, []};
false -> {error, not_found}
end;
db_mock_handle_request(get, Url) ->
case binary:match(Url, <<"_all_dbs">>) of
nomatch ->
%% db_info request
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, #{<<"db_name">> => <<"testdb">>}),
{ok, 200, [], Ref};
_ ->
%% all_dbs request
Dbs = get(mock_dbs),
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, Dbs),
{ok, 200, [], Ref}
end;
db_mock_handle_request(_Method, _Url) ->
{ok, 200, [], make_ref()}.
%% Extract database name from URL
extract_mock_dbname(Url) when is_binary(Url) ->
%% URL like "http://127.0.0.1:5984/dbname" or with query params
case hackney_url:parse_url(Url) of
#hackney_url{path = Path} ->
%% Path is like "/dbname" or "/dbname/"
PathBin = iolist_to_binary(Path),
case binary:split(PathBin, <<"/">>, [global, trim_all]) of
[DbName | _] -> DbName;
[] -> <<>>
end;
_ ->
<<>>
end.
basic_doc_test() ->
couchbeam_mocks:setup(),
%% Track documents: #{DocId => {Doc, Rev}}
put(mock_docs, #{}),
put(mock_uuid_counter, 0),
try
{ok, _} = application:ensure_all_started(couchbeam),
%% Mock couchbeam_uuids:get_uuids for auto-generated IDs
meck:new(couchbeam_uuids, [passthrough, no_link]),
meck:expect(couchbeam_uuids, get_uuids, fun(_Server, Count) ->
Counter = get(mock_uuid_counter),
Uuids = [list_to_binary("auto-uuid-" ++ integer_to_list(Counter + I))
|| I <- lists:seq(1, Count)],
put(mock_uuid_counter, Counter + Count),
Uuids
end),
%% Mock db_request for document operations
meck:expect(couchbeam_httpc, db_request,
fun(Method, Url, Headers, Body, _Options, _Expect) ->
doc_mock_handle_request(Method, Url, Headers, Body)
end),
%% Mock request for db operations (create_db uses this via db_mock_test pattern)
meck:expect(couchbeam_httpc, request,
fun(get, Url, _H, _B, _O) ->
%% For open_or_create_db check
case binary:match(Url, <<"couchbeam_testdb">>) of
nomatch -> {ok, 404, [], make_ref()};
_ -> {ok, 200, [], make_ref()}
end;
(Method, Url, Headers, Body, Options) ->
meck:passthrough([Method, Url, Headers, Body, Options])
end),
Server = couchbeam:server_connection(),
Db = #db{server=Server, name = <<"couchbeam_testdb">>, options=[]},
%% Test save_doc with auto-generated ID
{ok, Doc} = couchbeam:save_doc(Db, #{<<"test">> => <<"blah">>}),
?assert(is_map(Doc)),
%% Test save_doc with specific ID
{ok, Props} = couchbeam:save_doc(Db, #{<<"_id">> => <<"test">>, <<"test">> => <<"blah">>}),
?assertEqual(<<"test">>, maps:get(<<"_id">>, Props)),
%% Test conflict on duplicate save
?assertEqual({error, conflict}, couchbeam:save_doc(Db, #{<<"_id">> => <<"test">>, <<"test">> => <<"blah">>})),
%% Test lookup_doc_rev
{ok, Rev} = couchbeam:lookup_doc_rev(Db, "test"),
?assertMatch(<<_/binary>>, Rev),
%% Test open_doc
{ok, Doc1} = couchbeam:open_doc(Db, <<"test">>),
?assertEqual(Rev, maps:get(<<"_rev">>, Doc1)),
?assertEqual(<<"blah">>, maps:get(<<"test">>, Doc1)),
%% Test save and open another doc
_ = couchbeam:save_doc(Db, #{<<"_id">> => <<"test2">>, <<"test">> => <<"blah">>}),
{ok, Doc2} = couchbeam:open_doc(Db, "test2"),
?assert(is_map(Doc2)),
?assertEqual(true, couchbeam_doc:is_saved(Doc2)),
?assertEqual(<<"test2">>, couchbeam_doc:get_id(Doc2)),
%% Test doc_exists
?assertMatch(true, couchbeam:doc_exists(Db, "test2")),
%% Test delete_doc
?assertMatch({ok, _}, couchbeam:delete_doc(Db, Doc2)),
?assertEqual({error, not_found}, couchbeam:open_doc(Db, "test2")),
?assertMatch(false, couchbeam:doc_exists(Db, "test2")),
%% Test special characters in doc ID
Doc3 = #{<<"_id">> => <<"special-chars-test">>},
{ok, _Doc4} = couchbeam:save_doc(Db, Doc3),
{ok, Doc5} = couchbeam:open_doc(Db, <<"special-chars-test">>),
?assertEqual(<<"special-chars-test">>, couchbeam_doc:get_id(Doc5)),
ok
after
erase(mock_docs),
erase(mock_uuid_counter),
catch meck:unload(couchbeam_uuids),
couchbeam_mocks:teardown()
end.
%% Helper for basic_doc_mock_test - handle document requests
doc_mock_handle_request(put, Url, _Headers, Body) ->
%% save_doc - PUT /db/docid
DocId = extract_mock_docid(Url),
Docs = get(mock_docs),
DocProps = couchbeam_ejson:decode(Body),
%% Extract and store inline attachments if present
case maps:get(<<"_attachments">>, DocProps, undefined) of
undefined -> ok;
AttMap when is_map(AttMap) ->
Atts = case get(mock_attachments) of undefined -> #{}; A -> A end,
NewAtts = maps:fold(fun(AttName, AttProps, Acc) ->
case maps:get(<<"data">>, AttProps, undefined) of
undefined -> Acc;
Base64Data ->
Data = base64:decode(Base64Data),
maps:put({DocId, AttName}, Data, Acc)
end
end, Atts, AttMap),
put(mock_attachments, NewAtts)
end,
case maps:is_key(DocId, Docs) of
true ->
%% Check if update (has _rev) or conflict
case maps:get(<<"_rev">>, DocProps, undefined) of
undefined ->
{error, conflict};
_Rev ->
%% Update with new rev
NewRev = generate_mock_rev(),
UpdatedDoc = maps:merge(maps:remove(<<"_rev">>, maps:remove(<<"_id">>, DocProps)),
#{<<"_id">> => DocId, <<"_rev">> => NewRev}),
put(mock_docs, maps:put(DocId, {UpdatedDoc, NewRev}, Docs)),
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"id">> => DocId, <<"rev">> => NewRev}),
{ok, 201, [], Ref}
end;
false ->
%% New document
NewRev = generate_mock_rev(),
NewDoc = maps:merge(maps:remove(<<"_rev">>, maps:remove(<<"_id">>, DocProps)),
#{<<"_id">> => DocId, <<"_rev">> => NewRev}),
put(mock_docs, maps:put(DocId, {NewDoc, NewRev}, Docs)),
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"id">> => DocId, <<"rev">> => NewRev}),
{ok, 201, [], Ref}
end;
doc_mock_handle_request(get, Url, _Headers, _Body) ->
%% open_doc - GET /db/docid
DocId = extract_mock_docid(Url),
Docs = get(mock_docs),
case maps:get(DocId, Docs, undefined) of
undefined ->
{error, not_found};
{Doc, _Rev} ->
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, Doc),
{ok, 200, [], Ref}
end;
doc_mock_handle_request(head, Url, _Headers, _Body) ->
%% lookup_doc_rev or doc_exists - HEAD /db/docid
DocId = extract_mock_docid(Url),
Docs = get(mock_docs),
case maps:get(DocId, Docs, undefined) of
undefined ->
{error, not_found};
{_Doc, Rev} ->
ETag = <<"\"", Rev/binary, "\"">>,
{ok, 200, [{<<"ETag">>, ETag}]}
end;
doc_mock_handle_request(post, Url, _Headers, Body) ->
%% _bulk_docs - POST /db/_bulk_docs
case binary:match(Url, <<"_bulk_docs">>) of
nomatch ->
{ok, 200, [], make_ref()};
_ ->
BodyProps = couchbeam_ejson:decode(Body),
InputDocs = maps:get(<<"docs">>, BodyProps, []),
Results = lists:map(fun(DocProps) ->
DocId = maps:get(<<"_id">>, DocProps),
IsDeleted = maps:get(<<"_deleted">>, DocProps, false),
case IsDeleted of
true ->
put(mock_docs, maps:remove(DocId, get(mock_docs))),
#{<<"ok">> => true, <<"id">> => DocId, <<"rev">> => generate_mock_rev()};
false ->
NewRev = generate_mock_rev(),
NewDoc = maps:merge(maps:without([<<"_deleted">>, <<"_rev">>, <<"_id">>], DocProps),
#{<<"_id">> => DocId, <<"_rev">> => NewRev}),
put(mock_docs, maps:put(DocId, {NewDoc, NewRev}, get(mock_docs))),
#{<<"ok">> => true, <<"id">> => DocId, <<"rev">> => NewRev}
end
end, InputDocs),
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, Results),
{ok, 201, [], Ref}
end;
doc_mock_handle_request(copy, Url, Headers, _Body) ->
%% copy_doc - COPY /db/docid with Destination header
DocId = extract_mock_docid(Url),
Docs = get(mock_docs),
case maps:get(DocId, Docs, undefined) of
undefined ->
{error, not_found};
{SourceDoc, _SourceRev} ->
%% Get destination from headers
DestId = case proplists:get_value(<<"Destination">>, Headers) of
undefined -> generate_mock_uuid();
DestHeader ->
%% Strip ?rev= if present
case binary:split(DestHeader, <<"?">>) of
[Id | _] -> Id;
_ -> DestHeader
end
end,
%% Create new doc with destination ID
NewRev = generate_mock_rev(),
NewDoc = maps:merge(maps:without([<<"_rev">>, <<"_id">>], SourceDoc),
#{<<"_id">> => DestId, <<"_rev">> => NewRev}),
put(mock_docs, maps:put(DestId, {NewDoc, NewRev}, get(mock_docs))),
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"id">> => DestId, <<"rev">> => NewRev}),
{ok, 201, [], Ref}
end;
doc_mock_handle_request(_Method, _Url, _Headers, _Body) ->
{ok, 200, [], make_ref()}.
%% Extract document ID from URL like "http://127.0.0.1:5984/db/docid"
extract_mock_docid(Url) when is_binary(Url) ->
case hackney_url:parse_url(Url) of
#hackney_url{path = Path} ->
PathBin = iolist_to_binary(Path),
case binary:split(PathBin, <<"/">>, [global, trim_all]) of
[_Db, DocId | _] -> DocId;
_ -> <<>>
end;
_ ->
<<>>
end.
%% Generate a mock revision
generate_mock_rev() ->
Counter = get(mock_uuid_counter),
put(mock_uuid_counter, Counter + 1),
iolist_to_binary(["1-mock-rev-", integer_to_list(Counter)]).
%% Generate a mock UUID
generate_mock_uuid() ->
Counter = get(mock_uuid_counter),
put(mock_uuid_counter, Counter + 1),
iolist_to_binary(["mock-uuid-", integer_to_list(Counter)]).
bulk_doc_test() ->
couchbeam_mocks:setup(),
put(mock_docs, #{}),
put(mock_uuid_counter, 0),
try
{ok, _} = application:ensure_all_started(couchbeam),
%% Mock db_request for document operations (reuse doc_mock_handle_request)
meck:expect(couchbeam_httpc, db_request,
fun(Method, Url, Headers, Body, _Options, _Expect) ->
doc_mock_handle_request(Method, Url, Headers, Body)
end),
Server = couchbeam:server_connection(),
Db = #db{server=Server, name = <<"couchbeam_testdb">>, options=[]},
%% Test bulk save
Doc1 = #{<<"_id">> => <<"a">>},
Doc2 = #{<<"_id">> => <<"b">>},
{ok, [Props1, Props2]} = couchbeam:save_docs(Db, [Doc1, Doc2]),
?assertEqual(<<"a">>, maps:get(<<"id">>, Props1)),
?assertEqual(<<"b">>, maps:get(<<"id">>, Props2)),
%% Verify docs exist
?assertMatch(true, couchbeam:doc_exists(Db, "a")),
?assertMatch(true, couchbeam:doc_exists(Db, "b")),
%% Test bulk delete
{ok, Doc3} = couchbeam:open_doc(Db, <<"a">>),
{ok, Doc4} = couchbeam:open_doc(Db, <<"b">>),
couchbeam:delete_docs(Db, [Doc3, Doc4]),
?assertEqual({error, not_found}, couchbeam:open_doc(Db, <<"a">>)),
?assertEqual({error, not_found}, couchbeam:open_doc(Db, <<"b">>)),
ok
after
erase(mock_docs),
erase(mock_uuid_counter),
couchbeam_mocks:teardown()
end.
copy_doc_test() ->
couchbeam_mocks:setup(),
put(mock_docs, #{}),
put(mock_uuid_counter, 0),
%% Mock UUIDs for copy_doc without destination
meck:new(couchbeam_uuids, [passthrough, no_link]),
meck:expect(couchbeam_uuids, get_uuids, fun(_Server, _Count) ->
[generate_mock_uuid()]
end),
try
{ok, _} = application:ensure_all_started(couchbeam),
%% Mock db_request for document operations
meck:expect(couchbeam_httpc, db_request,
fun(Method, Url, Headers, Body, _Options, _Expect) ->
doc_mock_handle_request(Method, Url, Headers, Body)
end),
Server = couchbeam:server_connection(),
Db = #db{server=Server, name = <<"couchbeam_testdb">>, options=[]},
%% Create a document to copy
Doc = #{<<"_id">> => <<"original">>, <<"test">> => 1},
{ok, SavedDoc} = couchbeam:save_doc(Db, Doc),
%% Test copy without destination (generates UUID)
{ok, CopyId, _Rev} = couchbeam:copy_doc(Db, SavedDoc),
{ok, Doc2} = couchbeam:open_doc(Db, CopyId),
?assertEqual(1, couchbeam_doc:get_value(<<"test">>, Doc2)),
%% Test copy with specific destination
{ok, CopyId1, _} = couchbeam:copy_doc(Db, SavedDoc, <<"test_copy">>),
?assertEqual(<<"test_copy">>, CopyId1),
{ok, Doc4} = couchbeam:open_doc(Db, CopyId1),
?assertEqual(1, couchbeam_doc:get_value(<<"test">>, Doc4)),
ok
after
erase(mock_docs),
erase(mock_uuid_counter),
catch meck:unload(couchbeam_uuids),
couchbeam_mocks:teardown()
end.
attachments_test() ->
couchbeam_mocks:setup(),
put(mock_docs, #{}),
put(mock_attachments, #{}),
put(mock_uuid_counter, 0),
try
{ok, _} = application:ensure_all_started(couchbeam),
%% Mock db_request for document and attachment operations
meck:expect(couchbeam_httpc, db_request,
fun(Method, Url, Headers, Body, _Options, _Expect) ->
att_mock_handle_request(Method, Url, Headers, Body)
end),
%% Mock hackney:get for fetch_attachment
meck:expect(hackney, get, fun(Url, _Headers, _Body, _Opts) ->
case extract_mock_attachment_info(Url) of
{DocId, AttName} ->
Atts = get(mock_attachments),
case maps:get({DocId, AttName}, Atts, undefined) of
undefined ->
{ok, 404, [], make_ref()};
AttBody ->
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, AttBody),
{ok, 200, [], Ref}
end;
_ ->
{ok, 404, [], make_ref()}
end
end),
Server = couchbeam:server_connection(),
Db = #db{server=Server, name = <<"couchbeam_testdb">>, options=[]},
%% Create a document
Doc = #{<<"_id">> => <<"test">>},
{ok, Doc1} = couchbeam:save_doc(Db, Doc),
RevDoc1 = couchbeam_doc:get_rev(Doc1),
%% Put attachment
{ok, Res} = couchbeam:put_attachment(Db, "test", "test.txt", <<"hello">>, [{rev, RevDoc1}]),
RevDoc11 = maps:get(<<"rev">>, Res),
?assertNot(RevDoc1 =:= RevDoc11),
%% Fetch attachment
{ok, Attachment} = couchbeam:fetch_attachment(Db, "test", "test.txt"),
?assertEqual(<<"hello">>, Attachment),
%% Open doc and delete attachment
{ok, Doc2} = couchbeam:open_doc(Db, "test"),
?assertMatch({ok, _}, couchbeam:delete_attachment(Db, Doc2, "test.txt")),
%% Verify attachment is deleted
?assertEqual({error, not_found}, couchbeam:fetch_attachment(Db, "test", "test.txt")),
%% Test inline attachments
Doc3 = #{<<"_id">> => <<"test2">>},
Doc4 = couchbeam_attachments:add_inline(Doc3, "test", "test.txt"),
{ok, _} = couchbeam:save_doc(Db, Doc4),
{ok, Attachment1} = couchbeam:fetch_attachment(Db, "test2", "test.txt"),
?assertEqual(<<"test">>, Attachment1),
ok
after
erase(mock_docs),
erase(mock_attachments),
erase(mock_uuid_counter),
couchbeam_mocks:teardown()
end.
%% Helper for attachments mock test
att_mock_handle_request(put, Url, _Headers, Body) ->
case is_attachment_url(Url) of
{true, DocId, AttName} ->
%% PUT attachment
Atts = get(mock_attachments),
BodyBin = if is_binary(Body) -> Body; true -> iolist_to_binary(Body) end,
put(mock_attachments, maps:put({DocId, AttName}, BodyBin, Atts)),
NewRev = generate_mock_rev(),
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"id">> => DocId, <<"rev">> => NewRev}),
{ok, 201, [], Ref};
false ->
%% Regular doc PUT
doc_mock_handle_request(put, Url, [], Body)
end;
att_mock_handle_request(delete, Url, _Headers, _Body) ->
case is_attachment_url(Url) of
{true, DocId, AttName} ->
Atts = get(mock_attachments),
put(mock_attachments, maps:remove({DocId, AttName}, Atts)),
NewRev = generate_mock_rev(),
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"rev">> => NewRev}),
{ok, 200, [], Ref};
false ->
doc_mock_handle_request(delete, Url, [], <<>>)
end;
att_mock_handle_request(Method, Url, Headers, Body) ->
doc_mock_handle_request(Method, Url, Headers, Body).
%% Check if URL is for an attachment (has 3 path parts: db/doc/att)
is_attachment_url(Url) when is_binary(Url) ->
case hackney_url:parse_url(Url) of
#hackney_url{path = Path} ->
PathBin = iolist_to_binary(Path),
case binary:split(PathBin, <<"/">>, [global, trim_all]) of
[_Db, DocId, AttName | _] ->
%% Check it's not a special endpoint
case binary:match(AttName, <<"_">>) of
{0, _} -> false; % _all_docs, _bulk_docs, etc.
_ -> {true, DocId, AttName}
end;
_ -> false
end;
_ -> false
end.
%% Extract doc id and attachment name from URL
extract_mock_attachment_info(Url) when is_binary(Url) ->
case hackney_url:parse_url(Url) of
#hackney_url{path = Path} ->
PathBin = iolist_to_binary(Path),
case binary:split(PathBin, <<"/">>, [global, trim_all]) of
[_Db, DocId, AttName | _] -> {DocId, AttName};
_ -> undefined
end;
_ -> undefined
end.
%% Replicate test - tests replication document creation and related operations
replicate_test() ->
couchbeam_mocks:setup(),
put(mock_docs, #{}),
put(mock_uuid_counter, 0),
%% Mock UUIDs for replication doc
meck:new(couchbeam_uuids, [passthrough, no_link]),
meck:expect(couchbeam_uuids, get_uuids, fun(_Server, _Count) ->
[generate_mock_uuid()]
end),
try
{ok, _} = application:ensure_all_started(couchbeam),
%% Mock db_request for replication operations
meck:expect(couchbeam_httpc, db_request,
fun(Method, Url, _H, Body, _O, _E) ->
replicate_mock_handle(Method, Url, Body)
end),
Server = couchbeam:server_connection(),
Db = #db{server=Server, name = <<"source">>, options=[]},
Db2 = #db{server=Server, name = <<"target">>, options=[]},
%% Test replicate - creates a replication document
{ok, RepDoc} = couchbeam:replicate(Server, Db, Db2, [{<<"continuous">>, true}]),
?assert(couchbeam_doc:is_saved(RepDoc)),
%% Test get_missing_revs
{ok, Missing} = couchbeam:get_missing_revs(Db2, [{<<"doc1">>, [<<"1-abc">>, <<"2-def">>]}]),
?assertEqual([{<<"doc1">>, [<<"1-abc">>, <<"2-def">>], []}], Missing),
%% Test ensure_full_commit
{ok, StartTime} = couchbeam:ensure_full_commit(Db),
?assert(is_binary(StartTime)),
ok
after
erase(mock_docs),
erase(mock_uuid_counter),
catch meck:unload(couchbeam_uuids),
couchbeam_mocks:teardown()
end.
%% Handle replication-related mock requests
replicate_mock_handle(put, Url, Body) ->
UrlBin = iolist_to_binary(Url),
case binary:match(UrlBin, <<"_replicator">>) of
nomatch ->
doc_mock_handle_request(put, Url, [], Body);
_ ->
%% Save replication doc to _replicator
DocId = generate_mock_uuid(),
NewRev = generate_mock_rev(),
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"id">> => DocId, <<"rev">> => NewRev}),
{ok, 201, [], Ref}
end;
replicate_mock_handle(post, Url, Body) ->
UrlBin = iolist_to_binary(Url),
case binary:match(UrlBin, <<"_revs_diff">>) of
nomatch ->
case binary:match(UrlBin, <<"_ensure_full_commit">>) of
nomatch ->
{ok, 200, [], make_ref()};
_ ->
%% ensure_full_commit
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"instance_start_time">> => <<"0">>}),
{ok, 201, [], Ref}
end;
_ ->
%% _revs_diff - return all revisions as missing
IdRevs = couchbeam_ejson:decode(Body),
Result = maps:fold(fun(DocId, Revs, Acc) ->
maps:put(DocId, #{<<"missing">> => Revs}, Acc)
end, #{}, IdRevs),
Ref = make_ref(),
couchbeam_mocks:set_body(Ref, Result),
{ok, 200, [], Ref}
end;
replicate_mock_handle(Method, Url, Body) ->
doc_mock_handle_request(Method, Url, [], Body).
-endif.