Skip to content

Commit b62a09f

Browse files
Ajit Pratap Singhclaude
authored andcommitted
feat(integrations): add OpenTelemetry and GORM sub-modules (#451 #452)
- integrations/opentelemetry: InstrumentedParse() wraps gosqlx.Parse() with OTel spans (db.system, db.statement.type, db.sql.tables, db.sql.columns); errors recorded on span with status Error; 3 tests pass with race detector - integrations/gorm: Plugin struct implementing gorm.Plugin; afterStatement callback normalizes GORM SQL (backtick→double-quote, ?→$N) before parsing; Stats()/Reset() API; nil Statement guard; 5 tests pass with race detector - .github/workflows/integrations.yml: separate jobs for each sub-module - CHANGELOG.md: document new sub-modules under [Unreleased] Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e87c1dc commit b62a09f

10 files changed

Lines changed: 531 additions & 0 deletions

File tree

.github/workflows/integrations.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Integrations
2+
3+
on:
4+
push:
5+
paths:
6+
- 'integrations/**'
7+
- '.github/workflows/integrations.yml'
8+
pull_request:
9+
paths:
10+
- 'integrations/**'
11+
- '.github/workflows/integrations.yml'
12+
13+
jobs:
14+
opentelemetry:
15+
name: OpenTelemetry Integration
16+
runs-on: ubuntu-latest
17+
defaults:
18+
run:
19+
working-directory: integrations/opentelemetry
20+
steps:
21+
- uses: actions/checkout@v4
22+
- uses: actions/setup-go@v5
23+
with:
24+
go-version: '1.23'
25+
- run: go mod tidy
26+
- run: go test -race -timeout 60s ./...
27+
28+
gorm:
29+
name: GORM Integration
30+
runs-on: ubuntu-latest
31+
defaults:
32+
run:
33+
working-directory: integrations/gorm
34+
steps:
35+
- uses: actions/checkout@v4
36+
- uses: actions/setup-go@v5
37+
with:
38+
go-version: '1.23'
39+
- run: go mod tidy
40+
- run: go test -race -timeout 60s ./...

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111
- **MariaDB dialect** (`--dialect mariadb`): New SQL dialect extending MySQL with support for SEQUENCE DDL (`CREATE/DROP/ALTER SEQUENCE` with full option set), temporal tables (`FOR SYSTEM_TIME`, `WITH SYSTEM VERSIONING`, `PERIOD FOR`), and `CONNECT BY` hierarchical queries with `PRIOR`, `START WITH`, and `NOCYCLE`
12+
- `integrations/opentelemetry/` sub-module: `InstrumentedParse()` wraps `gosqlx.Parse()` with OpenTelemetry spans including `db.system`, `db.statement.type`, `db.sql.tables`, `db.sql.columns` attributes
13+
- `integrations/gorm/` sub-module: GORM plugin that records executed query metadata (tables, columns, statement type) via GoSQLX parsing with GORM SQL normalization (backtick identifiers, `?` placeholders); exposes `Stats()` and `Reset()` APIs
14+
- CI workflow for integration sub-modules (`.github/workflows/integrations.yml`)
1215

1316
## [1.13.0] - 2026-03-20
1417

integrations/gorm/go.mod

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
module github.com/ajitpratap0/GoSQLX/integrations/gorm
2+
3+
go 1.26.1
4+
5+
require (
6+
github.com/ajitpratap0/GoSQLX v1.13.0
7+
gorm.io/driver/sqlite v1.5.6
8+
gorm.io/gorm v1.25.10
9+
)
10+
11+
require (
12+
github.com/jinzhu/inflection v1.0.0 // indirect
13+
github.com/jinzhu/now v1.1.5 // indirect
14+
github.com/mattn/go-sqlite3 v1.14.22 // indirect
15+
)
16+
17+
replace github.com/ajitpratap0/GoSQLX => ../../

integrations/gorm/go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
2+
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
3+
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
4+
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
5+
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
6+
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
7+
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
8+
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
9+
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
10+
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=

integrations/gorm/plugin.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Package gosqlxgorm provides a GORM plugin that parses each executed query
2+
// with GoSQLX and records extracted metadata (tables, columns, statement type).
3+
package gosqlxgorm
4+
5+
import (
6+
"regexp"
7+
"strconv"
8+
"strings"
9+
"sync"
10+
11+
"github.com/ajitpratap0/GoSQLX/pkg/gosqlx"
12+
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
13+
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
14+
"gorm.io/gorm"
15+
)
16+
17+
// reQuestionMark replaces GORM's ? positional placeholders.
18+
var reQuestionMark = regexp.MustCompile(`\?`)
19+
20+
// QueryRecord holds metadata about a single recorded GORM query.
21+
type QueryRecord struct {
22+
SQL string
23+
Tables []string
24+
Columns []string
25+
Type string // SELECT, INSERT, UPDATE, DELETE, ...
26+
ParseOK bool
27+
}
28+
29+
// PluginStats is the aggregate of all queries observed since initialization.
30+
type PluginStats struct {
31+
TotalQueries int
32+
ParseErrors int
33+
Queries []QueryRecord
34+
}
35+
36+
// Plugin is a GORM plugin that parses each executed query with GoSQLX
37+
// and records extracted metadata (tables, columns, statement type).
38+
type Plugin struct {
39+
mu sync.Mutex
40+
queries []QueryRecord
41+
}
42+
43+
// NewPlugin returns a new GoSQLX GORM plugin.
44+
func NewPlugin() *Plugin { return &Plugin{} }
45+
46+
// Name implements gorm.Plugin.
47+
func (p *Plugin) Name() string { return "gosqlx" }
48+
49+
// Initialize implements gorm.Plugin by registering after-callbacks.
50+
func (p *Plugin) Initialize(db *gorm.DB) error {
51+
db.Callback().Query().After("gorm:query").Register("gosqlx:after_query", p.afterStatement)
52+
db.Callback().Create().After("gorm:create").Register("gosqlx:after_create", p.afterStatement)
53+
db.Callback().Update().After("gorm:update").Register("gosqlx:after_update", p.afterStatement)
54+
db.Callback().Delete().After("gorm:delete").Register("gosqlx:after_delete", p.afterStatement)
55+
db.Callback().Raw().After("gorm:raw").Register("gosqlx:after_raw", p.afterStatement)
56+
return nil
57+
}
58+
59+
func (p *Plugin) afterStatement(db *gorm.DB) {
60+
// Guard against nil Statement — this can happen during initialization callbacks.
61+
if db.Statement == nil {
62+
return
63+
}
64+
sql := db.Statement.SQL.String()
65+
if sql == "" {
66+
return
67+
}
68+
69+
rec := QueryRecord{SQL: sql}
70+
71+
// Normalize GORM-generated SQL for GoSQLX compatibility:
72+
// 1. Replace backtick-quoted identifiers with double-quoted identifiers
73+
// (GORM SQLite/MySQL driver uses backticks; GoSQLX standard mode uses double-quotes).
74+
// 2. Replace ? positional placeholders with $N (PostgreSQL style).
75+
normalized := normalizeSQLForParsing(sql)
76+
77+
// Try PostgreSQL dialect (handles double-quoted identifiers and $N placeholders),
78+
// then fall back to standard SQL parsing.
79+
tree, err := gosqlx.ParseWithDialect(normalized, keywords.DialectPostgreSQL)
80+
if err != nil {
81+
tree, err = gosqlx.Parse(normalized)
82+
}
83+
if err != nil {
84+
rec.ParseOK = false
85+
} else {
86+
rec.ParseOK = true
87+
rec.Tables = gosqlx.ExtractTables(tree)
88+
rec.Columns = gosqlx.ExtractColumns(tree)
89+
if tree != nil && len(tree.Statements) > 0 {
90+
rec.Type = stmtTypeName(tree.Statements[0])
91+
}
92+
}
93+
94+
p.mu.Lock()
95+
p.queries = append(p.queries, rec)
96+
p.mu.Unlock()
97+
}
98+
99+
// normalizeSQLForParsing converts GORM-generated SQL into a form that GoSQLX
100+
// can parse: backtick identifiers become double-quoted, and ? placeholders
101+
// become $N placeholders.
102+
func normalizeSQLForParsing(sql string) string {
103+
// Replace backtick-quoted identifiers with double-quoted identifiers.
104+
sql = strings.ReplaceAll(sql, "`", "\"")
105+
// Replace ? positional placeholders with $N.
106+
n := 0
107+
sql = reQuestionMark.ReplaceAllStringFunc(sql, func(string) string {
108+
n++
109+
return "$" + strconv.Itoa(n)
110+
})
111+
return sql
112+
}
113+
114+
// Stats returns a snapshot of all recorded queries.
115+
func (p *Plugin) Stats() PluginStats {
116+
p.mu.Lock()
117+
defer p.mu.Unlock()
118+
var errCount int
119+
for _, q := range p.queries {
120+
if !q.ParseOK {
121+
errCount++
122+
}
123+
}
124+
qs := make([]QueryRecord, len(p.queries))
125+
copy(qs, p.queries)
126+
return PluginStats{
127+
TotalQueries: len(p.queries),
128+
ParseErrors: errCount,
129+
Queries: qs,
130+
}
131+
}
132+
133+
// Reset clears all recorded queries.
134+
func (p *Plugin) Reset() {
135+
p.mu.Lock()
136+
p.queries = p.queries[:0]
137+
p.mu.Unlock()
138+
}
139+
140+
// stmtTypeName returns a human-readable SQL statement type name.
141+
func stmtTypeName(stmt ast.Statement) string {
142+
switch stmt.(type) {
143+
case *ast.SelectStatement:
144+
return "SELECT"
145+
case *ast.InsertStatement:
146+
return "INSERT"
147+
case *ast.UpdateStatement:
148+
return "UPDATE"
149+
case *ast.DeleteStatement:
150+
return "DELETE"
151+
default:
152+
return "OTHER"
153+
}
154+
}

integrations/gorm/plugin_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package gosqlxgorm_test
2+
3+
import (
4+
"testing"
5+
6+
gosqlxgorm "github.com/ajitpratap0/GoSQLX/integrations/gorm"
7+
"gorm.io/driver/sqlite"
8+
"gorm.io/gorm"
9+
"gorm.io/gorm/logger"
10+
)
11+
12+
type User struct {
13+
gorm.Model
14+
Name string
15+
Email string
16+
}
17+
18+
type Order struct {
19+
gorm.Model
20+
UserID uint
21+
Total float64
22+
}
23+
24+
func openTestDB(t *testing.T) *gorm.DB {
25+
t.Helper()
26+
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
27+
Logger: logger.Default.LogMode(logger.Silent),
28+
})
29+
if err != nil {
30+
t.Fatalf("open gorm db: %v", err)
31+
}
32+
_ = db.AutoMigrate(&User{}, &Order{})
33+
return db
34+
}
35+
36+
func TestPlugin_Name(t *testing.T) {
37+
plugin := gosqlxgorm.NewPlugin()
38+
if plugin.Name() != "gosqlx" {
39+
t.Errorf("plugin name: got %q want gosqlx", plugin.Name())
40+
}
41+
}
42+
43+
func TestPlugin_Initialize_NoError(t *testing.T) {
44+
db := openTestDB(t)
45+
plugin := gosqlxgorm.NewPlugin()
46+
if err := db.Use(plugin); err != nil {
47+
t.Fatalf("Use plugin: %v", err)
48+
}
49+
}
50+
51+
func TestPlugin_RecordsQueriesOnQuery(t *testing.T) {
52+
db := openTestDB(t)
53+
plugin := gosqlxgorm.NewPlugin()
54+
if err := db.Use(plugin); err != nil {
55+
t.Fatal(err)
56+
}
57+
58+
var users []User
59+
db.Find(&users)
60+
61+
stats := plugin.Stats()
62+
if stats.TotalQueries == 0 {
63+
t.Error("expected at least one recorded query")
64+
}
65+
}
66+
67+
func TestPlugin_RecordsTableName(t *testing.T) {
68+
db := openTestDB(t)
69+
plugin := gosqlxgorm.NewPlugin()
70+
_ = db.Use(plugin)
71+
72+
var users []User
73+
db.Where("name = ?", "alice").Find(&users)
74+
75+
stats := plugin.Stats()
76+
found := false
77+
for _, q := range stats.Queries {
78+
for _, tbl := range q.Tables {
79+
if tbl == "users" {
80+
found = true
81+
}
82+
}
83+
}
84+
if !found {
85+
t.Errorf("expected 'users' in recorded table names; got %+v", stats.Queries)
86+
}
87+
}
88+
89+
func TestPlugin_ParseErrorDoesNotPanic(t *testing.T) {
90+
db := openTestDB(t)
91+
plugin := gosqlxgorm.NewPlugin()
92+
_ = db.Use(plugin)
93+
94+
// Raw SQL that might not parse perfectly — plugin must not panic
95+
var result int
96+
db.Raw("SELECT 1 + 1").Scan(&result)
97+
98+
// No panic = success
99+
}

