Skip to content
NewComposite primary keys5 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 instead of all at once. It holds its own pool, keeps no identity map or session, and passes plain objects both ways.

A red pill and a blue pill standing side by side on a plain background

Four moves, in this order: scaffold entities from the live database, run UQL beside what you have, translate the queries you write today, then move traffic in phases. The habits that translate badly are at the end.

Step 1: Scaffold entities from the database you have

Section titled “Step 1: Scaffold entities from the database you have”

You do not hand-write entities for tables that already exist. Point the CLI at the live database and it writes the @Entity classes, including relations inferred from the foreign keys it finds:

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

That needs a config with a pool. If your columns are snake_case and your code is camelCase, set the naming strategy here and the translation applies to both queries and generated DDL:

uql.config.ts
import { SnakeCaseNamingStrategy, type Config } from 'uql-orm';
import { PgQuerierPool } from 'uql-orm/postgres';
const pool = new PgQuerierPool(
{ connectionString: process.env.DATABASE_URL },
{ namingStrategy: new SnakeCaseNamingStrategy() },
);
export default { pool, migrationsPath: './migrations' } satisfies Config;
export { pool };

Then check the scaffold against reality before trusting it:

Terminal window
npx uql-migrate drift:check

Drift check compares the entities to the running database and reports missing tables and columns, type mismatches, and unexpected columns. Keep it in CI: it is the same command that later tells you an entity and a legacy migration have diverged.

Introspection has one blind spot worth knowing. Relations come off foreign key constraints, so a junction table that declares none arrives as a plain entity. Indexes, by contrast, come back whole - access method, partial predicates and INCLUDE columns included. See scaffolding for what comes back, drift detection for what it compares.

This is the one time the database defines the entities. From here the arrow reverses: edit a class and generate:entities writes the migration that brings the database to match.

Nothing is shared between the two: no cache to invalidate, no session to keep consistent, no entity that can be attached to the wrong context. The cost is a second connection pool: lower each one’s max so the pair still fits inside the database’s connection limit.

The connection model itself is the one you already use. In Prisma, Sequelize, and TypeORM a plain query takes its own pooled connection, so Promise.all parallelizes. UQL’s pool does the same for reads and writes alike: pool.findMany(User, {...}) and pool.insertOne(User, {...}) each acquire, run, and release. Reach for pool.withQuerier or pool.transaction when several statements must share one connection or commit atomically.

What you cannot do is span a transaction across both ORMs. A unit of work that writes through the legacy ORM and through UQL runs as two transactions on two connections, so migrate at the boundary of a whole unit of work rather than splitting one in half.

Each source ORM has one idea you have to put down. The rest is vocabulary.

Prisma keeps the schema in a .prisma file and a generate step turns it into a client, so your model and your code are two artifacts that can disagree, and CI grows a build step.

  • Prisma: edit .prisma, run npx prisma generate, then use the generated client.
  • UQL: edit the @Entity class, then query it. The TypeScript class is the schema, and the standard decorators are checked against the properties they annotate.

From Drizzle: a declarative object instead of composed SQL

Section titled “From Drizzle: a declarative object instead of composed SQL”

Drizzle builds SQL out of functions, so every condition is an import and a complex query accumulates eq(), and(), and sql templates. UQL queries are plain JSON, which is also why they survive a trip over the network.

  • Drizzle: db.select().from(users).where(and(eq(users.id, 1), gte(users.age, 18)))
  • UQL: pool.findMany(User, { $where: { id: 1, age: { $gte: 18 } } })

From MikroORM or TypeORM: explicit mutations instead of managed state

Section titled “From MikroORM or TypeORM: explicit mutations instead of managed state”

A Unit of Work and Identity Map track loaded entities and flush changes for you. That buys convenience and costs you detached-entity errors and writes you did not ask for. UQL never tracks an object, so a mutation happens only where you call one.

  • Managed: user.name = 'New Name'; await em.flush();
  • UQL: await pool.updateOneById(User, id, { name: 'New Name' });

From Mongoose: keep the query style, gain SQL

Section titled “From Mongoose: keep the query style, gain SQL”

Mongoose filters are objects of operators ($gte, $in, $regex, $elemMatch, $or), and so are UQL’s. The vocabulary carries over almost intact. Only the target changes: a document becomes an @Entity class mapped to a table and its relations. Because the same query runs on MongoDB and every supported SQL engine, you can move off Mongo table by table instead of rewriting the data layer in one release.

  • Mongoose: User.find({ status: 'active' }).sort('-createdAt').limit(10)
  • UQL: pool.findMany(User, { $where: { status: 'active' }, $sort: { createdAt: 'desc' }, $limit: 10 })

