Switching to UQL
UQL runs in the same process as Drizzle, MikroORM, Mongoose, Prisma or TypeORM, so you can move one endpoint at a time: scaffold entities, run both ORMs, translate your queries, then move traffic in phases.
Step 1: Scaffold entities from the database you have
Section titled “Step 1: Scaffold entities from the database you have”The CLI writes @Entity classes from your tables, with relations from the foreign keys:
npx uql-migrate generate:from-db --output ./src/entitiesIt reads the pool from uql.config.ts. For snake_case columns and camelCase code, set a
naming strategy there; it applies to queries and DDL alike:
import { SnakeCaseNamingStrategy, type Config } from 'uql-orm';import { PgQuerierPool } from 'uql-orm/postgres';
export const pool = new PgQuerierPool( { connectionString: process.env.DATABASE_URL }, { namingStrategy: new SnakeCaseNamingStrategy() },);
export default { pool, migrationsPath: './migrations' } satisfies Config;Then run npx uql-migrate drift:check, and keep it in CI. A junction table without foreign keys arrives as a plain
entity. From here on, edit the classes and let generate:entities write
each migration. See scaffolding and
drift detection.
Step 2: Run it beside your current ORM
Section titled “Step 2: Run it beside your current ORM”The two ORMs share nothing but the database. Lower each pool’s max so both fit its connection limit. A transaction cannot span both, so move a whole unit of work at once.
Each pool call takes its own connection, as in Prisma and TypeORM.
pool.withQuerier and pool.transaction share one across statements.
The shift in mental model
Section titled “The shift in mental model”Each ORM has one habit that does not carry over. The rest is renaming.
From Prisma, there is no schema file and no prisma generate. The @Entity class is the schema, and its
decorators are type-checked against the properties.
From Drizzle, conditions are keys in an object instead of imported functions (eq(), and()), so a query is
data that can travel over the network.
From MikroORM or TypeORM, nothing is tracked and nothing flushes. A write happens only where you call one:
await pool.updateOneById(User, id, { name: 'New Name' }); // instead of user.name = ...; em.flush()From Mongoose, the operators are the same ($gte, $in, $regex,
$elemMatch, $or), and so are the update operators on a JSON column ($set with dotted
paths, $unset, $push, $pull, $addToSet). A document becomes an @Entity class. The same query runs on MongoDB
and every SQL engine, so you can move off Mongo one collection at a time, comparing both
stores’ answers before you switch, as this walkthrough does.
Translating what you already write
Section titled “Translating what you already write”Method and operator equivalents
Section titled “Method and operator equivalents”Every method below exists on the pool and on a querier, with the same arguments.
| Prisma | UQL |
|---|---|
find |
find |
find |
find |
find |
find |
count |
count |
group / aggregate |
aggregate |
create |
insert returns the id; pass { returning } for the row |
create |
insert returns an id per row |
update |
update |
update |
update |
upsert |
upsert |
delete / delete |
delete / delete |
include / nested select |
$populate |
$transaction |
pool. |
$query / $execute |
all / run |
{ contains: 'x' } |
{ $includes: 'x' }, or $iincludes for mode: 'insensitive' |
{ starts |
{ $starts / { $istarts |
{ not |
{ $nin: [...] } |
{ field: null } |
the same, or { $is |
AND / OR / NOT |
$and / $or / $not |
| Drizzle | UQL |
|---|---|
db. |
find |
db. |
$select: { id: true } |
db. |
$populate: { posts: true } |
. |
$sort: { created |
. / . |
$limit: n / $skip: n |
db.$count |
count |
. |
aggregate |
db. |
insert |
db. |
update |
db. |
delete |
. |
upsert |
db. |
pool. |
db. |
all / run |
and / or / not |
$and / $or / $not; $and is implicit between keys |
like / ilike |
{ $like } / { $ilike }, or $includes / $iincludes to skip the wildcards |
in / not |
{ $in } / { $nin } |
is / is |
{ $is / { $is |
| TypeORM | UQL |
|---|---|
find |
find |
find / find |
find / find |
count / count |
count |
relations: ['posts'], left |
$populate, with $required: true for an inner join |
create |
aggregate |
insert |
insert / insert |
save |
save, upserts when the key is set |
update |
update |
delete |
delete |
soft / restore |
delete / restore |
manager. |
pool. |
manager. |
all / run |
Between |
{ $between: [a, b] } |
Like / ILike |
{ $includes: 'x' } / { $iincludes: 'x' } |
In / Not |
{ $in: [...] } / { $nin: [...] } |
Is |
{ $is |
| MikroORM | UQL |
|---|---|
em. |
find |
em. |
find / find |
em. |
count |
populate + populate |
$populate with a $where per relation |
qb. |
aggregate |
em. + em. |
insert; there is no flush |
em. |
update / update |
em. |
delete / delete |
em. / em. |
upsert / upsert |
em. |
pool. |
em. |
all / run |
$gte, $nin, $like, $or, $elem |
same names, typed per field |
$ilike (PostgreSQL only) |
$ilike, $istarts, $iincludes on every dialect |
filters: { soft |
@Field, plus general query filters |
| Mongoose | UQL |
|---|---|
User. |
find |
User. |
find |
User. |
find |
User. |
count |
. |
$select: { id: true, name: true } |
. |
$populate: { posts: true }, as a join |
User. |
aggregate |
User. / insert |
insert / insert |
find |
update |
update |
update |
delete / delete |
delete / delete |
session. |
pool. |
{ $regex: 'x', $options: 'i' } |
{ $iincludes: 'x' } for a plain substring, { $regex: '(?i)x' } for a pattern |
{ field: { $exists: true } } |
{ field: { $ne: null } }: a column always exists, so absent is NULL |
$set: { 'settings. |
settings: { $set: { theme: 'dark' } }, the JSON column first; deeper paths stay dotted |
$push, $pull, $add, $unset on a subdocument |
the same operators on a JSON column |
$push: { tags: 'x' } / $add / $pull on an array field |
tags: { $push: ['x'] } on a JSON array column, each operator taking a list |
$inc, $mul |
{ stock: { $inc: -1 } } |
$set with upsert: true |
upsert: row is inserted, update applies on a conflict |
$current |
the value current |
find |
update |
. |
$populate: { posts: { $where, $select, $limit } }, whose $limit is per parent; Mongoose’s options. caps the total |
Model. |
aggregate |
. |
nothing: rows are plain objects |
| virtuals | @Field |
schema. |
hooks such as @Before, or a database trigger |
timestamps: true |
a base class whose created/updated take default and on |
Object |
the key’s type: keep the 24-hex text, or move to 'uuid' |
| Subdocuments and arrays of objects | a JSON column, or a relation if you filter or join on it |
In aggregate queries, grouped columns go in $group and computed ones are named in $select, and $having and $sort are checked against those names. Semantic search is a $sort with $vector.
Patterns worth seeing side by side
Section titled “Patterns worth seeing side by side”The comparison page covers every common operation; these two need more than renaming keys.
Filtering, sorting, paging
Section titled “Filtering, sorting, paging”prisma.user.findMany({ where: { age: { gte: 18 }, status: 'active', email: { contains: '@uql-orm.dev' } }, orderBy: { createdAt: 'desc' }, take: 10});import { and, gte, eq, like, desc } from 'drizzle-orm';
db.select() .from(users) .where(and( gte(users.age, 18), eq(users.status, 'active'), like(users.email, '%@uql-orm.dev%') )) .orderBy(desc(users.createdAt)) .limit(10);import { MoreThanOrEqual, Like } from 'typeorm';
manager.find(User, { where: { age: MoreThanOrEqual(18), status: 'active', email: Like('%@uql-orm.dev%') }, order: { createdAt: 'DESC' }, take: 10});em.find(User, { age: { $gte: 18 }, status: 'active', email: { $like: '%@uql-orm.dev%' }}, { orderBy: { createdAt: 'DESC' }, limit: 10});User.find({ age: { $gte: 18 }, status: 'active', email: { $regex: '@uql-orm.dev' }}) .sort({ createdAt: -1 }) .limit(10);pool.findMany(User, { $where: { age: { $gte: 18 }, status: 'active', email: { $includes: '@uql-orm.dev' } }, $sort: { createdAt: 'desc' }, $limit: 10});Atomic JSON updates
Section titled “Atomic JSON updates”Update one key of a JSON column in place:
await db.execute( `UPDATE users SET settings = jsonb_set(settings, '{theme}', '"dark"') WHERE id = 1`);await pool.updateOneById(User, 1, { settings: { $set: { theme: 'dark' } }});Rewriting the whole object would lose concurrent writes to other keys. $set, $unset, $push, $pull and
$addToSet work on every dialect (JSON / JSONB).
Migrating in phases
Section titled “Migrating in phases”Keep each old path until the new one is proven in production.
- Move one non-critical read. Run old and new side by side and log any difference.
- Build new tables and features on UQL, with
generate:entitiescreating the tables. - Move writes one entity at a time, so each table has one writer. Port the old ORM’s callbacks, cascades and validation to lifecycle hooks in the same change: nothing warns you if they go missing.
- When nothing uses the old ORM, remove it with its
.prismafile, Drizzle snapshots or data source config. Keep its migration history table if you may need to audit it.