Skip to main content

Implementing Search

Learn how to implement powerful search capabilities in Stack9 applications. This guide covers full-text search, filtering, autocomplete, search optimization, Elasticsearch integration, result ranking, and advanced search patterns.

What You'll Build

In this guide, you'll implement a Product Search System with:

  • Full-text search across multiple fields
  • Faceted filtering and refinement
  • Autocomplete/typeahead functionality
  • Search result ranking and relevance
  • Performance optimizations
  • Elasticsearch integration (optional)
  • Advanced search patterns

Time to complete: 45-60 minutes

Prerequisites

Understanding Search in Stack9

Stack9 provides multiple search capabilities:

1. Basic Search (Built-in)

Uses PostgreSQL's full-text search capabilities:

  • Text matching across fields
  • Simple filtering
  • Sorting and pagination
  • Good for small-medium datasets

2. Advanced Search with Elasticsearch

For large datasets and complex queries:

  • Full-text search with ranking
  • Fuzzy matching and typo tolerance
  • Faceted navigation
  • Near real-time indexing
  • Scalable to millions of records

Step 1: Create Searchable Entities

First, create entities optimized for search.

Product Entity with Search Fields

Create src/entities/custom/product.json:

{
"head": {
"name": "Product",
"key": "product",
"pluralisedName": "products",
"icon": "ShoppingOutlined",
"isActive": true
},
"fields": [
{
"label": "SKU",
"key": "sku",
"type": "TextField",
"validateRules": {
"required": true,
"maxLength": 50
},
"index": true,
"searchable": true
},
{
"label": "Product Name",
"key": "name",
"type": "TextField",
"validateRules": {
"required": true,
"maxLength": 200
},
"index": true,
"searchable": true,
"searchWeight": 5
},
{
"label": "Description",
"key": "description",
"type": "RichTextEditor",
"searchable": true,
"searchWeight": 2
},
{
"label": "Brand",
"key": "brand_id",
"type": "SingleDropDown",
"relationshipOptions": {
"ref": "brand"
},
"typeOptions": {
"label": "name"
},
"index": true
},
{
"label": "Category",
"key": "category_id",
"type": "SingleDropDown",
"relationshipOptions": {
"ref": "category"
},
"typeOptions": {
"label": "name"
},
"validateRules": {
"required": true
},
"index": true
},
{
"label": "Tags",
"key": "tags",
"type": "MultiDropDown",
"relationshipOptions": {
"ref": "tag"
},
"typeOptions": {
"label": "name"
}
},
{
"label": "Price",
"key": "price",
"type": "NumericField",
"validateRules": {
"required": true,
"min": 0
},
"typeOptions": {
"decimals": 2
},
"index": true
},
{
"label": "Stock Quantity",
"key": "stock_quantity",
"type": "NumericField",
"defaultValue": 0,
"typeOptions": {
"decimals": 0
}
},
{
"label": "Status",
"key": "status",
"type": "OptionSet",
"typeOptions": {
"values": ["Active", "Discontinued", "Out of Stock", "Coming Soon"]
},
"defaultValue": "Active",
"index": true
},
{
"label": "Is Featured",
"key": "is_featured",
"type": "Checkbox",
"defaultValue": false,
"index": true
},
{
"label": "Rating",
"key": "rating",
"type": "NumericField",
"typeOptions": {
"decimals": 1,
"min": 0,
"max": 5
}
},
{
"label": "Review Count",
"key": "review_count",
"type": "NumericField",
"defaultValue": 0,
"typeOptions": {
"decimals": 0
}
}
]
}

Key Search Configuration:

  • index: true - Creates database index for fast filtering
  • searchable: true - Includes field in full-text search
  • searchWeight - Higher weight = more important in ranking

Supporting Entities

Create src/entities/custom/brand.json:

{
"head": {
"name": "Brand",
"key": "brand",
"pluralisedName": "brands"
},
"fields": [
{
"label": "Name",
"key": "name",
"type": "TextField",
"validateRules": {
"required": true
},
"index": true,
"searchable": true
},
{
"label": "Description",
"key": "description",
"type": "TextField"
}
]
}

Create src/entities/custom/category.json:

{
"head": {
"name": "Category",
"key": "category",
"pluralisedName": "categories"
},
"fields": [
{
"label": "Name",
"key": "name",
"type": "TextField",
"validateRules": {
"required": true
},
"index": true
},
{
"label": "Slug",
"key": "slug",
"type": "TextField",
"validateRules": {
"required": true
}
},
{
"label": "Parent Category",
"key": "parent_id",
"type": "SingleDropDown",
"relationshipOptions": {
"ref": "category"
}
}
]
}

Step 2: Implement Basic Search Query

Create src/query-library/searchproducts.json:

