Skip to content

Commit f55e721

Browse files
authored
Add GORM dialector and quickstart coverage (#56)
* Add GORM dialector and quickstart coverage Introduce a Pinot GORM dialector with read-only driver, add example and tests, and wire the CI integration workflow to exercise GORM against quickstart. * Fix lint issues and review feedback Handle uint overflow in row conversion, clean up unused helpers, and satisfy lint checks for errcheck and goimports grouping. * Improve test coverage for gormpinot and pinot Add tests for dialector, driver, and migrator helpers, exercise connection factory branches, and cover prepared statement errors.
1 parent d74a89b commit f55e721

24 files changed

Lines changed: 1825 additions & 64 deletions

.github/workflows/tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ jobs:
8888
CONTROLLER_PORT_FORWARD: 9000
8989
BROKER_PORT_FORWARD: 8000
9090

91+
- name: GORM Example
92+
run: go run ./examples/gorm-example
93+
9194
- name: Integration Test
9295
run: make integration-test
9396
env:

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,45 @@ if err != nil {
167167
log.Infof("Query Stats: response time - %d ms, scanned docs - %d, total docs - %d", brokerResp.TimeUsedMs, brokerResp.NumDocsScanned, brokerResp.TotalDocs)
168168
```
169169

170+
## Query Pinot with GORM
171+
172+
Use the GORM dialector to build read-only queries against Pinot SQL:
173+
174+
```go
175+
conn, err := pinot.NewFromBrokerList([]string{"localhost:8000"})
176+
if err != nil {
177+
log.Error(err)
178+
}
179+
180+
db, err := gorm.Open(gormpinot.Open(gormpinot.Config{
181+
Conn: conn,
182+
DefaultTable: "baseballStats",
183+
}), &gorm.Config{})
184+
if err != nil {
185+
log.Error(err)
186+
}
187+
188+
type Player struct {
189+
PlayerName string `gorm:"column:playerName"`
190+
TeamID string `gorm:"column:teamID"`
191+
YearID int `gorm:"column:yearID"`
192+
HomeRuns int `gorm:"column:homeRuns"`
193+
}
194+
195+
var players []Player
196+
err = db.Table("baseballStats").
197+
Select("playerName, teamID, yearID, homeRuns").
198+
Where("teamID = ? AND yearID = ?", "OAK", 2004).
199+
Order("homeRuns DESC").
200+
Limit(5).
201+
Find(&players).Error
202+
if err != nil {
203+
log.Error(err)
204+
}
205+
```
206+
207+
See `examples/gorm-example/main.go` for a runnable example.
208+
170209
## Query Pinot with Multi-Stage Engine
171210

172211
Please see this [example](https://github.com/startreedata/pinot-client-go/blob/master/examples/multistage-quickstart/main.go) for your reference.

examples/gorm-example/main.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/startreedata/pinot-client-go/gormpinot"
7+
"github.com/startreedata/pinot-client-go/pinot"
8+
"gorm.io/gorm"
9+
)
10+
11+
type Player struct {
12+
PlayerName string `gorm:"column:playerName"`
13+
TeamID string `gorm:"column:teamID"`
14+
YearID int `gorm:"column:yearID"`
15+
HomeRuns int `gorm:"column:homeRuns"`
16+
}
17+
18+
func main() {
19+
conn, err := pinot.NewFromBrokerList([]string{"localhost:8000"})
20+
if err != nil {
21+
panic(err)
22+
}
23+
24+
db, err := gorm.Open(gormpinot.Open(gormpinot.Config{
25+
Conn: conn,
26+
DefaultTable: "baseballStats",
27+
}), &gorm.Config{})
28+
if err != nil {
29+
panic(err)
30+
}
31+
32+
var players []Player
33+
err = db.Table("baseballStats").
34+
Select("playerName, teamID, yearID, homeRuns").
35+
Where("teamID = ? AND yearID = ?", "OAK", 2004).
36+
Order("homeRuns DESC").
37+
Limit(5).
38+
Find(&players).Error
39+
if err != nil {
40+
panic(err)
41+
}
42+
43+
for _, player := range players {
44+
fmt.Printf("%s (%s) %d HR in %d\n", player.PlayerName, player.TeamID, player.HomeRuns, player.YearID)
45+
}
46+
}

go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@ require (
66
github.com/stretchr/testify v1.11.1
77
)
88

9+
require (
10+
github.com/jinzhu/inflection v1.0.0 // indirect
11+
github.com/jinzhu/now v1.1.5 // indirect
12+
golang.org/x/text v0.20.0 // indirect
13+
)
14+
915
require (
1016
github.com/davecgh/go-spew v1.1.1 // indirect
1117
github.com/pmezard/go-difflib v1.0.0 // indirect
1218
github.com/stretchr/objx v0.5.2 // indirect
1319
golang.org/x/sys v0.13.0 // indirect
1420
gopkg.in/yaml.v3 v3.0.1 // indirect
21+
gorm.io/gorm v1.31.1
1522
)
1623

1724
go 1.19

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
22
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
33
github.com/go-zookeeper/zk v1.0.4 h1:DPzxraQx7OrPyXq2phlGlNSIyWEsAox0RJmjTseMV6I=
44
github.com/go-zookeeper/zk v1.0.4/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
5+
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
6+
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
7+
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
8+
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
59
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
610
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
711
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
@@ -12,7 +16,11 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
1216
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
1317
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
1418
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
19+
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
20+
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
1521
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
1622
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
1723
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
1824
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
25+
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
26+
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

gormpinot/dialector.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package gormpinot
2+
3+
import (
4+
"database/sql"
5+
"errors"
6+
7+
"gorm.io/gorm"
8+
"gorm.io/gorm/callbacks"
9+
"gorm.io/gorm/clause"
10+
"gorm.io/gorm/logger"
11+
"gorm.io/gorm/schema"
12+
13+
"github.com/startreedata/pinot-client-go/pinot"
14+
)
15+
16+
// Config configures the Pinot GORM dialector.
17+
type Config struct {
18+
Conn *pinot.Connection
19+
DefaultTable string
20+
}
21+
22+
// Dialector is the GORM dialector for Pinot.
23+
type Dialector struct {
24+
config Config
25+
}
26+
27+
// Open returns a GORM dialector configured for Pinot.
28+
func Open(config Config) gorm.Dialector {
29+
return Dialector{config: config}
30+
}
31+
32+
// Name returns the dialector name.
33+
func (Dialector) Name() string {
34+
return "pinot"
35+
}
36+
37+
// Initialize wires the dialector into the GORM DB instance.
38+
func (d Dialector) Initialize(db *gorm.DB) error {
39+
if d.config.Conn == nil {
40+
return errors.New("pinot connection is required")
41+
}
42+
db.DisableAutomaticPing = true
43+
44+
connector := newConnector(d.config.Conn, d.config.DefaultTable)
45+
db.ConnPool = sql.OpenDB(connector)
46+
47+
callbacks.RegisterDefaultCallbacks(db, &callbacks.Config{
48+
CreateClauses: []string{"INSERT", "VALUES", "ON CONFLICT", "RETURNING"},
49+
UpdateClauses: []string{"UPDATE", "SET", "WHERE", "RETURNING"},
50+
DeleteClauses: []string{"DELETE", "FROM", "WHERE", "RETURNING"},
51+
LastInsertIDReversed: false,
52+
})
53+
return nil
54+
}
55+
56+
// Migrator returns a migrator that rejects schema operations.
57+
func (Dialector) Migrator(db *gorm.DB) gorm.Migrator {
58+
return unsupportedMigrator{db: db}
59+
}
60+
61+
// DataTypeOf returns an empty datatype since migrations are unsupported.
62+
func (Dialector) DataTypeOf(*schema.Field) string {
63+
return ""
64+
}
65+
66+
// DefaultValueOf returns DEFAULT for compatibility.
67+
func (Dialector) DefaultValueOf(*schema.Field) clause.Expression {
68+
return clause.Expr{SQL: "DEFAULT"}
69+
}
70+
71+
// BindVarTo writes a placeholder.
72+
func (Dialector) BindVarTo(writer clause.Writer, _ *gorm.Statement, _ interface{}) {
73+
writeByte(writer, '?')
74+
}
75+
76+
// QuoteTo quotes identifiers with double quotes.
77+
func (Dialector) QuoteTo(writer clause.Writer, str string) {
78+
var (
79+
underQuoted, selfQuoted bool
80+
continuousQuote int8
81+
shiftDelimiter int8
82+
)
83+
84+
for _, v := range []byte(str) {
85+
switch v {
86+
case '"':
87+
continuousQuote++
88+
if continuousQuote == 2 {
89+
writeString(writer, `""`)
90+
continuousQuote = 0
91+
}
92+
case '.':
93+
if continuousQuote > 0 || !selfQuoted {
94+
shiftDelimiter = 0
95+
underQuoted = false
96+
continuousQuote = 0
97+
writeByte(writer, '"')
98+
}
99+
writeByte(writer, v)
100+
continue
101+
default:
102+
if shiftDelimiter-continuousQuote <= 0 && !underQuoted {
103+
writeByte(writer, '"')
104+
underQuoted = true
105+
if selfQuoted = continuousQuote > 0; selfQuoted {
106+
continuousQuote--
107+
}
108+
}
109+
110+
for ; continuousQuote > 0; continuousQuote-- {
111+
writeString(writer, `""`)
112+
}
113+
114+
writeByte(writer, v)
115+
}
116+
shiftDelimiter++
117+
}
118+
119+
if continuousQuote > 0 && !selfQuoted {
120+
writeString(writer, `""`)
121+
}
122+
writeByte(writer, '"')
123+
}
124+
125+
func writeByte(writer clause.Writer, value byte) {
126+
//nolint:errcheck
127+
_ = writer.WriteByte(value)
128+
}
129+
130+
func writeString(writer clause.Writer, value string) {
131+
//nolint:errcheck
132+
_, _ = writer.WriteString(value)
133+
}
134+
135+
// Explain returns SQL with rendered parameters for logging.
136+
func (Dialector) Explain(sql string, vars ...interface{}) string {
137+
return logger.ExplainSQL(sql, nil, "'", vars...)
138+
}

gormpinot/dialector_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package gormpinot
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
"gorm.io/gorm"
9+
"gorm.io/gorm/clause"
10+
11+
"github.com/startreedata/pinot-client-go/pinot"
12+
)
13+
14+
func TestDialectorNameAndExplain(t *testing.T) {
15+
d := Dialector{}
16+
require.Equal(t, "pinot", d.Name())
17+
18+
explained := d.Explain("select * from foo where id = ?", 10)
19+
require.Contains(t, explained, "10")
20+
}
21+
22+
func TestDialectorQuoteTo(t *testing.T) {
23+
var buf bytes.Buffer
24+
d := Dialector{}
25+
d.QuoteTo(&buf, "my_table.my_column")
26+
require.Equal(t, `"my_table"."my_column"`, buf.String())
27+
}
28+
29+
func TestDialectorQuoteToEscapesQuotes(t *testing.T) {
30+
var buf bytes.Buffer
31+
d := Dialector{}
32+
d.QuoteTo(&buf, `weird"name`)
33+
require.Equal(t, `"weird""name"`, buf.String())
34+
}
35+
36+
func TestDialectorQuoteToSelfQuoted(t *testing.T) {
37+
var buf bytes.Buffer
38+
d := Dialector{}
39+
d.QuoteTo(&buf, `"quoted"`)
40+
require.Equal(t, `"quoted"`, buf.String())
41+
}
42+
43+
func TestDialectorQuoteToDoubleQuotes(t *testing.T) {
44+
var buf bytes.Buffer
45+
d := Dialector{}
46+
d.QuoteTo(&buf, `weird""name`)
47+
require.Equal(t, `"weird""name"`, buf.String())
48+
}
49+
50+
func TestDialectorBindVarTo(t *testing.T) {
51+
var buf bytes.Buffer
52+
d := Dialector{}
53+
d.BindVarTo(&buf, nil, nil)
54+
require.Equal(t, "?", buf.String())
55+
}
56+
57+
func TestDialectorInitializeRequiresConn(t *testing.T) {
58+
_, err := gorm.Open(Open(Config{}), &gorm.Config{})
59+
require.Error(t, err)
60+
}
61+
62+
func TestDialectorInitializeSuccess(t *testing.T) {
63+
_, err := gorm.Open(Open(Config{Conn: &pinot.Connection{}}), &gorm.Config{})
64+
require.NoError(t, err)
65+
}
66+
67+
func TestDialectorDefaultValueOf(t *testing.T) {
68+
d := Dialector{}
69+
expr := d.DefaultValueOf(nil)
70+
require.Equal(t, clause.Expr{SQL: "DEFAULT"}, expr)
71+
}
72+
73+
func TestDialectorDataTypeOf(t *testing.T) {
74+
d := Dialector{}
75+
require.Equal(t, "", d.DataTypeOf(nil))
76+
}
77+
78+
func TestDialectorMigrator(t *testing.T) {
79+
d := Dialector{}
80+
m := d.Migrator(&gorm.DB{})
81+
_, ok := m.(unsupportedMigrator)
82+
require.True(t, ok)
83+
}

gormpinot/doc.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Package gormpinot provides a GORM dialector that executes Pinot SQL over HTTP.
2+
//
3+
// Limitations:
4+
// - Read-only: INSERT/UPDATE/DELETE/DDL are not supported.
5+
// - Migrations are not supported.
6+
// - Broker selection uses Config.DefaultTable when provided; otherwise a best-effort
7+
// table name is inferred from the SQL or an empty table name is used.
8+
package gormpinot

0 commit comments

Comments
 (0)