finalize pagination endpoint

This commit is contained in:
Nate Kelley 2025-07-16 09:18:05 -06:00
parent b637bf356a
commit d9ed8b1423
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
5 changed files with 103 additions and 522 deletions

View File

@ -12,14 +12,12 @@ const app = new Hono().get(
zValidator('query', GetUserToOrganizationRequestSchema),
async (c) => {
const { id: userId } = c.get('busterUser');
const { page, page_size, filters } = c.req.valid('query');
console.log(page_size, c.req.query());
const options = c.req.valid('query');
try {
const result: GetUserToOrganizationResponse = await getUserToOrganization({
userId,
page,
page_size,
filters,
...options,
});
return c.json(result);

View File

@ -1,262 +0,0 @@
# Pagination Utilities
This directory contains reusable pagination utilities for Drizzle ORM queries.
## Overview
The pagination utilities provide a consistent way to add offset-based pagination to your database queries. They handle:
- Page calculation and offset computation
- Total count queries
- Consistent response format
- Type safety
## Recommended Usage
### Primary Method: `withPaginationMeta` (Handles Count Automatically)
This is the **recommended approach** for most use cases as it handles the count query for you:
```typescript
import { eq, desc } from 'drizzle-orm';
import { db } from '@/database';
import { users } from '@/schema';
import { withPaginationMeta } from '@/queries/shared-types';
const result = await withPaginationMeta({
query: db
.select()
.from(users)
.where(eq(users.active, true))
.$dynamic(),
orderBy: desc(users.createdAt),
page: 2,
page_size: 10,
countFrom: users,
countWhere: eq(users.active, true), // Optional: same conditions as main query
});
// Result shape:
// {
// data: User[],
// pagination: {
// page: number,
// page_size: number,
// total: number, // Automatically calculated
// total_pages: number // Automatically calculated
// }
// }
```
### Simple Pagination: `withPagination` (No Count Query)
Use this when you only need paginated results without metadata:
```typescript
import { db } from '@/database';
import { users } from '@/schema';
import { withPagination } from '@/queries/shared-types';
// Your base query needs to use .$dynamic()
const query = db.select().from(users).$dynamic();
// Apply pagination
const paginatedQuery = withPagination(query, users.createdAt, 2, 10);
const results = await paginatedQuery;
// Note: This only returns the data, no pagination metadata
```
### Special Cases: `createPaginatedResponse` (Manual Count)
Only use this when you:
- Already have the total count from another source
- Need to transform data after querying
- Are working with non-database data
```typescript
import { createPaginatedResponse } from '@/queries/shared-types';
// Example: When you already have count from elsewhere
const transformedData = existingData.map(item => ({
...item,
displayName: item.name || 'Unnamed'
}));
const response = createPaginatedResponse({
data: transformedData,
page: 1,
page_size: 10,
total: existingTotalCount, // You must provide this
});
```
## API Reference
### Types
#### `PaginationInput`
```typescript
interface PaginationInput {
page?: number; // Current page (default: 1)
page_size?: number; // Items per page (default: 250, max: 1000)
}
```
#### `PaginationMetadata`
```typescript
interface PaginationMetadata {
page: number; // Current page
page_size: number; // Items per page
total: number; // Total number of items
total_pages: number; // Total number of pages
}
```
#### `PaginatedResponse<T>`
```typescript
interface PaginatedResponse<T> {
data: T[];
pagination: PaginationMetadata;
}
```
### Functions
#### `withPaginationMeta(options)` ⭐ Recommended
Executes a paginated query and automatically handles the count query.
#### `withPagination(query, orderBy, page?, pageSize?)`
Adds pagination to a dynamic query without executing it or counting.
#### `createPaginatedResponse(options)`
Creates a paginated response when you already have the total count.
#### `createPaginationMetadata(options)`
Creates just the pagination metadata from count and page info.
## Best Practices
1. **Use `withPaginationMeta` by default**: It handles counting automatically
2. **Always use ordering**: Pagination without ordering can lead to inconsistent results
3. **Match WHERE conditions**: When using `countWhere`, ensure it matches your query's WHERE clause
4. **Consider performance**: For very large tables, consider cursor-based pagination
5. **Set reasonable limits**: Default page size is 250, max is 1000 to prevent performance issues
## Common Patterns
### Using the Composable Approach with JOINs (Recommended)
The `buildPaginationQueries` helper ensures your count query has the same structure as your data query:
```typescript
import { and, eq, isNull, asc } from 'drizzle-orm';
import {
buildPaginationQueries,
withPaginationMeta
} from '@/queries/shared-types';
// Define your WHERE condition once
const whereCondition = and(
eq(usersToOrganizations.organizationId, orgId),
isNull(usersToOrganizations.deletedAt)
);
// Build matching queries with the same structure
const { dataQuery, buildCountQuery } = buildPaginationQueries({
select: {
id: users.id,
name: users.name,
email: users.email,
role: usersToOrganizations.role,
status: usersToOrganizations.status,
},
from: users,
joins: [
{
type: 'inner',
table: usersToOrganizations,
on: eq(users.id, usersToOrganizations.userId),
}
],
where: whereCondition,
});
// Use withPaginationMeta to execute both queries
const result = await withPaginationMeta({
query: dataQuery,
buildCountQuery,
orderBy: asc(users.name),
page: 1,
page_size: 20,
});
```
### API Endpoint with Automatic Count
```typescript
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const page_size = parseInt(searchParams.get('per_page') || '25');
// This handles everything for you
const result = await withPaginationMeta({
query: db.select().from(users).$dynamic(),
orderBy: users.createdAt,
page,
page_size,
countFrom: users,
});
return Response.json(result);
}
```
### With Filters
```typescript
const activeUsers = await withPaginationMeta({
query: db
.select()
.from(users)
.where(and(
eq(users.active, true),
like(users.email, '%@company.com')
))
.$dynamic(),
orderBy: users.email,
page,
page_size,
countFrom: users,
countWhere: and( // Same conditions as query
eq(users.active, true),
like(users.email, '%@company.com')
),
});
```
## Migration Guide
If you're updating existing code:
1. **Replace manual pagination logic** with `withPaginationMeta`:
```typescript
// Before: Manual offset/limit and count
const offset = (page - 1) * pageSize;
const data = await db.select().from(users).limit(pageSize).offset(offset);
const [{ count }] = await db.select({ count: count() }).from(users);
// After: Automatic with withPaginationMeta
const result = await withPaginationMeta({
query: db.select().from(users).$dynamic(),
orderBy: users.id,
page,
page_size: pageSize,
countFrom: users,
});
```
2. **Add `.$dynamic()`** to your query builder
3. **Update response types** to use `PaginatedResponse<T>`
See `pagination.example.ts` for more complete examples.

View File

@ -15,14 +15,18 @@ import type { PaginatedResponse, PaginationInput, PaginationMetadata } from './p
*/
export function withPagination<T extends PgSelect>(
qb: T,
orderByColumn: PgColumn | SQL | SQL.Aliased,
orderByColumn?: PgColumn | SQL | SQL.Aliased | null,
page = 1,
pageSize = 250
) {
return qb
.orderBy(orderByColumn)
.limit(pageSize)
.offset((page - 1) * pageSize);
let query = qb;
// Only apply orderBy if orderByColumn is provided
if (orderByColumn) {
query = query.orderBy(orderByColumn);
}
return query.limit(pageSize).offset((page - 1) * pageSize);
}
/**
@ -38,7 +42,7 @@ export function withPagination<T extends PgSelect>(
* });
* ```
*/
export function createPaginationMetadata({
function createPaginationMetadata({
total,
page,
page_size,
@ -95,184 +99,3 @@ export function createPaginatedResponse<T>({
pagination: createPaginationMetadata({ total, page, page_size }),
};
}
/**
* Executes a paginated query and returns results with pagination metadata.
* This version properly handles queries with JOINs by requiring a separate count query builder.
*
* @example
* ```typescript
* // Simple query
* const result = await withPaginationMeta({
* query: db.select().from(users).where(eq(users.active, true)).$dynamic(),
* buildCountQuery: () => db.select({ count: count() }).from(users).where(eq(users.active, true)),
* orderBy: users.createdAt,
* page: 2,
* page_size: 10,
* });
*
* // Query with JOIN
* const whereCondition = and(
* eq(usersToOrganizations.organizationId, orgId),
* isNull(usersToOrganizations.deletedAt)
* );
*
* const result = await withPaginationMeta({
* query: db
* .select()
* .from(users)
* .innerJoin(usersToOrganizations, eq(users.id, usersToOrganizations.userId))
* .where(whereCondition)
* .$dynamic(),
* buildCountQuery: () => db
* .select({ count: count() })
* .from(users)
* .innerJoin(usersToOrganizations, eq(users.id, usersToOrganizations.userId))
* .where(whereCondition),
* orderBy: users.name,
* page: 1,
* page_size: 20,
* });
* ```
*/
export async function withPaginationMeta<T extends PgSelect>({
query,
buildCountQuery,
orderBy,
page = 1,
page_size = 250,
}: {
query: T;
buildCountQuery: () => PgSelect | Promise<{ count: number }[]>;
orderBy: PgColumn | SQL | SQL.Aliased;
page?: number;
page_size?: number;
}): Promise<PaginatedResponse<Awaited<ReturnType<T['execute']>>[number]>> {
// Apply pagination to the query
const paginatedQuery = withPagination(query, orderBy, page, page_size);
// Execute both queries in parallel for better performance
const [results, countResult] = await Promise.all([
// Execute the paginated query
paginatedQuery,
// Execute the count query
buildCountQuery(),
]);
const total = Number((countResult as any)[0]?.count || 0);
const total_pages = Math.ceil(total / page_size);
return {
data: results,
pagination: {
page,
page_size,
total,
total_pages,
},
};
}
/**
* Helper function to build matching data and count queries with the same structure.
* This ensures your WHERE conditions and JOINs are consistent between both queries.
*
* @example
* ```typescript
* // Build matching queries for pagination
* const whereCondition = and(
* eq(usersToOrganizations.organizationId, orgId),
* isNull(usersToOrganizations.deletedAt)
* );
*
* const { dataQuery, buildCountQuery } = buildPaginationQueries({
* select: {
* id: users.id,
* name: users.name,
* email: users.email,
* avatarUrl: users.avatarUrl,
* role: usersToOrganizations.role,
* status: usersToOrganizations.status,
* },
* from: users,
* joins: [
* {
* type: 'inner',
* table: usersToOrganizations,
* on: eq(users.id, usersToOrganizations.userId)
* }
* ],
* where: whereCondition,
* });
*
* // Use with withPaginationMeta
* const result = await withPaginationMeta({
* query: dataQuery,
* buildCountQuery,
* orderBy: users.name,
* page: 1,
* page_size: 20,
* });
* ```
*/
export function buildPaginationQueries<
TSelect extends Record<string, PgColumn | SQL | SQL.Aliased>,
>({
select,
from,
joins = [],
where,
}: {
select: TSelect;
from: PgTable<TableConfig>;
joins?: Array<{
type: 'inner' | 'left' | 'right' | 'full';
table: PgTable<TableConfig>;
on: SQL;
}>;
where?: SQL;
}) {
// Function to apply joins to a query
const applyJoins = (baseQuery: any) => {
let query = baseQuery;
for (const join of joins) {
switch (join.type) {
case 'inner':
query = query.innerJoin(join.table, join.on);
break;
case 'left':
query = query.leftJoin(join.table, join.on);
break;
case 'right':
query = query.rightJoin(join.table, join.on);
break;
case 'full':
query = query.fullJoin(join.table, join.on);
break;
}
}
return query;
};
// Build the data query
let dataQuery = db.select(select).from(from);
dataQuery = applyJoins(dataQuery);
if (where) {
dataQuery = dataQuery.where(where) as any;
}
// Build the count query function
const buildCountQuery = () => {
let countQuery = db.select({ count: count() }).from(from);
countQuery = applyJoins(countQuery);
if (where) {
countQuery = countQuery.where(where) as any;
}
return countQuery;
};
return {
dataQuery: dataQuery.$dynamic(),
buildCountQuery,
};
}

View File

@ -1,13 +1,11 @@
import { type InferSelectModel, and, asc, count, eq, isNull, like } from 'drizzle-orm';
import { type InferSelectModel, SQL, and, asc, count, eq, isNull, like } from 'drizzle-orm';
import { PgColumn } from 'drizzle-orm/pg-core';
import { z } from 'zod';
import { db } from '../../connection';
import { users, usersToOrganizations } from '../../schema';
import { getUserOrganizationId } from '../organizations/organizations';
import {
type PaginatedResponse,
buildPaginationQueries,
withPaginationMeta,
} from '../shared-types';
import { type PaginatedResponse, createPaginatedResponse } from '../shared-types';
import { withPagination } from '../shared-types/with-pagination';
type RawOrganizationUser = InferSelectModel<typeof usersToOrganizations>;
type RawUser = InferSelectModel<typeof users>;
@ -17,78 +15,107 @@ const GetUserToOrganizationInputSchema = z.object({
userId: z.string().uuid('User ID must be a valid UUID'),
page: z.number().optional().default(1),
page_size: z.number().optional().default(250),
filters: z
.object({
userName: z.string().optional(),
email: z.string().optional(),
})
.optional(),
user_name: z.string().optional(),
email: z.string().optional(),
});
export type GetUserToOrganizationInput = z.infer<typeof GetUserToOrganizationInputSchema>;
type GetUserToOrganizationInput = z.infer<typeof GetUserToOrganizationInputSchema>;
export type OrganizationUser = Pick<RawUser, 'id' | 'name' | 'email' | 'avatarUrl'> &
type OrganizationUser = Pick<RawUser, 'id' | 'name' | 'email' | 'avatarUrl'> &
Pick<RawOrganizationUser, 'role' | 'status'>;
export type GetUserToOrganizationResult = PaginatedResponse<OrganizationUser>;
export const getUserToOrganization = async ({
userId,
page = 1,
page_size = 250,
filters,
}: GetUserToOrganizationInput): Promise<GetUserToOrganizationResult> => {
// Validate input
const validated = GetUserToOrganizationInputSchema.parse({ userId, page, page_size, filters });
// Get the user's organization ID
const userOrg = await getUserOrganizationId(validated.userId);
if (!userOrg) {
throw new Error('User not found in any organization');
}
// Build the complete where condition
const whereCondition = and(
eq(usersToOrganizations.organizationId, userOrg.organizationId),
// Helper function to build the WHERE condition for user organization queries
function buildUserOrgWhereCondition(
organizationId: string,
filters?: Pick<GetUserToOrganizationInput, 'user_name' | 'email'>
) {
return and(
eq(usersToOrganizations.organizationId, organizationId),
isNull(usersToOrganizations.deletedAt),
validated.filters?.userName ? like(users.name, `%${validated.filters.userName}%`) : undefined,
validated.filters?.email ? like(users.email, `%${validated.filters.email}%`) : undefined
filters?.user_name ? like(users.name, `%${filters.user_name}%`) : undefined,
filters?.email ? like(users.email, `%${filters.email}%`) : undefined
);
}
// Helper function to build the base query with joins
function buildUserOrgBaseQuery<T extends Record<string, PgColumn | SQL>>(selectColumns: T) {
return db
.select(selectColumns)
.from(users)
.innerJoin(usersToOrganizations, eq(users.id, usersToOrganizations.userId));
}
// Helper function to get the total count of users in an organization
async function getUserToOrganizationTotal(
organizationId: string,
filters?: Pick<GetUserToOrganizationInput, 'user_name' | 'email'>
): Promise<number> {
try {
const query = buildUserOrgBaseQuery({ count: count() }).where(
buildUserOrgWhereCondition(organizationId, filters)
);
const result = await query;
return result[0]?.count ?? 0;
} catch (error) {
console.error(error);
return 0;
}
}
export const getUserToOrganization = async (
params: GetUserToOrganizationInput
): Promise<PaginatedResponse<OrganizationUser>> => {
// Validate input
const { user_name, email, page, page_size, userId } =
GetUserToOrganizationInputSchema.parse(params);
const filters = {
user_name,
email,
};
// Get the user's organization ID
const { organizationId } = await getUserOrganizationId(userId)
.then((userOrg) => {
if (!userOrg || !userOrg.organizationId) {
throw new Error('User not found in any organization');
}
return { organizationId: userOrg.organizationId };
})
.catch((error) => {
console.error(error);
throw new Error('Error fetching user organization');
});
try {
// Use the new composable approach to build matching queries
const { dataQuery, buildCountQuery } = buildPaginationQueries({
select: {
// Build and execute the data query using shared helpers
const dataQuery = withPagination(
buildUserOrgBaseQuery({
id: users.id,
name: users.name,
email: users.email,
avatarUrl: users.avatarUrl,
role: usersToOrganizations.role,
status: usersToOrganizations.status,
},
from: users,
joins: [
{
type: 'inner',
table: usersToOrganizations,
on: eq(users.id, usersToOrganizations.userId),
},
],
...(whereCondition && { where: whereCondition }),
});
})
.where(buildUserOrgWhereCondition(organizationId, filters))
.$dynamic(),
asc(users.name),
page,
page_size
);
// Use withPaginationMeta to handle pagination and counting
const result = await withPaginationMeta({
query: dataQuery,
buildCountQuery,
orderBy: asc(users.name),
page: validated.page,
page_size: validated.page_size,
});
// Execute queries in parallel for better performance
const [data, total] = await Promise.all([
dataQuery,
getUserToOrganizationTotal(organizationId, filters),
]);
// Return the result directly - it already has the correct format
return result;
// Use the simple createPaginatedResponse helper
return createPaginatedResponse({
data,
page,
page_size,
total,
});
} catch (error) {
console.error(error);
throw new Error('Error fetching users');

View File

@ -51,13 +51,8 @@ export type GetUserListRequest = z.infer<typeof GetUserListRequestSchema>;
export const GetUserToOrganizationRequestSchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
page_size: z.coerce.number().min(1).max(5000).optional().default(250),
filters: z
.object({
user_name: z.string().optional(),
email: z.string().optional(),
})
.default({})
.optional(),
user_name: z.string().optional(),
email: z.string().optional(),
});
export type GetUserToOrganizationRequest = z.infer<typeof GetUserToOrganizationRequestSchema>;