Switching to UQL
This guide maps the concepts you already know from Prisma, Drizzle, TypeORM, or MikroORM onto UQL, and outlines a phased strategy for migrating a production system.
The Mental Model Shift
Section titled “The Mental Model Shift”From Prisma: no schema file, no codegen
Section titled “From Prisma: no schema file, no codegen”Prisma requires a .prisma file and a generate step, which creates a gap between your code and your schema and adds a build step to CI.
- Prisma: Edit
.prisma→npx prisma generate→ use the generated client. - UQL: Edit the
@Entityclass → use the querier. The TypeScript class is the schema.
From Drizzle: declarative JSON instead of SQL construction
Section titled “From Drizzle: declarative JSON instead of SQL construction”Drizzle composes SQL from functions, so complex queries accumulate eq(), and(), and sql template calls. UQL queries are JSON objects, which also makes them transportable over the network.
- Drizzle:
db.select().from(users).where(and(eq(users.id, 1), gte(users.age, 18))) - UQL:
querier.findMany(User, { $where: { id: 1, age: { $gte: 18 } } })
From TypeORM / MikroORM: explicit mutations instead of managed state
Section titled “From TypeORM / MikroORM: explicit mutations instead of managed state”ORMs built on a Unit of Work / Identity Map track loaded entities and flush changes implicitly. That model is powerful but produces “detached entity” errors and surprise updates when an object is modified outside a managed context. UQL returns plain objects and mutates only when you ask it to.
- Managed:
user.name = 'New Name'; await em.flush();(implicit state tracking) - UQL:
await querier.updateOneById(User, id, { name: 'New Name' });(explicit mutation)
From Mongoose: keep the query style, gain SQL
Section titled “From Mongoose: keep the query style, gain SQL”Mongoose queries are objects of operators ($gte, $in, $regex, $elemMatch, $or), and so are UQL’s filters. The vocabulary carries over, so the syntax feels familiar from day one. The real shift is what the query targets: Mongoose reads documents, while UQL maps an @Entity class to a table and its relations. Because the same query runs on MongoDB and every supported SQL engine, UQL is a low-friction path off Mongo: adopt SQL incrementally instead of rewriting your data layer in one jump.
- Mongoose:
User.find({ status: 'active' }).sort('-createdAt').limit(10) - UQL:
querier.findMany(User, { $where: { status: 'active' }, $sort: { createdAt: 'desc' }, $limit: 10 })
Connection model: the same default you’re used to
Section titled “Connection model: the same default you’re used to”In Prisma, TypeORM, and Sequelize, a plain query grabs its own pooled connection, so Promise.all parallelizes. UQL’s pool reads work the same way - pool.findMany(User, {...}) acquires, runs, and releases per call. Reach for pool.withQuerier / pool.transaction when statements must share one connection or run atomically.
Pattern Translation: Before & After
Section titled “Pattern Translation: Before & After”1. Semantic Search (RAG)
Section titled “1. Semantic Search (RAG)”Retrieve the top matches by vector similarity, filtered by metadata - the core retrieval step of any RAG pipeline.
const results = await prisma.$queryRaw` SELECT id, title FROM "Article" WHERE category = 'docs' ORDER BY embedding <=> ${queryEmbedding}::vector LIMIT 5`;import { eq, cosineDistance } from 'drizzle-orm';
const results = await db.select() .from(articles) .where(eq(articles.category, 'docs')) .orderBy(cosineDistance(articles.embedding, queryEmbedding)) .limit(5);const results = await Article.aggregate([ { $vectorSearch: { index: 'embedding_index', path: 'embedding', queryVector: queryEmbedding, filter: { category: 'docs' }, numCandidates: 100, limit: 5 } }]);const results = await manager.createQueryBuilder(Article, 'article') .where('article.category = :category', { category: 'docs' }) .orderBy('article.embedding <=> :vector') .setParameter('vector', queryEmbedding) .limit(5) .getMany();import { cosineDistance } from 'pgvector/mikro-orm';
const results = await em.createQueryBuilder(Article, 'a') .where({ category: 'docs' }) .orderBy({ [cosineDistance('embedding', queryEmbedding)]: 'ASC' }) .limit(5) .getResult();const results = await querier.findMany(Article, { $where: { category: 'docs' }, $sort: { embedding: { $vector: queryEmbedding, $distance: 'cosine' } }, $limit: 5});Only UQL keeps the metadata filter and vector ranking in one type-safe JSON query that runs unchanged across pgvector, CockroachDB, MariaDB, SQLite, and MongoDB Atlas. The others are tied to one database or step outside the type-safe API: Prisma and TypeORM drop to raw SQL, Drizzle and MikroORM use Postgres-only pgvector helpers, and Mongoose needs an Atlas-specific aggregation. See AI & RAG for the full ingestion-to-retrieval walkthrough.
2. Complex Filtering & Search
Section titled “2. Complex Filtering & Search”How to filter on multiple conditions and ranges, then sort and page the results.
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);User.find({ age: { $gte: 18 }, status: 'active', email: { $regex: '@uql-orm.dev' }}) .sort({ createdAt: -1 }) .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});querier.findMany(User, { $where: { age: { $gte: 18 }, status: 'active', email: { $includes: '@uql-orm.dev' } }, $sort: { createdAt: 'desc' }, $limit: 10});3. Aggregation & Grouping
Section titled “3. Aggregation & Grouping”Group rows by a column, compute aggregates (COUNT, AVG), filter the groups with HAVING, then sort and page.
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,});import { count, avg, gt, desc } from 'drizzle-orm';
const results = await db .select({ status: users.status, count: count(), avgAge: avg(users.age), }) .from(users) .groupBy(users.status) .having(({ avgAge }) => gt(avgAge, 30)) .orderBy(desc(count())) .limit(10);const results = await User.aggregate([ { $group: { _id: '$status', count: { $sum: 1 }, avgAge: { $avg: '$age' } } }, { $match: { avgAge: { $gt: 30 } } }, { $sort: { count: -1 } }, { $limit: 10 },]);const results = await manager .createQueryBuilder(User, 'user') .select('user.status', 'status') .addSelect('COUNT(*)', 'count') .addSelect('AVG(user.age)', 'avgAge') .groupBy('user.status') .having('AVG(user.age) > :minAge', { minAge: 30 }) .orderBy('count', 'DESC') .limit(10) .getRawMany();import { sql } from '@mikro-orm/core';
const results = await em .createQueryBuilder(User, 'u') .select(['u.status', sql`count(*)`.as('count'), sql`avg(u.age)`.as('avgAge')]) .groupBy('u.status') .having({ avgAge: { $gt: 30 } }) .orderBy({ count: 'desc' }) .limit(10) .execute('all');const results = await querier.aggregate(User, { $group: { status: true }, // GROUP BY column, typed like $select $agg: { count: { $count: '*' }, avgAge: { $avg: 'age' } }, // computed columns $having: { avgAge: { $gt: 30 } }, $sort: { count: -1 }, $limit: 10,});UQL keeps the whole aggregate as one serializable JSON object that runs unchanged on every SQL engine and MongoDB, with typo-proof inputs: $group columns are checked against the entity like $select, the aggregated field references ($avg: 'age') are checked too, and $having / $sort accept only the grouped columns and computed aliases - a typo is a compile error. The others each trade something: Prisma’s groupBy is type-safe but SQL-only and keyed by operator (_avg, _count); Drizzle is type-safe but composes SQL by hand with function helpers; TypeORM and MikroORM drop to query-builder select strings and raw result rows; Mongoose runs a Mongo-only pipeline. See Aggregate Queries for the full reference.
4. Atomic JSON Updates
Section titled “4. Atomic JSON Updates”Updating a specific key inside a JSONB column without overwriting the whole object.
await db.execute( `UPDATE users SET settings = jsonb_set(settings, '{theme}', '"dark"') WHERE id = 1`);await querier.updateOneById(User, 1, { settings: { $merge: { theme: 'dark' } }});Migration Strategy
Section titled “Migration Strategy”A full rewrite in one release is risky. This phased approach lets you migrate a production system incrementally, with a working fallback at every step.
Phase 1: Read-only (low risk)
Section titled “Phase 1: Read-only (low risk)”Introduce UQL purely for read operations.
- Strategy: Pick a non-critical endpoint and implement it with UQL.
- Verification: Run both queries (legacy and UQL) in parallel and log any discrepancies in the result sets.
- Why it’s safe: UQL returns plain objects, so it can coexist with any other ORM without interfering with their internal caches or state.
Phase 2: New features (medium risk)
Section titled “Phase 2: New features (medium risk)”Implement all new tables and features using UQL.
- Strategy: Use
uql-migrateto create new tables. - Benefit: You test the full UQL lifecycle (definition → migration → query) on fresh data before touching legacy tables.
Phase 3: Mutations (high risk)
Section titled “Phase 3: Mutations (high risk)”Migrate INSERT, UPDATE, and DELETE operations.
- Strategy: Move mutations one entity at a time, keeping the legacy path available until each entity is verified.
Phase 4: Cutover
Section titled “Phase 4: Cutover”Once the legacy ORM is no longer used for any queries, remove it from package.json, along with the .prisma files, Drizzle snapshots, or TypeORM config. Your @Entity classes are the only source of truth left.
Common Pitfalls
Section titled “Common Pitfalls”Habits to unlearn
- Implicit flushes: UQL does not track state. Coming from MikroORM or TypeORM, remember that assigning
user.name = 'Bob'does nothing to the database; callupdateOneexplicitly. - Looking for the schema file: Coming from Prisma, the
@Entityclass is the schema. If a field or relation is not on the class, it does not exist in the database. - Embedded documents: Coming from Mongoose, data you nested inside a document becomes either a JSON column or a related entity you reach with
$populate. Use JSON for schemaless blobs; use a relation for anything you filter, sort, or join across. - CommonJS: UQL is ESM-only. If your project still uses
require(), migrate toimportand set"type": "module"inpackage.json. - Running a generate step: There is no codegen. After changing a field, just save the file and generate a migration.
When to Switch (and When Not To)
Section titled “When to Switch (and When Not To)”UQL is a good fit if you want serializable queries, no codegen, atomic JSON operators, or a unified query API for both SQL and MongoDB. It’s an especially strong fit for semantic search and RAG: vector fields, similarity ranking, and metadata filtering are first-class parts of the same query, with the identical JSON shape on backend and browser, across pgvector, SQLite, MariaDB, CockroachDB, and MongoDB Atlas. You skip the separate vector store and hand-written vector SQL most ORMs still push you toward. See the side-by-side comparison for details.
If you’re coming from MongoDB/Mongoose, it’s a natural landing spot: the operator-based query style carries over, and the same query runs on Mongo and SQL, so you can move onto a relational database gradually rather than rewriting your data layer in one release.
It is a poor fit if you depend on MSSQL or Oracle (not supported), or if your codebase relies heavily on Unit of Work semantics that would be costly to make explicit.