diff --git a/.doc/doki/doc/ruki/examples.md b/.doc/doki/doc/ruki/examples.md index cbafdce..9954b88 100644 --- a/.doc/doki/doc/ruki/examples.md +++ b/.doc/doki/doc/ruki/examples.md @@ -45,6 +45,14 @@ select where status = "done" order by updatedAt desc select where "bug" in tags order by priority asc, createdAt desc ``` +Select with limit: + +```sql +select order by priority limit 3 +select where "bug" in tags order by priority limit 5 +select id, title order by priority limit 2 | clipboard() +``` + Create a tiki: ```sql diff --git a/.doc/doki/doc/ruki/quick-start.md b/.doc/doki/doc/ruki/quick-start.md index 05df155..7b68dcc 100644 --- a/.doc/doki/doc/ruki/quick-start.md +++ b/.doc/doki/doc/ruki/quick-start.md @@ -40,6 +40,7 @@ select select title, status select id, title where status = "done" select where "bug" in tags and priority <= 2 +select where status != "done" order by priority limit 3 ``` `create` writes one or more assignments: diff --git a/.doc/doki/doc/ruki/semantics.md b/.doc/doki/doc/ruki/semantics.md index 3361c97..345f5cf 100644 --- a/.doc/doki/doc/ruki/semantics.md +++ b/.doc/doki/doc/ruki/semantics.md @@ -31,6 +31,12 @@ This page explains how `ruki` statements, triggers, conditions, and expressions - Duplicate fields are rejected. - Only bare field names are allowed — `old.` and `new.` qualifiers are not valid in `order by`. +`limit` + +- Must be a positive integer. +- Applied after filtering and sorting, before any pipe action. +- If the limit exceeds the result count, all results are returned (no error). + `create` - `create` is a list of assignments. @@ -172,8 +178,8 @@ For the detailed type rules and built-ins, see [Types And Values](types-and-valu `select` statements may include an optional pipe suffix: ```text -select where | run() -select where | clipboard() +select where [order by ...] [limit N] | run() +select where [order by ...] [limit N] | clipboard() ``` ### `| run(...)` — shell execution diff --git a/.doc/doki/doc/ruki/syntax.md b/.doc/doki/doc/ruki/syntax.md index aadd84a..f911cd6 100644 --- a/.doc/doki/doc/ruki/syntax.md +++ b/.doc/doki/doc/ruki/syntax.md @@ -48,7 +48,7 @@ The following EBNF-style summary shows the grammar: ```text statement = selectStmt | createStmt | updateStmt | deleteStmt ; -selectStmt = "select" [ fieldList | "*" ] [ "where" condition ] [ orderBy ] [ pipeAction ] ; +selectStmt = "select" [ fieldList | "*" ] [ "where" condition ] [ orderBy ] [ "limit" int ] [ pipeAction ] ; fieldList = identifier { "," identifier } ; pipeAction = "|" ( runAction | clipboardAction ) ; clipboardAction = "clipboard" "(" ")" ; @@ -83,7 +83,8 @@ Notes: - `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. +- `limit` truncates the result set to at most N rows, applied after filtering and sorting but before any pipe action. +- `asc`, `desc`, `order`, `by`, and `limit` are contextual keywords — they are only special in the SELECT clause. - Bare `select` and `select *` both mean all fields. A field list like `select title, status` projects only the named fields. - `every` wraps a CRUD statement with a periodic interval. Only `create`, `update`, and `delete` are allowed @@ -145,6 +146,14 @@ select where status = "done" order by updatedAt desc select where "bug" in tags order by priority asc, createdAt desc ``` +Limit: + +```sql +select order by priority limit 3 +select where status != "done" order by priority limit 5 +select limit 1 +``` + ## Expression grammar Expressions support literals, field references, qualifiers, function calls, list literals, parenthesized expressions, subqueries, and left-associative `+` or `-` chains: diff --git a/.doc/doki/doc/ruki/validation-and-errors.md b/.doc/doki/doc/ruki/validation-and-errors.md index 9205352..3268d51 100644 --- a/.doc/doki/doc/ruki/validation-and-errors.md +++ b/.doc/doki/doc/ruki/validation-and-errors.md @@ -9,6 +9,7 @@ - [Type and operator errors](#type-and-operator-errors) - [Enum and list errors](#enum-and-list-errors) - [Order by errors](#order-by-errors) +- [Limit errors](#limit-errors) - [Built-in and subquery errors](#built-in-and-subquery-errors) ## Overview @@ -216,6 +217,22 @@ Order by inside a subquery: select where count(select where status = "done" order by priority) >= 1 ``` +## Limit errors + +Validation error (must be positive): + +```sql +select limit 0 +``` + +Parse errors (invalid token after `limit`): + +```sql +select limit -1 +select limit "three" +select limit +``` + ## Pipe validation errors Pipe actions (`| run(...)` and `| clipboard()`) on `select` have several restrictions: diff --git a/ruki/ast.go b/ruki/ast.go index ebb8587..f84c4b4 100644 --- a/ruki/ast.go +++ b/ruki/ast.go @@ -13,11 +13,12 @@ type Statement struct { Delete *DeleteStmt } -// SelectStmt represents "select [fields] [where ] [order by [asc|desc], ...] [| run(...) | clipboard()]". +// SelectStmt represents "select [fields] [where ] [order by ...] [limit N] [| run(...) | clipboard()]". type SelectStmt struct { Fields []string // nil = all ("select" or "select *"); non-nil = specific fields Where Condition // nil = select all OrderBy []OrderByClause // nil = unordered + Limit *int // nil = no limit; positive = max result count Pipe *PipeAction // optional pipe suffix: "| run(...)" or "| clipboard()" } diff --git a/ruki/executor.go b/ruki/executor.go index e496479..e54fa2d 100644 --- a/ruki/executor.go +++ b/ruki/executor.go @@ -153,6 +153,10 @@ func (e *Executor) executeSelect(sel *SelectStmt, tasks []*task.Task) (*Result, e.sortTasks(filtered, sel.OrderBy) } + if sel.Limit != nil && *sel.Limit < len(filtered) { + filtered = filtered[:*sel.Limit] + } + if sel.Pipe != nil { switch { case sel.Pipe.Run != nil: diff --git a/ruki/executor_test.go b/ruki/executor_test.go index 6a9e914..076b0c6 100644 --- a/ruki/executor_test.go +++ b/ruki/executor_test.go @@ -299,6 +299,72 @@ func TestExecuteSelectNoOrderByPreservesInputOrder(t *testing.T) { } } +// --- limit --- + +func TestExecuteSelectLimit(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + tasks := makeTasks() + + tests := []struct { + name string + input string + wantIDs []string + }{ + { + "limit fewer than available", + "select order by priority limit 2", + []string{"TIKI-000002", "TIKI-000001"}, + }, + { + "limit equal to count", + "select limit 4", + []string{"TIKI-000001", "TIKI-000002", "TIKI-000003", "TIKI-000004"}, + }, + { + "limit greater than count", + "select limit 100", + []string{"TIKI-000001", "TIKI-000002", "TIKI-000003", "TIKI-000004"}, + }, + { + "limit 1", + "select order by priority limit 1", + []string{"TIKI-000002"}, + }, + { + "limit with where", + "select where priority <= 2 order by priority limit 1", + []string{"TIKI-000002"}, + }, + { + "limit without order by", + "select limit 2", + []string{"TIKI-000001", "TIKI-000002"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt, err := p.ParseStatement(tt.input) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != len(tt.wantIDs) { + t.Fatalf("expected %d tasks, got %d", len(tt.wantIDs), len(result.Select.Tasks)) + } + for i, wantID := range tt.wantIDs { + if result.Select.Tasks[i].ID != wantID { + t.Errorf("task[%d].ID = %q, want %q", i, result.Select.Tasks[i].ID, wantID) + } + } + }) + } +} + // --- enum normalization --- func TestExecuteEnumNormalization(t *testing.T) { diff --git a/ruki/grammar.go b/ruki/grammar.go index db9eab7..4dbe1b3 100644 --- a/ruki/grammar.go +++ b/ruki/grammar.go @@ -23,9 +23,16 @@ type selectGrammar struct { Fields *fieldNamesGrammar `parser:" | @@ )?"` Where *orCond `parser:"( 'where' @@ )?"` OrderBy *orderByGrammar `parser:"@@?"` + Limit *limitGrammar `parser:"@@?"` Pipe *pipeTargetGrammar `parser:"( Pipe @@ )?"` } +// --- limit grammar --- + +type limitGrammar struct { + Value int `parser:"'limit' @Int"` +} + // --- order by grammar --- type orderByGrammar struct { diff --git a/ruki/keyword/keyword.go b/ruki/keyword/keyword.go index 05b7042..85e8eb8 100644 --- a/ruki/keyword/keyword.go +++ b/ruki/keyword/keyword.go @@ -6,7 +6,7 @@ import "strings" var reserved = [...]string{ "select", "create", "update", "delete", "where", "set", "order", "by", - "asc", "desc", + "asc", "desc", "limit", "before", "after", "deny", "run", "every", "clipboard", "and", "or", "not", diff --git a/ruki/keyword/keyword_test.go b/ruki/keyword/keyword_test.go index e581b20..527fb72 100644 --- a/ruki/keyword/keyword_test.go +++ b/ruki/keyword/keyword_test.go @@ -19,6 +19,8 @@ func TestIsReserved(t *testing.T) { {"new", true}, {"clipboard", true}, {"CLIPBOARD", true}, + {"limit", true}, + {"LIMIT", true}, {"title", false}, {"priority", false}, {"foobar", false}, diff --git a/ruki/lower.go b/ruki/lower.go index fef4cb9..51fbec5 100644 --- a/ruki/lower.go +++ b/ruki/lower.go @@ -65,7 +65,12 @@ func lowerSelect(g *selectGrammar) (*SelectStmt, error) { } } orderBy := lowerOrderBy(g.OrderBy) - return &SelectStmt{Fields: fields, Where: where, OrderBy: orderBy}, nil + var limit *int + if g.Limit != nil { + v := g.Limit.Value + limit = &v + } + return &SelectStmt{Fields: fields, Where: where, OrderBy: orderBy, Limit: limit}, nil } func lowerPipeTarget(g *pipeTargetGrammar) (*PipeAction, error) { diff --git a/ruki/parser_test.go b/ruki/parser_test.go index c64be9c..813df3b 100644 --- a/ruki/parser_test.go +++ b/ruki/parser_test.go @@ -834,6 +834,28 @@ func TestParseSelectFieldsErrors(t *testing.T) { } } +func TestParseLimitErrors(t *testing.T) { + p := newTestParser() + + tests := []struct { + name string + input string + }{ + {"limit without value", "select limit"}, + {"limit with string", `select limit "three"`}, + {"limit with negative", "select limit -1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := p.ParseStatement(tt.input) + if err == nil { + t.Fatalf("expected parse error for %q, got nil", tt.input) + } + }) + } +} + func TestParseComment(t *testing.T) { p := newTestParser() diff --git a/ruki/pipe_test.go b/ruki/pipe_test.go index 479707d..36ff6cf 100644 --- a/ruki/pipe_test.go +++ b/ruki/pipe_test.go @@ -526,6 +526,37 @@ func TestExecuteClipboardMultipleRows(t *testing.T) { } } +// --- limit + pipe --- + +func TestExecuteClipboardWithLimit(t *testing.T) { + e := NewExecutor(testSchema{}, nil, ExecutorRuntime{Mode: ExecutorRuntimeCLI}) + p := newTestParser() + tasks := makeTasks() + + stmt, err := p.ParseStatement(`select id, priority order by priority limit 2 | clipboard()`) + if err != nil { + t.Fatalf("parse: %v", err) + } + + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result.Clipboard == nil { + t.Fatal("expected Clipboard result") + } + if len(result.Clipboard.Rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(result.Clipboard.Rows)) + } + // sorted by priority asc: TIKI-000002 (pri 1), TIKI-000001 (pri 2) + if result.Clipboard.Rows[0][0] != "TIKI-000002" { + t.Errorf("row[0][0] = %q, want %q", result.Clipboard.Rows[0][0], "TIKI-000002") + } + if result.Clipboard.Rows[1][0] != "TIKI-000001" { + t.Errorf("row[1][0] = %q, want %q", result.Clipboard.Rows[1][0], "TIKI-000001") + } +} + // --- pipeArgString --- func TestPipeArgString(t *testing.T) { diff --git a/ruki/semantic_validate.go b/ruki/semantic_validate.go index e74c7a9..00f60f3 100644 --- a/ruki/semantic_validate.go +++ b/ruki/semantic_validate.go @@ -588,6 +588,10 @@ func cloneSelect(sel *SelectStmt) *SelectStmt { Where: cloneCondition(sel.Where), OrderBy: orderBy, } + if sel.Limit != nil { + v := *sel.Limit + out.Limit = &v + } if sel.Pipe != nil { out.Pipe = &PipeAction{} if sel.Pipe.Run != nil { diff --git a/ruki/validate.go b/ruki/validate.go index e5617b5..f915674 100644 --- a/ruki/validate.go +++ b/ruki/validate.go @@ -70,6 +70,9 @@ func (p *Parser) validateStatement(s *Statement) error { if err := p.validateOrderBy(s.Select.OrderBy); err != nil { return err } + if err := p.validateLimit(s.Select.Limit); err != nil { + return err + } if s.Select.Pipe != nil { if len(s.Select.Fields) == 0 { return fmt.Errorf("pipe requires explicit field names in select (not select * or bare select)") @@ -233,6 +236,18 @@ func (p *Parser) validateOrderBy(clauses []OrderByClause) error { return nil } +// --- limit validation --- + +func (p *Parser) validateLimit(limit *int) error { + if limit == nil { + return nil + } + if *limit <= 0 { + return fmt.Errorf("limit must be a positive integer, got %d", *limit) + } + return nil +} + func isOrderableType(t ValueType) bool { switch t { case ValueInt, ValueDate, ValueTimestamp, ValueDuration, diff --git a/ruki/validate_test.go b/ruki/validate_test.go index 5d93a1e..cfa7ecc 100644 --- a/ruki/validate_test.go +++ b/ruki/validate_test.go @@ -2495,6 +2495,17 @@ func TestValidation_BlocksNonLiteralString(t *testing.T) { } } +func TestValidation_LimitZero(t *testing.T) { + p := newTestParser() + _, err := p.ParseStatement("select limit 0") + if err == nil { + t.Fatal("expected error for limit 0") + } + if !strings.Contains(err.Error(), "limit must be a positive integer, got 0") { + t.Fatalf("expected limit validation error, got: %v", err) + } +} + func TestValidation_InExprListElementTypeError(t *testing.T) { p := newTestParser() // construct a case where inferListElementType fails: