Skip to content

J4 · PostgreSQL et TypeORM ​

Jour 4 · vendredi 13 novembre · Livrable : persistance Projects

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 le QueryBuilder.
  • Comprendre pourquoi synchronize: true est 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 ​

SpringNestJS + TypeORMÀ retenir
spring.datasource.* + auto-configuration JPATypeOrmModule.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 QueryBuilderPas de requêtes dérivées du nom de méthode
Page<T> + PageablefindAndCount({ skip, take }) → [items, total]La page se construit à la main
@Query("select p from Project p …"), Criteria APIcreateQueryBuilder('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, @UpdateDateColumnRemplis 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=updatesynchronize: trueDéveloppement seulement ; migrations ensuite (J5)
Contexte de persistance, dirty checkingRien d'équivalentUne modification n'est écrite qu'au save()
Optional<T> renvoyé par findByIdT | null renvoyé par findOneByLe compilateur (strict) force le test de null

Comprendre ​

@nestjs/typeorm : forRoot, forRootAsync, forFeature ​

bash
npm i @nestjs/typeorm typeorm pg

Le module d'intégration expose trois points d'entrée :

  • TypeOrmModule.forRoot(options) crée la DataSource (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 provider Repository<T> par entité. On l'injecte avec @InjectRepository(Project), dont le token vaut la chaîne "ProjectRepository".
ts
// 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) :

ts
@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 :

ts
// 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: true ajoute à la DataSource chaque entité enregistrée par un forFeature(). Une entité seulement atteinte par une relation, sans forFeature, n'est pas chargée. Dans TaskFlow, chaque entité a son forFeature (Project et ProjectMember dans ProjectsModule, Task dans TasksModule, User dans UsersModule).
  • 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 DataSource elle-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écorateurRô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 logiqueSoft delete Hibernate
@Index('nom', ['col1', 'col2']) sur la classeIndex@Table(indexes = …)

Deux différences avec Spring Boot :

  • Noms de colonnes. Spring Boot convertit createdAt en created_at par défaut. TypeORM garde le nom de la propriété ("createdAt", avec majuscule, donc à guillemeter en SQL). TaskFlow écrit name: 'created_at' pour chaque colonne composée.
  • Le type de colonne s'écrit explicitement. Sans type, TypeORM le déduit de la métadonnée design:type émise par TypeScript. Or, en mode strict, description: string | null émet Object, et le démarrage échoue avec Data type "Object" in "Project.description" is not supported by "postgres" database. TaskFlow donne toujours type (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 ​

BesoinType PostgreSQLDéclaration TypeORMValeur côté JavaScript
Identifiantuuid@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 JSONjsonb@Column({ type: 'jsonb' })objet
Ensemble fermé de valeurstype ENUM@Column({ type: 'enum', enum: TaskStatus, enumName: 'task_status' })valeur de l'enum TypeScript
Textevarchar(n), text@Column({ type: 'varchar', length: 100 })string
  • timestamptz stocke un instant absolu, l'équivalent d'un Instant. Un timestamp sans fuseau dépend du fuseau du serveur et de la session : évitez-le pour des dates d'événements.
  • date revient sous forme de chaîne 'YYYY-MM-DD', pas de Date : c'est pourquoi Task.dueDate est typé string | null (J5). C'est la sémantique d'un LocalDate.
  • enumName donne au type PostgreSQL un nom stable (task_status, user_role), lisible dans les migrations. Ajouter une valeur à l'enum TypeScript demande une migration.
  • jsonb stocke un document indexable, utile pour des données sans schéma fixe :
ts
// 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éthodeEffetSpring Data
find(options), findBy(where)Liste ; options accepte where, order, skip, take, relations, withDeletedfindAll(...), requêtes dérivées
findOne(options), findOneBy(where)Premier résultat ou nullfindById() → Optional
findOneByOrFail(where)Lève EntityNotFoundError si rienorElseThrow()
findAndCount(options)[items, total] : la page découpée par skip / take, et le total des lignes qui correspondentPage<T>
existsBy(where)booleanexistsBy...
create(data)Instance, sans SQLnew
save(entity)INSERT ou UPDATE, renvoie l'entité avec les valeurs généréessave()
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_atSoft delete Hibernate
delete(where)DELETEdeleteById()

La correspondance du plan, sur un exemple :

java
// Spring Data
Page<Project> findByNameContainingIgnoreCase(String name, Pageable pageable);
ts
// 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 de LazyInitializationException).
  • save() lit avant d'écrire. Pour une entité qui a un id, il relit la ligne afin de choisir entre INSERT et UPDATE et de n'écrire que ce qui a changé. Pour modifier beaucoup de lignes, update() envoie un seul UPDATE : 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 les find* excluent la ligne ; withDeleted: true la fait réapparaître, restore() l'annule.
  • undefined et null dans un where sont refusés. TypeORM 1.1 lève par défaut une TypeORMError (« 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 le where avec les seuls filtres présents, et utilisez IsNull() pour chercher un NULL SQL.

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.

ts
// 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 }>();
TerminaisonRenvoie
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 un bigint, que le pilote pg transmet en chaîne : d'où count: string.
  • Avec des jointures, paginez avec skip() / take(), qui comptent des entités, et non avec offset() / 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 :

ts
// 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 ​

ts
@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 ajoute archived (archivage, J5), deletedAt (suppression logique) et deux relations, ajoutées en J5 et J8 (relations).
  • description: string | null et @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 de design:type.
  • @DeleteDateColumn suffit à rendre softDelete() 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 :

ts
// 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 alors null, 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() remplit deleted_at ; result.affected indique si une ligne a changé.

La table des membres a ses propres méthodes :

ts
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 un INSERT … 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_members en place : isMember() reste vrai pour un projet supprimé, d'où la vérification d'existence que fait aussi ProjectsService.findOne().
  • create() et archive() é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 :

ts
@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 ​

ts
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 (memberId absent) : de simples options de find, la forme la plus proche d'une requête dérivée. findAndCount renvoie la page et le total en un appel.
  • Vue membre : il faut joindre la table des membres avec une condition sur l'utilisateur. Le QueryBuilder l'exprime plus clairement (innerJoin sur la relation project.members), et getManyAndCount() joue le rôle de findAndCount.
  • Le where n'est construit qu'avec les filtres présents : where.name = undefined lèverait une erreur en TypeORM 1.1 (repository).
  • ILike produit un ILIKE PostgreSQL. La fonction likePattern, en tête du fichier, échappe %, _ et \ : une recherche de 50% 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 ​

ts
// 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 DataSource du CLI (data-source.ts), qui lit le même fichier .env.development que 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 finally ferme 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.
bash
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 → ILike avec un motif échappé (recherche).
  • Il faut une page avec le nombre total de résultats → findAndCount({ skip, take }) et un Page<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 colonne date (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 champ string | null émet design:type Object et le démarrage échoue (Data type "Object" … is not supported). Écrivez toujours type.
  • Repository non enregistré : oublier TypeOrmModule.forFeature([Project]) produit « Nest can't resolve dependencies of the TypeOrmProjectsStore », sur le token ProjectRepository.
  • Une colonne date n'est pas une Date : comparez des chaînes 'YYYY-MM-DD', ou convertissez explicitement.
  • Un agrégat COUNT renvoie une chaîne : convertissez-le avec Number().
  • Recherche avec ILike sur une saisie brute : % et _ du texte saisi deviennent des jokers. Échappez-les comme likePattern.
  • 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 dans project_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 (passwordHash en J8) : voir entités brutes.

Exercices ​

  • [ ] Installer @nestjs/typeorm, typeorm et pg, puis configurer TypeOrmModule.forRootAsync() avec les valeurs de connexion du .env.development.
  • [ ] Écrire l'entité Project (id UUID, name, description, createdAt, updatedAt) et l'enregistrer avec TypeOrmModule.forFeature().
  • [ ] Écrire TypeOrmProjectsStore, qui implémente ProjectsStore, et remplacer l'implémentation en mémoire sans changer le contrôleur. Vérifier avec curl que 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 un QueryBuilder.
  • [ ] Implémenter la pagination avec findAndCount + skip / take, et renvoyer un Page<T>.
  • [ ] Supprimer logiquement un projet, vérifier dans Adminer que deleted_at est 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 de Page<T> natif.

Pour aller plus loin ​