mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
semantic validator
This commit is contained in:
parent
7d48d538c3
commit
7957655487
14 changed files with 1653 additions and 115 deletions
|
|
@ -209,6 +209,7 @@ Ruki has these built-ins:
|
|||
| `now()` | `timestamp` | 0 | no additional validation |
|
||||
| `next_date(...)` | `date` | exactly 1 | argument must be `recurrence` |
|
||||
| `blocks(...)` | `list<ref>` | exactly 1 | argument must be `id`, `ref`, or string literal |
|
||||
| `id()` | `id` | 0 | valid only in plugin runtime; resolves to selected tiki ID |
|
||||
| `call(...)` | `string` | exactly 1 | argument must be `string` |
|
||||
| `user()` | `string` | 0 | no additional validation |
|
||||
|
||||
|
|
@ -219,10 +220,18 @@ select where count(select where status = "done") >= 1
|
|||
select where updatedAt < now()
|
||||
create title="x" due=next_date(recurrence)
|
||||
select where blocks(id) is empty
|
||||
select where id() in dependsOn
|
||||
create title=call("echo hi")
|
||||
select where assignee = user()
|
||||
```
|
||||
|
||||
Runtime notes:
|
||||
|
||||
- `id()` is semantically valid only in plugin runtime.
|
||||
- When a validated statement uses `id()`, plugin execution must provide a non-empty selected task ID.
|
||||
- `id()` is rejected for CLI, event-trigger, and time-trigger semantic runtimes.
|
||||
- `call(...)` is currently rejected by semantic validation.
|
||||
|
||||
`run(...)`
|
||||
|
||||
- not a normal expression built-in
|
||||
|
|
|
|||
|
|
@ -29,17 +29,18 @@ func RunQuery(gate *service.TaskMutationGate, query string, out io.Writer) error
|
|||
if err != nil {
|
||||
return fmt.Errorf("resolve current user: %w", err)
|
||||
}
|
||||
executor := ruki.NewExecutor(schema, func() string { return userName })
|
||||
executor := ruki.NewExecutor(schema, func() string { return userName }, ruki.ExecutorRuntime{Mode: ruki.ExecutorRuntimeCLI})
|
||||
|
||||
stmt, err := parser.ParseStatement(query)
|
||||
stmt, err := parser.ParseAndValidateStatement(query, ruki.ExecutorRuntimeCLI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
|
||||
// for CREATE, fetch template before execution so field references
|
||||
// (e.g. tags=tags+["new"]) resolve from template defaults
|
||||
var template *task.Task
|
||||
if stmt.Create != nil {
|
||||
var input ruki.ExecutionInput
|
||||
if stmt.RequiresCreateTemplate() {
|
||||
var template *task.Task
|
||||
template, err = readStore.NewTaskTemplate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create template: %w", err)
|
||||
|
|
@ -47,11 +48,11 @@ func RunQuery(gate *service.TaskMutationGate, query string, out io.Writer) error
|
|||
if template == nil {
|
||||
return fmt.Errorf("create template: store returned nil template")
|
||||
}
|
||||
executor.SetTemplate(template)
|
||||
input.CreateTemplate = template
|
||||
}
|
||||
|
||||
tasks := readStore.GetAllTasks()
|
||||
result, err := executor.Execute(stmt, tasks)
|
||||
result, err := executor.Execute(stmt, tasks, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute: %w", err)
|
||||
}
|
||||
|
|
@ -67,7 +68,7 @@ func RunQuery(gate *service.TaskMutationGate, query string, out io.Writer) error
|
|||
return persistAndSummarize(ctx, gate, result.Update, out)
|
||||
|
||||
case result.Create != nil:
|
||||
return persistCreate(ctx, gate, result.Create, template, out)
|
||||
return persistCreate(ctx, gate, result.Create, out)
|
||||
|
||||
case result.Delete != nil:
|
||||
return persistDelete(ctx, gate, result.Delete, out)
|
||||
|
|
@ -96,15 +97,19 @@ func RunSelectQuery(readStore store.ReadStore, query string, out io.Writer) erro
|
|||
if stmt.Select == nil {
|
||||
return fmt.Errorf("RunSelectQuery only supports SELECT statements")
|
||||
}
|
||||
validated, err := ruki.NewSemanticValidator(ruki.ExecutorRuntimeCLI).ValidateStatement(stmt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("semantic validate: %w", err)
|
||||
}
|
||||
|
||||
userName, err := resolveUser(readStore)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve current user: %w", err)
|
||||
}
|
||||
executor := ruki.NewExecutor(schema, func() string { return userName })
|
||||
executor := ruki.NewExecutor(schema, func() string { return userName }, ruki.ExecutorRuntime{Mode: ruki.ExecutorRuntimeCLI})
|
||||
|
||||
tasks := readStore.GetAllTasks()
|
||||
result, err := executor.Execute(stmt, tasks)
|
||||
result, err := executor.Execute(validated, tasks, ruki.ExecutionInput{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute: %w", err)
|
||||
}
|
||||
|
|
@ -137,11 +142,8 @@ func persistAndSummarize(ctx context.Context, gate *service.TaskMutationGate, ur
|
|||
return nil
|
||||
}
|
||||
|
||||
func persistCreate(ctx context.Context, gate *service.TaskMutationGate, cr *ruki.CreateResult, template *task.Task, out io.Writer) error {
|
||||
func persistCreate(ctx context.Context, gate *service.TaskMutationGate, cr *ruki.CreateResult, out io.Writer) error {
|
||||
t := cr.Task
|
||||
t.ID = template.ID
|
||||
t.CreatedBy = template.CreatedBy
|
||||
t.CreatedAt = template.CreatedAt
|
||||
|
||||
if err := gate.CreateTask(ctx, t); err != nil {
|
||||
return fmt.Errorf("create task: %w", err)
|
||||
|
|
|
|||
|
|
@ -335,14 +335,14 @@ func TestRunQueryResolveUserErrorUpdate(t *testing.T) {
|
|||
func TestRunQueryExecuteError(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
// call() is rejected at runtime, triggering an execute error
|
||||
// call() is rejected during semantic validation in RunQuery.
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(s), `select where call("echo") = "x"`, &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected execute error")
|
||||
t.Fatal("expected semantic validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "execute") {
|
||||
t.Errorf("expected execute error, got: %v", err)
|
||||
if !strings.Contains(err.Error(), "call() is not supported yet") {
|
||||
t.Errorf("expected call() semantic validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -354,8 +354,8 @@ func TestRunQueryUpdateInvalidPointsE2E(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("expected error for invalid points")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid points") {
|
||||
t.Errorf("expected 'invalid points' error, got: %v", err)
|
||||
if !strings.Contains(err.Error(), "points value out of range") {
|
||||
t.Errorf("expected points range error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -656,14 +656,14 @@ func TestRunQueryDeleteSilentFailure(t *testing.T) {
|
|||
func TestRunSelectQueryExecuteError(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
// call() is not supported at runtime, causing execute error
|
||||
// call() is rejected during semantic validation in RunSelectQuery.
|
||||
var buf bytes.Buffer
|
||||
err := RunSelectQuery(s, `select where call("echo") = "x"`, &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected execute error")
|
||||
t.Fatal("expected semantic validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "execute") {
|
||||
t.Errorf("expected execute error, got: %v", err)
|
||||
if !strings.Contains(err.Error(), "call() is not supported yet") {
|
||||
t.Errorf("expected call() semantic validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
113
ruki/executor.go
113
ruki/executor.go
|
|
@ -12,23 +12,23 @@ import (
|
|||
|
||||
// Executor evaluates parsed ruki statements against a set of tasks.
|
||||
type Executor struct {
|
||||
schema Schema
|
||||
userFunc func() string
|
||||
template *task.Task
|
||||
schema Schema
|
||||
userFunc func() string
|
||||
runtime ExecutorRuntime
|
||||
currentInput ExecutionInput
|
||||
}
|
||||
|
||||
// SetTemplate sets the base task template for CREATE execution.
|
||||
// When set, CREATE assignments are evaluated against a clone of this template,
|
||||
// so field references (e.g. tags=tags+["new"]) resolve from template defaults.
|
||||
func (e *Executor) SetTemplate(t *task.Task) { e.template = t }
|
||||
|
||||
// NewExecutor constructs an Executor with the given schema and user function.
|
||||
// If userFunc is nil, calling user() at runtime will return "".
|
||||
func NewExecutor(schema Schema, userFunc func() string) *Executor {
|
||||
func NewExecutor(schema Schema, userFunc func() string, runtime ExecutorRuntime) *Executor {
|
||||
if userFunc == nil {
|
||||
userFunc = func() string { return "" }
|
||||
}
|
||||
return &Executor{schema: schema, userFunc: userFunc}
|
||||
return &Executor{
|
||||
schema: schema,
|
||||
userFunc: userFunc,
|
||||
runtime: runtime.normalize(),
|
||||
}
|
||||
}
|
||||
|
||||
// Result holds the output of executing a statement.
|
||||
|
|
@ -62,19 +62,67 @@ type TaskProjection struct {
|
|||
}
|
||||
|
||||
// Execute dispatches on the statement type and returns results.
|
||||
func (e *Executor) Execute(stmt *Statement, tasks []*task.Task) (*Result, error) {
|
||||
// Preferred input is *ValidatedStatement; raw *Statement is accepted as a
|
||||
// low-level path and will be semantically validated for executor runtime mode.
|
||||
func (e *Executor) Execute(stmt any, tasks []*task.Task, inputs ...ExecutionInput) (*Result, error) {
|
||||
var input ExecutionInput
|
||||
if len(inputs) > 0 {
|
||||
input = inputs[0]
|
||||
}
|
||||
|
||||
if stmt == nil {
|
||||
return nil, fmt.Errorf("nil statement")
|
||||
}
|
||||
|
||||
var validated *ValidatedStatement
|
||||
var rawStmt *Statement
|
||||
rawInput := false
|
||||
requiresCreateTemplate := false
|
||||
switch s := stmt.(type) {
|
||||
case *ValidatedStatement:
|
||||
validated = s
|
||||
requiresCreateTemplate = true
|
||||
case *Statement:
|
||||
rawInput = true
|
||||
var err error
|
||||
validated, err = NewSemanticValidator(e.runtime.Mode).ValidateStatement(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported statement type %T", stmt)
|
||||
}
|
||||
|
||||
if validated != nil {
|
||||
if err := validated.mustBeSealed(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validated.runtime != e.runtime.Mode {
|
||||
return nil, &RuntimeMismatchError{
|
||||
ValidatedFor: validated.runtime,
|
||||
Runtime: e.runtime.Mode,
|
||||
}
|
||||
}
|
||||
if validated.usesIDFunc && e.runtime.Mode == ExecutorRuntimePlugin && strings.TrimSpace(input.SelectedTaskID) == "" {
|
||||
return nil, &MissingSelectedTaskIDError{}
|
||||
}
|
||||
rawStmt = validated.statement
|
||||
if rawInput {
|
||||
requiresCreateTemplate = false
|
||||
}
|
||||
}
|
||||
e.currentInput = input
|
||||
defer func() { e.currentInput = ExecutionInput{} }()
|
||||
|
||||
switch {
|
||||
case stmt.Select != nil:
|
||||
return e.executeSelect(stmt.Select, tasks)
|
||||
case stmt.Create != nil:
|
||||
return e.executeCreate(stmt.Create, tasks)
|
||||
case stmt.Update != nil:
|
||||
return e.executeUpdate(stmt.Update, tasks)
|
||||
case stmt.Delete != nil:
|
||||
return e.executeDelete(stmt.Delete, tasks)
|
||||
case rawStmt.Select != nil:
|
||||
return e.executeSelect(rawStmt.Select, tasks)
|
||||
case rawStmt.Create != nil:
|
||||
return e.executeCreate(rawStmt.Create, tasks, requiresCreateTemplate)
|
||||
case rawStmt.Update != nil:
|
||||
return e.executeUpdate(rawStmt.Update, tasks)
|
||||
case rawStmt.Delete != nil:
|
||||
return e.executeDelete(rawStmt.Delete, tasks)
|
||||
default:
|
||||
return nil, fmt.Errorf("empty statement")
|
||||
}
|
||||
|
|
@ -124,10 +172,13 @@ func (e *Executor) executeUpdate(upd *UpdateStmt, tasks []*task.Task) (*Result,
|
|||
return &Result{Update: &UpdateResult{Updated: clones}}, nil
|
||||
}
|
||||
|
||||
func (e *Executor) executeCreate(cr *CreateStmt, tasks []*task.Task) (*Result, error) {
|
||||
func (e *Executor) executeCreate(cr *CreateStmt, tasks []*task.Task, requireTemplate bool) (*Result, error) {
|
||||
if requireTemplate && e.currentInput.CreateTemplate == nil {
|
||||
return nil, &MissingCreateTemplateError{}
|
||||
}
|
||||
var t *task.Task
|
||||
if e.template != nil {
|
||||
t = e.template.Clone()
|
||||
if e.currentInput.CreateTemplate != nil {
|
||||
t = e.currentInput.CreateTemplate.Clone()
|
||||
} else {
|
||||
t = &task.Task{}
|
||||
}
|
||||
|
|
@ -551,6 +602,8 @@ func (e *Executor) evalListLiteral(ll *ListLiteral, t *task.Task, allTasks []*ta
|
|||
|
||||
func (e *Executor) evalFunctionCall(fc *FunctionCall, t *task.Task, allTasks []*task.Task) (interface{}, error) {
|
||||
switch fc.Name {
|
||||
case "id":
|
||||
return e.evalID()
|
||||
case "now":
|
||||
return time.Now(), nil
|
||||
case "user":
|
||||
|
|
@ -568,6 +621,17 @@ func (e *Executor) evalFunctionCall(fc *FunctionCall, t *task.Task, allTasks []*
|
|||
}
|
||||
}
|
||||
|
||||
func (e *Executor) evalID() (interface{}, error) {
|
||||
if e.runtime.Mode != ExecutorRuntimePlugin {
|
||||
return nil, fmt.Errorf("id() is only available in plugin runtime")
|
||||
}
|
||||
id := strings.TrimSpace(e.currentInput.SelectedTaskID)
|
||||
if id == "" {
|
||||
return nil, &MissingSelectedTaskIDError{}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (e *Executor) evalCount(fc *FunctionCall, allTasks []*task.Task) (interface{}, error) {
|
||||
sq, ok := fc.Args[0].(*SubQuery)
|
||||
if !ok {
|
||||
|
|
@ -875,6 +939,11 @@ func (e *Executor) exprFieldType(expr Expr) ValueType {
|
|||
name = e.Name
|
||||
case *QualifiedRef:
|
||||
name = e.Name
|
||||
case *FunctionCall:
|
||||
if e.Name == "id" {
|
||||
return ValueID
|
||||
}
|
||||
return -1
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
|
|
|
|||
74
ruki/executor_runtime.go
Normal file
74
ruki/executor_runtime.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// ExecutorRuntimeMode identifies the semantic/runtime environment in which
|
||||
// a validated AST is intended to execute.
|
||||
type ExecutorRuntimeMode string
|
||||
|
||||
const (
|
||||
ExecutorRuntimeCLI ExecutorRuntimeMode = "cli"
|
||||
ExecutorRuntimePlugin ExecutorRuntimeMode = "plugin"
|
||||
ExecutorRuntimeEventTrigger ExecutorRuntimeMode = "eventTrigger"
|
||||
ExecutorRuntimeTimeTrigger ExecutorRuntimeMode = "timeTrigger"
|
||||
)
|
||||
|
||||
// ExecutorRuntime configures executor identity/runtime semantics.
|
||||
// Per-execution payload (e.g. selected task id, create template) is passed
|
||||
// via ExecutionInput and is intentionally not part of this struct.
|
||||
type ExecutorRuntime struct {
|
||||
Mode ExecutorRuntimeMode
|
||||
}
|
||||
|
||||
// normalize returns a runtime with defaults applied.
|
||||
func (r ExecutorRuntime) normalize() ExecutorRuntime {
|
||||
if r.Mode == "" {
|
||||
r.Mode = ExecutorRuntimeCLI
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ExecutionInput carries per-execution payload that is not part of executor
|
||||
// runtime identity.
|
||||
type ExecutionInput struct {
|
||||
SelectedTaskID string
|
||||
CreateTemplate *task.Task
|
||||
}
|
||||
|
||||
// RuntimeMismatchError reports execution with a wrapper validated for a
|
||||
// different runtime mode.
|
||||
type RuntimeMismatchError struct {
|
||||
ValidatedFor ExecutorRuntimeMode
|
||||
Runtime ExecutorRuntimeMode
|
||||
}
|
||||
|
||||
func (e *RuntimeMismatchError) Error() string {
|
||||
return fmt.Sprintf("validated runtime %q does not match executor runtime %q", e.ValidatedFor, e.Runtime)
|
||||
}
|
||||
|
||||
func (e *RuntimeMismatchError) Unwrap() error { return ErrRuntimeMismatch }
|
||||
|
||||
// MissingSelectedTaskIDError reports plugin execution that requires selected id
|
||||
// (due to syntactic id() usage) but did not receive it.
|
||||
type MissingSelectedTaskIDError struct{}
|
||||
|
||||
func (e *MissingSelectedTaskIDError) Error() string {
|
||||
return "selected task id is required for plugin runtime when id() is used"
|
||||
}
|
||||
|
||||
// MissingCreateTemplateError reports CREATE execution without required template.
|
||||
type MissingCreateTemplateError struct{}
|
||||
|
||||
func (e *MissingCreateTemplateError) Error() string {
|
||||
return "create template is required for create execution"
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrRuntimeMismatch is used with errors.Is for runtime mismatch failures.
|
||||
ErrRuntimeMismatch = errors.New("runtime mismatch")
|
||||
)
|
||||
|
|
@ -10,7 +10,7 @@ import (
|
|||
)
|
||||
|
||||
func newTestExecutor() *Executor {
|
||||
return NewExecutor(testSchema{}, func() string { return "alice" })
|
||||
return NewExecutor(testSchema{}, func() string { return "alice" }, ExecutorRuntime{Mode: ExecutorRuntimeCLI})
|
||||
}
|
||||
|
||||
func testDate(m time.Month, d int) time.Time {
|
||||
|
|
@ -54,7 +54,7 @@ func TestExecuteNilStatement(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestNewExecutorNilUserFunc(t *testing.T) {
|
||||
e := NewExecutor(testSchema{}, nil)
|
||||
e := NewExecutor(testSchema{}, nil, ExecutorRuntime{Mode: ExecutorRuntimeCLI})
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "x", Status: "ready", Assignee: ""},
|
||||
}
|
||||
|
|
@ -880,19 +880,18 @@ func TestExecuteCreateWithTemplate(t *testing.T) {
|
|||
e := newTestExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
// set template with tags=["idea"] and priority=7
|
||||
e.SetTemplate(&task.Task{
|
||||
template := &task.Task{
|
||||
Tags: []string{"idea"},
|
||||
Priority: 7,
|
||||
Status: "ready",
|
||||
Type: "story",
|
||||
})
|
||||
}
|
||||
|
||||
stmt, err := p.ParseStatement(`create title="x" tags=tags+["new"]`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, nil)
|
||||
result, err := e.Execute(stmt, nil, ExecutionInput{CreateTemplate: template})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
|
@ -910,7 +909,6 @@ func TestExecuteCreateWithTemplate(t *testing.T) {
|
|||
func TestExecuteCreateWithoutTemplate(t *testing.T) {
|
||||
e := newTestExecutor()
|
||||
p := newTestParser()
|
||||
// no SetTemplate call — template is nil
|
||||
|
||||
stmt, err := p.ParseStatement(`create title="x" priority=3`)
|
||||
if err != nil {
|
||||
|
|
@ -3409,7 +3407,7 @@ func TestExecuteUpdatePriorityOutOfRange(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("expected error for out-of-range priority")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "priority must be between") {
|
||||
if !strings.Contains(err.Error(), "priority value out of range") {
|
||||
t.Errorf("expected range error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
|
@ -3462,8 +3460,8 @@ func TestExecuteUpdatePointsOutOfRange(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("expected error for invalid points value")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid points") {
|
||||
t.Errorf("expected 'invalid points' error, got: %v", err)
|
||||
if !strings.Contains(err.Error(), "points value out of range") {
|
||||
t.Errorf("expected points range error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,9 @@ func NewParser(schema Schema) *Parser {
|
|||
}
|
||||
}
|
||||
|
||||
// ParseStatement parses a CRUD statement and returns a validated AST.
|
||||
// ParseStatement parses a CRUD statement and performs syntax, structural,
|
||||
// built-in signature/arity, and type validation.
|
||||
// Semantic runtime validation is a separate step.
|
||||
func (p *Parser) ParseStatement(input string) (*Statement, error) {
|
||||
g, err := p.stmtParser.ParseString("", input)
|
||||
if err != nil {
|
||||
|
|
@ -88,7 +90,9 @@ func (p *Parser) ParseStatement(input string) (*Statement, error) {
|
|||
return stmt, nil
|
||||
}
|
||||
|
||||
// ParseTrigger parses a reactive trigger rule and returns a validated AST.
|
||||
// ParseTrigger parses a reactive trigger rule and performs syntax, structural,
|
||||
// built-in signature/arity, and type validation.
|
||||
// Semantic runtime validation is a separate step.
|
||||
func (p *Parser) ParseTrigger(input string) (*Trigger, error) {
|
||||
g, err := p.triggerParser.ParseString("", input)
|
||||
if err != nil {
|
||||
|
|
@ -105,7 +109,9 @@ func (p *Parser) ParseTrigger(input string) (*Trigger, error) {
|
|||
return trig, nil
|
||||
}
|
||||
|
||||
// ParseTimeTrigger parses a periodic time trigger and returns a validated AST.
|
||||
// ParseTimeTrigger parses a periodic time trigger and performs syntax,
|
||||
// structural, built-in signature/arity, and type validation.
|
||||
// Semantic runtime validation is a separate step.
|
||||
func (p *Parser) ParseTimeTrigger(input string) (*TimeTrigger, error) {
|
||||
g, err := p.timeTriggerParser.ParseString("", input)
|
||||
if err != nil {
|
||||
|
|
@ -122,8 +128,9 @@ func (p *Parser) ParseTimeTrigger(input string) (*TimeTrigger, error) {
|
|||
}
|
||||
|
||||
// ParseRule parses a trigger definition that is either an event trigger
|
||||
// (before/after) or a time trigger (every). The grammar dispatches internally
|
||||
// so the caller does not need to inspect the input string.
|
||||
// (before/after) or a time trigger (every), and performs syntax, structural,
|
||||
// built-in signature/arity, and type validation.
|
||||
// Semantic runtime validation is a separate step after branching.
|
||||
func (p *Parser) ParseRule(input string) (*Rule, error) {
|
||||
g, err := p.ruleParser.ParseString("", input)
|
||||
if err != nil {
|
||||
|
|
|
|||
166
ruki/runtime_safety_test.go
Normal file
166
ruki/runtime_safety_test.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExecuteRawStatementRejectsCallBeforeEvaluation(t *testing.T) {
|
||||
e := newTestExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
stmt, err := p.ParseStatement(`select where 1 = 2 and call("echo hello") = "x"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
_, err = e.Execute(stmt, makeTasks())
|
||||
if err == nil {
|
||||
t.Fatal("expected semantic validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "call() is not supported yet") {
|
||||
t.Fatalf("expected call() semantic validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRawStatementRejectsIDOutsidePluginRuntime(t *testing.T) {
|
||||
e := newTestExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
stmt, err := p.ParseStatement(`select where 1 = 2 and id() = "TIKI-000001"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
_, err = e.Execute(stmt, makeTasks())
|
||||
if err == nil {
|
||||
t.Fatal("expected semantic validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "id() is only available in plugin runtime") {
|
||||
t.Fatalf("expected id() runtime error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidatedStatementRuntimeMismatch(t *testing.T) {
|
||||
e := newTestExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
validated, err := p.ParseAndValidateStatement(`select`, ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
_, err = e.Execute(validated, makeTasks())
|
||||
if err == nil {
|
||||
t.Fatal("expected runtime mismatch error")
|
||||
}
|
||||
var mismatch *RuntimeMismatchError
|
||||
if !errors.As(err, &mismatch) {
|
||||
t.Fatalf("expected RuntimeMismatchError, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteUnsealedValidatedStatementRejected(t *testing.T) {
|
||||
e := newTestExecutor()
|
||||
unsealed := &ValidatedStatement{
|
||||
statement: &Statement{Select: &SelectStmt{}},
|
||||
}
|
||||
|
||||
_, err := e.Execute(unsealed, makeTasks())
|
||||
if err == nil {
|
||||
t.Fatal("expected unvalidated wrapper error")
|
||||
}
|
||||
var unvalidated *UnvalidatedWrapperError
|
||||
if !errors.As(err, &unvalidated) {
|
||||
t.Fatalf("expected UnvalidatedWrapperError, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidatedCreateRequiresTemplate(t *testing.T) {
|
||||
e := newTestExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
validated, err := p.ParseAndValidateStatement(`create title="x"`, ExecutorRuntimeCLI)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
_, err = e.Execute(validated, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing create template error")
|
||||
}
|
||||
var missing *MissingCreateTemplateError
|
||||
if !errors.As(err, &missing) {
|
||||
t.Fatalf("expected MissingCreateTemplateError, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePluginIDRequiresSelectedTaskID(t *testing.T) {
|
||||
p := newTestParser()
|
||||
e := NewExecutor(testSchema{}, func() string { return "alice" }, ExecutorRuntime{Mode: ExecutorRuntimePlugin})
|
||||
|
||||
validated, err := p.ParseAndValidateStatement(`select where id() = "TIKI-000001"`, ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
_, err = e.Execute(validated, makeTasks())
|
||||
if err == nil {
|
||||
t.Fatal("expected missing selected task id error")
|
||||
}
|
||||
var missing *MissingSelectedTaskIDError
|
||||
if !errors.As(err, &missing) {
|
||||
t.Fatalf("expected MissingSelectedTaskIDError, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatedTriggerCloneIsolated(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
validated, err := p.ParseAndValidateTrigger(`before create deny "blocked"`, ExecutorRuntimeEventTrigger)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
clone := validated.TriggerClone()
|
||||
if clone == nil {
|
||||
t.Fatal("expected non-nil trigger clone")
|
||||
}
|
||||
|
||||
clone.Timing = "after"
|
||||
clone.Event = "delete"
|
||||
clone.Deny = nil
|
||||
|
||||
after := validated.TriggerClone()
|
||||
if after == nil {
|
||||
t.Fatal("expected non-nil trigger clone after mutation")
|
||||
}
|
||||
if after.Timing != "before" || after.Event != "create" {
|
||||
t.Fatalf("validated trigger was mutated: timing=%q event=%q", after.Timing, after.Event)
|
||||
}
|
||||
if after.Deny == nil || *after.Deny != "blocked" {
|
||||
t.Fatalf("expected deny message to remain unchanged, got %#v", after.Deny)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatedTimeTriggerCloneIsolated(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
validated, err := p.ParseAndValidateTimeTrigger(`every 2day create title="x"`, ExecutorRuntimeTimeTrigger)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
clone := validated.TimeTriggerClone()
|
||||
if clone == nil {
|
||||
t.Fatal("expected non-nil time trigger clone")
|
||||
}
|
||||
|
||||
clone.Interval = DurationLiteral{Value: 9, Unit: "week"}
|
||||
clone.Action = nil
|
||||
|
||||
after := validated.TimeTriggerClone()
|
||||
if after == nil {
|
||||
t.Fatal("expected non-nil time trigger clone after mutation")
|
||||
}
|
||||
if after.Interval.Value != 2 || after.Interval.Unit != "day" {
|
||||
t.Fatalf("validated time trigger interval was mutated: %+v", after.Interval)
|
||||
}
|
||||
if after.Action == nil || after.Action.Create == nil {
|
||||
t.Fatal("expected action to remain unchanged")
|
||||
}
|
||||
}
|
||||
715
ruki/semantic_validate.go
Normal file
715
ruki/semantic_validate.go
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
type validationSeal struct{}
|
||||
|
||||
var validatedSeal = &validationSeal{}
|
||||
|
||||
// UnvalidatedWrapperError is returned when a validated wrapper was not created
|
||||
// by semantic validator constructors.
|
||||
type UnvalidatedWrapperError struct {
|
||||
Wrapper string
|
||||
}
|
||||
|
||||
func (e *UnvalidatedWrapperError) Error() string {
|
||||
return fmt.Sprintf("%s wrapper is not semantically validated", e.Wrapper)
|
||||
}
|
||||
|
||||
// ValidatedStatement is an immutable, semantically validated statement wrapper.
|
||||
type ValidatedStatement struct {
|
||||
seal *validationSeal
|
||||
runtime ExecutorRuntimeMode
|
||||
usesIDFunc bool
|
||||
statement *Statement
|
||||
}
|
||||
|
||||
func (v *ValidatedStatement) RuntimeMode() ExecutorRuntimeMode { return v.runtime }
|
||||
func (v *ValidatedStatement) UsesIDBuiltin() bool { return v.usesIDFunc }
|
||||
func (v *ValidatedStatement) RequiresCreateTemplate() bool {
|
||||
return v != nil && v.statement != nil && v.statement.Create != nil
|
||||
}
|
||||
|
||||
func (v *ValidatedStatement) mustBeSealed() error {
|
||||
if v == nil || v.seal != validatedSeal || v.statement == nil {
|
||||
return &UnvalidatedWrapperError{Wrapper: "statement"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatedTrigger is an immutable, semantically validated event-trigger wrapper.
|
||||
type ValidatedTrigger struct {
|
||||
seal *validationSeal
|
||||
runtime ExecutorRuntimeMode
|
||||
usesIDFunc bool
|
||||
trigger *Trigger
|
||||
}
|
||||
|
||||
func (v *ValidatedTrigger) RuntimeMode() ExecutorRuntimeMode { return v.runtime }
|
||||
func (v *ValidatedTrigger) UsesIDBuiltin() bool { return v.usesIDFunc }
|
||||
func (v *ValidatedTrigger) Timing() string {
|
||||
if v == nil || v.trigger == nil {
|
||||
return ""
|
||||
}
|
||||
return v.trigger.Timing
|
||||
}
|
||||
func (v *ValidatedTrigger) Event() string {
|
||||
if v == nil || v.trigger == nil {
|
||||
return ""
|
||||
}
|
||||
return v.trigger.Event
|
||||
}
|
||||
func (v *ValidatedTrigger) HasRunAction() bool {
|
||||
return v != nil && v.trigger != nil && v.trigger.Run != nil
|
||||
}
|
||||
func (v *ValidatedTrigger) DenyMessage() (string, bool) {
|
||||
if v == nil || v.trigger == nil || v.trigger.Deny == nil {
|
||||
return "", false
|
||||
}
|
||||
return *v.trigger.Deny, true
|
||||
}
|
||||
func (v *ValidatedTrigger) RequiresCreateTemplate() bool {
|
||||
return v != nil && v.trigger != nil && v.trigger.Action != nil && v.trigger.Action.Create != nil
|
||||
}
|
||||
func (v *ValidatedTrigger) TriggerClone() *Trigger {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneTrigger(v.trigger)
|
||||
}
|
||||
|
||||
func (v *ValidatedTrigger) mustBeSealed() error {
|
||||
if v == nil || v.seal != validatedSeal || v.trigger == nil {
|
||||
return &UnvalidatedWrapperError{Wrapper: "trigger"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatedTimeTrigger is an immutable, semantically validated time-trigger wrapper.
|
||||
type ValidatedTimeTrigger struct {
|
||||
seal *validationSeal
|
||||
runtime ExecutorRuntimeMode
|
||||
usesIDFunc bool
|
||||
timeTrigger *TimeTrigger
|
||||
}
|
||||
|
||||
func (v *ValidatedTimeTrigger) RuntimeMode() ExecutorRuntimeMode { return v.runtime }
|
||||
func (v *ValidatedTimeTrigger) UsesIDBuiltin() bool { return v.usesIDFunc }
|
||||
func (v *ValidatedTimeTrigger) IntervalLiteral() DurationLiteral {
|
||||
if v == nil || v.timeTrigger == nil {
|
||||
return DurationLiteral{}
|
||||
}
|
||||
return v.timeTrigger.Interval
|
||||
}
|
||||
func (v *ValidatedTimeTrigger) RequiresCreateTemplate() bool {
|
||||
return v != nil && v.timeTrigger != nil && v.timeTrigger.Action != nil && v.timeTrigger.Action.Create != nil
|
||||
}
|
||||
func (v *ValidatedTimeTrigger) TimeTriggerClone() *TimeTrigger {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneTimeTrigger(v.timeTrigger)
|
||||
}
|
||||
|
||||
func (v *ValidatedTimeTrigger) mustBeSealed() error {
|
||||
if v == nil || v.seal != validatedSeal || v.timeTrigger == nil {
|
||||
return &UnvalidatedWrapperError{Wrapper: "time trigger"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatedRule is a discriminated union for ParseAndValidateRule.
|
||||
type ValidatedRule interface {
|
||||
isValidatedRule()
|
||||
RuntimeMode() ExecutorRuntimeMode
|
||||
}
|
||||
|
||||
// ValidatedEventRule wraps a validated event trigger.
|
||||
type ValidatedEventRule struct {
|
||||
seal *validationSeal
|
||||
trigger *ValidatedTrigger
|
||||
}
|
||||
|
||||
func (ValidatedEventRule) isValidatedRule() {}
|
||||
func (r ValidatedEventRule) RuntimeMode() ExecutorRuntimeMode {
|
||||
if r.trigger == nil {
|
||||
return ""
|
||||
}
|
||||
return r.trigger.RuntimeMode()
|
||||
}
|
||||
func (r ValidatedEventRule) Trigger() *ValidatedTrigger { return r.trigger }
|
||||
|
||||
// ValidatedTimeRule wraps a validated time trigger.
|
||||
type ValidatedTimeRule struct {
|
||||
seal *validationSeal
|
||||
time *ValidatedTimeTrigger
|
||||
}
|
||||
|
||||
func (ValidatedTimeRule) isValidatedRule() {}
|
||||
func (r ValidatedTimeRule) RuntimeMode() ExecutorRuntimeMode {
|
||||
if r.time == nil {
|
||||
return ""
|
||||
}
|
||||
return r.time.RuntimeMode()
|
||||
}
|
||||
func (r ValidatedTimeRule) TimeTrigger() *ValidatedTimeTrigger { return r.time }
|
||||
|
||||
// SemanticValidator performs runtime-aware semantic validation after parse/type validation.
|
||||
type SemanticValidator struct {
|
||||
runtime ExecutorRuntimeMode
|
||||
}
|
||||
|
||||
// NewSemanticValidator creates a semantic validator for a specific runtime mode.
|
||||
func NewSemanticValidator(runtime ExecutorRuntimeMode) *SemanticValidator {
|
||||
if runtime == "" {
|
||||
runtime = ExecutorRuntimeCLI
|
||||
}
|
||||
return &SemanticValidator{runtime: runtime}
|
||||
}
|
||||
|
||||
// ParseAndValidateStatement parses a statement and applies runtime-aware semantic validation.
|
||||
func (p *Parser) ParseAndValidateStatement(input string, runtime ExecutorRuntimeMode) (*ValidatedStatement, error) {
|
||||
stmt, err := p.ParseStatement(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewSemanticValidator(runtime).ValidateStatement(stmt)
|
||||
}
|
||||
|
||||
// ParseAndValidateTrigger parses an event trigger and applies runtime-aware semantic validation.
|
||||
func (p *Parser) ParseAndValidateTrigger(input string, runtime ExecutorRuntimeMode) (*ValidatedTrigger, error) {
|
||||
trig, err := p.ParseTrigger(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewSemanticValidator(runtime).ValidateTrigger(trig)
|
||||
}
|
||||
|
||||
// ParseAndValidateTimeTrigger parses a time trigger and applies runtime-aware semantic validation.
|
||||
func (p *Parser) ParseAndValidateTimeTrigger(input string, runtime ExecutorRuntimeMode) (*ValidatedTimeTrigger, error) {
|
||||
tt, err := p.ParseTimeTrigger(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewSemanticValidator(runtime).ValidateTimeTrigger(tt)
|
||||
}
|
||||
|
||||
// ParseAndValidateRule parses a trigger rule union and applies the correct semantic runtime
|
||||
// validation branch (event trigger vs time trigger).
|
||||
func (p *Parser) ParseAndValidateRule(input string) (ValidatedRule, error) {
|
||||
rule, err := p.ParseRule(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case rule == nil:
|
||||
return nil, fmt.Errorf("empty rule")
|
||||
case rule.Trigger != nil:
|
||||
vt, err := NewSemanticValidator(ExecutorRuntimeEventTrigger).ValidateTrigger(rule.Trigger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ValidatedEventRule{seal: validatedSeal, trigger: vt}, nil
|
||||
case rule.TimeTrigger != nil:
|
||||
vt, err := NewSemanticValidator(ExecutorRuntimeTimeTrigger).ValidateTimeTrigger(rule.TimeTrigger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ValidatedTimeRule{seal: validatedSeal, time: vt}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("empty rule")
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateStatement applies runtime-aware semantic checks to a parsed statement.
|
||||
func (v *SemanticValidator) ValidateStatement(stmt *Statement) (*ValidatedStatement, error) {
|
||||
if stmt == nil {
|
||||
return nil, fmt.Errorf("nil statement")
|
||||
}
|
||||
usesID, hasCall, err := scanStatementSemantics(stmt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasCall {
|
||||
return nil, fmt.Errorf("call() is not supported yet")
|
||||
}
|
||||
if usesID && v.runtime != ExecutorRuntimePlugin {
|
||||
return nil, fmt.Errorf("id() is only available in plugin runtime")
|
||||
}
|
||||
if err := validateStatementAssignmentsSemantics(stmt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ValidatedStatement{
|
||||
seal: validatedSeal,
|
||||
runtime: v.runtime,
|
||||
usesIDFunc: usesID,
|
||||
statement: cloneStatement(stmt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateTrigger applies runtime-aware semantic checks to a parsed event trigger.
|
||||
func (v *SemanticValidator) ValidateTrigger(trig *Trigger) (*ValidatedTrigger, error) {
|
||||
if trig == nil {
|
||||
return nil, fmt.Errorf("nil trigger")
|
||||
}
|
||||
usesID, hasCall, err := scanTriggerSemantics(trig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasCall {
|
||||
return nil, fmt.Errorf("call() is not supported yet")
|
||||
}
|
||||
if usesID && v.runtime != ExecutorRuntimePlugin {
|
||||
return nil, fmt.Errorf("id() is only available in plugin runtime")
|
||||
}
|
||||
if trig.Action != nil {
|
||||
if err := validateStatementAssignmentsSemantics(trig.Action); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &ValidatedTrigger{
|
||||
seal: validatedSeal,
|
||||
runtime: v.runtime,
|
||||
usesIDFunc: usesID,
|
||||
trigger: cloneTrigger(trig),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateTimeTrigger applies runtime-aware semantic checks to a parsed time trigger.
|
||||
func (v *SemanticValidator) ValidateTimeTrigger(tt *TimeTrigger) (*ValidatedTimeTrigger, error) {
|
||||
if tt == nil {
|
||||
return nil, fmt.Errorf("nil time trigger")
|
||||
}
|
||||
usesID, hasCall, err := scanTimeTriggerSemantics(tt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasCall {
|
||||
return nil, fmt.Errorf("call() is not supported yet")
|
||||
}
|
||||
if usesID && v.runtime != ExecutorRuntimePlugin {
|
||||
return nil, fmt.Errorf("id() is only available in plugin runtime")
|
||||
}
|
||||
if tt.Action != nil {
|
||||
if err := validateStatementAssignmentsSemantics(tt.Action); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &ValidatedTimeTrigger{
|
||||
seal: validatedSeal,
|
||||
runtime: v.runtime,
|
||||
usesIDFunc: usesID,
|
||||
timeTrigger: cloneTimeTrigger(tt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateStatementAssignmentsSemantics(stmt *Statement) error {
|
||||
switch {
|
||||
case stmt.Create != nil:
|
||||
return validateAssignmentsSemantics(stmt.Create.Assignments)
|
||||
case stmt.Update != nil:
|
||||
return validateAssignmentsSemantics(stmt.Update.Set)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateAssignmentsSemantics(assignments []Assignment) error {
|
||||
for _, a := range assignments {
|
||||
switch a.Field {
|
||||
case "id", "createdBy", "createdAt", "updatedAt":
|
||||
return fmt.Errorf("field %q is immutable", a.Field)
|
||||
}
|
||||
switch a.Field {
|
||||
case "title", "status", "type", "priority":
|
||||
if _, ok := a.Value.(*EmptyLiteral); ok {
|
||||
return fmt.Errorf("field %q cannot be empty", a.Field)
|
||||
}
|
||||
}
|
||||
switch a.Field {
|
||||
case "priority":
|
||||
if lit, ok := a.Value.(*IntLiteral); ok && !task.IsValidPriority(lit.Value) {
|
||||
return fmt.Errorf("priority value out of range: %d", lit.Value)
|
||||
}
|
||||
case "points":
|
||||
if lit, ok := a.Value.(*IntLiteral); ok && !task.IsValidPoints(lit.Value) {
|
||||
return fmt.Errorf("points value out of range: %d", lit.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanStatementSemantics(stmt *Statement) (usesID bool, hasCall bool, err error) {
|
||||
switch {
|
||||
case stmt.Select != nil:
|
||||
return scanSelectSemantics(stmt.Select)
|
||||
case stmt.Create != nil:
|
||||
return scanAssignmentsSemantics(stmt.Create.Assignments)
|
||||
case stmt.Update != nil:
|
||||
u1, c1, err := scanConditionSemantics(stmt.Update.Where)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u2, c2, err := scanAssignmentsSemantics(stmt.Update.Set)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return u1 || u2, c1 || c2, nil
|
||||
case stmt.Delete != nil:
|
||||
return scanConditionSemantics(stmt.Delete.Where)
|
||||
default:
|
||||
return false, false, fmt.Errorf("empty statement")
|
||||
}
|
||||
}
|
||||
|
||||
func scanSelectSemantics(sel *SelectStmt) (usesID bool, hasCall bool, err error) {
|
||||
if sel == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
return scanConditionSemantics(sel.Where)
|
||||
}
|
||||
|
||||
func scanTriggerSemantics(trig *Trigger) (usesID bool, hasCall bool, err error) {
|
||||
var u, c bool
|
||||
if trig.Where != nil {
|
||||
uu, cc, err := scanConditionSemantics(trig.Where)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u, c = u || uu, c || cc
|
||||
}
|
||||
if trig.Action != nil {
|
||||
uu, cc, err := scanStatementSemantics(trig.Action)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u, c = u || uu, c || cc
|
||||
}
|
||||
if trig.Run != nil {
|
||||
uu, cc, err := scanExprSemantics(trig.Run.Command)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u, c = u || uu, c || cc
|
||||
}
|
||||
return u, c, nil
|
||||
}
|
||||
|
||||
func scanTimeTriggerSemantics(tt *TimeTrigger) (usesID bool, hasCall bool, err error) {
|
||||
if tt == nil || tt.Action == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
return scanStatementSemantics(tt.Action)
|
||||
}
|
||||
|
||||
func scanAssignmentsSemantics(assignments []Assignment) (usesID bool, hasCall bool, err error) {
|
||||
for _, a := range assignments {
|
||||
u, c, err := scanExprSemantics(a.Value)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
usesID = usesID || u
|
||||
hasCall = hasCall || c
|
||||
}
|
||||
return usesID, hasCall, nil
|
||||
}
|
||||
|
||||
func scanConditionSemantics(cond Condition) (usesID bool, hasCall bool, err error) {
|
||||
if cond == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
switch c := cond.(type) {
|
||||
case *BinaryCondition:
|
||||
u1, c1, err := scanConditionSemantics(c.Left)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u2, c2, err := scanConditionSemantics(c.Right)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return u1 || u2, c1 || c2, nil
|
||||
case *NotCondition:
|
||||
return scanConditionSemantics(c.Inner)
|
||||
case *CompareExpr:
|
||||
u1, c1, err := scanExprSemantics(c.Left)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u2, c2, err := scanExprSemantics(c.Right)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return u1 || u2, c1 || c2, nil
|
||||
case *IsEmptyExpr:
|
||||
return scanExprSemantics(c.Expr)
|
||||
case *InExpr:
|
||||
u1, c1, err := scanExprSemantics(c.Value)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u2, c2, err := scanExprSemantics(c.Collection)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return u1 || u2, c1 || c2, nil
|
||||
case *QuantifierExpr:
|
||||
u1, c1, err := scanExprSemantics(c.Expr)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u2, c2, err := scanConditionSemantics(c.Condition)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return u1 || u2, c1 || c2, nil
|
||||
default:
|
||||
return false, false, fmt.Errorf("unknown condition type %T", c)
|
||||
}
|
||||
}
|
||||
|
||||
func scanExprSemantics(expr Expr) (usesID bool, hasCall bool, err error) {
|
||||
if expr == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
switch e := expr.(type) {
|
||||
case *FunctionCall:
|
||||
if e.Name == "id" {
|
||||
usesID = true
|
||||
}
|
||||
if e.Name == "call" {
|
||||
hasCall = true
|
||||
}
|
||||
for _, arg := range e.Args {
|
||||
u, c, err := scanExprSemantics(arg)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
usesID = usesID || u
|
||||
hasCall = hasCall || c
|
||||
}
|
||||
return usesID, hasCall, nil
|
||||
case *BinaryExpr:
|
||||
u1, c1, err := scanExprSemantics(e.Left)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u2, c2, err := scanExprSemantics(e.Right)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return u1 || u2, c1 || c2, nil
|
||||
case *ListLiteral:
|
||||
for _, elem := range e.Elements {
|
||||
u, c, err := scanExprSemantics(elem)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
usesID = usesID || u
|
||||
hasCall = hasCall || c
|
||||
}
|
||||
return usesID, hasCall, nil
|
||||
case *SubQuery:
|
||||
return scanConditionSemantics(e.Where)
|
||||
default:
|
||||
return false, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func cloneStatement(stmt *Statement) *Statement {
|
||||
if stmt == nil {
|
||||
return nil
|
||||
}
|
||||
out := &Statement{}
|
||||
if stmt.Select != nil {
|
||||
out.Select = cloneSelect(stmt.Select)
|
||||
}
|
||||
if stmt.Create != nil {
|
||||
out.Create = cloneCreate(stmt.Create)
|
||||
}
|
||||
if stmt.Update != nil {
|
||||
out.Update = cloneUpdate(stmt.Update)
|
||||
}
|
||||
if stmt.Delete != nil {
|
||||
out.Delete = cloneDelete(stmt.Delete)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneSelect(sel *SelectStmt) *SelectStmt {
|
||||
if sel == nil {
|
||||
return nil
|
||||
}
|
||||
var fields []string
|
||||
if sel.Fields != nil {
|
||||
fields = append([]string(nil), sel.Fields...)
|
||||
}
|
||||
var orderBy []OrderByClause
|
||||
if sel.OrderBy != nil {
|
||||
orderBy = append([]OrderByClause(nil), sel.OrderBy...)
|
||||
}
|
||||
return &SelectStmt{
|
||||
Fields: fields,
|
||||
Where: cloneCondition(sel.Where),
|
||||
OrderBy: orderBy,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneCreate(cr *CreateStmt) *CreateStmt {
|
||||
if cr == nil {
|
||||
return nil
|
||||
}
|
||||
out := &CreateStmt{Assignments: make([]Assignment, len(cr.Assignments))}
|
||||
for i, a := range cr.Assignments {
|
||||
out.Assignments[i] = cloneAssignment(a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneUpdate(up *UpdateStmt) *UpdateStmt {
|
||||
if up == nil {
|
||||
return nil
|
||||
}
|
||||
out := &UpdateStmt{
|
||||
Where: cloneCondition(up.Where),
|
||||
Set: make([]Assignment, len(up.Set)),
|
||||
}
|
||||
for i, a := range up.Set {
|
||||
out.Set[i] = cloneAssignment(a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDelete(del *DeleteStmt) *DeleteStmt {
|
||||
if del == nil {
|
||||
return nil
|
||||
}
|
||||
return &DeleteStmt{Where: cloneCondition(del.Where)}
|
||||
}
|
||||
|
||||
func cloneTrigger(trig *Trigger) *Trigger {
|
||||
if trig == nil {
|
||||
return nil
|
||||
}
|
||||
out := &Trigger{
|
||||
Timing: trig.Timing,
|
||||
Event: trig.Event,
|
||||
Where: cloneCondition(trig.Where),
|
||||
Action: cloneStatement(trig.Action),
|
||||
}
|
||||
if trig.Run != nil {
|
||||
out.Run = &RunAction{Command: cloneExpr(trig.Run.Command)}
|
||||
}
|
||||
if trig.Deny != nil {
|
||||
s := *trig.Deny
|
||||
out.Deny = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneTimeTrigger(tt *TimeTrigger) *TimeTrigger {
|
||||
if tt == nil {
|
||||
return nil
|
||||
}
|
||||
return &TimeTrigger{
|
||||
Interval: tt.Interval,
|
||||
Action: cloneStatement(tt.Action),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneAssignment(a Assignment) Assignment {
|
||||
return Assignment{
|
||||
Field: a.Field,
|
||||
Value: cloneExpr(a.Value),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneCondition(cond Condition) Condition {
|
||||
if cond == nil {
|
||||
return nil
|
||||
}
|
||||
switch c := cond.(type) {
|
||||
case *BinaryCondition:
|
||||
return &BinaryCondition{
|
||||
Op: c.Op,
|
||||
Left: cloneCondition(c.Left),
|
||||
Right: cloneCondition(c.Right),
|
||||
}
|
||||
case *NotCondition:
|
||||
return &NotCondition{Inner: cloneCondition(c.Inner)}
|
||||
case *CompareExpr:
|
||||
return &CompareExpr{
|
||||
Left: cloneExpr(c.Left),
|
||||
Op: c.Op,
|
||||
Right: cloneExpr(c.Right),
|
||||
}
|
||||
case *IsEmptyExpr:
|
||||
return &IsEmptyExpr{
|
||||
Expr: cloneExpr(c.Expr),
|
||||
Negated: c.Negated,
|
||||
}
|
||||
case *InExpr:
|
||||
return &InExpr{
|
||||
Value: cloneExpr(c.Value),
|
||||
Collection: cloneExpr(c.Collection),
|
||||
Negated: c.Negated,
|
||||
}
|
||||
case *QuantifierExpr:
|
||||
return &QuantifierExpr{
|
||||
Expr: cloneExpr(c.Expr),
|
||||
Kind: c.Kind,
|
||||
Condition: cloneCondition(c.Condition),
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func cloneExpr(expr Expr) Expr {
|
||||
if expr == nil {
|
||||
return nil
|
||||
}
|
||||
switch e := expr.(type) {
|
||||
case *FieldRef:
|
||||
return &FieldRef{Name: e.Name}
|
||||
case *QualifiedRef:
|
||||
return &QualifiedRef{Qualifier: e.Qualifier, Name: e.Name}
|
||||
case *StringLiteral:
|
||||
return &StringLiteral{Value: e.Value}
|
||||
case *IntLiteral:
|
||||
return &IntLiteral{Value: e.Value}
|
||||
case *DateLiteral:
|
||||
return &DateLiteral{Value: e.Value}
|
||||
case *DurationLiteral:
|
||||
return &DurationLiteral{Value: e.Value, Unit: e.Unit}
|
||||
case *ListLiteral:
|
||||
elems := make([]Expr, len(e.Elements))
|
||||
for i, elem := range e.Elements {
|
||||
elems[i] = cloneExpr(elem)
|
||||
}
|
||||
return &ListLiteral{Elements: elems}
|
||||
case *EmptyLiteral:
|
||||
return &EmptyLiteral{}
|
||||
case *FunctionCall:
|
||||
args := make([]Expr, len(e.Args))
|
||||
for i, arg := range e.Args {
|
||||
args[i] = cloneExpr(arg)
|
||||
}
|
||||
return &FunctionCall{Name: e.Name, Args: args}
|
||||
case *BinaryExpr:
|
||||
return &BinaryExpr{
|
||||
Op: e.Op,
|
||||
Left: cloneExpr(e.Left),
|
||||
Right: cloneExpr(e.Right),
|
||||
}
|
||||
case *SubQuery:
|
||||
return &SubQuery{Where: cloneCondition(e.Where)}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -30,50 +30,123 @@ type TriggerContext struct {
|
|||
AllTasks []*task.Task
|
||||
}
|
||||
|
||||
// EvalGuard evaluates a trigger's Where condition against the triggering event.
|
||||
// EvalGuard evaluates a trigger's where condition against the triggering event.
|
||||
// Returns true if the trigger should fire (guard passes or no guard).
|
||||
func (te *TriggerExecutor) EvalGuard(trig *Trigger, tc *TriggerContext) (bool, error) {
|
||||
if trig.Where == nil {
|
||||
func (te *TriggerExecutor) EvalGuard(trig any, tc *TriggerContext) (bool, error) {
|
||||
validated, err := validateEventTriggerInput(trig)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
where := validated.trigger.Where
|
||||
if where == nil {
|
||||
return true, nil
|
||||
}
|
||||
// the guard evaluates qualified refs against old/new directly;
|
||||
// there is no "current task" — we use a sentinel that QualifiedRef overrides
|
||||
sentinel := te.guardSentinel(tc)
|
||||
exec := te.newExecWithOverrides(tc)
|
||||
return exec.evalCondition(trig.Where, sentinel, tc.AllTasks)
|
||||
return exec.evalCondition(where, sentinel, tc.AllTasks)
|
||||
}
|
||||
|
||||
// ExecTimeTriggerAction executes a time trigger's action against all tasks.
|
||||
// Uses a plain Executor (no old/new overrides) since time triggers have no
|
||||
// mutation context — the parser forbids qualified refs in them.
|
||||
func (te *TriggerExecutor) ExecTimeTriggerAction(tt *TimeTrigger, allTasks []*task.Task) (*Result, error) {
|
||||
if tt.Action == nil {
|
||||
return nil, fmt.Errorf("time trigger has no action")
|
||||
func (te *TriggerExecutor) ExecTimeTriggerAction(tt any, allTasks []*task.Task, inputs ...ExecutionInput) (*Result, error) {
|
||||
var input ExecutionInput
|
||||
if len(inputs) > 0 {
|
||||
input = inputs[0]
|
||||
}
|
||||
|
||||
exec := NewExecutor(te.schema, te.userFunc, ExecutorRuntime{Mode: ExecutorRuntimeTimeTrigger})
|
||||
switch t := tt.(type) {
|
||||
case *ValidatedTimeTrigger:
|
||||
if err := t.mustBeSealed(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.runtime != ExecutorRuntimeTimeTrigger {
|
||||
return nil, &RuntimeMismatchError{
|
||||
ValidatedFor: t.runtime,
|
||||
Runtime: ExecutorRuntimeTimeTrigger,
|
||||
}
|
||||
}
|
||||
if t.timeTrigger.Action == nil {
|
||||
return nil, fmt.Errorf("time trigger has no action")
|
||||
}
|
||||
action := &ValidatedStatement{
|
||||
seal: validatedSeal,
|
||||
runtime: ExecutorRuntimeTimeTrigger,
|
||||
usesIDFunc: t.usesIDFunc,
|
||||
statement: cloneStatement(t.timeTrigger.Action),
|
||||
}
|
||||
return exec.Execute(action, allTasks, input)
|
||||
case *TimeTrigger:
|
||||
if t.Action == nil {
|
||||
return nil, fmt.Errorf("time trigger has no action")
|
||||
}
|
||||
return exec.Execute(t.Action, allTasks, input)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported time trigger type %T", tt)
|
||||
}
|
||||
exec := NewExecutor(te.schema, te.userFunc)
|
||||
return exec.Execute(tt.Action, allTasks)
|
||||
}
|
||||
|
||||
// ExecAction executes a trigger's CRUD action statement and returns the result.
|
||||
// QualifiedRefs resolve against tc.Old/tc.New. Bare fields resolve against target tasks.
|
||||
// Returns *Result for persistence by service/.
|
||||
func (te *TriggerExecutor) ExecAction(trig *Trigger, tc *TriggerContext) (*Result, error) {
|
||||
if trig.Action == nil {
|
||||
return nil, fmt.Errorf("trigger has no action")
|
||||
func (te *TriggerExecutor) ExecAction(trig any, tc *TriggerContext, inputs ...ExecutionInput) (*Result, error) {
|
||||
var input ExecutionInput
|
||||
if len(inputs) > 0 {
|
||||
input = inputs[0]
|
||||
}
|
||||
|
||||
exec := te.newExecWithOverrides(tc)
|
||||
return exec.Execute(trig.Action, tc.AllTasks)
|
||||
switch t := trig.(type) {
|
||||
case *ValidatedTrigger:
|
||||
if err := t.mustBeSealed(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.runtime != ExecutorRuntimeEventTrigger {
|
||||
return nil, &RuntimeMismatchError{
|
||||
ValidatedFor: t.runtime,
|
||||
Runtime: ExecutorRuntimeEventTrigger,
|
||||
}
|
||||
}
|
||||
if t.trigger.Action == nil {
|
||||
return nil, fmt.Errorf("trigger has no action")
|
||||
}
|
||||
action := &ValidatedStatement{
|
||||
seal: validatedSeal,
|
||||
runtime: ExecutorRuntimeEventTrigger,
|
||||
usesIDFunc: t.usesIDFunc,
|
||||
statement: cloneStatement(t.trigger.Action),
|
||||
}
|
||||
return exec.Execute(action, tc.AllTasks, input)
|
||||
case *Trigger:
|
||||
if t.Action == nil {
|
||||
return nil, fmt.Errorf("trigger has no action")
|
||||
}
|
||||
return exec.Execute(t.Action, tc.AllTasks, input)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported trigger type %T", trig)
|
||||
}
|
||||
}
|
||||
|
||||
// ExecRun evaluates the run() command expression to a string against the trigger context.
|
||||
// Returns the command string for execution by service/.
|
||||
func (te *TriggerExecutor) ExecRun(trig *Trigger, tc *TriggerContext) (string, error) {
|
||||
if trig.Run == nil {
|
||||
func (te *TriggerExecutor) ExecRun(trig any, tc *TriggerContext) (string, error) {
|
||||
validated, err := validateEventTriggerInput(trig)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if validated.trigger.Run == nil {
|
||||
return "", fmt.Errorf("trigger has no run action")
|
||||
}
|
||||
command := validated.trigger.Run.Command
|
||||
if command == nil {
|
||||
return "", fmt.Errorf("trigger has no run action")
|
||||
}
|
||||
sentinel := te.guardSentinel(tc)
|
||||
exec := te.newExecWithOverrides(tc)
|
||||
val, err := exec.evalExpr(trig.Run.Command, sentinel, tc.AllTasks)
|
||||
val, err := exec.evalExpr(command, sentinel, tc.AllTasks)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("evaluating run command: %w", err)
|
||||
}
|
||||
|
|
@ -97,6 +170,26 @@ func (te *TriggerExecutor) guardSentinel(tc *TriggerContext) *task.Task {
|
|||
return &task.Task{}
|
||||
}
|
||||
|
||||
func validateEventTriggerInput(trig any) (*ValidatedTrigger, error) {
|
||||
switch t := trig.(type) {
|
||||
case *ValidatedTrigger:
|
||||
if err := t.mustBeSealed(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.runtime != ExecutorRuntimeEventTrigger {
|
||||
return nil, &RuntimeMismatchError{
|
||||
ValidatedFor: t.runtime,
|
||||
Runtime: ExecutorRuntimeEventTrigger,
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
case *Trigger:
|
||||
return NewSemanticValidator(ExecutorRuntimeEventTrigger).ValidateTrigger(t)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported trigger type %T", trig)
|
||||
}
|
||||
}
|
||||
|
||||
// triggerExecOverride wraps Executor and intercepts QualifiedRef evaluation.
|
||||
type triggerExecOverride struct {
|
||||
*Executor
|
||||
|
|
@ -106,7 +199,7 @@ type triggerExecOverride struct {
|
|||
// newExecWithOverrides creates a fresh Executor with QualifiedRef interception.
|
||||
func (te *TriggerExecutor) newExecWithOverrides(tc *TriggerContext) *triggerExecOverride {
|
||||
return &triggerExecOverride{
|
||||
Executor: NewExecutor(te.schema, te.userFunc),
|
||||
Executor: NewExecutor(te.schema, te.userFunc, ExecutorRuntime{Mode: ExecutorRuntimeEventTrigger}),
|
||||
tc: tc,
|
||||
}
|
||||
}
|
||||
|
|
@ -377,17 +470,61 @@ func (e *triggerExecOverride) evalNextDateOverride(fc *FunctionCall, t *task.Tas
|
|||
}
|
||||
|
||||
// Execute overrides the base Executor to use our evalExpr/evalCondition.
|
||||
func (e *triggerExecOverride) Execute(stmt *Statement, tasks []*task.Task) (*Result, error) {
|
||||
func (e *triggerExecOverride) Execute(stmt any, tasks []*task.Task, inputs ...ExecutionInput) (*Result, error) {
|
||||
var input ExecutionInput
|
||||
if len(inputs) > 0 {
|
||||
input = inputs[0]
|
||||
}
|
||||
if stmt == nil {
|
||||
return nil, fmt.Errorf("nil statement")
|
||||
}
|
||||
var validated *ValidatedStatement
|
||||
var rawStmt *Statement
|
||||
rawInput := false
|
||||
requiresCreateTemplate := false
|
||||
switch s := stmt.(type) {
|
||||
case *ValidatedStatement:
|
||||
validated = s
|
||||
requiresCreateTemplate = true
|
||||
case *Statement:
|
||||
rawInput = true
|
||||
var err error
|
||||
validated, err = NewSemanticValidator(e.runtime.Mode).ValidateStatement(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported statement type %T", stmt)
|
||||
}
|
||||
|
||||
if validated != nil {
|
||||
if err := validated.mustBeSealed(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validated.runtime != e.runtime.Mode {
|
||||
return nil, &RuntimeMismatchError{
|
||||
ValidatedFor: validated.runtime,
|
||||
Runtime: e.runtime.Mode,
|
||||
}
|
||||
}
|
||||
if validated.usesIDFunc && e.runtime.Mode == ExecutorRuntimePlugin && strings.TrimSpace(input.SelectedTaskID) == "" {
|
||||
return nil, &MissingSelectedTaskIDError{}
|
||||
}
|
||||
rawStmt = validated.statement
|
||||
if rawInput {
|
||||
requiresCreateTemplate = false
|
||||
}
|
||||
}
|
||||
e.currentInput = input
|
||||
defer func() { e.currentInput = ExecutionInput{} }()
|
||||
|
||||
switch {
|
||||
case stmt.Create != nil:
|
||||
return e.executeCreate(stmt.Create, tasks)
|
||||
case stmt.Update != nil:
|
||||
return e.executeUpdate(stmt.Update, tasks)
|
||||
case stmt.Delete != nil:
|
||||
return e.executeDelete(stmt.Delete, tasks)
|
||||
case rawStmt.Create != nil:
|
||||
return e.executeCreate(rawStmt.Create, tasks, requiresCreateTemplate)
|
||||
case rawStmt.Update != nil:
|
||||
return e.executeUpdate(rawStmt.Update, tasks)
|
||||
case rawStmt.Delete != nil:
|
||||
return e.executeDelete(rawStmt.Delete, tasks)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported trigger action type")
|
||||
}
|
||||
|
|
@ -419,8 +556,16 @@ func (e *triggerExecOverride) executeUpdate(upd *UpdateStmt, tasks []*task.Task)
|
|||
return &Result{Update: &UpdateResult{Updated: clones}}, nil
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) executeCreate(cr *CreateStmt, tasks []*task.Task) (*Result, error) {
|
||||
t := &task.Task{}
|
||||
func (e *triggerExecOverride) executeCreate(cr *CreateStmt, tasks []*task.Task, requireTemplate bool) (*Result, error) {
|
||||
if requireTemplate && e.currentInput.CreateTemplate == nil {
|
||||
return nil, &MissingCreateTemplateError{}
|
||||
}
|
||||
var t *task.Task
|
||||
if e.currentInput.CreateTemplate != nil {
|
||||
t = e.currentInput.CreateTemplate.Clone()
|
||||
} else {
|
||||
t = &task.Task{}
|
||||
}
|
||||
for _, a := range cr.Assignments {
|
||||
val, err := e.evalExpr(a.Value, t, tasks)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -938,8 +939,8 @@ func TestExecute_UnsupportedType(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("expected error for unsupported trigger action type")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported trigger action type") {
|
||||
t.Fatalf("expected 'unsupported trigger action type' error, got: %v", err)
|
||||
if !strings.Contains(err.Error(), "empty statement") {
|
||||
t.Fatalf("expected 'empty statement' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1016,6 +1017,92 @@ func TestExecRun_NonStringResult(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEvalGuardRawTriggerRejectsCallBeforeEvaluation(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`before update where 1 = 2 and call("echo hello") = "x" deny "blocked"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001", Status: "ready"},
|
||||
New: &task.Task{ID: "TIKI-000001", Status: "done"},
|
||||
}
|
||||
_, err = te.EvalGuard(trig, tc)
|
||||
if err == nil {
|
||||
t.Fatal("expected semantic validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "call() is not supported yet") {
|
||||
t.Fatalf("expected call() semantic validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecRunRawTriggerRejectsCall(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`after update run(call("echo hello"))`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001"},
|
||||
New: &task.Task{ID: "TIKI-000001"},
|
||||
}
|
||||
_, err = te.ExecRun(trig, tc)
|
||||
if err == nil {
|
||||
t.Fatal("expected semantic validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "call() is not supported yet") {
|
||||
t.Fatalf("expected call() semantic validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalGuardValidatedTriggerRuntimeMismatch(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
validated, err := p.ParseAndValidateTrigger(`before update where new.status = "done" deny "blocked"`, ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001", Status: "ready"},
|
||||
New: &task.Task{ID: "TIKI-000001", Status: "done"},
|
||||
}
|
||||
_, err = te.EvalGuard(validated, tc)
|
||||
if err == nil {
|
||||
t.Fatal("expected runtime mismatch error")
|
||||
}
|
||||
var mismatch *RuntimeMismatchError
|
||||
if !errors.As(err, &mismatch) {
|
||||
t.Fatalf("expected RuntimeMismatchError, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecTimeTriggerActionValidatedRuntimeMismatch(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
validated, err := p.ParseAndValidateTimeTrigger(`every 1day delete where status = "done"`, ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
|
||||
_, err = te.ExecTimeTriggerAction(validated, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected runtime mismatch error")
|
||||
}
|
||||
var mismatch *RuntimeMismatchError
|
||||
if !errors.As(err, &mismatch) {
|
||||
t.Fatalf("expected RuntimeMismatchError, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- coverage gap tests ---
|
||||
|
||||
func TestNewTriggerExecutor_NilUserFunc(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ var builtinFuncs = map[string]struct {
|
|||
maxArgs int
|
||||
}{
|
||||
"count": {ValueInt, 1, 1},
|
||||
"id": {ValueID, 0, 0},
|
||||
"now": {ValueTimestamp, 0, 0},
|
||||
"next_date": {ValueDate, 1, 1},
|
||||
"blocks": {ValueListRef, 1, 1},
|
||||
|
|
|
|||
|
|
@ -25,12 +25,14 @@ const runCommandTimeout = 30 * time.Second
|
|||
type triggerEntry struct {
|
||||
description string
|
||||
trigger *ruki.Trigger
|
||||
validated *ruki.ValidatedTrigger
|
||||
}
|
||||
|
||||
// TimeTriggerEntry holds a parsed time trigger and its description.
|
||||
type TimeTriggerEntry struct {
|
||||
Description string
|
||||
Trigger *ruki.TimeTrigger
|
||||
Validated *ruki.ValidatedTimeTrigger
|
||||
}
|
||||
|
||||
// TriggerEngine bridges parsed triggers with the mutation gate.
|
||||
|
|
@ -57,20 +59,28 @@ func NewTriggerEngine(triggers []triggerEntry, timeTriggers []TimeTriggerEntry,
|
|||
}
|
||||
|
||||
func (te *TriggerEngine) addTrigger(entry triggerEntry) {
|
||||
trig := entry.trigger
|
||||
timing, event, ok := triggerTimingEvent(entry)
|
||||
if !ok {
|
||||
slog.Warn("skipping trigger with missing timing/event metadata",
|
||||
"trigger", entry.description)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case trig.Timing == "before" && trig.Event == "create":
|
||||
case timing == "before" && event == "create":
|
||||
te.beforeCreate = append(te.beforeCreate, entry)
|
||||
case trig.Timing == "before" && trig.Event == "update":
|
||||
case timing == "before" && event == "update":
|
||||
te.beforeUpdate = append(te.beforeUpdate, entry)
|
||||
case trig.Timing == "before" && trig.Event == "delete":
|
||||
case timing == "before" && event == "delete":
|
||||
te.beforeDelete = append(te.beforeDelete, entry)
|
||||
case trig.Timing == "after" && trig.Event == "create":
|
||||
case timing == "after" && event == "create":
|
||||
te.afterCreate = append(te.afterCreate, entry)
|
||||
case trig.Timing == "after" && trig.Event == "update":
|
||||
case timing == "after" && event == "update":
|
||||
te.afterUpdate = append(te.afterUpdate, entry)
|
||||
case trig.Timing == "after" && trig.Event == "delete":
|
||||
case timing == "after" && event == "delete":
|
||||
te.afterDelete = append(te.afterDelete, entry)
|
||||
default:
|
||||
slog.Warn("skipping trigger with unsupported timing/event",
|
||||
"trigger", entry.description, "timing", timing, "event", event)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,14 +121,17 @@ func (te *TriggerEngine) RegisterWithGate(gate *TaskMutationGate) {
|
|||
func (te *TriggerEngine) makeBeforeValidator(entry triggerEntry) MutationValidator {
|
||||
return func(old, new *task.Task, allTasks []*task.Task) *Rejection {
|
||||
tc := &ruki.TriggerContext{Old: old, New: new, AllTasks: allTasks}
|
||||
match, err := te.executor.EvalGuard(entry.trigger, tc)
|
||||
match, err := te.executor.EvalGuard(eventTriggerForExec(entry), tc)
|
||||
if err != nil {
|
||||
return &Rejection{
|
||||
Reason: fmt.Sprintf("trigger %q guard evaluation failed: %v", entry.description, err),
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return &Rejection{Reason: *entry.trigger.Deny}
|
||||
if msg, ok := triggerDenyMessage(eventTriggerForExec(entry)); ok {
|
||||
return &Rejection{Reason: msg}
|
||||
}
|
||||
return &Rejection{Reason: "trigger rejected"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -138,7 +151,7 @@ func (te *TriggerEngine) makeAfterHook(entry triggerEntry) AfterHook {
|
|||
allTasks := te.gate.ReadStore().GetAllTasks()
|
||||
tc := &ruki.TriggerContext{Old: old, New: new, AllTasks: allTasks}
|
||||
|
||||
match, err := te.executor.EvalGuard(entry.trigger, tc)
|
||||
match, err := te.executor.EvalGuard(eventTriggerForExec(entry), tc)
|
||||
if err != nil {
|
||||
slog.Error("after-trigger guard evaluation failed",
|
||||
"trigger", entry.description, "error", err)
|
||||
|
|
@ -150,7 +163,7 @@ func (te *TriggerEngine) makeAfterHook(entry triggerEntry) AfterHook {
|
|||
|
||||
childCtx := withTriggerDepth(ctx, depth+1)
|
||||
|
||||
if entry.trigger.Run != nil {
|
||||
if triggerHasRunAction(eventTriggerForExec(entry)) {
|
||||
return te.execRun(childCtx, entry, tc)
|
||||
}
|
||||
return te.execAction(childCtx, entry, tc)
|
||||
|
|
@ -158,7 +171,18 @@ func (te *TriggerEngine) makeAfterHook(entry triggerEntry) AfterHook {
|
|||
}
|
||||
|
||||
func (te *TriggerEngine) execAction(ctx context.Context, entry triggerEntry, tc *ruki.TriggerContext) error {
|
||||
result, err := te.executor.ExecAction(entry.trigger, tc)
|
||||
input := ruki.ExecutionInput{}
|
||||
if triggerRequiresCreateTemplate(eventTriggerForExec(entry)) {
|
||||
tmpl, err := te.gate.ReadStore().NewTaskTemplate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create template: %w", err)
|
||||
}
|
||||
if tmpl == nil {
|
||||
return fmt.Errorf("create template: store returned nil template")
|
||||
}
|
||||
input.CreateTemplate = tmpl
|
||||
}
|
||||
result, err := te.executor.ExecAction(eventTriggerForExec(entry), tc, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trigger %q action execution failed: %w", entry.description, err)
|
||||
}
|
||||
|
|
@ -176,12 +200,6 @@ func (te *TriggerEngine) persistResult(ctx context.Context, result *ruki.Result)
|
|||
}
|
||||
case result.Create != nil:
|
||||
t := result.Create.Task
|
||||
tmpl, err := te.gate.ReadStore().NewTaskTemplate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create template: %w", err)
|
||||
}
|
||||
t.ID = tmpl.ID
|
||||
t.CreatedBy = tmpl.CreatedBy
|
||||
if err := te.gate.CreateTask(ctx, t); err != nil {
|
||||
return fmt.Errorf("trigger create failed: %w", err)
|
||||
}
|
||||
|
|
@ -196,7 +214,7 @@ func (te *TriggerEngine) persistResult(ctx context.Context, result *ruki.Result)
|
|||
}
|
||||
|
||||
func (te *TriggerEngine) execRun(ctx context.Context, entry triggerEntry, tc *ruki.TriggerContext) error {
|
||||
cmdStr, err := te.executor.ExecRun(entry.trigger, tc)
|
||||
cmdStr, err := te.executor.ExecRun(eventTriggerForExec(entry), tc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trigger %q run evaluation failed: %w", entry.description, err)
|
||||
}
|
||||
|
|
@ -251,22 +269,28 @@ func LoadAndRegisterTriggers(gate *TaskMutationGate, schema ruki.Schema, userFun
|
|||
desc = fmt.Sprintf("#%d", i+1)
|
||||
}
|
||||
|
||||
rule, err := parser.ParseRule(def.Ruki)
|
||||
rule, err := parser.ParseAndValidateRule(def.Ruki)
|
||||
if err != nil {
|
||||
return empty(), 0, fmt.Errorf("trigger %q: %w", desc, err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case rule.TimeTrigger != nil:
|
||||
switch r := rule.(type) {
|
||||
case ruki.ValidatedTimeRule:
|
||||
vtt := r.TimeTrigger()
|
||||
timeEntries = append(timeEntries, TimeTriggerEntry{
|
||||
Description: def.Description,
|
||||
Trigger: rule.TimeTrigger,
|
||||
Trigger: cloneTimeTriggerForService(vtt.TimeTriggerClone()),
|
||||
Validated: vtt,
|
||||
})
|
||||
case rule.Trigger != nil:
|
||||
case ruki.ValidatedEventRule:
|
||||
vt := r.Trigger()
|
||||
eventEntries = append(eventEntries, triggerEntry{
|
||||
description: def.Description,
|
||||
trigger: rule.Trigger,
|
||||
trigger: cloneTriggerForService(vt.TriggerClone()),
|
||||
validated: vt,
|
||||
})
|
||||
default:
|
||||
return empty(), 0, fmt.Errorf("trigger %q: unknown validated rule type %T", desc, rule)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -287,7 +311,13 @@ func (te *TriggerEngine) StartScheduler(ctx context.Context) {
|
|||
return
|
||||
}
|
||||
for _, entry := range te.timeTriggers {
|
||||
d, err := duration.ToDuration(entry.Trigger.Interval.Value, entry.Trigger.Interval.Unit)
|
||||
interval, ok := timeTriggerInterval(entry)
|
||||
if !ok {
|
||||
slog.Warn("skipping time trigger with missing interval metadata",
|
||||
"trigger", entry.Description)
|
||||
continue
|
||||
}
|
||||
d, err := duration.ToDuration(interval.Value, interval.Unit)
|
||||
if err != nil {
|
||||
slog.Error("invalid time trigger interval, skipping",
|
||||
"trigger", entry.Description, "error", err)
|
||||
|
|
@ -318,7 +348,20 @@ func (te *TriggerEngine) runTimeTrigger(ctx context.Context, entry TimeTriggerEn
|
|||
// executeTimeTrigger runs a single tick of a time trigger: snapshot tasks, execute, persist.
|
||||
func (te *TriggerEngine) executeTimeTrigger(ctx context.Context, entry TimeTriggerEntry) {
|
||||
allTasks := te.gate.ReadStore().GetAllTasks()
|
||||
result, err := te.executor.ExecTimeTriggerAction(entry.Trigger, allTasks)
|
||||
input := ruki.ExecutionInput{}
|
||||
if timeTriggerRequiresCreateTemplate(timeTriggerForExec(entry)) {
|
||||
tmpl, err := te.gate.ReadStore().NewTaskTemplate()
|
||||
if err != nil {
|
||||
slog.Error("create template failed", "trigger", entry.Description, "error", err)
|
||||
return
|
||||
}
|
||||
if tmpl == nil {
|
||||
slog.Error("create template failed", "trigger", entry.Description, "error", "store returned nil template")
|
||||
return
|
||||
}
|
||||
input.CreateTemplate = tmpl
|
||||
}
|
||||
result, err := te.executor.ExecTimeTriggerAction(timeTriggerForExec(entry), allTasks, input)
|
||||
if err != nil {
|
||||
slog.Error("time trigger action failed",
|
||||
"trigger", entry.Description, "error", err)
|
||||
|
|
@ -329,3 +372,124 @@ func (te *TriggerEngine) executeTimeTrigger(ctx context.Context, entry TimeTrigg
|
|||
"trigger", entry.Description, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func triggerTimingEvent(entry triggerEntry) (string, string, bool) {
|
||||
switch {
|
||||
case entry.validated != nil:
|
||||
timing, event := entry.validated.Timing(), entry.validated.Event()
|
||||
if timing == "" || event == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return timing, event, true
|
||||
case entry.trigger != nil:
|
||||
if entry.trigger.Timing == "" || entry.trigger.Event == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return entry.trigger.Timing, entry.trigger.Event, true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
func timeTriggerInterval(entry TimeTriggerEntry) (ruki.DurationLiteral, bool) {
|
||||
switch {
|
||||
case entry.Validated != nil:
|
||||
interval := entry.Validated.IntervalLiteral()
|
||||
if interval.Unit == "" {
|
||||
return ruki.DurationLiteral{}, false
|
||||
}
|
||||
return interval, true
|
||||
case entry.Trigger != nil:
|
||||
if entry.Trigger.Interval.Unit == "" {
|
||||
return ruki.DurationLiteral{}, false
|
||||
}
|
||||
return entry.Trigger.Interval, true
|
||||
default:
|
||||
return ruki.DurationLiteral{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func triggerDenyMessage(trig any) (string, bool) {
|
||||
switch t := trig.(type) {
|
||||
case *ruki.ValidatedTrigger:
|
||||
return t.DenyMessage()
|
||||
case *ruki.Trigger:
|
||||
if t.Deny == nil {
|
||||
return "", false
|
||||
}
|
||||
return *t.Deny, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func triggerHasRunAction(trig any) bool {
|
||||
switch t := trig.(type) {
|
||||
case *ruki.ValidatedTrigger:
|
||||
return t.HasRunAction()
|
||||
case *ruki.Trigger:
|
||||
return t.Run != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func triggerRequiresCreateTemplate(trig any) bool {
|
||||
switch t := trig.(type) {
|
||||
case *ruki.ValidatedTrigger:
|
||||
return t.RequiresCreateTemplate()
|
||||
case *ruki.Trigger:
|
||||
return t != nil && t.Action != nil && t.Action.Create != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func timeTriggerRequiresCreateTemplate(trig any) bool {
|
||||
switch t := trig.(type) {
|
||||
case *ruki.ValidatedTimeTrigger:
|
||||
return t.RequiresCreateTemplate()
|
||||
case *ruki.TimeTrigger:
|
||||
return t != nil && t.Action != nil && t.Action.Create != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func eventTriggerForExec(entry triggerEntry) any {
|
||||
if entry.validated != nil {
|
||||
return entry.validated
|
||||
}
|
||||
return entry.trigger
|
||||
}
|
||||
|
||||
func timeTriggerForExec(entry TimeTriggerEntry) any {
|
||||
if entry.Validated != nil {
|
||||
return entry.Validated
|
||||
}
|
||||
return entry.Trigger
|
||||
}
|
||||
|
||||
func cloneTriggerForService(trig *ruki.Trigger) *ruki.Trigger {
|
||||
if trig == nil {
|
||||
return nil
|
||||
}
|
||||
return &ruki.Trigger{
|
||||
Timing: trig.Timing,
|
||||
Event: trig.Event,
|
||||
Where: trig.Where,
|
||||
Action: trig.Action,
|
||||
Run: trig.Run,
|
||||
Deny: trig.Deny,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneTimeTriggerForService(tt *ruki.TimeTrigger) *ruki.TimeTrigger {
|
||||
if tt == nil {
|
||||
return nil
|
||||
}
|
||||
return &ruki.TimeTrigger{
|
||||
Interval: tt.Interval,
|
||||
Action: tt.Action,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
101
service/trigger_engine_safety_test.go
Normal file
101
service/trigger_engine_safety_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestTriggerEngine_ValidatedOnlyBeforeEntryDenies(t *testing.T) {
|
||||
p := ruki.NewParser(testTriggerSchema{})
|
||||
validated, err := p.ParseAndValidateTrigger(`before create deny "blocked by validated trigger"`, ruki.ExecutorRuntimeEventTrigger)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
|
||||
entry := triggerEntry{
|
||||
description: "validated-only",
|
||||
validated: validated,
|
||||
}
|
||||
gate, _ := newGateWithStoreAndTasks()
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
err = gate.CreateTask(context.Background(), &task.Task{
|
||||
ID: "TIKI-VAL001",
|
||||
Title: "should be blocked",
|
||||
Status: "ready",
|
||||
Type: "story",
|
||||
Priority: 3,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected create denial")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "blocked by validated trigger") {
|
||||
t.Fatalf("expected validated deny message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_EmptyEventEntryIsSkipped(t *testing.T) {
|
||||
gate, _ := newGateWithStoreAndTasks()
|
||||
engine := NewTriggerEngine([]triggerEntry{{description: "empty"}}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
if len(engine.beforeCreate)+len(engine.beforeUpdate)+len(engine.beforeDelete)+len(engine.afterCreate)+len(engine.afterUpdate)+len(engine.afterDelete) != 0 {
|
||||
t.Fatal("expected empty event entry to be skipped")
|
||||
}
|
||||
|
||||
err := gate.CreateTask(context.Background(), &task.Task{
|
||||
ID: "TIKI-EMP001",
|
||||
Title: "allowed",
|
||||
Status: "ready",
|
||||
Type: "story",
|
||||
Priority: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected create error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_ValidatedOnlyTimeEntryExecutes(t *testing.T) {
|
||||
p := ruki.NewParser(testTriggerSchema{})
|
||||
validated, err := p.ParseAndValidateTimeTrigger(`every 1day create title="from validated time trigger" priority=3 status="ready" type="story"`, ruki.ExecutorRuntimeTimeTrigger)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
|
||||
gate, s := newGateWithStoreAndTasks()
|
||||
engine := NewTriggerEngine(nil, []TimeTriggerEntry{
|
||||
{
|
||||
Description: "validated-time",
|
||||
Validated: validated,
|
||||
},
|
||||
}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
engine.executeTimeTrigger(context.Background(), engine.timeTriggers[0])
|
||||
|
||||
all := s.GetAllTasks()
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("expected one created task, got %d", len(all))
|
||||
}
|
||||
if all[0].Title != "from validated time trigger" {
|
||||
t.Fatalf("expected created title to match, got %q", all[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_StartSchedulerEmptyTimeEntryNoPanic(t *testing.T) {
|
||||
gate, _ := newGateWithStoreAndTasks()
|
||||
engine := NewTriggerEngine(nil, []TimeTriggerEntry{
|
||||
{Description: "empty-time-entry"},
|
||||
}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
engine.StartScheduler(ctx)
|
||||
}
|
||||
Loading…
Reference in a new issue