Custom Queries Tutorial
Create powerful custom API endpoints beyond basic CRUD operations - complex joins, aggregations, search, and batch operations
Custom Queries Tutorial
Learn how to create powerful custom API endpoints beyond basic CRUD operations using DataBridge custom queries.
Table of Contents
- Introduction
- When to Use Custom Queries
- Query Definition Syntax
- Parameter Types and Validation
- Authentication & Authorization
- Real-World Examples
- Best Practices
- Troubleshooting
Introduction
Custom queries allow you to define complex API endpoints that go beyond the standard CRUD operations generated by DataBridge. They give you full control over:
- Custom business logic - Implement complex operations across multiple models
- Aggregations & statistics - Calculate counts, averages, sums, etc.
- Search & filtering - Complex queries with dynamic conditions
- Batch operations - Create/update multiple records in one request
- Custom validations - Add business rules beyond schema constraints
When to Use Custom Queries
✅ Use Custom Queries For:
- Complex joins across multiple tables
- Aggregations and statistics (counts, averages, totals)
- Search endpoints with multiple filter options
- Batch operations (bulk create, update, delete)
- Custom business logic that doesn’t fit CRUD
- Performance-critical queries using raw SQL
- Third-party integrations (webhooks, external APIs)
❌ Use Standard CRUD For:
- Simple create, read, update, delete operations
- Single-record operations by ID
- Basic filtering and pagination
- Standard list endpoints with sorting
Example Decision:
// ❌ Don't use custom query - use standard CRUD
'getUserById': {
method: 'GET',
path: '/users/:id',
handler: async (prisma, { params }) => {
return prisma.user.findUnique({ where: { id: params.id } });
},
}
// ✅ Use custom query - complex logic
'getUserDashboard': {
method: 'GET',
path: '/users/:id/dashboard',
handler: async (prisma, { params }) => {
const [user, orders, stats] = await Promise.all([
prisma.user.findUnique({ where: { id: params.id } }),
prisma.order.findMany({ where: { userId: params.id }, take: 5 }),
prisma.order.aggregate({
where: { userId: params.id },
_count: true,
_sum: { total: true },
}),
]);
return { user, recentOrders: orders, totalSpent: stats._sum.total };
},
}
Query Definition Syntax
A custom query definition consists of these fields:
import { defineQueries } from './databridge.types';
export default defineQueries({
'queryName': {
// Required fields
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
path: '/custom/path',
handler: async (prisma, context) => { /* ... */ },
// Optional fields
description: 'Human-readable description',
tags: ['Custom', 'Analytics'],
params: { /* path parameters */ },
query: { /* query string parameters */ },
body: { /* request body schema */ },
responseType: 'UserPublic', // Use a custom schema
statusCode: 200,
auth: true,
},
});
Field Reference
| Field | Type | Required | Description |
|---|---|---|---|
method | string | ✅ | HTTP method: GET, POST, PUT, PATCH, DELETE |
path | string | ✅ | API endpoint path (must start with /) |
handler | function | ✅ | Async function that implements the query |
description | string | ❌ | Description for OpenAPI docs |
tags | string[] | ❌ | OpenAPI tags for grouping |
params | object | ❌ | Path parameter definitions |
query | object | ❌ | Query string parameter definitions |
body | object | ❌ | Request body schema |
responseType | string | ❌ | Custom schema name for response |
statusCode | number | ❌ | HTTP status code (default: 200) |
auth | boolean | ❌ | Require authentication |
Parameter Types and Validation
DataBridge uses Zod for runtime validation of request parameters.
Parameter Definition
{
paramName: {
type: 'string' | 'number' | 'boolean' | 'array',
required: boolean,
default: any,
enum: any[], // For strings only
min: number, // For numbers only
max: number, // For numbers only
pattern: string, // For strings only
}
}
Path Parameters
'getOrderDetails': {
method: 'GET',
path: '/users/:userId/orders/:orderId',
params: {
userId: {
type: 'number',
required: true,
},
orderId: {
type: 'number',
required: true,
},
},
handler: async (prisma, { params }) => {
// params.userId and params.orderId are validated as numbers
return prisma.order.findFirst({
where: {
id: params.orderId,
userId: params.userId,
},
});
},
}
Query String Parameters
'searchProducts': {
method: 'GET',
path: '/products/search',
query: {
q: {
type: 'string',
required: true,
min: 2,
},
category: {
type: 'string',
enum: ['electronics', 'clothing', 'books'],
},
minPrice: {
type: 'number',
min: 0,
default: 0,
},
maxPrice: {
type: 'number',
min: 0,
},
inStock: {
type: 'boolean',
default: false,
},
},
handler: async (prisma, { query }) => {
return prisma.product.findMany({
where: {
name: { contains: query.q },
...(query.category && { category: query.category }),
price: {
gte: query.minPrice,
...(query.maxPrice && { lte: query.maxPrice }),
},
...(query.inStock && { stock: { gt: 0 } }),
},
});
},
}
Request Body
'createOrder': {
method: 'POST',
path: '/orders',
body: {
userId: {
type: 'number',
required: true,
},
items: {
type: 'array',
required: true,
items: {
productId: { type: 'number', required: true },
quantity: { type: 'number', required: true, min: 1 },
notes: { type: 'string' },
},
},
shippingAddress: {
street: { type: 'string', required: true },
city: { type: 'string', required: true },
zipCode: { type: 'string', required: true, pattern: '^[0-9]{5}$' },
},
},
handler: async (prisma, { body }) => {
return prisma.order.create({
data: {
userId: body.userId,
items: {
create: body.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
notes: item.notes,
})),
},
shippingAddress: {
create: body.shippingAddress,
},
},
include: {
items: { include: { product: true } },
shippingAddress: true,
},
});
},
}
Authentication & Authorization
Basic Authentication
'getMyProfile': {
method: 'GET',
path: '/me/profile',
auth: true, // Requires authentication
handler: async (prisma, { user }) => {
// user is available from JWT token
return prisma.user.findUnique({
where: { id: user.id },
include: { profile: true },
});
},
}
Role-Based Authorization
'deleteUser': {
method: 'DELETE',
path: '/admin/users/:userId',
auth: true,
handler: async (prisma, { params, user }) => {
// Check if user has admin role
if (user.role !== 'ADMIN') {
throw new Error('Unauthorized: Admin role required');
}
return prisma.user.delete({
where: { id: params.userId },
});
},
}
Resource Ownership Check
'updateMyOrder': {
method: 'PUT',
path: '/me/orders/:orderId',
auth: true,
body: {
status: {
type: 'string',
enum: ['cancelled'],
},
},
handler: async (prisma, { params, body, user }) => {
// Verify order belongs to user
const order = await prisma.order.findFirst({
where: {
id: params.orderId,
userId: user.id,
},
});
if (!order) {
throw new Error('Order not found or access denied');
}
return prisma.order.update({
where: { id: params.orderId },
data: { status: body.status },
});
},
}
Real-World Examples
1. User Statistics Dashboard
'getUserStats': {
method: 'GET',
path: '/users/:userId/stats',
params: {
userId: { type: 'number', required: true },
},
query: {
period: {
type: 'string',
enum: ['7d', '30d', '90d', '1y'],
default: '30d',
},
},
handler: async (prisma, { params, query }) => {
const periodDays = {
'7d': 7,
'30d': 30,
'90d': 90,
'1y': 365,
}[query.period];
const since = new Date();
since.setDate(since.getDate() - periodDays);
const [orderCount, orderTotal, avgOrderValue, topProducts] = await Promise.all([
prisma.order.count({
where: { userId: params.userId, createdAt: { gte: since } },
}),
prisma.order.aggregate({
where: { userId: params.userId, createdAt: { gte: since } },
_sum: { total: true },
}),
prisma.order.aggregate({
where: { userId: params.userId, createdAt: { gte: since } },
_avg: { total: true },
}),
prisma.orderItem.groupBy({
by: ['productId'],
where: {
order: {
userId: params.userId,
createdAt: { gte: since },
},
},
_sum: { quantity: true },
_count: true,
orderBy: { _sum: { quantity: 'desc' } },
take: 5,
}),
]);
return {
period: query.period,
orderCount,
totalSpent: orderTotal._sum.total || 0,
averageOrderValue: avgOrderValue._avg.total || 0,
topProducts,
};
},
}
2. Advanced Search with Filters
'searchOrders': {
method: 'GET',
path: '/orders/search',
query: {
// Text search
customerEmail: { type: 'string' },
// Status filters
status: {
type: 'string',
enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled'],
},
// Date range
startDate: { type: 'string' },
endDate: { type: 'string' },
// Price range
minTotal: { type: 'number', min: 0 },
maxTotal: { type: 'number', min: 0 },
// Pagination
page: { type: 'number', min: 1, default: 1 },
limit: { type: 'number', min: 1, max: 100, default: 20 },
// Sorting
sortBy: {
type: 'string',
enum: ['createdAt', 'total', 'status'],
default: 'createdAt',
},
sortOrder: {
type: 'string',
enum: ['asc', 'desc'],
default: 'desc',
},
},
handler: async (prisma, { query }) => {
const where: any = {};
// Build dynamic where clause
if (query.customerEmail) {
where.user = {
email: { contains: query.customerEmail },
};
}
if (query.status) {
where.status = query.status;
}
if (query.startDate || query.endDate) {
where.createdAt = {};
if (query.startDate) where.createdAt.gte = new Date(query.startDate);
if (query.endDate) where.createdAt.lte = new Date(query.endDate);
}
if (query.minTotal || query.maxTotal) {
where.total = {};
if (query.minTotal) where.total.gte = query.minTotal;
if (query.maxTotal) where.total.lte = query.maxTotal;
}
const skip = (query.page - 1) * query.limit;
const [orders, total] = await Promise.all([
prisma.order.findMany({
where,
include: {
user: { select: { email: true, name: true } },
items: { include: { product: true } },
},
orderBy: { [query.sortBy]: query.sortOrder },
skip,
take: query.limit,
}),
prisma.order.count({ where }),
]);
return {
data: orders,
pagination: {
page: query.page,
limit: query.limit,
total,
pages: Math.ceil(total / query.limit),
},
};
},
}
3. Batch Create with Transactions
'createOrderWithItems': {
method: 'POST',
path: '/orders/batch',
body: {
userId: { type: 'number', required: true },
items: {
type: 'array',
required: true,
items: {
productId: { type: 'number', required: true },
quantity: { type: 'number', required: true, min: 1 },
price: { type: 'number', required: true, min: 0 },
},
},
paymentMethod: {
type: 'string',
enum: ['credit_card', 'paypal', 'bank_transfer'],
required: true,
},
},
handler: async (prisma, { body }) => {
// Use transaction to ensure all-or-nothing
return await prisma.$transaction(async (tx) => {
// Verify all products exist and have sufficient stock
for (const item of body.items) {
const product = await tx.product.findUnique({
where: { id: item.productId },
});
if (!product) {
throw new Error(`Product ${item.productId} not found`);
}
if (product.stock < item.quantity) {
throw new Error(`Insufficient stock for product ${product.name}`);
}
}
// Calculate total
const total = body.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
// Create order
const order = await tx.order.create({
data: {
userId: body.userId,
total,
paymentMethod: body.paymentMethod,
status: 'pending',
},
});
// Create order items and update stock
for (const item of body.items) {
await tx.orderItem.create({
data: {
orderId: order.id,
productId: item.productId,
quantity: item.quantity,
price: item.price,
},
});
await tx.product.update({
where: { id: item.productId },
data: { stock: { decrement: item.quantity } },
});
}
// Return order with items
return tx.order.findUnique({
where: { id: order.id },
include: {
items: { include: { product: true } },
user: true,
},
});
});
},
}
4. Aggregation Report
'getSalesReport': {
method: 'GET',
path: '/reports/sales',
auth: true,
query: {
startDate: { type: 'string', required: true },
endDate: { type: 'string', required: true },
groupBy: {
type: 'string',
enum: ['day', 'week', 'month'],
default: 'day',
},
},
handler: async (prisma, { query, user }) => {
// Admin only
if (user.role !== 'ADMIN') {
throw new Error('Unauthorized');
}
const startDate = new Date(query.startDate);
const endDate = new Date(query.endDate);
// Get all orders in date range
const orders = await prisma.order.findMany({
where: {
createdAt: {
gte: startDate,
lte: endDate,
},
status: { in: ['delivered', 'shipped'] },
},
select: {
createdAt: true,
total: true,
items: {
select: {
quantity: true,
product: {
select: {
category: true,
},
},
},
},
},
});
// Group by time period
const groupedSales: Record<string, { revenue: number; orders: number; items: number }> = {};
for (const order of orders) {
let key: string;
if (query.groupBy === 'day') {
key = order.createdAt.toISOString().split('T')[0];
} else if (query.groupBy === 'week') {
const weekNum = Math.floor(order.createdAt.getTime() / (7 * 24 * 60 * 60 * 1000));
key = `Week ${weekNum}`;
} else {
key = order.createdAt.toISOString().slice(0, 7);
}
if (!groupedSales[key]) {
groupedSales[key] = { revenue: 0, orders: 0, items: 0 };
}
groupedSales[key].revenue += order.total;
groupedSales[key].orders += 1;
groupedSales[key].items += order.items.reduce((sum, item) => sum + item.quantity, 0);
}
// Calculate category breakdown
const categoryRevenue: Record<string, number> = {};
for (const order of orders) {
for (const item of order.items) {
const category = item.product.category || 'Unknown';
categoryRevenue[category] = (categoryRevenue[category] || 0) + (order.total / order.items.length);
}
}
return {
period: {
start: query.startDate,
end: query.endDate,
groupBy: query.groupBy,
},
timeline: groupedSales,
categoryBreakdown: categoryRevenue,
totals: {
revenue: orders.reduce((sum, o) => sum + o.total, 0),
orders: orders.length,
averageOrderValue: orders.length > 0
? orders.reduce((sum, o) => sum + o.total, 0) / orders.length
: 0,
},
};
},
}
5. Export Data to CSV
'exportOrders': {
method: 'GET',
path: '/orders/export',
auth: true,
query: {
format: {
type: 'string',
enum: ['csv', 'json'],
default: 'csv',
},
startDate: { type: 'string' },
endDate: { type: 'string' },
},
handler: async (prisma, { query, user, reply }) => {
// Admin only
if (user.role !== 'ADMIN') {
throw new Error('Unauthorized');
}
const where: any = {};
if (query.startDate || query.endDate) {
where.createdAt = {};
if (query.startDate) where.createdAt.gte = new Date(query.startDate);
if (query.endDate) where.createdAt.lte = new Date(query.endDate);
}
const orders = await prisma.order.findMany({
where,
include: {
user: { select: { email: true, name: true } },
items: { include: { product: true } },
},
orderBy: { createdAt: 'desc' },
});
if (query.format === 'json') {
return orders;
}
// Generate CSV
const csv = [
'Order ID,Date,Customer Email,Customer Name,Status,Total,Items',
...orders.map(order => [
order.id,
order.createdAt.toISOString(),
order.user.email,
order.user.name,
order.status,
order.total,
order.items.length,
].join(',')),
].join('\n');
reply.header('Content-Type', 'text/csv');
reply.header('Content-Disposition', 'attachment; filename="orders.csv"');
return csv;
},
}
6. Webhook Handler
'stripeWebhook': {
method: 'POST',
path: '/webhooks/stripe',
// No validation - Stripe sends raw body
handler: async (prisma, { request, reply }) => {
const signature = request.headers['stripe-signature'];
const rawBody = await request.raw(); // Get raw request body
// Verify webhook signature (pseudo-code)
// const event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
// Handle different event types
const event = JSON.parse(rawBody.toString());
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object;
// Update order status
await prisma.order.updateMany({
where: { paymentIntentId: paymentIntent.id },
data: { status: 'paid' },
});
break;
case 'payment_intent.payment_failed':
const failedPayment = event.data.object;
await prisma.order.updateMany({
where: { paymentIntentId: failedPayment.id },
data: { status: 'payment_failed' },
});
break;
}
return { received: true };
},
}
7. GraphQL-Style Nested Query
'getOrderWithEverything': {
method: 'GET',
path: '/orders/:orderId/full',
params: {
orderId: { type: 'number', required: true },
},
query: {
// Allow client to specify what to include
includeUser: { type: 'boolean', default: true },
includeItems: { type: 'boolean', default: true },
includeShipping: { type: 'boolean', default: false },
includePayment: { type: 'boolean', default: false },
},
handler: async (prisma, { params, query }) => {
const include: any = {};
if (query.includeUser) {
include.user = {
select: {
id: true,
email: true,
name: true,
// Don't include sensitive data
},
};
}
if (query.includeItems) {
include.items = {
include: {
product: {
select: {
id: true,
name: true,
price: true,
imageUrl: true,
},
},
},
};
}
if (query.includeShipping) {
include.shippingAddress = true;
}
if (query.includePayment) {
include.payment = {
select: {
method: true,
status: true,
// Don't include card details
},
};
}
return prisma.order.findUnique({
where: { id: params.orderId },
include,
});
},
}
8. Scheduled Task Endpoint
'sendOrderReminders': {
method: 'POST',
path: '/cron/order-reminders',
auth: true,
handler: async (prisma, { user }) => {
// Verify this is a system/cron user
if (user.role !== 'SYSTEM') {
throw new Error('Unauthorized');
}
// Find abandoned carts (orders pending for > 24 hours)
const oneDayAgo = new Date();
oneDayAgo.setDate(oneDayAgo.getDate() - 1);
const abandonedOrders = await prisma.order.findMany({
where: {
status: 'pending',
createdAt: { lte: oneDayAgo },
reminderSent: false,
},
include: {
user: { select: { email: true, name: true } },
items: { include: { product: true } },
},
});
// Send reminder emails
const emailsSent: number[] = [];
for (const order of abandonedOrders) {
// Send email (pseudo-code)
// await sendEmail({
// to: order.user.email,
// subject: 'Complete your order',
// template: 'abandoned-cart',
// data: { order },
// });
// Mark reminder as sent
await prisma.order.update({
where: { id: order.id },
data: { reminderSent: true },
});
emailsSent.push(order.id);
}
return {
processed: abandonedOrders.length,
emailsSent,
};
},
}
9. Soft Delete Pattern
'deleteProduct': {
method: 'DELETE',
path: '/products/:productId',
auth: true,
params: {
productId: { type: 'number', required: true },
},
query: {
// Allow hard delete for admins
hard: { type: 'boolean', default: false },
},
handler: async (prisma, { params, query, user }) => {
if (query.hard) {
// Admin only for hard deletes
if (user.role !== 'ADMIN') {
throw new Error('Unauthorized: Admin role required for hard delete');
}
return prisma.product.delete({
where: { id: params.productId },
});
}
// Soft delete - mark as deleted
return prisma.product.update({
where: { id: params.productId },
data: {
deletedAt: new Date(),
active: false,
},
});
},
}
10. Health Check with Database Stats
'healthCheck': {
method: 'GET',
path: '/health',
handler: async (prisma) => {
try {
// Check database connection
await prisma.$queryRaw`SELECT 1`;
// Get some stats
const [userCount, productCount, orderCount] = await Promise.all([
prisma.user.count(),
prisma.product.count(),
prisma.order.count(),
]);
return {
status: 'healthy',
timestamp: new Date().toISOString(),
database: {
connected: true,
stats: {
users: userCount,
products: productCount,
orders: orderCount,
},
},
uptime: process.uptime(),
memory: {
used: process.memoryUsage().heapUsed / 1024 / 1024,
total: process.memoryUsage().heapTotal / 1024 / 1024,
},
};
} catch (error) {
return {
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString(),
};
}
},
}
Best Practices
1. Input Validation
Always validate all user input:
// ✅ Good - explicit validation
query: {
page: { type: 'number', min: 1, default: 1 },
limit: { type: 'number', min: 1, max: 100, default: 20 },
sortOrder: { type: 'string', enum: ['asc', 'desc'], default: 'asc' },
}
// ❌ Bad - no validation
query: {
page: { type: 'number' }, // Could be negative or 0
limit: { type: 'number' }, // Could be 999999
sortOrder: { type: 'string' }, // Could be 'DROP TABLE'
}
2. Error Handling
Provide meaningful error messages:
// ✅ Good - specific errors
if (!order) {
throw new Error('Order not found');
}
if (order.userId !== user.id) {
throw new Error('You do not have permission to view this order');
}
// ❌ Bad - generic errors
if (!order || order.userId !== user.id) {
throw new Error('Error');
}
3. Performance
Optimize database queries:
// ✅ Good - single query with includes
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
user: true,
items: { include: { product: true } },
},
});
// ❌ Bad - N+1 query problem
const order = await prisma.order.findUnique({ where: { id: orderId } });
const user = await prisma.user.findUnique({ where: { id: order.userId } });
const items = await prisma.orderItem.findMany({ where: { orderId } });
for (const item of items) {
item.product = await prisma.product.findUnique({ where: { id: item.productId } });
}
4. Security
Never trust user input:
// ✅ Good - sanitize and validate
handler: async (prisma, { query }) => {
const email = query.email.trim().toLowerCase();
if (!email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
throw new Error('Invalid email format');
}
// ... proceed with query
}
// ❌ Bad - raw user input in query
handler: async (prisma, { query }) => {
return prisma.$queryRaw`SELECT * FROM users WHERE email = '${query.email}'`;
}
5. Transactions
Use transactions for multi-step operations:
// ✅ Good - transaction ensures atomicity
return await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: orderData });
await tx.product.update({
where: { id: productId },
data: { stock: { decrement: quantity } },
});
return order;
});
// ❌ Bad - no transaction, can leave inconsistent state
const order = await prisma.order.create({ data: orderData });
await prisma.product.update({
where: { id: productId },
data: { stock: { decrement: quantity } },
}); // If this fails, order is created but stock not updated!
6. Pagination
Always paginate large result sets:
// ✅ Good - paginated
query: {
page: { type: 'number', min: 1, default: 1 },
limit: { type: 'number', min: 1, max: 100, default: 20 },
},
handler: async (prisma, { query }) => {
const skip = (query.page - 1) * query.limit;
return prisma.product.findMany({
skip,
take: query.limit,
});
}
// ❌ Bad - could return millions of records
handler: async (prisma) => {
return prisma.product.findMany(); // No limit!
}
Troubleshooting
Error: Path must start with /
❌ Invalid Query Path
Query 'myQuery' has invalid path: api/users
Solution: All paths must start with a forward slash:
path: '/api/users' // ✅ Correct
path: 'api/users' // ❌ Wrong
Error: Undeclared path parameter
❌ Undeclared Path Parameter
Query 'getOrder' uses path parameter ':userId' but it's not declared
Solution: Declare all path parameters:
path: '/users/:userId/orders',
params: {
userId: { type: 'number', required: true }, // ✅ Declared
}
Error: Invalid parameter type
❌ Invalid Parameter Type
Parameter 'id' has invalid type: integer
Solution: Use valid types: string, number, boolean, array:
params: {
id: { type: 'number' }, // ✅ Correct
id: { type: 'integer' }, // ❌ Wrong
}
Error: enum can only be used with string type
❌ Invalid Parameter Enum
Parameter 'status' uses enum with non-string type
Solution: Only use enum with string type:
query: {
status: {
type: 'string', // ✅ Must be string
enum: ['active', 'inactive'],
},
}
Error: min/max can only be used with number type
❌ Invalid Parameter Range
Parameter 'name' uses min/max with non-number type
Solution: Only use min/max with number type:
query: {
age: {
type: 'number', // ✅ Must be number
min: 0,
max: 120,
},
}
Next Steps
- Learn about Custom Schemas to transform query responses
- Check out the API Reference for complete type definitions
- See Getting Started Guide for project setup
Need help? Open an issue on GitHub
Was this page helpful?
Thank you for your feedback!