mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
select field list
This commit is contained in:
parent
2d9d81b741
commit
308c1f8a5e
12 changed files with 294 additions and 21 deletions
|
|
@ -28,6 +28,15 @@ Select with a basic filter:
|
|||
select where status = "done" and priority <= 2
|
||||
```
|
||||
|
||||
Select specific fields:
|
||||
|
||||
```sql
|
||||
select title, status
|
||||
select id, title where status = "done"
|
||||
select * where priority <= 2
|
||||
select title, status where "bug" in tags order by priority
|
||||
```
|
||||
|
||||
Select with ordering:
|
||||
|
||||
```sql
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 560">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1020 560">
|
||||
<!--
|
||||
Grid system for Ruki railroad diagrams (transparent/dark-bg compatible)
|
||||
========================================
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
<g transform="translate(0, 8)">
|
||||
|
||||
<!-- ==================== selectStmt ==================== -->
|
||||
<!-- Main line at y=50, bypass above at y=10 -->
|
||||
<!-- Main line at y=50, field-list bypass above at y=10, loopback below at y=90 -->
|
||||
<g transform="translate(0, 30)">
|
||||
<!-- label background pill -->
|
||||
<g class="label-group">
|
||||
|
|
@ -65,30 +65,81 @@
|
|||
<rect width="72" height="32" rx="16"/>
|
||||
<text x="36" y="16" text-anchor="middle" dominant-baseline="central">select</text>
|
||||
</g>
|
||||
<line class="track" x1="242" y1="50" x2="280" y2="50"/>
|
||||
<line class="track" x1="242" y1="50" x2="260" y2="50"/>
|
||||
|
||||
<!-- main path: where + condition -->
|
||||
<g class="terminal" transform="translate(280, 34)">
|
||||
<!-- === optional field list group (260–460) === -->
|
||||
|
||||
<!-- main path: * terminal -->
|
||||
<line class="track" x1="260" y1="50" x2="290" y2="50"/>
|
||||
<g class="terminal" transform="translate(290, 34)">
|
||||
<rect width="32" height="32" rx="16"/>
|
||||
<text x="16" y="16" text-anchor="middle" dominant-baseline="central">*</text>
|
||||
</g>
|
||||
<line class="track" x1="322" y1="50" x2="460" y2="50"/>
|
||||
|
||||
<!-- alternate path below: identifier { , identifier } at y=90 -->
|
||||
<path class="track" d="M 270,50 C 270,70 280,90 300,90"/>
|
||||
<g class="nonterminal" transform="translate(300, 74)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
|
||||
</g>
|
||||
<line class="track" x1="400" y1="90" x2="440" y2="90"/>
|
||||
<path class="track" d="M 440,90 C 450,90 460,80 460,50"/>
|
||||
|
||||
<!-- loopback below identifier: , identifier at y=130 -->
|
||||
<path class="track" d="M 430,90 C 430,110 420,130 400,130"/>
|
||||
<g class="terminal" transform="translate(356, 114)">
|
||||
<rect width="32" height="32" rx="16"/>
|
||||
<text x="16" y="16" text-anchor="middle" dominant-baseline="central">,</text>
|
||||
</g>
|
||||
<line class="track" x1="356" y1="130" x2="340" y2="130"/>
|
||||
<g class="nonterminal" transform="translate(230, 114)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
|
||||
</g>
|
||||
<path class="track" d="M 230,130 C 220,130 210,110 210,100"/>
|
||||
<!-- arrowhead pointing up at the rejoin into the identifier path -->
|
||||
<path d="M 205,100 L 210,90 L 215,100 z" fill="#94A3B8"/>
|
||||
|
||||
<!-- bypass path above: skip field list entirely at y=10 -->
|
||||
<path class="track" d="M 260,50 C 260,30 270,10 290,10 L 430,10 C 450,10 460,30 460,50"/>
|
||||
|
||||
<!-- === end field list group === -->
|
||||
<line class="track" x1="460" y1="50" x2="490" y2="50"/>
|
||||
|
||||
<!-- === optional where + condition group (490–680) === -->
|
||||
<g class="terminal" transform="translate(490, 34)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">where</text>
|
||||
</g>
|
||||
<line class="track" x1="350" y1="50" x2="380" y2="50"/>
|
||||
<g class="nonterminal" transform="translate(380, 34)">
|
||||
<line class="track" x1="560" y1="50" x2="580" y2="50"/>
|
||||
<g class="nonterminal" transform="translate(580, 34)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
|
||||
</g>
|
||||
<line class="track" x1="480" y1="50" x2="540" y2="50"/>
|
||||
<line class="track" x1="680" y1="50" x2="710" y2="50"/>
|
||||
|
||||
<!-- bypass path above: skip where+condition -->
|
||||
<path class="track" d="M 270,50 C 270,20 280,10 300,10 L 510,10 C 530,10 540,20 540,50"/>
|
||||
<!-- bypass above: skip where+condition -->
|
||||
<path class="track" d="M 480,50 C 480,20 490,10 510,10 L 680,10 C 700,10 710,20 710,50"/>
|
||||
|
||||
<!-- === optional orderBy group (710–830) === -->
|
||||
<line class="track" x1="710" y1="50" x2="740" y2="50"/>
|
||||
<g class="nonterminal" transform="translate(740, 34)">
|
||||
<rect width="88" height="32" rx="4"/>
|
||||
<text x="44" y="16" text-anchor="middle" dominant-baseline="central">orderBy</text>
|
||||
</g>
|
||||
<line class="track" x1="828" y1="50" x2="860" y2="50"/>
|
||||
|
||||
<!-- bypass above: skip orderBy -->
|
||||
<path class="track" d="M 730,50 C 730,20 740,10 760,10 L 830,10 C 850,10 860,20 860,50"/>
|
||||
|
||||
<!-- exit dot with double circle -->
|
||||
<circle class="track-dot" cx="540" cy="50" r="4"/>
|
||||
<circle cx="540" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
<circle class="track-dot" cx="860" cy="50" r="4"/>
|
||||
<circle cx="860" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between selectStmt and createStmt -->
|
||||
<line class="separator" x1="10" y1="140" x2="810" y2="140"/>
|
||||
<line class="separator" x1="10" y1="140" x2="1010" y2="140"/>
|
||||
|
||||
<!-- ==================== createStmt ==================== -->
|
||||
<!-- Main line at y=32, loopback below at y=72 -->
|
||||
|
|
@ -129,7 +180,7 @@
|
|||
</g>
|
||||
|
||||
<!-- separator between createStmt and updateStmt -->
|
||||
<line class="separator" x1="10" y1="270" x2="810" y2="270"/>
|
||||
<line class="separator" x1="10" y1="270" x2="1010" y2="270"/>
|
||||
|
||||
<!-- ==================== updateStmt ==================== -->
|
||||
<!-- Main line at y=32, loopback below at y=76 -->
|
||||
|
|
@ -190,7 +241,7 @@
|
|||
</g>
|
||||
|
||||
<!-- separator between updateStmt and deleteStmt -->
|
||||
<line class="separator" x1="10" y1="400" x2="810" y2="400"/>
|
||||
<line class="separator" x1="10" y1="400" x2="1010" y2="400"/>
|
||||
|
||||
<!-- ==================== deleteStmt ==================== -->
|
||||
<g transform="translate(0, 420)">
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -37,7 +37,8 @@ The simplest way to read Ruki is:
|
|||
|
||||
```sql
|
||||
select
|
||||
select where status = "done"
|
||||
select title, status
|
||||
select id, title where status = "done"
|
||||
select where "bug" in tags and priority <= 2
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ Ruki uses these token classes:
|
|||
- strings: double-quoted strings with backslash escapes
|
||||
- comparison operators: `=`, `!=`, `<`, `>`, `<=`, `>=`
|
||||
- binary operators: `+`, `-`
|
||||
- star: `*`
|
||||
- punctuation: `.`, `(`, `)`, `[`, `]`, `,`
|
||||
- identifiers: `[a-zA-Z_][a-zA-Z0-9_]*`
|
||||
|
||||
|
|
@ -47,7 +48,8 @@ The following EBNF-style summary shows the grammar:
|
|||
```text
|
||||
statement = selectStmt | createStmt | updateStmt | deleteStmt ;
|
||||
|
||||
selectStmt = "select" [ "where" condition ] [ orderBy ] ;
|
||||
selectStmt = "select" [ fieldList | "*" ] [ "where" condition ] [ orderBy ] ;
|
||||
fieldList = identifier { "," identifier } ;
|
||||
createStmt = "create" assignment { assignment } ;
|
||||
|
||||
orderBy = "order" "by" sortField { "," sortField } ;
|
||||
|
|
@ -78,6 +80,7 @@ Notes:
|
|||
- `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.
|
||||
- Bare `select` and `select *` both mean all fields. A field list like `select title, status` projects only the named fields.
|
||||
|
||||
## Condition grammar
|
||||
|
||||
|
|
@ -120,6 +123,15 @@ select where dependsOn any status != "done"
|
|||
select where not (status = "done" or priority = 1)
|
||||
```
|
||||
|
||||
Field list:
|
||||
|
||||
```sql
|
||||
select title, status
|
||||
select id, title where status = "done"
|
||||
select * where priority <= 2
|
||||
select title, status where "bug" in tags order by priority
|
||||
```
|
||||
|
||||
Order by:
|
||||
|
||||
```sql
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ type Statement struct {
|
|||
Delete *DeleteStmt
|
||||
}
|
||||
|
||||
// SelectStmt represents "select [where <condition>] [order by <field> [asc|desc], ...]".
|
||||
// SelectStmt represents "select [fields] [where <condition>] [order by <field> [asc|desc], ...]".
|
||||
type SelectStmt struct {
|
||||
Fields []string // nil = all ("select" or "select *"); non-nil = specific fields
|
||||
Where Condition // nil = select all
|
||||
OrderBy []OrderByClause // nil = unordered
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,16 @@ type statementGrammar struct {
|
|||
Delete *deleteGrammar `parser:"| @@"`
|
||||
}
|
||||
|
||||
type fieldNamesGrammar struct {
|
||||
First string `parser:"@Ident"`
|
||||
Rest []string `parser:"( ',' @Ident )*"`
|
||||
}
|
||||
|
||||
type selectGrammar struct {
|
||||
Where *orCond `parser:"'select' ( 'where' @@ )?"`
|
||||
OrderBy *orderByGrammar `parser:"@@?"`
|
||||
Star *string `parser:"'select' ( @Star"`
|
||||
Fields *fieldNamesGrammar `parser:" | @@ )?"`
|
||||
Where *orCond `parser:"( 'where' @@ )?"`
|
||||
OrderBy *orderByGrammar `parser:"@@?"`
|
||||
}
|
||||
|
||||
// --- order by grammar ---
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ var rukiLexer = lexer.MustSimple([]lexer.SimpleRule{
|
|||
{Name: "Int", Pattern: `\d+`},
|
||||
{Name: "String", Pattern: `"(?:[^"\\]|\\.)*"`},
|
||||
{Name: "CompareOp", Pattern: `!=|<=|>=|[=<>]`},
|
||||
{Name: "Star", Pattern: `\*`},
|
||||
{Name: "Plus", Pattern: `\+`},
|
||||
{Name: "Minus", Pattern: `-`},
|
||||
{Name: "Dot", Pattern: `\.`},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,52 @@ func TestTokenizeWordBoundary(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTokenizeStar(t *testing.T) {
|
||||
symbols := rukiLexer.Symbols()
|
||||
starType := symbols["Star"]
|
||||
keywordType := symbols["Keyword"]
|
||||
identType := symbols["Ident"]
|
||||
|
||||
t.Run("bare star", func(t *testing.T) {
|
||||
tokens := tokenize(t, "*")
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("expected 1 token, got %d: %v", len(tokens), tokens)
|
||||
}
|
||||
if tokens[0].Type != starType {
|
||||
t.Errorf("expected Star token, got type %d", tokens[0].Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("star among keywords", func(t *testing.T) {
|
||||
tokens := tokenize(t, "select * where")
|
||||
if len(tokens) != 3 {
|
||||
t.Fatalf("expected 3 tokens, got %d: %v", len(tokens), tokens)
|
||||
}
|
||||
if tokens[0].Type != keywordType {
|
||||
t.Errorf("expected Keyword for 'select', got type %d", tokens[0].Type)
|
||||
}
|
||||
if tokens[1].Type != starType {
|
||||
t.Errorf("expected Star for '*', got type %d", tokens[1].Type)
|
||||
}
|
||||
if tokens[2].Type != keywordType {
|
||||
t.Errorf("expected Keyword for 'where', got type %d", tokens[2].Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("star not consumed as ident", func(t *testing.T) {
|
||||
tokens := tokenize(t, "*foo")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("expected 2 tokens, got %d: %v", len(tokens), tokens)
|
||||
}
|
||||
if tokens[0].Type != starType {
|
||||
t.Errorf("expected Star for '*', got type %d", tokens[0].Type)
|
||||
}
|
||||
if tokens[1].Type != identType {
|
||||
t.Errorf("expected Ident for 'foo', got type %d", tokens[1].Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKeywordInIdentPosition_ParseError(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,13 @@ func lowerStatement(g *statementGrammar) (*Statement, error) {
|
|||
}
|
||||
|
||||
func lowerSelect(g *selectGrammar) (*SelectStmt, error) {
|
||||
var fields []string
|
||||
if g.Star == nil && g.Fields != nil {
|
||||
fields = make([]string, 0, 1+len(g.Fields.Rest))
|
||||
fields = append(fields, g.Fields.First)
|
||||
fields = append(fields, g.Fields.Rest...)
|
||||
}
|
||||
|
||||
var where Condition
|
||||
if g.Where != nil {
|
||||
var err error
|
||||
|
|
@ -50,7 +57,7 @@ func lowerSelect(g *selectGrammar) (*SelectStmt, error) {
|
|||
}
|
||||
}
|
||||
orderBy := lowerOrderBy(g.OrderBy)
|
||||
return &SelectStmt{Where: where, OrderBy: orderBy}, nil
|
||||
return &SelectStmt{Fields: fields, Where: where, OrderBy: orderBy}, nil
|
||||
}
|
||||
|
||||
func lowerCreate(g *createGrammar) (*CreateStmt, error) {
|
||||
|
|
|
|||
|
|
@ -721,6 +721,96 @@ func TestParseSelectOrderBy(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseSelectFields(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantFields []string // nil = all fields
|
||||
wantWhere bool
|
||||
wantOrderBy int
|
||||
}{
|
||||
{"bare select", "select", nil, false, 0},
|
||||
{"select star", "select *", nil, false, 0},
|
||||
{"single field", "select title", []string{"title"}, false, 0},
|
||||
{"two fields", "select id, title", []string{"id", "title"}, false, 0},
|
||||
{"many fields", "select id, title, status, priority", []string{"id", "title", "status", "priority"}, false, 0},
|
||||
{"fields + where", `select title, status where priority = 1`, []string{"title", "status"}, true, 0},
|
||||
{"single field + where", `select title where status = "done"`, []string{"title"}, true, 0},
|
||||
{"fields + order by", "select title order by priority", []string{"title"}, false, 1},
|
||||
{"fields + where + order by", `select id, title where status = "done" order by priority desc`, []string{"id", "title"}, true, 1},
|
||||
{"star + where", `select * where status = "done"`, nil, true, 0},
|
||||
{"star + order by", "select * order by title", nil, false, 1},
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// check fields
|
||||
if tt.wantFields == nil {
|
||||
if stmt.Select.Fields != nil {
|
||||
t.Fatalf("expected nil Fields (all), got %v", stmt.Select.Fields)
|
||||
}
|
||||
} else {
|
||||
if len(stmt.Select.Fields) != len(tt.wantFields) {
|
||||
t.Fatalf("expected %d fields, got %d: %v", len(tt.wantFields), len(stmt.Select.Fields), stmt.Select.Fields)
|
||||
}
|
||||
for i, want := range tt.wantFields {
|
||||
if stmt.Select.Fields[i] != want {
|
||||
t.Errorf("Fields[%d] = %q, want %q", i, stmt.Select.Fields[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check where
|
||||
if tt.wantWhere && stmt.Select.Where == nil {
|
||||
t.Fatal("expected Where condition")
|
||||
}
|
||||
if !tt.wantWhere && stmt.Select.Where != nil {
|
||||
t.Fatal("unexpected Where condition")
|
||||
}
|
||||
|
||||
// check order by
|
||||
if len(stmt.Select.OrderBy) != tt.wantOrderBy {
|
||||
t.Fatalf("expected %d OrderBy clauses, got %d", tt.wantOrderBy, len(stmt.Select.OrderBy))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSelectFieldsErrors(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"trailing comma", "select title,"},
|
||||
{"leading comma", "select , title"},
|
||||
{"star + named fields", "select *, title"},
|
||||
{"named fields + star", "select title, *"},
|
||||
{"double star", "select * *"},
|
||||
{"comma only", "select ,"},
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ func (p *Parser) validateStatement(s *Statement) error {
|
|||
case s.Delete != nil:
|
||||
return p.validateCondition(s.Delete.Where)
|
||||
case s.Select != nil:
|
||||
if err := p.validateSelectFields(s.Select.Fields); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Select.Where != nil {
|
||||
if err := p.validateCondition(s.Select.Where); err != nil {
|
||||
return err
|
||||
|
|
@ -138,6 +141,25 @@ func (p *Parser) validateAssignments(assignments []Assignment) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// --- select field validation ---
|
||||
|
||||
func (p *Parser) validateSelectFields(fields []string) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(fields))
|
||||
for _, f := range fields {
|
||||
if _, dup := seen[f]; dup {
|
||||
return fmt.Errorf("duplicate field %q in select", f)
|
||||
}
|
||||
seen[f] = struct{}{}
|
||||
if _, ok := p.schema.Field(f); !ok {
|
||||
return fmt.Errorf("unknown field %q in select", f)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- order by validation ---
|
||||
|
||||
func (p *Parser) validateOrderBy(clauses []OrderByClause) error {
|
||||
|
|
|
|||
|
|
@ -1527,6 +1527,32 @@ func TestValidation_EnumInRejectsFieldRefs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidation_SelectFields(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{"unknown field", "select foo", `unknown field "foo" in select`},
|
||||
{"duplicate field", "select title, title", `duplicate field "title" in select`},
|
||||
{"unknown among valid", "select title, foo", `unknown field "foo" in select`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
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_OrderBy(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue