Using the pg Library

Integrating PostgreSQL with Node.js Applications

Working with PostgreSQL in Node.js

In our previous lecture, we set up PostgreSQL and explored its features. Today, we'll dive deep into how to interact with PostgreSQL from a Node.js application using the pg library—the most widely used and well-maintained PostgreSQL client for Node.js.

Think of the pg library as a translator between your JavaScript code and the PostgreSQL database. It allows your application to speak the database's language, sending queries and receiving results in a way that integrates smoothly with Node.js's asynchronous paradigm.

flowchart LR A[Node.js Application] --> B[pg Library] B -- "SQL Queries" --> C[(PostgreSQL Database)] C -- "Results" --> B B -- "JavaScript Objects" --> A

Why the pg Library?

Setting Up the pg Library

Let's start by creating a new Node.js project and installing the necessary dependencies.

Project Initialization


# Create a new directory for your project
mkdir pg-node-demo
cd pg-node-demo

# Initialize a new Node.js project
npm init -y

# Install the pg library
npm install pg

# For development, install nodemon for auto-reloading
npm install --save-dev nodemon
            

Setting Up Environment Variables

It's a good practice to store database credentials in environment variables rather than hardcoding them into your application. We'll use the dotenv package to manage environment variables.


# Install dotenv
npm install dotenv
            

Create a .env file in your project root:


# .env file
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mydatabase
DB_USER=myuser
DB_PASSWORD=mypassword
            

Make sure to add .env to your .gitignore file to prevent committing sensitive credentials to version control:


# .gitignore
node_modules/
.env
            

Establishing Database Connections

The pg library provides two main approaches for connecting to a PostgreSQL database: Client and Pool. Understanding when to use each is crucial for building efficient applications.

Client

  • Single Connection: Represents a single client connection to the database
  • Manual Management: You need to connect and disconnect manually
  • Use When: Performing one-off operations or transactions where connection context is important

const { Client } = require('pg');

async function runQuery() {
  const client = new Client({
    host: process.env.DB_HOST,
    port: process.env.DB_PORT,
    database: process.env.DB_NAME,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
  });

  try {
    await client.connect();
    const res = await client.query('SELECT NOW()');
    console.log('PostgreSQL time:', res.rows[0].now);
    return res.rows;
  } catch (err) {
    console.error('Database query error:', err);
    throw err;
  } finally {
    // Always disconnect when done
    await client.end();
  }
}
                    

Pool

  • Multiple Connections: Manages a pool of client connections
  • Automatic Management: Handles connection acquisition and release
  • Reuse: Efficiently reuses connections across requests
  • Use When: Building web applications or services that handle multiple concurrent requests

const { Pool } = require('pg');

// Create a connection pool
const pool = new Pool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  // Additional pool configuration
  max: 20, // Maximum connections in the pool
  idleTimeoutMillis: 30000, // How long a client can stay idle
  connectionTimeoutMillis: 2000, // How long to wait for a connection
});

async function queryWithPool() {
  try {
    const res = await pool.query('SELECT NOW()');
    console.log('PostgreSQL time:', res.rows[0].now);
    return res.rows;
  } catch (err) {
    console.error('Pool query error:', err);
    throw err;
  }
}

// When shutting down your application
process.on('SIGINT', async () => {
  console.log('Closing pool connections...');
  await pool.end();
  process.exit(0);
});
                    

Connection URL Format

Instead of specifying individual connection parameters, you can also use a connection string URL:


const { Pool } = require('pg');

const pool = new Pool({
  connectionString: 'postgresql://myuser:mypassword@localhost:5432/mydatabase',
  // Or from environment variable
  // connectionString: process.env.DATABASE_URL,
});
                

This format is particularly useful for cloud deployments where database connection information is often provided as a single URL string.

Creating a Database Module

It's good practice to encapsulate database connection logic in a separate module. Here's how you can structure a simple database module:


// db.js
require('dotenv').config();
const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
});

