Merge pull request #77 from boolean-maybe/feature/ruki-limit

add limit clause
This commit is contained in:
boolean-maybe 2026-04-14 23:35:20 -04:00 committed by GitHub
commit 80444f1848
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 216 additions and 7 deletions

View file

@ -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

View file

@ -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:

View file

@ -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 <fields> where <condition> | run(<command>)
select <fields> where <condition> | clipboard()
select <fields> where <condition> [order by ...] [limit N] | run(<command>)
select <fields> where <condition> [order by ...] [limit N] | clipboard()
```
### `| run(...)` — shell execution

View file

@ -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:

View file

@ -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:

View file

@ -13,11 +13,12 @@ type Statement struct {
Delete *DeleteStmt
}
// SelectStmt represents "select [fields] [where <condition>] [order by <field> [asc|desc], ...] [| run(...) | clipboard()]".
// SelectStmt represents "select [fields] [where <condition>] [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()"
}

View file

@ -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:

View file

@ -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) {

View file

@ -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 {

View file

@ -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",

View file

@ -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},

View file

@ -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) {

View file

@ -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()

View file

@ -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) {

View file

@ -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 {

View file

@ -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,

View file

@ -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: