diff --git a/internal/builtinfunctions/functions.go b/internal/builtinfunctions/functions.go index 533323f1..077df319 100644 --- a/internal/builtinfunctions/functions.go +++ b/internal/builtinfunctions/functions.go @@ -35,6 +35,8 @@ func GetFunctions() map[string]schema.CallableFunction { toUpperFunction := getToUpperFunction() splitStringFunction := getSplitStringFunction() loadFileFunction := getReadFileFunction() + // Data transformation functions + bindConstantsFunction := getBindConstantsFunction() // Combine in a map allFunctions := map[string]schema.CallableFunction{ @@ -55,6 +57,7 @@ func GetFunctions() map[string]schema.CallableFunction { toUpperFunction.ID(): toUpperFunction, splitStringFunction.ID(): splitStringFunction, loadFileFunction.ID(): loadFileFunction, + bindConstantsFunction.ID(): bindConstantsFunction, } return allFunctions @@ -525,3 +528,92 @@ func getReadFileFunction() schema.CallableFunction { } return funcSchema } + +// CombinedObjPropertyConstantName is the identifier string key that points to the container of constant or repeated values. +const CombinedObjPropertyConstantName = "constant" + +// CombinedObjPropertyItemName is the identifier string key that points to the container of a list of objects. +const CombinedObjPropertyItemName = "item" + +// CombinedObjIDDelimiter is the delimiter for joining Object ID strings and/or TypeID strings. +const CombinedObjIDDelimiter = "__" + +// ListSchemaNameDelimiter is the delimiter for joining the names of the types nested within a list. +const ListSchemaNameDelimiter = "_" + +func getBindConstantsFunction() schema.CallableFunction { + funcSchema, err := schema.NewDynamicCallableFunction( + "bindConstants", + []schema.Type{ + schema.NewListSchema(schema.NewAnySchema(), nil, nil), + schema.NewAnySchema()}, + schema.NewDisplayValue( + schema.PointerTo("Bind Constants"), + schema.PointerTo( + "Creates a list of objects with ID `CombinedObject`. "+ + fmt.Sprintf("Each object has two properties `%s` and `%s`.\n", CombinedObjPropertyItemName, CombinedObjPropertyConstantName)+ + fmt.Sprintf("Param 1: Value(s) to be included in the `%s` field \n", CombinedObjPropertyItemName)+ + fmt.Sprintf("Param 2: Value(s) to populate the field `%s` with every output item", CombinedObjPropertyConstantName)), + nil), + func(items []any, columnValues any) (any, error) { + combinedItems := make([]any, len(items)) + for k, itemValue := range items { + combinedItems[k] = map[string]any{ + CombinedObjPropertyItemName: itemValue, + CombinedObjPropertyConstantName: columnValues, + } + } + return combinedItems, nil + }, + HandleTypeSchemaCombine, + ) + if err != nil { + panic(err) + } + return funcSchema +} + +// HandleTypeSchemaCombine returns the type that is output by the 'bindConstants' function. +// Its parameter list requires a ListSchema and one other schema of any type. +// A new ListSchema of ObjectSchemas is created from the two input types. +func HandleTypeSchemaCombine(inputType []schema.Type) (schema.Type, error) { + if len(inputType) != 2 { + return nil, fmt.Errorf("expected exactly two input types") + } + itemsType, isList := inputType[0].(*schema.ListSchema) + if !isList { + return nil, fmt.Errorf("expected first input type to be a list schema") + } + itemType := itemsType.ItemsValue + constantsTypeArg := inputType[1] + combinedObjectName := schemaName(itemType) + CombinedObjIDDelimiter + schemaName(constantsTypeArg) + return schema.NewListSchema( + schema.NewObjectSchema( + combinedObjectName, + map[string]*schema.PropertySchema{ + CombinedObjPropertyItemName: schema.NewPropertySchema(itemType, nil, false, nil, nil, nil, nil, nil), + CombinedObjPropertyConstantName: schema.NewPropertySchema(constantsTypeArg, nil, false, nil, nil, nil, nil, nil), + }), + nil, nil), nil +} + +func schemaName(typeSchema schema.Type) string { + return strings.Join(BuildSchemaNames(typeSchema, []string{}), ListSchemaNameDelimiter) +} + +// BuildSchemaNames appends the name or constituent names of a schema, the Object ID +// for ObjectSchemas and the stringified TypeID for all other schemas, to +// a list of strings. ListSchema names start with the ListSchema TypeID, and +// recursively append the name of list's ItemValue schema. +func BuildSchemaNames(typeSchema schema.Type, names []string) []string { + listSchema, isList := typeSchema.(*schema.ListSchema) + if isList { + names = append(names, string(listSchema.TypeID())) + return BuildSchemaNames(listSchema.ItemsValue, names) + } + objItemType, itemIsObject := schema.ConvertToObjectSchema(typeSchema) + if itemIsObject { + return append(names, objItemType.ID()) + } + return append(names, string(typeSchema.TypeID())) +} diff --git a/internal/builtinfunctions/functions_test.go b/internal/builtinfunctions/functions_test.go index 4af8497b..d9bb70cb 100644 --- a/internal/builtinfunctions/functions_test.go +++ b/internal/builtinfunctions/functions_test.go @@ -4,8 +4,11 @@ import ( "fmt" "go.arcalot.io/assert" "go.flow.arcalot.io/engine/internal/builtinfunctions" + "go.flow.arcalot.io/pluginsdk/schema" "math" "reflect" + "regexp" + "strings" "testing" ) @@ -819,3 +822,193 @@ func Test_floatToFormattedString_success(t *testing.T) { } } } + +func expectedOutputBindConstants(repInp any, items []any) []any { + return []any{ + map[string]any{builtinfunctions.CombinedObjPropertyItemName: items[0], builtinfunctions.CombinedObjPropertyConstantName: repInp}, + map[string]any{builtinfunctions.CombinedObjPropertyItemName: items[1], builtinfunctions.CombinedObjPropertyConstantName: repInp}, + map[string]any{builtinfunctions.CombinedObjPropertyItemName: items[2], builtinfunctions.CombinedObjPropertyConstantName: repInp}, + } +} + +func Test_bindConstants(t *testing.T) { + items := []any{ + map[string]any{"loop_id": 1}, + map[string]any{"loop_id": 2}, + map[string]any{"loop_id": 3}, + } + repeatedInputsMap := map[string]any{ + "a": "A", "b": "B", + } + repeatedInputsInt := 2 + repeatedInputsRegexPattern := regexp.MustCompile("p([a-z]+)ch") + testItems := map[string]any{ + "combine with map": repeatedInputsMap, + "combine with list": items, + "combine with int": repeatedInputsInt, + "combine with regex": repeatedInputsRegexPattern, + } + functionToTest := builtinfunctions.GetFunctions()["bindConstants"] + + for testName, testValue := range testItems { + tvLocal := testValue + t.Run(testName, func(t *testing.T) { + output, err := functionToTest.Call([]any{items, tvLocal}) + assert.NoError(t, err) + assert.Equals(t, output.([]any), expectedOutputBindConstants(tvLocal, items)) + }) + } + + t.Run("no items in input list", func(t *testing.T) { + output, err := functionToTest.Call([]any{[]any{}, repeatedInputsMap}) + assert.NoError(t, err) + assert.Equals(t, output.([]any), []any{}) + }) +} + +func defaultPropertySchema(t schema.Type) *schema.PropertySchema { + return schema.NewPropertySchema(t, nil, false, nil, nil, nil, nil, nil) +} + +func joinStrs(s1, s2 string) string { + return strings.Join([]string{s1, s2}, builtinfunctions.CombinedObjIDDelimiter) +} + +// TestHandleTypeSchemaCombine tests that the error cases for invalid +// and valid input cases create the expected error or type. +func TestHandleTypeSchemaCombine(t *testing.T) { + basicStringSchema := schema.NewStringSchema(nil, nil, nil) + basicIntSchema := schema.NewIntSchema(nil, nil, nil) + strTypeID := string(basicStringSchema.TypeID()) + intTypeID := string(basicIntSchema.TypeID()) + listInt := schema.NewListSchema(basicIntSchema, nil, nil) + listStr := schema.NewListSchema(basicStringSchema, nil, nil) + myFirstObj := schema.NewObjectSchema( + "ObjectTitle", + map[string]*schema.PropertySchema{ + "a": defaultPropertySchema(basicStringSchema), + "b": defaultPropertySchema(basicStringSchema), + }) + listMyFirstObj := schema.NewListSchema(myFirstObj, nil, nil) + constantsObj := schema.NewObjectSchema( + "Constants", + map[string]*schema.PropertySchema{ + "p_str": defaultPropertySchema(basicStringSchema), + "p_int": defaultPropertySchema(basicIntSchema), + }) + listPrefix := string(listInt.TypeID()) + builtinfunctions.ListSchemaNameDelimiter + + // invalid inputs + t.Run("first argument incorrect type", func(t *testing.T) { + _, err := builtinfunctions.HandleTypeSchemaCombine( + []schema.Type{basicStringSchema, basicIntSchema}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "expected first input type to be a list schema") + }) + t.Run("incorrect argument quantity", func(t *testing.T) { + _, err := builtinfunctions.HandleTypeSchemaCombine( + []schema.Type{listInt}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "expected exactly two input types") + }) + + // valid inputs + type testInput struct { + typeArgs []schema.Type + expectedResult string + } + testInputs := []testInput{ + { // Inputs: a list of int's and an object + typeArgs: []schema.Type{listInt, constantsObj}, + expectedResult: joinStrs(intTypeID, constantsObj.ID()), + }, + { // Inputs: a list of objects and an object + typeArgs: []schema.Type{listMyFirstObj, constantsObj}, + expectedResult: joinStrs(myFirstObj.ID(), constantsObj.ID()), + }, + { // Inputs: a list of objects and a string + typeArgs: []schema.Type{listMyFirstObj, basicStringSchema}, + expectedResult: joinStrs(myFirstObj.ID(), strTypeID), + }, + { // Inputs: a list of strings and an int + typeArgs: []schema.Type{listStr, basicIntSchema}, + expectedResult: joinStrs(strTypeID, intTypeID), + }, + { // Inputs: a list of strings and a list of objects + typeArgs: []schema.Type{listStr, listMyFirstObj}, + expectedResult: joinStrs(strTypeID, listPrefix+myFirstObj.ID()), + }, + } + + for _, input := range testInputs { + lclInput := input + t.Run(lclInput.expectedResult, func(t *testing.T) { + outputType, err := builtinfunctions.HandleTypeSchemaCombine(lclInput.typeArgs) + assert.NoError(t, err) + listItemObj, isObj := schema.ConvertToObjectSchema(outputType.(*schema.ListSchema).ItemsValue) + assert.Equals(t, isObj, true) + assert.Equals(t, listItemObj.ID(), lclInput.expectedResult) + }) + } +} + +func TestBuildSchemaNames(t *testing.T) { + basicStringSchema := schema.NewStringSchema(nil, nil, nil) + basicIntSchema := schema.NewIntSchema(nil, nil, nil) + intTypeID := string(basicIntSchema.TypeID()) + listInt := schema.NewListSchema(basicIntSchema, nil, nil) + myFirstObj := schema.NewObjectSchema( + "ObjectTitle", + map[string]*schema.PropertySchema{ + "a": defaultPropertySchema(basicStringSchema), + "b": defaultPropertySchema(basicStringSchema), + }) + listMyFirstObj := schema.NewListSchema(myFirstObj, nil, nil) + + listTypeID := string(listMyFirstObj.TypeID()) + + listListInt := schema.NewListSchema(listInt, nil, nil) + listListListInt := schema.NewListSchema(listListInt, nil, nil) + listListObj := schema.NewListSchema(listMyFirstObj, nil, nil) + listListListObj := schema.NewListSchema(listListObj, nil, nil) + + // valid inputs + type testInput struct { + typeArgs schema.Type + expectedResult []string + } + testInputs := []testInput{ + { + typeArgs: listInt, + expectedResult: []string{listTypeID, intTypeID}, + }, + { + typeArgs: listMyFirstObj, + expectedResult: []string{listTypeID, myFirstObj.ID()}, + }, + { + typeArgs: listListInt, + expectedResult: []string{listTypeID, listTypeID, intTypeID}, + }, + { + typeArgs: listListObj, + expectedResult: []string{listTypeID, listTypeID, myFirstObj.ID()}, + }, + { + typeArgs: listListListInt, + expectedResult: []string{listTypeID, listTypeID, listTypeID, intTypeID}, + }, + { + typeArgs: listListListObj, + expectedResult: []string{listTypeID, listTypeID, listTypeID, myFirstObj.ID()}, + }, + } + + for _, input := range testInputs { + lclInput := input + t.Run(strings.Join(lclInput.expectedResult, "_"), func(t *testing.T) { + outputNames := builtinfunctions.BuildSchemaNames(lclInput.typeArgs, []string{}) + assert.Equals(t, outputNames, lclInput.expectedResult) + }) + } +}