Skip to content

Full-Text Search (FTS) Usage Guide

This document describes the implementation and usage patterns for PostgreSQL Full-Text Search in the FarmCove Delta application.

Overview

The application uses PostgreSQL's built-in Full-Text Search capabilities for efficient and intelligent searching across various data types. We implement two main patterns:

  1. Direct FTS with buildFTSCondition - For searching within a single table
  2. RPC Functions - For complex searches involving parent-child relationships

Key Features

  • Prefix matching - Allows users to type partial words and get results (e.g., "john" matches "Johnny")
  • Email handling - Intelligent parsing of email addresses for better matching
  • Phone normalization - Strips formatting from phone numbers for consistent searching
  • Multi-format support - Searches multiple representations of the same data (e.g., with/without dashes)
  • Ranking - Results are ranked by relevance when searching

Implementation Patterns

Pattern 1: Direct Table Search with buildFTSCondition

Use this pattern when searching within a single table without complex joins.

Database Setup

  1. Add a generated search_query column with tsvector:
ALTER TABLE public.your_table
ADD COLUMN IF NOT EXISTS search_query tsvector GENERATED ALWAYS AS (
  to_tsvector('english',
    COALESCE(field1, '') || ' ' ||
    COALESCE(field2, '') || ' ' ||
    -- For phones, remove non-alphanumeric for better matching
    COALESCE(regexp_replace(phone_field, '\D', '', 'g'), '')
  )
) STORED;

-- Create GIN index for performance
CREATE INDEX IF NOT EXISTS idx_your_table_search_query
ON public.your_table USING gin(search_query);

Service Layer Usage

import { buildFTSCondition, findMany } from '@/lib/databaseHelper';

export async function searchRecords(searchTerm: string) {
  if (!searchTerm || searchTerm.length < 3) {
    return [];
  }

  const results = await findMany(
    DatabaseTable.YOUR_TABLE,
    [buildFTSCondition(searchTerm)],
    { limit: 50 }
  );

  return results;
}
// In services/address/base.ts
export async function findCandidateAddresses(
  addressData: CreateAddress,
  client?: DatabaseClient
): Promise<Address[]> {
  const searchConditions = [];

  // Build search from address and city
  const searchTerm = `${addressData.address_line_1} ${addressData.city}`.trim();

  if (searchTerm) {
    // Use FTS for flexible and fast searching
    searchConditions.push(buildFTSCondition(searchTerm));
  }

  const candidates = await findMany<Address>(
    DatabaseTable.ADDRESSES,
    searchConditions,
    { limit: 20, client }
  );

  return candidates || [];
}

Pattern 2: RPC Functions for Complex Searches

Use this pattern when you need to:

  • Search across parent and child tables
  • Apply complex business logic
  • Return computed fields or aggregations
  • Optimize performance for complex queries

Database Function Setup

-- Create the RPC function
CREATE OR REPLACE FUNCTION query_with_search(
  p_filter_param UUID,
  search_term TEXT DEFAULT NULL,
  p_limit INTEGER DEFAULT 50,
  p_offset INTEGER DEFAULT 0,
  p_count_only BOOLEAN DEFAULT FALSE
)
RETURNS TABLE (
  -- Define return columns
  id UUID,
  name TEXT,
  search_rank REAL,
  total_count INTEGER
)
AS $$
DECLARE
  formatted_query TEXT;
  has_search BOOLEAN;
  total_record_count INTEGER;
BEGIN
  -- Check if we have a search term
  has_search := search_term IS NOT NULL AND trim(search_term) != '';

  -- Format the query for FTS with prefix matching
  IF has_search THEN
    formatted_query := public.build_fts_query(search_term);
  END IF;

  -- Handle count-only requests efficiently
  IF p_count_only THEN
    -- Return just the count
    SELECT COUNT(*)::INTEGER INTO total_record_count
    FROM your_table t
    LEFT JOIN related_table r ON t.related_id = r.id
    WHERE t.filter_field = p_filter_param
    AND (
      NOT has_search OR
      t.search_query @@ to_tsquery('english', formatted_query) OR
      r.search_query @@ to_tsquery('english', formatted_query)
    );

    RETURN QUERY SELECT
      NULL::UUID, NULL::TEXT, 0::REAL, total_record_count;
    RETURN;
  END IF;

  -- Get total count for pagination
  -- ... (same count query as above)

  -- Return full results with ranking
  RETURN QUERY
  SELECT
    t.id,
    t.name,
    CASE WHEN has_search THEN
      GREATEST(
        COALESCE(ts_rank(t.search_query, to_tsquery('english', formatted_query)), 0),
        COALESCE(ts_rank(r.search_query, to_tsquery('english', formatted_query)), 0)
      )::REAL
    ELSE 0::REAL END as search_rank,
    total_record_count
  FROM your_table t
  LEFT JOIN related_table r ON t.related_id = r.id
  WHERE t.filter_field = p_filter_param
  AND (
    NOT has_search OR
    t.search_query @@ to_tsquery('english', formatted_query) OR
    r.search_query @@ to_tsquery('english', formatted_query)
  )
  ORDER BY
    CASE WHEN has_search THEN search_rank ELSE 0 END DESC,
    t.created_at DESC
  LIMIT p_limit OFFSET p_offset;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

