Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/blog-api-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ jobs:
mongodb: false
implementation: objection
database: ./blog-objection.sqlite
- name: fastify-ucast-sql-blog (Drizzle 1.0.0-rc.5)
package: fastify-ucast-sql-blog
mongodb: false
implementation: drizzle-v1
database: ./blog-drizzle-v1.sqlite
env:
DATABASE_URL: ${{ matrix.database }}
AUTH_SECRET: ci-blog-secret
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Currently this repository contains the next examples:
| [CASL Vue blog](./packages/vue-blog) | ACL in Blog app with REST API | |
| [CASL Express API](./packages/express-blog) | ACL in REST API for Blog app | |
| [CASL Fastify+Prisma API](./packages/fastify-prisma-blog) | ACL in REST API for Blog app | |
| [CASL Fastify+UCAST SQL API](./packages/fastify-ucast-sql-blog) | ACL in REST API for Blog app with MikroORM, TypeORM, Sequelize and Objection | |
| [CASL Fastify+UCAST SQL API](./packages/fastify-ucast-sql-blog) | ACL in REST API for Blog app with MikroORM, TypeORM, Sequelize, Objection and Drizzle ORM (1.x RC) | |

[CASL HelloWorld]: https://codesandbox.io/s/github/stalniy/casl-examples/tree/master/packages/hello-world
[CASL React Todo]: https://codesandbox.io/s/github/stalniy/casl-examples/tree/master/packages/react-todo
Expand Down
22 changes: 16 additions & 6 deletions packages/fastify-ucast-sql-blog/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Fastify blog with UCAST SQL

This example shares the Fastify blog API while selecting MikroORM 7.x, TypeORM 1.x, Sequelize 6.x, or Objection 3.x before startup. All repositories run CASL's effective rule AST through the matching `@ucast/sql` ORM interpreter. SQLite files are independent: selecting another implementation does not move or migrate data. Sequelize uses the `sqlite3` driver; Objection uses Knex with `better-sqlite3`.
This example shares the Fastify blog API while selecting MikroORM 7.x, TypeORM 1.x, Sequelize 6.x, Objection 3.x, or Drizzle ORM 1.0.0-rc.5 before startup. All repositories run CASL's effective rule AST through the matching `@ucast/sql` ORM interpreter. SQLite files are independent: selecting another implementation does not move or migrate data. Sequelize uses the `sqlite3` driver; Objection uses Knex with `better-sqlite3`. Drizzle uses `better-sqlite3`.

## Local setup

Expand All @@ -14,8 +14,8 @@ Pick one implementation and prepare its SQLite file. The schema command is expli

```sh
cd packages/fastify-ucast-sql-blog
pnpm schema:mikro # or schema:typeorm or schema:sequelize or schema:objection
pnpm seed:mikro # or schema:typeorm or schema:sequelize or schema:objection
pnpm schema:mikro # or schema:typeorm or schema:sequelize or schema:objection or schema:drizzle-v1
pnpm seed:mikro # or seed:typeorm or seed:sequelize or seed:objection or seed:drizzle-v1
```