// Test the connection
pool.query('SELECT NOW()', (err, res) => {
  if (err) {
    console.error('Database connection error:', err.stack);
  } else {
    console.log('Connected to PostgreSQL database');
  }
});

// Export the query function
module.exports = {
  query: (text, params) => pool.query(text, params),
  
  // For transactions or when you need a dedicated client
  getClient: async () => {
    const client = await pool.connect();
    return client;
  },
  
  // Utility function to end the pool (for tests or cleanup)
  end: () => pool.end(),
};
                

With this module, you can easily execute queries from anywhere in your application:


// other-file.js
const db = require('./db');

async function getUserById(id) {
  try {
    const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
    return result.rows[0]; // Return the first user found
  } catch (err) {
    console.error('Error fetching user:', err);
    throw err;
  }
}
                

Executing SQL Queries with pg

The pg library provides several ways to execute SQL queries, from simple one-off queries to complex transactions.

Basic Query Execution


const db = require('./db');

// Using async/await
async function getAllUsers() {
  try {
    const result = await db.query('SELECT * FROM users ORDER BY created_at DESC');
    return result.rows;
  } catch (err) {
    console.error('Error fetching users:', err);
    throw err;
  }
}

// Using promises
function getActiveUsers() {
  return db.query('SELECT * FROM users WHERE is_active = TRUE')
    .then(result => result.rows)
    .catch(err => {
      console.error('Error fetching active users:', err);
      throw err;
    });
}

// Using callbacks (older style, less common now)
function getUserCount(callback) {
  db.query('SELECT COUNT(*) FROM users', (err, result) => {
    if (err) {
      return callback(err);
    }
    callback(null, parseInt(result.rows[0].count, 10));
  });
}
            

Parameterized Queries

Never concatenate user input directly into your SQL strings! Always use parameterized queries to prevent SQL injection attacks.


// UNSAFE - DO NOT DO THIS!
const userId = req.params.id;
const badQuery = `SELECT * FROM users WHERE id = ${userId}`; // SQL Injection risk!

// SAFE - Do this instead
const safeQuery = 'SELECT * FROM users WHERE id = $1';
const values = [userId];
const result = await db.query(safeQuery, values);
            

Parameters in pg are specified with $1, $2, etc., and values are passed as an array in the second argument.

Named Parameters

For complex queries with many parameters, you can use named parameters for better readability:


const { Pool } = require('pg');
const pool = new Pool(/* config */);

// Query with named parameters
async function createUser(userData) {
  const query = {
    text: 'INSERT INTO users(username, email, password_hash, first_name, last_name) VALUES($1, $2, $3, $4, $5) RETURNING *',
    values: [
      userData.username,
      userData.email,
      userData.passwordHash,
      userData.firstName,
      userData.lastName
    ]
  };
  
  const result = await pool.query(query);
  return result.rows[0];
}
                

Query Result Structure

When executing a query with pg, the result object contains several useful properties:


const result = await db.query('SELECT * FROM users LIMIT 5');

console.log('Rows:', result.rows);
console.log('Row count:', result.rowCount);
console.log('Command:', result.command);
console.log('Column info:', result.fields.map(f => f.name));
            

Common Query Types

SELECT Queries

// Basic SELECT
const getUsers = async () => {
  const result = await db.query('SELECT * FROM users');
  return result.rows;
};

// SELECT with WHERE condition
const getUserById = async (id) => {
  const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
  return result.rows[0]; // Returns undefined if user not found
};

// SELECT with JOIN
const getUserWithPosts = async (userId) => {
  const query = `
    SELECT users.id, users.username, users.email, 
           posts.id AS post_id, posts.title, posts.content
    FROM users
    LEFT JOIN posts ON users.id = posts.user_id
    WHERE users.id = $1
  `;
  const result = await db.query(query, [userId]);
  
  // Process the joined data
  if (result.rows.length === 0) {
    return null;
  }
  
  // Group posts by user
  const user = {
    id: result.rows[0].id,
    username: result.rows[0].username,
    email: result.rows[0].email,
    posts: []
  };
  
  result.rows.forEach(row => {
    if (row.post_id) {
      user.posts.push({
        id: row.post_id,
        title: row.title,
        content: row.content
      });
    }
  });
  
  return user;
};
                