-- Grant permissions
GRANT EXECUTE ON FUNCTION query_with_search TO authenticated;

Service Layer Usage

// In services/your_service/base.ts
import { callFunction } from '@/lib/databaseHelper';
import { DatabaseFunction } from '@/constants/database';

export async function searchWithRelations(
  filterId: string,
  searchTerm?: string,
  pagination?: { page: number; pageSize: number }
) {
  const limit = pagination?.pageSize ?? 50;
  const offset = ((pagination?.page ?? 1) - 1) * limit;

  const results = await callFunction<YourType[]>(
    DatabaseFunction.QUERY_WITH_SEARCH,
    {
      p_filter_param: filterId,
      search_term: searchTerm,
      p_limit: limit,
      p_offset: offset,
      p_count_only: false,
    }
  );

  return results;
}

// Separate count function for efficient pagination
export async function getSearchCount(filterId: string, searchTerm?: string) {
  const result = await callFunction<{ total_count: number }[]>(
    DatabaseFunction.QUERY_WITH_SEARCH,
    {
      p_filter_param: filterId,
      search_term: searchTerm,
      p_count_only: true,
    }
  );

  return result[0]?.total_count ?? 0;
}

Tables with FTS Support

1. Entities Table

  • Column: search_query
  • Searches: business_name, first_name, last_name, preferred_name, email, phone_number (normalized)
  • Usage: Direct search via buildFTSCondition or joined in transaction/project relationship searches

2. Transactions Table

  • Column: search_query
  • Searches: transaction_code (multiple formats), reference_number, description
  • Usage: RPC function query_transactions for complex filtering with entity joins

3. Addresses Table

  • Column: search_query
  • Searches: address_line_1, address_line_2, city, state_province, postal_code, country
  • Usage: Direct search via buildFTSCondition for address deduplication

4. Project Relationships

  • Function: query_project_relationships
  • Searches: Entity fields (via join), role title, role title override
  • Usage: Complex search across multiple related tables

Frontend Integration

When using DataTable with server-side FTS, enable manual search mode:

const table = useDataTable({
  columns,
  data: relationships?.data ?? [],
  manualGlobalSearch: true, // Enable manual search
  enableGlobalFilter: true,
  // ... other options
});

// Handle search with minimum 3 characters
const handleSearch = (value: string) => {
  if (value.length >= 3 || value.length === 0) {
    setSearchTerm(value.length >= 3 ? value : undefined);
  }
};

Search Input Component

<SearchableInput
  value={searchTerm ?? ''}
  onChange={handleSearch}
  placeholder="Search (min 3 characters)..."
  minLength={3}
/>

Performance Considerations

  1. Index Management
  2. Always create GIN indexes on tsvector columns
  3. Monitor index size and rebuild if needed
  4. Consider partial indexes for large tables

  5. Query Optimization

  6. Use p_count_only parameter for efficient pagination counts
  7. Limit search results with reasonable defaults (50-100 items)
  8. Consider caching frequently searched terms

  9. Search Threshold

  10. Require minimum 3 characters to prevent expensive broad searches
  11. This reduces database load and improves user experience

  12. Ranking Weights

  13. Primary search fields (entity names) get full weight (1.0)
  14. Secondary fields (roles, overrides) get reduced weight (0.8)
  15. Adjust weights based on business importance

Filtering on Embedded Resources (IMPORTANT)

The !inner Requirement

When using Supabase/PostgREST with filters on embedded (joined) resources, you MUST use !inner in the select to properly filter parent rows.

The Problem

