import { Knex } from 'knex' ; import { systemRelationRows } from '../database/system-data/relations' ; import { ForbiddenException , InvalidPayloadException } from '../exceptions' ; import { AbstractServiceOptions , SchemaOverview , Relation , RelationMeta } from '../types' ; import { Query } from '@directus/shared/types' ; import { Accountability } from '@directus/shared/types' ; import { toArray } from '@directus/shared/utils' ; import { ItemsService , QueryOptions } from './items' ; import { PermissionsService } from './permissions' ; import SchemaInspector from '@directus/schema' ; import { ForeignKey } from 'knex-schema-inspector/dist/types/foreign-key' ; import getDatabase , { getSchemaInspector } from '../database' ; import { getDefaultIndexName } from '../utils/get-default-index-name' ; import { getCache } from '../cache' ; import Keyv from 'keyv' ; export class RelationsService { knex : Knex ; permissionsService : PermissionsService ; schemaInspector : ReturnType < typeof SchemaInspector > ; accountability : Accountability | null ; schema : SchemaOverview ; relationsItemService : ItemsService < RelationMeta > ; systemCache : Keyv < any > ; constructor ( options ) { this . knex = options . knex || getDatabase ( ) ; this . permissionsService = new PermissionsService ( options ) ; this . schemaInspector = options . knex ? SchemaInspector ( options . knex ) : getSchemaInspector ( ) ; this . schema = options . schema ; this . accountability = options . accountability || null ; this . relationsItemService = new ItemsService ( 'directus_relations' , { knex : this . knex , schema : this . schema , } ) ; this . systemCache = getCache ( ) . systemCache ; } async readAll ( collection ? , opts ? ) { if ( this . accountability && this . accountability . admin !== true && this . hasReadAccess === false ) { throw new ForbiddenException ( ) ; } const metaReadQuery = { limit : - 1 , } ; if ( collection ) { metaReadQuery . filter = { many_collection : { _eq : collection , } , } ; } const metaRows = [ ... ( await this . relationsItemService . readByQuery ( metaReadQuery , opts ) ) , ... systemRelationRows , ] . filter ( ( metaRow ) => { if ( ! collection ) return true ; return metaRow . many_collection === collection ; } ) ; const schemaRows = await this . schemaInspector . foreignKeys ( collection ) ; const results = this . stitchRelations ( metaRows , schemaRows ) ; return await this . filterForbidden ( results ) ; } async readOne ( collection , field ) { if ( this . accountability && this . accountability . admin !== true ) { if ( this . hasReadAccess === false ) { throw new ForbiddenException ( ) ; } const permissions = this . accountability . permissions ?. find ( ( permission ) => { return permission . action === 'read' && permission . collection === collection ; } ) ; if ( ! permissions || ! permissions . fields ) throw new ForbiddenException ( ) ; if ( permissions . fields . includes ( '*' ) === false ) { const allowedFields = permissions . fields ; if ( allowedFields . includes ( field ) === false ) throw new ForbiddenException ( ) ; } } const metaRow = await this . relationsItemService . readByQuery ( { limit : 1 , filter : { _and : [ { many_collection : { _eq : collection , } , } , { many_field : { _eq : field , } , } , ] , } , } ) ; const schemaRow = ( await this . schemaInspector . foreignKeys ( collection ) ) . find ( ( foreignKey ) => foreignKey . column === field ) ; const stitched = this . stitchRelations ( metaRow , schemaRow ? [ schemaRow ] : [ ] ) ; const results = await this . filterForbidden ( stitched ) ; if ( results . length === 0 ) { throw new ForbiddenException ( ) ; } return results [ 0 ] ; } /**
     * Create a new relationship / foreign key constraint
     */ async createOne ( relation ) { if ( this . accountability && this . accountability . admin !== true ) { throw new ForbiddenException ( ) ; } if ( ! relation . collection ) { throw new InvalidPayloadException ( '"collection" is required' ) ; } if ( ! relation . field ) { throw new InvalidPayloadException ( '"field" is required' ) ; } if ( relation . collection in this . schema . collections === false ) { throw new InvalidPayloadException ( `Collection "${ relation . collection }" doesn't exist` ) ; } if ( relation . field in this . schema . collections [ relation . collection ] . fields === false ) { throw new InvalidPayloadException ( `Field "${ relation . field }" doesn't exist in collection "${ relation . collection }"` ) ; } if ( relation . related_collection && relation . related_collection in this . schema . collections === false ) { throw new InvalidPayloadException ( `Collection "${ relation . related_collection }" doesn't exist` ) ; } const existingRelation = this . schema . relations . find ( ( existingRelation ) => existingRelation . collection === relation . collection && existingRelation . field === relation . field ) ; if ( existingRelation ) { throw new InvalidPayloadException ( `Field "${ relation . field }" in collection "${ relation . collection }" already has an associated relationship` ) ; } const metaRow = { ... ( relation . meta || { } ) , many_collection : relation . collection , many_field : relation . field , one_collection : relation . related_collection || null , } ; await this . knex . transaction ( async ( trx ) => { if ( relation . related_collection ) { await trx . schema . alterTable ( relation . collection ! , async ( table ) => { this . alterType ( table , relation ) ; const constraintName = getDefaultIndexName ( 'foreign' , relation . collection ! , relation . field ! ) ; const builder = table . foreign ( relation . field ! , constraintName ) . references ( `${ relation . related_collection ! }.${ this . schema . collections [ relation . related_collection ! ] . primary }` ) ; if ( relation . schema ?. on_delete ) { builder . onDelete ( relation . schema . on_delete ) ; } } ) ; } const relationsItemService = new ItemsService ( 'directus_relations' , { knex : trx , schema : this . schema , } ) ; await relationsItemService . createOne ( metaRow ) ; } ) ; await this . systemCache . clear ( ) ; } /**
     * Update an existing foreign key constraint
     *
     * Note: You can update anything under meta, but only the `on_delete` trigger under schema
     */ async updateOne ( collection , field , relation ) { if ( this . accountability && this . accountability . admin !== true ) { throw new ForbiddenException ( ) ; } if ( collection in this . schema . collections === false ) { throw new InvalidPayloadException ( `Collection "${ collection }" doesn't exist` ) ; } if ( field in this . schema . collections [ collection ] . fields === false ) { throw new InvalidPayloadException ( `Field "${ field }" doesn't exist in collection "${ collection }"` ) ; } const existingRelation = this . schema . relations . find ( ( existingRelation ) => existingRelation . collection === collection && existingRelation . field === field ) ; if ( ! existingRelation ) { throw new InvalidPayloadException ( `Field "${ field }" in collection "${ collection }" doesn't have a relationship.` ) ; } await this . knex . transaction ( async ( trx ) => { if ( existingRelation . related_collection ) { await trx . schema . alterTable ( collection , async ( table ) => { let constraintName = getDefaultIndexName ( 'foreign' , collection , field ) ; if ( existingRelation ?. schema ) { constraintName = existingRelation . schema . constraint_name || constraintName ; table . dropForeign ( field , constraintName ) ; } this . alterType ( table , relation ) ; const builder = table . foreign ( field , constraintName || undefined ) . references ( `${ existingRelation . related_collection ! }.${ this . schema . collections [ existingRelation . related_collection ! ] . primary }` ) ; if ( relation . schema ?. on_delete ) { builder . onDelete ( relation . schema . on_delete ) ; } } ) ; } const relationsItemService = new ItemsService ( 'directus_relations' , { knex : trx , schema : this . schema , } ) ; if ( relation . meta ) { if ( existingRelation ?. meta ) { await relationsItemService . updateOne ( existingRelation . meta . id , relation . meta ) ; } else { await relationsItemService . createOne ( { ... ( relation . meta || { } ) , many_collection : relation . collection , many_field : relation . field , one_collection : existingRelation . related_collection || null , } ) ; } } } ) ; await this . systemCache . clear ( ) ; } /**
     * Delete an existing relationship
     */ async deleteOne ( collection , field ) { if ( this . accountability && this . accountability . admin !== true ) { throw new ForbiddenException ( ) ; } if ( collection in this . schema . collections === false ) { throw new InvalidPayloadException ( `Collection "${ collection }" doesn't exist` ) ; } if ( field in this . schema . collections [ collection ] . fields === false ) { throw new InvalidPayloadException ( `Field "${ field }" doesn't exist in collection "${ collection }"` ) ; } const existingRelation = this . schema . relations . find ( ( existingRelation ) => existingRelation . collection === collection && existingRelation . field === field ) ; if ( ! existingRelation ) { throw new InvalidPayloadException ( `Field "${ field }" in collection "${ collection }" doesn't have a relationship.` ) ; } await this . knex . transaction ( async ( trx ) => { if ( existingRelation . schema ?. constraint_name ) { await trx . schema . alterTable ( existingRelation . collection , ( table ) => { table . dropForeign ( existingRelation . field , existingRelation . schema ! . constraint_name ! ) ; } ) ; } if ( existingRelation . meta ) { await trx ( 'directus_relations' ) . delete ( ) . where ( { many_collection : collection , many_field : field } ) ; } } ) ; await this . systemCache . clear ( ) ; } /**
     * Whether or not the current user has read access to relations
     */ private get hasReadAccess ( ) { return ! ! this . accountability ?. permissions ?. find ( ( permission ) => { return permission . collection === 'directus_relations' && permission . action === 'read' ; } ) ; } /**
     * Combine raw schema foreign key information with Directus relations meta rows to form final
     * Relation objects
     */ private stitchRelations ( metaRows , schemaRows ) { const results = schemaRows . map ( ( foreignKey ) : Relation => { return { collection : foreignKey . table , field : foreignKey . column , related_collection : foreignKey . foreign_key_table , schema : foreignKey , meta : metaRows . find ( ( meta ) => { if ( meta . many_collection !== foreignKey . table ) return false ; if ( meta . many_field !== foreignKey . column ) return false ; if ( meta . one_collection && meta . one_collection !== foreignKey . foreign_key_table ) return false ; return true ; } ) || null , } ; } ) ; /**
         * Meta rows that don't have a corresponding schema foreign key
         */ const remainingMetaRows = metaRows . filter ( ( meta ) => { return ! results . find ( ( relation ) => relation . meta === meta ) ; } ) . map ( ( meta ) : Relation => { return { collection : meta . many_collection , field : meta . many_field , related_collection : meta . one_collection ?? null , schema : null , meta : meta , } ; } ) ; results . push ( ... remainingMetaRows ) ; return results ; } /**
     * Loop over all relations and filter out the ones that contain collections/fields you don't have
     * permissions to
     */ private async filterForbidden ( relations ) { if ( this . accountability === null || this . accountability ?. admin === true ) return relations ; const allowedCollections = this . accountability . permissions ?. filter ( ( permission ) => { return permission . action === 'read' ; } ) . map ( ( { collection } ) => collection ) ?? [ ] ; const allowedFields = this . permissionsService . getAllowedFields ( 'read' ) ; relations = toArray ( relations ) ; return relations . filter ( ( relation ) => { let collectionsAllowed = true ; let fieldsAllowed = true ; if ( allowedCollections . includes ( relation . collection ) === false ) { collectionsAllowed = false ; } if ( relation . related_collection && allowedCollections . includes ( relation . related_collection ) === false ) { collectionsAllowed = false ; } if ( relation . meta ?. one_allowed_collections && relation . meta ?. one_allowed_collections . every ( ( collection ) => allowedCollections . includes ( collection ) ) === false ) { collectionsAllowed = false ; } if ( ! allowedFields [ relation . collection ] || ( allowedFields [ relation . collection ] . includes ( '*' ) === false && allowedFields [ relation . collection ] . includes ( relation . field ) === false ) ) { fieldsAllowed = false ; } if ( relation . related_collection && relation . meta ?. one_field && ( ! allowedFields [ relation . related_collection ] || ( allowedFields [ relation . related_collection ] . includes ( '*' ) === false && allowedFields [ relation . related_collection ] . includes ( relation . meta . one_field ) === false ) ) ) { fieldsAllowed = false ; } return collectionsAllowed && fieldsAllowed ; } ) ; } TODO private alterType ( table , relation ) { const m2oFieldDBType = this . schema . collections [ relation . collection ! ] . fields [ relation . field ! ] . dbType ; const relatedFieldDBType = this . schema . collections [ relation . related_collection ! ] . fields [ this . schema . collections [ relation . related_collection ! ] . primary ] . dbType ; if ( m2oFieldDBType !== relatedFieldDBType && m2oFieldDBType === 'int' && relatedFieldDBType === 'int unsigned' ) { table . specificType ( relation . field ! , 'int unsigned' ) . alter ( ) ; } } }