diff --git a/pkg/migrations/op_create_index.go b/pkg/migrations/op_create_index.go index 80f71de4..d4997beb 100644 --- a/pkg/migrations/op_create_index.go +++ b/pkg/migrations/op_create_index.go @@ -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) + } +} diff --git a/pkg/sql2pgroll/convert.go b/pkg/sql2pgroll/convert.go index a89596c7..a2e51d57 100644 --- a/pkg/sql2pgroll/convert.go +++ b/pkg/sql2pgroll/convert.go @@ -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 } diff --git a/pkg/sql2pgroll/create_index.go b/pkg/sql2pgroll/create_index.go new file mode 100644 index 00000000..47b18c41 --- /dev/null +++ b/pkg/sql2pgroll/create_index.go @@ -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 +} diff --git a/pkg/sql2pgroll/create_index_test.go b/pkg/sql2pgroll/create_index_test.go new file mode 100644 index 00000000..16f6d444 --- /dev/null +++ b/pkg/sql2pgroll/create_index_test.go @@ -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]) + }) + } +} diff --git a/pkg/sql2pgroll/expect/create_index.go b/pkg/sql2pgroll/expect/create_index.go new file mode 100644 index 00000000..224fc442 --- /dev/null +++ b/pkg/sql2pgroll/expect/create_index.go @@ -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, + } +}