Repository: genjidb/genji
Pull request #152 (2020-08-18 23:27:29)

Expose __genji_tables
This PR allows running SELECT queries against the `__genji_tables` table, which manages the `tableInfoStore`.


Patch:
diff --git a/cmd/genji/shell/command.go b/cmd/genji/shell/command.go
index 3c72b361..cfb46bfd 100644
--- a/cmd/genji/shell/command.go
+++ b/cmd/genji/shell/command.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 
 	"github.com/genjidb/genji"
+	"github.com/genjidb/genji/document"
 )
 
 func runTablesCmd(db *genji.DB, cmd []string) error {
@@ -11,20 +12,21 @@ func runTablesCmd(db *genji.DB, cmd []string) error {
 		return fmt.Errorf("usage: .tables")
 	}
 
-	var tables []string
-	err := db.View(func(tx *genji.Tx) error {
-		tables = tx.ListTables()
-		return nil
-	})
+	res, err := db.Query("SELECT table_name FROM __genji_tables")
 	if err != nil {
 		return err
 	}
+	defer res.Close()
 
-	for _, t := range tables {
-		fmt.Println(t)
-	}
-
-	return nil
+	return res.Iterate(func(d document.Document) error {
+		var tableName string
+		err = document.Scan(d, &tableName)
+		if err != nil {
+			return err
+		}
+		fmt.Println(tableName)
+		return nil
+	})
 }
 
 // displayTableIndex prints all indexes that the given table contains.
