Skip to content

@resourcefulManyToMany

The @resourcefulManyToMany decorator enhances AdonisJS Lucid manyToMany relationships with access control and metadata functionality.

For complete API reference, see resourcefulManyToMany.

Overview

The @resourcefulManyToMany decorator combines the functionality of Lucid's @manyToMany decorator with resourceful features:

  • Access Control: Relationship-level read permissions
  • OpenAPI Integration: Schema generation for API documentation
  • Metadata Storage: Rich metadata for runtime introspection
  • Type Safety: Full TypeScript support for related models
  • Pivot Table Support: Enhanced pivot table functionality

Basic Usage

Simple ManyToMany Relationship

typescript
import { resourcefulManyToMany } from '@nhtio/lucid-resourceful'
import { ManyToMany } from '@adonisjs/lucid/types/relations'

class User extends compose(BaseModel, withResourceful({ name: 'User' })) {
  @resourcefulManyToMany(() => Role, {
    pivotTable: 'user_roles'
  })
  declare roles: ManyToMany<typeof Role>
}

With Access Control

typescript
@resourcefulManyToMany(() => Role, {
  pivotTable: 'user_roles',
  readAccessControlFilters: [
    // Only authenticated users can see roles
    (ctx) => !!ctx.auth.user,
    // Users can see their own roles, admins can see all
    (ctx, app, user) => {
      return ctx.auth.user?.id === user.id || 
             ctx.auth.user?.role === 'admin'
    }
  ]
})
declare roles: ManyToMany<typeof Role>

Configuration Options

Lucid ManyToMany Options

All standard Lucid manyToMany relationship options are supported:

typescript
@resourcefulManyToMany(() => Role, {
  pivotTable: 'user_roles',           // Pivot table name
  localKey: 'id',                     // Local key column
  pivotForeignKey: 'user_id',         // Local foreign key in pivot
  relatedKey: 'id',                   // Related model key
  pivotRelatedForeignKey: 'role_id',  // Related foreign key in pivot
  pivotColumns: ['assigned_at', 'assigned_by'], // Additional pivot columns
  serializeAs: 'userRoles',           // Serialization name
  onQuery: (query) => {               // Query modifications
    query.where('is_active', true)
         .orderBy('name', 'asc')
  }
})
declare roles: ManyToMany<typeof Role>

Resourceful Relationship Options

readAccessControlFilters

  • Type: ResourcefulAccessControlFilter[]
  • Default: [] (no restrictions)

Control who can access the relationship data:

typescript
@resourcefulManyToMany(() => Role, {
  pivotTable: 'user_roles',
  readAccessControlFilters: [
    (ctx, app, user) => {
      // Allow if viewing own roles or user is admin
      return ctx.auth.user?.id === user.id || 
             ctx.auth.user?.role === 'admin'
    }
  ]
})
declare roles: ManyToMany<typeof Role>

description

  • Type: string
  • Optional: Yes

Description for OpenAPI documentation:

typescript
@resourcefulManyToMany(() => Role, {
  pivotTable: 'user_roles',
  description: 'Roles assigned to this user'
})
declare roles: ManyToMany<typeof Role>

Common Patterns

User Roles with Timestamps

typescript
class User extends compose(BaseModel, withResourceful({ name: 'User' })) {
  @resourcefulManyToMany(() => Role, {
    pivotTable: 'user_roles',
    pivotColumns: ['assigned_at', 'assigned_by', 'expires_at'],
    description: 'Roles assigned to this user',
    readAccessControlFilters: [
      (ctx, app, user) => ctx.auth.user?.id === user.id || 
                          ctx.auth.user?.role === 'admin'
    ],
    onQuery: (query) => {
      // Only include active, non-expired roles
      query.whereNull('expires_at')
           .orWhere('expires_at', '>', new Date())
    }
  })
  declare roles: ManyToMany<typeof Role>
}

Product Categories

