Skip to content

Commit a8e0358

Browse files
committed
First implementation of the library
Signed-off-by: Max Lambrecht <[email protected]>
1 parent e901019 commit a8e0358

File tree

14 files changed

+797
-2
lines changed

14 files changed

+797
-2
lines changed

Diff for: .github/workflows/ci.yml

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Checkout repository
15+
uses: actions/checkout@v4
16+
17+
- name: Set up Go
18+
uses: actions/setup-go@v5
19+
with:
20+
go-version-file: 'go.mod'
21+
22+
- name: Verify module dependencies
23+
run: go mod tidy && git diff --exit-code
24+
25+
- name: Run go fmt
26+
run: go fmt ./...
27+
28+
- name: Lint with golangci-lint
29+
uses: golangci/golangci-lint-action@v6
30+
with:
31+
version: v1.64.8
32+
33+
- name: Run tests with coverage
34+
run: go test -v -coverprofile=coverage.out ./...
35+
36+
- name: Upload Coverage to Coveralls
37+
uses: coverallsapp/github-action@v2
38+
with:
39+
github-token: ${{ secrets.GITHUB_TOKEN }}
40+
path-to-lcov: coverage.out

Diff for: Makefile

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
GO := go
2+
3+
.PHONY: test
4+
test:
5+
$(GO) test ./...
6+
7+
.PHONY: deps
8+
deps:
9+
$(GO) mod tidy

Diff for: README.md

+40-2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,40 @@
1-
# odatasql
2-
Convert OData filter expressions to SQL WHERE clauses in Go.
1+
# ODataSQL - Convert OData Filters to SQL
2+
3+
**ODataSQL** is a Go library that converts **OData filter expressions into SQL WHERE clauses** while preserving operator
4+
precedence.
5+
6+
## 🔹 Usage
7+
8+
```go
9+
func main() {
10+
sql, err := odatasql.Convert("name eq 'Alice' and age gt 30")
11+
if err != nil {
12+
log.Fatal(err)
13+
}
14+
fmt.Println(sql) // Output: name = 'Alice' AND age > 30
15+
}
16+
```
17+
18+
## 🛠 Supported Operators
19+
20+
| OData | SQL | Example OData | SQL Output |
21+
|-------|-------|------------------------------------|----------------------------------|
22+
| eq | `=` | `firtsName eq 'Bob'` | `first_name = 'Bob'` |
23+
| ne | `!=` | `status ne 'active'` | `status != 'active'` |
24+
| gt | `>` | `age gt 18` | `age > 18` |
25+
| ge | `>=` | `height ge 170` | `height >= 170` |
26+
| lt | `<` | `score lt 50` | `score < 50` |
27+
| le | `<=` | `price le 99.99` | `price <= 99.99` |
28+
| and | `AND` | `age gt 18 and status eq 'active'` | `age > 18 AND status = 'active'` |
29+
| or | `OR` | `age lt 18 or premium eq true` | `age < 18 OR premium = true` |
30+
| not | `NOT` | `not age gt 18` | `NOT age > 18` |
31+
| in | `IN` | `color in ('red', 'blue')` | `color IN ('red', 'blue')` |
32+
33+
## 📂 Running Examples
34+
35+
```sh
36+
go run examples/basic/basic.go
37+
go run examples/in_operator/in_operator.go
38+
go run examples/logical_operators/logical_operators.go
39+
go run examples/precedence/precedence.go
40+
```

Diff for: examples/basic/basic.go

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"log"
6+
7+
"github.com/maxlambrecht/odatasql"
8+
)
9+
10+
// Basic example of converting an OData filter into an SQL WHERE clause.
11+
func main() {
12+
sql, err := odatasql.Convert("name eq 'Alice' and age gt 30")
13+
if err != nil {
14+
log.Fatal(err)
15+
}
16+
fmt.Println(sql)
17+
}