{
"key": "searchproducts",
"name": "searchProducts",
"connector": "stack9_api",
"queryTemplate": {
"method": "post",
"path": "/product/search",
"bodyParams": "{\n \"$select\": [\n \"id\",\n \"sku\",\n \"name\",\n \"description\",\n \"brand_id\",\n \"brand.name\",\n \"category_id\",\n \"category.name\",\n \"price\",\n \"stock_quantity\",\n \"status\",\n \"is_featured\",\n \"rating\",\n \"review_count\",\n \"_created_at\"\n ],\n \"$where\": {\n \"_is_deleted\": false,\n \"$or\": [\n { \"name\": { \"$ilike\": \"%{{searchTerm}}%\" } },\n { \"sku\": { \"$ilike\": \"%{{searchTerm}}%\" } },\n { \"description\": { \"$ilike\": \"%{{searchTerm}}%\" } }\n ]\n },\n \"$withRelated\": [\n \"brand(notDeleted)\",\n \"category(notDeleted)\",\n \"tags(notDeleted)\"\n ],\n \"$orderBy\": [\n { \"column\": \"is_featured\", \"order\": \"desc\" },\n { \"column\": \"rating\", \"order\": \"desc\" },\n { \"column\": \"name\", \"order\": \"asc\" }\n ],\n \"$limit\": {{limit}},\n \"$offset\": {{offset}}\n}",
"queryParams": {}
},
"userParams": {
"searchTerm": "",
"limit": 20,
"offset": 0
}
}

Search Features:

  • $ilike - Case-insensitive pattern matching
  • $or - Search across multiple fields
  • $withRelated - Load related entities
  • Sorting by featured status, rating, then name
  • Pagination with limit/offset

Step 3: Add Faceted Filtering

Create src/query-library/searchproductswithfilters.json:

{
"key": "searchproductswithfilters",
"name": "searchProductsWithFilters",
"connector": "stack9_api",
"queryTemplate": {
"method": "post",
"path": "/product/search",
"bodyParams": "{\n \"$select\": [\n \"id\",\n \"sku\",\n \"name\",\n \"description\",\n \"brand_id\",\n \"brand.name\",\n \"category_id\",\n \"category.name\",\n \"price\",\n \"stock_quantity\",\n \"status\",\n \"is_featured\",\n \"rating\",\n \"review_count\"\n ],\n \"$where\": {\n \"_is_deleted\": false,\n \"$and\": [\n {\n \"$or\": [\n { \"name\": { \"$ilike\": \"%{{searchTerm}}%\" } },\n { \"sku\": { \"$ilike\": \"%{{searchTerm}}%\" } },\n { \"description\": { \"$ilike\": \"%{{searchTerm}}%\" } }\n ]\n },\n {{#if categoryId}}{ \"category_id\": {{categoryId}} },{{/if}}\n {{#if brandId}}{ \"brand_id\": {{brandId}} },{{/if}}\n {{#if status}}{ \"status\": \"{{status}}\" },{{/if}}\n {{#if minPrice}}{ \"price\": { \"$gte\": {{minPrice}} } },{{/if}}\n {{#if maxPrice}}{ \"price\": { \"$lte\": {{maxPrice}} } },{{/if}}\n {{#if minRating}}{ \"rating\": { \"$gte\": {{minRating}} } },{{/if}}\n {{#if inStock}}{ \"stock_quantity\": { \"$gt\": 0 } },{{/if}}\n {{#if featured}}{ \"is_featured\": true },{{/if}}\n { \"id\": { \"$ne\": null } }\n ]\n },\n \"$withRelated\": [\n \"brand(notDeleted)\",\n \"category(notDeleted)\",\n \"tags(notDeleted)\"\n ],\n \"$orderBy\": [\n {{#if sortBy}}\n {{#eq sortBy \"price_asc\"}}{ \"column\": \"price\", \"order\": \"asc\" }{{/eq}}\n {{#eq sortBy \"price_desc\"}}{ \"column\": \"price\", \"order\": \"desc\" }{{/eq}}\n {{#eq sortBy \"rating\"}}{ \"column\": \"rating\", \"order\": \"desc\" }{{/eq}}\n {{#eq sortBy \"newest\"}}{ \"column\": \"_created_at\", \"order\": \"desc\" }{{/eq}}\n {{#eq sortBy \"name\"}}{ \"column\": \"name\", \"order\": \"asc\" }{{/eq}}\n {{else}}\n { \"column\": \"is_featured\", \"order\": \"desc\" },\n { \"column\": \"rating\", \"order\": \"desc\" }\n {{/if}}\n ],\n \"$limit\": {{limit}},\n \"$offset\": {{offset}}\n}",
"queryParams": {}
},
"userParams": {
"searchTerm": "",
"categoryId": null,
"brandId": null,
"status": null,
"minPrice": null,
"maxPrice": null,
"minRating": null,
"inStock": false,
"featured": false,
"sortBy": "relevance",
"limit": 20,
"offset": 0
}
}

