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 @@
+
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 @@
+
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 @@
+
\ 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 @@
+
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 @@
+
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 @@
+
\ 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 @@
+
\ 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"
```
+
+
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`
+
+
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 ;
```
+
+
+
+
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 ;
```
+
+
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 ;
```
+
+
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
+
+
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()
]