Skip to content

Commit

Permalink
Convert CREATE INDEX SQL into pgroll operation (#551)
Browse files Browse the repository at this point in the history
Convert the majority of `CREATE INDEX` statements into `OpCreateIndex`

The following cases are covered:

```sql
CREATE INDEX idx_name ON foo (bar)
CREATE INDEX idx_name ON foo (bar ASC)
CREATE INDEX idx_name ON foo USING btree (bar)
CREATE INDEX idx_name ON foo USING brin (bar)
CREATE INDEX idx_name ON foo USING gin (bar)
CREATE INDEX idx_name ON foo USING gist (bar)
CREATE INDEX idx_name ON foo USING hash (bar)
CREATE INDEX idx_name ON foo USING spgist (bar)
CREATE INDEX CONCURRENTLY idx_name ON foo (bar)
CREATE INDEX idx_name ON schema.foo (bar)
CREATE INDEX idx_name ON foo (bar, baz)
CREATE UNIQUE INDEX idx_name ON foo (bar)
CREATE INDEX idx_name ON foo (bar) WHERE (foo > 0)
CREATE INDEX idx_name ON foo (bar) WHERE foo > 0
CREATE INDEX idx_name ON foo (bar) WITH (fillfactor = 70)
CREATE INDEX idx_name ON foo (bar) WITH (deduplicate_items = true)
CREATE INDEX idx_name ON foo (bar) WITH (buffering = ON)
CREATE INDEX idx_name ON foo (bar) WITH (buffering = OFF)
CREATE INDEX idx_name ON foo (bar) WITH (buffering = AUTO)
CREATE INDEX idx_name ON foo (bar) WITH (fastupdate = true)
CREATE INDEX idx_name ON foo (bar) WITH (pages_per_range = 100)
CREATE INDEX idx_name ON foo (bar) WITH (autosummarize = true)
CREATE INDEX idx_name ON foo (bar) WITH (fillfactor = 70, deduplicate_items = true)
```

And the following unsupported cases fall back to RAW SQL:

```sql
CREATE INDEX idx_name ON foo (bar) TABLESPACE baz
CREATE INDEX idx_name ON foo (bar COLLATE en_US)
CREATE INDEX idx_name ON foo (bar DESC)
CREATE INDEX idx_name ON foo (bar NULLS FIRST)
CREATE INDEX idx_name ON foo (bar NULLS LAST)
CREATE INDEX idx_name ON foo (bar) INCLUDE (baz)
CREATE INDEX idx_name ON foo (bar opclass (test = test))
CREATE INDEX idx_name ON foo (bar opclass)
CREATE INDEX idx_name ON ONLY foo (bar)
CREATE INDEX idx_name ON foo(a) NULLS NOT DISTINCT
CREATE INDEX IF NOT EXISTS idx_name ON foo(a)
CREATE INDEX idx_name ON foo(LOWER(a))
CREATE INDEX idx_name ON foo(a, LOWER(b))
```
  • Loading branch information
ryanslade authored Jan 2, 2025
1 parent 97ff17d commit e6e4523
Show file tree
Hide file tree
Showing 5 changed files with 381 additions and 0 deletions.
20 changes: 20 additions & 0 deletions pkg/migrations/op_create_index.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,23 @@ func quoteColumnNames(columns []string) (quoted []string) {
}
return quoted
}

// ParseCreateIndexMethod parsed index methods into OpCreateIndexMethod
func ParseCreateIndexMethod(method string) (OpCreateIndexMethod, error) {
switch method {
case "btree":
return OpCreateIndexMethodBtree, nil
case "hash":
return OpCreateIndexMethodHash, nil
case "gist":
return OpCreateIndexMethodGist, nil
case "spgist":
return OpCreateIndexMethodSpgist, nil
case "gin":
return OpCreateIndexMethodGin, nil
case "brin":
return OpCreateIndexMethodBrin, nil
default:
return OpCreateIndexMethodBtree, fmt.Errorf("unknown method: %s", method)
}
}
2 changes: 2 additions & 0 deletions pkg/sql2pgroll/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ func convert(sql string) (migrations.Operations, error) {
return convertRenameStmt(node.RenameStmt)
case *pgq.Node_DropStmt:
return convertDropStatement(node.DropStmt)
case *pgq.Node_IndexStmt:
return convertCreateIndexStmt(node.IndexStmt)
default:
return makeRawSQLOperation(sql), nil
}
Expand Down
124 changes: 124 additions & 0 deletions pkg/sql2pgroll/create_index.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// SPDX-License-Identifier: Apache-2.0

