Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ dsn := "sqlserver://gorm:LoremIpsum86@localhost:9930?database=gorm"
db, err := gorm.Open(sqlserver.Open(dsn), &gorm.Config{})
```

## Pagination compatibility

This driver detects the SQL Server version at connection time.

- SQL Server 2012 and newer use the native `OFFSET ... FETCH NEXT` pagination syntax.
- SQL Server 2008 / 2008 R2 and earlier automatically fallback to a legacy pagination strategy built from
`TOP()` and `ROW_NUMBER()` over the generated query.

This keeps the GORM `LIMIT`/`OFFSET` semantics working across mixed SQL Server installations
without changing application code.

## Azure AD Auth

```go
Expand Down
147 changes: 135 additions & 12 deletions sqlserver.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package sqlserver

import (
"context"
"database/sql"
"fmt"
"regexp"
Expand All @@ -21,6 +22,9 @@ type Config struct {
DSN string
DefaultStringSize int
Conn gorm.ConnPool

serverMajorVersion int
hasServerVersion bool
}

type Dialector struct {
Expand Down Expand Up @@ -63,12 +67,124 @@ func (dialector Dialector) Initialize(db *gorm.DB) (err error) {
}
}

if err = dialector.detectServerVersion(db); err != nil {
// Fall back to the modern OFFSET/FETCH syntax if the version cannot be
// determined, but keep the legacy path available for older SQL Server
// instances.
dialector.serverMajorVersion = 0
dialector.hasServerVersion = false
}

for k, v := range dialector.ClauseBuilders() {
db.ClauseBuilders[k] = v
}
return
}

func (dialector Dialector) supportsOffsetFetch() bool {
if !dialector.hasServerVersion {
return true
}
return dialector.serverMajorVersion >= 11
}

func (dialector Dialector) detectServerVersion(db *gorm.DB) error {
if db == nil || db.ConnPool == nil {
return nil
}

var version string
if err := db.ConnPool.QueryRowContext(context.Background(),
"SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128))").Scan(&version); err != nil {
return err
}

major, ok := parseSQLServerVersion(version)
if !ok {
return nil
}

dialector.serverMajorVersion = major
dialector.hasServerVersion = true
return nil
}

func parseSQLServerVersion(version string) (int, bool) {
if version == "" {
return 0, false
}

for _, part := range strings.FieldsFunc(version, func(ch rune) bool {
switch ch {
case '.', ' ', '-', '\t', '\n', '\r', ',', ';':
return true
default:
return false
}
}) {
major, err := strconv.Atoi(part)
if err == nil {
return major, true
}
}

return 0, false
}

func addTopClause(query string, limit int) string {
query = strings.TrimSpace(query)
if limit < 0 || query == "" {
return query
}

if !strings.HasPrefix(strings.ToUpper(query), "SELECT") {
return query
}

return strings.Replace(query, "SELECT", fmt.Sprintf("SELECT TOP (%d)", limit), 1)
}

func buildLegacyPaginationSQL(query string, offset int, limit *int) string {
query = strings.TrimSpace(query)
if query == "" {
return query
}

var size int
unlimited := limit == nil || *limit < 0
if !unlimited {
size = *limit
}
if offset == 0 {
if unlimited {
return query
}
return addTopClause(query, size)
}

orderByIndex := strings.Index(strings.ToUpper(query), " ORDER BY ")
if orderByIndex >= 0 {
orderBySQL := strings.TrimSpace(query[orderByIndex:])
baseQuery := strings.TrimRight(query[:orderByIndex], " ")
upperBound := ""
if !unlimited {
upperBound = fmt.Sprintf(" AND __gorm_rownum <= %d", offset+size)
}
return fmt.Sprintf(
"SELECT * FROM (SELECT __gorm_wrapped.*, ROW_NUMBER() OVER (%s) AS __gorm_rownum FROM (%s) AS __gorm_wrapped) AS __gorm_paged WHERE __gorm_rownum > %d%s ORDER BY __gorm_rownum",
orderBySQL,
baseQuery,
offset,
upperBound,
)
}

if unlimited {
return query
}
return addTopClause(query, offset+size)
}