integrations/opentelemetry/go.mod

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
module github.com/ajitpratap0/GoSQLX/integrations/opentelemetry
2+
3+
go 1.26.1
4+
5+
require (
6+
github.com/ajitpratap0/GoSQLX v1.13.0
7+
go.opentelemetry.io/otel v1.26.0
8+
go.opentelemetry.io/otel/sdk v1.26.0
9+
go.opentelemetry.io/otel/trace v1.26.0
10+
)
11+
12+
require (
13+
github.com/go-logr/logr v1.4.1 // indirect
14+
github.com/go-logr/stdr v1.2.2 // indirect
15+
go.opentelemetry.io/otel/metric v1.26.0 // indirect
16+
golang.org/x/sys v0.20.0 // indirect
17+
)
18+
19+
replace github.com/ajitpratap0/GoSQLX => ../../

integrations/opentelemetry/go.sum

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
4+
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
5+
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
6+
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
7+
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
8+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
9+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
10+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
11+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
12+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
13+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
14+
go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs=
15+
go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4=
16+
go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30=
17+
go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4=
18+
go.opentelemetry.io/otel/sdk v1.26.0 h1:Y7bumHf5tAiDlRYFmGqetNcLaVUZmh4iYfmGxtmz7F8=
19+
go.opentelemetry.io/otel/sdk v1.26.0/go.mod h1:0p8MXpqLeJ0pzcszQQN4F0S5FVjBLgypeGSngLsmirs=
20+
go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA=
21+
go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0=
22+
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
23+
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
24+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
25+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)