PostgREST has two different join behaviors:

  1. LEFT JOIN (!foreign_key):
  2. Filters on embedded resources do not affect parent rows
  3. Returns all parent rows, but embedded resource is null when filter doesn't match
  4. Wrong for filtering - users see rows they shouldn't

  5. INNER JOIN (!inner):

  6. Filters on embedded resources exclude parent rows
  7. Only returns parent rows where embedded resource matches the filter
  8. Correct for filtering - only matching rows returned

Example Issue

// ❌ WRONG: Using !entity_id (left join)
select: `
  *,
  entity:entities!entity_id (id, name, email)
`;

// Filter: entity.name matches "john"
// Result: Returns ALL project_relationships
//         entity = null for non-matching rows
//         User sees empty rows they shouldn't see
// ✅ CORRECT: Using !inner (inner join)
select: `
  *,
  entity:entities!inner (id, name, email)
`;

// Filter: entity.name matches "john"
// Result: Returns ONLY project_relationships where entity.name matches "john"
//         Non-matching rows are excluded entirely
//         User sees only relevant rows

When to Use !inner

Always use !inner when:

  • You filter on the embedded resource (e.g., entity.search_query, entity.type)
  • The foreign key is NOT NULL (relationship must exist)
  • You want consistent filtering behavior

Example implementation:

// In services/project/base.ts
const result = await findManyWithCount<ProjectRelationshipWithRelations>(
  DatabaseTable.PROJECT_RELATIONSHIPS,
  queryConditions,
  {
    select: `
      *,
      entity:entities!inner (
        id,
        type,
        search_query,
        email
      )
    `,
    // ... other options
  }
);

// Filter on embedded resource
queryConditions.push({
  column: 'entity.type',
  operator: 'eq',
  value: 'Person',
});

// This correctly excludes project_relationships where entity.type !== 'Person'

URL Syntax for Embedded Resource Filters

When filtering on embedded resources, use the alias from the select:

❌ Wrong:  entity:entities!entity_id.type=eq.Person
✅ Correct: entity.type=eq.Person

❌ Wrong:  entity:entities!entity_id.search_query=fts.john
✅ Correct: entity.search_query=fts.john

The filter parameter should reference the alias (entity), not the full PostgREST foreign key syntax.

Common Pitfall

// User searches for "john"
buildFTSCondition(searchTerm, 'entity.search_query');

// Without !inner: Returns all rows, entity=null for non-matches ❌
// With !inner: Returns only rows where entity matches "john" ✅

Reference

Best Practices

  1. Always normalize search data:
  2. Remove formatting from phones
  3. Include multiple representations of codes
  4. Use COALESCE to handle NULL values

  5. Use appropriate search pattern:

  6. Direct buildFTSCondition for single table
  7. RPC functions for complex joins
  8. Consider performance implications

  9. Handle empty searches gracefully:

  10. Return all results when no search term
  11. Maintain consistent ordering

  12. Provide user feedback:

  13. Show minimum character requirements
  14. Display result counts
  15. Indicate when searching

  16. Use !inner for embedded resource filtering:

  17. Always use !inner when filtering on joined tables
  18. This ensures parent rows are excluded when embedded resource doesn't match
  19. Prevents confusing null results for users
  20. Filter using the alias from select (e.g., entity.type), not PostgREST syntax

Migration Example

When adding FTS to an existing table:

-- Add FTS column to existing table
ALTER TABLE public.your_table
ADD COLUMN IF NOT EXISTS search_query tsvector GENERATED ALWAYS AS (
  to_tsvector('english',
    COALESCE(searchable_field1, '') || ' ' ||
    COALESCE(searchable_field2, '') || ' ' ||
    COALESCE(searchable_field3, '')
  )
) STORED;

-- Create index
CREATE INDEX IF NOT EXISTS idx_your_table_search_query
ON public.your_table USING gin(search_query);

-- Add comment
COMMENT ON COLUMN public.your_table.search_query IS
'Full-text search vector for efficient searching. Auto-maintained.';

Troubleshooting

Common Issues

  1. Search not returning expected results
  2. Check if search term meets minimum length (3 characters)
  3. Verify tsvector column is properly generated
  4. Test the FTS query directly in SQL

  5. Performance issues

  6. Ensure GIN index exists and is being used (check EXPLAIN)
  7. Consider increasing work_mem for complex searches
  8. Review search_query column generation for efficiency

  9. Special characters in search

  10. FTS ignores most punctuation
  11. Phone numbers should have formatting stripped
  12. Email addresses are split on @ for better matching

Additional Resources