search implement
This commit is contained in:
@@ -307,6 +307,8 @@ confirm-delete = Delete this for good?
|
||||
shop-title = Shop
|
||||
shop-subtitle = browse our products.
|
||||
shop-empty = There are no products here yet.
|
||||
search-placeholder = Search products…
|
||||
search-empty = Nothing matched your search:
|
||||
view-grid = Grid view
|
||||
view-list = List view
|
||||
categories = Categories
|
||||
|
||||
@@ -307,6 +307,8 @@ confirm-delete = Naozaj zmazať?
|
||||
shop-title = Obchod
|
||||
shop-subtitle = prezrite si našu ponuku produktov.
|
||||
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
||||
search-placeholder = Hľadať produkty…
|
||||
search-empty = Pre váš výraz sme nič nenašli:
|
||||
view-grid = Zobrazenie v mriežke
|
||||
view-list = Zobrazenie v zozname
|
||||
categories = Kategórie
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -83,6 +83,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
|
||||
{%- elif name == "chevron-double-left" -%}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5" /></svg>
|
||||
{%- elif name == "search" -%}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||
{%- else -%}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>
|
||||
{%- endif -%}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
{# Imported locally (not just inherited from base.html) so the card also renders
|
||||
inside standalone htmx fragments like shop/_results.html, where Tera's import
|
||||
chain from the layout isn't present. #}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
{# Adapted from the vendored Penguin UI component
|
||||
(penguinui-components/card/ecommerce-product-card.html):
|
||||
wired to our product data + i18n + htmx add-to-cart + toast. The demo rating
|
||||
|
||||
13
assets/views/shop/_results.html
Normal file
13
assets/views/shop/_results.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{# Search / listing results, swapped in by htmx on each query and rendered
|
||||
server-side on first load. Mirrors the empty-state handling of index.html. #}
|
||||
{% if products | length > 0 %}
|
||||
{% include "shop/_product_grid.html" %}
|
||||
{% elif query and query != "" %}
|
||||
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
|
||||
{{ t(key="search-empty", lang=lang | default(value='sk')) }} <span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ query }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
|
||||
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -5,17 +5,41 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<header class="space-y-2">
|
||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h1>
|
||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
|
||||
<header class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h1>
|
||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
|
||||
</div>
|
||||
|
||||
{# Live search: htmx GETs /search as the customer types (debounced) and
|
||||
swaps only the results below. hx-push-url keeps the URL shareable; the
|
||||
spinner shows while a request is in flight. Degrades to a normal GET form
|
||||
submit when JS/htmx is unavailable. #}
|
||||
<form action="/search" method="get" role="search"
|
||||
class="relative max-w-md"
|
||||
hx-get="/search" hx-target="#shop-results" hx-swap="innerHTML"
|
||||
hx-trigger="input changed delay:300ms from:input[name='q'], submit"
|
||||
hx-push-url="true" hx-indicator="#search-spinner">
|
||||
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
||||
{{ ui::icon(name="search", size="size-5") }}
|
||||
</span>
|
||||
<input type="search" name="q" value="{{ query | default(value='') }}"
|
||||
autocomplete="off"
|
||||
placeholder="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
||||
aria-label="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
||||
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-10 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark" />
|
||||
<span id="search-spinner"
|
||||
class="htmx-indicator pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
||||
<svg class="size-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
{% if products | length > 0 %}
|
||||
{% include "shop/_product_grid.html" %}
|
||||
{% else %}
|
||||
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
|
||||
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}
|
||||
<div id="shop-results">
|
||||
{% include "shop/_results.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -42,6 +42,8 @@ mod m20260621_000004_add_business_sale_price_to_products;
|
||||
mod m20260622_000001_audience_discount_profiles;
|
||||
mod m20260622_000002_product_variants;
|
||||
mod m20260622_000003_variant_stock_nullable;
|
||||
mod m20260622_000004_product_search;
|
||||
mod m20260622_000005_product_search_aggregate;
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -88,6 +90,8 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260622_000001_audience_discount_profiles::Migration),
|
||||
Box::new(m20260622_000002_product_variants::Migration),
|
||||
Box::new(m20260622_000003_variant_stock_nullable::Migration),
|
||||
Box::new(m20260622_000004_product_search::Migration),
|
||||
Box::new(m20260622_000005_product_search_aggregate::Migration),
|
||||
// inject-above (do not remove this comment)
|
||||
]
|
||||
}
|
||||
|
||||
92
migration/src/m20260622_000004_product_search.rs
Normal file
92
migration/src/m20260622_000004_product_search.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! Full-text + fuzzy search over the product catalog.
|
||||
//!
|
||||
//! Storefront search has to cope with Slovak text (diacritics, ad-hoc spelling)
|
||||
//! and customer typos, while staying entirely inside Postgres — the catalog is
|
||||
//! small (hundreds of products), so a separate search engine would be pure
|
||||
//! operational overhead. This migration sets up:
|
||||
//!
|
||||
//! 1. `unaccent` + `pg_trgm` extensions, and an IMMUTABLE `f_unaccent` wrapper
|
||||
//! (the stock `unaccent` is only STABLE, so it can't be used in an index
|
||||
//! expression without wrapping it).
|
||||
//! 2. a `sk_unaccent` text-search configuration: the `simple` dictionary
|
||||
//! (no English stemming, which would mangle Slovak) folded through
|
||||
//! `unaccent` so "kompresor" and "kompresór" tokenize identically.
|
||||
//! 3. a STORED generated `products.search_vector`, weighting the name above
|
||||
//! the description, with a GIN index for `@@` matching.
|
||||
//! 4. a trigram GIN index on the (unaccented) name for fuzzy matching.
|
||||
//!
|
||||
//! The matching query itself lives in `products::Entity::search`.
|
||||
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = m.get_connection();
|
||||
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
CREATE EXTENSION IF NOT EXISTS unaccent;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- IMMUTABLE wrapper so unaccent() can be used in generated columns
|
||||
-- and index expressions (the extension's own unaccent() is STABLE).
|
||||
CREATE OR REPLACE FUNCTION f_unaccent(text)
|
||||
RETURNS text
|
||||
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT AS
|
||||
$func$ SELECT public.unaccent('public.unaccent', $1) $func$;
|
||||
|
||||
-- 'simple' (no stemming) + unaccent: a good fit for Slovak, where
|
||||
-- English stemming is wrong and accents are typed inconsistently.
|
||||
DROP TEXT SEARCH CONFIGURATION IF EXISTS sk_unaccent;
|
||||
CREATE TEXT SEARCH CONFIGURATION sk_unaccent ( COPY = simple );
|
||||
ALTER TEXT SEARCH CONFIGURATION sk_unaccent
|
||||
ALTER MAPPING FOR hword, hword_part, word
|
||||
WITH unaccent, simple;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
ALTER TABLE products
|
||||
ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('sk_unaccent', COALESCE(name, '')), 'A') ||
|
||||
setweight(to_tsvector('sk_unaccent', COALESCE(description, '')), 'B')
|
||||
) STORED;
|
||||
|
||||
CREATE INDEX idx_products_search_vector
|
||||
ON products USING GIN (search_vector);
|
||||
CREATE INDEX idx_products_name_trgm
|
||||
ON products USING GIN (f_unaccent(name) gin_trgm_ops);
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = m.get_connection();
|
||||
|
||||
// Drop the trigram index (it depends on f_unaccent) before the function;
|
||||
// dropping the column takes its own GIN index with it.
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
DROP INDEX IF EXISTS idx_products_name_trgm;
|
||||
ALTER TABLE products DROP COLUMN IF EXISTS search_vector;
|
||||
DROP TEXT SEARCH CONFIGURATION IF EXISTS sk_unaccent;
|
||||
DROP FUNCTION IF EXISTS f_unaccent(text);
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The unaccent / pg_trgm extensions are left installed: other objects may
|
||||
// rely on them and they are harmless on their own.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
232
migration/src/m20260622_000005_product_search_aggregate.rs
Normal file
232
migration/src/m20260622_000005_product_search_aggregate.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
//! Broaden product search to the whole purchasable surface.
|
||||
//!
|
||||
//! The `product_search` migration could only index columns living on `products`
|
||||
//! itself (name, description), because a STORED generated column may not read
|
||||
//! other tables. To also match by tag, variant label and SKU, `search_vector`
|
||||
//! becomes a plain column maintained by triggers:
|
||||
//!
|
||||
//! * `kompress_build_product_search(name, description, id)` builds the weighted
|
||||
//! vector for one product, pulling tags + variant labels + SKUs by id
|
||||
//! (name = A, tags + labels = B, description + SKU = C).
|
||||
//! * a BEFORE trigger on `products` keeps a product's own row in sync, and
|
||||
//! * AFTER triggers on `product_variants`, `product_product_tags` and tag
|
||||
//! renames refresh the affected product(s).
|
||||
//!
|
||||
//! The result is one `products.search_vector` that every search query can reuse,
|
||||
//! always consistent with the catalog.
|
||||
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = m.get_connection();
|
||||
|
||||
// Swap the generated column (name + description only) for a plain column
|
||||
// the triggers can own. Dropping it takes its GIN index with it.
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
ALTER TABLE products DROP COLUMN IF EXISTS search_vector;
|
||||
ALTER TABLE products ADD COLUMN search_vector tsvector;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Single source of truth for a product's search document.
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
CREATE OR REPLACE FUNCTION kompress_build_product_search(
|
||||
p_name text, p_description text, p_id integer
|
||||
) RETURNS tsvector
|
||||
LANGUAGE sql STABLE AS $func$
|
||||
SELECT
|
||||
setweight(to_tsvector('sk_unaccent', COALESCE(p_name, '')), 'A')
|
||||
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||
SELECT string_agg(t.name, ' ')
|
||||
FROM product_product_tags ppt
|
||||
JOIN product_tags t ON t.id = ppt.product_tag_id
|
||||
WHERE ppt.product_id = p_id
|
||||
), '')), 'B')
|
||||
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||
SELECT string_agg(v.label, ' ')
|
||||
FROM product_variants v
|
||||
WHERE v.product_id = p_id
|
||||
), '')), 'B')
|
||||
|| setweight(to_tsvector('sk_unaccent', COALESCE(p_description, '')), 'C')
|
||||
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||
SELECT string_agg(v.sku, ' ')
|
||||
FROM product_variants v
|
||||
WHERE v.product_id = p_id AND v.sku IS NOT NULL
|
||||
), '')), 'C');
|
||||
$func$;
|
||||
|
||||
-- Refresh one product's stored vector (used by the satellite triggers).
|
||||
CREATE OR REPLACE FUNCTION kompress_refresh_product_search(p_id integer)
|
||||
RETURNS void LANGUAGE sql AS $func$
|
||||
UPDATE products
|
||||
SET search_vector = kompress_build_product_search(name, description, id)
|
||||
WHERE id = p_id;
|
||||
$func$;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// BEFORE trigger on products: recompute on its own writes. When a refresh
|
||||
// only touches search_vector (name + description unchanged) it skips the
|
||||
// recompute and keeps the supplied value — which also breaks recursion.
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
CREATE OR REPLACE FUNCTION kompress_products_search_tg() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $func$
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE'
|
||||
AND NEW.name IS NOT DISTINCT FROM OLD.name
|
||||
AND NEW.description IS NOT DISTINCT FROM OLD.description
|
||||
AND NEW.search_vector IS DISTINCT FROM OLD.search_vector THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
NEW.search_vector :=
|
||||
kompress_build_product_search(NEW.name, NEW.description, NEW.id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$func$;
|
||||
|
||||
DROP TRIGGER IF EXISTS products_search_tg ON products;
|
||||
CREATE TRIGGER products_search_tg
|
||||
BEFORE INSERT OR UPDATE ON products
|
||||
FOR EACH ROW EXECUTE FUNCTION kompress_products_search_tg();
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Variants: any change refreshes the owning product (both, on reparent).
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
CREATE OR REPLACE FUNCTION kompress_variants_search_tg() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $func$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
PERFORM kompress_refresh_product_search(OLD.product_id);
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
PERFORM kompress_refresh_product_search(NEW.product_id);
|
||||
IF TG_OP = 'UPDATE' AND NEW.product_id IS DISTINCT FROM OLD.product_id THEN
|
||||
PERFORM kompress_refresh_product_search(OLD.product_id);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$func$;
|
||||
|
||||
DROP TRIGGER IF EXISTS product_variants_search_tg ON product_variants;
|
||||
CREATE TRIGGER product_variants_search_tg
|
||||
AFTER INSERT OR UPDATE OR DELETE ON product_variants
|
||||
FOR EACH ROW EXECUTE FUNCTION kompress_variants_search_tg();
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Tag links: attaching/detaching a tag refreshes the product.
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
CREATE OR REPLACE FUNCTION kompress_product_tags_link_search_tg() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $func$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
PERFORM kompress_refresh_product_search(OLD.product_id);
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
PERFORM kompress_refresh_product_search(NEW.product_id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$func$;
|
||||
|
||||
DROP TRIGGER IF EXISTS product_product_tags_search_tg ON product_product_tags;
|
||||
CREATE TRIGGER product_product_tags_search_tg
|
||||
AFTER INSERT OR UPDATE OR DELETE ON product_product_tags
|
||||
FOR EACH ROW EXECUTE FUNCTION kompress_product_tags_link_search_tg();
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Renaming a tag refreshes every product carrying it.
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
CREATE OR REPLACE FUNCTION kompress_tag_rename_search_tg() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $func$
|
||||
BEGIN
|
||||
UPDATE products p
|
||||
SET search_vector =
|
||||
kompress_build_product_search(p.name, p.description, p.id)
|
||||
WHERE p.id IN (
|
||||
SELECT ppt.product_id FROM product_product_tags ppt
|
||||
WHERE ppt.product_tag_id = NEW.id
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$func$;
|
||||
|
||||
DROP TRIGGER IF EXISTS product_tags_rename_search_tg ON product_tags;
|
||||
CREATE TRIGGER product_tags_rename_search_tg
|
||||
AFTER UPDATE OF name ON product_tags
|
||||
FOR EACH ROW EXECUTE FUNCTION kompress_tag_rename_search_tg();
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Backfill existing rows, then (re)create the GIN index for `@@`.
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
UPDATE products
|
||||
SET search_vector = kompress_build_product_search(name, description, id);
|
||||
|
||||
CREATE INDEX idx_products_search_vector
|
||||
ON products USING GIN (search_vector);
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = m.get_connection();
|
||||
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
DROP TRIGGER IF EXISTS product_tags_rename_search_tg ON product_tags;
|
||||
DROP TRIGGER IF EXISTS product_product_tags_search_tg ON product_product_tags;
|
||||
DROP TRIGGER IF EXISTS product_variants_search_tg ON product_variants;
|
||||
DROP TRIGGER IF EXISTS products_search_tg ON products;
|
||||
DROP FUNCTION IF EXISTS kompress_tag_rename_search_tg();
|
||||
DROP FUNCTION IF EXISTS kompress_product_tags_link_search_tg();
|
||||
DROP FUNCTION IF EXISTS kompress_variants_search_tg();
|
||||
DROP FUNCTION IF EXISTS kompress_products_search_tg();
|
||||
DROP FUNCTION IF EXISTS kompress_refresh_product_search(integer);
|
||||
DROP FUNCTION IF EXISTS kompress_build_product_search(text, text, integer);
|
||||
ALTER TABLE products DROP COLUMN IF EXISTS search_vector;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Restore the name + description generated column from the prior migration.
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
ALTER TABLE products
|
||||
ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('sk_unaccent', COALESCE(name, '')), 'A') ||
|
||||
setweight(to_tsvector('sk_unaccent', COALESCE(description, '')), 'B')
|
||||
) STORED;
|
||||
|
||||
CREATE INDEX idx_products_search_vector
|
||||
ON products USING GIN (search_vector);
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Public storefront: product listings, product detail, category pages and the
|
||||
//! lazily-loaded category sidebar.
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::http::HeaderMap;
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
||||
@@ -13,6 +15,12 @@ use crate::{
|
||||
views::shop as view,
|
||||
};
|
||||
|
||||
/// Query string for the storefront search box.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct SearchParams {
|
||||
q: Option<String>,
|
||||
}
|
||||
|
||||
/// Shape a list of products into card rows for `user` (None = public). Each card
|
||||
/// shows the resolved price of the product's representative (first) variant; the
|
||||
/// `variant_count` lets the template render "from {price}" for multi-variant
|
||||
@@ -101,6 +109,7 @@ async fn index(
|
||||
"shop/index.html",
|
||||
json!({
|
||||
"products": product_rows(&ctx, user.as_ref(), list).await?,
|
||||
"query": "",
|
||||
"logged_in_admin": c.logged_in_admin,
|
||||
"logged_in_customer": c.logged_in_customer,
|
||||
"customer_name": c.customer_name,
|
||||
@@ -110,6 +119,59 @@ async fn index(
|
||||
)
|
||||
}
|
||||
|
||||
/// Storefront search. Reuses the shop listing's card shaping, ranking results by
|
||||
/// the hybrid full-text + fuzzy query in [`products::Entity::search`]. A blank
|
||||
/// query falls back to the full published listing (so clearing the box restores
|
||||
/// the shop). htmx requests get just the results fragment for live updates;
|
||||
/// direct navigation (or no-JS) renders the whole page.
|
||||
#[debug_handler]
|
||||
async fn search(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<SearchParams>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let q = params.q.unwrap_or_default();
|
||||
let trimmed = q.trim();
|
||||
let list = if trimmed.is_empty() {
|
||||
products::Entity::find()
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
} else {
|
||||
products::Entity::search(&ctx.db, trimmed).await?
|
||||
};
|
||||
|
||||
let user = guard::current_user(&ctx, &jar).await;
|
||||
let rows = product_rows(&ctx, user.as_ref(), list).await?;
|
||||
let lang = current_lang(&jar);
|
||||
|
||||
if headers.contains_key("HX-Request") {
|
||||
return format::view(
|
||||
&v,
|
||||
"shop/_results.html",
|
||||
json!({ "products": rows, "query": q, "lang": lang }),
|
||||
);
|
||||
}
|
||||
|
||||
let c = guard::chrome_from(&ctx, user.as_ref());
|
||||
format::view(
|
||||
&v,
|
||||
"shop/index.html",
|
||||
json!({
|
||||
"products": rows,
|
||||
"query": q,
|
||||
"logged_in_admin": c.logged_in_admin,
|
||||
"logged_in_customer": c.logged_in_customer,
|
||||
"customer_name": c.customer_name,
|
||||
"customer_account_type": c.customer_account_type,
|
||||
"lang": lang,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn show(
|
||||
jar: CookieJar,
|
||||
@@ -240,6 +302,9 @@ async fn category(
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/shop", get(index))
|
||||
// Top-level path (not /shop/search) so it never collides with the
|
||||
// /shop/{slug} product route.
|
||||
.add("/search", get(search))
|
||||
.add("/shop/{slug}", get(show))
|
||||
.add("/category/{slug}", get(category))
|
||||
.add("/partials/categories", get(category_sidebar))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::{ConnectionTrait, Statement};
|
||||
pub use crate::models::_entities::products::{ActiveModel, Column, Entity, Model};
|
||||
pub type Products = Entity;
|
||||
|
||||
@@ -25,4 +26,50 @@ impl Model {}
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
impl Entity {
|
||||
/// Published products matching a free-text query, best matches first.
|
||||
///
|
||||
/// Hybrid Postgres search. The `tsvector` full-text match covers the whole
|
||||
/// purchasable surface — name, description, tags, variant labels and SKUs
|
||||
/// (kept in sync by triggers; see the `product_search_aggregate` migration) —
|
||||
/// OR a `pg_trgm` `word_similarity` match on name/description for typo
|
||||
/// tolerance ("komprsor" still finds "kompresor"). Both sides run through
|
||||
/// `f_unaccent`, so diacritics never matter. Results are ranked by full-text
|
||||
/// rank, then trigram closeness of the name, then recency. An empty/blank
|
||||
/// query returns nothing — callers fall back to the plain listing.
|
||||
pub async fn search<C: ConnectionTrait>(db: &C, query: &str) -> Result<Vec<Model>, DbErr> {
|
||||
let q = query.trim();
|
||||
if q.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Only the model's own columns are selected; the generated `search_vector`
|
||||
// is left out so the row maps cleanly back onto `Model`. `$1` is reused
|
||||
// for every occurrence of the query term.
|
||||
let sql = r#"
|
||||
SELECT p.created_at, p.updated_at, p.id, p.name, p.slug, p.description,
|
||||
p.currency, p.view_count, p.published, p.published_at, p.category_id
|
||||
FROM products p
|
||||
WHERE p.published = TRUE
|
||||
AND (
|
||||
p.search_vector @@ websearch_to_tsquery('sk_unaccent', $1)
|
||||
OR word_similarity(f_unaccent($1), f_unaccent(p.name)) > 0.3
|
||||
OR word_similarity(f_unaccent($1), f_unaccent(COALESCE(p.description, ''))) > 0.3
|
||||
)
|
||||
ORDER BY
|
||||
ts_rank(p.search_vector, websearch_to_tsquery('sk_unaccent', $1)) DESC,
|
||||
word_similarity(f_unaccent($1), f_unaccent(p.name)) DESC,
|
||||
p.published_at DESC NULLS LAST
|
||||
LIMIT 60
|
||||
"#;
|
||||
|
||||
Entity::find()
|
||||
.from_raw_sql(Statement::from_sql_and_values(
|
||||
db.get_database_backend(),
|
||||
sql,
|
||||
[q.into()],
|
||||
))
|
||||
.all(db)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user