typeorm-foundation
v0.8.0
Published
Base repository and entity validation building blocks for TypeORM.
Maintainers
Readme
typeorm-foundation
This is an opinionated library to provide the missing pieces in daily life with typeorm.
It provides a foundation repository and entity validation building blocks for TypeORM: a
class-validator-backed validation pipeline wired into insert/update/upsert,
plus a repository extension with safe upserts and a way to add your own
methods to every repository the factory creates.
Install
pnpm add typeorm-foundationPeer dependencies (bring your own versions): typeorm, class-validator.
Quick start
import { DataSource } from 'typeorm';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { IsEmail } from 'class-validator';
import { createRepositoryFactory, isDirty, IsUnique } from 'typeorm-foundation';
@Entity('users')
class UserEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text' })
@IsEmail()
@IsUnique({ validateIf: (user) => isDirty(user, 'email') })
email!: string;
constructor(values: Partial<UserEntity> = {}) {
Object.assign(this, values);
}
}
const dataSource = new DataSource({ /* ... */ });
const createFoundationRepository = createRepositoryFactory();
const UserRepository = createFoundationRepository(dataSource.getRepository(UserEntity));
const user = await UserRepository.insertEntity(new UserEntity({ email: '[email protected]' }));
await UserRepository.updateEntity(user, { email: '[email protected]' });A repository built this way validates on every
insertEntity/updateEntity/upsertEntity call and throws a
ValidationError (one message per invalid field) if any decorator fails.
createRepositoryFactory
Returns createFoundationRepository(repository), which takes a plain TypeORM
Repository and extends it with everything below. No DataSource is needed —
the entity target, metadata and data source all come from the repository you
hand in.
The optional argument is a plain object of extra methods, added onto every
repository the factory creates. Inside those methods, this is typed as the
full repository — the underlying TypeORM Repository, every built-in method
below, and every other extension method — so they can call
this.createQueryBuilder(...), this.insertEntity(...), this.manager, or
each other:
export const createFoundationRepository = createRepositoryFactory({
async findManyByIds(ids: string[]) {
return this.createQueryBuilder(this.metadata.tableName)
.whereInIds(ids)
.getMany();
},
});
export const UserRepository = createFoundationRepository(AppDataSource.getRepository(UserEntity));Pass nothing and every repository is just the built-ins below, with no extras.
Since one extensions object is shared across every repository, this inside
those methods is typed against a generic entity rather than the specific one a
given repository is for. Anything that needs the concrete entity type belongs
on the individual repository instead — either via TypeORM's own .extend(...)
on the result, or via override(...), where this is entity-specific:
export const UserRepository = createFoundationRepository(AppDataSource.getRepository(UserEntity)).extend({
async findByEmail(email: string) {
return this.findOne({ where: { email } }); // `this` is entity-specific here
},
});Each repository built from it is a TypeORM Repository<Entity> extended with:
insertEntity(entity)— validates, then inserts.updateEntity(entity, updates)— validates, appliesupdates, runsbeforeUpdate/afterUpdatelisteners, and issues a singleUPDATEonly for columns that actually changed. No-ops (skipping validation and listeners) whenupdatesis empty. Automatically bumps anyisUpdateDatecolumn not explicitly included inupdates.upsertEntity(entity, { updates, key? })— validates, then runsINSERT ... ON CONFLICT (key) DO UPDATE ... RETURNING *, hydrating the returned row (including any column transformers) back ontoentity.updatesis either a list of property names to write or an object of values to merge onto the entity first. Needs aRETURNING-capable driver (only tested against Postgres here).upsertOrFailBy(findCondition, updates)—updateEntityif a row matchingfindConditionexists, otherwiseinsertEntity.removeEntity(entity)— removes a clone ofentityso the original object (and its primary key) is left untouched.reload(entity)— re-fetchesentityby primary key, throwingNotFoundErrorif it's gone.override(methods)— assignsmethodsonto one repository instance (mutating and returningthis). Unlike the factory'sextensions,thishere is typed against the concrete entity, because it runs on an already-built repository. Use it for anything needing real entity types, and the factory'sextensionsfor methods every repository should have.validateEntityOrFail(entity, fields)— no-op by default; override per repository (viaoverride(...)) to add validation beyond what decorators express. Called withfields: nullon insert/upsert and the list of changed keys on update.
FoundationRepository<Entity> is the type of what createFoundationRepository(repository)
returns — useful for typing a function that accepts one of these repositories,
or a class of your own that wraps one.
Errors
Everything this library throws extends FoundationError, an abstract
Error subclass, so catch (error) { if (error instanceof FoundationError) ... }
catches anything this library raises, as opposed to an error from your own code
or a dependency. Being abstract, FoundationError can't be constructed
directly — throw one of the concrete errors below (ValidationError is the one
you'd normally raise from your own validateEntityOrFail override).
ArgumentError— thrown when a call into the repository is malformed rather than the data being invalid:upsertEntitywith an emptyupdateslist or with anupdates/keyentry that maps to no column, andupdateEntity/reloadon an entity whose class declares no primary key or whose primary key isn't set.MissingValidationContextError— thrown when something that needs the validation context runs outside it, i.e.isNew/isChanged/isDirtyor one of theValidateWith/References/IsUniquedecorators called outside avalidateOrFailrun.NotFoundError— thrown byreloadwhen the entity no longer exists.ValidationError— thrown byvalidateOrFail(and so byinsertEntity/updateEntity/upsertEntity);error.errorsis aRecord<field, string[]>of every failing message, grouped by property. Its constructor also accepts a plain string (new ValidationError('something went wrong')), filed under thebasekey, for a validation failure that isn't tied to one field — e.g. from your ownvalidateEntityOrFailoverride.
Use plain instanceof to narrow a caught unknown to one of these errors. It
is safe even under dual module loading: each error class is registered on
globalThis under a version-scoped Symbol.for(...) key, so the ESM and CJS
builds of this package — or two copies of it in one dependency tree — resolve
to the very same class object, and an error thrown through one import matches
instanceof through the other. (Genuinely different versions of the package
get different keys, and so stay separate classes, as they should.)
For example, reload throws NotFoundError when the row has been deleted
since the entity was loaded, which is usually a case you want to handle rather
than propagate:
import { NotFoundError } from 'typeorm-foundation';
async function refreshUser(user: UserEntity) {
try {
return await UserRepository.reload(user);
} catch (error) {
if (!(error instanceof NotFoundError)) throw error;
// Someone deleted the row in the meantime.
return null;
}
}Re-throwing anything the check rejects keeps unrelated failures (a dropped
connection, an ArgumentError from an unset primary key) from being swallowed
as a missing row.
insertEntity, updateEntity and upsertEntity validate before writing and
throw ValidationError if any decorator on the entity fails, so nothing reaches
the database. Catch it to turn a failed write into a per-field response:
import { ValidationError } from 'typeorm-foundation';
try {
await UserRepository.insertEntity(new UserEntity({ email: 'not-an-email' }));
} catch (error) {
if (!(error instanceof ValidationError)) throw error;
error.errors; // { email: ['must be an email'] }
error.message; // 'email: must be an email'
}errors holds every failing message, grouped by property, with the leading
property name stripped from each message so you can render it next to your own
field label. message is those same entries flattened into one string.
error.name and ValidationError's errors are readonly.
Validation
Additional decorators build on class-validator. class-validator doesn't
allow passing a context, so validateOrFail sets up an AsyncLocalStorage
context that gives decorators access to the transactional entity manager,
which insertEntity/updateEntity/upsertEntity set up automatically.
The decorators below query the database, so they must not see values that
class-validator has already rejected — a non-uuid string reaching a uuid
column makes the driver raise instead of the validation failing cleanly.
validateOrFail therefore validates in two passes: the first runs the standard
class-validator decorators, the second runs the ones below, each skipped
unless every property it depends on survived the first pass. A decorator
depends on the property it is attached to, plus IsUnique's scope columns,
plus anything named in its dependencies option — declare that whenever a
callback reads other properties off the entity:
@Column({ type: 'text' })
@ValidateWith<BookingEntity, 'endsAt'>((value, booking) =>
value < booking.startsAt ? 'must be after the start' : undefined, { dependencies: ['startsAt'] })
endsAt!: Date;Without that, endsAt would be checked against a startsAt that the first
pass had already rejected.
The second pass only has something to check if the properties it depends on carry standard decorators, so give every property with a decorator from this library the type validation its column needs — otherwise nothing can fail in the first pass and the value reaches the query unchecked:
@Column({ type: 'text', nullable: true })
@IsOptional()
@IsUUID()
@References<UserEntity, 'teamId', TeamEntity>(() => TeamEntity)
teamId!: string | null;ValidateWith(validate, { dependencies? })— property decorator;validate(value, entity, entityManager)returns an error string (or aPromiseof one) to fail,undefinedto pass. The returned string is the message, so there is no separatemessageoption — to reuse a shared predicate with a per-property message, wrap it:ValidateWith((value) => isReserved(value) ? 'is not allowed' : undefined).References(() => RelatedEntity, { foreignKey?, primaryKey?, validate?, validateIf?, dependencies?, message? })— fails unless aRelatedEntityrow exists whoseprimaryKeycolumns equal this entity'sforeignKeycolumns, or if anyforeignKeycolumn isnull/undefined.foreignKeydefaults to the decorated property andprimaryKeytoRelatedEntity's primary columns, in declaration order; both take a single property or an array, paired by position, so a composite key is just two arrays.primaryKeycan name other columns than the real primary key, but they must be unique together: nothing checks that, and on duplicates the lookup picks an arbitrary row, sovalidatemay see the wrong one.foreignKeymust include the decorated property, and everyforeignKeycolumn is a dependency, so an invalid one skips the lookup. A misconfigured decorator throwsArgumentError: arrays of different lengths or aforeignKeywithout the decorated property when the class is defined, a length mismatch with the defaultprimaryKeyor a related entity without primary key on the first validation, even ifvalidateIfor anullvalue would skip the lookup. Only theprimaryKeycolumns are selected unless the optionalvalidate(relatedEntity, entity)callback is given, which receives the full row and can reject further (e.g. a status check), returning an error string the same wayValidateWithdoes.@References<PaymentEntity, 'currencyCode', CurrencyEntity>(() => CurrencyEntity, { primaryKey: 'code' }) currencyCode!: string; @References<AssignmentEntity, 'membershipId', MembershipEntity>(() => MembershipEntity, { foreignKey: ['organizationId', 'membershipId'], primaryKey: ['organizationId', 'id'], }) membershipId!: string;IsUnique({ scope?, caseInsensitive?, validateIf?, dependencies?, message? })— fails if another row (excluding the entity's own primary key) already has this value, optionally scoped to a set of sibling columns.caseInsensitivemakes the comparison ignore case by normalising both sides with SQLUPPER()('upper') orLOWER()('lower') — pick whichever matches a functional index you have, so the lookup can still use it. Left undefined (the default), the value is compared as-is. It applies to the decorated property only, not toscopecolumns, and only when the value is a string.isNew(entity)/isChanged(entity, property)/isDirty(entity, property)— call from inside a validator (or an entity's own@ValidateIf) to check the entity against the pre-update snapshot:isNewis true when there is no snapshot (an insert),isChangedcompares the property to the snapshot,isDirtyisisNew(entity) || isChanged(entity, property).validateOrFail({ entity, entityManager, original })— runs both passes described above overentityand throwsValidationError(see Errors) if any decorator fails;originalis the pre-update snapshot (ornullfor an insert) thatisNew/isChanged/isDirtyread from. Standardclass-validatordecorators run in both passes, so keep your own custom decorators free of side effects, or give them avalidateIfthat makes the second run cheap.
Please note: typeorm has no real dirty tracking. Therefore, when using
insertEntity everything is assumed to be changed/dirty and isNew returns
true. When using updateEntity, isChanged/isDirty compare each property to
the pre-update snapshot by reference (!==), so an object or array passed as a new
instance counts as changed even when its contents are equal, while one mutated in
place and passed back as the same instance does not.
Testing
Tests run against a real database rather than a mocked DataSource. Postgres
is the default; start it (and MySQL, for the DATABASE=mysql run) with
docker compose up -d, then pnpm test. Switch database with the DATABASE
env var:
pnpm test # postgres (localhost:5544, docker compose)
DATABASE=mysql pnpm test # mysql (localhost:3306, docker compose)
DATABASE=sqlite pnpm test # better-sqlite3, in-memory, no service neededupsertEntity relies on RETURNING, which MySQL and SQLite don't support
(only MariaDB does, not plain MySQL) — its tests are skipped outside of
DATABASE=postgres. CI runs the suite against all three.
