diff --git a/src/dev_copycat_graphql.erl b/src/dev_copycat_graphql.erl index 58c2b824a..b3422d1ef 100644 --- a/src/dev_copycat_graphql.erl +++ b/src/dev_copycat_graphql.erl @@ -6,7 +6,14 @@ -include_lib("eunit/include/eunit.hrl"). -define(SUPPORTED_FILTERS, - [<<"query">>, <<"tag">>, <<"owner">>, <<"recipient">>, <<"all">>] + [ + <<"query">>, + <<"tag">>, + <<"tags">>, + <<"owners">>, + <<"recipients">>, + <<"ids">>, + <<"all">>] ). %% @doc Takes a GraphQL query, optionally with a node address, and curses through @@ -131,36 +138,21 @@ index_graphql(Total, Query, Vars, Node, OpName, Opts) -> parse_query(Base, Req, Opts) -> % Merge the keys of the base and request maps, and remove duplicates. Merged = hb_maps:merge(Base, Req, Opts), - Keys = hb_maps:keys(Merged, Opts), + LoadedMerged = hb_cache:ensure_all_loaded(Merged, Opts), + Keys = hb_maps:keys(LoadedMerged, Opts), SupportedKeys = ?SUPPORTED_FILTERS, - ?event({finding_query, {supported, SupportedKeys}, {merged_req, Merged}}), - case lists:filter(fun(K) -> lists:member(K, SupportedKeys) end, Keys) of - [<<"query">>|_] -> - % Find the query in either the `query' field or the `body'. - case hb_maps:find(<<"query">>, Merged, Opts) of - {ok, QueryKeys} when is_map(QueryKeys) -> - default_query(<<"tags">>, QueryKeys, Opts); - {ok, Bin} when is_binary(Bin) -> - {ok, Bin}; - _ -> - case hb_maps:find(<<"body">>, Merged, Opts) of - {ok, Bin} when is_binary(Bin) -> - {ok, Bin}; - _ -> - {error, - #{ - <<"body">> => - <<"No query found in the request.">> - } - } - end - end; - [<<"tag">>|_] -> - Key = hb_maps:get(<<"tag">>, Merged, <<>>, Opts), - Value = hb_maps:get(<<"value">>, Merged, <<>>, Opts), - default_query(<<"tag">>, {Key, Value}, Opts); - [FilterKey|_] -> - default_query(FilterKey, Merged, Opts); + MatchingKeys = + lists:filter( + fun(K) -> lists:member(K, SupportedKeys) end, + Keys + ), + ?event( + {finding_query, + {supported, SupportedKeys}, + {merged_req, LoadedMerged} + } + ), + case MatchingKeys of [] -> {error, #{ @@ -177,62 +169,278 @@ parse_query(Base, Req, Opts) -> )/binary >> } - } + }; + [<<"query">>|_] -> + % Handle query parameter - can be map or binary + case hb_maps:find(<<"query">>, LoadedMerged, Opts) of + {ok, QueryKeys} when is_map(QueryKeys) -> + build_combined_query(QueryKeys, Opts); + {ok, Bin} when is_binary(Bin) -> + {ok, Bin}; + _ -> + case hb_maps:find(<<"body">>, LoadedMerged, Opts) of + {ok, Bin} when is_binary(Bin) -> + {ok, Bin}; + _ -> + {error, + #{ + <<"body">> => + <<"No query found in the request.">> + } + } + end + end; + [<<"tag">>|_] -> + Key = hb_maps:get(<<"tag">>, LoadedMerged, <<>>, Opts), + Value = hb_maps:get(<<"value">>, LoadedMerged, <<>>, Opts), + TagsMap = case {Key, Value} of + {<<>>, <<>>} -> #{}; + _ -> #{Key => Value} + end, + build_combined_query(#{<<"tags">> => TagsMap}, Opts); + _ -> + build_combined_query(LoadedMerged, Opts) end. -%% @doc Return a default query for a given filter type. -default_query(<<"tags">>, RawMessage, Opts) -> - Message = hb_cache:ensure_all_loaded(RawMessage, Opts), - BinaryPairs = - lists:map( - fun({Key, Value}) -> {hb_util:bin(Key), hb_util:bin(Value)} end, - hb_maps:to_list(Message, Opts) +%% @doc Build GraphQL array from single value or list of values +build_graphql_array(Values) when is_list(Values) -> + ValuesList = lists:map(fun hb_util:bin/1, Values), + ValuesStr = hb_util:bin(lists:join(<<"\", \"">>, ValuesList)), + <<"[\"", ValuesStr/binary, "\"]">>; +build_graphql_array(SingleValue) when is_binary(SingleValue) -> + <<"[\"", SingleValue/binary, "\"]">>. + +%% @doc Build combined GraphQL query supporting multiple filters +%% Handles: {"tags": {"type": "process"}, "owners": ["addr1"], "recipients": ["rec1"]} +build_combined_query(LoadedKeys, Opts) -> + TagsPart = + build_tags_part(hb_maps:get(<<"tags">>, LoadedKeys, #{}, Opts)), + OwnersPart = + build_filter_part( + <<"owners">>, + hb_maps:get(<<"owners">>, LoadedKeys, [], Opts) ), - TagsQueryStr = - hb_util:bin( - [ - <<"{name: \"", Key/binary, "\", values: [\"", Value/binary, "\"]}">> - || - {Key, Value} <- BinaryPairs - ] + RecipientsPart = + build_filter_part( + <<"recipients">>, + hb_maps:get(<<"recipients">>, LoadedKeys, [], Opts) + ), + IdsPart = + build_filter_part( + <<"ids">>, + hb_maps:get(<<"ids">>, LoadedKeys, [], Opts) + ), + %% Combine the filter criteria after preparing filters + AllParts = TagsPart ++ OwnersPart ++ RecipientsPart ++ IdsPart, + default_query(AllParts). + +%% @doc Build tags part - special handling for map structure +build_tags_part(TagsMap) when map_size(TagsMap) =:= 0 -> []; +build_tags_part(TagsMap) when is_map(TagsMap) -> + TagStrings = [ + <<"{name: \"", + (hb_util:bin(Key))/binary, + "\", values: ", + (build_graphql_array(Value))/binary, + "}">> + || {Key, Value} <- maps:to_list(TagsMap) + ], + [<<"tags: [", (iolist_to_binary(lists:join(<<", ">>, TagStrings)))/binary, "]">>]. + +%% @doc Build filter part with empty check +build_filter_part(_FilterName, []) -> []; +build_filter_part(FilterName, Values) -> + [<>]. + +%% @doc Build final GraphQL query for empty vs non-empty +default_query([]) -> + {ok, <<"query($after: String) { transactions(after: $after) { edges { ", + (hb_gateway_client:item_spec())/binary, + " } pageInfo { hasNextPage } } }">>}; +default_query(Parts) -> + CombinedFilters = iolist_to_binary(lists:join(<<", ">>, Parts)), + {ok, <<"query($after: String) { transactions(after: $after, ", + CombinedFilters/binary, + ") { edges { ", (hb_gateway_client:item_spec())/binary, + " } pageInfo { hasNextPage } } }">>}. + +%%% Tests +%% @doc Run node for testing +run_test_node() -> + Store = hb_test_utils:test_store(hb_store_lmdb), + Opts = #{ store => Store, priv_wallet => hb:wallet() }, + Node = hb_http_server:start_node(Opts), + {Node ,Opts}. +%% @doc Basic test to test copycat device +basic_test() -> + {Node, _Opts} = run_test_node(), + {ok, Res} = + hb_http:get( + Node, + #{ + <<"path">> => <<"~copycat@1.0/graphql?tag=type&value=process">> + }, + #{} + ), + ?event({basic_test_result, Res}), + ok. + +query_test() -> + Base = #{ + <<"query">> => #{ + <<"tags">> => #{ + <<"type">> => [<<"process">>,<<"assignment">>], + <<"Data-Protocol">> => <<"ao">> + }, + <<"owners">> => [<<"addr123">>], + <<"recipients">> => [<<"rec1">>, <<"rec2">>], + <<"ids">> => [<<"id1">>, <<"id2">>, <<"id3">>] + } + }, + Req = #{}, + Opts = #{}, + {ok, Query} = parse_query(Base, Req, Opts), + ?event({query_test_result, {explicit, Query}}), + ?assert( + binary:matches( + Query, + <<"{name: \"type\", values: [\"process\", \"assignment\"]}">> + ) =/= [] + ), + ?assert( + binary:matches( + Query, + <<"{name: \"Data-Protocol\", values: [\"ao\"]}">> + ) =/= [] + ), + ok. + +%% @doc Test tag/value pair format +tag_value_test() -> + Base = #{<<"tag">> => <<"type">>, <<"value">> => <<"process">>}, + {ok, Query} = parse_query(Base, #{}, #{}), + ?event({tag_value_test, {query, Query}}), + ?assert( + binary:matches( + Query, + <<"{name: \"type\", values: [\"process\"]}">> + ) =/= [] + ), + ok. + +%% @doc Test owners filter with single value +owners_filter_test() -> + Base = #{<<"owners">> => <<"addr123">>}, + {ok, Query} = parse_query(Base, #{}, #{}), + ?event({owners_filter_test, {query, Query}}), + ?assert( + binary:matches( + Query, + <<"owners: [\"addr123\"]">> + ) =/= [] + ), + ok. + +%% @doc Test recipients filter with array values +recipients_filter_test() -> + Base = #{<<"recipients">> => [<<"rec1">>, <<"rec2">>]}, + {ok, Query} = parse_query(Base, #{}, #{}), + ?event({recipients_filter_test, {query, Query}}), + ?assert( + binary:matches( + Query, + <<"recipients: [\"rec1\", \"rec2\"]">> + ) =/= [] + ), + ok. + +%% @doc Test ids filter +ids_filter_test() -> + Base = #{<<"ids">> => [<<"id1">>, <<"id2">>, <<"id3">>]}, + {ok, Query} = parse_query(Base, #{}, #{}), + ?event({ids_filter_test, {query, Query}}), + ?assert( + binary:matches( + Query, + <<"ids: [\"id1\", \"id2\", \"id3\"]">> + ) =/= [] + ), + ok. + +%% @doc Test all filter type +all_filter_test() -> + Base = #{<<"all">> => <<"true">>}, + {ok, Query} = parse_query(Base, #{}, #{}), + ?event({all_filter_test, {query, Query}}), + ?assert( + binary:matches( + Query, + <<"transactions(after: $after)">> + ) =/= [] + ), + ok. + +%% @doc Test combined multiple filters in one query +combined_filters_test() -> + Base = #{ + <<"query">> => #{ + <<"tags">> => #{ + <<"type">> => [<<"process">>, <<"assignment">>], + <<"Data-Protocol">> => <<"ao">> + }, + <<"owners">> => <<"addr123">>, + <<"recipients">> => [<<"rec1">>, <<"rec2">>], + <<"ids">> => [<<"id1">>, <<"id2">>] + } + }, + {ok, Query} = parse_query(Base, #{}, #{}), + ?event({combined_filters_test, {query, Query}}), + % Should have tags + ?assert( + binary:matches( + Query, + <<"{name: \"type\", values: [\"process\", \"assignment\"]}">> + ) =/= [] + ), + ?assert( + binary:matches( + Query, + <<"{name: \"Data-Protocol\", values: [\"ao\"]}">> + ) =/= [] + ), + % Should have owners + ?assert( + binary:matches(Query, <<"owners: [\"addr123\"]">>) + =/= [] + ), + % Should have recipients + ?assert( + binary:matches(Query, <<"recipients: [\"rec1\", \"rec2\"]">>) + =/= [] + ), + % Should have ids + ?assert( + binary:matches(Query, <<"ids: [\"id1\", \"id2\"]">>) + =/= [] + ), + ok. + +%% @doc Real world test with actual indexing +fetch_scheduler_location_test_disabled() -> + {Node, _Opts} = run_test_node(), + {ok, Res} = + hb_http:get( + Node, + #{ + <<"path">> => <<"~copycat@1.0/graphql?tags+map=Type=Scheduler-Location">> + }, + #{} ), - ?event({tags_query, - {message, Message}, - {binary_pairs, BinaryPairs}, - {tags_query_str, {string, TagsQueryStr}} - }), - {ok, <<"query($after: String) { ", - "transactions(after: $after, tags: [", - TagsQueryStr/binary, - "]) { ", - "edges { ", (hb_gateway_client:item_spec())/binary , " } ", - "pageInfo { hasNextPage }", - "} }">>}; -default_query(<<"tag">>, {Key, Value}, _Opts) -> - {ok, <<"query($after: String) { ", - "transactions(after: $after, tags: [", - "{name: \"", Key/binary, "\", values: [\"", Value/binary, "\"]}", - "]) { ", - "edges { ", (hb_gateway_client:item_spec())/binary , " } ", - "pageInfo { hasNextPage }", - "} }">>}; -default_query(<<"recipient">>, Merged, Opts) -> - Recipient = hb_maps:get(<<"recipient">>, Merged, <<>>, Opts), - {ok, <<"query($after: String) { ", - "transactions(after: $after, recipients: [\"", Recipient/binary, "\"]) { ", - "edges { ", (hb_gateway_client:item_spec())/binary , " } ", - "pageInfo { hasNextPage }", - "} }">>}; -default_query(<<"owner">>, Merged, Opts) -> - Owner = hb_maps:get(<<"owner">>, Merged, <<>>, Opts), - {ok, <<"query($after: String) { ", - "transactions(after: $after, owner: \"", Owner/binary, "\") { ", - "edges { ", (hb_gateway_client:item_spec())/binary , " } ", - "pageInfo { hasNextPage }", - "} }">>}; -default_query(<<"all">>, _Merged, _Opts) -> - {ok, <<"query($after: String) { ", - "transactions(after: $after) { ", - "edges { ", (hb_gateway_client:item_spec())/binary , " } ", - "pageInfo { hasNextPage }", - "} }">>}. \ No newline at end of file + ?event({graphql_indexing_completed, {response, Res}}), + ?assert(is_tuple(Res)), + {Status, Data} = Res, + ?assertEqual(ok, Status), + ?assert(is_integer(Data)), + ?assert(Data > 0), + ?event({schedulers_indexed, Data}), + ok.