Sitemap

AdonisJs: Table pivot ou modèle dédié ? L’alternative aux relations many to many.

4 min readMar 24, 2025

--

Dans AdonisJS, les relations many-to-many sont couramment utilisées pour connecter deux modèles via une table pivot. Cependant, lorsqu’il s’agit d’enregistrer plusieurs occurrences pour la même combinaison de clés étrangères, la table pivot classique présente des limites. Cet article vous propose une solution élégante pour contourner cette contrainte en transformant la table pivot en un modèle intermédiaire, afin de conserver toute la flexibilité nécessaire.

Rappel sur la relation Many to many

Imaginons une application de type “favoris”, où un utilisateur peut avoir plusieurs exercices (de sport) favoris, et chaque favori peut être associé à plusieurs utilisateurs. Vous pourriez implémenter cette relation many-to-many via une table pivot simple, comme ceci :

-- Table : user_favorites
| user_id | exercise_id | created_at | pivotColumnExample |
|----------|-------------|----------------------|---------------------|
| 1 | 5 | 2025-03-20 10:30:00 | "value1" |
| 1 | 8 | 2025-03-20 10:35:00 | "value2" |
| 2 | 5 | 2025-03-21 11:00:00 | "value3" |
export default class extends BaseSchema {
protected tableName = 'favorites'

async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('user_id').unsigned().references('users.id').notNullable().onDelete('CASCADE')
table
.integer('exercise_id')
.unsigned()
.references('exercises.id')
.notNullable()
.onDelete('CASCADE')
table.timestamp('created_at')

// UNIQUE RELATION
table.unique(['user_id', 'exercise_id'])
})
}

async down() {
this.schema.dropTable(this.tableName)
}
}

// In models/user

@manyToMany(() => Exercise, {
pivotTable: 'favorites',
pivotForeignKey: 'user_id',
pivotRelatedForeignKey: 'exercise_id',
pivotTimestamps: true,
})
declare favorites: ManyToMany<typeof Exercise>

Ici, chaque ligne représente un utilisateur et un exercice qu’il a marqué comme favori. La table est également configurée avec une contrainte d’unicité sur les colonnes user_id et exercise_id, garantissant qu'un utilisateur ne puisse pas ajouter plusieurs fois le même exercice en favoris.

L’utilisation de cette table est très simple et directe. Par exemple :

// Attach
await user.related('favorites').attach([5, 8, 12])
// Detach
await user.related('favorites').detach([5])
// Update
await user.related('favorites').sync({
5: {
pivotColumnExample: 'updatedValue', // Mettre à jour la colonne pivot
},
})

⚠️ Le Problème des Tables Pivot Classiques

Cependant, que se passe-t-il lorsqu’il n’y a pas de contrainte d’unicité et qu’il existe plusieurs entrées avec le même couple user_id et exercise_id dans la table pivot ?

Get Sylvain P.’s stories in your inbox

Join Medium for free to get updates from this writer.

Pour que cela soit plus claire, notre exemple maintenant sera la possibilité pour un utilisateur de pratiquer un ou plusieurs exercice. Dans ce cas, plusieurs entrées peuvent exister pour la même combinaison d’identifiants (Il peut pratiquer le même exercice plusieurs fois).

-- Table : practiced_exercises
| user_id | exercise_id | duration (int) | completed (boolean) |
|----------|--------------|----------------|----------------------|
export default class PracticedExercises extends BaseSchema {
protected tableName = 'practiced_exercises'

public async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE')
table.integer('exercise_id').unsigned().references('id').inTable('exercises').onDelete('CASCADE')
table.integer('duration')
table.boolean('completed').defaultTo(false)
table.timestamp('practiced_at').defaultTo(this.now())
table.timestamps(true)

// NO UNIQUE CONSTRAINT !!
})
}