Filter Types:

  • Category filter - Filter by category
  • Brand filter - Filter by brand
  • Price range - Min/max price filtering
  • Rating filter - Minimum rating threshold
  • Stock filter - In-stock only
  • Status filter - Active, discontinued, etc.
  • Featured filter - Featured products only
  • Dynamic sorting - Multiple sort options

Step 4: Create Search Screen

Create src/screens/product_search.json:

{
"head": {
"title": "Search Products",
"key": "product_search",
"route": "product-search",
"app": "products",
"icon": "SearchOutlined"
},
"screenType": "listView",
"listQuery": "searchproductswithfilters",
"entityKey": "product",
"searchConfig": {
"enabled": true,
"placeholder": "Search products by name, SKU, or description...",
"debounceMs": 300
},
"columnsConfiguration": [
{
"field": "sku",
"label": "SKU",
"value": "{{sku}}",
"renderAs": "Text",
"options": {
"linkProp": "/products/product-detail/{{id}}"
}
},
{
"field": "name",
"label": "Product Name",
"value": "{{name}}",
"renderAs": "Text",
"options": {
"linkProp": "/products/product-detail/{{id}}",
"highlight": true
}
},
{
"field": "brand",
"label": "Brand",
"value": "{{brand.name}}",
"renderAs": "Text"
},
{
"field": "category",
"label": "Category",
"value": "{{category.name}}",
"renderAs": "Tag",
"options": {
"color": "blue"
}
},
{
"field": "price",
"label": "Price",
"value": "{{price}}",
"renderAs": "Currency",
"options": {
"currency": "USD"
}
},
{
"field": "rating",
"label": "Rating",
"value": "{{rating}}",
"renderAs": "Rating",
"options": {
"max": 5,
"showCount": true,
"countField": "review_count"
}
},
{
"field": "stock_quantity",
"label": "Stock",
"value": "{{stock_quantity}}",
"renderAs": "Number",
"options": {
"colorRules": [
{ "condition": "{{stock_quantity}} === 0", "color": "red" },
{ "condition": "{{stock_quantity}} < 10", "color": "orange" },
{ "condition": "{{stock_quantity}} >= 10", "color": "green" }
]
}
},
{
"field": "status",
"label": "Status",
"value": "{{status}}",
"renderAs": "EnumTags",
"options": {
"colorMapping": {
"Active": "green",
"Discontinued": "red",
"Out of Stock": "orange",
"Coming Soon": "blue"
}
}
}
],
"filters": [
{
"field": "category_id",
"label": "Category",
"type": "dropdown",
"entityKey": "category",
"allowClear": true
},
{
"field": "brand_id",
"label": "Brand",
"type": "dropdown",
"entityKey": "brand",
"allowClear": true
},
{
"field": "status",
"label": "Status",
"type": "dropdown",
"options": ["Active", "Discontinued", "Out of Stock", "Coming Soon"],
"allowClear": true
},
{
"field": "price_range",
"label": "Price Range",
"type": "range",
"minField": "minPrice",
"maxField": "maxPrice",
"step": 10
},
{
"field": "min_rating",
"label": "Minimum Rating",
"type": "rating",
"max": 5
},
{
"field": "in_stock",
"label": "In Stock Only",
"type": "checkbox"
},
{
"field": "featured",
"label": "Featured Products",
"type": "checkbox"
}
],
"sortOptions": [
{
"label": "Relevance",
"value": "relevance"
},
{
"label": "Price: Low to High",
"value": "price_asc"
},
{
"label": "Price: High to Low",
"value": "price_desc"
},
{
"label": "Highest Rated",
"value": "rating"
},
{
"label": "Newest",
"value": "newest"
},
{
"label": "Name A-Z",
"value": "name"
}
]
}

Step 5: Implement Autocomplete/Typeahead

Create an action type for autocomplete suggestions.

Create src/action-types/productAutocomplete.ts:

import { Record, String, Number } from 'runtypes';
import { S9AutomationActionType } from '@april9/stack9-sdk';

const ProductAutocompleteParams = Record({
query: String,
limit: Number.optional(),
});

