Skip to content
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
27 changes: 27 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# macOS metadata
.DS_Store
**/.DS_Store

# Python generated files
__pycache__/
*.py[cod]
*$py.class

# Python virtual environments
.venv/
venv/

# Test/cache folders
.pytest_cache/
.mypy_cache/
.ruff_cache/

# RDS/Pulumi local generated files
rds-terraform/**/__pycache__/
rds-terraform/**/*.py[cod]
rds-terraform/**/.pytest_cache/
rds-terraform/**/.venv/
rds-terraform/**/venv/

# Search local generated files
ddl/Search/**/.DS_Store
6 changes: 6 additions & 0 deletions ddl/Search/01_enable_fuzzy_search.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Enable trigram support for fuzzy search.
-- Idempotent: Can run multiple times
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- Verify extension install.
-- SELECT extname FROM pg_extension WHERE extname = 'pg_trgm';
150 changes: 150 additions & 0 deletions ddl/Search/02_add_request_search.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
-- Request search.
-- Prerequisite: run 01_enable_fuzzy_search.sql.
-- Set search_path to the target schema before running.

-- Request-local full-text vector.
ALTER TABLE request
ADD COLUMN IF NOT EXISTS search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(req_subj, '')), 'A') ||
setweight(to_tsvector('english', coalesce(req_desc, '')), 'B') ||
setweight(to_tsvector('english', coalesce(req_loc, '')), 'C')
) STORED;

-- Request indexes.
CREATE INDEX IF NOT EXISTS idx_request_search_vector
ON request USING GIN (search_vector);

CREATE INDEX IF NOT EXISTS idx_request_subj_trgm
ON request USING GIN (req_subj gin_trgm_ops);

CREATE INDEX IF NOT EXISTS idx_request_desc_trgm
ON request USING GIN (req_desc gin_trgm_ops);

CREATE INDEX IF NOT EXISTS idx_request_loc_trgm
ON request USING GIN (req_loc gin_trgm_ops);

-- Category indexes used by request search.
CREATE INDEX IF NOT EXISTS idx_help_categories_name_fts
ON help_categories
USING GIN (to_tsvector('english', coalesce(cat_name, '')));

CREATE INDEX IF NOT EXISTS idx_help_categories_name_trgm
ON help_categories USING GIN (cat_name gin_trgm_ops);

-- Request search entry point.
CREATE OR REPLACE FUNCTION search_requests(
query_text TEXT,
limit_results INT DEFAULT 20,
requester_user_id VARCHAR(255) DEFAULT NULL,
requester_access_level SMALLINT DEFAULT NULL,
allowed_request_owner_ids VARCHAR(255)[] DEFAULT NULL
)
RETURNS TABLE (
req_id VARCHAR(255),
req_cat_id VARCHAR(50),
cat_name VARCHAR(100),
req_subj VARCHAR(125),
req_desc VARCHAR(255),
req_loc VARCHAR(125),
req_status_id INT,
req_priority_id INT,
submission_date TIMESTAMP,
relevance_score REAL
)
LANGUAGE plpgsql
SET search_path FROM CURRENT
AS $$
BEGIN
IF query_text IS NULL OR btrim(query_text) = '' THEN
RETURN;
END IF;

RETURN QUERY
WITH query_input AS (
SELECT plainto_tsquery('english', query_text) AS ts_query
),
ranked AS (
SELECT
r.req_id,
r.req_cat_id,
hc.cat_name,
r.req_subj,
r.req_desc,
r.req_loc,
r.req_status_id,
r.req_priority_id,
r.submission_date,
ts_rank_cd(
setweight(
to_tsvector('english', replace(coalesce(hc.cat_name, ''), '_', ' ')),
'A'
) ||
setweight(to_tsvector('english', coalesce(r.req_subj, '')), 'A') ||
setweight(to_tsvector('english', coalesce(r.req_desc, '')), 'B') ||
setweight(to_tsvector('english', coalesce(r.req_loc, '')), 'C'),
qi.ts_query
) AS fts_score,
GREATEST(
similarity(replace(coalesce(hc.cat_name, ''), '_', ' '), query_text),
word_similarity(query_text, replace(coalesce(hc.cat_name, ''), '_', ' ')),
similarity(coalesce(r.req_subj, ''), query_text),
word_similarity(query_text, coalesce(r.req_subj, '')),
similarity(coalesce(r.req_desc, ''), query_text),
word_similarity(query_text, coalesce(r.req_desc, '')),
similarity(coalesce(r.req_loc, ''), query_text)
) AS fuzzy_score
FROM request r
JOIN help_categories hc
ON hc.cat_id = r.req_cat_id
CROSS JOIN query_input qi
WHERE
(
coalesce(requester_access_level, 0) >= 4
OR (
requester_user_id IS NOT NULL
AND r.req_user_id = requester_user_id
)
OR (
allowed_request_owner_ids IS NOT NULL
AND r.req_user_id = ANY(allowed_request_owner_ids)
)
)
AND
(
(
setweight(
to_tsvector('english', replace(coalesce(hc.cat_name, ''), '_', ' ')),
'A'
) ||
r.search_vector
) @@ qi.ts_query
OR to_tsvector('english', replace(coalesce(hc.cat_name, ''), '_', ' ')) @@ qi.ts_query
OR coalesce(r.req_subj, '') % query_text
OR coalesce(r.req_desc, '') % query_text
OR coalesce(r.req_loc, '') % query_text
OR replace(coalesce(hc.cat_name, ''), '_', ' ') % query_text
OR word_similarity(query_text, replace(coalesce(hc.cat_name, ''), '_', ' ')) >= 0.45
OR word_similarity(query_text, coalesce(r.req_subj, '')) >= 0.45
OR word_similarity(query_text, coalesce(r.req_desc, '')) >= 0.45
)
)
SELECT
ranked.req_id,
ranked.req_cat_id,
ranked.cat_name,
ranked.req_subj,
ranked.req_desc,
ranked.req_loc,
ranked.req_status_id,
ranked.req_priority_id,
ranked.submission_date,
(ranked.fts_score * 0.70 + ranked.fuzzy_score * 0.30)::REAL AS relevance_score
FROM ranked
ORDER BY relevance_score DESC, submission_date DESC NULLS LAST
LIMIT GREATEST(limit_results, 1);
END;
$$;

