Skip to content

rogerpadilla/uql

Repository files navigation

uql maku

tests Coverage Status license npm version

UQL is the smartest ORM for TypeScript. It is engineered to be fast, safe, and universally compatible.

  • Runs Everywhere: Node.js, Bun, Deno, Cloudflare Workers, Electron, React Native, and the Browser.
  • Unified API: A consistent query interface for PostgreSQL (incl. CockroachDB, YugabyteDB), MySQL (incl. TiDB, Aurora), MariaDB, SQLite, LibSQL, Neon, Cloudflare D1, and MongoDB.

 

const users = await querier.findMany(User, {
  $select: { email: true, profile: { $select: { picture: true } } },
  $where: { email: { $endsWith: '@domain.com' } },
  $sort: { createdAt: 'desc' },
  $limit: 100,
});

 

Why UQL?

Feature UQL Traditional ORMs
API Unified & Intuitive: Same syntax for SQL & NoSQL. Fragmented: SQL and Mongo feel like different worlds.
Safety Deep Type-Safety: Validates relations & operators at any depth. Surface-level: Often loses types in complex joins.
Syntax Serializable JSON: Pure data, perfect for APIs/Websockets. Method-Chaining: Hard to transport over the wire.
Efficiency Sticky Connections: Minimal overhead, human-readable SQL. Heavy: Often generates "SQL Soup" that's hard to debug.

 

Features

Feature Description
Context-Aware Queries Deep type-safety for operators and relations at any depth.
Serializable JSON 100% valid JSON queries for easy transport over HTTP/Websockets.
Unified Dialects Write once, run anywhere: PostgreSQL, MySQL, SQLite, MongoDB, and more.
Naming Strategies Pluggable system to translate between TypeScript camelCase and database snake_case.
Smart SQL Engine Optimized sub-queries, placeholders ($1, $2), and minimal SQL generation via QueryContext.
Thread-Safe by Design Centralized task queue and @Serialized() decorator prevent race conditions.
Declarative Transactions Standard @Transactional() and @InjectQuerier() decorators for NestJS/DI.
Modern & Versatile Pure ESM, high-res timing, Soft-delete, and Vector/JSONB/JSON support.
Database Migrations Built-in Entity-First synchronization and a robust CLI for version-controlled schema evolution.
Logging & Monitoring Professional-grade monitoring with slow-query detection and colored output.

 

1. Install

Install the core package and the driver for your database:

# Core
npm install @uql/core       # or bun add / pnpm add

Supported Drivers (pick according to your database)

Database Command
PostgreSQL (incl. Neon, Cockroach, Yugabyte) npm install pg
MySQL (incl. TiDB, Aurora) npm install mysql2
MariaDB npm install mariadb
SQLite npm install better-sqlite3
LibSQL (incl. Turso) npm install @libsql/client
MongoDB npm install mongodb
Cloudflare D1 Native (no driver needed)

TypeScript Configuration

Ensure your tsconfig.json is configured to support decorators and metadata:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "module": "NodeNext",
    "target": "ESNext"
  }
}

 Note: ES2020+ will work for target as well.

2. Define the Entities

Annotate your classes with decorators. UQL's engine uses this metadata for both type-safe querying and precise DDL generation.

Core Decorators

Decorator Purpose
@Entity() Marks a class as a database table/collection.
@Id() Defines the Primary Key with support for onInsert generators (UUIDs, etc).
@Field() Standard column. Use { reference: ... } for Foreign Keys.
@OneToOne Defines a one-to-one relationship.
@OneToMany Defines a one-to-many relationship.
@ManyToOne Defines a many-to-one relationship.
@ManyToMany Defines a many-to-many relationship.
@Virtual() Defines a read-only field calculated via SQL (see Advanced).

Type Abstraction: Logical vs. Physical

UQL separates the intent of your data from its storage. Both properties are optional; if omitted, UQL performs a best-effort inference using the TypeScript types from your class (provided emitDecoratorMetadata is enabled).

Property Purpose Values
type Logical Type (Abstraction). Used for runtime behavior and automatic SQL mapping. String, Number, Boolean, Date, BigInt, or semantic strings: 'uuid', 'json', 'vector'.
columnType Physical Type (Implementation). Highest Priority. Bypasses UQL's inference for exact SQL control. Raw SQL types: 'varchar(100)', 'decimal(10,2)', 'smallint', etc.
@Field() // Inference: Maps to TEXT (Postgres) or VARCHAR(255) (MySQL) automatically.
name?: string;

@Field({ type: 'uuid' }) // Recommended: Cross-database abstraction for UUIDs.
id?: string;

@Field({ columnType: 'varchar(500)' }) // Control: Explicitly forces a specific SQL type.
bio?: string;

 

