AnnouncementsIntroducing Arkoren

    Menu

    Search Queries

    Combine search text, typed filters, permission boundaries, and ordering within a bounded result window.

    What's on this page
    1. Match words, phrases, prefixes, and exclusions without native backend syntax.
    2. Build typed catalog filters and group alternatives within trusted permission boundaries.
    3. Order hits and fetch pages within the first 1,000 results.
    4. Validate immutable queries and distinguish local item transformations from server-side filtering.
    On this page

    A search query combines text, field conditions, and result ordering. Start with the populated Products index and a configured engine. Import search from arkoren.dev/core/service/search and pagination from arkoren.dev/core/pagination; ctx is your operation context. These queries search indexed JSON documents, not SQL tables.

    Query Text

    For a search box, pass the text to NewQuery without translating it to native backend syntax:

    selection := search.NewQuery(Products, `red "running shoes" -boots`)

    This requires a word starting with red and the consecutive phrase running shoes in one field, while excluding boots. Only the last plain, nonexcluded word is a prefix, even when a phrase or exclusion follows it.

    Text Meaning
    red shoes Require both words; the last may match a prefix.
    "running shoes" Require consecutive words in one field.
    -boots Exclude a matching word.
    -"wool socks" Exclude a matching phrase.
    wool soc Require wool and a word beginning with soc.

    Matching lowercases words and splits punctuation without stemming. Nonphrase words can match different searchable fields, including words split from one punctuated term. soc wool requires the complete word soc; wool soc can match wool socks. A quoted word is not a prefix.

    Parsing never fails: an unclosed quote runs to the end, a minus not directly followed by a word or quote is ordinary text, and terms without letters or digits are dropped. Empty text, or text reduced to no terms, matches every document unless filters restrict it. Use NewQuery(Products, "") for this; the zero value of Query[Product] is invalid.

    Meilisearch additionally folds diacritics, limits positive words and phrases to ten, and rejects multiword nonphrase exclusions such as -t-shirt with search.ErrUnsupported. Review provider differences before relying on identical matches across backends.

    Catalog Filters

    Keep field names under application control. For a validated catalog request, use a typed input rather than accepting arbitrary predicates:

    type CatalogSearch struct {
    	Text     string  `json:"text"`
    	Category string  `json:"category"`
    	MaxPrice float64 `json:"max_price"`
    }

    Assume input has already been decoded and validated. Restrict matches using the index’s declared filterable fields:

    selection := search.NewQuery(Products, input.Text, func(criteria *search.QueryBuilder) {
    	criteria.Where("category", input.Category)
    	criteria.Where("price", "<=", input.MaxPrice)
    })

    The two-argument Where is equality; the three-argument form accepts =, !=, <, <=, >, or >=, either as strings or their search.Operator constants such as search.OpLessOrEqual. Conditions append with AND. Use membership methods, not OpIn passed to Where, for sets:

    selection := search.NewQuery(Products, "", func(criteria *search.QueryBuilder) {
    	criteria.WhereIn("category", "boots", "socks")
    	criteria.WhereNotIn("price", 15, 25)
    })

    Membership lists must be nonempty. Operands accept named or unnamed strings, booleans, integers, floats, and exact time.Time values. Numbers normalize to float64; integers above 2^53 can lose precision. Times normalize to RFC 3339 UTC strings, so store comparable document timestamps in UTC. Booleans permit only equality and inequality. Nil, unsupported types, NaN, and infinities invalidate the query.

    Strings compare case-insensitively. The typed contract does not equate numeric 15 with string "15". Missing, null, array, and object values match no scalar condition, including != and NOT IN. IN needs one same-kind equal operand; NOT IN needs every operand to have the stored kind and differ. Consequently, a mixed-kind NOT IN list matches no scalar. Prefer homogeneous lists matching the stored field. Meilisearch’s native coercion remains a portability limit, not a weaker contract.

    Permission Filters

    Search does not authorize results. Derive the tenant from authenticated, authorized application state, not from a client-supplied filter. For a trusted tenantID, make the permission boundary part of every search:

    selection := search.NewQuery(Products, input.Text, func(criteria *search.QueryBuilder) {
    	criteria.Where("tenant_id", tenantID)
    	criteria.WhereGroup(func(availability *search.PredicateBuilder) {
    		availability.Where("in_stock", true)
    		availability.OrWhere("backorder", true)
    	})
    })

    The expression is tenant_id = tenantID AND (in_stock OR backorder). Without the group, tenant AND in_stock OR backorder means (tenant AND in_stock) OR backorder and can expose another tenant’s documents. AND binds tighter than OR. Never append a root OrWhere alternative that escapes a permission filter.

    Group callbacks receive *search.PredicateBuilder, which supports predicates and nested groups but no ordering. Callbacks share one draft; their final combined group must be nonempty.

    For a cheaper view, derive a query without changing this permission-scoped base:

    discounts := selection.Derive(func(criteria *search.QueryBuilder) {
    	criteria.Where("price", "<", 50)
    })

    Derive preserves text, existing conditions, orders, and the first error; it adds settings rather than replacing existing restrictions. This is a convenience, not an authorization object: later application code must still preserve the logical boundary. If permissions can change before reindexing, recheck them against authoritative state before disclosing sensitive documents. Filtering the fetched page afterward does not repair leaked counts or a wrongly scoped search.

    Result Ordering

    To show cheaper products first and newer ones first at the same price, publish an ordered variant of your scoped query:

    ordered := selection.Derive(func(criteria *search.QueryBuilder) {
    	criteria.OrderBy("price")
    	criteria.OrderByDesc("created_at")
    })

    Only declared sortable fields are accepted. Orders apply in declaration order, then descending relevance, then ascending document ID by bytes. Strings sort by lowercase bytes, numbers numerically, and missing values last in either direction. Keep one scalar kind per field; mixed-kind ordering is undefined.

    With no explicit orders, positive text ranks by relevance then ID. Empty or exclusion-only text has score zero and falls back to ID. Scores are comparable only within one result, not across queries or drivers. Meilisearch’s string/ID length limits and OpenSearch’s ID-fielddata requirement can affect this ordering; see Search Drivers.

    Result Windows

    Run the query with page-number parameters to fetch a bounded window:

    page, err := engine.Search(ctx, ordered, pagination.PageParams{Page: 3, PerPage: 20})

    Zero values normalize to page 1 and 25 items; the default per-page maximum is 100. The pagination guide explains these shared page types and normalization, not search text or backend execution.

    search.MaxHits is 1,000. A normalized page times per-page above that bound returns search.ErrInvalidQuery before driver I/O. Total is exact up to 1,000 and never reports more. LastPage uses that capped total, but does not override the window check: at 30 items per page and a total of 1,000, its value is 34 and requesting page 34 fails because its window ends at 1,020. Use a page size that divides 1,000, such as 20 or the default 25, to navigate the full cap. Ask readers to narrow large searches instead of offering arbitrarily deep pages. A window past the last match is empty, not an error, when it stays within the bound.

    Pages are not a snapshot or cursor traversal. Concurrent document changes can alter contents between requests. The bound limits reachable results, not the backend’s indexing work or scan cost.

    After checking the search error, extract documents from the current page:

    products := page.Items.Map(func(_ int, hit search.Hit[Product]) Product {
    	return hit.Document
    })

    Map, Filter, and GroupBy are eager, local collection operations. They visit only the fetched window, make no search requests, and do not recompute page metadata. Put conditions in the search query when they must change server-side counts or ordering. JSON serializes items as an array with the page metadata.

    Query Validation

    NewQuery publishes an immutable value without I/O. Its first declaration error is retained by Err() and returned by Search before reaching the driver:

    if err := selection.Err(); err != nil {
    	return err
    }

    Undeclared filter/sort fields, unsupported values/operators, empty membership lists, and empty groups wrap search.ErrInvalidQuery. A zero query also wraps the missing index’s search.ErrInvalidName; deriving it does not repair that failure. Registration and schema matching are checked separately by the engine. Native provider limits can still return search.ErrUnsupported for a valid query. Never retry by silently dropping a filter, especially a permission filter.

    Callbacks run synchronously once in order even for invalid bases. Supplied nil callbacks panic; expanded empty callback slices omit edits. Empty groups remain invalid. Publication, derivation, and Inspect() detach owned text, condition, group, membership, and order containers; editing a retained draft or inspection cannot change a published query.

    When user input makes a query invalid, classify its error at your HTTP boundary. Prefer fixed fields and validated values instead of exposing the complete predicate builder to clients.

    For every function and option, see the search API reference.

    Open a bulk documentation archive?

    This file contains every guide in its scope, not API declarations. Loading it all into an agent can exhaust its context window and increase processing time and cost. For coding tasks, start with the index and read only the relevant pages.

    Use the full archive for offline indexing, bulk processing, or deliberate large-context use.

    Browse the index instead
    Open archive