Diff for: examples/in_operator/in_operator.go

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"github.com/maxlambrecht/odatasql"
6+
"log"
7+
)
8+
9+
func main() {
10+
sql, err := odatasql.Convert("color in ('red', 'blue') and category eq 'electronics'")
11+
if err != nil {
12+
log.Fatal(err)
13+
}
14+
fmt.Println(sql)
15+
}

Diff for: examples/logical_operators/logical_operators.go

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"github.com/maxlambrecht/odatasql"
6+
"log"
7+
)
8+
9+
func main() {
10+
sql, err := odatasql.Convert("age ge 30 or (status eq 'active' and premium eq true)")
11+
if err != nil {
12+
log.Fatal(err)
13+
}
14+
fmt.Println(sql)
15+
}

Diff for: examples/precedence/precedence.go

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"github.com/maxlambrecht/odatasql"
6+
"log"
7+
)
8+
9+
// Demonstrates how operator precedence and explicit parentheses are preserved.
10+
func main() {
11+
sql, err := odatasql.Convert("not (age gt 25 and status eq 'active') or premium eq true")
12+
if err != nil {
13+
log.Fatal(err)
14+
}
15+
fmt.Println(sql)
16+
}

Diff for: go.mod

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/maxlambrecht/odatasql
2+
3+
go 1.24.1

Diff for: internal/ast.go

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package internal
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
const (
9+
opAnd = "AND"
10+
opOr = "OR"
11+
opNot = "NOT"
12+
opIn = "IN"
13+
)
14+
15+
// Node represents any part of the parsed expression.
16+
type Node interface {
17+
// ToSQL generates the SQL snippet for the node.
18+
// The level parameter indicates nesting for internal use.
19+
ToSQL(level int) string
20+
}
21+
22+
// BinaryNode represents an expression combining two subexpressions with "AND" or "OR".
23+
type BinaryNode struct {
24+
Op string // "AND" or "OR"
25+
Left, Right Node
26+
}
27+
28+
// ToSQL converts a BinaryNode to its SQL representation.
29+
func (b *BinaryNode) ToSQL(level int) string {
30+
left := b.Left.ToSQL(level + 1)
31+
right := b.Right.ToSQL(level + 1)
32+
// For binary nodes, if not wrapped explicitly then add parentheses for nested expressions.
33+
if level > 0 {
34+
return fmt.Sprintf("(%s %s %s)", left, b.Op, right)
35+
}
36+
return fmt.Sprintf("%s %s %s", left, b.Op, right)
37+
}
38+
39+
// NotNode represents a "NOT" operation.
40+
type NotNode struct {
41+
Child Node
42+
}
43+
44+
func (n *NotNode) ToSQL(level int) string {
45+
child := n.Child.ToSQL(level + 1)
46+
// For a NOT node, always add parentheses for nested expressions.
47+
if level > 0 {
48+
return fmt.Sprintf("(%s %s)", opNot, child)
49+
}
50+
return fmt.Sprintf("%s %s", opNot, child)
51+
}
52+
53+
// ConditionNode represents a simple binary condition like "field = value".
54+
type ConditionNode struct {
55+
Field, Op, Value string
56+
}
57+
58+
func (c *ConditionNode) ToSQL(_ int) string {
59+
return fmt.Sprintf("%s %s %s", c.Field, c.Op, c.Value)
60+
}
61+
62+
// InNode represents an IN operator condition.
63+
type InNode struct {
64+
Field string
65+
Values []string
66+
}
67+
68+
func (i *InNode) ToSQL(_ int) string {
69+
return fmt.Sprintf("%s %s (%s)", i.Field, opIn, strings.Join(i.Values, ", "))
70+
}
71+
72+
// ParenNode represents an expression that was explicitly parenthesized in the input.
73+
type ParenNode struct {
74+
Child Node
75+
}
76+
77+
func (p *ParenNode) ToSQL(level int) string {
78+
// Always emit the surrounding parentheses regardless of level.
79+
// We call Child.ToSQL with level 0 so that inner nodes don't remove their grouping.
80+
return fmt.Sprintf("(%s)", p.Child.ToSQL(0))
81+
}

0 commit comments

Comments
 (0)