import { v7 as uuidv7 } from 'uuid';
import { Entity, Id, Field, OneToOne, OneToMany, ManyToOne, ManyToMany, type Relation } from '@uql/core';

@Entity()
export class User {
  @Id({ type: 'uuid', onInsert: () => uuidv7() })
  id?: string;

  @Field({
    index: true,
  })
  name?: string;

  @Field({
    unique: true,
    comment: 'User login email',
  })
  email?: string;

  @OneToOne({
    entity: () => Profile,
    mappedBy: (profile) => profile.user,
    cascade: true,
  })
  profile?: Relation<Profile>;

  @OneToMany({
    entity: () => Post,
    mappedBy: (post) => post.author,
  })
  posts?: Relation<Post>[];
}

@Entity()
export class Profile {
  @Id({ type: 'uuid', onInsert: () => uuidv7() })
  id?: string;

  @Field()
  bio?: string;

  @Field({ reference: () => User, foreignKey: 'fk_profile_user' })
  userId?: string;

  @OneToOne({ entity: () => User })
  user?: User;
}

@Entity()
export class Post {
  @Id()
  id?: number;

  @Field()
  title?: string;

  @Field({ reference: () => User })
  authorId?: string;

  @ManyToOne({ entity: () => User })
  author?: User;

  @ManyToMany({
    entity: () => Tag,
    through: () => PostTag,
  })
  tags?: Tag[];
}

@Entity()
export class Tag {
  @Id({ type: 'uuid', onInsert: () => uuidv7() })
  id?: string;

  @Field()
  name?: string;
}

@Entity()
export class PostTag {
  @Id({ type: 'uuid', onInsert: () => uuidv7() })
  id?: string;

  @Field({ reference: () => Post })
  postId?: number;

  @Field({ reference: () => Tag })
  tagId?: string;
}

Pro Tip: Use the Relation<T> utility type for relationship properties. It prevents TypeScript circular dependency errors while maintaining full type-safety.

 

3. Set up a pool

A pool manages connections (queriers). Initialize it once at application bootstrap (e.g., in server.ts).

import { SnakeCaseNamingStrategy, type Config } from '@uql/core';
import { PgQuerierPool } from '@uql/core/postgres'; // or mysql2, sqlite, etc.
import { User, Profile, Post } from './entities';

export const pool = new PgQuerierPool(
  { host: 'localhost', database: 'uql_app', max: 10 },
  {
    logger: ['error', 'warn', 'migration'],
    slowQueryThreshold: 1000,
    namingStrategy: new SnakeCaseNamingStrategy()
  }
);

export default {
  pool,
  entities: [User, Profile, Post],
  migrationsPath: './migrations',
} satisfies Config;

Pro Tip: Reusing the same connection pool for both your application and migrations is recommended. It reduces connection overhead and ensures consistent query settings (like naming strategies).

 

 

4. Manipulate the Data

UQL provides a straightforward API to interact with your data. Always ensure queriers are released back to the pool.

const querier = await pool.getQuerier();
try {
  const users = await querier.findMany(User, {
    $select: {
      name: true,
      profile: { $select: ['bio'], $required: true } // INNER JOIN
    },
    $where: {
      status: 'active',
      name: { $istartsWith: 'a' } // Case-insensitive search
    },
    $limit: 10,
    $skip: 0
  });
} finally {
  await querier.release(); // Essential for pool health
}

Generated SQL (PostgreSQL):

SELECT "User"."name", "profile"."id" AS "profile_id", "profile"."bio" AS "profile_bio"
FROM "User"
INNER JOIN "Profile" AS "profile" ON "profile"."userId" = "User"."id"
WHERE "User"."status" = 'active' AND "User"."name" ILIKE 'a%'
LIMIT 10 OFFSET 0

 

Advanced: Virtual Fields & Raw SQL

Define complex logic directly in your entities using raw functions. These are resolved during SQL generation for peak efficiency.

@Entity()
export class Item {
  @Field({
    virtual: raw(({ ctx, dialect, escapedPrefix }) => {
      ctx.append('(');
      dialect.count(ctx, ItemTag, {
        $where: { itemId: raw(({ ctx }) => ctx.append(`${escapedPrefix}.id`)) }
      }, { autoPrefix: true });
      ctx.append(')');
    })
  })
  tagsCount?: number;
}

 

Thread-Safe Transactions

UQL is one of the few ORMs with a centralized serialization engine. Transactions are guaranteed to be race-condition free.

Option A: Manual (Functional)

const result = await pool.transaction(async (querier) => {
  const user = await querier.findOne(User, { $where: { email: '...' } });
  await querier.insertOne(Profile, { userId: user.id, bio: '...' });
});

