Back to Blog
Database

Database Management with Supabase: Beyond Basic CRUD

Codoric Team

Codoric Team

9 min read
Database Management with Supabase: Beyond Basic CRUD

Database Management with Supabase: Beyond Basic CRUD

At the heart of Supabase lies a powerful PostgreSQL database, offering developers the full capabilities of this enterprise-grade relational database system. While Supabase makes basic CRUD operations simple, its true power comes from leveraging PostgreSQL's advanced features. In this article, we'll explore how to effectively manage your database in Supabase, going beyond basic operations to build robust, performant applications.

Understanding the Supabase Database Architecture

Supabase provides a fully managed PostgreSQL instance for each project. This means you get:

  • A dedicated PostgreSQL database with your own connection string
  • Full SQL access via the SQL Editor in the dashboard
  • Automatic backups and point-in-time recovery
  • Database extensions pre-installed and ready to use
  • Row-level security for fine-grained access control

This architecture gives you the flexibility to use Supabase's client libraries for simple operations while still having the option to execute complex SQL when needed.

Database Schema Design

Creating Tables with the Right Structure

Good schema design is fundamental to database performance and maintainability. Here's how to create well-structured tables in Supabase:

-- Create a products table with appropriate data types
CREATE TABLE products (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  name TEXT NOT NULL,
  description TEXT,
  price DECIMAL(10, 2) NOT NULL CHECK (price >= 0),
  stock_quantity INTEGER NOT NULL DEFAULT 0 CHECK (stock_quantity >= 0),
  category_id UUID REFERENCES categories(id),
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Create an index for faster category-based queries
CREATE INDEX idx_products_category ON products(category_id);

-- Create a trigger to automatically update the updated_at timestamp
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION trigger_set_updated_at();

Best Practices for Schema Design

  1. Use appropriate data types: Choose the most specific data type for each column (e.g., uuid for IDs, text for strings, timestamp with time zone for timestamps).

  2. Add constraints: Use NOT NULL, CHECK, UNIQUE, and foreign key constraints to enforce data integrity.

  3. Include audit fields: Add created_at and updated_at timestamps to track when records are created and modified.

  4. Use UUIDs for primary keys: UUIDs are globally unique and allow for distributed systems to generate IDs without conflicts.

  5. Create indexes strategically: Add indexes to columns frequently used in WHERE clauses, but be mindful of the overhead for write operations.

Managing Relationships

PostgreSQL excels at handling relationships between tables. Here's how to implement different types of relationships in Supabase:

One-to-Many Relationships

-- Categories (one) to Products (many)
CREATE TABLE categories (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  name TEXT NOT NULL UNIQUE,
  description TEXT
);

-- The products table (shown earlier) references categories
-- via the category_id foreign key

Many-to-Many Relationships

-- Products can belong to multiple collections, and collections can contain multiple products
CREATE TABLE collections (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  name TEXT NOT NULL UNIQUE,
  description TEXT
);

CREATE TABLE products_collections (
  product_id UUID REFERENCES products(id) ON DELETE CASCADE,
  collection_id UUID REFERENCES collections(id) ON DELETE CASCADE,
  PRIMARY KEY (product_id, collection_id)
);

One-to-One Relationships

-- Each product has exactly one detailed specification
CREATE TABLE product_specifications (
  product_id UUID PRIMARY KEY REFERENCES products(id) ON DELETE CASCADE,
  dimensions TEXT,
  weight DECIMAL(8, 2),
  material TEXT,
  country_of_origin TEXT
);

Advanced PostgreSQL Features in Supabase

Using PostgreSQL Functions

PostgreSQL functions allow you to encapsulate complex logic within the database:

-- Create a function to calculate the total value of inventory
CREATE OR REPLACE FUNCTION calculate_inventory_value()
RETURNS DECIMAL AS $$
DECLARE
  total_value DECIMAL;
BEGIN
  SELECT SUM(price * stock_quantity) INTO total_value FROM products;
  RETURN total_value;
END;
$$ LANGUAGE plpgsql;

-- Usage
SELECT calculate_inventory_value();

Implementing Custom Triggers

Triggers can automatically perform actions when data changes:

-- Create a function that will be called by the trigger
CREATE OR REPLACE FUNCTION update_product_history()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO product_history (
    product_id, 
    price_before, 
    price_after, 
    stock_before, 
    stock_after, 
    changed_at
  )
  VALUES (
    NEW.id,
    OLD.price,
    NEW.price,
    OLD.stock_quantity,
    NEW.stock_quantity,
    NOW()
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Create the trigger
CREATE TRIGGER track_product_changes
AFTER UPDATE OF price, stock_quantity ON products
FOR EACH ROW
EXECUTE FUNCTION update_product_history();

Using Views for Complex Queries

Views can simplify complex queries and provide a layer of abstraction:

-- Create a view for product inventory status
CREATE VIEW product_inventory_status AS
SELECT 
  p.id,
  p.name,
  p.price,
  p.stock_quantity,
  c.name AS category,
  CASE 
    WHEN p.stock_quantity = 0 THEN 'Out of stock'
    WHEN p.stock_quantity < 10 THEN 'Low stock'
    WHEN p.stock_quantity < 50 THEN 'In stock'
    ELSE 'Well stocked'
  END AS stock_status
FROM 
  products p
JOIN 
  categories c ON p.category_id = c.id;

-- Query the view
SELECT * FROM product_inventory_status WHERE stock_status = 'Low stock';

Implementing Full-Text Search

PostgreSQL has powerful full-text search capabilities:

-- Add a tsvector column for full-text search
ALTER TABLE products ADD COLUMN search_vector TSVECTOR;

-- Create a function to update the search vector
CREATE OR REPLACE FUNCTION products_search_vector_update() RETURNS TRIGGER AS $$
BEGIN
  NEW.search_vector := 
    setweight(to_tsvector('english', COALESCE(NEW.name, '')), 'A') ||
    setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Create a trigger to automatically update the search vector
CREATE TRIGGER products_search_vector_update
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION products_search_vector_update();

-- Create an index for the search vector
CREATE INDEX products_search_idx ON products USING GIN (search_vector);

-- Perform a full-text search
SELECT id, name, description
FROM products
WHERE search_vector @@ to_tsquery('english', 'organic & cotton');

Row Level Security (RLS) for Data Protection

Supabase uses PostgreSQL's Row Level Security to control access to your data:

-- Enable RLS on the products table
ALTER TABLE products ENABLE ROW LEVEL SECURITY;

-- Create a policy that allows authenticated users to see all products
CREATE POLICY "Users can view all products" 
ON products FOR SELECT 
USING (true);

-- Create a policy that allows only admins to insert products
CREATE POLICY "Only admins can insert products" 
ON products FOR INSERT 
WITH CHECK (auth.jwt() ->> 'role' = 'admin');

-- Create a policy that allows only admins to update products
CREATE POLICY "Only admins can update products" 
ON products FOR UPDATE 
USING (auth.jwt() ->> 'role' = 'admin');

-- Create a policy that allows only admins to delete products
CREATE POLICY "Only admins can delete products" 
ON products FOR DELETE 
USING (auth.jwt() ->> 'role' = 'admin');

Database Performance Optimization

Indexing Strategies

Proper indexing is crucial for database performance:

-- Create a B-tree index for equality and range queries
CREATE INDEX idx_products_price ON products(price);

-- Create a multi-column index for queries that filter on multiple columns
CREATE INDEX idx_products_category_price ON products(category_id, price);

-- Create a partial index for frequently queried subsets
CREATE INDEX idx_products_active ON products(id) WHERE is_active = true;

-- Create a GIN index for array operations
CREATE INDEX idx_products_tags ON products USING GIN (tags);

Query Optimization

Writing efficient queries is essential for good performance:

-- Use EXPLAIN ANALYZE to understand query execution
EXPLAIN ANALYZE
SELECT p.name, c.name as category
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE p.price < 100;

-- Use appropriate JOINs
-- INNER JOIN when you need matches in both tables
SELECT p.name, c.name
FROM products p
INNER JOIN categories c ON p.category_id = c.id;

-- LEFT JOIN when you need all products, even those without a category
SELECT p.name, c.name
FROM products p
LEFT JOIN categories c ON p.category_id = c.id;

-- Use WHERE conditions efficiently
-- This allows the use of an index on category_id
SELECT * FROM products WHERE category_id = 'some-uuid';

-- Avoid functions on indexed columns in WHERE clauses
-- Bad (can't use index efficiently):
SELECT * FROM products WHERE LOWER(name) = 'product name';
-- Good (can use index):
SELECT * FROM products WHERE name = 'Product Name';

Database Maintenance

Regular maintenance helps keep your database performing well:

-- Analyze tables to update statistics for the query planner
ANALYZE products;

-- Vacuum tables to reclaim space and update statistics
VACUUM ANALYZE products;

-- Reindex to rebuild indexes that might have become inefficient
REINDEX TABLE products;

Working with Supabase Database from Your Application

While SQL is powerful for database management, most day-to-day operations will be performed through the Supabase client libraries:

Basic CRUD Operations

// Initialize the Supabase client
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  'https://your-project-url.supabase.co',
  'your-anon-key'
);

// Create a record
const createProduct = async (product) => {
  const { data, error } = await supabase
    .from('products')
    .insert([product])
    .select();
    
  if (error) {
    console.error('Error creating product:', error);
    return null;
  }
  
  return data[0];
};

// Read records with filtering, sorting, and pagination
const getProducts = async ({ 
  category, 
  minPrice, 
  maxPrice, 
  sortBy = 'created_at', 
  sortOrder = 'desc',
  page = 1,
  pageSize = 20
}) => {
  let query = supabase
    .from('products')
    .select('*, categories(name)')
    .order(sortBy, { ascending: sortOrder === 'asc' })
    .range((page - 1) * pageSize, page * pageSize - 1);
    
  // Apply filters if provided
  if (category) {
    query = query.eq('category_id', category);
  }
  
  if (minPrice !== undefined) {
    query = query.gte('price', minPrice);
  }
  
  if (maxPrice !== undefined) {
    query = query.lte('price', maxPrice);
  }
  
  const { data, error, count } = await query;
  
  if (error) {
    console.error('Error fetching products:', error);
    return { products: [], count: 0 };
  }
  
  return { products: data, count };
};

// Update a record
const updateProduct = async (id, updates) => {
  const { data, error } = await supabase
    .from('products')
    .update(updates)
    .eq('id', id)
    .select();
    
  if (error) {
    console.error('Error updating product:', error);
    return null;
  }
  
  return data[0];
};

// Delete a record
const deleteProduct = async (id) => {
  const { error } = await supabase
    .from('products')
    .delete()
    .eq('id', id);
    
  if (error) {
    console.error('Error deleting product:', error);
    return false;
  }
  
  return true;
};

Working with Relationships

// Fetch products with their categories
const getProductsWithCategories = async () => {
  const { data, error } = await supabase
    .from('products')
    .select(`
      id,
      name,
      price,
      categories (
        id,
        name
      )
    `);
    
  if (error) {
    console.error('Error fetching products with categories:', error);
    return [];
  }
  
  return data;
};

// Fetch products with their collections (many-to-many)
const getProductsWithCollections = async () => {
  const { data, error } = await supabase
    .from('products')
    .select(`
      id,
      name,
      products_collections (
        collections (
          id,
          name
        )
      )
    `);
    
  if (error) {
    console.error('Error fetching products with collections:', error);
    return [];
  }
  
  // Transform the nested data for easier consumption
  return data.map(product => ({
    ...product,
    collections: product.products_collections.map(pc => pc.collections)
  }));
};

Calling PostgreSQL Functions

// Call a database function
const getInventoryValue = async () => {
  const { data, error } = await supabase
    .rpc('calculate_inventory_value');
    
  if (error) {
    console.error('Error calculating inventory value:', error);
    return 0;
  }
  
  return data;
};

Database Migration Strategies

As your application evolves, you'll need to update your database schema. Here are some approaches to managing migrations in Supabase:

Using the Supabase Migration CLI

Supabase provides a CLI tool for managing migrations:

# Install the Supabase CLI
npm install -g supabase

# Login to your Supabase account
supabase login

# Initialize Supabase in your project
supabase init

# Create a new migration
supabase migration new add_user_preferences

# Apply migrations to your local development environment
supabase db reset

# Deploy migrations to production
supabase db push

Manual Migration with SQL Scripts

For more complex migrations, you can write SQL scripts:

-- migration_001_add_user_preferences.sql

-- Create the user_preferences table
CREATE TABLE user_preferences (
  user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  theme TEXT NOT NULL DEFAULT 'light',
  notifications_enabled BOOLEAN NOT NULL DEFAULT true,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Enable RLS
ALTER TABLE user_preferences ENABLE ROW LEVEL SECURITY;

-- Create policies
CREATE POLICY "Users can view their own preferences" 
ON user_preferences FOR SELECT 
USING (auth.uid() = user_id);

CREATE POLICY "Users can update their own preferences" 
ON user_preferences FOR UPDATE 
USING (auth.uid() = user_id);

-- Create trigger for updated_at
CREATE TRIGGER set_user_preferences_updated_at
BEFORE UPDATE ON user_preferences
FOR EACH ROW
EXECUTE FUNCTION trigger_set_updated_at();

Backup and Recovery

Supabase automatically backs up your database, but it's good to understand the options:

Point-in-Time Recovery

Supabase allows you to restore your database to any point in time within the retention period:

  1. Go to your project dashboard
  2. Navigate to Database > Backups
  3. Select "Point in Time Recovery"
  4. Choose the date and time you want to restore to
  5. Click "Start Recovery"

Manual Backups

You can also create manual backups:

# Using the Supabase CLI
supabase db dump -f backup.sql

# Using pg_dump directly
pg_dump -h db.your-project-ref.supabase.co -U postgres -f backup.sql

Conclusion

Supabase's PostgreSQL foundation provides a robust platform for database management that goes far beyond basic CRUD operations. By leveraging advanced PostgreSQL features like functions, triggers, views, and indexes, you can build sophisticated applications with complex data requirements.

The combination of SQL power and Supabase's developer-friendly client libraries gives you the best of both worlds: the ability to perform complex database operations when needed, while still maintaining a simple, intuitive API for common tasks.

As you continue working with Supabase, remember that investing time in proper database design and optimization will pay dividends in application performance and maintainability. Whether you're building a small side project or a large-scale application, Supabase's PostgreSQL database provides the tools you need to succeed.

Share this article