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
```


Discussion:
@thorfour `(*Table)Write`  creates parquet rows then converts them to `arrow.Record` since we have merged  the new api that builds directly to `arrow.Record` I think we should deprecate this api.

I confirmed `parca` doesn't use `(*Table)Write` I think its better to invest more time into massaging `(*Table)InsertRecord`.  Now we can document and iron out bits about tables and how to correctly use them.
Agreed, we should just delete that API entirely now. 

Patch:
diff --git a/dynparquet/record_builder.go b/dynparquet/record_builder.go
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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)
 }