Skip to content
NewFrom Mongoose to SQL6 min read

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:

Terminal window
npx uql-migrate generate:from-db --output ./src/entities

It 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:

uql.config.ts
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.

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.

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.

Every method below exists on the pool and on a querier, with the same arguments.

Prisma UQL
findMany({ where, select, orderBy, take, skip }) findMany(User, { $where, $select, $sort, $limit, $skip })
findFirst({ where }) findOne(User, { $where })
findUnique({ where: { id } }) findOneById(User, id)
count({ where }) count(User, { $where })
groupBy / aggregate aggregate(User, { $group, $select, $having })
create({ data }) insertOne(User, data) returns the id; pass { returning } for the row
createMany({ data }) insertMany(User, data) returns an id per row
update({ where: { id }, data }) updateOneById(User, id, data)
updateMany({ where, data }) updateMany(User, { $where }, data)
upsert({ where, create, update }) upsertOne(User, conflictPaths, data)
delete / deleteMany deleteOneById / deleteMany
include / nested select $populate
$transaction(fn) pool.transaction(fn)
$queryRaw / $executeRaw all(sql`...`) / run(sql`...`)
{ contains: 'x' } { $includes: 'x' }, or $iincludes for mode: 'insensitive'
{ startsWith: 'x' } { $startsWith: 'x' } / { $istartsWith: 'x' }
{ notIn: [...] } { $nin: [...] }
{ field: null } the same, or { $isNull: true }
AND / OR / NOT $and / $or / $not

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.

The comparison page covers every common operation; these two need more than renaming keys.

prisma.user.findMany({
where: {
age: { gte: 18 },
status: 'active',
email: { contains: '@uql-orm.dev' }
},
orderBy: { createdAt: 'desc' },
take: 10
});

Update one key of a JSON column in place:

await db.execute(
`UPDATE users SET settings = jsonb_set(settings, '{theme}', '"dark"') WHERE id = 1`
);

Rewriting the whole object would lose concurrent writes to other keys. $set, $unset, $push, $pull and $addToSet work on every dialect (JSON / JSONB).

Keep each old path until the new one is proven in production.

  1. Move one non-critical read. Run old and new side by side and log any difference.
  2. Build new tables and features on UQL, with generate:entities creating the tables.
  3. 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.
  4. When nothing uses the old ORM, remove it with its .prisma file, Drizzle snapshots or data source config. Keep its migration history table if you may need to audit it.