Merge pull request #154 from DioCrafts/claude/optimize-performance-FbFK2

Add GIN trigram indexes for substring search performance
This commit is contained in:
Dionisio Pozo
2026-03-02 02:30:47 +01:00
committed by GitHub
4 changed files with 108 additions and 37 deletions
+43
View File
@@ -0,0 +1,43 @@
-- Migration 004: Add GIN trigram indexes for ILIKE/LIKE substring search
--
-- Eliminates full table scans on text search queries by enabling
-- PostgreSQL's pg_trgm extension and creating GIN indexes with
-- gin_trgm_ops on all columns used in ILIKE / LIKE '%text%' patterns.
--
-- CONCURRENTLY is used so that no table locks are held during index
-- creation — zero downtime for existing installations.
--
-- NOTE: CREATE INDEX CONCURRENTLY cannot run inside a transaction block.
-- If using sqlx migrate, run this file manually:
-- psql -f db/migrations/004_add_trigram_indexes.sql
-- 0. Enable the pg_trgm extension (idempotent)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- 1. Contacts — search_contacts(), get_contacts_by_email()
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_full_name_trgm
ON carddav.contacts USING gin (full_name gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_first_name_trgm
ON carddav.contacts USING gin (first_name gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_last_name_trgm
ON carddav.contacts USING gin (last_name gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_nickname_trgm
ON carddav.contacts USING gin (nickname gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_organization_trgm
ON carddav.contacts USING gin (organization gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_email_text_trgm
ON carddav.contacts USING gin ((email::text) gin_trgm_ops);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_phone_text_trgm
ON carddav.contacts USING gin ((phone::text) gin_trgm_ops);
-- 2. Calendar events — find_events_by_summary()
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_calendar_events_summary_trgm
ON caldav.calendar_events USING gin (summary gin_trgm_ops);
-- 3. Files — search_files_paginated(), search_files_in_subtree(), suggest_files_by_name()
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_files_name_trgm
ON storage.files USING gin (name gin_trgm_ops);
-- 4. Folders — search_folders(), list_descendant_folders(), suggest_folders_by_name()
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_folders_name_trgm
ON storage.folders USING gin (name gin_trgm_ops);
+29
View File
@@ -253,6 +253,9 @@ CREATE TABLE IF NOT EXISTS caldav.calendar_events (
CREATE INDEX IF NOT EXISTS idx_calendar_events_calendar_id ON caldav.calendar_events(calendar_id);
CREATE INDEX IF NOT EXISTS idx_calendar_events_ical_uid ON caldav.calendar_events(ical_uid);
CREATE INDEX IF NOT EXISTS idx_calendar_events_time_range ON caldav.calendar_events(calendar_id, start_time, end_time);
-- GIN trigram index for ILIKE substring search (find_events_by_summary)
CREATE INDEX IF NOT EXISTS idx_calendar_events_summary_trgm
ON caldav.calendar_events USING gin (summary gin_trgm_ops);
-- Calendar sharing
CREATE TABLE IF NOT EXISTS caldav.calendar_shares (
@@ -283,6 +286,10 @@ COMMENT ON TABLE caldav.calendar_events IS 'Calendar events (VEVENT) stored with
COMMENT ON TABLE caldav.calendar_shares IS 'Calendar sharing permissions between users';
COMMENT ON TABLE caldav.calendar_properties IS 'Custom WebDAV properties on calendars';
-- ── pg_trgm extension for GIN trigram indexes (ILIKE / LIKE substring search) ──
-- Required before creating any gin_trgm_ops indexes below.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- ============================================================
-- 3. CARDDAV SCHEMA (RFC 6352)
-- ============================================================
@@ -331,6 +338,22 @@ CREATE INDEX IF NOT EXISTS idx_contacts_address_book_id ON carddav.contacts(addr
CREATE INDEX IF NOT EXISTS idx_contacts_uid ON carddav.contacts(uid);
CREATE INDEX IF NOT EXISTS idx_contacts_full_name ON carddav.contacts(full_name);
-- GIN trigram indexes for ILIKE substring search (search_contacts, get_contacts_by_email)
CREATE INDEX IF NOT EXISTS idx_contacts_full_name_trgm
ON carddav.contacts USING gin (full_name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_contacts_first_name_trgm
ON carddav.contacts USING gin (first_name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_contacts_last_name_trgm
ON carddav.contacts USING gin (last_name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_contacts_nickname_trgm
ON carddav.contacts USING gin (nickname gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_contacts_organization_trgm
ON carddav.contacts USING gin (organization gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_contacts_email_text_trgm
ON carddav.contacts USING gin ((email::text) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_contacts_phone_text_trgm
ON carddav.contacts USING gin ((phone::text) gin_trgm_ops);
-- Address book sharing
CREATE TABLE IF NOT EXISTS carddav.address_book_shares (
id SERIAL PRIMARY KEY,
@@ -441,6 +464,9 @@ CREATE INDEX IF NOT EXISTS idx_folders_trashed ON storage.folders(user_id, is_tr
CREATE INDEX IF NOT EXISTS idx_folders_lpath ON storage.folders USING gist (lpath);
-- B-tree index on path for exact path lookups
CREATE INDEX IF NOT EXISTS idx_folders_path ON storage.folders (path text_pattern_ops);
-- GIN trigram index for ILIKE substring search (search_folders, suggest_folders_by_name)
CREATE INDEX IF NOT EXISTS idx_folders_name_trgm
ON storage.folders USING gin (name gin_trgm_ops);
-- ── ltree trigger: compute path & lpath on INSERT or UPDATE of name/parent_id ──
CREATE OR REPLACE FUNCTION storage.compute_folder_path()
@@ -528,6 +554,9 @@ CREATE INDEX IF NOT EXISTS idx_files_folder_id ON storage.files(folder_id);
CREATE INDEX IF NOT EXISTS idx_files_blob_hash ON storage.files(blob_hash);
CREATE INDEX IF NOT EXISTS idx_files_trashed ON storage.files(user_id, is_trashed);
CREATE INDEX IF NOT EXISTS idx_files_name_search ON storage.files(user_id, name text_pattern_ops);
-- GIN trigram index for ILIKE substring search (search_files, suggest_files_by_name)
CREATE INDEX IF NOT EXISTS idx_files_name_trgm
ON storage.files USING gin (name gin_trgm_ops);
-- Trash view combining trashed files and folders for the TrashRepository.
-- Only shows top-level trashed items: excludes files/folders whose parent
@@ -532,10 +532,10 @@ impl FileReadPort for FileBlobReadRepository {
}
if let Some(name) = &criteria.name_contains
&& !name.is_empty()
&& name.len() >= 3
{
bind_idx += 1;
conditions.push(format!("LOWER(fi.name) LIKE ${bind_idx}"));
conditions.push(format!("fi.name ILIKE ${bind_idx}"));
}
let where_clause = conditions.join(" AND ");
@@ -578,9 +578,9 @@ impl FileReadPort for FileBlobReadRepository {
query = query.bind(fid);
}
if let Some(name) = &criteria.name_contains
&& !name.is_empty()
&& name.len() >= 3
{
query = query.bind(format!("%{}%", name.to_lowercase()));
query = query.bind(format!("%{}%", name));
}
query = query.bind(limit).bind(offset);
@@ -652,10 +652,10 @@ impl FileReadPort for FileBlobReadRepository {
);
if let Some(name) = &criteria.name_contains
&& !name.is_empty()
&& name.len() >= 3
{
bind_idx += 1;
conditions.push(format!("LOWER(fi.name) LIKE ${bind_idx}"));
conditions.push(format!("fi.name ILIKE ${bind_idx}"));
}
if let Some(types) = &criteria.file_types
&& !types.is_empty()
@@ -737,9 +737,9 @@ impl FileReadPort for FileBlobReadRepository {
.bind(root_id);
if let Some(name) = &criteria.name_contains
&& !name.is_empty()
&& name.len() >= 3
{
query = query.bind(format!("%{}%", name.to_lowercase()));
query = query.bind(format!("%{}%", name));
}
if let Some(types) = &criteria.file_types
&& !types.is_empty()
@@ -807,9 +807,8 @@ impl FileReadPort for FileBlobReadRepository {
query: &str,
limit: usize,
) -> Result<Vec<File>, DomainError> {
let pattern = format!("%{}%", query.to_lowercase());
let pattern = format!("%{}%", query);
let limit_i64 = limit as i64;
let query_lower = query.to_lowercase();
let rows: Vec<(
String,
@@ -833,10 +832,10 @@ impl FileReadPort for FileBlobReadRepository {
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid
AND NOT fi.is_trashed
AND LOWER(fi.name) LIKE $2
AND fi.name ILIKE $2
ORDER BY CASE
WHEN LOWER(fi.name) = $3 THEN 0
WHEN LOWER(fi.name) LIKE $3 || '%' THEN 1
WHEN fi.name ILIKE $3 THEN 0
WHEN fi.name ILIKE $3 || '%' THEN 1
ELSE 2
END,
fi.name
@@ -845,7 +844,7 @@ impl FileReadPort for FileBlobReadRepository {
)
.bind(fid)
.bind(&pattern)
.bind(&query_lower)
.bind(query)
.bind(limit_i64)
.fetch_all(self.pool.as_ref())
.await
@@ -861,10 +860,10 @@ impl FileReadPort for FileBlobReadRepository {
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL
AND NOT fi.is_trashed
AND LOWER(fi.name) LIKE $1
AND fi.name ILIKE $1
ORDER BY CASE
WHEN LOWER(fi.name) = $2 THEN 0
WHEN LOWER(fi.name) LIKE $2 || '%' THEN 1
WHEN fi.name ILIKE $2 THEN 0
WHEN fi.name ILIKE $2 || '%' THEN 1
ELSE 2
END,
fi.name
@@ -872,7 +871,7 @@ impl FileReadPort for FileBlobReadRepository {
"#,
)
.bind(&pattern)
.bind(&query_lower)
.bind(query)
.bind(limit_i64)
.fetch_all(self.pool.as_ref())
.await
@@ -720,15 +720,16 @@ impl FolderRepository for FolderDbRepository {
.await;
}
// Build optional name filter
// Build optional name filter — use ILIKE (case-insensitive) so the
// GIN trigram index idx_folders_name_trgm is used instead of a seq scan.
let (name_clause, name_pattern) = match name_contains {
Some(name) if !name.is_empty() => (
Some(name) if name.len() >= 3 => (
if recursive {
" AND LOWER(fo.name) LIKE $2"
" AND fo.name ILIKE $2"
} else {
" AND LOWER(fo.name) LIKE $3"
" AND fo.name ILIKE $3"
},
Some(format!("%{}%", name.to_lowercase())),
Some(format!("%{}%", name)),
),
_ => ("", None),
};
@@ -794,7 +795,7 @@ impl FolderRepository for FolderDbRepository {
} else {
// Root folders: parent_id IS NULL, reindex params ($1=user_id, $2=pattern)
let name_clause_root = match name_contains {
Some(name) if !name.is_empty() => " AND LOWER(fo.name) LIKE $2",
Some(name) if name.len() >= 3 => " AND fo.name ILIKE $2",
_ => "",
};
format!(
@@ -866,9 +867,9 @@ impl FolderRepository for FolderDbRepository {
user_id: &str,
) -> Result<Vec<Folder>, DomainError> {
let (where_extra, name_pattern) = match name_contains {
Some(name) if !name.is_empty() => (
" AND LOWER(fo.name) LIKE $3",
Some(format!("%{}%", name.to_lowercase())),
Some(name) if name.len() >= 3 => (
" AND fo.name ILIKE $3",
Some(format!("%{}%", name)),
),
_ => ("", None),
};
@@ -924,8 +925,7 @@ impl FolderRepository for FolderDbRepository {
query: &str,
limit: usize,
) -> Result<Vec<Folder>, DomainError> {
let pattern = format!("%{}%", query.to_lowercase());
let query_lower = query.to_lowercase();
let pattern = format!("%{}%", query);
let limit_i64 = limit as i64;
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> =
@@ -938,10 +938,10 @@ impl FolderRepository for FolderDbRepository {
FROM storage.folders
WHERE parent_id = $1::uuid
AND NOT is_trashed
AND LOWER(name) LIKE $2
AND name ILIKE $2
ORDER BY CASE
WHEN LOWER(name) = $3 THEN 0
WHEN LOWER(name) LIKE $3 || '%' THEN 1
WHEN name ILIKE $3 THEN 0
WHEN name ILIKE $3 || '%' THEN 1
ELSE 2
END,
name
@@ -950,7 +950,7 @@ impl FolderRepository for FolderDbRepository {
)
.bind(pid)
.bind(&pattern)
.bind(&query_lower)
.bind(query)
.bind(limit_i64)
.fetch_all(self.pool())
.await
@@ -963,10 +963,10 @@ impl FolderRepository for FolderDbRepository {
FROM storage.folders
WHERE parent_id IS NULL
AND NOT is_trashed
AND LOWER(name) LIKE $1
AND name ILIKE $1
ORDER BY CASE
WHEN LOWER(name) = $2 THEN 0
WHEN LOWER(name) LIKE $2 || '%' THEN 1
WHEN name ILIKE $2 THEN 0
WHEN name ILIKE $2 || '%' THEN 1
ELSE 2
END,
name
@@ -974,7 +974,7 @@ impl FolderRepository for FolderDbRepository {
"#,
)
.bind(&pattern)
.bind(&query_lower)
.bind(query)
.bind(limit_i64)
.fetch_all(self.pool())
.await