-- Example usage:
-- SELECT * FROM search_requests('emergency medical');
127 changes: 127 additions & 0 deletions ddl/Search/03_add_user_and_volunteer_search.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
-- User search.
-- Prerequisite: run 01_enable_fuzzy_search.sql.
-- Set search_path to the target schema before running.

-- User-local full-text vector.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS search_vector tsvector
GENERATED ALWAYS AS (
setweight(
to_tsvector(
'english',
coalesce(full_name, '') || ' ' ||
coalesce(first_name, '') || ' ' ||
coalesce(last_name, '')
),
'A'
) ||
setweight(to_tsvector('simple', coalesce(primary_email_address, '')), 'B')
) STORED;

-- User indexes.
CREATE INDEX IF NOT EXISTS idx_users_search_vector
ON users USING GIN (search_vector);

CREATE INDEX IF NOT EXISTS idx_users_full_name_trgm
ON users USING GIN (full_name gin_trgm_ops);

CREATE INDEX IF NOT EXISTS idx_users_email_trgm
ON users USING GIN (primary_email_address gin_trgm_ops);

CREATE INDEX IF NOT EXISTS idx_users_email_exact
ON users (primary_email_address);

-- User search entry point.
CREATE OR REPLACE FUNCTION search_users(
query_text TEXT,
limit_results INT DEFAULT 20,
requester_user_id VARCHAR(255) DEFAULT NULL,
requester_access_level SMALLINT DEFAULT NULL,
allowed_user_ids VARCHAR(255)[] DEFAULT NULL
)
RETURNS TABLE (
user_id VARCHAR(255),
full_name VARCHAR(255),
primary_email_address VARCHAR(255),
user_category_id INT,
user_status_id INT,
relevance_score REAL
)
LANGUAGE plpgsql
SET search_path FROM CURRENT
AS $$
BEGIN
IF query_text IS NULL OR btrim(query_text) = '' THEN
RETURN;
END IF;

RETURN QUERY
WITH query_input AS (
SELECT
btrim(query_text) AS raw_query,
plainto_tsquery('english', query_text) AS name_ts_query,
plainto_tsquery('simple', query_text) AS email_ts_query
),
ranked AS (
SELECT
u.user_id,
u.full_name,
u.primary_email_address,
u.user_category_id,
u.user_status_id,
GREATEST(
ts_rank_cd(u.search_vector, qi.name_ts_query),
ts_rank_cd(u.search_vector, qi.email_ts_query)
) AS fts_score,
GREATEST(
similarity(coalesce(u.full_name, ''), qi.raw_query),
word_similarity(qi.raw_query, coalesce(u.full_name, '')),
similarity(coalesce(u.primary_email_address, ''), qi.raw_query)
) AS fuzzy_score,
CASE
WHEN lower(coalesce(u.primary_email_address, '')) = lower(qi.raw_query) THEN 1.0
ELSE 0.0
END AS exact_email_score
FROM users u
CROSS JOIN query_input qi
WHERE
(
coalesce(requester_access_level, 0) >= 4
OR (
requester_user_id IS NOT NULL
AND u.user_id = requester_user_id
)
OR (
allowed_user_ids IS NOT NULL
AND u.user_id = ANY(allowed_user_ids)
)
)
AND
(
u.search_vector @@ qi.name_ts_query
OR u.search_vector @@ qi.email_ts_query
OR coalesce(u.full_name, '') % qi.raw_query
OR word_similarity(qi.raw_query, coalesce(u.full_name, '')) >= 0.45
OR similarity(coalesce(u.primary_email_address, ''), qi.raw_query) >= 0.60
OR lower(coalesce(u.primary_email_address, '')) = lower(qi.raw_query)
)
)
SELECT
ranked.user_id,
ranked.full_name,
ranked.primary_email_address,
ranked.user_category_id,
ranked.user_status_id,
(
ranked.exact_email_score * 1.00 +
ranked.fts_score * 0.60 +
ranked.fuzzy_score * 0.40
)::REAL AS relevance_score
FROM ranked
ORDER BY relevance_score DESC, full_name ASC NULLS LAST, user_id ASC
LIMIT GREATEST(limit_results, 1);
END;
$$;

-- Example usage:
-- SELECT * FROM search_users('arup');
Loading