/**
* Product Autocomplete
*
* Provides fast autocomplete suggestions for product search
*/
export class ProductAutocomplete extends S9AutomationActionType {
key = 'product_autocomplete';
name = 'Product Autocomplete';
description = 'Get autocomplete suggestions for product search';

async exec(params: any) {
const { query, limit = 10 } = ProductAutocompleteParams.check(params);
const { db, logger } = this.context;

try {
// Return empty if query too short
if (query.length < 2) {
return {
success: true,
suggestions: [],
};
}

// Search products
const products = await db('products')
.select(
'id',
'sku',
'name',
'brand_id',
'price',
'category_id'
)
.leftJoin('brands', 'products.brand_id', 'brands.id')
.leftJoin('categories', 'products.category_id', 'categories.id')
.select('brands.name as brand_name')
.select('categories.name as category_name')
.where('products._is_deleted', false)
.where(function() {
this.where('products.name', 'ilike', `%${query}%`)
.orWhere('products.sku', 'ilike', `%${query}%`)
.orWhere('brands.name', 'ilike', `%${query}%`);
})
.orderByRaw(`
CASE
WHEN products.name ILIKE '${query}%' THEN 1
WHEN products.sku ILIKE '${query}%' THEN 2
WHEN products.name ILIKE '%${query}%' THEN 3
ELSE 4
END
`)
.orderBy('products.is_featured', 'desc')
.orderBy('products.rating', 'desc')
.limit(limit);

// Format suggestions
const suggestions = products.map(product => ({
id: product.id,
type: 'product',
title: product.name,
subtitle: `${product.sku} - ${product.brand_name}`,
category: product.category_name,
price: product.price,
url: `/products/product-detail/${product.id}`,
}));

// Also get matching categories
const categories = await db('categories')
.select('id', 'name', 'slug')
.where('_is_deleted', false)
.where('name', 'ilike', `%${query}%`)
.limit(3);

const categorySuggestions = categories.map(cat => ({
id: cat.id,
type: 'category',
title: cat.name,
subtitle: 'Category',
url: `/products/product-search?category_id=${cat.id}`,
}));

// Also get matching brands
const brands = await db('brands')
.select('id', 'name')
.where('_is_deleted', false)
.where('name', 'ilike', `%${query}%`)
.limit(3);

const brandSuggestions = brands.map(brand => ({
id: brand.id,
type: 'brand',
title: brand.name,
subtitle: 'Brand',
url: `/products/product-search?brand_id=${brand.id}`,
}));

return {
success: true,
suggestions: [
...categorySuggestions,
...brandSuggestions,
...suggestions,
],
};
} catch (error) {
logger.error(`Autocomplete error: ${error.message}`);
return {
success: false,
suggestions: [],
error: error.message,
};
}
}
}

Create Autocomplete Webhook

Create src/automations/webhook_product_autocomplete.json:

{
"name": "webhook_product_autocomplete",
"label": "Product Autocomplete Webhook",
"description": "Provides autocomplete suggestions for product search",
"trigger": {
"type": "webhook",
"method": "GET",
"path": "/product-autocomplete"
},
"actions": [
{
"name": "get_suggestions",
"actionType": "product_autocomplete",
"params": {
"query": "{{trigger.query.q}}",
"limit": "{{trigger.query.limit}}"
}
}
]
}

Usage:

# Get autocomplete suggestions
curl "http://localhost:3000/webhooks/product-autocomplete?q=laptop&limit=10"

Step 6: Optimize Search Performance

Add Database Indexes

Create src/migrations/add_search_indexes.sql:

-- Full-text search indexes
CREATE INDEX idx_products_name_trgm ON products USING gin(name gin_trgm_ops);
CREATE INDEX idx_products_description_trgm ON products USING gin(description gin_trgm_ops);
CREATE INDEX idx_products_sku_trgm ON products USING gin(sku gin_trgm_ops);

-- Filter indexes
CREATE INDEX idx_products_category_id ON products(category_id) WHERE _is_deleted = false;
CREATE INDEX idx_products_brand_id ON products(brand_id) WHERE _is_deleted = false;
CREATE INDEX idx_products_status ON products(status) WHERE _is_deleted = false;
CREATE INDEX idx_products_price ON products(price) WHERE _is_deleted = false;
CREATE INDEX idx_products_rating ON products(rating DESC) WHERE _is_deleted = false;

-- Composite indexes for common filter combinations
CREATE INDEX idx_products_category_status ON products(category_id, status) WHERE _is_deleted = false;
CREATE INDEX idx_products_featured_rating ON products(is_featured DESC, rating DESC) WHERE _is_deleted = false;

-- Stock availability index
CREATE INDEX idx_products_in_stock ON products(stock_quantity) WHERE _is_deleted = false AND stock_quantity > 0;

Implement Search Cache

Create src/action-types/cachedProductSearch.ts:

import { Record, String, Number } from 'runtypes';
import { S9AutomationActionType } from '@april9/stack9-sdk';
import crypto from 'crypto';

