> Every UQL docs page, as Markdown: https://uql-orm.dev/llms.txt
> The same docs over MCP: https://uql-orm.dev/mcp
> Before writing UQL code, read the skill: https://uql-orm.dev/.well-known/agent-skills/uql-orm/SKILL.md

# UQL 0.105: entities as data with no classes, and columns as builders

> UQL 0.105 lets you declare an entity as plain data, turns every column into a builder, and types more of what a query returns. What changed, with an example for each, and how to upgrade.

Source: https://uql-orm.dev/blog/uql-0-105

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

`defineEntity` takes the columns and returns the class. There is nothing to write twice:

```ts title="src/models.ts"
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

The same builders declare a decorated class’s columns:

```diff
@Field({ type: String, nullable: true, length: 80 })
@Field(f.string().nullable().length(80))
```

```ts title="You write"
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:

```ts
f.number().length(10); // error: a number has no length
```

## One decorator per class

`@Index`, `@Filter` and `@Trigger` are gone too. A class lists them in its `@Entity`:

```ts title="You write"
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

`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

`defineEntity`’s `.filters()` may now follow `.relations()`, and then reads through them at any depth, each key typed:

```ts title="You write"
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

`$select: { bio: false }` reads every field but `bio`, and the row’s type agrees:

```ts title="You write"
import { Author } from './models.js';

const authors = await pool.findMany(Author, { $select: { bio: false } });

authors[0]?.name;
authors[0]?.bio; // error: bio was not selected
```

A sort that projects a distance or a score puts it on the row, so the cast you used to write is gone:

```ts title="You write"
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

Run the codemod, first as a dry run:

```sh
npx uql-codemod --dry-run
npx uql-codemod
```

It 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](https://uql-orm.dev/upgrade-guide.md) has the rest, release by release.

## 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](https://github.com/rogerpadilla/uql/issues) for anything that reads wrong or gets in your way. What you hit now shapes v1.