Pick the ORM you are coming from. Every method below is on the pool and on a querier, with the same name and 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, $agg, $having })
create({ data }) insertOne(User, data) - returns the id, not the row
createMany({ data }) insertMany(User, data) - returns an id per row, on every database
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, values) / run(sql, values)
{ contains: 'x' } { $includes: 'x' }, or $iincludes for mode: 'insensitive'
{ startsWith: 'x' } { $startsWith: 'x' } / { $istartsWith: 'x' }
{ notIn: [...] } { $nin: [...] }
{ field: null } { $isNull: true }
AND / OR / NOT $and / $or / $not

Four cases where the translation is more than renaming keys. The comparison page has the same patterns with the trade-offs spelled out.

prisma.user.findMany({
where: {
age: { gte: 18 },
status: 'active',
email: { contains: '@uql-orm.dev' }
},
orderBy: { createdAt: 'desc' },
take: 10
});
const results = await prisma.user.groupBy({
by: ['status'],
_count: { status: true },
_avg: { age: true },
having: { age: { _avg: { gt: 30 } } },
orderBy: { _count: { status: 'desc' } },
take: 10,
});

Two things move in that translation. Grouped columns come out of $select into $group, and computed columns become named entries in $agg rather than operator keys (_avg) or select strings.

Everything downstream is then checked against those names: $having and $sort accept only grouped columns and $agg aliases, so a typo is a compile error and the result rows are typed. $where runs through the same filter engine as findMany, so soft-delete and tenant filters keep applying past a GROUP BY. Full reference: aggregate queries.

Changing one key of a JSON column without reading and rewriting the whole object:

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

Read-modify-write loses concurrent writes to other keys of the same document. $set, $unset, $push, and $pull compile to each dialect’s own JSON functions; see JSON / JSONB.

Vector similarity is where a switch usually deletes code rather than moving it: the metadata filter and the similarity ranking live in one typed query, so there is no raw SQL branch and no separate vector store.

const results = await prisma.$queryRaw`
SELECT id, title FROM "Article"
WHERE category = 'docs'
ORDER BY embedding <=> ${queryEmbedding}::vector
LIMIT 5
`;

That query runs unchanged on pgvector, CockroachDB, MariaDB, SQLite, and MongoDB Atlas. Prisma and TypeORM leave the type-safe API for raw SQL here, Drizzle and MikroORM use PostgreSQL-only pgvector helpers, and Mongoose needs an Atlas-specific pipeline. See AI & RAG for ingestion through retrieval.

A one-release rewrite gives you no way back. These four phases each leave the legacy path intact until the new one has proven itself.

Reimplement a single non-critical read with UQL and leave the old one running. Shadow it: run both, compare the result sets, log the differences. Plain objects in and out mean UQL cannot corrupt the other ORM’s cache or state while you do this, so the worst case is a bad response on one endpoint.

Build everything new on UQL, with uql-migrate generate:entities creating its tables from the entity classes. This exercises the whole loop - entity, migration, query, deploy - on data no existing code depends on.

Move INSERT, UPDATE, and DELETE per entity rather than per endpoint, so a given table has exactly one writer at a time. Keep the legacy write path in the codebase until that entity is verified in production. Where the legacy ORM had lifecycle callbacks, cascades, or validation hooks on the entity, port them to lifecycle hooks in the same change: they are the easiest thing to leave behind, and their absence is silent.

When no query runs through the legacy ORM, remove it from package.json along with its .prisma file, Drizzle snapshots, or data source config. Keep the old migration history table if it records applied SQL you may still need to audit. Your @Entity classes are then the only schema definition left, and drift:check in CI is what keeps them honest.

  • Implicit flushes. Coming from MikroORM or TypeORM: user.name = 'Bob' does nothing to the database. UQL never sees the assignment. Call updateOneById.
  • Looking for the schema file. Coming from Prisma: the @Entity class is the schema. If a field or relation is not on the class, it does not exist as far as UQL is concerned.
  • Running a generate step. There is no codegen. Save the file and generate a migration.
  • Embedded documents. Coming from Mongoose: nested data becomes either a JSON column or a related entity you reach with $populate. JSON for schemaless blobs, a relation for anything you filter, sort, or join across.
  • require(). UQL is ESM only, with no require() path to fall back to. Move to import, and add "type": "module" to package.json if Node runs your compiled output. See Requirements for the three tsconfig.json settings that go with it.