| 40 |
40 |
|
OR COALESCE(p.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
|
| 41 |
41 |
|
)"#;
|
| 42 |
42 |
|
|
|
43 |
+ |
// ILIKE-only variants for short queries (1-2 chars) where trigram similarity is unreliable.
|
|
44 |
+ |
|
|
45 |
+ |
const ITEM_SEARCH_CLAUSE_SHORT: &str = r#" AND (
|
|
46 |
+ |
i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
|
|
47 |
+ |
OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
|
|
48 |
+ |
)"#;
|
|
49 |
+ |
|
|
50 |
+ |
const PROJECT_SEARCH_CLAUSE_SHORT: &str = r#" AND (
|
|
51 |
+ |
p.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
|
|
52 |
+ |
OR COALESCE(p.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
|
|
53 |
+ |
)"#;
|
|
54 |
+ |
|
|
55 |
+ |
/// Maximum allowed search term length. Queries longer than this are truncated.
|
|
56 |
+ |
const MAX_SEARCH_LEN: usize = 200;
|
|
57 |
+ |
|
|
58 |
+ |
/// Normalize a search term: trim whitespace, truncate to [`MAX_SEARCH_LEN`],
|
|
59 |
+ |
/// and return `None` if the result is empty.
|
|
60 |
+ |
fn normalize_search(raw: Option<&str>) -> Option<String> {
|
|
61 |
+ |
let trimmed = raw?.trim();
|
|
62 |
+ |
if trimmed.is_empty() {
|
|
63 |
+ |
return None;
|
|
64 |
+ |
}
|
|
65 |
+ |
if trimmed.len() > MAX_SEARCH_LEN {
|
|
66 |
+ |
// Truncate at a char boundary
|
|
67 |
+ |
let end = trimmed
|
|
68 |
+ |
.char_indices()
|
|
69 |
+ |
.take_while(|(i, _)| *i < MAX_SEARCH_LEN)
|
|
70 |
+ |
.last()
|
|
71 |
+ |
.map(|(i, c)| i + c.len_utf8())
|
|
72 |
+ |
.unwrap_or(MAX_SEARCH_LEN);
|
|
73 |
+ |
Some(trimmed[..end].to_string())
|
|
74 |
+ |
} else {
|
|
75 |
+ |
Some(trimmed.to_string())
|
|
76 |
+ |
}
|
|
77 |
+ |
}
|
|
78 |
+ |
|
|
79 |
+ |
/// Returns `true` when the search term is too short for trigram matching (1-2 chars).
|
|
80 |
+ |
fn is_short_query(term: &str) -> bool {
|
|
81 |
+ |
term.trim().len() <= 2
|
|
82 |
+ |
}
|
|
83 |
+ |
|
| 43 |
84 |
|
/// Append discover-item filter clauses to a dynamic query.
|
| 44 |
85 |
|
/// Parameter positions: $1=search, $2=item_type, $3=min_price, $4=max_price, $5=tag, $6=label.
|
| 45 |
|
- |
fn append_item_discover_filters(query: &mut String, filters: &DiscoverFilters<'_>, has_search: bool) {
|
|
86 |
+ |
///
|
|
87 |
+ |
/// When `short_query` is `true`, the ILIKE-only clause is used instead of the
|
|
88 |
+ |
/// full trigram + ILIKE clause (trigram matching is unreliable for 1-2 char terms).
|
|
89 |
+ |
fn append_item_discover_filters(
|
|
90 |
+ |
query: &mut String,
|
|
91 |
+ |
filters: &DiscoverFilters<'_>,
|
|
92 |
+ |
has_search: bool,
|
|
93 |
+ |
short_query: bool,
|
|
94 |
+ |
) {
|
| 46 |
95 |
|
if has_search {
|
| 47 |
|
- |
query.push_str(ITEM_SEARCH_CLAUSE);
|
|
96 |
+ |
if short_query {
|
|
97 |
+ |
query.push_str(ITEM_SEARCH_CLAUSE_SHORT);
|
|
98 |
+ |
} else {
|
|
99 |
+ |
query.push_str(ITEM_SEARCH_CLAUSE);
|
|
100 |
+ |
}
|
| 48 |
101 |
|
}
|
| 49 |
102 |
|
if filters.item_type.is_some() {
|
| 50 |
103 |
|
query.push_str(" AND i.item_type = $2");
|
| 105 |
158 |
|
limit: i64,
|
| 106 |
159 |
|
offset: i64,
|
| 107 |
160 |
|
) -> Result<Vec<DbDiscoverItemRow>> {
|
| 108 |
|
- |
let search_term = filters.search.filter(|s| !s.trim().is_empty());
|
|
161 |
+ |
let search_term = normalize_search(filters.search);
|
| 109 |
162 |
|
let has_search = search_term.is_some();
|
|
163 |
+ |
let short_query = search_term.as_deref().map_or(false, is_short_query);
|
| 110 |
164 |
|
|
| 111 |
|
- |
// Build the base query with optional similarity score
|
|
165 |
+ |
// Build the base query with optional similarity score.
|
|
166 |
+ |
// For short queries (1-2 chars) use a constant match_score since trigram
|
|
167 |
+ |
// similarity is unreliable at that length.
|
| 112 |
168 |
|
// Use LEFT JOIN with pre-aggregated transaction counts to avoid N+1 subquery per row
|
| 113 |
169 |
|
// LEFT JOIN item_tags/tags for primary tag display
|
| 114 |
|
- |
let mut query = if has_search {
|
|
170 |
+ |
let mut query = if has_search && !short_query {
|
| 115 |
171 |
|
String::from(
|
| 116 |
172 |
|
r#"
|
| 117 |
173 |
|
SELECT
|
| 139 |
195 |
|
WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined'
|
| 140 |
196 |
|
"#,
|
| 141 |
197 |
|
)
|
|
198 |
+ |
} else if has_search {
|
|
199 |
+ |
// Short query: constant match_score, skip trigram similarity computation
|
|
200 |
+ |
String::from(
|
|
201 |
+ |
r#"
|
|
202 |
+ |
SELECT
|
|
203 |
+ |
i.id,
|
|
204 |
+ |
i.title,
|
|
205 |
+ |
i.description,
|
|
206 |
+ |
i.price_cents,
|
|
207 |
+ |
i.item_type,
|
|
208 |
+ |
i.created_at,
|
|
209 |
+ |
u.username,
|
|
210 |
+ |
p.title as project_title,
|
|
211 |
+ |
i.sales_count::bigint,
|
|
212 |
+ |
pt.name as primary_tag_name,
|
|
213 |
+ |
i.pwyw_enabled,
|
|
214 |
+ |
i.pwyw_min_cents,
|
|
215 |
+ |
1.0::real as match_score
|
|
216 |
+ |
FROM items i
|
|
217 |
+ |
JOIN projects p ON i.project_id = p.id
|
|
218 |
+ |
JOIN users u ON p.user_id = u.id
|
|
219 |
+ |
LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
|
|
220 |
+ |
LEFT JOIN tags pt ON pt.id = pit.tag_id
|
|
221 |
+ |
WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined'
|
|
222 |
+ |
"#,
|
|
223 |
+ |
)
|
| 142 |
224 |
|
} else {
|
| 143 |
225 |
|
String::from(
|
| 144 |
226 |
|
r#"
|
| 166 |
248 |
|
)
|
| 167 |
249 |
|
};
|
| 168 |
250 |
|
|
| 169 |
|
- |
append_item_discover_filters(&mut query, filters, has_search);
|
|
251 |
+ |
append_item_discover_filters(&mut query, filters, has_search, short_query);
|
| 170 |
252 |
|
|
| 171 |
253 |
|
// Determine ordering
|
| 172 |
254 |
|
let order = if has_search && (filters.sort_by.is_none() || filters.sort_by == Some(DiscoverSort::Newest)) {
|
| 185 |
267 |
|
let items = bind_item_discover_filters!(
|
| 186 |
268 |
|
sqlx::query_as::<_, DbDiscoverItemRow>(&query),
|
| 187 |
269 |
|
filters,
|
| 188 |
|
- |
search_term
|
|
270 |
+ |
search_term.as_deref()
|
| 189 |
271 |
|
)
|
| 190 |
272 |
|
.bind(limit)
|
| 191 |
273 |
|
.bind(offset)
|
| 200 |
282 |
|
pool: &PgPool,
|
| 201 |
283 |
|
filters: &DiscoverFilters<'_>,
|
| 202 |
284 |
|
) -> Result<i64> {
|
| 203 |
|
- |
let search_term = filters.search.filter(|s| !s.trim().is_empty());
|
|
285 |
+ |
let search_term = normalize_search(filters.search);
|
| 204 |
286 |
|
let has_search = search_term.is_some();
|
|
287 |
+ |
let short_query = search_term.as_deref().map_or(false, is_short_query);
|
| 205 |
288 |
|
|
| 206 |
289 |
|
let mut query = String::from(
|
| 207 |
290 |
|
r#"
|
| 212 |
295 |
|
"#,
|
| 213 |
296 |
|
);
|
| 214 |
297 |
|
|
| 215 |
|
- |
append_item_discover_filters(&mut query, filters, has_search);
|
|
298 |
+ |
append_item_discover_filters(&mut query, filters, has_search, short_query);
|
| 216 |
299 |
|
|
| 217 |
300 |
|
let count: i64 = bind_item_discover_filters!(
|
| 218 |
301 |
|
sqlx::query_scalar(&query),
|
| 219 |
302 |
|
filters,
|
| 220 |
|
- |
search_term
|
|
303 |
+ |
search_term.as_deref()
|
| 221 |
304 |
|
)
|
| 222 |
305 |
|
.fetch_one(pool)
|
| 223 |
306 |
|
.await?;
|
| 238 |
321 |
|
limit: i64,
|
| 239 |
322 |
|
offset: i64,
|
| 240 |
323 |
|
) -> Result<Vec<DbDiscoverProjectRow>> {
|
| 241 |
|
- |
let search_term = search.filter(|s| !s.trim().is_empty());
|
|
324 |
+ |
let search_term = normalize_search(search);
|
| 242 |
325 |
|
let has_search = search_term.is_some();
|
|
326 |
+ |
let short_query = search_term.as_deref().map_or(false, is_short_query);
|
| 243 |
327 |
|
|
| 244 |
|
- |
let mut query = if has_search {
|
|
328 |
+ |
let mut query = if has_search && !short_query {
|
| 245 |
329 |
|
String::from(
|
| 246 |
330 |
|
r#"
|
| 247 |
331 |
|
SELECT
|
| 265 |
349 |
|
WHERE p.is_public = true
|
| 266 |
350 |
|
"#,
|
| 267 |
351 |
|
)
|
|
352 |
+ |
} else if has_search {
|
|
353 |
+ |
// Short query: constant match_score, skip trigram similarity computation
|
|
354 |
+ |
String::from(
|
|
355 |
+ |
r#"
|
|
356 |
+ |
SELECT
|
|
357 |
+ |
p.slug,
|
|
358 |
+ |
p.title,
|
|
359 |
+ |
p.description,
|
|
360 |
+ |
p.project_type,
|
|
361 |
+ |
p.created_at,
|
|
362 |
+ |
u.username,
|
|
363 |
+ |
COUNT(i.id) FILTER (WHERE i.is_public = true AND i.listed = true) as item_count,
|
|
364 |
+ |
1.0::real as match_score,
|
|
365 |
+ |
pc.name as category_name,
|
|
366 |
+ |
pc.slug as category_slug
|
|
367 |
+ |
FROM projects p
|
|
368 |
+ |
JOIN users u ON p.user_id = u.id
|
|
369 |
+ |
LEFT JOIN items i ON i.project_id = p.id
|
|
370 |
+ |
LEFT JOIN project_categories pc ON pc.id = p.category_id
|
|
371 |
+ |
WHERE p.is_public = true
|
|
372 |
+ |
"#,
|
|
373 |
+ |
)
|
| 268 |
374 |
|
} else {
|
| 269 |
375 |
|
String::from(
|
| 270 |
376 |
|
r#"
|
| 289 |
395 |
|
};
|
| 290 |
396 |
|
|
| 291 |
397 |
|
if has_search {
|
| 292 |
|
- |
query.push_str(PROJECT_SEARCH_CLAUSE);
|
|
398 |
+ |
if short_query {
|
|
399 |
+ |
query.push_str(PROJECT_SEARCH_CLAUSE_SHORT);
|
|
400 |
+ |
} else {
|
|
401 |
+ |
query.push_str(PROJECT_SEARCH_CLAUSE);
|
|
402 |
+ |
}
|
| 293 |
403 |
|
}
|
| 294 |
404 |
|
|
| 295 |
405 |
|
if category_slug.is_some() {
|
| 317 |
427 |
|
query.push_str(&format!(" ORDER BY {} LIMIT $4 OFFSET $5", order));
|
| 318 |
428 |
|
|
| 319 |
429 |
|
let projects = sqlx::query_as::<_, DbDiscoverProjectRow>(&query)
|
| 320 |
|
- |
.bind(search_term.unwrap_or(""))
|
|
430 |
+ |
.bind(search_term.as_deref().unwrap_or(""))
|
| 321 |
431 |
|
.bind(category_slug.unwrap_or(""))
|
| 322 |
432 |
|
.bind(label_slug.unwrap_or(""))
|
| 323 |
433 |
|
.bind(limit)
|
| 335 |
445 |
|
category_slug: Option<&str>,
|
| 336 |
446 |
|
label_slug: Option<&str>,
|
| 337 |
447 |
|
) -> Result<i64> {
|
| 338 |
|
- |
let search_term = search.filter(|s| !s.trim().is_empty());
|
|
448 |
+ |
let search_term = normalize_search(search);
|
| 339 |
449 |
|
let has_search = search_term.is_some();
|
|
450 |
+ |
let short_query = search_term.as_deref().map_or(false, is_short_query);
|
| 340 |
451 |
|
|
| 341 |
452 |
|
let mut query = String::from(
|
| 342 |
453 |
|
r#"
|
| 347 |
458 |
|
);
|
| 348 |
459 |
|
|
| 349 |
460 |
|
if has_search {
|
| 350 |
|
- |
query.push_str(PROJECT_SEARCH_CLAUSE);
|
|
461 |
+ |
if short_query {
|
|
462 |
+ |
query.push_str(PROJECT_SEARCH_CLAUSE_SHORT);
|
|
463 |
+ |
} else {
|
|
464 |
+ |
query.push_str(PROJECT_SEARCH_CLAUSE);
|
|
465 |
+ |
}
|
| 351 |
466 |
|
}
|
| 352 |
467 |
|
|
| 353 |
468 |
|
if category_slug.is_some() {
|
| 367 |
482 |
|
}
|
| 368 |
483 |
|
|
| 369 |
484 |
|
let count: i64 = sqlx::query_scalar(&query)
|
| 370 |
|
- |
.bind(search_term.unwrap_or(""))
|
|
485 |
+ |
.bind(search_term.as_deref().unwrap_or(""))
|
| 371 |
486 |
|
.bind(category_slug.unwrap_or(""))
|
| 372 |
487 |
|
.bind(label_slug.unwrap_or(""))
|
| 373 |
488 |
|
.fetch_one(pool)
|
| 384 |
499 |
|
min_price: Option<i32>,
|
| 385 |
500 |
|
max_price: Option<i32>,
|
| 386 |
501 |
|
) -> Result<Vec<DbItemTypeCount>> {
|
| 387 |
|
- |
let search_term = search.filter(|s| !s.trim().is_empty());
|
|
502 |
+ |
let search_term = normalize_search(search);
|
| 388 |
503 |
|
let has_search = search_term.is_some();
|
|
504 |
+ |
let short_query = search_term.as_deref().map_or(false, is_short_query);
|
| 389 |
505 |
|
|
| 390 |
506 |
|
let mut query = String::from(
|
| 391 |
507 |
|
r#"
|
| 397 |
513 |
|
);
|
| 398 |
514 |
|
|
| 399 |
515 |
|
if has_search {
|
| 400 |
|
- |
query.push_str(ITEM_SEARCH_CLAUSE);
|
|
516 |
+ |
if short_query {
|
|
517 |
+ |
query.push_str(ITEM_SEARCH_CLAUSE_SHORT);
|
|
518 |
+ |
} else {
|
|
519 |
+ |
query.push_str(ITEM_SEARCH_CLAUSE);
|
|
520 |
+ |
}
|
| 401 |
521 |
|
}
|
| 402 |
522 |
|
|
| 403 |
523 |
|
if tag.is_some() {
|
| 422 |
542 |
|
query.push_str(" GROUP BY i.item_type ORDER BY count DESC");
|
| 423 |
543 |
|
|
| 424 |
544 |
|
let counts = sqlx::query_as::<_, DbItemTypeCount>(&query)
|
| 425 |
|
- |
.bind(search_term.unwrap_or(""))
|
|
545 |
+ |
.bind(search_term.as_deref().unwrap_or(""))
|
| 426 |
546 |
|
.bind(tag.unwrap_or(""))
|
| 427 |
547 |
|
.bind(min_price.unwrap_or(0))
|
| 428 |
548 |
|
.bind(max_price.unwrap_or(i32::MAX))
|
| 443 |
563 |
|
item_type: Option<ItemType>,
|
| 444 |
564 |
|
tag: Option<&str>,
|
| 445 |
565 |
|
) -> Result<DbPriceRangeCounts> {
|
| 446 |
|
- |
let search_term = search.filter(|s| !s.trim().is_empty());
|
|
566 |
+ |
let search_term = normalize_search(search);
|
| 447 |
567 |
|
let has_search = search_term.is_some();
|
|
568 |
+ |
let short_query = search_term.as_deref().map_or(false, is_short_query);
|
| 448 |
569 |
|
|
| 449 |
570 |
|
let mut query = String::from(
|
| 450 |
571 |
|
r#"
|
| 461 |
582 |
|
);
|
| 462 |
583 |
|
|
| 463 |
584 |
|
if has_search {
|
| 464 |
|
- |
query.push_str(ITEM_SEARCH_CLAUSE);
|
|
585 |
+ |
if short_query {
|
|
586 |
+ |
query.push_str(ITEM_SEARCH_CLAUSE_SHORT);
|
|
587 |
+ |
} else {
|
|
588 |
+ |
query.push_str(ITEM_SEARCH_CLAUSE);
|
|
589 |
+ |
}
|
| 465 |
590 |
|
}
|
| 466 |
591 |
|
|
| 467 |
592 |
|
if item_type.is_some() {
|
| 489 |
614 |
|
}
|
| 490 |
615 |
|
|
| 491 |
616 |
|
let row = sqlx::query_as::<_, PriceRow>(&query)
|
| 492 |
|
- |
.bind(search_term.unwrap_or(""))
|
|
617 |
+ |
.bind(search_term.as_deref().unwrap_or(""))
|
| 493 |
618 |
|
.bind(item_type.map(|t| t.to_string()).unwrap_or_default())
|
| 494 |
619 |
|
.bind(tag.unwrap_or(""))
|
| 495 |
620 |
|
.fetch_one(pool)
|