Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: case name templating #795

Merged
merged 4 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 73 additions & 4 deletions api/handle_scenarios.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package api

import (
"io"
"net/http"

"github.com/cockroachdb/errors"
"github.com/gin-gonic/gin"

"github.com/checkmarble/marble-backend/dto"
"github.com/checkmarble/marble-backend/models"
"github.com/checkmarble/marble-backend/models/ast"
"github.com/checkmarble/marble-backend/pure_utils"
"github.com/checkmarble/marble-backend/usecases"
"github.com/checkmarble/marble-backend/utils"
Expand All @@ -24,7 +28,13 @@ func listScenarios(uc usecases.Usecases) func(c *gin.Context) {
if presentError(ctx, c, err) {
return
}
c.JSON(http.StatusOK, pure_utils.Map(scenarios, dto.AdaptScenarioDto))

scenariosDto, err := pure_utils.MapErr(scenarios, dto.AdaptScenarioDto)
if presentError(ctx, c, err) {
return
}

c.JSON(http.StatusOK, scenariosDto)
}
}

Expand All @@ -49,7 +59,13 @@ func createScenario(uc usecases.Usecases) func(c *gin.Context) {
if presentError(ctx, c, err) {
return
}
c.JSON(http.StatusOK, dto.AdaptScenarioDto(scenario))

scenarioDto, err := dto.AdaptScenarioDto(scenario)
if presentError(ctx, c, err) {
return
}

c.JSON(http.StatusOK, scenarioDto)
}
}

Expand All @@ -64,7 +80,13 @@ func getScenario(uc usecases.Usecases) func(c *gin.Context) {
if presentError(ctx, c, err) {
return
}
c.JSON(http.StatusOK, dto.AdaptScenarioDto(scenario))

scenarioDto, err := dto.AdaptScenarioDto(scenario)
if presentError(ctx, c, err) {
return
}

c.JSON(http.StatusOK, scenarioDto)
}
}

Expand All @@ -86,6 +108,53 @@ func updateScenario(uc usecases.Usecases) func(c *gin.Context) {
if presentError(ctx, c, err) {
return
}
c.JSON(http.StatusOK, dto.AdaptScenarioDto(scenario))

scenarioDto, err := dto.AdaptScenarioDto(scenario)
if presentError(ctx, c, err) {
return
}

c.JSON(http.StatusOK, scenarioDto)
}
}

type PostScenarioAstValidationInputBody struct {
Node dto.NodeDto `json:"node" binding:"required"`
ExpectedReturnType string `json:"expected_return_type"`
}