/**
* Cached Product Search
*
* Implements caching layer for frequently searched queries
*/
export class CachedProductSearch extends S9AutomationActionType {
key = 'cached_product_search';
name = 'Cached Product Search';
description = 'Product search with result caching';

private getCacheKey(params: any): string {
const normalized = JSON.stringify(params, Object.keys(params).sort());
return crypto.createHash('md5').update(normalized).digest('hex');
}

async exec(params: any) {
const { db, logger, cache } = this.context;

try {
// Generate cache key from search parameters
const cacheKey = `product_search:${this.getCacheKey(params)}`;

// Check cache first
const cached = await cache.get(cacheKey);
if (cached) {
logger.info('Search results returned from cache');
return {
success: true,
results: JSON.parse(cached),
cached: true,
};
}

// Execute search query
const searchTerm = params.searchTerm || '';
const categoryId = params.categoryId;
const brandId = params.brandId;
const minPrice = params.minPrice;
const maxPrice = params.maxPrice;
const limit = params.limit || 20;
const offset = params.offset || 0;

let query = db('products')
.select(
'products.*',
'brands.name as brand_name',
'categories.name as category_name'
)
.leftJoin('brands', 'products.brand_id', 'brands.id')
.leftJoin('categories', 'products.category_id', 'categories.id')
.where('products._is_deleted', false);

// Apply search term
if (searchTerm) {
query = query.where(function() {
this.where('products.name', 'ilike', `%${searchTerm}%`)
.orWhere('products.sku', 'ilike', `%${searchTerm}%`)
.orWhere('products.description', 'ilike', `%${searchTerm}%`);
});
}

// Apply filters
if (categoryId) {
query = query.where('products.category_id', categoryId);
}
if (brandId) {
query = query.where('products.brand_id', brandId);
}
if (minPrice) {
query = query.where('products.price', '>=', minPrice);
}
if (maxPrice) {
query = query.where('products.price', '<=', maxPrice);
}

// Apply sorting
query = query
.orderBy('products.is_featured', 'desc')
.orderBy('products.rating', 'desc')
.orderBy('products.name', 'asc');

// Get total count
const countQuery = query.clone().clearSelect().clearOrder().count('* as count');
const [{ count }] = await countQuery;

// Apply pagination
const results = await query.limit(limit).offset(offset);

const response = {
results,
total: parseInt(count as string),
limit,
offset,
hasMore: offset + limit < parseInt(count as string),
};

// Cache results for 5 minutes
await cache.set(cacheKey, JSON.stringify(response), 300);

logger.info(`Search executed, found ${count} results`);

return {
success: true,
...response,
cached: false,
};
} catch (error) {
logger.error(`Search error: ${error.message}`);
throw error;
}
}
}

Step 7: Elasticsearch Integration (Optional)

For large-scale search requirements, integrate Elasticsearch.

Configure Elasticsearch Connector

Create src/connectors/elasticsearch.json:

{
"name": "elasticsearch",
"type": "rest",
"baseURL": "http://localhost:9200",
"headers": {
"Content-Type": "application/json"
},
"auth": {
"type": "basic",
"username": "{{env.ELASTICSEARCH_USERNAME}}",
"password": "{{env.ELASTICSEARCH_PASSWORD}}"
}
}

Create Elasticsearch Index Mapping

Create src/action-types/createProductIndex.ts:

import { S9AutomationActionType } from '@april9/stack9-sdk';

/**
* Create Product Index in Elasticsearch
*/
export class CreateProductIndex extends S9AutomationActionType {
key = 'create_product_index';
name = 'Create Product Index';
description = 'Initialize Elasticsearch index for products';

async exec(params: any) {
const { connectors, logger } = this.context;

try {
const elasticsearch = connectors['elasticsearch'];

// Create index with custom mapping
await elasticsearch.call({
method: 'PUT',
path: '/products',
body: {
settings: {
number_of_shards: 1,
number_of_replicas: 1,
analysis: {
analyzer: {
product_analyzer: {
type: 'custom',
tokenizer: 'standard',
filter: [
'lowercase',
'asciifolding',
'product_synonym',
'product_edge_ngram'
]
}
},
filter: {
product_synonym: {
type: 'synonym',
synonyms: [
'laptop, notebook, computer',
'phone, mobile, smartphone',
'tv, television'
]
},
product_edge_ngram: {
type: 'edge_ngram',
min_gram: 2,
max_gram: 15
}
}
}
},
mappings: {
properties: {
id: { type: 'integer' },
sku: {
type: 'text',
fields: {
keyword: { type: 'keyword' }
}
},
name: {
type: 'text',
analyzer: 'product_analyzer',
fields: {
keyword: { type: 'keyword' }
}
},
description: {
type: 'text',
analyzer: 'product_analyzer'
},
brand_id: { type: 'integer' },
brand_name: {
type: 'text',
fields: {
keyword: { type: 'keyword' }
}
},
category_id: { type: 'integer' },
category_name: {
type: 'text',
fields: {
keyword: { type: 'keyword' }
}
},
price: { type: 'float' },
stock_quantity: { type: 'integer' },
status: { type: 'keyword' },
is_featured: { type: 'boolean' },
rating: { type: 'float' },
review_count: { type: 'integer' },
tags: { type: 'keyword' },
created_at: { type: 'date' }
}
}
}
});

logger.info('Elasticsearch product index created successfully');

return {
success: true,
message: 'Product index created',
};
} catch (error) {
logger.error(`Error creating index: ${error.message}`);
throw error;
}
}
}

Index Products in Elasticsearch

Create src/action-types/indexProduct.ts:

import { Record, Number } from 'runtypes';
import { S9AutomationActionType } from '@april9/stack9-sdk';

const IndexProductParams = Record({
product_id: Number,
});

