From 97e0f8a7d96859c2749e0bd2a59a37dc26eacc2f Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Wed, 12 Aug 2026 23:17:40 +0300 Subject: [PATCH] browse: add TableHash for per-commit change detection --- browse/table_hash_test.go | 90 +++++++++++++++++++++++++++++++++++++++ browse/tables.go | 40 +++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 browse/table_hash_test.go diff --git a/browse/table_hash_test.go b/browse/table_hash_test.go new file mode 100644 index 0000000000000000000000000000000000000000..40d5c7d0d7ca5cdfa3efa5861b380d06e61c9512 --- /dev/null +++ b/browse/table_hash_test.go @@ -0,0 +1,90 @@ +package browse + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// tableHash is a helper for the common "the table must exist here" case. +func tableHash(t *testing.T, db *DB, ctx context.Context, refStr, table string) string { + t.Helper() + h, ok, err := db.TableHash(ctx, refStr, table) + require.NoError(t, err) + require.True(t, ok, "table %q must exist at %q", table, refStr) + require.NotEmpty(t, h) + return h +} + +func TestTableHashStable(t *testing.T) { + db := openFixture(t) + ctx := context.Background() + + first := tableHash(t, db, ctx, "main", "users") + second := tableHash(t, db, ctx, "main", "users") + assert.Equal(t, first, second, "the same table at the same ref must hash the same") + + // A branch and the commit hash it points at are the same root, so they must + // produce the same table hash. dev's head is C2. + byBranch := tableHash(t, db, ctx, "dev", "users") + byCommit := tableHash(t, db, ctx, commitHash(t, msgInsert), "users") + assert.Equal(t, byBranch, byCommit, "branch and its head commit must agree") +} + +// TestTableHashDetectsChange is the property the whole history walk rests on: +// the hash changes exactly when the table does. +func TestTableHashDetectsChange(t *testing.T) { + db := openFixture(t) + ctx := context.Background() + + atInsert := tableHash(t, db, ctx, commitHash(t, msgInsert), "users") // C2 + atModify := tableHash(t, db, ctx, commitHash(t, msgModify), "users") // C3: rows changed + atAddItem := tableHash(t, db, ctx, commitHash(t, msgAddItem), "users") + + assert.NotEqual(t, atInsert, atModify, "C3 changed users rows, hash must differ") + // C4 created items and docs and left users alone: a walk must be able to + // skip it without reading a row. + assert.Equal(t, atAddItem, atModify, "C4 did not touch users, hash must be unchanged") +} + +func TestTableHashMissingTable(t *testing.T) { + db := openFixture(t) + ctx := context.Background() + + // items is created by C4, so it does not exist at C2 — an ordinary answer + // for a backwards walk, not an error. + h, ok, err := db.TableHash(ctx, commitHash(t, msgInsert), "items") + require.NoError(t, err) + assert.False(t, ok) + assert.Empty(t, h) + + // A table that never existed behaves the same way. + h, ok, err = db.TableHash(ctx, "main", "no-such-table") + require.NoError(t, err) + assert.False(t, ok) + assert.Empty(t, h) +} + +func TestTableHashRefNotFound(t *testing.T) { + db := openFixture(t) + ctx := context.Background() + + _, ok, err := db.TableHash(ctx, "no-such-ref", "users") + require.ErrorIs(t, err, ErrRefNotFound) + assert.False(t, ok) +} + +func TestTableHashDistinctTables(t *testing.T) { + db := openFixture(t) + ctx := context.Background() + + users := tableHash(t, db, ctx, "main", "users") + items := tableHash(t, db, ctx, "main", "items") + docs := tableHash(t, db, ctx, "main", "docs") + + assert.NotEqual(t, users, items) + assert.NotEqual(t, users, docs) + assert.NotEqual(t, items, docs, "an empty table must not collide with another table") +} diff --git a/browse/tables.go b/browse/tables.go index f6040b4143ea3493368e3ab55a94c78b0955e98e..a9909d43dea94b056e4d8ab83107bb98c40829f3 100644 --- a/browse/tables.go +++ b/browse/tables.go @@ -93,6 +93,46 @@ func (db *DB) Tables(ctx context.Context, refStr string) ([]TableInfo, error) { return infos, nil } +// TableHash returns the content hash of one table in the committed root at ref +// (a branch name or a commit hash). It reads no rows: the hash is the address +// of the table struct itself, so answering "did this table change between two +// commits?" costs a root lookup and nothing more. That is what makes a history +// walk affordable — a walk that has to attribute a change to a commit can skip +// every commit whose table hash equals its neighbour's, and read rows only at +// the few commits that actually touched the table. +// +// The hash covers the whole table (schema, row data, secondary indexes), not +// just the rows. For change detection that is the wanted answer: a column added +// without touching a row is still a change to the table. +// +// A table that does not exist at ref is ok=false with a nil error, deliberately +// *not* ErrTableNotFound (which Rows returns). The caller of this primitive is +// a loop walking backwards through history asking "did it change?", and a table +// that had not been created yet at an old commit is an ordinary answer there, +// not a failure. Callers that need "absent" to be an error can test ok +// themselves; a caller that walked into an error at every pre-creation commit +// could not tell that case apart from a real one. +func (db *DB) TableHash(ctx context.Context, refStr, table string) (string, bool, error) { + root, err := db.resolveRoot(ctx, refStr) + if err != nil { + return "", false, err + } + + tbl, ok, err := root.GetTable(ctx, doltdb.TableName{Name: table}) + if err != nil { + return "", false, fmt.Errorf("browse: get table %q at %q: %w", table, refStr, err) + } + if !ok { + return "", false, nil + } + + h, err := tbl.HashOf() + if err != nil { + return "", false, fmt.Errorf("browse: hash of table %q at %q: %w", table, refStr, err) + } + return h.String(), true, nil +} + // columnInfos renders a schema's columns in natural table order. func columnInfos(sch schema.Schema) []ColumnInfo { cols := sch.GetAllCols().GetColumns()