diff --git a/expressions/errors.go b/expressions/errors.go new file mode 100644 index 000000000..7e2b8f672 --- /dev/null +++ b/expressions/errors.go @@ -0,0 +1,124 @@ +package expressions + +import ( + "fmt" + "regexp" + "strings" + + "github.com/google/cel-go/common/operators" + "github.com/google/cel-go/common/types" +) + +// converters which translate cel errors into better keel validation messages +var messageConverters = []errorConverter{ + undefinedField, + noFieldSelection, + noOperatorOverload, + undeclaredOperatorReference, + undeclaredVariableReference, + unrecognisedToken, + mismatchedInput, +} + +type errorConverter struct { + Regex string + Construct func(expectedReturnType *types.Type, values []string) string +} + +var undefinedField = errorConverter{ + Regex: `undefined field '(.+)'`, + Construct: func(expectedReturnType *types.Type, values []string) string { + return fmt.Sprintf("field '%s' does not exist", values[0]) + }, +} + +var noFieldSelection = errorConverter{ + Regex: `type '(.+)' does not support field selection`, + Construct: func(expectedReturnType *types.Type, values []string) string { + return fmt.Sprintf("type %s does not have any fields to select", mapType(values[0])) + }, +} + +var noOperatorOverload = errorConverter{ + Regex: `found no matching overload for '(.+)' applied to '\((.+),\s*(.+)\)'`, + Construct: func(expectedReturnType *types.Type, values []string) string { + return fmt.Sprintf("cannot use operator '%s' with types %s and %s", mapOperator(values[0]), mapType(values[1]), mapType(values[2])) + }, +} + +var undeclaredOperatorReference = errorConverter{ + Regex: `undeclared reference to '_(.+)_' \(in container ''\)`, + Construct: func(expectedReturnType *types.Type, values []string) string { + return fmt.Sprintf("operator '%s' not supported in this context", mapOperator(values[0])) + }, +} + +var undeclaredVariableReference = errorConverter{ + Regex: `undeclared reference to '(.+)' \(in container ''\)`, + Construct: func(expectedReturnType *types.Type, values []string) string { + switch { + case expectedReturnType.String() == "_Role" || expectedReturnType.String() == "_Role[]": + return fmt.Sprintf("%s is not a role defined in your schema", values[0]) + } + return fmt.Sprintf("unknown identifier '%s'", values[0]) + }, +} + +var unrecognisedToken = errorConverter{ + Regex: `Syntax error: token recognition error at: '(.+)'`, + Construct: func(expectedReturnType *types.Type, values []string) string { + if values[0] == "= " { + return "assignment operator '=' not valid - did you mean to use the comparison operator '=='?" + } + return fmt.Sprintf("invalid character(s) '%s' in expression", strings.Trim(values[0], " ")) + }, +} + +var mismatchedInput = errorConverter{ + Regex: `Syntax error: mismatched input '(.+)' expecting (.+)`, + Construct: func(expectedReturnType *types.Type, values []string) string { + return fmt.Sprintf("unknown or unsupported identifier or operator '%s' in expression", values[0]) + }, +} + +func mapOperator(op string) string { + switch op { + case operators.In: + return "in" + default: + return strings.Trim(op, "_") + } +} + +func mapType(t string) string { + isArray := false + + pattern := regexp.MustCompile(`list\((.+)\)`) + if matches := pattern.FindStringSubmatch(t); matches != nil { + isArray = true + t = matches[1] + } + + var keelType string + switch t { + case "string": + keelType = "Text" + case "int": + keelType = "Number" + case "double": + keelType = "Decimal" + case "bool": + keelType = "Boolean" + case "timestamp": + keelType = "Timestamp" + default: + // Enum or Model name + keelType = strings.TrimPrefix(t, "_") + } + + if isArray { + keelType += "[]" + } + + return keelType +} diff --git a/expressions/library.go b/expressions/library.go new file mode 100644 index 000000000..48612fc57 --- /dev/null +++ b/expressions/library.go @@ -0,0 +1,28 @@ +package expressions + +import ( + "github.com/google/cel-go/cel" +) + +type standardKeelLib struct{} + +var _ cel.Library = new(standardKeelLib) + +func standardKeelLibrary() cel.EnvOption { + return cel.Lib(&standardKeelLib{}) +} + +// LibraryName returns our standard library for expressions +func (*standardKeelLib) LibraryName() string { + return "keel" +} + +func (l *standardKeelLib) CompileOptions() []cel.EnvOption { + return []cel.EnvOption{ + // Define any globally configured CEL options here + } +} + +func (*standardKeelLib) ProgramOptions() []cel.ProgramOption { + return []cel.ProgramOption{} +} diff --git a/expressions/options/options.go b/expressions/options/options.go new file mode 100644 index 000000000..e82fb769e --- /dev/null +++ b/expressions/options/options.go @@ -0,0 +1,452 @@ +package options + +import ( + "fmt" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/operators" + "github.com/google/cel-go/common/overloads" + "github.com/google/cel-go/common/types" + "github.com/iancoleman/strcase" + + "github.com/teamkeel/keel/expressions" + "github.com/teamkeel/keel/expressions/typing" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/query" +) + +// Defines which types are compatible with each other for each comparison operator +// This is used to generate all the necessary combinations of operator overloads +var typeCompatibilityMapping = map[string][][]*types.Type{ + operators.Equals: { + {types.StringType, typing.Text, typing.ID, typing.Markdown}, + {types.IntType, types.DoubleType, typing.Number, typing.Decimal}, + {typing.Date, typing.Timestamp, types.TimestampType}, + {typing.Boolean, types.BoolType}, + {types.NewListType(types.StringType), typing.TextArray, typing.IDArray, typing.MarkdownArray}, + {types.NewListType(types.IntType), types.NewListType(types.DoubleType), typing.NumberArray, typing.DecimalArray}, + }, + operators.NotEquals: { + {types.StringType, typing.Text, typing.ID, typing.Markdown}, + {types.IntType, types.DoubleType, typing.Number, typing.Decimal}, + {typing.Date, typing.Timestamp, types.TimestampType}, + {typing.Boolean, types.BoolType}, + {types.NewListType(types.StringType), typing.TextArray, typing.IDArray, typing.MarkdownArray}, + {types.NewListType(types.IntType), types.NewListType(types.DoubleType), typing.NumberArray, typing.DecimalArray}, + }, + operators.Greater: { + {types.IntType, types.DoubleType, typing.Number, typing.Decimal}, + {typing.Date, typing.Timestamp, types.TimestampType}, + }, + operators.GreaterEquals: { + {types.IntType, types.DoubleType, typing.Number, typing.Decimal}, + {typing.Date, typing.Timestamp, types.TimestampType}, + }, + operators.Less: { + {types.IntType, types.DoubleType, typing.Number, typing.Decimal}, + {typing.Date, typing.Timestamp, types.TimestampType}, + }, + operators.LessEquals: { + {types.IntType, types.DoubleType, typing.Number, typing.Decimal}, + {typing.Date, typing.Timestamp, types.TimestampType}, + }, + operators.Add: { + {types.IntType, types.DoubleType, typing.Number, typing.Decimal}, + }, + operators.Subtract: { + {types.IntType, types.DoubleType, typing.Number, typing.Decimal}, + }, + operators.Multiply: { + {types.IntType, types.DoubleType, typing.Number, typing.Decimal}, + }, + operators.Divide: { + {types.IntType, types.DoubleType, typing.Number, typing.Decimal}, + }, +} + +// WithSchemaTypes declares schema models, enums and roles as types in the CEL environment +func WithSchemaTypes(schema []*parser.AST) expressions.Option { + return func(p *expressions.Parser) error { + p.Provider.Schema = schema + + var options []cel.EnvOption + for _, enum := range query.Enums(schema) { + options = append(options, cel.Constant(enum.Name.Value, types.NewObjectType(fmt.Sprintf("%s_Enum", enum.Name.Value)), nil)) + } + + for _, role := range query.Roles(schema) { + options = append(options, cel.Constant(role.Name.Value, typing.Role, nil)) + } + + for _, ast := range schema { + for _, env := range ast.EnvironmentVariables { + if p.Provider.Objects["_EnvironmentVariables"] == nil { + p.Provider.Objects["_EnvironmentVariables"] = map[string]*types.Type{} + } + + p.Provider.Objects["_EnvironmentVariables"][env] = types.StringType + } + } + + for _, ast := range schema { + for _, env := range ast.Secrets { + if p.Provider.Objects["_Secrets"] == nil { + p.Provider.Objects["_Secrets"] = map[string]*types.Type{} + } + + p.Provider.Objects["_Secrets"][env] = types.StringType + } + } + + if options != nil { + var err error + p.CelEnv, err = p.CelEnv.Extend(options...) + if err != nil { + return err + } + } + + return nil + } +} + +// WithVariable declares a new variable in the CEL environment +func WithVariable(identifier string, typeName string, isRepeated bool) expressions.Option { + return func(p *expressions.Parser) error { + t, err := typing.MapType(p.Provider.Schema, typeName, isRepeated) + if err != nil { + return err + } + + env, err := p.CelEnv.Extend(cel.Variable(identifier, t)) + if err != nil { + return err + } + + p.CelEnv = env + + return nil + } +} + +// WithConstant declares a new constant in the CEL environment +func WithConstant(identifier string, typeName string) expressions.Option { + return func(p *expressions.Parser) error { + t, err := typing.MapType(p.Provider.Schema, typeName, false) + if err != nil { + return err + } + + p.CelEnv, err = p.CelEnv.Extend(cel.Constant(identifier, t, nil)) + if err != nil { + return err + } + + return nil + } +} + +// WithCtx defines the ctx variable in the CEL environment +func WithCtx() expressions.Option { + return func(p *expressions.Parser) error { + p.Provider.Objects["_Context"] = map[string]*types.Type{ + "identity": types.NewObjectType(parser.IdentityModelName), + "isAuthenticated": types.BoolType, + "now": typing.Timestamp, + "secrets": types.NewObjectType("_Secrets"), + "env": types.NewObjectType("_EnvironmentVariables"), + "headers": types.NewObjectType("_Headers"), + } + + if p.Provider.Objects["_Secrets"] == nil { + p.Provider.Objects["_Secrets"] = map[string]*types.Type{} + } + + if p.Provider.Objects["_EnvironmentVariables"] == nil { + p.Provider.Objects["_EnvironmentVariables"] = map[string]*types.Type{} + } + + if p.Provider.Objects["_Headers"] == nil { + p.Provider.Objects["_Headers"] = map[string]*types.Type{} + } + + var err error + p.CelEnv, err = p.CelEnv.Extend(cel.Variable("ctx", types.NewObjectType("_Context"))) + if err != nil { + return err + } + + return nil + } +} + +// WithActionInputs declares variables in the CEL environment for each action input +func WithActionInputs(schema []*parser.AST, action *parser.ActionNode) expressions.Option { + return func(p *expressions.Parser) error { + model := query.ActionModel(schema, action.Name.Value) + opts := []cel.EnvOption{} + + // Add filter inputs as variables + for _, f := range action.Inputs { + if f.Type.Fragments[0].Fragment == parser.MessageFieldTypeAny { + continue + } + + if query.IsMessage(schema, f.Type.ToString()) { + continue + } + + typeName := query.ResolveInputType(schema, f, model, action) + + isRepeated := false + if field := query.ResolveInputField(schema, f, model); field != nil { + isRepeated = field.Repeated + } + + t, err := typing.MapType(p.Provider.Schema, typeName, isRepeated) + if err != nil { + return err + } + + opts = append(opts, cel.Variable(f.Name(), t)) + } + + // Add with inputs as variables + for _, f := range action.With { + typeName := query.ResolveInputType(schema, f, model, action) + + isRepeated := false + if field := query.ResolveInputField(schema, f, model); field != nil { + isRepeated = field.Repeated + } + + t, err := typing.MapType(p.Provider.Schema, typeName, isRepeated) + if err != nil { + return err + } + + opts = append(opts, cel.Variable(f.Name(), t)) + } + + env, err := p.CelEnv.Extend(opts...) + if err != nil { + return err + } + + p.CelEnv = env + + return nil + } +} + +// WithLogicalOperators enables support for the equals '==' and not equals '!=' operators for all types +func WithLogicalOperators() expressions.Option { + return func(p *expressions.Parser) error { + var err error + + p.CelEnv, err = p.CelEnv.Extend( + cel.Function(operators.LogicalAnd, + cel.Overload(overloads.LogicalAnd, []*types.Type{types.BoolType, types.BoolType}, types.BoolType)), + cel.Function(operators.LogicalOr, + cel.Overload(overloads.LogicalOr, []*types.Type{types.BoolType, types.BoolType}, types.BoolType)), + cel.Function(operators.LogicalNot, + cel.Overload(overloads.LogicalNot, []*types.Type{types.BoolType}, types.BoolType))) + if err != nil { + return err + } + + return nil + } +} + +// WithComparisonOperators enables support for comparison operators for all types +func WithComparisonOperators() expressions.Option { + return func(p *expressions.Parser) error { + mapping := map[string][][]*types.Type{} + + var err error + if p.Provider.Schema != nil { + // For each enum type, configure equals, not equals and 'in' operators + for _, enum := range query.Enums(p.Provider.Schema) { + enumType := types.NewOpaqueType(enum.Name.Value) + enumTypeArr := types.NewOpaqueType(enum.Name.Value + "[]") + + mapping[operators.Equals] = append(mapping[operators.Equals], + []*types.Type{enumType}, + []*types.Type{enumTypeArr, types.NewListType(enumType)}, + ) + + mapping[operators.NotEquals] = append(mapping[operators.NotEquals], + []*types.Type{enumType}, + []*types.Type{enumTypeArr, types.NewListType(enumType)}, + ) + + p.CelEnv, err = p.CelEnv.Extend( + cel.Function(operators.In, + cel.Overload(fmt.Sprintf("in_%s", strcase.ToLowerCamel(enum.Name.Value)), []*types.Type{enumType, enumTypeArr}, types.BoolType), + cel.Overload(fmt.Sprintf("in_%s_literal", strcase.ToLowerCamel(enum.Name.Value)), []*types.Type{enumType, types.NewListType(enumType)}, types.BoolType), + ), + cel.Function(operators.Equals, + cel.Overload(fmt.Sprintf("equals_%s[]_%s", strcase.ToLowerCamel(enum.Name.Value), strcase.ToLowerCamel(enum.Name.Value)), argTypes(enumTypeArr, enumType), types.BoolType), + cel.Overload(fmt.Sprintf("equals_%s_%s[]", strcase.ToLowerCamel(enum.Name.Value), strcase.ToLowerCamel(enum.Name.Value)), argTypes(enumType, enumTypeArr), types.BoolType), + )) + if err != nil { + return err + } + } + + // For each models, configure equals, not equals and 'in' operators + for _, model := range query.Models(p.Provider.Schema) { + modelType := types.NewObjectType(model.Name.Value) + modelTypeArr := types.NewObjectType(model.Name.Value + "[]") + + mapping[operators.Equals] = append(mapping[operators.Equals], + []*types.Type{modelType}, + []*types.Type{modelTypeArr}, + ) + + mapping[operators.NotEquals] = append(mapping[operators.NotEquals], + []*types.Type{modelType}, + []*types.Type{modelTypeArr}, + ) + + p.CelEnv, err = p.CelEnv.Extend( + cel.Function(operators.In, + cel.Overload(fmt.Sprintf("in_%s", strcase.ToLowerCamel(model.Name.Value)), []*types.Type{modelType, modelTypeArr}, types.BoolType), + )) + if err != nil { + return err + } + } + } + + for k, v := range typeCompatibilityMapping { + mapping[k] = append(mapping[k], v...) + } + + // Add operator overloads for each compatible type combination + options := []cel.EnvOption{} + for k, v := range mapping { + switch k { + case operators.Equals, operators.NotEquals, operators.Greater, operators.GreaterEquals, operators.Less, operators.LessEquals: + for _, t := range v { + for _, arg1 := range t { + for _, arg2 := range t { + opt := cel.Function(k, cel.Overload(overloadName(k, arg1, arg2), argTypes(arg1, arg2), types.BoolType)) + options = append(options, opt) + } + } + } + } + } + + p.CelEnv, err = p.CelEnv.Extend(options...) + if err != nil { + return err + } + + // Explicitly defining the 'in' operator overloads + p.CelEnv, err = p.CelEnv.Extend( + cel.Function(operators.In, + cel.Overload("in_string_list(string)", argTypes(types.StringType, types.NewListType(types.StringType)), types.BoolType), + cel.Overload("in_string_Text[]", argTypes(types.StringType, typing.TextArray), types.BoolType), + cel.Overload("in_Text_Text[]", argTypes(typing.Text, typing.TextArray), types.BoolType), + cel.Overload("in_Text_list(string)", argTypes(typing.Text, types.NewListType(types.StringType)), types.BoolType), + + cel.Overload("in_string_ID[]", argTypes(types.StringType, typing.IDArray), types.BoolType), + cel.Overload("in_ID_ID[]", argTypes(typing.ID, typing.IDArray), types.BoolType), + cel.Overload("in_ID_list(string)", argTypes(typing.ID, types.NewListType(types.StringType)), types.BoolType), + + cel.Overload("in_int_list(int)", argTypes(types.IntType, types.NewListType(types.IntType)), types.BoolType), + cel.Overload("in_int_Number[]", argTypes(types.IntType, typing.NumberArray), types.BoolType), + cel.Overload("in_Number_Number[]", argTypes(typing.Number, typing.NumberArray), types.BoolType), + cel.Overload("in_Number_list(int)", argTypes(typing.Number, types.NewListType(types.IntType)), types.BoolType), + + cel.Overload("in_double_list(double)", argTypes(types.DoubleType, types.NewListType(types.DoubleType)), types.BoolType), + cel.Overload("in_double_Decimal[]", argTypes(types.DoubleType, typing.DecimalArray), types.BoolType), + cel.Overload("in_Decimal_Decimal[]", argTypes(typing.Text, typing.DecimalArray), types.BoolType), + cel.Overload("in_Decimal_list(double)", argTypes(typing.Decimal, types.NewListType(types.DoubleType)), types.BoolType), + + cel.Overload("in_bool_list(bool)", argTypes(types.BoolType, types.NewListType(types.DoubleType)), types.BoolType), + cel.Overload("in_bool_Boolean[]", argTypes(types.BoolType, typing.BooleanArray), types.BoolType), + cel.Overload("in_Boolean_Boolean[]", argTypes(typing.Boolean, typing.BooleanArray), types.BoolType), + cel.Overload("in_Boolean_list(bool)", argTypes(typing.Boolean, types.NewListType(types.BoolType)), types.BoolType), + + cel.Overload("in_Timestamp_Timestamp[]", argTypes(typing.Timestamp, typing.TimestampArray), types.BoolType), + cel.Overload("in_Date_Date[]", argTypes(typing.Date, typing.DateArray), types.BoolType), + ), + ) + if err != nil { + return err + } + + // Backwards compatibility for relationships expressions like `organisation.people.name == "Keel"` which is actually performing an "ANY" query + // To be deprecated in favour of functions + p.CelEnv, err = p.CelEnv.Extend( + cel.Function(operators.Equals, + cel.Overload("equals_Text[]_Text", argTypes(typing.TextArray, typing.Text), types.BoolType), + cel.Overload("equals_Text[]_string", argTypes(typing.TextArray, types.StringType), types.BoolType), + cel.Overload("equals_Text_Text[]", argTypes(typing.Text, typing.TextArray), types.BoolType), + + cel.Overload("equals_ID[]_ID", argTypes(typing.IDArray, typing.ID), types.BoolType), + cel.Overload("equals_ID[]_string", argTypes(typing.IDArray, types.StringType), types.BoolType), + cel.Overload("equals_ID_ID[]", argTypes(typing.ID, typing.IDArray), types.BoolType), + + cel.Overload("equals_Number[]_Number", argTypes(typing.NumberArray, typing.Number), types.BoolType), + cel.Overload("equals_Number[]_int", argTypes(typing.NumberArray, types.IntType), types.BoolType), + cel.Overload("equals_Number_Number[]", argTypes(typing.Number, typing.NumberArray), types.BoolType), + ), + ) + if err != nil { + return err + } + + return nil + } +} + +// WithArithmeticOperators enables support for arithmetic operators +func WithArithmeticOperators() expressions.Option { + return func(p *expressions.Parser) error { + // Add operator overloads for each compatible type combination + options := []cel.EnvOption{} + for k, v := range typeCompatibilityMapping { + switch k { + case operators.Add, operators.Subtract, operators.Multiply, operators.Divide: + for _, t := range v { + for _, arg1 := range t { + for _, arg2 := range t { + opt := cel.Function(k, cel.Overload(overloadName(k, arg1, arg2), argTypes(arg1, arg2), typing.Decimal)) + options = append(options, opt) + } + } + } + } + } + + var err error + p.CelEnv, err = p.CelEnv.Extend(options...) + if err != nil { + return err + } + + return nil + } +} + +// WithReturnTypeAssertion will check that the expression evaluates to a specific type +func WithReturnTypeAssertion(returnType string, asArray bool) expressions.Option { + return func(p *expressions.Parser) error { + var err error + p.ExpectedReturnType, err = typing.MapType(p.Provider.Schema, returnType, asArray) + return err + } +} + +func argTypes(args ...*types.Type) []*types.Type { + return args +} + +func overloadName(op string, t1 *types.Type, t2 *types.Type) string { + return fmt.Sprintf("%s_%s_%s", op, t1.String(), t2.String()) +} diff --git a/expressions/parser.go b/expressions/parser.go new file mode 100644 index 000000000..bee6ad771 --- /dev/null +++ b/expressions/parser.go @@ -0,0 +1,182 @@ +package expressions + +import ( + "fmt" + "regexp" + "strings" + + "github.com/alecthomas/participle/v2/lexer" + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/teamkeel/keel/expressions/typing" + "github.com/teamkeel/keel/schema/node" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/validation/errorhandling" +) + +type Parser struct { + CelEnv *cel.Env + Provider *typing.TypeProvider + ExpectedReturnType *types.Type +} + +type Option func(*Parser) error + +// NewParser creates a new expression parser +func NewParser(options ...Option) (*Parser, error) { + typeProvider := typing.NewTypeProvider() + + env, err := cel.NewCustomEnv( + standardKeelLibrary(), + cel.ClearMacros(), + cel.CustomTypeProvider(typeProvider), + cel.EagerlyValidateDeclarations(true), + ) + if err != nil { + return nil, fmt.Errorf("program setup err: %s", err) + } + + parser := &Parser{ + CelEnv: env, + Provider: typeProvider, + } + + for _, opt := range options { + if err := opt(parser); err != nil { + return nil, err + } + } + + return parser, nil +} + +func (p *Parser) Validate(expression *parser.Expression) ([]*errorhandling.ValidationError, error) { + expr := expression.String() + ast, issues := p.CelEnv.Compile(expr) + + if issues != nil && issues.Err() != nil { + validationErrors := []*errorhandling.ValidationError{} + + for _, e := range issues.Errors() { + msg := e.Message + for _, match := range messageConverters { + pattern, err := regexp.Compile(match.Regex) + if err != nil { + return nil, err + } + if matches := pattern.FindStringSubmatch(e.Message); matches != nil { + msg = match.Construct(p.ExpectedReturnType, matches[1:]) + break + } + } + + var n node.Node + if e.ExprID == 0 { + // Synax errors means the expression could not be parsed, which means there are no expr nodes + n = expression.Node + } else { + parsed, _ := p.CelEnv.Parse(expr) + offset := parsed.NativeRep().SourceInfo().OffsetRanges()[e.ExprID] + start := parsed.NativeRep().SourceInfo().GetStartLocation(e.ExprID) + end := parsed.NativeRep().SourceInfo().GetStopLocation(e.ExprID) + + pos := lexer.Position{ + Offset: int(offset.Start), + Line: start.Line(), + Column: start.Column(), + } + endPos := lexer.Position{ + Offset: int(offset.Stop), + Line: end.Line(), + Column: end.Column(), + } + + n = node.Node{ + Pos: lexer.Position{ + Filename: expression.Pos.Filename, + Line: expression.Pos.Line + pos.Line - 1, + Column: expression.Pos.Column + pos.Column, + Offset: expression.Pos.Offset + pos.Offset, + }, + EndPos: lexer.Position{ + Filename: expression.Pos.Filename, + Line: expression.Pos.Line + endPos.Line - 1, + Column: expression.Pos.Column + endPos.Column, + Offset: expression.Pos.Offset + endPos.Offset, + }, + } + } + + validationErrors = append(validationErrors, + errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeExpressionError, + errorhandling.ErrorDetails{ + Message: msg, + }, + n, + )) + + // For syntax errors (i.e. unparseable expressions), we only need to show the first error. + if strings.HasPrefix(e.Message, "Syntax error:") { + break + } + } + + return validationErrors, nil + } + + if p.ExpectedReturnType != nil && ast.OutputType() != types.NullType { + out := mapType(ast.OutputType().String()) + + // Backwards compatibility for relationships expressions which is actually performing an "ANY" query + // For example, @where(supplier.products.brand.isActive) + if mapType(p.ExpectedReturnType.String()) == typing.Boolean.String() && out == typing.BooleanArray.String() { + return nil, nil + } + + if out != "dyn[]" && !typesAssignable(p.ExpectedReturnType, ast.OutputType()) { + return []*errorhandling.ValidationError{ + errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeExpressionError, + errorhandling.ErrorDetails{ + Message: fmt.Sprintf("expression expected to resolve to type %s but it is %s", mapType(p.ExpectedReturnType.String()), mapType(ast.OutputType().String())), + }, + expression), + }, nil + } + } + + return nil, nil +} + +// typesAssignable defines the compatible assignable types +// For e.g. Markdown can be assigned by a Text value +func typesAssignable(expected *types.Type, actual *types.Type) bool { + expectedMapped := mapType(expected.String()) + actualMapped := mapType(actual.String()) + + // Define type compatibility rules + // [key] can be assigned to by [values] + typeCompatibility := map[string][]string{ + typing.Date.String(): {mapType(typing.Date.String()), mapType(typing.Timestamp.String())}, + typing.Timestamp.String(): {mapType(typing.Date.String()), mapType(typing.Timestamp.String())}, + typing.Markdown.String(): {mapType(typing.Text.String()), mapType(typing.Markdown.String())}, + typing.ID.String(): {mapType(typing.Text.String()), mapType(typing.ID.String())}, + typing.Text.String(): {mapType(typing.Text.String()), mapType(typing.Markdown.String()), mapType(typing.ID.String())}, + typing.Number.String(): {mapType(typing.Number.String()), mapType(typing.Decimal.String())}, + typing.Decimal.String(): {mapType(typing.Number.String()), mapType(typing.Decimal.String())}, + } + + // Check if there are specific compatibility rules for the expected type + if compatibleTypes, exists := typeCompatibility[expected.String()]; exists { + for _, compatibleType := range compatibleTypes { + if actualMapped == compatibleType { + return true + } + } + return false + } + + // Default case: types must match exactly + return expectedMapped == actualMapped +} diff --git a/expressions/resolve/field_lookups.go b/expressions/resolve/field_lookups.go new file mode 100644 index 000000000..c928f655f --- /dev/null +++ b/expressions/resolve/field_lookups.go @@ -0,0 +1,108 @@ +package resolve + +import ( + "github.com/google/cel-go/common/operators" + "github.com/iancoleman/strcase" + "github.com/teamkeel/keel/schema/parser" +) + +// FieldLookups retrieves groups of ident lookups using equals comparison which could apply as a filter +func FieldLookups(model *parser.ModelNode, expression *parser.Expression) ([][]*parser.ExpressionIdent, error) { + ident, err := RunCelVisitor(expression, fieldLookups(model)) + if err != nil { + return nil, err + } + + return ident, nil +} + +func fieldLookups(model *parser.ModelNode) Visitor[[][]*parser.ExpressionIdent] { + return &fieldLookupsGen{ + uniqueLookupGroups: [][]*parser.ExpressionIdent{}, + current: 0, + modelName: model.Name.Value, + anyNull: false, + } +} + +var _ Visitor[[][]*parser.ExpressionIdent] = new(fieldLookupsGen) + +type fieldLookupsGen struct { + uniqueLookupGroups [][]*parser.ExpressionIdent + operands []*parser.ExpressionIdent + operator string + current int + modelName string + anyNull bool +} + +func (v *fieldLookupsGen) StartCondition(parenthesis bool) error { + return nil +} + +func (v *fieldLookupsGen) EndCondition(parenthesis bool) error { + if v.operator == operators.Equals && !v.anyNull { + if v.operands != nil { + if len(v.uniqueLookupGroups) == 0 { + v.uniqueLookupGroups = make([][]*parser.ExpressionIdent, 1) + } + + v.uniqueLookupGroups[v.current] = append(v.uniqueLookupGroups[v.current], v.operands...) + } + } + + v.operands = nil + v.operator = "" + v.anyNull = false + + return nil +} + +func (v *fieldLookupsGen) VisitAnd() error { + return nil +} + +func (v *fieldLookupsGen) VisitOr() error { + v.uniqueLookupGroups = append(v.uniqueLookupGroups, []*parser.ExpressionIdent{}) + + v.current++ + return nil +} + +func (v *fieldLookupsGen) VisitNot() error { + return nil +} + +func (v *fieldLookupsGen) VisitOperator(op string) error { + v.operator = op + return nil +} + +func (v *fieldLookupsGen) VisitLiteral(value any) error { + if value == nil { + v.anyNull = true + } + return nil +} + +func (v *fieldLookupsGen) VisitIdent(ident *parser.ExpressionIdent) error { + if ident.Fragments[0] == strcase.ToLowerCamel(v.modelName) { + if len(ident.Fragments) == 1 { + ident.Fragments = append(ident.Fragments, "id") + } + v.operands = append(v.operands, ident) + } + return nil +} + +func (v *fieldLookupsGen) VisitIdentArray(idents []*parser.ExpressionIdent) error { + return nil +} + +func (v *fieldLookupsGen) ModelName() string { + return v.modelName +} + +func (v *fieldLookupsGen) Result() ([][]*parser.ExpressionIdent, error) { + return v.uniqueLookupGroups, nil +} diff --git a/expressions/resolve/field_lookups_test.go b/expressions/resolve/field_lookups_test.go new file mode 100644 index 000000000..26b5dcfc6 --- /dev/null +++ b/expressions/resolve/field_lookups_test.go @@ -0,0 +1,133 @@ +package resolve_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/schema/parser" +) + +var model = &parser.ModelNode{Name: parser.NameNode{Value: "Product"}} + +func TestFieldLookups_ByModel(t *testing.T) { + expression, err := parser.ParseExpression("product == someProduct") + assert.NoError(t, err) + + lookups, err := resolve.FieldLookups(model, expression) + assert.NoError(t, err) + + assert.Len(t, lookups, 1) + assert.Len(t, lookups[0], 1) + assert.Equal(t, "product.id", lookups[0][0].String()) +} + +func TestFieldLookups_ById(t *testing.T) { + expression, err := parser.ParseExpression("product.id == someId") + assert.NoError(t, err) + + lookups, err := resolve.FieldLookups(model, expression) + assert.NoError(t, err) + + assert.Len(t, lookups, 1) + assert.Len(t, lookups[0], 1) + assert.Equal(t, "product.id", lookups[0][0].String()) +} + +func TestFieldLookups_Comparison(t *testing.T) { + expression, err := parser.ParseExpression("product.rating > 3") + assert.NoError(t, err) + + lookups, err := resolve.FieldLookups(model, expression) + assert.NoError(t, err) + + assert.Len(t, lookups, 0) +} + +func TestFieldLookups_Variables(t *testing.T) { + expression, err := parser.ParseExpression("product.sku == mySku") + assert.NoError(t, err) + + lookups, err := resolve.FieldLookups(model, expression) + assert.NoError(t, err) + + assert.Len(t, lookups, 1) + assert.Len(t, lookups[0], 1) + assert.Equal(t, "product.sku", lookups[0][0].String()) +} + +func TestFieldLookups_VariablesInverse(t *testing.T) { + expression, err := parser.ParseExpression("mySku == product.sku") + assert.NoError(t, err) + + lookups, err := resolve.FieldLookups(model, expression) + assert.NoError(t, err) + + assert.Len(t, lookups, 1) + assert.Len(t, lookups[0], 1) + assert.Equal(t, "product.sku", lookups[0][0].String()) +} + +func TestFieldLookups_NotEquals(t *testing.T) { + expression, err := parser.ParseExpression("product.sku != mySku") + assert.NoError(t, err) + + lookups, err := resolve.FieldLookups(model, expression) + assert.NoError(t, err) + + assert.Len(t, lookups, 0) +} + +func TestFieldLookups_WithAnd(t *testing.T) { + expression, err := parser.ParseExpression(`product.sku == mySku && product.name == "test"`) + assert.NoError(t, err) + + lookups, err := resolve.FieldLookups(model, expression) + assert.NoError(t, err) + + assert.Len(t, lookups, 1) + assert.Len(t, lookups[0], 2) + assert.Equal(t, "product.sku", lookups[0][0].String()) + assert.Equal(t, "product.name", lookups[0][1].String()) +} + +func TestFieldLookups_WithComparison(t *testing.T) { + expression, err := parser.ParseExpression(`product.sku == mySku && product.rating > 3`) + assert.NoError(t, err) + + lookups, err := resolve.FieldLookups(model, expression) + assert.NoError(t, err) + + assert.Len(t, lookups, 1) + assert.Len(t, lookups[0], 1) + assert.Equal(t, "product.sku", lookups[0][0].String()) +} + +func TestFieldLookups_WithOr(t *testing.T) { + expression, err := parser.ParseExpression(`product.sku == mySku || product.name == "test"`) + assert.NoError(t, err) + + lookups, err := resolve.FieldLookups(model, expression) + assert.NoError(t, err) + + assert.Len(t, lookups, 2) + assert.Len(t, lookups[0], 1) + assert.Len(t, lookups[1], 1) + assert.Equal(t, "product.sku", lookups[0][0].String()) + assert.Equal(t, "product.name", lookups[1][0].String()) +} + +func TestFieldLookups_Complex(t *testing.T) { + expression, err := parser.ParseExpression(`product.sku == mySku || product.name == "test" && product.id == "123"`) + assert.NoError(t, err) + + lookups, err := resolve.FieldLookups(model, expression) + assert.NoError(t, err) + + assert.Len(t, lookups, 2) + assert.Len(t, lookups[0], 1) + assert.Len(t, lookups[1], 2) + assert.Equal(t, "product.sku", lookups[0][0].String()) + assert.Equal(t, "product.name", lookups[1][0].String()) + assert.Equal(t, "product.id", lookups[1][1].String()) +} diff --git a/expressions/resolve/ident.go b/expressions/resolve/ident.go new file mode 100644 index 000000000..bf6184d59 --- /dev/null +++ b/expressions/resolve/ident.go @@ -0,0 +1,75 @@ +package resolve + +import ( + "errors" + + "github.com/teamkeel/keel/schema/parser" +) + +var ErrExpressionNotValidIdent = errors.New("expression is not an ident") + +// AsIdent expects and retrieves the single ident operand in an expression +func AsIdent(expression *parser.Expression) (*parser.ExpressionIdent, error) { + ident, err := RunCelVisitor(expression, ident()) + if err != nil { + return nil, err + } + + return ident, nil +} + +func ident() Visitor[*parser.ExpressionIdent] { + return &identGen{} +} + +var _ Visitor[*parser.ExpressionIdent] = new(identGen) + +type identGen struct { + ident *parser.ExpressionIdent +} + +func (v *identGen) StartCondition(parenthesis bool) error { + return nil +} + +func (v *identGen) EndCondition(parenthesis bool) error { + return nil +} + +func (v *identGen) VisitAnd() error { + return ErrExpressionNotValidIdent +} + +func (v *identGen) VisitOr() error { + return ErrExpressionNotValidIdent +} + +func (v *identGen) VisitNot() error { + return nil +} + +func (v *identGen) VisitOperator(op string) error { + return ErrExpressionNotValidIdent +} + +func (v *identGen) VisitLiteral(value any) error { + return ErrExpressionNotValidIdent +} + +func (v *identGen) VisitIdent(ident *parser.ExpressionIdent) error { + v.ident = ident + + return nil +} + +func (v *identGen) VisitIdentArray(idents []*parser.ExpressionIdent) error { + return nil +} + +func (v *identGen) Result() (*parser.ExpressionIdent, error) { + if v.ident == nil { + return nil, ErrExpressionNotValidIdent + } + + return v.ident, nil +} diff --git a/expressions/resolve/ident_array.go b/expressions/resolve/ident_array.go new file mode 100644 index 000000000..69e856279 --- /dev/null +++ b/expressions/resolve/ident_array.go @@ -0,0 +1,81 @@ +package resolve + +import ( + "errors" + "reflect" + + "github.com/teamkeel/keel/schema/parser" +) + +var ErrExpressionNotValidIdentArray = errors.New("expression is not an ident array") + +// AsIdentArray expects and retrieves the array of idents +func AsIdentArray(expression *parser.Expression) ([]*parser.ExpressionIdent, error) { + ident, err := RunCelVisitor(expression, identArray()) + if err != nil { + return nil, err + } + + return ident, nil +} + +func identArray() Visitor[[]*parser.ExpressionIdent] { + return &identArrayGen{} +} + +var _ Visitor[[]*parser.ExpressionIdent] = new(identArrayGen) + +type identArrayGen struct { + idents []*parser.ExpressionIdent +} + +func (v *identArrayGen) StartCondition(parenthesis bool) error { + return nil +} + +func (v *identArrayGen) EndCondition(parenthesis bool) error { + return nil +} + +func (v *identArrayGen) VisitAnd() error { + return ErrExpressionNotValidIdentArray +} + +func (v *identArrayGen) VisitOr() error { + return ErrExpressionNotValidIdentArray +} + +func (v *identArrayGen) VisitNot() error { + return nil +} + +func (v *identArrayGen) VisitOperator(op string) error { + return ErrExpressionNotValidIdentArray +} + +func (v *identArrayGen) VisitLiteral(value any) error { + // Check if the array is empty + if t := reflect.TypeOf(value); t.Kind() == reflect.Slice && reflect.ValueOf(value).Len() == 0 { + v.idents = []*parser.ExpressionIdent{} + } else { + return ErrExpressionNotValidIdentArray + } + return nil +} + +func (v *identArrayGen) VisitIdent(ident *parser.ExpressionIdent) error { + return ErrExpressionNotValidIdentArray +} + +func (v *identArrayGen) VisitIdentArray(idents []*parser.ExpressionIdent) error { + if v.idents != nil { + return ErrExpressionNotValidIdentArray + } + + v.idents = idents + return nil +} + +func (v *identArrayGen) Result() ([]*parser.ExpressionIdent, error) { + return v.idents, nil +} diff --git a/expressions/resolve/ident_array_test.go b/expressions/resolve/ident_array_test.go new file mode 100644 index 000000000..dba171fdc --- /dev/null +++ b/expressions/resolve/ident_array_test.go @@ -0,0 +1,43 @@ +package resolve_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/schema/parser" +) + +func TestIdentArray_Variables(t *testing.T) { + expression, err := parser.ParseExpression("[one,two]") + assert.NoError(t, err) + + operands, err := resolve.AsIdentArray(expression) + assert.NoError(t, err) + + assert.Len(t, operands, 2) + assert.Equal(t, "one", operands[0].String()) + assert.Equal(t, "two", operands[1].String()) +} + +func TestIdentArray_Enums(t *testing.T) { + expression, err := parser.ParseExpression("[MyEnum.One, MyEnum.Two]") + assert.NoError(t, err) + + operands, err := resolve.AsIdentArray(expression) + assert.NoError(t, err) + + assert.Len(t, operands, 2) + assert.Equal(t, "MyEnum.One", operands[0].String()) + assert.Equal(t, "MyEnum.Two", operands[1].String()) +} + +func TestIdentArray_Empty(t *testing.T) { + expression, err := parser.ParseExpression("[]") + assert.NoError(t, err) + + operands, err := resolve.AsIdentArray(expression) + assert.NoError(t, err) + + assert.Len(t, operands, 0) +} diff --git a/expressions/resolve/ident_test.go b/expressions/resolve/ident_test.go new file mode 100644 index 000000000..6dfb776e4 --- /dev/null +++ b/expressions/resolve/ident_test.go @@ -0,0 +1,53 @@ +package resolve_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/schema/parser" +) + +func TestIdent_ModelField(t *testing.T) { + expression, err := parser.ParseExpression("post.name") + assert.NoError(t, err) + + ident, err := resolve.AsIdent(expression) + assert.NoError(t, err) + + assert.Equal(t, "post.name", ident.String()) +} + +func TestIdent_Variable(t *testing.T) { + expression, err := parser.ParseExpression("name") + assert.NoError(t, err) + + ident, err := resolve.AsIdent(expression) + assert.NoError(t, err) + + assert.Equal(t, "name", ident.String()) +} + +func TestIdent_Literal(t *testing.T) { + expression, err := parser.ParseExpression("123") + assert.NoError(t, err) + + _, err = resolve.AsIdent(expression) + assert.ErrorIs(t, err, resolve.ErrExpressionNotValidIdent) +} + +func TestIdent_Operator(t *testing.T) { + expression, err := parser.ParseExpression("post.age + 1") + assert.NoError(t, err) + + _, err = resolve.AsIdent(expression) + assert.ErrorIs(t, err, resolve.ErrExpressionNotValidIdent) +} + +func TestIdent_Empty(t *testing.T) { + expression, err := parser.ParseExpression("") + assert.NoError(t, err) + + _, err = resolve.AsIdent(expression) + assert.ErrorIs(t, err, resolve.ErrExpressionNotParseable) +} diff --git a/expressions/resolve/operands.go b/expressions/resolve/operands.go new file mode 100644 index 000000000..cabf14254 --- /dev/null +++ b/expressions/resolve/operands.go @@ -0,0 +1,67 @@ +package resolve + +import ( + "github.com/teamkeel/keel/schema/parser" +) + +// IdentOperands retrieves all the ident operands in an expression +func IdentOperands(expression *parser.Expression) ([]*parser.ExpressionIdent, error) { + ident, err := RunCelVisitor(expression, operands()) + if err != nil { + return nil, err + } + + return ident, nil +} + +func operands() Visitor[[]*parser.ExpressionIdent] { + return &operandsResolver{} +} + +var _ Visitor[[]*parser.ExpressionIdent] = new(operandsResolver) + +type operandsResolver struct { + idents []*parser.ExpressionIdent +} + +func (v *operandsResolver) StartCondition(parenthesis bool) error { + return nil +} + +func (v *operandsResolver) EndCondition(parenthesis bool) error { + return nil +} + +func (v *operandsResolver) VisitAnd() error { + return nil +} + +func (v *operandsResolver) VisitOr() error { + return nil +} + +func (v *operandsResolver) VisitNot() error { + return nil +} + +func (v *operandsResolver) VisitOperator(op string) error { + return nil +} + +func (v *operandsResolver) VisitLiteral(value any) error { + return nil +} + +func (v *operandsResolver) VisitIdent(ident *parser.ExpressionIdent) error { + v.idents = append(v.idents, ident) + + return nil +} + +func (v *operandsResolver) VisitIdentArray(idents []*parser.ExpressionIdent) error { + return nil +} + +func (v *operandsResolver) Result() ([]*parser.ExpressionIdent, error) { + return v.idents, nil +} diff --git a/expressions/resolve/operands_test.go b/expressions/resolve/operands_test.go new file mode 100644 index 000000000..33c464eee --- /dev/null +++ b/expressions/resolve/operands_test.go @@ -0,0 +1,44 @@ +package resolve_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/schema/parser" +) + +func TestOperands_ModelField(t *testing.T) { + expression, err := parser.ParseExpression("post.isActive == true") + assert.NoError(t, err) + + ident, err := resolve.IdentOperands(expression) + assert.NoError(t, err) + + assert.Len(t, ident, 1) + assert.Equal(t, "post.isActive", ident[0].String()) +} + +func TestOperands_Variable(t *testing.T) { + expression, err := parser.ParseExpression("isActive == true") + assert.NoError(t, err) + + ident, err := resolve.IdentOperands(expression) + assert.NoError(t, err) + + assert.Len(t, ident, 1) + assert.Equal(t, "isActive", ident[0].String()) +} + +func TestOperands_Complex(t *testing.T) { + expression, err := parser.ParseExpression(`isPublic == true || (post.hasAdminAccess == true && ctx.identity.user.isAdmin)`) + assert.NoError(t, err) + + ident, err := resolve.IdentOperands(expression) + assert.NoError(t, err) + + assert.Len(t, ident, 3) + assert.Equal(t, "isPublic", ident[0].String()) + assert.Equal(t, "post.hasAdminAccess", ident[1].String()) + assert.Equal(t, "ctx.identity.user.isAdmin", ident[2].String()) +} diff --git a/expressions/resolve/value.go b/expressions/resolve/value.go new file mode 100644 index 000000000..6cc3f7005 --- /dev/null +++ b/expressions/resolve/value.go @@ -0,0 +1,82 @@ +package resolve + +import ( + "errors" + "fmt" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types/ref" + "github.com/teamkeel/keel/schema/parser" + "google.golang.org/protobuf/types/known/structpb" +) + +// ToValue expects and resolves to a specific type by evaluating the expression +func ToValue[T any](expression *parser.Expression) (T, bool, error) { + env, err := cel.NewEnv() + if err != nil { + return *new(T), false, err + } + + ast, issues := env.Parse(expression.String()) + if issues != nil && len(issues.Errors()) > 0 { + return *new(T), false, errors.New("expression has validation errors and cannot be evaluated") + } + + prg, err := env.Program(ast) + if err != nil { + return *new(T), false, err + } + + out, _, err := prg.Eval(map[string]any{}) + if err != nil { + return *new(T), false, err + } + + value := out.Value() + + if _, ok := value.(structpb.NullValue); ok { + return *new(T), true, nil + } else if value, ok := value.(T); ok { + return value, false, nil + } else { + return *new(T), false, fmt.Errorf("value is of type '%T' and cannot assert type '%T'", out.Value(), *new(T)) + } +} + +// ToValueArray expects and resolves to a specific array type by evaluating the expression +func ToValueArray[T any](expression *parser.Expression) ([]T, error) { + env, err := cel.NewEnv() + if err != nil { + return nil, err + } + + ast, issues := env.Parse(expression.String()) + if issues != nil && len(issues.Errors()) > 0 { + return nil, errors.New("expression has validation errors and cannot be evaluated") + } + + prg, err := env.Program(ast) + if err != nil { + return nil, err + } + + out, _, err := prg.Eval(map[string]any{}) + if err != nil { + return nil, err + } + + values, ok := out.Value().([]ref.Val) + if !ok { + return nil, errors.New("value is not an array") + } + arr := *new([]T) + for _, v := range values { + item, ok := v.Value().(T) + if !ok { + return nil, errors.New("element is not correct type") + } + arr = append(arr, item) + } + + return arr, nil +} diff --git a/expressions/resolve/value_test.go b/expressions/resolve/value_test.go new file mode 100644 index 000000000..63826fefc --- /dev/null +++ b/expressions/resolve/value_test.go @@ -0,0 +1,73 @@ +package resolve_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/schema/parser" +) + +func TestToValue_String(t *testing.T) { + expression, err := parser.ParseExpression(`"keel"`) + assert.NoError(t, err) + + v, _, err := resolve.ToValue[string](expression) + assert.NoError(t, err) + assert.Equal(t, "keel", v) +} + +func TestToValue_NotString(t *testing.T) { + expression, err := parser.ParseExpression(`1`) + assert.NoError(t, err) + + _, _, err = resolve.ToValue[string](expression) + assert.ErrorContains(t, err, "value is of type 'int64' and cannot assert type 'string'") +} + +func TestToValue_Number(t *testing.T) { + expression, err := parser.ParseExpression(`1 + 1`) + assert.NoError(t, err) + + v, _, err := resolve.ToValue[int64](expression) + assert.NoError(t, err) + assert.Equal(t, int64(2), v) +} + +func TestToValue_Float(t *testing.T) { + expression, err := parser.ParseExpression(`1.5 + 1.1`) + assert.NoError(t, err) + + v, _, err := resolve.ToValue[float64](expression) + assert.NoError(t, err) + assert.Equal(t, float64(2.6), v) +} + +func TestToValue_Boolean(t *testing.T) { + expression, err := parser.ParseExpression(`true`) + assert.NoError(t, err) + + v, _, err := resolve.ToValue[bool](expression) + assert.NoError(t, err) + assert.Equal(t, true, v) +} + +func TestToValueArray_StringArray(t *testing.T) { + expression, err := parser.ParseExpression(`["keel", "weave"]`) + assert.NoError(t, err) + + v, err := resolve.ToValueArray[string](expression) + assert.NoError(t, err) + assert.Equal(t, "keel", v[0]) + assert.Equal(t, "weave", v[1]) +} + +func TestToValueArray_Null(t *testing.T) { + expression, err := parser.ParseExpression(`null`) + assert.NoError(t, err) + + v, isNull, err := resolve.ToValue[any](expression) + assert.NoError(t, err) + assert.True(t, isNull) + assert.Equal(t, nil, v) +} diff --git a/expressions/resolve/visitor.go b/expressions/resolve/visitor.go new file mode 100644 index 000000000..00a1b69a8 --- /dev/null +++ b/expressions/resolve/visitor.go @@ -0,0 +1,493 @@ +package resolve + +import ( + "errors" + "fmt" + + "github.com/alecthomas/participle/v2/lexer" + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/operators" + "github.com/teamkeel/keel/schema/node" + "github.com/teamkeel/keel/schema/parser" + exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1" +) + +var ( + ErrExpressionNotParseable = errors.New("expression is invalid and cannot be parsed") +) + +type Visitor[T any] interface { + // StartCondition is called when a new condition is visited + StartCondition(nested bool) error + // EndCondition is called when a condition is finished + EndCondition(nested bool) error + // VisitAnd is called when an 'and' operator is visited between conditions + VisitAnd() error + // VisitAnd is called when an 'or' operator is visited between conditions + VisitOr() error + // VisitNot is called when a logical not '!' is visited before a condition + VisitNot() error + // VisitLiteral is called when a literal operand is visited (e.g. "Keel") + VisitLiteral(value any) error + // VisitIdent is called when a field operand, variable or enum value is visited (e.g. post.name) + VisitIdent(ident *parser.ExpressionIdent) error + // VisitIdentArray is called when an ident array is visited (e.g. [Category.Sport, Category.Edu]) + VisitIdentArray(idents []*parser.ExpressionIdent) error + // VisitOperator is called when a condition's operator visited (e.g. ==) + VisitOperator(operator string) error + // Returns a value after the visitor has completed executing + Result() (T, error) +} + +func RunCelVisitor[T any](expression *parser.Expression, visitor Visitor[T]) (T, error) { + resolver := &CelVisitor[T]{ + visitor: visitor, + expression: expression, + } + + return resolver.run(expression) +} + +// CelVisitor steps through the CEL AST and calls out to the visitor +type CelVisitor[T any] struct { + visitor Visitor[T] + expression *parser.Expression + ast *cel.Ast +} + +func (w *CelVisitor[T]) run(expression *parser.Expression) (T, error) { + var zero T + env, err := cel.NewEnv() + if err != nil { + return zero, fmt.Errorf("cel program setup err: %s", err) + } + + ast, issues := env.Parse(expression.String()) + if issues != nil && len(issues.Errors()) > 0 { + return zero, ErrExpressionNotParseable + } + + checkedExpr, err := cel.AstToParsedExpr(ast) + if err != nil { + return zero, err + } + + w.ast = ast + + if err := w.eval(checkedExpr.Expr, isComplexOperatorWithRespectTo(operators.LogicalAnd, checkedExpr.Expr), false); err != nil { + return zero, err + } + + return w.visitor.Result() +} + +func (w *CelVisitor[T]) eval(expr *exprpb.Expr, nested bool, inBinary bool) error { + var err error + + switch expr.ExprKind.(type) { + case *exprpb.Expr_ConstExpr, *exprpb.Expr_ListExpr, *exprpb.Expr_SelectExpr, *exprpb.Expr_IdentExpr: + if !inBinary { + err := w.visitor.StartCondition(false) + if err != nil { + return err + } + } + } + + switch expr.ExprKind.(type) { + case *exprpb.Expr_CallExpr: + err = w.visitor.StartCondition(nested) + if err != nil { + return err + } + + err := w.callExpr(expr) + if err != nil { + return err + } + + err = w.visitor.EndCondition(nested) + if err != nil { + return err + } + case *exprpb.Expr_ConstExpr: + err := w.constExpr(expr) + if err != nil { + return err + } + case *exprpb.Expr_ListExpr: + err := w.listExpr(expr) + if err != nil { + return err + } + case *exprpb.Expr_SelectExpr: + err := w.SelectExpr(expr) + if err != nil { + return err + } + case *exprpb.Expr_IdentExpr: + err := w.identExpr(expr) + if err != nil { + return err + } + default: + return fmt.Errorf("no support for expr type: %v", expr.ExprKind) + } + + switch expr.ExprKind.(type) { + case *exprpb.Expr_ConstExpr, *exprpb.Expr_ListExpr, *exprpb.Expr_SelectExpr, *exprpb.Expr_IdentExpr: + if !inBinary { + err := w.visitor.EndCondition(false) + if err != nil { + return err + } + } + } + + return err +} + +func (w *CelVisitor[T]) callExpr(expr *exprpb.Expr) error { + c := expr.GetCallExpr() + fun := c.GetFunction() + + var err error + switch fun { + case operators.LogicalNot: + err = w.unaryCall(expr) + case operators.Add, + operators.Divide, + operators.Equals, + operators.Greater, + operators.GreaterEquals, + operators.In, + operators.Less, + operators.LessEquals, + operators.LogicalAnd, + operators.LogicalOr, + operators.Multiply, + operators.NotEquals, + operators.OldIn, + operators.Subtract: + + err = w.binaryCall(expr) + default: + return errors.New("function calls not supported yet") + } + + return err +} + +func (w *CelVisitor[T]) binaryCall(expr *exprpb.Expr) error { + c := expr.GetCallExpr() + op := c.GetFunction() + args := c.GetArgs() + lhs := args[0] + lhsParen := isComplexOperatorWithRespectTo(op, lhs) + var err error + + inBinary := !(op == operators.LogicalAnd || op == operators.LogicalOr) + + if err := w.eval(lhs, lhsParen, inBinary); err != nil { + return err + } + + switch op { + case operators.LogicalOr: + err = w.visitor.VisitOr() + case operators.LogicalAnd: + err = w.visitor.VisitAnd() + default: + err = w.visitor.VisitOperator(op) + } + if err != nil { + return err + } + + rhs := args[1] + rhsParen := isComplexOperatorWithRespectTo(op, rhs) + if !rhsParen && isLeftRecursive(op) { + rhsParen = isSamePrecedence(op, rhs) + } + + if err := w.eval(rhs, rhsParen, inBinary); err != nil { + return err + } + + return nil +} + +func (w *CelVisitor[T]) unaryCall(expr *exprpb.Expr) error { + c := expr.GetCallExpr() + fun := c.GetFunction() + args := c.GetArgs() + + isComplex := isComplexOperator(args[0]) + + switch fun { + case operators.LogicalNot: + err := w.visitor.VisitNot() + if err != nil { + return err + } + default: + return fmt.Errorf("not implemented: %s", fun) + } + + if err := w.eval(args[0], isComplex, false); err != nil { + return err + } + + return nil +} + +func (w *CelVisitor[T]) constExpr(expr *exprpb.Expr) error { + c := expr.GetConstExpr() + + v, err := toNative(c) + if err != nil { + return err + } + + return w.visitor.VisitLiteral(v) +} + +func (w *CelVisitor[T]) listExpr(expr *exprpb.Expr) error { + elems := expr.GetListExpr().GetElements() + + // If it is empty, assume it's a literal array + if len(elems) == 0 { + return w.constArray(expr) + } + + switch elems[0].ExprKind.(type) { + case *exprpb.Expr_IdentExpr: + return w.identArray(expr) + case *exprpb.Expr_SelectExpr: + return w.identArray(expr) + case *exprpb.Expr_ConstExpr: + return w.constArray(expr) + } + + return fmt.Errorf("unexpected expr type: %s", expr.ExprKind) +} + +func (w *CelVisitor[T]) constArray(expr *exprpb.Expr) error { + elems := expr.GetListExpr().GetElements() + + arr := make([]any, len(elems)) + for i, elem := range elems { + c := elem.GetConstExpr() + v, err := toNative(c) + if err != nil { + return err + } + arr[i] = v + } + + return w.visitor.VisitLiteral(arr) +} + +func (w *CelVisitor[T]) identArray(expr *exprpb.Expr) error { + elems := expr.GetListExpr().GetElements() + + arr := make([]*parser.ExpressionIdent, len(elems)) + for i, elem := range elems { + switch elem.ExprKind.(type) { + case *exprpb.Expr_IdentExpr: + s := elem.GetIdentExpr() + + offsets := w.ast.NativeRep().SourceInfo().OffsetRanges()[elem.GetId()] + start := w.ast.NativeRep().SourceInfo().GetStartLocation(elem.GetId()) + end := w.ast.NativeRep().SourceInfo().GetStopLocation(elem.GetId()) + + exprIdent := parser.ExpressionIdent{ + Fragments: []string{s.GetName()}, + Node: node.Node{ + Pos: lexer.Position{ + Filename: w.expression.Pos.Filename, + Line: w.expression.Pos.Line + start.Line() - 1, + Column: w.expression.Pos.Column + start.Column(), + Offset: w.expression.Pos.Offset + int(offsets.Start), + }, + EndPos: lexer.Position{ + Filename: w.expression.Pos.Filename, + Line: w.expression.Pos.Line + end.Line() - 1, + Column: w.expression.Pos.Column + end.Column(), + Offset: w.expression.Pos.Offset + int(offsets.Stop), + }, + }, + } + arr[i] = &exprIdent + + case *exprpb.Expr_SelectExpr: + var err error + arr[i], err = w.selectToIdent(elem) + if err != nil { + return err + } + default: + return fmt.Errorf("not an ident or select: %v", expr.ExprKind) + } + } + + return w.visitor.VisitIdentArray(arr) +} + +func (w *CelVisitor[T]) identExpr(expr *exprpb.Expr) error { + ident := expr.GetIdentExpr() + + offsets := w.ast.NativeRep().SourceInfo().OffsetRanges()[expr.GetId()] + start := w.ast.NativeRep().SourceInfo().GetStartLocation(expr.GetId()) + end := w.ast.NativeRep().SourceInfo().GetStopLocation(expr.GetId()) + + exprIdent := &parser.ExpressionIdent{ + Fragments: []string{ident.GetName()}, + Node: node.Node{ + Pos: lexer.Position{ + Filename: w.expression.Pos.Filename, + Line: w.expression.Pos.Line + start.Line() - 1, + Column: w.expression.Pos.Column + start.Column(), + Offset: w.expression.Pos.Offset + int(offsets.Start), + }, + EndPos: lexer.Position{ + Filename: w.expression.Pos.Filename, + Line: w.expression.Pos.Line + end.Line() - 1, + Column: w.expression.Pos.Column + end.Column(), + Offset: w.expression.Pos.Offset + int(offsets.Stop), + }, + }, + } + + return w.visitor.VisitIdent(exprIdent) +} + +func (w *CelVisitor[T]) SelectExpr(expr *exprpb.Expr) error { + sel := expr.GetSelectExpr() + + switch expr.ExprKind.(type) { + case *exprpb.Expr_CallExpr: + err := w.eval(sel.GetOperand(), true, true) + if err != nil { + return err + } + } + + ident, err := w.selectToIdent(expr) + if err != nil { + return err + } + + return w.visitor.VisitIdent(ident) +} + +func (w *CelVisitor[T]) selectToIdent(expr *exprpb.Expr) (*parser.ExpressionIdent, error) { + ident := parser.ExpressionIdent{} + e := expr + + offset := 0 + for { + if s, ok := e.ExprKind.(*exprpb.Expr_SelectExpr); ok { + offsets := w.ast.NativeRep().SourceInfo().OffsetRanges()[s.SelectExpr.Operand.Id] + start := w.ast.NativeRep().SourceInfo().GetStartLocation(s.SelectExpr.Operand.Id) + + ident.Pos = lexer.Position{ + Filename: w.expression.Pos.Filename, + Line: w.expression.Pos.Line, + Column: w.expression.Pos.Column + start.Column(), + Offset: w.expression.Pos.Offset + int(offsets.Start), + } + + offset += len(s.SelectExpr.GetField()) + 1 + + ident.Fragments = append([]string{s.SelectExpr.GetField()}, ident.Fragments...) + e = s.SelectExpr.Operand + } else if _, ok := e.ExprKind.(*exprpb.Expr_IdentExpr); ok { + offset += len(e.GetIdentExpr().Name) + + ident.Fragments = append([]string{e.GetIdentExpr().Name}, ident.Fragments...) + break + } else { + return nil, fmt.Errorf("no support for expr kind in select: %v", expr.ExprKind) + } + } + + ident.EndPos = lexer.Position{ + Filename: w.expression.Pos.Filename, + Line: w.expression.Pos.Line, + Column: ident.Pos.Column + offset, + Offset: ident.Pos.Offset + offset, + } + + return &ident, nil +} + +// isLowerPrecedence indicates whether the precedence of the input operator is lower precedence +// than the (possible) operation represented in the input Expr. +// +// If the expr is not a Call, the result is false. +func isLowerPrecedence(op string, expr *exprpb.Expr) bool { + if expr.GetCallExpr() == nil { + return false + } + c := expr.GetCallExpr() + other := c.GetFunction() + return operators.Precedence(op) < operators.Precedence(other) +} + +// Indicates whether the expr is a complex operator, i.e., a call expression +// with 2 or more arguments. +func isComplexOperator(expr *exprpb.Expr) bool { + if expr.GetCallExpr() != nil && len(expr.GetCallExpr().GetArgs()) >= 2 { + return true + } + return false +} + +// Indicates whether it is a complex operation compared to another. +// expr is *not* considered complex if it is not a call expression or has +// less than two arguments, or if it has a higher precedence than op. +func isComplexOperatorWithRespectTo(op string, expr *exprpb.Expr) bool { + if expr.GetCallExpr() == nil || len(expr.GetCallExpr().GetArgs()) < 2 { + return false + } + return isLowerPrecedence(op, expr) +} + +// isLeftRecursive indicates whether the parser resolves the call in a left-recursive manner as +// this can have an effect of how parentheses affect the order of operations in the AST. +func isLeftRecursive(op string) bool { + return op != operators.LogicalAnd && op != operators.LogicalOr +} + +// isSamePrecedence indicates whether the precedence of the input operator is the same as the +// precedence of the (possible) operation represented in the input Expr. +// +// If the expr is not a Call, the result is false. +func isSamePrecedence(op string, expr *exprpb.Expr) bool { + if expr.GetCallExpr() == nil { + return false + } + c := expr.GetCallExpr() + other := c.GetFunction() + return operators.Precedence(op) == operators.Precedence(other) +} + +func toNative(c *exprpb.Constant) (any, error) { + switch c.ConstantKind.(type) { + case *exprpb.Constant_BoolValue: + return c.GetBoolValue(), nil + case *exprpb.Constant_DoubleValue: + return c.GetDoubleValue(), nil + case *exprpb.Constant_Int64Value: + return c.GetInt64Value(), nil + case *exprpb.Constant_StringValue: + return c.GetStringValue(), nil + case *exprpb.Constant_Uint64Value: + return c.GetUint64Value(), nil + case *exprpb.Constant_NullValue: + return nil, nil + default: + return nil, fmt.Errorf("const kind not implemented: %v", c) + } +} diff --git a/expressions/typing/provider.go b/expressions/typing/provider.go new file mode 100644 index 000000000..6d70d9b5a --- /dev/null +++ b/expressions/typing/provider.go @@ -0,0 +1,116 @@ +package typing + +import ( + "strings" + + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/query" + expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1" +) + +// TypeProvider supplies the CEL context with the relevant Keel types and identifiers +type TypeProvider struct { + Schema []*parser.AST + // Objects keeps track of complex object types and their fields as defined in the CEL environment, in particular: ctx, headers, secrets. + // For example, ctx would look like this: + // _Context -> + // isAuthenticated -> Bool + // now -> Timestamp + // identity -> Identity + Objects map[string]map[string]*types.Type +} + +var _ types.Provider = new(TypeProvider) + +func NewTypeProvider() *TypeProvider { + return &TypeProvider{ + Objects: map[string]map[string]*types.Type{}, + } +} + +func (p *TypeProvider) FindStructType(structType string) (*types.Type, bool) { + obj := strings.TrimSuffix(structType, "[]") + + switch { + case query.Model(p.Schema, obj) != nil: + return types.NewObjectType(structType), true + case strings.Contains(obj, "_Enum") && query.Enum(p.Schema, strings.TrimSuffix(obj, "_Enum")) != nil: + return types.NewObjectType(structType), true + case structType == "_Context": + return types.NewObjectType(structType), true + case structType == "_Headers": + return types.NewObjectType(structType), true + case structType == "_Secrets": + return types.NewObjectType(structType), true + case structType == "_EnvironmentVariables": + return types.NewObjectType(structType), true + } + + return nil, false +} + +func (p *TypeProvider) FindStructFieldType(structType, fieldName string) (*types.FieldType, bool) { + obj := strings.TrimSuffix(structType, "[]") + parentIsArray := strings.HasSuffix(structType, "[]") + + if model := query.Model(p.Schema, obj); model != nil { + field := query.Field(model, fieldName) + if field == nil { + return nil, false + } + + t, err := MapType(p.Schema, field.Type.Value, field.Repeated || parentIsArray) + if err != nil { + return nil, false + } + + return &types.FieldType{Type: t}, true + } + + if strings.Contains(structType, "_Enum") { + e := strings.TrimSuffix(structType, "_Enum") + if enum := query.Enum(p.Schema, e); enum != nil { + for _, v := range enum.Values { + if v.Name.Value == fieldName { + return &types.FieldType{Type: types.NewOpaqueType(e)}, true + } + } + } + } + + if field, has := p.Objects[structType][fieldName]; has { + return &types.FieldType{Type: field}, true + } + + if structType == "_Headers" { + return &types.FieldType{Type: types.StringType}, true + } + + return nil, false +} + +func (p *TypeProvider) EnumValue(enumName string) ref.Val { + return types.NewErr("unknown '%s'", enumName) +} + +func (p *TypeProvider) FindIdent(identName string) (ref.Val, bool) { + return nil, false +} + +func (p *TypeProvider) FindType(typeName string) (*expr.Type, bool) { + panic("not implemented") +} + +func (p *TypeProvider) FindStructFieldNames(structType string) ([]string, bool) { + panic("not implemented") +} + +func (p *TypeProvider) FindFieldType(messageType string, fieldName string) (*types.FieldType, bool) { + panic("not implemented") +} + +func (p *TypeProvider) NewValue(typeName string, fields map[string]ref.Val) ref.Val { + panic("not implemented") +} diff --git a/expressions/typing/types.go b/expressions/typing/types.go new file mode 100644 index 000000000..36ad93afe --- /dev/null +++ b/expressions/typing/types.go @@ -0,0 +1,86 @@ +package typing + +import ( + "fmt" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/query" +) + +type Ident []string + +var ( + ID = cel.OpaqueType(parser.FieldTypeID) + Text = cel.OpaqueType(parser.FieldTypeText) + Markdown = cel.OpaqueType(parser.FieldTypeMarkdown) + Number = cel.OpaqueType(parser.FieldTypeNumber) + Decimal = cel.OpaqueType(parser.FieldTypeDecimal) + Boolean = cel.OpaqueType(parser.FieldTypeBoolean) + Timestamp = cel.OpaqueType(parser.FieldTypeTimestamp) + Date = cel.OpaqueType(parser.FieldTypeDate) +) + +var ( + IDArray = cel.OpaqueType(fmt.Sprintf("%s[]", parser.FieldTypeID)) + TextArray = cel.OpaqueType(fmt.Sprintf("%s[]", parser.FieldTypeText)) + MarkdownArray = cel.OpaqueType(fmt.Sprintf("%s[]", parser.FieldTypeMarkdown)) + NumberArray = cel.OpaqueType(fmt.Sprintf("%s[]", parser.FieldTypeNumber)) + DecimalArray = cel.OpaqueType(fmt.Sprintf("%s[]", parser.FieldTypeDecimal)) + BooleanArray = cel.OpaqueType(fmt.Sprintf("%s[]", parser.FieldTypeBoolean)) + TimestampArray = cel.OpaqueType(fmt.Sprintf("%s[]", parser.FieldTypeTimestamp)) + DateArray = cel.OpaqueType(fmt.Sprintf("%s[]", parser.FieldTypeDate)) +) + +var ( + Role = cel.OpaqueType("_Role") +) + +func MapType(schema []*parser.AST, typeName string, isRepeated bool) (*types.Type, error) { + // For single operand conditions + if typeName == parser.FieldTypeBoolean && !isRepeated { + return types.BoolType, nil + } + + switch typeName { + case parser.FieldTypeID, + parser.FieldTypeText, + parser.FieldTypeMarkdown, + parser.FieldTypeNumber, + parser.FieldTypeBoolean, + parser.FieldTypeDecimal, + parser.FieldTypeTimestamp, + parser.FieldTypeDate, + parser.FieldTypeFile, + parser.FieldTypeVector, + parser.FieldTypeSecret, + parser.FieldTypePassword: + if isRepeated { + return cel.OpaqueType(fmt.Sprintf("%s[]", typeName)), nil + } else { + return cel.OpaqueType(typeName), nil + } + + case Role.String(), "_ActionType", "_FieldName": + if isRepeated { + typeName = typeName + "[]" + } + return types.NewOpaqueType(typeName), nil + } + + switch { + case query.Enum(schema, typeName) != nil: + if isRepeated { + typeName = typeName + "[]" + } + return types.NewOpaqueType(typeName), nil + case query.Model(schema, typeName) != nil: + if isRepeated { + typeName = typeName + "[]" + } + return types.NewObjectType(typeName), nil + } + + return nil, fmt.Errorf("unknown type '%s'", typeName) +} diff --git a/go.mod b/go.mod index 71f560818..842ff5bc5 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/99designs/gqlgen v0.17.16 github.com/Masterminds/semver/v3 v3.2.1 github.com/PaesslerAG/jsonpath v0.1.1 - github.com/alecthomas/participle/v2 v2.0.0-beta.5 + github.com/alecthomas/participle/v2 v2.1.1 github.com/bmatcuk/doublestar/v4 v4.2.0 github.com/bykof/gostradamus v1.0.4 github.com/charmbracelet/bubbles v0.16.1 @@ -16,9 +16,11 @@ require ( github.com/dchest/uniuri v1.2.0 github.com/docker/docker v24.0.9+incompatible github.com/docker/go-connections v0.4.0 + github.com/emirpasic/gods v1.18.1 github.com/fatih/camelcase v1.0.0 github.com/goccy/go-yaml v1.12.0 github.com/golang-jwt/jwt/v4 v4.4.2 + github.com/google/cel-go v0.21.0 github.com/hashicorp/go-version v1.6.0 github.com/iancoleman/strcase v0.2.0 github.com/jackc/pgx/v5 v5.5.5 @@ -42,6 +44,7 @@ require ( github.com/spf13/viper v1.15.0 github.com/stretchr/testify v1.8.4 github.com/teamkeel/graphql v0.8.2-0.20230531102419-995b8ab035b6 + github.com/test-go/testify v1.1.4 github.com/twitchtv/twirp v8.1.3+incompatible github.com/vincent-petithory/dataurl v1.0.0 github.com/xeipuuv/gojsonschema v1.2.0 @@ -51,8 +54,9 @@ require ( go.opentelemetry.io/otel/sdk v1.21.0 go.opentelemetry.io/otel/trace v1.21.0 go.opentelemetry.io/proto/otlp v1.0.0 - golang.org/x/exp v0.0.0-20220907003533-145caa8ea1d0 + golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc golang.org/x/oauth2 v0.13.0 + google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d google.golang.org/protobuf v1.33.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.5.0 @@ -61,6 +65,7 @@ require ( require ( github.com/PaesslerAG/gval v1.0.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect @@ -88,6 +93,7 @@ require ( github.com/spf13/afero v1.9.3 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect github.com/tkuchiki/go-timezone v0.2.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect @@ -98,7 +104,6 @@ require ( golang.org/x/text v0.14.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect google.golang.org/grpc v1.59.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 76ba43a8a..822b8c086 100644 --- a/go.sum +++ b/go.sum @@ -56,13 +56,15 @@ github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4Rq github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= -github.com/alecthomas/assert/v2 v2.0.3 h1:WKqJODfOiQG0nEJKFKzDIG3E29CN2/4zR9XGJzKIkbg= -github.com/alecthomas/assert/v2 v2.0.3/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA= -github.com/alecthomas/participle/v2 v2.0.0-beta.5 h1:y6dsSYVb1G5eK6mgmy+BgI3Mw35a3WghArZ/Hbebrjo= -github.com/alecthomas/participle/v2 v2.0.0-beta.5/go.mod h1:RC764t6n4L8D8ITAJv0qdokritYSNR3wV5cVwmIEaMM= -github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= -github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/alecthomas/assert/v2 v2.3.0 h1:mAsH2wmvjsuvyBvAmCtm7zFsBlb8mIHx5ySLVdDZXL0= +github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= +github.com/alecthomas/participle/v2 v2.1.1 h1:hrjKESvSqGHzRb4yW1ciisFJ4p3MGYih6icjJvbsmV8= +github.com/alecthomas/participle/v2 v2.1.1/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= +github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= +github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -115,6 +117,8 @@ github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKoh github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -182,6 +186,8 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/cel-go v0.21.0 h1:cl6uW/gxN+Hy50tNYvI691+sXxioCnstFzLp2WO4GCI= +github.com/google/cel-go v0.21.0/go.mod h1:rHUlWCcBKgyEk+eV03RPdZUekPp6YcJwV0FxuUksYxc= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -376,6 +382,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU= github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -395,6 +403,8 @@ github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8 github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/teamkeel/graphql v0.8.2-0.20230531102419-995b8ab035b6 h1:q8ZbAgqr7jJlZNJ4WAI+QMuZrcCBDOw9k7orYuy+Vqs= github.com/teamkeel/graphql v0.8.2-0.20230531102419-995b8ab035b6/go.mod h1:5td34OA5ZUdckc2w3GgE7QQoaG8MK6hIVR3dFI+qaK4= +github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE= +github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= github.com/thoas/go-funk v0.9.1/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= github.com/tkuchiki/go-timezone v0.2.0 h1:yyZVHtQRVZ+wvlte5HXvSpBkR0dPYnPEIgq9qqAqltk= @@ -459,8 +469,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20220907003533-145caa8ea1d0 h1:17k44ji3KFYG94XS5QEFC8pyuOlMh3IoR+vkmTZmJJs= -golang.org/x/exp v0.0.0-20220907003533-145caa8ea1d0/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= +golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/integration/testdata/action_errors/schema.keel b/integration/testdata/action_errors/schema.keel index a46e47b49..d7d821d83 100644 --- a/integration/testdata/action_errors/schema.keel +++ b/integration/testdata/action_errors/schema.keel @@ -174,7 +174,6 @@ model BookWithIdentity { @permission(expression: true) @set(bookWithIdentity.lastUpdatedBy.id = ctx.identity.id) } - create createDbPermissionRequiresIdentity() with (title) { @permission(expression: bookWithIdentity.lastUpdatedBy == ctx.identity) diff --git a/integration/testdata/arrays/tests.test.ts b/integration/testdata/arrays/tests.test.ts index 9227f4ced..353198d4f 100644 --- a/integration/testdata/arrays/tests.test.ts +++ b/integration/testdata/arrays/tests.test.ts @@ -459,10 +459,9 @@ test("array fields - list action implicit querying - text", async () => { }, }); - expect(things10.results).toHaveLength(3); - expect(things10.results[0].id).toEqual(t4.id); - expect(things10.results[1].id).toEqual(t5.id); - expect(things10.results[2].id).toEqual(t7.id); + expect(things10.results).toHaveLength(2); + expect(things10.results[0].id).toEqual(t5.id); + expect(things10.results[1].id).toEqual(t7.id); const things11 = await actions.listThings({ where: { @@ -474,13 +473,12 @@ test("array fields - list action implicit querying - text", async () => { }, }); - expect(things11.results).toHaveLength(6); + expect(things11.results).toHaveLength(5); expect(things11.results[0].id).toEqual(t1.id); expect(things11.results[1].id).toEqual(t2.id); expect(things11.results[2].id).toEqual(t3.id); - expect(things11.results[3].id).toEqual(t4.id); - expect(things11.results[4].id).toEqual(t5.id); - expect(things11.results[5].id).toEqual(t6.id); + expect(things11.results[3].id).toEqual(t5.id); + expect(things11.results[4].id).toEqual(t6.id); }); test("array fields - list action implicit querying - number", async () => { diff --git a/integration/testdata/arrays_expressions/schema.keel b/integration/testdata/arrays_expressions/schema.keel index 83e77ef9c..ea2e905cc 100644 --- a/integration/testdata/arrays_expressions/schema.keel +++ b/integration/testdata/arrays_expressions/schema.keel @@ -35,7 +35,7 @@ model ThingText { } list listLiteralNotInArrayField() { - @where("Weave" not in thingText.array1) + @where(!("Weave" in thingText.array1)) @orderBy(createdAt: asc) @permission(expression: true) } @@ -85,7 +85,7 @@ model ThingEnum { } list listEnumLiteralNotInArrayField() { - @where(MyEnum.Two not in thingEnum.array1) + @where(!(MyEnum.Two in thingEnum.array1)) @orderBy(createdAt: asc) @permission(expression: true) } diff --git a/integration/testdata/arrays_expressions/tests.test.ts b/integration/testdata/arrays_expressions/tests.test.ts index 5438f7d33..2cb247863 100644 --- a/integration/testdata/arrays_expressions/tests.test.ts +++ b/integration/testdata/arrays_expressions/tests.test.ts @@ -84,12 +84,10 @@ test("array expressions - text", async () => { expect(things4.results[4].id).toEqual(thing8.id); const things5 = await actions.listLiteralNotInArrayField(); - expect(things5.results).toHaveLength(5); - expect(things5.results[0].id).toEqual(thing4.id); - expect(things5.results[1].id).toEqual(thing6.id); - expect(things5.results[2].id).toEqual(thing7.id); - expect(things5.results[3].id).toEqual(thing9.id); - expect(things5.results[4].id).toEqual(thing10.id); + expect(things5.results).toHaveLength(3); + expect(things5.results[0].id).toEqual(thing7.id); + expect(things5.results[1].id).toEqual(thing9.id); + expect(things5.results[2].id).toEqual(thing10.id); const things6 = await actions.listFieldInArrayField(); expect(things6.results).toHaveLength(2); @@ -177,12 +175,10 @@ test("array expressions - enums", async () => { expect(things4.results[4].id).toEqual(thing8.id); const things5 = await actions.listEnumLiteralNotInArrayField(); - expect(things5.results).toHaveLength(5); - expect(things5.results[0].id).toEqual(thing4.id); - expect(things5.results[1].id).toEqual(thing6.id); - expect(things5.results[2].id).toEqual(thing7.id); - expect(things5.results[3].id).toEqual(thing9.id); - expect(things5.results[4].id).toEqual(thing10.id); + expect(things5.results).toHaveLength(3); + expect(things5.results[0].id).toEqual(thing7.id); + expect(things5.results[1].id).toEqual(thing9.id); + expect(things5.results[2].id).toEqual(thing10.id); const things6 = await actions.listEnumFieldInArrayField(); expect(things6.results).toHaveLength(2); diff --git a/integration/testdata/arrays_relationships/schema.keel b/integration/testdata/arrays_relationships/schema.keel index 8610b6f10..51a193e7a 100644 --- a/integration/testdata/arrays_relationships/schema.keel +++ b/integration/testdata/arrays_relationships/schema.keel @@ -32,7 +32,7 @@ model Collection { } list listNotInCollection(genre: Genre) { - @where(genre not in collection.books.genres) + @where(!(genre in collection.books.genres)) @orderBy(name: asc) @permission(expression: true) } diff --git a/integration/testdata/computed_fields/schema.keel b/integration/testdata/computed_fields/schema.keel new file mode 100644 index 000000000..c1f949113 --- /dev/null +++ b/integration/testdata/computed_fields/schema.keel @@ -0,0 +1,46 @@ +model ComputedDecimal { + fields { + price Decimal + quantity Number + total Decimal @computed(computedDecimal.quantity * computedDecimal.price) + totalWithShipping Decimal @computed(5 + computedDecimal.quantity * computedDecimal.price) + totalWithDiscount Decimal @computed(computedDecimal.quantity * (computedDecimal.price - (computedDecimal.price / 100 * 10))) + } +} + +model ComputedNumber { + fields { + price Decimal + quantity Number + total Number @computed(computedNumber.quantity * computedNumber.price) + totalWithShipping Number @computed(5 + computedNumber.quantity * computedNumber.price) + totalWithDiscount Number @computed(computedNumber.quantity * (computedNumber.price - (computedNumber.price / 100 * 10))) + } +} + +model ComputedBool { + fields { + price Decimal? + isActive Boolean + isExpensive Boolean @computed(computedBool.price > 100 && computedBool.isActive) + isCheap Boolean @computed(!computedBool.isExpensive) + } +} + +model ComputedNulls { + fields { + price Decimal? + quantity Number? + total Decimal? @computed(computedNulls.quantity * computedNulls.price) + } +} + +model ComputedDepends { + fields { + price Decimal + quantity Number + totalWithDiscount Decimal? @computed(computedDepends.totalWithShipping - (computedDepends.totalWithShipping / 100 * 10)) + totalWithShipping Decimal? @computed(computedDepends.total + 5) + total Decimal? @computed(computedDepends.quantity * computedDepends.price) + } +} diff --git a/integration/testdata/computed_fields/tests.test.ts b/integration/testdata/computed_fields/tests.test.ts new file mode 100644 index 000000000..0c75a00d2 --- /dev/null +++ b/integration/testdata/computed_fields/tests.test.ts @@ -0,0 +1,139 @@ +import { test, expect, beforeEach } from "vitest"; +import { models, resetDatabase } from "@teamkeel/testing"; + +beforeEach(resetDatabase); + +test("computed fields - decimal", async () => { + const item = await models.computedDecimal.create({ price: 5, quantity: 2 }); + expect(item.total).toEqual(10); + expect(item.totalWithShipping).toEqual(15); + expect(item.totalWithDiscount).toEqual(9); + + const get = await models.computedDecimal.findOne({ id: item.id }); + expect(get!.total).toEqual(10); + expect(get!.totalWithShipping).toEqual(15); + expect(get!.totalWithDiscount).toEqual(9); + + const updatePrice = await models.computedDecimal.update( + { id: item.id }, + { price: 10 } + ); + expect(updatePrice.total).toEqual(20); + expect(updatePrice.totalWithShipping).toEqual(25); + expect(updatePrice.totalWithDiscount).toEqual(18); + + const updateQuantity = await models.computedDecimal.update( + { id: item.id }, + { quantity: 3 } + ); + expect(updateQuantity.total).toEqual(30); + expect(updateQuantity.totalWithShipping).toEqual(35); + expect(updateQuantity.totalWithDiscount).toEqual(27); + + const updateBoth = await models.computedDecimal.update( + { id: item.id }, + { price: 12, quantity: 4 } + ); + expect(updateBoth.total).toEqual(48); + expect(updateBoth.totalWithShipping).toEqual(53); + expect(updateBoth.totalWithDiscount).toEqual(43.2); +}); + +test("computed fields - number", async () => { + const item = await models.computedNumber.create({ price: 5, quantity: 2 }); + expect(item.total).toEqual(10); + expect(item.totalWithShipping).toEqual(15); + expect(item.totalWithDiscount).toEqual(9); + + const get = await models.computedNumber.findOne({ id: item.id }); + expect(get!.total).toEqual(10); + expect(get!.totalWithShipping).toEqual(15); + expect(get!.totalWithDiscount).toEqual(9); + + const updatePrice = await models.computedNumber.update( + { id: item.id }, + { price: 10 } + ); + expect(updatePrice.total).toEqual(20); + expect(updatePrice.totalWithShipping).toEqual(25); + expect(updatePrice.totalWithDiscount).toEqual(18); + + const updateQuantity = await models.computedNumber.update( + { id: item.id }, + { quantity: 3 } + ); + expect(updateQuantity.total).toEqual(30); + expect(updateQuantity.totalWithShipping).toEqual(35); + expect(updateQuantity.totalWithDiscount).toEqual(27); + + const updateBoth = await models.computedNumber.update( + { id: item.id }, + { price: 12, quantity: 4 } + ); + expect(updateBoth.total).toEqual(48); + expect(updateBoth.totalWithShipping).toEqual(53); + expect(updateBoth.totalWithDiscount).toEqual(43); +}); + +test("computed fields - boolean", async () => { + const expensive = await models.computedBool.create({ + price: 200, + isActive: true, + }); + expect(expensive.isExpensive).toBeTruthy(); + expect(expensive.isCheap).toBeFalsy(); + + const notExpensive = await models.computedBool.create({ + price: 90, + isActive: true, + }); + expect(notExpensive.isExpensive).toBeFalsy(); + expect(notExpensive.isCheap).toBeTruthy(); + + const notActive = await models.computedBool.create({ + price: 200, + isActive: false, + }); + expect(notActive.isExpensive).toBeFalsy(); + expect(notActive.isCheap).toBeTruthy(); +}); + +test("computed fields - with nulls", async () => { + const item = await models.computedNulls.create({ price: 5 }); + expect(item.total).toBeNull(); + + const updateQty = await models.computedNulls.update( + { id: item.id }, + { quantity: 10 } + ); + expect(updateQty!.total).toEqual(50); + + const updatePrice2 = await models.computedNulls.update( + { id: item.id }, + { price: null } + ); + expect(updatePrice2!.total).toBeNull(); +}); + +test("computed fields - with dependencies", async () => { + const item = await models.computedDepends.create({ price: 5, quantity: 2 }); + expect(item.total).toEqual(10); + expect(item.totalWithShipping).toEqual(15); + expect(item.totalWithDiscount).toEqual(13.5); + + const updatedQty = await models.computedDepends.update( + { id: item.id }, + { quantity: 10 } + ); + expect(updatedQty.total).toEqual(50); + expect(updatedQty.totalWithShipping).toEqual(55); + expect(updatedQty.totalWithDiscount).toEqual(49.5); + + const updatePrice = await models.computedDepends.update( + { id: item.id }, + { price: 8 } + ); + expect(updatePrice.total).toEqual(80); + expect(updatePrice.totalWithShipping).toEqual(85); + expect(updatePrice.totalWithDiscount).toEqual(76.5); +}); diff --git a/integration/testdata/functions_permissions/schema.keel b/integration/testdata/functions_permissions/schema.keel index 538dd54b9..251fe0829 100644 --- a/integration/testdata/functions_permissions/schema.keel +++ b/integration/testdata/functions_permissions/schema.keel @@ -57,7 +57,7 @@ model Admission { // Critics can always watch films, unless they work for the Daily Mail @permission( - expression: ctx.identity.audience.isCritic and ctx.identity.audience.publication.name != "Daily Mail", + expression: ctx.identity.audience.isCritic && ctx.identity.audience.publication.name != "Daily Mail", actions: [create] ) diff --git a/integration/testdata/identity_backlinks/schema.keel b/integration/testdata/identity_backlinks/schema.keel index 067362c9e..c83967cff 100644 --- a/integration/testdata/identity_backlinks/schema.keel +++ b/integration/testdata/identity_backlinks/schema.keel @@ -38,7 +38,7 @@ model Film { } list listMembersFilms() { @where(ctx.identity.user.age >= film.ageRestriction) - @where(film.onlyMembers == false or ctx.identity.user.group.isActive == true) + @where(film.onlyMembers == false || ctx.identity.user.group.isActive == true) @orderBy(title: asc) } } diff --git a/integration/testdata/nullable_in_expressions/schema.keel b/integration/testdata/nullable_in_expressions/schema.keel index 1641c54c3..1c775bdca 100644 --- a/integration/testdata/nullable_in_expressions/schema.keel +++ b/integration/testdata/nullable_in_expressions/schema.keel @@ -18,7 +18,7 @@ model Person { } list uninitialesedPersons() { - @where(person.name == null or person.status == null) + @where(person.name == null || person.status == null) } diff --git a/integration/testdata/operation_list_where_explicit/schema.keel b/integration/testdata/operation_list_where_explicit/schema.keel index 15797bcc8..fb17d7b00 100644 --- a/integration/testdata/operation_list_where_explicit/schema.keel +++ b/integration/testdata/operation_list_where_explicit/schema.keel @@ -16,7 +16,7 @@ model Post { @where(whereArg == post.title) } list listPostsNotEqualString(whereArg: Text) { - @where(post.title != whereArg) + @where(this.title != whereArg) } list listPostsEqualDate(whereArg: Date) { @where(post.aDate == whereArg) diff --git a/integration/testdata/operation_list_where_permutations/schema.keel b/integration/testdata/operation_list_where_permutations/schema.keel index c5a9c8bb2..c28df7967 100644 --- a/integration/testdata/operation_list_where_permutations/schema.keel +++ b/integration/testdata/operation_list_where_permutations/schema.keel @@ -53,7 +53,7 @@ model Thing { } list notInTextFieldToLit() { - @where(thing.title not in ["title2", "title3"]) + @where(!(thing.title in ["title2", "title3"])) } } } diff --git a/integration/testdata/orderby_list_operation/schema.keel b/integration/testdata/orderby_list_operation/schema.keel index a476b3e63..1293790e5 100644 --- a/integration/testdata/orderby_list_operation/schema.keel +++ b/integration/testdata/orderby_list_operation/schema.keel @@ -15,7 +15,7 @@ model Contestant { silver: desc, bronze: desc ) - @where(contestant.disqualified == false and contestant.team.disqualified == false) + @where(contestant.disqualified == false && contestant.team.disqualified == false) @permission(expression: true) } } diff --git a/integration/testdata/permissions_list_operation/schema.keel b/integration/testdata/permissions_list_operation/schema.keel index a0773453b..95985a210 100644 --- a/integration/testdata/permissions_list_operation/schema.keel +++ b/integration/testdata/permissions_list_operation/schema.keel @@ -21,7 +21,7 @@ model Post { @set(post.identity = ctx.identity) } list listWithTextPermissionLiteral(isActive) { - @permission(expression: post.title == "hello") + @permission(expression: this.title == "hello") } list listWithNumberPermissionLiteral(isActive) { @permission(expression: post.views == 1) diff --git a/integration/testdata/permissions_user_role_model/schema.keel b/integration/testdata/permissions_user_role_model/schema.keel index 0cfb35ddd..adead0d5c 100644 --- a/integration/testdata/permissions_user_role_model/schema.keel +++ b/integration/testdata/permissions_user_role_model/schema.keel @@ -40,7 +40,7 @@ model Task { @permission( actions: [get], expression: ( - ctx.identity in task.project.users.user.identity and + ctx.identity in task.project.users.user.identity && "Admin" in task.project.users.role ) ) diff --git a/integration/testdata/real_world_bank_account_app/schema.keel b/integration/testdata/real_world_bank_account_app/schema.keel index 5339325cf..dcf8a24a7 100644 --- a/integration/testdata/real_world_bank_account_app/schema.keel +++ b/integration/testdata/real_world_bank_account_app/schema.keel @@ -20,7 +20,7 @@ model BankAccount { } update updateMyAccount() with (alias) { @where(bankAccount == ctx.identity.user.entity.account) - @permission(expression: ctx.isAuthenticated and ctx.identity.user.canUpdate) + @permission(expression: ctx.isAuthenticated && ctx.identity.user.canUpdate) } get getAccount(id) { @permission(expression: bankAccount.entity in ctx.identity.administrator.access.entity) diff --git a/integration/testdata/relationships_filtering_expressions/schema.keel b/integration/testdata/relationships_filtering_expressions/schema.keel index 611a26687..965ce81c6 100644 --- a/integration/testdata/relationships_filtering_expressions/schema.keel +++ b/integration/testdata/relationships_filtering_expressions/schema.keel @@ -28,7 +28,7 @@ model Post { @where(post.isActive == post.theAuthor.thePublisher.booleanValue) } list listActivePostsWithRhsField2() { - @where(post.theAuthor.thePublisher.isActive == post.theAuthor.thePublisher.booleanValue or post.theAuthor.isActive == post.theAuthor.thePublisher.booleanValue or post.isActive == post.theAuthor.thePublisher.booleanValue) + @where(post.theAuthor.thePublisher.isActive == post.theAuthor.thePublisher.booleanValue || post.theAuthor.isActive == post.theAuthor.thePublisher.booleanValue || post.isActive == post.theAuthor.thePublisher.booleanValue) } delete deleteActivePost(id) { @where(post.theAuthor.thePublisher.isActive == true) diff --git a/integration/testdata/relationships_in_identity_backlinks/schema.keel b/integration/testdata/relationships_in_identity_backlinks/schema.keel index c4b2727a5..852042e1f 100644 --- a/integration/testdata/relationships_in_identity_backlinks/schema.keel +++ b/integration/testdata/relationships_in_identity_backlinks/schema.keel @@ -24,7 +24,7 @@ model Account { // Accounts which I am not following yet list accountsNotFollowed() { @where(account.identity != ctx.identity) - @where(account.identity not in ctx.identity.primaryAccount.following.followee.identity) + @where(!(account.identity in ctx.identity.primaryAccount.following.followee.identity)) @orderBy(username: asc) } // Accounts which are following me @@ -35,7 +35,7 @@ model Account { // Accounts which are not following me yet list accountsNotFollowingMe() { @where(account.identity.id != ctx.identity.id) - @where(account.id not in ctx.identity.primaryAccount.followers.follower.id) + @where(!(account.id in ctx.identity.primaryAccount.followers.follower.id)) @orderBy(username: asc) } } diff --git a/integration/testdata/relationships_in_permissions/schema.keel b/integration/testdata/relationships_in_permissions/schema.keel index ca16ff509..edc1e85a4 100644 --- a/integration/testdata/relationships_in_permissions/schema.keel +++ b/integration/testdata/relationships_in_permissions/schema.keel @@ -8,13 +8,13 @@ model Post { actions { // For testing AND conditions create createPost() with (title, theAuthor.id, isActive?) { - @permission(expression: post.theAuthor.isActive == true and post.isActive) + @permission(expression: post.theAuthor.isActive == true && post.isActive) } get getPost(id) { - @permission(expression: post.theAuthor.isActive == true and post.isActive) + @permission(expression: post.theAuthor.isActive == true && post.isActive) } list listPosts() { - @permission(expression: post.theAuthor.isActive == true and post.isActive) + @permission(expression: post.theAuthor.isActive == true && post.isActive) } // For testing OR conditions create createPostORed() with (title, theAuthor.id, isActive?) { @@ -41,10 +41,10 @@ model Author { actions { get getAuthor(id) { - @permission(expression: true in author.thePosts.isActive and author.isActive) + @permission(expression: true in author.thePosts.isActive && author.isActive) } list listAuthors() { - @permission(expression: true in author.thePosts.isActive and author.isActive) + @permission(expression: true in author.thePosts.isActive && author.isActive) } // For testing OR conditions get getAuthorORed(id) { diff --git a/integration/testdata/relationships_in_where/schema.keel b/integration/testdata/relationships_in_where/schema.keel index c3c244340..b8055e2b6 100644 --- a/integration/testdata/relationships_in_where/schema.keel +++ b/integration/testdata/relationships_in_where/schema.keel @@ -8,17 +8,17 @@ model Post { actions { // For testing AND conditions get getPost(id) { - @where(expression: post.theAuthor.isActive == true and post.isActive) + @where(expression: post.theAuthor.isActive == true && post.isActive) } list listPosts() { - @where(expression: post.theAuthor.isActive == true and post.isActive) + @where(expression: post.theAuthor.isActive == true && post.isActive) } // For testing OR conditions get getPostORed(id) { - @where(expression: post.theAuthor.isActive or post.isActive) + @where(expression: post.theAuthor.isActive || post.isActive) } list listPostsORed() { - @where(expression: post.theAuthor.isActive or post.isActive) + @where(expression: post.theAuthor.isActive || post.isActive) } } @@ -35,17 +35,17 @@ model Author { actions { // For testing AND conditions get getAuthor(id) { - @where(expression: true in author.thePosts.isActive and author.isActive) + @where(expression: true in author.thePosts.isActive && author.isActive) } list listAuthors() { - @where(expression: true in author.thePosts.isActive and author.isActive) + @where(expression: true in author.thePosts.isActive && author.isActive) } // For testing OR conditions get getAuthorORed(id) { - @where(expression: true in author.thePosts.isActive or author.isActive) + @where(expression: true in author.thePosts.isActive || author.isActive) } list listAuthorsORed() { - @where(expression: true in author.thePosts.isActive or author.isActive) + @where(expression: true in author.thePosts.isActive || author.isActive) } } diff --git a/integration/testdata/sortable_list_operation/schema.keel b/integration/testdata/sortable_list_operation/schema.keel index 24eac8fb4..4ab24c14e 100644 --- a/integration/testdata/sortable_list_operation/schema.keel +++ b/integration/testdata/sortable_list_operation/schema.keel @@ -11,7 +11,7 @@ model Contestant { actions { list listRankings(name?, team.name?) { @sortable(gold, silver, bronze, name) - @where(contestant.disqualified == false and contestant.team.disqualified == false) + @where(contestant.disqualified == false && contestant.team.disqualified == false) @permission(expression: true) } } diff --git a/migrations/computed_functions.sql b/migrations/computed_functions.sql new file mode 100644 index 000000000..aaef2371b --- /dev/null +++ b/migrations/computed_functions.sql @@ -0,0 +1,8 @@ +SELECT + routine_name +FROM + information_schema.routines +WHERE + routine_type = 'FUNCTION' +AND + routine_schema = 'public' AND routine_name LIKE '%__computed'; \ No newline at end of file diff --git a/migrations/introspection.go b/migrations/introspection.go index 8e006d6f1..885faa8f4 100644 --- a/migrations/introspection.go +++ b/migrations/introspection.go @@ -22,6 +22,11 @@ func getColumns(database db.Database) ([]*ColumnRow, error) { return rows, database.GetDB().Raw(columnsQuery).Scan(&rows).Error } +func getComputedFunctions(database db.Database) ([]*FunctionRow, error) { + rows := []*FunctionRow{} + return rows, database.GetDB().Raw(computedFunctionsQuery).Scan(&rows).Error +} + var ( //go:embed columns.sql columnsQuery string @@ -31,6 +36,9 @@ var ( //go:embed triggers.sql triggersQuery string + + //go:embed computed_functions.sql + computedFunctionsQuery string ) type ColumnRow struct { @@ -80,3 +88,7 @@ type TriggerRow struct { // e.g. AFTER ActionTiming string `json:"action_timing"` } + +type FunctionRow struct { + RoutineName string `json:"routine_name"` +} diff --git a/migrations/migrations.go b/migrations/migrations.go index ce17547e1..ae6f902c4 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -5,6 +5,7 @@ import ( _ "embed" "errors" "fmt" + "slices" "strings" "github.com/iancoleman/strcase" @@ -12,7 +13,9 @@ import ( "github.com/teamkeel/keel/auditing" "github.com/teamkeel/keel/casing" "github.com/teamkeel/keel/db" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/proto" + "github.com/teamkeel/keel/schema/parser" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "google.golang.org/protobuf/encoding/protojson" @@ -174,7 +177,7 @@ func New(ctx context.Context, schema *proto.Schema, database db.Database) (*Migr return nil, err } - triggers, err := getTriggers(database) + existingTriggers, err := getTriggers(database) if err != nil { return nil, err } @@ -251,10 +254,10 @@ func New(ctx context.Context, schema *proto.Schema, database db.Database) (*Migr // Add audit log triggers all model tables excluding the audit table itself. for _, model := range schema.Models { if model.Name != strcase.ToCamel(auditing.TableName) { - stmt := createAuditTriggerStmts(triggers, model) + stmt := createAuditTriggerStmts(existingTriggers, model) statements = append(statements, stmt) - stmt = createUpdatedAtTriggerStmts(triggers, model) + stmt = createUpdatedAtTriggerStmts(existingTriggers, model) statements = append(statements, stmt) } } @@ -361,6 +364,38 @@ func New(ctx context.Context, schema *proto.Schema, database db.Database) (*Migr } } + // Fetch all computed functions in the database + existingComputedFns, err := getComputedFunctions(database) + if err != nil { + return nil, err + } + + // Computed fields functions and triggers + computedChanges, stmts, err := computedFieldsStmts(schema, existingComputedFns) + if err != nil { + return nil, err + } + + for _, change := range computedChanges { + // Dont add the db change if the field was already modified elsewhere + if lo.ContainsBy(changes, func(c *DatabaseChange) bool { + return c.Model == change.Model && c.Field == change.Field + }) { + continue + } + + // Dont add the db change if the model is new + if lo.ContainsBy(changes, func(c *DatabaseChange) bool { + return c.Model == change.Model && c.Field == "" && c.Type == ChangeTypeAdded + }) { + continue + } + + changes = append(changes, computedChanges...) + } + + statements = append(statements, stmts...) + stringChanges := lo.Map(changes, func(c *DatabaseChange, _ int) string { return c.String() }) span.SetAttributes(attribute.StringSlice("migration", stringChanges)) @@ -417,6 +452,187 @@ func compositeUniqueConstraints(schema *proto.Schema, model *proto.Model, constr return statements, nil } +// computedFieldDependencies returns a map of computed fields and every field it depends on +func computedFieldDependencies(schema *proto.Schema) (map[*proto.Field][]*proto.Field, error) { + dependencies := map[*proto.Field][]*proto.Field{} + + for _, model := range schema.Models { + for _, field := range model.Fields { + if field.ComputedExpression == nil { + continue + } + + expr, err := parser.ParseExpression(field.ComputedExpression.Source) + if err != nil { + return nil, err + } + + idents, err := resolve.IdentOperands(expr) + if err != nil { + return nil, err + } + + for _, ident := range idents { + for _, f := range schema.FindModel(strcase.ToCamel(ident.Fragments[0])).Fields { + if f.Name == ident.Fragments[1] { + dependencies[field] = append(dependencies[field], f) + break + } + } + } + } + } + + return dependencies, nil +} + +// computedFieldsStmts generates SQL statements for dropping or creating functions and triggers for computed fields +func computedFieldsStmts(schema *proto.Schema, existingComputedFns []*FunctionRow) (changes []*DatabaseChange, statements []string, err error) { + existingComputedFnNames := lo.Map(existingComputedFns, func(f *FunctionRow, _ int) string { + return f.RoutineName + }) + + fns := map[string]string{} + fieldsFns := map[*proto.Field]string{} + changedFields := map[*proto.Field]bool{} + + // Adding computed field triggers and functions + for _, model := range schema.Models { + modelFns := map[string]string{} + + for _, field := range model.GetComputedFields() { + changedFields[field] = false + fnName, computedFuncStmt, err := addComputedFieldFuncStmt(schema, model, field) + if err != nil { + return nil, nil, err + } + + fieldsFns[field] = fnName + modelFns[fnName] = computedFuncStmt + } + + // Get all the preexisting computed functions for computed fields on this model + existingComputedFnNamesForModel := lo.Filter(existingComputedFnNames, func(f string, _ int) bool { + return strings.HasPrefix(f, fmt.Sprintf("%s__", strcase.ToSnake(model.Name))) && + strings.HasSuffix(f, "_computed") + }) + + newFns, retiredFns := lo.Difference(lo.Keys(modelFns), existingComputedFnNamesForModel) + slices.Sort(newFns) + slices.Sort(retiredFns) + + // Functions to be created + for _, fn := range newFns { + statements = append(statements, modelFns[fn]) + + f := fieldFromComputedFnName(schema, fn) + changes = append(changes, &DatabaseChange{ + Model: f.ModelName, + Field: f.Name, + Type: ChangeTypeModified, + }) + changedFields[f] = true + } + + // Functions to be dropped + for _, fn := range retiredFns { + statements = append(statements, fmt.Sprintf("DROP FUNCTION %s;", fn)) + + f := fieldFromComputedFnName(schema, fn) + if f != nil { + change := &DatabaseChange{ + Model: f.ModelName, + Field: f.Name, + Type: ChangeTypeModified, + } + if !lo.ContainsBy(changes, func(c *DatabaseChange) bool { + return c.Model == change.Model && c.Field == change.Field + }) { + changes = append(changes, change) + } + changedFields[f] = true + } + } + + // When there all computed fields have been removed + if len(modelFns) == 0 && len(retiredFns) > 0 { + dropExecFn := dropComputedExecFunctionStmt(model) + dropTrigger := dropComputedTriggerStmt(model) + statements = append(statements, dropTrigger, dropExecFn) + } + + for k, v := range modelFns { + fns[k] = v + } + } + + dependencies, err := computedFieldDependencies(schema) + if err != nil { + return nil, nil, err + } + + for _, model := range schema.Models { + modelhasChanged := false + for k, v := range changedFields { + if k.ModelName == model.Name && v { + modelhasChanged = true + } + } + if !modelhasChanged { + continue + } + + computedFields := model.GetComputedFields() + if len(computedFields) == 0 { + continue + } + + // Sort fields based on dependencies + sorted := []*proto.Field{} + visited := map[*proto.Field]bool{} + var visit func(*proto.Field) + visit = func(field *proto.Field) { + if visited[field] || field.ComputedExpression == nil { + return + } + visited[field] = true + + // Process dependencies first + for _, dep := range dependencies[field] { + if dep.ModelName == field.ModelName { + visit(dep) + } + } + sorted = append(sorted, field) + } + + // Visit all fields to build sorted order + for _, field := range computedFields { + visit(field) + } + + // Generate SQL statements in dependency order + stmts := []string{} + for _, field := range sorted { + s := fmt.Sprintf("\tNEW.%s := %s(%s);\n", strcase.ToSnake(field.Name), fieldsFns[field], "NEW") + stmts = append(stmts, s) + } + + execFnName := computedExecFuncName(model) + triggerName := computedTriggerName(model) + + // Generate the trigger function which executes all the computed field functions for the model. + sql := fmt.Sprintf("CREATE OR REPLACE FUNCTION %s() RETURNS TRIGGER AS $$ BEGIN\n%s\tRETURN NEW;\nEND; $$ LANGUAGE plpgsql;", execFnName, strings.Join(stmts, "")) + + // Genrate the table trigger which executed the trigger function. + trigger := fmt.Sprintf("CREATE OR REPLACE TRIGGER %s BEFORE INSERT OR UPDATE ON \"%s\" FOR EACH ROW EXECUTE PROCEDURE %s();", triggerName, strcase.ToSnake(model.Name), execFnName) + + statements = append(statements, sql, trigger) + } + + return +} + func keelSchemaTableExists(ctx context.Context, database db.Database) (bool, error) { // to_regclass docs - https://www.postgresql.org/docs/current/functions-info.html#FUNCTIONS-INFO-CATALOG-TABLE // translates a textual relation name to its OID ... this function will diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go index eb8eabbc5..dd19b9e11 100644 --- a/migrations/migrations_test.go +++ b/migrations/migrations_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "os" "path/filepath" "regexp" @@ -108,7 +109,11 @@ func TestMigrations(t *testing.T) { require.NoError(t, err) // Assert correct SQL generated - assert.Equal(t, expectedSQL, m.SQL) + equal := assert.Equal(t, expectedSQL, m.SQL) + + if !equal { + fmt.Println(m.SQL) + } actualChanges, err := json.Marshal(m.Changes) require.NoError(t, err) diff --git a/migrations/sql.go b/migrations/sql.go index ced024833..335aacde0 100644 --- a/migrations/sql.go +++ b/migrations/sql.go @@ -1,6 +1,7 @@ package migrations import ( + "crypto/sha256" "fmt" "regexp" "strings" @@ -10,7 +11,9 @@ import ( "github.com/teamkeel/keel/auditing" "github.com/teamkeel/keel/casing" "github.com/teamkeel/keel/db" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/proto" + "github.com/teamkeel/keel/runtime/actions" "github.com/teamkeel/keel/schema/parser" "golang.org/x/exp/slices" ) @@ -89,6 +92,7 @@ func createTableStmt(schema *proto.Schema, model *proto.Model) (string, error) { PrimaryKeyConstraintName(model.Name, field.Name), Identifier(field.Name))) } + if field.Unique && !field.PrimaryKey { uniqueStmt, err := addUniqueConstraintStmt(schema, model.Name, []string{field.Name}) if err != nil { @@ -227,6 +231,74 @@ func alterColumnStmt(modelName string, field *proto.Field, column *ColumnRow) (s return strings.Join(stmts, "\n"), nil } +// computedFieldFuncName generates the name of the a computed field's function +func computedFieldFuncName(model *proto.Model, field *proto.Field) string { + // shortened alphanumeric hash from an expression + hash := fmt.Sprintf("%x", sha256.Sum256([]byte(field.ComputedExpression.Source)))[:8] + return fmt.Sprintf("%s__%s__%s__computed", strcase.ToSnake(model.Name), strcase.ToSnake(field.Name), hash) +} + +// computedExecFuncName generates the name for the table function which executed all computed functions +func computedExecFuncName(model *proto.Model) string { + return fmt.Sprintf("%s__exec_computed_fns", strcase.ToSnake(model.Name)) +} + +// computedTriggerName generates the name for the trigger which runs the function which executes computed functions +func computedTriggerName(model *proto.Model) string { + return fmt.Sprintf("%s__computed_trigger", strcase.ToSnake(model.Name)) +} + +// fieldFromComputedFnName determines the field from computed function name +func fieldFromComputedFnName(schema *proto.Schema, fn string) *proto.Field { + parts := strings.Split(fn, "__") + model := schema.FindModel(strcase.ToCamel(parts[0])) + for _, f := range model.Fields { + if f.Name == strcase.ToLowerCamel(parts[1]) { + return f + } + } + return nil +} + +// addComputedFieldFuncStmt generates the function for a computed field +func addComputedFieldFuncStmt(schema *proto.Schema, model *proto.Model, field *proto.Field) (string, string, error) { + var sqlType string + switch field.Type.Type { + case proto.Type_TYPE_DECIMAL, proto.Type_TYPE_INT, proto.Type_TYPE_BOOL: + sqlType = PostgresFieldTypes[field.Type.Type] + default: + return "", "", fmt.Errorf("type not supported for computed fields: %s", field.Type.Type) + } + + expression, err := parser.ParseExpression(field.ComputedExpression.Source) + if err != nil { + return "", "", err + } + + // Generate SQL from the computed attribute expression to set this field + stmt, err := resolve.RunCelVisitor(expression, actions.GenerateComputedFunction(schema, model, field)) + if err != nil { + return "", "", err + } + + fn := computedFieldFuncName(model, field) + sql := fmt.Sprintf("CREATE FUNCTION %s(r %s) RETURNS %s AS $$ BEGIN\n\tRETURN %s;\nEND; $$ LANGUAGE plpgsql;", + fn, + strcase.ToSnake(model.Name), + sqlType, + stmt) + + return fn, sql, nil +} + +func dropComputedExecFunctionStmt(model *proto.Model) string { + return fmt.Sprintf("DROP FUNCTION %s__exec_computed_fns;", strcase.ToSnake(model.Name)) +} + +func dropComputedTriggerStmt(model *proto.Model) string { + return fmt.Sprintf("DROP TRIGGER %s__computed_trigger ON %s;", strcase.ToSnake(model.Name), strcase.ToSnake(model.Name)) +} + func fieldDefinition(field *proto.Field) (string, error) { columnName := Identifier(field.Name) @@ -264,91 +336,165 @@ func fieldDefinition(field *proto.Field) (string, error) { } func getDefaultValue(field *proto.Field) (string, error) { + // Handle zero values first if field.DefaultValue.UseZeroValue { - if field.Type.Repeated { - return "{}", nil + return getZeroValue(field) + } + + // Handle specific types + switch { + case field.Type.Type == proto.Type_TYPE_ENUM: + return getEnumDefault(field) + case field.Type.Repeated: + return getRepeatedDefault(field) + default: + expression, err := parser.ParseExpression(field.DefaultValue.Expression.Source) + if err != nil { + return "", err } - switch field.Type.Type { - case proto.Type_TYPE_STRING, proto.Type_TYPE_MARKDOWN: - return db.QuoteLiteral(""), nil - case proto.Type_TYPE_INT, proto.Type_TYPE_DECIMAL: - return "0", nil - case proto.Type_TYPE_BOOL: - return "false", nil - case proto.Type_TYPE_DATE, proto.Type_TYPE_DATETIME, proto.Type_TYPE_TIMESTAMP: - return "now()", nil - case proto.Type_TYPE_ID: - return "ksuid()", nil + v, isNull, err := resolve.ToValue[any](expression) + if err != nil { + return "", err + } + + if isNull { + return "NULL", nil } + + return toSqlLiteral(v, field) } +} - expr, err := parser.ParseExpression(field.DefaultValue.Expression.Source) - if err != nil { - return "", err +// Helper functions to break down the logic +func getZeroValue(field *proto.Field) (string, error) { + if field.Type.Repeated { + return "{}", nil + } + + zeroValues := map[proto.Type]string{ + proto.Type_TYPE_STRING: db.QuoteLiteral(""), + proto.Type_TYPE_MARKDOWN: db.QuoteLiteral(""), + proto.Type_TYPE_INT: "0", + proto.Type_TYPE_DECIMAL: "0", + proto.Type_TYPE_BOOL: "false", + proto.Type_TYPE_DATE: "now()", + proto.Type_TYPE_DATETIME: "now()", + proto.Type_TYPE_TIMESTAMP: "now()", + proto.Type_TYPE_ID: "ksuid()", } - value, err := expr.ToValue() + if value, ok := zeroValues[field.Type.Type]; ok { + return value, nil + } + return "", fmt.Errorf("no zero value defined for type %v", field.Type.Type) +} + +func getEnumDefault(field *proto.Field) (string, error) { + expression, err := parser.ParseExpression(field.DefaultValue.Expression.Source) if err != nil { return "", err } - switch { - case value.Array != nil: - if len(value.Array.Values) == 0 { + if field.Type.Repeated { + enums, err := resolve.AsIdentArray(expression) + if err != nil { + return "", err + } + + if len(enums) == 0 { return "'{}'", nil } values := []string{} - for _, el := range value.Array.Values { - v, err := toSqlLiteral(el, field) - if err != nil { - return "", err - } - values = append(values, v) + for _, el := range enums { + values = append(values, db.QuoteLiteral(el.Fragments[1])) } - var cast string - switch field.Type.Type { - case proto.Type_TYPE_INT: - cast = "::INTEGER[]" - case proto.Type_TYPE_DECIMAL: - cast = "::NUMERIC[]" - case proto.Type_TYPE_BOOL: - cast = "::BOOL[]" - default: - cast = "::TEXT[]" - } + return fmt.Sprintf("ARRAY[%s]::TEXT[]", strings.Join(values, ",")), nil + } + + enum, err := resolve.AsIdent(expression) + if err != nil { + return "", err + } + + return db.QuoteLiteral(enum.Fragments[1]), nil +} + +func getRepeatedDefault(field *proto.Field) (string, error) { + var ( + values []string + err error + ) - return fmt.Sprintf("ARRAY[%s]%s", strings.Join(values, ","), cast), nil + switch field.Type.Type { + case proto.Type_TYPE_INT: + values, err = getArrayValues[int64](field) + case proto.Type_TYPE_DECIMAL: + values, err = getArrayValues[float64](field) + case proto.Type_TYPE_BOOL: + values, err = getArrayValues[bool](field) default: - return toSqlLiteral(value, field) + values, err = getArrayValues[string](field) } + if err != nil { + return "", err + } + + if len(values) == 0 { + return "'{}'", nil + } + + typeCasts := map[proto.Type]string{ + proto.Type_TYPE_INT: "INTEGER[]", + proto.Type_TYPE_DECIMAL: "NUMERIC[]", + proto.Type_TYPE_BOOL: "BOOL[]", + } + cast := typeCasts[field.Type.Type] + if cast == "" { + cast = "TEXT[]" + } + + return fmt.Sprintf("ARRAY[%s]::%s", strings.Join(values, ","), cast), nil } -func toSqlLiteral(operand *parser.Operand, field *proto.Field) (string, error) { - switch { - case operand.String != nil: - s := *operand.String - // Remove wrapping quotes - s = strings.TrimPrefix(s, `"`) - s = strings.TrimSuffix(s, `"`) - return db.QuoteLiteral(s), nil - case operand.Decimal != nil: - return fmt.Sprintf("%f", *operand.Decimal), nil - case operand.Number != nil: - return fmt.Sprintf("%d", *operand.Number), nil - case operand.True: - return "true", nil - case operand.False: - return "false", nil - case field.Type.Type == proto.Type_TYPE_ENUM && operand.Ident != nil: - if len(operand.Ident.Fragments) != 2 { - return "", fmt.Errorf("invalid default value %s for enum field %s", operand.Ident.ToString(), field.Name) +// Generic helper for array values +func getArrayValues[T any](field *proto.Field) ([]string, error) { + expression, err := parser.ParseExpression(field.DefaultValue.Expression.Source) + if err != nil { + return nil, err + } + + v, err := resolve.ToValueArray[T](expression) + if err != nil { + return nil, err + } + + values := make([]string, len(v)) + for i, el := range v { + val, err := toSqlLiteral(el, field) + if err != nil { + return nil, err } - return db.QuoteLiteral(operand.Ident.Fragments[1].Fragment), nil + values[i] = val + } + return values, nil +} + +func toSqlLiteral(value any, field *proto.Field) (string, error) { + switch { + case field.Type.Type == proto.Type_TYPE_STRING: + return db.QuoteLiteral(fmt.Sprintf("%s", value)), nil + case field.Type.Type == proto.Type_TYPE_DECIMAL: + return fmt.Sprintf("%f", value), nil + case field.Type.Type == proto.Type_TYPE_INT: + return fmt.Sprintf("%d", value), nil + case field.Type.Type == proto.Type_TYPE_BOOL: + return fmt.Sprintf("%v", value), nil + default: - return "", fmt.Errorf("field %s has unexpected default value %s", field.Name, operand.ToString()) + return "", fmt.Errorf("field %s has unexpected default value %s", field.Name, value) } } diff --git a/migrations/testdata/computed_field_changed_expression.txt b/migrations/testdata/computed_field_changed_expression.txt new file mode 100644 index 000000000..7f60b7065 --- /dev/null +++ b/migrations/testdata/computed_field_changed_expression.txt @@ -0,0 +1,35 @@ +model Item { + fields { + price Decimal + quantity Number + total Decimal @computed(item.quantity * item.price) + } +} + +=== + +model Item { + fields { + price Decimal + quantity Number + total Decimal @computed(item.price + 5) + } +} + +=== + +CREATE FUNCTION item__total__863346d0__computed(r item) RETURNS NUMERIC AS $$ BEGIN + RETURN r."price" + 5; +END; $$ LANGUAGE plpgsql; +DROP FUNCTION item__total__0614a79a__computed; +CREATE OR REPLACE FUNCTION item__exec_computed_fns() RETURNS TRIGGER AS $$ BEGIN + NEW.total := item__total__863346d0__computed(NEW); + RETURN NEW; +END; $$ LANGUAGE plpgsql; +CREATE OR REPLACE TRIGGER item__computed_trigger BEFORE INSERT OR UPDATE ON "item" FOR EACH ROW EXECUTE PROCEDURE item__exec_computed_fns(); + +=== + +[ + {"Model":"Item","Field":"total","Type":"MODIFIED"} +] diff --git a/migrations/testdata/computed_field_initial.txt b/migrations/testdata/computed_field_initial.txt new file mode 100644 index 000000000..5900302ee --- /dev/null +++ b/migrations/testdata/computed_field_initial.txt @@ -0,0 +1,78 @@ +=== + +model Item { + fields { + price Decimal + quantity Number + total Decimal @computed(item.quantity * item.price) + } +} + +=== + +CREATE TABLE "identity" ( +"email" TEXT, +"email_verified" BOOL NOT NULL DEFAULT false, +"password" TEXT, +"external_id" TEXT, +"issuer" TEXT, +"name" TEXT, +"given_name" TEXT, +"family_name" TEXT, +"middle_name" TEXT, +"nick_name" TEXT, +"profile" TEXT, +"picture" TEXT, +"website" TEXT, +"gender" TEXT, +"zone_info" TEXT, +"locale" TEXT, +"id" TEXT NOT NULL DEFAULT ksuid(), +"created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), +"updated_at" TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE "identity" ADD CONSTRAINT identity_id_pkey PRIMARY KEY ("id"); +ALTER TABLE "identity" ADD CONSTRAINT identity_email_issuer_udx UNIQUE ("email", "issuer"); +CREATE TABLE "item" ( +"price" NUMERIC NOT NULL, +"quantity" INTEGER NOT NULL, +"total" NUMERIC NOT NULL, +"id" TEXT NOT NULL DEFAULT ksuid(), +"created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), +"updated_at" TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE "item" ADD CONSTRAINT item_id_pkey PRIMARY KEY ("id"); +CREATE TABLE "keel_audit" ( +"id" TEXT NOT NULL DEFAULT ksuid(), +"table_name" TEXT NOT NULL, +"op" TEXT NOT NULL, +"data" jsonb NOT NULL, +"created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), +"identity_id" TEXT, +"trace_id" TEXT, +"event_processed_at" TIMESTAMPTZ +); +ALTER TABLE "keel_audit" ADD CONSTRAINT keel_audit_id_pkey PRIMARY KEY ("id"); +CREATE TRIGGER item_create AFTER INSERT ON "item" REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER item_update AFTER UPDATE ON "item" REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER item_delete AFTER DELETE ON "item" REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER item_updated_at BEFORE UPDATE ON "item" FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); +CREATE TRIGGER identity_create AFTER INSERT ON "identity" REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER identity_update AFTER UPDATE ON "identity" REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER identity_delete AFTER DELETE ON "identity" REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER identity_updated_at BEFORE UPDATE ON "identity" FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); +CREATE FUNCTION item__total__0614a79a__computed(r item) RETURNS NUMERIC AS $$ BEGIN + RETURN r."quantity" * r."price"; +END; $$ LANGUAGE plpgsql; +CREATE OR REPLACE FUNCTION item__exec_computed_fns() RETURNS TRIGGER AS $$ BEGIN + NEW.total := item__total__0614a79a__computed(NEW); + RETURN NEW; +END; $$ LANGUAGE plpgsql; +CREATE OR REPLACE TRIGGER item__computed_trigger BEFORE INSERT OR UPDATE ON "item" FOR EACH ROW EXECUTE PROCEDURE item__exec_computed_fns(); +=== + +[ + {"Model":"Identity","Field":"","Type":"ADDED"}, + {"Model":"Item","Field":"","Type":"ADDED"}, + {"Model":"KeelAudit","Field":"","Type":"ADDED"} +] diff --git a/migrations/testdata/computed_field_multiple_depend.txt b/migrations/testdata/computed_field_multiple_depend.txt new file mode 100644 index 000000000..de448f784 --- /dev/null +++ b/migrations/testdata/computed_field_multiple_depend.txt @@ -0,0 +1,42 @@ + +model Item { + fields { + price Decimal + quantity Number + totalWithShipping Decimal + total Decimal + } +} + +=== + +model Item { + fields { + price Decimal + quantity Number + totalWithShipping Decimal @computed(item.total + 5) + total Decimal @computed(item.quantity * item.price) + } +} + +=== + +CREATE FUNCTION item__total__0614a79a__computed(r item) RETURNS NUMERIC AS $$ BEGIN + RETURN r."quantity" * r."price"; +END; $$ LANGUAGE plpgsql; +CREATE FUNCTION item__total_with_shipping__53d0d09b__computed(r item) RETURNS NUMERIC AS $$ BEGIN + RETURN r."total" + 5; +END; $$ LANGUAGE plpgsql; +CREATE OR REPLACE FUNCTION item__exec_computed_fns() RETURNS TRIGGER AS $$ BEGIN + NEW.total := item__total__0614a79a__computed(NEW); + NEW.total_with_shipping := item__total_with_shipping__53d0d09b__computed(NEW); + RETURN NEW; +END; $$ LANGUAGE plpgsql; +CREATE OR REPLACE TRIGGER item__computed_trigger BEFORE INSERT OR UPDATE ON "item" FOR EACH ROW EXECUTE PROCEDURE item__exec_computed_fns(); + +=== + +[ + {"Model":"Item","Field":"total","Type":"MODIFIED"}, + {"Model":"Item","Field":"totalWithShipping","Type":"MODIFIED"} +] diff --git a/migrations/testdata/computed_field_removed_attr.txt b/migrations/testdata/computed_field_removed_attr.txt new file mode 100644 index 000000000..9cd5669a5 --- /dev/null +++ b/migrations/testdata/computed_field_removed_attr.txt @@ -0,0 +1,29 @@ +model Item { + fields { + price Decimal + quantity Number + total Decimal @computed(item.quantity * item.price) + } +} + +=== + +model Item { + fields { + price Decimal + quantity Number + total Decimal + } +} + +=== + +DROP FUNCTION item__total__0614a79a__computed; +DROP TRIGGER item__computed_trigger ON item; +DROP FUNCTION item__exec_computed_fns; + +=== + +[ + {"Model":"Item","Field":"total","Type":"MODIFIED"} +] diff --git a/migrations/testdata/computed_field_removed_field.txt b/migrations/testdata/computed_field_removed_field.txt new file mode 100644 index 000000000..5df57c56e --- /dev/null +++ b/migrations/testdata/computed_field_removed_field.txt @@ -0,0 +1,29 @@ +model Item { + fields { + price Decimal + quantity Number + total Decimal @computed(item.quantity * item.price) + } +} + +=== + +model Item { + fields { + price Decimal + quantity Number + } +} + +=== + +ALTER TABLE "item" DROP COLUMN "total"; +DROP FUNCTION item__total__0614a79a__computed; +DROP TRIGGER item__computed_trigger ON item; +DROP FUNCTION item__exec_computed_fns; + +=== + +[ + {"Model":"Item","Field":"total","Type":"REMOVED"} +] diff --git a/migrations/testdata/computed_field_renamed_field.txt b/migrations/testdata/computed_field_renamed_field.txt new file mode 100644 index 000000000..b16c52a4c --- /dev/null +++ b/migrations/testdata/computed_field_renamed_field.txt @@ -0,0 +1,38 @@ +model Item { + fields { + price Decimal + quantity Number + total Decimal @computed(item.quantity * item.price) + } +} + +=== + +model Item { + fields { + price Decimal + quantity Number + newTotal Decimal @computed(item.quantity * item.price) + } +} + +=== + +ALTER TABLE "item" ADD COLUMN "new_total" NUMERIC NOT NULL; +ALTER TABLE "item" DROP COLUMN "total"; +CREATE FUNCTION item__new_total__0614a79a__computed(r item) RETURNS NUMERIC AS $$ BEGIN + RETURN r."quantity" * r."price"; +END; $$ LANGUAGE plpgsql; +DROP FUNCTION item__total__0614a79a__computed; +CREATE OR REPLACE FUNCTION item__exec_computed_fns() RETURNS TRIGGER AS $$ BEGIN + NEW.new_total := item__new_total__0614a79a__computed(NEW); + RETURN NEW; +END; $$ LANGUAGE plpgsql; +CREATE OR REPLACE TRIGGER item__computed_trigger BEFORE INSERT OR UPDATE ON "item" FOR EACH ROW EXECUTE PROCEDURE item__exec_computed_fns(); + +=== + +[ + {"Model":"Item","Field":"newTotal","Type":"ADDED"}, + {"Model":"Item","Field":"total","Type":"REMOVED"} +] diff --git a/migrations/testdata/computed_field_unchanged.txt b/migrations/testdata/computed_field_unchanged.txt new file mode 100644 index 000000000..e7f97fc48 --- /dev/null +++ b/migrations/testdata/computed_field_unchanged.txt @@ -0,0 +1,24 @@ +model Item { + fields { + price Decimal + quantity Number + total Decimal @computed(item.quantity * item.price) + } +} + +=== + +model Item { + fields { + price Decimal + quantity Number + total Decimal @computed(item.quantity * item.price) + } +} + +=== + +=== + +[] + diff --git a/migrations/testdata/default_value_arrays.txt b/migrations/testdata/default_value_arrays.txt index 9a93d9e05..f315bc47e 100644 --- a/migrations/testdata/default_value_arrays.txt +++ b/migrations/testdata/default_value_arrays.txt @@ -13,7 +13,11 @@ model Person { booleans Boolean[] @default([true, true, false]) // Empty array - dates Date[] @default([]) + emptyTexts Text[] @default([]) + emptyEnums MyEnum[] @default([]) + emptyNumbers Number[] @default([]) + emptyBooleans Boolean[] @default([]) + emptyDates Date[] @default([]) } } @@ -58,7 +62,11 @@ CREATE TABLE "person" ( "enums" TEXT[] NOT NULL DEFAULT ARRAY['One','Two']::TEXT[], "numbers" INTEGER[] NOT NULL DEFAULT ARRAY[1,2,3]::INTEGER[], "booleans" BOOL[] NOT NULL DEFAULT ARRAY[true,true,false]::BOOL[], -"dates" DATE[] NOT NULL DEFAULT '{}', +"empty_texts" TEXT[] NOT NULL DEFAULT '{}', +"empty_enums" TEXT[] NOT NULL DEFAULT '{}', +"empty_numbers" INTEGER[] NOT NULL DEFAULT '{}', +"empty_booleans" BOOL[] NOT NULL DEFAULT '{}', +"empty_dates" DATE[] NOT NULL DEFAULT '{}', "id" TEXT NOT NULL DEFAULT ksuid(), "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now() diff --git a/node/codegen.go b/node/codegen.go index 6c48b4f7a..7ff37f16f 100644 --- a/node/codegen.go +++ b/node/codegen.go @@ -342,7 +342,7 @@ func writeCreateValuesType(w *codegen.Writer, schema *proto.Schema, model *proto } w.Write(field.Name) - if field.Optional || field.DefaultValue != nil || field.IsHasMany() { + if field.Optional || field.DefaultValue != nil || field.IsHasMany() || field.ComputedExpression != nil { w.Write("?") } @@ -431,7 +431,7 @@ func writeFindManyParamsInterface(w *codegen.Writer, model *proto.Model) { switch f.Type.Type { // scalar types are only permitted to sort by - case proto.Type_TYPE_BOOL, proto.Type_TYPE_DATE, proto.Type_TYPE_DATETIME, proto.Type_TYPE_INT, proto.Type_TYPE_STRING, proto.Type_TYPE_ENUM, proto.Type_TYPE_TIMESTAMP, proto.Type_TYPE_ID: + case proto.Type_TYPE_BOOL, proto.Type_TYPE_DATE, proto.Type_TYPE_DATETIME, proto.Type_TYPE_INT, proto.Type_TYPE_STRING, proto.Type_TYPE_ENUM, proto.Type_TYPE_TIMESTAMP, proto.Type_TYPE_ID, proto.Type_TYPE_DECIMAL: return true default: // includes types such as password, secret, model etc @@ -678,7 +678,7 @@ func writeModelAPIDeclaration(w *codegen.Writer, model *proto.Model) { w.Indent() nonOptionalFields := lo.Filter(model.Fields, func(f *proto.Field, _ int) bool { - return !f.Optional && f.DefaultValue == nil + return !f.Optional && f.DefaultValue == nil && f.ComputedExpression == nil }) tsDocComment(w, func(w *codegen.Writer) { diff --git a/node/codegen_test.go b/node/codegen_test.go index f8499e135..01ad8e351 100644 --- a/node/codegen_test.go +++ b/node/codegen_test.go @@ -42,6 +42,7 @@ model Person { height Decimal bio Markdown file File + heightInMetres Decimal @computed(person.height * 0.3048) } }` @@ -59,6 +60,7 @@ export interface PersonTable { height: number bio: string file: FileDbRecord + heightInMetres: number id: Generated createdAt: Generated updatedAt: Generated @@ -106,6 +108,7 @@ export interface Person { height: number bio: string file: runtime.File + heightInMetres: number id: string createdAt: Date updatedAt: Date @@ -131,6 +134,7 @@ export type PersonCreateValues = { height: number bio: string file: runtime.InlineFile | runtime.File + heightInMetres?: number id?: string createdAt?: Date updatedAt?: Date @@ -180,6 +184,7 @@ export interface PersonWhereConditions { tags?: string[] | runtime.StringArrayWhereCondition; height?: number | runtime.NumberWhereCondition; bio?: string | runtime.StringWhereCondition; + heightInMetres?: number | runtime.NumberWhereCondition; id?: string | runtime.IDWhereCondition; createdAt?: Date | runtime.DateWhereCondition; updatedAt?: Date | runtime.DateWhereCondition; @@ -314,6 +319,8 @@ export type PersonOrderBy = { dateOfBirth?: runtime.SortDirection, gender?: runtime.SortDirection, hasChildren?: runtime.SortDirection, + height?: runtime.SortDirection, + heightInMetres?: runtime.SortDirection, id?: runtime.SortDirection, createdAt?: runtime.SortDirection, updatedAt?: runtime.SortDirection diff --git a/node/permissions_test.go b/node/permissions_test.go index 1125a0819..9a8d5c370 100644 --- a/node/permissions_test.go +++ b/node/permissions_test.go @@ -70,7 +70,7 @@ module.exports.permissionFns = permissionFns; sql: ` SELECT DISTINCT "person"."id" FROM "person" - WHERE (true) AND "person"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) + WHERE true AND "person"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) `, }, { @@ -102,7 +102,7 @@ module.exports.permissionFns = permissionFns; sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE ("post"."publish_date" <= ${ctx.now()}) AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) + WHERE "post"."publish_date" <= ${ctx.now()} AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) `, }, { @@ -136,7 +136,7 @@ module.exports.permissionFns = permissionFns; SELECT DISTINCT "post"."id" FROM "post" LEFT JOIN "identity" AS "post$identity" ON "post"."identity_id" = "post$identity"."id" - WHERE ("post$identity"."email" IS NOT DISTINCT FROM ${"adam@keel.xyz"}) AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) + WHERE "post$identity"."email" IS NOT DISTINCT FROM ${"adam@keel.xyz"} AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) `, }, { @@ -168,7 +168,7 @@ module.exports.permissionFns = permissionFns; sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE ("post"."view_count" < ${10}) AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) + WHERE "post"."view_count" < ${10} AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) `, }, { @@ -201,7 +201,7 @@ module.exports.permissionFns = permissionFns; sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE ("post"."identity_id" IS NOT DISTINCT FROM ${ctx.identity ? ctx.identity.id : ''}) AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) + WHERE "post"."identity_id" IS NOT DISTINCT FROM ${ctx.identity ? ctx.identity.id : ''} AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) `, }, { @@ -235,7 +235,7 @@ module.exports.permissionFns = permissionFns; SELECT DISTINCT "post"."id" FROM "post" LEFT JOIN "identity" AS "post$identity" ON "post"."identity_id" = "post$identity"."id" - WHERE ("post$identity"."email" IS NOT DISTINCT FROM ${ctx.identity ? ctx.identity.email : ''}) AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) + WHERE "post$identity"."email" IS NOT DISTINCT FROM ${ctx.identity ? ctx.identity.email : ''} AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) `, }, { @@ -268,7 +268,7 @@ module.exports.permissionFns = permissionFns; sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE (${ctx.isAuthenticated}::boolean) AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) + WHERE ${ctx.isAuthenticated}::boolean AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) `, }, { @@ -300,7 +300,7 @@ module.exports.permissionFns = permissionFns; sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE (${ctx.headers["secretkey"] || ""} IS NOT DISTINCT FROM "post"."secret_key") AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) + WHERE ${ctx.headers["secretkey"] || ""} IS NOT DISTINCT FROM "post"."secret_key" AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) `, }, { @@ -332,7 +332,7 @@ module.exports.permissionFns = permissionFns; sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE (${ctx.secrets["SECRET_KEY"] || ""} IS NOT DISTINCT FROM "post"."secret_key") AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) + WHERE ${ctx.secrets["SECRET_KEY"] || ""} IS NOT DISTINCT FROM "post"."secret_key" AND "post"."id" IN (${(records.length > 0) ? sql.join(records.map(x => x.id)) : []}) `, }, } diff --git a/node/templates/client/core.ts b/node/templates/client/core.ts index 5e52f50a2..7b815213f 100644 --- a/node/templates/client/core.ts +++ b/node/templates/client/core.ts @@ -150,7 +150,9 @@ export class Core { /** * A promise that resolves when the session is refreshed. */ - refreshingPromise: undefined as Promise> | undefined, + refreshingPromise: undefined as + | Promise> + | undefined, /** * Returns data field set to the list of supported authentication providers and their SSO login URLs. @@ -324,10 +326,10 @@ export class Core { // If refreshing already, wait for the existing refreshing promisee if (!this.auth.refreshingPromise) { - this.auth.refreshingPromise = this.auth.requestToken({ - grant_type: "refresh_token", - refresh_token: refreshToken, - }); + this.auth.refreshingPromise = this.auth.requestToken({ + grant_type: "refresh_token", + refresh_token: refreshToken, + }); } const authResponse = await this.auth.refreshingPromise; diff --git a/permissions/permissions.go b/permissions/permissions.go index 7370b2810..c4a53ad0d 100644 --- a/permissions/permissions.go +++ b/permissions/permissions.go @@ -8,6 +8,7 @@ import ( "github.com/samber/lo" "github.com/teamkeel/keel/casing" "github.com/teamkeel/keel/db" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/proto" "github.com/teamkeel/keel/schema/parser" ) @@ -29,11 +30,95 @@ const ( type Value struct { Type ValueType StringValue string // Set if Type is ValueString - NumberValue int // Set if Type is ValueNumber + NumberValue int64 // Set if Type is ValueNumber HeaderKey string // Set if Type is ValueHeader SecretKey string // Set if Type is ValueSecret } +type permissionGen struct { + schema *proto.Schema + model *proto.Model + action *proto.Action + stmt *statement +} + +func gen(schema *proto.Schema, model *proto.Model, action *proto.Action, stmt *statement) resolve.Visitor[*statement] { + return &permissionGen{ + schema: schema, + model: model, + action: action, + stmt: stmt, + } +} + +func (v *permissionGen) StartCondition(nested bool) error { + if nested { + v.stmt.expression += "(" + } + return nil +} +func (v *permissionGen) EndCondition(nested bool) error { + if nested { + v.stmt.expression += ")" + } + + return nil +} +func (v *permissionGen) VisitAnd() error { + v.stmt.expression += " and " + return nil +} +func (v *permissionGen) VisitOr() error { + v.stmt.expression += " or " + return nil +} +func (v *permissionGen) VisitNot() error { + return nil +} +func (v *permissionGen) VisitOperator(op string) error { + v.stmt.expression += operatorMap[op][0] + return nil +} +func (v *permissionGen) VisitLiteral(value any) error { + if value == nil { + v.stmt.expression += "null" + return nil + } + + switch value := value.(type) { + case int64: + v.stmt.expression += "?" + v.stmt.values = append(v.stmt.values, &Value{Type: ValueNumber, NumberValue: value}) + case uint64: + v.stmt.expression += "?" + v.stmt.values = append(v.stmt.values, &Value{Type: ValueNumber, NumberValue: int64(value)}) + case string: + v.stmt.expression += "?" + v.stmt.values = append(v.stmt.values, &Value{Type: ValueString, StringValue: fmt.Sprintf("\"%s\"", value)}) + case bool: + if value { + v.stmt.expression += "true" + } else { + v.stmt.expression += "false" + } + default: + return fmt.Errorf("unsupported type in permission expression") + } + + return nil +} +func (v *permissionGen) VisitIdent(ident *parser.ExpressionIdent) error { + return handleOperand(v.schema, v.model, ident, v.stmt) +} +func (v *permissionGen) VisitIdentArray(idents []*parser.ExpressionIdent) error { + return nil +} +func (v *permissionGen) Result() (*statement, error) { + return v.stmt, nil +} + +var _ resolve.Visitor[*statement] = new(permissionGen) + // ToSQL creates a single SQL query that can be run to determine if permission is granted for the // given action and a set of records. // @@ -43,7 +128,10 @@ func ToSQL(s *proto.Schema, m *proto.Model, action *proto.Action) (sql string, v tableName := identifier(m.Name) pkField := identifier(m.PrimaryKeyFieldName()) - stmt := &statement{} + stmt := &statement{ + joins: []string{}, + values: []*Value{}, + } permissions := proto.PermissionsForAction(s, action) for _, p := range permissions { @@ -61,9 +149,9 @@ func ToSQL(s *proto.Schema, m *proto.Model, action *proto.Action) (sql string, v return sql, values, err } - err = handleExpression(s, m, expr, stmt) + stmt, err = resolve.RunCelVisitor(expr, gen(s, m, action, stmt)) if err != nil { - return sql, values, err + return "", nil, err } } @@ -92,118 +180,40 @@ func ToSQL(s *proto.Schema, m *proto.Model, action *proto.Action) (sql string, v return sql, stmt.values, nil } -func handleExpression(s *proto.Schema, m *proto.Model, expr *parser.Expression, stmt *statement) (err error) { - stmt.expression += "(" - for i, or := range expr.Or { - if i > 0 { - stmt.expression += " or " - } - for j, and := range or.And { - if j > 0 { - stmt.expression += " and " - } - - if and.Expression != nil { - err = handleExpression(s, m, and.Expression, stmt) - if err != nil { - return err - } - continue - } - - cond := and.Condition - err = handleOperand(s, m, cond.LHS, stmt) - if err != nil { - return err - } - - // If no RHS then we're done - if cond.Operator == nil { - continue - } - - op := operatorMap[cond.Operator.Symbol] - opOpen := op[0] - opClose := "" - if len(op) == 2 { - opClose = op[1] - } - - stmt.expression += opOpen - - err = handleOperand(s, m, cond.RHS, stmt) - if err != nil { - return err - } - - if opClose != "" { - stmt.expression += opClose - } +func handleOperand(s *proto.Schema, model *proto.Model, ident *parser.ExpressionIdent, stmt *statement) (err error) { + switch ident.Fragments[0] { + case "ctx": + return handleContext(s, ident, stmt) + case casing.ToLowerCamel(model.Name): + return handleModel(s, model, ident, stmt) + default: + // If not context of model must be enum, but still worth checking to be sure + enum := proto.FindEnum(s.Enums, ident.Fragments[0]) + if enum == nil { + return fmt.Errorf("unknown ident %s", ident.Fragments[0]) } - } - - stmt.expression += ")" - return nil -} -func handleOperand(s *proto.Schema, model *proto.Model, o *parser.Operand, stmt *statement) (err error) { - switch { - case o.True: - stmt.expression += "true" - return nil - case o.False: - stmt.expression += "false" - return nil - case o.String != nil: stmt.expression += "?" - stmt.values = append(stmt.values, &Value{Type: ValueString, StringValue: *o.String}) - return nil - case o.Number != nil: - stmt.expression += "?" - stmt.values = append(stmt.values, &Value{Type: ValueNumber, NumberValue: int(*o.Number)}) - return nil - case o.Null: - stmt.expression += "null" - return nil - case o.Array != nil: - return errors.New("arrays in permission rules not yet supported") - case o.Ident != nil: - switch o.Ident.Fragments[0].Fragment { - case "ctx": - return handleContext(s, o, stmt) - case casing.ToLowerCamel(model.Name): - return handleModel(s, model, o.Ident, stmt) - default: - // If not context of model must be enum, but still worth checking to be sure - enum := proto.FindEnum(s.Enums, o.Ident.Fragments[0].Fragment) - if enum == nil { - return fmt.Errorf("unknown ident %s", o.Ident.Fragments[0].Fragment) - } - - stmt.expression += "?" - stmt.values = append(stmt.values, &Value{Type: ValueString, StringValue: o.Ident.LastFragment()}) + stmt.values = append(stmt.values, &Value{Type: ValueString, StringValue: ident.Fragments[len(ident.Fragments)-1]}) - return nil - } + return nil } - - return fmt.Errorf("unsupported operand: %s", o.ToString()) } -func handleContext(s *proto.Schema, o *parser.Operand, stmt *statement) error { - if len(o.Ident.Fragments) < 2 { +func handleContext(s *proto.Schema, ident *parser.ExpressionIdent, stmt *statement) error { + if len(ident.Fragments) < 2 { return errors.New("ctx used in expression with no properties") } - switch o.Ident.Fragments[1].Fragment { + switch ident.Fragments[1] { case "identity": // ctx.identity is the same as ctx.identity.id - if len(o.Ident.Fragments) == 2 { + if len(ident.Fragments) == 2 { stmt.expression += "?" stmt.values = append(stmt.values, &Value{Type: ValueIdentityID}) return nil } - switch o.Ident.Fragments[2].Fragment { + switch ident.Fragments[2] { case "id": stmt.expression += "?" stmt.values = append(stmt.values, &Value{Type: ValueIdentityID}) @@ -217,9 +227,9 @@ func handleContext(s *proto.Schema, o *parser.Operand, stmt *statement) error { err := handleModel( s, s.FindModel("Identity"), - &parser.Ident{ + &parser.ExpressionIdent{ // We can drop the first fragments, which is "ctx" - Fragments: o.Ident.Fragments[1:], + Fragments: ident.Fragments[1:], }, inner, ) @@ -245,32 +255,32 @@ func handleContext(s *proto.Schema, o *parser.Operand, stmt *statement) error { return nil case "headers": stmt.expression += "?" - key := o.Ident.Fragments[2].Fragment + key := ident.Fragments[2] stmt.values = append(stmt.values, &Value{Type: ValueHeader, HeaderKey: key}) return nil case "secrets": stmt.expression += "?" - key := o.Ident.Fragments[2].Fragment + key := ident.Fragments[2] stmt.values = append(stmt.values, &Value{Type: ValueSecret, SecretKey: key}) return nil default: - return fmt.Errorf("unknown property %s of ctx", o.Ident.Fragments[1].Fragment) + return fmt.Errorf("unknown property %s of ctx", ident.Fragments[1]) } } -func handleModel(s *proto.Schema, model *proto.Model, ident *parser.Ident, stmt *statement) (err error) { +func handleModel(s *proto.Schema, model *proto.Model, ident *parser.ExpressionIdent, stmt *statement) (err error) { fieldName := "" for i, f := range ident.Fragments { switch { // The first fragment case i == 0: - fieldName += casing.ToSnake(f.Fragment) + fieldName += casing.ToSnake(f) // Remaining fragments default: - field := proto.FindField(s.Models, model.Name, f.Fragment) + field := proto.FindField(s.Models, model.Name, f) if field == nil { - return fmt.Errorf("model %s has no field %s", model.Name, f.Fragment) + return fmt.Errorf("model %s has no field %s", model.Name, f) } isLast := i == len(ident.Fragments)-1 @@ -282,14 +292,14 @@ func handleModel(s *proto.Schema, model *proto.Model, ident *parser.Ident, stmt leftAlias := fieldName // Append fragment to identifier - fieldName += "$" + casing.ToSnake(f.Fragment) + fieldName += "$" + casing.ToSnake(f) // Right alias is the join table rightAlias := fieldName - field := proto.FindField(s.Models, model.Name, f.Fragment) + field := proto.FindField(s.Models, model.Name, f) if field == nil { - return fmt.Errorf("model %s has no field %s", model.Name, f.Fragment) + return fmt.Errorf("model %s has no field %s", model.Name, f) } joinModel := s.FindModel(field.Type.ModelName.Value) @@ -331,7 +341,7 @@ func handleModel(s *proto.Schema, model *proto.Model, ident *parser.Ident, stmt fieldName += "." + identifier("id") } } else { - fieldName += "." + identifier(f.Fragment) + fieldName += "." + identifier(f) } } } @@ -357,11 +367,11 @@ type statement struct { // SQL operators can be provided as just a simple value // or as a pair of opening and closing values var operatorMap = map[string][]string{ - "==": {" IS NOT DISTINCT FROM "}, - "!=": {" IS DISTINCT FROM "}, - "<": {" < "}, - "<=": {" <= "}, - ">": {" > "}, - ">=": {" >= "}, - "in": {" IS NOT DISTINCT FROM "}, + "_==_": {" IS NOT DISTINCT FROM "}, + "_!=_": {" IS DISTINCT FROM "}, + "_<_": {" < "}, + "_<=_": {" <= "}, + "_>_": {" > "}, + "_>=_": {" >= "}, + "@in": {" IS NOT DISTINCT FROM "}, } diff --git a/permissions/permissions_test.go b/permissions/permissions_test.go index 076e9e8b3..6d3a7347c 100644 --- a/permissions/permissions_test.go +++ b/permissions/permissions_test.go @@ -45,7 +45,7 @@ func TestToSQL(t *testing.T) { sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE ("post"."public" IS NOT DISTINCT FROM false) AND "post"."id" IN (?) + WHERE "post"."public" IS NOT DISTINCT FROM false AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -73,7 +73,7 @@ func TestToSQL(t *testing.T) { sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE ("post"."public" IS NOT DISTINCT FROM true) AND "post"."id" IN (?) + WHERE "post"."public" IS NOT DISTINCT FROM true AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -101,7 +101,7 @@ func TestToSQL(t *testing.T) { sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE ("post"."is_public") AND "post"."id" IN (?) + WHERE "post"."is_public" AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -129,7 +129,7 @@ func TestToSQL(t *testing.T) { sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE ("post"."title" IS NOT DISTINCT FROM ?) AND "post"."id" IN (?) + WHERE "post"."title" IS NOT DISTINCT FROM ? AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -161,7 +161,7 @@ func TestToSQL(t *testing.T) { sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE ("post"."view_count" < ?) AND "post"."id" IN (?) + WHERE "post"."view_count" < ? AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -193,7 +193,7 @@ func TestToSQL(t *testing.T) { sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE ("post"."identity_id" IS NOT DISTINCT FROM null) AND "post"."id" IN (?) + WHERE "post"."identity_id" IS NOT DISTINCT FROM null AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -221,7 +221,7 @@ func TestToSQL(t *testing.T) { sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE ("post"."identity_id" IS DISTINCT FROM null) AND "post"."id" IN (?) + WHERE "post"."identity_id" IS DISTINCT FROM null AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -253,7 +253,7 @@ func TestToSQL(t *testing.T) { sql: ` SELECT DISTINCT "project"."id" FROM "project" - WHERE ("project"."visibility" IS NOT DISTINCT FROM ?) AND "project"."id" IN (?) + WHERE "project"."visibility" IS NOT DISTINCT FROM ? AND "project"."id" IN (?) `, values: []permissions.Value{ { @@ -285,7 +285,7 @@ func TestToSQL(t *testing.T) { sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE (? IS NOT DISTINCT FROM "post"."secret_key") AND "post"."id" IN (?) + WHERE ? IS NOT DISTINCT FROM "post"."secret_key" AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -317,7 +317,7 @@ func TestToSQL(t *testing.T) { sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE (? IS NOT DISTINCT FROM "post"."secret_key") AND "post"."id" IN (?) + WHERE ? IS NOT DISTINCT FROM "post"."secret_key" AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -354,7 +354,7 @@ func TestToSQL(t *testing.T) { sql: ` SELECT DISTINCT "post"."id" FROM "post" LEFT JOIN "author" AS "post$author" ON "post"."author_id" = "post$author"."id" - WHERE ("post$author"."identity_id" IS NOT DISTINCT FROM ?) AND "post"."id" IN (?) + WHERE "post$author"."identity_id" IS NOT DISTINCT FROM ? AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -397,7 +397,7 @@ func TestToSQL(t *testing.T) { FROM "post" LEFT JOIN "author" AS "post$author" ON "post"."author_id" = "post$author"."id" LEFT JOIN "account" AS "post$author$account" ON "post$author"."account_id" = "post$author$account"."id" - WHERE ("post$author$account"."identity_id" IS NOT DISTINCT FROM ?) AND "post"."id" IN (?) + WHERE "post$author$account"."identity_id" IS NOT DISTINCT FROM ? AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -435,7 +435,7 @@ func TestToSQL(t *testing.T) { SELECT DISTINCT "project"."id" FROM "project" LEFT JOIN "account" AS "project$accounts" ON "project"."id" = "project$accounts"."project_id" - WHERE (? IS NOT DISTINCT FROM "project$accounts"."identity_id") AND "project"."id" IN (?) + WHERE ? IS NOT DISTINCT FROM "project$accounts"."identity_id" AND "project"."id" IN (?) `, values: []permissions.Value{ { @@ -458,7 +458,7 @@ func TestToSQL(t *testing.T) { get getProject(id) } @permission( - expression: project.identity == ctx.identity or (project.public and ctx.isAuthenticated == false), + expression: project.identity == ctx.identity || (project.public && ctx.isAuthenticated == false), actions: [get] ) } @@ -466,8 +466,9 @@ func TestToSQL(t *testing.T) { action: "getProject", sql: ` SELECT DISTINCT "project"."id" FROM "project" - WHERE ("project"."identity_id" IS NOT DISTINCT - FROM ? or ("project"."public" and ?::boolean IS NOT DISTINCT FROM false)) AND "project"."id" IN (?) + WHERE + ("project"."identity_id" IS NOT DISTINCT FROM ? or + "project"."public" and ?::boolean IS NOT DISTINCT FROM false) AND "project"."id" IN (?) `, values: []permissions.Value{ { @@ -501,7 +502,7 @@ func TestToSQL(t *testing.T) { sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE ("post"."publish_date" <= ?) AND "post"."id" IN (?) + WHERE "post"."publish_date" <= ? AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -537,7 +538,7 @@ func TestToSQL(t *testing.T) { sql: ` SELECT DISTINCT "post"."id" FROM "post" - WHERE (("post"."publish_date" <= ?) or ("post"."identity_id" IS NOT DISTINCT FROM ?)) AND "post"."id" IN (?) + WHERE ("post"."publish_date" <= ? or "post"."identity_id" IS NOT DISTINCT FROM ?) AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -582,7 +583,7 @@ func TestToSQL(t *testing.T) { SELECT DISTINCT "post"."id" FROM "post" LEFT JOIN "account" AS "post$account" ON "post"."account_id" = "post$account"."id" - WHERE (("post$account"."identity_id" IS NOT DISTINCT FROM ?) or ("post$account"."posts_are_public")) AND "post"."id" IN (?) + WHERE ("post$account"."identity_id" IS NOT DISTINCT FROM ? or "post$account"."posts_are_public") AND "post"."id" IN (?) `, values: []permissions.Value{ { @@ -625,7 +626,7 @@ func TestToSQL(t *testing.T) { FROM "join" LEFT JOIN "table" AS "join$inner" ON "join"."inner_id" = "join$inner"."id" LEFT JOIN "select" AS "join$inner$group" ON "join$inner"."group_id" = "join$inner$group"."id" - WHERE ("join$inner$group"."by_id" IS NOT DISTINCT FROM ?) AND "join"."id" IN (?) + WHERE "join$inner$group"."by_id" IS NOT DISTINCT FROM ? AND "join"."id" IN (?) `, values: []permissions.Value{ { @@ -678,12 +679,12 @@ func TestToSQL(t *testing.T) { LEFT JOIN "post" AS "comment$post" ON "comment"."post_id" = "comment$post"."id" WHERE - ((SELECT "identity$user_profile"."id" + (SELECT "identity$user_profile"."id" FROM "identity" LEFT JOIN "user_profile" AS "identity$user_profile" ON "identity"."id" = "identity$user_profile"."identity_id" WHERE "identity"."id" IS NOT DISTINCT FROM ?) - IS NOT DISTINCT FROM "comment$post"."profile_id") + IS NOT DISTINCT FROM "comment$post"."profile_id" AND "comment"."id" IN (?) `, values: []permissions.Value{ @@ -738,11 +739,11 @@ func TestToSQL(t *testing.T) { ON "comment"."post_id" = "comment$post"."id" LEFT JOIN "user_profile" AS "comment$post$profile" ON "comment$post"."profile_id" = "comment$post$profile"."id" WHERE - ((SELECT "identity$user_profile"."id" + (SELECT "identity$user_profile"."id" FROM "identity" LEFT JOIN "user_profile" AS "identity$user_profile" ON "identity"."id" = "identity$user_profile"."identity_id" - WHERE "identity"."id" IS NOT DISTINCT FROM ?) IS NOT DISTINCT FROM "comment$post$profile"."id") + WHERE "identity"."id" IS NOT DISTINCT FROM ?) IS NOT DISTINCT FROM "comment$post$profile"."id" AND "comment"."id" IN (?) `, values: []permissions.Value{ @@ -793,10 +794,10 @@ func TestToSQL(t *testing.T) { LEFT JOIN "user_org" AS "user$orgs" ON "user"."id" = "user$orgs"."user_id" LEFT JOIN "org" AS "user$orgs$org" ON "user$orgs"."org_id" = "user$orgs$org"."id" LEFT JOIN "user_org" AS "user$orgs$org$users" ON "user$orgs$org"."id" = "user$orgs$org$users"."org_id" - WHERE ((SELECT "identity$user"."id" + WHERE (SELECT "identity$user"."id" FROM "identity" LEFT JOIN "user" AS "identity$user" ON "identity"."id" = "identity$user"."identity_id" - WHERE "identity"."id" IS NOT DISTINCT FROM ?) IS NOT DISTINCT FROM "user$orgs$org$users"."user_id") + WHERE "identity"."id" IS NOT DISTINCT FROM ?) IS NOT DISTINCT FROM "user$orgs$org$users"."user_id" AND "user"."id" IN (?) `, values: []permissions.Value{ diff --git a/proto/model.go b/proto/model.go index 40128e000..bf50547e5 100644 --- a/proto/model.go +++ b/proto/model.go @@ -46,3 +46,14 @@ func (m *Model) PrimaryKeyFieldName() string { } return "" } + +// GetComputedFields returns all the computed fields on the given model. +func (m *Model) GetComputedFields() []*Field { + fields := []*Field{} + for _, f := range m.Fields { + if f.ComputedExpression != nil { + fields = append(fields, f) + } + } + return fields +} diff --git a/proto/schema.pb.go b/proto/schema.pb.go index ee101606c..3e9d8fc28 100644 --- a/proto/schema.pb.go +++ b/proto/schema.pb.go @@ -559,6 +559,9 @@ type Field struct { // and then Author has posts which is of type Post, on the Post.author field this // value will be "posts" and on the Author.posts field this value will be "author". InverseFieldName *wrapperspb.StringValue `protobuf:"bytes,12,opt,name=inverse_field_name,json=inverseFieldName,proto3" json:"inverse_field_name,omitempty"` + // If computed then this field will contain the expression that will be evaluated to + // determine the value of the field at runtime. + ComputedExpression *Expression `protobuf:"bytes,13,opt,name=computed_expression,json=computedExpression,proto3" json:"computed_expression,omitempty"` } func (x *Field) Reset() { @@ -668,6 +671,13 @@ func (x *Field) GetInverseFieldName() *wrapperspb.StringValue { return nil } +func (x *Field) GetComputedExpression() *Expression { + if x != nil { + return x.ComputedExpression + } + return nil +} + type ForeignKeyInfo struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2110,7 +2120,7 @@ var file_proto_schema_proto_rawDesc = []byte{ 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0b, 0x70, 0x65, - 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xef, 0x03, 0x0a, 0x05, 0x46, 0x69, + 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xb3, 0x04, 0x0a, 0x05, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, @@ -2141,254 +2151,258 @@ var file_proto_schema_proto_rawDesc = []byte{ 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x10, 0x69, 0x6e, 0x76, 0x65, 0x72, - 0x73, 0x65, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x6e, 0x0a, 0x0e, 0x46, - 0x6f, 0x72, 0x65, 0x69, 0x67, 0x6e, 0x4b, 0x65, 0x79, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x2c, 0x0a, - 0x12, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x72, 0x65, 0x6c, 0x61, 0x74, - 0x65, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x2e, 0x0a, 0x13, 0x72, - 0x65, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x66, 0x69, 0x65, - 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x65, - 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x22, 0x67, 0x0a, 0x0c, 0x44, - 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x75, - 0x73, 0x65, 0x5f, 0x7a, 0x65, 0x72, 0x6f, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x0c, 0x75, 0x73, 0x65, 0x5a, 0x65, 0x72, 0x6f, 0x56, 0x61, 0x6c, 0x75, - 0x65, 0x12, 0x31, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x45, 0x78, - 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xeb, 0x04, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, - 0x1d, 0x0a, 0x0a, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, - 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, - 0x6d, 0x65, 0x12, 0x25, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, - 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x43, 0x0a, 0x0e, 0x69, 0x6d, 0x70, - 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x49, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0e, - 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x37, - 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x06, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x50, 0x65, 0x72, 0x6d, - 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, - 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3a, 0x0a, 0x0f, 0x73, 0x65, 0x74, 0x5f, 0x65, - 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x52, 0x0e, 0x73, 0x65, 0x74, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x73, 0x12, 0x3e, 0x0a, 0x11, 0x77, 0x68, 0x65, 0x72, 0x65, 0x5f, 0x65, 0x78, 0x70, - 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x52, 0x10, 0x77, 0x68, 0x65, 0x72, 0x65, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x73, 0x12, 0x48, 0x0a, 0x16, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x5f, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x09, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x45, 0x78, 0x70, 0x72, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x15, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x32, 0x0a, - 0x08, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x62, 0x79, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x42, 0x79, 0x53, - 0x74, 0x61, 0x74, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x42, - 0x79, 0x12, 0x2c, 0x0a, 0x12, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x69, - 0x6e, 0x70, 0x75, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, - 0x32, 0x0a, 0x15, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, - 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4e, - 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, - 0x65, 0x6d, 0x62, 0x65, 0x64, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x45, 0x6d, 0x62, 0x65, 0x64, 0x73, 0x4a, 0x04, 0x08, 0x05, - 0x10, 0x06, 0x22, 0x4c, 0x0a, 0x04, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, - 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, - 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, - 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x65, 0x6d, 0x61, 0x69, - 0x6c, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x73, - 0x22, 0xf6, 0x01, 0x0a, 0x0e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, - 0x75, 0x6c, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, + 0x73, 0x65, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x42, 0x0a, 0x13, 0x63, + 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x64, 0x5f, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x2e, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x12, 0x63, 0x6f, 0x6d, + 0x70, 0x75, 0x74, 0x65, 0x64, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, + 0x6e, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x65, 0x69, 0x67, 0x6e, 0x4b, 0x65, 0x79, 0x49, 0x6e, 0x66, + 0x6f, 0x12, 0x2c, 0x0a, 0x12, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x6d, 0x6f, 0x64, + 0x65, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x72, + 0x65, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x2e, 0x0a, 0x13, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, + 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x72, 0x65, + 0x6c, 0x61, 0x74, 0x65, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x22, + 0x67, 0x0a, 0x0c, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, + 0x24, 0x0a, 0x0e, 0x75, 0x73, 0x65, 0x5f, 0x7a, 0x65, 0x72, 0x6f, 0x5f, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x75, 0x73, 0x65, 0x5a, 0x65, 0x72, 0x6f, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x31, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2e, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x65, 0x78, + 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xeb, 0x04, 0x0a, 0x06, 0x41, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x4e, 0x61, - 0x6d, 0x65, 0x12, 0x3d, 0x0a, 0x0b, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, - 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0a, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x4e, 0x61, 0x6d, - 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, - 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x73, - 0x12, 0x31, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x45, 0x78, 0x70, - 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x0c, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x79, - 0x70, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x0b, 0x61, 0x63, - 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x73, 0x22, 0x66, 0x0a, 0x10, 0x4f, 0x72, 0x64, - 0x65, 0x72, 0x42, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1d, 0x0a, - 0x0a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x33, 0x0a, 0x09, - 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x44, 0x69, 0x72, - 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, - 0x6e, 0x22, 0x24, 0x0a, 0x0a, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x22, 0x49, 0x0a, 0x03, 0x41, 0x70, 0x69, 0x12, 0x12, - 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, - 0x6d, 0x65, 0x12, 0x2e, 0x0a, 0x0a, 0x61, 0x70, 0x69, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, - 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x41, - 0x70, 0x69, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x52, 0x09, 0x61, 0x70, 0x69, 0x4d, 0x6f, 0x64, 0x65, - 0x6c, 0x73, 0x22, 0x65, 0x0a, 0x08, 0x41, 0x70, 0x69, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x12, 0x1d, - 0x0a, 0x0a, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x3a, 0x0a, - 0x0d, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x41, 0x70, 0x69, - 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0c, 0x6d, 0x6f, 0x64, - 0x65, 0x6c, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x31, 0x0a, 0x0e, 0x41, 0x70, 0x69, - 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x61, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0a, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x44, 0x0a, 0x04, - 0x45, 0x6e, 0x75, 0x6d, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x2e, 0x45, 0x6e, 0x75, 0x6d, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x73, 0x22, 0x1f, 0x0a, 0x09, 0x45, 0x6e, 0x75, 0x6d, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, - 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x22, 0x6f, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x12, + 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x25, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0e, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x41, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x43, 0x0a, + 0x0e, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x41, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x52, 0x0e, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0b, + 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3a, 0x0a, 0x0f, 0x73, + 0x65, 0x74, 0x5f, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x07, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x45, 0x78, 0x70, + 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0e, 0x73, 0x65, 0x74, 0x45, 0x78, 0x70, 0x72, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3e, 0x0a, 0x11, 0x77, 0x68, 0x65, 0x72, 0x65, + 0x5f, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x45, 0x78, 0x70, 0x72, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x10, 0x77, 0x68, 0x65, 0x72, 0x65, 0x45, 0x78, 0x70, 0x72, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x48, 0x0a, 0x16, 0x76, 0x61, 0x6c, 0x69, 0x64, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x15, 0x76, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x73, 0x12, 0x32, 0x0a, 0x08, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x62, 0x79, 0x18, 0x0a, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4f, 0x72, 0x64, 0x65, + 0x72, 0x42, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x07, 0x6f, 0x72, + 0x64, 0x65, 0x72, 0x42, 0x79, 0x12, 0x2c, 0x0a, 0x12, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x10, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4e, + 0x61, 0x6d, 0x65, 0x12, 0x32, 0x0a, 0x15, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0c, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x13, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x72, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x5f, 0x65, 0x6d, 0x62, 0x65, 0x64, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x0e, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x45, 0x6d, 0x62, 0x65, 0x64, 0x73, + 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x22, 0x4c, 0x0a, 0x04, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, - 0x6d, 0x65, 0x12, 0x2b, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, - 0x23, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x04, - 0x74, 0x79, 0x70, 0x65, 0x22, 0xba, 0x01, 0x0a, 0x0c, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x46, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x04, - 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x04, 0x74, 0x79, 0x70, - 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x08, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x12, 0x1a, 0x0a, - 0x08, 0x6e, 0x75, 0x6c, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x08, 0x6e, 0x75, 0x6c, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x61, 0x72, - 0x67, 0x65, 0x74, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, - 0x74, 0x22, 0xcc, 0x03, 0x0a, 0x08, 0x54, 0x79, 0x70, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1f, - 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0b, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, - 0x39, 0x0a, 0x09, 0x65, 0x6e, 0x75, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x16, 0x0a, 0x06, + 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x65, 0x6d, + 0x61, 0x69, 0x6c, 0x73, 0x22, 0xf6, 0x01, 0x0a, 0x0e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x6f, 0x64, 0x65, 0x6c, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x6f, 0x64, + 0x65, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x3d, 0x0a, 0x0b, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, + 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0a, 0x61, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x4e, + 0x61, 0x6d, 0x65, 0x73, 0x12, 0x31, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x2e, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x65, 0x78, 0x70, + 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x0c, 0x61, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x11, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, + 0x52, 0x0b, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x73, 0x22, 0x66, 0x0a, + 0x10, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x42, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x4e, 0x61, 0x6d, 0x65, + 0x12, 0x33, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4f, 0x72, 0x64, 0x65, + 0x72, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x24, 0x0a, 0x0a, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x22, 0x49, 0x0a, 0x03, 0x41, + 0x70, 0x69, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2e, 0x0a, 0x0a, 0x61, 0x70, 0x69, 0x5f, 0x6d, 0x6f, + 0x64, 0x65, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2e, 0x41, 0x70, 0x69, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x52, 0x09, 0x61, 0x70, 0x69, + 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x22, 0x65, 0x0a, 0x08, 0x41, 0x70, 0x69, 0x4d, 0x6f, 0x64, + 0x65, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x4e, 0x61, 0x6d, + 0x65, 0x12, 0x3a, 0x0a, 0x0d, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x2e, 0x41, 0x70, 0x69, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, + 0x0c, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x31, 0x0a, + 0x0e, 0x41, 0x70, 0x69, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x1f, 0x0a, 0x0b, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x4e, 0x61, 0x6d, 0x65, + 0x22, 0x44, 0x0a, 0x04, 0x45, 0x6e, 0x75, 0x6d, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x06, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x45, 0x6e, 0x75, 0x6d, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x1f, 0x0a, 0x09, 0x45, 0x6e, 0x75, 0x6d, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x6f, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2b, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, + 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x52, 0x06, 0x66, 0x69, 0x65, + 0x6c, 0x64, 0x73, 0x12, 0x23, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x49, 0x6e, + 0x66, 0x6f, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xba, 0x01, 0x0a, 0x0c, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x12, 0x23, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, + 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x61, + 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x61, + 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x6e, 0x75, 0x6c, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x75, 0x6c, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x16, 0x0a, + 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x74, + 0x61, 0x72, 0x67, 0x65, 0x74, 0x22, 0xcc, 0x03, 0x0a, 0x08, 0x54, 0x79, 0x70, 0x65, 0x49, 0x6e, + 0x66, 0x6f, 0x12, 0x1f, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x0b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, + 0x79, 0x70, 0x65, 0x12, 0x39, 0x0a, 0x09, 0x65, 0x6e, 0x75, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x52, 0x08, 0x65, 0x6e, 0x75, 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x3b, + 0x0a, 0x0a, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, - 0x52, 0x08, 0x65, 0x6e, 0x75, 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x3b, 0x0a, 0x0a, 0x6d, 0x6f, - 0x64, 0x65, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, + 0x52, 0x09, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x3b, 0x0a, 0x0a, 0x66, + 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x09, 0x66, + 0x69, 0x65, 0x6c, 0x64, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x3f, 0x0a, 0x0c, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x09, 0x6d, 0x6f, - 0x64, 0x65, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x3b, 0x0a, 0x0a, 0x66, 0x69, 0x65, 0x6c, 0x64, - 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, - 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, - 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x09, 0x66, 0x69, 0x65, 0x6c, 0x64, - 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x3f, 0x0a, 0x0c, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, + 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0b, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x70, + 0x65, 0x61, 0x74, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x65, 0x70, + 0x65, 0x61, 0x74, 0x65, 0x64, 0x12, 0x3d, 0x0a, 0x0b, 0x75, 0x6e, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, + 0x61, 0x6d, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, - 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0b, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x70, 0x65, 0x61, 0x74, 0x65, - 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x65, 0x70, 0x65, 0x61, 0x74, 0x65, - 0x64, 0x12, 0x3d, 0x0a, 0x0b, 0x75, 0x6e, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, - 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, - 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0a, 0x75, 0x6e, 0x69, 0x6f, 0x6e, 0x4e, 0x61, 0x6d, 0x65, 0x73, - 0x12, 0x4e, 0x0a, 0x14, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x5f, 0x6c, 0x69, 0x74, 0x65, 0x72, - 0x61, 0x6c, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, - 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x12, 0x73, 0x74, - 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x69, 0x74, 0x65, 0x72, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, - 0x22, 0x45, 0x0a, 0x13, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x56, - 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, - 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, - 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x38, 0x0a, 0x06, 0x53, 0x65, 0x63, 0x72, 0x65, - 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, - 0x64, 0x22, 0xad, 0x01, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, - 0x12, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x69, 0x6e, 0x70, 0x75, 0x74, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x37, 0x0a, 0x0b, 0x70, - 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2b, 0x0a, 0x08, 0x73, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, 0x65, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x53, - 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, 0x65, 0x52, 0x08, 0x73, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, - 0x65, 0x22, 0x2a, 0x0a, 0x08, 0x53, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, 0x65, 0x12, 0x1e, 0x0a, - 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x6f, 0x0a, - 0x0a, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, + 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0a, 0x75, 0x6e, 0x69, 0x6f, 0x6e, 0x4e, + 0x61, 0x6d, 0x65, 0x73, 0x12, 0x4e, 0x0a, 0x14, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x5f, 0x6c, + 0x69, 0x74, 0x65, 0x72, 0x61, 0x6c, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, + 0x52, 0x12, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x69, 0x74, 0x65, 0x72, 0x61, 0x6c, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x22, 0x45, 0x0a, 0x13, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, + 0x65, 0x6e, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, + 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x08, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x38, 0x0a, 0x06, 0x53, + 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x71, + 0x75, 0x69, 0x72, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x65, 0x71, + 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0xad, 0x01, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x12, 0x0a, + 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x69, + 0x6e, 0x70, 0x75, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x37, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x50, 0x65, 0x72, + 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0b, 0x70, 0x65, 0x72, + 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2b, 0x0a, 0x08, 0x73, 0x63, 0x68, 0x65, + 0x64, 0x75, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2e, 0x53, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, 0x65, 0x52, 0x08, 0x73, 0x63, 0x68, + 0x65, 0x64, 0x75, 0x6c, 0x65, 0x22, 0x2a, 0x0a, 0x08, 0x53, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, + 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x22, 0x6f, 0x0a, 0x0a, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x10, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4e, 0x61, 0x6d, + 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, + 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x4e, 0x61, 0x6d, + 0x65, 0x73, 0x22, 0x6e, 0x0a, 0x05, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, - 0x2c, 0x0a, 0x12, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x69, 0x6e, 0x70, - 0x75, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, - 0x0b, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x0a, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x6e, - 0x0a, 0x05, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, - 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x32, 0x0a, 0x0b, 0x61, 0x63, - 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, - 0x70, 0x65, 0x52, 0x0a, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x2a, 0x9e, - 0x01, 0x0a, 0x14, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x1d, 0x41, 0x43, 0x54, 0x49, 0x4f, - 0x4e, 0x5f, 0x49, 0x4d, 0x50, 0x4c, 0x45, 0x4d, 0x45, 0x4e, 0x54, 0x41, 0x54, 0x49, 0x4f, 0x4e, - 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x1e, 0x0a, 0x1a, 0x41, 0x43, - 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x49, 0x4d, 0x50, 0x4c, 0x45, 0x4d, 0x45, 0x4e, 0x54, 0x41, 0x54, - 0x49, 0x4f, 0x4e, 0x5f, 0x41, 0x55, 0x54, 0x4f, 0x10, 0x01, 0x12, 0x20, 0x0a, 0x1c, 0x41, 0x43, - 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x49, 0x4d, 0x50, 0x4c, 0x45, 0x4d, 0x45, 0x4e, 0x54, 0x41, 0x54, - 0x49, 0x4f, 0x4e, 0x5f, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x02, 0x12, 0x21, 0x0a, 0x1d, - 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x49, 0x4d, 0x50, 0x4c, 0x45, 0x4d, 0x45, 0x4e, 0x54, - 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x52, 0x55, 0x4e, 0x54, 0x49, 0x4d, 0x45, 0x10, 0x03, 0x2a, - 0xc5, 0x01, 0x0a, 0x0a, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, - 0x0a, 0x13, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, - 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x16, 0x0a, 0x12, 0x41, 0x43, 0x54, 0x49, 0x4f, - 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x01, 0x12, - 0x13, 0x0a, 0x0f, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x47, - 0x45, 0x54, 0x10, 0x02, 0x12, 0x14, 0x0a, 0x10, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, - 0x59, 0x50, 0x45, 0x5f, 0x4c, 0x49, 0x53, 0x54, 0x10, 0x03, 0x12, 0x16, 0x0a, 0x12, 0x41, 0x43, - 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, - 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, - 0x45, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x05, 0x12, 0x14, 0x0a, 0x10, 0x41, 0x43, - 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x41, 0x44, 0x10, 0x06, - 0x12, 0x15, 0x0a, 0x11, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, - 0x57, 0x52, 0x49, 0x54, 0x45, 0x10, 0x07, 0x2a, 0xc1, 0x03, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, - 0x12, 0x10, 0x0a, 0x0c, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, - 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x53, 0x54, 0x52, 0x49, 0x4e, - 0x47, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x42, 0x4f, 0x4f, 0x4c, - 0x10, 0x02, 0x12, 0x0c, 0x0a, 0x08, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x49, 0x4e, 0x54, 0x10, 0x03, - 0x12, 0x12, 0x0a, 0x0e, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x54, 0x49, 0x4d, 0x45, 0x53, 0x54, 0x41, - 0x4d, 0x50, 0x10, 0x04, 0x12, 0x0d, 0x0a, 0x09, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x44, 0x41, 0x54, - 0x45, 0x10, 0x05, 0x12, 0x0b, 0x0a, 0x07, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x49, 0x44, 0x10, 0x06, - 0x12, 0x0e, 0x0a, 0x0a, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x4c, 0x10, 0x07, - 0x12, 0x11, 0x0a, 0x0d, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x55, 0x52, 0x52, 0x45, 0x4e, 0x43, - 0x59, 0x10, 0x08, 0x12, 0x11, 0x0a, 0x0d, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x44, 0x41, 0x54, 0x45, - 0x54, 0x49, 0x4d, 0x45, 0x10, 0x09, 0x12, 0x0d, 0x0a, 0x09, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x45, - 0x4e, 0x55, 0x4d, 0x10, 0x0a, 0x12, 0x0e, 0x0a, 0x0a, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x49, 0x4d, - 0x41, 0x47, 0x45, 0x10, 0x0c, 0x12, 0x0f, 0x0a, 0x0b, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4f, 0x42, - 0x4a, 0x45, 0x43, 0x54, 0x10, 0x0d, 0x12, 0x0f, 0x0a, 0x0b, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x53, - 0x45, 0x43, 0x52, 0x45, 0x54, 0x10, 0x0e, 0x12, 0x11, 0x0a, 0x0d, 0x54, 0x59, 0x50, 0x45, 0x5f, - 0x50, 0x41, 0x53, 0x53, 0x57, 0x4f, 0x52, 0x44, 0x10, 0x0f, 0x12, 0x10, 0x0a, 0x0c, 0x54, 0x59, - 0x50, 0x45, 0x5f, 0x4d, 0x45, 0x53, 0x53, 0x41, 0x47, 0x45, 0x10, 0x10, 0x12, 0x0c, 0x0a, 0x08, - 0x54, 0x59, 0x50, 0x45, 0x5f, 0x41, 0x4e, 0x59, 0x10, 0x11, 0x12, 0x17, 0x0a, 0x13, 0x54, 0x59, - 0x50, 0x45, 0x5f, 0x53, 0x4f, 0x52, 0x54, 0x5f, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, - 0x4e, 0x10, 0x12, 0x12, 0x0e, 0x0a, 0x0a, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x49, 0x4f, - 0x4e, 0x10, 0x13, 0x12, 0x17, 0x0a, 0x13, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x53, 0x54, 0x52, 0x49, - 0x4e, 0x47, 0x5f, 0x4c, 0x49, 0x54, 0x45, 0x52, 0x41, 0x4c, 0x10, 0x14, 0x12, 0x11, 0x0a, 0x0d, - 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x41, 0x52, 0x4b, 0x44, 0x4f, 0x57, 0x4e, 0x10, 0x15, 0x12, - 0x10, 0x0a, 0x0c, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x44, 0x45, 0x43, 0x49, 0x4d, 0x41, 0x4c, 0x10, - 0x16, 0x12, 0x0f, 0x0a, 0x0b, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x56, 0x45, 0x43, 0x54, 0x4f, 0x52, - 0x10, 0x17, 0x12, 0x0d, 0x0a, 0x09, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x46, 0x49, 0x4c, 0x45, 0x10, - 0x18, 0x12, 0x18, 0x0a, 0x14, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4c, 0x41, 0x54, 0x49, - 0x56, 0x45, 0x5f, 0x50, 0x45, 0x52, 0x49, 0x4f, 0x44, 0x10, 0x19, 0x2a, 0x6b, 0x0a, 0x0e, 0x4f, - 0x72, 0x64, 0x65, 0x72, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, - 0x17, 0x4f, 0x52, 0x44, 0x45, 0x52, 0x5f, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, 0x4e, - 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x1d, 0x0a, 0x19, 0x4f, 0x52, - 0x44, 0x45, 0x52, 0x5f, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x41, 0x53, - 0x43, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x1d, 0x0a, 0x19, 0x4f, 0x52, 0x44, - 0x45, 0x52, 0x5f, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x44, 0x45, 0x43, - 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x42, 0x20, 0x5a, 0x1e, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x65, 0x61, 0x6d, 0x6b, 0x65, 0x65, 0x6c, 0x2f, - 0x6b, 0x65, 0x65, 0x6c, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x1d, 0x0a, 0x0a, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x32, + 0x0a, 0x0b, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x41, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x0a, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, + 0x70, 0x65, 0x2a, 0x9e, 0x01, 0x0a, 0x14, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6d, 0x70, + 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x1d, 0x41, + 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x49, 0x4d, 0x50, 0x4c, 0x45, 0x4d, 0x45, 0x4e, 0x54, 0x41, + 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x1e, + 0x0a, 0x1a, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x49, 0x4d, 0x50, 0x4c, 0x45, 0x4d, 0x45, + 0x4e, 0x54, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x41, 0x55, 0x54, 0x4f, 0x10, 0x01, 0x12, 0x20, + 0x0a, 0x1c, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x49, 0x4d, 0x50, 0x4c, 0x45, 0x4d, 0x45, + 0x4e, 0x54, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x02, + 0x12, 0x21, 0x0a, 0x1d, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x49, 0x4d, 0x50, 0x4c, 0x45, + 0x4d, 0x45, 0x4e, 0x54, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x52, 0x55, 0x4e, 0x54, 0x49, 0x4d, + 0x45, 0x10, 0x03, 0x2a, 0xc5, 0x01, 0x0a, 0x0a, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, + 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, + 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x16, 0x0a, 0x12, 0x41, + 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, + 0x45, 0x10, 0x01, 0x12, 0x13, 0x0a, 0x0f, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, + 0x50, 0x45, 0x5f, 0x47, 0x45, 0x54, 0x10, 0x02, 0x12, 0x14, 0x0a, 0x10, 0x41, 0x43, 0x54, 0x49, + 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4c, 0x49, 0x53, 0x54, 0x10, 0x03, 0x12, 0x16, + 0x0a, 0x12, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x50, + 0x44, 0x41, 0x54, 0x45, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, + 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x05, 0x12, 0x14, + 0x0a, 0x10, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, + 0x41, 0x44, 0x10, 0x06, 0x12, 0x15, 0x0a, 0x11, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, + 0x59, 0x50, 0x45, 0x5f, 0x57, 0x52, 0x49, 0x54, 0x45, 0x10, 0x07, 0x2a, 0xc1, 0x03, 0x0a, 0x04, + 0x54, 0x79, 0x70, 0x65, 0x12, 0x10, 0x0a, 0x0c, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x4b, + 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x53, + 0x54, 0x52, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x54, 0x59, 0x50, 0x45, 0x5f, + 0x42, 0x4f, 0x4f, 0x4c, 0x10, 0x02, 0x12, 0x0c, 0x0a, 0x08, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x49, + 0x4e, 0x54, 0x10, 0x03, 0x12, 0x12, 0x0a, 0x0e, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x54, 0x49, 0x4d, + 0x45, 0x53, 0x54, 0x41, 0x4d, 0x50, 0x10, 0x04, 0x12, 0x0d, 0x0a, 0x09, 0x54, 0x59, 0x50, 0x45, + 0x5f, 0x44, 0x41, 0x54, 0x45, 0x10, 0x05, 0x12, 0x0b, 0x0a, 0x07, 0x54, 0x59, 0x50, 0x45, 0x5f, + 0x49, 0x44, 0x10, 0x06, 0x12, 0x0e, 0x0a, 0x0a, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, + 0x45, 0x4c, 0x10, 0x07, 0x12, 0x11, 0x0a, 0x0d, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x55, 0x52, + 0x52, 0x45, 0x4e, 0x43, 0x59, 0x10, 0x08, 0x12, 0x11, 0x0a, 0x0d, 0x54, 0x59, 0x50, 0x45, 0x5f, + 0x44, 0x41, 0x54, 0x45, 0x54, 0x49, 0x4d, 0x45, 0x10, 0x09, 0x12, 0x0d, 0x0a, 0x09, 0x54, 0x59, + 0x50, 0x45, 0x5f, 0x45, 0x4e, 0x55, 0x4d, 0x10, 0x0a, 0x12, 0x0e, 0x0a, 0x0a, 0x54, 0x59, 0x50, + 0x45, 0x5f, 0x49, 0x4d, 0x41, 0x47, 0x45, 0x10, 0x0c, 0x12, 0x0f, 0x0a, 0x0b, 0x54, 0x59, 0x50, + 0x45, 0x5f, 0x4f, 0x42, 0x4a, 0x45, 0x43, 0x54, 0x10, 0x0d, 0x12, 0x0f, 0x0a, 0x0b, 0x54, 0x59, + 0x50, 0x45, 0x5f, 0x53, 0x45, 0x43, 0x52, 0x45, 0x54, 0x10, 0x0e, 0x12, 0x11, 0x0a, 0x0d, 0x54, + 0x59, 0x50, 0x45, 0x5f, 0x50, 0x41, 0x53, 0x53, 0x57, 0x4f, 0x52, 0x44, 0x10, 0x0f, 0x12, 0x10, + 0x0a, 0x0c, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x45, 0x53, 0x53, 0x41, 0x47, 0x45, 0x10, 0x10, + 0x12, 0x0c, 0x0a, 0x08, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x41, 0x4e, 0x59, 0x10, 0x11, 0x12, 0x17, + 0x0a, 0x13, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x53, 0x4f, 0x52, 0x54, 0x5f, 0x44, 0x49, 0x52, 0x45, + 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x12, 0x12, 0x0e, 0x0a, 0x0a, 0x54, 0x59, 0x50, 0x45, 0x5f, + 0x55, 0x4e, 0x49, 0x4f, 0x4e, 0x10, 0x13, 0x12, 0x17, 0x0a, 0x13, 0x54, 0x59, 0x50, 0x45, 0x5f, + 0x53, 0x54, 0x52, 0x49, 0x4e, 0x47, 0x5f, 0x4c, 0x49, 0x54, 0x45, 0x52, 0x41, 0x4c, 0x10, 0x14, + 0x12, 0x11, 0x0a, 0x0d, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x41, 0x52, 0x4b, 0x44, 0x4f, 0x57, + 0x4e, 0x10, 0x15, 0x12, 0x10, 0x0a, 0x0c, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x44, 0x45, 0x43, 0x49, + 0x4d, 0x41, 0x4c, 0x10, 0x16, 0x12, 0x0f, 0x0a, 0x0b, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x56, 0x45, + 0x43, 0x54, 0x4f, 0x52, 0x10, 0x17, 0x12, 0x0d, 0x0a, 0x09, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x46, + 0x49, 0x4c, 0x45, 0x10, 0x18, 0x12, 0x18, 0x0a, 0x14, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, + 0x4c, 0x41, 0x54, 0x49, 0x56, 0x45, 0x5f, 0x50, 0x45, 0x52, 0x49, 0x4f, 0x44, 0x10, 0x19, 0x2a, + 0x6b, 0x0a, 0x0e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x1b, 0x0a, 0x17, 0x4f, 0x52, 0x44, 0x45, 0x52, 0x5f, 0x44, 0x49, 0x52, 0x45, 0x43, + 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x1d, + 0x0a, 0x19, 0x4f, 0x52, 0x44, 0x45, 0x52, 0x5f, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, + 0x4e, 0x5f, 0x41, 0x53, 0x43, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x1d, 0x0a, + 0x19, 0x4f, 0x52, 0x44, 0x45, 0x52, 0x5f, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, 0x4e, + 0x5f, 0x44, 0x45, 0x43, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x42, 0x20, 0x5a, 0x1e, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x65, 0x61, 0x6d, 0x6b, + 0x65, 0x65, 0x6c, 0x2f, 0x6b, 0x65, 0x65, 0x6c, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -2455,39 +2469,40 @@ var file_proto_schema_proto_depIdxs = []int32{ 8, // 15: proto.Field.default_value:type_name -> proto.DefaultValue 7, // 16: proto.Field.foreign_key_info:type_name -> proto.ForeignKeyInfo 28, // 17: proto.Field.inverse_field_name:type_name -> google.protobuf.StringValue - 13, // 18: proto.DefaultValue.expression:type_name -> proto.Expression - 1, // 19: proto.Action.type:type_name -> proto.ActionType - 0, // 20: proto.Action.implementation:type_name -> proto.ActionImplementation - 11, // 21: proto.Action.permissions:type_name -> proto.PermissionRule - 13, // 22: proto.Action.set_expressions:type_name -> proto.Expression - 13, // 23: proto.Action.where_expressions:type_name -> proto.Expression - 13, // 24: proto.Action.validation_expressions:type_name -> proto.Expression - 12, // 25: proto.Action.order_by:type_name -> proto.OrderByStatement - 28, // 26: proto.PermissionRule.action_name:type_name -> google.protobuf.StringValue - 13, // 27: proto.PermissionRule.expression:type_name -> proto.Expression - 1, // 28: proto.PermissionRule.action_types:type_name -> proto.ActionType - 3, // 29: proto.OrderByStatement.direction:type_name -> proto.OrderDirection - 15, // 30: proto.Api.api_models:type_name -> proto.ApiModel - 16, // 31: proto.ApiModel.model_actions:type_name -> proto.ApiModelAction - 18, // 32: proto.Enum.values:type_name -> proto.EnumValue - 20, // 33: proto.Message.fields:type_name -> proto.MessageField - 21, // 34: proto.Message.type:type_name -> proto.TypeInfo - 21, // 35: proto.MessageField.type:type_name -> proto.TypeInfo - 2, // 36: proto.TypeInfo.type:type_name -> proto.Type - 28, // 37: proto.TypeInfo.enum_name:type_name -> google.protobuf.StringValue - 28, // 38: proto.TypeInfo.model_name:type_name -> google.protobuf.StringValue - 28, // 39: proto.TypeInfo.field_name:type_name -> google.protobuf.StringValue - 28, // 40: proto.TypeInfo.message_name:type_name -> google.protobuf.StringValue - 28, // 41: proto.TypeInfo.union_names:type_name -> google.protobuf.StringValue - 28, // 42: proto.TypeInfo.string_literal_value:type_name -> google.protobuf.StringValue - 11, // 43: proto.Job.permissions:type_name -> proto.PermissionRule - 25, // 44: proto.Job.schedule:type_name -> proto.Schedule - 1, // 45: proto.Event.action_type:type_name -> proto.ActionType - 46, // [46:46] is the sub-list for method output_type - 46, // [46:46] is the sub-list for method input_type - 46, // [46:46] is the sub-list for extension type_name - 46, // [46:46] is the sub-list for extension extendee - 0, // [0:46] is the sub-list for field type_name + 13, // 18: proto.Field.computed_expression:type_name -> proto.Expression + 13, // 19: proto.DefaultValue.expression:type_name -> proto.Expression + 1, // 20: proto.Action.type:type_name -> proto.ActionType + 0, // 21: proto.Action.implementation:type_name -> proto.ActionImplementation + 11, // 22: proto.Action.permissions:type_name -> proto.PermissionRule + 13, // 23: proto.Action.set_expressions:type_name -> proto.Expression + 13, // 24: proto.Action.where_expressions:type_name -> proto.Expression + 13, // 25: proto.Action.validation_expressions:type_name -> proto.Expression + 12, // 26: proto.Action.order_by:type_name -> proto.OrderByStatement + 28, // 27: proto.PermissionRule.action_name:type_name -> google.protobuf.StringValue + 13, // 28: proto.PermissionRule.expression:type_name -> proto.Expression + 1, // 29: proto.PermissionRule.action_types:type_name -> proto.ActionType + 3, // 30: proto.OrderByStatement.direction:type_name -> proto.OrderDirection + 15, // 31: proto.Api.api_models:type_name -> proto.ApiModel + 16, // 32: proto.ApiModel.model_actions:type_name -> proto.ApiModelAction + 18, // 33: proto.Enum.values:type_name -> proto.EnumValue + 20, // 34: proto.Message.fields:type_name -> proto.MessageField + 21, // 35: proto.Message.type:type_name -> proto.TypeInfo + 21, // 36: proto.MessageField.type:type_name -> proto.TypeInfo + 2, // 37: proto.TypeInfo.type:type_name -> proto.Type + 28, // 38: proto.TypeInfo.enum_name:type_name -> google.protobuf.StringValue + 28, // 39: proto.TypeInfo.model_name:type_name -> google.protobuf.StringValue + 28, // 40: proto.TypeInfo.field_name:type_name -> google.protobuf.StringValue + 28, // 41: proto.TypeInfo.message_name:type_name -> google.protobuf.StringValue + 28, // 42: proto.TypeInfo.union_names:type_name -> google.protobuf.StringValue + 28, // 43: proto.TypeInfo.string_literal_value:type_name -> google.protobuf.StringValue + 11, // 44: proto.Job.permissions:type_name -> proto.PermissionRule + 25, // 45: proto.Job.schedule:type_name -> proto.Schedule + 1, // 46: proto.Event.action_type:type_name -> proto.ActionType + 47, // [47:47] is the sub-list for method output_type + 47, // [47:47] is the sub-list for method input_type + 47, // [47:47] is the sub-list for extension type_name + 47, // [47:47] is the sub-list for extension extendee + 0, // [0:47] is the sub-list for field type_name } func init() { file_proto_schema_proto_init() } diff --git a/proto/schema.proto b/proto/schema.proto index b0c55c67c..22f3fa672 100644 --- a/proto/schema.proto +++ b/proto/schema.proto @@ -78,6 +78,10 @@ message Field { // and then Author has posts which is of type Post, on the Post.author field this // value will be "posts" and on the Author.posts field this value will be "author". google.protobuf.StringValue inverse_field_name = 12; + + // If computed then this field will contain the expression that will be evaluated to + // determine the value of the field at runtime. + Expression computed_expression = 13; } message ForeignKeyInfo { diff --git a/runtime/actions/authorization.go b/runtime/actions/authorization.go index 8640ef80a..b57b20a26 100644 --- a/runtime/actions/authorization.go +++ b/runtime/actions/authorization.go @@ -6,10 +6,11 @@ import ( "fmt" "strings" + "github.com/google/cel-go/cel" "github.com/samber/lo" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/proto" "github.com/teamkeel/keel/runtime/auth" - "github.com/teamkeel/keel/runtime/expressions" "github.com/teamkeel/keel/schema/parser" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -43,7 +44,7 @@ func AuthoriseForActionType(scope *Scope, opType proto.ActionType, rowsToAuthori } // authorise checks authorisation for rows using the slice of permission rules provided. -func authorise(scope *Scope, permissions []*proto.PermissionRule, input map[string]any, rowsToAuthorise []map[string]any) (authorised bool, err error) { +func authorise(scope *Scope, permissions []*proto.PermissionRule, inputs map[string]any, rowsToAuthorise []map[string]any) (authorised bool, err error) { ctx, span := tracer.Start(scope.Context, "Check permissions") defer span.End() @@ -56,8 +57,15 @@ func authorise(scope *Scope, permissions []*proto.PermissionRule, input map[stri return false, nil } - canResolve, authorised, _ := TryResolveAuthorisationEarly(scope, permissions) - if canResolve { + canAuthorise, authorised, err := TryResolveAuthorisationEarly(scope, inputs, permissions) + if err != nil { + span.RecordError(err, trace.WithStackTrace(true)) + span.SetStatus(codes.Error, err.Error()) + return false, err + } + + // If access can be concluded by role permissions alone + if canAuthorise { return authorised, nil } @@ -74,7 +82,7 @@ func authorise(scope *Scope, permissions []*proto.PermissionRule, input map[stri }) // Generate SQL for the permission expressions. - stmt, err := GeneratePermissionStatement(scope, permissions, input, idsToAuthorise) + stmt, err := GeneratePermissionStatement(scope, permissions, inputs, idsToAuthorise) if err != nil { span.RecordError(err, trace.WithStackTrace(true)) span.SetStatus(codes.Error, err.Error()) @@ -102,9 +110,39 @@ func authorise(scope *Scope, permissions []*proto.PermissionRule, input map[stri return authorised, nil } +func TryResolveExpressionEarly(ctx context.Context, schema *proto.Schema, model *proto.Model, action *proto.Action, expression string, inputs map[string]any) (bool, bool) { + env, err := cel.NewEnv() + if err != nil { + return false, false + } + + ast, issues := env.Parse(expression) + if issues != nil && len(issues.Errors()) > 0 { + return false, false + } + + prg, err := env.Program(ast) + if err != nil { + return false, false + } + + out, _, err := prg.Eval(&OperandResolver{ + context: ctx, + schema: schema, + model: model, + action: action, + inputs: inputs, + }) + if err != nil { + return false, false + } + + return true, out.Value().(bool) +} + // TryResolveAuthorisationEarly will attempt to check authorisation early without row-based querying. // This will take into account logical conditions and multiple expression and role permission attributes. -func TryResolveAuthorisationEarly(scope *Scope, permissions []*proto.PermissionRule) (canResolveAll bool, authorised bool, err error) { +func TryResolveAuthorisationEarly(scope *Scope, inputs map[string]any, permissions []*proto.PermissionRule) (canResolveAll bool, authorised bool, err error) { hasDatabaseCheck := false canResolveAll = false for _, permission := range permissions { @@ -112,13 +150,9 @@ func TryResolveAuthorisationEarly(scope *Scope, permissions []*proto.PermissionR authorised := false switch { case permission.Expression != nil: - expression, err := parser.ParseExpression(permission.Expression.Source) - if err != nil { - return false, false, err - } // Try resolve the permission early. - canResolve, authorised = expressions.TryResolveExpressionEarly(scope.Context, scope.Schema, scope.Model, scope.Action, expression, map[string]any{}) + canResolve, authorised = TryResolveExpressionEarly(scope.Context, scope.Schema, scope.Model, scope.Action, permission.Expression.Source, inputs) if !canResolve { hasDatabaseCheck = true @@ -206,10 +240,11 @@ func GeneratePermissionStatement(scope *Scope, permissions []*proto.PermissionRu return nil, err } - err = query.whereByExpression(scope, expression, map[string]any{}) + _, err = resolve.RunCelVisitor(expression, GenerateFilterQuery(scope.Context, query, scope.Schema, scope.Model, scope.Action, input)) if err != nil { return nil, err } + // Or with the next permission attribute query.Or() } diff --git a/runtime/actions/authorization_test.go b/runtime/actions/authorization_test.go index 183177c5b..2da6affb3 100644 --- a/runtime/actions/authorization_test.go +++ b/runtime/actions/authorization_test.go @@ -245,7 +245,7 @@ var authorisationTestCases = []authorisationTestCase{ } actions { list listThings() { - @permission(expression: thing.isActive == true and thing.createdBy == ctx.identity) + @permission(expression: thing.isActive == true && thing.createdBy == ctx.identity) } } }`, @@ -256,7 +256,7 @@ var authorisationTestCases = []authorisationTestCase{ FROM "thing" WHERE - ( ( "thing"."is_active" IS NOT DISTINCT FROM ? AND "thing"."created_by_id" IS NOT DISTINCT FROM ? ) ) + ("thing"."is_active" IS NOT DISTINCT FROM ? AND "thing"."created_by_id" IS NOT DISTINCT FROM ?) AND "thing"."id" = ANY(ARRAY[?]::TEXT[])`, expectedArgs: []any{true, unverifiedIdentity[parser.FieldNameId].(string), "idToAuthorise"}, earlyAuth: CouldNotAuthoriseEarly(), @@ -272,7 +272,7 @@ var authorisationTestCases = []authorisationTestCase{ } actions { list listThings() { - @permission(expression: thing.isActive == true or thing.createdBy == ctx.identity) + @permission(expression: thing.isActive == true || thing.createdBy == ctx.identity) } } }`, @@ -335,7 +335,7 @@ var authorisationTestCases = []authorisationTestCase{ } actions { list listThings() { - @permission(expression: thing.isActive == true and thing.createdBy == ctx.identity) + @permission(expression: thing.isActive == true && thing.createdBy == ctx.identity) @permission(expression: thing.createdBy == thing.related.createdBy) } } @@ -349,7 +349,7 @@ var authorisationTestCases = []authorisationTestCase{ LEFT JOIN "related" AS "thing$related" ON "thing$related"."id" = "thing"."related_id" WHERE - ( ( "thing"."is_active" IS NOT DISTINCT FROM ? AND "thing"."created_by_id" IS NOT DISTINCT FROM ? ) OR "thing"."created_by_id" IS NOT DISTINCT FROM "thing$related"."created_by_id" ) + ( "thing"."is_active" IS NOT DISTINCT FROM ? AND "thing"."created_by_id" IS NOT DISTINCT FROM ? OR "thing"."created_by_id" IS NOT DISTINCT FROM "thing$related"."created_by_id" ) AND "thing"."id" = ANY(ARRAY[?]::TEXT[])`, expectedArgs: []any{true, unverifiedIdentity[parser.FieldNameId].(string), "idToAuthorise"}, earlyAuth: CouldNotAuthoriseEarly(), @@ -466,7 +466,7 @@ var authorisationTestCases = []authorisationTestCase{ } actions { get getThing(id) { - @permission(expression: ctx.isAuthenticated and thing.createdBy.id == ctx.identity.id) + @permission(expression: ctx.isAuthenticated && thing.createdBy.id == ctx.identity.id) } } }`, @@ -481,7 +481,7 @@ var authorisationTestCases = []authorisationTestCase{ LEFT JOIN "identity" AS "thing$created_by" ON "thing$created_by"."id" = "thing"."created_by_id" WHERE - ( ( ? IS NOT DISTINCT FROM ? AND "thing$created_by"."id" IS NOT DISTINCT FROM ? ) ) + (? IS NOT DISTINCT FROM ? AND "thing$created_by"."id" IS NOT DISTINCT FROM ?) AND "thing"."id" = ANY(ARRAY[?]::TEXT[])`, expectedArgs: []any{true, true, unverifiedIdentity[parser.FieldNameId].(string), "idToAuthorise"}, identity: unverifiedIdentity, @@ -495,7 +495,7 @@ var authorisationTestCases = []authorisationTestCase{ } actions { get getThing(id) { - @permission(expression: ctx.isAuthenticated or thing.createdBy.id == ctx.identity.id) + @permission(expression: ctx.isAuthenticated || thing.createdBy.id == ctx.identity.id) } } }`, @@ -504,7 +504,7 @@ var authorisationTestCases = []authorisationTestCase{ identity: unverifiedIdentity, }, { - name: "early_evaluate_multiple_attributes_with_database", + name: "early_evaluate_multiple_attributes_with_database_multiple_attributes", keelSchema: ` model Thing { fields { @@ -542,7 +542,7 @@ var authorisationTestCases = []authorisationTestCase{ model Thing { actions { get getThing(id) { - @permission(expression: ctx.isAuthenticated and ctx.isAuthenticated) + @permission(expression: ctx.isAuthenticated && ctx.isAuthenticated) } } }`, @@ -556,7 +556,7 @@ var authorisationTestCases = []authorisationTestCase{ model Thing { actions { get getThing(id) { - @permission(expression: ctx.isAuthenticated or ctx.isAuthenticated) + @permission(expression: ctx.isAuthenticated || ctx.isAuthenticated) } } }`, @@ -570,7 +570,7 @@ var authorisationTestCases = []authorisationTestCase{ model Thing { actions { get getThing(id) { - @permission(expression: ctx.isAuthenticated and false) + @permission(expression: ctx.isAuthenticated && false) } } }`, @@ -578,6 +578,36 @@ var authorisationTestCases = []authorisationTestCase{ earlyAuth: AuthorisationDeniedEarly(), identity: unverifiedIdentity, }, + { + name: "early_evaluate_inputs_authorised", + keelSchema: ` + model Thing { + actions { + get getThing(id, bool: Boolean) { + @permission(expression: bool == true) + } + } + }`, + actionName: "getThing", + input: map[string]any{"id": "123", "bool": true}, + earlyAuth: AuthorisationGrantedEarly(), + identity: unverifiedIdentity, + }, + { + name: "early_evaluate_inputs_not_authorised", + keelSchema: ` + model Thing { + actions { + get getThing(id, bool: Boolean) { + @permission(expression: bool == true) + } + } + }`, + actionName: "getThing", + input: map[string]any{"id": "123", "bool": false}, + earlyAuth: AuthorisationDeniedEarly(), + identity: unverifiedIdentity, + }, { name: "early_evaluate_roles_domain_authorised", keelSchema: ` @@ -1437,7 +1467,7 @@ func TestPermissionQueryBuilder(t *testing.T) { permissions := proto.PermissionsForAction(scope.Schema, scope.Action) - canResolveEarly, authorised, err := actions.TryResolveAuthorisationEarly(scope, permissions) + canResolveEarly, authorised, err := actions.TryResolveAuthorisationEarly(scope, testCase.input, permissions) if err != nil { require.NoError(t, err) } @@ -1452,7 +1482,7 @@ func TestPermissionQueryBuilder(t *testing.T) { } } } else { - require.Nil(t, testCase.earlyAuth, "earlyAuth should be CouldNotAuthoriseEarly() because authorised could not be determined given early.") + require.Nil(t, testCase.earlyAuth, "earlyAuth should be CouldNotAuthoriseEarly() because authorised could not be determined early.") } if !canResolveEarly { diff --git a/runtime/actions/create.go b/runtime/actions/create.go index 31af84337..0f8219a94 100644 --- a/runtime/actions/create.go +++ b/runtime/actions/create.go @@ -19,7 +19,7 @@ func Create(scope *Scope, input map[string]any) (res map[string]any, err error) permissions := proto.PermissionsForAction(scope.Schema, scope.Action) // Attempt to resolve permissions early; i.e. before row-based database querying. - canResolveEarly, authorised, err := TryResolveAuthorisationEarly(scope, permissions) + canResolveEarly, authorised, err := TryResolveAuthorisationEarly(scope, input, permissions) if err != nil { return nil, err } diff --git a/runtime/actions/delete.go b/runtime/actions/delete.go index 4a16d19c5..1691fade7 100644 --- a/runtime/actions/delete.go +++ b/runtime/actions/delete.go @@ -11,7 +11,7 @@ import ( func Delete(scope *Scope, input map[string]any) (res *string, err error) { // Attempt to resolve permissions early; i.e. before row-based database querying. permissions := proto.PermissionsForAction(scope.Schema, scope.Action) - canResolveEarly, authorised, err := TryResolveAuthorisationEarly(scope, permissions) + canResolveEarly, authorised, err := TryResolveAuthorisationEarly(scope, input, permissions) if err != nil { return nil, err } @@ -29,13 +29,12 @@ func Delete(scope *Scope, input map[string]any) (res *string, err error) { } var row map[string]any - switch { case canResolveEarly && !authorised: return nil, common.NewPermissionError() case !canResolveEarly: authQuery := NewQuery(scope.Model) - err := authQuery.applyImplicitFilters(scope, input) + err := authQuery.ApplyImplicitFilters(scope, input) if err != nil { return nil, err } @@ -85,7 +84,7 @@ func Delete(scope *Scope, input map[string]any) (res *string, err error) { } func GenerateDeleteStatement(query *QueryBuilder, scope *Scope, input map[string]any) (*Statement, error) { - err := query.applyImplicitFilters(scope, input) + err := query.ApplyImplicitFilters(scope, input) if err != nil { return nil, err } diff --git a/runtime/actions/evaluation.go b/runtime/actions/evaluation.go new file mode 100644 index 000000000..17470227e --- /dev/null +++ b/runtime/actions/evaluation.go @@ -0,0 +1,43 @@ +package actions + +import ( + "context" + "strings" + + "github.com/google/cel-go/interpreter" + "github.com/teamkeel/keel/proto" +) + +// OperandResolver is used to resolve expressions without database access if possible +// i.e. early evaluation +type OperandResolver struct { + context context.Context + schema *proto.Schema + model *proto.Model + action *proto.Action + inputs map[string]any +} + +func (a *OperandResolver) ResolveName(name string) (any, bool) { + fragments := strings.Split(name, ".") + + fragments, err := normalisedFragments(a.schema, fragments) + if err != nil { + return nil, false + } + + operand, err := generateOperand(a.context, a.schema, a.model, a.action, a.inputs, fragments) + if err != nil { + return nil, false + } + + if !operand.IsValue() { + return nil, false + } + + return operand.value, true +} + +func (a *OperandResolver) Parent() interpreter.Activation { + return nil +} diff --git a/runtime/actions/expression.go b/runtime/actions/expression.go index c7c3c7c3a..04e37bfeb 100644 --- a/runtime/actions/expression.go +++ b/runtime/actions/expression.go @@ -1,203 +1,47 @@ package actions import ( + "context" "fmt" + "net/textproto" + "os" + "strings" "github.com/iancoleman/strcase" "github.com/teamkeel/keel/casing" "github.com/teamkeel/keel/proto" "github.com/teamkeel/keel/runtime/auth" "github.com/teamkeel/keel/runtime/expressions" + "github.com/teamkeel/keel/runtime/runtimectx" "github.com/teamkeel/keel/schema/parser" ) -// Include a filter (where condition) on the query based on an implicit input filter. -func (query *QueryBuilder) whereByImplicitFilter(scope *Scope, targetField []string, operator ActionOperator, value any) error { - // Implicit inputs don't include the base model as the first fragment (unlike expressions), so we include it - fragments := append([]string{casing.ToLowerCamel(scope.Action.ModelName)}, targetField...) - - // The lhs QueryOperand is determined from the fragments in the implicit input field - left, err := operandFromFragments(scope.Schema, fragments) - if err != nil { - return err - } - - // The rhs QueryOperand is always a value in an implicit input - right := Value(value) - - // Add join for the implicit input - err = query.addJoinFromFragments(scope, fragments) - if err != nil { - return err - } - - // Add where condition to the query for the implicit input - err = query.Where(left, operator, right) - if err != nil { - return err - } - - return nil -} - -// Include a filter (where condition) on the query based on an expression. -func (query *QueryBuilder) whereByExpression(scope *Scope, expression *parser.Expression, args map[string]any) error { - // Only use parenthesis if there are multiple conditions - useParenthesis := len(expression.Or) > 1 - for _, or := range expression.Or { - if len(or.And) > 1 { - useParenthesis = true - break - } - } - - if useParenthesis { - query.OpenParenthesis() - } - - for _, or := range expression.Or { - for _, and := range or.And { - if and.Expression != nil { - err := query.whereByExpression(scope, and.Expression, args) - if err != nil { - return err - } - } - - if and.Condition != nil { - err := query.whereByCondition(scope, and.Condition, args) - if err != nil { - return err - } - } - query.And() - } - query.Or() - } - - if useParenthesis { - query.CloseParenthesis() - } - - return nil -} - -// Include a filter (where condition) on the query based on a single condition. -func (query *QueryBuilder) whereByCondition(scope *Scope, condition *parser.Condition, args map[string]any) error { - if condition.Type() != parser.ValueCondition && condition.Type() != parser.LogicalCondition { - return fmt.Errorf("can only handle condition type of LogicalCondition or ValueCondition, have: %s", condition.Type()) - } - - lhsResolver := expressions.NewOperandResolver(scope.Context, scope.Schema, scope.Model, scope.Action, condition.LHS) - rhsResolver := expressions.NewOperandResolver(scope.Context, scope.Schema, scope.Model, scope.Action, condition.RHS) - - var operator ActionOperator - var left, right *QueryOperand - - // Generate lhs QueryOperand - left, err := generateQueryOperand(lhsResolver, args) - if err != nil { - return err - } - - if lhsResolver.IsModelDbColumn() { - lhsFragments, err := lhsResolver.NormalisedFragments() - if err != nil { - return err - } - - // Generates joins based on the fragments that make up the operand - err = query.addJoinFromFragments(scope, lhsFragments) - if err != nil { - return err - } - } - - if condition.Type() == parser.ValueCondition { - lhsOperandType, _, err := lhsResolver.GetOperandType() - if err != nil { - return err - } - - if lhsOperandType != proto.Type_TYPE_BOOL { - return fmt.Errorf("single operands in a value condition must be of type boolean") - } - - // A value condition only has one operand in the expression, - // for example, permission(expression: ctx.isAuthenticated), - // so we must set the operator and RHS value (== true) ourselves. - operator = Equals - right = Value(true) - } else { - // The operator used in the expression - operator, err = expressionOperatorToActionOperator(condition.Operator.ToString()) - if err != nil { - return err - } - - _, isArray, err := rhsResolver.GetOperandType() - if err != nil { - return err - } - - // Generate the rhs QueryOperand - right, err = generateQueryOperand(rhsResolver, args) - if err != nil { - return err - } - - // If the operand is not an array field nor an inline query, - // then we know it's a nested relationship lookup and - // then rather use Equals and NotEquals because we are joining. - if !isArray && !right.IsInlineQuery() { - if operator == OneOf { - operator = Equals - } - if operator == NotOneOf { - operator = NotEquals - } - } - - if rhsResolver.IsModelDbColumn() { - rhsFragments, err := rhsResolver.NormalisedFragments() - if err != nil { - return err - } - - // Generates joins based on the fragments that make up the operand - err = query.addJoinFromFragments(scope, rhsFragments) - if err != nil { - return err - } - } +// Constructs and adds an LEFT JOIN from a splice of fragments (representing an operand in an expression or implicit input). +// The fragment slice must include the base model as the first item, for example: "post." in post.author.publisher.isActive +func (query *QueryBuilder) AddJoinFromFragments(schema *proto.Schema, fragments []string) error { + if fragments[0] == "ctx" { + return nil } - // Adds where condition to the query for the expression - err = query.Where(left, operator, right) + fragments, err := normalisedFragments(schema, fragments) if err != nil { return err } - return nil -} - -// Constructs and adds an LEFT JOIN from a splice of fragments (representing an operand in an expression or implicit input). -// The fragment slice must include the base model as the first item, for example: "post." in post.author.publisher.isActive -func (query *QueryBuilder) addJoinFromFragments(scope *Scope, fragments []string) error { model := casing.ToCamel(fragments[0]) fragmentCount := len(fragments) for i := 1; i < fragmentCount-1; i++ { currentFragment := fragments[i] - if !proto.ModelHasField(scope.Schema, model, currentFragment) { + if !proto.ModelHasField(schema, model, currentFragment) { return fmt.Errorf("this model: %s, does not have a field of name: %s", model, currentFragment) } // We know that the current fragment is a related model because it's not the last fragment - relatedModelField := proto.FindField(scope.Schema.Models, model, currentFragment) + relatedModelField := proto.FindField(schema.Models, model, currentFragment) relatedModel := relatedModelField.Type.ModelName.Value - foreignKeyField := proto.GetForeignKeyFieldName(scope.Schema.Models, relatedModelField) + foreignKeyField := proto.GetForeignKeyFieldName(schema.Models, relatedModelField) primaryKey := "id" var leftOperand *QueryOperand @@ -206,12 +50,12 @@ func (query *QueryBuilder) addJoinFromFragments(scope *Scope, fragments []string switch { case relatedModelField.IsBelongsTo(): // In a "belongs to" the foreign key is on _this_ model - leftOperand = ExpressionField(fragments[:i+1], primaryKey) - rightOperand = ExpressionField(fragments[:i], foreignKeyField) + leftOperand = ExpressionField(fragments[:i+1], primaryKey, false) + rightOperand = ExpressionField(fragments[:i], foreignKeyField, false) default: // In all others the foreign key is on the _other_ model - leftOperand = ExpressionField(fragments[:i+1], foreignKeyField) - rightOperand = ExpressionField(fragments[:i], primaryKey) + leftOperand = ExpressionField(fragments[:i+1], foreignKeyField, false) + rightOperand = ExpressionField(fragments[:i], primaryKey, false) } query.Join(relatedModel, leftOperand, rightOperand) @@ -222,65 +66,49 @@ func (query *QueryBuilder) addJoinFromFragments(scope *Scope, fragments []string return nil } -// Constructs a QueryOperand from a splice of fragments, representing an expression operand or implicit input. -// The fragment slice must include the base model as the first fragment, for example: post.author.publisher.isActive -func operandFromFragments(schema *proto.Schema, fragments []string) (*QueryOperand, error) { - var field string - model := casing.ToCamel(fragments[0]) - fragmentCount := len(fragments) - - for i := 1; i < fragmentCount; i++ { - currentFragment := fragments[i] - - if !proto.ModelHasField(schema, model, currentFragment) { - return nil, fmt.Errorf("this model: %s, does not have a field of name: %s", model, currentFragment) - } - - if i < fragmentCount-1 { - // We know that the current fragment is a model because it's not the last fragment - relatedModelField := proto.FindField(schema.Models, model, currentFragment) - model = relatedModelField.Type.ModelName.Value - } else { - // The last fragment is referencing the field - field = currentFragment - } +func generateOperand(ctx context.Context, schema *proto.Schema, model *proto.Model, action *proto.Action, inputs map[string]any, fragments []string) (*QueryOperand, error) { + ident, err := normalisedFragments(schema, fragments) + if err != nil { + return nil, err } - return ExpressionField(fragments[:len(fragments)-1], field), nil -} - -// Generates a database QueryOperand, either representing a field, inline query, a value or null. -func generateQueryOperand(resolver *expressions.OperandResolver, args map[string]any) (*QueryOperand, error) { var queryOperand *QueryOperand - switch { - case resolver.IsContextDbColumn(): - // If this is a value from ctx that requires a database read (such as with identity backlinks), - // then construct an inline query for this operand. This is necessary because we can't retrieve this value - // from the current query builder. - - fragments, err := resolver.NormalisedFragments() + case len(ident) == 2 && proto.EnumExists(schema.Enums, ident[0]): + return Value(ident[1]), nil + case model != nil && expressions.IsModelDbColumn(model, ident): + var err error + queryOperand, err = operandFromFragments(schema, ident) if err != nil { return nil, err } + case action != nil && expressions.IsInput(schema, action, ident): + value, ok := inputs[ident[0]] + if !ok { + return nil, fmt.Errorf("implicit or explicit input '%s' does not exist in arguments", ident[0]) + } + return Value(value), nil + case expressions.IsContextDbColumn(ident): + // If this is a value from ctx that requires a database read (such as with identity backlinks), + // then construct an inline query for this operand. This is necessary because we can't retrieve this value + // from the current query builder. // Remove the ctx fragment - fragments = fragments[1:] + ident = ident[1:] - identityModel := resolver.Schema.FindModel(strcase.ToCamel(fragments[0])) - ctxScope := NewModelScope(resolver.Context, identityModel, resolver.Schema) - query := NewQuery(identityModel) + identityModel := schema.FindModel(strcase.ToCamel(ident[0])) identityId := "" - if auth.IsAuthenticated(resolver.Context) { - identity, err := auth.GetIdentity(resolver.Context) + if auth.IsAuthenticated(ctx) { + identity, err := auth.GetIdentity(ctx) if err != nil { return nil, err } identityId = identity[parser.FieldNameId].(string) } - err = query.addJoinFromFragments(ctxScope, fragments) + query := NewQuery(identityModel) + err := query.AddJoinFromFragments(schema, ident) if err != nil { return nil, err } @@ -290,7 +118,10 @@ func generateQueryOperand(resolver *expressions.OperandResolver, args map[string return nil, err } - selectField := ExpressionField(fragments[:len(fragments)-1], fragments[len(fragments)-1]) + selectField, err := operandFromFragments(schema, ident) + if err != nil { + return nil, err + } // If there are no matches in the subquery then null will be returned, but null // will cause IN and NOT IN filtering of this subquery result to always evaluate as false. @@ -301,14 +132,7 @@ func generateQueryOperand(resolver *expressions.OperandResolver, args map[string return nil, err } - currModel := identityModel - for i := 1; i < len(fragments)-1; i++ { - name := proto.FindField(resolver.Schema.Models, currModel.Name, fragments[i]).Type.ModelName.Value - currModel = resolver.Schema.FindModel(name) - } - currField := proto.FindField(resolver.Schema.Models, currModel.Name, fragments[len(fragments)-1]) - - if currField.Type.Repeated { + if selectField.IsArrayField() { query.SelectUnnested(selectField) } else { query.Select(selectField) @@ -316,33 +140,169 @@ func generateQueryOperand(resolver *expressions.OperandResolver, args map[string queryOperand = InlineQuery(query, selectField) - case resolver.IsModelDbColumn(): - // If this is a model field then generate the appropriate column operand for the database query. - - fragments, err := resolver.NormalisedFragments() + case expressions.IsContextIdentityId(ident): + isAuthenticated := auth.IsAuthenticated(ctx) + if !isAuthenticated { + queryOperand = Null() + } else { + identity, err := auth.GetIdentity(ctx) + if err != nil { + return nil, err + } + queryOperand = Value(identity[parser.FieldNameId].(string)) + } + case expressions.IsContextIsAuthenticatedField(ident): + isAuthenticated := auth.IsAuthenticated(ctx) + queryOperand = Value(isAuthenticated) + case expressions.IsContextNowField(ident): + queryOperand = Value(runtimectx.GetNow()) + case expressions.IsContextEnvField(ident): + envVarName := ident[2] + queryOperand = Value(os.Getenv(envVarName)) + case expressions.IsContextSecretField(ident): + secret, err := runtimectx.GetSecret(ctx, ident[2]) if err != nil { return nil, err } + queryOperand = Value(secret) + case expressions.IsContextHeadersField(ident): + headerName := ident[2] - // Generate QueryOperand from the fragments that make up the expression operand - queryOperand, err = operandFromFragments(resolver.Schema, fragments) + // First we parse the header name to kebab. MyCustomHeader will become my-custom-header. + kebab := strcase.ToKebab(headerName) + + // Then get canonical name. my-custom-header will become My-Custom-Header. + // https://pkg.go.dev/net/http#Header.Get + canonicalName := textproto.CanonicalMIMEHeaderKey(kebab) + + headers, err := runtimectx.GetRequestHeaders(ctx) if err != nil { return nil, err } + if value, ok := headers[canonicalName]; ok { + queryOperand = Value(strings.Join(value, ", ")) + } else { + queryOperand = Value("") + } default: - // For all others operands, we know we can resolve their value without the datebase. + return nil, fmt.Errorf("cannot handle fragments: %s", strings.Join(ident, ".")) + } - value, err := resolver.ResolveValue(args) - if err != nil { - return nil, err + return queryOperand, nil +} + +func normalisedFragments(schema *proto.Schema, fragments []string) ([]string, error) { + isModelField := false + isCtx := fragments[0] == "ctx" + + if isCtx { + // We dont bother normalising ctx.isAuthenticated, ctx.secrets, etc. + if fragments[1] != "identity" { + return fragments, nil } - if value == nil { - queryOperand = Null() + // If this is a context backlink, then remove the first "ctx" fragment. + fragments = fragments[1:] + } + + // The first fragment will always be the root model name, e.g. "author" in author.posts.title + modelTarget := schema.FindModel(casing.ToCamel(fragments[0])) + if modelTarget == nil { + // If it's not the model, then it could be an input + return fragments, nil + //return nil, fmt.Errorf("model '%s' does not exist in schema", casing.ToCamel(fragments[0])) + } + + var fieldTarget *proto.Field + for i := 1; i < len(fragments); i++ { + fieldTarget = proto.FindField(schema.Models, modelTarget.Name, fragments[i]) + if fieldTarget.Type.Type == proto.Type_TYPE_MODEL { + modelTarget = schema.FindModel(fieldTarget.Type.ModelName.Value) + if modelTarget == nil { + return nil, fmt.Errorf("model '%s' does not exist in schema", fieldTarget.Type.ModelName.Value) + } + } + } + + // If no field is provided, for example: @where(account in ...) + // Or if the target field is a MODEL, for example: + if fieldTarget == nil || fieldTarget.Type.Type == proto.Type_TYPE_MODEL { + isModelField = true + } + + if isModelField && len(fragments) == 1 { + // One fragment is only possible if the expression is only referencing the model. + // For example, @where(account in ...) + // Add a new fragment 'id' + fragments = append(fragments, parser.FieldNameId) + } else if isModelField { + i := 0 + if fragments[0] == "ctx" { + i++ + } + + modelTarget := schema.FindModel(casing.ToCamel(fragments[i])) + if modelTarget == nil { + return nil, fmt.Errorf("model '%s' does not exist in schema", casing.ToCamel(fragments[i])) + } + + var fieldTarget *proto.Field + for i := i + 1; i < len(fragments); i++ { + fieldTarget = proto.FindField(schema.Models, modelTarget.Name, fragments[i]) + if fieldTarget.Type.Type == proto.Type_TYPE_MODEL { + modelTarget = schema.FindModel(fieldTarget.Type.ModelName.Value) + if modelTarget == nil { + return nil, fmt.Errorf("model '%s' does not exist in schema", fieldTarget.Type.ModelName.Value) + } + } + } + + if fieldTarget.IsHasOne() || fieldTarget.IsHasMany() { + // Add a new fragment 'id' + fragments = append(fragments, parser.FieldNameId) } else { - queryOperand = Value(value) + // Replace the last fragment with the foreign key field + fragments[len(fragments)-1] = fmt.Sprintf("%sId", fragments[len(fragments)-1]) } } - return queryOperand, nil + if isCtx { + fragments = append([]string{"ctx"}, fragments...) + } + + return fragments, nil +} + +// Constructs a QueryOperand from a splice of fragments, representing an expression operand or implicit input. +// The fragment slice must include the base model as the first fragment, for example: post.author.publisher.isActive +func operandFromFragments(schema *proto.Schema, fragments []string) (*QueryOperand, error) { + fragments, err := normalisedFragments(schema, fragments) + if err != nil { + return nil, err + } + + var field string + model := casing.ToCamel(fragments[0]) + fragmentCount := len(fragments) + isArray := false + + for i := 1; i < fragmentCount; i++ { + currentFragment := fragments[i] + + if !proto.ModelHasField(schema, model, currentFragment) { + return nil, fmt.Errorf("this model: %s, does not have a field of name: %s", model, currentFragment) + } + + if i < fragmentCount-1 { + // We know that the current fragment is a model because it's not the last fragment + relatedModelField := proto.FindField(schema.Models, model, currentFragment) + model = relatedModelField.Type.ModelName.Value + } else { + // The last fragment is referencing the field + field = currentFragment + isArray = proto.FindField(schema.Models, model, currentFragment).Type.Repeated + } + } + + return ExpressionField(fragments[:len(fragments)-1], field, isArray), nil } diff --git a/runtime/actions/filters.go b/runtime/actions/filters.go index 0d19ab124..74bc4fb0b 100644 --- a/runtime/actions/filters.go +++ b/runtime/actions/filters.go @@ -3,12 +3,14 @@ package actions import ( "fmt" + "github.com/teamkeel/keel/casing" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/proto" "github.com/teamkeel/keel/schema/parser" ) // Applies all implicit input filters to the query. -func (query *QueryBuilder) applyImplicitFilters(scope *Scope, args map[string]any) error { +func (query *QueryBuilder) ApplyImplicitFilters(scope *Scope, args map[string]any) error { message := proto.FindWhereInputMessage(scope.Schema, scope.Action.Name) if message == nil { return nil @@ -38,6 +40,35 @@ func (query *QueryBuilder) applyImplicitFilters(scope *Scope, args map[string]an return nil } +// Include a filter (where condition) on the query based on an implicit input filter. +func (query *QueryBuilder) whereByImplicitFilter(scope *Scope, targetField []string, operator ActionOperator, value any) error { + // Implicit inputs don't include the base model as the first fragment (unlike expressions), so we include it + fragments := append([]string{casing.ToLowerCamel(scope.Action.ModelName)}, targetField...) + + // The lhs QueryOperand is determined from the fragments in the implicit input field + left, err := operandFromFragments(scope.Schema, fragments) + if err != nil { + return err + } + + // The rhs QueryOperand is always a value in an implicit input + right := Value(value) + + // Add join for the implicit input + err = query.AddJoinFromFragments(scope.Schema, fragments) + if err != nil { + return err + } + + // Add where condition to the query for the implicit input + err = query.Where(left, operator, right) + if err != nil { + return err + } + + return nil +} + // Applies all exlicit where attribute filters to the query. func (query *QueryBuilder) applyExpressionFilters(scope *Scope, args map[string]any) error { for _, where := range scope.Action.WhereExpressions { @@ -46,13 +77,11 @@ func (query *QueryBuilder) applyExpressionFilters(scope *Scope, args map[string] return err } - // Resolve the database statement for this expression - err = query.whereByExpression(scope, expression, args) + _, err = resolve.RunCelVisitor(expression, GenerateFilterQuery(scope.Context, query, scope.Schema, scope.Model, scope.Action, args)) if err != nil { return err } - // Where attributes are ANDed together query.And() } diff --git a/runtime/actions/generate_computed.go b/runtime/actions/generate_computed.go new file mode 100644 index 000000000..793bd57cd --- /dev/null +++ b/runtime/actions/generate_computed.go @@ -0,0 +1,119 @@ +package actions + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/google/cel-go/common/operators" + "github.com/iancoleman/strcase" + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/proto" + + "github.com/teamkeel/keel/schema/parser" +) + +// GenerateComputedFunction visits the expression and generates a SQL expression +func GenerateComputedFunction(schema *proto.Schema, model *proto.Model, field *proto.Field) resolve.Visitor[string] { + return &computedQueryGen{ + schema: schema, + model: model, + field: field, + sql: "", + } +} + +var _ resolve.Visitor[string] = new(computedQueryGen) + +type computedQueryGen struct { + schema *proto.Schema + model *proto.Model + field *proto.Field + sql string +} + +func (v *computedQueryGen) StartCondition(nested bool) error { + if nested { + v.sql += "(" + } + return nil +} + +func (v *computedQueryGen) EndCondition(nested bool) error { + if nested { + v.sql += ")" + } + return nil +} + +func (v *computedQueryGen) VisitAnd() error { + v.sql += " AND " + return nil +} + +func (v *computedQueryGen) VisitOr() error { + v.sql += " OR " + return nil +} + +func (v *computedQueryGen) VisitNot() error { + v.sql += " NOT " + return nil +} + +func (v *computedQueryGen) VisitOperator(op string) error { + // Map CEL operators to SQL operators + sqlOp := map[string]string{ + operators.Add: "+", + operators.Subtract: "-", + operators.Multiply: "*", + operators.Divide: "/", + operators.Equals: "IS NOT DISTINCT FROM", + operators.NotEquals: "IS DISTINCT FROM", + operators.Greater: ">", + operators.GreaterEquals: ">=", + operators.Less: "<", + operators.LessEquals: "<=", + }[op] + + if sqlOp == "" { + return fmt.Errorf("unsupported operator: %s", op) + } + + v.sql += " " + sqlOp + " " + return nil +} + +func (v *computedQueryGen) VisitLiteral(value any) error { + switch val := value.(type) { + case int64: + v.sql += fmt.Sprintf("%v", val) + case float64: + v.sql += fmt.Sprintf("%v", val) + case string: + v.sql += fmt.Sprintf("\"%v\"", val) + case bool: + v.sql += fmt.Sprintf("%t", val) + case nil: + v.sql += "NULL" + default: + return fmt.Errorf("unsupported literal type: %T", value) + } + return nil +} + +func (v *computedQueryGen) VisitIdent(ident *parser.ExpressionIdent) error { + v.sql += "r." + sqlQuote(strcase.ToSnake(ident.Fragments[len(ident.Fragments)-1])) + return nil +} + +func (v *computedQueryGen) VisitIdentArray(idents []*parser.ExpressionIdent) error { + return errors.New("ident arrays not supported in computed expressions") +} + +func (v *computedQueryGen) Result() (string, error) { + // Remove multiple whitespaces and trim + re := regexp.MustCompile(`\s+`) + return re.ReplaceAllString(strings.TrimSpace(v.sql), " "), nil +} diff --git a/runtime/actions/generate_computed_test.go b/runtime/actions/generate_computed_test.go new file mode 100644 index 000000000..1f2405c2b --- /dev/null +++ b/runtime/actions/generate_computed_test.go @@ -0,0 +1,171 @@ +package actions_test + +import ( + "strings" + "testing" + + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/proto" + "github.com/teamkeel/keel/runtime/actions" + "github.com/teamkeel/keel/schema" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/reader" + "github.com/test-go/testify/assert" +) + +const testSchema = ` +model Item { + fields { + product Text + price Decimal? + quantity Number + isActive Boolean + #placeholder# + } +}` + +type computedTestCase struct { + // Name given to the test case + name string + // Valid keel schema for this test case + keelSchema string + // action name to run test upon + field string + // Input map for action + expectedSql string +} + +var computedTestCases = []computedTestCase{ + + { + name: "adding field with literal", + keelSchema: testSchema, + field: "total Decimal @computed(item.price + 100)", + expectedSql: `r."price" + 100`, + }, + { + name: "subtracting field with literal", + keelSchema: testSchema, + field: "total Decimal @computed(item.price - 100)", + expectedSql: `r."price" - 100`, + }, + { + name: "dividing field with literal", + keelSchema: testSchema, + field: "total Decimal @computed(item.price / 100)", + expectedSql: `r."price" / 100`, + }, + { + name: "multiplying field with literal", + keelSchema: testSchema, + field: "total Decimal @computed(item.price * 100)", + expectedSql: `r."price" * 100`, + }, + { + name: "multiply fields on same model", + keelSchema: testSchema, + field: "total Decimal @computed(item.price * item.quantity)", + expectedSql: `r."price" * r."quantity"`, + }, + { + name: "parenthesis", + keelSchema: testSchema, + field: "total Decimal @computed(item.quantity * (1 + item.quantity) / (100 * (item.price + 1)))", + expectedSql: `r."quantity" * (1 + r."quantity") / (100 * (r."price" + 1))`, + }, + { + name: "no parenthesis", + keelSchema: testSchema, + field: "total Decimal @computed(item.quantity * 1 + item.quantity / 100 * item.price + 1)", + expectedSql: `r."quantity" * 1 + r."quantity" / 100 * r."price" + 1`, + }, + { + name: "bool greater than", + keelSchema: testSchema, + field: "isExpensive Boolean @computed(item.price > 100)", + expectedSql: `r."price" > 100`, + }, + { + name: "bool greater or equals", + keelSchema: testSchema, + field: "isExpensive Boolean @computed(item.price >= 100)", + expectedSql: `r."price" >= 100`, + }, + { + name: "bool less than", + keelSchema: testSchema, + field: "isCheap Boolean @computed(item.price < 100)", + expectedSql: `r."price" < 100`, + }, + { + name: "bool less or equals", + keelSchema: testSchema, + field: "isCheap Boolean @computed(item.price <= 100)", + expectedSql: `r."price" <= 100`, + }, + { + name: "bool is not null", + keelSchema: testSchema, + field: "hasPrice Boolean @computed(item.price != null)", + expectedSql: `r."price" IS DISTINCT FROM NULL`, + }, + { + name: "bool is null", + keelSchema: testSchema, + field: "noPrice Boolean @computed(item.price == null)", + expectedSql: `r."price" IS NOT DISTINCT FROM NULL`, + }, + { + name: "bool with and", + keelSchema: testSchema, + field: "isExpensive Boolean @computed(item.price > 100 && item.isActive)", + expectedSql: `r."price" > 100 AND r."is_active"`, + }, + { + name: "bool with or", + keelSchema: testSchema, + field: "isExpensive Boolean @computed(item.price > 100 || item.isActive)", + expectedSql: `(r."price" > 100 OR r."is_active")`, + }, + { + name: "negation", + keelSchema: testSchema, + field: "isExpensive Boolean @computed(item.price > 100 || !item.isActive)", + expectedSql: `(r."price" > 100 OR NOT r."is_active")`, + }, +} + +func TestGeneratedComputed(t *testing.T) { + t.Parallel() + for _, testCase := range computedTestCases { + t.Run(testCase.name, func(t *testing.T) { + raw := strings.Replace(testCase.keelSchema, "#placeholder#", testCase.field, 1) + + schemaFiles := + &reader.Inputs{ + SchemaFiles: []*reader.SchemaFile{ + { + Contents: raw, + FileName: "schema.keel", + }, + }, + } + + builder := &schema.Builder{} + schema, err := builder.MakeFromInputs(schemaFiles) + assert.NoError(t, err) + + model := schema.Models[0] + fieldName := strings.Split(testCase.field, " ")[0] + field := proto.FindField(schema.Models, model.Name, fieldName) + + expression, err := parser.ParseExpression(field.ComputedExpression.Source) + assert.NoError(t, err) + + sql, err := resolve.RunCelVisitor(expression, actions.GenerateComputedFunction(schema, model, field)) + assert.NoError(t, err) + + assert.Equal(t, testCase.expectedSql, sql, "expected `%s` but got `%s`", testCase.expectedSql, sql) + }) + } +} diff --git a/runtime/actions/generate_filter.go b/runtime/actions/generate_filter.go new file mode 100644 index 000000000..7f2b8b52a --- /dev/null +++ b/runtime/actions/generate_filter.go @@ -0,0 +1,180 @@ +package actions + +import ( + "context" + "errors" + "fmt" + + "github.com/emirpasic/gods/stacks/arraystack" + "github.com/google/cel-go/common/operators" + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/proto" + "github.com/teamkeel/keel/schema/parser" +) + +// GenerateFilterQuery visits the expression and adds filter conditions to the provided query builder +func GenerateFilterQuery(ctx context.Context, query *QueryBuilder, schema *proto.Schema, model *proto.Model, action *proto.Action, inputs map[string]any) resolve.Visitor[*QueryBuilder] { + return &whereQueryGen{ + ctx: ctx, + query: query, + schema: schema, + model: model, + action: action, + inputs: inputs, + operators: arraystack.New(), + operands: arraystack.New(), + } +} + +var _ resolve.Visitor[*QueryBuilder] = new(whereQueryGen) + +type whereQueryGen struct { + ctx context.Context + query *QueryBuilder + schema *proto.Schema + model *proto.Model + action *proto.Action + inputs map[string]any + operators *arraystack.Stack + operands *arraystack.Stack +} + +func (v *whereQueryGen) StartCondition(nested bool) error { + if op, ok := v.operators.Peek(); ok && op == Not { + _, _ = v.operators.Pop() + v.query.Not() + } + + // Only add parenthesis if we're in a nested condition + if nested { + v.query.OpenParenthesis() + } + + return nil +} + +func (v *whereQueryGen) EndCondition(nested bool) error { + if _, ok := v.operators.Peek(); ok && v.operands.Size() == 2 { + operator, _ := v.operators.Pop() + + r, ok := v.operands.Pop() + if !ok { + return errors.New("expected rhs operand") + } + l, ok := v.operands.Pop() + if !ok { + return errors.New("expected lhs operand") + } + + lhs := l.(*QueryOperand) + rhs := r.(*QueryOperand) + + err := v.query.Where(lhs, operator.(ActionOperator), rhs) + if err != nil { + return err + } + } else if _, ok := v.operators.Peek(); !ok { + l, hasOperand := v.operands.Pop() + if hasOperand { + lhs := l.(*QueryOperand) + err := v.query.Where(lhs, Equals, Value(true)) + if err != nil { + return err + } + } + } + + // Only close parenthesis if we're nested + if nested { + v.query.CloseParenthesis() + } + + return nil +} + +func (v *whereQueryGen) VisitAnd() error { + v.query.And() + return nil +} + +func (v *whereQueryGen) VisitOr() error { + v.query.Or() + return nil +} + +func (v *whereQueryGen) VisitNot() error { + v.operators.Push(Not) + return nil +} + +func (v *whereQueryGen) VisitOperator(op string) error { + operator, err := toActionOperator(op) + if err != nil { + return err + } + + v.operators.Push(operator) + + return nil +} + +func toActionOperator(op string) (ActionOperator, error) { + switch op { + case operators.Equals: + return Equals, nil + case operators.NotEquals: + return NotEquals, nil + case operators.Greater: + return GreaterThan, nil + case operators.GreaterEquals: + return GreaterThanEquals, nil + case operators.Less: + return LessThan, nil + case operators.LessEquals: + return LessThanEquals, nil + case operators.In: + return OneOf, nil + default: + return Unknown, fmt.Errorf("not implemented: %s", op) + } +} + +func (v *whereQueryGen) VisitLiteral(value any) error { + if value == nil { + v.operands.Push(Null()) + } else { + v.operands.Push(Value(value)) + } + return nil +} + +func (v *whereQueryGen) VisitIdent(ident *parser.ExpressionIdent) error { + operand, err := generateOperand(v.ctx, v.schema, v.model, v.action, v.inputs, ident.Fragments) + if err != nil { + return err + } + + err = v.query.AddJoinFromFragments(v.schema, ident.Fragments) + if err != nil { + return err + } + + v.operands.Push(operand) + + return nil +} + +func (v *whereQueryGen) VisitIdentArray(idents []*parser.ExpressionIdent) error { + arr := []string{} + for _, e := range idents { + arr = append(arr, e.Fragments[1]) + } + + v.operands.Push(Value(arr)) + + return nil +} + +func (v *whereQueryGen) Result() (*QueryBuilder, error) { + return v.query, nil +} diff --git a/runtime/actions/generate_select.go b/runtime/actions/generate_select.go new file mode 100644 index 000000000..60fa69ecf --- /dev/null +++ b/runtime/actions/generate_select.go @@ -0,0 +1,93 @@ +package actions + +import ( + "context" + "errors" + "fmt" + + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/proto" + "github.com/teamkeel/keel/schema/parser" +) + +// GenerateSelectQuery visits the expression and adds select clauses to the provided query builder +func GenerateSelectQuery(ctx context.Context, query *QueryBuilder, schema *proto.Schema, model *proto.Model, action *proto.Action, inputs map[string]any) resolve.Visitor[*QueryOperand] { + return &setQueryGen{ + ctx: ctx, + query: query, + schema: schema, + model: model, + action: action, + inputs: inputs, + } +} + +var _ resolve.Visitor[*QueryOperand] = new(setQueryGen) + +type setQueryGen struct { + ctx context.Context + query *QueryBuilder + operand *QueryOperand + schema *proto.Schema + model *proto.Model + action *proto.Action + inputs map[string]any +} + +func (v *setQueryGen) StartCondition(parenthesis bool) error { + return nil +} + +func (v *setQueryGen) EndCondition(parenthesis bool) error { + return nil +} + +func (v *setQueryGen) VisitAnd() error { + return errors.New("and operator not supported with set") +} + +func (v *setQueryGen) VisitOr() error { + return errors.New("or operator not supported with set") +} + +func (v *setQueryGen) VisitNot() error { + return nil +} + +func (v *setQueryGen) VisitOperator(op string) error { + return fmt.Errorf("%s operator not supported with set", op) +} + +func (v *setQueryGen) VisitLiteral(value any) error { + if value == nil { + v.operand = Null() + } else { + v.operand = Value(value) + } + return nil +} + +func (v *setQueryGen) VisitIdent(ident *parser.ExpressionIdent) error { + operand, err := generateOperand(v.ctx, v.schema, v.model, v.action, v.inputs, ident.Fragments) + if err != nil { + return err + } + v.operand = operand + + return nil +} + +func (v *setQueryGen) VisitIdentArray(idents []*parser.ExpressionIdent) error { + arr := []string{} + for _, e := range idents { + arr = append(arr, e.Fragments[1]) + } + + v.operand = Value(arr) + + return nil +} + +func (v *setQueryGen) Result() (*QueryOperand, error) { + return v.operand, nil +} diff --git a/runtime/actions/get.go b/runtime/actions/get.go index 6ca8c3ee5..f04b8bab8 100644 --- a/runtime/actions/get.go +++ b/runtime/actions/get.go @@ -14,7 +14,7 @@ func Get(scope *Scope, input map[string]any) (map[string]any, error) { permissions := proto.PermissionsForAction(scope.Schema, scope.Action) // Attempt to resolve permissions early; i.e. before row-based database querying. - canResolveEarly, authorised, err := TryResolveAuthorisationEarly(scope, permissions) + canResolveEarly, authorised, err := TryResolveAuthorisationEarly(scope, input, permissions) if err != nil { return nil, err } @@ -87,7 +87,7 @@ func Get(scope *Scope, input map[string]any) (map[string]any, error) { } func GenerateGetStatement(query *QueryBuilder, scope *Scope, input map[string]any) (*Statement, error) { - err := query.applyImplicitFilters(scope, input) + err := query.ApplyImplicitFilters(scope, input) if err != nil { return nil, err } diff --git a/runtime/actions/list.go b/runtime/actions/list.go index 111123c69..c717b096f 100644 --- a/runtime/actions/list.go +++ b/runtime/actions/list.go @@ -145,7 +145,7 @@ func List(scope *Scope, input map[string]any) (map[string]any, error) { permissions := proto.PermissionsForAction(scope.Schema, scope.Action) // Attempt to resolve permissions early; i.e. before row-based database querying. - canResolveEarly, authorised, err := TryResolveAuthorisationEarly(scope, permissions) + canResolveEarly, authorised, err := TryResolveAuthorisationEarly(scope, input, permissions) if err != nil { return nil, err } diff --git a/runtime/actions/operator.go b/runtime/actions/operator.go index 2d4fc6e97..1301190a1 100644 --- a/runtime/actions/operator.go +++ b/runtime/actions/operator.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/teamkeel/keel/proto" - "github.com/teamkeel/keel/schema/parser" ) // An ActionOperator gives a symbolic, machine-readable name to each @@ -25,6 +24,7 @@ const ( Equals EqualsRelative NotEquals + Not StartsWith EndsWith GreaterThan @@ -160,33 +160,6 @@ func allQueryOperatorToActionOperator(in string) (out ActionOperator, err error) } } -// expressionOperatorToActionOperator converts the conditional operators that are used -// in Keel Expressions (such as ">=") to its symbolic constant, -// machine-readable, ActionOperator value. -func expressionOperatorToActionOperator(in string) (out ActionOperator, err error) { - switch in { - case parser.OperatorEquals: - return Equals, nil - case parser.OperatorNotEquals: - return NotEquals, nil - case parser.OperatorGreaterThanOrEqualTo: - return GreaterThanEquals, nil - case parser.OperatorLessThanOrEqualTo: - return LessThanEquals, nil - case parser.OperatorLessThan: - return LessThan, nil - case parser.OperatorGreaterThan: - return GreaterThan, nil - case parser.OperatorIn: - return OneOf, nil - case parser.OperatorNotIn: - return NotOneOf, nil - - default: - return Unknown, fmt.Errorf("this is not a recognized conditional operator: %s", in) - } -} - func toSql(o proto.OrderDirection) (string, error) { switch o { case proto.OrderDirection_ORDER_DIRECTION_ASCENDING: diff --git a/runtime/actions/query.go b/runtime/actions/query.go index b065749dd..57869c9bf 100644 --- a/runtime/actions/query.go +++ b/runtime/actions/query.go @@ -46,10 +46,11 @@ func AllFields() *QueryOperand { } // Some field from the fragments of an expression or input. -func ExpressionField(fragments []string, field string) *QueryOperand { +func ExpressionField(fragments []string, field string, arrayField bool) *QueryOperand { return &QueryOperand{ - table: casing.ToSnake(strings.Join(fragments, "$")), - column: casing.ToSnake(field), + table: casing.ToSnake(strings.Join(fragments, "$")), + column: casing.ToSnake(field), + arrayField: arrayField, } } @@ -78,6 +79,12 @@ func Null() *QueryOperand { return &QueryOperand{} } +func RuntimeEvaluated(identifier string) *QueryOperand { + return &QueryOperand{ + runtimeIdentifier: identifier, + } +} + func ValueOrNullIfEmpty(value any) *QueryOperand { if value == nil || reflect.ValueOf(value).IsZero() { return Null() @@ -86,11 +93,13 @@ func ValueOrNullIfEmpty(value any) *QueryOperand { } type QueryOperand struct { - query *QueryBuilder - raw string - table string - column string - value any + query *QueryBuilder + raw string + table string + column string + arrayField bool + value any + runtimeIdentifier string } // A query builder to be evaluated and injected as an operand. @@ -138,6 +147,10 @@ func (o *QueryOperand) IsArrayValue() bool { return true } +func (o *QueryOperand) IsArrayField() bool { + return o.arrayField +} + func (o *QueryOperand) IsNull() bool { return o.query == nil && o.table == "" && o.column == "" && o.value == nil && o.raw == "" } @@ -463,6 +476,11 @@ func (query *QueryBuilder) Or() { } } +// Opens a new conditional scope in the where expression (i.e. open parethesis). +func (query *QueryBuilder) Not() { + query.filters = append(query.filters, "NOT") +} + // Opens a new conditional scope in the where expression (i.e. open parethesis). func (query *QueryBuilder) OpenParenthesis() { query.filters = append(query.filters, "(") @@ -810,13 +828,13 @@ func (query *QueryBuilder) generateInsertCte(ctes []string, args []any, row *Row // we want to create the common table expressions first, // and ensure we only create the CTE once (as there may be more // than once reference by other fields). - for _, col := range orderedKeys { + for i, col := range orderedKeys { operand := row.values[col] if !operand.IsInlineQuery() { continue } - cteAlias := fmt.Sprintf("select_%s", operand.query.table) + cteAlias := fmt.Sprintf("select_%s_%v", operand.query.table, i) cteExists := false for _, c := range ctes { if strings.HasPrefix(c, sqlQuote(cteAlias)) { @@ -841,7 +859,7 @@ func (query *QueryBuilder) generateInsertCte(ctes []string, args []any, row *Row } } - for _, col := range orderedKeys { + for i, col := range orderedKeys { colName := casing.ToSnake(col) columnNames = append(columnNames, sqlQuote(colName)) operand := row.values[col] @@ -854,7 +872,7 @@ func (query *QueryBuilder) generateInsertCte(ctes []string, args []any, row *Row columnValues = append(columnValues, sql) args = append(args, opArgs...) case operand.IsInlineQuery(): - cteAlias := fmt.Sprintf("select_%s", operand.query.table) + cteAlias := fmt.Sprintf("select_%s_%v", operand.query.table, i) columnAlias := "" for i, s := range operand.query.selection { @@ -950,13 +968,13 @@ func (query *QueryBuilder) UpdateStatement(ctx context.Context) *Statement { } sort.Strings(orderedKeys) - for _, v := range orderedKeys { + for i, v := range orderedKeys { operand := query.writeValues.values[v] if !operand.IsInlineQuery() { continue } - cteAlias := fmt.Sprintf("select_%s", operand.query.table) + cteAlias := fmt.Sprintf("select_%s_%v", operand.query.table, i) cteExists := false for _, c := range ctes { if strings.HasPrefix(c, sqlQuote(cteAlias)) { @@ -981,11 +999,11 @@ func (query *QueryBuilder) UpdateStatement(ctx context.Context) *Statement { } } - for _, v := range orderedKeys { + for i, v := range orderedKeys { operand := query.writeValues.values[v] if operand.IsInlineQuery() { - cteAlias := fmt.Sprintf("select_%s", operand.query.table) + cteAlias := fmt.Sprintf("select_%s_%v", operand.query.table, i) columnAlias := "" for i, s := range operand.query.selection { @@ -1386,6 +1404,18 @@ func (query *QueryBuilder) generateConditionTemplate(lhs *QueryOperand, operator return "", nil, errors.New("no handling for rhs QueryOperand type") } + // If the operand is not an array value nor an inline query, + // then we know it's a nested relationship lookup and + // so rather use Equals and NotEquals because we are joining. + if !rhs.IsArrayField() && !rhs.IsArrayValue() && !rhs.IsInlineQuery() { + if operator == OneOf { + operator = Equals + } + if operator == NotOneOf { + operator = NotEquals + } + } + switch operator { case Equals: template = fmt.Sprintf("%s IS NOT DISTINCT FROM %s", lhsSqlOperand, rhsSqlOperand) @@ -1405,11 +1435,7 @@ func (query *QueryBuilder) generateConditionTemplate(lhs *QueryOperand, operator if rhs.IsInlineQuery() { template = fmt.Sprintf("%s NOT IN %s", lhsSqlOperand, rhsSqlOperand) } else { - if rhs.IsField() { - template = fmt.Sprintf("(NOT %s = ANY(%s) OR %s IS NOT DISTINCT FROM NULL)", lhsSqlOperand, rhsSqlOperand, rhsSqlOperand) - } else { - template = fmt.Sprintf("NOT %s = ANY(%s)", lhsSqlOperand, rhsSqlOperand) - } + template = fmt.Sprintf("NOT %s = ANY(%s)", lhsSqlOperand, rhsSqlOperand) } case LessThan, Before: template = fmt.Sprintf("%s < %s", lhsSqlOperand, rhsSqlOperand) @@ -1424,7 +1450,7 @@ func (query *QueryBuilder) generateConditionTemplate(lhs *QueryOperand, operator case AnyEquals: template = fmt.Sprintf("%s = ANY(%s)", rhsSqlOperand, lhsSqlOperand) case AnyNotEquals: - template = fmt.Sprintf("(NOT %s = ANY(%s) OR %s IS NOT DISTINCT FROM NULL)", rhsSqlOperand, lhsSqlOperand, lhsSqlOperand) + template = fmt.Sprintf("NOT %s = ANY(%s)", rhsSqlOperand, lhsSqlOperand) case AnyLessThan, AnyBefore: template = fmt.Sprintf("%s > ANY(%s)", rhsSqlOperand, lhsSqlOperand) case AnyLessThanEquals, AnyOnOrBefore: @@ -1438,7 +1464,7 @@ func (query *QueryBuilder) generateConditionTemplate(lhs *QueryOperand, operator case AllEquals: template = fmt.Sprintf("(%s = ALL(%s) AND %s IS DISTINCT FROM '{}')", rhsSqlOperand, lhsSqlOperand, lhsSqlOperand) case AllNotEquals: - template = fmt.Sprintf("(NOT %s = ALL(%s) OR %s IS NOT DISTINCT FROM '{}' OR %s IS NOT DISTINCT FROM NULL)", rhsSqlOperand, lhsSqlOperand, lhsSqlOperand, lhsSqlOperand) + template = fmt.Sprintf("(NOT %s = ALL(%s) OR %s IS NOT DISTINCT FROM '{}')", rhsSqlOperand, lhsSqlOperand, lhsSqlOperand) case AllLessThan, AllBefore: template = fmt.Sprintf("%s > ALL(%s)", rhsSqlOperand, lhsSqlOperand) case AllLessThanEquals, AllOnOrBefore: @@ -1448,7 +1474,7 @@ func (query *QueryBuilder) generateConditionTemplate(lhs *QueryOperand, operator case AllGreaterThanEquals, AllOnOrAfter: template = fmt.Sprintf("%s <= ALL(%s)", rhsSqlOperand, lhsSqlOperand) - /* All relative date operators */ + /* Relative date operators */ case BeforeRelative: template = fmt.Sprintf("%s < %s", lhsSqlOperand, rhsSqlOperand) case AfterRelative: @@ -1472,6 +1498,7 @@ func (query *QueryBuilder) generateConditionTemplate(lhs *QueryOperand, operator } template = fmt.Sprintf("%s >= %s AND %s < %s", lhsSqlOperand, rhsSqlOperand, lhsSqlOperand, end) + default: return "", nil, fmt.Errorf("operator: %v is not yet supported", operator) } diff --git a/runtime/actions/query_test.go b/runtime/actions/query_test.go index f18fb2a68..b5bacb377 100644 --- a/runtime/actions/query_test.go +++ b/runtime/actions/query_test.go @@ -88,6 +88,60 @@ var testCases = []testCase{ AND "thing"."is_active" IS NOT DISTINCT FROM ?`, expectedArgs: []any{"123", true}, }, + { + name: "get_op_by_id_where_single_operand", + keelSchema: ` + model Thing { + fields { + isActive Boolean + } + actions { + get getThing(id) { + @where(thing.isActive) + } + } + @permission(expression: true, actions: [get]) + }`, + actionName: "getThing", + input: map[string]any{"id": "123"}, + expectedTemplate: ` + SELECT + DISTINCT ON("thing"."id") "thing".* + FROM + "thing" + WHERE + "thing"."id" IS NOT DISTINCT FROM ? + AND "thing"."is_active" IS NOT DISTINCT FROM ?`, + expectedArgs: []any{"123", true}, + }, + { + name: "get_op_by_id_where_single_operand_nested", + keelSchema: ` + model Thing { + fields { + related RelatedThing + } + actions { + get getThing(id) { + @where(thing.related.isActive) + } + } + @permission(expression: true, actions: [get]) + } + model RelatedThing { + fields { + isActive Boolean + } + }`, + actionName: "getThing", + input: map[string]any{"id": "123"}, + expectedTemplate: ` + SELECT DISTINCT ON("thing"."id") "thing".* + FROM "thing" + LEFT JOIN "related_thing" AS "thing$related" ON "thing$related"."id" = "thing"."related_id" + WHERE "thing"."id" IS NOT DISTINCT FROM ? AND "thing$related"."is_active" IS NOT DISTINCT FROM ?`, + expectedArgs: []any{"123", true}, + }, { name: "create_op_default_attribute", keelSchema: ` @@ -255,21 +309,50 @@ var testCases = []testCase{ input: map[string]any{"name": "Dave"}, expectedTemplate: ` WITH - "select_identity" ("column_0") AS ( + "select_identity_1" ("column_0") AS ( SELECT "identity$user"."id" FROM "identity" LEFT JOIN "company_user" AS "identity$user" ON "identity$user"."identity_id" = "identity"."id" - WHERE "identity"."id" IS NOT DISTINCT FROM ?), + WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity$user"."id" IS DISTINCT FROM NULL), "new_1_record" AS ( INSERT INTO "record" ("name", "user_id") VALUES ( ?, - (SELECT "column_0" FROM "select_identity")) + (SELECT "column_0" FROM "select_identity_1")) RETURNING *) SELECT *, set_identity_id(?) AS __keel_identity_id FROM "new_1_record"`, identity: identity, expectedArgs: []any{identity[parser.FieldNameId].(string), "Dave", identity[parser.FieldNameId].(string)}, }, + { + name: "create_op_set_attribute_set_related_model_by_id", + keelSchema: ` + model CompanyUser { + } + model Record { + fields { + name Text + user CompanyUser + } + actions { + create createRecord() with (name, someId: ID) { + @set(record.user.id = someId) + } + } + @permission(expression: true, actions: [create]) + }`, + actionName: "createRecord", + input: map[string]any{"name": "Dave", "someId": "123"}, + expectedTemplate: ` + WITH + "new_1_record" AS ( + INSERT INTO "record" ("name", "user_id") + VALUES (?, ?) + RETURNING *) + SELECT *, set_identity_id(?) AS __keel_identity_id FROM "new_1_record"`, + identity: identity, + expectedArgs: []any{"Dave", "123", identity[parser.FieldNameId].(string)}, + }, { name: "create_op_set_attribute_identity_user_backlink_field", keelSchema: ` @@ -297,23 +380,27 @@ var testCases = []testCase{ input: map[string]any{"name": "Dave"}, expectedTemplate: ` WITH - "select_identity" ("column_0", "column_1") AS ( - SELECT "identity$user"."id", "identity$user"."is_active" + "select_identity_0" ("column_0") AS ( + SELECT "identity$user"."is_active" + FROM "identity" + LEFT JOIN "company_user" AS "identity$user" ON "identity$user"."identity_id" = "identity"."id" + WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity$user"."is_active" IS DISTINCT FROM NULL), + "select_identity_2" ("column_0") AS ( + SELECT "identity$user"."id" FROM "identity" LEFT JOIN "company_user" AS "identity$user" ON "identity$user"."identity_id" = "identity"."id" - WHERE "identity"."id" IS NOT DISTINCT FROM ?), + WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity$user"."id" IS DISTINCT FROM NULL), "new_1_record" AS ( INSERT INTO "record" ("is_active", "name", "user_id") VALUES ( - (SELECT "column_1" FROM "select_identity"), + (SELECT "column_0" FROM "select_identity_0"), ?, - (SELECT "column_0" FROM "select_identity")) + (SELECT "column_0" FROM "select_identity_2")) RETURNING *) SELECT *, set_identity_id(?) AS __keel_identity_id FROM "new_1_record"`, identity: identity, - expectedArgs: []any{identity[parser.FieldNameId].(string), "Dave", identity[parser.FieldNameId].(string)}, + expectedArgs: []any{identity[parser.FieldNameId].(string), identity[parser.FieldNameId].(string), "Dave", identity[parser.FieldNameId].(string)}, }, - { name: "update_op_set_attribute_context_identity_id", keelSchema: ` @@ -403,6 +490,39 @@ var testCases = []testCase{ RETURNING "person".*`, expectedArgs: []any{"Dave", "Dave", "xyz"}, }, + { + name: "update_op_set_attribute_ctx", + keelSchema: ` + model Person { + fields { + name Text + signedIn Boolean + } + actions { + update updatePerson(id) with (name) { + @set(person.signedIn = ctx.isAuthenticated) + } + } + @permission(expression: true, actions: [update]) + }`, + actionName: "updatePerson", + input: map[string]any{ + "where": map[string]any{ + "id": "xyz", + }, + "values": map[string]any{ + "name": "Dave", + }, + }, + expectedTemplate: ` + UPDATE "person" + SET + "name" = ?, + "signed_in" = ? + WHERE "person"."id" IS NOT DISTINCT FROM ? + RETURNING "person".*`, + expectedArgs: []any{"Dave", false, "xyz"}, + }, { name: "update_op_set_attribute_identity_user_backlink_field", keelSchema: ` @@ -433,21 +553,18 @@ var testCases = []testCase{ }, }, expectedTemplate: ` - WITH - "select_identity" ("column_0", "column_1") AS ( - SELECT "identity$user"."id", "identity$user"."is_active" - FROM "identity" - LEFT JOIN "company_user" AS "identity$user" ON "identity$user"."identity_id" = "identity"."id" - WHERE "identity"."id" IS NOT DISTINCT FROM ?) - UPDATE "record" - SET - "is_active" = (SELECT "column_1" FROM "select_identity"), - "user_id" = (SELECT "column_0" FROM "select_identity") - WHERE - "record"."id" IS NOT DISTINCT FROM ? + WITH + "select_identity_0" ("column_0") AS + (SELECT "identity$user"."is_active" FROM "identity" LEFT JOIN "company_user" AS "identity$user" ON "identity$user"."identity_id" = "identity"."id" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity$user"."is_active" IS DISTINCT FROM NULL), + "select_identity_1" ("column_0") AS + (SELECT "identity$user"."id" FROM "identity" LEFT JOIN "company_user" AS "identity$user" ON "identity$user"."identity_id" = "identity"."id" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity$user"."id" IS DISTINCT FROM NULL) + UPDATE "record" SET + "is_active" = (SELECT "column_0" FROM "select_identity_0"), + "user_id" = (SELECT "column_0" FROM "select_identity_1") + WHERE "record"."id" IS NOT DISTINCT FROM ? RETURNING "record".*, set_identity_id(?) AS __keel_identity_id`, identity: identity, - expectedArgs: []any{identity[parser.FieldNameId].(string), "xyz", identity[parser.FieldNameId].(string)}, + expectedArgs: []any{identity[parser.FieldNameId].(string), identity[parser.FieldNameId].(string), "xyz", identity[parser.FieldNameId].(string)}, }, { name: "create_op_optional_inputs", @@ -746,6 +863,74 @@ var testCases = []testCase{ "thing"."id" ASC LIMIT ?`, expectedArgs: []any{"Technical", "Food", "Technical", "Food", 50}, }, + { + name: "list_op_implicit_input_enum_in_expression", + keelSchema: ` + model Thing { + fields { + category Category + } + actions { + list listThings() { + @where(thing.category in [Category.Technical, Category.Food]) + } + + } + @permission(expression: true, actions: [list]) + } + enum Category { + Technical + Food + Lifestyle + }`, + actionName: "listThings", + input: map[string]any{}, + expectedTemplate: ` + SELECT + DISTINCT ON("thing"."id") "thing".*, CASE WHEN LEAD("thing"."id") OVER (ORDER BY "thing"."id" ASC) IS NOT NULL THEN true ELSE false END AS hasNext, + (SELECT COUNT(DISTINCT "thing"."id") FROM "thing" WHERE "thing"."category" = ANY(ARRAY[?, ?]::TEXT[])) AS totalCount + FROM + "thing" + WHERE + "thing"."category" = ANY(ARRAY[?, ?]::TEXT[]) + ORDER BY + "thing"."id" ASC LIMIT ?`, + expectedArgs: []any{"Technical", "Food", "Technical", "Food", 50}, + }, + { + name: "list_op_implicit_input_enum_not_in_expression", + keelSchema: ` + model Thing { + fields { + category Category + } + actions { + list listThings() { + @where(!(thing.category in [Category.Technical, Category.Food])) + } + + } + @permission(expression: true, actions: [list]) + } + enum Category { + Technical + Food + Lifestyle + }`, + actionName: "listThings", + input: map[string]any{}, + expectedTemplate: ` + SELECT + DISTINCT ON("thing"."id") "thing".*, CASE WHEN LEAD("thing"."id") OVER (ORDER BY "thing"."id" ASC) IS NOT NULL THEN true ELSE false END AS hasNext, + (SELECT COUNT(DISTINCT "thing"."id") FROM "thing" WHERE NOT ("thing"."category" = ANY(ARRAY[?, ?]::TEXT[]))) AS totalCount + FROM + "thing" + WHERE + NOT ("thing"."category" = ANY(ARRAY[?, ?]::TEXT[])) + ORDER BY + "thing"."id" ASC LIMIT ?`, + expectedArgs: []any{"Technical", "Food", "Technical", "Food", 50}, + }, { name: "list_op_implicit_input_timestamp_after", keelSchema: ` @@ -1018,13 +1203,13 @@ var testCases = []testCase{ } model Thing { fields { - title Text + title Text repeatedThings RepeatedThing[] - } + } actions { list listRepeatedThings() { - @where(thing.title not in thing.repeatedThings.name) - } + @where(!(thing.title in thing.repeatedThings.name)) + } } @permission(expression: true, actions: [list]) }`, @@ -1040,11 +1225,11 @@ var testCases = []testCase{ LEFT JOIN "repeated_thing" AS "thing$repeated_things" ON "thing$repeated_things"."thing_id" = "thing"."id" WHERE - "thing"."title" IS DISTINCT FROM "thing$repeated_things"."name") AS totalCount FROM "thing" + NOT ("thing"."title" IS NOT DISTINCT FROM "thing$repeated_things"."name")) AS totalCount FROM "thing" LEFT JOIN "repeated_thing" AS "thing$repeated_things" ON "thing$repeated_things"."thing_id" = "thing"."id" WHERE - "thing"."title" IS DISTINCT FROM "thing$repeated_things"."name" + NOT ("thing"."title" IS NOT DISTINCT FROM "thing$repeated_things"."name") ORDER BY "thing"."id" ASC LIMIT ?`, expectedArgs: []any{50}, @@ -1054,11 +1239,11 @@ var testCases = []testCase{ keelSchema: ` model Thing { fields { - title Text - } + title Text + } actions { list listThings() { - @where(thing.title not in ["title1", "title2"]) + @where(!(thing.title in ["title1", "title2"])) } } @permission(expression: true, actions: [list]) @@ -1068,11 +1253,11 @@ var testCases = []testCase{ expectedTemplate: ` SELECT DISTINCT ON("thing"."id") "thing".*, CASE WHEN LEAD("thing"."id") OVER (ORDER BY "thing"."id" ASC) IS NOT NULL THEN true ELSE false END AS hasNext, - (SELECT COUNT(DISTINCT "thing"."id") FROM "thing" WHERE NOT "thing"."title" = ANY(ARRAY[?, ?]::TEXT[])) AS totalCount + (SELECT COUNT(DISTINCT "thing"."id") FROM "thing" WHERE NOT ("thing"."title" = ANY(ARRAY[?, ?]::TEXT[]))) AS totalCount FROM "thing" WHERE - NOT "thing"."title" = ANY(ARRAY[?, ?]::TEXT[]) + NOT ("thing"."title" = ANY(ARRAY[?, ?]::TEXT[])) ORDER BY "thing"."id" ASC LIMIT ?`, expectedArgs: []any{"title1", "title2", "title1", "title2", 50}, @@ -1110,11 +1295,11 @@ var testCases = []testCase{ keelSchema: ` model Thing { fields { - age Number - } + age Number + } actions { list listThings() { - @where(thing.age not in [10, 20]) + @where(!(thing.age in [10, 20])) } } @permission(expression: true, actions: [list]) @@ -1124,11 +1309,11 @@ var testCases = []testCase{ expectedTemplate: ` SELECT DISTINCT ON("thing"."id") "thing".*, CASE WHEN LEAD("thing"."id") OVER (ORDER BY "thing"."id" ASC) IS NOT NULL THEN true ELSE false END AS hasNext, - (SELECT COUNT(DISTINCT "thing"."id") FROM "thing" WHERE NOT "thing"."age" = ANY(ARRAY[?, ?]::INTEGER[])) AS totalCount + (SELECT COUNT(DISTINCT "thing"."id") FROM "thing" WHERE NOT ("thing"."age" = ANY(ARRAY[?, ?]::INTEGER[]))) AS totalCount FROM "thing" WHERE - NOT "thing"."age" = ANY(ARRAY[?, ?]::INTEGER[]) + NOT ("thing"."age" = ANY(ARRAY[?, ?]::INTEGER[])) ORDER BY "thing"."id" ASC LIMIT ?`, expectedArgs: []any{int64(10), int64(20), int64(10), int64(20), 50}, @@ -1166,7 +1351,7 @@ var testCases = []testCase{ SELECT DISTINCT ON("account"."username", "account"."id") "account".*, CASE WHEN LEAD("account"."id") OVER (ORDER BY "account"."username" ASC, "account"."id" ASC) IS NOT NULL THEN true ELSE false END AS hasNext, - (SELECT COUNT(DISTINCT ("account"."username", "account"."id")) FROM "account" WHERE "account"."id" IN (SELECT "identity$account$following"."followee_id" FROM "identity" LEFT JOIN "account" AS "identity$account" ON "identity$account"."identity_id" = "identity"."id" LEFT JOIN "follow" AS "identity$account$following" ON "identity$account$following"."follower_id" = "identity$account"."id" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity$account$following"."followee_id" IS DISTINCT FROM NULL )) AS totalCount + (SELECT COUNT(DISTINCT ("account"."username", "account"."id")) FROM "account" WHERE "account"."id" IN (SELECT "identity$account$following"."followee_id" FROM "identity" LEFT JOIN "account" AS "identity$account" ON "identity$account"."identity_id" = "identity"."id" LEFT JOIN "follow" AS "identity$account$following" ON "identity$account$following"."follower_id" = "identity$account"."id" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity$account$following"."followee_id" IS DISTINCT FROM NULL)) AS totalCount FROM "account" WHERE "account"."id" IN @@ -1176,7 +1361,7 @@ var testCases = []testCase{ LEFT JOIN "follow" AS "identity$account$following" ON "identity$account$following"."follower_id" = "identity$account"."id" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND - "identity$account$following"."followee_id" IS DISTINCT FROM NULL ) + "identity$account$following"."followee_id" IS DISTINCT FROM NULL) ORDER BY "account"."username" ASC, "account"."id" ASC LIMIT ?`, expectedArgs: []any{"identityId", "identityId", 50}, }, @@ -1245,8 +1430,8 @@ var testCases = []testCase{ actions { list accountsNotFollowed() { @where(account.identity != ctx.identity) - @where(account.id not in ctx.identity.primaryAccount.following.followee.id) - @orderBy(username: asc) + @where(!(account.id in ctx.identity.primaryAccount.following.followee.id)) + @orderBy(username: asc) @permission(expression: ctx.isAuthenticated) } } @@ -1265,12 +1450,12 @@ var testCases = []testCase{ SELECT DISTINCT ON("account"."username", "account"."id") "account".*, CASE WHEN LEAD("account"."id") OVER (ORDER BY "account"."username" ASC, "account"."id" ASC) IS NOT NULL THEN true ELSE false END AS hasNext, - (SELECT COUNT(DISTINCT ("account"."username", "account"."id")) FROM "account" WHERE "account"."identity_id" IS DISTINCT FROM ? AND "account"."id" NOT IN (SELECT "identity$primary_account$following$followee"."id" FROM "identity" LEFT JOIN "account" AS "identity$primary_account" ON "identity$primary_account"."identity_id" = "identity"."id" LEFT JOIN "follow" AS "identity$primary_account$following" ON "identity$primary_account$following"."follower_id" = "identity$primary_account"."id" LEFT JOIN "account" AS "identity$primary_account$following$followee" ON "identity$primary_account$following$followee"."id" = "identity$primary_account$following"."followee_id" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity$primary_account$following$followee"."id" IS DISTINCT FROM NULL )) AS totalCount + (SELECT COUNT(DISTINCT ("account"."username", "account"."id")) FROM "account" WHERE "account"."identity_id" IS DISTINCT FROM ? AND NOT ("account"."id" IN (SELECT "identity$primary_account$following$followee"."id" FROM "identity" LEFT JOIN "account" AS "identity$primary_account" ON "identity$primary_account"."identity_id" = "identity"."id" LEFT JOIN "follow" AS "identity$primary_account$following" ON "identity$primary_account$following"."follower_id" = "identity$primary_account"."id" LEFT JOIN "account" AS "identity$primary_account$following$followee" ON "identity$primary_account$following$followee"."id" = "identity$primary_account$following"."followee_id" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity$primary_account$following$followee"."id" IS DISTINCT FROM NULL))) AS totalCount FROM "account" WHERE "account"."identity_id" IS DISTINCT FROM ? AND - "account"."id" NOT IN + NOT ("account"."id" IN (SELECT "identity$primary_account$following$followee"."id" FROM "identity" LEFT JOIN "account" AS "identity$primary_account" ON "identity$primary_account"."identity_id" = "identity"."id" @@ -1278,7 +1463,7 @@ var testCases = []testCase{ LEFT JOIN "account" AS "identity$primary_account$following$followee" ON "identity$primary_account$following$followee"."id" = "identity$primary_account$following"."followee_id" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND - "identity$primary_account$following$followee"."id" IS DISTINCT FROM NULL ) + "identity$primary_account$following$followee"."id" IS DISTINCT FROM NULL)) ORDER BY "account"."username" ASC, "account"."id" ASC LIMIT ?`, @@ -1440,6 +1625,43 @@ var testCases = []testCase{ "thing"."id" ASC LIMIT ?`, expectedArgs: []any{"bob", "bob", 50}, }, + { + name: "get_op_where_expression_on_nested_model", + keelSchema: ` + model Parent { + fields { + name Text + isActive Boolean + } + } + model Thing { + fields { + parent Parent + } + actions { + get getThing(id) { + @where(thing.parent.isActive == false) + } + } + @permission(expression: true, actions: [get]) + }`, + actionName: "getThing", + input: map[string]any{ + "id": "some-id", + }, + expectedTemplate: ` + SELECT + DISTINCT ON("thing"."id") "thing".* + FROM + "thing" + LEFT JOIN + "parent" AS "thing$parent" + ON "thing$parent"."id" = "thing"."parent_id" + WHERE + "thing"."id" IS NOT DISTINCT FROM ? + AND "thing$parent"."is_active" IS NOT DISTINCT FROM ?`, + expectedArgs: []any{"some-id", false}, + }, { name: "list_op_where_expression_on_nested_model", keelSchema: ` @@ -1936,7 +2158,7 @@ var testCases = []testCase{ } actions { list listThing() { - @where(thing.first == "first" and thing.second == 10 or thing.third == true and thing.second > 100) + @where(thing.first == "first" && thing.second == 10 || thing.third == true && thing.second > 100) } } @permission(expression: true, actions: [list]) @@ -1968,7 +2190,7 @@ var testCases = []testCase{ } actions { list listThing() { - @where((thing.first == "first" and thing.second == 10) or (thing.third == true and thing.second > 100)) + @where((thing.first == "first" && thing.second == 10) || (thing.third == true && thing.second > 100)) } } @permission(expression: true, actions: [list]) @@ -1977,13 +2199,13 @@ var testCases = []testCase{ expectedTemplate: ` SELECT DISTINCT ON("thing"."id") "thing".*, CASE WHEN LEAD("thing"."id") OVER (ORDER BY "thing"."id" ASC) IS NOT NULL THEN true ELSE false END AS hasNext, - (SELECT COUNT(DISTINCT "thing"."id") FROM "thing" WHERE ( ( "thing"."first" IS NOT DISTINCT FROM ? AND "thing"."second" IS NOT DISTINCT FROM ? ) OR ( "thing"."third" IS NOT DISTINCT FROM ? AND "thing"."second" > ? ) )) AS totalCount + (SELECT COUNT(DISTINCT "thing"."id") FROM "thing" WHERE ( "thing"."first" IS NOT DISTINCT FROM ? AND "thing"."second" IS NOT DISTINCT FROM ? OR "thing"."third" IS NOT DISTINCT FROM ? AND "thing"."second" > ? )) AS totalCount FROM "thing" WHERE - ( ( "thing"."first" IS NOT DISTINCT FROM ? AND "thing"."second" IS NOT DISTINCT FROM ? ) + ( "thing"."first" IS NOT DISTINCT FROM ? AND "thing"."second" IS NOT DISTINCT FROM ? OR - ( "thing"."third" IS NOT DISTINCT FROM ? AND "thing"."second" > ? ) ) + "thing"."third" IS NOT DISTINCT FROM ? AND "thing"."second" > ? ) ORDER BY "thing"."id" ASC LIMIT ?`, expectedArgs: []any{"first", int64(10), true, int64(100), "first", int64(10), true, int64(100), 50}, @@ -1999,7 +2221,7 @@ var testCases = []testCase{ } actions { list listThing() { - @where((thing.first == "first" or thing.second == 10) and (thing.third == true or thing.second > 100)) + @where((thing.first == "first" || thing.second == 10) && (thing.third || thing.second > 100)) } } @permission(expression: true, actions: [list]) @@ -2008,13 +2230,13 @@ var testCases = []testCase{ expectedTemplate: ` SELECT DISTINCT ON("thing"."id") "thing".*, CASE WHEN LEAD("thing"."id") OVER (ORDER BY "thing"."id" ASC) IS NOT NULL THEN true ELSE false END AS hasNext, - (SELECT COUNT(DISTINCT "thing"."id") FROM "thing" WHERE ( ( "thing"."first" IS NOT DISTINCT FROM ? OR "thing"."second" IS NOT DISTINCT FROM ? ) AND ( "thing"."third" IS NOT DISTINCT FROM ? OR "thing"."second" > ? ) )) AS totalCount + (SELECT COUNT(DISTINCT "thing"."id") FROM "thing" WHERE ( "thing"."first" IS NOT DISTINCT FROM ? OR "thing"."second" IS NOT DISTINCT FROM ? ) AND ( "thing"."third" IS NOT DISTINCT FROM ? OR "thing"."second" > ? ) ) AS totalCount FROM "thing" WHERE - ( ( "thing"."first" IS NOT DISTINCT FROM ? OR "thing"."second" IS NOT DISTINCT FROM ? ) + ( "thing"."first" IS NOT DISTINCT FROM ? OR "thing"."second" IS NOT DISTINCT FROM ? ) AND - ( "thing"."third" IS NOT DISTINCT FROM ? OR "thing"."second" > ? ) ) + ( "thing"."third" IS NOT DISTINCT FROM ? OR "thing"."second" > ? ) ORDER BY "thing"."id" ASC LIMIT ?`, expectedArgs: []any{"first", int64(10), true, int64(100), "first", int64(10), true, int64(100), 50}, @@ -2030,7 +2252,7 @@ var testCases = []testCase{ } actions { list listThing() { - @where(thing.first == "first" or (thing.second == 10 and (thing.third == true or thing.second > 100))) + @where(thing.first == "first" || (thing.second == 10 && (thing.third || thing.second > 100))) } } @permission(expression: true, actions: [list]) @@ -2039,13 +2261,13 @@ var testCases = []testCase{ expectedTemplate: ` SELECT DISTINCT ON("thing"."id") "thing".*, CASE WHEN LEAD("thing"."id") OVER (ORDER BY "thing"."id" ASC) IS NOT NULL THEN true ELSE false END AS hasNext, - (SELECT COUNT(DISTINCT "thing"."id") FROM "thing" WHERE ( "thing"."first" IS NOT DISTINCT FROM ? OR ( "thing"."second" IS NOT DISTINCT FROM ? AND ( "thing"."third" IS NOT DISTINCT FROM ? OR "thing"."second" > ? ) ) )) AS totalCount + (SELECT COUNT(DISTINCT "thing"."id") FROM "thing" WHERE ( "thing"."first" IS NOT DISTINCT FROM ? OR "thing"."second" IS NOT DISTINCT FROM ? AND ( "thing"."third" IS NOT DISTINCT FROM ? OR "thing"."second" > ? ) )) AS totalCount FROM "thing" WHERE ( "thing"."first" IS NOT DISTINCT FROM ? OR - ( "thing"."second" IS NOT DISTINCT FROM ? AND - ( "thing"."third" IS NOT DISTINCT FROM ? OR "thing"."second" > ? ) ) ) + "thing"."second" IS NOT DISTINCT FROM ? AND + ( "thing"."third" IS NOT DISTINCT FROM ? OR "thing"."second" > ? ) ) ORDER BY "thing"."id" ASC LIMIT ?`, expectedArgs: []any{"first", int64(10), true, int64(100), "first", int64(10), true, int64(100), 50}, @@ -2061,7 +2283,7 @@ var testCases = []testCase{ } actions { list listThing(first, explicitSecond: Number) { - @where(thing.second == explicitSecond or thing.third == false) + @where(thing.second == explicitSecond || thing.third == false) } } @permission(expression: true, actions: [list]) @@ -2096,7 +2318,7 @@ var testCases = []testCase{ } actions { list listThing(first, explicitSecond: Number) { - @where(thing.second == explicitSecond or thing.third == false) + @where(thing.second == explicitSecond || thing.third == false) } } @permission(expression: true, actions: [list]) @@ -2139,7 +2361,7 @@ var testCases = []testCase{ } actions { update updateThing(id) with (name) { - @where(thing.code == "XYZ" or thing.code == "ABC") + @where(thing.code == "XYZ" || thing.code == "ABC") } } @permission(expression: true, actions: [create]) @@ -2180,7 +2402,7 @@ var testCases = []testCase{ } actions { delete deleteThing(id) { - @where(thing.code == "XYZ" or thing.code == "ABC") + @where(thing.code == "XYZ" || thing.code == "ABC") } } @permission(expression: true, actions: [create]) @@ -2847,22 +3069,28 @@ var testCases = []testCase{ input: map[string]any{}, identity: identity, expectedTemplate: ` - WITH - "select_identity" ("column_0", "column_1", "column_2", "column_3", "column_4") AS ( - SELECT "identity"."email", "identity"."created_at", "identity"."email_verified", "identity"."external_id", "identity"."issuer" - FROM "identity" - WHERE "identity"."id" IS NOT DISTINCT FROM ?), + WITH + "select_identity_0" ("column_0") AS + (SELECT "identity"."created_at" FROM "identity" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity"."created_at" IS DISTINCT FROM NULL), + "select_identity_1" ("column_0") AS + (SELECT "identity"."email" FROM "identity" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity"."email" IS DISTINCT FROM NULL), + "select_identity_2" ("column_0") AS + (SELECT "identity"."email_verified" FROM "identity" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity"."email_verified" IS DISTINCT FROM NULL), + "select_identity_3" ("column_0") AS + (SELECT "identity"."external_id" FROM "identity" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity"."external_id" IS DISTINCT FROM NULL), + "select_identity_4" ("column_0") AS + (SELECT "identity"."issuer" FROM "identity" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity"."issuer" IS DISTINCT FROM NULL), "new_1_person" AS ( - INSERT INTO "person" ("created", "email", "email_verified", "external_id", "issuer") + INSERT INTO "person" ("created", "email", "email_verified", "external_id", "issuer") VALUES ( - (SELECT "column_1" FROM "select_identity"), - (SELECT "column_0" FROM "select_identity"), - (SELECT "column_2" FROM "select_identity"), - (SELECT "column_3" FROM "select_identity"), - (SELECT "column_4" FROM "select_identity")) - RETURNING *) - SELECT *, set_identity_id(?) AS __keel_identity_id FROM "new_1_person"`, - expectedArgs: []any{identity[parser.FieldNameId].(string), identity[parser.FieldNameId].(string)}, + (SELECT "column_0" FROM "select_identity_0"), + (SELECT "column_0" FROM "select_identity_1"), + (SELECT "column_0" FROM "select_identity_2"), + (SELECT "column_0" FROM "select_identity_3"), + (SELECT "column_0" FROM "select_identity_4")) + RETURNING *) + SELECT *, set_identity_id(?) AS __keel_identity_id FROM "new_1_person"`, + expectedArgs: []any{identity[parser.FieldNameId].(string), identity[parser.FieldNameId].(string), identity[parser.FieldNameId].(string), identity[parser.FieldNameId].(string), identity[parser.FieldNameId].(string), identity[parser.FieldNameId].(string)}, }, { name: "update_set_ctx_identity_fields", @@ -2890,20 +3118,27 @@ var testCases = []testCase{ input: map[string]any{"where": map[string]any{"id": "xyz"}}, identity: identity, expectedTemplate: ` - WITH - "select_identity" ("column_0", "column_1", "column_2", "column_3", "column_4") AS ( - SELECT "identity"."email", "identity"."created_at", "identity"."email_verified", "identity"."external_id", "identity"."issuer" - FROM "identity" - WHERE "identity"."id" IS NOT DISTINCT FROM ?) - UPDATE "person" SET - "created" = (SELECT "column_1" FROM "select_identity"), - "email" = (SELECT "column_0" FROM "select_identity"), - "email_verified" = (SELECT "column_2" FROM "select_identity"), - "external_id" = (SELECT "column_3" FROM "select_identity"), - "issuer" = (SELECT "column_4" FROM "select_identity") - WHERE "person"."id" IS NOT DISTINCT FROM ? + WITH + "select_identity_0" ("column_0") AS + (SELECT "identity"."created_at" FROM "identity" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity"."created_at" IS DISTINCT FROM NULL), + "select_identity_1" ("column_0") AS + (SELECT "identity"."email" FROM "identity" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity"."email" IS DISTINCT FROM NULL), + "select_identity_2" ("column_0") AS + (SELECT "identity"."email_verified" FROM "identity" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity"."email_verified" IS DISTINCT FROM NULL), + "select_identity_3" ("column_0") AS + (SELECT "identity"."external_id" FROM "identity" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity"."external_id" IS DISTINCT FROM NULL), + "select_identity_4" ("column_0") AS + (SELECT "identity"."issuer" FROM "identity" WHERE "identity"."id" IS NOT DISTINCT FROM ? AND "identity"."issuer" IS DISTINCT FROM NULL) + UPDATE "person" + SET + "created" = (SELECT "column_0" FROM "select_identity_0"), + "email" = (SELECT "column_0" FROM "select_identity_1"), + "email_verified" = (SELECT "column_0" FROM "select_identity_2"), + "external_id" = (SELECT "column_0" FROM "select_identity_3"), + "issuer" = (SELECT "column_0" FROM "select_identity_4") + WHERE "person"."id" IS NOT DISTINCT FROM ? RETURNING "person".*, set_identity_id(?) AS __keel_identity_id`, - expectedArgs: []any{identity[parser.FieldNameId].(string), "xyz", identity[parser.FieldNameId].(string)}, + expectedArgs: []any{identity[parser.FieldNameId].(string), identity[parser.FieldNameId].(string), identity[parser.FieldNameId].(string), identity[parser.FieldNameId].(string), identity[parser.FieldNameId].(string), "xyz", identity[parser.FieldNameId].(string)}, }, { name: "create_array", @@ -3224,7 +3459,7 @@ var testCases = []testCase{ } actions { list listSciencePosts() { - @where("science" not in post.tags) + @where(!("science" in post.tags)) } } }`, @@ -3232,12 +3467,12 @@ var testCases = []testCase{ input: map[string]any{}, expectedTemplate: ` SELECT - DISTINCT ON("post"."id") "post".*, - CASE WHEN LEAD("post"."id") OVER (ORDER BY "post"."id" ASC) IS NOT NULL THEN true ELSE false END AS hasNext, - (SELECT COUNT(DISTINCT "post"."id") FROM "post" WHERE (NOT ? = ANY("post"."tags") OR "post"."tags" IS NOT DISTINCT FROM NULL)) AS totalCount - FROM "post" - WHERE (NOT ? = ANY("post"."tags") OR "post"."tags" IS NOT DISTINCT FROM NULL) - ORDER BY "post"."id" ASC + DISTINCT ON("post"."id") "post".*, + CASE WHEN LEAD("post"."id") OVER (ORDER BY "post"."id" ASC) IS NOT NULL THEN true ELSE false END AS hasNext, + (SELECT COUNT(DISTINCT "post"."id") FROM "post" WHERE NOT (? = ANY("post"."tags"))) AS totalCount + FROM "post" + WHERE NOT (? = ANY("post"."tags")) + ORDER BY "post"."id" ASC LIMIT ?`, expectedArgs: []any{"science", "science", 50}, }, @@ -3340,7 +3575,7 @@ var testCases = []testCase{ } actions { list listInCollection(genre: Text) { - @where(genre not in collection.books.genres) + @where(!(genre in collection.books.genres)) @orderBy(name: asc) } } @@ -3360,15 +3595,15 @@ var testCases = []testCase{ (SELECT COUNT(DISTINCT ("collection"."name", "collection"."id")) FROM "collection" LEFT JOIN "book" AS "collection$books" ON "collection$books"."col_id" = "collection"."id" - WHERE (NOT ? = ANY("collection$books"."genres") OR "collection$books"."genres" IS NOT DISTINCT FROM NULL)) AS totalCount + WHERE NOT (? = ANY("collection$books"."genres"))) AS totalCount FROM "collection" LEFT JOIN "book" AS "collection$books" ON "collection$books"."col_id" = "collection"."id" - WHERE (NOT ? = ANY("collection$books"."genres") OR "collection$books"."genres" IS NOT DISTINCT FROM NULL) + WHERE NOT (? = ANY("collection$books"."genres")) ORDER BY "collection"."name" ASC, "collection"."id" ASC LIMIT ?`, expectedArgs: []any{"fantasy", "fantasy", 50}, }, { - name: "list_nested_array_expression_not_in", + name: "list_nested_array_expression_in_relationship", keelSchema: ` model Collection { fields { @@ -3464,6 +3699,88 @@ var testCases = []testCase{ ORDER BY "book"."id" ASC LIMIT ?`, expectedArgs: []any{identity["id"].(string), identity["id"].(string), 50}, }, + { + name: "not_comparisons", + keelSchema: ` + model Thing { + fields { + number Number + } + actions { + get getThing(id) { + @where(!(thing.number < 18)) + @where(!(thing.number <= 18)) + @where(!(thing.number > 18)) + @where(!(thing.number >= 18)) + } + } + @permission(expression: true, actions: [get]) + }`, + actionName: "getThing", + input: map[string]any{"id": "123"}, + expectedTemplate: ` + SELECT + DISTINCT ON("thing"."id") "thing".* + FROM + "thing" + WHERE + "thing"."id" IS NOT DISTINCT FROM ? AND + NOT ("thing"."number" < ?) AND NOT ("thing"."number" <= ?) AND NOT ("thing"."number" > ?) AND NOT ("thing"."number" >= ?)`, + expectedArgs: []any{"123", int64(18), int64(18), int64(18), int64(18)}, + }, + { + name: "not_parenthesis", + keelSchema: ` + model Thing { + fields { + number Number + } + actions { + get getThing(id) { + @where(!(thing.number < 18) || !(true == true && false)) + } + } + @permission(expression: true, actions: [get]) + }`, + actionName: "getThing", + input: map[string]any{"id": "123"}, + expectedTemplate: ` + SELECT + DISTINCT ON("thing"."id") "thing".* + FROM + "thing" + WHERE + "thing"."id" IS NOT DISTINCT FROM ? AND + (NOT ("thing"."number" < ?) OR NOT (? IS NOT DISTINCT FROM ? AND ? IS NOT DISTINCT FROM ?))`, + expectedArgs: []any{"123", int64(18), true, true, false, true}, + }, + { + name: "not_multiple_conditions", + keelSchema: ` + model Thing { + fields { + number Number + isActive Boolean + } + actions { + get getThing(id) { + @where(!(thing.isActive == true || thing.number != 0)) + } + } + @permission(expression: true, actions: [get]) + }`, + actionName: "getThing", + input: map[string]any{"id": "123"}, + expectedTemplate: ` + SELECT + DISTINCT ON("thing"."id") "thing".* + FROM + "thing" + WHERE + "thing"."id" IS NOT DISTINCT FROM ? AND + NOT ("thing"."is_active" IS NOT DISTINCT FROM ? OR "thing"."number" IS DISTINCT FROM ?)`, + expectedArgs: []any{"123", true, int64(0)}, + }, } func TestQueryBuilder(t *testing.T) { diff --git a/runtime/actions/scope.go b/runtime/actions/scope.go index ca7808056..0dc2885b5 100644 --- a/runtime/actions/scope.go +++ b/runtime/actions/scope.go @@ -142,9 +142,9 @@ func Execute(scope *Scope, input any) (result any, meta *common.ResponseMetadata } func executeCustomFunction(scope *Scope, inputs any) (any, *common.ResponseMetadata, error) { + inputsAsMap, _ := inputs.(map[string]any) permissions := proto.PermissionsForAction(scope.Schema, scope.Action) - - canAuthoriseEarly, authorised, err := TryResolveAuthorisationEarly(scope, permissions) + canAuthoriseEarly, authorised, err := TryResolveAuthorisationEarly(scope, inputsAsMap, permissions) if err != nil { return nil, nil, err } diff --git a/runtime/actions/update.go b/runtime/actions/update.go index 6dbc4d43b..23d04e4e6 100644 --- a/runtime/actions/update.go +++ b/runtime/actions/update.go @@ -11,7 +11,7 @@ import ( func Update(scope *Scope, input map[string]any) (res map[string]any, err error) { // Attempt to resolve permissions early; i.e. before row-based database querying. permissions := proto.PermissionsForAction(scope.Schema, scope.Action) - canResolveEarly, authorised, err := TryResolveAuthorisationEarly(scope, permissions) + canResolveEarly, authorised, err := TryResolveAuthorisationEarly(scope, input, permissions) if err != nil { return nil, err } @@ -104,7 +104,7 @@ func GenerateUpdateStatement(query *QueryBuilder, scope *Scope, input map[string where = map[string]any{} } - err = query.applyImplicitFilters(scope, where) + err = query.ApplyImplicitFilters(scope, where) if err != nil { return nil, err } diff --git a/runtime/actions/writeinputs.go b/runtime/actions/writeinputs.go index 2583fc649..6d0274b7d 100644 --- a/runtime/actions/writeinputs.go +++ b/runtime/actions/writeinputs.go @@ -7,51 +7,30 @@ import ( "github.com/iancoleman/strcase" "github.com/samber/lo" "github.com/teamkeel/keel/casing" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/proto" - "github.com/teamkeel/keel/runtime/auth" - "github.com/teamkeel/keel/runtime/expressions" "github.com/teamkeel/keel/schema/parser" ) // Updates the query with all set attributes defined on the action. func (query *QueryBuilder) captureSetValues(scope *Scope, args map[string]any) error { - model := scope.Schema.FindModel(strcase.ToCamel("identity")) - ctxScope := NewModelScope(scope.Context, model, scope.Schema) - identityQuery := NewQuery(model) - - identityId := "" - if auth.IsAuthenticated(scope.Context) { - identity, err := auth.GetIdentity(scope.Context) - if err != nil { - return err - } - identityId = identity[parser.FieldNameId].(string) - } - - err := identityQuery.Where(IdField(), Equals, Value(identityId)) - if err != nil { - return err - } - for _, setExpression := range scope.Action.SetExpressions { expression, err := parser.ParseExpression(setExpression.Source) if err != nil { return err } - assignment, err := expression.ToAssignmentCondition() + lhs, rhs, err := expression.ToAssignmentExpression() if err != nil { return err } - lhsResolver := expressions.NewOperandResolver(scope.Context, scope.Schema, scope.Model, scope.Action, assignment.LHS) - rhsResolver := expressions.NewOperandResolver(scope.Context, scope.Schema, scope.Model, scope.Action, assignment.RHS) - - if !lhsResolver.IsModelDbColumn() { - return errors.New("lhs operand of assignment expression must be a model field") + target, err := resolve.AsIdent(lhs) + if err != nil { + return err } - fragments, err := lhsResolver.NormalisedFragments() + ident, err := normalisedFragments(scope.Schema, target.Fragments) if err != nil { return err } @@ -59,15 +38,15 @@ func (query *QueryBuilder) captureSetValues(scope *Scope, args map[string]any) e currRows := []*Row{query.writeValues} // The model field to update. - field := fragments[len(fragments)-1] - targetsLessField := fragments[:len(fragments)-1] + field := ident[len(ident)-1] + targetsLessField := ident[:len(target.Fragments)-1] // If we are associating (as opposed to creating) then rather update the foreign key // i.e. person.employerId and not person.employer.id - isAssoc := targetAssociating(scope, fragments) + isAssoc := targetAssociating(scope, ident) if isAssoc { - field = fmt.Sprintf("%sId", fragments[len(fragments)-2]) - targetsLessField = fragments[:len(fragments)-2] + field = fmt.Sprintf("%sId", ident[len(ident)-2]) + targetsLessField = ident[:len(target.Fragments)-2] } // Iterate through the fragments in the @set expression AND traverse the graph until we have a set of rows to update. @@ -96,46 +75,14 @@ func (query *QueryBuilder) captureSetValues(scope *Scope, args map[string]any) e currRows = nextRows } + operand, err := resolve.RunCelVisitor(rhs, GenerateSelectQuery(scope.Context, query, scope.Schema, scope.Model, scope.Action, args)) + if err != nil { + return err + } + // Set the field on all rows. for _, row := range currRows { - if rhsResolver.IsModelDbColumn() { - rhsFragments, err := rhsResolver.NormalisedFragments() - if err != nil { - return err - } - - row.values[field] = ExpressionField(rhsFragments, field) - } else if rhsResolver.IsContextDbColumn() { - // If this is a value from ctx that requires a database read (such as with identity backlinks), - // then construct an inline query for this operand. This is necessary because we can't retrieve this value - // from the current query builder. - - fragments, err := rhsResolver.NormalisedFragments() - if err != nil { - return err - } - - // Remove the ctx fragment - fragments = fragments[1:] - - err = identityQuery.addJoinFromFragments(ctxScope, fragments) - if err != nil { - return err - } - - selectField := ExpressionField(fragments[:len(fragments)-1], fragments[len(fragments)-1]) - - identityQuery.Select(selectField) - - row.values[field] = InlineQuery(identityQuery, selectField) - } else if rhsResolver.IsContextField() || rhsResolver.IsLiteral() || rhsResolver.IsExplicitInput() || rhsResolver.IsImplicitInput() { - value, err := rhsResolver.ResolveValue(args) - if err != nil { - return err - } - - row.values[field] = Value(value) - } + row.values[field] = operand } } return nil diff --git a/runtime/expressions/evaluate.go b/runtime/expressions/evaluate.go deleted file mode 100644 index e6f887634..000000000 --- a/runtime/expressions/evaluate.go +++ /dev/null @@ -1,213 +0,0 @@ -package expressions - -import ( - "context" - "fmt" - - "github.com/segmentio/ksuid" - "github.com/teamkeel/keel/proto" - "github.com/teamkeel/keel/schema/parser" -) - -// TryResolveExpressionEarly attempts to evaluate the expression in the runtime process without generating a row-based query against the database. -func TryResolveExpressionEarly(ctx context.Context, schema *proto.Schema, model *proto.Model, action *proto.Action, expression *parser.Expression, args map[string]any) (canResolveInMemory bool, resolvedValue bool) { - can := false - value := false - - for _, or := range expression.Or { - currCanResolve := true - currExpressionValue := true - - for _, and := range or.And { - if and.Expression != nil { - currCan, currValue := TryResolveExpressionEarly(ctx, schema, model, action, and.Expression, args) - currCanResolve = currCan && currCanResolve - currExpressionValue = currExpressionValue && currValue - } - - if and.Condition != nil { - currCan, currValue := resolveConditionEarly(ctx, schema, model, action, and.Condition, args) - currCanResolve = currCan && currCanResolve - currExpressionValue = currExpressionValue && currValue - } - } - - can = can || currCanResolve - value = value || currExpressionValue - } - - return can, value -} - -// canResolveConditionEarly determines if a single condition can be resolved in the process without generating a row-based query against the database. -func canResolveConditionEarly(ctx context.Context, schema *proto.Schema, model *proto.Model, action *proto.Action, condition *parser.Condition) bool { - lhsResolver := NewOperandResolver(ctx, schema, model, action, condition.LHS) - - if condition.Type() == parser.ValueCondition { - return !lhsResolver.IsModelDbColumn() && !lhsResolver.IsContextDbColumn() - } - - rhsResolver := NewOperandResolver(ctx, schema, model, action, condition.RHS) - referencesDatabaseColumns := lhsResolver.IsModelDbColumn() || rhsResolver.IsModelDbColumn() || lhsResolver.IsContextDbColumn() || rhsResolver.IsContextDbColumn() - - return !(referencesDatabaseColumns) -} - -// resolveConditionEarly resolves a single condition in the process without generating a row-based query against the database. -func resolveConditionEarly(ctx context.Context, schema *proto.Schema, model *proto.Model, action *proto.Action, condition *parser.Condition, args map[string]any) (canResolveEarly bool, resolvedValue bool) { - if !canResolveConditionEarly(ctx, schema, model, action, condition) { - return false, false - } - - lhsResolver := NewOperandResolver(ctx, schema, model, action, condition.LHS) - operandType, _, _ := lhsResolver.GetOperandType() - lhsValue, _ := lhsResolver.ResolveValue(args) - - if condition.Type() == parser.ValueCondition { - result, _ := evaluate(lhsValue, true, operandType, &parser.Operator{Symbol: parser.OperatorEquals}) - return true, result - } - - rhsResolver := NewOperandResolver(ctx, schema, model, action, condition.RHS) - rhsValue, _ := rhsResolver.ResolveValue(args) - result, _ := evaluate(lhsValue, rhsValue, operandType, condition.Operator) - - return true, result -} - -// Evaluate lhs and rhs with an operator in this process. -func evaluate( - lhs any, - rhs any, - operandType proto.Type, - operator *parser.Operator, -) (bool, error) { - // Evaluate when either operand or both are nil - if lhs == nil && rhs == nil { - return true && (operator.Symbol != parser.OperatorNotEquals), nil - } else if lhs == nil || rhs == nil { - return false || (operator.Symbol == parser.OperatorNotEquals), nil - } - - // Evaluate with non-nil operands - switch operandType { - case proto.Type_TYPE_STRING: - return compareString(lhs.(string), rhs.(string), operator) - case proto.Type_TYPE_INT: - // todo: unify these to a single type at the source? - switch v := lhs.(type) { - case int: - // Sourced from GraphQL input parameters. - lhs = int64(v) - case float64: - // Sourced from integration test framework. - lhs = int64(v) - case int32: - // Sourced from database. - lhs = int64(v) // todo: https://linear.app/keel/issue/RUN-98/number-type-as-int32-or-int64 - } - switch v := rhs.(type) { - case int: - // Sourced from GraphQL input parameters. - rhs = int64(v) - case float64: - // Sourced from integration test framework. - rhs = int64(v) - case int32: - // Sourced from database. - rhs = int64(v) // todo: https://linear.app/keel/issue/RUN-98/number-type-as-int32-or-int64 - } - return compareInt(lhs.(int64), rhs.(int64), operator) - case proto.Type_TYPE_BOOL: - return compareBool(lhs.(bool), rhs.(bool), operator) - case proto.Type_TYPE_ENUM: - return compareEnum(lhs.(string), rhs.(string), operator) - case proto.Type_TYPE_ID, proto.Type_TYPE_MODEL: - return compareIdentity(lhs.(ksuid.KSUID), rhs.(ksuid.KSUID), operator) - default: - return false, fmt.Errorf("cannot yet handle comparison of type: %s", operandType) - } -} - -func compareString( - lhs string, - rhs string, - operator *parser.Operator, -) (bool, error) { - switch operator.Symbol { - case parser.OperatorEquals: - return lhs == rhs, nil - case parser.OperatorNotEquals: - return lhs != rhs, nil - default: - return false, fmt.Errorf("operator: %s, not supported for type: %s", operator.Symbol, proto.Type_TYPE_STRING) - } -} - -func compareInt( - lhs int64, - rhs int64, - operator *parser.Operator, -) (bool, error) { - switch operator.Symbol { - case parser.OperatorEquals: - return lhs == rhs, nil - case parser.OperatorNotEquals: - return lhs != rhs, nil - case parser.OperatorGreaterThan: - return lhs > rhs, nil - case parser.OperatorGreaterThanOrEqualTo: - return lhs >= rhs, nil - case parser.OperatorLessThan: - return lhs < rhs, nil - case parser.OperatorLessThanOrEqualTo: - return lhs <= rhs, nil - default: - return false, fmt.Errorf("operator: %s, not supported for type: %s", operator.Symbol, proto.Type_TYPE_INT) - } -} - -func compareBool( - lhs bool, - rhs bool, - operator *parser.Operator, -) (bool, error) { - switch operator.Symbol { - case parser.OperatorEquals: - return lhs == rhs, nil - case parser.OperatorNotEquals: - return lhs != rhs, nil - default: - return false, fmt.Errorf("operator: %s, not supported for type: %s", operator.Symbol, proto.Type_TYPE_BOOL) - } -} - -func compareEnum( - lhs string, - rhs string, - operator *parser.Operator, -) (bool, error) { - switch operator.Symbol { - case parser.OperatorEquals: - return lhs == rhs, nil - case parser.OperatorNotEquals: - return lhs != rhs, nil - default: - return false, fmt.Errorf("operator: %s, not supported for type: %s", operator.Symbol, proto.Type_TYPE_STRING) - } -} - -func compareIdentity( - lhs ksuid.KSUID, - rhs ksuid.KSUID, - operator *parser.Operator, -) (bool, error) { - switch operator.Symbol { - case parser.OperatorEquals: - return lhs == rhs, nil - case parser.OperatorNotEquals: - return lhs != rhs, nil - default: - return false, fmt.Errorf("operator: %s, not supported for type: %s", operator.Symbol, proto.Type_TYPE_ID) - } -} diff --git a/runtime/expressions/operand.go b/runtime/expressions/operand.go index d580121cc..8687dceeb 100644 --- a/runtime/expressions/operand.go +++ b/runtime/expressions/operand.go @@ -1,479 +1,144 @@ package expressions import ( - "context" - "errors" - "fmt" - "net/textproto" - "os" - "strconv" - "strings" - "time" - "github.com/iancoleman/strcase" "github.com/samber/lo" - "github.com/teamkeel/keel/casing" "github.com/teamkeel/keel/proto" - "github.com/teamkeel/keel/runtime/auth" - "github.com/teamkeel/keel/runtime/runtimectx" - "github.com/teamkeel/keel/schema/parser" ) -// OperandResolver hides some of the complexity of expression parsing so that the runtime action code -// can reason about and execute expression logic without stepping through the AST. -type OperandResolver struct { - Context context.Context - Schema *proto.Schema - Model *proto.Model - Action *proto.Action - operand *parser.Operand +// IsModelDbColumn returns true if the expression operand refers to a field value residing in the database. +// For example, a where condition might filter on reading data, +// such as: @where(post.author.isActive) +func IsModelDbColumn(model *proto.Model, fragments []string) bool { + return fragments[0] == strcase.ToLowerCamel(model.Name) } -func NewOperandResolver(ctx context.Context, schema *proto.Schema, model *proto.Model, action *proto.Action, operand *parser.Operand) *OperandResolver { - return &OperandResolver{ - Context: ctx, - Schema: schema, - Model: model, - Action: action, - operand: operand, - } +// IsContextDbColumn returns true if the expression refers to a value on the context +// which will require database access (such as with identity backlinks), +// such as: @permission(expression: ctx.identity.user.isActive) +func IsContextDbColumn(fragments []string) bool { + return IsContextIdentity(fragments) && !IsContextIdentityId(fragments) } -// NormalisedFragments will return the expression fragments "in full" so that they can be processed for query building -// For example, note the two expressions in the condition @where(account in ctx.identity.primaryAccount.following.followee) -// NormalisedFragments will transform each of these operands as follows: -// -// account.id -// ctx.identity.primaryAccount.following.followeeId -func (resolver *OperandResolver) NormalisedFragments() ([]string, error) { - fragments := lo.Map(resolver.operand.Ident.Fragments, func(fragment *parser.IdentFragment, _ int) string { return fragment.Fragment }) - - operandType, _, err := resolver.GetOperandType() - if err != nil { - return nil, err +func IsContextIdentity(fragments []string) bool { + if !IsContext(fragments) { + return false + } + if len(fragments) > 1 && fragments[1] == "identity" { + return true } - if operandType == proto.Type_TYPE_MODEL && len(fragments) == 1 { - // One fragment is only possible if the expression is only referencing the model. - // For example, @where(account in ...) - // Add a new fragment 'id' - fragments = append(fragments, parser.FieldNameId) - } else if operandType == proto.Type_TYPE_MODEL { - i := 0 - if fragments[0] == "ctx" { - i++ - } + return false +} - modelTarget := resolver.Schema.FindModel(casing.ToCamel(fragments[i])) - if modelTarget == nil { - return nil, fmt.Errorf("model '%s' does not exist in schema", casing.ToCamel(fragments[i])) - } +func IsContextIdentityId(fragments []string) bool { + if !IsContextIdentity(fragments) { + return false + } + if len(fragments) == 2 { + return true + } + if len(fragments) == 3 && fragments[2] == "id" { + return true + } - var fieldTarget *proto.Field - for i := i + 1; i < len(fragments); i++ { - fieldTarget = proto.FindField(resolver.Schema.Models, modelTarget.Name, fragments[i]) - if fieldTarget.Type.Type == proto.Type_TYPE_MODEL { - modelTarget = resolver.Schema.FindModel(fieldTarget.Type.ModelName.Value) - if modelTarget == nil { - return nil, fmt.Errorf("model '%s' does not exist in schema", fieldTarget.Type.ModelName.Value) - } - } - } + return false +} - if fieldTarget.IsHasOne() || fieldTarget.IsHasMany() { - // Add a new fragment 'id' - fragments = append(fragments, parser.FieldNameId) - } else { - // Replace the last fragment with the foreign key field - fragments[len(fragments)-1] = fmt.Sprintf("%sId", fragments[len(fragments)-1]) - } +func IsContextIsAuthenticatedField(fragments []string) bool { + if IsContext(fragments) && len(fragments) == 2 { + return fragments[1] == "isAuthenticated" } - return fragments, nil + return false } -// IsLiteral returns true if the expression operand is a literal type. -// For example, a number or string literal written straight into the Keel schema, -// such as the right-hand side operand in @where(person.age > 21). -func (resolver *OperandResolver) IsLiteral() bool { - // Check if literal or array of literals, such as a "keel" or ["keel", "weave"] - isLiteral, _ := resolver.operand.IsLiteralType() - if isLiteral { - return true - } +func IsContextField(fragments []string) bool { + return IsContext(fragments) && !IsContextDbColumn(fragments) +} - // Check if an enum, such as Sport.Cricket - isEnumLiteral := resolver.operand.Ident != nil && proto.EnumExists(resolver.Schema.Enums, resolver.operand.Ident.Fragments[0].Fragment) - if isEnumLiteral { - return true +func IsContextNowField(fragments []string) bool { + if IsContext(fragments) && len(fragments) == 2 { + return fragments[1] == "now" } + return false +} - if resolver.operand.Ident == nil && resolver.operand.Array != nil { - // Check if an empty array, such as [] - isEmptyArray := resolver.operand.Ident == nil && resolver.operand.Array != nil && len(resolver.operand.Array.Values) == 0 - if isEmptyArray { - return true - } +func IsContextHeadersField(fragments []string) bool { + if IsContext(fragments) && len(fragments) == 3 { + return fragments[1] == "headers" + } + return false +} - // Check if an array of enums, such as [Sport.Cricket, Sport.Rugby] - isEnumLiteralArray := true - for _, item := range resolver.operand.Array.Values { - if !proto.EnumExists(resolver.Schema.Enums, item.Ident.Fragments[0].Fragment) { - isEnumLiteralArray = false - } - } - if isEnumLiteralArray { - return true - } +func IsContextEnvField(fragments []string) bool { + if IsContext(fragments) && len(fragments) == 3 { + return fragments[1] == "env" } + return false +} +func IsContextSecretField(fragments []string) bool { + if IsContext(fragments) && len(fragments) == 3 { + return fragments[1] == "secrets" + } return false } +func IsContext(fragments []string) bool { + return fragments[0] == "ctx" +} + // IsImplicitInput returns true if the expression operand refers to an implicit input on an action. // For example, an input value provided in a create action might require validation, // such as: create createThing() with (name) @validation(name != "") -func (resolver *OperandResolver) IsImplicitInput() bool { - isSingleFragment := resolver.operand.Ident != nil && len(resolver.operand.Ident.Fragments) == 1 - - if !isSingleFragment { +func IsImplicitInput(schema *proto.Schema, action *proto.Action, fragments []string) bool { + if len(fragments) <= 1 { return false } foundImplicitWhereInput := false foundImplicitValueInput := false - whereInputs := proto.FindWhereInputMessage(resolver.Schema, resolver.Action.Name) + whereInputs := proto.FindWhereInputMessage(schema, action.Name) if whereInputs != nil { _, foundImplicitWhereInput = lo.Find(whereInputs.Fields, func(in *proto.MessageField) bool { - return in.Name == resolver.operand.Ident.Fragments[0].Fragment && in.IsModelField() + return in.Name == fragments[0] && in.IsModelField() }) } - valuesInputs := proto.FindValuesInputMessage(resolver.Schema, resolver.Action.Name) + valuesInputs := proto.FindValuesInputMessage(schema, action.Name) if valuesInputs != nil { _, foundImplicitValueInput = lo.Find(valuesInputs.Fields, func(in *proto.MessageField) bool { - return in.Name == resolver.operand.Ident.Fragments[0].Fragment && in.IsModelField() + return in.Name == fragments[0] && in.IsModelField() }) } return foundImplicitWhereInput || foundImplicitValueInput } -// IsExplicitInput returns true if the expression operand refers to an explicit input on an action. -// For example, a where condition might use an explicit input, +// IsInput returns true if the expression operand refers to a named input or a model field input on an action. +// For example, for a where condition might use an named input, // such as: list listThings(isActive: Boolean) @where(thing.isActive == isActive) -func (resolver *OperandResolver) IsExplicitInput() bool { - isSingleFragmentIdent := resolver.operand.Ident != nil && len(resolver.operand.Ident.Fragments) == 1 - - if !isSingleFragmentIdent { - return false - } - +// Or a model field input, +// such as: list listThings(thing.isActive) +func IsInput(schema *proto.Schema, action *proto.Action, fragments []string) bool { foundExplicitWhereInput := false foundExplicitValueInput := false - whereInputs := proto.FindWhereInputMessage(resolver.Schema, resolver.Action.Name) + whereInputs := proto.FindWhereInputMessage(schema, action.Name) if whereInputs != nil { _, foundExplicitWhereInput = lo.Find(whereInputs.Fields, func(in *proto.MessageField) bool { - return in.Name == resolver.operand.Ident.Fragments[0].Fragment && !in.IsModelField() + return in.Name == fragments[0] }) } - valuesInputs := proto.FindValuesInputMessage(resolver.Schema, resolver.Action.Name) + valuesInputs := proto.FindValuesInputMessage(schema, action.Name) if valuesInputs != nil { _, foundExplicitValueInput = lo.Find(valuesInputs.Fields, func(in *proto.MessageField) bool { - return in.Name == resolver.operand.Ident.Fragments[0].Fragment && !in.IsModelField() + return in.Name == fragments[0] }) } return foundExplicitWhereInput || foundExplicitValueInput } - -// IsModelDbColumn returns true if the expression operand refers to a field value residing in the database. -// For example, a where condition might filter on reading data, -// such as: @where(post.author.isActive) -func (resolver *OperandResolver) IsModelDbColumn() bool { - return !resolver.IsLiteral() && - !resolver.IsContext() && - !resolver.IsExplicitInput() && - !resolver.IsImplicitInput() -} - -// IsContextDbColumn returns true if the expression refers to a value on the context -// which will require database access (such as with identity backlinks), -// such as: @permission(expression: ctx.identity.user.isActive) -func (resolver *OperandResolver) IsContextDbColumn() bool { - return resolver.operand.Ident.IsContextIdentity() && !resolver.operand.Ident.IsContextIdentityId() -} - -// IsContextField returns true if the expression operand refers to a value on the context -// which does not require to be read from the database. -// For example, a permission condition may check against the current identity, -// such as: @permission(thing.identity == ctx.identity) -// -// However if the expression traverses onwards from identity (using an Identity-backlink) -// like this: -// "ctx.identity.user" -// then it returns false, because that can no longer be resolved solely from the -// in memory context data. -func (resolver *OperandResolver) IsContextField() bool { - return resolver.operand.Ident.IsContext() && !resolver.IsContextDbColumn() -} - -func (resolver *OperandResolver) IsContext() bool { - return resolver.operand.Ident.IsContext() -} - -// GetOperandType returns the equivalent protobuf type for the expression operand and whether it is an array or not -func (resolver *OperandResolver) GetOperandType() (proto.Type, bool, error) { - operand := resolver.operand - action := resolver.Action - schema := resolver.Schema - - switch { - case resolver.IsLiteral(): - if operand.Ident == nil { - switch { - case operand.String != nil: - return proto.Type_TYPE_STRING, false, nil - case operand.Number != nil: - return proto.Type_TYPE_INT, false, nil - case operand.Decimal != nil: - return proto.Type_TYPE_DECIMAL, false, nil - case operand.True || operand.False: - return proto.Type_TYPE_BOOL, false, nil - case operand.Array != nil: - return proto.Type_TYPE_UNKNOWN, true, nil - case operand.Null: - return proto.Type_TYPE_UNKNOWN, false, nil - default: - return proto.Type_TYPE_UNKNOWN, false, fmt.Errorf("cannot handle operand type") - } - } else if resolver.operand.Ident != nil && proto.EnumExists(resolver.Schema.Enums, resolver.operand.Ident.Fragments[0].Fragment) { - return proto.Type_TYPE_ENUM, false, nil - } else { - return proto.Type_TYPE_UNKNOWN, false, fmt.Errorf("unknown literal type") - } - - case resolver.IsModelDbColumn(), resolver.IsContextDbColumn(): - fragments := operand.Ident.Fragments - - if resolver.IsContextDbColumn() { - // If this is a context backlink, then remove the first "ctx" fragment. - fragments = fragments[1:] - } - - // The first fragment will always be the root model name, e.g. "author" in author.posts.title - modelTarget := schema.FindModel(casing.ToCamel(fragments[0].Fragment)) - if modelTarget == nil { - return proto.Type_TYPE_UNKNOWN, false, fmt.Errorf("model '%s' does not exist in schema", casing.ToCamel(fragments[0].Fragment)) - } - - var fieldTarget *proto.Field - for i := 1; i < len(fragments); i++ { - fieldTarget = proto.FindField(schema.Models, modelTarget.Name, fragments[i].Fragment) - if fieldTarget.Type.Type == proto.Type_TYPE_MODEL { - modelTarget = schema.FindModel(fieldTarget.Type.ModelName.Value) - if modelTarget == nil { - return proto.Type_TYPE_UNKNOWN, false, fmt.Errorf("model '%s' does not exist in schema", fieldTarget.Type.ModelName.Value) - } - } - } - - // If no field is provided, for example: @where(account in ...) - // Or if the target field is a MODEL, for example: - if fieldTarget == nil || fieldTarget.Type.Type == proto.Type_TYPE_MODEL { - return proto.Type_TYPE_MODEL, false, nil - } - - return fieldTarget.Type.Type, fieldTarget.Type.Repeated, nil - case resolver.IsImplicitInput(): - modelTarget := casing.ToCamel(action.ModelName) - inputName := operand.Ident.Fragments[0].Fragment - field := proto.FindField(schema.Models, modelTarget, inputName) - return field.Type.Type, field.Type.Repeated, nil - case resolver.IsExplicitInput(): - inputName := operand.Ident.Fragments[0].Fragment - var field *proto.MessageField - switch action.Type { - case proto.ActionType_ACTION_TYPE_CREATE: - message := proto.FindValuesInputMessage(schema, action.Name) - field = message.FindField(inputName) - case proto.ActionType_ACTION_TYPE_GET, proto.ActionType_ACTION_TYPE_LIST, proto.ActionType_ACTION_TYPE_DELETE: - message := proto.FindWhereInputMessage(schema, action.Name) - field = message.FindField(inputName) - case proto.ActionType_ACTION_TYPE_UPDATE: - message := proto.FindValuesInputMessage(schema, action.Name) - field = message.FindField(inputName) - if field == nil { - message := proto.FindWhereInputMessage(schema, action.Name) - field = message.FindField(inputName) - } - default: - return proto.Type_TYPE_UNKNOWN, false, fmt.Errorf("unhandled action type %s for explicit input", action.Type) - } - if field == nil { - return proto.Type_TYPE_UNKNOWN, false, fmt.Errorf("could not find explicit input %s on action %s", inputName, action.Name) - } - return field.Type.Type, field.Type.Repeated, nil - case resolver.operand.Ident.IsContextNowField(): - return proto.Type_TYPE_TIMESTAMP, false, nil - case resolver.operand.Ident.IsContextEnvField(): - return proto.Type_TYPE_STRING, false, nil - case resolver.operand.Ident.IsContextSecretField(): - return proto.Type_TYPE_STRING, false, nil - case resolver.operand.Ident.IsContextHeadersField(): - return proto.Type_TYPE_STRING, false, nil - case operand.Ident.IsContext(): - fieldName := operand.Ident.Fragments[1].Fragment - return runtimectx.ContextFieldTypes[fieldName], false, nil - default: - return proto.Type_TYPE_UNKNOWN, false, fmt.Errorf("cannot handle operand target %s", operand.Ident.Fragments[0].Fragment) - } -} - -// ResolveValue returns the actual value of the operand, provided a database read is not required. -func (resolver *OperandResolver) ResolveValue(args map[string]any) (any, error) { - operandType, _, err := resolver.GetOperandType() - if err != nil { - return nil, err - } - - switch { - case resolver.IsLiteral(): - isLiteral, _ := resolver.operand.IsLiteralType() - if isLiteral { - return toNative(resolver.operand, operandType) - } else if resolver.operand.Ident != nil && proto.EnumExists(resolver.Schema.Enums, resolver.operand.Ident.Fragments[0].Fragment) { - return resolver.operand.Ident.Fragments[1].Fragment, nil - } else if resolver.operand.Ident == nil && resolver.operand.Array != nil { - values := []string{} - enum := "" - for _, v := range resolver.operand.Array.Values { - if !proto.EnumExists(resolver.Schema.Enums, v.Ident.Fragments[0].Fragment) { - return nil, fmt.Errorf("unknown enum type '%s'", v.Ident.Fragments[0].Fragment) - } - - if enum == "" { - enum = v.Ident.Fragments[0].Fragment - } else if enum != v.Ident.Fragments[0].Fragment { - return nil, fmt.Errorf("enum '%s' array cannot have value from enum type '%s'", enum, v.Ident.Fragments[0].Fragment) - } - - values = append(values, v.Ident.Fragments[1].Fragment) - } - return values, nil - } - - return nil, errors.New("unknown literal type") - - case resolver.IsImplicitInput(), resolver.IsExplicitInput(): - inputName := resolver.operand.Ident.Fragments[0].Fragment - value, ok := args[inputName] - if !ok { - return nil, fmt.Errorf("implicit or explicit input '%s' does not exist in arguments", inputName) - } - return value, nil - case resolver.IsModelDbColumn(), resolver.IsContextDbColumn(): - // todo: https://linear.app/keel/issue/RUN-153/set-attribute-to-support-targeting-database-fields - panic("cannot resolve operand value from the database") - case resolver.operand.Ident.IsContextIdentityId(): - isAuthenticated := auth.IsAuthenticated(resolver.Context) - if !isAuthenticated { - return nil, nil - } - - identity, err := auth.GetIdentity(resolver.Context) - if err != nil { - return nil, err - } - return identity[parser.FieldNameId].(string), nil - case resolver.operand.Ident.IsContextIsAuthenticatedField(): - isAuthenticated := auth.IsAuthenticated(resolver.Context) - return isAuthenticated, nil - case resolver.operand.Ident.IsContextNowField(): - return runtimectx.GetNow(), nil - case resolver.operand.Ident.IsContextEnvField(): - envVarName := resolver.operand.Ident.Fragments[2].Fragment - return os.Getenv(envVarName), nil - case resolver.operand.Ident.IsContextSecretField(): - secret := resolver.operand.Ident.Fragments[2].Fragment - return runtimectx.GetSecret(resolver.Context, secret) - case resolver.operand.Ident.IsContextHeadersField(): - headerName := resolver.operand.Ident.Fragments[2].Fragment - - // First we parse the header name to kebab. MyCustomHeader will become my-custom-header. - kebab := strcase.ToKebab(headerName) - - // Then get canonical name. my-custom-header will become My-Custom-Header. - // https://pkg.go.dev/net/http#Header.Get - canonicalName := textproto.CanonicalMIMEHeaderKey(kebab) - - headers, err := runtimectx.GetRequestHeaders(resolver.Context) - if err != nil { - return nil, err - } - if value, ok := headers[canonicalName]; ok { - return strings.Join(value, ", "), nil - } - return "", nil - case resolver.operand.Type() == parser.TypeArray: - return nil, fmt.Errorf("cannot yet handle operand of type non-literal array") - default: - return nil, fmt.Errorf("cannot handle operand of unknown type") - } -} - -func toNative(v *parser.Operand, fieldType proto.Type) (any, error) { - if v.Array != nil { - values := []any{} - for _, v := range v.Array.Values { - value, err := toNative(v, fieldType) - if err != nil { - return nil, err - } - values = append(values, value) - } - return values, nil - } - - switch { - case v.False: - return false, nil - case v.True: - return true, nil - case v.Number != nil: - return *v.Number, nil - case v.Decimal != nil: - return *v.Decimal, nil - case v.String != nil: - v := *v.String - v = strings.TrimPrefix(v, `"`) - v = strings.TrimSuffix(v, `"`) - switch fieldType { - case proto.Type_TYPE_DATE: - return toDate(v), nil - case proto.Type_TYPE_DATETIME, proto.Type_TYPE_TIMESTAMP: - return toTime(v), nil - } - return v, nil - case v.Null: - return nil, nil - default: - return nil, fmt.Errorf("toNative() does yet support this expression operand: %+v", v) - } -} - -func toDate(s string) time.Time { - segments := strings.Split(s, `/`) - day, _ := strconv.Atoi(segments[0]) - month, _ := strconv.Atoi(segments[1]) - year, _ := strconv.Atoi(segments[2]) - return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) -} - -func toTime(s string) time.Time { - tm, _ := time.Parse(time.RFC3339, s) - return tm -} diff --git a/runtime/runtime.go b/runtime/runtime.go index 4b24212be..dffa1ecfa 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -195,7 +195,7 @@ func (handler JobHandler) RunJob(ctx context.Context, jobName string, input map[ if trigger == functions.ManualTrigger { // Check if authorisation can be achieved early. - canAuthoriseEarly, authorised, err := actions.TryResolveAuthorisationEarly(scope, job.Permissions) + canAuthoriseEarly, authorised, err := actions.TryResolveAuthorisationEarly(scope, input, job.Permissions) if err != nil { return err } diff --git a/schema/attributes/composite_unique.go b/schema/attributes/composite_unique.go new file mode 100644 index 000000000..3510d4781 --- /dev/null +++ b/schema/attributes/composite_unique.go @@ -0,0 +1,26 @@ +package attributes + +import ( + "github.com/teamkeel/keel/expressions" + "github.com/teamkeel/keel/expressions/options" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/query" + "github.com/teamkeel/keel/schema/validation/errorhandling" +) + +func ValidateCompositeUnique(model *parser.ModelNode, expression *parser.Expression) ([]*errorhandling.ValidationError, error) { + opts := []expressions.Option{ + options.WithReturnTypeAssertion("_FieldName", true), + } + + for _, f := range query.ModelFields(model) { + opts = append(opts, options.WithConstant(f.Name.Value, "_FieldName")) + } + + p, err := expressions.NewParser(opts...) + if err != nil { + return nil, err + } + + return p.Validate(expression) +} diff --git a/schema/attributes/composite_unique_test.go b/schema/attributes/composite_unique_test.go new file mode 100644 index 000000000..bb8fbd077 --- /dev/null +++ b/schema/attributes/composite_unique_test.go @@ -0,0 +1,83 @@ +package attributes_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/teamkeel/keel/schema/attributes" + "github.com/teamkeel/keel/schema/query" + "github.com/teamkeel/keel/schema/reader" +) + +func TestUnique_Valid(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Account { + fields { + code Text + country Country + } + @unique([code, country]) + } + enum Country { + ZA + UK + US + }`}) + + model := query.Model(schema, "Account") + expression := model.Sections[1].Attribute.Arguments[0].Expression + + issues, err := attributes.ValidateCompositeUnique(model, expression) + require.NoError(t, err) + require.Len(t, issues, 0) +} + +func TestUnique_NotArray(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Account { + fields { + code Text + country Country + } + @unique(code) + } + enum Country { + ZA + UK + US + }`}) + + model := query.Model(schema, "Account") + expression := model.Sections[1].Attribute.Arguments[0].Expression + + issues, err := attributes.ValidateCompositeUnique(model, expression) + require.NoError(t, err) + require.Len(t, issues, 1) + + require.Equal(t, "expression expected to resolve to type FieldName[] but it is FieldName", issues[0].Message) +} + +func TestUnique_UnknownIdentifier(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Account { + fields { + code Text + country Country + } + @unique([unknown]) + } + enum Country { + ZA + UK + US + }`}) + + model := query.Model(schema, "Account") + expression := model.Sections[1].Attribute.Arguments[0].Expression + + issues, err := attributes.ValidateCompositeUnique(model, expression) + require.NoError(t, err) + require.Len(t, issues, 1) + + require.Equal(t, "unknown identifier 'unknown'", issues[0].Message) +} diff --git a/schema/attributes/computed.go b/schema/attributes/computed.go new file mode 100644 index 000000000..3e1c1d5cc --- /dev/null +++ b/schema/attributes/computed.go @@ -0,0 +1,28 @@ +package attributes + +import ( + "github.com/iancoleman/strcase" + "github.com/teamkeel/keel/expressions" + "github.com/teamkeel/keel/expressions/options" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/validation/errorhandling" +) + +func ValidateComputedExpression(schema []*parser.AST, model *parser.ModelNode, field *parser.FieldNode, expression *parser.Expression) ([]*errorhandling.ValidationError, error) { + opts := []expressions.Option{ + options.WithSchemaTypes(schema), + options.WithVariable(strcase.ToLowerCamel(model.Name.Value), model.Name.Value, false), + options.WithVariable(parser.ThisVariable, model.Name.Value, false), + options.WithComparisonOperators(), + options.WithLogicalOperators(), + options.WithArithmeticOperators(), + options.WithReturnTypeAssertion(field.Type.Value, field.Repeated), + } + + p, err := expressions.NewParser(opts...) + if err != nil { + return nil, err + } + + return p.Validate(expression) +} diff --git a/schema/attributes/default.go b/schema/attributes/default.go new file mode 100644 index 000000000..37dd1df75 --- /dev/null +++ b/schema/attributes/default.go @@ -0,0 +1,27 @@ +package attributes + +import ( + "github.com/teamkeel/keel/expressions" + "github.com/teamkeel/keel/expressions/options" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/validation/errorhandling" +) + +func ValidateDefaultExpression(schema []*parser.AST, field *parser.FieldNode, expression *parser.Expression) ([]*errorhandling.ValidationError, error) { + returnType := field.Type.Value + if field.Type.Value == parser.FieldTypeID { + returnType = parser.FieldTypeText + } + + opts := []expressions.Option{ + options.WithSchemaTypes(schema), + options.WithReturnTypeAssertion(returnType, field.Repeated), + } + + p, err := expressions.NewParser(opts...) + if err != nil { + return nil, err + } + + return p.Validate(expression) +} diff --git a/schema/attributes/default_test.go b/schema/attributes/default_test.go new file mode 100644 index 000000000..39810617c --- /dev/null +++ b/schema/attributes/default_test.go @@ -0,0 +1,256 @@ +package attributes_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/teamkeel/keel/schema/attributes" + "github.com/teamkeel/keel/schema/query" + "github.com/teamkeel/keel/schema/reader" +) + +func TestDefault_ValidString(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + name Text @default("Keelson") + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "name") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestDefault_InvalidString(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + name Text @default(1) + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "name") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Len(t, issues, 1) + + require.Equal(t, "expression expected to resolve to type Text but it is Number", issues[0].Message) +} + +func TestDefault_ValidStringArray(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + names Text[] @default(["Keelson", "Weave"]) + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "names") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestDefault_InValidStringArray(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + names Text[] @default("Keelson") + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "names") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Len(t, issues, 1) + require.Equal(t, "expression expected to resolve to type Text[] but it is Text", issues[0].Message) +} + +func TestDefault_ValidNumber(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + age Number @default(123) + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "age") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestDefault_ValidNumberFromDecimal(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + age Number @default(1.5) + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "age") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Len(t, issues, 0) +} + +func TestDefault_ValidDecimalFromNumber(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + age Decimal @default(1) + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "age") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Len(t, issues, 0) +} + +func TestDefault_ValidID(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + personId ID @default("123") + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "personId") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestDefault_InvalidID(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + personId ID @default(123) + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "personId") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Len(t, issues, 1) + require.Equal(t, "expression expected to resolve to type Text but it is Number", issues[0].Message) +} + +func TestDefault_ValidBooleanb(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + isEmployed Boolean @default(false) + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "isEmployed") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestDefault_InvalidBoolean(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + isEmployed Boolean @default(1) + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "isEmployed") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Len(t, issues, 1) + require.Equal(t, "expression expected to resolve to type Boolean but it is Number", issues[0].Message) +} + +func TestDefault_InvalidWithOperators(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + isEmployed Boolean @default(true == true) + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "isEmployed") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Len(t, issues, 1) + require.Equal(t, "operator '==' not supported in this context", issues[0].Message) +} + +func TestDefault_InvalidWithCtx(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + isEmployed Boolean @default(ctx.isAuthenticated) + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "isEmployed") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Len(t, issues, 1) + require.Equal(t, "unknown identifier 'ctx'", issues[0].Message) +} + +func TestDefault_InvalidArithmetic(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + num Number @default(1 + 1) + } + }`}) + + model := query.Model(schema, "Person") + field := query.Field(model, "num") + expression := field.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateDefaultExpression(schema, field, expression) + require.NoError(t, err) + require.Len(t, issues, 1) + require.Equal(t, "operator '+' not supported in this context", issues[0].Message) +} diff --git a/schema/attributes/permission.go b/schema/attributes/permission.go new file mode 100644 index 000000000..bf84056f2 --- /dev/null +++ b/schema/attributes/permission.go @@ -0,0 +1,68 @@ +package attributes + +import ( + "github.com/iancoleman/strcase" + "github.com/teamkeel/keel/expressions" + "github.com/teamkeel/keel/expressions/options" + "github.com/teamkeel/keel/expressions/typing" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/validation/errorhandling" +) + +func ValidatePermissionExpression(schema []*parser.AST, model *parser.ModelNode, action *parser.ActionNode, job *parser.JobNode, expression *parser.Expression) ([]*errorhandling.ValidationError, error) { + opts := []expressions.Option{ + options.WithCtx(), + options.WithSchemaTypes(schema), + options.WithComparisonOperators(), + options.WithLogicalOperators(), + options.WithReturnTypeAssertion(parser.FieldTypeBoolean, false), + } + + if action != nil { + opts = append(opts, options.WithActionInputs(schema, action)) + } + + if model != nil { + opts = append(opts, options.WithVariable(strcase.ToLowerCamel(model.Name.Value), model.Name.Value, false)) + opts = append(opts, options.WithVariable(parser.ThisVariable, model.Name.Value, false)) + } + + p, err := expressions.NewParser(opts...) + if err != nil { + return nil, err + } + + return p.Validate(expression) +} + +func ValidatePermissionRoles(schema []*parser.AST, expression *parser.Expression) ([]*errorhandling.ValidationError, error) { + opts := []expressions.Option{ + options.WithSchemaTypes(schema), + options.WithReturnTypeAssertion(typing.Role.String(), true), + } + + p, err := expressions.NewParser(opts...) + if err != nil { + return nil, err + } + + return p.Validate(expression) +} + +func ValidatePermissionActions(expression *parser.Expression) ([]*errorhandling.ValidationError, error) { + opts := []expressions.Option{ + options.WithConstant(parser.ActionTypeGet, "_ActionType"), + options.WithConstant(parser.ActionTypeCreate, "_ActionType"), + options.WithConstant(parser.ActionTypeUpdate, "_ActionType"), + options.WithConstant(parser.ActionTypeList, "_ActionType"), + options.WithConstant(parser.ActionTypeDelete, "_ActionType"), + options.WithReturnTypeAssertion("_ActionType", true), + } + + p, err := expressions.NewParser(opts...) + if err != nil { + return nil, err + } + + return p.Validate(expression) +} diff --git a/schema/attributes/permission_test.go b/schema/attributes/permission_test.go new file mode 100644 index 000000000..ac5a8d621 --- /dev/null +++ b/schema/attributes/permission_test.go @@ -0,0 +1,102 @@ +package attributes_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/teamkeel/keel/schema/attributes" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/query" + "github.com/teamkeel/keel/schema/reader" +) + +func TestPermissionRole_Valid(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + actions { + list listPeople() { + @permission(roles: [Admin]) + } + } + } + role Admin { + }`}) + + action := query.Action(schema, "listPeople") + expression := action.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidatePermissionRoles(schema, expression) + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestPermissionRole_InvalidNotArray(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + actions { + list listPeople() { + @permission(roles: Admin) + } + } + } + role Admin { + }`}) + + action := query.Action(schema, "listPeople") + expression := action.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidatePermissionRoles(schema, expression) + require.NoError(t, err) + require.Len(t, issues, 1) + require.Equal(t, "expression expected to resolve to type Role[] but it is Role", issues[0].Message) +} + +func TestPermissionRole_UnknownRole(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + actions { + list listPeople() { + @permission(roles: Unknown) + } + } + } + role Admin { + }`}) + + action := query.Action(schema, "listPeople") + expression := action.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidatePermissionRoles(schema, expression) + require.NoError(t, err) + require.Len(t, issues, 1) + require.Equal(t, "Unknown is not a role defined in your schema", issues[0].Message) +} + +func TestPermissionActions_Valid(t *testing.T) { + expression, err := parser.ParseExpression("[get, list, create, update, delete]") + require.NoError(t, err) + + issues, err := attributes.ValidatePermissionActions(expression) + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestPermissionActions_NotArray(t *testing.T) { + expression, err := parser.ParseExpression("list") + require.NoError(t, err) + + issues, err := attributes.ValidatePermissionActions(expression) + require.NoError(t, err) + require.Len(t, issues, 1) + require.Equal(t, "expression expected to resolve to type ActionType[] but it is ActionType", issues[0].Message) +} + +func TestPermissionActions_UnknownValue(t *testing.T) { + expression, err := parser.ParseExpression("[list,write]") + require.NoError(t, err) + + issues, err := attributes.ValidatePermissionActions(expression) + require.NoError(t, err) + require.Len(t, issues, 1) + require.Equal(t, "unknown identifier 'write'", issues[0].Message) +} diff --git a/schema/attributes/set.go b/schema/attributes/set.go new file mode 100644 index 000000000..84b46db68 --- /dev/null +++ b/schema/attributes/set.go @@ -0,0 +1,77 @@ +package attributes + +import ( + "fmt" + + "github.com/iancoleman/strcase" + + "github.com/teamkeel/keel/expressions" + "github.com/teamkeel/keel/expressions/options" + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/query" + "github.com/teamkeel/keel/schema/validation/errorhandling" +) + +func ValidateSetExpression(schema []*parser.AST, action *parser.ActionNode, lhs *parser.Expression, rhs *parser.Expression) ([]*errorhandling.ValidationError, error) { + model := query.ActionModel(schema, action.Name.Value) + + lhsOpts := []expressions.Option{ + options.WithSchemaTypes(schema), + options.WithVariable(strcase.ToLowerCamel(model.Name.Value), model.Name.Value, false), + } + + lhsParser, err := expressions.NewParser(lhsOpts...) + if err != nil { + return nil, err + } + + issues, err := lhsParser.Validate(lhs) + if err != nil { + return nil, err + } + + if len(issues) > 0 { + return issues, err + } + + targetField, err := resolve.AsIdent(lhs) + if err != nil { + return nil, err + } + + if len(targetField.Fragments) < 2 { + return nil, fmt.Errorf("lhs operand is less than two fragments") + } + + if targetField.Fragments[0] != strcase.ToLowerCamel(model.Name.Value) { + return nil, fmt.Errorf("wrong model") + } + + var field *parser.FieldNode + currModel := model + for i, fragment := range targetField.Fragments { + if i == 0 { + continue + } + field = query.Field(currModel, fragment) + if i < len(targetField.Fragments)-1 { + currModel = query.Model(schema, field.Type.Value) + } + } + + rhsOpts := []expressions.Option{ + options.WithCtx(), + options.WithSchemaTypes(schema), + options.WithVariable(strcase.ToLowerCamel(model.Name.Value), model.Name.Value, false), + options.WithActionInputs(schema, action), + options.WithReturnTypeAssertion(field.Type.Value, field.Repeated), + } + + rhsParser, err := expressions.NewParser(rhsOpts...) + if err != nil { + return nil, err + } + + return rhsParser.Validate(rhs) +} diff --git a/schema/attributes/set_test.go b/schema/attributes/set_test.go new file mode 100644 index 000000000..1ed816f08 --- /dev/null +++ b/schema/attributes/set_test.go @@ -0,0 +1,52 @@ +package attributes_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/schema/attributes" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/query" + "github.com/teamkeel/keel/schema/reader" +) + +func TestSet_Valid(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + name Text + isActive Boolean + } + actions { + create createPerson(name) { + @set(person.isActive = true) + } + } + }`}) + + action := query.Action(schema, "createPerson") + set := action.Attributes[0] + + target, expression, err := set.Arguments[0].Expression.ToAssignmentExpression() + require.NoError(t, err) + + lhs, err := resolve.AsIdent(target) + require.NoError(t, err) + + require.Equal(t, "person", lhs.Fragments[0]) + require.Equal(t, "isActive", lhs.Fragments[1]) + + issues, err := attributes.ValidateSetExpression(schema, action, target, expression) + require.NoError(t, err) + require.Empty(t, issues) +} + +func parse(t *testing.T, s *reader.SchemaFile) []*parser.AST { + schema, err := parser.Parse(s) + if err != nil { + require.Fail(t, err.Error()) + } + + return []*parser.AST{schema} +} diff --git a/schema/attributes/where.go b/schema/attributes/where.go new file mode 100644 index 000000000..8cd5e4c1a --- /dev/null +++ b/schema/attributes/where.go @@ -0,0 +1,32 @@ +package attributes + +import ( + "github.com/iancoleman/strcase" + "github.com/teamkeel/keel/expressions" + "github.com/teamkeel/keel/expressions/options" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/query" + "github.com/teamkeel/keel/schema/validation/errorhandling" +) + +func ValidateWhereExpression(schema []*parser.AST, action *parser.ActionNode, expression *parser.Expression) ([]*errorhandling.ValidationError, error) { + model := query.ActionModel(schema, action.Name.Value) + + opts := []expressions.Option{ + options.WithCtx(), + options.WithSchemaTypes(schema), + options.WithActionInputs(schema, action), + options.WithVariable(strcase.ToLowerCamel(model.Name.Value), model.Name.Value, false), + options.WithVariable(parser.ThisVariable, model.Name.Value, false), + options.WithComparisonOperators(), + options.WithLogicalOperators(), + options.WithReturnTypeAssertion(parser.FieldTypeBoolean, false), + } + + p, err := expressions.NewParser(opts...) + if err != nil { + return nil, err + } + + return p.Validate(expression) +} diff --git a/schema/attributes/where_test.go b/schema/attributes/where_test.go new file mode 100644 index 000000000..605f69f7a --- /dev/null +++ b/schema/attributes/where_test.go @@ -0,0 +1,212 @@ +package attributes_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/teamkeel/keel/schema/attributes" + "github.com/teamkeel/keel/schema/query" + "github.com/teamkeel/keel/schema/reader" +) + +func TestWhere_Valid(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + name Text + isActive Boolean + } + actions { + list listPeople(name) { + @where(person.name == "Keel") + } + } + }`}) + + action := query.Action(schema, "listPeople") + expression := action.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateWhereExpression(schema, action, expression) + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestWhere_This(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + name Text + isActive Boolean + } + actions { + list listPeople(name) { + @where(this.name == "Keel") + } + } + }`}) + + action := query.Action(schema, "listPeople") + expression := action.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateWhereExpression(schema, action, expression) + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestWhere_InvalidType(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + name Text + isActive Boolean + } + actions { + list listPeople(name) { + @where(person.name == 123) + } + } + }`}) + + action := query.Action(schema, "listPeople") + expression := action.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateWhereExpression(schema, action, expression) + require.NoError(t, err) + + require.Len(t, issues, 1) + require.Equal(t, "cannot use operator '==' with types Text and Number", issues[0].Message) +} + +func TestWhere_UnknownVariable(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + name Text + isActive Boolean + } + actions { + list listPeople(name) { + @where(person.name == something) + } + } + }`}) + + action := query.Action(schema, "listPeople") + expression := action.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateWhereExpression(schema, action, expression) + require.NoError(t, err) + require.Len(t, issues, 1) + require.Equal(t, "unknown identifier 'something'", issues[0].Message) +} + +func TestWhere_ValidField(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + name Text + secondName Text + } + actions { + list listPeople(name) { + @where(person.name == person.secondName) + } + } + }`}) + + action := query.Action(schema, "listPeople") + expression := action.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateWhereExpression(schema, action, expression) + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestWhere_UnknownField(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + name Text + secondName Text + } + actions { + list listPeople(name) { + @where(person.name == person.something) + } + } + }`}) + + action := query.Action(schema, "listPeople") + expression := action.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateWhereExpression(schema, action, expression) + require.NoError(t, err) + require.Len(t, issues, 1) + require.Equal(t, "field 'something' does not exist", issues[0].Message) +} + +func TestWhere_NamedInput(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + name Text + isActive Boolean + } + actions { + list listPeople(n: Text) { + @where(person.name == n) + } + } + }`}) + + action := query.Action(schema, "listPeople") + expression := action.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateWhereExpression(schema, action, expression) + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestWhere_FieldInput(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + name Text + isActive Boolean + } + actions { + list listPeople(name) { + @where(person.name == name) + } + } + }`}) + + action := query.Action(schema, "listPeople") + expression := action.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateWhereExpression(schema, action, expression) + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestWhere_MultiConditions(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Person { + fields { + name Text + isActive Boolean + } + actions { + list listPeople() { + @where(person.name == "Keel" && person.isActive) + } + } + }`}) + + action := query.Action(schema, "listPeople") + expression := action.Attributes[0].Arguments[0].Expression + + issues, err := attributes.ValidateWhereExpression(schema, action, expression) + require.NoError(t, err) + require.Empty(t, issues) +} diff --git a/schema/backlinks.go b/schema/backlinks.go index aa0f4cae0..795059c61 100644 --- a/schema/backlinks.go +++ b/schema/backlinks.go @@ -2,6 +2,7 @@ package schema import ( "github.com/teamkeel/keel/casing" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/query" "github.com/teamkeel/keel/schema/validation/errorhandling" @@ -61,8 +62,8 @@ func (scm *Builder) insertBackLinkField( backlinkName := casing.ToLowerCamel(parentModel.Name.Value) relation := query.FieldGetAttribute(forwardRelnField, parser.AttributeRelation) if relation != nil { - relationValue, _ := relation.Arguments[0].Expression.ToValue() - backlinkName = relationValue.ToString() + relationValue, _ := resolve.AsIdent(relation.Arguments[0].Expression) + backlinkName = relationValue.String() } // If the field already exists don't add another one as this will just create a diff --git a/schema/completions/completions.go b/schema/completions/completions.go index 202355a53..de1ec44b4 100644 --- a/schema/completions/completions.go +++ b/schema/completions/completions.go @@ -254,6 +254,7 @@ func getBlockCompletions(asts []*parser.AST, tokenAtPos *TokensAtPosition, keywo parser.AttributeUnique, parser.AttributeDefault, parser.AttributeRelation, + parser.AttributeComputed, }) } @@ -323,6 +324,7 @@ func getBlockCompletions(asts []*parser.AST, tokenAtPos *TokensAtPosition, keywo parser.AttributeUnique, parser.AttributeDefault, parser.AttributeRelation, + parser.AttributeComputed, }) } @@ -644,7 +646,7 @@ func getAttributeArgCompletions(asts []*parser.AST, t *TokensAtPosition, cfg *co enclosingBlock := getTypeOfEnclosingBlock(t) switch attrName { - case parser.AttributeSet, parser.AttributeWhere, parser.AttributeValidate: + case parser.AttributeSet, parser.AttributeWhere, parser.AttributeValidate, parser.AttributeComputed: return getExpressionCompletions(asts, t, cfg) case parser.AttributePermission: return getPermissionArgCompletions(asts, t, cfg) @@ -670,7 +672,7 @@ func getAttributeArgCompletions(asts []*parser.AST, t *TokensAtPosition, cfg *co fields := query.ModelFields(model, func(f *parser.FieldNode) bool { return f.IsScalar() && - f.Type.Value != parser.FieldTypeDatetime && + f.Type.Value != parser.FieldTypeTimestamp && f.Type.Value != parser.FieldTypeSecret && f.Type.Value != parser.FieldTypePassword && f.Type.Value != parser.FieldTypeID diff --git a/schema/completions/completions_test.go b/schema/completions/completions_test.go index 3a7e64e64..2d42b33be 100644 --- a/schema/completions/completions_test.go +++ b/schema/completions/completions_test.go @@ -434,7 +434,7 @@ func TestFieldCompletions(t *testing.T) { } } }`, - expected: []string{"@unique", "@default", "@relation"}, + expected: []string{"@unique", "@default", "@relation", "@computed"}, }, { name: "field-attributes-bare-at", @@ -443,7 +443,7 @@ func TestFieldCompletions(t *testing.T) { name Text @ } }`, - expected: []string{"@unique", "@default", "@relation"}, + expected: []string{"@unique", "@default", "@relation", "@computed"}, }, { name: "field-attributes-whitespace", @@ -453,7 +453,7 @@ func TestFieldCompletions(t *testing.T) { name Text } }`, - expected: []string{"@unique", "@default", "@relation"}, + expected: []string{"@unique", "@default", "@relation", "@computed"}, }, } @@ -1089,6 +1089,37 @@ func TestSetAttributeCompletions(t *testing.T) { runTestsCases(t, cases) } +func TestComputedAttributeCompletions(t *testing.T) { + cases := []testCase{ + { + name: "computed-attribute-operands", + schema: ` + model Item { + fields { + price Decimal + quantity Decimal + total Decimal @computed() + } + }`, + expected: []string{"ctx", "item"}, + }, + { + name: "computed-attribute-model-fields", + schema: ` + model Item { + fields { + price Decimal + quantity Decimal + total Decimal @computed(item.) + } + }`, + expected: []string{"createdAt", "id", "price", "quantity", "total", "updatedAt"}, + }, + } + + runTestsCases(t, cases) +} + func TestFunctionCompletions(t *testing.T) { cases := []testCase{ // name tests diff --git a/schema/definitions/definitions.go b/schema/definitions/definitions.go index 8bc0f60dd..2cb11a032 100644 --- a/schema/definitions/definitions.go +++ b/schema/definitions/definitions.go @@ -2,7 +2,6 @@ package definitions import ( "github.com/alecthomas/participle/v2/lexer" - "github.com/iancoleman/strcase" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/query" "github.com/teamkeel/keel/schema/reader" @@ -66,29 +65,6 @@ func GetDefinition(schemaFiles []*reader.SchemaFile, pos Position) *Definition { return def } } - - for _, attr := range action.Attributes { - for _, arg := range attr.Arguments { - if arg.Expression == nil { - continue - } - for _, cond := range arg.Expression.Conditions() { - for _, op := range []*parser.Operand{cond.LHS, cond.RHS} { - if op == nil || op.Ident == nil { - continue - } - if op.Ident.Fragments[0].Fragment != strcase.ToLowerCamel(model.Name.Value) { - continue - } - op.Ident.Fragments = op.Ident.Fragments[1:] - def := definitionFromIdent(asts, model, op.Ident, pos) - if def != nil { - return def - } - } - } - } - } } } diff --git a/schema/definitions/definitions_test.go b/schema/definitions/definitions_test.go index 9ec1080aa..28f050d59 100644 --- a/schema/definitions/definitions_test.go +++ b/schema/definitions/definitions_test.go @@ -135,89 +135,6 @@ model Publisher { fields { name Text } -} - `, - }, - }, - ExpectSchemaDefinition: true, - }, - { - Name: "go to field from @set expression", - Files: []*reader.SchemaFile{ - { - FileName: "schema.keel", - Contents: ` -model Author { - fields { - identity Identity - } - actions { - create newAuthor() { - @set(author.identity == ctx.identity) - } - } -} - `, - }, - }, - ExpectSchemaDefinition: true, - }, - { - Name: "go to field from @where expression", - Files: []*reader.SchemaFile{ - { - FileName: "schema.keel", - Contents: ` -model Book { - fields { - published Boolean - } - actions { - list books() { - @where(book.published == true) - } - } -} - `, - }, - }, - ExpectSchemaDefinition: true, - }, - { - Name: "go to field from @where expression with relationship in different file", - Files: []*reader.SchemaFile{ - { - FileName: "publisher.keel", - Contents: ` -model Publisher { - fields { - isActive Boolean - } -} - `, - }, - { - FileName: "author.keel", - Contents: ` -model Author { - fields { - publisher Publisher - } -} - `, - }, - { - FileName: "book.keel", - Contents: ` -model Book { - fields { - author Author - } - actions { - list books() { - @where(book.author.publisher.isActive == true) - } - } } `, }, diff --git a/schema/expressions/consts.go b/schema/expressions/consts.go deleted file mode 100644 index c4beeb6c1..000000000 --- a/schema/expressions/consts.go +++ /dev/null @@ -1,101 +0,0 @@ -package expressions - -import "github.com/teamkeel/keel/schema/parser" - -type OperandPosition = string - -const ( - OperandPositionLhs OperandPosition = "lhs" - OperandPositionRhs OperandPosition = "rhs" -) - -const ( - TypeStringMap = "StringMap" -) - -// Defines which operators can be used for each field type -var operatorsForType = map[string][]string{ - parser.FieldTypeText: { - parser.OperatorEquals, - parser.OperatorNotEquals, - parser.OperatorAssignment, - }, - parser.FieldTypeID: { - parser.OperatorEquals, - parser.OperatorNotEquals, - parser.OperatorAssignment, - }, - parser.FieldTypeNumber: { - parser.OperatorEquals, - parser.OperatorNotEquals, - parser.OperatorGreaterThan, - parser.OperatorGreaterThanOrEqualTo, - parser.OperatorLessThan, - parser.OperatorLessThanOrEqualTo, - parser.OperatorAssignment, - parser.OperatorIncrement, - parser.OperatorDecrement, - }, - parser.FieldTypeDecimal: { - parser.OperatorEquals, - parser.OperatorNotEquals, - parser.OperatorGreaterThan, - parser.OperatorGreaterThanOrEqualTo, - parser.OperatorLessThan, - parser.OperatorLessThanOrEqualTo, - parser.OperatorAssignment, - parser.OperatorIncrement, - parser.OperatorDecrement, - }, - parser.FieldTypeBoolean: { - parser.OperatorAssignment, - parser.OperatorEquals, - parser.OperatorNotEquals, - }, - parser.FieldTypeDate: { - parser.OperatorEquals, - parser.OperatorNotEquals, - parser.OperatorGreaterThan, - parser.OperatorGreaterThanOrEqualTo, - parser.OperatorLessThan, - parser.OperatorLessThanOrEqualTo, - parser.OperatorAssignment, - }, - parser.FieldTypeDatetime: { - parser.OperatorEquals, - parser.OperatorNotEquals, - parser.OperatorGreaterThan, - parser.OperatorGreaterThanOrEqualTo, - parser.OperatorLessThan, - parser.OperatorLessThanOrEqualTo, - parser.OperatorAssignment, - }, - parser.TypeEnum: { - parser.OperatorEquals, - parser.OperatorNotEquals, - parser.OperatorAssignment, - }, - parser.TypeArray: { - parser.OperatorIn, - parser.OperatorNotIn, - parser.OperatorEquals, - parser.OperatorNotEquals, - parser.OperatorAssignment, - }, - parser.TypeNull: { - parser.OperatorEquals, - parser.OperatorNotEquals, - parser.OperatorAssignment, - }, - parser.TypeModel: { - parser.OperatorEquals, - parser.OperatorNotEquals, - parser.OperatorAssignment, - }, - parser.FieldTypeMarkdown: { - parser.OperatorEquals, - parser.OperatorNotEquals, - parser.OperatorAssignment, - }, - parser.FieldTypeVector: {}, -} diff --git a/schema/expressions/expressions.go b/schema/expressions/expressions.go deleted file mode 100644 index 5e0973f72..000000000 --- a/schema/expressions/expressions.go +++ /dev/null @@ -1,254 +0,0 @@ -package expressions - -import ( - "fmt" - - "github.com/teamkeel/keel/schema/parser" - "github.com/teamkeel/keel/schema/query" - "github.com/teamkeel/keel/schema/validation/errorhandling" -) - -type ConditionResolver struct { - condition *parser.Condition - context *ExpressionContext - asts []*parser.AST -} - -func (c *ConditionResolver) Resolve() (resolvedLhs *ExpressionScopeEntity, resolvedRhs *ExpressionScopeEntity, errors []error) { - lhs := NewOperandResolver( - c.condition.LHS, - c.asts, - c.context, - OperandPositionLhs, - ) - - resolvedLhs, lhsErr := lhs.Resolve() - if lhsErr != nil { - errors = append(errors, lhsErr.ToValidationError()) - } - - // Check RHS only if it exists - if c.condition.RHS != nil { - rhs := NewOperandResolver( - c.condition.RHS, - c.asts, - c.context, - OperandPositionRhs, - ) - - resolvedRhs, rhsErr := rhs.Resolve() - if rhsErr != nil { - errors = append(errors, rhsErr.ToValidationError()) - } - - return resolvedLhs, resolvedRhs, errors - } else if resolvedLhs != nil && resolvedLhs.GetType() != parser.FieldTypeBoolean { - errors = append(errors, - errorhandling.NewValidationError( - errorhandling.ErrorExpressionSingleConditionNotBoolean, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "Value": lhs.operand.ToString(), - "Attribute": fmt.Sprintf("@%s", c.context.Attribute.Name.Value), - "Suggestion": fmt.Sprintf("%s == xxx", lhs.operand.ToString()), - }, - }, - lhs.operand.Node, - ), - ) - } - - return resolvedLhs, nil, errors -} - -func NewConditionResolver(condition *parser.Condition, asts []*parser.AST, context *ExpressionContext) *ConditionResolver { - return &ConditionResolver{ - condition: condition, - context: context, - asts: asts, - } -} - -type OperandResolver struct { - operand *parser.Operand - asts []*parser.AST - context *ExpressionContext - scope *ExpressionScope - position OperandPosition -} - -func NewOperandResolver(operand *parser.Operand, asts []*parser.AST, context *ExpressionContext, position OperandPosition) *OperandResolver { - return &OperandResolver{ - operand: operand, - asts: asts, - context: context, - scope: &ExpressionScope{}, - position: position, - } -} - -// A condition is composed of a LHS operand (and an operator, and a RHS operand if not a value only condition like expression: true) -// Given an operand of a condition, tries to resolve all of the fragments defined within the operand -// an operand might be: -// - post.author.name -// - post.author -// - MyEnum.ValueName -// - "123" -// - true -// - ctx.identity.account -// - ctx.identity.account.name -// All of these types above are checked / attempted to be resolved in this method. -func (o *OperandResolver) Resolve() (entity *ExpressionScopeEntity, err *ResolutionError) { - // build the default expression scope for all expressions - o.scope = buildRootExpressionScope(o.asts, o.context) - // build additional root scopes based on position of operand - // and also what type attribute the expression is used in. - o.scope = applyAdditionalOperandScopes(o.asts, o.scope, o.context) - - // If it is a literal then handle differently. - if ok, _ := o.operand.IsLiteralType(); ok { - if o.operand.Type() == parser.TypeArray { - array := []*ExpressionScopeEntity{} - - for _, item := range o.operand.Array.Values { - array = append(array, - &ExpressionScopeEntity{ - Literal: item, - }, - ) - } - - entity = &ExpressionScopeEntity{ - Array: array, - } - - return entity, nil - } else { - entity = &ExpressionScopeEntity{ - Literal: o.operand, - } - return entity, nil - } - } - - // For an array of enums, we need to rather iterate through and resolve each value - if o.operand.Array != nil { - array := []*ExpressionScopeEntity{} - - for _, operand := range o.operand.Array.Values { - r := NewOperandResolver( - operand, - o.asts, - o.context, - o.position, - ) - - e, err := r.Resolve() - if err != nil { - return nil, err - } - - array = append(array, e) - } - - return &ExpressionScopeEntity{ - Array: array, - }, nil - } - - // We want to loop over every fragment in the Ident, each time checking if the Ident matches anything - // stored in the expression scope. - // e.g if the first ident fragment is "ctx", and the ExpressionScope has a matching key - // (which it does if you use the DefaultExpressionScope) - // then it will continue onto the next fragment, setting the new scope to Ctx - // so that the next fragment can be compared to fields that exist on the Ctx object -fragments: - for _, fragment := range o.operand.Ident.Fragments { - if entity != nil && entity.Type == TypeStringMap { - o.scope = &ExpressionScope{ - Parent: o.scope, - } - - entity = &ExpressionScopeEntity{ - Type: parser.TypeText, - } - - continue - } - - for _, e := range o.scope.Entities { - if e.Name != fragment.Fragment { - continue - } - - switch { - case e.Model != nil: - // crucially, scope is redefined for the next iteration of the outer loop - // so that we check the subsequent fragment against the models fields - // e.g post.field - fragment at idx 0 would be post, so scopeFromModel finds the fields on the - // Post model and populates the new scope with them. - o.scope = scopeFromModel(o.scope, e, e.Model) - case e.Field != nil: - - model := query.Model(o.asts, e.Field.Type.Value) - enum := query.Enum(o.asts, e.Field.Type.Value) - - if model != nil { - // If the field type is a model the scope is now that models fields - o.scope = scopeFromModel(o.scope, e, model) - } else { - // For enums we add some extra context to the scope entity so we can - // resolve it properly later on - if enum != nil { - e.Type = parser.TypeEnum - } - - // Non-model fields have no sub-properties, so the scope is now empty - o.scope = &ExpressionScope{ - Parent: o.scope, - } - } - case e.Object != nil: - // object is a special wrapper type to describe entities we want - // to be in scope that aren't models. It is a more flexible type that - // allows us to add fields to an object at our choosing - // Mostly used for ctx - o.scope = scopeFromObject(o.scope, e) - case e.Enum != nil: - // if the first fragment of the Ident matches an Enum name, then we want to proceed populating the scope for the next fragment - // with all of the potential values of the enum - o.scope = scopeFromEnum(o.scope, e) - case e.EnumValue != nil: - // if we are evaluating an EnumValue, then there are no more - // child entities to append as an EnumValue is a termination point. - // e.g EnumName.EnumValue.SomethingElse isnt a thing. - o.scope = &ExpressionScope{ - Parent: o.scope, - } - case e.Type != "": - // Otherwise, the scope is empty of any new entities - o.scope = &ExpressionScope{ - Parent: o.scope, - } - } - - entity = e - continue fragments - } - - parent := "" - - if entity != nil { - parent = entity.GetType() - } - - return nil, &ResolutionError{ - fragment: fragment, - parent: parent, - scope: o.scope, - operand: o.operand, - } - } - - return entity, nil -} diff --git a/schema/expressions/scope.go b/schema/expressions/scope.go deleted file mode 100644 index 58e0af109..000000000 --- a/schema/expressions/scope.go +++ /dev/null @@ -1,397 +0,0 @@ -package expressions - -import ( - "fmt" - - "github.com/samber/lo" - "github.com/teamkeel/keel/casing" - "github.com/teamkeel/keel/schema/parser" - "github.com/teamkeel/keel/schema/query" - "github.com/teamkeel/keel/schema/validation/errorhandling" -) - -// ExpressionContext represents all of the metadata that we need to know about -// to resolve an expression. -// For example, we need to know the parent constructs in the schema such as the -// current model, the current attribute or the current action in order to determine -// what fragments are expected in an expression -type ExpressionContext struct { - Model *parser.ModelNode - Action *parser.ActionNode - Attribute *parser.AttributeNode - Field *parser.FieldNode -} - -type ResolutionError struct { - scope *ExpressionScope - fragment *parser.IdentFragment - parent string - operand *parser.Operand -} - -func (e *ResolutionError) InScopeEntities() []string { - return lo.Map(e.scope.Entities, func(e *ExpressionScopeEntity, _ int) string { - return e.Name - }) -} - -func (e *ResolutionError) Error() string { - return fmt.Sprintf("Could not resolve %s in %s", e.fragment.Fragment, e.operand.ToString()) -} - -func (e *ResolutionError) ToValidationError() *errorhandling.ValidationError { - suggestions := errorhandling.NewCorrectionHint(e.InScopeEntities(), e.fragment.Fragment) - - literals := map[string]string{ - "Fragment": e.fragment.Fragment, - "Parent": e.parent, - } - - if len(suggestions.Results) > 0 { - literals["Suggestion"] = suggestions.ToString() - } - - return errorhandling.NewValidationError( - errorhandling.ErrorUnresolvableExpression, - errorhandling.TemplateLiterals{ - Literals: literals, - }, - e.fragment, - ) -} - -// ExpressionScope is used to represent things that should be in the scope -// of an expression. -// Operands in an expression are composed of fragments, -// which are dot separated identifiers: -// e.g post.title -// The base scope that is constructed before we start evaluating the first -// fragment contains things like ctx, any input parameters, the current model etc -type ExpressionScope struct { - Parent *ExpressionScope - Entities []*ExpressionScopeEntity -} - -func buildRootExpressionScope(asts []*parser.AST, context *ExpressionContext) *ExpressionScope { - if context.Model == nil { - return DefaultExpressionScope(asts) - } - - contextualScope := &ExpressionScope{ - Entities: []*ExpressionScopeEntity{ - { - Name: casing.ToLowerCamel(context.Model.Name.Value), - Model: context.Model, - }, - }, - } - - return DefaultExpressionScope(asts).Merge(contextualScope) -} - -func (a *ExpressionScope) Merge(b *ExpressionScope) *ExpressionScope { - return &ExpressionScope{ - Entities: append(a.Entities, b.Entities...), - } -} - -type ExpressionObjectEntity struct { - Name string - Fields []*ExpressionScopeEntity -} - -// An ExpressionScopeEntity is an individual item that is inserted into an -// expression scope. So a scope might have multiple entities of different types in it -// at one single time: -// example: -// &ExpressionScope{Entities: []*ExpressionScopeEntity{{ Name: "ctx": Object: {....} }}, Parent: nil} -// Parent is used to provide useful metadata about any upper scopes (e.g previous fragments that were evaluated) -type ExpressionScopeEntity struct { - Name string - - Object *ExpressionObjectEntity - Model *parser.ModelNode - Field *parser.FieldNode - Literal *parser.Operand - Enum *parser.EnumNode - EnumValue *parser.EnumValueNode - Array []*ExpressionScopeEntity - - // Type can be things like "Text", "Boolean" etc.. but can also be "Enum". - // If the value is "Enum" then the Field attribute will be populated - // with a model field that is an enum - Type string - - Parent *ExpressionScopeEntity -} - -func (e *ExpressionScopeEntity) IsNull() bool { - return e.Literal != nil && e.Literal.Null -} - -func (e *ExpressionScopeEntity) IsOptional() bool { - return e.Field != nil && e.Field.Optional -} - -func (e *ExpressionScopeEntity) IsEnumField() bool { - return e.Field != nil && e.Type == parser.TypeEnum -} - -func (e *ExpressionScopeEntity) IsEnumValue() bool { - return e.Parent != nil && e.Parent.Enum != nil && e.EnumValue != nil -} - -func (e *ExpressionScopeEntity) GetType() string { - if e.Object != nil { - return e.Object.Name - } - - if e.Model != nil { - return e.Model.Name.Value - } - - if e.Field != nil { - return e.Field.Type.Value - } - - if e.Literal != nil { - return e.Literal.Type() - } - - if e.Enum != nil { - return e.Enum.Name.Value - } - - if e.EnumValue != nil { - return e.Parent.Enum.Name.Value - } - - if e.Array != nil { - return parser.TypeArray - } - - if e.Type != "" { - return e.Type - } - - return "" -} - -func (e *ExpressionScopeEntity) AllowedOperators(asts []*parser.AST) []string { - t := e.GetType() - - arrayEntity := e.IsRepeated() - - if !arrayEntity && e.Model != nil { - t = parser.TypeModel - } - - // When the field is of type model - if !arrayEntity && e.Field != nil && query.Model(asts, e.Field.Type.Value) != nil { - t = parser.TypeModel - } - - if arrayEntity { - t = parser.TypeArray - } - - if (e.IsEnumField() || e.IsEnumValue()) && !arrayEntity { - t = parser.TypeEnum - } - - return operatorsForType[t] -} - -func DefaultExpressionScope(asts []*parser.AST) *ExpressionScope { - var envVarEntities []*ExpressionScopeEntity - var secretsEntities []*ExpressionScopeEntity - for _, ast := range asts { - for _, envVar := range ast.EnvironmentVariables { - envVarEntities = append(envVarEntities, &ExpressionScopeEntity{ - Name: envVar, - Type: parser.FieldTypeText, - }) - } - for _, secret := range ast.Secrets { - secretsEntities = append(secretsEntities, &ExpressionScopeEntity{ - Name: secret, - Type: parser.FieldTypeText, - }) - } - } - - entities := []*ExpressionScopeEntity{ - { - Name: "ctx", - Object: &ExpressionObjectEntity{ - Name: "Context", - Fields: []*ExpressionScopeEntity{ - { - Name: "identity", - Model: query.Model(asts, "Identity"), - }, - { - Name: "isAuthenticated", - Type: parser.FieldTypeBoolean, - }, - { - Name: "now", - Type: parser.FieldTypeDatetime, - }, - { - Name: "env", - Object: &ExpressionObjectEntity{ - Name: "Environment Variables", - Fields: envVarEntities, - }, - }, - { - Name: "headers", - Type: TypeStringMap, - }, - { - Name: "secrets", - Object: &ExpressionObjectEntity{ - Name: "Secrets", - Fields: secretsEntities, - }, - }, - }, - }, - }, - } - - for _, enum := range query.Enums(asts) { - entities = append(entities, &ExpressionScopeEntity{ - Name: enum.Name.Value, - Enum: enum, - }) - } - - return &ExpressionScope{ - Entities: entities, - } -} - -// IsRepeated returns true if the entity is a repeated value -// This can be because it is a literal array e.g. [1,2,3] -// or because it's a repeated field or at least one parent -// entity is a repeated field e.g. order.items.product.price -// would be a list of prices (assuming order.items is an -// array of items) -func (e *ExpressionScopeEntity) IsRepeated() bool { - entity := e - if len(entity.Array) > 0 { - return true - } - if entity.Field != nil && entity.Field.Repeated { - return true - } - for entity.Parent != nil { - entity = entity.Parent - if entity.Field != nil && entity.Field.Repeated { - return true - } - } - return false -} - -func scopeFromModel(parentScope *ExpressionScope, parentEntity *ExpressionScopeEntity, model *parser.ModelNode) *ExpressionScope { - newEntities := []*ExpressionScopeEntity{} - - for _, field := range query.ModelFields(model) { - newEntities = append(newEntities, &ExpressionScopeEntity{ - Name: field.Name.Value, - Field: field, - Parent: parentEntity, - }) - } - - return &ExpressionScope{ - Entities: newEntities, - Parent: parentScope, - } -} - -func scopeFromObject(parentScope *ExpressionScope, parentEntity *ExpressionScopeEntity) *ExpressionScope { - newEntities := []*ExpressionScopeEntity{} - - for _, entity := range parentEntity.Object.Fields { - // create a shallow copy by getting the _value_ of entity - entityCopy := *entity - // update parent (this does _not_ mutate entity) - entityCopy.Parent = parentEntity - // then add a pointer to the _copy_ - newEntities = append(newEntities, &entityCopy) - } - - return &ExpressionScope{ - Entities: newEntities, - Parent: parentScope, - } -} - -func scopeFromEnum(parentScope *ExpressionScope, parentEntity *ExpressionScopeEntity) *ExpressionScope { - newEntities := []*ExpressionScopeEntity{} - - for _, value := range parentEntity.Enum.Values { - newEntities = append(newEntities, &ExpressionScopeEntity{ - Name: value.Name.Value, - EnumValue: value, - Parent: parentEntity, - }) - } - - return &ExpressionScope{ - Entities: newEntities, - Parent: parentScope, - } -} - -func applyAdditionalOperandScopes(asts []*parser.AST, scope *ExpressionScope, context *ExpressionContext) *ExpressionScope { - additionalScope := &ExpressionScope{} - - attribute := context.Attribute - action := context.Action - - // If there is no action, then we dont want to do anything - if action == nil { - return scope - } - - switch attribute.Name.Value { - case parser.AttributePermission: - // no inputs allowed in permissions - default: - scope = applyInputsInScope(asts, context, scope) - } - - return scope.Merge(additionalScope) -} - -func applyInputsInScope(asts []*parser.AST, context *ExpressionContext, scope *ExpressionScope) *ExpressionScope { - additionalScope := &ExpressionScope{} - - inputs := []*parser.ActionInputNode{} - inputs = append(inputs, context.Action.Inputs...) - inputs = append(inputs, context.Action.With...) - - for _, input := range inputs { - // inputs using short-hand syntax that refer to relationships - // don't get added to the scope - if input.Label == nil && len(input.Type.Fragments) > 1 { - continue - } - - resolvedType := query.ResolveInputType(asts, input, context.Model, context.Action) - if resolvedType == "" { - continue - } - additionalScope.Entities = append(additionalScope.Entities, &ExpressionScopeEntity{ - Name: input.Name(), - Type: resolvedType, - }) - } - - return scope.Merge(additionalScope) -} diff --git a/schema/format/format.go b/schema/format/format.go index 4a81c4e77..2375dac3c 100644 --- a/schema/format/format.go +++ b/schema/format/format.go @@ -482,7 +482,7 @@ func printAttributes(writer *Writer, attributes []*parser.AttributeNode) { if arg.Label != nil { writer.write("%s: ", lowerCamel(arg.Label.Value)) } - expr, _ := arg.Expression.ToString() + expr := arg.Expression.CleanString() writer.write(expr) }) } diff --git a/schema/makeproto.go b/schema/makeproto.go index e21809d82..20abc2872 100644 --- a/schema/makeproto.go +++ b/schema/makeproto.go @@ -7,6 +7,7 @@ import ( "github.com/samber/lo" "github.com/teamkeel/keel/casing" "github.com/teamkeel/keel/cron" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/proto" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/query" @@ -1465,9 +1466,9 @@ func (scm *Builder) makeField(parserField *parser.FieldNode, modelName string) * continue } - value, _ := attr.Arguments[0].Expression.ToValue() - fieldNames := lo.Map(value.Array.Values, func(v *parser.Operand, i int) string { - return v.Ident.ToString() + idents, _ := resolve.AsIdentArray(attr.Arguments[0].Expression) + fieldNames := lo.Map(idents, func(v *parser.ExpressionIdent, i int) string { + return v.String() }) if !lo.Contains(fieldNames, parserField.Name.Value) { @@ -1567,9 +1568,8 @@ func (scm *Builder) setInverseFieldName(thisParserField *parser.FieldNode, thisP // you know that it is well formed for that. func attributeFirstArgAsIdentifier(attr *parser.AttributeNode) string { expr := attr.Arguments[0].Expression - operand, _ := expr.ToValue() - theString := operand.Ident.Fragments[0].Fragment - return theString + theString, _ := resolve.AsIdent(expr) + return theString.String() } func (scm *Builder) makeActions(actions []*parser.ActionNode, modelName string, builtIn bool) []*proto.Action { @@ -1727,7 +1727,7 @@ func (scm *Builder) parserTypeToProtoType(parserType string) proto.Type { return proto.Type_TYPE_INT case parserType == parser.FieldTypeDate: return proto.Type_TYPE_DATE - case parserType == parser.FieldTypeDatetime: + case parserType == parser.FieldTypeTimestamp: return proto.Type_TYPE_DATETIME case parserType == parser.FieldTypeSecret: return proto.Type_TYPE_SECRET @@ -1811,15 +1811,14 @@ func (scm *Builder) applyModelAttribute(parserModel *parser.ModelNode, protoMode perm.ModelName = protoModel.Name protoModel.Permissions = append(protoModel.Permissions, perm) case parser.AttributeOn: - subscriberArg, _ := attribute.Arguments[1].Expression.ToValue() - subscriberName := subscriberArg.Ident.Fragments[0].Fragment + subscriberName, _ := resolve.AsIdent(attribute.Arguments[1].Expression) // Create the subscriber if it has not yet been created yet. - subscriber := proto.FindSubscriber(scm.proto.Subscribers, subscriberName) + subscriber := proto.FindSubscriber(scm.proto.Subscribers, subscriberName.Fragments[0]) if subscriber == nil { subscriber = &proto.Subscriber{ - Name: subscriberName, - InputMessageName: makeSubscriberMessageName(subscriberName), + Name: subscriberName.Fragments[0], + InputMessageName: makeSubscriberMessageName(subscriberName.Fragments[0]), EventNames: []string{}, } scm.proto.Subscribers = append(scm.proto.Subscribers, subscriber) @@ -1827,9 +1826,9 @@ func (scm *Builder) applyModelAttribute(parserModel *parser.ModelNode, protoMode // For each event, add to the proto schema if it doesn't exist, // and add it to the current subscriber's EventNames field. - actionTypesArg, _ := attribute.Arguments[0].Expression.ToValue() - for _, arg := range actionTypesArg.Array.Values { - actionType := scm.mapToActionType(arg.Ident.Fragments[0].Fragment) + actionTypesArg, _ := resolve.AsIdentArray(attribute.Arguments[0].Expression) + for _, arg := range actionTypesArg { + actionType := scm.mapToActionType(arg.Fragments[0]) eventName := makeEventName(parserModel.Name.Value, mapToEventType(actionType)) event := proto.FindEvent(scm.proto.Events, eventName) @@ -1956,26 +1955,26 @@ func (scm *Builder) applyActionAttributes(action *parser.ActionNode, protoAction perm.ActionName = wrapperspb.String(protoAction.Name) protoAction.Permissions = append(protoAction.Permissions, perm) case parser.AttributeWhere: - expr, _ := attribute.Arguments[0].Expression.ToString() + expr := attribute.Arguments[0].Expression.String() where := &proto.Expression{Source: expr} protoAction.WhereExpressions = append(protoAction.WhereExpressions, where) case parser.AttributeSet: - expr, _ := attribute.Arguments[0].Expression.ToString() + expr := attribute.Arguments[0].Expression.String() set := &proto.Expression{Source: expr} protoAction.SetExpressions = append(protoAction.SetExpressions, set) case parser.AttributeValidate: - expr, _ := attribute.Arguments[0].Expression.ToString() + expr := attribute.Arguments[0].Expression.String() set := &proto.Expression{Source: expr} protoAction.ValidationExpressions = append(protoAction.ValidationExpressions, set) case parser.AttributeEmbed: for _, arg := range attribute.Arguments { - expr, _ := arg.Expression.ToString() + expr := arg.Expression.String() protoAction.ResponseEmbeds = append(protoAction.ResponseEmbeds, expr) } case parser.AttributeOrderBy: for _, arg := range attribute.Arguments { field := arg.Label.Value - direction, _ := arg.Expression.ToString() + direction := arg.Expression.String() orderBy := &proto.OrderByStatement{ FieldName: field, Direction: mapToOrderByDirection(direction), @@ -1997,15 +1996,17 @@ func (scm *Builder) applyFieldAttributes(parserField *parser.FieldNode, protoFie case parser.AttributeDefault: defaultValue := &proto.DefaultValue{} if len(fieldAttribute.Arguments) == 1 { - expr := fieldAttribute.Arguments[0].Expression - source, _ := expr.ToString() defaultValue.Expression = &proto.Expression{ - Source: source, + Source: fieldAttribute.Arguments[0].Expression.String(), } } else { defaultValue.UseZeroValue = true } protoField.DefaultValue = defaultValue + case parser.AttributeComputed: + protoField.ComputedExpression = &proto.Expression{ + Source: fieldAttribute.Arguments[0].Expression.String(), + } case parser.AttributeRelation: // We cannot process this field attribute here. But here is an explanation // of why that is so - for future readers. @@ -2033,17 +2034,17 @@ func (scm *Builder) permissionAttributeToProtoPermission(attr *parser.AttributeN for _, arg := range attr.Arguments { switch arg.Label.Value { case "expression": - expr, _ := arg.Expression.ToString() + expr := arg.Expression.String() pr.Expression = &proto.Expression{Source: expr} case "roles": - value, _ := arg.Expression.ToValue() - for _, item := range value.Array.Values { - pr.RoleNames = append(pr.RoleNames, item.Ident.Fragments[0].Fragment) + idents, _ := resolve.AsIdentArray(arg.Expression) + for _, item := range idents { + pr.RoleNames = append(pr.RoleNames, item.Fragments[0]) } case "actions": - value, _ := arg.Expression.ToValue() - for _, v := range value.Array.Values { - pr.ActionTypes = append(pr.ActionTypes, scm.mapToActionType(v.Ident.Fragments[0].Fragment)) + idents, _ := resolve.AsIdentArray(arg.Expression) + for _, items := range idents { + pr.ActionTypes = append(pr.ActionTypes, scm.mapToActionType(items.Fragments[0])) } } } @@ -2100,9 +2101,8 @@ func (scm *Builder) applyJobAttribute(protoJob *proto.Job, attribute *parser.Att case parser.AttributePermission: protoJob.Permissions = append(protoJob.Permissions, scm.permissionAttributeToProtoPermission(attribute)) case parser.AttributeSchedule: - val, _ := attribute.Arguments[0].Expression.ToValue() - - src := strings.TrimPrefix(*val.String, `"`) + val, _, _ := resolve.ToValue[string](attribute.Arguments[0].Expression) + src := strings.TrimPrefix(val, `"`) src = strings.TrimSuffix(src, `"`) c, _ := cron.Parse(src) diff --git a/schema/parser/consts.go b/schema/parser/consts.go index d2c34c9df..5481027ae 100644 --- a/schema/parser/consts.go +++ b/schema/parser/consts.go @@ -19,21 +19,6 @@ const ( KeywordInput = "inputs" ) -// Types are roughly analogous to field types but they are used to type expressions -const ( - TypeNumber = "Number" - TypeText = "Text" - TypeBoolean = "Boolean" - TypeDecimal = "Decimal" - - // These are unique to expressions - TypeNull = "Null" - TypeArray = "Array" - TypeIdent = "Ident" - TypeEnum = "Enum" - TypeModel = "Model" -) - const ( DefaultApi = "Api" ) @@ -41,18 +26,18 @@ const ( // Built in Keel types. Worth noting a field type can also reference // another user-defined model const ( - FieldTypeID = "ID" // a uuid or similar - FieldTypeText = "Text" // a string - FieldTypeNumber = "Number" // an integer - FieldTypeDecimal = "Decimal" // a decimal - FieldTypeDate = "Date" // a date with no time element - FieldTypeDatetime = "Timestamp" // a UTC unix timestamp - FieldTypeBoolean = "Boolean" // a boolean - FieldTypeSecret = "Secret" // an encrypted secret - FieldTypePassword = "Password" // a hashed password - FieldTypeMarkdown = "Markdown" // a markdown rich text - FieldTypeVector = "Vector" // a vector - FieldTypeFile = "File" // a inline file supplied as a data-url + FieldTypeID = "ID" // a uuid or similar + FieldTypeText = "Text" // a string + FieldTypeNumber = "Number" // an integer + FieldTypeDecimal = "Decimal" // a decimal + FieldTypeDate = "Date" // a date with no time element + FieldTypeTimestamp = "Timestamp" // a UTC unix timestamp + FieldTypeBoolean = "Boolean" // a boolean + FieldTypeSecret = "Secret" // an encrypted secret + FieldTypePassword = "Password" // a hashed password + FieldTypeMarkdown = "Markdown" // a markdown rich text + FieldTypeVector = "Vector" // a vector + FieldTypeFile = "File" // a inline file supplied as a data-url ) // Types for Message fields @@ -61,18 +46,18 @@ const ( ) var BuiltInTypes = map[string]bool{ - FieldTypeID: true, - FieldTypeText: true, - FieldTypeNumber: true, - FieldTypeDecimal: true, - FieldTypeDate: true, - FieldTypeDatetime: true, - FieldTypeBoolean: true, - FieldTypeSecret: true, - FieldTypePassword: true, - FieldTypeMarkdown: true, - FieldTypeVector: true, - FieldTypeFile: true, + FieldTypeID: true, + FieldTypeText: true, + FieldTypeNumber: true, + FieldTypeDecimal: true, + FieldTypeDate: true, + FieldTypeTimestamp: true, + FieldTypeBoolean: true, + FieldTypeSecret: true, + FieldTypePassword: true, + FieldTypeMarkdown: true, + FieldTypeVector: true, + FieldTypeFile: true, } func IsBuiltInFieldType(s string) bool { @@ -155,6 +140,11 @@ const ( AttributeFunction = "function" AttributeOn = "on" AttributeEmbed = "embed" + AttributeComputed = "computed" +) + +const ( + ThisVariable = "this" ) const ( diff --git a/schema/parser/expressions.go b/schema/parser/expressions.go index 9452dda8c..7fab3cfdb 100644 --- a/schema/parser/expressions.go +++ b/schema/parser/expressions.go @@ -2,145 +2,118 @@ package parser import ( "errors" - "fmt" + "strings" "github.com/alecthomas/participle/v2" - "github.com/samber/lo" + "github.com/alecthomas/participle/v2/lexer" "github.com/teamkeel/keel/schema/node" ) type Expression struct { node.Node - - Or []*OrExpression `@@ ("or" @@)*` } -func (e *Expression) Conditions() []*Condition { - conds := []*Condition{} +func (e *Expression) Parse(lex *lexer.PeekingLexer) error { + parenCount := 0 + for { + t := lex.Peek() - for _, or := range e.Or { - for _, and := range or.And { - conds = append(conds, and.Condition) + if t.EOF() { + e.EndPos = t.Pos + return nil + } - if and.Expression != nil { - conds = append(conds, and.Expression.Conditions()...) + if t.Value == ")" || t.Value == "]" { + parenCount-- + if parenCount < 0 { + e.EndPos = t.Pos + return nil } } - } - ret := []*Condition{} - for _, cond := range conds { - if cond != nil { - ret = append(ret, cond) + if t.Value == "(" || t.Value == "[" { + parenCount++ } - } - return ret -} - -type OrExpression struct { - node.Node - - And []*ConditionWrap `@@ ("and" @@)*` -} -type ConditionWrap struct { - node.Node - - Expression *Expression `( "(" @@ ")"` - Condition *Condition `| @@ )` -} + if t.Value == "," && parenCount == 0 { + e.EndPos = t.Pos + return nil + } -type Condition struct { - node.Node + t = lex.Next() + e.Tokens = append(e.Tokens, *t) - LHS *Operand `@@` - Operator *Operator `(@@` - RHS *Operand `@@ )?` + if len(e.Tokens) == 1 { + e.Pos = t.Pos + } + } } -var ( - AssignmentCondition = "assignment" - LogicalCondition = "logical" - ValueCondition = "value" - UnknownCondition = "unknown" -) - -func (c *Condition) Type() string { - if c.Operator == nil && c.RHS == nil && c.LHS != nil { - return ValueCondition +func (e *Expression) String() string { + if len(e.Tokens) == 0 { + return "" } - if lo.Contains(AssignmentOperators, c.Operator.Symbol) { - return AssignmentCondition - } + var result strings.Builder + firstToken := e.Tokens[0] + currentLine := e.Pos.Line + currentColumn := e.Pos.Column - if lo.Contains(LogicalOperators, c.Operator.Symbol) { - return LogicalCondition + // Handle first token + if firstToken.Pos.Line > currentLine { + // Add necessary newlines + result.WriteString(strings.Repeat("\n", firstToken.Pos.Line-currentLine)) + // Reset column position for new line + currentColumn = 0 + } + // Add spaces to reach the correct column position + if firstToken.Pos.Column > currentColumn { + result.WriteString(strings.Repeat(" ", firstToken.Pos.Column-currentColumn)) } + result.WriteString(firstToken.Value) + currentLine = firstToken.Pos.Line + currentColumn = firstToken.Pos.Column + len(firstToken.Value) - return UnknownCondition -} + // Handle subsequent tokens + for i := 1; i < len(e.Tokens); i++ { + curr := e.Tokens[i] -type Operator struct { - node.Node + if curr.Pos.Line > currentLine { + // Add necessary newlines + result.WriteString(strings.Repeat("\n", curr.Pos.Line-currentLine)) + // Reset column position for new line + currentColumn = 0 + } - // Todo need to figure out how we can share with the consts below - Symbol string `@( "=" "=" | "!" "=" | ">" "=" | "<" "=" | ">" | "<" | "not" "in" | "in" | "+" "=" | "-" "=" | "=")` -} + // Add spaces to reach the correct column position + if curr.Pos.Column > currentColumn { + result.WriteString(strings.Repeat(" ", curr.Pos.Column-currentColumn)) + } -func (o *Operator) ToString() string { - if o == nil { - return "" + result.WriteString(curr.Value) + currentLine = curr.Pos.Line + currentColumn = curr.Pos.Column + len(curr.Value) } - return o.Symbol + return result.String() } -var ( - OperatorEquals = "==" - OperatorAssignment = "=" - OperatorNotEquals = "!=" - OperatorGreaterThanOrEqualTo = ">=" - OperatorLessThanOrEqualTo = "<=" - OperatorLessThan = "<" - OperatorGreaterThan = ">" - OperatorIn = "in" - OperatorNotIn = "notin" - OperatorIncrement = "+=" - OperatorDecrement = "-=" -) - -var AssignmentOperators = []string{ - OperatorAssignment, -} - -var LogicalOperators = []string{ - OperatorEquals, - OperatorNotEquals, - OperatorGreaterThan, - OperatorGreaterThanOrEqualTo, - OperatorLessThan, - OperatorLessThanOrEqualTo, - OperatorIn, - OperatorNotIn, -} - -func (condition *Condition) ToString() string { - result := "" - - if condition == nil { - panic("condition is nil") - } - if condition.LHS != nil { - result += condition.LHS.ToString() - } - - if condition.Operator != nil && condition.RHS != nil { - result += fmt.Sprintf(" %s ", condition.Operator.Symbol) - result += condition.RHS.ToString() +// CleanString removes new lines and unnecessary whitespaces, preserving single spaces between tokens +func (e *Expression) CleanString() string { + v := "" + for i, t := range e.Tokens { + if i == 0 { + v += t.Value + continue + } + last := e.Tokens[i-1] + hasWhitespace := (last.Pos.Offset + len(last.Value)) < t.Pos.Offset + if hasWhitespace { + v += " " + } + v += t.Value } - - return result + return v } func ParseExpression(source string) (*Expression, error) { @@ -157,110 +130,64 @@ func ParseExpression(source string) (*Expression, error) { return expr, nil } -func (expr *Expression) ToString() (string, error) { - result := "" - - for i, orExpr := range expr.Or { - if i > 0 { - result += " or " - } - - for j, andExpr := range orExpr.And { - if j > 0 { - result += " and " - } +type ExpressionIdent struct { + node.Node - if andExpr.Expression != nil { - r, err := andExpr.Expression.ToString() - if err != nil { - return result, err - } - result += "(" + r + ")" - continue - } + Fragments []string +} - result += andExpr.Condition.LHS.ToString() +func (ident ExpressionIdent) String() string { + return strings.Join(ident.Fragments, ".") +} - op := andExpr.Condition.Operator +var ErrInvalidAssignmentExpression = errors.New("expression is not a valid assignment") - if op != nil && op.Symbol == "" { - continue +// ToAssignmentExpression splits an assignment expression into two separate expressions. +// E.g. the expression `post.age = 1 + 1` will become `post.age` and `1 + 1` +func (expr *Expression) ToAssignmentExpression() (*Expression, *Expression, error) { + lhs := Expression{} + lhs.Pos = expr.Pos + lhs.Tokens = []lexer.Token{} + assignmentAt := 0 + for i, token := range expr.Tokens { + if token.Value == "=" { + if i == 0 { + return nil, nil, ErrInvalidAssignmentExpression } - // special case for "not in" - if op != nil && op.Symbol == "notin" { - result += " not in " - } else if op != nil { - result += fmt.Sprintf(" %s ", op.Symbol) + if i == len(expr.Tokens)-1 { + return nil, nil, ErrInvalidAssignmentExpression } - if andExpr.Condition.RHS != nil { - result += andExpr.Condition.RHS.ToString() + if expr.Tokens[i-1].Type > 0 || (expr.Tokens[i+1].Type > 0 && expr.Tokens[i+1].Type != 91) { + return nil, nil, ErrInvalidAssignmentExpression } - } - } - - return result, nil -} - -func (expr *Expression) IsValue() bool { - v, _ := expr.ToValue() - return v != nil -} -var ErrNotValue = errors.New("expression is not a single value") - -func (expr *Expression) ToValue() (*Operand, error) { - if len(expr.Or) > 1 { - return nil, ErrNotValue - } - - or := expr.Or[0] - if len(or.And) > 1 { - return nil, ErrNotValue - } - and := or.And[0] - - if and.Expression != nil { - return nil, ErrNotValue - } - - cond := and.Condition - - if cond.Operator != nil && cond.Operator.Symbol != "" { - return nil, ErrNotValue - } - - return cond.LHS, nil -} - -var ErrNotAssignment = errors.New("expression is not using an assignment, e.g. a = b") - -func (expr *Expression) IsAssignment() bool { - v, _ := expr.ToAssignmentCondition() - return v != nil -} - -func (expr *Expression) ToAssignmentCondition() (*Condition, error) { - if len(expr.Or) > 1 { - return nil, ErrNotAssignment + assignmentAt = i + lhs.EndPos = token.Pos + break + } + lhs.Tokens = append(lhs.Tokens, token) } - or := expr.Or[0] - if len(or.And) > 1 { - return nil, ErrNotAssignment + if assignmentAt == 0 { + return nil, nil, ErrInvalidAssignmentExpression } - and := or.And[0] - - if and.Expression != nil { - return nil, ErrNotAssignment + if len(expr.Tokens) == assignmentAt+1 { + return nil, nil, ErrInvalidAssignmentExpression } - cond := and.Condition - if cond.Operator == nil || !lo.Contains(AssignmentOperators, cond.Operator.Symbol) { - return nil, ErrNotAssignment + rhs := Expression{} + rhs.Pos = expr.Tokens[assignmentAt+1].Pos + rhs.EndPos = expr.EndPos + rhs.Tokens = []lexer.Token{} + for i, token := range expr.Tokens { + if i < assignmentAt+1 { + continue + } + rhs.Tokens = append(rhs.Tokens, token) } - return cond, nil + return &lhs, &rhs, nil } diff --git a/schema/parser/expressions_test.go b/schema/parser/expressions_test.go deleted file mode 100644 index 9dc1e0483..000000000 --- a/schema/parser/expressions_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package parser_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/teamkeel/keel/schema/parser" -) - -func TestRoundTrip(t *testing.T) { - fixtures := map[string]string{ - "single ident": "a", - "array of values": "[a, 2, true, false, null, \"literal\"]", - "equals": "a == b", - "not equals": "a != b", - "greater than or equals": "a >= b", - "less than or equals": "a <= b", - "greater than": "a > b", - "less than": "a < b", - "not in": "a not in b", - "in": "a in b", - "increment by": "a += b", - "decrement by": "a -= b", - "assignment": "a = b", - "or condition": "a == b or a > c", - "and condition": "a == b and a > c", - "mixed or/and": "a == b or a < c and a > d", - "parenthesis": "(a == b or a < c) and a > d", - "dot notation": "a.b.c == d.e.f", - "negative integer": "a = -1", - "decimal number": "a = 1.580000", // %f uses a default precision of 6 digits after the decimal point - } - - for name, fixture := range fixtures { - t.Run(name, func(t *testing.T) { - expr, err := parser.ParseExpression(fixture) - assert.NoError(t, err) - - str, err := expr.ToString() - assert.NoError(t, err) - assert.Equal(t, fixture, str) - }) - } -} - -func TestToString(t *testing.T) { - source := ` - a == b or - ( - (c < d) and - (e > f) - ) - ` - - expr, err := parser.ParseExpression(source) - assert.NoError(t, err) - - output, err := expr.ToString() - assert.NoError(t, err) - - assert.Equal(t, "a == b or ((c < d) and (e > f))", output) -} - -func TestIsValue(t *testing.T) { - fixtures := map[string]bool{ - "a": true, - "1": true, - "true": true, - "false": true, - "null": true, - "42": true, - "[1,2,3]": true, - "1.12": true, - - "a == b": false, - "true or a == b": false, - "true and a == b": false, - "(a == b)": false, - "a = b": false, - } - - for input, expected := range fixtures { - t.Run(input, func(t *testing.T) { - expr, err := parser.ParseExpression(input) - assert.NoError(t, err) - - assert.Equal(t, expected, expr.IsValue()) - }) - } -} - -func TestIsAssignment(t *testing.T) { - fixtures := map[string]bool{ - "a": false, - "1": false, - "-1": false, - "true": false, - "false": false, - "null": false, - "42": false, - "[1,2,3]": false, - "1.23": false, - "-1.23": false, - - "a == b": false, - "true or a == b": false, - "true and a == b": false, - "(a == b)": false, - "a = b": true, - "a = -1": true, - "a += 1": false, - "a -= 1": false, - "a = 1.23": true, - "a = -1.23": true, - } - - for input, expected := range fixtures { - t.Run(input, func(t *testing.T) { - expr, err := parser.ParseExpression(input) - assert.NoError(t, err) - - actual := expr.IsAssignment() - assert.Equal(t, expected, actual) - }) - } -} - -func TestLogicalExpressions(t *testing.T) { - fixtures := map[string]bool{ - "a": false, - "1": false, - "true": false, - "false": false, - "null": false, - "42": false, - "[1,2,3]": false, - "a = b": false, - - "a == b": true, - "a.b.c == b": true, - "a == b.c": true, - "a.b == b.c or a.c == b.c": true, - "a.b == b.c and b.c == x.x or a.b.c == b.c.a": true, - "a > b": true, - "a >= b": true, - "a < b": true, - "a <= b": true, - "a in b": true, - "a != b": true, - "a not in b": true, - "a.b.c not in b.c.a": true, - } - - for input, expected := range fixtures { - t.Run(input, func(t *testing.T) { - expr, err := parser.ParseExpression(input) - assert.NoError(t, err) - - for _, cond := range expr.Conditions() { - if expected { - assert.Equal(t, parser.LogicalCondition, cond.Type()) - } else { - assert.NotEqual(t, parser.LogicalCondition, cond.Type()) - } - } - }) - } -} diff --git a/schema/parser/operand.go b/schema/parser/operand.go deleted file mode 100644 index f942e6e17..000000000 --- a/schema/parser/operand.go +++ /dev/null @@ -1,214 +0,0 @@ -package parser - -import ( - "fmt" - - "github.com/teamkeel/keel/schema/node" -) - -type Operand struct { - node.Node - - Number *int64 ` @('-'? Int)` - Decimal *float64 `| @('-'? Float)` - String *string `| @String` - Null bool `| @"null"` - True bool `| @"true"` - False bool `| @"false"` - Array *Array `| @@` - Ident *Ident `| @@` -} - -func (o *Operand) ToString() string { - if o == nil { - return "" - } - - switch o.Type() { - case TypeDecimal: - return fmt.Sprintf("%f", *o.Decimal) - case TypeNumber: - return fmt.Sprintf("%d", *o.Number) - case TypeText: - return *o.String - case TypeNull: - return "null" - case TypeBoolean: - if o.False { - return "false" - } else { - return "true" - } - case TypeArray: - r := "[" - for i, el := range o.Array.Values { - if i > 0 { - r += ", " - } - r += el.ToString() - } - return r + "]" - case TypeIdent: - return o.Ident.ToString() - default: - return "" - } -} - -func (o *Operand) Type() string { - switch { - case o.Decimal != nil: - return TypeDecimal - case o.Number != nil: - return TypeNumber - case o.String != nil: - return TypeText - case o.Null: - return TypeNull - case o.False: - return TypeBoolean - case o.True: - return TypeBoolean - case o.Array != nil: - return TypeArray - case o.Ident != nil && len(o.Ident.Fragments) > 0: - return TypeIdent - default: - return "" - } -} - -func (o *Operand) IsLiteralType() (bool, string) { - switch { - case o.Number != nil: - return true, o.ToString() - case o.Decimal != nil: - return true, o.ToString() - case o.String != nil: - return true, o.ToString() - case o.Null: - return true, o.ToString() - case o.False: - return true, o.ToString() - case o.True: - return true, o.ToString() - case o.Array != nil: - allLiterals := true - - for _, item := range o.Array.Values { - if ok, _ := item.IsLiteralType(); ok { - continue - } - - allLiterals = false - } - - return allLiterals, o.ToString() - case o.Ident != nil && len(o.Ident.Fragments) > 0: - return false, o.ToString() - default: - return true, o.ToString() - } -} - -type Ident struct { - node.Node - - Fragments []*IdentFragment `( @@ ( "." @@ )* )` -} - -func (ident *Ident) LastFragment() string { - return ident.Fragments[len(ident.Fragments)-1].Fragment -} - -func (ident *Ident) ToString() string { - ret := "" - for i, fragment := range ident.Fragments { - if i == len(ident.Fragments)-1 { - ret += fragment.Fragment - } else { - ret += fmt.Sprintf("%s.", fragment.Fragment) - } - } - - return ret -} - -func (ident *Ident) IsContext() bool { - return ident != nil && ident.Fragments[0].Fragment == "ctx" -} - -func (ident *Ident) IsContextIdentity() bool { - if !ident.IsContext() { - return false - } - - if len(ident.Fragments) > 1 && ident.Fragments[1].Fragment == "identity" { - return true - } - - return false -} - -func (ident *Ident) IsContextIdentityId() bool { - if !ident.IsContextIdentity() { - return false - } - - if len(ident.Fragments) == 2 { - return true - } - - if len(ident.Fragments) == 3 && ident.Fragments[2].Fragment == "id" { - return true - } - - return false -} - -func (ident *Ident) IsContextIsAuthenticatedField() bool { - if ident.IsContext() && len(ident.Fragments) == 2 { - return ident.Fragments[1].Fragment == "isAuthenticated" - } - return false -} - -func (ident *Ident) IsContextNowField() bool { - if ident.IsContext() && len(ident.Fragments) == 2 { - return ident.Fragments[1].Fragment == "now" - } - return false -} - -func (ident *Ident) IsContextHeadersField() bool { - if ident.IsContext() && len(ident.Fragments) == 3 { - return ident.Fragments[1].Fragment == "headers" - } - return false -} - -func (ident *Ident) IsContextEnvField() bool { - if ident.IsContext() && len(ident.Fragments) == 3 { - return ident.Fragments[1].Fragment == "env" - } - return false -} - -func (ident *Ident) IsContextSecretField() bool { - if ident.IsContext() && len(ident.Fragments) == 3 { - return ident.Fragments[1].Fragment == "secrets" - } - return false -} - -type IdentFragment struct { - node.Node - - Fragment string `@Ident` -} - -type Array struct { - node.Node - - Values []*Operand `"[" @@* ( "," @@ )* "]"` -} diff --git a/schema/parser/parser.go b/schema/parser/parser.go index 8b0bb22d4..d46031b24 100644 --- a/schema/parser/parser.go +++ b/schema/parser/parser.go @@ -43,9 +43,9 @@ type ModelNode struct { type ModelSectionNode struct { node.Node - Fields []*FieldNode `( "fields" "{" @@* "}"` - Actions []*ActionNode `| "actions" "{" @@* "}"` - Attribute *AttributeNode `| @@)` + Fields []*FieldNode `( ("fields" "{" @@* "}")` + Actions []*ActionNode `| ("actions" "{" @@* "}")` + Attribute *AttributeNode `| @@ )` } type NameNode struct { @@ -82,7 +82,7 @@ func (f *FieldNode) IsScalar() bool { FieldTypeNumber, FieldTypeDecimal, FieldTypeText, - FieldTypeDatetime, + FieldTypeTimestamp, FieldTypeDate, FieldTypeSecret, FieldTypeID, @@ -105,8 +105,8 @@ type APINode struct { type APISectionNode struct { node.Node - Models []*APIModelNode `("models" "{" @@* "}"` - Attribute *AttributeNode `| @@)` + Models []*APIModelNode `( "models" "{" @@* "}"` + Attribute *AttributeNode `| @@ )` } type APIModelNode struct { @@ -165,7 +165,7 @@ type JobSectionNode struct { node.Node Inputs []*JobInputNode `( "inputs" "{" @@* "}"` - Attribute *AttributeNode `| @@)` + Attribute *AttributeNode `| @@ )` } type JobInputNode struct { @@ -196,7 +196,7 @@ type AttributeNode struct { // - no parenthesis at all // - empty parenthesis // - parenthesis with args - Arguments []*AttributeArgumentNode `(( "(" @@ ( "," @@ )* ")" ) | ( "(" ")" ) )?` + Arguments []*AttributeArgumentNode `(( "(" ")" ) | ( "(" @@ ( "," @@ )* ")" ) )?` } type AttributeArgumentNode struct { @@ -240,6 +240,31 @@ type ActionInputNode struct { Optional bool `@( "?" )?` } +type Ident struct { + node.Node + + Fragments []*IdentFragment `( @@ ( "." @@ )* )` +} + +func (ident *Ident) ToString() string { + ret := "" + for i, fragment := range ident.Fragments { + if i == len(ident.Fragments)-1 { + ret += fragment.Fragment + } else { + ret += fmt.Sprintf("%s.", fragment.Fragment) + } + } + + return ret +} + +type IdentFragment struct { + node.Node + + Fragment string `@Ident` +} + func (a *ActionInputNode) Name() string { if a.Label != nil { return a.Label.Value diff --git a/schema/parser/parser_test.go b/schema/parser/parser_test.go index 35e66beee..079f4ff01 100644 --- a/schema/parser/parser_test.go +++ b/schema/parser/parser_test.go @@ -3,7 +3,9 @@ package parser_test import ( "testing" + "github.com/alecthomas/participle/v2/lexer" "github.com/stretchr/testify/assert" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/reader" ) @@ -180,20 +182,18 @@ func TestModelWithPermissionAttributes(t *testing.T) { assert.Equal(t, "permission", schema.Declarations[0].Model.Sections[2].Attribute.Name.Value) arg1 := schema.Declarations[0].Model.Sections[2].Attribute.Arguments[0] - assert.Equal(t, true, arg1.Expression.IsValue()) assert.Equal(t, "expression", arg1.Label.Value) arg2 := schema.Declarations[0].Model.Sections[2].Attribute.Arguments[1] - assert.Equal(t, true, arg2.Expression.IsValue()) assert.Equal(t, "actions", arg2.Label.Value) - v1, err := arg1.Expression.ToValue() + v1, _, err := resolve.ToValue[bool](arg1.Expression) assert.NoError(t, err) - assert.Equal(t, true, v1.True) + assert.Equal(t, true, v1) - v2, err := arg2.Expression.ToValue() + v2, err := resolve.AsIdentArray(arg2.Expression) assert.NoError(t, err) - assert.Equal(t, "get", v2.Array.Values[0].Ident.Fragments[0].Fragment) + assert.Equal(t, "get", v2[0].Fragments[0]) } func TestAttributeWithNamedArguments(t *testing.T) { @@ -221,20 +221,18 @@ func TestAttributeWithNamedArguments(t *testing.T) { }`}) arg1 := schema.Declarations[0].Model.Sections[2].Attribute.Arguments[0] - assert.Equal(t, true, arg1.Expression.IsValue()) assert.Equal(t, "role", arg1.Label.Value) arg2 := schema.Declarations[0].Model.Sections[2].Attribute.Arguments[1] - assert.Equal(t, true, arg2.Expression.IsValue()) assert.Equal(t, "actions", arg2.Label.Value) - v1, err := arg1.Expression.ToValue() + v1, err := resolve.AsIdent(arg1.Expression) assert.NoError(t, err) - assert.Equal(t, "Admin", v1.Ident.Fragments[0].Fragment) + assert.Equal(t, "Admin", v1.String()) - v2, err := arg2.Expression.ToValue() + v2, err := resolve.AsIdentArray(arg2.Expression) assert.NoError(t, err) - assert.Equal(t, "create", v2.Array.Values[0].Ident.Fragments[0].Fragment) + assert.Equal(t, "create", v2[0].String()) } func TestOperationWithOrderByAttribute(t *testing.T) { @@ -257,20 +255,18 @@ model Author { assert.Equal(t, "orderBy", attribute.Name.Value) arg1 := attribute.Arguments[0] - assert.Equal(t, true, arg1.Expression.IsValue()) assert.Equal(t, "firstName", arg1.Label.Value) arg2 := attribute.Arguments[1] - assert.Equal(t, true, arg2.Expression.IsValue()) assert.Equal(t, "surname", arg2.Label.Value) - v1, err := arg1.Expression.ToValue() + v1, err := resolve.AsIdent(arg1.Expression) assert.NoError(t, err) - assert.Equal(t, "asc", v1.Ident.Fragments[0].Fragment) + assert.Equal(t, "asc", v1.String()) - v2, err := arg2.Expression.ToValue() + v2, err := resolve.AsIdent(arg2.Expression) assert.NoError(t, err) - assert.Equal(t, "desc", v2.Ident.Fragments[0].Fragment) + assert.Equal(t, "desc", v2.String()) } func TestOperationWithSortableAttribute(t *testing.T) { @@ -293,20 +289,18 @@ model Author { assert.Equal(t, "sortable", attribute.Name.Value) arg1 := attribute.Arguments[0] - assert.Equal(t, true, arg1.Expression.IsValue()) assert.Nil(t, arg1.Label) arg2 := attribute.Arguments[1] - assert.Equal(t, true, arg2.Expression.IsValue()) assert.Nil(t, arg2.Label) - v1, err := arg1.Expression.ToValue() + v1, err := resolve.AsIdent(arg1.Expression) assert.NoError(t, err) - assert.Equal(t, "firstName", v1.Ident.Fragments[0].Fragment) + assert.Equal(t, "firstName", v1.String()) - v2, err := arg2.Expression.ToValue() + v2, err := resolve.AsIdent(arg2.Expression) assert.NoError(t, err) - assert.Equal(t, "surname", v2.Ident.Fragments[0].Fragment) + assert.Equal(t, "surname", v2.String()) } func TestOperationWithEmbedAttribute(t *testing.T) { @@ -351,28 +345,25 @@ model Genre { assert.Equal(t, "embed", attribute.Name.Value) arg1 := attribute.Arguments[0] - assert.Equal(t, true, arg1.Expression.IsValue()) assert.Nil(t, arg1.Label) arg2 := attribute.Arguments[1] - assert.Equal(t, true, arg2.Expression.IsValue()) assert.Nil(t, arg2.Label) - v1, err := arg1.Expression.ToString() + v1, err := resolve.AsIdent(arg1.Expression) assert.NoError(t, err) - assert.Equal(t, "genre", v1) + assert.Equal(t, "genre", v1.String()) - v2, err := arg2.Expression.ToString() + v2, err := resolve.AsIdent(arg2.Expression) assert.NoError(t, err) - assert.Equal(t, "category", v2) + assert.Equal(t, "category", v2.String()) - arg := attribute2.Arguments[0] - assert.Equal(t, true, arg.Expression.IsValue()) - assert.Nil(t, arg.Label) + arg3 := attribute2.Arguments[0] + assert.Nil(t, arg3.Label) - v, err := arg.Expression.ToString() + v3, err := resolve.AsIdent(arg3.Expression) assert.NoError(t, err) - assert.Equal(t, "author.category", v) + assert.Equal(t, "author.category", v3.String()) } func TestAPI(t *testing.T) { @@ -578,22 +569,146 @@ func TestOnAttributeArgsParsing(t *testing.T) { assert.Len(t, model.Sections[0].Attribute.Arguments, 2) assert.Len(t, model.Sections[1].Attribute.Arguments, 2) - on1actiontypes, err := schema.Declarations[0].Model.Sections[0].Attribute.Arguments[0].Expression.ToValue() + on1actiontypes, err := resolve.AsIdentArray(schema.Declarations[0].Model.Sections[0].Attribute.Arguments[0].Expression) assert.NoError(t, err) - assert.Len(t, on1actiontypes.Array.Values, 1) - assert.Equal(t, "create", on1actiontypes.Array.Values[0].Ident.Fragments[0].Fragment) + assert.Len(t, on1actiontypes, 1) + assert.Equal(t, "create", on1actiontypes[0].String()) - on1subscriber, err := schema.Declarations[0].Model.Sections[0].Attribute.Arguments[1].Expression.ToValue() + on1subscriber, err := resolve.AsIdent(schema.Declarations[0].Model.Sections[0].Attribute.Arguments[1].Expression) assert.NoError(t, err) - assert.Equal(t, "sendWelcomeMail", on1subscriber.Ident.Fragments[0].Fragment) + assert.Equal(t, "sendWelcomeMail", on1subscriber.String()) - on2actiontypes, err := schema.Declarations[0].Model.Sections[1].Attribute.Arguments[0].Expression.ToValue() + on2actiontypes, err := resolve.AsIdentArray(schema.Declarations[0].Model.Sections[1].Attribute.Arguments[0].Expression) assert.NoError(t, err) - assert.Len(t, on2actiontypes.Array.Values, 2) - assert.Equal(t, "create", on2actiontypes.Array.Values[0].Ident.Fragments[0].Fragment) - assert.Equal(t, "update", on2actiontypes.Array.Values[1].Ident.Fragments[0].Fragment) + assert.Len(t, on2actiontypes, 2) + assert.Equal(t, "create", on2actiontypes[0].String()) + assert.Equal(t, "update", on2actiontypes[1].String()) - on2subscriber, err := schema.Declarations[0].Model.Sections[1].Attribute.Arguments[1].Expression.ToValue() + on2subscriber, err := resolve.AsIdent(schema.Declarations[0].Model.Sections[1].Attribute.Arguments[1].Expression) assert.NoError(t, err) - assert.Equal(t, "verifyEmail", on2subscriber.Ident.Fragments[0].Fragment) + assert.Equal(t, "verifyEmail", on2subscriber.String()) +} + +func TestAttributeNoArgs(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Author { + fields { + code Text @unique + } + }`}) + attribute := schema.Declarations[0].Model.Sections[0].Fields[0].Attributes[0] + assert.Len(t, attribute.Arguments, 0) +} + +func TestAttributeWithParenthesisNoArgs(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Author { + fields { + code Text @unique() + } + }`}) + attribute := schema.Declarations[0].Model.Sections[0].Fields[0].Attributes[0] + assert.Len(t, attribute.Arguments, 0) +} + +func TestExpressionToAssignmentValid(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Author { + fields { + isActive Boolean + } + @set(expression: author.isActive = true) + }`}) + expression := schema.Declarations[0].Model.Sections[1].Attribute.Arguments[0].Expression + lhs, rhs, err := expression.ToAssignmentExpression() + assert.NoError(t, err) + + assert.Equal(t, "author.isActive", lhs.String()) + assert.Equal(t, expression.Pos, lhs.Pos) + assert.Equal(t, lexer.Position{Filename: "test.keel", Column: 36, Offset: 87, Line: 6}, lhs.EndPos) + + assert.Equal(t, "true", rhs.String()) + assert.Equal(t, lexer.Position{Filename: "test.keel", Column: 38, Offset: 89, Line: 6}, rhs.Pos) + assert.Equal(t, expression.EndPos, rhs.EndPos) +} + +func TestExpressionToAssignmentEquality(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Author { + fields { + isActive Boolean + } + @set(expression: author.isActive == true) + }`}) + expression := schema.Declarations[0].Model.Sections[1].Attribute.Arguments[0].Expression + lhs, rhs, err := expression.ToAssignmentExpression() + assert.ErrorIs(t, err, parser.ErrInvalidAssignmentExpression) + assert.Nil(t, lhs) + assert.Nil(t, rhs) +} + +func TestExpressionToAssignmentNoLhs(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Author { + fields { + isActive Boolean + } + @set(expression: = true) + }`}) + expression := schema.Declarations[0].Model.Sections[1].Attribute.Arguments[0].Expression + lhs, rhs, err := expression.ToAssignmentExpression() + assert.ErrorIs(t, err, parser.ErrInvalidAssignmentExpression) + assert.Nil(t, lhs) + assert.Nil(t, rhs) +} + +func TestExpressionToAssignmentNoRhs(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Author { + fields { + isActive Boolean + } + @set(expression: post.IsActive =) + }`}) + expression := schema.Declarations[0].Model.Sections[1].Attribute.Arguments[0].Expression + lhs, rhs, err := expression.ToAssignmentExpression() + assert.ErrorIs(t, err, parser.ErrInvalidAssignmentExpression) + assert.Nil(t, lhs) + assert.Nil(t, rhs) +} + +func TestExpressionToAssignmentNoAssignment(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Author { + fields { + isActive Boolean + } + @set(expression: post.IsActive) + }`}) + expression := schema.Declarations[0].Model.Sections[1].Attribute.Arguments[0].Expression + lhs, rhs, err := expression.ToAssignmentExpression() + assert.ErrorIs(t, err, parser.ErrInvalidAssignmentExpression) + assert.Nil(t, lhs) + assert.Nil(t, rhs) +} + +func TestExpressionToStringPreserveWhitespaces(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Author { + @permission(expression: ctx.isAuthenticated == true) + }`}) + expression := schema.Declarations[0].Model.Sections[0].Attribute.Arguments[0].Expression + assert.Equal(t, "ctx.isAuthenticated == true", expression.String()) +} + +func TestExpressionToStringPreserveNewLines(t *testing.T) { + schema := parse(t, &reader.SchemaFile{FileName: "test.keel", Contents: ` + model Author { + @permission(expression: ctx.isAuthenticated + == true) + }`}) + expression := schema.Declarations[0].Model.Sections[0].Attribute.Arguments[0].Expression + assert.Equal(t, + `ctx.isAuthenticated + == true`, expression.String()) } diff --git a/schema/query/query.go b/schema/query/query.go index 2a08e75e2..676a47079 100644 --- a/schema/query/query.go +++ b/schema/query/query.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/samber/lo" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/schema/parser" ) @@ -82,6 +83,40 @@ func Model(asts []*parser.AST, name string) *parser.ModelNode { return nil } +func Action(asts []*parser.AST, name string) *parser.ActionNode { + for _, ast := range asts { + for _, decl := range ast.Declarations { + if decl.Model != nil { + for _, sec := range decl.Model.Sections { + for _, action := range sec.Actions { + if action.Name.Value == name { + return action + } + } + } + } + } + } + return nil +} + +func ActionModel(asts []*parser.AST, name string) *parser.ModelNode { + for _, ast := range asts { + for _, decl := range ast.Declarations { + if decl.Model != nil { + for _, sec := range decl.Model.Sections { + for _, action := range sec.Actions { + if action.Name.Value == name { + return decl.Model + } + } + } + } + } + } + return nil +} + // Field provides the field of the given name from the given model. (Or nil). func Field(model *parser.ModelNode, name string) *parser.FieldNode { for _, f := range ModelFields(model) { @@ -315,6 +350,10 @@ func FieldIsUnique(field *parser.FieldNode) bool { return FieldHasAttribute(field, parser.AttributePrimaryKey) || FieldHasAttribute(field, parser.AttributeUnique) } +func FieldIsComputed(field *parser.FieldNode) bool { + return FieldHasAttribute(field, parser.AttributeComputed) +} + // CompositeUniqueFields returns the model's fields that make up a composite unique attribute func CompositeUniqueFields(model *parser.ModelNode, attribute *parser.AttributeNode) []*parser.FieldNode { if attribute.Name.Value != parser.AttributeUnique { @@ -324,21 +363,15 @@ func CompositeUniqueFields(model *parser.ModelNode, attribute *parser.AttributeN fields := []*parser.FieldNode{} if len(attribute.Arguments) > 0 { - value, err := attribute.Arguments[0].Expression.ToValue() + operands, err := resolve.AsIdentArray(attribute.Arguments[0].Expression) if err != nil { return fields } - if value.Array != nil { - fieldNames := lo.Map(value.Array.Values, func(o *parser.Operand, _ int) string { - return o.Ident.ToString() - }) - - for _, f := range fieldNames { - field := Field(model, f) - if field != nil { - fields = append(fields, field) - } + for _, f := range operands { + field := Field(model, f.String()) + if field != nil { + fields = append(fields, field) } } } @@ -375,11 +408,11 @@ func ActionSortableFieldNames(action *parser.ActionNode) ([]string, error) { if attribute != nil { for _, arg := range attribute.Arguments { - fieldName, err := arg.Expression.ToValue() + fieldName, err := resolve.AsIdent(arg.Expression) if err != nil { return nil, err } - fields = append(fields, fieldName.Ident.Fragments[0].Fragment) + fields = append(fields, fieldName.String()) } } @@ -570,9 +603,10 @@ func SubscriberNames(asts []*parser.AST) (res []string) { if len(attribute.Arguments) == 2 { subscriberArg := attribute.Arguments[1] - operand, err := subscriberArg.Expression.ToValue() - if err == nil && operand.Ident != nil && len(operand.Ident.Fragments) == 1 { - name := operand.Ident.Fragments[0].Fragment + + ident, err := resolve.AsIdent(subscriberArg.Expression) + if err == nil && ident != nil && len(ident.Fragments) == 1 { + name := ident.String() if !lo.Contains(res, name) { res = append(res, name) } @@ -759,15 +793,14 @@ func RelationAttributeValue(attr *parser.AttributeNode) (field string, ok bool) return "", false } - expr := attr.Arguments[0].Expression - operand, err := expr.ToValue() + operand, err := resolve.AsIdent(attr.Arguments[0].Expression) if err != nil { return "", false } - if operand.Ident == nil { + if operand == nil { return "", false } - return operand.Ident.Fragments[0].Fragment, true + return operand.Fragments[0], true } diff --git a/schema/schema.go b/schema/schema.go index 880710b0f..38c86e567 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -3,6 +3,7 @@ package schema import ( "errors" "fmt" + "strings" "github.com/alecthomas/participle/v2/lexer" "github.com/samber/lo" @@ -243,7 +244,7 @@ func (scm *Builder) insertBuiltInFields(declarations *parser.AST) { Value: parser.FieldNameCreatedAt, }, Type: parser.NameNode{ - Value: parser.FieldTypeDatetime, + Value: parser.FieldTypeTimestamp, }, Attributes: []*parser.AttributeNode{ { @@ -259,7 +260,7 @@ func (scm *Builder) insertBuiltInFields(declarations *parser.AST) { Value: parser.FieldNameUpdatedAt, }, Type: parser.NameNode{ - Value: parser.FieldTypeDatetime, + Value: parser.FieldTypeTimestamp, }, Attributes: []*parser.AttributeNode{ { @@ -387,6 +388,8 @@ func (scm *Builder) insertIdentityModel(declarations *parser.AST, schemaFile *re }, } + falseLiteral, _ := parser.ParseExpression("false") + identityFields := []*parser.FieldNode{ { BuiltIn: true, @@ -414,21 +417,7 @@ func (scm *Builder) insertIdentityModel(declarations *parser.AST, schemaFile *re }, Arguments: []*parser.AttributeArgumentNode{ { - Expression: &parser.Expression{ - Or: []*parser.OrExpression{ - { - And: []*parser.ConditionWrap{ - { - Condition: &parser.Condition{ - LHS: &parser.Operand{ - False: true, - }, - }, - }, - }, - }, - }, - }, + Expression: falseLiteral, }, }, }, @@ -739,26 +728,7 @@ func (scm *Builder) addSecrets(declarations *parser.AST) { } func (scm *Builder) emailUniqueAttributeNode() *parser.AttributeNode { - operands := []*parser.Operand{ - { - Ident: &parser.Ident{ - Fragments: []*parser.IdentFragment{ - { - Fragment: "email", - }, - }, - }, - }, - { - Ident: &parser.Ident{ - Fragments: []*parser.IdentFragment{ - { - Fragment: "issuer", - }, - }, - }, - }, - } + operands := []string{"email", "issuer"} if scm.Config != nil { for _, c := range scm.Config.Auth.Claims { @@ -766,41 +736,19 @@ func (scm *Builder) emailUniqueAttributeNode() *parser.AttributeNode { continue } - operands = append(operands, &parser.Operand{ - Ident: &parser.Ident{ - Fragments: []*parser.IdentFragment{ - { - Fragment: c.Field, - }, - }, - }, - }) + operands = append(operands, c.Field) } } + operandsExpr, _ := parser.ParseExpression(fmt.Sprintf("[%s]", strings.Join(operands, ","))) + return &parser.AttributeNode{ Name: parser.AttributeNameToken{ Value: parser.AttributeUnique, }, Arguments: []*parser.AttributeArgumentNode{ { - Expression: &parser.Expression{ - Or: []*parser.OrExpression{ - { - And: []*parser.ConditionWrap{ - { - Condition: &parser.Condition{ - LHS: &parser.Operand{ - Array: &parser.Array{ - Values: operands, - }, - }, - }, - }, - }, - }, - }, - }, + Expression: operandsExpr, }, }, } diff --git a/schema/schema_test.go b/schema/schema_test.go index 2c42bfc79..82038be8c 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -79,7 +79,6 @@ func TestValidation(t *testing.T) { } testCaseDir := filepath.Join(dir, testCase.Name()) - t.Run(testCase.Name(), func(t *testing.T) { t.Parallel() b, err := os.ReadFile(testCaseDir) @@ -90,7 +89,7 @@ func TestValidation(t *testing.T) { verrs := &errorhandling.ValidationErrors{} if !errors.As(err, &verrs) { - t.Errorf("no validation errors returned: %v", err) + t.Errorf("no validation errors returned in %s: %v", testCase.Name(), err) } expectedErrors := []*errorhandling.ValidationError{} diff --git a/schema/testdata/errors/array_fields_default.keel b/schema/testdata/errors/array_fields_default.keel index b44fe48bd..95fc7f77f 100644 --- a/schema/testdata/errors/array_fields_default.keel +++ b/schema/testdata/errors/array_fields_default.keel @@ -1,14 +1,14 @@ model Thing { fields { - //expect-error:31:40:E048:"science" is Text but field texts is Text[] + //expect-error:31:40:AttributeExpressionError:expression expected to resolve to type Text[] but it is Text texts Text[] @default("science") - //expect-error:33:44:E048:["science"] is Text[] but field enums is MyEnum[] + //expect-error:33:44:AttributeExpressionError:expression expected to resolve to type MyEnum[] but it is Text[] enums MyEnum[] @default(["science"]) - //expect-error:30:41:E048:["science"] is Text[] but field enum is MyEnum + //expect-error:30:41:AttributeExpressionError:expression expected to resolve to type MyEnum but it is Text[] enum MyEnum @default(["science"]) - //expect-error:28:39:E048:["science"] is Text[] but field text is Text + //expect-error:28:39:AttributeExpressionError:expression expected to resolve to type Text but it is Text[] text Text @default(["science"]) - //expect-error:32:42:E048:MyEnum.One is MyEnum but field texts2 is Text[] + //expect-error:32:42:AttributeExpressionError:expression expected to resolve to type Text[] but it is MyEnum texts2 Text[] @default(MyEnum.One) } } diff --git a/schema/testdata/errors/array_fields_expression_incorrect_operator.keel b/schema/testdata/errors/array_fields_expression_incorrect_operator.keel index 32a5a8379..504ca2652 100644 --- a/schema/testdata/errors/array_fields_expression_incorrect_operator.keel +++ b/schema/testdata/errors/array_fields_expression_incorrect_operator.keel @@ -7,27 +7,27 @@ model Thing { actions { list listThings1() { - //expect-error:20:44:E026:thing.texts is Text[] but "science" is Text + //expect-error:32:34:AttributeExpressionError:cannot use operator 'in' with types Text[] and Text @where(thing.texts in "science") } list listThings2() { - //expect-error:20:31:E027:left hand side operand cannot be an array for 'in' and 'not in' + //expect-error:32:34:AttributeExpressionError:cannot use operator 'in' with types Text[] and Text[] @where(thing.texts in ["science"]) } list listThings3() { - //expect-error:20:31:E027:left hand side operand cannot be an array for 'in' and 'not in' + //expect-error:32:34:AttributeExpressionError:cannot use operator 'in' with types Text[] and Text[] @where(["science"] in thing.texts) } - list listThings4() { - //expect-error:20:44:E026:thing.texts is Text[] but "science" is Text - @where(thing.texts == "science") - } + // This won't be validated against until we deprecate '==' acting as an ANY query for relationships + //list listThings4() { + // @where(thing.texts == "science") + //} list listThings5() { - //expect-error:30:32:E030:thing.texts is an array. Only 'in' or 'not in' can be used + //expect-error:30:32:AttributeExpressionError:cannot use operator '==' with types Text and Text[] @where("science" == thing.texts) } } diff --git a/schema/testdata/errors/array_fields_expression_type_mismatch.keel b/schema/testdata/errors/array_fields_expression_type_mismatch.keel index 10c6bbed3..af7ea82f3 100644 --- a/schema/testdata/errors/array_fields_expression_type_mismatch.keel +++ b/schema/testdata/errors/array_fields_expression_type_mismatch.keel @@ -6,27 +6,27 @@ model Thing { actions { list listThings1() { - //expect-error:20:48:E026:["science"] is an array of Text and thing.numbers is an array of Number + //expect-error:32:34:AttributeExpressionError:cannot use operator '==' with types Text[] and Number[] @where(["science"] == thing.numbers) } list listThings2() { - //expect-error:20:43:E026:[10, 20] is an array of Number and thing.texts is an array of Text + //expect-error:29:31:AttributeExpressionError:cannot use operator '==' with types Number[] and Text[] @where([10, 20] == thing.texts) } list listThings3() { - //expect-error:20:48:E026:thing.numbers is an array of Number and ["science"] is an array of Text + //expect-error:34:36:AttributeExpressionError:cannot use operator '==' with types Number[] and Text[] @where(thing.numbers == ["science"]) } list listThings4() { - //expect-error:20:48:E026:thing.texts is an array of Text and thing.numbers is an array of Number + //expect-error:32:34:AttributeExpressionError:cannot use operator '==' with types Text[] and Number[] @where(thing.texts == thing.numbers) } list listThings5() { - //expect-error:20:38:E026:100 is Number and thing.texts is an array of Text + //expect-error:24:26:AttributeExpressionError:cannot use operator 'in' with types Number and Text[] @where(100 in thing.texts) } } diff --git a/schema/testdata/errors/array_fields_set.keel b/schema/testdata/errors/array_fields_set.keel index 07ed7939e..ef4ccf426 100644 --- a/schema/testdata/errors/array_fields_set.keel +++ b/schema/testdata/errors/array_fields_set.keel @@ -10,41 +10,41 @@ model Thing { actions { create createThing() { - //expect-error:18:41:E026:thing.texts is Text[] but "science" is Text + //expect-error:32:41:AttributeExpressionError:expression expected to resolve to type Text[] but it is Text @set(thing.texts = "science") - //expect-error:18:42:E026:thing.enums is MyEnum[] but MyEnum.One is MyEnum + //expect-error:32:42:AttributeExpressionError:expression expected to resolve to type MyEnum[] but it is MyEnum @set(thing.enums = MyEnum.One) - //expect-error:18:37:E026:thing.numbers is Number[] but 123 is Number + //expect-error:34:37:AttributeExpressionError:expression expected to resolve to type Number[] but it is Number @set(thing.numbers = 123) - //expect-error:29:30:E030:["science"] is an array. Only 'in' or 'not in' can be used + //expect-error:31:42:AttributeExpressionError:expression expected to resolve to type Text but it is Text[] @set(thing.text = ["science"]) - //expect-error:29:30:E030:[MyEnum.One, MyEnum.Two] is an array. Only 'in' or 'not in' can be used + //expect-error:31:55:AttributeExpressionError:expression expected to resolve to type MyEnum but it is MyEnum[] @set(thing.enum = [MyEnum.One, MyEnum.Two]) - //expect-error:31:32:E030:[123, 456] is an array. Only 'in' or 'not in' can be used + //expect-error:33:42:AttributeExpressionError:expression expected to resolve to type Number but it is Number[] @set(thing.number = [123,456]) } update updateThing(id) { - //expect-error:18:41:E026:thing.texts is Text[] but "science" is Text + //expect-error:32:41:AttributeExpressionError:expression expected to resolve to type Text[] but it is Text @set(thing.texts = "science") - //expect-error:18:42:E026:thing.enums is MyEnum[] but MyEnum.One is MyEnum + //expect-error:32:42:AttributeExpressionError:expression expected to resolve to type MyEnum[] but it is MyEnum @set(thing.enums = MyEnum.One) - //expect-error:18:37:E026:thing.numbers is Number[] but 123 is Number + //expect-error:34:37:AttributeExpressionError:expression expected to resolve to type Number[] but it is Number @set(thing.numbers = 123) - //expect-error:29:30:E030:["science"] is an array. Only 'in' or 'not in' can be used + //expect-error:31:42:AttributeExpressionError:expression expected to resolve to type Text but it is Text[] @set(thing.text = ["science"]) - //expect-error:29:30:E030:[MyEnum.One, MyEnum.Two] is an array. Only 'in' or 'not in' can be used + //expect-error:31:55:AttributeExpressionError:expression expected to resolve to type MyEnum but it is MyEnum[] @set(thing.enum = [MyEnum.One, MyEnum.Two]) - //expect-error:31:32:E030:[123, 456] is an array. Only 'in' or 'not in' can be used + //expect-error:33:42:AttributeExpressionError:expression expected to resolve to type Number but it is Number[] @set(thing.number = [123,456]) } create createNulls() { - //expect-error:18:36:E060:texts cannot be null + //expect-error:18:29:AttributeExpressionError:'texts' cannot be set to null @set(thing.texts = null) - //expect-error:18:36:E060:enums cannot be null + //expect-error:18:29:AttributeExpressionError:'enums' cannot be set to null @set(thing.enums = null) - //expect-error:18:38:E060:numbers cannot be null + //expect-error:18:31:AttributeExpressionError:'numbers' cannot be set to null @set(thing.numbers = null) @set(thing.text = "") @@ -53,11 +53,11 @@ model Thing { } update updateNulls(id) { - //expect-error:18:36:E060:texts cannot be null + //expect-error:18:29:AttributeExpressionError:'texts' cannot be set to null @set(thing.texts = null) - //expect-error:18:36:E060:enums cannot be null + //expect-error:18:29:AttributeExpressionError:'enums' cannot be set to null @set(thing.enums = null) - //expect-error:18:38:E060:numbers cannot be null + //expect-error:18:31:AttributeExpressionError:'numbers' cannot be set to null @set(thing.numbers = null) @set(thing.text = "") diff --git a/schema/testdata/errors/attribute_computed.keel b/schema/testdata/errors/attribute_computed.keel new file mode 100644 index 000000000..9c9282ae8 --- /dev/null +++ b/schema/testdata/errors/attribute_computed.keel @@ -0,0 +1,25 @@ +model Item { + fields { + //expect-error:23:32:AttributeArgumentError:0 argument(s) provided to @computed but expected 1 + total Decimal @computed + //expect-error:26:47:AttributeNotAllowedError:@computed cannot be used on repeated fields + //expect-error:36:46:AttributeExpressionError:expression expected to resolve to type Decimal[] but it is Decimal + totals Decimal[] @computed(item.total) + //expect-error:19:36:AttributeNotAllowedError:@computed cannot be used on field of type File + file File @computed("file") + //expect-error:23:42:AttributeNotAllowedError:@computed cannot be used on field of type Vector + vector Vector @computed("vector") + //expect-error:27:48:AttributeNotAllowedError:@computed cannot be used on field of type Password + password Password @computed("password") + //expect-error:23:42:AttributeNotAllowedError:@computed cannot be used on field of type Secret + secret Secret @computed("secret") + } + actions { + get getItem(id) { + //expect-error:13:22:E011:actions 'getItem' has an unrecognised attribute @computed + @computed(price * quantity) + } + } + //expect-error:5:14:E011:model 'Item' has an unrecognised attribute @computed + @computed(price * quantity) +} \ No newline at end of file diff --git a/schema/testdata/errors/attribute_computed_expression.keel b/schema/testdata/errors/attribute_computed_expression.keel new file mode 100644 index 000000000..c4114046c --- /dev/null +++ b/schema/testdata/errors/attribute_computed_expression.keel @@ -0,0 +1,30 @@ +model Invoice { + fields { + items Item[] + } +} + +model Item { + fields { + invoice Invoice + description Text + price Decimal + quantity Number + //expect-error:38:45:AttributeExpressionError:unknown identifier 'invalid' + unknownVar Decimal @computed(invalid) + //expect-error:37:42:AttributeExpressionError:unknown identifier 'price' + //expect-error:45:53:AttributeExpressionError:unknown identifier 'quantity' + noRootVar Decimal @computed(price * quantity) + //expect-error:37:53:AttributeExpressionError:expression expected to resolve to type Decimal but it is Text + wrongType Decimal @computed(item.description) + //expect-error:31:34:AttributeExpressionError:unknown identifier 'ctx' + ctx Boolean @computed(ctx.isAuthenticated) + //expect-error:27:50:AttributeNotAllowedError:@computed cannot be used on field of type Identity + identity Identity @computed(ctx.identity) + //expect-error:33:43:AttributeArgumentError:@computed expressions cannot reference itself + total Decimal @computed(item.total * 5) + } + actions { + get getItem(id) + } +} \ No newline at end of file diff --git a/schema/testdata/errors/attribute_embed.keel b/schema/testdata/errors/attribute_embed.keel index aef3d99c7..7e64e5e89 100644 --- a/schema/testdata/errors/attribute_embed.keel +++ b/schema/testdata/errors/attribute_embed.keel @@ -36,7 +36,7 @@ model Book { @embed(code) //expect-error:20:21:AttributeArgumentError:The @embed attribute can only be used with valid model fields @embed(1) - //expect-error:20:28:AttributeArgumentError:@embed argument is not correctly formatted + //expect-error:20:28:AttributeArgumentError:The @embed attribute can only be used with valid model fields @embed(code = 1) //expect-error:29:30:AttributeArgumentError:The @embed attribute can only be used with valid model fields @embed(reviews, 2) diff --git a/schema/testdata/errors/attribute_expression_complex.keel b/schema/testdata/errors/attribute_expression_complex.keel index 455abdb79..e7df361be 100755 --- a/schema/testdata/errors/attribute_expression_complex.keel +++ b/schema/testdata/errors/attribute_expression_complex.keel @@ -4,8 +4,8 @@ model Order { } @permission( - //expect-error:155:162:E020:'postCod' not found on 'Address' - expression: order.customer.address.firstLine == "123 Fake Street" or (order.customer.address.secondLine == "Fake Town" and order.customer.address.postCod == "ABC123"), + //expect-error:153:154:AttributeExpressionError:field 'postCod' does not exist + expression: order.customer.address.firstLine == "123 Fake Street" || (order.customer.address.secondLine == "Fake Town" && order.customer.address.postCod == "ABC123"), actions: [get] ) } diff --git a/schema/testdata/errors/attribute_expression_invalid_root_model.keel b/schema/testdata/errors/attribute_expression_invalid_root_model.keel index 73c1b931c..88b2fb0a5 100755 --- a/schema/testdata/errors/attribute_expression_invalid_root_model.keel +++ b/schema/testdata/errors/attribute_expression_invalid_root_model.keel @@ -4,7 +4,7 @@ model Post { } @permission( - //expect-error:21:24:E020:'pos' not found + //expect-error:21:24:AttributeExpressionError:unknown identifier 'pos' expression: pos.title != "", actions: [get] ) diff --git a/schema/testdata/errors/attribute_expression_unresolvable_lhs.keel b/schema/testdata/errors/attribute_expression_unresolvable_lhs.keel index 8dd2cf09f..a8069d043 100755 --- a/schema/testdata/errors/attribute_expression_unresolvable_lhs.keel +++ b/schema/testdata/errors/attribute_expression_unresolvable_lhs.keel @@ -4,19 +4,19 @@ model Post { } @permission( - //expect-error:26:31:E020:'autho' not found on 'Post' + //expect-error:25:26:AttributeExpressionError:field 'autho' does not exist expression: post.autho.name == "adam", actions: [get] ) @permission( - //expect-error:21:27:E061:Non-boolean single operand conditions such as '"dave"' not permitted on @permission + //expect-error:21:27:AttributeExpressionError:expression expected to resolve to type Boolean but it is Text expression: "dave", actions: [get] ) @permission( - //expect-error:21:37:E061:Non-boolean single operand conditions such as 'post.author.name' not permitted on @permission + //expect-error:21:37:AttributeExpressionError:expression expected to resolve to type Boolean but it is Text expression: post.author.name, actions: [get] ) diff --git a/schema/testdata/errors/attribute_expression_unresolvable_rhs.keel b/schema/testdata/errors/attribute_expression_unresolvable_rhs.keel index a1a42f7bd..4a60599f5 100755 --- a/schema/testdata/errors/attribute_expression_unresolvable_rhs.keel +++ b/schema/testdata/errors/attribute_expression_unresolvable_rhs.keel @@ -2,6 +2,8 @@ model Post { fields { author Author title Text + subTitle Text + someId ID } actions { @@ -9,13 +11,11 @@ model Post { //expect-error:18:28:ActionInputError:title is already being used as an input so cannot also be used in an expression @set(post.title = title) } + update updatePost2(id) with (title) { + //expect-error:34:39:AttributeExpressionError:unknown identifier 'thing' + @set(post.subTitle = thing.title) + } } - - @permission( - //expect-error:41:45:E020:'auth' not found on 'Post' - expression: post.author == post.auth, - actions: [get] - ) } model Author { diff --git a/schema/testdata/errors/attribute_on.keel b/schema/testdata/errors/attribute_on.keel index 04150e8c1..a417d7a1e 100755 --- a/schema/testdata/errors/attribute_on.keel +++ b/schema/testdata/errors/attribute_on.keel @@ -18,7 +18,7 @@ model Account { update updateAccount(id) { //expect-error:13:16:E011:actions 'updateAccount' has an unrecognised attribute @on //expect-error:13:16:AttributeArgumentError:@on requires two arguments - an array of action types and a subscriber name - //expect-error:17:32:AttributeArgumentError:@on action types argument must be an array + //expect-error:17:32:AttributeArgumentError:@on argument must be an array of action types @on(sendWelcomeMail) //expect-error:13:16:E011:actions 'updateAccount' has an unrecognised attribute @on @on( diff --git a/schema/testdata/errors/attribute_on_invalid_action_type_args.keel b/schema/testdata/errors/attribute_on_invalid_action_type_args.keel index 54e422439..9622aaf10 100755 --- a/schema/testdata/errors/attribute_on_invalid_action_type_args.keel +++ b/schema/testdata/errors/attribute_on_invalid_action_type_args.keel @@ -11,19 +11,19 @@ model Account { @on @on( - //expect-error:10:13:AttributeArgumentError:@on only supports the following action types: create, delete, update + //expect-error:9:14:AttributeArgumentError:@on argument must be an array of action types [123], verifyEmail ) @on( - //expect-error:9:15:AttributeArgumentError:@on action types argument must be an array + //expect-error:9:15:AttributeArgumentError:@on argument must be an array of action types create, verifyEmail ) @on( - //expect-error:9:17:AttributeArgumentError:@on action types argument must be an array + //expect-error:9:17:AttributeArgumentError:@on argument must be an array of action types (create), verifyEmail ) diff --git a/schema/testdata/errors/attribute_sortable_invalid_args.keel b/schema/testdata/errors/attribute_sortable_invalid_args.keel index 607d185f0..e5e6326d5 100755 --- a/schema/testdata/errors/attribute_sortable_invalid_args.keel +++ b/schema/testdata/errors/attribute_sortable_invalid_args.keel @@ -33,7 +33,7 @@ model Author { ) } list listAuthors5() { - //expect-error:23:26:AttributeArgumentError:@sortable argument is not correct formatted + //expect-error:23:26:AttributeArgumentError:@sortable argument is not correctly formatted @sortable(123) } } diff --git a/schema/testdata/errors/attribute_unique.keel b/schema/testdata/errors/attribute_unique.keel index a68a4e6bb..85d232ce6 100755 --- a/schema/testdata/errors/attribute_unique.keel +++ b/schema/testdata/errors/attribute_unique.keel @@ -4,31 +4,33 @@ model Person { lastName Text } - //expect-error:13:24:E016:Invalid value, expected at least two field names to be provided + //expect-error:13:24:AttributeArgumentError:at least two field names to be provided @unique([firstName]) + //expect-error:5:12:AttributeArgumentError:2 argument(s) provided to @unique but expected 1 @unique( firstName, //expect-error:9:17:AttributeArgumentError:unexpected argument for @unique as only a single argument is expected lastName ) - //expect-error:25:32:E016:Invalid value, expected any of the following identifiers - firstName, or lastName + //expect-error:25:32:AttributeExpressionError:unknown identifier 'surname' @unique([firstName, surname]) + //expect-error:5:12:AttributeArgumentError:2 argument(s) provided to @unique but expected 1 @unique( unknown1, //expect-error:9:17:AttributeArgumentError:unexpected argument for @unique as only a single argument is expected unknown2 ) + //expect-error:5:12:AttributeArgumentError:2 argument(s) provided to @unique but expected 1 @unique( "first_name", //expect-error:9:20:AttributeArgumentError:unexpected argument for @unique as only a single argument is expected "last_name" ) - //expect-error:14:26:E016:Invalid value, expected any of the following identifiers - firstName, or lastName - //expect-error:28:39:E016:Invalid value, expected any of the following identifiers - firstName, or lastName + //expect-error:13:40:AttributeExpressionError:expression expected to resolve to type FieldName[] but it is Text[] @unique(["first_name", "last_name"]) } diff --git a/schema/testdata/errors/attribute_unique_invalids_args.keel b/schema/testdata/errors/attribute_unique_invalids_args.keel index 9da707031..a198f6708 100755 --- a/schema/testdata/errors/attribute_unique_invalids_args.keel +++ b/schema/testdata/errors/attribute_unique_invalids_args.keel @@ -1,6 +1,7 @@ model Post { fields { //expect-error:28:31:AttributeArgumentError:unexpected argument for @unique as no arguments are expected + //expect-error:20:32:AttributeArgumentError:1 argument(s) provided to @unique but expected 0 title Text @unique(arg) } } diff --git a/schema/testdata/errors/default_invalid_expression.keel b/schema/testdata/errors/default_invalid_expression.keel index a9d0366a3..532148ab9 100755 --- a/schema/testdata/errors/default_invalid_expression.keel +++ b/schema/testdata/errors/default_invalid_expression.keel @@ -1,6 +1,8 @@ model Post { fields { - //expect-error:36:49:E051:default expression doesn't support operators + //expect-error:41:43:AttributeExpressionError:operator '==' not supported in this context published Boolean @default(true == false) + //expect-error:35:38:AttributeExpressionError:unknown identifier 'ctx' + isAuthed Boolean @default(ctx.isAuthenticated) } } diff --git a/schema/testdata/errors/enum_default_gibberish.keel b/schema/testdata/errors/enum_default_gibberish.keel index 6de781fb3..fc0e64937 100755 --- a/schema/testdata/errors/enum_default_gibberish.keel +++ b/schema/testdata/errors/enum_default_gibberish.keel @@ -1,6 +1,6 @@ model Post { fields { - //expect-error:32:41:E020:'Gibberish' not found + //expect-error:32:41:AttributeExpressionError:unknown identifier 'Gibberish' type PostType @default(Gibberish) } } diff --git a/schema/testdata/errors/enum_default_multiple_conditions.keel b/schema/testdata/errors/enum_default_multiple_conditions.keel index 45ec10d2f..4afd5374d 100755 --- a/schema/testdata/errors/enum_default_multiple_conditions.keel +++ b/schema/testdata/errors/enum_default_multiple_conditions.keel @@ -1,6 +1,6 @@ model Post { fields { - //expect-error:36:49:E049:expression should have a single value - published Boolean @default(true or false) + //expect-error:41:43:AttributeExpressionError:operator '||' not supported in this context + published Boolean @default(true || false) } } diff --git a/schema/testdata/errors/enum_default_no_expression.keel b/schema/testdata/errors/enum_default_no_expression.keel index 644f9fc55..514633d88 100755 --- a/schema/testdata/errors/enum_default_no_expression.keel +++ b/schema/testdata/errors/enum_default_no_expression.keel @@ -1,6 +1,6 @@ model Post { fields { - //expect-error:23:31:E050:default requires an expression + //expect-error:23:31:AttributeArgumentError:default requires an expression type PostType @default } } diff --git a/schema/testdata/errors/enum_default_wrong_type.keel b/schema/testdata/errors/enum_default_wrong_type.keel index eeae3d0d7..62b13fb51 100755 --- a/schema/testdata/errors/enum_default_wrong_type.keel +++ b/schema/testdata/errors/enum_default_wrong_type.keel @@ -1,6 +1,6 @@ model Post { fields { - //expect-error:32:39:E048:"Draft" is Text but field type is PostType + //expect-error:32:39:AttributeExpressionError:expression expected to resolve to type PostType but it is Text type PostType @default("Draft") } } diff --git a/schema/testdata/errors/environment_variables.keel b/schema/testdata/errors/environment_variables.keel index 3c026a34b..da6068afc 100755 --- a/schema/testdata/errors/environment_variables.keel +++ b/schema/testdata/errors/environment_variables.keel @@ -1,6 +1,6 @@ model MyModel { @permission( - //expect-error:29:32:E020:'FOO' not found on 'Environment Variables' + //expect-error:28:29:AttributeExpressionError:field 'FOO' does not exist expression: ctx.env.FOO == "d", actions: [get] ) diff --git a/schema/testdata/errors/expression_array_operator_mismatch.keel b/schema/testdata/errors/expression_array_operator_mismatch.keel index 578be9e0d..386756a65 100755 --- a/schema/testdata/errors/expression_array_operator_mismatch.keel +++ b/schema/testdata/errors/expression_array_operator_mismatch.keel @@ -4,7 +4,7 @@ model Post { } @permission( - //expect-error:27:28:E030:post.authors.name is an array. Only 'in' or 'not in' can be used + //expect-error:27:28:AttributeExpressionError:cannot use operator '>' with types Text and Text[] expression: "bob" > post.authors.name, actions: [get] ) diff --git a/schema/testdata/errors/expression_enum_set_operator_mismatch.keel b/schema/testdata/errors/expression_enum_set_operator_mismatch.keel index e29dbc350..d5df9c943 100755 --- a/schema/testdata/errors/expression_enum_set_operator_mismatch.keel +++ b/schema/testdata/errors/expression_enum_set_operator_mismatch.keel @@ -5,7 +5,7 @@ model Post { actions { update published(id) { - //expect-error:18:57:E026:post.status is PostStatus and OtherPostStatus.Something is OtherPostStatus + //expect-error:32:57:AttributeExpressionError:expression expected to resolve to type PostStatus but it is OtherPostStatus @set(post.status = OtherPostStatus.Something) } } diff --git a/schema/testdata/errors/expression_enum_set_with_invalid_rhs_operators.keel b/schema/testdata/errors/expression_enum_set_with_invalid_rhs_operators.keel new file mode 100755 index 000000000..052429e64 --- /dev/null +++ b/schema/testdata/errors/expression_enum_set_with_invalid_rhs_operators.keel @@ -0,0 +1,22 @@ +model Thing { + fields { + number Number + } + + actions { + update updateThing1(id) { + //expect-error:46:47:AttributeExpressionError:operator '+' not supported in this context + @set(thing.number = thing.number + 1) + } + } +} + +enum PostStatus { + Published + Draft +} + +enum OtherPostStatus { + Something + Else +} diff --git a/schema/testdata/errors/expression_enum_where_operator_mismatch.keel b/schema/testdata/errors/expression_enum_where_operator_mismatch.keel index df13977b9..fc6659e7d 100755 --- a/schema/testdata/errors/expression_enum_where_operator_mismatch.keel +++ b/schema/testdata/errors/expression_enum_where_operator_mismatch.keel @@ -5,7 +5,7 @@ model Post { actions { list getPublishedPosts() { - //expect-error:20:60:E026:post.status is PostStatus and OtherPostStatus.Something is OtherPostStatus + //expect-error:32:34:AttributeExpressionError:cannot use operator '==' with types PostStatus and OtherPostStatus @where(post.status == OtherPostStatus.Something) } } diff --git a/schema/testdata/errors/expression_forbidden_lhs_array.keel b/schema/testdata/errors/expression_forbidden_lhs_array.keel index e32a02ee0..7ed1ccb17 100755 --- a/schema/testdata/errors/expression_forbidden_lhs_array.keel +++ b/schema/testdata/errors/expression_forbidden_lhs_array.keel @@ -4,7 +4,7 @@ model Post { } @permission( - //expect-error:21:38:E027:left hand side operand cannot be an array for 'in' and 'not in' + //expect-error:39:41:AttributeExpressionError:cannot use operator 'in' with types Text[] and Text[] expression: post.authors.name in post.authors.name, actions: [get] ) diff --git a/schema/testdata/errors/expression_in_array.keel b/schema/testdata/errors/expression_in_array.keel index a8e74327f..378034f29 100755 --- a/schema/testdata/errors/expression_in_array.keel +++ b/schema/testdata/errors/expression_in_array.keel @@ -4,7 +4,7 @@ model Post { } @permission( - //expect-error:21:47:E026:"bob" is Text and post.authors.name is an array of Number + //expect-error:27:29:AttributeExpressionError:cannot use operator 'in' with types Text and Number[] expression: "bob" in post.authors.name, actions: [get] ) diff --git a/schema/testdata/errors/expression_literal_string_in_literal_array.keel b/schema/testdata/errors/expression_literal_string_in_literal_array.keel index cecffbca0..cce949c9d 100755 --- a/schema/testdata/errors/expression_literal_string_in_literal_array.keel +++ b/schema/testdata/errors/expression_literal_string_in_literal_array.keel @@ -1,6 +1,6 @@ model Post { @permission( - //expect-error:21:36:E026:"bob" is Text and [1, 2] is an array of Number + //expect-error:27:29:AttributeExpressionError:cannot use operator 'in' with types Text and Number[] expression: "bob" in [1, 2], actions: [get] ) diff --git a/schema/testdata/errors/expression_mixed_rhs_array.keel b/schema/testdata/errors/expression_mixed_rhs_array.keel deleted file mode 100755 index 926cf6d79..000000000 --- a/schema/testdata/errors/expression_mixed_rhs_array.keel +++ /dev/null @@ -1,7 +0,0 @@ -model Post { - @permission( - //expect-error:34:39:E032:Cannot have mixed types in an array literal - expression: "bob" in [1, "123"], - actions: [get] - ) -} diff --git a/schema/testdata/errors/expression_null_non_optional_field.keel b/schema/testdata/errors/expression_null_non_optional_field.keel index a0299ef08..0c96f1d0e 100755 --- a/schema/testdata/errors/expression_null_non_optional_field.keel +++ b/schema/testdata/errors/expression_null_non_optional_field.keel @@ -6,22 +6,10 @@ model Person { actions { create createPerson() with (employer.name) { - //expect-error:18:36:E060:name cannot be null + //expect-error:18:29:AttributeExpressionError:'name' cannot be set to null @set(person.name = null) - //expect-error:18:48:E060:country cannot be null + //expect-error:18:41:AttributeExpressionError:'country' cannot be set to null @set(person.employer.country = null) - //expect-error:37:56:E060:name cannot be null - @permission(expression: person.name != null) - } - list listPersons() { - //expect-error:20:39:E060:name cannot be null - @where(person.name == null) - //expect-error:20:39:E060:name cannot be null - @where(null == person.name) - //expect-error:20:48:E060:name cannot be null - @where(person.employer.name == null) - //expect-error:37:56:E060:name cannot be null - @permission(expression: person.name != null) } } } diff --git a/schema/testdata/errors/expression_preserving_spaces_and_tabs.keel b/schema/testdata/errors/expression_preserving_spaces_and_tabs.keel new file mode 100644 index 000000000..8d45a276d --- /dev/null +++ b/schema/testdata/errors/expression_preserving_spaces_and_tabs.keel @@ -0,0 +1,7 @@ +model Person { + @permission( + //expect-error:62:63:AttributeExpressionError:field 'unknown' does not exist + expression: ctx.isAuthenticated == ctx.unknown, + actions: [get] + ) +} \ No newline at end of file diff --git a/schema/testdata/errors/expressions_compare_model_operand_base_diff_types.keel b/schema/testdata/errors/expressions_compare_model_operand_base_diff_types.keel index 30f6c17e0..0ab754eb8 100755 --- a/schema/testdata/errors/expressions_compare_model_operand_base_diff_types.keel +++ b/schema/testdata/errors/expressions_compare_model_operand_base_diff_types.keel @@ -8,8 +8,8 @@ model BankAccount { actions { get getBankAccount(id) { - //expect-error:32:38:E027:Cannot compare BankAccount with operator 'notin' - @where(bankAccount not in ctx.identity.mainAccount) + //expect-error:34:36:AttributeExpressionError:cannot use operator 'in' with types BankAccount and BankAccount + @where(!(bankAccount in ctx.identity.mainAccount)) } } } diff --git a/schema/testdata/errors/expressions_compare_model_operand_diff_types.keel b/schema/testdata/errors/expressions_compare_model_operand_diff_types.keel index eeccf5921..5eddfdcc9 100755 --- a/schema/testdata/errors/expressions_compare_model_operand_diff_types.keel +++ b/schema/testdata/errors/expressions_compare_model_operand_diff_types.keel @@ -11,7 +11,7 @@ model BankAccount { actions { get getBankAccount(id) { - //expect-error:37:78:E026:ctx.identity.user is User and bankAccount.identity is Identity + //expect-error:55:57:AttributeExpressionError:cannot use operator '==' with types User and Identity @permission(expression: ctx.identity.user == bankAccount.identity) } } diff --git a/schema/testdata/errors/expressions_compare_model_operand_incompatible_operator.keel b/schema/testdata/errors/expressions_compare_model_operand_incompatible_operator.keel index 4ed883915..501d21e4a 100755 --- a/schema/testdata/errors/expressions_compare_model_operand_incompatible_operator.keel +++ b/schema/testdata/errors/expressions_compare_model_operand_incompatible_operator.keel @@ -11,9 +11,9 @@ model BankAccount { actions { get getBankAccount(id) { - //expect-error:55:57:E027:Cannot compare User with operator 'in' + //expect-error:55:57:AttributeExpressionError:cannot use operator 'in' with types User and User @permission(expression: ctx.identity.user in bankAccount.identity.user) - //expect-error:55:56:E027:Cannot compare User with operator '>' + //expect-error:55:56:AttributeExpressionError:cannot use operator '>' with types User and User @permission(expression: ctx.identity.user > bankAccount.identity.user) } } diff --git a/schema/testdata/errors/jobs_permission_attribute_expression_invalid_args.keel b/schema/testdata/errors/jobs_permission_attribute_expression_invalid_args.keel index 2a882a9a5..c78edb7b6 100755 --- a/schema/testdata/errors/jobs_permission_attribute_expression_invalid_args.keel +++ b/schema/testdata/errors/jobs_permission_attribute_expression_invalid_args.keel @@ -1,39 +1,40 @@ job MyJob1 { - //expect-error:29:38:E020:'something' not found + //expect-error:29:38:AttributeExpressionError:unknown identifier 'something' @permission(expression: something > 5) } job MyJob2 { - //expect-error:29:35:E020:'myJob3' not found + //expect-error:29:35:AttributeExpressionError:unknown identifier 'myJob3' @permission(expression: myJob3.id == true) } job MyJob3 { - //expect-error:29:38:E061:Non-boolean single operand conditions such as '"invalid"' not permitted on @permission + //expect-error:29:38:AttributeExpressionError:expression expected to resolve to type Boolean but it is Text @permission(expression: "invalid") } job MyJob4 { - //expect-error:5:16:AttributeArgumentError:@permission requires either the 'expressions' or 'roles' argument to be provided //expect-error:17:36:AttributeArgumentError:unexpected argument for @permission + //expect-error:5:16:AttributeArgumentError:@permission requires either the 'expressions' or 'roles' argument to be provided @permission(ctx.isAuthenticated) } job MyJob5 { //expect-error:5:16:AttributeArgumentError:@permission requires either the 'expressions' or 'roles' argument to be provided //expect-error:17:21:AttributeArgumentError:unexpected argument 'expr' for @permission + //expect-error:17:21:AttributeArgumentError:'expr' is not a valid argument for @permission @permission(expr: ctx.isAuthenticated) } job MyJob6 { - //expect-error:29:30:E020:'c' not found + //expect-error:29:30:AttributeExpressionError:unknown identifier 'c' @permission(expression: c.isAuthenticated) } job MyJob7 { - //expect-error:49:56:E020:'invalid' not found on 'Boolean' - //expect-error:77:83:E020:'person' not found - @permission(expression: ctx.isAuthenticated.invalid and ctx.identity in person.organisation.invalid) + //expect-error:48:49:AttributeExpressionError:type Boolean does not have any fields to select + //expect-error:76:82:AttributeExpressionError:unknown identifier 'person' + @permission(expression: ctx.isAuthenticated.invalid && ctx.identity in person.organisation.invalid) } role Admin { diff --git a/schema/testdata/errors/jobs_permission_attribute_role_invalid_args.keel b/schema/testdata/errors/jobs_permission_attribute_role_invalid_args.keel index 2cd28c0de..ee7948598 100755 --- a/schema/testdata/errors/jobs_permission_attribute_role_invalid_args.keel +++ b/schema/testdata/errors/jobs_permission_attribute_role_invalid_args.keel @@ -1,15 +1,15 @@ job MyJob1 { - //expect-error:25:35:AttributeArgumentError:NoRoleName is not a role defined in your schema + //expect-error:25:35:AttributeExpressionError:NoRoleName is not a role defined in your schema @permission(roles: [NoRoleName]) } job MyJob2 { - //expect-error:32:42:AttributeArgumentError:NoRoleName is not a role defined in your schema + //expect-error:32:42:AttributeExpressionError:NoRoleName is not a role defined in your schema @permission(roles: [Admin, NoRoleName]) } job MyJob3 { - //expect-error:24:29:AttributeArgumentError:value should be a list e.g. [Admin] + //expect-error:24:29:AttributeExpressionError:expression expected to resolve to type Role[] but it is Role @permission(roles: Admin) } diff --git a/schema/testdata/errors/jobs_schedule.keel b/schema/testdata/errors/jobs_schedule.keel index e2df2a47c..40ce781bb 100755 --- a/schema/testdata/errors/jobs_schedule.keel +++ b/schema/testdata/errors/jobs_schedule.keel @@ -18,7 +18,7 @@ job WrongArgType { job Labelled { //expect-error:15:19:AttributeArgumentError:unexpected argument 'cron' for @schedule as only a single argument is expected - //expect-error:21:26:AttributeArgumentError:invalid schedule - must be expression like 'every day at 9am' or cron syntax e.g. '0 9 * * *' + //expect-error:15:19:AttributeArgumentError:argument to @schedule cannot be labelled @schedule(cron: "foo") } @@ -27,6 +27,11 @@ job InvalidSchedule { @schedule("every 10 cats") } +job InvalidScheduleValue { + //expect-error:22:25:AttributeArgumentError:invalid day 'bob' - expected day of week e.g. 'monday', 'day' for every day, or 'weekday' for monday-friday + @schedule("every BOB hours") +} + job InvalidCron { //expect-error:27:30:AttributeArgumentError:invalid value 'BOB' for day-of-week field @schedule("*/10 * * * BOB") diff --git a/schema/testdata/errors/lhs_rhs_type_mismatch.keel b/schema/testdata/errors/lhs_rhs_type_mismatch.keel index 03dd5f6b3..5c3df4f9b 100755 --- a/schema/testdata/errors/lhs_rhs_type_mismatch.keel +++ b/schema/testdata/errors/lhs_rhs_type_mismatch.keel @@ -4,7 +4,7 @@ model Post { } @permission( - //expect-error:21:37:E026:post.title is Text and 12 is Number + //expect-error:32:34:AttributeExpressionError:cannot use operator '==' with types Text and Number expression: post.title == 12, actions: [get] ) diff --git a/schema/testdata/errors/operation_set_expression_forbidden_operator.keel b/schema/testdata/errors/operation_set_expression_forbidden_operator.keel index 428070a77..98b653420 100755 --- a/schema/testdata/errors/operation_set_expression_forbidden_operator.keel +++ b/schema/testdata/errors/operation_set_expression_forbidden_operator.keel @@ -6,8 +6,7 @@ model Profile { actions { update createProfile(id) with (username) { - //expect-error:35:37:E022:Operator '==' not permitted on @set - //expect-error:18:50:AttributeArgumentError:The @set attribute cannot be a logical condition and must express an assignment + //expect-error:18:50:AttributeExpressionError:the @set attribute must be an assignment expression @set(profile.identity == ctx.identity) } } diff --git a/schema/testdata/errors/operation_set_expression_invalid_lhs.keel b/schema/testdata/errors/operation_set_expression_invalid_lhs.keel new file mode 100755 index 000000000..5ef7b7470 --- /dev/null +++ b/schema/testdata/errors/operation_set_expression_invalid_lhs.keel @@ -0,0 +1,13 @@ +model Profile { + fields { + username Text @unique + } + + actions { + update createProfile(id) with (username) { + //expect-error:18:21:AttributeExpressionError:The @set attribute can only be used to set model fields + @set(123 = ctx.identity) + } + } +} + diff --git a/schema/testdata/errors/operation_set_expression_unresolvable_lhs.keel b/schema/testdata/errors/operation_set_expression_unresolvable_lhs.keel index a80318f3e..c4b079dab 100755 --- a/schema/testdata/errors/operation_set_expression_unresolvable_lhs.keel +++ b/schema/testdata/errors/operation_set_expression_unresolvable_lhs.keel @@ -5,7 +5,7 @@ model Profile { actions { update createProfile(id) with (username) { - //expect-error:26:33:E020:'identit' not found on 'Profile' + //expect-error:25:26:AttributeExpressionError:field 'identit' does not exist @set(profile.identit = ctx.identity) } } diff --git a/schema/testdata/errors/operation_set_expression_unresolvable_rhs.keel b/schema/testdata/errors/operation_set_expression_unresolvable_rhs.keel index 5c38b0d34..b4d208a18 100755 --- a/schema/testdata/errors/operation_set_expression_unresolvable_rhs.keel +++ b/schema/testdata/errors/operation_set_expression_unresolvable_rhs.keel @@ -5,8 +5,8 @@ model Profile { actions { create createProfile() { - //expect-error:37:44:E020:'context' not found - @set(profile.username = context.identity) + //expect-error:37:49:AttributeExpressionError:expression expected to resolve to type Text but it is Identity + @set(profile.username = ctx.identity) } } } diff --git a/schema/testdata/errors/operation_set_expression_wrong_type.keel b/schema/testdata/errors/operation_set_expression_wrong_type.keel new file mode 100755 index 000000000..e47ce8c4f --- /dev/null +++ b/schema/testdata/errors/operation_set_expression_wrong_type.keel @@ -0,0 +1,12 @@ +model Profile { + fields { + username Text @unique + } + + actions { + create createProfile() { + //expect-error:37:40:AttributeExpressionError:expression expected to resolve to type Text but it is Number + @set(profile.username = 123) + } + } +} diff --git a/schema/testdata/errors/operation_set_forbidden_value_expression.keel b/schema/testdata/errors/operation_set_forbidden_value_expression.keel index 1575c42a1..d8ecb32e2 100755 --- a/schema/testdata/errors/operation_set_forbidden_value_expression.keel +++ b/schema/testdata/errors/operation_set_forbidden_value_expression.keel @@ -6,7 +6,7 @@ model Profile { actions { update createProfile(id) with (username) { - //expect-error:18:34:AttributeArgumentError:The @set attribute cannot be a value condition and must express an assignment + //expect-error:18:34:AttributeExpressionError:the @set attribute must be an assignment expression @set(profile.identity) } } diff --git a/schema/testdata/errors/operation_where_expression_forbidden_operator.keel b/schema/testdata/errors/operation_where_expression_forbidden_operator.keel index 34e1f8fc9..c73783af8 100755 --- a/schema/testdata/errors/operation_where_expression_forbidden_operator.keel +++ b/schema/testdata/errors/operation_where_expression_forbidden_operator.keel @@ -6,7 +6,7 @@ model Profile { actions { update updateProfile(id) with (username) { - //expect-error:37:38:E022:Operator '=' not permitted on @where + //expect-error:20:51:AttributeExpressionError:assignment operator '=' not valid - did you mean to use the comparison operator '=='? @where(profile.identity = ctx.identity) } } diff --git a/schema/testdata/errors/operation_where_expression_unresolvable_lhs.keel b/schema/testdata/errors/operation_where_expression_unresolvable_lhs.keel index 668b05ed2..a37f0dd3e 100755 --- a/schema/testdata/errors/operation_where_expression_unresolvable_lhs.keel +++ b/schema/testdata/errors/operation_where_expression_unresolvable_lhs.keel @@ -6,7 +6,7 @@ model Profile { actions { get getProfile(username) { - //expect-error:28:35:E020:'identit' not found on 'Profile' + //expect-error:27:28:AttributeExpressionError:field 'identit' does not exist @where(profile.identit == ctx.identity) } } diff --git a/schema/testdata/errors/operation_where_expression_unresolvable_rhs.keel b/schema/testdata/errors/operation_where_expression_unresolvable_rhs.keel index 599e1c3c1..5e018a672 100755 --- a/schema/testdata/errors/operation_where_expression_unresolvable_rhs.keel +++ b/schema/testdata/errors/operation_where_expression_unresolvable_rhs.keel @@ -6,7 +6,7 @@ model Profile { actions { get getProfile(username) { - //expect-error:40:47:E020:'context' not found + //expect-error:40:47:AttributeExpressionError:unknown identifier 'context' @where(profile.identity == context.identity) } } diff --git a/schema/testdata/errors/operation_where_forbidden_value_expression.keel b/schema/testdata/errors/operation_where_forbidden_value_expression.keel index b300bd388..fb6896c5f 100755 --- a/schema/testdata/errors/operation_where_forbidden_value_expression.keel +++ b/schema/testdata/errors/operation_where_forbidden_value_expression.keel @@ -6,7 +6,7 @@ model Profile { actions { get getProfile(username) { - //expect-error:20:36:E061:Non-boolean single operand conditions such as 'profile.identity' not permitted on @where + //expect-error:20:36:AttributeExpressionError:expression expected to resolve to type Boolean but it is Identity @where(profile.identity) } } diff --git a/schema/testdata/errors/operation_where_too_many_arguments.keel b/schema/testdata/errors/operation_where_too_many_arguments.keel index 8466f9f4d..bfa8afd8e 100755 --- a/schema/testdata/errors/operation_where_too_many_arguments.keel +++ b/schema/testdata/errors/operation_where_too_many_arguments.keel @@ -6,6 +6,7 @@ model Profile { actions { get getProfile(username) { + //expect-error:13:14:AttributeArgumentError:2 argument(s) provided to @unique but expected 1 @where( profile.identity == ctx.identity, //expect-error:17:45:AttributeArgumentError:unexpected argument for @where diff --git a/schema/testdata/errors/permission_LHS_identity_comparison.keel b/schema/testdata/errors/permission_LHS_identity_comparison.keel index 5f6c60608..6a834115a 100755 --- a/schema/testdata/errors/permission_LHS_identity_comparison.keel +++ b/schema/testdata/errors/permission_LHS_identity_comparison.keel @@ -17,7 +17,7 @@ model Project { } @permission( - //expect-error:34:36:E030:project.users.user.identity is an array. Only 'in' or 'not in' can be used + //expect-error:34:36:AttributeExpressionError:cannot use operator '==' with types Identity and Identity[] expression: ctx.identity == project.users.user.identity, actions: [create] ) diff --git a/schema/testdata/errors/permission_action.keel b/schema/testdata/errors/permission_action.keel index 8caea9ad1..8f09f30d9 100755 --- a/schema/testdata/errors/permission_action.keel +++ b/schema/testdata/errors/permission_action.keel @@ -8,18 +8,19 @@ model Person { // Invalid to provide actions inside an action @permission( //expect-error:17:24:AttributeArgumentError:unexpected argument 'actions' for @permission + //expect-error:17:24:AttributeArgumentError:cannot provide 'actions' arguments when using @permission in an action actions: [create], expression: true ) } read customFunction(Any) returns (Any) { // Cannot use row-based permission inside a custom function - //expect-error:37:43:AttributeArgumentError:cannot use row-based permissions in a read action + //expect-error:37:52:AttributeArgumentError:cannot use row-based permissions in a read action @permission(expression: person.identity == ctx.identity) } read otherCustomFunction(Any) returns (Any) { // Cannot use row-based permission inside a custom function (RHS check) - //expect-error:53:59:AttributeArgumentError:cannot use row-based permissions in a read action + //expect-error:53:68:AttributeArgumentError:cannot use row-based permissions in a read action @permission(expression: ctx.identity == person.identity) } } diff --git a/schema/testdata/errors/permission_attribute_action_as_actiontype_arg.keel b/schema/testdata/errors/permission_attribute_action_as_actiontype_arg.keel index e9c2c755c..c40242224 100755 --- a/schema/testdata/errors/permission_attribute_action_as_actiontype_arg.keel +++ b/schema/testdata/errors/permission_attribute_action_as_actiontype_arg.keel @@ -5,7 +5,7 @@ model Person { @permission( expression: true, - //expect-error:19:28:AttributeArgumentError:getPerson is not a valid action type + //expect-error:19:28:AttributeExpressionError:unknown identifier 'getPerson' actions: [getPerson] ) } diff --git a/schema/testdata/errors/permission_attribute_expression_forbidden_operator.keel b/schema/testdata/errors/permission_attribute_expression_forbidden_operator.keel index ba51bf46e..b0381cf57 100755 --- a/schema/testdata/errors/permission_attribute_expression_forbidden_operator.keel +++ b/schema/testdata/errors/permission_attribute_expression_forbidden_operator.keel @@ -4,7 +4,7 @@ model Profile { } @permission( - //expect-error:38:39:E022:Operator '=' not permitted on @permission + //expect-error:21:48:AttributeExpressionError:assignment operator '=' not valid - did you mean to use the comparison operator '=='? expression: profile.username = "adaam2", actions: [get] ) diff --git a/schema/testdata/errors/permission_attribute_expression_invalid_operands.keel b/schema/testdata/errors/permission_attribute_expression_invalid_operands.keel index ba5c2e250..3376a5b65 100755 --- a/schema/testdata/errors/permission_attribute_expression_invalid_operands.keel +++ b/schema/testdata/errors/permission_attribute_expression_invalid_operands.keel @@ -5,51 +5,51 @@ model Person { } @permission( - //expect-error:21:28:E020:'invalid' not found + //expect-error:21:28:AttributeExpressionError:unknown identifier 'invalid' expression: invalid, actions: [get] ) @permission( - //expect-error:21:23:E020:'ct' not found + //expect-error:21:23:AttributeExpressionError:unknown identifier 'ct' expression: ct.isAuthenticated, actions: [get] ) @permission( - //expect-error:25:32:E020:'invalid' not found on 'Context' + //expect-error:24:25:AttributeExpressionError:field 'invalid' does not exist expression: ctx.invalid, actions: [get] ) @permission( - //expect-error:21:27:E061:Non-boolean single operand conditions such as '"true"' not permitted on @permission + //expect-error:21:27:AttributeExpressionError:expression expected to resolve to type Boolean but it is Text expression: "true", actions: [get] ) @permission( - //expect-error:29:30:E020:'c' not found - expression: true or c.isAuthenticated, + //expect-error:29:30:AttributeExpressionError:unknown identifier 'c' + expression: true || c.isAuthenticated, actions: [get] ) @permission( - //expect-error:57:64:E020:'invalid' not found on 'Organisation' + //expect-error:56:57:AttributeExpressionError:field 'invalid' does not exist expression: ctx.identity in person.organisation.invalid, actions: [get] ) @permission( - //expect-error:81:88:E020:'invalid' not found on 'Organisation' - expression: ctx.isAuthenticated and ctx.identity in person.organisation.invalid, + //expect-error:79:80:AttributeExpressionError:field 'invalid' does not exist + expression: ctx.isAuthenticated && ctx.identity in person.organisation.invalid, actions: [get] ) @permission( - //expect-error:41:48:E020:'invalid' not found on 'Boolean' - //expect-error:89:96:E020:'invalid' not found on 'Organisation' - expression: ctx.isAuthenticated.invalid and ctx.identity in person.organisation.invalid, + //expect-error:40:41:AttributeExpressionError:type Boolean does not have any fields to select + //expect-error:87:88:AttributeExpressionError:field 'invalid' does not exist + expression: ctx.isAuthenticated.invalid && ctx.identity in person.organisation.invalid, actions: [get] ) } diff --git a/schema/testdata/errors/permission_attribute_invalid_actiontype_arg.keel b/schema/testdata/errors/permission_attribute_invalid_actiontype_arg.keel deleted file mode 100755 index bf85c5c93..000000000 --- a/schema/testdata/errors/permission_attribute_invalid_actiontype_arg.keel +++ /dev/null @@ -1,11 +0,0 @@ -model Person { - actions { - get getPerson(id) - } - - @permission( - expression: true, - //expect-error:24:28:AttributeArgumentError:true is not a valid action type - actions: [get, true] - ) -} diff --git a/schema/testdata/errors/permission_attribute_invalid_roles.keel b/schema/testdata/errors/permission_attribute_invalid_roles.keel index ed4f65c16..30b368f20 100755 --- a/schema/testdata/errors/permission_attribute_invalid_roles.keel +++ b/schema/testdata/errors/permission_attribute_invalid_roles.keel @@ -4,25 +4,25 @@ model Person { } @permission( - //expect-error:17:29:AttributeArgumentError:NotValidRole is not a role defined in your schema + //expect-error:17:29:AttributeExpressionError:NotValidRole is not a role defined in your schema roles: [NotValidRole], actions: [get] ) @permission( - //expect-error:16:20:AttributeArgumentError:value should be a list e.g. [Admin] + //expect-error:16:20:AttributeExpressionError:expression expected to resolve to type Role[] but it is Number roles: 1234, actions: [get] ) @permission( - //expect-error:16:22:AttributeArgumentError:value should be a list e.g. [Admin] + //expect-error:16:22:AttributeExpressionError:expression expected to resolve to type Role[] but it is Text roles: "1234", actions: [get] ) @permission( - //expect-error:17:33:AttributeArgumentError:"thisisnotvalid" is not a role defined in your schema + //expect-error:16:34:AttributeExpressionError:expression expected to resolve to type Role[] but it is Text[] roles: ["thisisnotvalid"], actions: [get] ) diff --git a/schema/testdata/errors/permission_job.keel b/schema/testdata/errors/permission_job.keel index 46a1efd3a..a1c3732a7 100755 --- a/schema/testdata/errors/permission_job.keel +++ b/schema/testdata/errors/permission_job.keel @@ -1,5 +1,6 @@ job MyJob { @permission( + //expect-error:9:16:AttributeArgumentError:cannot provide 'actions' arguments when using @permission in a job //expect-error:9:16:AttributeArgumentError:unexpected argument 'actions' for @permission actions: [get], roles: [MyRole] diff --git a/schema/testdata/errors/permission_rule_input.keel b/schema/testdata/errors/permission_rule_input.keel deleted file mode 100755 index 9bb93c4fd..000000000 --- a/schema/testdata/errors/permission_rule_input.keel +++ /dev/null @@ -1,20 +0,0 @@ -model Person { - fields { - name Text - } - - actions { - create createPerson() with (name) { - //expect-error:37:41:E020:'name' not found - @permission(expression: name == "123") - } - create createPersonRhsInput() with (name) { - //expect-error:46:50:E020:'name' not found - @permission(expression: "123" == name) - } - update updatePerson(id) with (name) { - //expect-error:37:41:E020:'name' not found - @permission(expression: name == "123") - } - } -} diff --git a/schema/testdata/errors/request_headers_invalid_dot_notation.keel b/schema/testdata/errors/request_headers_invalid_dot_notation.keel index ef22f98db..80a0d1275 100755 --- a/schema/testdata/errors/request_headers_invalid_dot_notation.keel +++ b/schema/testdata/errors/request_headers_invalid_dot_notation.keel @@ -5,7 +5,7 @@ model Something { actions { create createSomething() with (foo) { - //expect-error:76:80:E020:'KEY2' not found on 'Text' + //expect-error:75:76:AttributeExpressionError:type Text does not have any fields to select @permission(expression: something.createdAt == ctx.headers.KEY.KEY2) } } diff --git a/schema/testdata/errors/set_attribute_backlink_repeated_fields.keel b/schema/testdata/errors/set_attribute_backlink_repeated_fields.keel index 6fe17db34..577a0a458 100755 --- a/schema/testdata/errors/set_attribute_backlink_repeated_fields.keel +++ b/schema/testdata/errors/set_attribute_backlink_repeated_fields.keel @@ -8,7 +8,7 @@ model Record { actions { create createRecordWithChildren() with (name, children.name) { - //expect-error:42:73:E026:cannot assign from a to-many relationship lookup + //expect-error:42:73:AttributeExpressionError:expression expected to resolve to type User but it is User[] @set(record.children.owner = ctx.identity.user.records.owner) } } diff --git a/schema/testdata/errors/set_attribute_backlink_repeated_lhs_fields.keel b/schema/testdata/errors/set_attribute_backlink_repeated_lhs_fields.keel index 30263095c..f3ffbfb9f 100755 --- a/schema/testdata/errors/set_attribute_backlink_repeated_lhs_fields.keel +++ b/schema/testdata/errors/set_attribute_backlink_repeated_lhs_fields.keel @@ -7,7 +7,7 @@ model Record { actions { create createRecordWithChildren() { - //expect-error:31:32:E030:ctx.identity.user.records.owner is an array. Only 'in' or 'not in' can be used + //expect-error:33:64:AttributeExpressionError:expression expected to resolve to type User but it is User[] @set(record.owner = ctx.identity.user.records.owner) } } diff --git a/schema/testdata/errors/set_attribute_built_in_fields.keel b/schema/testdata/errors/set_attribute_built_in_fields.keel index f36bc0fec..d5b3caec8 100755 --- a/schema/testdata/errors/set_attribute_built_in_fields.keel +++ b/schema/testdata/errors/set_attribute_built_in_fields.keel @@ -8,27 +8,27 @@ model Post { actions { create createPost() with (name, published) { - //expect-error:23:32:AttributeArgumentError:Cannot set the field 'createdAt' as it is a built-in field and can only be mutated internally + //expect-error:18:32:AttributeExpressionError:Cannot set the field 'createdAt' as it is a built-in field and can only be mutated internally @set(post.createdAt = ctx.now) - //expect-error:23:32:AttributeArgumentError:Cannot set the field 'updatedAt' as it is a built-in field and can only be mutated internally + //expect-error:18:32:AttributeExpressionError:Cannot set the field 'updatedAt' as it is a built-in field and can only be mutated internally @set(post.updatedAt = ctx.now) } create createPost2() with (name, published, publisher.name) { - //expect-error:33:42:AttributeArgumentError:Cannot set the field 'createdAt' as it is a built-in field and can only be mutated internally + //expect-error:18:42:AttributeExpressionError:Cannot set the field 'createdAt' as it is a built-in field and can only be mutated internally @set(post.publisher.createdAt = ctx.now) - //expect-error:33:42:AttributeArgumentError:Cannot set the field 'updatedAt' as it is a built-in field and can only be mutated internally + //expect-error:18:42:AttributeExpressionError:Cannot set the field 'updatedAt' as it is a built-in field and can only be mutated internally @set(post.publisher.updatedAt = ctx.now) } update updatePost(id) with (name, published) { - //expect-error:23:32:AttributeArgumentError:Cannot set the field 'createdAt' as it is a built-in field and can only be mutated internally + //expect-error:18:32:AttributeExpressionError:Cannot set the field 'createdAt' as it is a built-in field and can only be mutated internally @set(post.createdAt = ctx.now) - //expect-error:23:32:AttributeArgumentError:Cannot set the field 'updatedAt' as it is a built-in field and can only be mutated internally + //expect-error:18:32:AttributeExpressionError:Cannot set the field 'updatedAt' as it is a built-in field and can only be mutated internally @set(post.updatedAt = ctx.now) } - update updatePost2(id) with (name, published) { - //expect-error:18:42:AttributeArgumentError:Cannot set a field which is beyond scope of the data being created or updated + update updatePost2(id) with (name, publisher.name) { + //expect-error:18:42:AttributeExpressionError:Cannot set the field 'createdAt' as it is a built-in field and can only be mutated internally @set(post.publisher.createdAt = ctx.now) - //expect-error:18:42:AttributeArgumentError:Cannot set a field which is beyond scope of the data being created or updated + //expect-error:18:42:AttributeExpressionError:Cannot set the field 'updatedAt' as it is a built-in field and can only be mutated internally @set(post.publisher.updatedAt = ctx.now) } } diff --git a/schema/testdata/errors/set_attribute_ctx_identity_fields_invalid_types.keel b/schema/testdata/errors/set_attribute_ctx_identity_fields_invalid_types.keel index 29467b657..7f1152e33 100755 --- a/schema/testdata/errors/set_attribute_ctx_identity_fields_invalid_types.keel +++ b/schema/testdata/errors/set_attribute_ctx_identity_fields_invalid_types.keel @@ -9,28 +9,28 @@ model UserExtension { actions { create createExt() { - //expect-error:18:58:E026:userExtension.email is Number and ctx.identity.email is Text + //expect-error:40:58:AttributeExpressionError:expression expected to resolve to type Number but it is Text @set(userExtension.email = ctx.identity.email) - //expect-error:18:71:E026:userExtension.isVerified is Number and ctx.identity.emailVerified is Boolean + //expect-error:45:71:AttributeExpressionError:expression expected to resolve to type Number but it is Boolean @set(userExtension.isVerified = ctx.identity.emailVerified) - //expect-error:18:67:E026:userExtension.signedUpAt is Number and ctx.identity.createdAt is Timestamp + //expect-error:45:67:AttributeExpressionError:expression expected to resolve to type Number but it is Timestamp @set(userExtension.signedUpAt = ctx.identity.createdAt) - //expect-error:18:60:E026:userExtension.issuer is Number and ctx.identity.issuer is Text + //expect-error:41:60:AttributeExpressionError:expression expected to resolve to type Number but it is Text @set(userExtension.issuer = ctx.identity.issuer) - //expect-error:18:68:E026:userExtension.externalId is Number and ctx.identity.externalId is Text + //expect-error:45:68:AttributeExpressionError:expression expected to resolve to type Number but it is Text @set(userExtension.externalId = ctx.identity.externalId) @permission(expression: ctx.isAuthenticated) } update updateExt(id) { - //expect-error:18:58:E026:userExtension.email is Number and ctx.identity.email is Text + //expect-error:40:58:AttributeExpressionError:expression expected to resolve to type Number but it is Text @set(userExtension.email = ctx.identity.email) - //expect-error:18:71:E026:userExtension.isVerified is Number and ctx.identity.emailVerified is Boolean + //expect-error:45:71:AttributeExpressionError:expression expected to resolve to type Number but it is Boolean @set(userExtension.isVerified = ctx.identity.emailVerified) - //expect-error:18:67:E026:userExtension.signedUpAt is Number and ctx.identity.createdAt is Timestamp + //expect-error:45:67:AttributeExpressionError:expression expected to resolve to type Number but it is Timestamp @set(userExtension.signedUpAt = ctx.identity.createdAt) - //expect-error:18:60:E026:userExtension.issuer is Number and ctx.identity.issuer is Text + //expect-error:41:60:AttributeExpressionError:expression expected to resolve to type Number but it is Text @set(userExtension.issuer = ctx.identity.issuer) - //expect-error:18:68:E026:userExtension.externalId is Number and ctx.identity.externalId is Text + //expect-error:45:68:AttributeExpressionError:expression expected to resolve to type Number but it is Text @set(userExtension.externalId = ctx.identity.externalId) @permission(expression: ctx.isAuthenticated) } diff --git a/schema/testdata/errors/set_attribute_ctx_identity_invalid_fields.keel b/schema/testdata/errors/set_attribute_ctx_identity_invalid_fields.keel index 0128fe7c6..b6649b8b8 100755 --- a/schema/testdata/errors/set_attribute_ctx_identity_invalid_fields.keel +++ b/schema/testdata/errors/set_attribute_ctx_identity_invalid_fields.keel @@ -5,11 +5,11 @@ model UserExtension { actions { create createExt() { - //expect-error:55:62:E020:'unknown' not found on 'Identity' + //expect-error:54:55:AttributeExpressionError:field 'unknown' does not exist @set(userExtension.unknown = ctx.identity.unknown) } update updateExt(id) { - //expect-error:55:62:E020:'unknown' not found on 'Identity' + //expect-error:54:55:AttributeExpressionError:field 'unknown' does not exist @set(userExtension.unknown = ctx.identity.unknown) } } diff --git a/schema/testdata/errors/set_attribute_invalid_assignment_condition.keel b/schema/testdata/errors/set_attribute_invalid_assignment_condition.keel index a1183f691..0510711d9 100755 --- a/schema/testdata/errors/set_attribute_invalid_assignment_condition.keel +++ b/schema/testdata/errors/set_attribute_invalid_assignment_condition.keel @@ -7,29 +7,28 @@ model Post { actions { create ctxOnly() with (name, published) { - //expect-error:18:21:AttributeArgumentError:The @set attribute cannot be a value condition and must express an assignment + //expect-error:18:21:AttributeExpressionError:the @set attribute must be an assignment expression @set(ctx) } create literalOnly() with (name, published) { - //expect-error:18:25:AttributeArgumentError:The @set attribute cannot be a value condition and must express an assignment + //expect-error:18:25:AttributeExpressionError:the @set attribute must be an assignment expression @set("hello") } create trueOnly() with (name, published) { - //expect-error:18:22:AttributeArgumentError:The @set attribute cannot be a value condition and must express an assignment + //expect-error:18:22:AttributeExpressionError:the @set attribute must be an assignment expression @set(true) } create fieldOnly() with (name) { - //expect-error:18:32:AttributeArgumentError:The @set attribute cannot be a value condition and must express an assignment + //expect-error:18:32:AttributeExpressionError:the @set attribute must be an assignment expression @set(post.published) } create equality() with (name) { - //expect-error:33:35:E022:Operator '==' not permitted on @set - //expect-error:18:40:AttributeArgumentError:The @set attribute cannot be a logical condition and must express an assignment + //expect-error:18:40:AttributeExpressionError:the @set attribute must be an assignment expression @set(post.published == true) } create multipleConditions() { - //expect-error:18:63:AttributeArgumentError:A @set attribute can only consist of a single assignment expression - @set(post.published = true and post.name = "hello") + //expect-error:35:62:AttributeExpressionError:assignment operator '=' not valid - did you mean to use the comparison operator '=='? + @set(post.published = true && post.name = "hello") } } } diff --git a/schema/testdata/errors/set_attribute_lhs_is_invalid.keel b/schema/testdata/errors/set_attribute_lhs_is_invalid.keel index cbed73921..477b58899 100755 --- a/schema/testdata/errors/set_attribute_lhs_is_invalid.keel +++ b/schema/testdata/errors/set_attribute_lhs_is_invalid.keel @@ -7,35 +7,36 @@ model Post { actions { create unknownIdentifier() with (name, published) { - //expect-error:18:22:AttributeArgumentError:The @set attribute can only be used to set model fields + //expect-error:18:22:AttributeExpressionError:unknown identifier 'name' @set(name = "hello") } + //expect-error:35:36:ActionInputError:n is not used. Labelled inputs must be used in the action, for example in a @set or @where attribute create namedInput() with (n: Text, published) { - //expect-error:18:19:AttributeArgumentError:The @set attribute can only be used to set model fields + //expect-error:18:19:AttributeExpressionError:unknown identifier 'n' @set(n = post.name) } create literal() with (name, published) { - //expect-error:18:25:AttributeArgumentError:The @set attribute can only be used to set model fields + //expect-error:18:25:AttributeExpressionError:The @set attribute can only be used to set model fields @set("hello" = post.name) } create null() with (name, published) { - //expect-error:18:22:AttributeArgumentError:The @set attribute can only be used to set model fields + //expect-error:18:22:AttributeExpressionError:The @set attribute can only be used to set model fields @set(null = post.name) } create ctx() with (name, published) { - //expect-error:18:37:AttributeArgumentError:The @set attribute can only be used to set model fields + //expect-error:18:21:AttributeExpressionError:unknown identifier 'ctx' @set(ctx.isAuthenticated = post.published) } create ctxIdentity() with (name, published) { - //expect-error:18:30:AttributeArgumentError:The @set attribute can only be used to set model fields + //expect-error:18:21:AttributeExpressionError:unknown identifier 'ctx' @set(ctx.identity = post.identity) } create ctxIdentityEmail() with (name, published) { - //expect-error:18:36:AttributeArgumentError:The @set attribute can only be used to set model fields + //expect-error:18:21:AttributeExpressionError:unknown identifier 'ctx' @set(ctx.identity.email = "email") } create anotherModel() { - //expect-error:18:27:E020:'publisher' not found + //expect-error:18:27:AttributeExpressionError:unknown identifier 'publisher' @set(publisher.name = "email") } } diff --git a/schema/testdata/errors/set_attribute_lhs_not_within_write_scope.keel b/schema/testdata/errors/set_attribute_lhs_not_within_write_scope.keel index 626e64dc6..65255f462 100755 --- a/schema/testdata/errors/set_attribute_lhs_not_within_write_scope.keel +++ b/schema/testdata/errors/set_attribute_lhs_not_within_write_scope.keel @@ -8,11 +8,11 @@ model Post { actions { create nestedData1() with (name) { - //expect-error:18:37:AttributeArgumentError:Cannot set a field which is beyond scope of the data being created or updated + //expect-error:18:37:AttributeExpressionError:Cannot set a field which is beyond scope of the data being created or updated @set(post.publisher.name = "someName") } create nestedData2() with (name) { - //expect-error:18:38:AttributeArgumentError:Cannot set a field which is beyond scope of the data being created or updated + //expect-error:18:38:AttributeExpressionError:Cannot set a field which is beyond scope of the data being created or updated @set(post.publisher.admin = ctx.identity) } create nestedData3() with ( @@ -20,22 +20,22 @@ model Post { publisher.departments.name, publisher.departments.number, ) { - //expect-error:18:45:AttributeArgumentError:Cannot set a field which is beyond scope of the data being created or updated + //expect-error:18:45:AttributeExpressionError:Cannot set a field which is beyond scope of the data being created or updated @set(post.publisher.country.name = "some country") } create nestedData4() with ( publisher.departments.name, publisher.departments.number, ) { - //expect-error:18:45:AttributeArgumentError:Cannot set a field which is beyond scope of the data being created or updated + //expect-error:18:45:AttributeExpressionError:Cannot set a field which is beyond scope of the data being created or updated @set(post.publisher.country.name = "some country") } create nestedData5() with (publisher.country.id) { - //expect-error:18:49:AttributeArgumentError:Cannot set a field which is beyond scope of the data being created or updated + //expect-error:18:49:AttributeExpressionError:Cannot set a field which is beyond scope of the data being created or updated @set(post.publisher.departments.name = "some department") } create nestedData6() with (name) { - //expect-error:18:41:AttributeArgumentError:Cannot set a field which is beyond scope of the data being created or updated + //expect-error:18:41:AttributeExpressionError:Cannot set a field which is beyond scope of the data being created or updated @set(post.publisher.admin.id = ctx.identity.id) } } diff --git a/schema/testdata/errors/set_attribute_rhs_is_invalid.keel b/schema/testdata/errors/set_attribute_rhs_is_invalid.keel new file mode 100755 index 000000000..ef484c1c1 --- /dev/null +++ b/schema/testdata/errors/set_attribute_rhs_is_invalid.keel @@ -0,0 +1,32 @@ +model Post { + fields { + name Text? + published Boolean? + identity Identity? + } + + actions { + create createPost1() { + //expect-error:18:37:AttributeExpressionError:the @set attribute must be an assignment expression + @set(post.name == "Keel") + } + create createPost2() { + //expect-error:37:38:AttributeExpressionError:operator '+' not supported in this context + @set(post.name = "Keel" + "son") + } + create createPost3() { + //expect-error:30:33:AttributeExpressionError:expression expected to resolve to type Text but it is Number + @set(post.name = 123) + } + create createPost4() { + //expect-error:18:29:AttributeExpressionError:the @set attribute must be an assignment expression + @set(post.name =) + } + } +} + +model Publisher { + fields { + name Text + } +} diff --git a/schema/testdata/errors/set_invalid_operators.keel b/schema/testdata/errors/set_invalid_operators.keel index 4f983ef57..f82bb9b21 100755 --- a/schema/testdata/errors/set_invalid_operators.keel +++ b/schema/testdata/errors/set_invalid_operators.keel @@ -5,16 +5,15 @@ model Post { actions { update incrementViews(id) { - //expect-error:29:31:E022:Operator '+=' not permitted on @set + //expect-error:18:33:AttributeExpressionError:the @set attribute must be an assignment expression @set(post.views += 1) } update decrementViews(id) { - //expect-error:29:31:E022:Operator '-=' not permitted on @set + //expect-error:18:33:AttributeExpressionError:the @set attribute must be an assignment expression @set(post.views -= 1) } update compareViews(id) { - //expect-error:29:31:E022:Operator '==' not permitted on @set - //expect-error:18:33:AttributeArgumentError:The @set attribute cannot be a logical condition and must express an assignment + //expect-error:18:33:AttributeExpressionError:the @set attribute must be an assignment expression @set(post.views == 1) } } diff --git a/schema/testdata/errors/unique_composite_lookup.keel b/schema/testdata/errors/unique_composite_lookup.keel index 4f3543e5a..4b7b1134e 100755 --- a/schema/testdata/errors/unique_composite_lookup.keel +++ b/schema/testdata/errors/unique_composite_lookup.keel @@ -37,22 +37,22 @@ model Product { } //expect-error:13:33:ActionInputError:The action 'getBySupplierSkuOrId' can only get a single record and therefore must be filtered by unique fields get getBySupplierSkuOrId(supplierSku: Text, supplierId: ID) { - @where(product.supplierSku == supplierSku or product.supplier.id == supplierId) + @where(product.supplierSku == supplierSku || product.supplier.id == supplierId) } //expect-error:13:45:ActionInputError:The action 'getBySupplierSkuAndCodeOrShampoo' can only get a single record and therefore must be filtered by unique fields get getBySupplierSkuAndCodeOrShampoo(supplierSku: Text, supplierCode: Text) { - @where(product.supplierSku == supplierSku and product.supplier.supplierCode == supplierCode or product.name == "Shampoo") + @where(product.supplierSku == supplierSku && product.supplier.supplierCode == supplierCode || product.name == "Shampoo") } //expect-error:13:43:ActionInputError:The action 'getBySupplierSkuAndIdOrShampoo' can only get a single record and therefore must be filtered by unique fields get getBySupplierSkuAndIdOrShampoo(supplierSku: Text, supplierId: ID) { - @where(product.supplierSku == supplierSku and (product.supplier.id == supplierId or product.name == "Shampoo")) + @where(product.supplierSku == supplierSku && (product.supplier.id == supplierId || product.name == "Shampoo")) } //expect-error:13:56:ActionInputError:The action 'getBySupplierSkuAndCodeOrShampooParenthesis' can only get a single record and therefore must be filtered by unique fields get getBySupplierSkuAndCodeOrShampooParenthesis( supplierSku: Text, supplierCode: Text, ) { - @where(product.supplierSku == supplierSku and (product.supplier.supplierCode == supplierCode or product.name == "Shampoo")) + @where(product.supplierSku == supplierSku && (product.supplier.supplierCode == supplierCode || product.name == "Shampoo")) } } diff --git a/schema/testdata/errors/unique_lookup.keel b/schema/testdata/errors/unique_lookup.keel index 8163d45cd..99f337af6 100755 --- a/schema/testdata/errors/unique_lookup.keel +++ b/schema/testdata/errors/unique_lookup.keel @@ -24,11 +24,11 @@ model Product { } //expect-error:13:30:ActionInputError:The action 'getBySkuOrShampoo' can only get a single record and therefore must be filtered by unique fields get getBySkuOrShampoo() { - @where(product.sku == ctx.identity.user.assignedProduct.sku or product.name == "Shampoo") + @where(product.sku == ctx.identity.user.assignedProduct.sku || product.name == "Shampoo") } //expect-error:13:34:ActionInputError:The action 'getBySkuOrIdOrShampoo' can only get a single record and therefore must be filtered by unique fields get getBySkuOrIdOrShampoo(productId: ID) { - @where(product.sku == ctx.identity.user.assignedProduct.sku and product.id == productId or product.name == "Shampoo") + @where(product.sku == ctx.identity.user.assignedProduct.sku && product.id == productId || product.name == "Shampoo") } } } diff --git a/schema/testdata/errors/unique_lookup_nested.keel b/schema/testdata/errors/unique_lookup_nested.keel index 9a78d9d75..6d61b2a9a 100755 --- a/schema/testdata/errors/unique_lookup_nested.keel +++ b/schema/testdata/errors/unique_lookup_nested.keel @@ -12,7 +12,7 @@ model Product { } //expect-error:13:38:ActionInputError:The action 'getByBarcodeOrShampooExpr' can only get a single record and therefore must be filtered by unique fields get getByBarcodeOrShampooExpr(barcode: Text) { - @where(product.stock.barcode == barcode or product.name == "Shampoo") + @where(product.stock.barcode == barcode || product.name == "Shampoo") } //expect-error:13:34:ActionInputError:The action 'getBySupplierSkuInput' can only get a single record and therefore must be filtered by unique fields get getBySupplierSkuInput(stock.supplierSku) diff --git a/schema/testdata/errors/unique_restrictions.keel b/schema/testdata/errors/unique_restrictions.keel index 1ced815a7..217f807b2 100755 --- a/schema/testdata/errors/unique_restrictions.keel +++ b/schema/testdata/errors/unique_restrictions.keel @@ -10,10 +10,9 @@ model Author { name Text anotherField Text // timestamp cant be unique - //expect-error:30:37:TypeError:@unique is not permitted on Timestamp fields + //expect-error:30:37:TypeError:@unique is not permitted on Timestamp or Date fields joinedDate Timestamp @unique // has many cant be unique - //expect-error:9:14:RelationshipError:Cannot use @unique on a repeated model field //expect-error:22:29:TypeError:@unique is not permitted on has many relationships or arrays posts Post[] @unique } diff --git a/schema/testdata/errors/unresolvable_lhs_with_incorrect_operator.keel b/schema/testdata/errors/unresolvable_lhs_with_incorrect_operator.keel index 8a6cd7235..cfdb6132c 100755 --- a/schema/testdata/errors/unresolvable_lhs_with_incorrect_operator.keel +++ b/schema/testdata/errors/unresolvable_lhs_with_incorrect_operator.keel @@ -6,7 +6,7 @@ model Post { actions { get posts(id) { - //expect-error:32:33:E022:Operator '=' not permitted on @where + //expect-error:20:37:AttributeExpressionError:assignment operator '=' not valid - did you mean to use the comparison operator '=='? @where(post.titles = 123) } } diff --git a/schema/testdata/errors/where_expression_invalid_operands.keel b/schema/testdata/errors/where_expression_invalid_operands.keel index 6f2de85d7..0c21795cc 100755 --- a/schema/testdata/errors/where_expression_invalid_operands.keel +++ b/schema/testdata/errors/where_expression_invalid_operands.keel @@ -6,33 +6,33 @@ model Person { actions { list listPeople() { - //expect-error:32:39:E020:'invalid' not found + //expect-error:32:39:AttributeExpressionError:unknown identifier 'invalid' @where(expression: invalid) } list listPeople2() { - //expect-error:32:34:E020:'ct' not found + //expect-error:32:34:AttributeExpressionError:unknown identifier 'ct' @where(expression: ct.identity == person.identity) } list listPeople3() { - //expect-error:32:34:E020:'ct' not found - @where(expression: ct.invalid == person.identity) + //expect-error:35:36:AttributeExpressionError:field 'invalid' does not exist + @where(expression: ctx.invalid == person.identity) } list listPeople4() { - //expect-error:32:38:E061:Non-boolean single operand conditions such as '"true"' not permitted on @where + //expect-error:32:38:AttributeExpressionError:expression expected to resolve to type Boolean but it is Text @where(expression: "true") } list listPeople5() { - //expect-error:68:75:E020:'invalid' not found on 'Organisation' + //expect-error:67:68:AttributeExpressionError:field 'invalid' does not exist @where(expression: ctx.identity in person.organisation.invalid) } list listPeople6() { - //expect-error:92:99:E020:'invalid' not found on 'Organisation' - @where(expression: ctx.isAuthenticated and ctx.identity in person.organisation.invalid) + //expect-error:90:91:AttributeExpressionError:field 'invalid' does not exist + @where(expression: ctx.isAuthenticated && ctx.identity in person.organisation.invalid) } list listPeople7() { - //expect-error:52:59:E020:'invalid' not found on 'Boolean' - //expect-error:100:107:E020:'invalid' not found on 'Organisation' - @where(expression: ctx.isAuthenticated.invalid and ctx.identity in person.organisation.invalid) + //expect-error:98:99:AttributeExpressionError:field 'invalid' does not exist + //expect-error:51:52:AttributeExpressionError:type Boolean does not have any fields to select + @where(expression: ctx.isAuthenticated.invalid && ctx.identity in person.organisation.invalid) } } } diff --git a/schema/testdata/errors/where_expression_single_string_literal.keel b/schema/testdata/errors/where_expression_single_string_literal.keel index 492d5f075..a0190ff6f 100755 --- a/schema/testdata/errors/where_expression_single_string_literal.keel +++ b/schema/testdata/errors/where_expression_single_string_literal.keel @@ -6,7 +6,7 @@ model Post { actions { get posts(id) { - //expect-error:20:31:E061:Non-boolean single operand conditions such as '"something"' not permitted on @where + //expect-error:20:31:AttributeExpressionError:expression expected to resolve to type Boolean but it is Text @where("something") } } diff --git a/schema/testdata/errors/where_mismatched_operator_for_matching_lhs_rhs.keel b/schema/testdata/errors/where_mismatched_operator_for_matching_lhs_rhs.keel index d2406c0cf..bed3acffb 100755 --- a/schema/testdata/errors/where_mismatched_operator_for_matching_lhs_rhs.keel +++ b/schema/testdata/errors/where_mismatched_operator_for_matching_lhs_rhs.keel @@ -4,7 +4,7 @@ model Profile { } @permission( - //expect-error:38:40:E027:Cannot compare Text with operator '>=' + //expect-error:38:40:AttributeExpressionError:cannot use operator '>=' with types Text and Text expression: profile.username >= "adaam2", actions: [get] ) diff --git a/schema/testdata/proto/array_fields/proto.json b/schema/testdata/proto/array_fields/proto.json index 49c708cbf..0a06f4af3 100644 --- a/schema/testdata/proto/array_fields/proto.json +++ b/schema/testdata/proto/array_fields/proto.json @@ -103,9 +103,7 @@ "type": "TYPE_STRING" }, "optional": true, - "uniqueWith": [ - "issuer" - ] + "uniqueWith": ["issuer"] }, { "modelName": "Identity", @@ -142,9 +140,7 @@ "type": "TYPE_STRING" }, "optional": true, - "uniqueWith": [ - "email" - ] + "uniqueWith": ["email"] }, { "modelName": "Identity", @@ -376,9 +372,7 @@ "fieldName": "texts", "repeated": true }, - "target": [ - "texts" - ] + "target": ["texts"] }, { "messageName": "CreateThingInput", @@ -389,9 +383,7 @@ "fieldName": "numbers", "repeated": true }, - "target": [ - "numbers" - ] + "target": ["numbers"] }, { "messageName": "CreateThingInput", @@ -402,9 +394,7 @@ "fieldName": "booleans", "repeated": true }, - "target": [ - "booleans" - ] + "target": ["booleans"] }, { "messageName": "CreateThingInput", @@ -415,9 +405,7 @@ "fieldName": "dates", "repeated": true }, - "target": [ - "dates" - ] + "target": ["dates"] }, { "messageName": "CreateThingInput", @@ -428,9 +416,7 @@ "fieldName": "timestamps", "repeated": true }, - "target": [ - "timestamps" - ] + "target": ["timestamps"] } ] }, @@ -1093,9 +1079,7 @@ "type": "TYPE_MESSAGE", "messageName": "StringArrayQueryInput" }, - "target": [ - "texts" - ] + "target": ["texts"] }, { "messageName": "ListThingsWhere", @@ -1104,9 +1088,7 @@ "type": "TYPE_MESSAGE", "messageName": "IntArrayQueryInput" }, - "target": [ - "numbers" - ] + "target": ["numbers"] }, { "messageName": "ListThingsWhere", @@ -1115,9 +1097,7 @@ "type": "TYPE_MESSAGE", "messageName": "BooleanArrayQueryInput" }, - "target": [ - "booleans" - ] + "target": ["booleans"] }, { "messageName": "ListThingsWhere", @@ -1126,9 +1106,7 @@ "type": "TYPE_MESSAGE", "messageName": "DateArrayQueryInput" }, - "target": [ - "dates" - ] + "target": ["dates"] }, { "messageName": "ListThingsWhere", @@ -1137,9 +1115,7 @@ "type": "TYPE_MESSAGE", "messageName": "TimestampArrayQueryInput" }, - "target": [ - "timestamps" - ] + "target": ["timestamps"] } ] }, @@ -1189,4 +1165,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/schema/testdata/proto/array_fields_default/schema.keel b/schema/testdata/proto/array_fields_default/schema.keel index 8739deff7..dcd90f459 100644 --- a/schema/testdata/proto/array_fields_default/schema.keel +++ b/schema/testdata/proto/array_fields_default/schema.keel @@ -1,7 +1,7 @@ model Thing { fields { texts Text[] @default(["science"]) - numbers Number[] @default([123,456]) + numbers Number[] @default([123, 456]) enums MyEnum[] @default([MyEnum.One, MyEnum.Two]) emptyTexts Text[] @default([]) diff --git a/schema/testdata/proto/array_fields_set/schema.keel b/schema/testdata/proto/array_fields_set/schema.keel index 0fc053d46..1dfd8f482 100644 --- a/schema/testdata/proto/array_fields_set/schema.keel +++ b/schema/testdata/proto/array_fields_set/schema.keel @@ -9,7 +9,7 @@ model Thing { create createThing() { @set(thing.texts = ["science"]) @set(thing.enums = [MyEnum.One, MyEnum.Two]) - @set(thing.numbers = [123,456]) + @set(thing.numbers = [123, 456]) } create createEmptyThing() { diff --git a/schema/testdata/proto/attribute_computed/proto.json b/schema/testdata/proto/attribute_computed/proto.json new file mode 100644 index 000000000..980149a3c --- /dev/null +++ b/schema/testdata/proto/attribute_computed/proto.json @@ -0,0 +1,363 @@ +{ + "models": [ + { + "name": "Item", + "fields": [ + { + "modelName": "Item", + "name": "price", + "type": { + "type": "TYPE_DECIMAL" + } + }, + { + "modelName": "Item", + "name": "units", + "type": { + "type": "TYPE_DECIMAL" + } + }, + { + "modelName": "Item", + "name": "total", + "type": { + "type": "TYPE_DECIMAL" + }, + "computedExpression": { + "source": "item.price * item.units" + } + }, + { + "modelName": "Item", + "name": "id", + "type": { + "type": "TYPE_ID" + }, + "unique": true, + "primaryKey": true, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Item", + "name": "createdAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Item", + "name": "updatedAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + } + ], + "actions": [ + { + "modelName": "Item", + "name": "createItem", + "type": "ACTION_TYPE_CREATE", + "implementation": "ACTION_IMPLEMENTATION_AUTO", + "inputMessageName": "CreateItemInput" + } + ] + }, + { + "name": "Identity", + "fields": [ + { + "modelName": "Identity", + "name": "email", + "type": { + "type": "TYPE_STRING" + }, + "optional": true, + "uniqueWith": ["issuer"] + }, + { + "modelName": "Identity", + "name": "emailVerified", + "type": { + "type": "TYPE_BOOL" + }, + "defaultValue": { + "expression": { + "source": "false" + } + } + }, + { + "modelName": "Identity", + "name": "password", + "type": { + "type": "TYPE_PASSWORD" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "externalId", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "issuer", + "type": { + "type": "TYPE_STRING" + }, + "optional": true, + "uniqueWith": ["email"] + }, + { + "modelName": "Identity", + "name": "name", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "givenName", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "familyName", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "middleName", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "nickName", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "profile", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "picture", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "website", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "gender", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "zoneInfo", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "locale", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "id", + "type": { + "type": "TYPE_ID" + }, + "unique": true, + "primaryKey": true, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Identity", + "name": "createdAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Identity", + "name": "updatedAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + } + ], + "actions": [ + { + "modelName": "Identity", + "name": "requestPasswordReset", + "type": "ACTION_TYPE_WRITE", + "implementation": "ACTION_IMPLEMENTATION_RUNTIME", + "inputMessageName": "RequestPasswordResetInput", + "responseMessageName": "RequestPasswordResetResponse" + }, + { + "modelName": "Identity", + "name": "resetPassword", + "type": "ACTION_TYPE_WRITE", + "implementation": "ACTION_IMPLEMENTATION_RUNTIME", + "inputMessageName": "ResetPasswordInput", + "responseMessageName": "ResetPasswordResponse" + } + ] + } + ], + "apis": [ + { + "name": "Api", + "apiModels": [ + { + "modelName": "Item", + "modelActions": [ + { + "actionName": "createItem" + } + ] + }, + { + "modelName": "Identity", + "modelActions": [ + { + "actionName": "requestPasswordReset" + }, + { + "actionName": "resetPassword" + } + ] + } + ] + } + ], + "messages": [ + { + "name": "Any" + }, + { + "name": "RequestPasswordResetInput", + "fields": [ + { + "messageName": "RequestPasswordResetInput", + "name": "email", + "type": { + "type": "TYPE_STRING" + } + }, + { + "messageName": "RequestPasswordResetInput", + "name": "redirectUrl", + "type": { + "type": "TYPE_STRING" + } + } + ] + }, + { + "name": "RequestPasswordResetResponse" + }, + { + "name": "ResetPasswordInput", + "fields": [ + { + "messageName": "ResetPasswordInput", + "name": "token", + "type": { + "type": "TYPE_STRING" + } + }, + { + "messageName": "ResetPasswordInput", + "name": "password", + "type": { + "type": "TYPE_STRING" + } + } + ] + }, + { + "name": "ResetPasswordResponse" + }, + { + "name": "CreateItemInput", + "fields": [ + { + "messageName": "CreateItemInput", + "name": "price", + "type": { + "type": "TYPE_DECIMAL", + "modelName": "Item", + "fieldName": "price" + }, + "target": ["price"] + }, + { + "messageName": "CreateItemInput", + "name": "units", + "type": { + "type": "TYPE_DECIMAL", + "modelName": "Item", + "fieldName": "units" + }, + "target": ["units"] + } + ] + } + ] +} diff --git a/schema/testdata/proto/attribute_computed/schema.keel b/schema/testdata/proto/attribute_computed/schema.keel new file mode 100644 index 000000000..15ea81375 --- /dev/null +++ b/schema/testdata/proto/attribute_computed/schema.keel @@ -0,0 +1,10 @@ +model Item { + fields { + price Decimal + units Decimal + total Decimal @computed(item.price * item.units) + } + actions { + create createItem() with (price, units) + } +} \ No newline at end of file diff --git a/schema/testdata/proto/computed_fields/proto.json b/schema/testdata/proto/computed_fields/proto.json new file mode 100644 index 000000000..6046dd4b5 --- /dev/null +++ b/schema/testdata/proto/computed_fields/proto.json @@ -0,0 +1,402 @@ +{ + "models": [ + { + "name": "Invoice", + "fields": [ + { + "modelName": "Invoice", + "name": "items", + "type": { + "type": "TYPE_MODEL", + "modelName": "Item", + "repeated": true + }, + "inverseFieldName": "invoice" + }, + { + "modelName": "Invoice", + "name": "id", + "type": { + "type": "TYPE_ID" + }, + "unique": true, + "primaryKey": true, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Invoice", + "name": "createdAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Invoice", + "name": "updatedAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + } + ] + }, + { + "name": "Item", + "fields": [ + { + "modelName": "Item", + "name": "invoice", + "type": { + "type": "TYPE_MODEL", + "modelName": "Invoice" + }, + "foreignKeyFieldName": "invoiceId", + "inverseFieldName": "items" + }, + { + "modelName": "Item", + "name": "invoiceId", + "type": { + "type": "TYPE_ID" + }, + "foreignKeyInfo": { + "relatedModelName": "Invoice", + "relatedModelField": "id" + } + }, + { + "modelName": "Item", + "name": "description", + "type": { + "type": "TYPE_STRING" + } + }, + { + "modelName": "Item", + "name": "price", + "type": { + "type": "TYPE_DECIMAL" + } + }, + { + "modelName": "Item", + "name": "quantity", + "type": { + "type": "TYPE_INT" + } + }, + { + "modelName": "Item", + "name": "total", + "type": { + "type": "TYPE_DECIMAL" + }, + "computedExpression": { + "source": "item.price * item.quantity" + } + }, + { + "modelName": "Item", + "name": "id", + "type": { + "type": "TYPE_ID" + }, + "unique": true, + "primaryKey": true, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Item", + "name": "createdAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Item", + "name": "updatedAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + } + ] + }, + { + "name": "Identity", + "fields": [ + { + "modelName": "Identity", + "name": "email", + "type": { + "type": "TYPE_STRING" + }, + "optional": true, + "uniqueWith": ["issuer"] + }, + { + "modelName": "Identity", + "name": "emailVerified", + "type": { + "type": "TYPE_BOOL" + }, + "defaultValue": { + "expression": { + "source": "false" + } + } + }, + { + "modelName": "Identity", + "name": "password", + "type": { + "type": "TYPE_PASSWORD" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "externalId", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "issuer", + "type": { + "type": "TYPE_STRING" + }, + "optional": true, + "uniqueWith": ["email"] + }, + { + "modelName": "Identity", + "name": "name", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "givenName", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "familyName", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "middleName", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "nickName", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "profile", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "picture", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "website", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "gender", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "zoneInfo", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "locale", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "id", + "type": { + "type": "TYPE_ID" + }, + "unique": true, + "primaryKey": true, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Identity", + "name": "createdAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Identity", + "name": "updatedAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + } + ], + "actions": [ + { + "modelName": "Identity", + "name": "requestPasswordReset", + "type": "ACTION_TYPE_WRITE", + "implementation": "ACTION_IMPLEMENTATION_RUNTIME", + "inputMessageName": "RequestPasswordResetInput", + "responseMessageName": "RequestPasswordResetResponse" + }, + { + "modelName": "Identity", + "name": "resetPassword", + "type": "ACTION_TYPE_WRITE", + "implementation": "ACTION_IMPLEMENTATION_RUNTIME", + "inputMessageName": "ResetPasswordInput", + "responseMessageName": "ResetPasswordResponse" + } + ] + } + ], + "apis": [ + { + "name": "Api", + "apiModels": [ + { + "modelName": "Invoice" + }, + { + "modelName": "Item" + }, + { + "modelName": "Identity", + "modelActions": [ + { + "actionName": "requestPasswordReset" + }, + { + "actionName": "resetPassword" + } + ] + } + ] + } + ], + "messages": [ + { + "name": "Any" + }, + { + "name": "RequestPasswordResetInput", + "fields": [ + { + "messageName": "RequestPasswordResetInput", + "name": "email", + "type": { + "type": "TYPE_STRING" + } + }, + { + "messageName": "RequestPasswordResetInput", + "name": "redirectUrl", + "type": { + "type": "TYPE_STRING" + } + } + ] + }, + { + "name": "RequestPasswordResetResponse" + }, + { + "name": "ResetPasswordInput", + "fields": [ + { + "messageName": "ResetPasswordInput", + "name": "token", + "type": { + "type": "TYPE_STRING" + } + }, + { + "messageName": "ResetPasswordInput", + "name": "password", + "type": { + "type": "TYPE_STRING" + } + } + ] + }, + { + "name": "ResetPasswordResponse" + } + ] +} diff --git a/schema/testdata/proto/computed_fields/schema.keel b/schema/testdata/proto/computed_fields/schema.keel new file mode 100644 index 000000000..157b6c708 --- /dev/null +++ b/schema/testdata/proto/computed_fields/schema.keel @@ -0,0 +1,15 @@ +model Invoice { + fields { + items Item[] + } +} + +model Item { + fields { + invoice Invoice + description Text + price Decimal + quantity Number + total Decimal @computed(item.price * item.quantity) + } +} \ No newline at end of file diff --git a/schema/testdata/proto/expressions_this_variable/proto.json b/schema/testdata/proto/expressions_this_variable/proto.json new file mode 100644 index 000000000..4fb1895aa --- /dev/null +++ b/schema/testdata/proto/expressions_this_variable/proto.json @@ -0,0 +1,398 @@ +{ + "models": [ + { + "name": "Thing", + "fields": [ + { + "modelName": "Thing", + "name": "number1", + "type": { + "type": "TYPE_INT" + } + }, + { + "modelName": "Thing", + "name": "number2", + "type": { + "type": "TYPE_INT" + }, + "computedExpression": { + "source": "this.number1 + 1" + } + }, + { + "modelName": "Thing", + "name": "id", + "type": { + "type": "TYPE_ID" + }, + "unique": true, + "primaryKey": true, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Thing", + "name": "createdAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Thing", + "name": "updatedAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + } + ], + "actions": [ + { + "modelName": "Thing", + "name": "listThings", + "type": "ACTION_TYPE_LIST", + "implementation": "ACTION_IMPLEMENTATION_AUTO", + "permissions": [ + { + "modelName": "Thing", + "actionName": "listThings", + "expression": { + "source": "this.number1 == 1" + } + } + ], + "whereExpressions": [ + { + "source": "this.number1 == 1" + } + ], + "inputMessageName": "ListThingsInput" + } + ] + }, + { + "name": "Identity", + "fields": [ + { + "modelName": "Identity", + "name": "email", + "type": { + "type": "TYPE_STRING" + }, + "optional": true, + "uniqueWith": [ + "issuer" + ] + }, + { + "modelName": "Identity", + "name": "emailVerified", + "type": { + "type": "TYPE_BOOL" + }, + "defaultValue": { + "expression": { + "source": "false" + } + } + }, + { + "modelName": "Identity", + "name": "password", + "type": { + "type": "TYPE_PASSWORD" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "externalId", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "issuer", + "type": { + "type": "TYPE_STRING" + }, + "optional": true, + "uniqueWith": [ + "email" + ] + }, + { + "modelName": "Identity", + "name": "name", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "givenName", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "familyName", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "middleName", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "nickName", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "profile", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "picture", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "website", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "gender", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "zoneInfo", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "locale", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "modelName": "Identity", + "name": "id", + "type": { + "type": "TYPE_ID" + }, + "unique": true, + "primaryKey": true, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Identity", + "name": "createdAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + }, + { + "modelName": "Identity", + "name": "updatedAt", + "type": { + "type": "TYPE_DATETIME" + }, + "defaultValue": { + "useZeroValue": true + } + } + ], + "actions": [ + { + "modelName": "Identity", + "name": "requestPasswordReset", + "type": "ACTION_TYPE_WRITE", + "implementation": "ACTION_IMPLEMENTATION_RUNTIME", + "inputMessageName": "RequestPasswordResetInput", + "responseMessageName": "RequestPasswordResetResponse" + }, + { + "modelName": "Identity", + "name": "resetPassword", + "type": "ACTION_TYPE_WRITE", + "implementation": "ACTION_IMPLEMENTATION_RUNTIME", + "inputMessageName": "ResetPasswordInput", + "responseMessageName": "ResetPasswordResponse" + } + ] + } + ], + "apis": [ + { + "name": "Api", + "apiModels": [ + { + "modelName": "Thing", + "modelActions": [ + { + "actionName": "listThings" + } + ] + }, + { + "modelName": "Identity", + "modelActions": [ + { + "actionName": "requestPasswordReset" + }, + { + "actionName": "resetPassword" + } + ] + } + ] + } + ], + "messages": [ + { + "name": "Any" + }, + { + "name": "RequestPasswordResetInput", + "fields": [ + { + "messageName": "RequestPasswordResetInput", + "name": "email", + "type": { + "type": "TYPE_STRING" + } + }, + { + "messageName": "RequestPasswordResetInput", + "name": "redirectUrl", + "type": { + "type": "TYPE_STRING" + } + } + ] + }, + { + "name": "RequestPasswordResetResponse" + }, + { + "name": "ResetPasswordInput", + "fields": [ + { + "messageName": "ResetPasswordInput", + "name": "token", + "type": { + "type": "TYPE_STRING" + } + }, + { + "messageName": "ResetPasswordInput", + "name": "password", + "type": { + "type": "TYPE_STRING" + } + } + ] + }, + { + "name": "ResetPasswordResponse" + }, + { + "name": "ListThingsWhere" + }, + { + "name": "ListThingsInput", + "fields": [ + { + "messageName": "ListThingsInput", + "name": "where", + "type": { + "type": "TYPE_MESSAGE", + "messageName": "ListThingsWhere" + }, + "optional": true + }, + { + "messageName": "ListThingsInput", + "name": "first", + "type": { + "type": "TYPE_INT" + }, + "optional": true + }, + { + "messageName": "ListThingsInput", + "name": "after", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + }, + { + "messageName": "ListThingsInput", + "name": "last", + "type": { + "type": "TYPE_INT" + }, + "optional": true + }, + { + "messageName": "ListThingsInput", + "name": "before", + "type": { + "type": "TYPE_STRING" + }, + "optional": true + } + ] + } + ] +} \ No newline at end of file diff --git a/schema/testdata/proto/expressions_this_variable/schema.keel b/schema/testdata/proto/expressions_this_variable/schema.keel new file mode 100644 index 000000000..c05eef512 --- /dev/null +++ b/schema/testdata/proto/expressions_this_variable/schema.keel @@ -0,0 +1,12 @@ +model Thing { + fields { + number1 Number + number2 Number @computed(this.number1 + 1) + } + actions { + list listThings() { + @where(this.number1 == 1) + @permission(expression: this.number1 == 1) + } + } +} diff --git a/schema/testdata/proto/fields_attributes/proto.json b/schema/testdata/proto/fields_attributes/proto.json index 9411d240c..1295ceac0 100644 --- a/schema/testdata/proto/fields_attributes/proto.json +++ b/schema/testdata/proto/fields_attributes/proto.json @@ -24,33 +24,6 @@ } } }, - { - "modelName": "Foo", - "name": "identity", - "type": { - "type": "TYPE_MODEL", - "modelName": "Identity" - }, - "optional": true, - "foreignKeyFieldName": "identityId", - "defaultValue": { - "expression": { - "source": "ctx.identity" - } - } - }, - { - "modelName": "Foo", - "name": "identityId", - "type": { - "type": "TYPE_ID" - }, - "optional": true, - "foreignKeyInfo": { - "relatedModelName": "Identity", - "relatedModelField": "id" - } - }, { "modelName": "Foo", "name": "id", diff --git a/schema/testdata/proto/fields_attributes/schema.keel b/schema/testdata/proto/fields_attributes/schema.keel index 2b92a8037..64591fc58 100644 --- a/schema/testdata/proto/fields_attributes/schema.keel +++ b/schema/testdata/proto/fields_attributes/schema.keel @@ -2,6 +2,5 @@ model Foo { fields { bar Text @unique baz Text? @default("foo") - identity Identity? @default(ctx.identity) } } diff --git a/schema/testdata/proto/operations_attr_set/proto.json b/schema/testdata/proto/operations_attr_set/proto.json index 5a0b03f46..61b3714e7 100644 --- a/schema/testdata/proto/operations_attr_set/proto.json +++ b/schema/testdata/proto/operations_attr_set/proto.json @@ -3,51 +3,29 @@ { "name": "Foo", "fields": [ - { - "modelName": "Foo", - "name": "f1", - "type": { - "type": "TYPE_BOOL" - } - }, - { - "modelName": "Foo", - "name": "f2", - "type": { - "type": "TYPE_STRING" - } - }, + { "modelName": "Foo", "name": "f1", "type": { "type": "TYPE_BOOL" } }, + { "modelName": "Foo", "name": "f2", "type": { "type": "TYPE_STRING" } }, + { "modelName": "Foo", "name": "f3", "type": { "type": "TYPE_STRING" } }, + { "modelName": "Foo", "name": "someId", "type": { "type": "TYPE_ID" } }, { "modelName": "Foo", "name": "id", - "type": { - "type": "TYPE_ID" - }, + "type": { "type": "TYPE_ID" }, "unique": true, "primaryKey": true, - "defaultValue": { - "useZeroValue": true - } + "defaultValue": { "useZeroValue": true } }, { "modelName": "Foo", "name": "createdAt", - "type": { - "type": "TYPE_DATETIME" - }, - "defaultValue": { - "useZeroValue": true - } + "type": { "type": "TYPE_DATETIME" }, + "defaultValue": { "useZeroValue": true } }, { "modelName": "Foo", "name": "updatedAt", - "type": { - "type": "TYPE_DATETIME" - }, - "defaultValue": { - "useZeroValue": true - } + "type": { "type": "TYPE_DATETIME" }, + "defaultValue": { "useZeroValue": true } } ], "actions": [ @@ -56,12 +34,24 @@ "name": "createPost", "type": "ACTION_TYPE_CREATE", "implementation": "ACTION_IMPLEMENTATION_AUTO", - "setExpressions": [ - { - "source": "foo.f1 = true" - } - ], + "setExpressions": [{ "source": "foo.f1 = true" }], "inputMessageName": "CreatePostInput" + }, + { + "modelName": "Foo", + "name": "updatePost1", + "type": "ACTION_TYPE_UPDATE", + "implementation": "ACTION_IMPLEMENTATION_AUTO", + "setExpressions": [{ "source": "foo.f3 = f2" }], + "inputMessageName": "UpdatePost1Input" + }, + { + "modelName": "Foo", + "name": "updatePost2", + "type": "ACTION_TYPE_UPDATE", + "implementation": "ACTION_IMPLEMENTATION_AUTO", + "setExpressions": [{ "source": "foo.someId = id" }], + "inputMessageName": "UpdatePost2Input" } ] }, @@ -71,168 +61,120 @@ { "modelName": "Identity", "name": "email", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true, "uniqueWith": ["issuer"] }, { "modelName": "Identity", "name": "emailVerified", - "type": { - "type": "TYPE_BOOL" - }, - "defaultValue": { - "expression": { - "source": "false" - } - } + "type": { "type": "TYPE_BOOL" }, + "defaultValue": { "expression": { "source": "false" } } }, { "modelName": "Identity", "name": "password", - "type": { - "type": "TYPE_PASSWORD" - }, + "type": { "type": "TYPE_PASSWORD" }, "optional": true }, { "modelName": "Identity", "name": "externalId", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true }, { "modelName": "Identity", "name": "issuer", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true, "uniqueWith": ["email"] }, { "modelName": "Identity", "name": "name", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true }, { "modelName": "Identity", "name": "givenName", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true }, { "modelName": "Identity", "name": "familyName", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true }, { "modelName": "Identity", "name": "middleName", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true }, { "modelName": "Identity", "name": "nickName", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true }, { "modelName": "Identity", "name": "profile", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true }, { "modelName": "Identity", "name": "picture", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true }, { "modelName": "Identity", "name": "website", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true }, { "modelName": "Identity", "name": "gender", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true }, { "modelName": "Identity", "name": "zoneInfo", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true }, { "modelName": "Identity", "name": "locale", - "type": { - "type": "TYPE_STRING" - }, + "type": { "type": "TYPE_STRING" }, "optional": true }, { "modelName": "Identity", "name": "id", - "type": { - "type": "TYPE_ID" - }, + "type": { "type": "TYPE_ID" }, "unique": true, "primaryKey": true, - "defaultValue": { - "useZeroValue": true - } + "defaultValue": { "useZeroValue": true } }, { "modelName": "Identity", "name": "createdAt", - "type": { - "type": "TYPE_DATETIME" - }, - "defaultValue": { - "useZeroValue": true - } + "type": { "type": "TYPE_DATETIME" }, + "defaultValue": { "useZeroValue": true } }, { "modelName": "Identity", "name": "updatedAt", - "type": { - "type": "TYPE_DATETIME" - }, - "defaultValue": { - "useZeroValue": true - } + "type": { "type": "TYPE_DATETIME" }, + "defaultValue": { "useZeroValue": true } } ], "actions": [ @@ -262,78 +204,106 @@ { "modelName": "Foo", "modelActions": [ - { - "actionName": "createPost" - } + { "actionName": "createPost" }, + { "actionName": "updatePost1" }, + { "actionName": "updatePost2" } ] }, { "modelName": "Identity", "modelActions": [ - { - "actionName": "requestPasswordReset" - }, - { - "actionName": "resetPassword" - } + { "actionName": "requestPasswordReset" }, + { "actionName": "resetPassword" } ] } ] } ], "messages": [ - { - "name": "Any" - }, + { "name": "Any" }, { "name": "RequestPasswordResetInput", "fields": [ { "messageName": "RequestPasswordResetInput", "name": "email", - "type": { - "type": "TYPE_STRING" - } + "type": { "type": "TYPE_STRING" } }, { "messageName": "RequestPasswordResetInput", "name": "redirectUrl", - "type": { - "type": "TYPE_STRING" - } + "type": { "type": "TYPE_STRING" } } ] }, - { - "name": "RequestPasswordResetResponse" - }, + { "name": "RequestPasswordResetResponse" }, { "name": "ResetPasswordInput", "fields": [ { "messageName": "ResetPasswordInput", "name": "token", - "type": { - "type": "TYPE_STRING" - } + "type": { "type": "TYPE_STRING" } }, { "messageName": "ResetPasswordInput", "name": "password", + "type": { "type": "TYPE_STRING" } + } + ] + }, + { "name": "ResetPasswordResponse" }, + { + "name": "CreatePostInput", + "fields": [ + { + "messageName": "CreatePostInput", + "name": "f2", + "type": { + "type": "TYPE_STRING", + "modelName": "Foo", + "fieldName": "f2" + }, + "target": ["f2"] + }, + { + "messageName": "CreatePostInput", + "name": "f3", + "type": { + "type": "TYPE_STRING", + "modelName": "Foo", + "fieldName": "f3" + }, + "target": ["f3"] + }, + { + "messageName": "CreatePostInput", + "name": "someId", "type": { - "type": "TYPE_STRING" - } + "type": "TYPE_ID", + "modelName": "Foo", + "fieldName": "someId" + }, + "target": ["someId"] } ] }, { - "name": "ResetPasswordResponse" + "name": "UpdatePost1Where", + "fields": [ + { + "messageName": "UpdatePost1Where", + "name": "id", + "type": { "type": "TYPE_ID", "modelName": "Foo", "fieldName": "id" }, + "target": ["id"] + } + ] }, { - "name": "CreatePostInput", + "name": "UpdatePost1Values", "fields": [ { - "messageName": "CreatePostInput", + "messageName": "UpdatePost1Values", "name": "f2", "type": { "type": "TYPE_STRING", @@ -343,6 +313,52 @@ "target": ["f2"] } ] + }, + { + "name": "UpdatePost1Input", + "fields": [ + { + "messageName": "UpdatePost1Input", + "name": "where", + "type": { "type": "TYPE_MESSAGE", "messageName": "UpdatePost1Where" } + }, + { + "messageName": "UpdatePost1Input", + "name": "values", + "type": { "type": "TYPE_MESSAGE", "messageName": "UpdatePost1Values" } + } + ] + }, + { + "name": "UpdatePost2Where", + "fields": [ + { + "messageName": "UpdatePost2Where", + "name": "id", + "type": { "type": "TYPE_ID", "modelName": "Foo", "fieldName": "id" }, + "target": ["id"] + } + ] + }, + { "name": "UpdatePost2Values" }, + { + "name": "UpdatePost2Input", + "fields": [ + { + "messageName": "UpdatePost2Input", + "name": "where", + "type": { "type": "TYPE_MESSAGE", "messageName": "UpdatePost2Where" } + }, + { + "messageName": "UpdatePost2Input", + "name": "values", + "type": { + "type": "TYPE_MESSAGE", + "messageName": "UpdatePost2Values" + }, + "optional": true + } + ] } ] } diff --git a/schema/testdata/proto/operations_attr_set/schema.keel b/schema/testdata/proto/operations_attr_set/schema.keel index 73572e452..f7178adef 100644 --- a/schema/testdata/proto/operations_attr_set/schema.keel +++ b/schema/testdata/proto/operations_attr_set/schema.keel @@ -2,10 +2,18 @@ model Foo { fields { f1 Boolean f2 Text + f3 Text + someId ID } actions { - create createPost() with (f2) { + create createPost() with (f2, f3, someId) { @set(foo.f1 = true) } + update updatePost1(id) with (f2) { + @set(foo.f3 = f2) + } + update updatePost2(id) { + @set(foo.someId = id) + } } } \ No newline at end of file diff --git a/schema/testdata/proto/unique_composite_lookup/proto.json b/schema/testdata/proto/unique_composite_lookup/proto.json index 8244e56e4..f76b64686 100644 --- a/schema/testdata/proto/unique_composite_lookup/proto.json +++ b/schema/testdata/proto/unique_composite_lookup/proto.json @@ -213,7 +213,7 @@ "implementation": "ACTION_IMPLEMENTATION_AUTO", "whereExpressions": [ { - "source": "product.supplierSku == supplierSku and product.supplier.supplierCode == supplierCode" + "source": "product.supplierSku == supplierSku && product.supplier.supplierCode == supplierCode" } ], "inputMessageName": "GetBySupplierSkuExprAndCodeExprInput" @@ -225,7 +225,7 @@ "implementation": "ACTION_IMPLEMENTATION_AUTO", "whereExpressions": [ { - "source": "product.supplierSku == supplierSku and product.supplier.id == supplierId" + "source": "product.supplierSku == supplierSku && product.supplier.id == supplierId" } ], "inputMessageName": "GetBySupplierSkuExprAndIdExprInput" @@ -252,7 +252,7 @@ "implementation": "ACTION_IMPLEMENTATION_AUTO", "whereExpressions": [ { - "source": "product.isActive == true and product.supplier.supplierCode == \"XYZ\"" + "source": "product.isActive == true && product.supplier.supplierCode == \"XYZ\"" } ], "inputMessageName": "GetBySupplierSkuForXyzInput" diff --git a/schema/testdata/proto/unique_composite_lookup/schema.keel b/schema/testdata/proto/unique_composite_lookup/schema.keel index 82adc0d8c..96877bd63 100644 --- a/schema/testdata/proto/unique_composite_lookup/schema.keel +++ b/schema/testdata/proto/unique_composite_lookup/schema.keel @@ -21,10 +21,10 @@ model Product { get getBySupplierSkuInputAndIdInput(supplierSku, supplier.id) get getBySupplierSkuInputAndCodeInput(supplierSku, supplier.supplierCode) get getBySupplierSkuExprAndCodeExpr(supplierSku: Text, supplierCode: Text) { - @where(product.supplierSku == supplierSku and product.supplier.supplierCode == supplierCode) + @where(product.supplierSku == supplierSku && product.supplier.supplierCode == supplierCode) } get getBySupplierSkuExprAndIdExpr(supplierSku: Text, supplierId: ID) { - @where(product.supplierSku == supplierSku and product.supplier.id == supplierId) + @where(product.supplierSku == supplierSku && product.supplier.id == supplierId) } get getBySupplierSkuExprAndIdExprMultiWhere( supplierSku: Text, @@ -34,7 +34,7 @@ model Product { @where(product.supplier.id == supplierId) } get getBySupplierSkuForXyz(supplierSku) { - @where(product.isActive == true and product.supplier.supplierCode == "XYZ") + @where(product.isActive == true && product.supplier.supplierCode == "XYZ") } get getByBarcode() { @where(product.stock.barcode == "1234") diff --git a/schema/testdata/proto/unique_lookup/proto.json b/schema/testdata/proto/unique_lookup/proto.json index ab4bc2946..78507bd75 100644 --- a/schema/testdata/proto/unique_lookup/proto.json +++ b/schema/testdata/proto/unique_lookup/proto.json @@ -114,7 +114,7 @@ "implementation": "ACTION_IMPLEMENTATION_AUTO", "whereExpressions": [ { - "source": "product.sku == sku and product.name == \"Shampoo\"" + "source": "product.sku == sku && product.name == \"Shampoo\"" } ], "inputMessageName": "GetbySkuAndShampooInput" @@ -126,7 +126,7 @@ "implementation": "ACTION_IMPLEMENTATION_AUTO", "whereExpressions": [ { - "source": "product.name == \"Shampoo\" and product.sku == sku" + "source": "product.name == \"Shampoo\" && product.sku == sku" } ], "inputMessageName": "GetbySkuAndShampooInverseInput" @@ -138,7 +138,7 @@ "implementation": "ACTION_IMPLEMENTATION_AUTO", "whereExpressions": [ { - "source": "product.sku == ctx.identity.user.assignedProduct.sku or product.id == productId" + "source": "product.sku == ctx.identity.user.assignedProduct.sku || product.id == productId" } ], "inputMessageName": "GetbySkuOrIdInput" @@ -150,7 +150,7 @@ "implementation": "ACTION_IMPLEMENTATION_AUTO", "whereExpressions": [ { - "source": "product.sku == sku and product.name != \"Shampoo\"" + "source": "product.sku == sku && product.name != \"Shampoo\"" } ], "inputMessageName": "GetbySkuAndNotShampooInput" diff --git a/schema/testdata/proto/unique_lookup/schema.keel b/schema/testdata/proto/unique_lookup/schema.keel index 9490d8213..1c369bb38 100644 --- a/schema/testdata/proto/unique_lookup/schema.keel +++ b/schema/testdata/proto/unique_lookup/schema.keel @@ -19,16 +19,16 @@ model Product { @where(product.name == "Shampoo") } get getbySkuAndShampoo(sku: Text) { - @where(product.sku == sku and product.name == "Shampoo") + @where(product.sku == sku && product.name == "Shampoo") } get getbySkuAndShampooInverse(sku: Text) { - @where(product.name == "Shampoo" and product.sku == sku) + @where(product.name == "Shampoo" && product.sku == sku) } get getbySkuOrId(productId: ID) { - @where(product.sku == ctx.identity.user.assignedProduct.sku or product.id == productId) + @where(product.sku == ctx.identity.user.assignedProduct.sku || product.id == productId) } get getbySkuAndNotShampoo(sku: Text) { - @where(product.sku == sku and product.name != "Shampoo") + @where(product.sku == sku && product.name != "Shampoo") } get getbySkuFromCtx() { @where(product.sku == ctx.identity.user.assignedProduct.sku) diff --git a/schema/testdata/proto/unique_lookup_nested/proto.json b/schema/testdata/proto/unique_lookup_nested/proto.json index 5e9227373..c320b9e42 100644 --- a/schema/testdata/proto/unique_lookup_nested/proto.json +++ b/schema/testdata/proto/unique_lookup_nested/proto.json @@ -122,7 +122,7 @@ "implementation": "ACTION_IMPLEMENTATION_AUTO", "whereExpressions": [ { - "source": "product.stock.barcode == barcode and product.name == \"Shampoo\"" + "source": "product.stock.barcode == barcode && product.name == \"Shampoo\"" } ], "inputMessageName": "GetByBarcodeAndShampooExprInput" diff --git a/schema/testdata/proto/unique_lookup_nested/schema.keel b/schema/testdata/proto/unique_lookup_nested/schema.keel index a4ce29e0f..c3af2d0b6 100644 --- a/schema/testdata/proto/unique_lookup_nested/schema.keel +++ b/schema/testdata/proto/unique_lookup_nested/schema.keel @@ -15,7 +15,7 @@ model Product { @where(product.name == "Shampoo") } get getByBarcodeAndShampooExpr(barcode: Text) { - @where(product.stock.barcode == barcode and product.name == "Shampoo") + @where(product.stock.barcode == barcode && product.name == "Shampoo") } get getByStockIdExpr(stockId: ID) { @where(product.stock.id == stockId) diff --git a/schema/validation/attribute_arguments.go b/schema/validation/attribute_arguments.go index 62df36f91..551595242 100644 --- a/schema/validation/attribute_arguments.go +++ b/schema/validation/attribute_arguments.go @@ -174,7 +174,7 @@ func AttributeArgumentsRules(asts []*parser.AST, errs *errorhandling.ValidationE errorhandling.AttributeArgumentError, errorhandling.ErrorDetails{ Message: message, - //Hint: hint, + Hint: hint, }, node, )) diff --git a/schema/validation/computed_attribute.go b/schema/validation/computed_attribute.go new file mode 100644 index 000000000..304ca3cb6 --- /dev/null +++ b/schema/validation/computed_attribute.go @@ -0,0 +1,128 @@ +package validation + +import ( + "fmt" + + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/schema/attributes" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/query" + "github.com/teamkeel/keel/schema/validation/errorhandling" +) + +func ComputedAttributeRules(asts []*parser.AST, errs *errorhandling.ValidationErrors) Visitor { + var model *parser.ModelNode + var field *parser.FieldNode + var attribute *parser.AttributeNode + + return Visitor{ + EnterModel: func(m *parser.ModelNode) { + model = m + }, + LeaveModel: func(*parser.ModelNode) { + model = nil + }, + EnterField: func(f *parser.FieldNode) { + field = f + }, + LeaveField: func(n *parser.FieldNode) { + field = nil + }, + EnterAttribute: func(attr *parser.AttributeNode) { + if field == nil || attr.Name.Value != parser.AttributeComputed { + return + } + + switch field.Type.Value { + case parser.FieldTypeBoolean, + parser.FieldTypeNumber, + parser.FieldTypeDecimal: + attribute = attr + default: + errs.AppendError( + errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeNotAllowedError, + errorhandling.ErrorDetails{ + Message: fmt.Sprintf("@computed cannot be used on field of type %s", field.Type.Value), + }, + attr, + ), + ) + } + + if field.Repeated { + errs.AppendError( + errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeNotAllowedError, + errorhandling.ErrorDetails{ + Message: "@computed cannot be used on repeated fields", + }, + attr, + ), + ) + } + + if len(attr.Arguments) != 1 { + errs.AppendError( + errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeArgumentError, + errorhandling.ErrorDetails{ + Message: fmt.Sprintf("%v argument(s) provided to @computed but expected 1", len(attr.Arguments)), + }, + attr, + ), + ) + } + }, + LeaveAttribute: func(*parser.AttributeNode) { + attribute = nil + }, + EnterExpression: func(expression *parser.Expression) { + if attribute == nil { + return + } + + issues, err := attributes.ValidateComputedExpression(asts, model, field, expression) + if err != nil { + errs.AppendError(errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeExpressionError, + errorhandling.ErrorDetails{ + Message: "expression could not be parsed", + }, + expression)) + return + } + + if len(issues) > 0 { + for _, issue := range issues { + errs.AppendError(issue) + } + } + + operands, err := resolve.IdentOperands(expression) + if err != nil { + return + } + + for _, operand := range operands { + if len(operand.Fragments) < 2 { + continue + } + + f := query.Field(model, operand.Fragments[1]) + + if f == field { + errs.AppendError( + errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeArgumentError, + errorhandling.ErrorDetails{ + Message: "@computed expressions cannot reference itself", + }, + operand, + ), + ) + } + } + }, + } +} diff --git a/schema/validation/conflicting_inputs.go b/schema/validation/conflicting_inputs.go index 5fdfe4593..1c606a25d 100644 --- a/schema/validation/conflicting_inputs.go +++ b/schema/validation/conflicting_inputs.go @@ -2,8 +2,10 @@ package validation import ( "fmt" + "strings" "github.com/samber/lo" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/validation/errorhandling" ) @@ -60,37 +62,48 @@ func ConflictingInputsRule(_ []*parser.AST, errs *errorhandling.ValidationErrors inputs = writeInputs } - for _, cond := range expr.Conditions() { - operands := []*parser.Operand{cond.LHS} - if n.Name.Value == parser.AttributeWhere { - operands = append(operands, cond.RHS) + idents := []*parser.ExpressionIdent{} + var err error + switch n.Name.Value { + case parser.AttributeWhere: + idents, err = resolve.IdentOperands(n.Arguments[0].Expression) + if err != nil { + return } - - for _, operand := range operands { - if operand == nil || operand.Ident == nil { - continue + case parser.AttributeSet: + lhs, _, err := n.Arguments[0].Expression.ToAssignmentExpression() + if err != nil { + return + } else { + idents, err = resolve.IdentOperands(lhs) + if err != nil { + return } - for in := range inputs { - // in an expression the first ident fragment will be the model name - // we create an indent without the first fragment - identWithoutModelName := &parser.Ident{ - Fragments: operand.Ident.Fragments[1:], - } + } + } + if err != nil { + return + } - if in.Type.ToString() != identWithoutModelName.ToString() { - continue - } + for _, operand := range idents { + for in := range inputs { + // in an expression the first ident fragment will be the model name + // we create an indent without the first fragment + identWithoutModelName := operand.Fragments[1:] - errs.AppendError( - errorhandling.NewValidationErrorWithDetails( - errorhandling.ActionInputError, - errorhandling.ErrorDetails{ - Message: fmt.Sprintf("%s is already being used as an input so cannot also be used in an expression", in.Type.ToString()), - }, - operand.Ident, - ), - ) + if in.Type.ToString() != strings.Join(identWithoutModelName, ".") { + continue } + + errs.AppendError( + errorhandling.NewValidationErrorWithDetails( + errorhandling.ActionInputError, + errorhandling.ErrorDetails{ + Message: fmt.Sprintf("%s is already being used as an input so cannot also be used in an expression", in.Type.ToString()), + }, + operand, + ), + ) } } }, diff --git a/schema/validation/default_attribute.go b/schema/validation/default_attribute.go new file mode 100644 index 000000000..16c180634 --- /dev/null +++ b/schema/validation/default_attribute.go @@ -0,0 +1,65 @@ +package validation + +import ( + "github.com/samber/lo" + "github.com/teamkeel/keel/schema/attributes" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/validation/errorhandling" +) + +func DefaultAttributeExpressionRules(asts []*parser.AST, errs *errorhandling.ValidationErrors) Visitor { + var field *parser.FieldNode + var attribute *parser.AttributeNode + + return Visitor{ + EnterField: func(f *parser.FieldNode) { + field = f + }, + LeaveField: func(_ *parser.FieldNode) { + field = nil + }, + EnterAttribute: func(a *parser.AttributeNode) { + attribute = a + + if a == nil || a.Name.Value != parser.AttributeDefault { + return + } + + typesWithZeroValue := []string{"Text", "Number", "Boolean", "ID", "Timestamp"} + if len(a.Arguments) == 0 && !lo.Contains(typesWithZeroValue, field.Type.Value) { + errs.AppendError(errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeArgumentError, + errorhandling.ErrorDetails{ + Message: "default requires an expression", + Hint: "Try @default(MyDefaultValue) instead", + }, + a, + )) + } + }, + LeaveAttribute: func(*parser.AttributeNode) { + attribute = nil + }, + EnterExpression: func(expression *parser.Expression) { + if attribute.Name.Value != parser.AttributeDefault { + return + } + + issues, err := attributes.ValidateDefaultExpression(asts, field, expression) + if err != nil { + errs.AppendError(errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeExpressionError, + errorhandling.ErrorDetails{ + Message: "expression could not be parsed", + }, + expression)) + } + + if len(issues) > 0 { + for _, issue := range issues { + errs.AppendError(issue) + } + } + }, + } +} diff --git a/schema/validation/embed_attribute.go b/schema/validation/embed_attribute.go index ead0ee25f..026ca1ba4 100644 --- a/schema/validation/embed_attribute.go +++ b/schema/validation/embed_attribute.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/samber/lo" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/query" "github.com/teamkeel/keel/schema/validation/errorhandling" @@ -84,33 +85,8 @@ func EmbedAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErrors return } - if !arg.Expression.IsValue() { - errs.AppendError(errorhandling.NewValidationErrorWithDetails( - errorhandling.AttributeArgumentError, - errorhandling.ErrorDetails{ - Message: "@embed argument is not correctly formatted", - Hint: "For example, use @embed(fieldName)", - }, - arg, - )) - return - } - - operand, err := arg.Expression.ToValue() + ident, err := resolve.AsIdent(arg.Expression) if err != nil { - errs.AppendError(errorhandling.NewValidationErrorWithDetails( - errorhandling.AttributeArgumentError, - errorhandling.ErrorDetails{ - Message: "Ab @embed argument must reference a field", - Hint: "For example, use @embed(fieldName)", - }, - arg, - )) - return - } - - // check if the arg is an identifier - if operand.Type() != parser.TypeIdent { errs.AppendError(errorhandling.NewValidationErrorWithDetails( errorhandling.AttributeArgumentError, errorhandling.ErrorDetails{ @@ -124,17 +100,17 @@ func EmbedAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErrors // now we go through the identifier fragments and ensure that they are relationships model := currentModel - for _, fragment := range operand.Ident.Fragments { + for _, fragment := range ident.Fragments { // get the field in the relationship fragments - currentField := query.ModelField(model, fragment.Fragment) + currentField := query.ModelField(model, fragment) if currentField == nil { errs.AppendError(errorhandling.NewValidationErrorWithDetails( errorhandling.AttributeArgumentError, errorhandling.ErrorDetails{ - Message: fmt.Sprintf("%s is not a field in the %s model", fragment.Fragment, model.Name.Value), + Message: fmt.Sprintf("%s is not a field in the %s model", fragment, model.Name.Value), Hint: "The @embed attribute must reference an existing model field", }, - arg, + ident, )) return @@ -149,25 +125,25 @@ func EmbedAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErrors Message: fmt.Sprintf("%s is not a model field", currentField.Name.Value), Hint: "The @embed attribute must reference a related model field", }, - arg, + ident, )) return } } - if lo.SomeBy(arguments, func(a string) bool { return a == operand.Ident.ToString() }) { + if lo.SomeBy(arguments, func(a string) bool { return a == ident.String() }) { errs.AppendError(errorhandling.NewValidationErrorWithDetails( errorhandling.AttributeArgumentError, errorhandling.ErrorDetails{ - Message: fmt.Sprintf("@embed argument '%s' already defined within this action", operand.Ident.ToString()), + Message: fmt.Sprintf("@embed argument '%s' already defined within this action", ident.String()), }, - arg.Expression, + ident, )) return } - arguments = append(arguments, operand.Ident.ToString()) + arguments = append(arguments, ident.String()) }, } } diff --git a/schema/validation/errorhandling/errors.go b/schema/validation/errorhandling/errors.go index 27b356f4b..66c4f8312 100644 --- a/schema/validation/errorhandling/errors.go +++ b/schema/validation/errorhandling/errors.go @@ -13,51 +13,27 @@ import ( // error codes const ( - ErrorUpperCamel = "E001" - ErrorActionNameLowerCamel = "E002" - ErrorFieldNamesUniqueInModel = "E003" - ErrorActionUniqueGlobally = "E004" - ErrorInvalidActionInput = "E005" - ErrorReservedFieldName = "E006" - ErrorUnsupportedFieldType = "E009" - ErrorUniqueModelsGlobally = "E010" - ErrorUnsupportedAttributeType = "E011" - ErrorFieldNameLowerCamel = "E012" - ErrorInvalidAttributeArgument = "E013" - ErrorAttributeRequiresNamedArguments = "E014" - ErrorAttributeMissingRequiredArgument = "E015" - ErrorInvalidValue = "E016" - ErrorUniqueAPIGlobally = "E017" - ErrorUniqueRoleGlobally = "E018" - ErrorUniqueEnumGlobally = "E019" - ErrorUnresolvableExpression = "E020" - ErrorForbiddenExpressionAction = "E022" - ErrorInvalidSyntax = "E025" - ErrorExpressionTypeMismatch = "E026" - ErrorForbiddenOperator = "E027" - ErrorNonBooleanValueCondition = "E028" - ErrorExpressionArrayMismatchingOperator = "E030" - ErrorExpressionMixedTypesInArrayLiteral = "E032" - ErrorCreateActionNoInputs = "E033" - ErrorCreateActionMissingInput = "E034" - ErrorNonDirectComparisonOperatorUsed = "E037" - ErrorUnusedInput = "E038" - ErrorInvalidOneToOneRelationship = "E039" - ErrorInvalidActionType = "E040" - ErrorModelNotAllowedAsInput = "E041" - ErrorClashingImplicitInput = "E043" - ErrorMissingRelationshipField = "E044" - ErrorAmbiguousRelationship = "E045" - ErrorModelNotFound = "E047" - ErrorExpressionFieldTypeMismatch = "E048" - ErrorExpressionMultipleConditions = "E049" - ErrorDefaultExpressionNeeded = "E050" - ErrorDefaultExpressionOperatorPresent = "E051" - ErrorFieldNamesMaxLength = "E052" - ErrorModelNamesMaxLength = "E053" - ErrorCreateActionAmbiguousRelationship = "E059" - ErrorExpressionTypeNotNullable = "E060" - ErrorExpressionSingleConditionNotBoolean = "E061" + ErrorUpperCamel = "E001" + ErrorActionNameLowerCamel = "E002" + ErrorFieldNamesUniqueInModel = "E003" + ErrorActionUniqueGlobally = "E004" + ErrorInvalidActionInput = "E005" + ErrorReservedFieldName = "E006" + ErrorUnsupportedFieldType = "E009" + ErrorUniqueModelsGlobally = "E010" + ErrorUnsupportedAttributeType = "E011" + ErrorInvalidAttributeArgument = "E013" + ErrorAttributeRequiresNamedArguments = "E014" + ErrorUniqueAPIGlobally = "E017" + ErrorUniqueRoleGlobally = "E018" + ErrorForbiddenExpressionAction = "E022" + ErrorInvalidSyntax = "E025" + ErrorCreateActionNoInputs = "E033" + ErrorCreateActionMissingInput = "E034" + ErrorInvalidActionType = "E040" + ErrorModelNotFound = "E047" + ErrorFieldNamesMaxLength = "E052" + ErrorModelNamesMaxLength = "E053" ) type ErrorDetails struct { @@ -160,6 +136,7 @@ const ( ActionInputError ErrorType = "ActionInputError" AttributeArgumentError ErrorType = "AttributeArgumentError" AttributeNotAllowedError ErrorType = "AttributeNotAllowedError" + AttributeExpressionError ErrorType = "AttributeExpressionError" RelationshipError ErrorType = "RelationshipError" JobDefinitionError ErrorType = "JobDefinitionError" UnsupportedFeatureError ErrorType = "UnsupportedFeatureError" diff --git a/schema/validation/errorhandling/errors.yml b/schema/validation/errorhandling/errors.yml index 79ad1bba5..7a7583d99 100644 --- a/schema/validation/errorhandling/errors.yml +++ b/schema/validation/errorhandling/errors.yml @@ -29,53 +29,23 @@ en: E011: message: "{{ .DefinedOn }} '{{ .ParentName }}' has an unrecognised attribute @{{ .Name }}" hint: "{{ if .Suggestions }}Did you mean {{ .Suggestions }}?{{ end }}" - E012: - message: "You have a field name that is not lowerCamel {{ .Name }}" - hint: "Did you mean '{{ .Suggested }}'?" E013: message: "The {{ .AttributeName }} attribute doesn't accept the argument {{ .ArgumentName }}{{ if .Location }} when used inside an {{ .Location }}{{ end }}" hint: "{{ if .ValidArgumentNames }}Did you mean one of {{ .ValidArgumentNames }}?{{ else }}Maybe remove the {{ .ArgumentName }} argument?{{ end }}" E014: message: "{{ .AttributeName }} requires all arguments to be named, for example @permission(roles: [MyRole])" hint: "Valid argument names for {{ .AttributeName }} are {{ .ValidArgumentNames }}" - E015: - message: "The @{{ .AttributeName }} attribute is missing required argument {{ .ArgumentName }}" - hint: "" - E016: - message: "Invalid value{{ if .Expected }}, expected {{ .Expected }}{{ end }}" - hint: "" E017: message: "You have a duplicate definition for 'api {{ .Name }}'" hint: "Please remove one of the definitions" E018: message: "You have a duplicate definition for 'role {{ .Name }}'" hint: "Please remove one of the definitions" - E019: - message: "You have a duplicate definition for 'enum {{ .Name }}'" - hint: "Please remove one of the definitions" - E020: - message: "'{{ .Fragment }}' not found{{ if .Parent }} on '{{ .Parent }}'{{ end }}" - hint: "{{if .Suggestion}}{{ .Suggestion }}{{end}}" E022: message: "Operator '{{ .Operator }}' not permitted on {{ .Attribute }}" hint: "{{ .Suggestion }}" E025: message: "{{ .Message }}" - E026: - message: "{{ .LHS }} is {{ .LHSType }} and {{ .RHS }} is {{ .RHSType }}" - hint: "Please make sure that you are evaluating entities of the same type" - E027: - message: "Cannot compare {{ .Type }} with operator '{{ .Operator }}'" - hint: "{{ .Suggestion }}" - E028: - message: "Cannot use '{{ .Value }}' as a single value in {{ .Attribute }}" - hint: "Only boolean literals are allowed e.g true or false" - E030: - message: "{{ .RHS }} is an array. Only 'in' or 'not in' can be used" - hint: "Change '{{ .Operator }}' to either 'in' or 'not in'." - E032: - message: "Cannot have mixed types in an array literal" - hint: "Expected {{ .Item }} to be of type {{ .Type }}" E033: message: "create actions cannot take read inputs" hint: "maybe add {{ .Input }} to the with() inputs" @@ -88,56 +58,14 @@ en: E036: message: "{{ .Ident }} is not a unique field. {{ .ActionType }} actions can only filter on unique fields" hint: "" - E037: - message: "The {{ .Operator }} operator is not allowed in a {{ .ActionType }} action, only equality operators ('==' and 'in') are allowed" - hint: "" - E038: - message: "{{ .InputName }} is not used. Labelled inputs must be used in the action, for example in a @set or @where attribute" - hint: "" - E039: - message: "{{ .ModelA }} and {{ .ModelB }} define a singular relationship with one another" - hint: "It is not clear which model owns the relationship. Define one of the fields with @unique to indicate this." E040: message: "{{ .Type }} is not a valid action type. Valid types are {{ .ValidTypes }}" - E041: - message: "{{ .Input }} refers to the model {{ .ModelName }} which can't be used as an input to a {{ .ActionType }} action" - hint: "did you mean {{ .Input }}.id?" - E043: - message: "You have overridden '{{ .ImplicitInputName }}' with an explicit input" - hint: "Try removing '{{ .ImplicitInputName }}' from the inputs list" - E044: - message: "The '{{ .ModelB }}' model does not include a field that references {{ .ModelA }}" - hint: "Try adding '{{ .Suggestion }}' to the fields definition of the {{ .ModelB }} model." - E045: - message: "The @relation attribute must be used as more than one field on the {{ .ModelA }} model references {{ .ModelB }}" - hint: "Define the @relation attribute on this field to indicate which field on {{ .ModelA }} it references" E047: message: "api '{{ .API }}' has an unrecognised model {{ .Model }}" hint: "" - E048: - message: "{{ .Exp }} is {{ .Type }} but field {{ .FieldName }} is {{ .FieldType }}" - hint: "Please make sure that you provide a value of the field type" - E049: - message: "expression should have a single value" - hint: "" - E050: - message: "default requires an expression" - hint: "Try @default(MyDefaultValue) instead" - E051: - message: "default expression doesn't support operators" - hint: "Try removing '{{ .Op }}'" E052: message: "Cannot use '{{ .Name }}' as a field name as it is too long." hint: "Rename this field to a shorter name" E053: message: "Cannot use '{{ .Name }}' as a model name as it is too long." hint: "Rename this model to a shorter name" - E059: - message: "You cannot set values for both {{.IdPath}} and {{.ConflictingPath}} in this create action - because the first one indicates that you want to refer to an existing {{.ModelName}}" - hint: "Either 1) use the .id form, and none of the {{.ModelName}} fields, or 2) omit the .id form and specify all of the fields of {{.ModelName}} that are needed to create one." - E060: - message: "{{.OperandName}} cannot be null" - hint: "You cannot evaluate a field against null unless it is defined as optional" - E061: - message: "Non-boolean single operand conditions such as '{{ .Value }}' not permitted on {{ .Attribute }}" - hint: "Please add an operator and second operand. Did you mean '{{ .Suggestion }}'?" diff --git a/schema/validation/on_attribute.go b/schema/validation/on_attribute.go index 4ceb68413..d9f066831 100644 --- a/schema/validation/on_attribute.go +++ b/schema/validation/on_attribute.go @@ -6,6 +6,7 @@ import ( "github.com/iancoleman/strcase" "github.com/samber/lo" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/schema/node" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/validation/errorhandling" @@ -39,6 +40,7 @@ func OnAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErrors) V }, attribute.Name, )) + return } }, LeaveAttribute: func(n *parser.AttributeNode) { @@ -56,7 +58,7 @@ func OnAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErrors) V errorhandling.AttributeArgumentError, errorhandling.ErrorDetails{ Message: "@on does not support or require named arguments", - Hint: "For example, @on([create, update], verifyEmailAddress)", + Hint: "For example, use @on([create, update], verifyEmailAddress)", }, arg, )) @@ -65,19 +67,14 @@ func OnAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErrors) V // Rules for the first argument (the action types array) if len(arguments) == 1 { - operand, err := arg.Expression.ToValue() + operands, err := resolve.AsIdentArray(arg.Expression) if err != nil { errs.AppendError(actionTypesNonArrayError(arg)) return } - if operand.Array == nil { - errs.AppendError(actionTypesNonArrayError(arg)) - return - } - - for _, element := range operand.Array.Values { - if element.Ident == nil || len(element.Ident.Fragments) != 1 { + for _, element := range operands { + if len(element.Fragments) != 1 { errs.AppendError(errorhandling.NewValidationErrorWithDetails( errorhandling.AttributeArgumentError, errorhandling.ErrorDetails{ @@ -89,14 +86,14 @@ func OnAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErrors) V continue } - if !lo.Contains(supportedActionTypes, element.Ident.Fragments[0].Fragment) { + if !lo.Contains(supportedActionTypes, element.Fragments[0]) { errs.AppendError(errorhandling.NewValidationErrorWithDetails( errorhandling.AttributeArgumentError, errorhandling.ErrorDetails{ Message: fmt.Sprintf("@on only supports the following action types: %s", strings.Join(supportedActionTypes, ", ")), Hint: "For example, @on([create, update], verifyEmailAddress)", }, - element.Ident.Fragments[0], + element, )) } } @@ -104,24 +101,18 @@ func OnAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErrors) V // Rules for the second argument (the subscriber name) if len(arguments) == 2 { - operand, err := arg.Expression.ToValue() + ident, err := resolve.AsIdent(arg.Expression) if err != nil { errs.AppendError(subscriberNameInvalidError(arg)) return } - if operand.Ident == nil { - errs.AppendError(subscriberNameInvalidError(arg)) - return - } - - if len(operand.Ident.Fragments) != 1 { - errs.AppendError(subscriberNameInvalidError(arg)) + if len(ident.Fragments) != 1 { + errs.AppendError(subscriberNameInvalidError(ident)) return } - name := operand.Ident.Fragments[0].Fragment - + name := ident.String() if name != strcase.ToLowerCamel(name) { errs.AppendError(errorhandling.NewValidationErrorWithDetails( errorhandling.AttributeArgumentError, @@ -129,7 +120,7 @@ func OnAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErrors) V Message: "a valid function name must be in lower camel case", Hint: fmt.Sprintf("Try use '%s'", strcase.ToLowerCamel(name)), }, - arg, + ident, )) return } @@ -153,7 +144,7 @@ func actionTypesNonArrayError(position node.ParserNode) *errorhandling.Validatio return errorhandling.NewValidationErrorWithDetails( errorhandling.AttributeArgumentError, errorhandling.ErrorDetails{ - Message: "@on action types argument must be an array", + Message: "@on argument must be an array of action types", Hint: "For example, @on([create, update], verifyEmailAddress)", }, position) diff --git a/schema/validation/orderby_attribute.go b/schema/validation/orderby_attribute.go index 75ae8349f..9445e334b 100644 --- a/schema/validation/orderby_attribute.go +++ b/schema/validation/orderby_attribute.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/samber/lo" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/query" "github.com/teamkeel/keel/schema/validation/errorhandling" @@ -140,27 +141,27 @@ func OrderByAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErro argumentLabels = append(argumentLabels, arg.Label.Value) - operand, err := arg.Expression.ToValue() + ident, err := resolve.AsIdent(arg.Expression) if err != nil { errs.AppendError(errorhandling.NewValidationErrorWithDetails( errorhandling.AttributeArgumentError, errorhandling.ErrorDetails{ - Message: "@orderBy argument is not correctly formatted", + Message: "@orderBy argument value must either be asc or desc", Hint: "For example, @orderBy(surname: asc, firstName: asc)", }, - arg, + arg.Expression, )) return } - if operand.Ident == nil || (operand.Ident.Fragments[0].Fragment != parser.OrderByAscending && operand.Ident.Fragments[0].Fragment != parser.OrderByDescending) { + if ident == nil || (ident.Fragments[0] != parser.OrderByAscending && ident.Fragments[0] != parser.OrderByDescending) { errs.AppendError(errorhandling.NewValidationErrorWithDetails( errorhandling.AttributeArgumentError, errorhandling.ErrorDetails{ Message: "@orderBy argument value must either be asc or desc", Hint: "For example, @orderBy(surname: asc, firstName: asc)", }, - arg.Expression, + ident, )) return } diff --git a/schema/validation/permission_attribute.go b/schema/validation/permission_attribute.go new file mode 100644 index 000000000..d02e74b5a --- /dev/null +++ b/schema/validation/permission_attribute.go @@ -0,0 +1,188 @@ +package validation + +import ( + "fmt" + + "github.com/samber/lo" + "github.com/teamkeel/keel/casing" + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/schema/attributes" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/validation/errorhandling" +) + +func PermissionsAttribute(asts []*parser.AST, errs *errorhandling.ValidationErrors) Visitor { + var model *parser.ModelNode + var action *parser.ActionNode + var job *parser.JobNode + var attribute *parser.AttributeNode + var arg *parser.AttributeArgumentNode + + return Visitor{ + EnterModel: func(m *parser.ModelNode) { + model = m + }, + LeaveModel: func(_ *parser.ModelNode) { + model = nil + }, + EnterAction: func(a *parser.ActionNode) { + action = a + }, + LeaveAction: func(_ *parser.ActionNode) { + action = nil + }, + EnterJob: func(j *parser.JobNode) { + job = j + }, + LeaveJob: func(_ *parser.JobNode) { + job = nil + }, + EnterAttribute: func(attr *parser.AttributeNode) { + attribute = attr + + if attr.Name.Value != parser.AttributePermission { + return + } + + hasActions := false + hasExpression := false + hasRoles := false + + for _, arg := range attr.Arguments { + if arg.Label == nil || arg.Label.Value == "" { + continue + } + + switch arg.Label.Value { + case "actions": + hasActions = true + + if action != nil || job != nil { + errs.AppendError(errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeArgumentError, + errorhandling.ErrorDetails{ + Message: fmt.Sprintf( + "cannot provide 'actions' arguments when using @permission in %s", + lo.Ternary(action != nil, "an action", "a job"), + ), + }, + arg.Label, + )) + continue + } + case "expression": + hasExpression = true + + // Extra check for using row-based expression in a read/write function + // Ideally this would be done as part of the expression validation, but + // if we don't provide the model as context the error is not very helpful. + if action != nil && (action.Type.Value == "read" || action.Type.Value == "write") { + operands, err := resolve.IdentOperands(arg.Expression) + if err != nil { + return + } + + for _, op := range operands { + // An ident must have at least one fragment - we only care about the first one + fragment := op.Fragments[0] + if fragment == casing.ToLowerCamel(model.Name.Value) { + errs.AppendError(errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeArgumentError, + errorhandling.ErrorDetails{ + Message: fmt.Sprintf( + "cannot use row-based permissions in a %s action", + action.Type.Value, + ), + Hint: "implement your permissions logic in your function code using the permissions API - https://docs.keel.so/functions#permissions", + }, + op, + )) + } + } + } + case "roles": + hasRoles = true + default: + errs.AppendError(errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeArgumentError, + errorhandling.ErrorDetails{ + Message: fmt.Sprintf( + "'%s' is not a valid argument for @permission", + arg.Label.Value, + ), + Hint: "Did you mean one of 'actions', 'expression', or 'roles'?", + }, + arg.Label, + )) + } + } + + // Missing actions argument which is required + if job == nil && action == nil && !hasActions { + errs.AppendError(errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeArgumentError, + errorhandling.ErrorDetails{ + Message: "required argument 'actions' missing", + }, + attr.Name, + )) + } + + // One of expression or roles must be provided + if !hasExpression && !hasRoles { + errs.AppendError(errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeArgumentError, + errorhandling.ErrorDetails{ + Message: "@permission requires either the 'expressions' or 'roles' argument to be provided", + }, + attr.Name, + )) + } + }, + LeaveAttribute: func(*parser.AttributeNode) { + attribute = nil + }, + EnterAttributeArgument: func(a *parser.AttributeArgumentNode) { + arg = a + }, + LeaveAttributeArgument: func(*parser.AttributeArgumentNode) { + arg = nil + }, + EnterExpression: func(expression *parser.Expression) { + if attribute.Name.Value != parser.AttributePermission { + return + } + + if arg.Label == nil { + return + } + + var err error + issues := []*errorhandling.ValidationError{} + + switch arg.Label.Value { + case "expression": + issues, err = attributes.ValidatePermissionExpression(asts, model, action, job, expression) + case "roles": + issues, err = attributes.ValidatePermissionRoles(asts, expression) + case "actions": + issues, err = attributes.ValidatePermissionActions(expression) + } + + if err != nil { + errs.AppendError(errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeExpressionError, + errorhandling.ErrorDetails{ + Message: "expression could not be parsed", + }, + expression)) + } + + if len(issues) > 0 { + for _, issue := range issues { + errs.AppendError(issue) + } + } + }, + } +} diff --git a/schema/validation/permission_attribute_arguments.go b/schema/validation/permission_attribute_arguments.go deleted file mode 100644 index 84672bac1..000000000 --- a/schema/validation/permission_attribute_arguments.go +++ /dev/null @@ -1,191 +0,0 @@ -package validation - -import ( - "fmt" - "strings" - - "github.com/samber/lo" - "github.com/teamkeel/keel/casing" - "github.com/teamkeel/keel/schema/expressions" - "github.com/teamkeel/keel/schema/parser" - "github.com/teamkeel/keel/schema/query" - "github.com/teamkeel/keel/schema/validation/errorhandling" - "github.com/teamkeel/keel/schema/validation/rules/expression" -) - -func PermissionsAttributeArguments(asts []*parser.AST, errs *errorhandling.ValidationErrors) Visitor { - var model *parser.ModelNode - var action *parser.ActionNode - var job *parser.JobNode - - return Visitor{ - EnterModel: func(m *parser.ModelNode) { - model = m - }, - LeaveModel: func(_ *parser.ModelNode) { - model = nil - }, - EnterAction: func(a *parser.ActionNode) { - action = a - }, - LeaveAction: func(_ *parser.ActionNode) { - action = nil - }, - EnterJob: func(j *parser.JobNode) { - job = j - }, - LeaveJob: func(_ *parser.JobNode) { - job = nil - }, - EnterAttribute: func(attr *parser.AttributeNode) { - if attr.Name.Value != parser.AttributePermission { - return - } - - hasActions := false - hasExpression := false - hasRoles := false - - for _, arg := range attr.Arguments { - if arg.Label == nil { - // Argument validation happens elsewhere - continue - } - - switch arg.Label.Value { - case "actions": - hasActions = true - - errs.Concat(validateIdentArray(arg.Expression, []string{ - parser.ActionTypeGet, - parser.ActionTypeCreate, - parser.ActionTypeUpdate, - parser.ActionTypeList, - parser.ActionTypeDelete, - }, "valid action type")) - case "expression": - hasExpression = true - - context := expressions.ExpressionContext{ - Model: model, - Attribute: attr, - Action: action, - } - rules := []expression.Rule{ - expression.OperatorLogicalRule, - } - - expressionErrors := expression.ValidateExpression( - asts, - arg.Expression, - rules, - context, - ) - for _, err := range expressionErrors { - // TODO: remove cast when expression.ValidateExpression returns correct type - errs.AppendError(err.(*errorhandling.ValidationError)) - } - - // Extra check for using row-based expression in a read/write function - // Ideally this would be done as part of the expression validation, but - // if we don't provide the model as context the error is not very helpful. - if action != nil && (action.Type.Value == "read" || action.Type.Value == "write") { - for _, cond := range arg.Expression.Conditions() { - for _, op := range []*parser.Operand{cond.LHS, cond.RHS} { - if op == nil || op.Ident == nil { - continue - } - // An ident must have at least one fragment - we only care about the first one - fragment := op.Ident.Fragments[0] - if fragment.Fragment == casing.ToLowerCamel(model.Name.Value) { - errs.AppendError(errorhandling.NewValidationErrorWithDetails( - errorhandling.AttributeArgumentError, - errorhandling.ErrorDetails{ - Message: fmt.Sprintf( - "cannot use row-based permissions in a %s action", - action.Type.Value, - ), - Hint: "implement your permissions logic in your function code using the permissions API - https://docs.keel.so/functions#permissions", - }, - fragment, - )) - } - } - } - } - - case "roles": - hasRoles = true - - roles := []string{} - for _, role := range query.Roles(asts) { - roles = append(roles, role.Name.Value) - } - - errs.Concat(validateIdentArray(arg.Expression, roles, "role defined in your schema")) - } - } - - // Missing actions argument which is required - if job == nil && action == nil && !hasActions { - errs.AppendError(errorhandling.NewValidationErrorWithDetails( - errorhandling.AttributeArgumentError, - errorhandling.ErrorDetails{ - Message: "required argument 'actions' missing", - }, - attr.Name, - )) - } - - // One of expression or roles must be provided - if !hasExpression && !hasRoles { - errs.AppendError(errorhandling.NewValidationErrorWithDetails( - errorhandling.AttributeArgumentError, - errorhandling.ErrorDetails{ - Message: "@permission requires either the 'expressions' or 'roles' argument to be provided", - }, - attr.Name, - )) - } - }, - } -} - -func validateIdentArray(expr *parser.Expression, allowed []string, identType string) (errs errorhandling.ValidationErrors) { - value, err := expr.ToValue() - if err != nil || value.Array == nil { - example := "" - if len(allowed) > 0 { - example = allowed[0] - } - errs.AppendError(errorhandling.NewValidationErrorWithDetails( - errorhandling.AttributeArgumentError, - errorhandling.ErrorDetails{ - Message: fmt.Sprintf("value should be a list e.g. [%s]", example), - }, - expr, - )) - return - } - - for _, item := range value.Array.Values { - valid := item.Ident != nil && lo.Contains(allowed, item.ToString()) - - if !valid { - hint := "" - if len(allowed) > 0 { - hint = fmt.Sprintf("valid values are: %s", strings.Join(allowed, ", ")) - } - errs.AppendError(errorhandling.NewValidationErrorWithDetails( - errorhandling.AttributeArgumentError, - errorhandling.ErrorDetails{ - Message: fmt.Sprintf("%s is not a %s", item.ToString(), identType), - Hint: hint, - }, - item, - )) - } - } - - return -} diff --git a/schema/validation/relationships.go b/schema/validation/relationships.go index 034e2df95..0acdd192d 100644 --- a/schema/validation/relationships.go +++ b/schema/validation/relationships.go @@ -137,11 +137,7 @@ func RelationshipsRules(asts []*parser.AST, errs *errorhandling.ValidationErrors // This field is not @unique and relation field on other model is not repeated if query.FieldIsUnique(currentField) && currentField.Repeated { - errs.AppendError(makeRelationshipError( - "Cannot use @unique on a repeated model field", - fmt.Sprintf("In a one to one relationship, there are no repeated fields. %s", learnMore), - currentField.Name, - )) + // This is handled elsewhere return } diff --git a/schema/validation/rules/actions/create_required.go b/schema/validation/rules/actions/create_required.go index 236c958cc..f2f47379e 100644 --- a/schema/validation/rules/actions/create_required.go +++ b/schema/validation/rules/actions/create_required.go @@ -6,6 +6,7 @@ import ( "github.com/samber/lo" "github.com/teamkeel/keel/casing" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/query" "github.com/teamkeel/keel/schema/validation/errorhandling" @@ -64,13 +65,15 @@ func checkField( // - relationship repeated fields // - fields which have a default // - built-in fields like CreatedAt, Id etc. +// - computed fields func isNotNeeded(asts []*parser.AST, model *parser.ModelNode, f *parser.FieldNode) bool { switch { case f.Optional, (f.Repeated && !f.IsScalar()), query.FieldHasAttribute(f, parser.AttributeDefault), query.IsBelongsToModelField(asts, model, f), - f.BuiltIn: + f.BuiltIn, + query.FieldIsComputed(f): return true default: return false @@ -223,28 +226,25 @@ func satisfiedBySetExpr(rootModelName string, dotDelimPath string, action *parse setExpressions := setExpressions(action) for _, expr := range setExpressions { - assignment, err := expr.ToAssignmentCondition() + l, _, err := expr.ToAssignmentExpression() if err != nil { continue } - lhs := assignment.LHS - if lhs.Ident == nil { + lhs, err := resolve.AsIdent(l) + if err != nil { continue } - fragStrings := lo.Map(lhs.Ident.Fragments, func(frag *parser.IdentFragment, _ int) string { - return frag.Fragment - }) - if len(fragStrings) < 2 { + if len(lhs.Fragments) < 2 { continue } - if fragStrings[0] != rootModelName { + if lhs.Fragments[0] != rootModelName { continue } - remainingFragments := fragStrings[1:] + remainingFragments := lhs.Fragments[1:] remainingPath := strings.Join(remainingFragments, ".") if remainingPath == dotDelimPath { return true diff --git a/schema/validation/rules/attribute/attribute.go b/schema/validation/rules/attribute/attribute.go index c7b32df71..cb4e7d49f 100644 --- a/schema/validation/rules/attribute/attribute.go +++ b/schema/validation/rules/attribute/attribute.go @@ -5,11 +5,9 @@ import ( "github.com/samber/lo" "github.com/teamkeel/keel/formatting" - "github.com/teamkeel/keel/schema/expressions" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/query" "github.com/teamkeel/keel/schema/validation/errorhandling" - "github.com/teamkeel/keel/schema/validation/rules/expression" ) // attributeLocationsRule checks that attributes are used in valid places @@ -65,6 +63,7 @@ var attributeLocations = map[string][]string{ parser.AttributeDefault, parser.AttributePrimaryKey, parser.AttributeRelation, + parser.AttributeComputed, }, parser.KeywordActions: { parser.AttributeSet, @@ -112,204 +111,3 @@ func checkAttributes(attributes []*parser.AttributeNode, definedOn string, paren return } - -func ValidateFieldAttributeRule(asts []*parser.AST) (errs errorhandling.ValidationErrors) { - for _, model := range query.Models(asts) { - for _, field := range query.ModelFields(model) { - for _, attr := range field.Attributes { - if attr.Name.Value != parser.AttributeDefault { - continue - } - - errs.Concat( - validateModelFieldDefaultAttribute(asts, model, field, attr), - ) - } - } - } - - return -} - -func SetWhereAttributeRule(asts []*parser.AST) (errs errorhandling.ValidationErrors) { - for _, model := range query.Models(asts) { - for _, action := range query.ModelActions(model) { - for _, attr := range action.Attributes { - if attr.Name.Value != parser.AttributeSet && attr.Name.Value != parser.AttributeWhere { - continue - } - - errs.Concat( - validateActionAttributeWithExpression(asts, model, action, attr), - ) - } - } - } - - return -} -func validateModelFieldDefaultAttribute( - asts []*parser.AST, - model *parser.ModelNode, - field *parser.FieldNode, - attr *parser.AttributeNode, -) (errs errorhandling.ValidationErrors) { - expressionContext := expressions.ExpressionContext{ - Model: model, - Attribute: attr, - Field: field, - } - - argLength := len(attr.Arguments) - - if argLength == 0 { - err := expression.DefaultCanUseZeroValueRule(asts, attr, expressionContext) - for _, e := range err { - errs.AppendError(e) - } - return - } - - expr := attr.Arguments[0].Expression - - rules := []expression.Rule{expression.ValueTypechecksRule} - - err := expression.ValidateExpression( - asts, - expr, - rules, - expressionContext, - ) - for _, e := range err { - // TODO: remove case when expression.ValidateExpression returns correct type - errs.AppendError(e.(*errorhandling.ValidationError)) - } - - return -} - -// validateActionAttributeWithExpression validates attributes that have the -// signature @attributeName(expression) and exist inside an action. This applies -// to @set, @where, and @validate attributes -func validateActionAttributeWithExpression( - asts []*parser.AST, - model *parser.ModelNode, - action *parser.ActionNode, - attr *parser.AttributeNode, -) (errs errorhandling.ValidationErrors) { - expr := attr.Arguments[0].Expression - rules := []expression.Rule{} - - if attr.Name.Value == parser.AttributeSet { - rules = append(rules, expression.OperatorAssignmentRule) - } else { - rules = append(rules, expression.OperatorLogicalRule) - } - - err := expression.ValidateExpression( - asts, - expr, - rules, - expressions.ExpressionContext{ - Model: model, - Attribute: attr, - Action: action, - }, - ) - for _, e := range err { - // TODO: remove case when expression.ValidateExpression returns correct type - errs.AppendError(e.(*errorhandling.ValidationError)) - } - - return -} - -func validateIdentArray(expr *parser.Expression, allowedIdents []string) (errs errorhandling.ValidationErrors) { - value, err := expr.ToValue() - if err != nil || value.Array == nil { - expected := "" - if len(allowedIdents) > 0 { - expected = "an array containing any of the following identifiers - " + formatting.HumanizeList(allowedIdents, formatting.DelimiterOr) - } - // Check expression is an array - errs.Append(errorhandling.ErrorInvalidValue, - map[string]string{ - "Expected": expected, - }, - expr, - ) - return - } - - for _, item := range value.Array.Values { - // Each item should be a singular ident e.g. "foo" and not "foo.baz.bop" - // String literal idents e.g ["thisisinvalid"] are assumed not to be invalid - valid := false - - if item.Ident != nil { - valid = len(item.Ident.Fragments) == 1 - } - - if valid { - // If it is a single ident check it's an allowed value - name := item.Ident.Fragments[0].Fragment - valid = lo.Contains(allowedIdents, name) - } - - if !valid { - expected := "" - if len(allowedIdents) > 0 { - expected = "any of the following identifiers - " + formatting.HumanizeList(allowedIdents, formatting.DelimiterOr) - } - errs.Append(errorhandling.ErrorInvalidValue, - - map[string]string{ - "Expected": expected, - }, - - item, - ) - } - } - - return -} - -func UniqueAttributeArgsRule(asts []*parser.AST) (errs errorhandling.ValidationErrors) { - for _, model := range query.Models(asts) { - // we dont want to validate built in models - if model.BuiltIn { - continue - } - - // model level e.g. @unique([fieldOne, fieldTwo]) - for _, attr := range query.ModelAttributes(model) { - if attr.Name.Value != parser.AttributeUnique { - continue - } - - if len(attr.Arguments) != 1 { - // Attribute required arguments are validated elsewhere - continue - } - - e := validateIdentArray(attr.Arguments[0].Expression, query.ModelFieldNames(model)) - errs.Concat(e) - if len(e.Errors) > 0 { - continue - } - - value, _ := attr.Arguments[0].Expression.ToValue() - if len(value.Array.Values) < 2 { - errs.Append(errorhandling.ErrorInvalidValue, - map[string]string{ - "Expected": "at least two field names to be provided", - }, - attr.Arguments[0].Expression, - ) - } - } - } - - return -} diff --git a/schema/validation/rules/expression/expression.go b/schema/validation/rules/expression/expression.go deleted file mode 100644 index 7e6d5dc42..000000000 --- a/schema/validation/rules/expression/expression.go +++ /dev/null @@ -1,567 +0,0 @@ -package expression - -import ( - "fmt" - - "github.com/samber/lo" - "github.com/teamkeel/keel/schema/expressions" - "github.com/teamkeel/keel/schema/parser" - "github.com/teamkeel/keel/schema/validation/errorhandling" - "golang.org/x/exp/slices" -) - -type Rule func(asts []*parser.AST, expression *parser.Expression, context expressions.ExpressionContext) []error - -func ValidateExpression(asts []*parser.AST, expression *parser.Expression, rules []Rule, context expressions.ExpressionContext) (errors []error) { - for _, rule := range rules { - errs := rule(asts, expression, context) - errors = append(errors, errs...) - } - - return errors -} - -// Validates that the field type has a zero value (no expression necessary). -// Zero values are the following: -// * Text -> "" -// * Number => 0 -// * Boolean -> false -// * ID -> a ksuid -// * Timestamp -> now -func DefaultCanUseZeroValueRule(asts []*parser.AST, attr *parser.AttributeNode, context expressions.ExpressionContext) (errors []*errorhandling.ValidationError) { - typesWithZeroValue := []string{"Text", "Number", "Boolean", "ID", "Timestamp"} - - if !lo.Contains(typesWithZeroValue, context.Field.Type.Value) { - errors = append(errors, - errorhandling.NewValidationError( - errorhandling.ErrorDefaultExpressionNeeded, - errorhandling.TemplateLiterals{}, - attr, - ), - ) - return errors - } - - return errors -} - -// Validates that the expression has a single value and it matches the expected type -func ValueTypechecksRule(asts []*parser.AST, expression *parser.Expression, context expressions.ExpressionContext) (errors []error) { - conditions := expression.Conditions() - if len(conditions) != 1 { - errors = append(errors, - errorhandling.NewValidationError( - errorhandling.ErrorExpressionMultipleConditions, - errorhandling.TemplateLiterals{}, - expression, - ), - ) - return errors - } - condition := conditions[0] - - if condition.RHS != nil { - errors = append(errors, - errorhandling.NewValidationError( - errorhandling.ErrorDefaultExpressionOperatorPresent, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "Op": condition.Operator.Symbol, - }, - }, - condition, - ), - ) - return errors - } - operand := condition.LHS - - resolver := expressions.NewOperandResolver( - operand, - asts, - &context, - expressions.OperandPositionLhs, - ) - expressionScopeEntity, err := resolver.Resolve() - if err != nil { - errors = append(errors, err.ToValidationError()) - return errors - } - - expectedType := context.Field.Type.Value - resolvedType := expressionScopeEntity.GetType() - - if context.Field.Repeated { - expectedType = context.Field.Type.Value + "[]" - - if resolvedType != parser.TypeArray { - errors = append(errors, - errorhandling.NewValidationError( - errorhandling.ErrorExpressionFieldTypeMismatch, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "Exp": operand.ToString(), - "Type": resolvedType, - "FieldName": context.Field.Name.Value, - "FieldType": expectedType, - }, - }, - expression, - ), - ) - return errors - } - } - - if resolvedType == parser.TypeArray { - isEmptyArray := len(expressionScopeEntity.Array) == 0 - if isEmptyArray { - return errors - } - - resolvedType = expressionScopeEntity.Array[0].GetType() + "[]" - - if !context.Field.Repeated { - errors = append(errors, - errorhandling.NewValidationError( - errorhandling.ErrorExpressionFieldTypeMismatch, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "Exp": operand.ToString(), - "Type": resolvedType, - "FieldName": context.Field.Name.Value, - "FieldType": expectedType, - }, - }, - expression, - ), - ) - return errors - } - } - - if expectedType != resolvedType { - errors = append(errors, - errorhandling.NewValidationError( - errorhandling.ErrorExpressionFieldTypeMismatch, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "Exp": operand.ToString(), - "Type": resolvedType, - "FieldName": context.Field.Name.Value, - "FieldType": expectedType, - }, - }, - expression, - ), - ) - return errors - } - - return errors -} - -// Validates that all operands resolve correctly -// This handles operands of all types including operands such as model.associationA.associationB -// as well as simple value types such as string, number, bool etc -func OperandResolutionRule(asts []*parser.AST, condition *parser.Condition, context expressions.ExpressionContext) (errors []error) { - resolver := expressions.NewConditionResolver(condition, asts, &context) - _, _, errs := resolver.Resolve() - errors = append(errors, errs...) - - return errors -} - -// Validates that all conditions in an expression use assignment -func OperatorAssignmentRule(asts []*parser.AST, expression *parser.Expression, context expressions.ExpressionContext) (errors []error) { - conditions := expression.Conditions() - - for _, condition := range conditions { - // If there is no operator, then it means there is no rhs - if condition.Operator == nil { - continue - } - - if condition.Type() != parser.AssignmentCondition { - correction := errorhandling.NewCorrectionHint([]string{"="}, condition.Operator.Symbol) - - errors = append(errors, - errorhandling.NewValidationError( - errorhandling.ErrorForbiddenExpressionAction, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "Operator": condition.Operator.Symbol, - "Suggestion": correction.ToString(), - "Attribute": fmt.Sprintf("@%s", context.Attribute.Name.Value), - }, - }, - condition.Operator, - ), - ) - - continue - } - - errors = append(errors, runSideEffectOperandRules(asts, condition, context, parser.AssignmentOperators)...) - } - - return errors -} - -// Validates that all conditions in an expression use logical operators -func OperatorLogicalRule(asts []*parser.AST, expression *parser.Expression, context expressions.ExpressionContext) (errors []error) { - conditions := expression.Conditions() - - for _, condition := range conditions { - // If there is no operator, then it means there is no rhs - if condition.Type() != parser.LogicalCondition && condition.Operator != nil { - correction := errorhandling.NewCorrectionHint([]string{"=="}, condition.Operator.Symbol) - errors = append(errors, - errorhandling.NewValidationError( - errorhandling.ErrorForbiddenExpressionAction, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "Operator": condition.Operator.Symbol, - "Attribute": fmt.Sprintf("@%s", context.Attribute.Name.Value), - "Suggestion": correction.ToString(), - }, - }, - condition.Operator, - ), - ) - continue - } - - errors = append(errors, runSideEffectOperandRules(asts, condition, context, parser.LogicalOperators)...) - } - - return errors -} - -func InvalidOperatorForOperandsRule(asts []*parser.AST, condition *parser.Condition, context expressions.ExpressionContext, permittedOperators []string) (errors []error) { - resolver := expressions.NewConditionResolver(condition, asts, &context) - resolvedLHS, resolvedRHS, _ := resolver.Resolve() - - // If there is no operator, then we are not interested in validating this rule - if condition.Operator == nil { - return nil - } - - allowedOperatorsLHS := resolvedLHS.AllowedOperators(asts) - allowedOperatorsRHS := resolvedRHS.AllowedOperators(asts) - - if len(allowedOperatorsLHS) == 0 || len(allowedOperatorsRHS) == 0 { - return nil - } - - if slices.Equal(allowedOperatorsLHS, allowedOperatorsRHS) { - if !lo.Contains(allowedOperatorsLHS, condition.Operator.Symbol) { - collection := lo.Intersect(permittedOperators, allowedOperatorsLHS) - corrections := errorhandling.NewCorrectionHint(collection, condition.Operator.Symbol) - - errors = append(errors, errorhandling.NewValidationError( - errorhandling.ErrorForbiddenOperator, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "Type": resolvedLHS.GetType(), - "Operator": condition.Operator.Symbol, - "Suggestion": corrections.ToString(), - }, - }, - condition.Operator, - )) - } - - if condition.Operator.Symbol == parser.OperatorNotIn || condition.Operator.Symbol == parser.OperatorIn { - if resolvedLHS.IsRepeated() { - errors = append(errors, errorhandling.NewValidationErrorWithDetails( - errorhandling.ErrorForbiddenOperator, - errorhandling.ErrorDetails{ - Message: "left hand side operand cannot be an array for 'in' and 'not in'", - }, - condition.LHS, - ), - ) - } - } - - if condition.Operator.Symbol == parser.OperatorAssignment { - if resolvedRHS.IsRepeated() && resolvedRHS.Field != nil && !resolvedRHS.Field.Repeated { - errors = append(errors, - errorhandling.NewValidationErrorWithDetails( - errorhandling.ErrorExpressionTypeMismatch, - errorhandling.ErrorDetails{ - Message: "cannot assign from a to-many relationship lookup", - }, - condition.RHS, - ), - ) - return errors - } - } - } else if resolvedLHS.IsRepeated() || resolvedRHS.IsRepeated() { - if !resolvedLHS.IsRepeated() && resolvedRHS.IsRepeated() && !(condition.Operator.Symbol == parser.OperatorNotIn || condition.Operator.Symbol == parser.OperatorIn) { - errors = append(errors, errorhandling.NewValidationError( - errorhandling.ErrorExpressionArrayMismatchingOperator, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "RHS": condition.RHS.ToString(), - "Operator": condition.Operator.Symbol, - }, - }, - condition.Operator, - )) - } else if resolvedLHS.IsRepeated() && !resolvedRHS.IsRepeated() { - // Only enforce this rule if the actual field is an array and not during nested to-many sets - if resolvedLHS.Field != nil && resolvedLHS.Field.Repeated && !resolvedRHS.IsNull() { - errors = append(errors, - errorhandling.NewValidationErrorWithDetails( - errorhandling.ErrorExpressionTypeMismatch, - errorhandling.ErrorDetails{ - Message: fmt.Sprintf("%s is %s[] but %s is %s", condition.LHS.ToString(), resolvedLHS.GetType(), condition.RHS.ToString(), resolvedRHS.GetType()), - }, - condition, - ), - ) - return errors - } - } else if resolvedLHS.IsRepeated() && resolvedRHS.IsRepeated() && (condition.Operator.Symbol == parser.OperatorNotIn || condition.Operator.Symbol == parser.OperatorIn) { - collection := lo.Intersect(permittedOperators, allowedOperatorsLHS) - corrections := errorhandling.NewCorrectionHint(collection, condition.Operator.Symbol) - - errors = append(errors, errorhandling.NewValidationError( - errorhandling.ErrorForbiddenOperator, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "Type": resolvedLHS.GetType(), - "Operator": condition.Operator.Symbol, - "Suggestion": corrections.ToString(), - }, - }, - condition.Operator, - )) - } - } else if !lo.Contains(allowedOperatorsLHS, condition.Operator.Symbol) && !lo.Contains(allowedOperatorsRHS, condition.Operator.Symbol) { - collection := lo.Intersect(permittedOperators, allowedOperatorsLHS) - corrections := errorhandling.NewCorrectionHint(collection, condition.Operator.Symbol) - - errors = append(errors, errorhandling.NewValidationError( - errorhandling.ErrorForbiddenOperator, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "Type": resolvedLHS.GetType(), - "Operator": condition.Operator.Symbol, - "Suggestion": corrections.ToString(), - }, - }, - condition.Operator, - )) - } - - return errors -} - -// OperandTypesMatchRule checks that the left-hand side and right-hand side are -// compatible. -// - LHS and RHS are the same type -// - LHS and RHS are of _compatible_ types -// - LHS is of type T and RHS is an array of type T -// - LHS or RHS is an optional field and the other side is an explicit null -func OperandTypesMatchRule(asts []*parser.AST, condition *parser.Condition, context expressions.ExpressionContext) (errors []error) { - resolver := expressions.NewConditionResolver(condition, asts, &context) - resolvedLHS, resolvedRHS, _ := resolver.Resolve() - - // If either side fails to resolve then no point checking compatibility - if resolvedLHS == nil || resolvedRHS == nil { - return nil - } - - // Case: LHS and RHS are the same type - if resolvedLHS.GetType() == resolvedRHS.GetType() { - if condition.Operator != nil && condition.Operator.Symbol == parser.OperatorAssignment { - if resolvedLHS.IsRepeated() == resolvedRHS.IsRepeated() { - return nil - } - if resolvedLHS.IsRepeated() && !resolvedRHS.IsRepeated() { - return nil - } - } else if resolvedLHS.IsRepeated() == resolvedRHS.IsRepeated() { - return nil - } - } - - // Case: LHS and RHS are of _compatible_ types - // Possibly this only applies to Date and Timestamp - comparable := [][]string{ - {parser.FieldTypeDate, parser.FieldTypeDatetime}, - {parser.FieldTypeMarkdown, parser.FieldTypeText}, - {parser.FieldTypeDecimal, parser.FieldTypeNumber}, - } - for _, c := range comparable { - if lo.Contains(c, resolvedLHS.GetType()) && lo.Contains(c, resolvedRHS.GetType()) { - return nil - } - } - - // Case: LHS is of type T and RHS is an array of type T - if resolvedRHS.IsRepeated() { - // First check array contains only one type - arrayType := resolvedRHS.GetType() - valid := true - for i, item := range resolvedRHS.Array { - if i == 0 { - arrayType = item.GetType() - continue - } - - if arrayType != item.GetType() { - valid = false - errors = append(errors, - errorhandling.NewValidationError( - errorhandling.ErrorExpressionMixedTypesInArrayLiteral, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "Item": item.Literal.ToString(), - "Type": arrayType, - }, - }, - item.Literal, - ), - ) - } - } - - if !valid { - return errors - } - - // Now we know the RHS is an array of type T we can check if - // the LHS is also of type T - if arrayType == resolvedLHS.GetType() { - return nil - } - } - - // Case: RHS is of type T and LHS is an array of type T - if resolvedLHS.IsRepeated() { - // First check array contains only one type - arrayType := resolvedLHS.GetType() - valid := true - for i, item := range resolvedLHS.Array { - if i == 0 { - arrayType = item.GetType() - continue - } - - if arrayType != item.GetType() { - valid = false - errors = append(errors, - errorhandling.NewValidationError( - errorhandling.ErrorExpressionMixedTypesInArrayLiteral, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "Item": item.Literal.ToString(), - "Type": arrayType, - }, - }, - item.Literal, - ), - ) - } - } - - if !valid { - return errors - } - - // Now we know the LHS is an array of type T we can check if - // the RHS is also of type T - if arrayType == resolvedRHS.GetType() { - return nil - } - } - - // Case: LHS or RHS is an optional field and the other side is an explicit null - if (!resolvedLHS.IsOptional() && resolvedRHS.IsNull()) || - (!resolvedRHS.IsOptional() && resolvedLHS.IsNull()) { - operandName := resolvedLHS.Name - if resolvedLHS.IsNull() { - operandName = resolvedRHS.Name - } - - errors = append(errors, - errorhandling.NewValidationError( - errorhandling.ErrorExpressionTypeNotNullable, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "OperandName": operandName, - }, - }, - condition, - ), - ) - return errors - } else if resolvedRHS.IsNull() || resolvedLHS.IsNull() { - return nil - } - - if condition.Operator != nil && condition.Operator.Symbol == parser.OperatorAssignment { - if resolvedLHS.IsRepeated() && !resolvedRHS.IsRepeated() { - return nil - } - } - - lhsType := resolvedLHS.GetType() - if resolvedLHS.IsRepeated() { - if resolvedLHS.Array != nil { - lhsType = "an array of " + resolvedLHS.Array[0].GetType() - } else { - lhsType = "an array of " + lhsType - } - } - - rhsType := resolvedRHS.GetType() - if resolvedRHS.IsRepeated() { - if resolvedRHS.Array != nil { - rhsType = "an array of " + resolvedRHS.Array[0].GetType() - } else { - rhsType = "an array of " + rhsType - } - } - - // LHS and RHS types do not match, report error - errors = append(errors, - errorhandling.NewValidationError( - errorhandling.ErrorExpressionTypeMismatch, - errorhandling.TemplateLiterals{ - Literals: map[string]string{ - "Operator": condition.Operator.Symbol, - "LHS": condition.LHS.ToString(), - "LHSType": lhsType, - "RHS": condition.RHS.ToString(), - "RHSType": rhsType, - }, - }, - condition, - ), - ) - - return errors -} -func runSideEffectOperandRules(asts []*parser.AST, condition *parser.Condition, context expressions.ExpressionContext, permittedOperators []string) (errors []error) { - errors = append(errors, OperandResolutionRule(asts, condition, context)...) - - if len(errors) > 0 { - return errors - } - - errors = append(errors, OperandTypesMatchRule(asts, condition, context)...) - - if len(errors) > 0 { - return errors - } - - errors = append(errors, InvalidOperatorForOperandsRule(asts, condition, context, permittedOperators)...) - - return errors -} diff --git a/schema/validation/schedule_attribute.go b/schema/validation/schedule_attribute.go index 26f74a397..4ed9b7dab 100644 --- a/schema/validation/schedule_attribute.go +++ b/schema/validation/schedule_attribute.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/teamkeel/keel/cron" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/validation/errorhandling" ) @@ -21,8 +22,19 @@ func ScheduleAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErr } arg := attribute.Arguments[0] - op, err := arg.Expression.ToValue() - if err != nil || op.String == nil { + if arg.Label != nil { + errs.AppendError(errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeArgumentError, + errorhandling.ErrorDetails{ + Message: "argument to @schedule cannot be labelled", + }, + arg.Label, + )) + return + } + + value, _, err := resolve.ToValue[string](attribute.Arguments[0].Expression) + if err != nil { errs.AppendError(errorhandling.NewValidationErrorWithDetails( errorhandling.AttributeArgumentError, errorhandling.ErrorDetails{ @@ -34,7 +46,7 @@ func ScheduleAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErr return } - src := strings.TrimPrefix(*op.String, `"`) + src := strings.TrimPrefix(value, `"`) src = strings.TrimSuffix(src, `"`) _, err = cron.Parse(src) @@ -53,7 +65,7 @@ func ScheduleAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErr start, end := arg.Expression.GetPositionRange() tok := cronError.Token - endOffset := (len(*op.String) - tok.End) + endOffset := (len(value) - tok.End) errs.AppendError(&errorhandling.ValidationError{ Code: string(errorhandling.AttributeArgumentError), @@ -68,9 +80,9 @@ func ScheduleAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErr }, EndPos: errorhandling.LexerPos{ Filename: end.Filename, - Offset: end.Offset - endOffset, + Offset: end.Offset - endOffset - 2, Line: end.Line, - Column: end.Column - endOffset, + Column: end.Column - endOffset - 2, }, }) } diff --git a/schema/validation/set_attribute_expression.go b/schema/validation/set_attribute.go similarity index 69% rename from schema/validation/set_attribute_expression.go rename to schema/validation/set_attribute.go index f8ad23407..9dc954c36 100644 --- a/schema/validation/set_attribute_expression.go +++ b/schema/validation/set_attribute.go @@ -6,7 +6,8 @@ import ( "github.com/iancoleman/strcase" "github.com/samber/lo" - "github.com/teamkeel/keel/schema/expressions" + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/schema/attributes" "github.com/teamkeel/keel/schema/node" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/query" @@ -23,6 +24,7 @@ var ( func SetAttributeExpressionRules(asts []*parser.AST, errs *errorhandling.ValidationErrors) Visitor { var model *parser.ModelNode var action *parser.ActionNode + var attribute *parser.AttributeNode return Visitor{ EnterModel: func(m *parser.ModelNode) { @@ -37,61 +39,49 @@ func SetAttributeExpressionRules(asts []*parser.AST, errs *errorhandling.Validat LeaveAction: func(_ *parser.ActionNode) { action = nil }, - EnterAttribute: func(attribute *parser.AttributeNode) { - if attribute == nil || attribute.Name.Value != parser.AttributeSet { - return - } - - if len(attribute.Arguments) != 1 || attribute.Arguments[0].Expression == nil { + EnterAttribute: func(a *parser.AttributeNode) { + attribute = a + }, + LeaveAttribute: func(*parser.AttributeNode) { + attribute = nil + }, + EnterExpression: func(expression *parser.Expression) { + if attribute.Name.Value != parser.AttributeSet { return } - conditions := attribute.Arguments[0].Expression.Conditions() - - if len(conditions) > 1 { - errs.AppendError(makeSetExpressionError( - "A @set attribute can only consist of a single assignment expression", - fmt.Sprintf("For example, assign a value to a field on this model with @set(%s.isActive = true)", strcase.ToLowerCamel(model.Name.Value)), - attribute.Arguments[0].Expression, + lhs, rhs, err := expression.ToAssignmentExpression() + if err != nil { + errs.AppendError(errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeExpressionError, + errorhandling.ErrorDetails{ + Message: "the @set attribute must be an assignment expression", + Hint: fmt.Sprintf("For example, assign a value to a field on this model with @set(%s.isActive = true)", strcase.ToLowerCamel(model.Name.Value)), + }, + expression, )) return } - expressionContext := expressions.ExpressionContext{ - Model: model, - Attribute: attribute, - Action: action, - } - - if conditions[0].Type() == parser.ValueCondition { + issues, err := attributes.ValidateSetExpression(asts, action, lhs, rhs) + if err != nil { errs.AppendError(makeSetExpressionError( - "The @set attribute cannot be a value condition and must express an assignment", + "The @set attribute can only be used to set model fields", fmt.Sprintf("For example, assign a value to a field on this model with @set(%s.isActive = true)", strcase.ToLowerCamel(model.Name.Value)), - conditions[0], + lhs, )) return } - if conditions[0].Type() == parser.LogicalCondition { - errs.AppendError(makeSetExpressionError( - "The @set attribute cannot be a logical condition and must express an assignment", - fmt.Sprintf("For example, assign a value to a field on this model with @set(%s.isActive = true)", strcase.ToLowerCamel(model.Name.Value)), - conditions[0], - )) + if len(issues) > 0 { + for _, issue := range issues { + errs.AppendError(issue) + } return } - // We resolve whether the actual fragments are valid idents in other validations, - // but we need to exit early here if they dont resolve. - resolver := expressions.NewConditionResolver(conditions[0], asts, &expressionContext) - _, _, err := resolver.Resolve() + ident, err := resolve.AsIdent(lhs) if err != nil { - return - } - - lhs := conditions[0].LHS - - if lhs.Ident == nil { errs.AppendError(makeSetExpressionError( "The @set attribute can only be used to set model fields", fmt.Sprintf("For example, assign a value to a field on this model with @set(%s.isActive = true)", strcase.ToLowerCamel(model.Name.Value)), @@ -101,9 +91,9 @@ func SetAttributeExpressionRules(asts []*parser.AST, errs *errorhandling.Validat } // Drop the 'id' at the end if it exists - fragments := []*parser.IdentFragment{} - for _, fragment := range lhs.Ident.Fragments { - if fragment.Fragment != "id" { + fragments := []string{} + for _, fragment := range ident.Fragments { + if fragment != "id" { fragments = append(fragments, fragment) } } @@ -116,7 +106,7 @@ func SetAttributeExpressionRules(asts []*parser.AST, errs *errorhandling.Validat // - is starts at the root model // - it is a field which is part of a model being created or updated (including nested creates) for i, fragment := range fragments { - if i == 0 && fragment.Fragment != strcase.ToLowerCamel(model.Name.Value) { + if i == 0 && fragment != strcase.ToLowerCamel(model.Name.Value) { errs.AppendError(makeSetExpressionError( "The @set attribute can only be used to set model fields", fmt.Sprintf("For example, assign a value to a field on this model with @set(%s.isActive = true)", strcase.ToLowerCamel(model.Name.Value)), @@ -127,7 +117,7 @@ func SetAttributeExpressionRules(asts []*parser.AST, errs *errorhandling.Validat if i > 0 { // get the next field in the relationship fragments - currentField = query.ModelField(currentModel, fragment.Fragment) + currentField = query.ModelField(currentModel, fragment) // currentModel will be null if this is not a model field currentModel = query.Model(asts, currentField.Type.Value) } @@ -161,7 +151,7 @@ func SetAttributeExpressionRules(asts []*parser.AST, errs *errorhandling.Validat continue } - if lhs.Ident.Fragments[i].Fragment == input.Type.Fragments[i-1].Fragment { + if fragments[i] == input.Type.Fragments[i-1].Fragment { if input.Type.Fragments[i].Fragment != "id" { withinWriteScope = true } @@ -185,11 +175,8 @@ func SetAttributeExpressionRules(asts []*parser.AST, errs *errorhandling.Validat // is being created and not associated. if i == len(fragments)-1 && currentModel != nil { // We know this is setting (associating to an existing model) at this point - setFrags := lo.Map(fragments, func(f *parser.IdentFragment, _ int) string { - return f.Fragment - }) - - setFragsString := strings.Join(setFrags[1:], ".") + setFrags := ident + setFragsString := strings.Join(setFrags.Fragments[1:], ".") for _, input := range action.With { inputFrags := lo.Map(input.Type.Fragments, func(f *parser.IdentFragment, _ int) string { @@ -204,7 +191,7 @@ func SetAttributeExpressionRules(asts []*parser.AST, errs *errorhandling.Validat errs.AppendError(makeSetExpressionError( fmt.Sprintf("Cannot associate to the %s model here as it is already provided as an action input.", currentModel.Name.Value), "", - lhs, + ident, )) return } @@ -217,7 +204,17 @@ func SetAttributeExpressionRules(asts []*parser.AST, errs *errorhandling.Validat errs.AppendError(makeSetExpressionError( fmt.Sprintf("Cannot set the field '%s' as it is a built-in field and can only be mutated internally", currentField.Name.Value), "Target another field on the model or remove the @set attribute entirely", - fragment, + ident, + )) + return + } + + _, isNull, _ := resolve.ToValue[any](rhs) + if !currentField.Optional && isNull { + errs.AppendError(makeSetExpressionError( + fmt.Sprintf("'%s' cannot be set to null", currentField.Name.Value), + "", + ident, )) return } @@ -229,7 +226,7 @@ func SetAttributeExpressionRules(asts []*parser.AST, errs *errorhandling.Validat func makeSetExpressionError(message string, hint string, node node.ParserNode) *errorhandling.ValidationError { return errorhandling.NewValidationErrorWithDetails( - errorhandling.AttributeArgumentError, + errorhandling.AttributeExpressionError, errorhandling.ErrorDetails{ Message: message, Hint: hint, diff --git a/schema/validation/sortable_attribute.go b/schema/validation/sortable_attribute.go index 241a0dedc..2759c384c 100644 --- a/schema/validation/sortable_attribute.go +++ b/schema/validation/sortable_attribute.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/samber/lo" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/query" "github.com/teamkeel/keel/schema/validation/errorhandling" @@ -100,7 +101,7 @@ func SortableAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErr return } - operand, err := arg.Expression.ToValue() + ident, err := resolve.AsIdent(arg.Expression) if err != nil { errs.AppendError(errorhandling.NewValidationErrorWithDetails( errorhandling.AttributeArgumentError, @@ -113,19 +114,19 @@ func SortableAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErr return } - if operand.Ident == nil || len(operand.Ident.Fragments) != 1 { + if len(ident.Fragments) != 1 { errs.AppendError(errorhandling.NewValidationErrorWithDetails( errorhandling.AttributeArgumentError, errorhandling.ErrorDetails{ Message: "@sortable argument is not correct formatted", Hint: "For example, use @sortable(firstName, surname)", }, - arg.Expression, + ident, )) return } - argumentValue := operand.Ident.Fragments[0].Fragment + argumentValue := ident.String() modelField := query.ModelField(currentModel, argumentValue) if modelField == nil { @@ -134,7 +135,7 @@ func SortableAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErr errorhandling.ErrorDetails{ Message: fmt.Sprintf("@sortable argument '%s' must correspond to a field on this model", argumentValue), }, - arg.Expression, + ident, )) return } @@ -145,7 +146,7 @@ func SortableAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErr errorhandling.ErrorDetails{ Message: "@sortable does not support ordering of relationships fields", }, - arg.Expression, + ident, )) return } @@ -156,7 +157,7 @@ func SortableAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErr errorhandling.ErrorDetails{ Message: fmt.Sprintf("@sortable argument name '%s' already defined", argumentValue), }, - arg.Expression, + ident, )) return } diff --git a/schema/validation/unique_attribute.go b/schema/validation/unique_attribute.go index 7810734ed..4ce985f8a 100644 --- a/schema/validation/unique_attribute.go +++ b/schema/validation/unique_attribute.go @@ -4,6 +4,8 @@ import ( "fmt" "github.com/samber/lo" + "github.com/teamkeel/keel/expressions/resolve" + "github.com/teamkeel/keel/schema/attributes" "github.com/teamkeel/keel/schema/node" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/query" @@ -19,92 +21,103 @@ import ( func UniqueAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErrors) Visitor { var currentModel *parser.ModelNode var currentField *parser.FieldNode + var attribute *parser.AttributeNode - currentModelIsBuiltIn := false + attributeArgsErr := false return Visitor{ EnterModel: func(m *parser.ModelNode) { - if m.BuiltIn { - currentModelIsBuiltIn = true - } - currentModel = m }, LeaveModel: func(m *parser.ModelNode) { currentModel = nil - currentModelIsBuiltIn = false }, EnterField: func(f *parser.FieldNode) { - if f.BuiltIn { - return - } currentField = f }, LeaveField: func(f *parser.FieldNode) { currentField = nil }, EnterAttribute: func(attr *parser.AttributeNode) { - if currentModelIsBuiltIn { - return - } + attribute = attr + attributeArgsErr = false + if attr.Name.Value != parser.AttributeUnique { return } compositeUnique := currentField == nil + if !compositeUnique && len(attr.Arguments) != 0 { + errs.AppendError( + errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeArgumentError, + errorhandling.ErrorDetails{ + Message: fmt.Sprintf("%v argument(s) provided to @unique but expected 0", len(attr.Arguments)), + }, + attr, + ), + ) + attributeArgsErr = true + } + switch { case compositeUnique: - if len(attr.Arguments) > 0 { - value, _ := attr.Arguments[0].Expression.ToValue() - - if value.Array != nil { - invalidFieldNames := lo.SomeBy(value.Array.Values, func(o *parser.Operand) bool { - return o.Ident == nil - }) - if invalidFieldNames { - return - } - - fieldNames := lo.Map(value.Array.Values, func(o *parser.Operand, _ int) string { - return o.Ident.ToString() - }) + if len(attr.Arguments) != 1 { + errs.AppendError( + errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeArgumentError, + errorhandling.ErrorDetails{ + Message: fmt.Sprintf("%v argument(s) provided to @unique but expected 1", len(attr.Arguments)), + }, + attr.Name, + ), + ) + attributeArgsErr = true + } else { + operands, err := resolve.AsIdentArray(attr.Arguments[0].Expression) + if err != nil { + return + } - // check there are no duplicate field names specified in the composite uniqueness - // constraint e.g @unique([fieldA, fieldA]) + // fieldNames := lo.Map(operands, func(o *parser.ExpressionIdent, _ int) string { + // return o.ToString() + // }) - dupes := findDuplicateConstraints(fieldNames) + // check there are no duplicate field names specified in the composite uniqueness + // constraint e.g @unique([fieldA, fieldA]) + dupes := findDuplicateConstraints(operands) - if len(dupes) > 0 { - for _, dupe := range dupes { - // find the last occurrence of the duplicate in the composite uniqueness constraint values list - // so we can highlight that node in the validation error. - _, index, found := lo.FindLastIndexOf(value.Array.Values, func(o *parser.Operand) bool { - return o.Ident.ToString() == dupe - }) + if len(dupes) > 0 { + for _, dupe := range dupes { + // find the last occurrence of the duplicate in the composite uniqueness constraint values list + // so we can highlight that node in the validation error. + _, _, found := lo.FindLastIndexOf(operands, func(o *parser.ExpressionIdent) bool { + return o.String() == dupe.String() + }) - if found { - errs.AppendError(uniqueRestrictionError(value.Array.Values[index].Node, fmt.Sprintf("Field '%s' has already been specified as a constraint", dupe))) - } + if found { + errs.AppendError(uniqueRestrictionError(dupe.Node, fmt.Sprintf("Field '%s' has already been specified as a constraint", dupe.String()))) } } + } - // check every field specified in the unique constraint against the standard - // restrictions for @unique attribute usage - for i, uniqueField := range fieldNames { - field := query.ModelField(currentModel, uniqueField) + // check every field specified in the unique constraint against the standard + // restrictions for @unique attribute usage + for _, uniqueField := range operands { + field := query.ModelField(currentModel, uniqueField.String()) - if field == nil { - // the field isnt a recognised field on the model, so abort as this is covered - // by another validation - continue - } - if permitted, reason := uniquePermitted(field); !permitted { - errs.AppendError(uniqueRestrictionError(value.Array.Values[i].Node, reason)) - } + if field == nil { + // the field isnt a recognised field on the model, so abort as this is covered + // by another validation + continue + } + if permitted, reason := uniquePermitted(field); !permitted { + errs.AppendError(uniqueRestrictionError(uniqueField.Node, reason)) } } } + default: // in this case, we know we are dealing with a @unique attribute attached // to a field @@ -113,6 +126,69 @@ func UniqueAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationError } } }, + LeaveAttribute: func(n *parser.AttributeNode) { + attribute = nil + }, + EnterExpression: func(expression *parser.Expression) { + if attribute.Name.Value != parser.AttributeUnique { + return + } + + if currentField != nil { + // There is no need to validate field-level @unique as there will be no expression present + return + } + + if attributeArgsErr { + return + } + + issues, err := attributes.ValidateCompositeUnique(currentModel, expression) + if err != nil { + errs.AppendError(errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeExpressionError, + errorhandling.ErrorDetails{ + Message: "expression could not be parsed", + }, + expression)) + } + + if len(issues) > 0 { + for _, issue := range issues { + errs.AppendError(issue) + } + return + } + + idents, err := resolve.AsIdentArray(expression) + if err != nil { + if err != nil { + errs.AppendError( + errorhandling.NewValidationErrorWithDetails( + errorhandling.ActionInputError, + errorhandling.ErrorDetails{ + Message: "@unique argument must be an array of field names", + Hint: "For example, @unique([sku, supplierCode])", + }, + expression, + ), + ) + return + } + } + + if len(idents) < 2 || err != nil { + errs.AppendError( + errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeArgumentError, + errorhandling.ErrorDetails{ + Message: "at least two field names to be provided", + }, + expression, + ), + ) + } + }, } } @@ -126,17 +202,17 @@ func uniqueRestrictionError(node node.Node, reason string) *errorhandling.Valida ) } -func findDuplicateConstraints(constraints []string) (dupes []string) { +func findDuplicateConstraints(constraints []*parser.ExpressionIdent) (dupes []*parser.ExpressionIdent) { seen := map[string]bool{} for _, constraint := range constraints { - if _, found := seen[constraint]; found { + if _, found := seen[constraint.String()]; found { dupes = append(dupes, constraint) continue } - seen[constraint] = true + seen[constraint.String()] = true } return dupes @@ -148,8 +224,8 @@ func uniquePermitted(f *parser.FieldNode) (bool, string) { return false, "@unique is not permitted on has many relationships or arrays" } - if f.Type.Value == parser.FieldTypeDatetime { - return false, "@unique is not permitted on Timestamp fields" + if f.Type.Value == parser.FieldTypeTimestamp || f.Type.Value == parser.FieldTypeDate { + return false, "@unique is not permitted on Timestamp or Date fields" } return true, "" diff --git a/schema/validation/unique_lookup.go b/schema/validation/unique_lookup.go index 03df26a7a..0c7fe0f31 100644 --- a/schema/validation/unique_lookup.go +++ b/schema/validation/unique_lookup.go @@ -5,6 +5,7 @@ import ( "github.com/samber/lo" "github.com/teamkeel/keel/casing" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/query" "github.com/teamkeel/keel/schema/validation/errorhandling" @@ -109,7 +110,12 @@ func UniqueLookup(asts []*parser.AST, errs *errorhandling.ValidationErrors) Visi } var fieldsInComposite map[*parser.ModelNode][]*parser.FieldNode - hasUniqueLookup, fieldsInComposite = fragmentsUnique(asts, model, input.Type.Fragments) + + fragments := lo.Map(input.Type.Fragments, func(ident *parser.IdentFragment, _ int) string { + return ident.Fragment + }) + + hasUniqueLookup, fieldsInComposite = fragmentsUnique(asts, model, fragments) for k, v := range fieldsInComposite { fieldsInCompositeUnique[k] = append(fieldsInCompositeUnique[k], v...) @@ -132,101 +138,73 @@ func UniqueLookup(asts []*parser.AST, errs *errorhandling.ValidationErrors) Visi } // Does not have an expression - if len(attr.Arguments) == 0 || attr.Arguments[0].Expression == nil { + if len(attr.Arguments) == 0 { return } - hasUniqueLookup = expressionHasUniqueLookup(asts, attr.Arguments[0].Expression, fieldsInCompositeUnique) + hasUniqueLookup = expressionHasUniqueLookup(asts, model, attr.Arguments[0].Expression, fieldsInCompositeUnique) }, } } -// expressionHasUniqueLookup will work through the logical expression syntax to determine if a unique lookup is possible -func expressionHasUniqueLookup(asts []*parser.AST, expression *parser.Expression, fieldsInCompositeUnique map[*parser.ModelNode][]*parser.FieldNode) bool { - hasUniqueLookup := false - for _, or := range expression.Or { - for _, and := range or.And { - if and.Expression != nil { - hasUniqueLookup = expressionHasUniqueLookup(asts, and.Expression, fieldsInCompositeUnique) - } - - if and.Condition != nil { - if and.Condition.Type() != parser.LogicalCondition { - continue - } - - operator := and.Condition.Operator.Symbol - - // Only the equal operator can guarantee unique lookups - if operator != parser.OperatorEquals { - continue - } - - operands := []*parser.Operand{and.Condition.LHS} - - // If it's an equal operator we can check both sides - if operator == parser.OperatorEquals { - operands = append(operands, and.Condition.RHS) - } - - for _, op := range operands { - if op.Null { - hasUniqueLookup = false - break - } +func expressionHasUniqueLookup(asts []*parser.AST, model *parser.ModelNode, expression *parser.Expression, fieldsInCompositeUnique map[*parser.ModelNode][]*parser.FieldNode) bool { + lookupGroups, _ := resolve.FieldLookups(model, expression) - if op.Ident == nil { - continue - } + if len(lookupGroups) == 0 { + return false + } - modelName := op.Ident.Fragments[0].Fragment - model := query.Model(asts, casing.ToCamel(modelName)) + // If any group of lookups provides a unique lookup, the whole expression is unique + for _, lookups := range lookupGroups { + fieldsInComposite := map[*parser.ModelNode][]*parser.FieldNode{} + for m, fields := range fieldsInCompositeUnique { + fieldsInComposite[m] = append(fieldsInComposite[m], fields...) + } - if model == nil { - // For example, ctx, or an explicit input - continue - } + groupHasUnique := false + for _, lookup := range lookups { + modelName := lookup.Fragments[0] + model := query.Model(asts, casing.ToCamel(modelName)) - // If there is only a single fragment in the expression, - // and we know it's the model, therefore this is a unique lookup - if len(op.Ident.Fragments) == 1 { - return true - } + hasUnique, f := fragmentsUnique(asts, model, lookup.Fragments[1:]) + for m, fields := range f { + fieldsInComposite[m] = append(fieldsInComposite[m], fields...) + } - var fieldsInComposite map[*parser.ModelNode][]*parser.FieldNode - hasUniqueLookup, fieldsInComposite = fragmentsUnique(asts, model, op.Ident.Fragments[1:]) + if hasUnique { + groupHasUnique = true + } + } - if len(expression.Or) == 1 { - for k, v := range fieldsInComposite { - fieldsInCompositeUnique[k] = append(fieldsInCompositeUnique[k], v...) - } - } + for m, fields := range fieldsInComposite { + for _, attribute := range query.ModelAttributes(m) { + uniqueFields := query.CompositeUniqueFields(m, attribute) + diff, _ := lo.Difference(uniqueFields, fields) + if len(diff) == 0 { + groupHasUnique = true } } - // Once we find a unique lookup between ANDs, - // then we know the expression is a unique lookup - if hasUniqueLookup { - break + // If there is only one group, then we know it will always be used in the action's filter + if len(lookupGroups) == 1 { + fieldsInCompositeUnique[m] = append(fieldsInCompositeUnique[m], fields...) } } - // There is no point checking further conditions in this expression - // because all ORed conditions need to be unique lookup - if !hasUniqueLookup { + if !groupHasUnique { return false } } - return hasUniqueLookup + return true } -func fragmentsUnique(asts []*parser.AST, model *parser.ModelNode, fragments []*parser.IdentFragment) (bool, map[*parser.ModelNode][]*parser.FieldNode) { +func fragmentsUnique(asts []*parser.AST, model *parser.ModelNode, fragments []string) (bool, map[*parser.ModelNode][]*parser.FieldNode) { fieldsInCompositeUnique := map[*parser.ModelNode][]*parser.FieldNode{} hasUniqueLookup := true for i, fragment := range fragments { - field := query.ModelField(model, fragment.Fragment) + field := query.ModelField(model, fragment) if field == nil { // Input field does not exist on the model return false, nil diff --git a/schema/validation/unused_inputs.go b/schema/validation/unused_inputs.go index 562f99527..7e268275c 100644 --- a/schema/validation/unused_inputs.go +++ b/schema/validation/unused_inputs.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/samber/lo" + "github.com/teamkeel/keel/expressions/resolve" "github.com/teamkeel/keel/schema/parser" "github.com/teamkeel/keel/schema/validation/errorhandling" ) @@ -58,18 +59,30 @@ func UnusedInputRule(_ []*parser.AST, errs *errorhandling.ValidationErrors) Visi return } - expr := n.Arguments[0].Expression - if expr == nil { + var expression *parser.Expression + if n.Name.Value == parser.AttributeSet { + _, rhs, err := n.Arguments[0].Expression.ToAssignmentExpression() + if err != nil { + return + } + expression = rhs + } else { + expression = n.Arguments[0].Expression + if expression == nil { + return + } + } + + operands, err := resolve.IdentOperands(expression) + if err != nil { return } - for _, cond := range expr.Conditions() { - for _, operand := range []*parser.Operand{cond.LHS, cond.RHS} { - if operand == nil || operand.Ident == nil { - continue + for _, operand := range operands { + for k, u := range unused { + if u.Value == operand.String() { + delete(unused, k) } - - delete(unused, operand.Ident.ToString()) } } }, diff --git a/schema/validation/validation.go b/schema/validation/validation.go index 9d3c2bcc2..a21251e1a 100644 --- a/schema/validation/validation.go +++ b/schema/validation/validation.go @@ -38,20 +38,12 @@ var validatorFuncs = []validationFunc{ actions.ActionModelInputsRule, actions.CreateOperationNoReadInputsRule, actions.CreateOperationRequiredFieldsRule, - field.ValidFieldTypesRule, field.UniqueFieldNamesRule, field.FieldNamesMaxLengthRule, - model.ModelNamesMaxLengthRule, - attribute.AttributeLocationsRule, - attribute.SetWhereAttributeRule, - attribute.ValidateFieldAttributeRule, - attribute.UniqueAttributeArgsRule, - role.UniqueRoleNamesRule, - api.UniqueAPINamesRule, api.NamesCorrespondToModels, } @@ -73,15 +65,18 @@ var visitorFuncs = []VisitorFunc{ UniqueLookup, InvalidWithUsage, AttributeArgumentsRules, + DefaultAttributeExpressionRules, UniqueAttributeRule, + WhereAttributeRule, OrderByAttributeRule, SortableAttributeRule, SetAttributeExpressionRules, + ComputedAttributeRules, Jobs, MessagesRule, ScheduleAttributeRule, DuplicateInputsRule, - PermissionsAttributeArguments, + PermissionsAttribute, FunctionDisallowedBehavioursRule, OnAttributeRule, EmbedAttributeRule, diff --git a/schema/validation/visitor.go b/schema/validation/visitor.go index 6eb3ccff1..7083e8001 100644 --- a/schema/validation/visitor.go +++ b/schema/validation/visitor.go @@ -67,6 +67,9 @@ type Visitor struct { EnterJobInput func(n *parser.JobInputNode) LeaveJobInput func(n *parser.JobInputNode) + + EnterExpression func(e *parser.Expression) + LeaveExpression func(e *parser.Expression) } type VisitorFunc func([]*parser.AST, *errorhandling.ValidationErrors) Visitor diff --git a/schema/validation/where_attribute.go b/schema/validation/where_attribute.go new file mode 100644 index 000000000..729433acb --- /dev/null +++ b/schema/validation/where_attribute.go @@ -0,0 +1,66 @@ +package validation + +import ( + "fmt" + + "github.com/teamkeel/keel/schema/attributes" + "github.com/teamkeel/keel/schema/parser" + "github.com/teamkeel/keel/schema/validation/errorhandling" +) + +func WhereAttributeRule(asts []*parser.AST, errs *errorhandling.ValidationErrors) Visitor { + var action *parser.ActionNode + var attribute *parser.AttributeNode + + return Visitor{ + EnterAction: func(a *parser.ActionNode) { + action = a + }, + LeaveAction: func(*parser.ActionNode) { + action = nil + }, + EnterAttribute: func(attr *parser.AttributeNode) { + if attr.Name.Value != parser.AttributeWhere { + return + } + + attribute = attr + + if len(attr.Arguments) != 1 { + errs.AppendError( + errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeArgumentError, + errorhandling.ErrorDetails{ + Message: fmt.Sprintf("%v argument(s) provided to @unique but expected 1", len(attr.Arguments)), + }, + attr, + ), + ) + } + }, + LeaveAttribute: func(*parser.AttributeNode) { + attribute = nil + }, + EnterExpression: func(expression *parser.Expression) { + if attribute == nil { + return + } + + issues, err := attributes.ValidateWhereExpression(asts, action, expression) + if err != nil { + errs.AppendError(errorhandling.NewValidationErrorWithDetails( + errorhandling.AttributeExpressionError, + errorhandling.ErrorDetails{ + Message: "expression could not be parsed", + }, + expression)) + } + + if len(issues) > 0 { + for _, issue := range issues { + errs.AppendError(issue) + } + } + }, + } +}