J4 · PostgreSQL et TypeORM
TypeORM tient à la fois le rôle de JPA/Hibernate et celui de Spring Data : des entités décorées, un Repository<T> par entité, un QueryBuilder pour le reste. Trois habitudes Spring ne fonctionnent plus : les requêtes dérivées du nom de méthode, le Page<T> intégré, et le contexte de persistance qui enregistre seul les modifications. Ce chapitre branche PostgreSQL dans TaskFlow et remplace le stockage en mémoire des projets par TypeORM, sans toucher au contrôleur.
Objectifs
- Brancher TypeORM avec
@nestjs/typeorm:forRoot,forRootAsync,forFeature. - Écrire des entités et choisir les types PostgreSQL :
uuid,timestamptz,jsonb, enums. - Utiliser
Repository<T>(find,findOne,save,softDelete) et leQueryBuilder. - Comprendre pourquoi
synchronize: trueest réservé au développement. - Persister les projets dans PostgreSQL sans changer le contrôleur, et alimenter la base de développement avec un seed.
Spring → NestJS
| Spring | NestJS + TypeORM | À retenir |
|---|---|---|
spring.datasource.* + auto-configuration JPA | TypeOrmModule.forRoot() / forRootAsync() | Configuré une fois, dans le module racine |
interface ProjectRepository extends JpaRepository<Project, UUID> | @InjectRepository(Project) repo: Repository<Project> + TypeOrmModule.forFeature([Project]) | Pas d'interface à déclarer : le repository est générique |
findByNameContaining(name) (ou …IgnoreCase) | find({ where: { name: ILike('%x%') } }) ou un QueryBuilder | Pas de requêtes dérivées du nom de méthode |
Page<T> + Pageable | findAndCount({ skip, take }) → [items, total] | La page se construit à la main |
@Query("select p from Project p …"), Criteria API | createQueryBuilder('project') | Proche du SQL, paramètres nommés |
@Id @GeneratedValue(strategy = UUID) | @PrimaryGeneratedColumn('uuid') | Généré par PostgreSQL (uuid_generate_v4()) |
@CreationTimestamp, @UpdateTimestamp | @CreateDateColumn, @UpdateDateColumn | Remplis par TypeORM |
@Enumerated(EnumType.STRING) | @Column({ type: 'enum', enum: TaskStatus }) | TypeORM stocke les valeurs, jamais l'ordinal |
Soft delete Hibernate (@SQLDelete + restriction) | @DeleteDateColumn + softDelete() | Les find* excluent les lignes supprimées |
spring.jpa.hibernate.ddl-auto=update | synchronize: true | Développement seulement ; migrations ensuite (J5) |
| Contexte de persistance, dirty checking | Rien d'équivalent | Une modification n'est écrite qu'au save() |
Optional<T> renvoyé par findById | T | null renvoyé par findOneBy | Le compilateur (strict) force le test de null |
Comprendre
@nestjs/typeorm : forRoot, forRootAsync, forFeature
npm i @nestjs/typeorm typeorm pgLe module d'intégration expose trois points d'entrée :
TypeOrmModule.forRoot(options)crée laDataSource(le pool de connexions) au démarrage, avec des options écrites en dur.TypeOrmModule.forRootAsync({ inject, useFactory })fait la même chose avec des options calculées à partir d'autres providers, typiquement la configuration. C'est un module dynamique.TypeOrmModule.forFeature([Project, ProjectMember]), dans un module de domaine, enregistre un providerRepository<T>par entité. On l'injecte avec@InjectRepository(Project), dont le token vaut la chaîne"ProjectRepository".
// Exemple hors TaskFlow : options écrites en dur
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'taskflow',
password: 'taskflow',
database: 'taskflow',
autoLoadEntities: true,
synchronize: false,
});TaskFlow lit ses options dans la configuration typée (J7) :
@Module({
imports: [
TypeOrmModule.forRootAsync({
inject: [databaseConfig.KEY],
useFactory: (db: DatabaseConfig) => ({
...postgresOptions(db),
// Picks up every entity registered with TypeOrmModule.forFeature().
autoLoadEntities: true,
}),
}),
],
})
export class DatabaseModule {}Les options communes sont dans une fonction partagée avec le CLI TypeORM, pour que l'application et les migrations visent la même base avec les mêmes réglages :
// Shared by the Nest app (database.module.ts) and the TypeORM CLI
// (data-source.ts), so migrations run against the same settings.
export function postgresOptions(db: DatabaseConfig): DataSourceOptions {
return {
type: 'postgres',
...db,
// Never true outside a throwaway database: it alters tables to match the
// entities and can drop columns with their data. Use migrations instead.
synchronize: false,
migrations: [join(__dirname, 'migrations', '*.{ts,js}')],
migrationsTableName: 'typeorm_migrations',
};
}autoLoadEntities: trueajoute à laDataSourcechaque entité enregistrée par unforFeature(). Une entité seulement atteinte par une relation, sansforFeature, n'est pas chargée. Dans TaskFlow, chaque entité a sonforFeature(ProjectetProjectMemberdansProjectsModule,TaskdansTasksModule,UserdansUsersModule).- Le CLI TypeORM ne démarre pas Nest : il a sa propre
DataSource, qui trouve les entités par un motif de fichiers (J5). - La
DataSourceelle-même est injectable par sa classe (private readonly dataSource: DataSource) : elle sert aux transactions (J5).
Si PostgreSQL ne répond pas au démarrage, le module réessaie : par défaut 10 tentatives espacées de 3 s, chacune journalisée avec « Unable to connect to the database ». Une application qui « bloque » une trentaine de secondes au lancement a souvent simplement oublié docker compose up -d.
Entités et colonnes
Une entité est une classe décorée par @Entity('nom_de_table') ; chaque propriété persistée porte un décorateur de colonne.
| Décorateur | Rôle | Équivalent JPA |
|---|---|---|
@Entity('projects') | Classe ↔ table | @Entity + @Table(name = "projects") |
@PrimaryGeneratedColumn('uuid') | Clé primaire générée par la base | @Id @GeneratedValue |
@PrimaryColumn() | Clé primaire fournie par le code ; plusieurs = clé composite | @Id, @EmbeddedId |
@Column({ type, length, nullable, default, name }) | Colonne | @Column |
@CreateDateColumn(), @UpdateDateColumn() | Dates remplies à l'insertion et à la mise à jour | @CreationTimestamp, @UpdateTimestamp |
@DeleteDateColumn() | Marqueur de suppression logique | Soft delete Hibernate |
@Index('nom', ['col1', 'col2']) sur la classe | Index | @Table(indexes = …) |
Deux différences avec Spring Boot :
- Noms de colonnes. Spring Boot convertit
createdAtencreated_atpar défaut. TypeORM garde le nom de la propriété ("createdAt", avec majuscule, donc à guillemeter en SQL). TaskFlow écritname: 'created_at'pour chaque colonne composée. - Le type de colonne s'écrit explicitement. Sans
type, TypeORM le déduit de la métadonnéedesign:typeémise par TypeScript. Or, en modestrict,description: string | nullémetObject, et le démarrage échoue avecData type "Object" in "Project.description" is not supported by "postgres" database.TaskFlow donne toujourstype(métadonnées de types).
Les propriétés d'entité n'ont pas d'initialiseur : c'est possible parce que le tsconfig.json désactive strictPropertyInitialization. repository.create({ ... }) construit une instance sans aucune requête, comme un new.
Types PostgreSQL : uuid, timestamptz, jsonb, enums
| Besoin | Type PostgreSQL | Déclaration TypeORM | Valeur côté JavaScript |
|---|---|---|---|
| Identifiant | uuid | @PrimaryGeneratedColumn('uuid'), @Column({ type: 'uuid' }) | string |
| Instant (création, modification) | timestamptz | @CreateDateColumn({ type: 'timestamptz' }) | Date |
| Jour calendaire (échéance) | date | @Column({ type: 'date' }) | string au format 'YYYY-MM-DD' |
| Document JSON | jsonb | @Column({ type: 'jsonb' }) | objet |
| Ensemble fermé de valeurs | type ENUM | @Column({ type: 'enum', enum: TaskStatus, enumName: 'task_status' }) | valeur de l'enum TypeScript |
| Texte | varchar(n), text | @Column({ type: 'varchar', length: 100 }) | string |
timestamptzstocke un instant absolu, l'équivalent d'unInstant. Untimestampsans fuseau dépend du fuseau du serveur et de la session : évitez-le pour des dates d'événements.daterevient sous forme de chaîne'YYYY-MM-DD', pas deDate: c'est pourquoiTask.dueDateest typéstring | null(J5). C'est la sémantique d'unLocalDate.enumNamedonne au type PostgreSQL un nom stable (task_status,user_role), lisible dans les migrations. Ajouter une valeur à l'enum TypeScript demande une migration.jsonbstocke un document indexable, utile pour des données sans schéma fixe :
// Exemple hors TaskFlow
@Column({ type: 'jsonb', nullable: true })
settings: Record<string, unknown> | null;Interroger l'intérieur du document passe par le QueryBuilder et les opérateurs PostgreSQL, par exemple .where("project.settings ->> 'theme' = :theme", { theme: 'dark' }).
Repository : find, findOne, save, softDelete
Repository<T> regroupe les opérations courantes. Les where sont des objets typés d'après l'entité, et des opérateurs (ILike, In, LessThan, IsNull, Not, Between…) expriment le reste.
| Méthode | Effet | Spring Data |
|---|---|---|
find(options), findBy(where) | Liste ; options accepte where, order, skip, take, relations, withDeleted | findAll(...), requêtes dérivées |
findOne(options), findOneBy(where) | Premier résultat ou null | findById() → Optional |
findOneByOrFail(where) | Lève EntityNotFoundError si rien | orElseThrow() |
findAndCount(options) | [items, total] : la page découpée par skip / take, et le total des lignes qui correspondent | Page<T> |
existsBy(where) | boolean | existsBy... |
create(data) | Instance, sans SQL | new |
save(entity) | INSERT ou UPDATE, renvoie l'entité avec les valeurs générées | save() |
merge(entity, changes) | Copie les changements dans l'entité | — |
insert(data), update(where, changes) | Une requête directe, sans charger l'entité ni cascade | @Modifying @Query |
softDelete(where), restore(where) | Remplit ou vide deleted_at | Soft delete Hibernate |
delete(where) | DELETE | deleteById() |
La correspondance du plan, sur un exemple :
// Spring Data
Page<Project> findByNameContainingIgnoreCase(String name, Pageable pageable);// Exemple hors TaskFlow : l'équivalent TypeORM
const [items, total] = await this.projects.findAndCount({
where: { name: ILike(`%${name}%`) },
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});TaskFlow construit la page lui-même avec l'interface Page<T> de pagination.dto.ts (J2), et échappe les jokers de recherche (cas TaskFlow).
Là où l'analogie avec JPA casse :
- Pas de contexte de persistance. Modifier une entité chargée n'écrit rien : il faut appeler
save(). Il n'y a ni dirty checking, ni cache de premier niveau, ni proxy paresseux (donc pas deLazyInitializationException). save()lit avant d'écrire. Pour une entité qui a un id, il relit la ligne afin de choisir entreINSERTetUPDATEet de n'écrire que ce qui a changé. Pour modifier beaucoup de lignes,update()envoie un seulUPDATE: c'est ce que fait le marquage des tâches en retard (J9).- Soft delete intégré. Avec un
@DeleteDateColumn,softDelete()remplit la colonne et tous lesfind*excluent la ligne ;withDeleted: truela fait réapparaître,restore()l'annule. undefinedetnulldans unwheresont refusés. TypeORM 1.1 lève par défaut uneTypeORMError(« Undefined value encountered in property … of a where condition ») au lieu d'ignorer la propriété en silence, comme le faisaient les versions précédentes. Construisez lewhereavec les seuls filtres présents, et utilisezIsNull()pour chercher unNULLSQL.
QueryBuilder
Les options de find couvrent les filtres simples. Pour une jointure avec condition, un agrégat, une sous-requête ou un opérateur propre à PostgreSQL, utilisez le QueryBuilder. C'est l'équivalent d'une requête JPQL @Query ou de la Criteria API, en plus proche du SQL : on désigne les propriétés par alias.propriété, et les valeurs passent toujours par des paramètres nommés (:search), jamais par concaténation.
// Exemple hors TaskFlow : nombre de tâches par statut dans un projet
const rows = await this.tasks
.createQueryBuilder('task')
.select('task.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('task.projectId = :projectId', { projectId })
.groupBy('task.status')
.getRawMany<{ status: TaskStatus; count: string }>();| Terminaison | Renvoie |
|---|---|
getMany(), getOne() | Des entités |
getManyAndCount() | [entités, total], pour la pagination |
getRawMany(), getRawOne() | Des lignes brutes (agrégats, colonnes calculées) |
execute() | Le résultat d'un insert(), update() ou delete() construit |
COUNT(*)renvoie unbigint, que le pilotepgtransmet en chaîne : d'oùcount: string.- Avec des jointures, paginez avec
skip()/take(), qui comptent des entités, et non avecoffset()/limit(), qui comptent des lignes SQL.
TaskFlow utilise les deux styles dans la même méthode (recherche et pagination).
synchronize : en développement seulement
Avec synchronize: true, TypeORM compare à chaque démarrage les entités au schéma et exécute les CREATE, ALTER et DROP nécessaires pour les aligner. C'est ddl-auto=update, en plus brutal : TypeORM ne détecte pas les renommages, donc renommer une propriété supprime la colonne (et ses données) puis en crée une vide.
C'est acceptable sur une base jetable, le temps de J4, quand le schéma change toutes les heures :
// Exemple hors TaskFlow : réglage temporaire de J4, supprimé en J5
synchronize: process.env.NODE_ENV === 'development',Jamais en production
synchronize: true sur une base qui contient des données réelles peut supprimer des colonnes au redémarrage suivant. Dès que le schéma se stabilise, passez aux migrations (J5). TaskFlow, dans sa version finale, a synchronize: false partout. Voir synchronize en production.
Cas TaskFlow
L'entité Project
@Entity('projects')
export class Project {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'boolean', default: false })
archived: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// softDelete() fills this column; normal find() calls then skip the row.
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
@OneToMany(() => Task, (task) => task.project)
tasks: Task[];
@OneToMany(() => ProjectMember, (member) => member.project)
members: ProjectMember[];
}- Le plan demande
id(UUID),name,description,createdAt,updatedAt. TaskFlow ajoutearchived(archivage, J5),deletedAt(suppression logique) et deux relations, ajoutées en J5 et J8 (relations). description: string | nullet@Column({ type: 'text', nullable: true })disent la même chose, l'un au compilateur, l'autre à la base. Le type explicite évite le piège dedesign:type.@DeleteDateColumnsuffit à rendresoftDelete()et le filtrage automatique opérationnels.- La table correspondante (
CREATE TABLE "projects" …) est visible dans la migration de J5.
TypeORM derrière ProjectsStore
TypeOrmProjectsStore implémente la même interface ProjectsStore que le store en mémoire (J3). Son constructeur reçoit @InjectRepository(Project), @InjectRepository(ProjectMember) et la DataSource. Les méthodes simples sont directes :
// findOneBy() skips soft-deleted rows (deleted_at IS NOT NULL) automatically.
findById(id: string): Promise<Project | null> {
return this.projects.findOneBy({ id });
}
async update(id: string, changes: UpdateProjectDto): Promise<Project | null> {
const project = await this.findById(id);
if (!project) return null;
// merge() copies the DTO onto the loaded entity; save() issues the UPDATE.
return this.projects.save(this.projects.merge(project, changes));
}
async softDelete(id: string): Promise<boolean> {
// UPDATE ... SET deleted_at = now(): the row and its relations stay.
const result = await this.projects.softDelete({ id });
return (result.affected ?? 0) > 0;
}findOneBy({ id })exclut les projets supprimés logiquement :findById()renvoie alorsnull, et le service répond 404 (J3).update()relit le projet,merge()copie le DTO sur l'entité chargée,save()émet l'UPDATE.softDelete()remplitdeleted_at;result.affectedindique si une ligne a changé.
La table des membres a ses propres méthodes :
isMember(projectId: string, userId: string): Promise<boolean> {
return this.members.existsBy({ projectId, userId });
}
// INSERT ... ON CONFLICT DO NOTHING: adding an existing member is not an error.
async addMember(projectId: string, userId: string): Promise<void> {
await this.members
.createQueryBuilder()
.insert()
.values({ projectId, userId })
.orIgnore()
.execute();
}existsBy()répond par un booléen sans charger la ligne.orIgnore()produit unINSERT … ON CONFLICT DO NOTHING: ajouter un membre déjà présent n'est pas une erreur.- Une suppression logique du projet laisse ses lignes
project_membersen place :isMember()reste vrai pour un projet supprimé, d'où la vérification d'existence que fait aussiProjectsService.findOne(). create()etarchive()écrivent dans plusieurs tables : ils passent par une transaction (J5).
Le choix de l'implémentation se fait dans le module, par la variable PROJECTS_STORE :
@Module({
imports: [TypeOrmModule.forFeature([Project, ProjectMember])],
controllers: [ProjectsController],
providers: [
ProjectsService,
InMemoryProjectsStore,
TypeOrmProjectsStore,
// A @Bean method chosen by configuration, like @ConditionalOnProperty.
{
provide: PROJECTS_STORE,
inject: [ConfigService, InMemoryProjectsStore, TypeOrmProjectsStore],
useFactory: (config: ConfigService, memory: InMemoryProjectsStore, typeorm: TypeOrmProjectsStore) =>
config.get('PROJECTS_STORE') === 'memory' ? memory : typeorm,
},
],
// TasksModule needs assertAccess(): export the service, not the store.
exports: [ProjectsService],
})
export class ProjectsModule {}TypeOrmModule.forFeature([Project, ProjectMember]) fournit les deux repositories. Avec PROJECTS_STORE=typeorm (la valeur par défaut, voir .env.example), l'API persiste dans PostgreSQL ; avec memory, elle revient au stockage de J2. Le contrôleur et le service n'ont pas changé d'une ligne : les tests unitaires de ProjectsService tournent sur le store en mémoire, les tests e2e sur PostgreSQL, avec les mêmes classes.
Recherche et pagination
async findPage(query: ProjectQueryDto, memberId?: string): Promise<Page<Project>> {
if (!memberId) {
// Simple filters: find options, the closest thing to a derived query.
// Build `where` from the filters actually present, never with undefined.
const where: FindOptionsWhere<Project> = {};
if (query.search) where.name = ILike(likePattern(query.search));
if (query.archived !== undefined) where.archived = query.archived;
const [items, total] = await this.projects.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: query.skip,
take: query.limit,
});
return { items, total, page: query.page, limit: query.limit };
}
// A join on the membership table: the QueryBuilder is clearer here.
const qb = this.projects
.createQueryBuilder('project')
.innerJoin('project.members', 'member', 'member.userId = :memberId', { memberId })
.orderBy('project.createdAt', 'DESC')
.skip(query.skip)
.take(query.limit);
if (query.search) qb.andWhere('project.name ILIKE :search', { search: likePattern(query.search) });
if (query.archived !== undefined) qb.andWhere('project.archived = :archived', { archived: query.archived });
const [items, total] = await qb.getManyAndCount();
return { items, total, page: query.page, limit: query.limit };
}- Vue administrateur (
memberIdabsent) : de simples options defind, la forme la plus proche d'une requête dérivée.findAndCountrenvoie la page et le total en un appel. - Vue membre : il faut joindre la table des membres avec une condition sur l'utilisateur. Le
QueryBuilderl'exprime plus clairement (innerJoinsur la relationproject.members), etgetManyAndCount()joue le rôle defindAndCount. - Le
wheren'est construit qu'avec les filtres présents :where.name = undefinedlèverait une erreur en TypeORM 1.1 (repository). ILikeproduit unILIKEPostgreSQL. La fonctionlikePattern, en tête du fichier, échappe%,_et\: une recherche de50%trouve le texte « 50% » au lieu de tout ce qui commence par « 50 ».
Le résultat a la forme Page<T> vue en J2 : { items, total, page, limit }.
Seed de développement
// Development data: npm run seed. Safe to run twice (skips if the admin exists).
async function seed() {
await dataSource.initialize();
try {
await dataSource.transaction(async (manager) => {
const email = 'admin@taskflow.local';
if (await manager.existsBy(User, { email })) return;
const admin = await manager.save(
manager.create(User, {
email,
passwordHash: await argon2.hash(process.env.SEED_ADMIN_PASSWORD ?? 'admin-password-dev'),
role: Role.Admin,
}),
);
const project = await manager.save(
manager.create(Project, { name: 'TaskFlow demo', description: 'Seeded for local development' }),
);
await manager.insert(ProjectMember, { projectId: project.id, userId: admin.id });
await manager.insert(Task, [
{ title: 'Read the NestJS overview', status: TaskStatus.Done, projectId: project.id },
{ title: 'Write the first migration', status: TaskStatus.InProgress, projectId: project.id, dueDate: '2026-11-16' },
{ title: 'Add JWT authentication', projectId: project.id, dueDate: '2026-11-19', assigneeId: admin.id },
]);
});
} finally {
await dataSource.destroy();
}
}- Le script tourne hors de Nest : il réutilise la
DataSourcedu CLI (data-source.ts), qui lit le même fichier.env.developmentque l'application. - Il est idempotent : si l'administrateur existe, il ne fait rien. Vous pouvez le relancer sans dupliquer les données.
- Tout est écrit dans une seule transaction (J5) : un seed à moitié appliqué est pire que pas de seed.
- Le bloc
finallyferme les connexions (dataSource.destroy()), y compris quand le seed échoue. - Le mot de passe vient de
SEED_ADMIN_PASSWORD, avec une valeur par défaut réservée au développement ; il est haché avec argon2 (J8). En J4, un seed qui ne crée que des projets suffit ; utilisateurs et tâches s'y ajoutent avec J5 et J8.
docker compose up -d
npm run migration:run # crée les tables (J5) ; en J4, synchronize s'en chargeait
npm run seed # admin@taskflow.local + un projet de démonstration et ses tâches
npm run seed # seconde exécution : rien n'est crééL'équivalent Spring serait un CommandLineRunner activé par un profil, ou un data.sql ; ici, c'est un script npm séparé, qui ne s'exécute jamais par accident en production.
Situations
- Vous devez chercher « contient », sans tenir compte de la casse →
ILikeavec un motif échappé (recherche). - Il faut une page avec le nombre total de résultats →
findAndCount({ skip, take })et unPage<T>construit à la main (repository). - La requête a une jointure filtrée ou un agrégat →
createQueryBuilder()(QueryBuilder). - Une suppression doit rester récupérable →
@DeleteDateColumn+softDelete(),restore()pour annuler (repository) ; le contrôle d'accès vérifie alors l'existence de la ligne en plus de l'appartenance (TypeORM derrière le store). - Les paramètres de connexion viennent de la configuration →
TypeOrmModule.forRootAsync({ inject, useFactory })(module). - Vous voulez des données de démonstration en local → un script de seed idempotent, lancé à la main (seed).
- Une échéance revient en chaîne et non en
Date→ c'est le comportement d'une colonnedate(types). - Un test unitaire doit simuler un repository →
{ provide: getRepositoryToken(Task), useValue: mock }(J10).
Pièges
synchronize: true hors d'une base jetable
Il aligne le schéma sur les entités à chaque démarrage, quitte à supprimer des colonnes et leurs données. Développement uniquement, puis migrations (synchronize).
Modifier une entité sans save()
project.archived = true; sans save() ne fait rien : il n'y a pas de dirty checking comme dans un contexte JPA transactionnel.
undefined dans un where
findBy({ archived: query.archived }) avec archived absent lève une TypeORMError en TypeORM 1.1. Les anciennes versions ignoraient la propriété en silence, ce qui pouvait renvoyer toutes les lignes. N'ajoutez au where que les filtres présents, et utilisez IsNull() pour NULL.
- Colonne sans
type: un champstring | nullémetdesign:typeObjectet le démarrage échoue (Data type "Object" … is not supported). Écrivez toujourstype. - Repository non enregistré : oublier
TypeOrmModule.forFeature([Project])produit « Nest can't resolve dependencies of the TypeOrmProjectsStore », sur le tokenProjectRepository. - Une colonne
daten'est pas uneDate: comparez des chaînes'YYYY-MM-DD', ou convertissez explicitement. - Un agrégat
COUNTrenvoie une chaîne : convertissez-le avecNumber(). - Recherche avec
ILikesur une saisie brute :%et_du texte saisi deviennent des jokers. Échappez-les commelikePattern. - Noms de colonnes en camelCase par défaut : fixez
name: 'snake_case'dès le début, renommer plus tard demande une migration. softDelete()ne touche pas les lignes liées : les membres d'un projet supprimé restent dansproject_members. Un contrôle d'accès qui ne lirait que cette table laisserait passer ; TaskFlow vérifie aussi que le projet existe (TypeORM derrière le store).- Renvoyer l'entité telle quelle expose tous ses champs, y compris ceux ajoutés plus tard (
passwordHashen J8) : voir entités brutes.
Exercices
- [ ] Installer
@nestjs/typeorm,typeormetpg, puis configurerTypeOrmModule.forRootAsync()avec les valeurs de connexion du.env.development. - [ ] Écrire l'entité
Project(id UUID, name, description, createdAt, updatedAt) et l'enregistrer avecTypeOrmModule.forFeature(). - [ ] Écrire
TypeOrmProjectsStore, qui implémenteProjectsStore, et remplacer l'implémentation en mémoire sans changer le contrôleur. Vérifier aveccurlque l'API se comporte de la même façon. - [ ] Implémenter la recherche par nom avec
find({ where: { name: ILike(...) } }), puis la même avec unQueryBuilder. - [ ] Implémenter la pagination avec
findAndCount+skip/take, et renvoyer unPage<T>. - [ ] Supprimer logiquement un projet, vérifier dans Adminer que
deleted_atest rempli et que l'API renvoie 404, sur le projet comme sur ses tâches (GET /v1/projects/:id/tasks, à partir du J5). - [ ] Écrire un seed de données de développement, et vérifier qu'il peut être relancé sans doublon.
- [ ] Sur une base jetable avec
synchronize: true, renommer une propriété et observer ce que devient la colonne. Repasser àfalse. - [ ] Noter dans
LEARNINGS.md:JpaRepository↔Repository<T>, pas de requêtes dérivées, pas dePage<T>natif.
Pour aller plus loin
- NestJS : TypeORM
- Documentation TypeORM : entités, find options, QueryBuilder
- PostgreSQL 16 : types de données
- node-postgres : conversion des types