Option B: Declarative (Decorators)

Perfect for NestJS and other Dependency Injection frameworks. Use @Transactional() to wrap a method and @InjectQuerier() to access the managed connection.

import { Transactional, InjectQuerier, type Querier } from '@uql/core';

export class UserService {
  @Transactional()
  async register({picture, ...user}: UserProfile, @InjectQuerier() querier?: Querier) {
    const userId = await querier.insertOne(User, user);
    await querier.insertOne(Profile, { userId, picture });
  }
}

 

5. Migrations & Synchronization

1. Unified Configuration

Ideally, use the same uql.config.ts for your application bootstrap and the CLI:

// uql.config.ts
import type { Config } from '@uql/core';

export default {
  pool: new PgQuerierPool({ /* ... */ }),
  entities: [User, Profile, Post],
  migrationsPath: './migrations',
} satisfies Config;

Why? Using a single config for both your app and the CLI is recommended for consistency. It prevents bugs where your runtime uses one naming strategy (e.g. camelCase) but your migrations use another (e.g. snake_case), or where the CLI isn't aware of all your entities. It enforces a Single Source of Truth for your database connection and schema.

2. Manage via CLI

Use the CLI to manage your database schema evolution.

Command Description
generate <name> Creates an empty timestamped file for manual SQL migrations (e.g., data backfills).
generate:entities <name> Auto-generates a migration by diffing your entities against the current DB schema.
up Applies all pending migrations.
down Rolls back the last applied migration batch.
status Shows which migrations have been executed and which are pending.

Usage Examples

# 1. Create a manual migration
npx uql-migrate generate seed_default_roles

# 2. Auto-generate schema changes from your code
npx uql-migrate generate:entities add_profile_table

# 3. Apply changes
npx uql-migrate up

# 4. Revert changes if needed
npx uql-migrate down

# 5. Run with custom config
npx uql-migrate up --config ./configs/uql.config.ts

Bun Users: If your uql.config.ts uses TypeScript path aliases (e.g., ~app/...), run migrations with the --bun flag to ensure proper resolution:

bun run --bun uql-migrate status

Or add a script to your package.json: "uql": "bun run --bun uql-migrate", then run commands like, e.g., bun run uql status.

3. AutoSync (Development)

Keep your schema in sync without manual migrations. It is Safe by Default: In safe mode (default), it strictly adds new tables and columns but blocks any destructive operations (column drops or type alterations) to prevent data loss. It provides Transparent Feedback by logging detailed warnings for any blocked changes, so you know exactly what remains to be migrated manually.

Important: For autoSync to detect your entities, they must be loaded (imported) before calling autoSync.

Using Your Config (Recommended)

If you follow the unified configuration pattern, your entities are already imported. Simply reuse it:

import { Migrator } from '@uql/core/migrate';
import config from './uql.config.js';

const migrator = new Migrator(config.pool, {
  entities: config.entities,
});
await migrator.autoSync({ logging: true });

Explicit Entities

Alternatively, pass entities directly if you want to be explicit about which entities to sync:

import { Migrator } from '@uql/core/migrate';
import { User, Profile, Post } from './entities/index.js';

const migrator = new Migrator(pool, {
  entities: [User, Profile, Post],
});
await migrator.autoSync({ logging: true });

 

6. Logging & Monitoring

UQL features a professional-grade, structured logging system designed for high visibility and sub-millisecond performance monitoring.

Log Levels

Level Description
query Standard Queries: Beautifully formatted SQL/Command logs with execution time.
slowQuery Bottleneck Alerts: Dedicated logging for queries exceeding your threshold.
error / warn System Health: Detailed error traces and potential issue warnings.
migration Audit Trail: Step-by-step history of schema changes.
skippedMigration Safety: Logs blocked unsafe schema changes during autoSync.
schema / info Lifecycle: Informative logs about ORM initialization and sync events.

Visual Feedback

The DefaultLogger provides high-contrast, colored output out of the box:

query: SELECT * FROM "user" WHERE "id" = $1 -- [123] [2ms]
slow query: UPDATE "post" SET "title" = $1 -- ["New Title"] [1250ms]
error: Failed to connect to database: Connection timeout

Pro Tip: Even if you disable general query logging in production (logger: ['error', 'warn', 'slowQuery']), UQL stays silent until a query exceeds your threshold.

 

Learn more about UQL at uql.app for details on:

 

🛠 Deep Dive: Tests & Technical Resources

For those who want to see the "engine under the hood," check out these resources in the source code:

 

Built with ❤️ and supported by

UQL is an open-source project proudly sponsored by Variability.ai.