func validateScenarioAst(uc usecases.Usecases) func(c *gin.Context) {
return func(c *gin.Context) {
ctx := c.Request.Context()
var input PostScenarioAstValidationInputBody
err := c.ShouldBindJSON(&input)
if err != nil && err != io.EOF { //nolint:errorlint
c.Status(http.StatusBadRequest)
return
}

scenarioId := c.Param("scenario_id")

astNode, err := dto.AdaptASTNode(input.Node)
if err != nil {
presentError(ctx, c, errors.Wrap(models.BadParameterError, err.Error()))
return
}

expectedReturnType := "bool"
if input.ExpectedReturnType != "" {
expectedReturnType = input.ExpectedReturnType
}

usecase := usecasesWithCreds(ctx, uc).NewScenarioUsecase()
astValidation, err := usecase.ValidateScenarioAst(ctx, scenarioId, &astNode, expectedReturnType)

if presentError(ctx, c, err) {
return
}

c.JSON(http.StatusOK, gin.H{
"ast_validation": ast.AdaptNodeEvaluationDto(astValidation),
})
}
}
1 change: 1 addition & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func addRoutes(r *gin.Engine, conf Configuration, uc usecases.Usecases, auth Aut
router.POST("/scenarios", tom, createScenario(uc))
router.GET("/scenarios/:scenario_id", tom, getScenario(uc))
router.PATCH("/scenarios/:scenario_id", tom, updateScenario(uc))
router.POST("/scenarios/:scenario_id/validate-ast", tom, validateScenarioAst(uc))

router.GET("/scenario-iterations", tom, handleListScenarioIterations(uc))
router.POST("/scenario-iterations", tom, handleCreateScenarioIteration(uc))
Expand Down
24 changes: 22 additions & 2 deletions dto/scenarios.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dto

import (
"fmt"
"time"

"github.com/checkmarble/marble-backend/models"
Expand All @@ -15,15 +16,16 @@ type ScenarioDto struct {
DecisionToCaseOutcomes []string `json:"decision_to_case_outcomes"`
DecisionToCaseInboxId null.String `json:"decision_to_case_inbox_id"`
DecisionToCaseWorkflowType string `json:"decision_to_case_workflow_type"`
DecisionToCaseNameTemplate *NodeDto `json:"decision_to_case_name_template"`
Description string `json:"description"`
LiveVersionID *string `json:"live_version_id,omitempty"`
Name string `json:"name"`
OrganizationId string `json:"organization_id"`
TriggerObjectType string `json:"trigger_object_type"`
}

func AdaptScenarioDto(scenario models.Scenario) ScenarioDto {
return ScenarioDto{
func AdaptScenarioDto(scenario models.Scenario) (ScenarioDto, error) {
scenarioDto := ScenarioDto{
Id: scenario.Id,
CreatedAt: scenario.CreatedAt,
DecisionToCaseInboxId: null.StringFromPtr(scenario.DecisionToCaseInboxId),
Expand All @@ -36,6 +38,17 @@ func AdaptScenarioDto(scenario models.Scenario) ScenarioDto {
OrganizationId: scenario.OrganizationId,
TriggerObjectType: scenario.TriggerObjectType,
}

if scenario.DecisionToCaseNameTemplate != nil {
astDto, err := AdaptNodeDto(*scenario.DecisionToCaseNameTemplate)
if err != nil {
return ScenarioDto{},
fmt.Errorf("unable to marshal ast expression: %w", err)
}
scenarioDto.DecisionToCaseNameTemplate = &astDto
}

return scenarioDto, nil
}

// Create scenario DTO
Expand All @@ -61,6 +74,7 @@ type UpdateScenarioBody struct {
DecisionToCaseOutcomes []string `json:"decision_to_case_outcomes"`
DecisionToCaseInboxId null.String `json:"decision_to_case_inbox_id"`
DecisionToCaseWorkflowType *string `json:"decision_to_case_workflow_type"`
DecisionToCaseNameTemplate *NodeDto `json:"decision_to_case_name_template"`
Description *string `json:"description"`
Name *string `json:"name"`
}
Expand All @@ -79,5 +93,11 @@ func AdaptUpdateScenarioInput(scenarioId string, input UpdateScenarioBody) model
val := models.WorkflowType(*input.DecisionToCaseWorkflowType)
parsedInput.DecisionToCaseWorkflowType = &val
}
if input.DecisionToCaseNameTemplate != nil {
astNode, err := AdaptASTNode(*input.DecisionToCaseNameTemplate)
if err == nil {
parsedInput.DecisionToCaseNameTemplate = &astNode
}
}
return parsedInput
}
5 changes: 5 additions & 0 deletions models/ast/ast_function.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const (
FUNC_STRING_STARTS_WITH
FUNC_STRING_ENDS_WITH
FUNC_IS_MULTIPLE_OF
FUNC_STRING_TEMPLATE
FUNC_UNDEFINED Function = -1
FUNC_UNKNOWN Function = -2
)
Expand Down Expand Up @@ -229,6 +230,10 @@ var FuncAttributesMap = map[Function]FuncAttributes{
AstName: "IsMultipleOf",
NamedArguments: []string{"value", "divider"},
},
FUNC_STRING_TEMPLATE: {
DebugName: "FUNC_STRING_TEMPLATE",
AstName: "StringTemplate",
},
FUNC_FILTER: FuncFilterAttributes,
}

Expand Down
14 changes: 13 additions & 1 deletion models/ast/ast_node_evaluation.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,17 @@ func (root NodeEvaluation) GetBoolReturnValue() (bool, error) {
}

return false, errors.New(
fmt.Sprintf("root ast expression does not return a boolean, '%v' instead", root.ReturnValue))
fmt.Sprintf("root ast expression does not return a boolean, '%T' instead", root.ReturnValue))
}

func (root NodeEvaluation) GetStringReturnValue() (string, error) {
if root.ReturnValue == nil {
return "", ErrNullFieldRead
}

if returnValue, ok := root.ReturnValue.(string); ok {
return returnValue, nil
}

return "", errors.New(fmt.Sprintf("ast expression expected to return a string, got '%T' instead", root.ReturnValue))
}
3 changes: 3 additions & 0 deletions models/scenarios.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package models
import (
"time"

"github.com/checkmarble/marble-backend/models/ast"
"github.com/guregu/null/v5"
)