/**
* Index Product in Elasticsearch
*/
export class IndexProduct extends S9AutomationActionType {
key = 'index_product';
name = 'Index Product';
description = 'Index a product in Elasticsearch';

async exec(params: any) {
const { product_id } = IndexProductParams.check(params);
const { db, connectors, logger } = this.context;

try {
// Fetch product with relationships
const product = await db('products')
.select(
'products.*',
'brands.name as brand_name',
'categories.name as category_name'
)
.leftJoin('brands', 'products.brand_id', 'brands.id')
.leftJoin('categories', 'products.category_id', 'categories.id')
.where('products.id', product_id)
.where('products._is_deleted', false)
.first();

if (!product) {
return {
success: false,
message: 'Product not found',
};
}

// Get tags
const tags = await db('product_tag')
.join('tags', 'product_tag.tag_id', 'tags.id')
.select('tags.name')
.where('product_tag.product_id', product_id)
.where('tags._is_deleted', false);

// Prepare document for Elasticsearch
const document = {
id: product.id,
sku: product.sku,
name: product.name,
description: product.description,
brand_id: product.brand_id,
brand_name: product.brand_name,
category_id: product.category_id,
category_name: product.category_name,
price: product.price,
stock_quantity: product.stock_quantity,
status: product.status,
is_featured: product.is_featured,
rating: product.rating,
review_count: product.review_count,
tags: tags.map(t => t.name),
created_at: product._created_at,
};

// Index in Elasticsearch
const elasticsearch = connectors['elasticsearch'];
await elasticsearch.call({
method: 'PUT',
path: `/products/_doc/${product_id}`,
body: document
});

logger.info(`Product ${product_id} indexed in Elasticsearch`);

return {
success: true,
message: 'Product indexed successfully',
};
} catch (error) {
logger.error(`Error indexing product: ${error.message}`);
throw error;
}
}
}

Search with Elasticsearch

Create src/action-types/elasticsearchProductSearch.ts:

import { Record, String, Number } from 'runtypes';
import { S9AutomationActionType } from '@april9/stack9-sdk';

/**
* Elasticsearch Product Search
*
* Advanced search using Elasticsearch with fuzzy matching,
* boosting, and advanced relevance scoring
*/
export class ElasticsearchProductSearch extends S9AutomationActionType {
key = 'elasticsearch_product_search';
name = 'Elasticsearch Product Search';
description = 'Search products using Elasticsearch';

async exec(params: any) {
const { connectors, logger } = this.context;

try {
const {
query = '',
categoryId,
brandId,
minPrice,
maxPrice,
status,
inStock,
featured,
from = 0,
size = 20,
} = params;

const elasticsearch = connectors['elasticsearch'];

// Build Elasticsearch query
const must: any[] = [];
const filter: any[] = [];

// Text search with boosting
if (query) {
must.push({
multi_match: {
query: query,
fields: [
'name^3', // Name is most important
'sku^2', // SKU is important
'brand_name^2', // Brand name is important
'description', // Description least important
'category_name'
],
type: 'best_fields',
fuzziness: 'AUTO',
prefix_length: 2,
operator: 'or'
}
});
}

// Apply filters
if (categoryId) {
filter.push({ term: { category_id: categoryId } });
}
if (brandId) {
filter.push({ term: { brand_id: brandId } });
}
if (status) {
filter.push({ term: { status: status } });
}
if (featured) {
filter.push({ term: { is_featured: true } });
}
if (inStock) {
filter.push({ range: { stock_quantity: { gt: 0 } } });
}

// Price range filter
if (minPrice || maxPrice) {
const priceRange: any = {};
if (minPrice) priceRange.gte = minPrice;
if (maxPrice) priceRange.lte = maxPrice;
filter.push({ range: { price: priceRange } });
}

// Build search request
const searchBody = {
from,
size,
query: {
bool: {
must: must.length > 0 ? must : { match_all: {} },
filter: filter.length > 0 ? filter : undefined,
}
},
sort: [
{ is_featured: { order: 'desc' } },
'_score',
{ rating: { order: 'desc' } },
{ name: { order: 'asc' } }
],
highlight: {
fields: {
name: {},
description: {}
}
},
aggs: {
categories: {
terms: {
field: 'category_name.keyword',
size: 20
}
},
brands: {
terms: {
field: 'brand_name.keyword',
size: 20
}
},
price_ranges: {
range: {
field: 'price',
ranges: [
{ to: 50, key: 'Under $50' },
{ from: 50, to: 100, key: '$50 - $100' },
{ from: 100, to: 200, key: '$100 - $200' },
{ from: 200, to: 500, key: '$200 - $500' },
{ from: 500, key: 'Over $500' }
]
}
},
avg_rating: {
avg: { field: 'rating' }
}
}
};

// Execute search
const response = await elasticsearch.call({
method: 'POST',
path: '/products/_search',
body: searchBody
});

// Format results
const hits = response.hits.hits;
const results = hits.map((hit: any) => ({
...hit._source,
score: hit._score,
highlights: hit.highlight
}));

logger.info(`Elasticsearch search found ${response.hits.total.value} results`);

return {
success: true,
results,
total: response.hits.total.value,
from,
size,
facets: {
categories: response.aggregations.categories.buckets,
brands: response.aggregations.brands.buckets,
priceRanges: response.aggregations.price_ranges.buckets,
avgRating: response.aggregations.avg_rating.value
}
};
} catch (error) {
logger.error(`Elasticsearch search error: ${error.message}`);
throw error;
}
}
}

