@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
321 lines (320 loc) • 12.9 kB
JavaScript
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { z } from 'zod';
import { FieldsService } from '../../..//services/fields.js';
import { CollectionsService } from '../../../services/collections.js';
import { RelationsService } from '../../../services/relations.js';
import { requireText } from '../../../utils/require-text.js';
import { defineTool } from '../define-tool.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
export const SchemaValidateSchema = z.strictObject({
keys: z.array(z.string()).optional(),
});
export const SchemaInputSchema = z.object({
keys: z
.array(z.string())
.optional()
.describe('Collection names to get detailed schema for. If omitted, returns a lightweight list of all collections.'),
});
export const schema = defineTool({
name: 'schema',
description: requireText(resolve(__dirname, './prompt.md')),
annotations: {
title: 'Directus - Schema',
},
inputSchema: SchemaInputSchema,
validateSchema: SchemaValidateSchema,
async handler({ args, accountability, schema }) {
const serviceOptions = {
schema,
accountability,
};
const collectionsService = new CollectionsService(serviceOptions);
const collections = await collectionsService.readByQuery();
// If no keys provided, return lightweight collection list
if (!args.keys || args.keys.length === 0) {
const lightweightOverview = {
collections: [],
collection_folders: [],
notes: {},
};
collections.forEach((collection) => {
// Separate folders from real collections
if (!collection.schema) {
lightweightOverview.collection_folders.push(collection.collection);
}
else {
lightweightOverview.collections.push(collection.collection);
}
// Extract note if exists (for both collections and folders)
if (collection.meta?.note && !collection.meta.note.startsWith('$t')) {
lightweightOverview.notes[collection.collection] = collection.meta.note;
}
});
return {
type: 'text',
data: lightweightOverview,
};
}
// If keys provided, return detailed schema for requested collections
const overview = {};
const fieldsService = new FieldsService(serviceOptions);
const fields = await fieldsService.readAll();
const relationsService = new RelationsService(serviceOptions);
const relations = await relationsService.readAll();
const snapshot = {
collections,
fields,
relations,
};
fields.forEach((field) => {
// Skip collections not requested
if (!args.keys?.includes(field.collection))
return;
// Skip UI-only fields
if (field.type === 'alias' && field.meta?.special?.includes('no-data'))
return;
if (!overview[field.collection]) {
overview[field.collection] = {};
}
const fieldOverview = {
type: field.type,
};
if (field.schema?.is_primary_key) {
fieldOverview.primary_key = field.schema?.is_primary_key;
}
if (field.meta?.required) {
fieldOverview.required = field.meta.required;
}
if (field.meta?.readonly) {
fieldOverview.readonly = field.meta.readonly;
}
if (field.meta?.note) {
fieldOverview.note = field.meta.note;
}
if (field.meta?.interface) {
fieldOverview.interface = {
type: field.meta.interface,
};
if (field.meta.options?.['choices']) {
fieldOverview.interface.choices = field.meta.options['choices'].map(
// Only return the value of the choice to reduce size and potential for confusion.
(choice) => choice.value);
}
}
// Process nested fields for JSON fields with options.fields (like repeaters)
if (field.type === 'json' && field.meta?.options?.['fields']) {
const nestedFields = field.meta.options['fields'];
fieldOverview.fields = processNestedFields({
fields: nestedFields,
maxDepth: 5,
currentDepth: 0,
snapshot,
});
}
// Handle collection-item-dropdown interface
if (field.type === 'json' && field.meta?.interface === 'collection-item-dropdown') {
fieldOverview.fields = processCollectionItemDropdown({
field,
snapshot,
});
}
// Handle relationships
if (field.meta?.special) {
const relationshipType = getRelationType(field.meta.special);
if (relationshipType) {
fieldOverview.relation = buildRelationInfo(field, relationshipType, snapshot);
}
}
overview[field.collection][field.field] = fieldOverview;
});
return {
type: 'text',
data: overview,
};
},
});
// Helpers
function processNestedFields(options) {
const { fields, maxDepth = 5, currentDepth = 0, snapshot } = options;
const result = {};
if (currentDepth >= maxDepth) {
return result;
}
if (!Array.isArray(fields)) {
return result;
}
for (const field of fields) {
const fieldKey = field.field || field.name;
if (!fieldKey)
continue;
const fieldOverview = {
type: field.type ?? 'any',
};
if (field.meta) {
const { required, readonly, note, interface: interfaceConfig, options } = field.meta;
if (required)
fieldOverview.required = required;
if (readonly)
fieldOverview.readonly = readonly;
if (note)
fieldOverview.note = note;
if (interfaceConfig) {
fieldOverview.interface = { type: interfaceConfig };
if (options?.choices) {
fieldOverview.interface.choices = options.choices;
}
}
}
// Handle nested fields recursively
const nestedFields = field.meta?.options?.fields || field.options?.fields;
if (field.type === 'json' && nestedFields) {
fieldOverview.fields = processNestedFields({
fields: nestedFields,
maxDepth,
currentDepth: currentDepth + 1,
snapshot,
});
}
// Handle collection-item-dropdown interface
if (field.type === 'json' && field.meta?.interface === 'collection-item-dropdown') {
fieldOverview.fields = processCollectionItemDropdown({
field,
snapshot,
});
}
result[fieldKey] = fieldOverview;
}
return result;
}
function processCollectionItemDropdown(options) {
const { field, snapshot } = options;
const selectedCollection = field.meta?.options?.['selectedCollection'];
let keyType = 'string | number | uuid';
// Find the primary key type for the selected collection
if (selectedCollection && snapshot?.fields) {
const primaryKeyField = snapshot.fields.find((f) => f.collection === selectedCollection && f.schema?.is_primary_key);
if (primaryKeyField) {
keyType = primaryKeyField.type;
}
}
return {
collection: {
value: selectedCollection,
type: 'string',
},
key: {
type: keyType,
},
};
}
function getRelationType(special) {
if (special.includes('m2o') || special.includes('file'))
return 'm2o';
if (special.includes('o2m'))
return 'o2m';
if (special.includes('m2m') || special.includes('files'))
return 'm2m';
if (special.includes('m2a'))
return 'm2a';
return null;
}
function buildRelationInfo(field, type, snapshot) {
switch (type) {
case 'm2o':
return buildManyToOneRelation(field, snapshot);
case 'o2m':
return buildOneToManyRelation(field, snapshot);
case 'm2m':
return buildManyToManyRelation(field, snapshot);
case 'm2a':
return buildManyToAnyRelation(field, snapshot);
default:
return { type };
}
}
function buildManyToOneRelation(field, snapshot) {
// For M2O, the relation is directly on this field
const relation = snapshot.relations.find((r) => r.collection === field.collection && r.field === field.field);
// The target collection is either in related_collection or foreign_key_table
const targetCollection = relation?.related_collection || relation?.schema?.foreign_key_table || field.schema?.foreign_key_table;
return {
type: 'm2o',
collection: targetCollection,
};
}
function buildOneToManyRelation(field, snapshot) {
// For O2M, we need to find the relation that points BACK to this field
// The relation will have this field stored in meta.one_field
const reverseRelation = snapshot.relations.find((r) => r.meta?.one_collection === field.collection && r.meta?.one_field === field.field);
if (!reverseRelation) {
return { type: 'o2m' };
}
return {
type: 'o2m',
collection: reverseRelation.collection,
many_field: reverseRelation.field,
};
}
function buildManyToManyRelation(field, snapshot) {
// Find the junction table relation that references this field
// This relation will have our field as meta.one_field
const junctionRelation = snapshot.relations.find((r) => r.meta?.one_field === field.field &&
r.meta?.one_collection === field.collection &&
r.collection !== field.collection);
if (!junctionRelation) {
return { type: 'm2m' };
}
// Find the other side of the junction (pointing to the target collection)
// This is stored in meta.junction_field
const targetRelation = snapshot.relations.find((r) => r.collection === junctionRelation.collection && r.field === junctionRelation.meta?.junction_field);
const targetCollection = targetRelation?.related_collection || 'directus_files';
const result = {
type: 'm2m',
collection: targetCollection,
junction: {
collection: junctionRelation.collection,
many_field: junctionRelation.field,
junction_field: junctionRelation.meta?.junction_field,
},
};
if (junctionRelation.meta?.sort_field) {
result.junction.sort_field = junctionRelation.meta.sort_field;
}
return result;
}
function buildManyToAnyRelation(field, snapshot) {
// Find the junction table relation that references this field
// This relation will have our field as meta.one_field
const junctionRelation = snapshot.relations.find((r) => r.meta?.one_field === field.field && r.meta?.one_collection === field.collection);
if (!junctionRelation) {
return { type: 'm2a' };
}
// Find the polymorphic relation in the junction table
// This relation will have one_allowed_collections set
const polymorphicRelation = snapshot.relations.find((r) => r.collection === junctionRelation.collection &&
r.meta?.one_allowed_collections &&
r.meta.one_allowed_collections.length > 0);
if (!polymorphicRelation) {
return { type: 'm2a' };
}
// Find the relation back to our parent collection
const parentRelation = snapshot.relations.find((r) => r.collection === junctionRelation.collection &&
r.related_collection === field.collection &&
r.field !== polymorphicRelation.field);
const result = {
type: 'm2a',
one_allowed_collections: polymorphicRelation.meta?.one_allowed_collections,
junction: {
collection: junctionRelation.collection,
many_field: parentRelation?.field || `${field.collection}_id`,
junction_field: polymorphicRelation.field,
one_collection_field: polymorphicRelation.meta?.one_collection_field || 'collection',
},
};
const sortField = parentRelation?.meta?.sort_field || polymorphicRelation.meta?.sort_field;
if (sortField) {
result.junction.sort_field = sortField;
}
return result;
}