func (dialector Dialector) ClauseBuilders() map[string]clause.ClauseBuilder {
return map[string]clause.ClauseBuilder{
"LIMIT": func(c clause.Clause, builder clause.Builder) {
Expand All @@ -83,21 +199,28 @@ func (dialector Dialector) ClauseBuilders() map[string]clause.ClauseBuilder {
builder.WriteString("ORDER BY (SELECT NULL) ")
}
}
}

if limit.Offset > 0 {
builder.WriteString("OFFSET ")
builder.WriteString(strconv.Itoa(limit.Offset))
builder.WriteString(" ROWS")
}
if !dialector.supportsOffsetFetch() {
rewritten := buildLegacyPaginationSQL(stmt.SQL.String(), limit.Offset, limit.Limit)
stmt.SQL.Reset()
stmt.SQL.WriteString(rewritten)
return
}

if limit.Limit != nil && *limit.Limit >= 0 {
if limit.Offset == 0 {
builder.WriteString("OFFSET 0 ROW")
if limit.Offset > 0 {
builder.WriteString("OFFSET ")
builder.WriteString(strconv.Itoa(limit.Offset))
builder.WriteString(" ROWS")
}

if limit.Limit != nil && *limit.Limit >= 0 {
if limit.Offset == 0 {
builder.WriteString("OFFSET 0 ROW")
}
builder.WriteString(" FETCH NEXT ")
builder.WriteString(strconv.Itoa(*limit.Limit))
builder.WriteString(" ROWS ONLY")
}
builder.WriteString(" FETCH NEXT ")
builder.WriteString(strconv.Itoa(*limit.Limit))
builder.WriteString(" ROWS ONLY")
}
}
},
Expand Down
63 changes: 63 additions & 0 deletions sqlserver_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package sqlserver

import (
"strings"
"testing"
)

func TestParseSQLServerVersion(t *testing.T) {
for _, tc := range []struct {
name string
version string
want int
wantOK bool
}{
{name: "server 2008", version: "10.50.6000.34", want: 10, wantOK: true},
{name: "server 2012", version: "11.0.7001.0", want: 11, wantOK: true},
{name: "server 2016", version: "13.0.5026.0", want: 13, wantOK: true},
{name: "server 2019", version: "15.0.2000.5", want: 15, wantOK: true},
{name: "empty", version: "", want: 0, wantOK: false},
} {
t.Run(tc.name, func(t *testing.T) {
got, ok := parseSQLServerVersion(tc.version)
if ok != tc.wantOK {
t.Fatalf("parseSQLServerVersion(%q) ok = %v, want %v", tc.version, ok, tc.wantOK)
}
if got != tc.want {
t.Fatalf("parseSQLServerVersion(%q) = %d, want %d", tc.version, got, tc.want)
}
})
}
}

func TestAddTopClause(t *testing.T) {
query := "SELECT * FROM users WHERE active = 1 ORDER BY id"
got := addTopClause(query, 10)
if !strings.Contains(got, "SELECT TOP (10) *") {
t.Fatalf("addTopClause result = %q, want TOP(10) in SELECT", got)
}
}

func TestBuildLegacyPaginationSQL(t *testing.T) {
t.Run("offset zero", func(t *testing.T) {
limit := 10
got := buildLegacyPaginationSQL("SELECT * FROM users WHERE active = 1 ORDER BY id", 0, &limit)
if !strings.Contains(got, "SELECT TOP (10)") {
t.Fatalf("legacy query without offset = %q, want TOP clause", got)
}
})

t.Run("offset greater than zero", func(t *testing.T) {
limit := 10
got := buildLegacyPaginationSQL("SELECT * FROM users WHERE active = 1 ORDER BY id", 20, &limit)
if !strings.Contains(got, "ROW_NUMBER() OVER (ORDER BY id)") {
t.Fatalf("legacy query with offset = %q, want ROW_NUMBER() wrap", got)
}
if !strings.Contains(got, "__gorm_rownum > 20") {
t.Fatalf("legacy query with offset = %q, want offset filter applied", got)
}
if !strings.Contains(got, "__gorm_rownum <= 30") {
t.Fatalf("legacy query with offset = %q, want upper bound applied", got)
}
})
}
Loading