diff --git a/go/cmd/dolt/commands/schcmds/copy-tags.go b/go/cmd/dolt/commands/schcmds/copy-tags.go index 7eb4778330a..4d9a9c29098 100644 --- a/go/cmd/dolt/commands/schcmds/copy-tags.go +++ b/go/cmd/dolt/commands/schcmds/copy-tags.go @@ -334,7 +334,7 @@ func updateRootWithNewColumnTag(ctx context.Context, root doltdb.RootValue, tabl } // Update the column tag in the schema - updatedSchema, err := updateColumnTag(sch, columnName, tag) + updatedSchema, err := schema.WithUpdatedColumnTag(sch, columnName, tag) if err != nil { return nil, err } diff --git a/go/cmd/dolt/commands/schcmds/update-tag.go b/go/cmd/dolt/commands/schcmds/update-tag.go index ad6d2da69d1..e2ec7e62e01 100644 --- a/go/cmd/dolt/commands/schcmds/update-tag.go +++ b/go/cmd/dolt/commands/schcmds/update-tag.go @@ -16,7 +16,6 @@ package schcmds import ( "context" - "fmt" "strconv" "github.com/dolthub/dolt/go/cmd/dolt/cli" @@ -88,7 +87,7 @@ func (cmd UpdateTagCmd) Exec(ctx context.Context, commandStr string, args []stri tbl, tName, ok, err := doltdb.GetTableInsensitive(ctx, root, doltdb.TableName{Name: tableName}) if err != nil { - return commands.HandleVErrAndExitCode(errhand.BuildDError("failed to get table").Build(), usage) + return commands.HandleVErrAndExitCode(errhand.BuildDError("failed to get table").AddCause(err).Build(), usage) } if !ok { return commands.HandleVErrAndExitCode(errhand.BuildDError("table %s does not exist", tableName).Build(), usage) @@ -96,10 +95,10 @@ func (cmd UpdateTagCmd) Exec(ctx context.Context, commandStr string, args []stri sch, err := tbl.GetSchema(ctx) if err != nil { - return commands.HandleVErrAndExitCode(errhand.BuildDError("failed to get schema").Build(), usage) + return commands.HandleVErrAndExitCode(errhand.BuildDError("failed to get schema").AddCause(err).Build(), usage) } - newSch, err := updateColumnTag(sch, columnName, tag) + newSch, err := schema.WithUpdatedColumnTag(sch, columnName, tag) if err != nil { return commands.HandleVErrAndExitCode(errhand.BuildDError("failed to update column tag").AddCause(err).Build(), usage) } @@ -121,34 +120,3 @@ func (cmd UpdateTagCmd) Exec(ctx context.Context, commandStr string, args []stri return commands.HandleVErrAndExitCode(nil, usage) } - -func updateColumnTag(sch schema.Schema, name string, tag uint64) (schema.Schema, error) { - var found bool - columns := sch.GetAllCols().GetColumns() - // Find column and update its tag - for i, col := range columns { - if col.Name == name { - col.Tag = tag - columns[i] = col - found = true - break - } - } - - if !found { - return nil, fmt.Errorf("column %s does not exist", name) - } - - newSch, err := schema.SchemaFromCols(schema.NewColCollection(columns...)) - if err != nil { - return nil, err - } - - err = newSch.SetPkOrdinals(sch.GetPkOrdinals()) - if err != nil { - return nil, err - } - newSch.SetCollation(sch.GetCollation()) - - return newSch, nil -} diff --git a/go/libraries/doltcore/doltdb/root_val.go b/go/libraries/doltcore/doltdb/root_val.go index b7cd3b53abf..8c2b8f11d19 100644 --- a/go/libraries/doltcore/doltdb/root_val.go +++ b/go/libraries/doltcore/doltdb/root_val.go @@ -1368,6 +1368,29 @@ func (root *rootValue) DebugString(ctx context.Context, transitive bool) string return buf.String() } +// DiffTableHashes returns the table-level changes between |from| and |to|, both produced by +// [MapTableHashes] on two roots. Modified and added tables map to their |to| hash. Deleted +// tables map to the zero hash rather than being omitted, so two diffs computed against the +// same baseline compare equal under [maps.Equal] when the same tables were dropped. +func DiffTableHashes(from, to map[TableName]hash.Hash) map[TableName]hash.Hash { + diffs := make(map[TableName]hash.Hash) + for name, fromHash := range from { + if toHash, ok := to[name]; ok { + if toHash != fromHash { + diffs[name] = toHash + } + } else { + diffs[name] = hash.Hash{} + } + } + for name, toHash := range to { + if _, ok := from[name]; !ok { + diffs[name] = toHash + } + } + return diffs +} + // MapTableHashes returns a map of each table name and hash. func MapTableHashes(ctx context.Context, root RootValue) (map[TableName]hash.Hash, error) { names, err := UnionTableNames(ctx, root) diff --git a/go/libraries/doltcore/doltdb/roots_status.go b/go/libraries/doltcore/doltdb/roots_status.go new file mode 100644 index 00000000000..cb921b8f0aa --- /dev/null +++ b/go/libraries/doltcore/doltdb/roots_status.go @@ -0,0 +1,95 @@ +// 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 doltdb + +import ( + "context" + "maps" + + "github.com/dolthub/dolt/go/store/hash" +) + +// RootsStatus holds a [Roots] bundle's staged and unstaged table diffs. Construct with +// [NewRootsStatus]; a nil value means the branch has no uncommitted changes. +type RootsStatus struct { + // Head holds the committed table hashes, used to classify entries in Staged and Unstaged. + Head map[TableName]hash.Hash + // Staged is the head-to-staged diff: tables where staged differs from head. + Staged map[TableName]hash.Hash + // Unstaged is the staged-to-working diff: tables where working differs from staged. + Unstaged map[TableName]hash.Hash +} + +// NewRootsStatus returns |roots|' staged and unstaged table diffs, or nil when both are +// empty. Read-only system tables and tables in |ignored| are excluded. +func NewRootsStatus(ctx context.Context, roots Roots, ignored *TableNameSet) (*RootsStatus, error) { + head, err := userTableHashes(ctx, roots.Head, ignored) + if err != nil { + return nil, err + } + stagedHashes, err := userTableHashes(ctx, roots.Staged, ignored) + if err != nil { + return nil, err + } + workingHashes, err := userTableHashes(ctx, roots.Working, ignored) + if err != nil { + return nil, err + } + staged := DiffTableHashes(head, stagedHashes) + unstaged := DiffTableHashes(stagedHashes, workingHashes) + if len(staged) == 0 && len(unstaged) == 0 { + return nil, nil + } + return &RootsStatus{Head: head, Staged: staged, Unstaged: unstaged}, nil +} + +// Added returns staged tables that are absent from head. +func (s *RootsStatus) Added() []TableName { + var out []TableName + for name := range s.Staged { + if _, inHead := s.Head[name]; !inHead { + out = append(out, name) + } + } + return out +} + +// Untracked returns unstaged tables that are absent from both staged and head. +func (s *RootsStatus) Untracked() []TableName { + var out []TableName + for name := range s.Unstaged { + if _, inHead := s.Head[name]; inHead { + continue + } + if _, inStaged := s.Staged[name]; inStaged { + continue + } + out = append(out, name) + } + return out +} + +// userTableHashes returns |root|'s table hashes excluding read-only system tables and any +// table named in |ignored|. +func userTableHashes(ctx context.Context, root RootValue, ignored *TableNameSet) (map[TableName]hash.Hash, error) { + hashes, err := MapTableHashes(ctx, root) + if err != nil { + return nil, err + } + maps.DeleteFunc(hashes, func(name TableName, _ hash.Hash) bool { + return IsReadOnlySystemTable(name) || ignored.Contains(name) + }) + return hashes, nil +} diff --git a/go/libraries/doltcore/env/actions/carry_tables.go b/go/libraries/doltcore/env/actions/carry_tables.go new file mode 100644 index 00000000000..0a79abc65fe --- /dev/null +++ b/go/libraries/doltcore/env/actions/carry_tables.go @@ -0,0 +1,180 @@ +// 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 actions + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/dolthub/dolt/go/libraries/doltcore/doltdb" + "github.com/dolthub/dolt/go/libraries/doltcore/schema" + "github.com/dolthub/dolt/go/store/types" +) + +// CarryTablesAbsentFromBaseline copies tables that exist in |src| but not in |baseline| into +// |dest|, resolving column tag collisions and carrying matching foreign keys. Tables already +// on |dest| are left alone. Names in |exclude| are skipped; pass nil to carry all. +func CarryTablesAbsentFromBaseline(ctx context.Context, src, baseline, dest doltdb.RootValue, exclude *doltdb.TableNameSet) (doltdb.RootValue, error) { + srcSchemas, err := doltdb.GetAllSchemas(ctx, src) + if err != nil { + return nil, err + } + destSchemas, err := doltdb.GetAllSchemas(ctx, dest) + if err != nil { + return nil, err + } + + absent, err := tablesAbsentFromBaseline(ctx, srcSchemas, baseline, exclude) + if err != nil { + return nil, err + } + + dest, carried, tagRemaps, err := carryTables(ctx, src, dest, absent, destSchemas) + if err != nil { + return nil, err + } + + if len(carried) == 0 { + return dest, nil + } + + return carryForeignKeys(ctx, src, dest, carried, tagRemaps, srcSchemas, destSchemas) +} + +// tablesAbsentFromBaseline returns the entries of |srcSchemas| not present in |baseline|, +// excluding read-only dolt system tables and any names in |exclude|. +func tablesAbsentFromBaseline(ctx context.Context, srcSchemas map[doltdb.TableName]schema.Schema, baseline doltdb.RootValue, exclude *doltdb.TableNameSet) (map[doltdb.TableName]schema.Schema, error) { + baselineNames, err := baseline.GetAllTableNames(ctx, false) + if err != nil { + return nil, err + } + baselineSet := doltdb.NewTableNameSet(baselineNames) + + absent := make(map[doltdb.TableName]schema.Schema, len(srcSchemas)) + for name, sch := range srcSchemas { + if baselineSet.Contains(name) || doltdb.IsReadOnlySystemTable(name) { + continue + } + if exclude != nil && exclude.Contains(name) { + continue + } + absent[name] = sch + } + return absent, nil +} + +// carryTables copies each entry of |toCarry| from |src| into |dest|, retagging columns whose +// tag collides with one held by |dest| or by an earlier carried table. Names already on |dest| +// are skipped so the dest version wins. Returns the updated |dest|, the names that were +// carried, and the per-table tag remaps so the caller can fix up foreign keys. +func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry, destSchemas map[doltdb.TableName]schema.Schema) (doltdb.RootValue, []doltdb.TableName, map[doltdb.TableName]map[uint64]uint64, error) { + heldTags := make(schema.TagMapping) + for tblName, tblSch := range destSchemas { + for _, t := range tblSch.GetAllCols().Tags { + heldTags.Add(t, tblName.Name) + } + } + + allTagRemaps := make(map[doltdb.TableName]map[uint64]uint64) + var carried []doltdb.TableName + // Sort so retags reproduce across runs. Each table's new tags depend on tables + // carried before it. + names := make([]doltdb.TableName, 0, len(toCarry)) + for name := range toCarry { + names = append(names, name) + } + slices.SortFunc(names, func(a, b doltdb.TableName) int { return strings.Compare(a.String(), b.String()) }) + for _, name := range names { + if _, exists := destSchemas[name]; exists { + continue + } + + tbl, exists, err := src.GetTable(ctx, name) + if err != nil { + return nil, nil, nil, err + } + if !exists { + return nil, nil, nil, fmt.Errorf("table %q does not exist in src root", name.String()) + } + + sch := toCarry[name] + columns := sch.GetAllCols().GetColumns() + tagRemap := make(map[uint64]uint64) + priorKinds := make([]types.NomsKind, 0, len(columns)) + for _, column := range columns { + if heldTags.Contains(column.Tag) { + newTag := schema.AutoGenerateTag(heldTags, name.Name, priorKinds, column.Name, column.Kind) + tagRemap[column.Tag] = newTag + column.Tag = newTag + } + priorKinds = append(priorKinds, column.Kind) + heldTags.Add(column.Tag, name.Name) + } + + if len(tagRemap) > 0 { + allTagRemaps[name] = tagRemap + newSch, err := schema.WithRemappedColumnTags(sch, tagRemap) + if err != nil { + return nil, nil, nil, err + } + tbl, err = tbl.UpdateSchema(ctx, newSch) + if err != nil { + return nil, nil, nil, err + } + } + dest, err = dest.PutTable(ctx, name, tbl) + if err != nil { + return nil, nil, nil, err + } + carried = append(carried, name) + } + return dest, carried, allTagRemaps, nil +} + +// carryForeignKeys copies foreign keys from |src| whose child table is in |carried| into +// |dest|, applying |tagRemaps| to child columns. Parent column tags are re-resolved by +// column name via |destSchemas| when the parent already lives on |dest|. An existing key +// of the same name on |dest| is replaced. +func carryForeignKeys(ctx context.Context, src, dest doltdb.RootValue, carried []doltdb.TableName, tagRemaps map[doltdb.TableName]map[uint64]uint64, srcSchemas, destSchemas map[doltdb.TableName]schema.Schema) (doltdb.RootValue, error) { + destFks, err := dest.GetForeignKeyCollection(ctx) + if err != nil { + return nil, err + } + srcFks, err := src.GetForeignKeyCollection(ctx) + if err != nil { + return nil, err + } + carriedSet := doltdb.NewTableNameSet(carried) + err = srcFks.Iter(func(fk doltdb.ForeignKey) (stop bool, err error) { + if !carriedSet.Contains(fk.TableName) { + return false, nil + } + fk.TableColumns = schema.RemapTags(fk.TableColumns, tagRemaps[fk.TableName]) + if carriedSet.Contains(fk.ReferencedTableName) { + fk.ReferencedTableColumns = schema.RemapTags(fk.ReferencedTableColumns, tagRemaps[fk.ReferencedTableName]) + } else { + fk.ReferencedTableColumns = schema.RemapTagsByColumnName(fk.ReferencedTableColumns, srcSchemas[fk.ReferencedTableName], destSchemas[fk.ReferencedTableName]) + } + // Remove the merge's stale copy first. AddKeys would otherwise refuse the duplicate name. + destFks.RemoveKeyByName(fk.Name, fk.TableName) + return false, destFks.AddKeys(fk) + }) + if err != nil { + return nil, err + } + return dest.PutForeignKeyCollection(ctx, destFks) +} diff --git a/go/libraries/doltcore/env/actions/carry_tables_test.go b/go/libraries/doltcore/env/actions/carry_tables_test.go new file mode 100644 index 00000000000..709cde4e4f9 --- /dev/null +++ b/go/libraries/doltcore/env/actions/carry_tables_test.go @@ -0,0 +1,269 @@ +// 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 actions + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/dolthub/dolt/go/libraries/doltcore/doltdb" + "github.com/dolthub/dolt/go/libraries/doltcore/dtestutils" + "github.com/dolthub/dolt/go/libraries/doltcore/schema" + "github.com/dolthub/dolt/go/libraries/doltcore/schema/typeinfo" +) + +// putFK installs a single-entry foreign key collection onto |root| and returns the updated root. +func putFK(t *testing.T, ctx context.Context, root doltdb.RootValue, fk doltdb.ForeignKey) doltdb.RootValue { + t.Helper() + fks, err := doltdb.NewForeignKeyCollection(fk) + require.NoError(t, err) + updated, err := root.PutForeignKeyCollection(ctx, fks) + require.NoError(t, err) + return updated +} + +// newEmptyRoot returns a context and the empty working root of a freshly initialized repo. +func newEmptyRoot(t *testing.T) (context.Context, doltdb.RootValue) { + t.Helper() + ctx := context.Background() + emptyRoot, err := dtestutils.CreateTestEnv().WorkingRoot(ctx) + require.NoError(t, err) + return ctx, emptyRoot +} + +// singleColPKSchema builds a one-column primary key schema using |colName| and |tag|. +func singleColPKSchema(t *testing.T, colName string, tag uint64) schema.Schema { + t.Helper() + col, err := schema.NewColumnWithTypeInfo(colName, tag, typeinfo.StringDefaultType, true, "", false, "", schema.NotNullConstraint{}) + require.NoError(t, err) + return dtestutils.CreateSchema(col) +} + +// TestCarryTablesAbsentFromBaseline verifies that CarryTablesAbsentFromBaseline carries untracked tables into the +// target root, resolves tag collisions, and propagates foreign keys. +func TestCarryTablesAbsentFromBaseline(t *testing.T) { + // See https://github.com/dolthub/dolt/issues/11007 + ctx, emptyRoot := newEmptyRoot(t) + + const collidingTag uint64 = 9815 + const postsTitleTag uint64 = 13593 + + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPKSchema(t, "code", collidingTag)) + require.NoError(t, err) + + // Source has bar (so the foreign key can reference it), plus untracked users and posts. + working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPKSchema(t, "code", collidingTag)) + require.NoError(t, err) + // bar.code and users.name share collidingTag so the carry must retag users.name. + usersSch := singleColPKSchema(t, "name", collidingTag) + _, err = usersSch.Indexes().AddIndexByColTags("idx_name", []uint64{collidingTag}, nil, schema.IndexProperties{IsUserDefined: true, IsUnique: true}) + require.NoError(t, err) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "users"}, usersSch) + require.NoError(t, err) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "posts"}, singleColPKSchema(t, "title", postsTitleTag)) + require.NoError(t, err) + + working = putFK(t, ctx, working, doltdb.ForeignKey{ + Name: "fk_bar", + TableName: doltdb.TableName{Name: "users"}, + TableColumns: []uint64{collidingTag}, + ReferencedTableName: doltdb.TableName{Name: "bar"}, + ReferencedTableColumns: []uint64{collidingTag}, + }) + + result, err := CarryTablesAbsentFromBaseline(ctx, working, target, target, nil) + require.NoError(t, err) + + t.Run("preserves tag on non-collision", func(t *testing.T) { + tbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "posts"}) + require.NoError(t, err) + require.True(t, ok, "posts table must be present in the result") + sch, err := tbl.GetSchema(ctx) + require.NoError(t, err) + col, ok := sch.GetAllCols().GetByName("title") + require.True(t, ok) + require.Equal(t, postsTitleTag, col.Tag, "posts.title tag must not change when there is no collision") + }) + + t.Run("retags colliding column", func(t *testing.T) { + tbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "users"}) + require.NoError(t, err) + require.True(t, ok, "users table must be present in the result") + sch, err := tbl.GetSchema(ctx) + require.NoError(t, err) + col, ok := sch.GetAllCols().GetByName("name") + require.True(t, ok) + require.NotEqual(t, collidingTag, col.Tag, "users.name must be retagged to avoid colliding with bar.code") + + idx, ok := sch.Indexes().GetByNameCaseInsensitive("idx_name") + require.True(t, ok, "idx_name must survive the retag") + require.Equal(t, []uint64{col.Tag}, idx.IndexedColumnTags(), "idx_name must reference the retagged column, not the old tag") + require.Equal(t, schema.IndexProperties{IsUserDefined: true, IsUnique: true}, idx.Properties(), + "idx_name properties must survive the retag unchanged") + }) + + t.Run("FK child column retagged, parent column unchanged", func(t *testing.T) { + usersTbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "users"}) + require.NoError(t, err) + require.True(t, ok) + usersSch, err := usersTbl.GetSchema(ctx) + require.NoError(t, err) + nameCol, ok := usersSch.GetAllCols().GetByName("name") + require.True(t, ok) + + resultFks, err := result.GetForeignKeyCollection(ctx) + require.NoError(t, err) + got, ok := resultFks.GetByNameCaseInsensitive("fk_bar", doltdb.TableName{Name: "users"}) + require.True(t, ok, "fk_bar must be present in result FK collection") + require.Equal(t, []uint64{nameCol.Tag}, got.TableColumns, + "child column must follow the retagged users.name tag") + require.Equal(t, []uint64{collidingTag}, got.ReferencedTableColumns, + "committed parent column tag must not change") + }) +} + +// TestCarryTablesAbsentFromBaselineMergedForeignKeyFollowsRetag verifies that when a carried child's column +// is retagged to resolve a collision, a foreign key already merged onto the target is rewritten to +// the new tag instead of being left pointing at the stale source tag. +func TestCarryTablesAbsentFromBaselineMergedForeignKeyFollowsRetag(t *testing.T) { + // See https://github.com/dolthub/dolt/issues/11007 + ctx, emptyRoot := newEmptyRoot(t) + + // Shared by target anchor.x and source child.cid, forcing a retag + const collide uint64 = 70001 + const parentTag uint64 = 70002 + + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "id", parentTag)) + require.NoError(t, err) + target, err = doltdb.CreateEmptyTable(ctx, target, doltdb.TableName{Name: "anchor"}, singleColPKSchema(t, "x", collide)) + require.NoError(t, err) + + working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "id", parentTag)) + require.NoError(t, err) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "child"}, singleColPKSchema(t, "cid", collide)) + require.NoError(t, err) + + fk := doltdb.ForeignKey{ + Name: "fk_child", + TableName: doltdb.TableName{Name: "child"}, + TableColumns: []uint64{collide}, + ReferencedTableName: doltdb.TableName{Name: "parent"}, + ReferencedTableColumns: []uint64{parentTag}, + } + working = putFK(t, ctx, working, fk) + target = putFK(t, ctx, target, fk) + + result, err := CarryTablesAbsentFromBaseline(ctx, working, target, target, nil) + require.NoError(t, err) + + childTbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "child"}) + require.NoError(t, err) + require.True(t, ok) + childSch, err := childTbl.GetSchema(ctx) + require.NoError(t, err) + cid, ok := childSch.GetAllCols().GetByName("cid") + require.True(t, ok) + require.NotEqual(t, collide, cid.Tag, "child.cid must be retagged off the colliding tag") + + resultFks, err := result.GetForeignKeyCollection(ctx) + require.NoError(t, err) + got, ok := resultFks.GetByNameCaseInsensitive("fk_child", doltdb.TableName{Name: "child"}) + require.True(t, ok, "fk_child must be present") + require.Equal(t, []uint64{cid.Tag}, got.TableColumns, + "carried foreign key must follow the child column retag, not keep the stale source tag") +} + +// TestCarryTablesAbsentFromBaselineParentTagMismatch verifies that a foreign key carried from source onto +// target gets its referenced column tags rewritten to match the target parent schema when the +// same parent column has a different tag on source and target. +func TestCarryTablesAbsentFromBaselineParentTagMismatch(t *testing.T) { + // See https://github.com/dolthub/dolt/issues/11007 + ctx, emptyRoot := newEmptyRoot(t) + + const parentTagOnSource uint64 = 60001 + const parentTagOnTarget uint64 = 60002 + const childTag uint64 = 60003 + + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "id", parentTagOnTarget)) + require.NoError(t, err) + + working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "id", parentTagOnSource)) + require.NoError(t, err) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "child"}, singleColPKSchema(t, "pid", childTag)) + require.NoError(t, err) + + working = putFK(t, ctx, working, doltdb.ForeignKey{ + Name: "fk_cross", + TableName: doltdb.TableName{Name: "child"}, + TableColumns: []uint64{childTag}, + ReferencedTableName: doltdb.TableName{Name: "parent"}, + ReferencedTableColumns: []uint64{parentTagOnSource}, + }) + + result, err := CarryTablesAbsentFromBaseline(ctx, working, target, target, nil) + require.NoError(t, err) + + resultFks, err := result.GetForeignKeyCollection(ctx) + require.NoError(t, err) + got, ok := resultFks.GetByNameCaseInsensitive("fk_cross", doltdb.TableName{Name: "child"}) + require.True(t, ok, "fk_cross must be present in result FK collection") + require.Equal(t, []uint64{parentTagOnTarget}, got.ReferencedTableColumns, + "referenced column tag must be rewritten to match target's parent schema") + require.Equal(t, []uint64{childTag}, got.TableColumns, + "child column tag must be unchanged because there was no collision") +} + +// TestCarryTablesAbsentFromBaselineParentColumnRenamedKeepsSourceTag documents the accepted divergence when +// the referenced parent column has a different name on source and target (renamed on the target +// branch). RemapTagsByColumnName cannot match the source column name against the target parent, so it +// leaves the carried foreign key on the source tag. +func TestCarryTablesAbsentFromBaselineParentColumnRenamedKeepsSourceTag(t *testing.T) { + // See https://github.com/dolthub/dolt/issues/11007 + ctx, emptyRoot := newEmptyRoot(t) + + const parentTagOnSource uint64 = 60101 + const parentTagOnTarget uint64 = 60102 + const childTag uint64 = 60103 + + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "ident", parentTagOnTarget)) + require.NoError(t, err) + + working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "id", parentTagOnSource)) + require.NoError(t, err) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "child"}, singleColPKSchema(t, "pid", childTag)) + require.NoError(t, err) + + working = putFK(t, ctx, working, doltdb.ForeignKey{ + Name: "fk_cross", + TableName: doltdb.TableName{Name: "child"}, + TableColumns: []uint64{childTag}, + ReferencedTableName: doltdb.TableName{Name: "parent"}, + ReferencedTableColumns: []uint64{parentTagOnSource}, + }) + + result, err := CarryTablesAbsentFromBaseline(ctx, working, target, target, nil) + require.NoError(t, err) + + resultFks, err := result.GetForeignKeyCollection(ctx) + require.NoError(t, err) + got, ok := resultFks.GetByNameCaseInsensitive("fk_cross", doltdb.TableName{Name: "child"}) + require.True(t, ok, "fk_cross must be present in result FK collection") + require.Equal(t, []uint64{parentTagOnSource}, got.ReferencedTableColumns, + "referenced tag stays on the source tag because the parent column name does not exist on target") + require.Equal(t, []uint64{childTag}, got.TableColumns, + "child column tag must be unchanged because there was no collision") +} diff --git a/go/libraries/doltcore/env/actions/checkout.go b/go/libraries/doltcore/env/actions/checkout.go index fa3992e7d60..b740a0be02e 100644 --- a/go/libraries/doltcore/env/actions/checkout.go +++ b/go/libraries/doltcore/env/actions/checkout.go @@ -16,6 +16,8 @@ package actions import ( "context" + "maps" + "slices" "strings" "time" @@ -119,10 +121,79 @@ func FindTableInRoots(ctx *sql.Context, roots doltdb.Roots, name string) (doltdb return doltdb.TableName{}, nil, false, nil } -// RootsForBranch returns the roots needed for a branch checkout. |roots.Head| should be the pre-checkout head. The -// returned roots struct has |Head| set to |branchRoot|. -func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.RootValue, force bool) (doltdb.Roots, error) { - conflicts := doltdb.NewTableNameSet(nil) +// CheckoutWouldOverwriteWorkingSets returns an error if checking out from a branch with +// uncommitted state |src| onto |destRoots| would silently lose work. This happens when an +// added source table collides with a committed table of the same name on destination, or +// when both branches carry differing uncommitted changes. Pass nil |src| if the source has +// none. Read-only system tables and tables in |ignored| are skipped. +func CheckoutWouldOverwriteWorkingSets(ctx context.Context, src *doltdb.RootsStatus, destRoots doltdb.Roots, ignored *doltdb.TableNameSet) error { + if src == nil { + return nil + } + localChange, err := findCollisions(ctx, src.Added(), destRoots.Head) + if err != nil { + return err + } + untracked, err := findCollisions(ctx, src.Untracked(), destRoots.Head) + if err != nil { + return err + } + // An unstaged working modification to a tracked table whose committed version on the + // target branch differs from source's head would be silently overwritten by the checkout. + for name := range src.Unstaged { + srcHeadHash, inHead := src.Head[name] + if !inHead { + continue + } + destHeadHash, _, err := destRoots.Head.GetTableHash(ctx, name) + if err != nil { + return err + } + if destHeadHash != srcHeadHash { + localChange = append(localChange, name.String()) + } + } + if len(localChange) > 0 || len(untracked) > 0 { + slices.Sort(localChange) + slices.Sort(untracked) + return ErrCheckoutWouldOverwrite{LocalChangeTables: localChange, UntrackedTables: untracked} + } + dest, err := doltdb.NewRootsStatus(ctx, destRoots, ignored) + if err != nil || dest == nil { + return err + } + if !maps.Equal(src.Staged, dest.Staged) || !maps.Equal(src.Unstaged, dest.Unstaged) { + return ErrWorkingSetsOnBothBranches + } + return nil +} + +// findCollisions returns the names in |candidates| that already exist as committed tables +// on |destHead|. Such names cannot be safely carried: the carry keeps the destination's +// version and the source-side cleanup then discards the source's copy. +func findCollisions(ctx context.Context, candidates []doltdb.TableName, destHead doltdb.RootValue) ([]string, error) { + var out []string + for _, name := range candidates { + targetHash, _, err := destHead.GetTableHash(ctx, name) + if err != nil { + return nil, err + } + if targetHash.IsEmpty() { + continue + } + out = append(out, name.String()) + } + return out, nil +} + +// RootsForBranch returns the roots for checking out a branch whose head is |branchRoot|. +// |roots.Head| must be the pre-checkout head. Uncommitted tables, those present in working +// or staged but absent from the old head, are moved into the new root via +// [CarryTablesAbsentFromBaseline], except the tables in |ignored|, which stay on the source branch. +// The caller is responsible for running [CheckoutWouldOverwriteWorkingSets] before reaching +// here. When |force| skipped that check, the carry below keeps the destination's version on +// any name collision. +func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.RootValue, force bool, ignored *doltdb.TableNameSet) (doltdb.Roots, error) { if roots.Head == nil { roots.Working = branchRoot roots.Staged = branchRoot @@ -130,26 +201,41 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return roots, nil } - wrkTblHashes, err := moveModifiedTables(ctx, roots.Head, branchRoot, roots.Working, conflicts, force) + // Force discards tracked local changes but preserves untracked tables. + if force { + working, err := CarryTablesAbsentFromBaseline(ctx, roots.Working, roots.Staged, branchRoot, ignored) + if err != nil { + return doltdb.Roots{}, err + } + return doltdb.Roots{Working: working, Staged: branchRoot, Head: branchRoot}, nil + } + + conflicts := doltdb.NewTableNameSet(nil) + + // Snapshot the pre-checkout roots before the three-way merge below reassigns roots.Working + // and roots.Staged so the carry step still sees the original values. + preCheckoutRoots := roots + + wrkTblHashes, err := threeWayMergeTableHashes(ctx, roots.Head, branchRoot, roots.Working, conflicts, force) if err != nil { return doltdb.Roots{}, err } - stgTblHashes, err := moveModifiedTables(ctx, roots.Head, branchRoot, roots.Staged, conflicts, force) + stgTblHashes, err := threeWayMergeTableHashes(ctx, roots.Head, branchRoot, roots.Staged, conflicts, force) if err != nil { return doltdb.Roots{}, err } if conflicts.Size() > 0 { - return doltdb.Roots{}, ErrCheckoutWouldOverwrite{conflicts.AsStringSlice()} + return doltdb.Roots{}, ErrCheckoutWouldOverwrite{LocalChangeTables: conflicts.AsStringSlice()} } - workingForeignKeys, err := moveForeignKeys(ctx, roots.Head, branchRoot, roots.Working, force) + workingForeignKeys, err := threeWayMergeForeignKeys(ctx, roots.Head, branchRoot, roots.Working, force) if err != nil { return doltdb.Roots{}, err } - stagedForeignKeys, err := moveForeignKeys(ctx, roots.Head, branchRoot, roots.Staged, force) + stagedForeignKeys, err := threeWayMergeForeignKeys(ctx, roots.Head, branchRoot, roots.Staged, force) if err != nil { return doltdb.Roots{}, err } @@ -164,6 +250,7 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return doltdb.Roots{}, err } + // Put the merged collections first so CarryTablesAbsentFromBaseline layers untracked keys on top. roots.Working, err = roots.Working.PutForeignKeyCollection(ctx, workingForeignKeys) if err != nil { return doltdb.Roots{}, err @@ -174,11 +261,23 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return doltdb.Roots{}, err } + // Ignored tables stay on the source branch because dolt_ignore is branch-scoped. + roots.Working, err = CarryTablesAbsentFromBaseline(ctx, preCheckoutRoots.Working, preCheckoutRoots.Head, roots.Working, ignored) + if err != nil { + return doltdb.Roots{}, err + } + + roots.Staged, err = CarryTablesAbsentFromBaseline(ctx, preCheckoutRoots.Staged, preCheckoutRoots.Head, roots.Staged, ignored) + if err != nil { + return doltdb.Roots{}, err + } + roots.Head = branchRoot return roots, nil } -// CleanOldWorkingSet resets the source branch's working set to the branch head, leaving the source branch unchanged +// CleanOldWorkingSet resets the source branch's working set to its head so uncommitted +// changes do not remain after a checkout has moved them onto the destination branch. func CleanOldWorkingSet( ctx *sql.Context, dbData env.DbData[*sql.Context], @@ -216,8 +315,9 @@ func CleanOldWorkingSet( Staged: workingSet.StagedRoot(), } - // we also have to do a clean, because we the ResetHard won't touch any new tables (tables only in the working set) - newRoots, err := CleanUntracked(ctx, resetRoots, []string{}, false, true, false) + // we also have to do a clean, because the ResetHard won't touch any new tables (tables only in the working set). + // Respect ignore rules so dolt_ignore-matched tables stay on the source branch. + newRoots, err := CleanUntracked(ctx, resetRoots, []string{}, false, true, true) if err != nil { return err } @@ -271,11 +371,11 @@ func BranchHeadRoot(ctx context.Context, db *doltdb.DoltDB, brName string) (dolt return branchRoot, nil } -// moveModifiedTables handles working set changes during a branch change. -// When moving between branches, changes in the working set should travel with you. -// Working set changes cannot be moved if the table differs between the old and new head, -// in this case, we throw a conflict and error (as per Git). -func moveModifiedTables(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.RootValue, conflicts *doltdb.TableNameSet, force bool) (map[doltdb.TableName]hash.Hash, error) { +// threeWayMergeTableHashes performs a 3-way merge of per-table hashes across |oldRoot|, +// |newRoot|, and |changedRoot|. Each table picks the side that changed against |oldRoot|, or +// |newRoot| when |force| is true. Tables changed on both sides go into |conflicts| so the +// caller can surface ErrCheckoutWouldOverwrite. Uncommitted tables are skipped here. +func threeWayMergeTableHashes(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.RootValue, conflicts *doltdb.TableNameSet, force bool) (map[doltdb.TableName]hash.Hash, error) { resultMap := make(map[doltdb.TableName]hash.Hash) tblNames, err := doltdb.UnionTableNames(ctx, newRoot) if err != nil { @@ -298,7 +398,10 @@ func moveModifiedTables(ctx context.Context, oldRoot, newRoot, changedRoot doltd return nil, err } - if oldHash == changedHash { + if oldHash == changedHash || changedHash == newHash { + // Either the source did not modify this table, or it modified the table to the + // content the target already has. Either way the target's version is the result + // and no work is lost. resultMap[tblName] = newHash } else if oldHash == newHash { resultMap[tblName] = changedHash @@ -326,8 +429,11 @@ func moveModifiedTables(ctx context.Context, oldRoot, newRoot, changedRoot doltd return nil, err } + // Skip uncommitted tables here so CarryTablesAbsentFromBaseline can pick them up after + // the merged tracked state lands on the destination. Carry needs that final state + // to detect column tag collisions and to rewrite foreign key references. if oldHash == emptyHash { - resultMap[tblName] = changedHash + continue } else if force { resultMap[tblName] = oldHash } else if oldHash != changedHash { @@ -388,8 +494,10 @@ func CheckOverwrittenIgnoredTables(ctx context.Context, roots doltdb.Roots, bran return nil } -// moveForeignKeys returns the foreign key collection that should be used for the new working set. -func moveForeignKeys(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.RootValue, force bool) (*doltdb.ForeignKeyCollection, error) { +// threeWayMergeForeignKeys performs a 3-way merge of the foreign key collections from |oldRoot|, +// |newRoot|, and |changedRoot|. If one side did not change the collection it returns the other, +// and otherwise delegates to mergeForeignKeyChanges to merge changes from both sides. +func threeWayMergeForeignKeys(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.RootValue, force bool) (*doltdb.ForeignKeyCollection, error) { oldFks, err := oldRoot.GetForeignKeyCollection(ctx) if err != nil { return nil, err @@ -511,7 +619,7 @@ func mergeForeignKeyChanges( } if conflicts.Size() > 0 { - return nil, ErrCheckoutWouldOverwrite{conflicts.AsStringSlice()} + return nil, ErrCheckoutWouldOverwrite{LocalChangeTables: conflicts.AsStringSlice()} } fks := make([]doltdb.ForeignKey, 0) @@ -556,49 +664,6 @@ func writeTableHashes(ctx context.Context, head doltdb.RootValue, tblHashes map[ return head, nil } -// CheckoutWouldStompWorkingSetChanges checks that the current working set is "compatible" with the dest working set. -// This means that if both working sets are present (ie there are changes on both source and dest branches), -// we check if the changes are identical before allowing a clobbering checkout. -// Working set errors are ignored by this function, because they are properly handled elsewhere. -func CheckoutWouldStompWorkingSetChanges(ctx context.Context, sourceRoots, destRoots doltdb.Roots) (bool, error) { - - wouldStomp := doRootsHaveIncompatibleChanges(sourceRoots, destRoots) - - if !wouldStomp { - return false, nil - } - - // In some cases, a working set differs from its head only by the feature version. - // If this is the case, moving the working set is safe. - modifiedSourceRoots, err := ClearFeatureVersion(ctx, sourceRoots) - if err != nil { - return true, err - } - - modifiedDestRoots, err := ClearFeatureVersion(ctx, destRoots) - if err != nil { - return true, err - } - - return doRootsHaveIncompatibleChanges(modifiedSourceRoots, modifiedDestRoots), nil -} - -func doRootsHaveIncompatibleChanges(sourceRoots, destRoots doltdb.Roots) bool { - sourceHasChanges, sourceWorkingHash, sourceStagedHash, err := RootHasUncommittedChanges(sourceRoots) - if err != nil { - return false - } - - destHasChanges, destWorkingHash, destStagedHash, err := RootHasUncommittedChanges(destRoots) - if err != nil { - return false - } - - // This is a stomping checkout operation if both the source and dest have uncommitted changes, and they're not the - // same uncommitted changes - return sourceHasChanges && destHasChanges && (sourceWorkingHash != destWorkingHash || sourceStagedHash != destStagedHash) -} - // ClearFeatureVersion creates a new version of the provided roots where all three roots have the same // feature version. By hashing these new roots, we can easily determine whether the roots differ only by // their feature version. @@ -624,31 +689,3 @@ func ClearFeatureVersion(ctx context.Context, roots doltdb.Roots) (doltdb.Roots, Staged: modifiedStaged, }, nil } - -// RootHasUncommittedChanges returns whether the roots given have uncommitted changes, and the hashes of -// the working and staged roots are identical. This function will ignore any difference in feature -// versions between the root values. -func RootHasUncommittedChanges(roots doltdb.Roots) (hasChanges bool, workingHash hash.Hash, stagedHash hash.Hash, err error) { - roots, err = ClearFeatureVersion(context.Background(), roots) - if err != nil { - return false, hash.Hash{}, hash.Hash{}, err - } - - headHash, err := roots.Head.HashOf() - if err != nil { - return false, hash.Hash{}, hash.Hash{}, err - } - - workingHash, err = roots.Working.HashOf() - if err != nil { - return false, hash.Hash{}, hash.Hash{}, err - } - - stagedHash, err = roots.Staged.HashOf() - if err != nil { - return false, hash.Hash{}, hash.Hash{}, err - } - - hasChanges = workingHash != stagedHash || stagedHash != headHash - return hasChanges, workingHash, stagedHash, nil -} diff --git a/go/libraries/doltcore/env/actions/errors.go b/go/libraries/doltcore/env/actions/errors.go index 2a5b1dd9643..f37c4e0a701 100644 --- a/go/libraries/doltcore/env/actions/errors.go +++ b/go/libraries/doltcore/env/actions/errors.go @@ -15,7 +15,7 @@ package actions import ( - "bytes" + "errors" "strings" goerrors "gopkg.in/src-d/go-errors.v1" @@ -115,35 +115,41 @@ func GetTablesForError(err error) []doltdb.TableName { return te.tables } +// ErrCheckoutWouldOverwrite reports the conflicting tables a checkout would overwrite. +// |LocalChangeTables| lists tables with tracked or staged changes the user can commit or stash. +// |UntrackedTables| lists tables present only in the working set the user must move or remove. type ErrCheckoutWouldOverwrite struct { - tables []string + LocalChangeTables []string + UntrackedTables []string } func (cwo ErrCheckoutWouldOverwrite) Error() string { - var buffer bytes.Buffer - buffer.WriteString("Your local changes to the following tables would be overwritten by checkout:\n") - for _, tbl := range cwo.tables { - buffer.WriteString("\t" + tbl + "\n") + var buffer strings.Builder + if len(cwo.LocalChangeTables) > 0 { + buffer.WriteString("Your local changes to the following tables would be overwritten by checkout:\n") + for _, tbl := range cwo.LocalChangeTables { + buffer.WriteString("\t") + buffer.WriteString(tbl) + buffer.WriteString("\n") + } + buffer.WriteString("Please commit your changes or stash them before you switch branches.\n") + } + if len(cwo.UntrackedTables) > 0 { + buffer.WriteString("The following untracked tables would be overwritten by checkout:\n") + for _, tbl := range cwo.UntrackedTables { + buffer.WriteString("\t") + buffer.WriteString(tbl) + buffer.WriteString("\n") + } + buffer.WriteString("Please move or remove them before you switch branches.\n") } - - buffer.WriteString("Please commit your changes or stash them before you switch branches.\n") buffer.WriteString("Aborting") return buffer.String() } func IsCheckoutWouldOverwrite(err error) bool { - _, ok := err.(ErrCheckoutWouldOverwrite) - return ok -} - -func CheckoutWouldOverwriteTables(err error) []string { - cwo, ok := err.(ErrCheckoutWouldOverwrite) - - if !ok { - panic("Must validate with IsCheckoutWouldOverwrite before calling CheckoutWouldOverwriteTables") - } - - return cwo.tables + var cwo ErrCheckoutWouldOverwrite + return errors.As(err, &cwo) } var ErrCheckoutWouldOverwriteIgnoredTables = goerrors.NewKind( @@ -168,7 +174,7 @@ func NothingStagedTblDiffs(err error) []diff.TableDelta { ns, ok := err.(NothingStaged) if !ok { - panic("Must validate with IsCheckoutWouldOverwrite before calling CheckoutWouldOverwriteTables") + panic("Must validate with IsNothingStaged before calling NothingStagedTblDiffs") } return ns.NotStagedTbls diff --git a/go/libraries/doltcore/env/actions/reset.go b/go/libraries/doltcore/env/actions/reset.go index d3b664ea390..92ac546d4dc 100644 --- a/go/libraries/doltcore/env/actions/reset.go +++ b/go/libraries/doltcore/env/actions/reset.go @@ -30,8 +30,9 @@ import ( ) // resetHardTables resolves a new HEAD commit from a refSpec and updates working set roots by -// resetting the table contexts for tracked tables. New tables are ignored. Returns new HEAD -// Commit and Roots. +// resetting tracked tables to that HEAD. Uncommitted tables are carried forward onto the new HEAD +// via CarryTablesAbsentFromBaseline so a hard reset does not destroy tables that were never committed. +// Returns the new HEAD Commit and Roots. func resetHardTables[C doltdb.Context](ctx C, dbData env.DbData[C], cSpecStr string, roots doltdb.Roots) (*doltdb.Commit, doltdb.Roots, error) { ddb := dbData.Ddb rsr := dbData.Rsr @@ -63,73 +64,13 @@ func resetHardTables[C doltdb.Context](ctx C, dbData env.DbData[C], cSpecStr str } } - newWorking, err := MoveUntrackedTables(ctx, roots.Working, roots.Staged, roots.Head) + newWorking, err := CarryTablesAbsentFromBaseline(ctx, roots.Working, roots.Staged, roots.Head, nil) if err != nil { return nil, doltdb.Roots{}, err } return newHead, doltdb.Roots{Head: roots.Head, Working: newWorking, Staged: roots.Head}, nil } -// MoveUntrackedTables copies tables present in |sourceWorking| but absent from |sourceStaged| -// onto |target|, returning the resulting root. Tables that name-collide with one already in -// |target| are skipped so the target version wins. Tables whose column tags collide with one -// in |target| are dropped silently. -// TODO(elianddb): retag colliding columns instead of dropping the table. -func MoveUntrackedTables(ctx context.Context, sourceWorking, sourceStaged, target doltdb.RootValue) (doltdb.RootValue, error) { - untracked, err := doltdb.GetAllSchemas(ctx, sourceWorking) - if err != nil { - return nil, err - } - - staged, err := doltdb.GetAllSchemas(ctx, sourceStaged) - if err != nil { - return nil, err - } - for name := range staged { - delete(untracked, name) - } - - targetSchemas, err := doltdb.GetAllSchemas(ctx, target) - if err != nil { - return nil, err - } - targetTags := make(map[uint64]struct{}) - for _, sch := range targetSchemas { - for _, tag := range sch.GetAllCols().Tags { - targetTags[tag] = struct{}{} - } - } - - for name, sch := range untracked { - for _, col := range sch.GetAllCols().GetColumns() { - if _, ok := targetTags[col.Tag]; ok { - delete(untracked, name) - break - } - } - } - - for name := range untracked { - // Skip when target has a table of the same name so the target version wins. - if _, exists := targetSchemas[name]; exists { - continue - } - tbl, exists, err := sourceWorking.GetTable(ctx, name) - if err != nil { - return nil, err - } - if !exists { - return nil, fmt.Errorf("untracked table %s does not exist in working set", name) - } - target, err = target.PutTable(ctx, name, tbl) - if err != nil { - return nil, fmt.Errorf("failed to write table back to database: %s", err) - } - } - - return target, nil -} - func GetAllTableNames(ctx context.Context, root doltdb.RootValue) []doltdb.TableName { tableNames := make([]doltdb.TableName, 0) _ = root.IterTables(ctx, func(name doltdb.TableName, table *doltdb.Table, sch schema.Schema) (stop bool, err error) { diff --git a/go/libraries/doltcore/merge/merge.go b/go/libraries/doltcore/merge/merge.go index c8f066f47b2..90b3f7c9b14 100644 --- a/go/libraries/doltcore/merge/merge.go +++ b/go/libraries/doltcore/merge/merge.go @@ -429,8 +429,8 @@ func MergeWouldStompChanges(ctx context.Context, roots doltdb.Roots, mergeCommit return nil, nil, err } - headWorkingDiffs := diffTableHashes(headTableHashes, workingTableHashes) - mergedHeadDiffs := diffTableHashes(headTableHashes, mergeTableHashes) + headWorkingDiffs := doltdb.DiffTableHashes(headTableHashes, workingTableHashes) + mergedHeadDiffs := doltdb.DiffTableHashes(headTableHashes, mergeTableHashes) stompedTables := make([]doltdb.TableName, 0, len(headWorkingDiffs)) for tName := range headWorkingDiffs { @@ -442,27 +442,3 @@ func MergeWouldStompChanges(ctx context.Context, roots doltdb.Roots, mergeCommit return stompedTables, headWorkingDiffs, nil } - -func diffTableHashes(headTableHashes, otherTableHashes map[doltdb.TableName]hash.Hash) map[doltdb.TableName]hash.Hash { - diffs := make(map[doltdb.TableName]hash.Hash) - for tName, hh := range headTableHashes { - if h, ok := otherTableHashes[tName]; ok { - if h != hh { - // modification - diffs[tName] = h - } - } else { - // deletion - diffs[tName] = hash.Hash{} - } - } - - for tName, h := range otherTableHashes { - if _, ok := headTableHashes[tName]; !ok { - // addition - diffs[tName] = h - } - } - - return diffs -} diff --git a/go/libraries/doltcore/schema/index.go b/go/libraries/doltcore/schema/index.go index 64e4d4bb4cf..8b442d6a3be 100644 --- a/go/libraries/doltcore/schema/index.go +++ b/go/libraries/doltcore/schema/index.go @@ -56,6 +56,8 @@ type Index interface { FullTextProperties() FullTextProperties // VectorProperties returns all properties belonging to a vector index. VectorProperties() VectorProperties + // Properties returns the IndexProperties describing this index. + Properties() IndexProperties } var _ Index = (*indexImpl)(nil) @@ -280,6 +282,20 @@ func (ix *indexImpl) VectorProperties() VectorProperties { return ix.vectorProperties } +// Properties implements Index. +func (ix *indexImpl) Properties() IndexProperties { + return IndexProperties{ + IsUnique: ix.isUnique, + IsSpatial: ix.isSpatial, + IsFullText: ix.isFullText, + IsUserDefined: ix.isUserDefined, + Comment: ix.comment, + FullTextProperties: ix.fullTextProps, + IsVector: ix.isVector, + VectorProperties: ix.vectorProperties, + } +} + // copy returns an exact copy of the calling index. func (ix *indexImpl) copy() *indexImpl { newIx := *ix diff --git a/go/libraries/doltcore/schema/schema_impl.go b/go/libraries/doltcore/schema/schema_impl.go index ba045714164..6bf8f44fcf2 100644 --- a/go/libraries/doltcore/schema/schema_impl.go +++ b/go/libraries/doltcore/schema/schema_impl.go @@ -17,6 +17,7 @@ package schema import ( "errors" "fmt" + "slices" "strconv" "strings" @@ -131,6 +132,52 @@ func SchemaFromCols(allCols *ColCollection) (Schema, error) { return sch, nil } +// WithRemappedColumnTags returns a copy of |sch| with column and index tags rewritten via +// |tagRemap|. Schema metadata and check constraints are preserved. Returns |sch| unchanged +// when |tagRemap| is empty. +func WithRemappedColumnTags(sch Schema, tagRemap map[uint64]uint64) (Schema, error) { + if len(tagRemap) == 0 { + return sch, nil + } + + allCols := sch.GetAllCols() + cols := allCols.GetColumns() + for oldTag, newTag := range tagRemap { + if idx, ok := allCols.TagToIdx[oldTag]; ok { + cols[idx].Tag = newTag + } + } + + pkOrdinals := slices.Clone(sch.GetPkOrdinals()) + newSch, err := NewSchema(NewColCollection(cols...), pkOrdinals, sch.GetCollation(), nil, sch.Checks().Copy()) + if err != nil { + return nil, err + } + newSch.SetTargetRowSize(sch.GetTargetRowSize()) + newSch.SetComment(sch.GetComment()) + + err = sch.Indexes().Iter(func(idx Index) (stop bool, err error) { + tags := RemapTags(idx.IndexedColumnTags(), tagRemap) + _, err = newSch.Indexes().AddIndexByColTags(idx.Name(), tags, idx.PrefixLengths(), idx.Properties()) + return false, err + }) + if err != nil { + return nil, err + } + + return newSch, nil +} + +// WithUpdatedColumnTag returns a copy of |sch| with the tag of the column named |name| set to |tag|. +// It returns an error if no column named |name| exists. +func WithUpdatedColumnTag(sch Schema, name string, tag uint64) (Schema, error) { + col, ok := sch.GetAllCols().GetByName(name) + if !ok { + return nil, fmt.Errorf("column %q does not exist", name) + } + return WithRemappedColumnTags(sch, map[uint64]uint64{col.Tag: tag}) +} + // SchemaFromColCollections creates a schema from the three collections. // // Deprecated: Use NewSchema instead. diff --git a/go/libraries/doltcore/schema/schema_test.go b/go/libraries/doltcore/schema/schema_test.go index 75b12774717..85cb418def8 100644 --- a/go/libraries/doltcore/schema/schema_test.go +++ b/go/libraries/doltcore/schema/schema_test.go @@ -417,3 +417,40 @@ func validateCols(t *testing.T, cols []Column, colColl *ColCollection, msg strin t.Error() } } + +// TestWithRemappedColumnTagsPreservesMetadata verifies that WithRemappedColumnTags rewrites the +// column and index tags and carries collation, comment, target row size, and index properties onto +// the returned schema. +func TestWithRemappedColumnTagsPreservesMetadata(t *testing.T) { + pk, err := NewColumnWithTypeInfo("id", 100, typeinfo.Int64Type, true, "", false, "", NotNullConstraint{}) + require.NoError(t, err) + val, err := NewColumnWithTypeInfo("val", 101, typeinfo.StringDefaultType, false, "", false, "") + require.NoError(t, err) + sch, err := NewSchema(NewColCollection(pk, val), []int{0}, Collation_utf8mb4_general_ci, nil, nil) + require.NoError(t, err) + sch.SetComment("test comment") + sch.SetTargetRowSize(4096) + _, err = sch.Indexes().AddIndexByColTags("val_idx", []uint64{101}, nil, IndexProperties{IsUnique: true, IsUserDefined: true, Comment: "idx comment"}) + require.NoError(t, err) + wantProps := sch.Indexes().GetByName("val_idx").Properties() + + remapped, err := WithRemappedColumnTags(sch, map[uint64]uint64{100: 200, 101: 201}) + require.NoError(t, err) + + // New tags resolve and the old ones are gone. + got, ok := remapped.GetAllCols().GetByName("val") + require.True(t, ok) + require.Equal(t, uint64(201), got.Tag, "column tag must be remapped") + _, ok = remapped.GetAllCols().GetByTag(101) + require.False(t, ok, "old column tag must be gone") + + // The index tags are remapped, and every other index property survives the retag. + idx := remapped.Indexes().GetByName("val_idx") + require.NotNil(t, idx) + require.Equal(t, []uint64{201}, idx.IndexedColumnTags(), "index tags must be remapped") + require.Equal(t, wantProps, idx.Properties(), "all index properties must survive the retag") + + require.Equal(t, Collation_utf8mb4_general_ci, remapped.GetCollation(), "collation must survive WithRemappedColumnTags") + require.Equal(t, "test comment", remapped.GetComment(), "comment must survive WithRemappedColumnTags") + require.Equal(t, uint16(4096), remapped.GetTargetRowSize(), "target row size must survive WithRemappedColumnTags") +} diff --git a/go/libraries/doltcore/schema/tag.go b/go/libraries/doltcore/schema/tag.go index 4f6d1d7e0a8..da29cf2673a 100644 --- a/go/libraries/doltcore/schema/tag.go +++ b/go/libraries/doltcore/schema/tag.go @@ -76,6 +76,46 @@ func (tm TagMapping) Size() int { return len(tm) } +// RemapTags returns a new slice with each entry of |tags| replaced by its value in |remap|, +// or kept unchanged when absent. When |remap| is empty the input |tags| slice is returned as is +// rather than a fresh allocation, so the result must be treated as read-only. +func RemapTags(tags []uint64, remap map[uint64]uint64) []uint64 { + if len(remap) == 0 { + return tags + } + out := make([]uint64, len(tags)) + for i, t := range tags { + if newTag, ok := remap[t]; ok { + out[i] = newTag + } else { + out[i] = t + } + } + return out +} + +// RemapTagsByColumnName returns |tags| with each tag pointing at the same-named column on +// |destSch| instead of |srcSch|. Returns |tags| unchanged (treat as read-only) when either +// schema is nil or any tag cannot be resolved on both sides, avoiding a partial remap. +func RemapTagsByColumnName(tags []uint64, srcSch, destSch Schema) []uint64 { + if srcSch == nil || destSch == nil { + return tags + } + out := make([]uint64, len(tags)) + for i, srcTag := range tags { + srcCol, ok := srcSch.GetAllCols().GetByTag(srcTag) + if !ok { + return tags + } + destCol, ok := destSch.GetAllCols().GetByName(srcCol.Name) + if !ok { + return tags + } + out[i] = destCol.Tag + } + return out +} + // AutoGenerateTag generates a random tag that doesn't exist in the provided SuperSchema. // It uses a deterministic random number generator that is seeded with the NomsKinds of any existing columns in the // schema and the NomsKind of the column being added to the schema. Deterministic tag generation means that branches diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go index c1259431782..a2cf9538418 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go @@ -68,37 +68,38 @@ func MoveWorkingSetToBranch(ctx *sql.Context, brName string, force bool, isNewBr return err } - if !force { - currentRoots, hasRoots := dSess.GetRoots(ctx, dbName) - if !hasRoots { - return fmt.Errorf("unable to resolve roots for %s", dbName) - } - newBranchRoots, err := db.ResolveBranchRoots(ctx, branchRef) - if err != nil { - return err - } - if !isNewBranch { - wouldStomp, err := actions.CheckoutWouldStompWorkingSetChanges(ctx, currentRoots, newBranchRoots) - if err != nil { - return err - } - if wouldStomp { - return actions.ErrWorkingSetsOnBothBranches - } - } - } - initialRoots, hasRoots := dSess.GetRoots(ctx, dbName) if !hasRoots { - return fmt.Errorf("unable to get roots") + return fmt.Errorf("unable to resolve roots for %s", dbName) + } + + ignoredNames, err := doltdb.UnionTableNames(ctx, initialRoots.Working, initialRoots.Staged, branchHead) + if err != nil { + return err } + ignoredList, err := doltdb.IdentifyIgnoredTables(ctx, initialRoots, ignoredNames) + if err != nil { + return err + } + ignored := doltdb.NewTableNameSet(ignoredList) - hasChanges := false + var srcChanges *doltdb.RootsStatus if workingSetExists { - hasChanges, _, _, err = actions.RootHasUncommittedChanges(initialRoots) + srcChanges, err = doltdb.NewRootsStatus(ctx, initialRoots, ignored) + if err != nil { + return err + } + } + + if !force && !isNewBranch { + newBranchRoots, err := db.ResolveBranchRoots(ctx, branchRef) if err != nil { return err } + // The destination branch must not have its own uncommitted changes. + if err := actions.CheckoutWouldOverwriteWorkingSets(ctx, srcChanges, newBranchRoots, ignored); err != nil { + return err + } } dbData, ok := dSess.GetDbData(ctx, dbName) @@ -110,11 +111,8 @@ func MoveWorkingSetToBranch(ctx *sql.Context, brName string, force bool, isNewBr return err } - // Only if the current working set has uncommitted changes do we carry them forward to the branch being checked out. - // If this is the case, then the destination branch must *not* have any uncommitted changes, as checked by - // checkoutWouldStompWorkingSetChanges - if hasChanges { - err = transferWorkingChanges(ctx, dbName, initialRoots, branchHead, branchRef, force) + if srcChanges != nil { + err = transferWorkingChanges(ctx, dbName, initialRoots, branchHead, branchRef, force, ignored) if err != nil { return err } @@ -128,10 +126,9 @@ func MoveWorkingSetToBranch(ctx *sql.Context, brName string, force bool, isNewBr if err != nil { return err } - } - if workingSetExists && hasChanges { + if workingSetExists && srcChanges != nil { name, email, _, _, err := dsess.ResolveNameEmail(ctx, dsess.DoltCommitterName, dsess.DoltCommitterEmail) if err != nil { return err @@ -155,12 +152,13 @@ func transferWorkingChanges( branchHead doltdb.RootValue, branchRef ref.BranchRef, force bool, + ignored *doltdb.TableNameSet, ) error { dSess := dsess.DSessFromSess(ctx.Session) // Compute the new roots before switching the working set. // This way, we don't leave the branch in a bad state in the event of an error. - newRoots, err := actions.RootsForBranch(ctx, initialRoots, branchHead, force) + newRoots, err := actions.RootsForBranch(ctx, initialRoots, branchHead, force, ignored) if err != nil { return err } diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go b/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go index c42eb4eb9c8..cee9506db54 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go @@ -809,7 +809,7 @@ func continueRebase(ctx *sql.Context) rebaseResult { if err != nil { return newRebaseError(err) } - restoredWorking, err := actions.MoveUntrackedTables(ctx, preRebaseWorkingRoot, preRebaseStagedRoot, postCopyWS.WorkingRoot()) + restoredWorking, err := actions.CarryTablesAbsentFromBaseline(ctx, preRebaseWorkingRoot, preRebaseStagedRoot, postCopyWS.WorkingRoot(), nil) if err != nil { return newRebaseError(err) } diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go b/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go index 299107fead3..af3cb82488e 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go @@ -57,7 +57,7 @@ func doltUpdateColumnTag(ctx *sql.Context, args ...string) (sql.RowIter, error) return nil, err } - newSch, err := updateColumnTag(sch, columnName, tag) + newSch, err := schema.WithUpdatedColumnTag(sch, columnName, tag) if err != nil { return nil, fmt.Errorf("failed to update column tag: %w", err) } @@ -100,35 +100,3 @@ func parseUpdateColumnTagArgs(args ...string) (tableName, columnName string, tag return tableName, columnName, tag, nil } - -// updateColumnTag updates |sch| by setting the tag for the column named |name| to |tag|. -func updateColumnTag(sch schema.Schema, name string, tag uint64) (schema.Schema, error) { - var found bool - columns := sch.GetAllCols().GetColumns() - // Find column and update its tag - for i, col := range columns { - if col.Name == name { - col.Tag = tag - columns[i] = col - found = true - break - } - } - - if !found { - return nil, fmt.Errorf("column %s does not exist", name) - } - - newSch, err := schema.SchemaFromCols(schema.NewColCollection(columns...)) - if err != nil { - return nil, err - } - - if err = newSch.SetPkOrdinals(sch.GetPkOrdinals()); err != nil { - return nil, err - } - newSch.SetCollation(sch.GetCollation()) - newSch.SetTargetRowSize(sch.GetTargetRowSize()) - - return newSch, nil -} diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go index 3d1ccbfc9fd..efd629ef4bf 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go @@ -3992,6 +3992,98 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, }, }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_checkout leaves untracked tables with secondary indexes and foreign keys on the source branch", + SetUpScript: []string{ + "call dolt_branch('empty');", + "create table parent (id int primary key, name varchar(64));", + "create table child (id int primary key, parent_id int, val varchar(64));", + "create unique index idx_val on child (val);", + "alter table child add constraint fk_parent foreign key (parent_id) references parent(id);", + "insert into parent values (1, 'alice');", + "insert into child values (10, 1, 'c_val');", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_checkout('empty');", + Expected: []sql.Row{{0, "Switched to branch 'empty'"}}, + }, + { + Query: "select count(*) from information_schema.tables where table_schema = database() and table_name in ('parent', 'child');", + // TODO(elianddb): the information_schema assertions in these carry tests are gated + // to mysql because Doltgres does not populate information_schema.statistics yet. + Dialect: "mysql", + Expected: []sql.Row{{0}}, + }, + { + Query: "call dolt_checkout('main');", + Expected: []sql.Row{{0, "Switched to branch 'main'"}}, + }, + { + Query: "select id, name from parent;", + Expected: []sql.Row{{1, "alice"}}, + }, + { + Query: "select id, parent_id, val from child;", + Expected: []sql.Row{{10, 1, "c_val"}}, + }, + { + Query: "select index_name, non_unique, seq_in_index, column_name from information_schema.statistics where table_schema = database() and table_name = 'child' and index_name = 'idx_val';", + Dialect: "mysql", + Expected: []sql.Row{{"idx_val", 0, 1, "val"}}, + }, + { + Query: "select id from child where val = 'c_val';", + Expected: []sql.Row{{10}}, + }, + { + Query: "select constraint_name, table_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", + Dialect: "mysql", + Expected: []sql.Row{{"fk_parent", "child"}}, + }, + { + Query: "insert into child values (99, 999, 'orphan');", + Dialect: "mysql", + ExpectedErr: sql.ErrForeignKeyChildViolation, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_checkout keeps an untracked foreign key on the source even when the parent is missing on the target", + Dialect: "mysql", + SetUpScript: []string{ + "create table parent (id int primary key);", + "call dolt_commit('-Am', 'add parent');", + "call dolt_branch('no_parent');", + "call dolt_checkout('no_parent');", + "drop table parent;", + "call dolt_commit('-Am', 'drop parent on no_parent');", + "call dolt_checkout('main');", + "create table child (id int primary key, pid int, constraint fk_orphan foreign key (pid) references parent(id));", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_checkout('no_parent');", + Expected: []sql.Row{{0, "Switched to branch 'no_parent'"}}, + }, + { + Query: "select count(*) from information_schema.tables where table_schema = database() and table_name = 'child';", + // child stayed on main, so no foreign key exists on no_parent. + Expected: []sql.Row{{0}}, + }, + { + Query: "call dolt_checkout('main');", + Expected: []sql.Row{{0, "Switched to branch 'main'"}}, + }, + { + Query: "select constraint_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", + // child still references parent on main, where parent exists. + Expected: []sql.Row{{"fk_orphan"}}, + }, + }, + }, } var DoltCheckoutReadOnlyScripts = []queries.ScriptTest{ @@ -4674,6 +4766,202 @@ var DoltResetTestScripts = []queries.ScriptTest{ }, }, }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_reset('--hard') preserves untracked tables after resetting to a divergent branch", + SetUpScript: []string{ + "call dolt_checkout('-b', 'feat');", + "create table bar (code varchar(64) primary key);", + "call dolt_commit('-Am', 'add bar');", + "call dolt_checkout('main');", + "create table users (name varchar(64) primary key, email varchar(64));", + "create index idx_email on users (email);", + "insert into users values ('alice', 'alice@example.com');", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_reset('--hard', 'feat');", + Expected: []sql.Row{{0}}, + }, + { + Query: "select name from users;", + Expected: []sql.Row{{"alice"}}, + }, + { + Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'users' and index_name = 'idx_email';", + Dialect: "mysql", + Expected: []sql.Row{{"idx_email", 1, 1, "email", "YES", "BTREE", ""}}, + }, + { + Query: "select name from users where email = 'alice@example.com';", + Expected: []sql.Row{{"alice"}}, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + // Collision verified in bats-mirror test in reset.bats as tag 9815. + Name: "dolt_reset('--hard') retags an untracked table whose primary key column collides with the target and preserves its secondary indexes", + SetUpScript: []string{ + "call dolt_checkout('-b', 'feat');", + "create table bar (code varchar(64) primary key);", + "call dolt_commit('-Am', 'add bar on feat');", + "call dolt_checkout('main');", + "create table users (name varchar(64) primary key, email varchar(64), label varchar(64));", + "create index idx_email on users (email);", + "create index idx_composite on users (email, label);", + "insert into users values ('alice', 'alice@example.com', 'admin');", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_reset('--hard', 'feat');", + Expected: []sql.Row{{0}}, + }, + { + Query: "select name from users;", + Expected: []sql.Row{{"alice"}}, + }, + { + Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'users' and index_name = 'idx_email';", + Dialect: "mysql", + Expected: []sql.Row{{"idx_email", 1, 1, "email", "YES", "BTREE", ""}}, + }, + { + Query: "select name from users where email = 'alice@example.com';", + Expected: []sql.Row{{"alice"}}, + }, + { + Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'users' and index_name = 'idx_composite' order by seq_in_index;", + Dialect: "mysql", + // Both column positions of the composite index follow the carried columns after the retag. + Expected: []sql.Row{{"idx_composite", 1, 1, "email", "YES", "BTREE", ""}, {"idx_composite", 1, 2, "label", "YES", "BTREE", ""}}, + }, + { + Query: "select name from users where email = 'alice@example.com' and label = 'admin';", + Expected: []sql.Row{{"alice"}}, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_reset('--hard') preserves FK on untracked child when parent is committed on target", + SetUpScript: []string{ + "call dolt_checkout('-b', 'feat');", + "create table parent (id int primary key, name varchar(64));", + "insert into parent values (1, 'alice'), (2, 'bob');", + "call dolt_commit('-Am', 'add parent on feat');", + "call dolt_checkout('main');", + // A local copy of parent is needed for the FK definition. The committed version wins after reset. + "create table parent (id int primary key, name varchar(64));", + "create table child (id int primary key, parent_id int, constraint fk_parent foreign key (parent_id) references parent(id));", + "insert into parent values (1, 'alice'), (2, 'bob');", + "insert into child values (10, 1), (20, 2);", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_reset('--hard', 'feat');", + Expected: []sql.Row{{0}}, + }, + { + Query: "select id, name from parent order by id;", + // The committed parent from feat replaces the untracked local copy. + Expected: []sql.Row{{1, "alice"}, {2, "bob"}}, + }, + { + Query: "select id, parent_id from child order by id;", + Expected: []sql.Row{{10, 1}, {20, 2}}, + }, + { + Query: "select constraint_name, table_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", + Dialect: "mysql", + Expected: []sql.Row{{"fk_parent", "child"}}, + }, + { + Query: "insert into child values (99, 999);", + Dialect: "mysql", + ExpectedErr: sql.ErrForeignKeyChildViolation, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_reset('--hard') preserves untracked table schema properties", + // Auto-increment, table comment/collation, and generated columns are MySQL-dialect, which + // Doltgres handles differently, so this whole script is gated to the mysql harness. + Dialect: "mysql", + SetUpScript: []string{ + "call dolt_branch('empty');", + "create table ai (id int auto_increment primary key, val varchar(64));", + "insert into ai (val) values ('row1'), ('row2');", + "create table meta (id int primary key) comment 'my comment' collate utf8mb4_general_ci;", + "create table defaults_tbl (id int primary key, score int default 99, label varchar(64) default 'n/a');", + "insert into defaults_tbl (id) values (1);", + "create table gen (id int primary key, base int, doubled int generated always as (base * 2));", + "insert into gen (id, base) values (1, 7);", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_reset('--hard', 'empty');", + Expected: []sql.Row{{0}}, + }, + { + Query: "insert into ai (val) values ('row3');", + // The auto-increment counter is not reset by the hard reset. + Expected: []sql.Row{{types.OkResult{RowsAffected: 1, InsertID: 3}}}, + }, + { + Query: "select id from ai where val = 'row3';", + Expected: []sql.Row{{3}}, + }, + { + Query: "select table_comment from information_schema.tables where table_schema = database() and table_name = 'meta';", + Expected: []sql.Row{{"my comment"}}, + }, + { + Query: "select table_collation from information_schema.tables where table_schema = database() and table_name = 'meta';", + Expected: []sql.Row{{"utf8mb4_general_ci"}}, + }, + { + Query: "select score, label from defaults_tbl where id = 1;", + Expected: []sql.Row{{99, "n/a"}}, + }, + { + Query: "insert into defaults_tbl (id) values (2);", + Expected: []sql.Row{{types.OkResult{RowsAffected: 1}}}, + }, + { + Query: "select score, label from defaults_tbl where id = 2;", + Expected: []sql.Row{{99, "n/a"}}, + }, + { + Query: "select doubled from gen where id = 1;", + Expected: []sql.Row{{14}}, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_reset('--hard') replaces an untracked table when the target has the same name", + SetUpScript: []string{ + "call dolt_checkout('-b', 'feat');", + "create table overlap (code varchar(64) primary key);", + "insert into overlap values ('feat_data');", + "call dolt_commit('-Am', 'add overlap');", + "call dolt_checkout('main');", + "create table overlap (code varchar(64) primary key);", + "insert into overlap values ('main_data');", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_reset('--hard', 'feat');", + Expected: []sql.Row{{0}}, + }, + { + Query: "select code from overlap;", + Expected: []sql.Row{{"feat_data"}}, + }, + }, + }, } func gcSetup() []string { diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go index 6456861340c..2b4ef4044de 100755 --- a/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go @@ -2445,6 +2445,249 @@ var BranchIsolationTests = []queries.TransactionTest{ }, }, }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_checkout without --move is session-local across every uncommitted-state variant", + SetUpScript: []string{ + "create table tracked_tbl (x int primary key)", + "insert into tracked_tbl values (0)", + "call dolt_commit('-Am', 'add tracked_tbl with row 0')", + "call dolt_branch('feat')", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "/* client a */ select active_branch()", + Expected: []sql.Row{{"main"}}, + }, + { + Query: "/* client b */ select active_branch()", + Expected: []sql.Row{{"main"}}, + }, + { + Query: "/* client a */ create table untracked_tbl (x int primary key)", + Expected: []sql.Row{{types.NewOkResult(0)}}, + }, + { + Query: "/* client a */ insert into untracked_tbl values (1)", + Expected: []sql.Row{{types.NewOkResult(1)}}, + }, + { + Query: "/* client a */ create table staged_tbl (x int primary key)", + Expected: []sql.Row{{types.NewOkResult(0)}}, + }, + { + Query: "/* client a */ insert into staged_tbl values (2)", + Expected: []sql.Row{{types.NewOkResult(1)}}, + }, + { + Query: "/* client a */ call dolt_add('staged_tbl')", + Expected: []sql.Row{{0}}, + }, + { + Query: "/* client a */ insert into tracked_tbl values (3)", + Expected: []sql.Row{{types.NewOkResult(1)}}, + }, + { + Query: "/* client b */ call dolt_checkout('feat')", + Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, + }, + { + Query: "/* client b */ select active_branch()", + Expected: []sql.Row{{"feat"}}, + }, + { + Query: "/* client b */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'untracked_tbl'", + // The untracked variant stays on main because bare dolt_checkout without + // --move does not carry uncommitted tables. + Expected: []sql.Row{{0}}, + }, + { + Query: "/* client b */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'staged_tbl'", + Expected: []sql.Row{{0}}, + }, + { + Query: "/* client b */ select x from tracked_tbl order by x", + // tracked_tbl exists on both branches and feat sees only its own HEAD row, + // so the pending row A inserted on main does not appear. + Expected: []sql.Row{{0}}, + }, + { + Query: "/* client a */ select x from untracked_tbl", + // Client A is still on main and keeps full visibility into all three pending + // variants because B's checkout never touched main's working set. + Expected: []sql.Row{{1}}, + }, + { + Query: "/* client a */ select x from staged_tbl", + Expected: []sql.Row{{2}}, + }, + { + Query: "/* client a */ select x from tracked_tbl order by x", + Expected: []sql.Row{{0}, {3}}, + }, + { + Query: "/* client b */ select x from `mydb/main`.untracked_tbl", + // The pending state was left behind on main rather than destroyed, so + // revision-qualified access from feat still reaches it. + Expected: []sql.Row{{1}}, + }, + { + Query: "/* client b */ select x from `mydb/main`.staged_tbl", + Expected: []sql.Row{{2}}, + }, + { + Query: "/* client b */ select x from `mydb/main`.tracked_tbl order by x", + Expected: []sql.Row{{0}, {3}}, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + // See https://docs.dolthub.com/sql-reference/version-control/branches for transaction-start snapshot semantics. + Name: "dolt_checkout without --move respects transaction-start snapshot semantics", + SetUpScript: []string{ + "create table tracked_tbl (x int primary key)", + "call dolt_commit('-Am', 'add tracked_tbl')", + "call dolt_branch('feat')", + "set autocommit = 0", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "/* client b */ start transaction", + Expected: []sql.Row{}, + }, + { + Query: "/* client a */ start transaction", + Expected: []sql.Row{}, + }, + { + Query: "/* client a */ create table untracked_tbl (x int primary key)", + Expected: []sql.Row{{types.NewOkResult(0)}}, + }, + { + Query: "/* client a */ insert into untracked_tbl values (42)", + Expected: []sql.Row{{types.NewOkResult(1)}}, + }, + { + Query: "/* client a */ commit", + Expected: []sql.Row{}, + }, + { + Query: "/* client b */ call dolt_checkout('feat')", + Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, + }, + { + Query: "/* client b */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'untracked_tbl'", + // B's snapshot pre-dates A's commit, and bare checkout does not carry, so feat + // does not pick up A's untracked_tbl even inside an open transaction. + Expected: []sql.Row{{0}}, + }, + { + Query: "/* client b */ select count(*) from information_schema.tables where table_schema = 'mydb/main' and table_name = 'untracked_tbl'", + Expected: []sql.Row{{0}}, + }, + { + Query: "/* client b */ commit", + Expected: []sql.Row{}, + }, + { + Query: "/* client b */ start transaction", + Expected: []sql.Row{}, + }, + { + Query: "/* client b */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'untracked_tbl'", + // A fresh transaction refreshes B's snapshot, and untracked_tbl still belongs + // to main rather than feat because bare checkout never carries it. + Expected: []sql.Row{{0}}, + }, + { + Query: "/* client b */ select x from `mydb/main`.untracked_tbl", + // Revision-qualified access from feat reaches main's state regardless of which + // branch the session is currently on. + Expected: []sql.Row{{42}}, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_checkout('--move') moves uncommitted state from source to target and cleans the source's working set for all clients", + SetUpScript: []string{ + "create table tracked_tbl (x int primary key)", + "call dolt_commit('-Am', 'add tracked_tbl')", + "call dolt_branch('feat')", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "/* client b */ create table moves_with_b (x int primary key)", + Expected: []sql.Row{{types.NewOkResult(0)}}, + }, + { + Query: "/* client b */ insert into moves_with_b values (7)", + Expected: []sql.Row{{types.NewOkResult(1)}}, + }, + { + Query: "/* client b */ call dolt_checkout('--move', 'feat')", + Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, + }, + { + Query: "/* client b */ select x from moves_with_b", + // The --move flag carried the untracked table onto feat together with B's + // pending row. + Expected: []sql.Row{{7}}, + }, + { + Query: "/* client a */ select active_branch()", + Expected: []sql.Row{{"main"}}, + }, + { + Query: "/* client a */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'moves_with_b'", + // --move cleaned main's working set on behalf of client B, so the carried + // table is no longer visible to client A on main. + Expected: []sql.Row{{0}}, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_checkout('--move') inside an open transaction switches the session but the eventual commit fails because the transaction spans two branches", + SetUpScript: []string{ + "create table tracked_tbl (x int primary key)", + "call dolt_commit('-Am', 'add tracked_tbl')", + "call dolt_branch('feat')", + "set autocommit = 0", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "/* client a */ start transaction", + Expected: []sql.Row{}, + }, + { + Query: "/* client a */ create table u (x int primary key)", + Expected: []sql.Row{{types.NewOkResult(0)}}, + }, + { + Query: "/* client a */ insert into u values (7)", + Expected: []sql.Row{{types.NewOkResult(1)}}, + }, + { + Query: "/* client a */ call dolt_checkout('--move', 'feat')", + Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, + }, + { + Query: "/* client a */ select active_branch()", + Expected: []sql.Row{{"feat"}}, + }, + { + Query: "/* client a */ select x from u", + // The carried table is visible in the same session before commit. + Expected: []sql.Row{{7}}, + }, + { + Query: "/* client a */ commit", + ExpectedErrStr: "Cannot commit changes on more than one branch / database", + }, + }, + }, } var MultiDbTransactionTests = []queries.ScriptTest{ diff --git a/integration-tests/bats/checkout.bats b/integration-tests/bats/checkout.bats index d061310bf21..a6220ce83f3 100755 --- a/integration-tests/bats/checkout.bats +++ b/integration-tests/bats/checkout.bats @@ -1494,3 +1494,373 @@ SQL [ "$status" -eq 0 ] [[ "$output" =~ "1,42" ]] || false } + +@test "checkout: untracked table with foreign key is carried to target branch without error" { + # See https://github.com/dolthub/dolt/issues/11007 + dolt sql -q "CREATE TABLE parent (id INT PRIMARY KEY)" + dolt commit -Am "add parent" + dolt branch other + + dolt sql -q "CREATE TABLE child (id INT PRIMARY KEY, pid INT, CONSTRAINT fk_child FOREIGN KEY (pid) REFERENCES parent(id))" + + run dolt checkout other + [ "$status" -eq 0 ] + + run dolt sql -q "SELECT table_name FROM information_schema.tables WHERE table_schema = database() AND table_name = 'child'" -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "child" ]] || false + + run dolt sql -q "SELECT constraint_name FROM information_schema.table_constraints WHERE table_schema = database() AND constraint_type = 'FOREIGN KEY' AND table_name = 'child'" -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "fk_child" ]] || false +} + +@test "checkout: blocks untracked table that collides with target's committed table regardless of content" { + # See https://github.com/dolthub/dolt/issues/11007 + dolt checkout -b feat + dolt sql -q "CREATE TABLE conflict_tbl (id INT PRIMARY KEY, val INT); INSERT INTO conflict_tbl VALUES (1, 5);" + dolt commit -Am "add conflict_tbl on feat" + dolt checkout main + + dolt sql -q "CREATE TABLE conflict_tbl (id INT PRIMARY KEY)" + + run dolt checkout feat + [ "$status" -ne 0 ] + # conflict_tbl was never staged, so it is reported as untracked, not a local change. + [[ "$output" =~ "untracked tables would be overwritten by checkout" ]] || false + [[ "$output" =~ "conflict_tbl" ]] || false + [[ "$output" =~ "Aborting" ]] || false + + run dolt branch --show-current + [ "$status" -eq 0 ] + [[ "$output" =~ "main" ]] || false + + run dolt schema show conflict_tbl + [ "$status" -eq 0 ] + [[ ! "$output" =~ "val" ]] || false + + # Drop the different-content copy and recreate with byte-identical content to feat's: + # the block must still fire because the source-side cleanup would discard it anyway. + dolt sql -q "DROP TABLE conflict_tbl; CREATE TABLE conflict_tbl (id INT PRIMARY KEY, val INT); INSERT INTO conflict_tbl VALUES (1, 5);" + + run dolt checkout feat + [ "$status" -ne 0 ] + [[ "$output" =~ "untracked tables would be overwritten by checkout" ]] || false + [[ "$output" =~ "conflict_tbl" ]] || false +} + +@test "checkout: ignored tables stay on the source branch" { + # See https://github.com/dolthub/dolt/issues/11007 + dolt sql -q "INSERT INTO dolt_ignore VALUES ('generated_*', 1)" + dolt commit -Am "ignore generated_*" + dolt branch other + + dolt sql -q "CREATE TABLE generated_x (pk INT PRIMARY KEY)" + dolt sql -q "INSERT INTO generated_x VALUES (1)" + + run dolt checkout other + [ "$status" -eq 0 ] + + run dolt sql -q "SELECT count(*) FROM information_schema.tables WHERE table_name = 'generated_x'" -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "0" ]] || false + + run dolt checkout main + [ "$status" -eq 0 ] + + run dolt sql -q "SELECT pk FROM generated_x" -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "1" ]] || false +} + +@test "checkout: aborts when an untracked table matches conflicting dolt_ignore patterns" { + # See https://github.com/dolthub/dolt/issues/11007 + # TODO(elianddb): break ties when no pattern is more specific. dolt_ignore rows have no + # order, so give them one and let the last rule added win. + # See https://git-scm.com/docs/gitignore#_description + dolt sql -q "INSERT INTO dolt_ignore VALUES ('data*', true), ('*set', false)" + dolt commit -Am "conflicting ignore rules" + dolt branch other + + dolt sql -q "CREATE TABLE dataset (pk INT PRIMARY KEY)" + dolt sql -q "INSERT INTO dataset VALUES (7)" + + run dolt checkout other + [ "$status" -ne 0 ] + [[ "$output" =~ "conflicting patterns in dolt_ignore" ]] || false + + run dolt branch --show-current + [ "$status" -eq 0 ] + [[ "$output" =~ "main" ]] || false + + run dolt sql -q "SELECT pk FROM dataset" -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "7" ]] || false +} + +@test "checkout: allows convergent edits where staged content matches target" { + # See https://github.com/dolthub/dolt/issues/11007 + dolt sql <