package sql2pgroll

import (
"fmt"

pgq "github.com/xataio/pg_query_go/v6"

"github.com/xataio/pgroll/pkg/migrations"
)

// convertCreateIndexStmt converts CREATE INDEX statements into pgroll operations.
func convertCreateIndexStmt(stmt *pgq.IndexStmt) (migrations.Operations, error) {
if !canConvertCreateIndexStmt(stmt) {
return nil, nil
}

// Get the qualified table name
tableName := getQualifiedRelationName(stmt.GetRelation())
var columns []string

// Get the columns on which the index is defined
for _, param := range stmt.GetIndexParams() {
if colName := param.GetIndexElem().GetName(); colName != "" {
columns = append(columns, colName)
}
}

// Parse the access method
method, err := migrations.ParseCreateIndexMethod(stmt.GetAccessMethod())
if err != nil {
return nil, fmt.Errorf("parse create index method: %w", err)
}

// Get index uniqueness
unique := false
if stmt.GetUnique() {
unique = true
}

// Deparse WHERE clause
var predicate string
if where := stmt.GetWhereClause(); where != nil {
predicate, err = pgq.DeparseExpr(where)
if err != nil {
return nil, fmt.Errorf("parsing where clause: %w", err)
}
}

// Deparse storage parameters
var storageParams string
if len(stmt.GetOptions()) > 0 {
storageParams, err = pgq.DeparseRelOptions(stmt.GetOptions())
if err != nil {
return nil, fmt.Errorf("parsing options: %w", err)
}
// strip outer parentheses
storageParams = storageParams[1 : len(storageParams)-1]
}

return migrations.Operations{
&migrations.OpCreateIndex{
Table: tableName,
Columns: columns,
Name: stmt.GetIdxname(),
Method: method,
Unique: unique,
Predicate: predicate,
StorageParameters: storageParams,
},
}, nil
}

func canConvertCreateIndexStmt(stmt *pgq.IndexStmt) bool {
// Tablespaces are not supported
if stmt.GetTableSpace() != "" {
return false
}
// Indexes with INCLUDE are not supported
if stmt.GetIndexIncludingParams() != nil {
return false
}
// Indexes created with ONLY are not supported
if !stmt.GetRelation().GetInh() {
return false
}
// Indexes with NULLS NOT DISTINCT are not supported
if stmt.GetNullsNotDistinct() {
return false
}
// IF NOT EXISTS is unsupported
if stmt.GetIfNotExists() {
return false
}
// Indexes defined on expressions are not supported
for _, node := range stmt.GetIndexParams() {
if node.GetIndexElem().GetExpr() != nil {
return false
}
}

for _, param := range stmt.GetIndexParams() {
// Indexes with non-default collations are not supported
if param.GetIndexElem().GetCollation() != nil {
return false
}
// Indexes with non-default ordering are not supported
ordering := param.GetIndexElem().GetOrdering()
if ordering != pgq.SortByDir_SORTBY_DEFAULT && ordering != pgq.SortByDir_SORTBY_ASC {
return false
}
// Indexes with non-default nulls ordering are not supported
if param.GetIndexElem().GetNullsOrdering() != pgq.SortByNulls_SORTBY_NULLS_DEFAULT {
return false
}
// Indexes with opclasses are not supported
if param.GetIndexElem().GetOpclass() != nil || param.GetIndexElem().GetOpclassopts() != nil {
return false
}
}

return true
}
168 changes: 168 additions & 0 deletions pkg/sql2pgroll/create_index_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// SPDX-License-Identifier: Apache-2.0

