purerosefallen/nicot
Nest.js interacting with class-validator + OpenAPI + TypeORM for Nest.js Restful API development.
NICOT is an entity-driven REST framework for NestJS + TypeORM.
You define an entity once, and NICOT generates:
with explicit, field-level control over:
NICOT stands for:
The name also hints at “nicotto” / “nicotine”: something small that can be habit-forming. The idea is:
One entity definition becomes the contract for everything
(DB, validation, DTO, OpenAPI, CRUD, pagination, relations).
NICOT’s design is:
npm install nicot @nestjs/config typeorm @nestjs/typeorm class-validator class-transformer reflect-metadata @nestjs/swagger
NICOT targets:
The test suite expects PostgreSQL on localhost:5432 with:
postgrespostgresNo download data available
No tracked packages depend on this.
postgresBring up the test database with:
docker compose -f tests/docker-compose.yml up -d
This project's full-text tests use @QueryFullText({ parser: 'zhparser' }), so the test compose uses zhparser/zhparser:bookworm-16 instead of the plain postgres image.
import { Entity } from 'typeorm';
import {
IdBase,
StringColumn,
IntColumn,
BoolColumn,
DateColumn,
NotInResult,
NotWritable,
QueryEqual,
QueryMatchBoolean,
} from 'nicot';
@Entity()
export class User extends IdBase() {
@StringColumn(255, { required: true, description: 'User name' })
@QueryEqual()
name: string;
@IntColumn('int', { unsigned: true })
age: number;
@BoolColumn({ default: true })
@QueryMatchBoolean()
isActive: boolean;
@StringColumn(255)
@NotInResult()
password: string;
@DateColumn()
@NotWritable()
createdAt: Date;
isValidInCreate() {
return this.age < 18 ? 'Minors are not allowed to register' : undefined;
}
isValidInUpdate() {
return undefined;
}
}
Best practice: one factory file per entity.
// user.factory.ts
export const UserFactory = new RestfulFactory(User, {
relations: [], // explicitly loaded relations (for DTO + queries)
skipNonQueryableFields: true, // query DTO = only fields with @QueryXXX
});
// user.service.ts
@Injectable()
export class UserService extends UserFactory.crudService() {
constructor(@InjectRepository(User) repo: Repository<User>) {
super(repo);
}
}
// user.controller.ts
import { Controller } from '@nestjs/common';
import { PutUser } from '../auth/put-user.decorator'; // your own decorator
// Fix DTO types up front
export class CreateUserDto extends UserFactory.createDto {}
export class UpdateUserDto extends UserFactory.updateDto {}
export class FindAllUserDto extends UserFactory.findAllDto {}
@Controller('users')
export class UserController {
constructor(private readonly service: UserService) {}
@UserFactory.create()
async create(
@UserFactory.createParam() dto: CreateUserDto,
@PutUser() currentUser: User,
) {
// business logic - attach owner
dto.ownerId = currentUser.id;
return this.service.create(dto);
}
@UserFactory.findAll()
async findAll(
@UserFactory.findAllParam() dto: FindAllUserDto,
@PutUser() currentUser: User,
) {
return this.service.findAll(dto, qb => {
qb.andWhere('user.ownerId = :uid', { uid: currentUser.id });
});
}
@UserFactory.findOne()
async findOne(@UserFactory.idParam() id: number) {
return this.service.findOne(id);
}
@UserFactory.update()
async update(
@UserFactory.idParam() id: number,
@UserFactory.updateParam() dto: UpdateUserDto,
) {
return this.service.update(id, dto);
}
@UserFactory.delete()
async delete(@UserFactory.idParam() id: number) {
return this.service.delete(id);
}
}
Start the Nest app, and you get:
POST /usersGET /users/:idGET /usersPATCH /users/:idDELETE /users/:idPOST /users/importDocumented in Swagger, with DTOs derived directly from your entity definition.
In NICOT you usually don’t hand-roll primary key fields. Instead you inherit one of the base classes.
IdBase() — numeric auto-increment primary key@Entity()
export class Article extends IdBase({ description: 'Article ID' }) {
// id: number (bigint unsigned, primary, auto-increment)
}
Behavior:
id: number column (bigint unsigned, primary: true, Generated('increment'))@NotWritable() — cannot be written via create/update DTO@IntColumn('bigint', { unsigned: true, ... })@QueryEqual() — usable as ?id=... in GET queriesORDER BY id DESC in applyQuery (you can override or disable with noOrderById: true)StringIdBase() — string / UUID primary key@Entity()
export class ApiKey extends StringIdBase({
uuid: true,
description: 'API key ID',
}) {
// id: string (uuid, primary)
}
Behavior:
id: string columnuuid: true:
@UuidColumn({ primary: true, generated: true, ... })@NotWritable()uuid: false or omitted:
@StringColumn(length || 255, { required: true, ... })@IsNotEmpty() + @NotChangeable() (writable only at create time)ORDER BY id ASC (can be disabled via noOrderById)Summary:
| Base class | Type | Default order | Generation strategy |
|---|---|---|---|
IdBase() | number | id DESC | auto increment (bigint) |
StringIdBase() | string | id ASC | UUID or manual string |
NICOT’s ***Column() decorators combine:
@ApiProperty() metadataCommon ones:
| Decorator | DB type | Validation defaults |
|---|---|---|
@StringColumn(len) | varchar(len) | @IsString(), @Length() |
@TextColumn() | text | @IsString() |
@UuidColumn() | uuid | @IsUUID() |
@IntColumn(type) | integer types | @IsInt() |
@FloatColumn(type) | float/decimal | @IsNumber() |
@BoolColumn() | boolean | @IsBoolean() |
@DateColumn() | timestamp/date | @IsDate() |
@JsonColumn(T) | jsonb | @IsObject() / nested val. |
@SimpleJsonColumn | json | same as above |
@StringJsonColumn | text (stringified JSON) | same as above |
@EnumColumn(Enum) | enum or text | enum validation |
All of them accept an options parameter:
@StringColumn(255, {
required: true,
description: 'Display name',
default: 'Anonymous',
})
displayName: string;
These decorators control where a field appears:
ResultDto)| Decorator | Effect on DTOs |
|---|---|
@NotWritable() | Removed from both Create & Update DTO |
@NotCreatable() | Removed from Create DTO only |
@NotChangeable() | Removed from Update DTO only |
@NotQueryable() | Removed from GET DTO (query params), can’t be used in filters |
@NotInResult() | Removed from all response DTOs (including nested relations) |
| Decorator | Meaning |
|---|---|
@NotColumn() | Not mapped to DB; usually set in afterGet() as a computed field |
@QueryColumn() | Only exists in query DTO (no DB column), used with @QueryXXX() |
@RelationComputed(() => Class) | Virtual field that depends on relations; participates in relation pruning |
Example:
@Entity()
export class User extends IdBase() {
@StringColumn(255, { required: true })
name: string;
@StringColumn(255)
@NotInResult()
password: string;
@DateColumn()
@NotWritable()
createdAt: Date;
@NotColumn()
@RelationComputed(() => Profile)
profileSummary: ProfileSummary;
}
When NICOT generates DTOs, it applies a whitelist/cut-down pipeline. Roughly:
@NotColumn@NotWritable@NotCreatablefieldsToOmit, writeFieldsToOmit, createFieldsToOmit@NotColumn@NotWritable@NotChangeablefieldsToOmit, writeFieldsToOmit, updateFieldsToOmit@NotColumn@NotQueryableResultDto) omits:
@NotInResultoutputFieldsToOmitrelations whitelistIn short:
If you mark something as “not writable / queryable / in result”, that wins, regardless of column type or other decorators.
Query decorators define how a field is translated into SQL in GET queries.
Internally they all use a QueryCondition callback:
export const QueryCondition = (cond: QueryCond) =>
Metadata.set(
'queryCondition',
cond,
'queryConditionFields',
) as PropertyDecorator;
findAll() / findAllCursorPaginated()When you call CrudBase.findAll(ent):
beforeGet() (if present) — good place to adjust defaults.entity.applyQuery(qb, alias) — from your base class (e.g. IdBase adds orderBy(id desc)).relations config).QueryCondition metadata and runs the conditions to mutate the SelectQueryBuilder.So @QueryXXX() is a declarative hook into the query building stage.
Based on QueryWrap / QueryCondition:
@QueryEqual()@QueryGreater(), @QueryGreaterEqual()@QueryLess(), @QueryLessEqual()@QueryNotEqual()@QueryOperator('<', 'fieldName?') for fully custom operators@QueryLike() (prefix match field LIKE value%)@QuerySearch() (contains match field LIKE %value%)@QueryMatchBoolean() — parses "true" / "false" / "1" / "0"@QueryIn() — IN (...), supports comma-separated strings or arrays@QueryNotIn() — NOT IN (...)@QueryEqualZeroNullable() — 0 (or "0") becomes IS NULL, others = :value@QueryJsonbHas() — Postgres ? operator on jsonb fieldAll of these are high-level wrappers over the central abstraction:
export const QueryWrap = (wrapper: QueryWrapper, field?: string) =>
QueryCondition((obj, qb, entityName, key) => {
// ...convert obj[key] and call qb.andWhere(...)
});
You can combine multiple QueryCondition implementations:
export const QueryAnd = (...decs: PropertyDecorator[]) => { /* ... */ };
export const QueryOr = (...decs: PropertyDecorator[]) => { /* ... */ };
QueryAnd(A, B) — run both conditions on the same field (AND).QueryOr(A, B) — build an (A) OR (B) bracket group.These are useful for e.g. multi-column search or fallback logic.
QueryFullTextPostgreSQL-only helper:
@StringColumn(255)
@QueryFullText({
configuration: 'english',
tsQueryFunction: 'websearch_to_tsquery',
orderBySimilarity: true,
})
content: string;
NICOT will:
to_tsvector(...) @@ websearch_to_tsquery(...).rank subject and order by it when orderBySimilarity: true.Note: full-text features are intended for PostgreSQL. On other databases they are not supported.
GET query params are always strings on the wire, but entities may want richer types (arrays, numbers, JSON objects).
NICOT uses:
@GetMutator(...) metadata on the entity fieldMutatorPipe to apply the conversion at runtimePatchColumnsInGet to adjust Swagger docs for GET DTOsMutatorPipe reads the string value and calls your mutator function.@JsonColumn(SomeFilterObject)
@GetMutatorJson() // parse JSON string from ?filter=...
@QueryOperator('@>') // use jsonb containment operator
filter: SomeFilterObject;
Built-in helpers include:
@GetMutatorBool()@GetMutatorInt()@GetMutatorFloat()@GetMutatorStringSeparated(',')@GetMutatorIntSeparated()@GetMutatorFloatSeparated()@GetMutatorJson()Internally, PatchColumnsInGet tweaks Swagger metadata so that:
type: string (with example / enum if provided by the mutator metadata).And RestfulFactory.findAllParam() wires everything together:
MutatorPipe if GetMutators exist.OmitPipe(fieldsInGetToOmit) to strip non-queryable fields.PickPipe(queryableFields) when skipNonQueryableFields: true.skipNonQueryableFields: only expose explicitly declared query fieldsBy default, findAllDto is:
@NotColumn@NotQueryablePageSettingsDto’s pagination fields (pageCount, recordsPerPage).If you want GET queries to accept only fields that have @QueryEqual() / @QueryLike() / @QueryIn() etc, use:
const UserFactory = new RestfulFactory(User, {
relations: [],
skipNonQueryableFields: true,
});
Effects:
findAllDto keeps only fields that:
QueryCondition (i.e. some @QueryXXX() decorator),NotQueryable, NotColumn, missing mutator).findAllParam() runs PickPipe(queryableFields), so stray query params are dropped.Mental model:
“If you want a field to be filterable in GET
/users, you must explicitly add a@QueryXXX()decorator. Otherwise it’s invisible.”
Recommended:
skipNonQueryableFields: true ON.In real systems, you often need to isolate data by context:
Typical rules:
qb.andWhere('userId = :id', ...) everywhere.NICOT provides BindingColumn / BindingValue / useBinding / beforeSuper on top of CrudBase so that
multi-tenant isolation becomes part of the entity contract, not scattered per-controller logic.
Use @BindingColumn on entity fields that should be filled and filtered by the backend context,
instead of coming from the client payload.
@Entity()
export class Article extends IdBase() {
@BindingColumn() // default bindingKey: "default"
@IntColumn('int', { unsigned: true })
userId: number;
@BindingColumn('app') // bindingKey: "app"
@IntColumn('int', { unsigned: true })
appId: number;
}
NICOT will:
create:
userId / appId (if provided)findAll:
WHERE userId = :value / appId = :valueupdate / delete:
Effectively: binding columns are your “ownership / tenant” fields.
@BindingValue is placed on service properties or methods that provide the actual binding values.
@Injectable()
class ArticleService extends CrudService(Article) {
constructor(@InjectRepository(Article) repo: Repository<Article>) {
super(repo);
}
@BindingValue() // for BindingColumn()
get currentUserId() {
return this.ctx.userId;
}
@BindingValue('app') // for BindingColumn('app')
get currentAppId() {
return this.ctx.appId;
}
}
At runtime, NICOT will:
BindingValue metadata{ userId, appId, ... }createWHERE conditions on findAll, update, deleteIf both client payload and BindingValue provide a value, BindingValue wins for binding columns.
You can use:
- properties (sync)
- getters
- methods (sync)
- async methods
> NICOT will await async BindingValues when necessary.
The “canonical” way to provide binding values in a web app is:
@BindingValue simply read from that provider.This keeps:
Using createProvider from nesties, you can declare a strongly-typed request-scoped provider:
export const BindingContextProvider = createProvider(
{
provide: 'BindingContext',
scope: Scope.REQUEST, // ⭐ one instance per HTTP request
inject: [REQUEST, AuthService] as const,
},
async (req, auth) => {
const user = await auth.getUserFromRequest(req);
return {
userId: user.id,
appId: Number(req.headers['x-app-id']),
};
},
);
Key points:
scope: Scope.REQUEST → each request has its own context instance.inject: [REQUEST, AuthService] → you can pull anything you need to compute bindings.createProvider infers (req, auth) types automatically.@Injectable()
class ArticleService extends CrudService(Article) {
constructor(
@InjectRepository(Article) repo: Repository<Article>,
@Inject('BindingContext')
private readonly ctx: { userId: number; appId: number },
) {
super(repo);
}
@BindingValue()
get currentUserId() {
return this.ctx.userId;
}
@BindingValue('app')
get currentAppId() {
return this.ctx.appId;
}
}
With this setup:
{ userId, appId } context@BindingValue simply reads from that contextCrudBase applies bindings for create / findAll / update / delete automaticallyuserId conditionsThis is the recommended way to use binding in a NestJS HTTP app.
For tests, scripts, or some internal flows, you may want to override binding values per call
instead of relying on @BindingValue.
Use useBinding for this:
// create with explicit binding
const res = await articleService
.useBinding(7) // bindingKey: "default"
.useBinding(44, 'app') // bindingKey: "app"
.create({ name: 'Article 1' });
// query in the same binding scope
const list = await articleService
.useBinding(7)
.useBinding(44, 'app')
.findAll({});
Key properties:
useBinding values are isolated@BindingValue (explicit useBinding can override default BindingValue)This is particularly handy in unit tests and CLI scripts.
CrudService subclasses are singletons, but bindings are per call.
If you override findAll / update / delete and add await before calling super,
you can accidentally mess with binding order / concurrency.
NICOT offers beforeSuper as a small helper:
@Injectable()
class SlowArticleService extends ArticleService {
override async findAll(
...args: Parameters<typeof ArticleService.prototype.findAll>
) {
await this.beforeSuper(async () => {
// any async work before delegating to CrudBase
await new Promise((resolve) => setTimeout(resolve, 100));
});
return super.findAll(...args);
}
}
What beforeSuper ensures:
CrudBase with the correct bindingsThis is an advanced hook; most users don’t need it. For typical per-request isolation, prefer request-scoped context + @BindingValue.
On each CRUD operation, NICOT does roughly:
BindingValue from the service (properties / getters / methods / async methods)useBinding(...) overlayscreate: force binding fieldsfindAll / update / delete: add binding-based WHERE conditionsbeforeGet / beforeUpdate / beforeCreate@QueryXXX)You can think of Binding as “automatic ownership filters” configured declaratively on:
@BindingColumn)@BindingValue, useBinding, beforeSuper, request-scoped context)Upsert is NICOT’s idempotent write primitive. Given a set of conflict keys, NICOT will insert a new row if no existing row matches, or update the matched row if it already exists.
Unlike create or update, upsert is a first-class operation with its own semantics:
@UpsertColumn@UpsertColumn() marks entity fields that participate in the upsert conflict key (often called a “natural key”).
Example: upserting an article by slug.
import { Entity } from 'typeorm';
import { IdBase, StringColumn } from 'nicot';
import { UpsertColumn, UpsertableEntity } from 'nicot';
@Entity()
@UpsertableEntity()
export class Article extends IdBase() {
@UpsertColumn()
@StringColumn(64, { required: true, description: 'Unique slug per tenant' })
slug: string;
@StringColumn(255, { required: true })
title: string;
isValidInUpsert() {
return !this.slug ? 'slug is required' : undefined;
}
async beforeUpsert() {}
async afterUpsert() {}
}
Notes:
@UpsertColumn() can be used as conflict keys.isValidInUpsert(), parallel to isValidInCreate() and isValidInUpdate().@UpsertableEntity@UpsertableEntity() marks an entity as upsert-capable and enforces structural correctness.
It guarantees that:
UpsertColumn or BindingColumn (or has an upsert-capable base id)This aligns with PostgreSQL’s requirement for:
INSERT ... ON CONFLICT (...) DO UPDATE
where the conflict target must match a unique index or unique constraint (the primary key also qualifies).
In practice, NICOT builds uniqueness from:
@UpsertColumn() fields@BindingColumn() fieldsunless the conflict key degenerates to the primary key alone.
StringIdBase() and upsert keys (UUID vs manual)NICOT provides StringIdBase() as the string-primary-key base.
StringIdBase({ uuid: true })
@UpsertColumn() (and possibly binding columns)StringIdBase({ uuid: false }) (or omitted)
id alone, meaning this mode behaves as if id is an upsert key out of the boxThis is important when you combine id with additional @UpsertColumn() fields:
id and another field (e.g. slug), then id differs but slug matches should be treated as a different row (because the conflict key is the full tuple).id, do not include id in the conflict key.(Which key set is used is determined by your upsert columns + binding columns + base id behavior.)
When Binding is used, binding columns automatically participate in the upsert conflict key.
import { Entity } from 'typeorm';
import { IdBase, IntColumn, StringColumn } from 'nicot';
import { BindingColumn, BindingValue } from 'nicot';
import { UpsertColumn, UpsertableEntity } from 'nicot';
import { Injectable } from '@nestjs/common';
import { CrudService } from 'nicot';
import { InjectRepository } from '@nestjs/typeorm';
@Entity()
@UpsertableEntity()
export class TenantArticle extends IdBase() {
@BindingColumn('app')
@IntColumn('int', { unsigned: true })
appId: number;
@UpsertColumn()
@StringColumn(64)
slug: string;
@StringColumn(255)
title: string;
}
@Injectable()
export class TenantArticleService extends CrudService(TenantArticle) {
constructor(@InjectRepository(TenantArticle) repo) {
super(repo);
}
@BindingValue('app')
get currentAppId() {
return 44;
}
}
Effective conflict key:
This ensures:
Upsert is exposed as a PUT request on the resource root path.
Factory definition:
import { RestfulFactory } from 'nicot';
export const ArticleFactory = new RestfulFactory(TenantArticle, {
relations: ['author'],
upsertIncludeRelations: true,
skipNonQueryableFields: true,
});
Service:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@Injectable()
export class ArticleService extends ArticleFactory.crudService() {
constructor(@InjectRepository(TenantArticle) repo) {
super(repo);
}
}
Controller:
import { Controller } from '@nestjs/common';
export class UpsertArticleDto extends ArticleFactory.upsertDto {}
@Controller('articles')
export class ArticleController {
constructor(private readonly service: ArticleService) {}
// PUT /articles
@ArticleFactory.upsert()
upsert(@ArticleFactory.upsertParam() dto: UpsertArticleDto) {
return this.service.upsert(dto);
}
}
Upsert returns NICOT’s standard response envelope.
When upsertIncludeRelations is enabled, NICOT will:
relations whitelistWhen disabled, upsert returns only the entity’s own columns, which is usually faster and avoids unnecessary joins.
All NICOT base entities include a soft-delete column (deleteTime).
If an upsert matches a row that is currently soft-deleted:
deleteTime to null during upsertwithDeleted()This makes upsert fully idempotent even across delete–recreate cycles.
slug, code, externalId) as upsert columns.isValidInUpsert().StringIdBase({ uuid: false }), treat id as the natural key by default; add extra upsert columns only when you explicitly want a composite identity.Every findAll() uses offset pagination via PageSettingsDto:
pageCount (1-based)recordsPerPage (default 25).take(recordsPerPage).skip((pageCount - 1) * recordsPerPage)If your entity extends PageSettingsDto, it can control defaults by overriding methods like getRecordsPerPage().
You can also effectively “disable” pagination for specific entities by returning a large value:
@Entity()
export class LogEntry extends IdBase() {
// ...
getRecordsPerPage() {
return this.recordsPerPage || 99999;
}
}
NICOT also supports cursor-based pagination via:
CrudBase.findAllCursorPaginated()RestfulFactory.findAllCursorPaginatedDtoentityCursorPaginationReturnMessageDtoUsage sketch:
class FindAllUserCursorDto extends UserFactory.findAllCursorPaginatedDto {}
@UserFactory.findAllCursorPaginated()
async findAll(
@UserFactory.findAllParam() dto: FindAllUserCursorDto,
) {
return this.service.findAllCursorPaginated(dto);
}
Notes:
paginateType: 'offset' | 'cursor' | 'none' in baseController()).CrudBase<T> holds the core CRUD and query logic:
create(ent, beforeCreate?)findOne(id, extraQuery?)findAll(dto?, extraQuery?)findAllCursorPaginated(dto?, extraQuery?)update(id, dto, cond?)delete(id, cond?)importEntities(entities, extraChecking?)exists(id)onModuleInit() (full-text index loader for Postgres)It honors:
relations → joins + DTO shape)NotInResult / outputFieldsToOmit in responses (cleanEntityNotInResultFields())beforeCreate / afterCreatebeforeGet / afterGetbeforeUpdate / afterUpdateisValidInCreate / isValidInUpdate (return a string = validation error)You usually don’t subclass CrudBase directly; instead you use:
export function CrudService<T extends ValidCrudEntity<T>>(
entityClass: ClassType<T>,
crudOptions: CrudOptions<T> = {},
) {
return class CrudServiceImpl extends CrudBase<T> {
constructor(repo: Repository<T>) {
super(entityClass, repo, crudOptions);
}
};
}
And let RestfulFactory call this for you via factory.crudService().
You can still use TypeORM’s repository methods directly in custom business methods, but when you do, entity lifecycle hooks (
beforeGet(),afterGet(), etc.) are not automatically applied. For NICOT-managed resources, prefer going throughCrudBasewhen you want its behavior.
RestfulFactory<T> is the heart of “entity → DTOs → controller decorators” mapping.
interface RestfulFactoryOptions<T> {
fieldsToOmit?: (keyof T)[];
writeFieldsToOmit?: (keyof T)[];
createFieldsToOmit?: (keyof T)[];
updateFieldsToOmit?: (keyof T)[];
findAllFieldsToOmit?: (keyof T)[];
outputFieldsToOmit?: (keyof T)[];
prefix?: string;
keepEntityVersioningDates?: boolean;
entityClassName?: string;
relations?: (string | RelationDef)[];
skipNonQueryableFields?: boolean;
}
Key ideas:
@NotInResult).v1/users).For a factory:
export const UserFactory = new RestfulFactory(User, { relations: [] });
NICOT gives you:
UserFactory.createDtoUserFactory.updateDtoUserFactory.findAllDtoUserFactory.findAllCursorPaginatedDtoUserFactory.entityResultDtoUserFactory.entityCreateResultDtoUserFactory.entityReturnMessageDtoUserFactory.entityCreateReturnMessageDtoUserFactory.entityArrayReturnMessageDtoUserFactory.entityCursorPaginationReturnMessageDtoRecommended usage:
export class CreateUserDto extends UserFactory.createDto {}
export class UpdateUserDto extends UserFactory.updateDto {}
export class FindAllUserDto extends UserFactory.findAllDto {}
export class UserResultDto extends UserFactory.entityResultDto {}
This keeps types stable and easy to re-use in custom endpoints or guards.
Each factory exposes decorators that match CRUD methods:
create() + createParam()findOne() + idParam()findAll() / findAllCursorPaginated() + findAllParam()update() + updateParam()delete()import() (POST /import)These decorators stack:
Example (revised):
// post.factory.ts
export const PostFactory = new RestfulFactory(Post, {
relations: [], // no relations for this resource
});
// post.service.ts
@Injectable()
export class PostService extends PostFactory.crudService() {
constructor(@InjectRepository(Post) repo: Repository<Post>) {
super(repo);
}
}
// post.controller.ts
import { PutUser } from '../common/put-user.decorator';
export class FindAllPostDto extends PostFactory.findAllDto {}
export class CreatePostDto extends PostFactory.createDto {}
@Controller('posts')
export class PostController {
constructor(private readonly service: PostService) {}
@PostFactory.findAll()
async findAll(
@PostFactory.findAllParam() dto: FindAllPostDto,
@PutUser() user: User,
) {
return this.service.findAll(dto, qb => {
qb.andWhere('post.userId = :uid', { uid: user.id });
});
}
@PostFactory.create()
async create(
@PostFactory.createParam() dto: CreatePostDto,
@PutUser() user: User,
) {
dto.userId = user.id;
return this.service.create(dto);
}
}
baseController() shortcutIf you don’t have extra logic, you can generate a full controller class:
@Controller('users')
export class UserController extends UserFactory.baseController({
paginateType: 'offset', // 'offset' | 'cursor' | 'none'
globalMethodDecorators: [],
routes: {
import: { enabled: false }, // disable /import
},
}) {
constructor(service: UserService) {
super(service);
}
}
routes has enabled: true, then only explicitly enabled routes are generated.enabled: false.This is useful for quickly bootstrapping admin APIs, then selectively disabling / overriding certain endpoints.
Relations are controlled by:
@ManyToOne, @OneToMany, etc.relations whitelist in:
RestfulFactory optionsCrudOptions for CrudService / CrudBaseExample:
@Entity()
export class User extends IdBase() {
@OneToMany(() => Article, article => article.user)
articles: Article[];
}
@Entity()
export class Article extends IdBase() {
@ManyToOne(() => User, user => user.articles)
user: User;
}
If you configure:
export const UserFactory = new RestfulFactory(User, {
relations: ['articles'],
});
Then:
UserResultDto includes articles but not articles.user (no recursive explosion).user.articles when using findOne / findAll.RelationComputedSometimes you want a computed field that conceptually depends on relations, but is not itself a DB column.
Example:
@Entity()
export class Match extends IdBase() {
@ManyToOne(() => Participant, p => p.matches1)
player1: Participant;
@ManyToOne(() => Participant, p => p.matches2)
player2: Participant;
@NotColumn()
@RelationComputed(() => Participant)
players: Participant[];
async afterGet() {
this.players = [this.player1, this.player2].filter(Boolean);
}
}
export const MatchFactory = new RestfulFactory(Match, {
relations: ['player1', 'player2', 'players'],
});
NICOT will:
players as a “computed relation” for pruning rules.players in the result DTO, but not recursively include all fields from Participant.matches1/matches2 etc.NICOT uses a uniform wrapper for all responses:
{
statusCode: number;
success: boolean;
message: string;
timestamp?: string;
data?: any;
}
Types are built via generics:
ReturnMessageDto(Entity) — single payloadPaginatedReturnMessageDto(Entity) — with total, totalPages, etc.CursorPaginationReturnMessageDto(Entity) — with nextCursor, previousCursorBlankReturnMessageDto — for responses with no dataAnd correspondingly in RestfulFactory:
entityReturnMessageDtoentityCreateReturnMessageDtoentityArrayReturnMessageDtoentityCursorPaginationReturnMessageDtoYou can still build custom endpoints and return these wrappers manually if needed.
NICOT’s CRUD flows are TypeORM-based, but by default each repository call is not automatically wrapped in a single database transaction.
If you want “one HTTP request = one DB transaction”, NICOT provides a small TypeORM wrapper:
TransactionalTypeOrmInterceptor() — starts a TypeORM transaction at the beginning of a request and commits or rolls it back when request processing finishes or fails.TransactionalTypeOrmModule.forFeature(...) — provides request-scoped transaction-aware EntityManager / Repository injection tokens, and also includes the TypeORM forFeature() import/export.Use transactional mode when you want:
create/update/delete and custom repo operationsBlankReturnMessageDto(...).toException()Avoid it for:
In the module that owns your resource:
import { Module } from '@nestjs/common';
import { User } from './user.entity';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { TransactionalTypeOrmModule } from 'nicot'; // or your local path
@Module({
imports: [
// ⭐ includes TypeOrmModule.forFeature([User]) internally and re-exports it
TransactionalTypeOrmModule.forFeature([User]),
],
controllers: [UserController],
providers: [UserService],
})
export class UserModule {}
Notes:
TransactionalTypeOrmModule.forFeature(...) are Scope.REQUEST.TypeOrmModule.forRoot(...) (or equivalent) at app root to configure the DataSource.Apply the interceptor to ensure a transaction is created for each HTTP request:
import { Controller, UseInterceptors } from '@nestjs/common';
import { RestfulFactory } from 'nicot';
import { User } from './user.entity';
import { TransactionalTypeOrmInterceptor } from 'nicot';
export const UserFactory = new RestfulFactory(User);
@Controller('users')
@UseInterceptors(TransactionalTypeOrmInterceptor())
export class UserController extends UserFactory.baseController() {
constructor(service: UserService) {
super(service);
}
}
Behavior:
To actually use the transaction context, inject the transactional repo/em instead of the default TypeORM ones.
import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { RestfulFactory } from 'nicot';
import { InjectTransactionalRepository } from 'nicot';
import { User } from './user.entity';
export const UserFactory = new RestfulFactory(User);
@Injectable()
export class UserService extends UserFactory.crudService() {
constructor(
@InjectTransactionalRepository(User)
repo: Repository<User>,
) {
super(repo);
}
}
Now all NICOT CRUD operations (create/findAll/update/delete/import) will run using the transaction-bound repository when the interceptor is active.
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { InjectTransactionalEntityManager } from 'nicot';
@Injectable()
export class UserTxService {
constructor(
@InjectTransactionalEntityManager()
private readonly em: EntityManager,
) {}
async doSomethingComplex() {
// this.em is transaction-bound when interceptor is active
}
}
If you throw a NICOT exception after writing, the transaction will roll back:
@Post('fail')
async fail() {
await this.service.repo.save({ name: 'ROLL' } as any);
throw new BlankReturnMessageDto(404, 'message').toException();
}
Expected:
404NICOT provides an operation abstraction for implementing
atomic, row-level business logic on top of a CrudService.
This abstraction exists on two different layers:
CrudService.operation()RestfulFactory.operation()They are designed to be orthogonal:
You may use either one independently, or combine them.
CrudService.operation())CrudService.operation() executes a callback with:
@BindingColumn, @BindingValue)Internally, it follows this lifecycle:
options.repo is provided)pessimistic_write lock@Injectable()
class UserService extends UserFactory.crudService() {
async disableUser(id: number) {
return this.operation(id, async (user) => {
user.isActive = false;
});
}
}
Return behavior:
void | undefined | nullBlankReturnMessageDto(200, 'success')GenericReturnMessageDto(200, 'success', value)async disableAndReport(id: number) {
return this.operation(id, async (user) => {
user.isActive = false;
return { disabled: true };
});
}
Any exception thrown inside the callback causes a rollback.
async dangerousOperation(id: number) {
return this.operation(id, async () => {
throw new BlankReturnMessageDto(403, 'Forbidden').toException();
});
}
operation() never bypasses NICOT binding rules.
If your entity has:
@BindingColumn()
userId: number;
and your service defines:
@BindingValue()
get currentUserId() {
return this.ctx.userId;
}
then operation() will automatically:
to the current binding scope.
options.repo (integration with transactional interceptors)By default, operation() opens its own transaction.
If you pass a repository via options.repo,
no new transaction will be created.
This is intended for integration with
TransactionalTypeOrmModule and TransactionalTypeOrmInterceptor.
@Injectable()
class UserService extends UserFactory.crudService() {
constructor(
@InjectTransactionalRepository(User)
private readonly repo: Repository<User>,
) {
super(repo);
}
async updateInsideRequestTransaction(id: number) {
return this.operation(
id,
async (user) => {
user.name = 'Updated in request transaction';
},
{
repo: this.repo,
},
);
}
}
This allows:
RestfulFactory.operation())RestfulFactory.operation() does not implement any business logic.
It only:
Think of it as a declarative endpoint generator, not an executor.
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@UserFactory.operation('disable')
async disable(@UserFactory.idParam() id: number) {
return this.userService.disableUser(id);
}
}
This generates:
POST /users/:id/disable@UserFactory.operation('reset-password', {
returnType: ResetPasswordResultDto,
})
async resetPassword(@UserFactory.idParam() id: number) {
return this.userService.resetPassword(id);
}
The recommended pattern is:
CrudService.operation()RestfulFactory.operation()This gives you:
@Injectable()
class UserService extends UserFactory.crudService() {
async disableUser(id: number) {
return this.operation(id, async (user) => {
user.isActive = false;
});
}
}
@Controller('users')
class UserController {
constructor(private readonly userService: UserService) {}
@UserFactory.operation('disable')
disable(@UserFactory.idParam() id: number) {
return this.userService.disableUser(id);
}
}
operation() is not a CRUD replacementIn NICOT:
CRUD is declarative
Operations express intent
*.factory.ts file.
@NotWritable, @NotInResult, @NotQueryable)@QueryXXX)skipNonQueryableFields: true@QueryXXX only on fields you really want public filtering on.CrudService / CrudBase for NICOT-managed resources, so:
MIT