diff --git a/server/hook/rename_table.go b/server/hook/rename_table.go new file mode 100644 index 0000000000..0ea6c9f19b --- /dev/null +++ b/server/hook/rename_table.go @@ -0,0 +1,105 @@ +// Copyright 2026 Dolthub, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hook + +import ( + "fmt" + + "github.com/cockroachdb/errors" + "github.com/dolthub/dolt/go/libraries/doltcore/doltdb" + "github.com/dolthub/go-mysql-server/sql" + "github.com/dolthub/go-mysql-server/sql/plan" + + "github.com/dolthub/doltgresql/core" + "github.com/dolthub/doltgresql/core/id" + pgtypes "github.com/dolthub/doltgresql/server/types" +) + +// AfterTableRename handles updating various columns using the table type, alongside other validation that's unique +// to Doltgres. +func AfterTableRename(ctx *sql.Context, runner sql.StatementRunner, nodeInterface sql.Node) error { + n, ok := nodeInterface.(*plan.RenameTable) + if !ok { + return errors.Errorf("RENAME TABLE post-hook expected `*plan.RenameTable` but received `%T`", nodeInterface) + } + + // Grab the table being altered (so we know the schema) + sqlTable, ok := n.TableExists(ctx, n.NewNames[0]) + if !ok { + // Views do not manifest as tables, so we'll return here if this isn't a table + return nil + } + doltTable := core.SQLTableToDoltTable(sqlTable) + if doltTable == nil { + // If this table isn't a Dolt table then we don't have anything to do + return nil + } + _, root, err := core.GetRootFromContext(ctx) + if err != nil { + return err + } + tableName := doltTable.TableName() + tableName.Name = n.OldNames[0] + tableAsType := id.NewType(tableName.Schema, tableName.Name) + allTableNames, err := root.GetAllTableNames(ctx, false) + if err != nil { + return err + } + + for _, otherTableName := range allTableNames { + if doltdb.IsSystemTable(otherTableName) { + // System tables don't use any table types + continue + } + otherTable, ok, err := root.GetTable(ctx, otherTableName) + if err != nil { + return err + } + if !ok { + return errors.Errorf("root returned table name `%s` but it could not be found?", otherTableName.String()) + } + otherTableSch, err := otherTable.GetSchema(ctx) + if err != nil { + return err + } + for _, otherCol := range otherTableSch.GetAllCols().GetColumns() { + colType := otherCol.TypeInfo.ToSqlType() + dgtype, ok := colType.(*pgtypes.DoltgresType) + if !ok { + // If this isn't a Doltgres type, then it can't be a table type so we can ignore it + continue + } + if dgtype.ID != tableAsType { + // This column isn't our table type, so we can ignore it + continue + } + // The ALTER updates the type on the schema since it still has the old one + alterStr := fmt.Sprintf(`ALTER TABLE "%s"."%s" ALTER COLUMN "%s" TYPE "%s"."%s";`, + otherTableName.Schema, otherTableName.Name, otherCol.Name, tableName.Schema, n.NewNames[0]) + // We run the statement as though it's interpreted since we're running new statements inside the original + _, err = sql.RunInterpreted(ctx, func(subCtx *sql.Context) ([]sql.Row, error) { + _, rowIter, _, err := runner.QueryWithBindings(subCtx, alterStr, nil, nil, nil) + if err != nil { + return nil, err + } + return sql.RowIterToRows(subCtx, rowIter) + }) + if err != nil { + return err + } + } + } + return nil +} diff --git a/server/hook/table_modify_column.go b/server/hook/table_modify_column.go new file mode 100644 index 0000000000..b7330e7928 --- /dev/null +++ b/server/hook/table_modify_column.go @@ -0,0 +1,106 @@ +// Copyright 2026 Dolthub, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hook + +import ( + "github.com/cockroachdb/errors" + "github.com/dolthub/dolt/go/libraries/doltcore/doltdb" + "github.com/dolthub/go-mysql-server/sql" + "github.com/dolthub/go-mysql-server/sql/plan" + + "github.com/dolthub/doltgresql/core" + "github.com/dolthub/doltgresql/core/id" + pgtypes "github.com/dolthub/doltgresql/server/types" +) + +// beforeTableModifyColumnChange represents what properties of a column changed when a call is made to BeforeTableModifyColumn. +type beforeTableModifyColumnChange uint8 + +const ( + beforeTableModifyColumnChange_None beforeTableModifyColumnChange = iota + beforeTableModifyColumnChange_Type +) + +// BeforeTableModifyColumn handles validation that's unique to Doltgres. +func BeforeTableModifyColumn(ctx *sql.Context, runner sql.StatementRunner, nodeInterface sql.Node) (sql.Node, error) { + n, ok := nodeInterface.(*plan.ModifyColumn) + if !ok { + return nil, errors.Errorf("MODIFY COLUMN pre-hook expected `*plan.ModifyColumn` but received `%T`", nodeInterface) + } + + // Figure out what was changed. We know it's not the name because we have a dedicated *RenameColumn node. + changed := beforeTableModifyColumnChange_None + newColumn := n.NewColumn() + for _, col := range n.TargetSchema() { + if col.Name == newColumn.Name { + if !col.Type.Equals(newColumn.Type) { + changed = beforeTableModifyColumnChange_Type + } + } + } + if changed == beforeTableModifyColumnChange_None { + return n, nil + } + + // Grab the table being altered (so we know the schema) + doltTable := core.SQLNodeToDoltTable(n.Table) + if doltTable == nil { + // If this table isn't a Dolt table then we don't have anything to do + return n, nil + } + _, root, err := core.GetRootFromContext(ctx) + if err != nil { + return n, nil + } + tableName := doltTable.TableName() + tableAsType := id.NewType(tableName.Schema, tableName.Name) + allTableNames, err := root.GetAllTableNames(ctx, false) + if err != nil { + return nil, err + } + + for _, otherTableName := range allTableNames { + if doltdb.IsSystemTable(otherTableName) { + // System tables don't use any table types + continue + } + otherTable, ok, err := root.GetTable(ctx, otherTableName) + if err != nil { + return nil, err + } + if !ok { + return nil, errors.Errorf("root returned table name `%s` but it could not be found?", otherTableName.String()) + } + otherTableSch, err := otherTable.GetSchema(ctx) + if err != nil { + return nil, err + } + for _, otherCol := range otherTableSch.GetAllCols().GetColumns() { + colType := otherCol.TypeInfo.ToSqlType() + dgtype, ok := colType.(*pgtypes.DoltgresType) + if !ok { + // If this isn't a Doltgres type, then it can't be a table type so we can ignore it + continue + } + if dgtype.ID != tableAsType { + // This column isn't our table type, so we can ignore it + continue + } + return nil, errors.Errorf(`cannot alter table "%s" because column "%s.%s" uses its row type`, + tableName.Name, otherTableName.Name, otherCol.Name) + } + } + return n, nil +} diff --git a/server/hook/table_rename_column.go b/server/hook/table_rename_column.go new file mode 100644 index 0000000000..78f8f16097 --- /dev/null +++ b/server/hook/table_rename_column.go @@ -0,0 +1,101 @@ +// Copyright 2026 Dolthub, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hook + +import ( + "fmt" + + "github.com/cockroachdb/errors" + "github.com/dolthub/dolt/go/libraries/doltcore/doltdb" + "github.com/dolthub/go-mysql-server/sql" + "github.com/dolthub/go-mysql-server/sql/plan" + + "github.com/dolthub/doltgresql/core" + "github.com/dolthub/doltgresql/core/id" + pgtypes "github.com/dolthub/doltgresql/server/types" +) + +// AfterTableRenameColumn handles updating various table columns, alongside other validation that's unique to Doltgres. +func AfterTableRenameColumn(ctx *sql.Context, runner sql.StatementRunner, nodeInterface sql.Node) error { + n, ok := nodeInterface.(*plan.RenameColumn) + if !ok { + return errors.Errorf("RENAME COLUMN post-hook expected `*plan.RenameColumn` but received `%T`", nodeInterface) + } + if n.ColumnName == n.NewColumnName { + return nil + } + + // Grab the table being altered + doltTable := core.SQLNodeToDoltTable(n.Table) + if doltTable == nil { + // If this table isn't a Dolt table then we don't have anything to do + return nil + } + _, root, err := core.GetRootFromContext(ctx) + if err != nil { + return err + } + tableName := doltTable.TableName() + tableAsType := id.NewType(tableName.Schema, tableName.Name) + allTableNames, err := root.GetAllTableNames(ctx, false) + if err != nil { + return err + } + + for _, otherTableName := range allTableNames { + if doltdb.IsSystemTable(otherTableName) { + // System tables don't use any table types + continue + } + otherTable, ok, err := root.GetTable(ctx, otherTableName) + if err != nil { + return err + } + if !ok { + return errors.Errorf("root returned table name `%s` but it could not be found?", otherTableName.String()) + } + otherTableSch, err := otherTable.GetSchema(ctx) + if err != nil { + return err + } + for _, otherCol := range otherTableSch.GetAllCols().GetColumns() { + colType := otherCol.TypeInfo.ToSqlType() + dgtype, ok := colType.(*pgtypes.DoltgresType) + if !ok { + // If this isn't a Doltgres type, then it can't be a table type so we can ignore it + continue + } + if dgtype.ID != tableAsType { + // This column isn't our table type, so we can ignore it + continue + } + // The ALTER updates the type on the schema since it still has the old one + alterStr := fmt.Sprintf(`ALTER TABLE "%s"."%s" ALTER COLUMN "%s" TYPE "%s"."%s";`, + otherTableName.Schema, otherTableName.Name, otherCol.Name, tableName.Schema, tableName.Name) + // We run the statement as though it were interpreted since we're running new statements inside the original + _, err = sql.RunInterpreted(ctx, func(subCtx *sql.Context) ([]sql.Row, error) { + _, rowIter, _, err := runner.QueryWithBindings(subCtx, alterStr, nil, nil, nil) + if err != nil { + return nil, err + } + return sql.RowIterToRows(subCtx, rowIter) + }) + if err != nil { + return err + } + } + } + return nil +} diff --git a/servercfg/config.go b/servercfg/config.go index f2d4a85534..857a7298fa 100755 --- a/servercfg/config.go +++ b/servercfg/config.go @@ -47,6 +47,9 @@ func (*DoltgresConfig) Overrides() sql.EngineOverrides { Parser: pgsql.NewPostgresParser(), }, Hooks: sql.ExecutionHooks{ + RenameTable: sql.RenameTable{ + PostSQLExecution: hook.AfterTableRename, + }, DropTable: sql.DropTable{ PreSQLExecution: hook.BeforeTableDeletion, }, @@ -54,6 +57,12 @@ func (*DoltgresConfig) Overrides() sql.EngineOverrides { PreSQLExecution: hook.BeforeTableAddColumn, PostSQLExecution: hook.AfterTableAddColumn, }, + TableRenameColumn: sql.TableRenameColumn{ + PostSQLExecution: hook.AfterTableRenameColumn, + }, + TableModifyColumn: sql.TableModifyColumn{ + PreSQLExecution: hook.BeforeTableModifyColumn, + }, TableDropColumn: sql.TableDropColumn{ PostSQLExecution: hook.AfterTableDropColumn, }, diff --git a/testing/go/alter_table_test.go b/testing/go/alter_table_test.go index e80f286e56..a04591baa8 100644 --- a/testing/go/alter_table_test.go +++ b/testing/go/alter_table_test.go @@ -697,5 +697,301 @@ func TestAlterTable(t *testing.T) { }, }, }, + { + Name: "ALTER TABLE RENAME with table types", + SetUpScript: []string{ + `CREATE TABLE t1a (a INT4, b VARCHAR(3));`, + `CREATE TABLE t1b (a VARCHAR(3), b INT4);`, + `CREATE TABLE t2 (id INT4, t1a t1a, t1b t1b);`, + `INSERT INTO t2 VALUES (1, ROW(1, 'abc'), ROW('abc', 1));`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(1,abc)", "(abc,1)"}, + }, + }, + { + Query: `ALTER TABLE t1a RENAME TO t1x;`, + Expected: []sql.Row{}, + }, + { + Query: `INSERT INTO t2 VALUES (2, ROW(2, 'def'), ROW('def', 2));`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(1,abc)", "(abc,1)"}, + {2, "(2,def)", "(def,2)"}, + }, + }, + { + Query: `ALTER TABLE t1x RENAME TO t1y;`, + Expected: []sql.Row{}, + }, + { + Query: `INSERT INTO t2 VALUES (3, ROW(4, 'ghi'), ROW('kjl', 5));`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(1,abc)", "(abc,1)"}, + {2, "(2,def)", "(def,2)"}, + {3, "(4,ghi)", "(kjl,5)"}, + }, + }, + }, + }, + { + Name: "ALTER TABLE RENAME COLUMN with table types", + SetUpScript: []string{ + `CREATE TABLE t1a (a INT4, b VARCHAR(3));`, + `CREATE TABLE t1b (a VARCHAR(3), b INT4);`, + `CREATE TABLE t2 (id INT4, t1a t1a, t1b t1b);`, + `INSERT INTO t2 VALUES (1, ROW(2, 'abc'), ROW('def', 3));`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(2,abc)", "(def,3)"}, + }, + }, + { + Query: `ALTER TABLE t1a RENAME COLUMN a TO x;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT (t1a).a FROM t2;`, + ExpectedErr: "not found", + }, + { + Query: `SELECT (t1a).x, (t1a).@1 FROM t2;`, + Expected: []sql.Row{ + {2, 2}, + }, + }, + { + Query: `ALTER TABLE t1b RENAME COLUMN b TO bb;`, + Expected: []sql.Row{}, + }, + { + Query: `ALTER TABLE t1b RENAME COLUMN a TO aa;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(2,abc)", "(def,3)"}, + }, + }, + { + Query: `INSERT INTO t2 VALUES (4, ROW(5, 'ghi'), ROW('jkl', 6));`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT (t1b).aa, (t1b).@1, (t1b).bb, (t1b).@2 FROM t2;`, + Expected: []sql.Row{ + {"def", "def", 3, 3}, + {"jkl", "jkl", 6, 6}, + }, + }, + }, + }, + { + Name: "ALTER TABLE SET DEFAULT with table types", + SetUpScript: []string{ + `CREATE TABLE t1a (a INT4, b VARCHAR(3));`, + `CREATE TABLE t1b (a VARCHAR(3), b INT4);`, + `CREATE TABLE t2 (id INT4, t1a t1a, t1b t1b);`, + `INSERT INTO t2 VALUES (1, ROW(2, 'abc'), ROW('def', 3));`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(2,abc)", "(def,3)"}, + }, + }, + { + Query: `ALTER TABLE t1a ALTER COLUMN a SET DEFAULT 55;`, + Expected: []sql.Row{}, + }, + { + Query: `ALTER TABLE t1b ALTER COLUMN b SET DEFAULT 77, ALTER COLUMN a SET DEFAULT 'hi';`, + Expected: []sql.Row{}, + }, + { + Query: `INSERT INTO t2 VALUES (4, ROW(5, 'ghi'), ROW('kjl', 6));`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(2,abc)", "(def,3)"}, + {4, "(5,ghi)", "(kjl,6)"}, + }, + }, + }, + }, + { + Name: "ALTER TABLE DROP DEFAULT with table types", + SetUpScript: []string{ + `CREATE TABLE t1a (a INT4 DEFAULT 55, b VARCHAR(3) DEFAULT 'hi');`, + `CREATE TABLE t1b (a VARCHAR(5) DEFAULT 'hello', b INT4 DEFAULT 77);`, + `CREATE TABLE t2 (id INT4, t1a t1a, t1b t1b);`, + `INSERT INTO t2 VALUES (1, ROW(2, 'abc'), ROW('def', 3));`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(2,abc)", "(def,3)"}, + }, + }, + { + Query: `ALTER TABLE t1a ALTER COLUMN a DROP DEFAULT;`, + Expected: []sql.Row{}, + }, + { + Query: `ALTER TABLE t1a ALTER COLUMN b DROP DEFAULT, ALTER COLUMN a DROP DEFAULT;`, + Expected: []sql.Row{}, + }, + { + Query: `INSERT INTO t2 VALUES (4, ROW(5, 'ghi'), ROW('kjl', 6));`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(2,abc)", "(def,3)"}, + {4, "(5,ghi)", "(kjl,6)"}, + }, + }, + }, + }, + { + Name: "ALTER TABLE SET DATA TYPE with table types", + SetUpScript: []string{ + `CREATE TABLE t1a (a INT4, b VARCHAR(3));`, + `CREATE TABLE t1b (a VARCHAR(3), b INT4);`, + `CREATE TABLE t2 (id INT4, t1a t1a, t1b t1b);`, + `INSERT INTO t2 VALUES (1, ROW(2, 'abc'), ROW('def', 3));`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(2,abc)", "(def,3)"}, + }, + }, + { // Different data type + Query: `ALTER TABLE t1a ALTER COLUMN a SET DATA TYPE INT8;`, + ExpectedErr: `cannot alter table "t1a" because column "t2.t1a" uses its row type`, + }, + { // Same data type, still restricted + Query: `ALTER TABLE t1a ALTER COLUMN a SET DATA TYPE INT4;`, + Skip: true, // TODO: we can't just analyze ModifyColumn for changes, we need to know the original statement + ExpectedErr: `cannot alter table "t1a" because column "t2.t1a" uses its row type`, + }, + { + Query: `ALTER TABLE t2 DROP COLUMN t1a;`, + Expected: []sql.Row{}, + }, + { // Dependency removed + Query: `ALTER TABLE t1a ALTER COLUMN a SET DATA TYPE INT8, ALTER COLUMN b SET DATA TYPE TEXT;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(def,3)"}, + }, + }, + }, + }, + { + Name: "ALTER TABLE SET/DROP NOT NULL with table types", + SetUpScript: []string{ + `CREATE TABLE t1a (a INT4, b VARCHAR(3));`, + `CREATE TABLE t1b (a VARCHAR(3), b INT4);`, + `CREATE TABLE t2 (id INT4, t1a t1a, t1b t1b);`, + `INSERT INTO t2 VALUES (1, ROW(2, 'abc'), ROW('def', 3));`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(2,abc)", "(def,3)"}, + }, + }, + { + Query: `ALTER TABLE t1a ALTER COLUMN a SET NOT NULL;`, + Expected: []sql.Row{}, + }, + { + Query: `ALTER TABLE t1b ALTER COLUMN b SET NOT NULL, ALTER COLUMN a SET NOT NULL;`, + Expected: []sql.Row{}, + }, + { + Query: `INSERT INTO t1a VALUES (NULL, 'hi');`, + ExpectedErr: "non-nullable", + }, + { // The original table's NOT NULL doesn't affect columns that use the table's type + Query: `INSERT INTO t2 VALUES (4, ROW(NULL, 'ghi'), ROW(NULL, 6));`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(2,abc)", "(def,3)"}, + {4, "(,ghi)", "(,6)"}, + }, + }, + { + Query: `ALTER TABLE t1b ALTER COLUMN b DROP NOT NULL, ALTER COLUMN a DROP NOT NULL;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM t2;`, + Expected: []sql.Row{ + {1, "(2,abc)", "(def,3)"}, + {4, "(,ghi)", "(,6)"}, + }, + }, + }, + }, + { + Name: "ALTER TABLE RENAME on view", + SetUpScript: []string{ + `CREATE TABLE tenk1 ( + unique1 int4, + unique2 int4, + two int4, + four int4, + ten int4, + twenty int4, + hundred int4, + thousand int4, + twothousand int4, + fivethous int4, + tenthous int4, + odd int4, + even int4, + stringu1 name, + stringu2 name, + string4 name);`, + `CREATE VIEW attmp_view (unique1) AS SELECT unique1 FROM tenk1;`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `ALTER TABLE attmp_view RENAME TO attmp_view_new;`, + Expected: []sql.Row{}, + }, + }, + }, }) }