diff --git a/go.mod b/go.mod index 28808e6b56..2ef6f2bd90 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,10 @@ require ( github.com/dolthub/dolt/go v0.40.5-0.20260901102237-645f6accd917 github.com/dolthub/eventsapi_schema v0.0.0-20260715220557-d9b4a1c6b4d4 github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2 - github.com/dolthub/go-mysql-server v0.20.1-0.20260901192735-5d6be3976cd5 + github.com/dolthub/go-mysql-server v0.20.1-0.20260901200741-de2dfefb8dec github.com/dolthub/pg_query_go/v6 v6.0.0-20251215122834-fb20be4254d1 github.com/dolthub/sqllogictest/go v0.0.0-20260624223518-788480b24166 - github.com/dolthub/vitess v0.0.0-20260828193927-f9eb707fd659 + github.com/dolthub/vitess v0.0.0-20260831192502-e34df639d960 github.com/fatih/color v1.13.0 github.com/go-sql-driver/mysql v1.9.3 github.com/goccy/go-json v0.10.2 @@ -142,6 +142,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgproto3/v2 v2.3.3 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d // indirect github.com/kch42/buzhash v0.0.0-20160816060738-9bdec3dec7c6 // indirect github.com/klauspost/compress v1.18.0 // indirect diff --git a/go.sum b/go.sum index 3dfa8324f7..28a9170558 100644 --- a/go.sum +++ b/go.sum @@ -256,8 +256,8 @@ github.com/dolthub/fslock v0.0.5 h1:QoXhBhgY1oumHE26qyE7tgmXUT8qjJwxsIzo54O/B/k= github.com/dolthub/fslock v0.0.5/go.mod h1:sdofYYqE0D79zNZyB4/kmlnsQOVap1C2yByjGKSirEM= github.com/dolthub/go-icu-regex v0.0.0-20260610153742-72563bc7ca83 h1:FEMjCGEroDnY/BXyAffVZxUpXhP2GpoUJyyq5KaLn8c= github.com/dolthub/go-icu-regex v0.0.0-20260610153742-72563bc7ca83/go.mod h1:F3cnm+vMRK1HaU6+rNqQrOCyR03HHhR1GWG2gnPOqaE= -github.com/dolthub/go-mysql-server v0.20.1-0.20260901192735-5d6be3976cd5 h1:OjIEbos9AZW660TkKANOzDjMnOZgFchyJkhcXXulCcE= -github.com/dolthub/go-mysql-server v0.20.1-0.20260901192735-5d6be3976cd5/go.mod h1:CtDdfkAma4klv+UQbEmV4AkVvkSyXf/V3iRpIDu6c2I= +github.com/dolthub/go-mysql-server v0.20.1-0.20260901200741-de2dfefb8dec h1:kwUmkRRUt7HD9umb/+mM5OTCnA9LYznwJBqyMpF6dMM= +github.com/dolthub/go-mysql-server v0.20.1-0.20260901200741-de2dfefb8dec/go.mod h1:W1u45n8fFmy/C4dYXoQ2jQPzbKTA8WX2kNjLkusig2I= github.com/dolthub/gozstd v0.0.0-20240423170813-23a2903bca63 h1:OAsXLAPL4du6tfbBgK0xXHZkOlos63RdKYS3Sgw/dfI= github.com/dolthub/gozstd v0.0.0-20240423170813-23a2903bca63/go.mod h1:lV7lUeuDhH5thVGDCKXbatwKy2KW80L4rMT46n+Y2/Q= github.com/dolthub/ishell v0.0.0-20260414231531-5f031e3e9037 h1:oIW9HwuWrhxv+4HZxA+QQSKHLqWFyXZ2FmNjUYwkdiM= @@ -268,8 +268,8 @@ github.com/dolthub/pg_query_go/v6 v6.0.0-20251215122834-fb20be4254d1 h1:GY17cGA4 github.com/dolthub/pg_query_go/v6 v6.0.0-20251215122834-fb20be4254d1/go.mod h1:qnrZP3/1slFl2Bq5yw38HLOsArZareGwdpEceriblLc= github.com/dolthub/sqllogictest/go v0.0.0-20260624223518-788480b24166 h1:fdexJBpLDyAGnFbviZYfZ0jhLMnbTWTh39CyQ1EhA28= github.com/dolthub/sqllogictest/go v0.0.0-20260624223518-788480b24166/go.mod h1:e/FIZVvT2IR53HBCAo41NjqgtEnjMJGKca3Y/dAmZaA= -github.com/dolthub/vitess v0.0.0-20260828193927-f9eb707fd659 h1:DJfoBlihBdLY6KokA+wCXoYYeOgwrKuiZFPHJP8koi8= -github.com/dolthub/vitess v0.0.0-20260828193927-f9eb707fd659/go.mod h1:5SVEJgAhw5nnQUFnGgKI1Svqes1Mw+zKUsalyxmOuG0= +github.com/dolthub/vitess v0.0.0-20260831192502-e34df639d960 h1:NMg7ETQiEHsSfH8ckCTI7q0ukycwOF4qEuJ/jO1QUIM= +github.com/dolthub/vitess v0.0.0-20260831192502-e34df639d960/go.mod h1:5SVEJgAhw5nnQUFnGgKI1Svqes1Mw+zKUsalyxmOuG0= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -520,7 +520,6 @@ github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/gofork v0.0.0-20180107083740-2aebee971930/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= diff --git a/server/ast/drop_table.go b/server/ast/drop_table.go index 94d8754b5b..2278bc6364 100644 --- a/server/ast/drop_table.go +++ b/server/ast/drop_table.go @@ -15,8 +15,6 @@ package ast import ( - "github.com/cockroachdb/errors" - vitess "github.com/dolthub/vitess/go/vt/sqlparser" "github.com/dolthub/doltgresql/postgres/parser/sem/tree" @@ -24,18 +22,10 @@ import ( ) // nodeDropTable handles *tree.DropTable nodes. -func nodeDropTable(ctx *Context, node *tree.DropTable) (*vitess.DDL, error) { +func nodeDropTable(ctx *Context, node *tree.DropTable) (vitess.Statement, error) { if node == nil || len(node.Names) == 0 { return nil, nil } - switch node.DropBehavior { - case tree.DropDefault: - // Default behavior, nothing to do - case tree.DropRestrict: - return nil, errors.Errorf("RESTRICT is not yet supported") - case tree.DropCascade: - return nil, errors.Errorf("CASCADE is not yet supported") - } tableNames := make([]vitess.TableName, len(node.Names)) authTableNames := make([]string, 0, len(node.Names)*3) for i := range node.Names { @@ -47,14 +37,19 @@ func nodeDropTable(ctx *Context, node *tree.DropTable) (*vitess.DDL, error) { authTableNames = append(authTableNames, tableNames[i].DbQualifier.String(), tableNames[i].SchemaQualifier.String(), tableNames[i].Name.String()) } + authInformation := vitess.AuthInformation{ + AuthType: auth.AuthType_DROPTABLE, + TargetType: auth.AuthTargetType_TableIdentifiers, + TargetNames: authTableNames, + } return &vitess.DDL{ Action: vitess.DropStr, FromTables: tableNames, IfExists: node.IfExists, - Auth: vitess.AuthInformation{ - AuthType: auth.AuthType_DROPTABLE, - TargetType: auth.AuthTargetType_TableIdentifiers, - TargetNames: authTableNames, - }, + // RESTRICT is the default behavior in Postgres, so both are handled by the standard DROP TABLE path, which + // refuses to drop a table that other objects depend on. CASCADE also drops the dependent objects, which the + // DROP TABLE pre-execution hook handles. + Cascade: node.DropBehavior == tree.DropCascade, + Auth: authInformation, }, nil } diff --git a/server/hook/delete_table.go b/server/hook/delete_table.go index 59011c4c94..66dc602855 100644 --- a/server/hook/delete_table.go +++ b/server/hook/delete_table.go @@ -30,7 +30,7 @@ import ( // BeforeTableDeletion performs all validation necessary to ensure that table deletion does not leave the database in an // invalid state. -func BeforeTableDeletion(ctx *sql.Context, _ sql.StatementRunner, nodeInterface sql.Node) (sql.Node, error) { +func BeforeTableDeletion(ctx *sql.Context, runner sql.StatementRunner, nodeInterface sql.Node) (sql.Node, error) { // TODO: handle casts using a table name n, ok := nodeInterface.(*plan.DropTable) if !ok { @@ -47,7 +47,14 @@ func BeforeTableDeletion(ctx *sql.Context, _ sql.StatementRunner, nodeInterface resolvedTables = append(resolvedTables, doltTable) allTableNames = append(allTableNames, doltTable.TableName()) } - // TODO: handle DROP TABLE CASCADE + if n.Cascade { + // CASCADE drops the objects that depend on the dropped tables before the standard drop path runs. + if err := cascadeDropDependencies(ctx, runner, allTableNames); err != nil { + return nil, err + } + } + // These checks error on any remaining dependency: everything with CASCADE was dropped above, so anything left + // (or anything at all, without CASCADE) blocks the drop. for _, doltTable := range resolvedTables { // Check if the table is in a column if err := beforeTableDeletionCheckTableColumns(ctx, doltTable, allTableNames); err != nil { diff --git a/server/hook/delete_table_cascade.go b/server/hook/delete_table_cascade.go new file mode 100644 index 0000000000..8df17e2018 --- /dev/null +++ b/server/hook/delete_table_cascade.go @@ -0,0 +1,540 @@ +// 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" + "strings" + + "github.com/cockroachdb/errors" + "github.com/dolthub/dolt/go/libraries/doltcore/doltdb" + "github.com/dolthub/go-mysql-server/sql" + + "github.com/dolthub/doltgresql/core" + "github.com/dolthub/doltgresql/core/functions" + "github.com/dolthub/doltgresql/core/id" + "github.com/dolthub/doltgresql/core/procedures" + "github.com/dolthub/doltgresql/postgres/parser/parser" + "github.com/dolthub/doltgresql/postgres/parser/sem/tree" + "github.com/dolthub/doltgresql/server/settings" + pgtypes "github.com/dolthub/doltgresql/server/types" +) + +// cascadeDropDependencies drops the objects that depend on the tables being dropped, implementing the CASCADE drop +// behavior. Views whose definitions reference the dropped tables (directly, or through other dependent views) are +// dropped, as are foreign keys on other tables that reference the dropped tables, functions and procedures with a +// parameter of a dropped table's row type, and columns of other tables whose type is a dropped table's row type. +// Sequences owned by the dropped tables' columns (e.g. SERIAL columns) are dropped by the standard DROP TABLE path +// itself. Foreign keys declared by tables that are themselves being dropped (including self-referential keys) are +// also removed by the standard path. +func cascadeDropDependencies(ctx *sql.Context, runner sql.StatementRunner, allDeletedTables []doltdb.TableName) error { + if err := cascadeDropViews(ctx, runner, allDeletedTables); err != nil { + return err + } + if err := cascadeDropForeignKeys(ctx, allDeletedTables); err != nil { + return err + } + if err := cascadeDropRoutines(ctx, allDeletedTables); err != nil { + return err + } + return cascadeDropDependentColumns(ctx, runner, allDeletedTables) +} + +// relationKey identifies a relation (table or view) by its lowercased schema and name. +type relationKey struct { + schema string + name string +} + +// newRelationKey returns the relationKey for the given schema and name. +func newRelationKey(schema string, name string) relationKey { + return relationKey{schema: strings.ToLower(schema), name: strings.ToLower(name)} +} + +// cascadeView is a view in the current database, along with the table names its definition references. +type cascadeView struct { + schema string + name string + refs []*tree.TableName + dependent bool +} + +// cascadeDropViews drops the views whose definitions reference the tables being dropped. A view depends on a dropped +// table if its definition references the table directly, or references another view that does. Dependencies are +// determined by parsing each view's definition and resolving the referenced names the same way the engine would when +// the view is queried: explicitly qualified names match as given, and unqualified names resolve against the search +// path. Column-level dependencies are not tracked, so a view is only dropped when a dropped relation appears in its +// definition by name. +func cascadeDropViews(ctx *sql.Context, runner sql.StatementRunner, allDeletedTables []doltdb.TableName) error { + views, viewExists, err := loadDatabaseViews(ctx) + if err != nil || len(views) == 0 { + return err + } + _, root, err := core.GetRootFromContext(ctx) + if err != nil { + return err + } + searchPath, err := settings.GetCurrentSchemas(ctx) + if err != nil { + return err + } + + // Compute the closure of dependent relations: dropping a view may make further views dependent, so iterate until + // no new views are added. + closure := make(map[relationKey]struct{}, len(allDeletedTables)) + for _, tblName := range allDeletedTables { + closure[newRelationKey(tblName.Schema, tblName.Name)] = struct{}{} + } + for changed := true; changed; { + changed = false + for _, view := range views { + if view.dependent { + continue + } + dependent, err := viewDependsOnClosure(ctx, root, view, closure, searchPath, viewExists) + if err != nil { + return err + } + if dependent { + view.dependent = true + closure[newRelationKey(view.schema, view.name)] = struct{}{} + changed = true + } + } + } + + for _, view := range views { + if !view.dependent { + continue + } + // TODO: issue a notice that the view is being dropped ("drop cascades to view ...") + dropStmt := fmt.Sprintf(`DROP VIEW %s.%s;`, quoteIdentifier(view.schema), quoteIdentifier(view.name)) + if err = runStatement(ctx, runner, dropStmt); err != nil { + return err + } + } + return nil +} + +// loadDatabaseViews returns all views in the current database with their parsed table references, along with a set of +// the views' relation keys for name resolution. +func loadDatabaseViews(ctx *sql.Context) ([]*cascadeView, map[relationKey]struct{}, error) { + db, err := core.GetSqlDatabaseFromContext(ctx, "") + if err != nil { + return nil, nil, err + } + schemaDb, ok := db.(sql.SchemaDatabase) + if !ok { + return nil, nil, nil + } + schemas, err := schemaDb.AllSchemas(ctx) + if err != nil { + return nil, nil, err + } + var views []*cascadeView + viewExists := make(map[relationKey]struct{}) + for _, schema := range schemas { + viewDb, ok := schema.(sql.ViewDatabase) + if !ok { + continue + } + defs, err := viewDb.AllViews(ctx) + if err != nil { + return nil, nil, err + } + for _, def := range defs { + stmts, err := parser.Parse(def.CreateViewStatement) + if err != nil || len(stmts) == 0 { + return nil, nil, errors.Newf("could not parse the definition of view %s.%s: %v", + schema.SchemaName(), def.Name, err) + } + createView, ok := stmts[0].AST.(*tree.CreateView) + if !ok { + // Definitions that aren't plain CREATE VIEW statements (e.g. materialized views) don't have their + // dependencies tracked yet, so they are left in place. + continue + } + collector := newTableRefCollector() + collector.collectSelect(createView.AsSource) + views = append(views, &cascadeView{ + schema: schema.SchemaName(), + name: def.Name, + refs: collector.refs, + }) + viewExists[newRelationKey(schema.SchemaName(), def.Name)] = struct{}{} + } + } + return views, viewExists, nil +} + +// viewDependsOnClosure returns whether any of the given view's referenced names resolve to a relation in the given +// closure. Unqualified names resolve to the first schema on the search path containing a relation with that name, +// mirroring how the engine resolves the name when the view is queried. +func viewDependsOnClosure(ctx *sql.Context, root *core.RootValue, view *cascadeView, closure map[relationKey]struct{}, + searchPath []string, viewExists map[relationKey]struct{}) (bool, error) { + for _, ref := range view.refs { + if ref.ExplicitSchema { + if _, ok := closure[newRelationKey(ref.Schema(), ref.Table())]; ok { + return true, nil + } + continue + } + for _, schema := range searchPath { + key := newRelationKey(schema, ref.Table()) + if _, ok := closure[key]; ok { + return true, nil + } + // If the name resolves to a relation that isn't being dropped, then it shadows any same-named relation + // later on the search path. + if _, ok := viewExists[key]; ok { + break + } + hasTable, err := root.HasTable(ctx, doltdb.TableName{Schema: schema, Name: ref.Table()}) + if err != nil { + return false, err + } + if hasTable { + break + } + } + } + return false, nil +} + +// cascadeDropForeignKeys drops the foreign keys on other tables that reference the tables being dropped. These +// constraints depend on the dropped tables, so CASCADE removes them. This uses the same interface calls that the +// engine's DROP TABLE execution uses for a dropped table's declared keys. +func cascadeDropForeignKeys(ctx *sql.Context, allDeletedTables []doltdb.TableName) error { + _, root, err := core.GetRootFromContext(ctx) + if err != nil { + return err + } + fkc, err := root.GetForeignKeyCollection(ctx) + if err != nil { + return err + } + for _, tblName := range allDeletedTables { + _, referencedByFk := fkc.KeysForTable(tblName) + if len(referencedByFk) == 0 { + continue + } + sqlTable, err := core.GetSqlTableFromContext(ctx, "", tblName) + if err != nil { + return err + } + if sqlTable == nil { + return errors.Newf(`table "%s" was resolved but could not be found`, tblName.Name) + } + fkTable, ok := sqlTable.(sql.ForeignKeyTable) + if !ok { + continue + } + for _, fk := range referencedByFk { + if tableNameInSet(fk.TableName, allDeletedTables) { + continue + } + // TODO: issue a notice that the constraint is being dropped ("drop cascades to constraint ... on table ...") + if err = fkTable.DropForeignKey(ctx, fk.Name, fk.TableName.Name, fk.TableName.Schema); err != nil { + return err + } + } + } + return nil +} + +// cascadeDropRoutines drops the functions and procedures that have a parameter typed with a dropped table's row type. +func cascadeDropRoutines(ctx *sql.Context, allDeletedTables []doltdb.TableName) error { + deletedTypes := deletedTableTypes(allDeletedTables) + funcsColl, err := core.GetFunctionsCollectionFromContext(ctx, "") + if err != nil { + return err + } + var funcIDs []id.Function + err = funcsColl.IterateFunctions(ctx, func(f functions.Function) (stop bool, err error) { + for _, param := range f.AllParams { + if _, ok := deletedTypes[param.Type]; ok { + funcIDs = append(funcIDs, f.ID) + break + } + } + return false, nil + }) + if err != nil { + return err + } + // TODO: issue a notice for each dropped routine ("drop cascades to function ...") + if err = funcsColl.DropFunction(ctx, funcIDs...); err != nil { + return err + } + procsColl, err := core.GetProceduresCollectionFromContext(ctx, "") + if err != nil { + return err + } + var procIDs []id.Procedure + err = procsColl.IterateProcedures(ctx, func(p procedures.Procedure) (stop bool, err error) { + for _, param := range p.AllParams { + if _, ok := deletedTypes[param.Type]; ok { + procIDs = append(procIDs, p.ID) + break + } + } + return false, nil + }) + if err != nil { + return err + } + return procsColl.DropProcedure(ctx, procIDs...) +} + +// cascadeDropDependentColumns drops the columns of other tables whose type is a dropped table's row type. This matches +// Postgres, which drops just the dependent column rather than the whole table. +func cascadeDropDependentColumns(ctx *sql.Context, runner sql.StatementRunner, allDeletedTables []doltdb.TableName) error { + _, root, err := core.GetRootFromContext(ctx) + if err != nil { + return err + } + deletedTypes := deletedTableTypes(allDeletedTables) + allTableNames, err := root.GetAllTableNames(ctx, false) + if err != nil { + return err + } + type dependentColumn struct { + table doltdb.TableName + column string + } + // Collect all the dependent columns before dropping any, so that the scan works from a consistent root. + var dependentColumns []dependentColumn + for _, otherTableName := range allTableNames { + if doltdb.IsSystemTable(otherTableName) { + // System tables don't use any table types + continue + } + if tableNameInSet(otherTableName, allDeletedTables) { + // If we're also deleting this table, then it doesn't matter what the columns have + continue + } + otherTable, ok, err := root.GetTable(ctx, otherTableName) + if err != nil { + return err + } + if !ok { + return errors.Newf("root returned table name `%s` but it could not be found?", otherTableName.String()) + } + otherTableSch, err := otherTable.GetSchema(ctx) + if err != nil { + return err + } + for _, col := range otherTableSch.GetAllCols().GetColumns() { + dgtype, ok := col.TypeInfo.ToSqlType().(*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 _, ok = deletedTypes[dgtype.ID]; ok { + dependentColumns = append(dependentColumns, dependentColumn{table: otherTableName, column: col.Name}) + } + } + } + for _, depCol := range dependentColumns { + // TODO: issue a notice that the column is being dropped ("drop cascades to column ... of table ...") + alterStmt := fmt.Sprintf(`ALTER TABLE %s DROP COLUMN %s;`, + quotedQualifiedName(depCol.table), quoteIdentifier(depCol.column)) + if err = runStatement(ctx, runner, alterStmt); err != nil { + return err + } + } + return nil +} + +// deletedTableTypes returns the set of row types belonging to the given tables. +func deletedTableTypes(allDeletedTables []doltdb.TableName) map[id.Type]struct{} { + deletedTypes := make(map[id.Type]struct{}, len(allDeletedTables)) + for _, tblName := range allDeletedTables { + deletedTypes[id.NewType(tblName.Schema, tblName.Name)] = struct{}{} + } + return deletedTypes +} + +// tableNameInSet returns whether the given table name is in the given set of table names, ignoring case. +func tableNameInSet(name doltdb.TableName, set []doltdb.TableName) bool { + for _, candidate := range set { + if candidate.EqualFold(name) { + return true + } + } + return false +} + +// runStatement runs the given statement on the given runner, draining and discarding any returned rows. The statement +// runs as though it were interpreted, since it's a new statement running inside the original one. +func runStatement(ctx *sql.Context, runner sql.StatementRunner, statement string) error { + _, err := sql.RunInterpreted(ctx, func(subCtx *sql.Context) (struct{}, error) { + _, rowIter, _, err := runner.QueryWithBindings(subCtx, statement, nil, nil, nil) + if err != nil { + return struct{}{}, err + } + _, err = sql.RowIterToRows(subCtx, rowIter) + return struct{}{}, err + }) + return err +} + +// quoteIdentifier returns the given identifier in its quoted form. +func quoteIdentifier(name string) string { + return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` +} + +// quotedQualifiedName returns the given table name in its quoted, schema-qualified form. Names without a schema are +// returned unqualified. +func quotedQualifiedName(name doltdb.TableName) string { + if len(name.Schema) == 0 { + return quoteIdentifier(name.Name) + } + return quoteIdentifier(name.Schema) + "." + quoteIdentifier(name.Name) +} + +// tableRefCollector collects the table names referenced by a view definition. Common table expression names are +// tracked so that references to them are not mistaken for table references. +type tableRefCollector struct { + refs []*tree.TableName + cteNames map[string]struct{} +} + +var _ tree.Visitor = (*tableRefCollector)(nil) + +// newTableRefCollector returns a new *tableRefCollector. +func newTableRefCollector() *tableRefCollector { + return &tableRefCollector{cteNames: make(map[string]struct{})} +} + +// VisitPre implements the interface tree.Visitor. It recurses into subqueries appearing in expression position. +func (c *tableRefCollector) VisitPre(expr tree.Expr) (recurse bool, newExpr tree.Expr) { + if subquery, ok := expr.(*tree.Subquery); ok { + c.collectSelectStatement(subquery.Select) + } + return true, expr +} + +// VisitPost implements the interface tree.Visitor. +func (c *tableRefCollector) VisitPost(expr tree.Expr) tree.Expr { + return expr +} + +// collectExpr collects the table references from any subqueries within the given expression. +func (c *tableRefCollector) collectExpr(expr tree.Expr) { + if expr != nil { + tree.WalkExpr(c, expr) + } +} + +// collectSelect collects the table references from the given SELECT statement, including its CTEs, ORDER BY, and +// LIMIT clauses. +func (c *tableRefCollector) collectSelect(sel *tree.Select) { + if sel == nil { + return + } + if sel.With != nil { + for _, cte := range sel.With.CTEList { + c.collectStatement(cte.Stmt) + c.cteNames[strings.ToLower(string(cte.Name.Alias))] = struct{}{} + } + } + c.collectSelectStatement(sel.Select) + for _, order := range sel.OrderBy { + c.collectExpr(order.Expr) + } + if sel.Limit != nil { + c.collectExpr(sel.Limit.Count) + c.collectExpr(sel.Limit.Offset) + } +} + +// collectSelectStatement collects the table references from the given select statement variant. +func (c *tableRefCollector) collectSelectStatement(stmt tree.SelectStatement) { + switch stmt := stmt.(type) { + case *tree.ParenSelect: + c.collectSelect(stmt.Select) + case *tree.SelectClause: + for _, tableExpr := range stmt.From.Tables { + c.collectTableExpr(tableExpr) + } + for i := range stmt.Exprs { + c.collectExpr(stmt.Exprs[i].Expr) + } + if stmt.Where != nil { + c.collectExpr(stmt.Where.Expr) + } + for _, expr := range stmt.GroupBy { + c.collectExpr(expr) + } + if stmt.Having != nil { + c.collectExpr(stmt.Having.Expr) + } + for _, expr := range stmt.DistinctOn { + c.collectExpr(expr) + } + case *tree.UnionClause: + c.collectSelect(stmt.Left) + c.collectSelect(stmt.Right) + case *tree.ValuesClause: + for _, row := range stmt.Rows { + for _, expr := range row { + c.collectExpr(expr) + } + } + } +} + +// collectStatement collects the table references from the given statement, which may be any statement form that can +// appear in a CTE. +func (c *tableRefCollector) collectStatement(stmt tree.Statement) { + switch stmt := stmt.(type) { + case *tree.Select: + c.collectSelect(stmt) + case tree.SelectStatement: + c.collectSelectStatement(stmt) + } +} + +// collectTableExpr collects the table references from the given table expression. +func (c *tableRefCollector) collectTableExpr(expr tree.TableExpr) { + switch expr := expr.(type) { + case *tree.AliasedTableExpr: + c.collectTableExpr(expr.Expr) + case *tree.ParenTableExpr: + c.collectTableExpr(expr.Expr) + case *tree.JoinTableExpr: + c.collectTableExpr(expr.Left) + c.collectTableExpr(expr.Right) + if cond, ok := expr.Cond.(*tree.OnJoinCond); ok { + c.collectExpr(cond.Expr) + } + case *tree.TableName: + if !expr.ExplicitSchema { + if _, ok := c.cteNames[strings.ToLower(expr.Table())]; ok { + return + } + } + c.refs = append(c.refs, expr) + case *tree.Subquery: + c.collectSelectStatement(expr.Select) + case *tree.StatementSource: + c.collectStatement(expr.Statement) + case *tree.RowsFromExpr: + for _, item := range expr.Items { + c.collectExpr(item) + } + } +} diff --git a/testing/generation/command_docs/output/drop_table_test.go b/testing/generation/command_docs/output/drop_table_test.go index cdccad18c2..d7dc5407f4 100644 --- a/testing/generation/command_docs/output/drop_table_test.go +++ b/testing/generation/command_docs/output/drop_table_test.go @@ -22,14 +22,14 @@ func TestDropTable(t *testing.T) { Converts("DROP TABLE IF EXISTS name"), Converts("DROP TABLE name , name"), Converts("DROP TABLE IF EXISTS name , name"), - Parses("DROP TABLE name CASCADE"), - Parses("DROP TABLE IF EXISTS name CASCADE"), - Parses("DROP TABLE name , name CASCADE"), - Parses("DROP TABLE IF EXISTS name , name CASCADE"), - Parses("DROP TABLE name RESTRICT"), - Parses("DROP TABLE IF EXISTS name RESTRICT"), - Parses("DROP TABLE name , name RESTRICT"), - Parses("DROP TABLE IF EXISTS name , name RESTRICT"), + Converts("DROP TABLE name CASCADE"), + Converts("DROP TABLE IF EXISTS name CASCADE"), + Converts("DROP TABLE name , name CASCADE"), + Converts("DROP TABLE IF EXISTS name , name CASCADE"), + Converts("DROP TABLE name RESTRICT"), + Converts("DROP TABLE IF EXISTS name RESTRICT"), + Converts("DROP TABLE name , name RESTRICT"), + Converts("DROP TABLE IF EXISTS name , name RESTRICT"), } RunTests(t, tests) } diff --git a/testing/go/drop_table_test.go b/testing/go/drop_table_test.go index 57e44e9120..0b5db65a48 100644 --- a/testing/go/drop_table_test.go +++ b/testing/go/drop_table_test.go @@ -107,3 +107,361 @@ func TestDropTable(t *testing.T) { }, }) } + +func TestDropTableCascade(t *testing.T) { + RunScripts(t, []ScriptTest{ + { + Name: "DROP TABLE CASCADE drops foreign keys referencing the table", + SetUpScript: []string{ + `CREATE TABLE parent (pk INT4 PRIMARY KEY, v1 TEXT);`, + `CREATE TABLE child (pk INT4 PRIMARY KEY, parent_pk INT4, CONSTRAINT child_parent_fk FOREIGN KEY (parent_pk) REFERENCES parent (pk));`, + `INSERT INTO parent VALUES (1, 'one');`, + `INSERT INTO child VALUES (10, 1);`, + }, + Assertions: []ScriptTestAssertion{ + { + // Without CASCADE, dropping a table that a foreign key references is an error + Query: `DROP TABLE parent;`, + ExpectedErr: "cannot drop table", + }, + { + // RESTRICT is the default behavior, so it errors the same way + Query: `DROP TABLE parent RESTRICT;`, + ExpectedErr: "cannot drop table", + }, + { + Query: `DROP TABLE parent CASCADE;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM parent;`, + ExpectedErr: "not found", + }, + { + // The child table sticks around, only its foreign key constraint was dropped + Query: `SELECT * FROM child;`, + Expected: []sql.Row{{10, 1}}, + }, + { + // The foreign key constraint no longer exists, so this insert succeeds + Query: `INSERT INTO child VALUES (11, 99);`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM child ORDER BY pk;`, + Expected: []sql.Row{{10, 1}, {11, 99}}, + }, + }, + }, + { + Name: "DROP TABLE RESTRICT succeeds when nothing depends on the table", + SetUpScript: []string{ + `CREATE TABLE test (pk INT4 PRIMARY KEY);`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `DROP TABLE test RESTRICT;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM test;`, + ExpectedErr: "not found", + }, + }, + }, + { + Name: "DROP TABLE CASCADE with no dependencies", + SetUpScript: []string{ + `CREATE TABLE test (pk INT4 PRIMARY KEY);`, + `INSERT INTO test VALUES (1);`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `DROP TABLE test CASCADE;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM test;`, + ExpectedErr: "not found", + }, + }, + }, + { + Name: "DROP TABLE CASCADE with a self-referential foreign key", + SetUpScript: []string{ + `CREATE TABLE test (pk INT4 PRIMARY KEY, parent_pk INT4, CONSTRAINT test_self_fk FOREIGN KEY (parent_pk) REFERENCES test (pk));`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `DROP TABLE test CASCADE;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM test;`, + ExpectedErr: "not found", + }, + }, + }, + { + Name: "DROP TABLE CASCADE with multiple tables", + SetUpScript: []string{ + `CREATE TABLE parent (pk INT4 PRIMARY KEY);`, + `CREATE TABLE middle (pk INT4 PRIMARY KEY, parent_pk INT4, CONSTRAINT middle_parent_fk FOREIGN KEY (parent_pk) REFERENCES parent (pk));`, + `CREATE TABLE child (pk INT4 PRIMARY KEY, middle_pk INT4, CONSTRAINT child_middle_fk FOREIGN KEY (middle_pk) REFERENCES middle (pk));`, + }, + Assertions: []ScriptTestAssertion{ + { + // parent is listed before middle, which references it; middle's foreign key on parent is handled + // by dropping both tables, while child's foreign key on middle is dropped by the cascade + Query: `DROP TABLE parent, middle CASCADE;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM parent;`, + ExpectedErr: "not found", + }, + { + Query: `SELECT * FROM middle;`, + ExpectedErr: "not found", + }, + { + Query: `INSERT INTO child VALUES (1, 99);`, + Expected: []sql.Row{}, + }, + }, + }, + { + Name: "DROP TABLE IF EXISTS CASCADE", + SetUpScript: []string{ + `CREATE TABLE parent (pk INT4 PRIMARY KEY);`, + `CREATE TABLE child (pk INT4 PRIMARY KEY, parent_pk INT4, CONSTRAINT child_parent_fk FOREIGN KEY (parent_pk) REFERENCES parent (pk));`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `DROP TABLE IF EXISTS doesnotexist CASCADE;`, + Expected: []sql.Row{}, + }, + { + Query: `DROP TABLE doesnotexist CASCADE;`, + // This matches the standard DROP TABLE path's error; Postgres says `table "doesnotexist" does not exist` + ExpectedErr: `table not found: doesnotexist`, + }, + { + Query: `DROP TABLE IF EXISTS doesnotexist, parent CASCADE;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM parent;`, + ExpectedErr: "not found", + }, + { + Query: `INSERT INTO child VALUES (1, 99);`, + Expected: []sql.Row{}, + }, + }, + }, + { + Name: "DROP TABLE CASCADE with schema-qualified names", + SetUpScript: []string{ + `CREATE SCHEMA sch1;`, + `CREATE SCHEMA sch2;`, + `CREATE TABLE sch1.parent (pk INT4 PRIMARY KEY);`, + `CREATE TABLE sch2.child (pk INT4 PRIMARY KEY, parent_pk INT4, CONSTRAINT child_parent_fk FOREIGN KEY (parent_pk) REFERENCES sch1.parent (pk));`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `DROP TABLE sch1.parent;`, + ExpectedErr: "cannot drop table", + }, + { + Query: `DROP TABLE sch1.parent CASCADE;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM sch1.parent;`, + ExpectedErr: "not found", + }, + { + Query: `INSERT INTO sch2.child VALUES (1, 99);`, + Expected: []sql.Row{}, + }, + }, + }, + { + Name: "DROP TABLE CASCADE resolves tables on the search path", + SetUpScript: []string{ + `CREATE SCHEMA sch1;`, + `SET search_path TO sch1;`, + `CREATE TABLE parent (pk INT4 PRIMARY KEY);`, + `CREATE TABLE child (pk INT4 PRIMARY KEY, parent_pk INT4, CONSTRAINT child_parent_fk FOREIGN KEY (parent_pk) REFERENCES parent (pk));`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `DROP TABLE parent CASCADE;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM sch1.parent;`, + ExpectedErr: "not found", + }, + { + Query: `INSERT INTO sch1.child VALUES (1, 99);`, + Expected: []sql.Row{}, + }, + }, + }, + { + Name: "DROP TABLE CASCADE drops sequences owned by the table", + SetUpScript: []string{ + `CREATE TABLE test (pk SERIAL PRIMARY KEY, v1 TEXT);`, + `INSERT INTO test (v1) VALUES ('one');`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `SELECT nextval('test_pk_seq');`, + Expected: []sql.Row{{2}}, + }, + { + Query: `DROP TABLE test CASCADE;`, + Expected: []sql.Row{}, + }, + { + // The sequence backing the SERIAL column was dropped along with the table + Query: `SELECT nextval('test_pk_seq');`, + ExpectedErr: "does not exist", + }, + }, + }, + { + Name: "DROP TABLE CASCADE with a dependent view", + SetUpScript: []string{ + `CREATE TABLE test (pk INT4 PRIMARY KEY, v1 TEXT);`, + `INSERT INTO test VALUES (1, 'one');`, + `CREATE VIEW test_view AS SELECT * FROM test;`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `DROP TABLE test CASCADE;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM test_view;`, + ExpectedErr: "not found", + }, + }, + }, + { + Name: "DROP TABLE CASCADE drops transitively dependent views only", + SetUpScript: []string{ + `CREATE TABLE test (pk INT4 PRIMARY KEY, v1 TEXT);`, + `CREATE TABLE other (pk INT4 PRIMARY KEY);`, + `INSERT INTO test VALUES (1, 'one');`, + `INSERT INTO other VALUES (7);`, + `CREATE VIEW test_view AS SELECT * FROM test;`, + `CREATE VIEW test_view_view AS SELECT pk FROM test_view;`, + `CREATE VIEW other_view AS SELECT * FROM other;`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `DROP TABLE test CASCADE;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM test_view;`, + ExpectedErr: "not found", + }, + { + // The view on the dependent view is dropped as well + Query: `SELECT * FROM test_view_view;`, + ExpectedErr: "not found", + }, + { + // Views that don't depend on the dropped table are left alone + Query: `SELECT * FROM other_view;`, + Expected: []sql.Row{{7}}, + }, + }, + }, + { + Name: "DROP TABLE CASCADE does not drop views resolving to a same-named table in another schema", + SetUpScript: []string{ + `CREATE SCHEMA sch1;`, + `CREATE TABLE test (pk INT4 PRIMARY KEY);`, + `CREATE TABLE sch1.test (pk INT4 PRIMARY KEY);`, + `INSERT INTO sch1.test VALUES (3);`, + `CREATE VIEW qualified_view AS SELECT * FROM sch1.test;`, + `CREATE VIEW unqualified_view AS SELECT * FROM test;`, + }, + Assertions: []ScriptTestAssertion{ + { + // Drops public.test; sch1.test and the views that resolve to it survive + Query: `DROP TABLE test CASCADE;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM qualified_view;`, + Expected: []sql.Row{{3}}, + }, + { + // The unqualified reference resolved to public.test, which was dropped + Query: `SELECT * FROM unqualified_view;`, + ExpectedErr: "not found", + }, + }, + }, + { + Name: "DROP TABLE CASCADE drops columns using the table's row type", + SetUpScript: []string{ + `CREATE TABLE test1 (pk INT4 PRIMARY KEY, v1 TEXT);`, + `CREATE TABLE test2 (pk INT4 PRIMARY KEY, v1 test1);`, + `INSERT INTO test2 VALUES (1, ROW(2, 'abc')::test1);`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `DROP TABLE test1;`, + ExpectedErr: "cannot drop table test1 because other objects depend on it", + }, + { + // The dependent column is dropped, not the whole table + Query: `DROP TABLE test1 CASCADE;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM test2;`, + Expected: []sql.Row{{1}}, + }, + }, + }, + { + Name: "DROP TABLE CASCADE drops functions and procedures using the table's row type", + SetUpScript: []string{ + `CREATE TABLE test (pk INT4 PRIMARY KEY, v1 TEXT);`, + `CREATE FUNCTION dependent_func(t test) RETURNS INT4 AS $$ BEGIN RETURN t.pk * 2; END; $$ LANGUAGE plpgsql;`, + `CREATE FUNCTION unrelated_func(v INT4) RETURNS INT4 AS $$ BEGIN RETURN v + 1; END; $$ LANGUAGE plpgsql;`, + `CREATE PROCEDURE dependent_proc(input test) AS $$ BEGIN END; $$ LANGUAGE plpgsql;`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `DROP TABLE test;`, + ExpectedErr: "cannot drop table test because other objects depend on it", + }, + { + Query: `DROP TABLE test CASCADE;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT dependent_func(NULL);`, + ExpectedErr: "not found", + }, + { + Query: `SELECT unrelated_func(1);`, + Expected: []sql.Row{{2}}, + }, + { + Query: `CALL dependent_proc(NULL);`, + ExpectedErr: "does not exist", + }, + }, + }, + }) +}