typescript
class Product extends compose(BaseModel, withResourceful({ name: 'Product' })) {
  @resourcefulManyToMany(() => Category, {
    pivotTable: 'product_categories',
    pivotColumns: ['sort_order', 'is_primary'],
    description: 'Categories this product belongs to',
    readAccessControlFilters: [
      // Anyone can see product categories
      () => true
    ],
    onQuery: (query) => {
      query.where('is_active', true)
           .orderBy('pivot_sort_order', 'asc')
    }
  })
  declare categories: ManyToMany<typeof Category>
}

User Permissions

typescript
class User extends compose(BaseModel, withResourceful({ name: 'User' })) {
  @resourcefulManyToMany(() => Permission, {
    pivotTable: 'user_permissions',
    pivotColumns: ['granted_at', 'granted_by', 'scope'],
    description: 'Direct permissions granted to this user',
    readAccessControlFilters: [
      // Only admins and the user themselves can see permissions
      (ctx, app, user) => ctx.auth.user?.id === user.id || 
                          ctx.auth.user?.role === 'admin'
    ],
    onQuery: (query) => {
      query.where('is_active', true)
    }
  })
  declare permissions: ManyToMany<typeof Permission>
}

Team Memberships

typescript
class User extends compose(BaseModel, withResourceful({ name: 'User' })) {
  @resourcefulManyToMany(() => Team, {
    pivotTable: 'team_members',
    pivotColumns: ['joined_at', 'role', 'is_active'],
    description: 'Teams this user belongs to',
    readAccessControlFilters: [
      // Users can see their own team memberships
      (ctx, app, user) => ctx.auth.user?.id === user.id,
      // Team members can see other members
      (ctx, app, user) => {
        return user.teams?.some(team => 
          team.pivot.isActive && 
          ctx.auth.user?.teams?.some(userTeam => 
            userTeam.id === team.id && userTeam.pivot.isActive
          )
        )
      },
      // Admins can see all
      (ctx) => ctx.auth.user?.role === 'admin'
    ],
    onQuery: (query) => {
      query.where('pivot_is_active', true)
           .orderBy('pivot_joined_at', 'desc')
    }
  })
  declare teams: ManyToMany<typeof Team>
}

Skills and Endorsements

typescript
class User extends compose(BaseModel, withResourceful({ name: 'User' })) {
  @resourcefulManyToMany(() => Skill, {
    pivotTable: 'user_skills',
    pivotColumns: ['level', 'years_experience', 'endorsed_count'],
    description: 'Skills possessed by this user',
    readAccessControlFilters: [
      // Public profiles show skills
      (ctx, app, user) => user.isPublic,
      // Users can see their own skills
      (ctx, app, user) => ctx.auth.user?.id === user.id,
      // Connections can see skills
      (ctx, app, user) => {
        return user.connections?.some(conn => 
          conn.id === ctx.auth.user?.id && conn.pivot.status === 'accepted'
        )
      }
    ],
    onQuery: (query) => {
      query.orderBy('pivot_level', 'desc')
           .orderBy('pivot_endorsed_count', 'desc')
    }
  })
  declare skills: ManyToMany<typeof Skill>
}

Advanced Examples

Multi-tenant Team Access

typescript
class Project extends compose(BaseModel, withResourceful({ name: 'Project' })) {
  @resourcefulManyToMany(() => User, {
    pivotTable: 'project_members',
    pivotColumns: ['role', 'joined_at', 'permissions'],
    serializeAs: 'members',
    description: 'Users who have access to this project',
    readAccessControlFilters: [
      // Project members can see other members
      (ctx, app, project) => {
        return project.members?.some(member => 
          member.id === ctx.auth.user?.id
        )
      },
      // Organization admins can see members
      (ctx, app, project) => {
        return ctx.auth.user?.role === 'admin' && 
               ctx.auth.user?.organizationId === project.organizationId
      }
    ],
    onQuery: (query, { ctx }) => {
      // Ensure tenant isolation
      query.where('organization_id', ctx.auth.user?.organizationId)
           .orderBy('pivot_joined_at', 'asc')
    }
  })
  declare members: ManyToMany<typeof User>
}