package sql2pgroll_test

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/xataio/pgroll/pkg/migrations"
"github.com/xataio/pgroll/pkg/sql2pgroll"
"github.com/xataio/pgroll/pkg/sql2pgroll/expect"
)

func TestConvertCreateIndexStatements(t *testing.T) {
t.Parallel()

tests := []struct {
sql string
expectedOp migrations.Operation
}{
{
sql: "CREATE INDEX idx_name ON foo (bar)",
expectedOp: expect.CreateIndexOp1,
},
{
sql: "CREATE INDEX idx_name ON foo (bar ASC)",
expectedOp: expect.CreateIndexOp1,
},
{
sql: "CREATE INDEX idx_name ON foo USING btree (bar)",
expectedOp: expect.CreateIndexOp1,
},
{
sql: "CREATE INDEX idx_name ON foo USING brin (bar)",
expectedOp: expect.CreateIndexOp1WithMethod("brin"),
},
{
sql: "CREATE INDEX idx_name ON foo USING gin (bar)",
expectedOp: expect.CreateIndexOp1WithMethod("gin"),
},
{
sql: "CREATE INDEX idx_name ON foo USING gist (bar)",
expectedOp: expect.CreateIndexOp1WithMethod("gist"),
},
{
sql: "CREATE INDEX idx_name ON foo USING hash (bar)",
expectedOp: expect.CreateIndexOp1WithMethod("hash"),
},
{
sql: "CREATE INDEX idx_name ON foo USING spgist (bar)",
expectedOp: expect.CreateIndexOp1WithMethod("spgist"),
},
{
sql: "CREATE INDEX CONCURRENTLY idx_name ON foo (bar)",
expectedOp: expect.CreateIndexOp1,
},
{
sql: "CREATE INDEX idx_name ON schema.foo (bar)",
expectedOp: expect.CreateIndexOp2,
},
{
sql: "CREATE INDEX idx_name ON foo (bar, baz)",
expectedOp: expect.CreateIndexOp3,
},
{
sql: "CREATE UNIQUE INDEX idx_name ON foo (bar)",
expectedOp: expect.CreateIndexOp4,
},
{
sql: "CREATE INDEX idx_name ON foo (bar) WHERE (foo > 0)",
expectedOp: expect.CreateIndexOp5,
},
{
sql: "CREATE INDEX idx_name ON foo (bar) WHERE foo > 0",
expectedOp: expect.CreateIndexOp5,
},
{
sql: "CREATE INDEX idx_name ON foo (bar) WITH (fillfactor = 70)",
expectedOp: expect.CreateIndexOpWithStorageParam("fillfactor=70"),
},
{
sql: "CREATE INDEX idx_name ON foo (bar) WITH (deduplicate_items = true)",
expectedOp: expect.CreateIndexOpWithStorageParam("deduplicate_items=true"),
},
{
sql: "CREATE INDEX idx_name ON foo (bar) WITH (buffering = ON)",
expectedOp: expect.CreateIndexOpWithStorageParam("buffering=on"),
},
{
sql: "CREATE INDEX idx_name ON foo (bar) WITH (buffering = OFF)",
expectedOp: expect.CreateIndexOpWithStorageParam("buffering=off"),
},
{
sql: "CREATE INDEX idx_name ON foo (bar) WITH (buffering = AUTO)",
expectedOp: expect.CreateIndexOpWithStorageParam("buffering=auto"),
},
{
sql: "CREATE INDEX idx_name ON foo (bar) WITH (fastupdate = true)",
expectedOp: expect.CreateIndexOpWithStorageParam("fastupdate=true"),
},
{
sql: "CREATE INDEX idx_name ON foo (bar) WITH (pages_per_range = 100)",
expectedOp: expect.CreateIndexOpWithStorageParam("pages_per_range=100"),
},
{
sql: "CREATE INDEX idx_name ON foo (bar) WITH (autosummarize = true)",
expectedOp: expect.CreateIndexOpWithStorageParam("autosummarize=true"),
},
{
sql: "CREATE INDEX idx_name ON foo (bar) WITH (fillfactor = 70, deduplicate_items = true)",
expectedOp: expect.CreateIndexOpWithStorageParam("fillfactor=70, deduplicate_items=true"),
},
}

for _, tc := range tests {
t.Run(tc.sql, func(t *testing.T) {
ops, err := sql2pgroll.Convert(tc.sql)
require.NoError(t, err)

require.Len(t, ops, 1)

assert.Equal(t, tc.expectedOp, ops[0])
})
}
}

