Repository: polarsignals/frostdb (go)

Issue:
Panic while writing structs with dynamic map column
Calling `(*Table).Write` with struct that has map field defined as a dynamic column triggers a panic

Here is a test case to reproduce 

```go
func TestTable_write_struct_with_map_field(t *testing.T) {
	type SampleMap struct {
		Labels map[string]string
	}
	schema := &schemapb.Schema{
		Name: "test",
		Columns: []*schemapb.Column{
			{
				Name: "labels",
				StorageLayout: &schemapb.StorageLayout{
					Type:     schemapb.StorageLayout_TYPE_STRING,
					Nullable: true,
					Encoding: schemapb.StorageLayout_ENCODING_RLE_DICTIONARY,
				},
				Dynamic: true,
			},
		},
	}

	store, err := New()
	require.Nil(t, err)
	defer store.Close()
	db, err := store.DB(context.Background(), "tes")
	require.Nil(t, err)
	table, err := db.Table("test", NewTableConfig(schema))
	require.Nil(t, err)
	_, err = table.Write(context.Background(),
		SampleMap{
			Labels: map[string]string{
				"label1": "value1",
				"label2": "value2",
			},
		},
		SampleMap{
			Labels: map[string]string{
				"label1": "value1",
				"label2": "value2",
				"label3": "value3",
			},
		},
		SampleMap{
			Labels: map[string]string{
				"label1": "value1",
				"label2": "value2",
			},
		},
	)
	require.Nil(t, err)
}
```

Here is the stacktrace
```
--- FAIL: TestTable_write_struct_with_map_field (0.00s)
panic: runtime error: index out of range [3] with length 3 [recovered]
	panic: runtime error: index out of range [3] with length 3

goroutine 7 [running]:
testing.tRunner.func1.2({0x1a0a460, 0xc0000ca078})
	/usr/local/Cellar/go/1.21.4/libexec/src/testing/testing.go:1545 +0x238
testing.tRunner.func1()
	/usr/local/Cellar/go/1.21.4/libexec/src/testing/testing.go:1548 +0x397
panic({0x1a0a460?, 0xc0000ca078?})
	/usr/local/Cellar/go/1.21.4/libexec/src/runtime/panic.go:914 +0x21f
github.com/parquet-go/parquet-go.(*Buffer).WriteRows(0xc0002f43f0, {0xc00007f860?, 0x3, 0x0?})
	/Users/g.ernest/go/pkg/mod/github.com/parquet-go/parquet-go@v0.20.0/buffer.go:392 +0x418
github.com/polarsignals/frostdb/dynparquet.(*Buffer).WriteRows(...)
	/Volumes/code/frostdb/dynparquet/schema.go:960
github.com/polarsignals/frostdb/dynparquet.ValuesToBuffer(0xc0002f41b0, {0xc000151f28, 0x3, 0x120?})
	/Volumes/code/frostdb/dynparquet/schema.go:1141 +0x3d8
github.com/polarsignals/frostdb.(*Table).Write(0xc00047eaa0, {0x1bd33f0, 0x235d6c0}, {0xc000151f28?, 0x0?, 0x0?})
	/Volumes/code/frostdb/table.go:550 +0x7e
github.com/polarsignals/frostdb.TestTable_write_struct_with_map_field(0x0?)
	/Volumes/code/frostdb/db_test.go:2280 +0x505
testing.tRunner(0xc00017ed00, 0x1aca4e0)
	/usr/local/Cellar/go/1.21.4/libexec/src/testing/testing.go:1595 +0xff
created by testing.(*T).Run in goroutine 1
	/usr/local/Cellar/go/1.21.4/libexec/src/testing/testing.go:1648 +0x3ad
exit status 2
FAIL	github.com/polarsignals/frostdb	0.914s
```

Pull request description:
Remove (*Table)Write and introduce (*GenericTable[T]).Write
This commit removes `(*Table).Write` in favor of `(*GenericTable[T]).Write`

`GenericTable` offers a lot of improvements from the previous API. One of the biggest advantage is there is no longer need to define schema before hand instead the schema is part of the struct that is written in the form of struct tags.

Example code has been refactored to use `map[string]T` for dynamic columns, however slice struct dynamic columns still works, this is intentional to avoid the PR from blowing out of proportion.

