diff --git a/README.md b/README.md index 8f335f7..5905774 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/sqlserver.go b/sqlserver.go index 2c09a53..5fdc423 100644 --- a/sqlserver.go +++ b/sqlserver.go @@ -1,6 +1,7 @@ package sqlserver import ( + "context" "database/sql" "fmt" "regexp" @@ -21,6 +22,9 @@ type Config struct { DSN string DefaultStringSize int Conn gorm.ConnPool + + serverMajorVersion int + hasServerVersion bool } type Dialector struct { @@ -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) { @@ -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") } } }, diff --git a/sqlserver_test.go b/sqlserver_test.go new file mode 100644 index 0000000..0766e93 --- /dev/null +++ b/sqlserver_test.go @@ -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) + } + }) +}