~bigbes/sr-ht-dolt

97e0f8a7d96859c2749e0bd2a59a37dc26eacc2f — Eugene Blikh 5 days ago 587483e
browse: add TableHash for per-commit change detection
2 files changed, 130 insertions(+), 0 deletions(-)

A browse/table_hash_test.go
M browse/tables.go
A browse/table_hash_test.go => browse/table_hash_test.go +90 -0
@@ 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")
}

M browse/tables.go => browse/tables.go +40 -0
@@ 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()