mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
add limit clause
This commit is contained in:
parent
ae3634da1b
commit
31cfd46453
17 changed files with 216 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue