select field list

This commit is contained in:
booleanmaybe 2026-04-03 14:35:46 -04:00
parent 2d9d81b741
commit 308c1f8a5e
12 changed files with 294 additions and 21 deletions

View file

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

View file

@ -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 (260460) === -->
<!-- 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 (490680) === -->
<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 (710830) === -->
<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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: `\.`},

View file

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

View file

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

View file

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

View file

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

View file

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