src/models/m_search.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2009-2026 Marc Worrell
%% @doc Search model, used as an interface to the search functions of modules etc.
%% @end

%% Copyright 2009-2026 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%%     http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.

%% A deprecated search question is represented by:
%%      {search_name, [PropList]}
%% The search result is represented by:
%%      {search_result, [Results], [PropList], PagingInfo}
%%
%% {% for id in m.search.featured::%{ cat: "accessoiries" } %}
%%

-module(m_search).
-moduledoc("
The m_search model provides access to different kinds of search queries for searching through models.

Most searches in Zotonic are implemented in the [mod_search](/id/doc_module_mod_search) module, searching through the
`rsc` table in different kinds of ways.

Though, any module can implement a search by observing the `search_query` notification.

The search module is used inside templates. For example, the following snippet fetches the latest 10 modified pages in
the \"text\" category:


```django
{% for id in m.search[{latest cat=\"text\" pagelen=10}] %}
    {{ m.rsc[id].title }}
{% endfor %}
```

Another example, searching for a text and requesting the second page with 20 results at a time:


```django
{% for id, rank in m.search.paged[{fulltext text=query_string page=2 pagelen=20}] %}
    {{ m.rsc[id].title }}
{% endfor %}
```

Available Model API Paths
-------------------------

| Method | Path pattern | Description |
| --- | --- | --- |
| `get` | `/paged/+searchname/...` | Run named search `+searchname` with request arguments and return paged search results. |
| `get` | `/count/+searchname/...` | Run named search `+searchname` and return only the total match count. |
| `get` | `/paged/+name_props_searchprops/...` | Run deprecated tuple-style search (`{Name, Props}`) and return paged results. |
| `get` | `/+name_props_searchprops/...` | Run deprecated tuple-style search (`{Name, Props}`) and return non-paged results. |
| `get` | `/paged` | Run search from payload/query arguments and return paged results. No further lookups. |
| `get` | `/count` | Run search from payload/query arguments and return only the count. No further lookups. |
| `get` | `/+searchname/...` | Run named search `+searchname` with request arguments and return results. |
| `get` | `/` | Run search from payload/query arguments using the default search handling. No further lookups. |

`/+name` marks a variable path segment. A trailing `/...` means extra path segments are accepted for further lookups.

See also

[Search](/id/doc_developerguide_search#guide-datamodel-query-model) , [pager](/id/doc_template_scomp_scomp_pager#scomp-pager) tag , [mod_search](/id/doc_module_mod_search) module , [Custom search](/id/doc_cookbook_custom_search#cookbook-custom-search)").
-author("Marc Worrell <marc@worrell.nl").

-behaviour(zotonic_model).

%% interface functions
-export([
    m_get/3,

    search/2,
    search_pager/2,

    search/3
]).

-include_lib("zotonic.hrl").

%% @doc Fetch the value for the key from a model source
-spec m_get( list(), zotonic_model:opt_msg(), z:context() ) -> zotonic_model:return().
m_get([ <<"paged">>, SearchName | Rest ], Msg, Context) when is_binary(SearchName) ->
    case search(SearchName, search_args(Msg), Context) of
        {error, _} = Error ->
            Error;
        {ok, Result} ->
            {ok, {Result, Rest}}
    end;
m_get([ <<"count">>, SearchName | Rest ], Msg, Context) when is_binary(SearchName) ->
    case search(SearchName, search_args(Msg), #{ is_count_rows => true }, Context) of
        {error, _} = Error ->
            Error;
        {ok, Result} ->
            {ok, {Result, Rest}}
    end;
m_get([ <<"paged">>, {Name, Props} = SearchProps | Rest ], _Msg, Context) when is_list(Props), is_atom(Name) ->
    case search_deprecated(SearchProps, true, Context) of
        {error, _} = Error ->
            Error;
        {ok, Result} ->
            {ok, {Result, Rest}}
    end;
m_get([ {Name, Props} = SearchProps | Rest ], _Msg, Context) when is_list(Props), is_atom(Name) ->
    case search_deprecated(SearchProps, false, Context) of
        {error, _} = Error ->
            Error;
        {ok, Result} ->
            {ok, {Result, Rest}}
    end;
m_get([ <<"paged">> ], Msg, Context) ->
    case search(<<"query">>, search_args(Msg), Context) of
        {error, _} = Error ->
            Error;
        {ok, Result} ->
            {ok, {Result, []}}
    end;
m_get([ <<"count">> ], Msg, Context) ->
    case search(<<"query">>, search_args(Msg), #{ is_count_rows => true }, Context) of
        {error, _} = Error ->
            Error;
        {ok, Result} ->
            {ok, {Result, []}}
    end;
m_get([ SearchName | Rest ], Msg, Context) when is_binary(SearchName) ->
    case search(SearchName, search_args(Msg), Context) of
        {error, _} = Error ->
            Error;
        {ok, Result} ->
            {ok, {Result, Rest}}
    end;
m_get([], Msg, Context) ->
    case search(<<"query">>, search_args(Msg), Context) of
        {error, _} = Error ->
            Error;
        {ok, Result} ->
            {ok, {Result, []}}
    end.


%% @doc Perform a search. Pass page and pagelen as arguments for paging.
-spec search( binary(), map(), z:context() ) -> {ok, #search_result{}} | {error, term()}.
search(Name, Args, Context) when is_binary(Name), is_map(Args) ->
    search(Name, Args, #{}, Context).

-spec search( binary(), map(), ForcedOptions, z:context() ) -> {ok, #search_result{}} | {error, term()} when
    ForcedOptions :: z_search:search_options().
search(Name, Args, ForcedOptions, Context) when is_binary(Name), is_map(Args), is_map(ForcedOptions) ->
    {Page, PageLen, Args1} = get_paging_props(Args, Context),
    {Options, Args2} = get_search_options(Args1),
    Options1 = maps:merge(Options, ForcedOptions),
    try
        {ok, z_search:search(Name, Args2, Page, PageLen, Options1, Context)}
    catch
        Result:Reason:Stack ->
            ?LOG_ERROR(#{
                text => <<"Error in m.search">>,
                in => zotonic_core,
                result => case Result of
                    throw -> error;
                    _ -> Result
                end,
                reason => Reason,
                search_name => Name,
                search_args => Args,
                stack => Stack
            }),
            {error, Reason}
    end.

%% @deprecated Use m_search:search/3
-spec search(S, Context) -> #search_result{}
    when S :: {atom(), proplists:proplist()}
            | {binary(), map()}
            | {binary(), proplists:proplist()},
         Context :: z:context().
search({Name, Args}, Context) when is_binary(Name), is_map(Args) ->
    case search(Name, Args, Context) of
        {ok, S} -> S;
        {error, _} -> empty_result()
    end;
search({Name, Args}, Context) when is_binary(Name), is_list(Args) ->
    Args1 = z_search:props_to_map(Args),
    case search(Name, Args1, Context) of
        {ok, S} -> S;
        {error, _} -> empty_result()
    end;
search(Search, Context) ->
    case search_deprecated(Search, false, Context) of
        {ok, Result} ->
            Result;
        {error, _} ->
            empty_result()
    end.

%% @deprecated Use m_search:search/3
-spec search_pager(S, Context) -> #search_result{}
    when S :: {atom(), proplists:proplist()}
            | {binary(), map()}
            | {binary(), proplists:proplist()},
         Context :: z:context().
search_pager(Search, Context) ->
    case search_deprecated(Search, true, Context) of
        {ok, Result} ->
            Result;
        {error, _} ->
            empty_result()
    end.

search_args(#{ payload := Args }) when is_map(Args) ->
    Args;
search_args(#{ payload := [ [_,_] | _ ] = Args }) ->
    z_search:props_to_map(Args);
search_args(_) ->
    #{ <<"qargs">> => true }.


% Deprecated interface.
search_deprecated({Name, Props}, _IsPaged = true, Context) when is_atom(Name), is_list(Props) ->
    {Page, PageLen, Props1} = get_paging_props(Props, Context),
    try
        {ok, z_search:search_pager({Name, Props1}, Page, PageLen, Context)}
    catch
        throw:Error ->
            ?LOG_ERROR(#{
                text => <<"Error in m.search">>,
                in => zotonic_core,
                result => error,
                reason => Error,
                search_name => Name,
                search_args => Props
            }),
            {error, Error}
    end;
search_deprecated({Name, Props}, _IsPaged = false, Context) when is_atom(Name), is_list(Props) ->
    {Page, PageLen, Props1} = get_optional_paging_props(Props, Context),
    try
        Offset = (Page - 1) * PageLen + 1,
        Result = z_search:search({Name, Props1}, {Offset, PageLen}, Context),
        Result1 = case Result#search_result.total of
            undefined -> Result#search_result{ total = length(Result#search_result.result) };
            _ -> Result
        end,
        {ok, Result1}
    catch
        throw:Error ->
            ?LOG_ERROR(#{
                text => <<"Error in m.search">>,
                in => zotonic_core,
                result => error,
                reason => Error,
                search_name => Name,
                search_args => Props
            }),
            {error, Error}
    end.

empty_result() ->
    #search_result{
        search_name = <<"error">>,
        search_args = #{},
        result = [],
        options = #{},
        page = 1,
        pagelen = ?SEARCH_PAGELEN,
        total = 0,
        is_total_estimated = false,
        pages = 1
    }.


get_search_options(#{ <<"options">> := Options } = Args) when is_map(Options) ->
    Options1 = z_search:map_to_options(Options),
    {Options1, maps:remove(<<"options">>, Args)};
get_search_options(Args) ->
    {#{}, Args}.

get_optional_paging_props(Props, Context) when is_list(Props) ->
    % Deprecated proplists handling
    case proplists:is_defined(page, Props) orelse proplists:is_defined(pagelen, Props) of
        true -> get_paging_props(Props, Context);
        false -> {1, ?SEARCH_PAGELEN, Props}
    end.

get_paging_props(#{ <<"qargs">> := true } = Args, Context) ->
    try
        Page = case z_convert:to_integer(maps:get(<<"page">>, Args, undefined)) of
            undefined ->
                case z_convert:to_integer(z_context:get_q(<<"page">>, Context)) of
                    undefined -> 1;
                    P -> z_convert:to_integer(P)
                end;
            P ->
                P
        end,
        PageLen = case z_convert:to_integer(maps:get(<<"pagelen">>, Args, undefined)) of
            undefined ->
                case z_convert:to_integer(z_context:get_q(<<"pagelen">>, Context)) of
                    undefined -> ?SEARCH_PAGELEN;
                    PL -> z_convert:to_integer(PL)
                end;
            PL ->
                PL
        end,
        {Page, PageLen, maps:without([ <<"page">>, <<"pagelen">> ], Args)}
    catch
        _:_ ->
            {1, ?SEARCH_PAGELEN, maps:without([ <<"page">>, <<"pagelen">> ], Args)}
    end;
get_paging_props(Args, _Context) when is_map(Args) ->
    Page = case maps:get(<<"page">>, Args, 1) of
        undefined -> 1;
        P -> try z_convert:to_integer(P) catch _:_ -> 1 end
    end,
    PageLen = case maps:get(<<"pagelen">>, Args, ?SEARCH_PAGELEN) of
        undefined -> ?SEARCH_PAGELEN;
        PL -> try z_convert:to_integer(PL) catch _:_ -> ?SEARCH_PAGELEN end
    end,
    {Page, PageLen, maps:without([ <<"page">>, <<"pagelen">> ], Args)};
get_paging_props(Props, _Context) when is_list(Props) ->
    % Deprecated proplists handling
    Page = case proplists:get_value(page, Props) of
        undefined -> 1;
        PageProp -> try z_convert:to_integer(PageProp) catch _:_ -> 1 end
    end,
    PageLen = case proplists:get_value(pagelen, Props) of
        undefined -> ?SEARCH_PAGELEN;
        PageLenProp -> try z_convert:to_integer(PageLenProp) catch _:_ -> ?SEARCH_PAGELEN end
    end,
    P1 = proplists:delete(page, Props),
    P2 = proplists:delete(pagelen, P1),
    {Page, PageLen, P2}.