UQL 0.105: entities as data with no classes, and columns as builders
UQL v0.105 is out, and it changes how to declare an entity. Now, a column is a builder, the whole entity can be plain data, and the types are simpler to write. Old definitions break but we have it covered by npx uql-codemod, it rewrites most of them for you, automatically.
Notice: both ways (decorators and defineEntity) are still supported, but now both are simpler yet stronger.
An entity as data
Section titled “An entity as data”defineEntity takes the columns and returns the class. There is nothing to write twice:
import { defineEntity, f } from 'uql-orm';
export const Author = defineEntity({ name: 'Author', fields: { id: f.number().id(), name: f.string(), bio: f.string().nullable(), },}).relations((rel) => ({ books: () => rel.oneToMany(Book),}));export type Author = InstanceType<typeof Author>;
export const Book = defineEntity({ name: 'Book', fields: { id: f.number().id(), title: f.string(), status: f.enum(['draft', 'published']).default('draft'), authorId: f.number(), },}).relations((rel) => ({ author: () => rel.manyToOne(Author, { references: (book) => book.authorId }),}));export type Book = InstanceType<typeof Book>;Book reads title as string and status as 'draft' | 'published', and Author reads bio as string | null. An insert may leave out id and status, since the database and the default fill them. No !, no ?, no class.
Decorated classes still work, and both styles mix in one project. Reach for a class when a row needs methods of its own.
Columns are builders
Section titled “Columns are builders”The same builders declare a decorated class’s columns:
@Field({ type: String, nullable: true, length: 80 })@Field(f.string().nullable().length(80))import { Entity, Field, f } from 'uql-orm';
@Entity()export class Customer { @Field(f.number().autoIncrement()) id?: number;
@Field(f.string().length(120).unique()) email!: string;
@Field(f.string().nullable()) nickname?: string | null;}A column is NOT NULL unless you say .nullable(), and a key is .id(), so @Id is gone. A builder only offers the options its type can take:
f.number().length(10); // error: a number has no lengthOne decorator per class
Section titled “One decorator per class”@Index, @Filter and @Trigger are gone too. A class lists them in its @Entity:
import { Entity, Field, f } from 'uql-orm';
@Entity({ indexes: (post, index) => [index([post.title], { unique: true })], filters: { published: { where: { publishedAt: { $ne: null } }, default: false }, },})export class Post { @Field(f.number().autoIncrement()) id?: number;
@Field(f.string()) title!: string;
@Field(f.date().nullable()) publishedAt?: Date | null;}Each index is written with the index helper its callback hands you, so a column is a typed reference, never a string. Rename title and the index follows.
Relations find their own join
Section titled “Relations find their own join”rel.oneToMany(Book) in Author above takes no options. Book has one relation pointing back at Author, so that is the join. Add mappedBy only when there are two to choose from.
Filters that read through relations
Section titled “Filters that read through relations”defineEntity’s .filters() may now follow .relations(), and then reads through them at any depth, each key typed:
import { defineEntity, f } from 'uql-orm';
export const Project = defineEntity({ name: 'Project', fields: { id: f.number().id(), archived: f.boolean() },});
export const Task = defineEntity({ name: 'Task', fields: { id: f.number().id(), title: f.string(), projectId: f.number(), },}) .relations((rel) => ({ project: () => rel.manyToOne(Project, { references: (task) => task.projectId }), })) .filters({ live: { where: { project: { archived: false } } } });Every read of Task now leaves out the tasks of archived projects. A typo such as archivd is a compile error, as it is in a $where.
Queries type what they return
Section titled “Queries type what they return”$select: { bio: false } reads every field but bio, and the row’s type agrees:
import { Author } from './models.js';
const authors = await pool.findMany(Author, { $select: { bio: false } });
authors[0]?.name;authors[0]?.bio; // error: bio was not selectedA sort that projects a distance or a score puts it on the row, so the cast you used to write is gone:
import { defineEntity, f } from 'uql-orm';
export const Note = defineEntity({ name: 'Note', fields: { id: f.number().id(), body: f.string(), embedding: f.vector(3) },});
const nearest = await pool.findMany(Note, { $select: { body: true }, $sort: { embedding: { $vector: await embed('release notes'), $distance: 'cosine', $project: 'distance', }, }, $limit: 5,});
nearest[0]?.distance;Upgrading
Section titled “Upgrading”Run the codemod, first as a dry run:
npx uql-codemod --dry-runnpx uql-codemodIt rewrites option objects into builders, folds @Index, @Filter and @Trigger into @Entity, turns $exclude into $select, and reports what it cannot decide. Two changes compile unchanged, so check them by hand: the next migration rebuilds each unnamed JSON index once, and on MongoDB Atlas a vector $sort’s $project now holds the distance, lower nearer, so a check like score > 0.8 flips into a cut-off on the distance. The upgrade guide has the rest, release by release.
Next: v1
Section titled “Next: v1”UQL is close to v1. The next release we are aiming for is the first v1 beta: the API in this post is the one we plan to ship. Try it, and open an issue for anything that reads wrong or gets in your way. What you hit now shapes v1.