Skip to content

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.

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 .prismanpx prisma generate → use the generated client.
  • UQL: Edit the @Entity class → 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.


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
`;

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.

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
});

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,
});

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.

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`
);

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.

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.

Implement all new tables and features using UQL.

  • Strategy: Use uql-migrate to create new tables.
  • Benefit: You test the full UQL lifecycle (definition → migration → query) on fresh data before touching legacy tables.

Migrate INSERT, UPDATE, and DELETE operations.

  • Strategy: Move mutations one entity at a time, keeping the legacy path available until each entity is verified.

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.


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; call updateOne explicitly.
  • 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 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 to import and set "type": "module" in package.json.
  • Running a generate step: There is no codegen. After changing a field, just save the file and generate a migration.

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.