INSERT Queries

// Basic INSERT
const createUser = async (user) => {
  const query = `
    INSERT INTO users (username, email, password_hash, created_at)
    VALUES ($1, $2, $3, NOW())
    RETURNING *
  `;
  const values = [user.username, user.email, user.passwordHash];
  const result = await db.query(query, values);
  return result.rows[0];
};

// INSERT multiple rows in one query
const addProducts = async (products) => {
  // Creating a parameterized query for multiple rows
  const values = [];
  const valueStrings = [];
  let counter = 1;
  
  products.forEach(product => {
    values.push(product.name, product.price, product.category);
    valueStrings.push(`($${counter}, $${counter + 1}, $${counter + 2})`);
    counter += 3;
  });
  
  const query = `
    INSERT INTO products (name, price, category)
    VALUES ${valueStrings.join(', ')}
    RETURNING *
  `;
  
  const result = await db.query(query, values);
  return result.rows;
};
                
UPDATE Queries

// Basic UPDATE
const updateUser = async (id, updates) => {
  const query = `
    UPDATE users
    SET username = $1, email = $2, updated_at = NOW()
    WHERE id = $3
    RETURNING *
  `;
  const values = [updates.username, updates.email, id];
  const result = await db.query(query, values);
  
  // Check if any row was updated
  if (result.rowCount === 0) {
    throw new Error('User not found');
  }
  
  return result.rows[0];
};

// Dynamic UPDATE query
const dynamicUpdateUser = async (id, updates) => {
  // Create a dynamic query based on the provided updates
  const setValues = [];
  const values = [];
  let paramCounter = 1;
  
  // Add each property to the SET clause
  Object.entries(updates).forEach(([key, value]) => {
    setValues.push(`${key} = $${paramCounter}`);
    values.push(value);
    paramCounter++;
  });
  
  // Add updated_at and ID
  setValues.push(`updated_at = NOW()`);
  values.push(id);
  
  const query = `
    UPDATE users
    SET ${setValues.join(', ')}
    WHERE id = $${paramCounter}
    RETURNING *
  `;
  
  const result = await db.query(query, values);
  
  if (result.rowCount === 0) {
    throw new Error('User not found');
  }
  
  return result.rows[0];
};
                
DELETE Queries

// Basic DELETE
const deleteUser = async (id) => {
  const query = 'DELETE FROM users WHERE id = $1 RETURNING *';
  const result = await db.query(query, [id]);
  
  if (result.rowCount === 0) {
    throw new Error('User not found');
  }
  
  return result.rows[0];
};

// DELETE with multiple conditions
const deleteInactiveUsers = async (olderThanDays) => {
  const query = `
    DELETE FROM users
    WHERE is_active = FALSE
    AND created_at < NOW() - INTERVAL '${olderThanDays} days'
    RETURNING *
  `;
  const result = await db.query(query);
  return result.rows;
};
                

Working with Transactions

Transactions are crucial for operations that need to be atomic—either all succeed or all fail. PostgreSQL provides robust transaction support, and the pg library makes it easy to work with transactions in Node.js.

sequenceDiagram participant App as Node.js Application participant Client as pg Client participant DB as PostgreSQL App->>Client: Get dedicated client Client->>App: Returns client App->>Client: Begin Transaction Client->>DB: BEGIN Note over App,DB: Series of operations App->>Client: Execute Query 1 Client->>DB: Query 1 DB->>Client: Result 1 Client->>App: Result 1 App->>Client: Execute Query 2 Client->>DB: Query 2 DB->>Client: Result 2 Client->>App: Result 2 alt Success App->>Client: Commit Transaction Client->>DB: COMMIT else Error App->>Client: Rollback Transaction Client->>DB: ROLLBACK end App->>Client: Release Client Client->>DB: Return to Pool