Time-based Access Control

typescript
class Course extends compose(BaseModel, withResourceful({ name: 'Course' })) {
  @resourcefulManyToMany(() => User, {
    pivotTable: 'course_enrollments',
    pivotColumns: ['enrolled_at', 'completed_at', 'access_expires_at', 'status'],
    serializeAs: 'students',
    description: 'Students enrolled in this course',
    readAccessControlFilters: [
      // Instructors can see all students
      (ctx, app, course) => course.instructors?.some(inst => 
        inst.id === ctx.auth.user?.id
      ),
      // Students can see other students if course allows it
      (ctx, app, course) => {
        if (!course.allowStudentList) return false
        return course.students?.some(student => 
          student.id === ctx.auth.user?.id && 
          student.pivot.status === 'active' &&
          (!student.pivot.accessExpiresAt || 
           student.pivot.accessExpiresAt > new Date())
        )
      }
    ],
    onQuery: (query) => {
      query.where('pivot_status', 'active')
           .where(builder => {
             builder.whereNull('pivot_access_expires_at')
                   .orWhere('pivot_access_expires_at', '>', new Date())
           })
           .orderBy('pivot_enrolled_at', 'asc')
    }
  })
  declare students: ManyToMany<typeof User>
}

Hierarchical Permissions

typescript
class User extends compose(BaseModel, withResourceful({ name: 'User' })) {
  @resourcefulManyToMany(() => Resource, {
    pivotTable: 'user_resource_permissions',
    pivotColumns: ['permission_type', 'granted_at', 'granted_by', 'inherited_from'],
    description: 'Resources this user has access to',
    readAccessControlFilters: [
      // Users can see their own permissions
      (ctx, app, user) => ctx.auth.user?.id === user.id,
      // Managers can see permissions for their reports
      (ctx, app, user) => user.managerId === ctx.auth.user?.id,
      // Admins can see all
      (ctx) => ctx.auth.user?.role === 'admin'
    ],
    onQuery: (query) => {
      query.whereIn('pivot_permission_type', ['read', 'write', 'admin'])
           .orderBy('pivot_permission_type', 'desc')
           .orderBy('pivot_granted_at', 'desc')
    }
  })
  declare accessibleResources: ManyToMany<typeof Resource>
}

Complete Example

typescript
import { BaseModel } from '@adonisjs/lucid/orm'
import { compose } from '@adonisjs/core/helpers'
import { ManyToMany } from '@adonisjs/lucid/types/relations'
import { 
  withResourceful, 
  resourcefulColumn, 
  resourcefulManyToMany 
} from '@nhtio/lucid-resourceful'
import { 
  ResourcefulUnsignedIntegerType,
  ResourcefulStringType,
  ResourcefulBooleanType,
  ResourcefulDateTimeType
} from '@nhtio/lucid-resourceful/definitions'