Expand All @@ -26,6 +27,7 @@ type Scenario struct {
DecisionToCaseOutcomes []Outcome
DecisionToCaseInboxId *string
DecisionToCaseWorkflowType WorkflowType
DecisionToCaseNameTemplate *ast.Node
Description string
LiveVersionID *string
Name string
Expand All @@ -45,6 +47,7 @@ type UpdateScenarioInput struct {
DecisionToCaseOutcomes []Outcome
DecisionToCaseInboxId null.String
DecisionToCaseWorkflowType *WorkflowType
DecisionToCaseNameTemplate *ast.Node
Description *string
Name *string
}
Expand Down
10 changes: 10 additions & 0 deletions repositories/dbmodels/db_scenario.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dbmodels

import (
"fmt"
"time"

"github.com/checkmarble/marble-backend/models"
Expand All @@ -16,6 +17,7 @@ type DBScenario struct {
DecisionToCaseInboxId pgtype.Text `db:"decision_to_case_inbox_id"`
DecisionToCaseOutcomes []string `db:"decision_to_case_outcomes"`
DecisionToCaseWorkflowType string `db:"decision_to_case_workflow_type"`
DecisionToCaseNameTemplate []byte `db:"decision_to_case_name_template"`
DeletedAt pgtype.Time `db:"deleted_at"`
Description string `db:"description"`
LiveVersionID pgtype.Text `db:"live_scenario_iteration_id"`
Expand Down Expand Up @@ -46,5 +48,13 @@ func AdaptScenario(dto DBScenario) (models.Scenario, error) {
if dto.LiveVersionID.Valid {
scenario.LiveVersionID = &dto.LiveVersionID.String
}

var err error
scenario.DecisionToCaseNameTemplate, err =
AdaptSerializedAstExpression(dto.DecisionToCaseNameTemplate)
if err != nil {
return scenario, fmt.Errorf("unable to unmarshal ast expression: %w", err)
}

return scenario, nil
}
11 changes: 11 additions & 0 deletions repositories/migrations/20250102151657_case_name_template.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE scenarios
ADD COLUMN decision_to_case_name_template JSON;
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
ALTER TABLE scenarios
DROP COLUMN decision_to_case_name_template;
-- +goose StatementEnd
10 changes: 10 additions & 0 deletions repositories/scenarios_write.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package repositories

import (
"context"
"fmt"

"github.com/checkmarble/marble-backend/models"
"github.com/checkmarble/marble-backend/repositories/dbmodels"
Expand Down Expand Up @@ -66,6 +67,15 @@ func (repo *MarbleDbRepository) UpdateScenario(ctx context.Context, exec Executo
sql = sql.Set("decision_to_case_workflow_type", scenario.DecisionToCaseWorkflowType)
countApply++
}
if scenario.DecisionToCaseNameTemplate != nil {
serializedAst, err := dbmodels.SerializeFormulaAstExpression(scenario.DecisionToCaseNameTemplate)
if err != nil {
return fmt.Errorf(
"unable to marshal ast expression: %w", err)
}
sql = sql.Set("decision_to_case_name_template", serializedAst)
countApply++
}
if scenario.Description != nil {
sql = sql.Set("description", scenario.Description)
countApply++
Expand Down
71 changes: 71 additions & 0 deletions usecases/ast_eval/evaluate/eval_string_template.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package evaluate

import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"

"github.com/checkmarble/marble-backend/models/ast"
cockroachdbErrors "github.com/cockroachdb/errors"
)

var stringTemplateVariableRegexp = regexp.MustCompile(`(?mi)%([a-z0-9_]+)%`)

type StringTemplate struct{}

func (f StringTemplate) Evaluate(ctx context.Context, arguments ast.Arguments) (any, []error) {
if err := verifyNumberOfArguments(arguments.Args, 1); err != nil {
return MakeEvaluateError(err)
}

if arguments.Args[0] == nil || arguments.Args[0] == "" {
return nil, MakeAdaptedArgsErrors([]error{ast.ErrArgumentRequired})
}

template, templateErr := adaptArgumentToString(arguments.Args[0])
if templateErr != nil {
return MakeEvaluateError(templateErr)
}

var execErrors []error
replacedTemplate := template
for _, match := range stringTemplateVariableRegexp.FindAllStringSubmatch(template, -1) {
variableValue, argErr := adapatVariableValue(arguments.NamedArgs, match[1])
if argErr != nil {
if !errors.Is(argErr, ast.ErrArgumentRequired) {
execErrors = append(execErrors, argErr)
continue
}
variableValue = "{}"
}
replacedTemplate = strings.Replace(replacedTemplate,
fmt.Sprintf("%%%s%%", match[1]), variableValue, -1)
}

errs := MakeAdaptedArgsErrors(execErrors)
if len(errs) > 0 {
return nil, errs
}

return replacedTemplate, nil
}

func adapatVariableValue(namedArgs map[string]any, name string) (string, error) {
if value, err := AdaptNamedArgument(namedArgs, name, adaptArgumentToString); err == nil {
return value, nil
}

if value, err := AdaptNamedArgument(namedArgs, name, promoteArgumentToFloat64); err == nil {
return strconv.FormatFloat(value, 'f', 2, 64), nil
}

if value, err := AdaptNamedArgument(namedArgs, name, promoteArgumentToInt64); err == nil {
return strconv.FormatInt(value, 10), nil
}

return "", cockroachdbErrors.Wrap(ast.ErrArgumentInvalidType,
"all variables to String Template Evaluate must be string, int or float")
}
Loading
Loading