Basic Transaction Example


const db = require('./db');

async function transferFunds(fromAccountId, toAccountId, amount) {
  // Get a dedicated client from the pool
  const client = await db.getClient();
  
  try {
    // Start transaction
    await client.query('BEGIN');
    
    // First operation: Deduct from the source account
    const debitResult = await client.query(
      'UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1 RETURNING *',
      [amount, fromAccountId]
    );
    
    // Check if the account exists and has sufficient funds
    if (debitResult.rows.length === 0) {
      throw new Error('Insufficient funds or account not found');
    }
    
    // Second operation: Add to the destination account
    const creditResult = await client.query(
      'UPDATE accounts SET balance = balance + $1 WHERE id = $2 RETURNING *',
      [amount, toAccountId]
    );
    
    // Check if the destination account exists
    if (creditResult.rows.length === 0) {
      throw new Error('Destination account not found');
    }
    
    // Create a transfer record
    await client.query(
      'INSERT INTO transfers (from_account, to_account, amount, transferred_at) VALUES ($1, $2, $3, NOW())',
      [fromAccountId, toAccountId, amount]
    );
    
    // If all operations succeed, commit the transaction
    await client.query('COMMIT');
    
    return {
      success: true,
      source: debitResult.rows[0],
      destination: creditResult.rows[0]
    };
  } catch (err) {
    // If any operation fails, roll back the transaction
    await client.query('ROLLBACK');
    console.error('Transaction failed:', err);
    throw err;
  } finally {
    // Always release the client back to the pool
    client.release();
  }
}
            
⚠️ Important Transaction Considerations
  • Error Handling: Always implement proper error handling with try/catch
  • Client Release: Always release the client back to the pool, even if errors occur
  • Avoiding Deadlocks: Structure your operations to access tables in a consistent order
  • Transaction Isolation: Be aware of PostgreSQL's isolation levels (READ COMMITTED is the default)
  • Performance: Keep transactions as short as possible to avoid blocking other operations

Setting Transaction Isolation Levels