it will automatically create `blog-${orm}.sqlite` database file in the current directory. It's also possible to configure this by specifying `DATABASE_URL` environment variable, for example:
Expand All @@ -31,20 +31,30 @@ To start the matching implementation. `DATABASE_URL` accepts a relative or absol
IMPLEMENTATION=mikro pnpm start
```

`IMPLEMENTATION` variable can be one of `mikro`, `typeorm`, `sequelize`, or `objection`. The default is `mikro`; the default database filename is `blog-${IMPLEMENTATION}$.sqlite`, also possible to set `DATABASE_URL` to provide custom database path. The server never creates or clears schema/data on startup. It closes the selected ORM connection when Fastify closes.
`IMPLEMENTATION` variable can be one of `mikro`, `typeorm`, `sequelize`, `objection`, or `drizzle-v1`. The default is `mikro`; the default database filename is `blog-${IMPLEMENTATION}.sqlite`, also possible to set `DATABASE_URL` to provide custom database path. The server never creates or clears schema/data on startup. It closes the selected ORM connection when Fastify closes.

## API and rules

The API stays under `/api`: `POST /session`, user create/read/update routes, and article list/read/create/update/delete routes. Session response shape, string IDs, article `author`, list `createdBy`, and the reference pagination bounds are retained. Anonymous users see published articles, writers also see their own drafts and manage their own articles, and admins can manage all records. Missing individual resources return 404; existing resources that the caller cannot access or change return 403. Article lists return newest records first, and empty accessible lists return `{ items: [], count: 0 }`. Denied creation and publish transitions return 403.

`src/createApp.ts` composes the app and registers the `auth`, `users`, and `articles` modules under `src/modules`, matching the Fastify Prisma example. Each module exposes its routes through `index.ts`; authentication, ability definitions, and token services live in the auth module. Shared entity types remain in `src/domain.ts`, and app configuration is defined in `src/config.ts`.

`src/services/orm.ts` initializes the selected ORM and returns a `MikroBlogRepository`, `TypeOrmBlogRepository`, `SequelizeBlogRepository`, or `ObjectionBlogRepository` instance to `createApp.ts`. The app injects that repository into `BlogService` and exposes the service to routes and authentication. `src/services/blog.ts` owns record permissions, create and publish checks, and missing/forbidden results. It derives query filters from CASL rules through `src/services/authorization.ts` so lists and counts include only accessible records.
`src/services/orm.ts` initializes the selected ORM and returns a `MikroBlogRepository`, `TypeOrmBlogRepository`, `SequelizeBlogRepository`, `ObjectionBlogRepository`, or `DrizzleV1BlogRepository` instance to `createApp.ts`. The app injects that repository into `BlogService` and exposes the service to routes and authentication. `src/services/blog.ts` owns record permissions, create and publish checks, and missing/forbidden results. It derives query filters from CASL rules through `src/services/authorization.ts` so lists and counts include only accessible records.

Entity schemas live alongside their corresponding repository classes in `src/repositories`. Repositories only perform database operations: metadata inspection, query construction, UCAST interpretation of supplied filters, and writes. They do not receive abilities or decide permissions. Their metadata keeps property names and physical column names identical, including `author`, `createdAt`, and `updatedAt`. Updates and deletes apply the service's filters in the changing SQL statement.

MikroORM creates its interpreter once with the ORM metadata and platform, then adds a fresh predicate to each query. TypeORM adds the interpreter's `Brackets` predicate with `andWhere`. Sequelize combines the interpreter's `where` expression with the requested identifier using `Op.and` and escapes its replacements with Sequelize's SQL-aware replacement utility before composing the query, because Sequelize 6 updates generate binds that cannot coexist with replacements. It selects the model alias for reads and the table name for writes. Objection passes its model to the interpreter and adds the returned predicate with `where`. It binds its models to each repository's Knex connection. Both expose the author through `createdBy` while retaining the scalar `author` column, and normalize timestamps to domain `Date` values.

The Drizzle repository lives in `src/repositories/drizzle-v1.ts` and uses exactly `drizzle-orm@1.0.0-rc.5`. It defines native relations with `defineRelations()` and uses the `@ucast/sql/drizzle-orm/v1.x` interpreter. Predicates are combined with identifiers in reads, updates, and deletes. An explicit join loads only the author's ID and email as `createdBy`; text timestamps are converted to domain `Date` values.

For example, to run Drizzle v1:

```sh
pnpm schema:drizzle-v1
pnpm seed:drizzle-v1
IMPLEMENTATION=drizzle-v1 pnpm start
```

## Testing

To test api contracts, run server running on port 3000 (default), and then run `npm run test:server` from `packages/express-blog`.
CI runs the shared API contract suite against every implementation, including Drizzle 1.0.0-rc.5. To test API contracts locally, run server running on port 3000 (default), and then run `npm run test:server` from `packages/express-blog`.
7 changes: 5 additions & 2 deletions packages/fastify-ucast-sql-blog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"schema:sequelize": "tsx src/schema.ts sequelize",
"seed:sequelize": "tsx src/seed.ts sequelize",
"schema:objection": "tsx src/schema.ts objection",
"seed:objection": "tsx src/seed.ts objection"
"seed:objection": "tsx src/seed.ts objection",
"schema:drizzle-v1": "tsx src/schema.ts drizzle-v1",
"seed:drizzle-v1": "tsx src/seed.ts drizzle-v1"
},
"dependencies": {
"@casl/ability": "^7.0.0",
Expand All @@ -29,7 +31,8 @@
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.8",
"sqlite3": "^5.1.7",
"typeorm": "^1.0.0"
"typeorm": "^1.0.0",
"drizzle-orm": "1.0.0-rc.5"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
Expand Down
4 changes: 2 additions & 2 deletions packages/fastify-ucast-sql-blog/src/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { usersRoutes } from './modules/users/index.js';
import { articlesRoutes } from './modules/articles/index.js';

export async function createApp(config: AppConfig, injectedRepository?: BlogRepository): Promise<FastifyInstance> {
if (!['mikro', 'typeorm', 'sequelize', 'objection'].includes(config.IMPLEMENTATION)) {
throw new Error(`Unknown implementation "${config.IMPLEMENTATION}". Use mikro, typeorm, sequelize or objection.`);
if (!['mikro', 'typeorm', 'sequelize', 'objection', 'drizzle-v1'].includes(config.IMPLEMENTATION)) {
throw new Error(`Unknown implementation "${config.IMPLEMENTATION}". Use mikro, typeorm, sequelize, objection or drizzle-v1.`);
}
const repository = injectedRepository ?? await initializeOrm(config.IMPLEMENTATION, config.DATABASE_URL);
const fastify = Fastify({ logger: true });
Expand Down
159 changes: 159 additions & 0 deletions packages/fastify-ucast-sql-blog/src/repositories/drizzle-v1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { and, asc, count, desc, eq, getColumns, defineRelations, sql } from 'drizzle-orm';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { createDrizzleInterpreter } from '@ucast/sql/drizzle-orm/v1.x';
import type { Article, Role, User } from '../domain.js';
import type { ArticlePatch, BlogRepository, QueryFilter, UserPatch } from './types.js';

export const users = sqliteTable('users', {
id: text('id').primaryKey().notNull(),
name: text('name'),
email: text('email').notNull().unique(),
password: text('password').notNull(),
role: text('role').$type<Role>().notNull(),
createdAt: text('createdAt').notNull(),
updatedAt: text('updatedAt').notNull(),
});

export const articles = sqliteTable('articles', {
id: text('id').primaryKey().notNull(),
author: text('author').notNull().references(() => users.id),
title: text('title').notNull(),
body: text('body').notNull(),
published: integer('published', { mode: 'boolean' }).notNull().default(false),
createdAt: text('createdAt').notNull(),
updatedAt: text('updatedAt').notNull(),
});

export const articleRelations = defineRelations({ users, articles }, r => ({
articles: {
createdBy: r.one.users({ from: r.articles.author, to: r.users.id, optional: false }),
},
}));
const schema = { users, articles, articleRelations };
const interpret = createDrizzleInterpreter({ schema });

type UserRow = typeof users.$inferSelect;
type ArticleRow = typeof articles.$inferSelect & { createdBy: Pick<User, 'id' | 'email'> };

export class DrizzleV1BlogRepository implements BlogRepository {
readonly kind = 'drizzle-v1';
private readonly connection;
private readonly db;

constructor(filename: string) {
const connection = new Database(filename);
try {
connection.pragma('foreign_keys = ON');
this.db = drizzle({ client: connection, relations: articleRelations });
this.connection = connection;
} catch (error) {
connection.close();
throw error;
}
}

schemaMetadata() {
return ([[users, 'User'], [articles, 'Article']] as const).map(([table, entity]) => ({
entity,
properties: Object.entries(getColumns(table))
.map(([propertyName, column]) => ({ propertyName, columnName: column.name })),
}));
}

async close(): Promise<void> {
this.connection.close();
}

async findUserByEmail(email: string): Promise<User | null> {
const row = this.db.select().from(users).where(eq(users.email, email)).get();
return row ? this.#toUser(row) : null;
}

async userExists(id: string): Promise<boolean> {
return Boolean(this.db.select({ id: users.id }).from(users).where(eq(users.id, id)).get());
}

async articleExists(id: string): Promise<boolean> {
return Boolean(this.db.select({ id: articles.id }).from(articles).where(eq(articles.id, id)).get());
}

async findUserById(id: string, filter: QueryFilter = { kind: 'all' }): Promise<User | null> {
const row = this.db.select().from(users).where(and(eq(users.id, id), this.#filter('users', filter))).get();
return row ? this.#toUser(row) : null;
}

async createUser(value: Omit<User, 'id'> & { id?: string }): Promise<User> {
const row = this.db.insert(users).values({
...value, id: value.id ?? crypto.randomUUID(),
createdAt: value.createdAt.toISOString(), updatedAt: value.updatedAt.toISOString(),
}).returning().get();
return this.#toUser(row);
}

async updateUser(id: string, patch: UserPatch, filter: QueryFilter): Promise<User | null> {
const row = this.db.update(users).set({ ...patch, updatedAt: new Date().toISOString() })
.where(and(eq(users.id, id), this.#filter('users', filter))).returning().get();
return row ? this.#toUser(row) : null;
}

async findArticle(id: string, filter: QueryFilter = { kind: 'all' }): Promise<Article | null> {
const row = this.#articleQuery().where(and(eq(articles.id, id), this.#filter('articles', filter))).get();
return row ? this.#toArticle(row) : null;
}

async listArticles(filter: QueryFilter, page: number, pageSize: number): Promise<{ items: Article[]; count: number }> {
const predicate = this.#filter('articles', filter);
const total = this.db.select({ count: count() }).from(articles).where(predicate).get()!;
const rows = this.#articleQuery().where(predicate).orderBy(desc(articles.createdAt), asc(articles.id))
.limit(pageSize).offset((page - 1) * pageSize).all();
return { items: rows.map(row => this.#toArticle(row)), count: total.count };
}

async createArticle(value: Omit<Article, 'id'> & { id?: string }): Promise<Article> {
const { createdBy, ...attributes } = value;
const row = this.db.insert(articles).values({
...attributes, id: value.id ?? crypto.randomUUID(),
createdAt: value.createdAt.toISOString(), updatedAt: value.updatedAt.toISOString(),
}).returning().get();
const created = await this.findArticle(row.id);
if (!created) throw new Error('Created article could not be loaded with its author');
return created;
}

async updateArticle(id: string, patch: ArticlePatch, filter: QueryFilter): Promise<Article | null> {
const current = await this.findArticle(id, filter);
if (!current) return null;
const updatedAt = new Date();
const result = this.db.update(articles).set({ ...patch, updatedAt: updatedAt.toISOString() })
.where(and(eq(articles.id, id), this.#filter('articles', filter))).run();
return result.changes ? { ...current, ...patch, updatedAt } : null;
}

async deleteArticle(id: string, filter: QueryFilter): Promise<Article | null> {
const current = await this.findArticle(id, filter);
if (!current) return null;
const result = this.db.delete(articles)
.where(and(eq(articles.id, id), this.#filter('articles', filter))).run();
return result.changes ? current : null;
}

#filter(table: 'users' | 'articles', filter: QueryFilter) {
if (filter.kind === 'none') return sql`0 = 1`;
return filter.kind === 'ast' ? interpret(filter.ast, table) : undefined;
}

#articleQuery() {
return this.db.select({ ...getColumns(articles), createdBy: { id: users.id, email: users.email } })
.from(articles).innerJoin(users, eq(articles.author, users.id));
}

#toUser(row: UserRow): User {
return { ...row, createdAt: new Date(row.createdAt), updatedAt: new Date(row.updatedAt) };
}

#toArticle(row: ArticleRow): Article {
return { ...row, createdAt: new Date(row.createdAt), updatedAt: new Date(row.updatedAt) };
}
}
2 changes: 1 addition & 1 deletion packages/fastify-ucast-sql-blog/src/repositories/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type UserPatch = Partial<Pick<User, 'name' | 'email' | 'password'>>;
export type QueryFilter = { kind: 'all' } | { kind: 'none' } | { kind: 'ast'; ast: any };

export interface BlogRepository {
readonly kind: 'mikro' | 'typeorm' | 'sequelize' | 'objection';
readonly kind: 'mikro' | 'typeorm' | 'sequelize' | 'objection' | 'drizzle-v1';
schemaMetadata(): Array<{ entity: string; properties: Array<{ propertyName: string; columnName: string }> }>;
close(): Promise<void>;
findUserByEmail(email: string): Promise<User | null>;
Expand Down
2 changes: 1 addition & 1 deletion packages/fastify-ucast-sql-blog/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { dirname, resolve } from 'node:path';
import { ensureSqliteSchema } from './sqlite-schema.js';

const implementation = process.argv[2];
if (!['mikro', 'typeorm', 'sequelize', 'objection'].includes(implementation)) throw new Error('Usage: tsx src/schema.ts mikro|typeorm|sequelize|objection');
if (!['mikro', 'typeorm', 'sequelize', 'objection', 'drizzle-v1'].includes(implementation)) throw new Error('Usage: tsx src/schema.ts mikro|typeorm|sequelize|objection|drizzle-v1');
const configuredPath = process.env.DATABASE_URL ?? `./blog-${implementation}.sqlite`;
const filename = resolve(configuredPath.startsWith('file:') ? configuredPath.slice(5) : configuredPath);
mkdirSync(dirname(filename), { recursive: true });
Expand Down
2 changes: 1 addition & 1 deletion packages/fastify-ucast-sql-blog/src/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { initializeOrm } from './services/orm.js';

async function seed() {
const implementation = process.argv[2];
if (!['mikro', 'typeorm', 'sequelize', 'objection'].includes(implementation)) throw new Error('Usage: tsx src/seed.ts mikro|typeorm|sequelize|objection');
if (!['mikro', 'typeorm', 'sequelize', 'objection', 'drizzle-v1'].includes(implementation)) throw new Error('Usage: tsx src/seed.ts mikro|typeorm|sequelize|objection|drizzle-v1');
const configuredPath = process.env.DATABASE_URL ?? `./blog-${implementation}.sqlite`;
const filename = resolve(configuredPath.startsWith('file:') ? configuredPath.slice(5) : configuredPath);
const repo = await initializeOrm(implementation, filename);
Expand Down
5 changes: 3 additions & 2 deletions packages/fastify-ucast-sql-blog/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import 'reflect-metadata';
import { createApp } from './createApp.js';
import type { AppConfig } from './config.js';

const implementation = process.env.IMPLEMENTATION ?? 'mikro';
const config: AppConfig = {
IMPLEMENTATION: process.env.IMPLEMENTATION ?? 'mikro',
DATABASE_URL: process.env.DATABASE_URL ?? './blog-mikro.sqlite',
IMPLEMENTATION: implementation,
DATABASE_URL: process.env.DATABASE_URL ?? `./blog-${implementation}.sqlite`,
SERVER_PORT: Number(process.env.SERVER_PORT ?? 3000),
AUTH_SECRET: process.env.AUTH_SECRET ?? 'development-only-secret',
AUTH_ISSUER: process.env.AUTH_ISSUER ?? 'CASL.Fastify.UcastSql',
Expand Down
5 changes: 4 additions & 1 deletion packages/fastify-ucast-sql-blog/src/services/orm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { MikroORM, SqliteDriver } from '@mikro-orm/sqlite';
import { DataSource } from 'typeorm';
import { Sequelize } from 'sequelize';
import knex from 'knex';
import { DrizzleV1BlogRepository } from '../repositories/drizzle-v1.js';
import type { BlogRepository } from '../repositories/types.js';
import { MikroBlogRepository, MikroUsers, MikroArticles } from '../repositories/mikro.js';
import { TypeOrmBlogRepository, TypeOrmUsers, TypeOrmArticles } from '../repositories/typeorm.js';
Expand Down Expand Up @@ -67,7 +68,9 @@ export async function initializeOrm(implementation: string, databaseUrl: string)
throw error;
}
}
case 'drizzle-v1':
return new DrizzleV1BlogRepository(filename);
default:
throw new Error(`Unknown implementation "${implementation}". Use mikro, typeorm, sequelize or objection.`);
throw new Error(`Unknown implementation "${implementation}". Use mikro, typeorm, sequelize, objection or drizzle-v1.`);
}
}
Loading
Loading