Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Search debounce #348

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ If you add or change queries run this command to generate new sqlx SQL files:
cargo sqlx prepare --workspace
```

Schema changes, including new indices, go into the `migrations` folder as SQL DDL scripts.
Schema changes, including new indices, go into the `migrations` folder as SQL DDL scripts.

### Testing

Expand Down
48 changes: 33 additions & 15 deletions crates/sage-database/src/coin_states.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,23 +432,37 @@ async fn get_block_heights(
let mut query = sqlx::QueryBuilder::new(
"
WITH filtered_coins AS (
SELECT cs.coin_id, cs.kind,
cats.ticker,
cats.name,
created_height as height
SELECT cs.coin_id,
cs.kind,
cats.ticker,
cats.name as cat_name,
dids.name as did_name,
nfts.name as nft_name,
cs.created_height as height
FROM coin_states cs
LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id
LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id
WHERE created_height IS NOT NULL
LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id
LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id
LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id
LEFT JOIN dids ON did_coins.coin_id = dids.coin_id
LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id
LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id
WHERE cs.created_height IS NOT NULL
UNION ALL
SELECT cs.coin_id, cs.kind,
cats.ticker,
cats.name,
spent_height as height
SELECT cs.coin_id,
cs.kind,
cats.ticker,
cats.name as cat_name,
dids.name as did_name,
nfts.name as nft_name,
cs.spent_height as height
FROM coin_states cs
LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id
LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id
WHERE spent_height IS NOT NULL
LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id
LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id
LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id
LEFT JOIN dids ON did_coins.coin_id = dids.coin_id
LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id
LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id
WHERE cs.spent_height IS NOT NULL
),
filtered_heights AS (
SELECT DISTINCT height
Expand Down Expand Up @@ -476,7 +490,11 @@ async fn get_block_heights(
query
.push("ticker LIKE ")
.push_bind(format!("%{}%", value))
.push(" OR name LIKE ")
.push(" OR cat_name LIKE ")
.push_bind(format!("%{}%", value))
.push(" OR did_name LIKE ")
.push_bind(format!("%{}%", value))
.push(" OR nft_name LIKE ")
.push_bind(format!("%{}%", value))
.push(")");
}
Expand Down
181 changes: 69 additions & 112 deletions crates/sage-database/src/primitives/nfts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -833,137 +833,94 @@ async fn nfts_by_metadata_hash(
.collect()
}

fn escape_fts_query(query: &str) -> String {
// First escape backslashes by doubling them
// Then escape quotes by doubling them
// Finally wrap in quotes to treat as literal string
let escaped = query.replace('\\', "\\\\").replace('"', "\"\"");
format!("\"{escaped}\"")
}

async fn search_nfts(
conn: impl SqliteExecutor<'_>,
params: NftSearchParams,
limit: u32,
offset: u32,
) -> Result<(Vec<NftRow>, u32)> {
let mut conditions = vec!["is_owned = 1"];

// Group filtering (Collection/DID)
match params.group {
Some(NftGroup::Collection(_)) => conditions.push("collection_id = ?"),
Some(NftGroup::NoCollection) => conditions.push("collection_id IS NULL"),
Some(NftGroup::MinterDid(_)) => conditions.push("minter_did = ?"),
Some(NftGroup::NoMinterDid) => conditions.push("minter_did IS NULL"),
Some(NftGroup::OwnerDid(_)) => conditions.push("owner_did = ?"),
Some(NftGroup::NoOwnerDid) => conditions.push("owner_did IS NULL"),
None => {}
}
let mut query = sqlx::QueryBuilder::new(
"SELECT launcher_id,
coin_id,
collection_id,
minter_did,
owner_did,
visible,
sensitive_content,
name,
is_owned,
created_height,
metadata_hash,
is_named,
is_pending,
COUNT(*) OVER() as total_count
FROM nfts
WHERE 1=1
AND is_owned = 1
",
);

// Visibility condition
// Add visibility condition if not including hidden NFTs
if !params.include_hidden {
conditions.push("visible = 1");
query.push(" AND visible = 1");
}

// Build base conditions
let where_clause = conditions.join(" AND ");

// Common parts
let order_by = format!(
r"ORDER BY {visible_order}
is_pending DESC,
{sort_order},
launcher_id ASC
LIMIT ? OFFSET ?",
visible_order = if params.include_hidden {
"visible DESC,"
} else {
""
},
sort_order = match params.sort_mode {
NftSortMode::Recent => "created_height DESC",
NftSortMode::Name => "is_named DESC, name ASC",
// Add group filtering (Collection/DID)
if let Some(group) = &params.group {
match group {
NftGroup::Collection(id) => {
query.push(" AND collection_id = ");
query.push_bind(id.as_ref());
}
NftGroup::NoCollection => {
query.push(" AND collection_id IS NULL");
}
NftGroup::MinterDid(id) => {
query.push(" AND minter_did = ");
query.push_bind(id.as_ref());
}
NftGroup::NoMinterDid => {
query.push(" AND minter_did IS NULL");
}
NftGroup::OwnerDid(id) => {
query.push(" AND owner_did = ");
query.push_bind(id.as_ref());
}
NftGroup::NoOwnerDid => {
query.push(" AND owner_did IS NULL");
}
}
);
}

// Choose index based on sort mode and group type
let index = match (params.sort_mode, &params.group) {
// Collection grouping
(NftSortMode::Name, Some(NftGroup::Collection(_) | NftGroup::NoCollection)) => {
"nft_col_name"
}
(NftSortMode::Recent, Some(NftGroup::Collection(_) | NftGroup::NoCollection)) => {
"nft_col_recent"
}
// Add name search if present
if let Some(name_search) = &params.name {
query.push(" AND name LIKE ");
query.push_bind(format!("%{}%", name_search));
}

// Minter DID grouping
(NftSortMode::Name, Some(NftGroup::MinterDid(_) | NftGroup::NoMinterDid)) => {
"nft_minter_did_name"
}
(NftSortMode::Recent, Some(NftGroup::MinterDid(_) | NftGroup::NoMinterDid)) => {
"nft_minter_did_recent"
}
// Add ORDER BY clause based on sort_mode
query.push(" ORDER BY ");

// Add visible DESC to sort order if including hidden NFTs
if params.include_hidden {
query.push("visible DESC, ");
}

// Owner DID grouping
(NftSortMode::Name, Some(NftGroup::OwnerDid(_) | NftGroup::NoOwnerDid)) => {
"nft_owner_did_name"
match params.sort_mode {
NftSortMode::Recent => {
query.push("is_pending DESC, created_height DESC, launcher_id ASC");
}
(NftSortMode::Recent, Some(NftGroup::OwnerDid(_) | NftGroup::NoOwnerDid)) => {
"nft_owner_did_recent"
NftSortMode::Name => {
query.push("is_pending DESC, is_named DESC, name ASC, launcher_id ASC");
}

// Global sorting
(NftSortMode::Name, None) => "nft_name",
(NftSortMode::Recent, None) => "nft_recent",
};

// Construct query based on whether we're doing a name search
let query = if params.name.is_some() {
format!(
r"
WITH matched_names AS (
SELECT launcher_id
FROM nft_name_fts
WHERE name MATCH ? || '*'
ORDER BY rank
)
SELECT nfts.*, COUNT(*) OVER() as total_count
FROM nfts INDEXED BY {index}
INNER JOIN matched_names ON nfts.launcher_id = matched_names.launcher_id
WHERE {where_clause}
{order_by}
"
)
} else {
format!(
r"
SELECT *, COUNT(*) OVER() as total_count
FROM nfts INDEXED BY {index}
WHERE {where_clause}
{order_by}
"
)
};

// Execute query with bindings
let mut query = sqlx::query_as::<_, NftSearchRow>(&query);

// Bind name search if present
if let Some(name_search) = params.name {
query = query.bind(escape_fts_query(&name_search));
}

// Bind group parameters if present
//if let Some(NftGroup::Collection(id) | NftGroup::MinterDid(id)) = &params.group {
if let Some(NftGroup::Collection(id) | NftGroup::MinterDid(id) | NftGroup::OwnerDid(id)) =
&params.group
{
query = query.bind(id.as_ref());
}
query.push(" LIMIT ? OFFSET ?");

let query = query.build_query_as::<NftSearchRow>();

// Limit and offset
query = query.bind(limit);
query = query.bind(offset);
// Bind limit and offset
let query = query.bind(limit).bind(offset);

let rows = query.fetch_all(conn).await?;
let total_count = rows.first().map_or(0, |row| row.total_count as u32);
Expand Down
42 changes: 35 additions & 7 deletions src/components/NftOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ import {
DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { Input } from './ui/input';
import { Pagination } from './Pagination';
import { CardSizeToggle } from './CardSizeToggle';
import { motion, AnimatePresence } from 'framer-motion';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useDebounce } from '@/hooks/useDebounce';

export interface NftOptionsProps {
isCollection?: boolean;
Expand All @@ -58,7 +58,7 @@ const optionsPaginationVariants = {

export function NftOptions({
isCollection,
params: { sort, group, showHidden, query, cardSize },
params: { sort, group, showHidden, query, cardSize, page },
setParams,
multiSelect,
setMultiSelect,
Expand All @@ -69,6 +69,34 @@ export function NftOptions({
const navigate = useNavigate();
const isFilteredView = Boolean(collection_id || owner_did || minter_did);
const allowSearch = group === NftGroupMode.None || isFilteredView;
const [searchValue, setSearchValue] = useState(query ?? '');
const debouncedSearch = useDebounce(searchValue, 400);
const prevSearchRef = useRef(query);

useEffect(() => {
setSearchValue(query ?? '');
}, [query]);

useEffect(() => {
if (debouncedSearch !== query) {
const shouldResetPage = prevSearchRef.current !== debouncedSearch;
prevSearchRef.current = debouncedSearch;

setParams({
query: debouncedSearch || null,
...(shouldResetPage && { page: 1 }),
});
}
}, [debouncedSearch, query, setParams]);

const handleInputChange = useCallback((value: string) => {
setSearchValue(value);
}, []);

const handleClearSearch = useCallback(() => {
setSearchValue('');
}, []);

const handleBack = () => {
if (collection_id) {
setParams({ group: NftGroupMode.Collection, page: 1 });
Expand Down Expand Up @@ -107,24 +135,24 @@ export function NftOptions({
aria-hidden='true'
/>
<Input
value={query ?? ''}
value={searchValue}
aria-label={t`Search NFTs...`}
title={t`Search NFTs...`}
placeholder={t`Search NFTs...`}
onChange={(e) => setParams({ query: e.target.value, page: 1 })}
onChange={(e) => handleInputChange(e.target.value)}
className='w-full pl-8 pr-8'
disabled={!allowSearch}
aria-disabled={!allowSearch}
/>
</div>
{query && (
{searchValue && (
<Button
variant='ghost'
size='icon'
title={t`Clear search`}
aria-label={t`Clear search`}
className='absolute right-0 top-0 h-full px-2 hover:bg-transparent'
onClick={() => setParams({ query: '', page: 1 })}
onClick={handleClearSearch}
disabled={!allowSearch}
>
<XIcon className='h-4 w-4' aria-hidden='true' />
Expand Down
Loading
Loading