Production-Grade MySQL Queries with TypeScript
Where Clauses
String Filters
const users = await prisma.user.findMany({
where: {
email: { contains: '@gmail.com' }, // MySQL: LIKE '%@gmail.com%'
name: { startsWith: 'John' }, // MySQL: LIKE 'John%'
username: { endsWith: '_admin' }, // MySQL: LIKE '%_admin'
bio: { not: null }, // MySQL: IS NOT NULL
role: { in: ['USER', 'MODERATOR'] }, // MySQL: IN (...)
status: { notIn: ['BANNED', 'SUSPENDED'] }, // MySQL: NOT IN (...)
},
});
MySQL Tip: MySQL string comparisons are case-insensitive by default with `utf8mb4_unicode_ci` collation. So `contains: 'john'` matches 'John', 'JOHN', 'john'. Add `mode: 'sensitive'` only if your MySQL collation is case-sensitive.
Numeric & Date Filters
const posts = await prisma.post.findMany({
where: {
views: { gt: 1000 }, // > 1000
likes: { gte: 50, lte: 500 }, // BETWEEN 50 AND 500
createdAt: {
gte: new Date('2027-01-01'), // After Jan 1 2027
lt: new Date('2027-07-01'),
},
},
});
Logical Operators
Prisma supports full boolean logic with composable operators. prisma.io/docs/reference/api-reference/prisma-client-reference#and
// OR — any condition matches
const users = await prisma.user.findMany({
where: {
OR: [
{ role: 'ADMIN' },
{ role: 'SUPERUSER' },
],
},
});
// AND — all conditions must match (also default behavior)
const activeAdmins = await prisma.user.findMany({
where: {
AND: [
{ role: 'ADMIN' },
{ active: true },
{ emailVerified: true },
],
},
});
// NOT — exclude matches
const nonDeleted = await prisma.user.findMany({
where: { NOT: { status: 'DELETED' } },
});
// Nested logic
const complex = await prisma.user.findMany({
where: {
AND: [
{ active: true },
{
OR: [{ role: 'ADMIN' }, { role: 'MANAGER' }],
},
],
},
});
Sorting with `orderBy`
// Single field sort
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
});
// Multi-field sort (priority order)
const posts = await prisma.post.findMany({
orderBy: [
{ published: 'desc' }, // Published first
{ createdAt: 'desc' }, // Then newest
{ title: 'asc' }, // Then alphabetically
],
});
// Sort by relation field (generates JOIN in MySQL)
const posts = await prisma.post.findMany({
orderBy: { author: { name: 'asc' } },
include: { author: { select: { name: true } } },
});
Offset Pagination
The simplest form of pagination — skip N records, take M. Offset pagination degrades at scale. On page 1000 with `limit=10`, MySQL scans and discards 9,990 rows before returning 10. Use cursor pagination for feeds or large datasets.
// GET /posts?page=2&limit=10
const page = Number(req.query.page) || 1;
const limit = Math.min(Number(req.query.limit) || 10, 100); // Cap at 100
const skip = (page - 1) * limit;
const [posts, total] = await Promise.all([
prisma.post.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' },
select: { id: true, title: true, createdAt: true },
}),
prisma.post.count(), // MySQL: SELECT COUNT(*) FROM posts
]);
res.json({
data: posts,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
hasNextPage: page < Math.ceil(total / limit),
hasPrevPage: page > 1,
},
});
Cursor-Based Pagination
Cursor pagination is O(1) regardless of page depth — perfect for infinite scroll, timelines, and large MySQL tables. prisma.io/docs/concepts/components/prisma-client/pagination#cursor-based-pagination
// GET /posts?cursor=50&limit=10
const cursor = req.query.cursor ? Number(req.query.cursor) : undefined;
const limit = 10;
const posts = await prisma.post.findMany({
take: limit + 1, // Fetch one extra to detect hasNextPage
...(cursor && {
cursor: { id: cursor },
skip: 1, // Skip the cursor record itself
}),
orderBy: { id: 'asc' }, // Must match cursor field
select: { id: true, title: true, createdAt: true },
});
const hasNextPage = posts.length > limit;
const items = hasNextPage ? posts.slice(0, limit) : posts;
const nextCursor = hasNextPage ? items[items.length - 1].id : null;
res.json({
data: items,
nextCursor,
hasNextPage,
});
Reusable Pagination Utility in TypeScript
Step 01: Seeding data
// prisma/seed.ts
import { PrismaClient } from '../src/generated/prisma';
import { PrismaMariaDb } from '@prisma/adapter-mariadb';
import dotenv from 'dotenv';
dotenv.config();
const adapter = new PrismaMariaDb({
host: process.env.DATABASE_HOST!,
user: process.env.DATABASE_USER!,
password: process.env.DATABASE_PASSWORD!,
database: process.env.DATABASE_NAME!,
connectionLimit: 5,
});
const prisma = new PrismaClient({ adapter });
async function main() {
console.log('Seeding database...');
// Upsert to avoid duplicate errors on repeated seed runs
for (let i = 1; i <= 100; i++) {
await prisma.user.upsert({
where: { email: `user${i}@example.com` },
update: {},
create: {
email: `user${i}@example.com`,
name: `User Number ${i}`,
posts: {
create: [
{ title: `Post A by user ${i}`, published: true },
{ title: `Post B by user ${i}`, published: false },
],
},
},
});
}
console.log('Seeded successfully!');
}
main()
.catch((e) => {
console.error('Seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
Step 02: Create reusable pagination utility
// src/utils/paginate.ts
interface PaginationParams {
page?: number;
limit?: number;
}
interface PaginatedResult<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
};
}
export async function paginate<T>(
model: any,
args: any,
{ page = 1, limit = 10 }: PaginationParams
): Promise<PaginatedResult<T>> {
const safePage = Math.max(1, page);
const safeLimit = Math.min(Math.max(1, limit), 100);
const skip = (safePage - 1) * safeLimit;
const [data, total] = await Promise.all([
model.findMany({ ...args, skip, take: safeLimit }),
model.count({ where: args.where }),
]);
const totalPages = Math.ceil(total / safeLimit);
return {
data,
meta: {
total,
page: safePage,
limit: safeLimit,
totalPages,
hasNextPage: safePage < totalPages,
hasPrevPage: safePage > 1,
},
};
}
Step 03: Update your route
// src/routes/users.ts
import { paginate } from '../utils/paginate';
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const result = await paginate(
prisma.user,
{
select: { id: true, email: true, name: true, createdAt: true },
orderBy: { createdAt: 'desc' },
},
{ page, limit }
);
res.json(result);
} catch (err) {
next(err);
}
});
Step 04: Enjoy
# Page 1 (first 10 users)
GET http://localhost:3000/users
# Page 2 with 10 per page
GET http://localhost:3000/users?page=2&limit=10
# Page 1 with 20 per page
GET http://localhost:3000/users?page=1&limit=20
Full-Text Search in MySQL
MySQL has native full-text search. Enable it in Prisma.
MySQL full-text search requires `previewFeatures = ["fullTextSearch"]` in schema. prisma.io/docs/concepts/components/prisma-client/full-text-search
Step 03: Update your route
// schema.prisma — enable preview feature
generator client {
provider = "prisma-client-js"
previewFeatures = ["fullTextSearch"]
}
model Post {
id Int @id @default(autoincrement())
title String @db.VarChar(255)
content String @db.Text
@@fulltext([title, content]) // MySQL FULLTEXT index
@@map("posts")
}
Step 04: Enjoy
const posts = await prisma.post.findMany({
where: {
// MySQL MATCH AGAINST full-text search
title: { search: 'prisma typescript' },
// OR across multiple fields:
content: { search: 'prisma typescript' },
},
orderBy: { _relevance: { fields: ['title', 'content'],
search: 'prisma typescript', sort: 'desc' } },
});
Explore project snapshots or discuss custom web solutions.
Premature optimization is the root of all evil, but that doesn't mean you should ignore obvious performance problems.
Thank You for Spending Your Valuable Time
I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Frequently Asked Questions
Offset pagination (`skip/take`) requires MySQL to scan and discard all skipped rows — O(n) cost that grows with offset size. Cursor pagination uses a `WHERE id > cursor` index seek — O(1) cost regardless of position. For datasets > 10k records, always use cursor pagination.
MySQL with `utf8mb4_unicode_ci` collation is case-insensitive by default. For case-sensitive search, either change your column collation to `utf8mb4_bin`, or use a raw query: `prisma.$queryRaw` with `BINARY` keyword.
Build a dynamic `where` object: `const where = query ? { OR: [{ title: { contains: query } }, { content: { contains: query } }] } : {}`. Pass this to any `findMany` call.
Dramatically disseminate real-time portals rather than top-line action items. Uniquely provide access to low-risk high-yield products without dynamic products. Progressively re-engineer low-risk high-yield ideas rather than emerging alignments.
Cap it in your utility: `const limit = Math.min(Number(req.query.limit) || 10, 100)`. This prevents `limit=99999` from hammering your MySQL server.
Comments are closed