Auto-Index Products on Create/Update

Create src/automations/when_product_changed.json:

{
"name": "when_product_changed",
"label": "When Product Changed",
"description": "Sync product to Elasticsearch on create/update",
"trigger": {
"type": "afterCreate,afterUpdate",
"entity": "product"
},
"actions": [
{
"name": "index_in_elasticsearch",
"actionType": "index_product",
"params": {
"product_id": "{{trigger.entity.id}}"
}
}
]
}

Step 8: Advanced Search Patterns

// Implement search scoring based on field matches
export class WeightedSearch extends S9AutomationActionType {
async exec(params: any) {
const { query } = params;
const { db } = this.context;

const results = await db('products')
.select('*')
.selectRaw(`
CASE
WHEN name ILIKE '${query}%' THEN 100
WHEN sku = '${query}' THEN 90
WHEN name ILIKE '%${query}%' THEN 50
WHEN description ILIKE '%${query}%' THEN 20
ELSE 10
END as relevance_score
`)
.where(function() {
this.where('name', 'ilike', `%${query}%`)
.orWhere('sku', 'ilike', `%${query}%`)
.orWhere('description', 'ilike', `%${query}%`);
})
.orderBy('relevance_score', 'desc')
.orderBy('is_featured', 'desc')
.orderBy('rating', 'desc');

return { success: true, results };
}
}

Pattern 2: Search with Typo Tolerance

// Use PostgreSQL's similarity extension for fuzzy matching
export class FuzzySearch extends S9AutomationActionType {
async exec(params: any) {
const { query } = params;
const { db } = this.context;

// Enable pg_trgm extension first
// CREATE EXTENSION IF NOT EXISTS pg_trgm;

const results = await db('products')
.select('*')
.selectRaw(`
GREATEST(
similarity(name, ?),
similarity(sku, ?)
) as similarity_score
`, [query, query])
.where(function() {
this.whereRaw('name % ?', [query])
.orWhereRaw('sku % ?', [query]);
})
.orderBy('similarity_score', 'desc')
.limit(20);

return { success: true, results };
}
}

Pattern 3: Search with Synonyms

// Expand search terms with synonyms
export class SynonymSearch extends S9AutomationActionType {
private synonyms: Record<string, string[]> = {
'laptop': ['notebook', 'computer', 'macbook'],
'phone': ['mobile', 'smartphone', 'cell'],
'tv': ['television', 'screen', 'display'],
};

private expandQuery(query: string): string[] {
const terms = [query];
const lowerQuery = query.toLowerCase();

// Add synonyms if found
for (const [key, values] of Object.entries(this.synonyms)) {
if (lowerQuery.includes(key)) {
terms.push(...values);
}
}

return terms;
}

async exec(params: any) {
const { query } = params;
const { db } = this.context;

const expandedTerms = this.expandQuery(query);

const results = await db('products')
.select('*')
.where(function() {
expandedTerms.forEach(term => {
this.orWhere('name', 'ilike', `%${term}%`)
.orWhere('description', 'ilike', `%${term}%`);
});
})
.orderBy('is_featured', 'desc')
.orderBy('rating', 'desc');

return { success: true, results, expandedTerms };
}
}
// Search within category and all subcategories
export class CategoryTreeSearch extends S9AutomationActionType {
async getCategoryTree(categoryId: number, db: any): Promise<number[]> {
const categories = [categoryId];

// Recursively get all subcategories
const getChildren = async (parentId: number) => {
const children = await db('categories')
.select('id')
.where('parent_id', parentId)
.where('_is_deleted', false);

for (const child of children) {
categories.push(child.id);
await getChildren(child.id);
}
};

await getChildren(categoryId);
return categories;
}

async exec(params: any) {
const { query, categoryId } = params;
const { db } = this.context;

// Get all categories in tree
const categoryIds = await this.getCategoryTree(categoryId, db);

const results = await db('products')
.select('*')
.whereIn('category_id', categoryIds)
.where(function() {
if (query) {
this.where('name', 'ilike', `%${query}%`)
.orWhere('description', 'ilike', `%${query}%`);
}
})
.where('_is_deleted', false);

return { success: true, results, categoryIds };
}
}

Step 9: Search Analytics

Track search queries and results to improve search relevance.

Create src/entities/custom/search_log.json:

