# `PhoenixKitCatalogue.Catalogue.Search`
[🔗](https://github.com/BeamLabEU/phoenix_kit_catalogue/blob/v0.31.3/lib/phoenix_kit_catalogue/catalogue/search.ex#L1)

Item search — global, per-catalogue, and per-category, with optional
scope composition (`catalogue_uuids` AND `category_uuids`).

Matches case-insensitively against `name`, `description`, `sku`, and
the multilang `data` JSONB. Excludes items in deleted catalogues or
deleted categories. Uncategorized items are included unless a
`:category_uuids` filter narrows the search.

Public surface is re-exported from `PhoenixKitCatalogue.Catalogue`.

# `count_search_items`

```elixir
@spec count_search_items(String.t(), keyword()) :: non_neg_integer()
```

Returns the total number of items matching `search_items/2`'s filters.
Ignores `:limit`/`:offset`/`:order`. Same scope opts as `search_items/2`.

# `count_search_items_in_catalogue`

```elixir
@spec count_search_items_in_catalogue(Ecto.UUID.t(), String.t()) :: non_neg_integer()
```

Total match count for `search_items_in_catalogue/3`.

# `count_search_items_in_category`

```elixir
@spec count_search_items_in_category(Ecto.UUID.t(), String.t()) :: non_neg_integer()
```

Total match count for `search_items_in_category/3`.

# `match_text`

```elixir
@spec match_text(Ecto.Query.t(), String.t() | nil) :: Ecto.Query.t()
```

Narrows any query with an `:item` named binding to the items a search
term matches — name, description, SKU, and every translated string in
the record's `data`.

Public because the attribute filter's FACET COUNTS have to agree with
the list beside them: a value offered as live while a search is on has
to still be live under that search, and it can only promise that by
asking the same question the listing asks. `nil` or a blank term is
"no text constraint" and leaves the query alone.

Callers pass a raw user string; trimming and LIKE-escaping happen here.

# `search_categories`

```elixir
@spec search_categories(Ecto.UUID.t(), String.t(), keyword()) :: [
  PhoenixKitCatalogue.Schemas.Category.t()
]
```

Categories whose NAME or description matches, within one catalogue.

Item search never covered these: searching a catalogue for a category
it contains returned nothing, and the page looked like it had matched
only because that category's own card happened to be on screen (Max,
2026-08-28).

`:parent_uuid` narrows to that category's SUBTREE (itself excluded),
mirroring how `search_items_in_category/3` scopes items when the user
has drilled in. Deleted categories and categories of deleted
catalogues are excluded, as everywhere else.

# `search_items`

```elixir
@spec search_items(String.t(), keyword()) :: [PhoenixKitCatalogue.Schemas.Item.t()]
```

Searches items with flexible scope.

## Options

  * `:catalogue_uuids` — list of catalogue UUIDs to scope to. `nil` or `[]` = all.
  * `:category_uuids` — list of category UUIDs to scope to. `nil` or `[]` = all + uncategorized.
    Must contain only non-nil UUIDs; passing `[nil]` raises `ArgumentError`
    (use `:only => :uncategorized_only` for that intent).
  * `:include_descendants` — when `true` (default since V103), each
    entry in `:category_uuids` is expanded to include every descendant
    category in the nested-category tree. Pass `false` to scope
    strictly to the given UUIDs.
  * `:only` — `:uncategorized_only` restricts to items with no
    `category_uuid`; `:categorized_only` restricts to items that
    belong to some category. `nil` (default) is unrestricted.
    Combining `:uncategorized_only` with a non-empty `:category_uuids`
    is a logical contradiction and raises `ArgumentError`.
  * `:statuses` — list of item statuses to include (`"active"`,
    `"inactive"`, `"discontinued"`). `nil` or `[]` = all non-deleted
    (the historical default). Soft-deleted rows stay excluded even if
    `"deleted"` is listed. Atoms are accepted and stringified.
  * `:order` — `:position` (default: the admin's Manual document
    order — catalogue position, category position, item position,
    name; `{:position, dir}` is accepted and the direction ignored,
    like the admin's Manual sort), `:name`, or `{field, :asc | :desc}`
    for `name` / `sku` / `base_price` / `status`. Anything else raises
    `ArgumentError`.
    Known limits of the Manual chain: catalogue positions are one
    sequence per folder level, so across folders it is position then
    name rather than the index's folder walk; and a subtree listing
    (a search with `:include_descendants`) orders by each category's
    sibling position, not a depth-first walk — the same order the
    admin's own in-catalogue search has always had.
  * `:limit` — max results (default 50).
  * `:offset` — paging offset (default 0).
  * `:preload` — extra associations appended to the default
    `[:catalogue, category: :catalogue]`. Pass
    `[catalogue_rules: :referenced_catalogue]` for smart-pricing.

# `search_items_in_catalogue`

```elixir
@spec search_items_in_catalogue(Ecto.UUID.t(), String.t(), keyword()) :: [
  PhoenixKitCatalogue.Schemas.Item.t()
]
```

Searches items within a specific catalogue. Convenience wrapper
around `search_items/2` with `catalogue_uuids: [catalogue_uuid]` in
the admin's Manual document order (category position, then item
position, name, uuid) — a stable walk through a catalogue's
categories. It used to carry its own copy of that chain; it now
delegates, so the two cannot drift. Pins `:order` — a caller's own
`:order` is overridden, since Manual is this wrapper's contract.

Same `:preload` opt as `search_items/2` (extra associations appended
to the default `[:catalogue, category: :catalogue]`).

# `search_items_in_category`

```elixir
@spec search_items_in_category(Ecto.UUID.t(), String.t(), keyword()) :: [
  PhoenixKitCatalogue.Schemas.Item.t()
]
```

Searches items within a specific category. Convenience wrapper around
`search_items/2` with `category_uuids: [category_uuid]`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