diff --git a/database/config.go b/database/config.go
index 1ec207b5..fc58dea2 100644
--- a/database/config.go
+++ b/database/config.go
@@ -1,10 +1,10 @@
 package database
 
 import (
+	"bytes"
 	"crypto/rand"
 	"encoding/binary"
 	"fmt"
-	"sort"
 	"sync"
 	"time"
 
@@ -66,13 +66,13 @@ func (f *FieldConstraint) ScanDocument(d document.Document) error {
 }
 
 type TableInfo struct {
+	tableName string
+	// storeID is used as a key to reference a table.
+	storeID  []byte
+	readOnly bool
 	// if non-zero, this tableInfo has been created during the current transaction.
 	// it will be removed if the transaction is rolled back or set to false if its commited.
 	transactionID int64
-	// storeID is a generated ID that acts as a key to reference a table.
-	// The first-4 bytes represents the timestamp in second and the last-2 bytes are
-	// randomly generated.
-	storeID [6]byte
 
 	FieldConstraints []FieldConstraint
 }
@@ -92,7 +92,8 @@ func (ti *TableInfo) GetPrimaryKey() *FieldConstraint {
 func (ti *TableInfo) ToDocument() document.Document {
 	buf := document.NewFieldBuffer()
 
-	buf.Add("storeID", document.NewBlobValue(ti.storeID[:]))
+	buf.Add("table_name", document.NewTextValue(ti.tableName))
+	buf.Add("store_id", document.NewBlobValue(ti.storeID))
 
 	vbuf := document.NewValueBuffer()
 	for _, fc := range ti.FieldConstraints {
@@ -101,15 +102,23 @@ func (ti *TableInfo) ToDocument() document.Document {
 
 	buf.Add("field_constraints", document.NewArrayValue(vbuf))
 
+	buf.Add("read_only", document.NewBoolValue(ti.readOnly))
 	return buf
 }
 
 func (ti *TableInfo) ScanDocument(d document.Document) error {
-	v, err := d.GetByField("storeID")
+	v, err := d.GetByField("table_name")
 	if err != nil {
 		return err
 	}
-	copy(ti.storeID[:], v.V.([]byte))
+	ti.tableName = v.V.(string)
+
+	v, err = d.GetByField("store_id")
+	if err != nil {
+		return err
+	}
+	ti.storeID = make([]byte, len(v.V.([]byte)))
+	copy(ti.storeID, v.V.([]byte))
 
 	v, err = d.GetByField("field_constraints")
 	if err != nil {
@@ -124,9 +133,20 @@ func (ti *TableInfo) ScanDocument(d document.Document) error {
 
 	ti.FieldConstraints = make([]FieldConstraint, l)
 
-	return ar.Iterate(func(i int, value document.Value) error {
+	err = ar.Iterate(func(i int, value document.Value) error {
 		return ti.FieldConstraints[i].ScanDocument(v.V.(document.Document))
 	})
+	if err != nil {
+		return err
+	}
+
+	v, err = d.GetByField("read_only")
+	if err != nil {
+		return err
+	}
+
+	ti.readOnly = v.V.(bool)
+	return nil
 }
 
 // tableInfoStore manages table information.
@@ -264,6 +284,10 @@ func (t *tableInfoStore) loadAllTableInfo(tx engine.Transaction) error {
 		t.tableInfos[string(itm.Key())] = ti
 	}
 
+	t.tableInfos[tableInfoStoreName] = TableInfo{
+		storeID:  []byte(tableInfoStoreName),
+		readOnly: true,
+	}
 	return nil
 }
 
@@ -295,30 +319,23 @@ func (t *tableInfoStore) commit(tx *Transaction) {
 	}
 }
 
-// ListTables lists all the tables. It ignores tables created by
-// other transactions that haven't been commited yet.
-// The returned slice is lexicographically ordered.
-func (t *tableInfoStore) ListTables(tx *Transaction) []string {
+// GetTableInfo returns a copy of all the table information.
+func (t *tableInfoStore) GetTableInfo() map[string]TableInfo {
 	t.mu.RLock()
 	defer t.mu.RUnlock()
 
-	names := make([]string, len(t.tableInfos))
-	var i int
-	for k := range t.tableInfos {
-		if t.tableInfos[k].transactionID != 0 && t.tableInfos[k].transactionID != tx.id {
-			continue
-		}
-
-		names[i] = k
-		i++
+	ti := make(map[string]TableInfo, len(t.tableInfos))
+	for k, v := range t.tableInfos {
+		ti[k] = v
 	}
 
-	sort.Strings(names)
-
-	return names
+	return ti
 }
 
-func (t *tableInfoStore) generateStoreID() [6]byte {
+// generateStoreID generates an ID used as a key to reference a table.
+// The first 4 bytes represent the timestamp in second and the last-2 bytes are
+// randomly generated.
+func (t *tableInfoStore) generateStoreID() []byte {
 	t.mu.RLock()
 	defer t.mu.RUnlock()
 
@@ -332,7 +349,7 @@ func (t *tableInfoStore) generateStoreID() [6]byte {
 
 		found = false
 		for _, ti := range t.tableInfos {
-			if ti.storeID == id {
+			if bytes.Equal(ti.storeID, id[:]) {
 				// A store with this id already exists.
 				// Let's generate a new one.
 				found = true
@@ -341,7 +358,7 @@ func (t *tableInfoStore) generateStoreID() [6]byte {
 		}
 	}
 
-	return id
+	return id[:]
 }
 
 // IndexConfig holds the configuration of an index.
diff --git a/database/table.go b/database/table.go
index 80c487a5..c172f1d7 100644
--- a/database/table.go
+++ b/database/table.go
@@ -42,7 +42,16 @@ func (t *Table) Truncate() error {
 // in the given document.
 // If no primary key has been selected, a monotonic autoincremented integer key will be generated.
 func (t *Table) Insert(d document.Document) ([]byte, error) {
-	d, err := t.ValidateConstraints(d)
+	info, err := t.Info()
+	if err != nil {
+		return nil, err
+	}
+
+	if info.readOnly {
+		return nil, errors.New("cannot write to read-only table")
+	}
+
+	d, err = t.ValidateConstraints(d)
 	if err != nil {
 		return nil, err
 	}
@@ -99,6 +108,15 @@ func (t *Table) Insert(d document.Document) ([]byte, error) {
 // Delete a document by key.
 // Indexes are automatically updated.
 func (t *Table) Delete(key []byte) error {
+	info, err := t.Info()
+	if err != nil {
+		return err
+	}
+
+	if info.readOnly {
+		return errors.New("cannot write to read-only table")
+	}
+
 	d, err := t.GetDocument(key)
 	if err != nil {
 		return err
@@ -128,7 +146,16 @@ func (t *Table) Delete(key []byte) error {
 // An error is returned if the key doesn't exist.
 // Indexes are automatically updated.
 func (t *Table) Replace(key []byte, d document.Document) error {
-	d, err := t.ValidateConstraints(d)
+	info, err := t.Info()
+	if err != nil {
+		return err
+	}
+
+	if info.readOnly {
+		return errors.New("cannot write to read-only table")
+	}
+
+	d, err = t.ValidateConstraints(d)
 	if err != nil {
 		return err
 	}
@@ -587,6 +614,15 @@ func getParentValue(d document.Document, p document.ValuePath) (document.Value,
 
 // ReIndex all the indexes of the table.
 func (t *Table) ReIndex() error {
+	info, err := t.Info()
+	if err != nil {
+		return err
+	}
+
+	if info.readOnly {
+		return errors.New("cannot write to read-only table")
+	}
+
 	indexes, err := t.Indexes()
 	if err != nil {
 		return err
diff --git a/database/transaction.go b/database/transaction.go
index 016a6fe6..8b7589cc 100644
--- a/database/transaction.go
+++ b/database/transaction.go
@@ -1,7 +1,9 @@
 package database
 
 import (
+	"errors"
 	"fmt"
+	"strings"
 
 	"github.com/genjidb/genji/document"
 	"github.com/genjidb/genji/document/encoding/msgpack"
@@ -10,8 +12,9 @@ import (
 )
 
 var (
-	tableInfoStoreName = "__genji.tables"
-	indexStoreName     = "__genji.indexes"
+	internalPrefix     = "__genji_"
+	tableInfoStoreName = internalPrefix + "tables"
+	indexStoreName     = internalPrefix + "indexes"
 )
 
 // Transaction represents a database transaction. It provides methods for managing the
@@ -52,17 +55,22 @@ func (tx *Transaction) Writable() bool {
 // CreateTable creates a table with the given name.
 // If it already exists, returns ErrTableAlreadyExists.
 func (tx *Transaction) CreateTable(name string, info *TableInfo) error {
+	if strings.HasPrefix(name, internalPrefix) {
+		return fmt.Errorf("table name must not start with %s", internalPrefix)
+	}
+
 	if info == nil {
 		info = new(TableInfo)
 	}
 
+	info.tableName = name
 	info.storeID = tx.tableInfoStore.generateStoreID()
 	err := tx.tableInfoStore.Insert(tx, name, info)
 	if err != nil {
 		return err
 	}
 
-	err = tx.Tx.CreateStore(info.storeID[:])
+	err = tx.Tx.CreateStore(info.storeID)
 	if err != nil {
 		return fmt.Errorf("failed to create table %q: %w", name, err)
 	}
@@ -77,7 +85,7 @@ func (tx *Transaction) GetTable(name string) (*Table, error) {
 		return nil, err
 	}
 
-	s, err := tx.Tx.GetStore(ti.storeID[:])
+	s, err := tx.Tx.GetStore(ti.storeID)
 	if err != nil {
 		return nil, err
 	}
@@ -98,6 +106,11 @@ func (tx *Transaction) RenameTable(oldName, newName string) error {
 		return err
 	}
 
+	if ti.readOnly {
+		return errors.New("cannot write to read-only table")
+	}
+
+	ti.tableName = newName
 	// Insert the TableInfo keyed by the newName name.
 	err = tx.tableInfoStore.Insert(tx, newName, ti)
 	if err != nil {
@@ -125,10 +138,18 @@ func (tx *Transaction) RenameTable(oldName, newName string) error {
 
 // DropTable deletes a table from the database.
 func (tx *Transaction) DropTable(name string) error {
+	ti, err := tx.tableInfoStore.Get(tx, name)
+	if err != nil {
+		return err
+	}
+
+	if ti.readOnly {
+		return errors.New("cannot write to read-only table")
+	}
+
 	it := tx.indexStore.st.NewIterator(engine.IteratorConfig{})
 
 	var buf msgpack.EncodedDocument
-	var err error
 	for it.Seek(nil); it.Valid(); it.Next() {
 		item := it.Item()
 		var opts IndexConfig
@@ -160,23 +181,12 @@ func (tx *Transaction) DropTable(name string) error {
 		return err
 	}
 
-	ti, err := tx.tableInfoStore.Get(tx, name)
-	if err != nil {
-		return err
-	}
-
 	err = tx.tableInfoStore.Delete(tx, name)
 	if err != nil {
 		return err
 	}
 
-	return tx.Tx.DropStore(ti.storeID[:])
-}
-
-// ListTables lists all the tables.
-// The returned slice is lexicographically ordered.
-func (tx *Transaction) ListTables() []string {
-	return tx.tableInfoStore.ListTables(tx)
+	return tx.Tx.DropStore(ti.storeID)
 }
 
 // CreateIndex creates an index with the given name.


Test patch:
diff --git a/database/config_test.go b/database/config_test.go
index f75210fc..f2744c46 100644
--- a/database/config_test.go
+++ b/database/config_test.go
@@ -35,17 +35,9 @@ func TestTableInfoStore(t *testing.T) {
 		err = tx.tableInfoStore.Insert(tx, "foo1", info)
 		require.Equal(t, err, ErrTableAlreadyExists)
 
-		// Listing all tables should return their name
-		// lexicographically ordered.
-		_ = tx.tableInfoStore.Insert(tx, "foo3", info)
-		_ = tx.tableInfoStore.Insert(tx, "foo2", info)
-		lt := tx.tableInfoStore.ListTables(tx)
-		require.Equal(t, []string{"foo1", "foo2", "foo3"}, lt)
-
 		// Getting an existing TableInfo should work.
-		received, err := tx.tableInfoStore.Get(tx, "foo1")
+		_, err = tx.tableInfoStore.Get(tx, "foo1")
 		require.NoError(t, err)
-		require.NotNil(t, received.storeID)
 
 		// Getting a non-existing TableInfo should not work.
 		_, err = tx.tableInfoStore.Get(tx, "unknown")
diff --git a/database/transaction_test.go b/database/transaction_test.go
index c91a2e75..2458a46d 100644
--- a/database/transaction_test.go
+++ b/database/transaction_test.go
@@ -26,7 +26,6 @@ func newTestDB(t testing.TB) (*database.Transaction, func()) {
 // - CreateTable
 // - GetTable
 // - DropTable
-// - ListTables
 // - RenameTable
 func TestTxTable(t *testing.T) {
 	t.Run("Create", func(t *testing.T) {
@@ -39,6 +38,10 @@ func TestTxTable(t *testing.T) {
 		// Creating a table that already exists should fail.
 		err = tx.CreateTable("test", nil)
 		require.EqualError(t, err, database.ErrTableAlreadyExists.Error())
+
+		// Creating a table that starts with __genji_ should fail.
+		err = tx.CreateTable("__genji_foo", nil)
+		require.Error(t, err)
 	})
 
 	t.Run("Create and rollback", func(t *testing.T) {
@@ -97,28 +100,6 @@ func TestTxTable(t *testing.T) {
 		require.EqualError(t, err, database.ErrTableNotFound.Error())
 	})
 
-	t.Run("List", func(t *testing.T) {
-		tx, cleanup := newTestDB(t)
-		defer cleanup()
-
-		tables := tx.ListTables()
-		require.Len(t, tables, 0)
-
-		err := tx.CreateTable("foo", nil)
-		require.NoError(t, err)
-
-		err = tx.CreateTable("bar", nil)
-		require.NoError(t, err)
-
-		err = tx.CreateTable("baz", nil)
-		require.NoError(t, err)
-
-		tables = tx.ListTables()
-		// The returned slice should be lexicographically ordered.
-		exp := []string{"bar", "baz", "foo"}
-		require.Equal(t, exp, tables)
-	})
-
 	t.Run("Rename", func(t *testing.T) {
 		tx, cleanup := newTestDB(t)
 		defer cleanup()
@@ -150,7 +131,7 @@ func TestTxTable(t *testing.T) {
 		// The field constraints should be the same.
 		info, err := tb.Info()
 		require.NoError(t, err)
-		require.Equal(t, ti, info)
+		require.Equal(t, ti.FieldConstraints, info.FieldConstraints)
 
 		// Check that the indexes have been updated as well.
 		idxs, err := tx.ListIndexes()
diff --git a/sql/query/alter_test.go b/sql/query/alter_test.go
index e78aec16..8074477b 100644
--- a/sql/query/alter_test.go
+++ b/sql/query/alter_test.go
@@ -38,4 +38,8 @@ func TestAlterTable(t *testing.T) {
 	err = document.ToJSON(&buf, d)
 	require.NoError(t, err)
 	require.JSONEq(t, `{"name": "John Doe", "age": 99}`, buf.String())
+
+	// Renaming a read-only table should fail
+	err = db.Exec("ALTER TABLE __genji_tables RENAME TO bar")
+	require.Error(t, err)
 }
diff --git a/sql/query/delete_test.go b/sql/query/delete_test.go
index f392c0f6..3ba0a12e 100644
--- a/sql/query/delete_test.go
+++ b/sql/query/delete_test.go
@@ -20,6 +20,7 @@ func TestDeleteStmt(t *testing.T) {
 		{"No cond", `DELETE FROM test`, false, "", nil},
 		{"With cond", "DELETE FROM test WHERE b = 'bar1'", false, `{"d": "foo3", "b": "bar2", "e": "bar3"}`, nil},
 		{"Table not found", "DELETE FROM foo WHERE b = 'bar1'", true, "", nil},
+		{"Read-only table", "DELETE FROM __genji_tables", true, "", nil},
 	}
 
 	for _, test := range tests {
diff --git a/sql/query/drop_test.go b/sql/query/drop_test.go
index 64692a16..81dfd5b9 100644
--- a/sql/query/drop_test.go
+++ b/sql/query/drop_test.go
@@ -5,6 +5,7 @@ import (
 
 	"github.com/genjidb/genji"
 	"github.com/genjidb/genji/database"
+	"github.com/genjidb/genji/document"
 	"github.com/stretchr/testify/require"
 )
 
@@ -28,12 +29,25 @@ func TestDropTable(t *testing.T) {
 	require.Error(t, err)
 
 	// Assert that only the table `test1` has been dropped.
+	res, err := db.Query("SELECT table_name FROM __genji_tables")
+	require.NoError(t, err)
 	var tables []string
-	err = db.View(func(tx *genji.Tx) error {
-		tables = tx.ListTables()
+	err = res.Iterate(func(d document.Document) error {
+		v, err := d.GetByField("table_name")
+		if err != nil {
+			return err
+		}
+		tables = append(tables, v.V.(string))
 		return nil
 	})
+	require.NoError(t, err)
+	require.NoError(t, res.Close())
+
 	require.Len(t, tables, 2)
+
+	// Dropping a read-only table should fail.
+	err = db.Exec("DROP TABLE __genji_tables")
+	require.Error(t, err)
 }
 
 func TestDropIndex(t *testing.T) {
diff --git a/sql/query/insert_test.go b/sql/query/insert_test.go
index 15ccd230..768b80fa 100644
--- a/sql/query/insert_test.go
+++ b/sql/query/insert_test.go
@@ -37,6 +37,7 @@ func TestInsertStmt(t *testing.T) {
 		{"Documents / strings", `INSERT INTO test VALUES {'a': 'a', b: 2.3}`, false, `{"pk()":1,"a":"a","b":2.3}`, nil},
 		{"Documents / double quotes", `INSERT INTO test VALUES {"a": "b"}`, false, `{"pk()":1,"a":"b"}`, nil},
 		{"Documents / with reference to other fields", `INSERT INTO test VALUES {a: 400, b: a * 4}`, false, `{"pk()":1,"a":400,"b":1600}`, nil},
+		{"Read-only tables", `INSERT INTO __genji_tables VALUES {a: 400, b: a * 4}`, true, ``, nil},
 	}
 
 	for _, test := range tests {
diff --git a/sql/query/reindex_test.go b/sql/query/reindex_test.go
index 79f5e3f3..008828fa 100644
--- a/sql/query/reindex_test.go
+++ b/sql/query/reindex_test.go
@@ -19,6 +19,7 @@ func TestReIndex(t *testing.T) {
 		{"ReIndex table", `REINDEX test2`, []string{"idx_test2_a", "idx_test2_b"}, false},
 		{"ReIndex index", `REINDEX idx_test1_a`, []string{"idx_test1_a"}, false},
 		{"ReIndex unknown", `REINDEX doesntexist`, []string{}, true},
+		{"ReIndex read-only", `REINDEX __genji_tables`, []string{}, true},
 	}
 
 	for _, test := range tests {
diff --git a/sql/query/update_test.go b/sql/query/update_test.go
index 21734232..d7ff9aef 100644
--- a/sql/query/update_test.go
+++ b/sql/query/update_test.go
@@ -18,8 +18,8 @@ func TestUpdateStmt(t *testing.T) {
 		expected string
 		params   []interface{}
 	}{
-		// Test without any clause.
 		{"No clause", `UPDATE test`, true, "", nil},
+		{"Read-only table", `UPDATE __genji_tables SET a = 1`, true, "", nil},
 
 		// SET tests.
 		{"SET / No cond", `UPDATE test SET a = 'boo'`, false, `[{"a":"boo","b":"bar1","c":"baz1"},{"a":"boo","b":"bar2"},{"a":"boo","d":"bar3","e":"baz3"}]`, nil},