diff --git a/.doc/doki/doc/ruki/examples.md b/.doc/doki/doc/ruki/examples.md index 20c434c..eaef4fe 100644 --- a/.doc/doki/doc/ruki/examples.md +++ b/.doc/doki/doc/ruki/examples.md @@ -28,6 +28,14 @@ Select with a basic filter: select where status = "done" and priority <= 2 ``` +Select with ordering: + +```sql +select order by priority +select where status = "done" order by updatedAt desc +select where "bug" in tags order by priority asc, createdAt desc +``` + Create a tiki: ```sql @@ -220,3 +228,16 @@ Non-string `run(...)` command: ```sql after update run(1 + 2) ``` + +Ordering by a non-orderable field: + +```sql +select order by tags +select order by dependsOn +``` + +Order by inside a subquery: + +```sql +select where count(select where status = "done" order by priority) >= 1 +``` diff --git a/.doc/doki/doc/ruki/images/binary-op-types.svg b/.doc/doki/doc/ruki/images/binary-op-types.svg new file mode 100644 index 0000000..c377556 --- /dev/null +++ b/.doc/doki/doc/ruki/images/binary-op-types.svg @@ -0,0 +1,202 @@ + + + + + + + + + + Binary operator type resolution + + + + + + + + addition / concatenation + + + + string + int + date + tstamp + duration + list<str> + list<ref> + + + string + + string + + + + + + + + + + int + + + int + + + + + + + + date + + + + + + date + + + + + + tstamp + + + + + + tstamp + + + + + list<str> + + list<str> + + + + + + list<str> + + + + + list<ref> + + + + + + + + list<ref> + + + + + + + + subtraction / removal + + + + string + int + date + tstamp + duration + list<str> + list<ref> + + + int + + + int + + + + + + + + + date + + + + duration + + + date + + + + + tstamp + + + + + duration + + tstamp + + + + + + list<str> + + list<str> + + + + + + list<str> + + + + list<ref> + + + + + + + + list<ref> + + + + + valid (shows result type) + — invalid + + string includes string-like types: string, status, type, id, ref + list<ref> + also accepts bare id/ref values on the right side + diff --git a/.doc/doki/doc/ruki/images/cond-railroad.svg b/.doc/doki/doc/ruki/images/cond-railroad.svg new file mode 100644 index 0000000..754e4fb --- /dev/null +++ b/.doc/doki/doc/ruki/images/cond-railroad.svg @@ -0,0 +1,339 @@ + + + + + + + + + + + + + + Condition grammar + + + + + + + + + condition + + + + + + + + + orCond + + + + + + + + + + + + + + + + orCond + + + + + + + + + andCond + + + + + + + + or + + + + + + + + + + + + + + + + + andCond + + + + + + + + + notCond + + + + + + + + and + + + + + + + + + + + + + + + + + notCond + + + + + + + + + + + not + + + + + notCond + + + + + + + + primaryCond + + + + + + + + + + + + + + + + + primaryCond + + + + + + + + + + + ( + + + + + condition + + + + + ) + + + + + + + + exprCond + + + + + + + + + + + + + + + + + exprCond + + + + + + + + + expr + + + + + + + + + + + + compareOp + + + + + expr + + + + + + + + is + + + + + empty + + + + + + + + is + + + + + not + + + + + empty + + + + + + + + in + + + + + expr + + + + + + + + not + + + + + in + + + + + expr + + + + + + + + any + + + + + primaryCond + + + + + + + + all + + + + + primaryCond + + + + + + + + + + + diff --git a/.doc/doki/doc/ruki/images/expr-railroad.svg b/.doc/doki/doc/ruki/images/expr-railroad.svg new file mode 100644 index 0000000..5949057 --- /dev/null +++ b/.doc/doki/doc/ruki/images/expr-railroad.svg @@ -0,0 +1,303 @@ + + + + + + + + + + + + + + Expression grammar + + + + + + + + + + expr + + + + + + + + + unaryExpr + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + unaryExpr + + + + + + + + + + + funcCall + + + + + + + + subQuery + + + + + + + + qualifiedRef + + + + + + + + listLiteral + + + + + + + + string + + + + + + + + date + + + + + + + + duration + + + + + + + + int + + + + + + + + empty + + + + + + + + fieldRef + + + + + + + + ( + + + + + expr + + + + + ) + + + + + + + + + + + + + + + + + + funcCall + + + + + + + + + identifier + + + + + + ( + + + + + + + + expr + + + + + + + + , + + + + + + + + + + ) + + + + + + + + + + + + + + + qualifiedRef + + + + + + + + + + + old + + + + + + + new + + + + + + + . + + + + + identifier + + + + + + + + + \ No newline at end of file diff --git a/.doc/doki/doc/ruki/images/qualifier-scope.svg b/.doc/doki/doc/ruki/images/qualifier-scope.svg new file mode 100644 index 0000000..ddeb7e6 --- /dev/null +++ b/.doc/doki/doc/ruki/images/qualifier-scope.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + Qualifier scope by context + + + + + + + + + + old. + new. + + + + + + standalone statement + + + + + + + + create trigger + + + + + + + update trigger + + + + + + + + delete trigger + + + + + + + + + + + quantifier body (any / all): both old. and new. are disabled + + + + + allowed + + + + not allowed + diff --git a/.doc/doki/doc/ruki/images/stmt-railroad.svg b/.doc/doki/doc/ruki/images/stmt-railroad.svg new file mode 100644 index 0000000..276d96e --- /dev/null +++ b/.doc/doki/doc/ruki/images/stmt-railroad.svg @@ -0,0 +1,235 @@ + + + + + + + + + + + + + + Statement grammar + + + + + + + + + + + selectStmt + + + + + + + + + + + select + + + + + + + where + + + + + condition + + + + + + + + + + + + + + + + + + + + + createStmt + + + + + + + + + + + create + + + + + + + assignment + + + + + + + + + + + + + + + + + + + + + + + updateStmt + + + + + + + + + + + update + + + + + + + where + + + + + + + condition + + + + + + + set + + + + + + + assignment + + + + + + + + + + + + + + + + + + + + + deleteStmt + + + + + + + + + + + delete + + + + + + + where + + + + + + + condition + + + + + + + + + + diff --git a/.doc/doki/doc/ruki/images/trigger-railroad.svg b/.doc/doki/doc/ruki/images/trigger-railroad.svg new file mode 100644 index 0000000..13838fd --- /dev/null +++ b/.doc/doki/doc/ruki/images/trigger-railroad.svg @@ -0,0 +1,307 @@ + + + + + + + + + + + + + + Trigger grammar + + + + + + + + + + trigger + + + + + + + + + + + timing + + + + + + + event + + + + + + + where + + + + + condition + + + + + + + + + + + + action + + + + + + + + deny + + + + + + + + + + + + + + + + + timing + + + + + + + + + + + + before + + + + + + + + after + + + + + + + + + + + + + + + + + event + + + + + + + + + + + + create + + + + + + + update + + + + + + + + + + delete + + + + + + + + + + + + + + + + + + action + + + + + + + + + + + + runAction + + + + + + + + createStmt + + + + + + + + updateStmt + + + + + + + + deleteStmt + + + + + + + + + + + + + + + + + deny + + + + + + + + + + deny + + + + + + string + + + + + + + + + + + + + + + runAction + + + + + + + + + + run + + + + + + ( + + + + + + expr + + + + + + ) + + + + + + + + + \ No newline at end of file diff --git a/.doc/doki/doc/ruki/images/validation-pipeline.svg b/.doc/doki/doc/ruki/images/validation-pipeline.svg new file mode 100644 index 0000000..d75af4a --- /dev/null +++ b/.doc/doki/doc/ruki/images/validation-pipeline.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + Validation pipeline + + + + + + Input text + + + + + Lexer / Parser + + + + + AST + + + + + Semantic Validator + + + + + Valid AST + + + + + + stage 1 + + + Parse errors + unknown keyword, missing clause, bad syntax + + + + + + stage 2 + + + + Semantic validation errors + + + + + + + + + Structural + dup fields, trigger rules + + + Field / Qualifier + unknown, old./new. scope + + + Type / Operator + mismatch, bad usage + + + + processing stage + + data + + error + \ No newline at end of file diff --git a/.doc/doki/doc/ruki/semantics.md b/.doc/doki/doc/ruki/semantics.md index c7a4629..4f25679 100644 --- a/.doc/doki/doc/ruki/semantics.md +++ b/.doc/doki/doc/ruki/semantics.md @@ -18,7 +18,17 @@ This page explains how Ruki statements, triggers, conditions, and expressions be - `select` without `where` means a statement with no condition node. - `select where ...` validates the condition and its contained expressions. -- A subquery form `select` or `select where ...` can appear only inside `count(...)`. +- `select ... order by [asc|desc], ...` specifies result ordering. +- A subquery form `select` or `select where ...` can appear only inside `count(...)`. Subqueries do not support `order by`. + +`order by` + +- Each field must exist in the schema and be an orderable type. +- Orderable types: `int`, `date`, `timestamp`, `duration`, `string`, `status`, `type`, `id`, `ref`. +- Non-orderable types: `list`, `list`, `recurrence`, `bool`. +- Default direction is ascending. Use `desc` for descending. +- Duplicate fields are rejected. +- Only bare field names are allowed — `old.` and `new.` qualifiers are not valid in `order by`. `create` @@ -80,6 +90,8 @@ before delete where old.priority <= 2 deny "cannot delete high priority tikis" before update where old.status = "in progress" and new.status = "done" deny "tikis must go through review before completion" ``` +![Qualifier scope by context](images/qualifier-scope.svg) + Important special case: - inside a quantifier body such as `dependsOn any ...`, qualifiers are disabled again @@ -121,5 +133,7 @@ Binary `+` and `-` are semantic rather than purely numeric: - `timestamp - duration` yields `timestamp` - `timestamp - timestamp` yields `duration` +![Binary operator type resolution](images/binary-op-types.svg) + For the detailed type rules and built-ins, see [Types And Values](types-and-values.md) and [Operators And Built-ins](operators-and-builtins.md). diff --git a/.doc/doki/doc/ruki/syntax.md b/.doc/doki/doc/ruki/syntax.md index 4120d91..f65f1cf 100644 --- a/.doc/doki/doc/ruki/syntax.md +++ b/.doc/doki/doc/ruki/syntax.md @@ -47,8 +47,11 @@ The following EBNF-style summary shows the grammar: ```text statement = selectStmt | createStmt | updateStmt | deleteStmt ; -selectStmt = "select" [ "where" condition ] ; +selectStmt = "select" [ "where" condition ] [ orderBy ] ; createStmt = "create" assignment { assignment } ; + +orderBy = "order" "by" sortField { "," sortField } ; +sortField = identifier [ "asc" | "desc" ] ; updateStmt = "update" "where" condition "set" assignment { assignment } ; deleteStmt = "delete" "where" condition ; @@ -63,12 +66,18 @@ runAction = "run" "(" expr ")" ; deny = "deny" string ; ``` +![Statement grammar railroad diagram](images/stmt-railroad.svg) + +![Trigger grammar railroad diagram](images/trigger-railroad.svg) + Notes: - `select` is a valid top-level statement, but it is not valid as a trigger action. - `create` requires at least one assignment. - `update` requires both `where` and `set`. - `delete` requires `where`. +- `order by` is only valid on `select`, not on subqueries inside `count(...)`. +- `asc`, `desc`, `order`, and `by` are contextual keywords — they are only special in the ORDER BY clause. ## Condition grammar @@ -99,6 +108,8 @@ anyTail = "any" primaryCond ; allTail = "all" primaryCond ; ``` +![Condition grammar railroad diagram](images/cond-railroad.svg) + Examples: ```sql @@ -109,6 +120,14 @@ select where dependsOn any status != "done" select where not (status = "done" or priority = 1) ``` +Order by: + +```sql +select order by priority +select where status = "done" order by updatedAt desc +select where "bug" in tags order by priority asc, createdAt desc +``` + ## Expression grammar Expressions support literals, field references, qualifiers, function calls, list literals, parenthesized expressions, subqueries, and left-associative `+` or `-` chains: @@ -136,6 +155,8 @@ emptyLiteral = "empty" ; fieldRef = identifier ; ``` +![Expression grammar railroad diagram](images/expr-railroad.svg) + Examples: ```sql diff --git a/.doc/doki/doc/ruki/validation-and-errors.md b/.doc/doki/doc/ruki/validation-and-errors.md index 43f1049..4ed8114 100644 --- a/.doc/doki/doc/ruki/validation-and-errors.md +++ b/.doc/doki/doc/ruki/validation-and-errors.md @@ -8,6 +8,7 @@ - [Field and qualifier errors](#field-and-qualifier-errors) - [Type and operator errors](#type-and-operator-errors) - [Enum and list errors](#enum-and-list-errors) +- [Order by errors](#order-by-errors) - [Built-in and subquery errors](#built-in-and-subquery-errors) ## Overview @@ -16,6 +17,8 @@ This page explains the errors you can get in Ruki. It covers syntax errors, unkn ## Validation layers +![Validation pipeline](images/validation-pipeline.svg) + Ruki has two distinct failure stages: 1. Parse-time failures @@ -166,6 +169,34 @@ select where status in dependsOn select where tags any status = "done" ``` +## Order by errors + +Unknown field: + +```sql +select order by nonexistent +``` + +Non-orderable types: + +```sql +select order by tags +select order by dependsOn +select order by recurrence +``` + +Duplicate field: + +```sql +select order by priority, priority desc +``` + +Order by inside a subquery: + +```sql +select where count(select where status = "done" order by priority) >= 1 +``` + ## Built-in and subquery errors Unknown function: diff --git a/ruki/ast.go b/ruki/ast.go index 77b569b..d135897 100644 --- a/ruki/ast.go +++ b/ruki/ast.go @@ -13,9 +13,10 @@ type Statement struct { Delete *DeleteStmt } -// SelectStmt represents "select [where ]". +// SelectStmt represents "select [where ] [order by [asc|desc], ...]". type SelectStmt struct { - Where Condition // nil = select all + Where Condition // nil = select all + OrderBy []OrderByClause // nil = unordered } // CreateStmt represents "create =...". @@ -181,6 +182,14 @@ func (*FunctionCall) exprNode() {} func (*BinaryExpr) exprNode() {} func (*SubQuery) exprNode() {} +// --- order by --- + +// OrderByClause represents a single sort criterion in "order by [asc|desc]". +type OrderByClause struct { + Field string // field name + Desc bool // true = descending, false = ascending (default) +} + // --- assignments --- // Assignment represents "field=value" in create/update statements. diff --git a/ruki/grammar.go b/ruki/grammar.go index 5168357..d00fee6 100644 --- a/ruki/grammar.go +++ b/ruki/grammar.go @@ -14,7 +14,20 @@ type statementGrammar struct { } type selectGrammar struct { - Where *orCond `parser:"'select' ( 'where' @@ )?"` + Where *orCond `parser:"'select' ( 'where' @@ )?"` + OrderBy *orderByGrammar `parser:"@@?"` +} + +// --- order by grammar --- + +type orderByGrammar struct { + First orderByField `parser:"'order' 'by' @@"` + Rest []orderByField `parser:"( ',' @@ )*"` +} + +type orderByField struct { + Field string `parser:"@Ident"` + Direction *string `parser:"@( 'asc' | 'desc' )?"` } type createGrammar struct { @@ -156,7 +169,8 @@ type funcCallExpr struct { } type subQueryExpr struct { - Where *orCond `parser:"'select' ( 'where' @@ )?"` + Where *orCond `parser:"'select' ( 'where' @@ )?"` + OrderBy *orderByGrammar `parser:"@@?"` } type qualRefExpr struct { diff --git a/ruki/lower.go b/ruki/lower.go index cfc7885..580cb76 100644 --- a/ruki/lower.go +++ b/ruki/lower.go @@ -49,7 +49,8 @@ func lowerSelect(g *selectGrammar) (*SelectStmt, error) { return nil, err } } - return &SelectStmt{Where: where}, nil + orderBy := lowerOrderBy(g.OrderBy) + return &SelectStmt{Where: where, OrderBy: orderBy}, nil } func lowerCreate(g *createGrammar) (*CreateStmt, error) { @@ -317,6 +318,9 @@ func lowerFuncCall(g *funcCallExpr) (Expr, error) { } func lowerSubQuery(g *subQueryExpr) (Expr, error) { + if g.OrderBy != nil { + return nil, fmt.Errorf("order by is not valid inside a subquery") + } var where Condition if g.Where != nil { var err error @@ -340,6 +344,25 @@ func lowerListLit(g *listLitExpr) (Expr, error) { return &ListLiteral{Elements: elems}, nil } +// --- order by lowering --- + +func lowerOrderBy(g *orderByGrammar) []OrderByClause { + if g == nil { + return nil + } + clauses := make([]OrderByClause, 0, 1+len(g.Rest)) + clauses = append(clauses, lowerOrderByField(&g.First)) + for i := range g.Rest { + clauses = append(clauses, lowerOrderByField(&g.Rest[i])) + } + return clauses +} + +func lowerOrderByField(g *orderByField) OrderByClause { + desc := g.Direction != nil && *g.Direction == "desc" + return OrderByClause{Field: g.Field, Desc: desc} +} + // --- literal helpers --- func unquoteString(s string) string { diff --git a/ruki/parser_test.go b/ruki/parser_test.go index c857c27..5d03b0e 100644 --- a/ruki/parser_test.go +++ b/ruki/parser_test.go @@ -618,6 +618,109 @@ func TestParseStatementErrors(t *testing.T) { } } +func TestParseSelectOrderBy(t *testing.T) { + p := newTestParser() + + tests := []struct { + name string + input string + wantWhere bool + wantOrderBy []OrderByClause + }{ + { + "order by single field", + "select order by priority", + false, + []OrderByClause{{Field: "priority", Desc: false}}, + }, + { + "order by desc", + "select order by priority desc", + false, + []OrderByClause{{Field: "priority", Desc: true}}, + }, + { + "order by asc", + "select order by priority asc", + false, + []OrderByClause{{Field: "priority", Desc: false}}, + }, + { + "order by multiple fields", + "select order by priority desc, createdAt asc", + false, + []OrderByClause{ + {Field: "priority", Desc: true}, + {Field: "createdAt", Desc: false}, + }, + }, + { + "order by mixed directions", + "select order by status, priority desc, title", + false, + []OrderByClause{ + {Field: "status", Desc: false}, + {Field: "priority", Desc: true}, + {Field: "title", Desc: false}, + }, + }, + { + "where and order by", + `select where status = "done" order by updatedAt desc`, + true, + []OrderByClause{{Field: "updatedAt", Desc: true}}, + }, + { + "where and order by multiple", + `select where "bug" in tags order by priority asc, createdAt desc`, + true, + []OrderByClause{ + {Field: "priority", Desc: false}, + {Field: "createdAt", Desc: true}, + }, + }, + { + "select without order by still works", + `select where status = "done"`, + true, + nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt, err := p.ParseStatement(tt.input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if stmt.Select == nil { + t.Fatal("expected Select") + } + if tt.wantWhere && stmt.Select.Where == nil { + t.Fatal("expected Where condition") + } + if !tt.wantWhere && stmt.Select.Where != nil { + t.Fatal("unexpected Where condition") + } + if len(tt.wantOrderBy) == 0 && len(stmt.Select.OrderBy) != 0 { + t.Fatalf("expected no OrderBy, got %v", stmt.Select.OrderBy) + } + if len(tt.wantOrderBy) != len(stmt.Select.OrderBy) { + t.Fatalf("expected %d OrderBy clauses, got %d", len(tt.wantOrderBy), len(stmt.Select.OrderBy)) + } + for i, want := range tt.wantOrderBy { + got := stmt.Select.OrderBy[i] + if got.Field != want.Field { + t.Errorf("OrderBy[%d].Field = %q, want %q", i, got.Field, want.Field) + } + if got.Desc != want.Desc { + t.Errorf("OrderBy[%d].Desc = %v, want %v", i, got.Desc, want.Desc) + } + } + }) + } +} + func TestParseComment(t *testing.T) { p := newTestParser() diff --git a/ruki/validate.go b/ruki/validate.go index 3df6d11..27ab192 100644 --- a/ruki/validate.go +++ b/ruki/validate.go @@ -60,9 +60,11 @@ func (p *Parser) validateStatement(s *Statement) error { return p.validateCondition(s.Delete.Where) case s.Select != nil: if s.Select.Where != nil { - return p.validateCondition(s.Select.Where) + if err := p.validateCondition(s.Select.Where); err != nil { + return err + } } - return nil + return p.validateOrderBy(s.Select.OrderBy) default: return fmt.Errorf("empty statement") } @@ -136,6 +138,39 @@ func (p *Parser) validateAssignments(assignments []Assignment) error { return nil } +// --- order by validation --- + +func (p *Parser) validateOrderBy(clauses []OrderByClause) error { + if len(clauses) == 0 { + return nil + } + seen := make(map[string]struct{}, len(clauses)) + for _, c := range clauses { + if _, dup := seen[c.Field]; dup { + return fmt.Errorf("duplicate field %q in order by", c.Field) + } + seen[c.Field] = struct{}{} + fs, ok := p.schema.Field(c.Field) + if !ok { + return fmt.Errorf("unknown field %q in order by", c.Field) + } + if !isOrderableType(fs.Type) { + return fmt.Errorf("cannot order by %s field %q", typeName(fs.Type), c.Field) + } + } + return nil +} + +func isOrderableType(t ValueType) bool { + switch t { + case ValueInt, ValueDate, ValueTimestamp, ValueDuration, + ValueString, ValueStatus, ValueTaskType, ValueID, ValueRef: + return true + default: + return false + } +} + // --- condition validation with type-checking --- func (p *Parser) validateCondition(c Condition) error { diff --git a/ruki/validate_test.go b/ruki/validate_test.go index bf7174e..30c25b4 100644 --- a/ruki/validate_test.go +++ b/ruki/validate_test.go @@ -1527,6 +1527,92 @@ func TestValidation_EnumInRejectsFieldRefs(t *testing.T) { } } +func TestValidation_OrderBy(t *testing.T) { + p := newTestParser() + + t.Run("valid cases", func(t *testing.T) { + valid := []struct { + name string + input string + }{ + {"int field", "select order by priority"}, + {"date field", "select order by due"}, + {"timestamp field", "select order by createdAt desc"}, + {"string field", "select order by title asc"}, + {"status field", "select order by status"}, + {"type field", "select order by type desc"}, + {"id field", "select order by id"}, + {"multiple fields", "select order by priority desc, createdAt"}, + {"with where", `select where status = "done" order by priority`}, + } + for _, tt := range valid { + t.Run(tt.name, func(t *testing.T) { + _, err := p.ParseStatement(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } + }) + + t.Run("invalid cases", func(t *testing.T) { + invalid := []struct { + name string + input string + wantErr string + }{ + { + "unknown field", + "select order by nonexistent", + "unknown field", + }, + { + "list not orderable", + "select order by tags", + "cannot order by", + }, + { + "list not orderable", + "select order by dependsOn", + "cannot order by", + }, + { + "recurrence not orderable", + "select order by recurrence", + "cannot order by", + }, + { + "duplicate field", + "select order by priority, priority desc", + "duplicate field", + }, + } + for _, tt := range invalid { + t.Run(tt.name, func(t *testing.T) { + _, err := p.ParseStatement(tt.input) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err) + } + }) + } + }) +} + +func TestValidation_OrderByInSubquery(t *testing.T) { + p := newTestParser() + + _, err := p.ParseStatement(`select where count(select where status = "done" order by priority) > 0`) + if err == nil { + t.Fatal("expected error for order by inside subquery") + } + if !strings.Contains(err.Error(), "order by is not valid inside a subquery") { + t.Fatalf("expected subquery error, got: %v", err) + } +} + func TestValidation_ListAssignmentRejectsFieldRefs(t *testing.T) { p := newTestParser()