async function highIsolationOperation() {
  const client = await db.getClient();
  
  try {
    await client.query('BEGIN');
    
    // Set a higher isolation level for this transaction
    await client.query('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
    
    // Perform operations...
    
    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}
            
PostgreSQL Transaction Isolation Levels
Isolation Level Dirty Read Non-repeatable Read Phantom Read Use Case
READ UNCOMMITTED
(Same as READ COMMITTED in PostgreSQL)
No Yes Yes When you need to read quickly, and consistency isn't critical
READ COMMITTED
(Default in PostgreSQL)
No Yes Yes General-purpose transactions with good performance
REPEATABLE READ No No Yes When you need to ensure data doesn't change during a transaction
SERIALIZABLE No No No For critical transactions requiring the highest level of consistency

Handling PostgreSQL Data Types

PostgreSQL supports a rich set of data types, and understanding how they map to JavaScript types is important for effective database integration.

Basic Type Conversions

PostgreSQL Type JavaScript Type Notes
INTEGER, SMALLINT, BIGINT Number JavaScript has limited precision for very large integers
NUMERIC, DECIMAL String Returned as strings to preserve precision
REAL, DOUBLE PRECISION Number Floating-point numbers
BOOLEAN Boolean Direct mapping
VARCHAR, CHAR, TEXT String Direct mapping
DATE, TIMESTAMP Date Automatically converted to JavaScript Date objects
JSON, JSONB Object, Array Automatically parsed into JavaScript objects/arrays
BYTEA Buffer Binary data
ARRAY Array Converted to JavaScript arrays

Working with Dates and Times


// Sending dates to PostgreSQL
const createEvent = async (event) => {
  const query = `
    INSERT INTO events (title, description, event_date, created_at)
    VALUES ($1, $2, $3, NOW())
    RETURNING *
  `;
  
  // JavaScript Date objects are converted automatically
  const values = [event.title, event.description, new Date(event.eventDate)];
  const result = await db.query(query, values);
  
  return result.rows[0];
};

// Parsing dates from PostgreSQL
const getRecentEvents = async () => {
  const result = await db.query(`
    SELECT * FROM events
    WHERE event_date > NOW()
    ORDER BY event_date ASC
    LIMIT 10
  `);
  
  // Date manipulation with JavaScript methods
  return result.rows.map(event => ({
    ...event,
    formattedDate: event.event_date.toLocaleDateString(),
    isToday: isToday(event.event_date)
  }));
};

function isToday(date) {
  const today = new Date();
  return date.getDate() === today.getDate() &&
    date.getMonth() === today.getMonth() &&
    date.getFullYear() === today.getFullYear();
}
            

Working with JSON Data


// Storing JSON data
const saveUserPreferences = async (userId, preferences) => {
  const query = `
    UPDATE users
    SET preferences = $1
    WHERE id = $2
    RETURNING *
  `;
  
  // JavaScript objects are automatically converted to JSON
  const values = [preferences, userId];
  const result = await db.query(query, values);
  
  return result.rows[0];
};

// Querying inside JSON data
const findUsersByTheme = async (theme) => {
  const query = `
    SELECT * FROM users
    WHERE preferences->>'theme' = $1
  `;
  
  const result = await db.query(query, [theme]);
  return result.rows;
};

// Updating nested JSON data
const updateUserTheme = async (userId, theme) => {
  const query = `
    UPDATE users
    SET preferences = jsonb_set(preferences, '{theme}', $1)
    WHERE id = $2
    RETURNING *
  `;
  
  // Need to stringify the value since jsonb_set expects a JSON string
  const values = [JSON.stringify(theme), userId];
  const result = await db.query(query, values);
  
  return result.rows[0];
};
            

Working with Arrays


// Storing array data
const createPost = async (post) => {
  const query = `
    INSERT INTO posts (title, content, tags)
    VALUES ($1, $2, $3)
    RETURNING *
  `;
  
  // JavaScript arrays are automatically converted to PostgreSQL arrays
  const values = [post.title, post.content, post.tags];
  const result = await db.query(query, values);
  
  return result.rows[0];
};

// Querying with array operations
const findPostsByTag = async (tag) => {
  const query = `
    SELECT * FROM posts
    WHERE $1 = ANY(tags)
    ORDER BY created_at DESC
  `;
  
  const result = await db.query(query, [tag]);
  return result.rows;
};

// Adding to an array
const addTagToPost = async (postId, tag) => {
  const query = `
    UPDATE posts
    SET tags = array_append(tags, $1)
    WHERE id = $2 AND NOT ($1 = ANY(tags))
    RETURNING *
  `;
  
  const values = [tag, postId];
  const result = await db.query(query, values);
  
  return result.rows[0];
};
            

Error Handling and Debugging

Proper error handling is essential when working with databases. The pg library provides detailed error information to help you diagnose and handle database errors effectively.

Common PostgreSQL Error Types

Robust Error Handling


const createUser = async (userData) => {
  try {
    const query = `
      INSERT INTO users (username, email, password_hash)
      VALUES ($1, $2, $3)
      RETURNING *
    `;
    const values = [userData.username, userData.email, userData.passwordHash];
    const result = await db.query(query, values);
    
    return result.rows[0];
  } catch (err) {
    // Check for specific error types
    if (err.code === '23505') { // Unique violation
      if (err.constraint === 'users_username_key') {
        throw new Error('Username already exists');
      } else if (err.constraint === 'users_email_key') {
        throw new Error('Email already registered');
      }
    }
    
    // Log the detailed error for debugging
    console.error('Database error:', {
      message: err.message,
      code: err.code,
      detail: err.detail,
      table: err.table,
      constraint: err.constraint,
      query: err.query,
      position: err.position
    });
    
    // Throw a general error for the client
    throw new Error('Database error occurred');
  }
};
            

Custom Error Classes

Creating custom error classes can help organize and handle different types of database errors consistently:


// errors.js
class DatabaseError extends Error {
  constructor(message, pgError) {
    super(message);
    this.name = 'DatabaseError';
    this.pgError = pgError;
    this.code = pgError ? pgError.code : null;
  }
}

class NotFoundError extends Error {
  constructor(entity, id) {
    super(`${entity} with ID ${id} not found`);
    this.name = 'NotFoundError';
    this.entity = entity;
    this.entityId = id;
  }
}

class ValidationError extends Error {
  constructor(message, fields = {}) {
    super(message);
    this.name = 'ValidationError';
    this.fields = fields;
  }
}

class UniqueConstraintError extends DatabaseError {
  constructor(field, value, pgError) {
    super(`${field} "${value}" is already in use`, pgError);
    this.name = 'UniqueConstraintError';
    this.field = field;
    this.value = value;
  }
}

module.exports = {
  DatabaseError,
  NotFoundError,
  ValidationError,
  UniqueConstraintError
};

// usage in repository
const { NotFoundError, UniqueConstraintError } = require('./errors');

const updateUser = async (id, updates) => {
  try {
    const query = `
      UPDATE users
      SET username = $1, email = $2
      WHERE id = $3
      RETURNING *
    `;
    const values = [updates.username, updates.email, id];
    const result = await db.query(query, values);
    
    if (result.rowCount === 0) {
      throw new NotFoundError('User', id);
    }
    
    return result.rows[0];
  } catch (err) {
    if (err.code === '23505') { // Unique violation
      if (err.constraint === 'users_username_key') {
        throw new UniqueConstraintError('username', updates.username, err);
      } else if (err.constraint === 'users_email_key') {
        throw new UniqueConstraintError('email', updates.email, err);
      }
    }
    // Re-throw other errors
    throw err;
  }
};

// In API routes
app.put('/api/users/:id', async (req, res) => {
  try {
    const user = await updateUser(req.params.id, req.body);
    res.json(user);
  } catch (err) {
    if (err instanceof NotFoundError) {
      return res.status(404).json({ error: err.message });
    }
    
    if (err instanceof UniqueConstraintError) {
      return res.status(400).json({ 
        error: err.message,
        field: err.field
      });
    }
    
    console.error('Unexpected error:', err);
    res.status(500).json({ error: 'An unexpected error occurred' });
  }
});
            

Best Practices for pg Library

Connection Management

  • Use connection pooling for web applications
  • Configure reasonable pool size based on your workload (typically 10-20 connections)
  • Set appropriate connection timeouts
  • Close the pool gracefully when your application shuts down
  • Use dedicated clients for transactions, then release them

Query Optimization

  • Use parameterized queries to prevent SQL injection
  • Batch operations when possible (multiple inserts in one query)
  • Use prepared statements for frequently executed queries
  • Be mindful of N+1 query problems (fetching related data for each item)
  • Use EXPLAIN ANALYZE to understand query performance

Error Handling

  • Implement proper try/catch blocks around database operations
  • Check for specific error codes to provide meaningful feedback
  • Log detailed error information for debugging
  • Release resources (like clients) in finally blocks
  • Implement retry logic for transient errors

Code Organization

  • Create a centralized database module for connection management
  • Use the repository pattern to organize database operations
  • Keep SQL queries clean and readable
  • Consider using an SQL template library for complex queries
  • Use environment variables for database configuration

Repository Pattern Example

Organizing your database operations into repositories can help maintain clean, testable code:


// userRepository.js
const db = require('../db');
const { NotFoundError, UniqueConstraintError } = require('../errors');

class UserRepository {
  async findAll() {
    const result = await db.query('SELECT * FROM users ORDER BY created_at DESC');
    return result.rows;
  }
  
  async findById(id) {
    const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
    const user = result.rows[0];
    
    if (!user) {
      throw new NotFoundError('User', id);
    }
    
    return user;
  }
  
  async findByEmail(email) {
    const result = await db.query('SELECT * FROM users WHERE email = $1', [email]);
    return result.rows[0];
  }
  
  async create(userData) {
    try {
      const query = `
        INSERT INTO users (username, email, password_hash, created_at)
        VALUES ($1, $2, $3, NOW())
        RETURNING *
      `;
      const values = [userData.username, userData.email, userData.passwordHash];
      const result = await db.query(query, values);
      
      return result.rows[0];
    } catch (err) {
      this._handleErrors(err, userData);
    }
  }
  
  async update(id, updates) {
    try {
      // Build dynamic SET clause
      const setValues = [];
      const values = [];
      let paramCounter = 1;
      
      Object.entries(updates).forEach(([key, value]) => {
        if (['username', 'email', 'first_name', 'last_name', 'bio'].includes(key)) {
          setValues.push(`${key} = $${paramCounter}`);
          values.push(value);
          paramCounter++;
        }
      });
      
      if (setValues.length === 0) {
        return this.findById(id); // Nothing to update
      }
      
      setValues.push(`updated_at = NOW()`);
      values.push(id);
      
      const query = `
        UPDATE users
        SET ${setValues.join(', ')}
        WHERE id = $${paramCounter}
        RETURNING *
      `;
      
      const result = await db.query(query, values);
      
      if (result.rowCount === 0) {
        throw new NotFoundError('User', id);
      }
      
      return result.rows[0];
    } catch (err) {
      this._handleErrors(err, updates);
    }
  }
  
  async delete(id) {
    const result = await db.query(
      'DELETE FROM users WHERE id = $1 RETURNING *',
      [id]
    );
    
    if (result.rowCount === 0) {
      throw new NotFoundError('User', id);
    }
    
    return result.rows[0];
  }
  
  // Helper for common error handling
  _handleErrors(err, userData) {
    if (err.code === '23505') { // Unique violation
      if (err.constraint === 'users_username_key') {
        throw new UniqueConstraintError('username', userData.username, err);
      }
      if (err.constraint === 'users_email_key') {
        throw new UniqueConstraintError('email', userData.email, err);
      }
    }
    
    // Re-throw other errors
    throw err;
  }
}

module.exports = new UserRepository();
            

Practical Activities

Activity 1: Setting Up a Basic CRUD API

Create a complete REST API for a task management application:

  1. Set up a PostgreSQL database with a tasks table (id, title, description, status, due_date, created_at)
  2. Create a Node.js Express server with the pg library for database connections
  3. Implement the following endpoints:
    • GET /api/tasks - List all tasks
    • GET /api/tasks/:id - Get a single task
    • POST /api/tasks - Create a new task
    • PUT /api/tasks/:id - Update a task
    • DELETE /api/tasks/:id - Delete a task
  4. Add proper error handling and validation
  5. Test your API with a tool like Postman or curl

Activity 2: Implementing Transactions

Extend your task management API with the following features:

  1. Add a projects table (id, name, description, created_at)
  2. Modify the tasks table to include a project_id foreign key
  3. Implement an endpoint to move multiple tasks between projects using a transaction
  4. Add validation to ensure tasks can only be moved to valid projects
  5. Implement proper error handling and rollback in case of failure

Activity 3: Advanced Query Building

Implement a flexible task search feature:

  1. Add a search endpoint that accepts various query parameters:
    • status (multiple values allowed)
    • dueDate (date range)
    • projectId
    • search (text search in title and description)
    • sortBy and sortDir
    • page and limit for pagination
  2. Dynamically build the SQL query based on the provided parameters
  3. Ensure proper parameterization for security
  4. Return metadata about the results (total count, pages, etc.)

Further Reading and Resources

Coming Up: Connection Pooling

In our next session, we'll dive deeper into connection pooling strategies with PostgreSQL, exploring advanced configuration, performance optimization, and monitoring techniques.