AdonisJs: Table pivot ou modèle dédié ? L’alternative aux relations many to many.
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)
}
}- 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.
- 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_exercisesavant 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. - 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.
- 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,afterDeletepour 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.





