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.
Why the pg Library?
- Mature and Stable: Actively maintained since 2010 with a focus on stability and performance
- Pure JavaScript: No external dependencies or native bindings required
- Connection Pooling: Efficiently manages database connections
- Parameterized Queries: Provides protection against SQL injection
- Async/Await Support: Integrates well with modern JavaScript
- TypeScript Support: Includes type definitions for TypeScript projects
- Wide Adoption: Used by thousands of production applications
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:
- rows: Array of row objects returned by the query
- rowCount: Number of rows affected by the query
- command: The SQL command type (SELECT, INSERT, etc.)
- fields: Metadata about the result columns
- oid: The object ID (relevant for some operations)
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.
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
- 23505: Unique constraint violation
- 23503: Foreign key constraint violation
- 42P01: Undefined table
- 42703: Undefined column
- 42601: Syntax error
- 08003/08006: Connection exceptions
- 53300: Too many connections
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:
- Set up a PostgreSQL database with a
taskstable (id, title, description, status, due_date, created_at) - Create a Node.js Express server with the
pglibrary for database connections - 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
- Add proper error handling and validation
- Test your API with a tool like Postman or curl
Activity 2: Implementing Transactions
Extend your task management API with the following features:
- Add a
projectstable (id, name, description, created_at) - Modify the
taskstable to include aproject_idforeign key - Implement an endpoint to move multiple tasks between projects using a transaction
- Add validation to ensure tasks can only be moved to valid projects
- Implement proper error handling and rollback in case of failure
Activity 3: Advanced Query Building
Implement a flexible task search feature:
- 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
- Dynamically build the SQL query based on the provided parameters
- Ensure proper parameterization for security
- Return metadata about the results (total count, pages, etc.)
Further Reading and Resources
- node-postgres Official Documentation
- PostgreSQL Official Documentation
- "PostgreSQL Development Essentials" by Manpreet Kaur and Baji Shaik
- "Effective SQL: 61 Specific Ways to Write Better SQL" by John L. Viescas
- "Node.js Design Patterns" by Mario Casciaro and Luciano Mammino