func TestUnconvertableCreateIndexStatements(t *testing.T) {
t.Parallel()

tests := []string{
// Tablespaces are not supported
"CREATE INDEX idx_name ON foo (bar) TABLESPACE baz",
// Index collations are not supported
"CREATE INDEX idx_name ON foo (bar COLLATE en_US)",
// Index ordering other than the default ASC is not supported
"CREATE INDEX idx_name ON foo (bar DESC)",
// Index nulls ordering is not supported
"CREATE INDEX idx_name ON foo (bar NULLS FIRST)",
"CREATE INDEX idx_name ON foo (bar NULLS LAST)",
// Included columns are not supported
"CREATE INDEX idx_name ON foo (bar) INCLUDE (baz)",
// opclasses with or without options are not supported
"CREATE INDEX idx_name ON foo (bar opclass (test = test))",
"CREATE INDEX idx_name ON foo (bar opclass)",
// Indexes created with ONLY are not supported
"CREATE INDEX idx_name ON ONLY foo (bar)",
// Indexes with NULLS NOT DISTINCT are not supported
"CREATE INDEX idx_name ON foo(a) NULLS NOT DISTINCT",
// IF NOT EXISTS is unsupported
"CREATE INDEX IF NOT EXISTS idx_name ON foo(a)",
// Indexes defined on expressions are not supported
"CREATE INDEX idx_name ON foo(LOWER(a))",
"CREATE INDEX idx_name ON foo(a, LOWER(b))",
}

for _, sql := range tests {
t.Run(sql, func(t *testing.T) {
ops, err := sql2pgroll.Convert(sql)
require.NoError(t, err)

require.Len(t, ops, 1)

assert.Equal(t, expect.RawSQLOp(sql), ops[0])
})
}
}
67 changes: 67 additions & 0 deletions pkg/sql2pgroll/expect/create_index.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: Apache-2.0

package expect

import (
"github.com/xataio/pgroll/pkg/migrations"
)

var CreateIndexOp1 = &migrations.OpCreateIndex{
Name: "idx_name",
Table: "foo",
Columns: []string{"bar"},
Method: migrations.OpCreateIndexMethodBtree,
}

func CreateIndexOp1WithMethod(method string) *migrations.OpCreateIndex {
parsed, err := migrations.ParseCreateIndexMethod(method)
if err != nil {
panic(err)
}
return &migrations.OpCreateIndex{
Name: "idx_name",
Table: "foo",
Columns: []string{"bar"},
Method: parsed,
}
}

var CreateIndexOp2 = &migrations.OpCreateIndex{
Name: "idx_name",
Table: "schema.foo",
Columns: []string{"bar"},
Method: migrations.OpCreateIndexMethodBtree,
}

var CreateIndexOp3 = &migrations.OpCreateIndex{
Name: "idx_name",
Table: "foo",
Columns: []string{"bar", "baz"},
Method: migrations.OpCreateIndexMethodBtree,
}

var CreateIndexOp4 = &migrations.OpCreateIndex{
Name: "idx_name",
Table: "foo",
Columns: []string{"bar"},
Method: migrations.OpCreateIndexMethodBtree,
Unique: true,
}

var CreateIndexOp5 = &migrations.OpCreateIndex{
Name: "idx_name",
Table: "foo",
Columns: []string{"bar"},
Method: migrations.OpCreateIndexMethodBtree,
Predicate: "foo > 0",
}

func CreateIndexOpWithStorageParam(param string) *migrations.OpCreateIndex {
return &migrations.OpCreateIndex{
Name: "idx_name",
Table: "foo",
Columns: []string{"bar"},
Method: migrations.OpCreateIndexMethodBtree,
StorageParameters: param,
}
}

0 comments on commit e6e4523

Please sign in to comment.