This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/ext_db_schema_test.go
Jeffrey Smith 190905b3e6
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-sqlite (push) Successful in 2m59s
CI/CD / test-go-pg (push) Successful in 2m58s
CI/CD / build-and-deploy (push) Successful in 1m14s
Feat v0.8.3 vector column (#70)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-03 09:41:32 +00:00

425 lines
13 KiB
Go

package handlers
import (
"context"
"database/sql"
"strings"
"testing"
_ "modernc.org/sqlite"
"armature/store"
)
// ── mock ExtDataStore ────────────────────────────────────────────────────────
// memExtDataStore is an in-memory ExtDataStore for testing.
type memExtDataStore struct {
tables map[string][]string // packageID → []logicalName
}
func newMemExtDataStore() *memExtDataStore {
return &memExtDataStore{tables: map[string][]string{}}
}
func (m *memExtDataStore) RegisterTable(_ context.Context, packageID, tableName string) error {
m.tables[packageID] = append(m.tables[packageID], tableName)
return nil
}
func (m *memExtDataStore) ListTables(_ context.Context, packageID string) ([]string, error) {
return m.tables[packageID], nil
}
func (m *memExtDataStore) DeletePackageTables(_ context.Context, packageID string) error {
delete(m.tables, packageID)
return nil
}
// ── helpers ──────────────────────────────────────────────────────────────────
func newSchemaTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func storesWithExtData(m *memExtDataStore) store.Stores {
return store.Stores{ExtData: m}
}
// ── ParseDBTables ────────────────────────────────────────────────────────────
func TestParseDBTables_Valid(t *testing.T) {
manifest := map[string]any{
"db_tables": map[string]any{
"logs": map[string]any{
"columns": map[string]any{
"message": "text",
"count": "int",
},
"indexes": []any{[]any{"message"}},
},
},
}
tables, ok := ParseDBTables(manifest)
if !ok {
t.Fatal("expected ok=true")
}
if _, has := tables["logs"]; !has {
t.Fatal("expected 'logs' in parsed tables")
}
if tables["logs"].Columns["message"] != "text" {
t.Errorf("expected column message=text, got %q", tables["logs"].Columns["message"])
}
if len(tables["logs"].Indexes) != 1 {
t.Errorf("expected 1 index, got %d", len(tables["logs"].Indexes))
}
}
func TestParseDBTables_Missing(t *testing.T) {
_, ok := ParseDBTables(map[string]any{"title": "hi"})
if ok {
t.Fatal("expected ok=false when db_tables absent")
}
}
func TestParseDBTables_InvalidTableName(t *testing.T) {
manifest := map[string]any{
"db_tables": map[string]any{
"bad name": map[string]any{},
"good_name": map[string]any{"columns": map[string]any{"x": "text"}},
},
}
tables, ok := ParseDBTables(manifest)
if !ok {
t.Fatal("expected ok=true (good_name is valid)")
}
if _, has := tables["bad name"]; has {
t.Error("invalid table name should be skipped")
}
if _, has := tables["good_name"]; !has {
t.Error("valid table name should be present")
}
}
func TestParseDBTables_InvalidColumnName(t *testing.T) {
manifest := map[string]any{
"db_tables": map[string]any{
"events": map[string]any{
"columns": map[string]any{
"bad col": "text",
"ok_col": "text",
},
},
},
}
tables, ok := ParseDBTables(manifest)
if !ok {
t.Fatal("expected ok=true")
}
if _, has := tables["events"].Columns["bad col"]; has {
t.Error("invalid column name should be skipped")
}
if _, has := tables["events"].Columns["ok_col"]; !has {
t.Error("valid column name should be present")
}
}
// ── mapColType ───────────────────────────────────────────────────────────────
func TestMapColType_SQLite(t *testing.T) {
cases := []struct{ in, want string }{
{"text", "TEXT"},
{"int", "INTEGER"},
{"integer", "INTEGER"},
{"real", "REAL"},
{"float", "REAL"},
{"bool", "INTEGER"},
{"boolean", "INTEGER"},
{"timestamp", "TEXT"},
{"unknown", "TEXT"},
}
for _, c := range cases {
got := mapColType(c.in, false, false)
if got != c.want {
t.Errorf("mapColType(%q, sqlite) = %q, want %q", c.in, got, c.want)
}
}
}
func TestMapColType_Postgres(t *testing.T) {
if mapColType("bool", true, false) != "BOOLEAN" {
t.Error("expected BOOLEAN for bool in postgres")
}
if mapColType("timestamp", true, false) != "TIMESTAMPTZ" {
t.Error("expected TIMESTAMPTZ for timestamp in postgres")
}
if mapColType("int", true, false) != "INTEGER" {
t.Error("expected INTEGER for int in postgres")
}
}
// ── extPhysicalTable ─────────────────────────────────────────────────────────
func TestExtPhysicalTable(t *testing.T) {
got := extPhysicalTable("my-ext", "logs")
if got != "ext_my_ext_logs" {
t.Errorf("got %q, want ext_my_ext_logs", got)
}
got = extPhysicalTable("simple", "events")
if got != "ext_simple_events" {
t.Errorf("got %q, want ext_simple_events", got)
}
}
// ── CreateExtTables ──────────────────────────────────────────────────────────
func TestCreateExtTables_CreatesTable(t *testing.T) {
db := newSchemaTestDB(t)
m := newMemExtDataStore()
ctx := context.Background()
tables := map[string]TableDef{
"logs": {
Columns: map[string]string{"message": "text", "count": "int"},
},
}
if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "test-pkg", tables, false); err != nil {
t.Fatalf("CreateExtTables: %v", err)
}
// Verify table exists by inserting a row.
_, err := db.ExecContext(ctx,
`INSERT INTO ext_test_pkg_logs (id, message, count) VALUES ('1', 'hello', 42)`)
if err != nil {
t.Errorf("table not created or wrong schema: %v", err)
}
}
func TestCreateExtTables_WithIndexes(t *testing.T) {
db := newSchemaTestDB(t)
m := newMemExtDataStore()
ctx := context.Background()
tables := map[string]TableDef{
"events": {
Columns: map[string]string{"user_id": "text", "kind": "text"},
Indexes: [][]string{{"user_id"}, {"user_id", "kind"}},
},
}
if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "idx-pkg", tables, false); err != nil {
t.Fatalf("CreateExtTables: %v", err)
}
// Verify indexes exist via sqlite_master.
rows, err := db.QueryContext(ctx,
`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='ext_idx_pkg_events'`)
if err != nil {
t.Fatalf("query indexes: %v", err)
}
defer rows.Close()
var idxNames []string
for rows.Next() {
var name string
rows.Scan(&name)
idxNames = append(idxNames, name)
}
if len(idxNames) < 2 {
t.Errorf("expected at least 2 indexes, got %d: %v", len(idxNames), idxNames)
}
}
func TestCreateExtTables_CatalogRegistration(t *testing.T) {
db := newSchemaTestDB(t)
m := newMemExtDataStore()
ctx := context.Background()
tables := map[string]TableDef{
"logs": {Columns: map[string]string{"msg": "text"}},
"errors": {Columns: map[string]string{"detail": "text"}},
}
if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "cat-pkg", tables, false); err != nil {
t.Fatalf("CreateExtTables: %v", err)
}
registered := m.tables["cat-pkg"]
if len(registered) != 2 {
t.Errorf("expected 2 registered tables, got %d: %v", len(registered), registered)
}
}
func TestCreateExtTables_NilDBIsNoop(t *testing.T) {
m := newMemExtDataStore()
ctx := context.Background()
// Should not panic or error.
err := CreateExtTables(ctx, nil, false, storesWithExtData(m), "pkg", map[string]TableDef{
"t": {Columns: map[string]string{"x": "text"}},
}, false)
if err != nil {
t.Errorf("expected nil error for nil db, got: %v", err)
}
}
// ── DropExtTables ─────────────────────────────────────────────────────────────
func TestDropExtTables_DropsAndClearsCatalog(t *testing.T) {
db := newSchemaTestDB(t)
m := newMemExtDataStore()
ctx := context.Background()
// Create table first.
tables := map[string]TableDef{
"items": {Columns: map[string]string{"name": "text"}},
}
if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "drop-pkg", tables, false); err != nil {
t.Fatalf("CreateExtTables: %v", err)
}
// Drop.
if err := DropExtTables(ctx, db, storesWithExtData(m), "drop-pkg"); err != nil {
t.Fatalf("DropExtTables: %v", err)
}
// Table should be gone.
var count int
db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='ext_drop_pkg_items'`,
).Scan(&count)
if count != 0 {
t.Error("expected table to be dropped, still present")
}
// Catalog should be empty.
if len(m.tables["drop-pkg"]) != 0 {
t.Errorf("expected empty catalog, got: %v", m.tables["drop-pkg"])
}
}
func TestDropExtTables_MultipleTables(t *testing.T) {
db := newSchemaTestDB(t)
m := newMemExtDataStore()
ctx := context.Background()
tables := map[string]TableDef{
"alpha": {Columns: map[string]string{"v": "text"}},
"beta": {Columns: map[string]string{"v": "text"}},
}
CreateExtTables(ctx, db, false, storesWithExtData(m), "multi-pkg", tables, false)
DropExtTables(ctx, db, storesWithExtData(m), "multi-pkg")
for _, name := range []string{"ext_multi_pkg_alpha", "ext_multi_pkg_beta"} {
var count int
db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name,
).Scan(&count)
if count != 0 {
t.Errorf("expected %s to be dropped", name)
}
}
}
func TestCreateExtTables_DDLContainsAutoColumns(t *testing.T) {
// Verify that the DDL-generated table has 'id' and 'created_at' columns
// even when no columns are declared in the manifest.
db := newSchemaTestDB(t)
m := newMemExtDataStore()
ctx := context.Background()
tables := map[string]TableDef{"empty": {Columns: map[string]string{}}}
if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "auto-pkg", tables, false); err != nil {
t.Fatalf("CreateExtTables: %v", err)
}
// Insert with just id — should work (created_at has default).
_, err := db.ExecContext(ctx, `INSERT INTO ext_auto_pkg_empty (id) VALUES ('x')`)
if err != nil {
t.Errorf("expected auto-columns (id, created_at) to exist: %v", err)
}
// Verify created_at was populated.
var ca string
db.QueryRowContext(ctx, `SELECT created_at FROM ext_auto_pkg_empty WHERE id='x'`).Scan(&ca)
if strings.TrimSpace(ca) == "" {
t.Error("expected created_at to be populated by default")
}
}
// ── parseVectorDim ──────────────────────────────────────────────────────────
func TestParseVectorDim(t *testing.T) {
cases := []struct {
input string
dim int
ok bool
}{
{"vector(384)", 384, true},
{"vector(1)", 1, true},
{"vector(4096)", 4096, true},
{"Vector(128)", 128, true}, // case insensitive
{"vector(0)", 0, false},
{"vector(5000)", 0, false}, // exceeds 4096
{"vector()", 0, false},
{"vector(abc)", 0, false},
{"text", 0, false},
{"vector", 0, false},
}
for _, c := range cases {
dim, ok := parseVectorDim(c.input)
if ok != c.ok || dim != c.dim {
t.Errorf("parseVectorDim(%q) = (%d, %v), want (%d, %v)", c.input, dim, ok, c.dim, c.ok)
}
}
}
// ── mapColType vector ───────────────────────────────────────────────────────
func TestMapColType_VectorSQLite(t *testing.T) {
got := mapColType("vector(384)", false, false)
if got != "TEXT" {
t.Errorf("vector on SQLite: got %q, want TEXT", got)
}
}
func TestMapColType_VectorPgNoPgvector(t *testing.T) {
got := mapColType("vector(384)", true, false)
if got != "JSONB" {
t.Errorf("vector on PG without pgvector: got %q, want JSONB", got)
}
}
func TestMapColType_VectorPgvector(t *testing.T) {
got := mapColType("vector(384)", true, true)
if got != "vector(384)" {
t.Errorf("vector on PG with pgvector: got %q, want vector(384)", got)
}
}
// ── CreateExtTables with vector column ──────────────────────────────────────
func TestCreateExtTables_VectorColumn(t *testing.T) {
db := newSchemaTestDB(t)
m := newMemExtDataStore()
ctx := context.Background()
tables := map[string]TableDef{
"docs": {
Columns: map[string]string{"title": "text", "embedding": "vector(384)"},
},
}
if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "vec-pkg", tables, false); err != nil {
t.Fatalf("CreateExtTables: %v", err)
}
// Verify table exists and embedding is TEXT (SQLite fallback).
_, err := db.ExecContext(ctx,
`INSERT INTO ext_vec_pkg_docs (id, title, embedding) VALUES ('1', 'test', '[0.1,0.2]')`)
if err != nil {
t.Errorf("table not created or wrong schema: %v", err)
}
}