{
"head": {
"name": "SearchLog",
"key": "search_log",
"pluralisedName": "search_logs"
},
"fields": [
{
"label": "Query",
"key": "query",
"type": "TextField",
"index": true
},
{
"label": "Results Count",
"key": "results_count",
"type": "NumericField"
},
{
"label": "Execution Time (ms)",
"key": "execution_time",
"type": "NumericField"
},
{
"label": "User ID",
"key": "user_id",
"type": "NumericField",
"index": true
},
{
"label": "Filters Applied",
"key": "filters",
"type": "TextField"
},
{
"label": "Has Results",
"key": "has_results",
"type": "Checkbox",
"index": true
}
]
}

Create src/action-types/logSearch.ts:

import { S9AutomationActionType } from '@april9/stack9-sdk';

/**
* Log Search Query
*
* Tracks search queries for analytics and optimization
*/
export class LogSearch extends S9AutomationActionType {
key = 'log_search';
name = 'Log Search';
description = 'Log search query for analytics';

async exec(params: any) {
const { query, resultsCount, executionTime, filters, userId } = params;
const { db } = this.context;

try {
await db('search_logs').insert({
query: query,
results_count: resultsCount,
execution_time: executionTime,
user_id: userId,
filters: JSON.stringify(filters),
has_results: resultsCount > 0,
_created_at: new Date(),
_updated_at: new Date(),
});

return {
success: true,
message: 'Search logged',
};
} catch (error) {
// Don't fail if logging fails
this.context.logger.error(`Error logging search: ${error.message}`);
return {
success: true,
message: 'Search logging failed (non-critical)',
};
}
}
}

Best Practices

Performance

  1. Index strategically - Index fields used in WHERE clauses and sorting
  2. Use LIMIT and OFFSET - Always paginate results
  3. **Avoid SELECT *** - Select only needed fields
  4. Cache frequent searches - Cache popular queries
  5. Use connection pooling - Reuse database connections

Search Quality

  1. Weight fields appropriately - Name > SKU > Description
  2. Handle typos - Use fuzzy matching (pg_trgm or Elasticsearch)
  3. Support synonyms - Expand search terms
  4. Boost popular items - Factor in rating, reviews, sales
  5. Show relevant filters - Display only filters with results

User Experience

  1. Debounce search input - Wait 300ms after typing stops
  2. Show autocomplete quickly - Return suggestions fast
  3. Highlight matches - Bold matched text in results
  4. Display facet counts - Show result count per filter
  5. Remember search state - Preserve filters in URL

Analytics

  1. Track queries - Log all search queries
  2. Monitor zero-result searches - Find gaps in inventory
  3. Analyze popular searches - Optimize for common queries
  4. Track click-through rate - Measure search relevance
  5. A/B test ranking - Experiment with different algorithms

Troubleshooting

Slow Search Queries

Problem: Search taking too long

Solutions:

  1. Check indexes exist: \d products in psql
  2. Analyze query plan: EXPLAIN ANALYZE SELECT ...
  3. Add missing indexes on filter fields
  4. Reduce number of JOINs
  5. Consider Elasticsearch for large datasets

No Results Found

Problem: Search returns empty results

Solutions:

  1. Check search term spelling
  2. Verify data exists in database
  3. Test with simpler queries
  4. Check case sensitivity (ilike vs like)
  5. Ensure _is_deleted = false filter is correct

Autocomplete Not Working

Problem: Autocomplete suggestions not appearing

Solutions:

  1. Check webhook is registered
  2. Verify minimum query length (usually 2 chars)
  3. Check API endpoint is accessible
  4. Look for JavaScript errors in browser console
  5. Verify debounce delay is not too long

Elasticsearch Connection Failed

Problem: Cannot connect to Elasticsearch

Solutions:

  1. Verify Elasticsearch is running: curl localhost:9200
  2. Check credentials in connector configuration
  3. Verify network connectivity
  4. Check firewall rules
  5. Review Elasticsearch logs

Irrelevant Results

Problem: Search returns irrelevant products

Solutions:

  1. Adjust field weights (boost important fields)
  2. Improve ranking algorithm
  3. Add synonyms for common terms
  4. Filter out discontinued/out-of-stock products
  5. Factor in popularity metrics (rating, sales)

Next Steps

You've implemented comprehensive search functionality! Continue learning:

Summary

In this guide, you learned how to:

  • Implement basic full-text search with PostgreSQL
  • Add faceted filtering with multiple criteria
  • Create autocomplete/typeahead functionality
  • Optimize search performance with indexes and caching
  • Integrate Elasticsearch for advanced search
  • Implement custom ranking algorithms
  • Track search analytics
  • Handle edge cases and troubleshooting

Search capabilities implemented:

  • Full-text search across multiple fields
  • Category and brand filtering
  • Price range filtering
  • Rating and stock filters
  • Multiple sort options
  • Fast autocomplete suggestions
  • Result highlighting
  • Faceted navigation
  • Search caching
  • Elasticsearch integration (optional)