Composing real-app search

Copy Markdown View Source

This guide is the canonical v1.22 story for reusable search defaults, metadata-driven host rendering, and multi-search composition. The contract stays narrow: request-edge helpers normalize plain data, Scrypath.Composition lowers that data into Scrypath.search/3 or Scrypath.search_many/2, and your context remains the application boundary.

If you have not normalized browser params yet, start with Request-edge search. If you want the first-hour setup instead, start with the Golden path.

Why composition exists after request-edge normalization

Scrypath.QueryParams solves request-edge normalization. Composition solves a different problem: once several screens or callers share the same search defaults, where do those reusable rules live without turning Scrypath into a second runtime or a framework facade?

The answer is plain data:

Scrypath.Composition does not execute search, move search ownership onto schemas, or replace Phoenix, controllers, LiveView, or your context boundary.

Presets, scopes, defaults, and fixed

The public vocabulary is intentionally small:

  • defaults fill in search-shaped values when the caller omitted them
  • fixed locks filter-bearing fields and fails on conflicts
  • applied, defaulted, fixed, and unsupported remain inspectable in the result

That keeps the seam useful for tests, logs, and honest host rendering without exposing %Scrypath.Query{} or inventing schema-generated runtime verbs.

base_catalog =
  %{
    defaults: %{
      sort: [desc: :published_at],
      page: [size: 24],
      facets: [:category, :author]
    },
    fixed: %{
      filter: [published: true]
    }
  }

criteria = %{
  text: "ecto",
  facet_filter: [category: ["books"]]
}

{:ok, composition} = Scrypath.Composition.compose(base_catalog, criteria)
{text, search_opts} = Scrypath.Composition.to_search_args(composition)

MyApp.Catalog.search_books(text, search_opts)

Metadata supports host-rendered honest controls

Composition is only half of the real-app seam. Hosts often need to know what the schema declares and what the current search input resolved to before they render controls.

Use:

capabilities = Scrypath.schema_capabilities(MyApp.Catalog.Book)

reflection =
  Scrypath.reflect_search(MyApp.Catalog.Book, %{
    text: "ecto",
    facets: [:category],
    facet_filter: [category: ["books"], region: ["eu"]]
  })

Those helpers return plain data for host rendering. They do not generate UI, claim tenant-safe authz, or promise related-data propagation or rebuild correctness. Those remain host-owned concerns.

One runtime, two proof flows

Phase 85 freezes two flagship real-app proofs on top of the same runtime boundary:

Single-schema catalog flow

Use one schema, one context-owned Scrypath.search/3 call, and metadata-driven controls for a searchable Phoenix catalog page. Composition reduces repeated query glue, while schema_capabilities/1 and reflect_search/2 keep the control state inspectable and honest.

Read Faceted search with Phoenix LiveView for the concrete single-schema proof.

Multi-schema global-search flow

Use Scrypath.Composition.compose_many/2 when several schemas share some defaults but still need entry-scoped criteria, capabilities, and failure handling. The helper lowers into the existing tuple/shared-option contract for Scrypath.search_many/2; it does not create a merged capability graph or a fake universal ranking scale.

Read Multi-index search for the concrete multi-schema proof.

compose_many/2 lowers into search_many/2

Multi-search composition follows the same boundary discipline:

  • per-entry composition is canonical
  • shared composition lowers defaults only
  • shared fixed is intentionally unsupported
  • entry-scoped capability differences stay visible
  • partial failures stay explicit
{:ok, many} =
  Scrypath.Composition.compose_many(
    [
      %{
        schema: MyApp.Post,
        text: "release",
        fragments: [%{defaults: %{filter: [published: true]}}],
        criteria: %{facets: [:status]}
      },
      %{
        schema: MyApp.User,
        text: "release",
        criteria: %{filter: [active: true]}
      }
    ],
    shared: %{defaults: %{page: [size: 8]}}
  )

{entries, shared_opts} = Scrypath.Composition.to_search_many_args(many)

Scrypath.search_many(entries, Keyword.merge(shared_opts, repo: MyApp.Repo))

The output stays inspectable plain data all the way to the runtime call.

Non-goals

This guide is also the canonical boundary page for what v1.22 does not promise:

  • no public %Scrypath.Query{}
  • no schema-generated runtime verbs
  • no generated UI widgets, forms, or components
  • no tenant/authz guarantees
  • no related-data propagation or rebuild correctness claims

Scrypath helps you compose search-shaped data and reflect honest state. Your app still owns policy, rendering, and operational follow-through.

Continue