`GenericTable` embeds `Table` so from user perspective only few changes are needed to work with the new API.

Also 
Closes #623 

Patch:
diff --git a/dynparquet/record_builder.go b/dynparquet/record_builder.go
index 1fe437e..f60ba51 100644
--- a/dynparquet/record_builder.go
+++ b/dynparquet/record_builder.go
@@ -343,6 +343,9 @@ func newMapFieldBuilder(newField func(string) fieldBuilder) *mapFieldBuilder {
 var _ fieldBuilder = (*mapFieldBuilder)(nil)
 
 func (m *mapFieldBuilder) Fields() (o []arrow.Field) {
+	if len(m.columns) == 0 {
+		return []arrow.Field{}
+	}
 	o = make([]arrow.Field, 0, len(m.columns))
 	m.keys = slices.Grow(m.keys, len(m.columns))
 	for k := range m.columns {
@@ -356,6 +359,9 @@ func (m *mapFieldBuilder) Fields() (o []arrow.Field) {
 }
 
 func (m *mapFieldBuilder) NewArray(a []arrow.Array) []arrow.Array {
+	if len(m.columns) == 0 {
+		return a
+	}
 	m.keys = m.keys[:0]
 	for k := range m.columns {
 		m.keys = append(m.keys, k)
@@ -393,6 +399,12 @@ func (m *mapFieldBuilder) Append(v reflect.Value) error {
 }
 
 func (m *mapFieldBuilder) appendMap(v reflect.Value) error {
+	if v.IsNil() || v.Len() == 0 {
+		for _, v := range m.columns {
+			v.AppendNull()
+		}
+		return nil
+	}
 	clear(m.seen)
 	keys := v.MapKeys()
 	size := m.Len()
@@ -416,6 +428,12 @@ func (m *mapFieldBuilder) appendMap(v reflect.Value) error {
 }
 
 func (m *mapFieldBuilder) appendSlice(v reflect.Value) error {
+	if v.IsNil() || v.Len() == 0 {
+		for _, v := range m.columns {
+			v.AppendNull()
+		}
+		return nil
+	}
 	clear(m.seen)
 	size := m.Len()
 	for n := 0; n < v.Len(); n++ {
diff --git a/examples/aggregations/aggregations.go b/examples/aggregations/aggregations.go
index b997bfe..0187f70 100644
--- a/examples/aggregations/aggregations.go
+++ b/examples/aggregations/aggregations.go
@@ -8,7 +8,6 @@ import (
 	"github.com/apache/arrow/go/v14/arrow/memory"
 
 	"github.com/polarsignals/frostdb"
-	schemapb "github.com/polarsignals/frostdb/gen/proto/go/frostdb/schema/v1alpha1"
 	"github.com/polarsignals/frostdb/query"
 	"github.com/polarsignals/frostdb/query/logicalplan"
 )
@@ -23,38 +22,34 @@ func main() {
 	// Open up a database in the column store
 	database, _ := columnstore.DB(context.Background(), "weather_db")
 
-	// Define our aggregation schema of labels and values
-	schema := aggregationSchema()
-
-	// Create a table named snowfall_table in our database
-	table, _ := database.Table(
-		"snowfall_table",
-		frostdb.NewTableConfig(schema),
-	)
-
 	// Create values to insert into the database. We support a dynamic structure for city to
 	// accommodate cities in different regions
 	type WeatherRecord struct {
-		City     interface{}
-		Day      string
+		City     map[string]string `frostdb:",rle_dict,asc(0)"`
+		Day      string            `frostdb:",rle_dict,asc(1)"`
 		Snowfall float64
 	}
 
-	type CityInProvince struct {
-		Name     string
-		Province string
-	}
+	// Create a table named snowfall_table in our database
+	table, _ := frostdb.NewGenericTable[WeatherRecord](
+		database, "snowfall_table", memory.DefaultAllocator,
+	)
+	defer table.Release()
 
-	type CityInState struct {
-		Name  string
-		State string
+	montreal := map[string]string{
+		"name":     "Montreal",
+		"province": "Quebec",
+	}
+	toronto := map[string]string{
+		"name":     "Toronto",
+		"province": "Ontario",
+	}
+	minneapolis := map[string]string{
+		"name":  "Minneapolis",
+		"state": "Minnesota",
 	}
 
-	montreal := CityInProvince{Name: "Montreal", Province: "Quebec"}
-	toronto := CityInProvince{Name: "Toronto", Province: "Ontario"}
-	minneapolis := CityInState{Name: "Minneapolis", State: "Minnesota"}
-
-	_, _ = table.Write(context.Background(),
+	_ = table.Write(context.Background(),
 		WeatherRecord{Day: "Mon", Snowfall: 20, City: montreal},
 		WeatherRecord{Day: "Tue", Snowfall: 00, City: montreal},
 		WeatherRecord{Day: "Wed", Snowfall: 30, City: montreal},
@@ -105,45 +100,3 @@ func main() {
 			return nil
 		})
 }
-
-func aggregationSchema() *schemapb.Schema {
-	return &schemapb.Schema{
-		Name: "snowfall_table",
-		Columns: []*schemapb.Column{
-			{
-				Name: "city",
-				StorageLayout: &schemapb.StorageLayout{
-					Type:     schemapb.StorageLayout_TYPE_STRING,
-					Encoding: schemapb.StorageLayout_ENCODING_RLE_DICTIONARY,
-					Nullable: false,
-				},
-				Dynamic: true,
-			},
-			{
-				Name: "day",
-				StorageLayout: &schemapb.StorageLayout{
-					Type:     schemapb.StorageLayout_TYPE_STRING,
-					Encoding: schemapb.StorageLayout_ENCODING_RLE_DICTIONARY,
-					Nullable: false,
-				},
-			},
-			{
-				Name: "snowfall",
-				StorageLayout: &schemapb.StorageLayout{
-					Type: schemapb.StorageLayout_TYPE_DOUBLE,
-				},
-				Dynamic: false,
-			},
-		},
-		SortingColumns: []*schemapb.SortingColumn{
-			{
-				Name:      "city",
-				Direction: schemapb.SortingColumn_DIRECTION_ASCENDING,
-			},
-			{
-				Name:      "day",
-				Direction: schemapb.SortingColumn_DIRECTION_ASCENDING,
-			},
-		},
-	}
-}
diff --git a/examples/simple/simple.go b/examples/simple/simple.go
index 79e3723..2bf1eb5 100644
--- a/examples/simple/simple.go
+++ b/examples/simple/simple.go
@@ -6,10 +6,8 @@ import (
 
 	"github.com/apache/arrow/go/v14/arrow"
 	"github.com/apache/arrow/go/v14/arrow/memory"
-	"google.golang.org/protobuf/proto"
 
 	"github.com/polarsignals/frostdb"
-	schemapb "github.com/polarsignals/frostdb/gen/proto/go/frostdb/schema/v1alpha1"
 	"github.com/polarsignals/frostdb/query"
 	"github.com/polarsignals/frostdb/query/logicalplan"
 )
@@ -23,63 +21,41 @@ func main() {
 	// Open up a database in the column store
 	database, _ := columnstore.DB(context.Background(), "simple_db")
 
-	// Define our simple schema of labels and values
-	schema := simpleSchema()
-
-	// Create a table named simple in our database
-	table, _ := database.Table(
-		"simple_table",
-		frostdb.NewTableConfig(schema),
-	)
-
-	type FirstLast struct {
-		FirstName string
-		Surname   string
-	}
-
 	type Simple struct {
-		Names FirstLast
+		Names map[string]string `frostdb:",asc"`
 		Value int64
 	}
+	table, _ := frostdb.NewGenericTable[Simple](
+		database, "simple_table", memory.DefaultAllocator,
+	)
 	// Create values to insert into the database these first rows havel dynamic label names of 'firstname' and 'surname'
 	frederic := Simple{
-		Names: FirstLast{
-			FirstName: "Frederic",
-			Surname:   "Brancz",
+		Names: map[string]string{
+			"first_name": "Frederic",
+			"surname":    "Brancz",
 		},
 		Value: 100,
 	}
 
 	thor := Simple{
-		Names: FirstLast{
-			FirstName: "Thor",
-			Surname:   "Hansen",
+		Names: map[string]string{
+			"first_name": "Thor",
+			"surname":    "Hansen",
 		},
 		Value: 99,
 	}
-	_, _ = table.Write(context.Background(), frederic, thor)
+	_ = table.Write(context.Background(), frederic, thor)
 
 	// Now we can insert rows that have middle names into our dynamic column
-	matthias := struct {
-		Names struct {
-			FirstName  string
-			MiddleName string
-			Surname    string
-		}
-		Value int64
-	}{
-		Names: struct {
-			FirstName  string
-			MiddleName string
-			Surname    string
-		}{
-			FirstName:  "Matthias",
-			MiddleName: "Oliver Rainer",
-			Surname:    "Loibl",
+	matthias := Simple{
+		Names: map[string]string{
+			"first_name":  "Matthias",
+			"middle_name": "Oliver Rainer",
+			"surname":     "Loibl",
 		},
 		Value: 101,
 	}
-	_, _ = table.Write(context.Background(), matthias)
+	_ = table.Write(context.Background(), matthias)
 
 	// Create a new query engine to retrieve data and print the results
 	engine := query.NewEngine(memory.DefaultAllocator, database.TableProvider())
@@ -92,28 +68,3 @@ func main() {
 		return nil
 	})
 }
-
-func simpleSchema() proto.Message {
-	return &schemapb.Schema{
-		Name: "simple_schema",
-		Columns: []*schemapb.Column{{
-			Name: "names",
-			StorageLayout: &schemapb.StorageLayout{
-				Type:     schemapb.StorageLayout_TYPE_STRING,
-				Encoding: schemapb.StorageLayout_ENCODING_RLE_DICTIONARY,
-				Nullable: true,
-			},
-			Dynamic: true,
-		}, {
-			Name: "value",
-			StorageLayout: &schemapb.StorageLayout{
-				Type: schemapb.StorageLayout_TYPE_INT64,
-			},
-			Dynamic: false,
-		}},
-		SortingColumns: []*schemapb.SortingColumn{{
-			Name:      "names",
-			Direction: schemapb.SortingColumn_DIRECTION_ASCENDING,
-		}},
-	}
-}
diff --git a/table.go b/table.go
index 70d5ebe..e3a953a 100644
--- a/table.go
+++ b/table.go
@@ -101,6 +101,18 @@ func WithoutWAL() TableOption {
 	}
 }
 
+func WithUniquePrimaryIndex(unique bool) TableOption {
+	return func(config *tablepb.TableConfig) error {
+		switch e := config.Schema.(type) {
+		case *tablepb.TableConfig_DeprecatedSchema:
+			e.DeprecatedSchema.UniquePrimaryIndex = unique
+		case *tablepb.TableConfig_SchemaV2:
+			e.SchemaV2.UniquePrimaryIndex = unique
+		}
+		return nil
+	}
+}
+
 // FromConfig sets the table configuration from the given config.
 // NOTE: that this does not override the schema even though that is included in the passed in config.
 func FromConfig(config *tablepb.TableConfig) TableOption {
@@ -151,6 +163,36 @@ type completedBlock struct {
 	tx     uint64
 }
 
+type GenericTable[T any] struct {
+	*Table
+	mu    sync.Mutex
+	build *dynparquet.Build[T]
+}
+
+func (t *GenericTable[T]) Release() {
+	t.build.Release()
+}
+
+func (t *GenericTable[T]) Write(ctx context.Context, values ...T) error {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+	err := t.build.Append(values...)
+	if err != nil {
+		return err
+	}
+	_, err = t.InsertRecord(ctx, t.build.NewRecord())
+	return err
+}
+
+func NewGenericTable[T any](db *DB, name string, mem memory.Allocator, options ...TableOption) (*GenericTable[T], error) {
+	build := dynparquet.NewBuild[T](mem)
+	table, err := db.Table(name, NewTableConfig(build.Schema(name), options...))
+	if err != nil {
+		return nil, err
+	}
+	return &GenericTable[T]{build: build, Table: table}, nil
+}
+
 type Table struct {
 	db      *DB
 	name    string
@@ -547,26 +589,6 @@ func (t *Table) EnsureCompaction() error {
 	return t.ActiveBlock().EnsureCompaction()
 }
 
-// Write objects into the table.
-func (t *Table) Write(ctx context.Context, vals ...any) (uint64, error) {
-	b, err := dynparquet.ValuesToBuffer(t.Schema(), vals...)
-	if err != nil {
-		return 0, err
-	}
-
-	converter := pqarrow.NewParquetConverter(memory.NewGoAllocator(), logicalplan.IterOptions{})
-	defer converter.Close()
-
-	if err := converter.Convert(ctx, b); err != nil {
-		return 0, err
-	}
-
-	r := converter.NewRecord()
-	defer r.Release()
-
-	return t.InsertRecord(ctx, r)
-}
-
 func (t *Table) InsertRecord(ctx context.Context, record arrow.Record) (uint64, error) {
 	block, finish, err := t.appender(ctx)
 	if err != nil {


Test patch:
diff --git a/aggregate_test.go b/aggregate_test.go
index fcb356c..f067510 100644
--- a/aggregate_test.go
+++ b/aggregate_test.go
@@ -256,48 +256,17 @@ func TestDurationAggregation(t *testing.T) {
 	db, err := c.DB(context.Background(), "test")
 	require.NoError(t, err)
 
-	schema := &schemapb.Schema{
-		Name: "test",
-		Columns: []*schemapb.Column{
-			{
-				Name: "timestamp",
-				StorageLayout: &schemapb.StorageLayout{
-					Type: schemapb.StorageLayout_TYPE_INT64,
-				},
-				Dynamic: false,
-			},
-			{
-				Name: "stacktrace",
-				StorageLayout: &schemapb.StorageLayout{
-					Type: schemapb.StorageLayout_TYPE_STRING,
-				},
-				Dynamic: false,
-			},
-			{
-				Name: "value",
-				StorageLayout: &schemapb.StorageLayout{
-					Type: schemapb.StorageLayout_TYPE_INT64,
-				},
-				Dynamic: false,
-			},
-		},
-		SortingColumns: []*schemapb.SortingColumn{
-			{
-				Name:      "timestamp",
-				Direction: schemapb.SortingColumn_DIRECTION_ASCENDING,
-			},
-		},
+	type Record struct {
+		Timestamp  int64 `frostdb:",asc"`
+		Stacktrace string
+		Value      int64
 	}
 
-	config := NewTableConfig(schema)
-	table, err := db.Table("test", config)
+	table, err := NewGenericTable[Record](db, "test", memory.NewGoAllocator())
 	require.NoError(t, err)
+	defer table.Release()
 
-	records := []struct {
-		Timestamp  int64
-		Stacktrace string
-		Value      int64
-	}{
+	records := []Record{
 		{
 			Timestamp:  1 * int64(time.Second),
 			Stacktrace: "stack1",
@@ -324,11 +293,8 @@ func TestDurationAggregation(t *testing.T) {
 			Value:      3,
 		},
 	}
-
-	for _, record := range records {
-		_, err := table.Write(context.Background(), record)
-		require.NoError(t, err)
-	}
+	err = table.Write(context.Background(), records...)
+	require.NoError(t, err)
 
 	engine := query.NewEngine(memory.DefaultAllocator, db.TableProvider())
 
diff --git a/db_test.go b/db_test.go
index 703f7c3..2940905 100644
--- a/db_test.go
+++ b/db_test.go
@@ -1062,37 +1062,12 @@ func Test_DB_Block_Optimization(t *testing.T) {
 
 func Test_DB_TableWrite_FlatSchema(t *testing.T) {
 	ctx := context.Background()
-	flatDefinition := &schemapb.Schema{
-		Name: "test",
-		Columns: []*schemapb.Column{{
-			Name: "example_type",
-			StorageLayout: &schemapb.StorageLayout{
-				Type:     schemapb.StorageLayout_TYPE_STRING,
-				Encoding: schemapb.StorageLayout_ENCODING_RLE_DICTIONARY,
-			},
-			Dynamic: false,
-		}, {
-			Name: "timestamp",
-			StorageLayout: &schemapb.StorageLayout{
-				Type: schemapb.StorageLayout_TYPE_INT64,
-			},
-			Dynamic: false,
-		}, {
-			Name: "value",
-			StorageLayout: &schemapb.StorageLayout{
-				Type: schemapb.StorageLayout_TYPE_INT64,
-			},
-			Dynamic: false,
-		}},
-		SortingColumns: []*schemapb.SortingColumn{{
-			Name:      "example_type",
-			Direction: schemapb.SortingColumn_DIRECTION_ASCENDING,
-		}, {
-			Name:      "timestamp",
-			Direction: schemapb.SortingColumn_DIRECTION_ASCENDING,
-		}},
+
+	type Flat struct {
+		ExampleType string `frostdb:",rle_dict,asc(0)"`
+		Timestamp   int64  `frostdb:",asc(1)"`
+		Value       int64
 	}
-	config := NewTableConfig(flatDefinition)
 
 	c, err := New(WithLogger(newTestLogger(t)))
 	require.NoError(t, err)
@@ -1101,20 +1076,15 @@ func Test_DB_TableWrite_FlatSchema(t *testing.T) {
 	db, err := c.DB(ctx, "flatschema")
 	require.NoError(t, err)
 
-	table, err := db.Table("test", config)
+	table, err := NewGenericTable[Flat](db, "test", memory.NewGoAllocator())
 	require.NoError(t, err)
+	defer table.Release()
 
-	s := struct {
-		ExampleType string
-		Timestamp   int64
-		Value       int64
-	}{
+	err = table.Write(ctx, Flat{
 		ExampleType: "hello-world",
 		Timestamp:   7,
 		Value:       8,
-	}
-
-	_, err = table.Write(ctx, s)
+	})
 	require.NoError(t, err)
 
 	engine := query.NewEngine(
@@ -1132,9 +1102,6 @@ func Test_DB_TableWrite_FlatSchema(t *testing.T) {
 
 func Test_DB_TableWrite_DynamicSchema(t *testing.T) {
 	ctx := context.Background()
-	config := NewTableConfig(
-		dynparquet.SampleDefinition(),
-	)
 
 	c, err := New(WithLogger(newTestLogger(t)))
 	require.NoError(t, err)
@@ -1143,8 +1110,9 @@ func Test_DB_TableWrite_DynamicSchema(t *testing.T) {
 	db, err := c.DB(ctx, "sampleschema")
 	require.NoError(t, err)
 
-	table, err := db.Table("test", config)
+	table, err := NewGenericTable[dynparquet.Sample](db, "test", memory.NewGoAllocator())
 	require.NoError(t, err)
+	defer table.Release()
 
 	now := time.Now()
 	ts := now.UnixMilli()
@@ -1191,7 +1159,7 @@ func Test_DB_TableWrite_DynamicSchema(t *testing.T) {
 		},
 	}
 
-	_, err = table.Write(ctx, samples[0], samples[1], samples[2])
+	err = table.Write(ctx, samples[0], samples[1], samples[2])
 	require.NoError(t, err)
 
 	engine := query.NewEngine(
diff --git a/snapshot_test.go b/snapshot_test.go
index 2928182..465c92c 100644
--- a/snapshot_test.go
+++ b/snapshot_test.go
@@ -16,7 +16,6 @@ import (
 	"golang.org/x/sync/errgroup"
 
 	"github.com/polarsignals/frostdb/dynparquet"
-	schemapb "github.com/polarsignals/frostdb/gen/proto/go/frostdb/schema/v1alpha1"
 	"github.com/polarsignals/frostdb/query"
 	"github.com/polarsignals/frostdb/query/logicalplan"
 )
@@ -365,28 +364,17 @@ func TestSnapshotIsTakenOnUncompressedInserts(t *testing.T) {
 	db, err := c.DB(ctx, dbAndTableName)
 	require.NoError(t, err)
 
-	// Create a schema that is highly compressible.
-	schema := &schemapb.Schema{
-		Name: dbAndTableName,
-		Columns: []*schemapb.Column{
-			{
-				Name: "bytes",
-				StorageLayout: &schemapb.StorageLayout{
-					Type:     schemapb.StorageLayout_TYPE_STRING,
-					Encoding: schemapb.StorageLayout_ENCODING_RLE_DICTIONARY,
-				},
-			},
-		},
+	type model struct {
+		Bytes string `frostdb:",rle_dict"`
 	}
-
-	table, err := db.Table(dbAndTableName, NewTableConfig(schema))
+	table, err := NewGenericTable[model](
+		db, dbAndTableName, memory.NewGoAllocator(),
+	)
 	require.NoError(t, err)
+	defer table.Release()
 
-	type model struct {
-		Bytes string
-	}
 	for i := 0; i < numInserts; i++ {
-		_, err = table.Write(ctx, model{Bytes: "test"})
+		err = table.Write(ctx, model{Bytes: "test"})
 		require.NoError(t, err)
 	}
 	activeBlock := table.ActiveBlock()
@@ -403,7 +391,7 @@ func TestSnapshotIsTakenOnUncompressedInserts(t *testing.T) {
 	// These writes should now trigger a snapshot even though the active block
 	// size is much lower than the uncompressed insert size.
 	for i := 0; i < 2; i++ {
-		_, err = table.Write(ctx, model{Bytes: "test"})
+		err = table.Write(ctx, model{Bytes: "test"})
 		require.NoError(t, err)
 	}
 
diff --git a/table_test.go b/table_test.go
index 7c6da9d..cce1dc9 100644
--- a/table_test.go
+++ b/table_test.go
@@ -1054,40 +1054,22 @@ func Test_Table_DynamicColumnMap(t *testing.T) {
 	db, err := c.DB(context.Background(), "test")
 	require.NoError(t, err)
 
-	schema := &schemapb.Schema{
-		Name: "dynamic_col_map",
-		Columns: []*schemapb.Column{
-			{
-				Name: "name",
-				StorageLayout: &schemapb.StorageLayout{
-					Type: schemapb.StorageLayout_TYPE_STRING,
-				},
-			},
-			{
-				Name: "attributes",
-				StorageLayout: &schemapb.StorageLayout{
-					Type: schemapb.StorageLayout_TYPE_STRING,
-				},
-				Dynamic: true,
-			},
-		},
+	type ColMap struct {
+		Name       string
+		Attributes map[string]string
 	}
-
-	config := NewTableConfig(schema)
-	table, err := db.Table("test", config)
+	table, err := NewGenericTable[ColMap](
+		db, "test", memory.NewGoAllocator(),
+	)
 	require.NoError(t, err)
+	defer table.Release()
 
-	record := struct {
-		Name       string
-		Attributes map[string]string
-	}{
+	err = table.Write(context.Background(), ColMap{
 		Name: "albert",
 		Attributes: map[string]string{
 			"age": "9999",
 		},
-	}
-
-	_, err = table.Write(context.Background(), record)
+	})
 	require.NoError(t, err)
 }
 
@@ -1101,37 +1083,20 @@ func Test_Table_DynamicColumnNotDefined(t *testing.T) {
 	db, err := c.DB(context.Background(), "test")
 	require.NoError(t, err)
 
-	schema := &schemapb.Schema{
-		Name: "dynamic_col_not_defined",
-		Columns: []*schemapb.Column{
-			{
-				Name: "name",
-				StorageLayout: &schemapb.StorageLayout{
-					Type: schemapb.StorageLayout_TYPE_STRING,
-				},
-			},
-			{
-				Name: "attributes",
-				StorageLayout: &schemapb.StorageLayout{
-					Type: schemapb.StorageLayout_TYPE_STRING,
-				},
-				Dynamic: true,
-			},
-		},
+	type ColMap struct {
+		Name       string
+		Attributes map[string]string
 	}
 
-	config := NewTableConfig(schema)
-	table, err := db.Table("test", config)
+	table, err := NewGenericTable[ColMap](
+		db, "test", memory.NewGoAllocator(),
+	)
 	require.NoError(t, err)
+	defer table.Release()
 
-	// write a record where the dynamic column is not defined
-	record := struct {
-		Name string
-	}{
+	err = table.Write(context.Background(), ColMap{
 		Name: "albert",
-	}
-
-	_, err = table.Write(context.Background(), record)
+	})
 	require.NoError(t, err)
 }
 
@@ -1146,34 +1111,19 @@ func TestTableUniquePrimaryIndex(t *testing.T) {
 	db, err := c.DB(context.Background(), "test")
 	require.NoError(t, err)
 
-	schema := &schemapb.Schema{
-		Name: "unique_primary_index",
-		Columns: []*schemapb.Column{
-			{
-				Name: "name",
-				StorageLayout: &schemapb.StorageLayout{
-					Type: schemapb.StorageLayout_TYPE_STRING,
-				},
-			},
-		},
-		SortingColumns: []*schemapb.SortingColumn{
-			{
-				Name:      "name",
-				Direction: schemapb.SortingColumn_DIRECTION_ASCENDING,
-			},
-		},
-		UniquePrimaryIndex: true,
+	type Record struct {
+		Name string `frostdb:",asc"`
 	}
-
 	const tableName = "test"
-	table, err := db.Table(tableName, NewTableConfig(schema))
+	table, err := NewGenericTable[Record](
+		db, tableName, memory.NewGoAllocator(), WithUniquePrimaryIndex(true),
+	)
 	require.NoError(t, err)
+	defer table.Release()
 
 	const numRecords = 9
 	for i := 0; i < numRecords; i++ {
-		_, err = table.Write(context.Background(), struct {
-			Name string
-		}{
+		err = table.Write(context.Background(), Record{
 			Name: "duplicate",
 		})
 		require.NoError(t, err)
@@ -1191,11 +1141,11 @@ func TestTableUniquePrimaryIndex(t *testing.T) {
 	require.Equal(t, numRecords, rowsRead)
 
 	// Trigger compaction with a new record.
-	_, err = table.Write(context.Background(), struct {
-		Name string
-	}{
+	err = table.Write(context.Background(), Record{
 		Name: "duplicate",
 	})
+	require.NoError(t, err)
+
 	require.NoError(t, err)
 	require.NoError(t, table.ActiveBlock().EnsureCompaction())
 
@@ -1211,7 +1161,6 @@ func TestTableUniquePrimaryIndex(t *testing.T) {
 }
 
 func TestTable_write_ptr_struct(t *testing.T) {
-	// This is taken from simple example and modified to use pointers
 	columnstore, err := New()
 	require.Nil(t, err)
 	defer columnstore.Close()
@@ -1219,59 +1168,14 @@ func TestTable_write_ptr_struct(t *testing.T) {
 	database, err := columnstore.DB(context.Background(), "simple_db")
 	require.Nil(t, err)
 
-	schema := &schemapb.Schema{
-		Name: "simple_schema",
-		Columns: []*schemapb.Column{{
-			Name: "names",
-			StorageLayout: &schemapb.StorageLayout{
-				Type:     schemapb.StorageLayout_TYPE_STRING,
-				Encoding: schemapb.StorageLayout_ENCODING_RLE_DICTIONARY,
-				Nullable: true,
-			},
-			Dynamic: true,
-		}, {
-			Name: "value",
-			StorageLayout: &schemapb.StorageLayout{
-				Type: schemapb.StorageLayout_TYPE_INT64,
-			},
-			Dynamic: false,
-		}},
-		SortingColumns: []*schemapb.SortingColumn{{
-			Name:      "names",
-			Direction: schemapb.SortingColumn_DIRECTION_ASCENDING,
-		}},
-	}
-
-	table, err := database.Table(
-		"simple_table",
-		NewTableConfig(schema),
+	table, err := NewGenericTable[*dynparquet.Sample](
+		database, "simple_table", memory.NewGoAllocator(),
 	)
 	require.Nil(t, err)
+	defer table.Release()
 
-	type FirstLast struct {
-		FirstName string
-		Surname   string
-	}
-
-	type Simple struct {
-		Names *FirstLast
-		Value int64
-	}
-	frederic := &Simple{
-		Names: &FirstLast{
-			FirstName: "Frederic",
-			Surname:   "Brancz",
-		},
-		Value: 100,
-	}
-
-	thor := &Simple{
-		Names: &FirstLast{
-			FirstName: "Thor",
-			Surname:   "Hansen",
-		},
-		Value: 99,
-	}
-	_, err = table.Write(context.Background(), frederic, thor)
+	err = table.Write(context.Background(), &dynparquet.Sample{
+		ExampleType: "ptr",
+	})
 	require.Nil(t, err)
 }