public async down() {
this.schema.dropTable(this.tableName)
}
}
  1. Si l’on a besoin de réaliser des requêtes qui concerne directement la table pivot et que c’est sur celle ci que le focus est mit alors on se retrouve bloqué par le framework.
  2. Si vous devez modifier une entrée spécifique dans la table, il devient plus complexe d’utiliser une relation many-to-many classique. Vous devez d’abord identifier l’ID unique de la ligne dans la table practiced_exercises avant de pouvoir procéder à la modification. Cela devient particulièrement délicat lorsque vous devez manipuler les colonnes pivot, car AdonisJS ne facilite pas cette tâche sans connaître l'ID spécifique.
  3. De plus, si vous souhaitez utiliser des hooks spécifiques à cette entité (par exemple, pour gérer le cache lié à l’ajout ou à la suppression d’une ligne dans la table), le modèle classique de relation many-to-many devient limitant.
  4. De plus, si vous souhaitez utiliser des hooks spécifiques à cette entité (par exemple, pour gérer le cache lié à l’ajout ou à la suppression d’une ligne dans la table).

🛠️ La Solution : Utiliser un Modèle Intermédiaire

Une solution élégante et maintenable consiste à transformer cette relation many-to-many en une relation has-many, en utilisant un modèle intermédiaire. Cette approche offre plusieurs avantages :

  • Hooks : Vous pouvez définir des hooks comme beforeCreate, afterDelete pour gérer des opérations supplémentaires comme la gestion du cache.
  • Scopes : Vous pouvez créer des scopes pour filtrer facilement les exercices pratiqués aujourd’hui ou à une date spécifique.
  • Lisibilité et Clarté : La relation est désormais clairement définie et vous pouvez utiliser des sous-requêtes comme vous le souhaitez.

Cette solution est présenté aussi dans une des vidéos de la série Let’s Learn AdonisJS 6 du Site Adocast.

export default class PracticedExercise extends BaseModel {
@column({ isPrimary: true })
declare id: number

@column()
declare duration: number

@belongsTo(() => User)
declare user: BelongsTo<typeof User>

@belongsTo(() => Exercise)
declare exercise: BelongsTo<typeof Exercise>

@column.dateTime({ autoCreate: true })
declare createdAt: DateTime

@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime

@afterCreate()
@afterDelete()
static async invalidCache(practicedExercise: PracticedExercise) {
await CacheService.delete(
`practice_time:${practicedExercise.userId}:${DateTime.now().toFormat('yyyy-LL-dd')}`
)
}

static today = scope((query) => {
query
.where('createdAt', '>=', DateTime.now().startOf('day').toSQL())
.andWhere('createdAt', '<=', DateTime.now().endOf('day').toSQL())
})

static atSpecificDate = scope((query, date: DateTime<Valid>) => {
query
.where('createdAt', '>=', date.startOf('day').toSQL())
.andWhere('createdAt', '<=', date.endOf('day').toSQL())
})
}

Dans le modèle User: nos exercices pratiqués en ajoutant la relation HasMany<typeof PracticedExercise>

// In models/user

@hasMany(() => PracticedExercise)
declare practicedExercises: HasMany<typeof PracticedExercise>

Exemple de Requête

Une fois la relation établie, vous pouvez facilement filtrer les exercices pratiqués aujourd’hui avec une requête simple

const practices = await ctx.auth.user
?.related('practicedExercises')
.query()
.apply((scope) => scope.today())

Conclusion

Il s’agit de mon tout premier article sur la plateforme, donc j’espère qu’il n’y aura pas trop d’erreurs ou d’incohérences.

En tout cas, je vous ai présenté ici la manière la plus simple de travailler avec une table pivot et HasMany plutôt que ManyToMany.

Cette approche vous permet de gagner en flexibilité et en lisibilité tout en ayant un meilleur contrôle sur vos données pivot. En utilisant un modèle dédié pour votre relation, vous pouvez facilement gérer des hooks, des scopes, et des opérations supplémentaires comme la gestion du cache ou la création de sous-requêtes. Elle est particulièrement utile lorsque la table pivot est au centre de votre logique métier et nécessite des opérations plus complexes.

Bonne journée.

--

--

Sylvain P.
Sylvain P.

Written by Sylvain P.

Développeur web Typescript/NodeJs (AdonisJs et Vuejs). Musicien et Jazz aficionado.