Skip to content

Commit 4c8b1c1

Browse files
authored
feat: add advanced search using Couchbase FTS (#122)
* integrate couchbase FTS * bump up go version in CI * move query-parser to the project folder * ignore parser gen tests * update go version and api version * bump up golangci-lint to 1.63
1 parent b9e5ae3 commit 4c8b1c1

File tree

15 files changed

+1036
-16
lines changed

15 files changed

+1036
-16
lines changed

.github/workflows/ci.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ jobs:
77
name: Build & Test Go Package
88
strategy:
99
matrix:
10-
go-version: [1.21.x]
10+
go-version: [1.23.x]
1111
os: [ubuntu-latest]
1212
runs-on: ${{ matrix.os }}
1313
steps:
@@ -25,7 +25,7 @@ jobs:
2525
go build -v ./...
2626
2727
- name: Test With Coverage
28-
run: go test ./... -race -coverprofile=coverage -covermode=atomic
28+
run: go test ./... -race -coverprofile=coverage -covermode=atomic -skip TestGenerate
2929

3030
- name: Upload coverage to Codecov
3131
uses: codecov/codecov-action@v4
@@ -35,7 +35,7 @@ jobs:
3535
- name: golangci-lint
3636
uses: golangci/golangci-lint-action@v6
3737
with:
38-
version: v1.59
38+
version: v1.63
3939

4040
build-container:
4141
runs-on: ubuntu-20.04

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# STEP 1 build executable binary
33
################################
44

5-
FROM golang:1.21-alpine AS build-stage
5+
FROM golang:1.23-alpine AS build-stage
66

77
# Install git + SSL ca certificates.
88
# Git is required for fetching the dependencies.
@@ -34,7 +34,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo \
3434

3535
FROM alpine:latest
3636
LABEL maintainer="https://github.com/saferwall/saferwall-api"
37-
LABEL version="0.8.0"
37+
LABEL version="0.9.0"
3838
LABEL description="Saferwall web APIs service"
3939

4040
ENV USER=saferwall

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.8.0
1+
0.9.0

cmd/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"os/signal"
1414
"time"
1515

16+
"github.com/MicahParks/recaptcha"
1617
"github.com/go-playground/locales/en"
1718
ut "github.com/go-playground/universal-translator"
1819
"github.com/go-playground/validator/v10"
@@ -29,11 +30,10 @@ import (
2930
tpl "github.com/saferwall/saferwall-api/internal/template"
3031
"github.com/saferwall/saferwall-api/pkg/log"
3132
"github.com/yeka/zip"
32-
"github.com/MicahParks/recaptcha"
3333
)
3434

3535
// Version indicates the current version of the application.
36-
var Version = "0.8.0"
36+
var Version = "0.9.0"
3737

3838
var flagConfig = flag.String("config", "./../configs/", "path to the config file")
3939
var flagN1QLFiles = flag.String("db", "./../db/", "path to the n1ql files")

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/saferwall/saferwall-api
22

3-
go 1.21
3+
go 1.23.0
44

55
require (
66
github.com/MicahParks/recaptcha v0.0.5
@@ -15,6 +15,7 @@ require (
1515
github.com/labstack/echo/v4 v4.9.1
1616
github.com/minio/minio-go/v7 v7.0.73
1717
github.com/nsqio/go-nsq v1.1.0
18+
github.com/saferwall/advanced-search v0.0.0-20250120184926-f1a096cecd50
1819
github.com/spf13/viper v1.19.0
1920
github.com/stretchr/testify v1.9.0
2021
github.com/swaggo/swag v1.16.3

go.sum

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/db/db.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ import (
88
"context"
99
"encoding/json"
1010
"errors"
11+
"fmt"
1112
"math/rand"
1213
"strings"
1314
"time"
1415

1516
gocb "github.com/couchbase/gocb/v2"
16-
"github.com/couchbase/gocb/v2/search"
17+
"github.com/saferwall/saferwall-api/internal/query-parser/gen"
1718
)
1819

1920
const (
@@ -248,15 +249,18 @@ func shortID(length int) string {
248249
return string(b)
249250
}
250251

251-
func (db *DB) Search(ctx context.Context, val *interface{}, totalHits *uint64) error {
252+
func (db *DB) Search(ctx context.Context, stringQuery string, val *interface{}, totalHits *uint64) error {
252253

253-
queryOne := search.NewMatchQuery("spyware").Field("multiav.last_scan.avast.output")
254-
queryTwo := search.NewTermQuery("exe").Field("file_extension")
255-
query := search.NewConjunctionQuery(queryOne, queryTwo)
254+
fmt.Printf("Query: %v", stringQuery)
255+
query, err := gen.Generate(stringQuery)
256+
if err != nil {
257+
panic(err.Error())
258+
// return err
259+
}
256260

257261
// sfw._default.sfw_fts
258262
result, err := db.Cluster.SearchQuery(
259-
"multiav_fts", query,
263+
"sfw._default.sfw_fts", query,
260264
&gocb.SearchOptions{
261265
Limit: 100,
262266
Fields: []string{"size", "file_extension", "file_format", "first_seen", "last_scan", "tags.packer", "tags.pe",

internal/file/repository.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ func (r repository) MetaUI(ctx context.Context, id string) (
259259
func (r repository) Search(ctx context.Context, input FileSearchRequest) (FileSearchResponse, error) {
260260

261261
resp := FileSearchResponse{}
262-
err := r.db.Search(ctx, &resp.Results, &resp.TotalHits)
262+
err := r.db.Search(ctx, input.Query, &resp.Results, &resp.TotalHits)
263263
if err != nil {
264264
return resp, err
265265
}

internal/query-parser/gen/gen.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package gen
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
7+
"github.com/couchbase/gocb/v2/search"
8+
"github.com/saferwall/advanced-search/gen"
9+
"github.com/saferwall/advanced-search/parser"
10+
"github.com/saferwall/advanced-search/token"
11+
"github.com/saferwall/saferwall-api/internal/query-parser/lexer"
12+
)
13+
14+
func Generate(input string) (search.Query, error) {
15+
l := lexer.New(input)
16+
var tokens []*token.Token
17+
for tok := l.NextToken(); tok.Type != token.EOF; tok = l.NextToken() {
18+
tokCopy := tok
19+
tokens = append(tokens, &tokCopy)
20+
}
21+
22+
p := parser.New(tokens)
23+
expr, err := p.Parse()
24+
if err != nil {
25+
return nil, err
26+
}
27+
result, err := gen.GenerateCouchbaseFTS(expr)
28+
if err != nil {
29+
return nil, err
30+
}
31+
return result, nil
32+
}
33+
34+
func GenerateCouchbaseFTS(expr parser.Expression) (search.Query, error) {
35+
switch e := expr.(type) {
36+
case *parser.BinaryExpression:
37+
return generateBinaryCouchbase(e)
38+
case *parser.ComparisonExpression:
39+
return generateComparisonCouchbase(e)
40+
default:
41+
return nil, fmt.Errorf("unsupported expression type: %T", expr)
42+
}
43+
}
44+
45+
func generateBinaryCouchbase(expr *parser.BinaryExpression) (search.Query, error) {
46+
left, err := GenerateCouchbaseFTS(expr.Left)
47+
if err != nil {
48+
return nil, err
49+
}
50+
51+
right, err := GenerateCouchbaseFTS(expr.Right)
52+
if err != nil {
53+
return nil, err
54+
}
55+
56+
switch expr.Operator.Type {
57+
case token.AND:
58+
return search.NewConjunctionQuery(left, right), nil
59+
60+
case token.OR:
61+
return search.NewDisjunctionQuery(left, right), nil
62+
default:
63+
return nil, fmt.Errorf("unsupported operator type: %T", expr.Operator.Type)
64+
}
65+
}
66+
67+
func generateComparisonCouchbase(expr *parser.ComparisonExpression) (search.Query, error) {
68+
// NOTE: might need to support term match query
69+
70+
switch expr.Operator.Type {
71+
case token.ASSIGN:
72+
return search.NewMatchQuery(expr.Right).Field(expr.Left), nil
73+
case token.NOT_EQ:
74+
return search.NewBooleanQuery().MustNot(search.NewMatchQuery(expr.Right).Field(expr.Left)), nil
75+
case token.GT, token.GE, token.LT, token.LE:
76+
return generateRangeQuery(expr)
77+
default:
78+
return nil, fmt.Errorf("unsupported comparison operator: %s", expr.Operator.Type)
79+
}
80+
}
81+
82+
func generateRangeQuery(expr *parser.ComparisonExpression) (search.Query, error) {
83+
switch expr.Operator.Type {
84+
case token.GT, token.GE:
85+
isInclusive := expr.Operator.Type == token.GE
86+
if v, ok := isValidF32(expr.Right); ok {
87+
return search.NewNumericRangeQuery().Field(expr.Left).Min(v, isInclusive), nil
88+
} else if lexer.IsISODate(expr.Right) {
89+
return search.NewDateRangeQuery().Field(expr.Left).Start(expr.Right, isInclusive), nil
90+
} else {
91+
return search.NewTermRangeQuery(expr.Left).Min(expr.Right, isInclusive), nil
92+
}
93+
94+
case token.LT, token.LE:
95+
isInclusive := expr.Operator.Type == token.LE
96+
if v, ok := isValidF32(expr.Right); ok {
97+
return search.NewNumericRangeQuery().Field(expr.Left).Max(v, isInclusive), nil
98+
} else if lexer.IsISODate(expr.Right) {
99+
return search.NewDateRangeQuery().Field(expr.Left).End(expr.Right, isInclusive), nil
100+
} else {
101+
return search.NewTermRangeQuery(expr.Left).Max(expr.Right, isInclusive), nil
102+
}
103+
}
104+
105+
return nil, fmt.Errorf("unsupported range operator: %s", expr.Operator.Type)
106+
}
107+
108+
func isValidF32(s string) (float32, bool) {
109+
// Attempt to parse the string as a float64
110+
value, err := strconv.ParseFloat(s, 32)
111+
// Check for parsing errors and ensure it fits in float32 range
112+
if err == nil {
113+
// Convert the value to float32 and back to float64 for precision check
114+
f32Value := float32(value)
115+
if float64(f32Value) == value {
116+
return f32Value, true
117+
}
118+
}
119+
return 0, false
120+
}

internal/query-parser/gen/gen_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package gen
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
"github.com/couchbase/gocb/v2/search"
8+
)
9+
10+
func TestGenerate(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
input string
14+
wantErr bool
15+
expected search.Query
16+
}{
17+
{
18+
name: "simple equality",
19+
input: "size=100",
20+
wantErr: false,
21+
expected: search.NewMatchQuery("100").
22+
Field("size"),
23+
},
24+
{
25+
name: "simple inequality",
26+
input: "size!=100",
27+
wantErr: false,
28+
expected: search.NewBooleanQuery().
29+
MustNot(search.NewMatchQuery("100").Field("size")),
30+
},
31+
{
32+
name: "numeric greater than",
33+
input: "size>100",
34+
wantErr: false,
35+
expected: search.NewNumericRangeQuery().
36+
Field("size").
37+
Min(float32(100), false),
38+
},
39+
{
40+
name: "numeric greater than or equal",
41+
input: "size>=100",
42+
wantErr: false,
43+
expected: search.NewNumericRangeQuery().
44+
Field("size").
45+
Min(float32(100), true),
46+
},
47+
{
48+
name: "date comparison",
49+
input: "created>2024-01-01",
50+
wantErr: false,
51+
expected: search.NewDateRangeQuery().
52+
Field("created").
53+
Start("2024-01-01", false),
54+
},
55+
{
56+
name: "string range",
57+
input: "name>alice",
58+
wantErr: false,
59+
expected: search.NewTermRangeQuery("name").
60+
Min("alice", false),
61+
},
62+
{
63+
name: "AND operation",
64+
input: "size=100 AND name=test",
65+
wantErr: false,
66+
expected: search.NewConjunctionQuery(
67+
search.NewMatchQuery("100").Field("size"),
68+
search.NewMatchQuery("test").Field("name"),
69+
),
70+
},
71+
{
72+
name: "OR operation",
73+
input: "size=100 OR name=test",
74+
wantErr: false,
75+
expected: search.NewDisjunctionQuery(
76+
search.NewMatchQuery("100").Field("size"),
77+
search.NewMatchQuery("test").Field("name"),
78+
),
79+
},
80+
{
81+
name: "invalid syntax",
82+
input: "size=",
83+
wantErr: true,
84+
},
85+
}
86+
87+
for _, tt := range tests {
88+
t.Run(tt.name, func(t *testing.T) {
89+
query, err := Generate(tt.input)
90+
if tt.wantErr {
91+
if err == nil {
92+
t.Error("expected error, got nil")
93+
}
94+
return
95+
}
96+
if err != nil {
97+
t.Errorf("unexpected error: %v", err)
98+
return
99+
}
100+
if !reflect.DeepEqual(query, tt.expected) {
101+
t.Errorf("\nexpected: %#v\ngot: %#v", tt.expected, query)
102+
}
103+
})
104+
}
105+
}

0 commit comments

Comments
 (0)