export default class User extends compose(BaseModel, withResourceful({ 
  name: 'User' 
})) {
  @resourcefulColumn({ 
    isPrimary: true,
    type: ResourcefulUnsignedIntegerType({ readOnly: true })
  })
  declare id: number

  @resourcefulColumn.string({
    type: ResourcefulStringType({ minLength: 1, maxLength: 100 })
  })
  declare name: string

  @resourcefulColumn.boolean({
    type: ResourcefulBooleanType()
  })
  declare isPublic: boolean

  // User roles with assignment tracking
  @resourcefulManyToMany(() => Role, {
    pivotTable: 'user_roles',
    pivotColumns: ['assigned_at', 'assigned_by', 'expires_at'],
    description: 'Roles assigned to this user',
    readAccessControlFilters: [
      (ctx, app, user) => ctx.auth.user?.id === user.id || 
                          ctx.auth.user?.role === 'admin'
    ],
    onQuery: (query) => {
      query.where('is_active', true)
           .where(builder => {
             builder.whereNull('pivot_expires_at')
                   .orWhere('pivot_expires_at', '>', new Date())
           })
           .orderBy('name', 'asc')
    }
  })
  declare roles: ManyToMany<typeof Role>

  // Skills with proficiency levels
  @resourcefulManyToMany(() => Skill, {
    pivotTable: 'user_skills',
    pivotColumns: ['level', 'years_experience', 'endorsed_count', 'verified'],
    description: 'Skills and expertise areas',
    readAccessControlFilters: [
      // Public profiles show verified skills
      (ctx, app, user) => user.isPublic,
      // Users can see their own skills
      (ctx, app, user) => ctx.auth.user?.id === user.id,
      // Connected users can see skills
      (ctx, app, user) => {
        return user.connections?.some(conn => 
          conn.id === ctx.auth.user?.id && 
          conn.pivot.status === 'accepted'
        )
      }
    ],
    onQuery: (query, { ctx, instance }) => {
      // Show different skill sets based on access level
      if (ctx.auth.user?.id === instance.id) {
        // User can see all their skills
        query.orderBy('pivot_level', 'desc')
      } else {
        // Others see only verified or highly endorsed skills
        query.where(builder => {
          builder.where('pivot_verified', true)
                .orWhere('pivot_endorsed_count', '>=', 5)
        })
        .orderBy('pivot_endorsed_count', 'desc')
      }
    }
  })
  declare skills: ManyToMany<typeof Skill>

  // Team memberships
  @resourcefulManyToMany(() => Team, {
    pivotTable: 'team_members',
    pivotColumns: ['role', 'joined_at', 'is_active'],
    description: 'Teams this user belongs to',
    readAccessControlFilters: [
      // Users can see their own teams
      (ctx, app, user) => ctx.auth.user?.id === user.id,
      // Team members can see each other
      (ctx, app, user) => {
        return user.teams?.some(team => 
          team.pivot.isActive && 
          ctx.auth.user?.teams?.some(userTeam => 
            userTeam.id === team.id && userTeam.pivot.isActive
          )
        )
      },
      // Admins can see all teams
      (ctx) => ctx.auth.user?.role === 'admin'
    ],
    onQuery: (query) => {
      query.where('pivot_is_active', true)
           .where('is_active', true)
           .orderBy('pivot_joined_at', 'desc')
    }
  })
  declare teams: ManyToMany<typeof Team>

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

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

Pivot Table Best Practices

Naming Conventions

typescript
// Good: Clear table and column names
@resourcefulManyToMany(() => Role, {
  pivotTable: 'user_roles',
  pivotForeignKey: 'user_id',
  pivotRelatedForeignKey: 'role_id'
})

// Good: Descriptive pivot columns
@resourcefulManyToMany(() => Team, {
  pivotTable: 'team_members',
  pivotColumns: ['joined_at', 'role', 'is_active', 'invited_by']
})

Common Pivot Patterns

typescript
// Audit trail pattern
pivotColumns: ['created_at', 'created_by', 'updated_at', 'updated_by']

// Status tracking pattern  
pivotColumns: ['status', 'status_changed_at', 'notes']

// Hierarchical pattern
pivotColumns: ['parent_id', 'level', 'path']

// Approval workflow pattern
pivotColumns: ['status', 'requested_at', 'approved_at', 'approved_by']

Best Practices

  1. Use descriptive pivot table names: Follow model1_model2 convention
  2. Include audit columns: Track when and by whom relationships were created
  3. Implement appropriate access control: Secure relationship data based on permissions
  4. Use pivot columns effectively: Store relationship-specific metadata
  5. Handle inactive relationships: Use status columns to manage active/inactive states
  6. Optimize queries: Filter at the database level using onQuery
  7. Consider time-based access: Implement expiration dates for temporary access
  8. Use serializeAs for clarity: Rename relationships for better API field names
  9. Document relationships: Add clear descriptions for complex many-to-many relationships
  10. Handle large datasets: Consider pagination and limiting for relationships with many records