Infer array current item schema (#15115)

This PR allows to infer the schema of the current item of an iterator
step:
- iterator step receive a variable
- added an util that navigate to the array in schema -
navigateOutputSchemaProperty
- use the array value in schema to generate a new schema - used the
existing getFunctionOutputSchema that I renamed and moved to
twenty-shared

Also cleaned a bit the existing schema for AI.

Before


https://github.com/user-attachments/assets/9767fc89-3524-4bfb-b1ab-8abe92084767

After


https://github.com/user-attachments/assets/3650c1d2-14f2-44f9-b10c-e649fe04128d
This commit is contained in:
Thomas Trompette
2025-10-15 18:15:08 +02:00
committed by GitHub
parent b16ab1b7c9
commit d3f3f991a5
42 changed files with 774 additions and 170 deletions
@@ -1,5 +1,4 @@
import { type Leaf } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { type Leaf } from 'twenty-shared/workflow';
export const DEFAULT_ITERATOR_CURRENT_ITEM: Leaf = {
label: 'Current Item',
isLeaf: true,
@@ -0,0 +1,55 @@
import { extractPropertyPathFromVariable } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/extract-property-path-from-variable';
describe('extractPropertyPathFromVariable', () => {
it('should extract single property path', () => {
const result = extractPropertyPathFromVariable('{{step1.result}}');
expect(result).toEqual(['result']);
});
it('should extract nested property path', () => {
const result = extractPropertyPathFromVariable('{{step1.result.items}}');
expect(result).toEqual(['result', 'items']);
});
it('should extract deeply nested property path', () => {
const result = extractPropertyPathFromVariable(
'{{step1.data.user.address.street}}',
);
expect(result).toEqual(['data', 'user', 'address', 'street']);
});
it('should handle variable without brackets', () => {
const result = extractPropertyPathFromVariable('step1.result.items');
expect(result).toEqual(['result', 'items']);
});
it('should return empty array for variable with only step id', () => {
const result = extractPropertyPathFromVariable('{{step1}}');
expect(result).toEqual([]);
});
it('should return empty array for variable with only step id without brackets', () => {
const result = extractPropertyPathFromVariable('step1');
expect(result).toEqual([]);
});
it('should handle complex step ids', () => {
const result = extractPropertyPathFromVariable(
'{{step_with_underscore.result.data}}',
);
expect(result).toEqual(['result', 'data']);
});
it('should handle numeric step ids', () => {
const result = extractPropertyPathFromVariable('{{step123.output.value}}');
expect(result).toEqual(['output', 'value']);
});
});
@@ -0,0 +1,210 @@
import { type Leaf, type Node } from 'twenty-shared/workflow';
import { DEFAULT_ITERATOR_CURRENT_ITEM } from 'src/modules/workflow/workflow-builder/workflow-schema/constants/default-iterator-current-item.const';
import { inferArrayItemSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/infer-array-item-schema';
describe('inferArrayItemSchema', () => {
it('should return DEFAULT_ITERATOR_CURRENT_ITEM for non-leaf node', () => {
const schemaNode: Node = {
isLeaf: false,
type: 'object',
label: 'test',
value: {},
};
const result = inferArrayItemSchema({ schemaNode });
expect(result).toEqual(DEFAULT_ITERATOR_CURRENT_ITEM);
});
it('should return DEFAULT_ITERATOR_CURRENT_ITEM for non-array leaf', () => {
const schemaNode: Leaf = {
isLeaf: true,
type: 'string',
label: 'test',
value: 'not an array',
};
const result = inferArrayItemSchema({ schemaNode });
expect(result).toEqual(DEFAULT_ITERATOR_CURRENT_ITEM);
});
it('should return DEFAULT_ITERATOR_CURRENT_ITEM for empty array', () => {
const schemaNode: Leaf = {
isLeaf: true,
type: 'array',
label: 'items',
value: [],
};
const result = inferArrayItemSchema({ schemaNode });
expect(result).toEqual(DEFAULT_ITERATOR_CURRENT_ITEM);
});
it('should infer schema for array of strings', () => {
const schemaNode: Leaf = {
isLeaf: true,
type: 'array',
label: 'items',
value: ['item1', 'item2', 'item3'],
};
const result = inferArrayItemSchema({ schemaNode });
expect(result).toEqual({
isLeaf: true,
type: 'string',
label: 'Current Item',
value: 'item1',
});
});
it('should infer schema for array of numbers', () => {
const schemaNode: Leaf = {
isLeaf: true,
type: 'array',
label: 'items',
value: [1, 2, 3],
};
const result = inferArrayItemSchema({ schemaNode });
expect(result).toEqual({
isLeaf: true,
type: 'number',
label: 'Current Item',
value: 1,
});
});
it('should infer schema for array of booleans', () => {
const schemaNode: Leaf = {
isLeaf: true,
type: 'array',
label: 'items',
value: [true, false],
};
const result = inferArrayItemSchema({ schemaNode });
expect(result).toEqual({
isLeaf: true,
type: 'boolean',
label: 'Current Item',
value: true,
});
});
it('should infer full schema for array of simple objects', () => {
const schemaNode: Leaf = {
isLeaf: true,
type: 'array',
label: 'items',
value: [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
],
};
const result = inferArrayItemSchema({ schemaNode });
expect(result).toEqual({
isLeaf: false,
type: 'object',
label: 'Current Item',
value: {
id: {
isLeaf: true,
type: 'number',
label: 'id',
value: 1,
},
name: {
isLeaf: true,
type: 'string',
label: 'name',
value: 'Item 1',
},
},
});
});
it('should infer full schema for array of nested objects', () => {
const schemaNode: Leaf = {
isLeaf: true,
type: 'array',
label: 'items',
value: [
{
toto: {
titi: 1,
tata: 'hello',
},
},
{
toto: {
titi: 2,
tata: 'world',
},
},
],
};
const result = inferArrayItemSchema({ schemaNode });
expect(result).toEqual({
isLeaf: false,
type: 'object',
label: 'Current Item',
value: {
toto: {
isLeaf: false,
type: 'object',
label: 'toto',
value: {
titi: {
isLeaf: true,
type: 'number',
label: 'titi',
value: 1,
},
tata: {
isLeaf: true,
type: 'string',
label: 'tata',
value: 'hello',
},
},
},
},
});
});
it('should handle array with null first item', () => {
const schemaNode: Leaf = {
isLeaf: true,
type: 'array',
label: 'items',
value: [null, 'item2'],
};
const result = inferArrayItemSchema({ schemaNode });
expect(result).toEqual(DEFAULT_ITERATOR_CURRENT_ITEM);
});
it('should handle array with undefined first item', () => {
const schemaNode: Leaf = {
isLeaf: true,
type: 'array',
label: 'items',
value: [undefined, 'item2'],
};
const result = inferArrayItemSchema({ schemaNode });
expect(result).toEqual(DEFAULT_ITERATOR_CURRENT_ITEM);
});
});
@@ -0,0 +1,14 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow';
export const extractPropertyPathFromVariable = (
rawVariableName: string,
): string[] => {
const variableWithoutBrackets = rawVariableName.replace(
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
(_, variableName) => variableName,
);
const parts = variableWithoutBrackets.split('.');
return parts.slice(1);
};
@@ -0,0 +1,65 @@
import {
isArray,
isBoolean,
isNumber,
isObject,
isString,
} from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import {
buildOutputSchemaFromValue,
type Leaf,
type LeafType,
type Node,
} from 'twenty-shared/workflow';
import { DEFAULT_ITERATOR_CURRENT_ITEM } from 'src/modules/workflow/workflow-builder/workflow-schema/constants/default-iterator-current-item.const';
export const inferArrayItemSchema = ({
schemaNode,
}: {
schemaNode: Leaf | Node;
}): Leaf | Node => {
if (!schemaNode.isLeaf || schemaNode.type !== 'array') {
return DEFAULT_ITERATOR_CURRENT_ITEM;
}
const arrayValue = schemaNode.value;
if (!Array.isArray(arrayValue) || arrayValue.length === 0) {
return DEFAULT_ITERATOR_CURRENT_ITEM;
}
const firstItem = arrayValue[0];
if (!isDefined(firstItem)) {
return DEFAULT_ITERATOR_CURRENT_ITEM;
}
if (isObject(firstItem)) {
const itemSchema = buildOutputSchemaFromValue(firstItem);
return {
isLeaf: false,
type: 'object',
label: 'Current Item',
value: itemSchema,
};
}
const getValueType = (value: unknown): LeafType => {
if (isString(value)) return 'string';
if (isNumber(value)) return 'number';
if (isBoolean(value)) return 'boolean';
if (isArray(value)) return 'array';
return 'unknown';
};
return {
isLeaf: true,
type: getValueType(firstItem),
label: 'Current Item',
value: firstItem,
};
};
@@ -3,9 +3,11 @@ import { Injectable } from '@nestjs/common';
import { isString } from '@sniptt/guards';
import { isDefined, isValidVariable } from 'twenty-shared/utils';
import {
BaseOutputSchemaV2,
BulkRecordsAvailability,
extractRawVariableNamePart,
GlobalAvailability,
navigateOutputSchemaProperty,
SingleRecordAvailability,
TRIGGER_STEP_ID,
} from 'twenty-shared/workflow';
@@ -22,10 +24,12 @@ import {
Node,
type OutputSchema,
} from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { extractPropertyPathFromVariable } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/extract-property-path-from-variable';
import { generateFakeArrayItem } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-array-item';
import { generateFakeFormResponse } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-form-response';
import { generateFakeObjectRecord } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record';
import { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record-event';
import { inferArrayItemSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/infer-array-item-schema';
import { type FormFieldMetadata } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
import {
type WorkflowAction,
@@ -413,7 +417,19 @@ export class WorkflowSchemaWorkspaceService {
return DEFAULT_ITERATOR_CURRENT_ITEM;
}
// TODO(t.trompette): handle other trigger types
case WorkflowTriggerType.WEBHOOK: {
const propertyPath = extractPropertyPathFromVariable(items);
const schemaNode = navigateOutputSchemaProperty({
schema: trigger.settings.outputSchema as BaseOutputSchemaV2,
propertyPath,
});
if (!isDefined(schemaNode)) {
return DEFAULT_ITERATOR_CURRENT_ITEM;
}
return inferArrayItemSchema({ schemaNode });
}
default: {
return DEFAULT_ITERATOR_CURRENT_ITEM;
}
@@ -447,6 +463,20 @@ export class WorkflowSchemaWorkspaceService {
}),
};
}
case WorkflowActionType.CODE:
case WorkflowActionType.HTTP_REQUEST: {
const propertyPath = extractPropertyPathFromVariable(items);
const schemaNode = navigateOutputSchemaProperty({
schema: step.settings.outputSchema as BaseOutputSchemaV2,
propertyPath,
});
if (!isDefined(schemaNode)) {
return DEFAULT_ITERATOR_CURRENT_ITEM;
}
return inferArrayItemSchema({ schemaNode });
}
default: {
return DEFAULT_ITERATOR_CURRENT_ITEM;
}