merge: quick/fase2-models-sqlite — modelos Go, SQLite FTS5 y CRUD

This commit is contained in:
2026-03-28 02:04:34 +01:00
7 changed files with 733 additions and 0 deletions
+8
View File
@@ -58,6 +58,14 @@ Tipos algebraicos: `product` (struct, todos los campos presentes) o `sum` (union
- **Diseño del schema:** carpeta docs/
- **registry.db:** solo indice, regenerable con `fn index`
## Build
```bash
# Requiere CGO y tag fts5 para SQLite FTS5
CGO_ENABLED=1 go build -tags fts5 ./...
CGO_ENABLED=1 go test -tags fts5 ./...
```
## CLI (cmd/fn)
```bash
+2
View File
@@ -1,3 +1,5 @@
module fn-registry
go 1.22.2
require github.com/mattn/go-sqlite3 v1.14.37
+2
View File
@@ -0,0 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+153
View File
@@ -0,0 +1,153 @@
package registry
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
const schemaSQL = `
CREATE TABLE IF NOT EXISTS functions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('function','pipeline','component')),
lang TEXT NOT NULL,
domain TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '1.0.0',
purity TEXT NOT NULL CHECK(purity IN ('pure','impure')),
signature TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
uses_functions TEXT NOT NULL DEFAULT '[]',
uses_types TEXT NOT NULL DEFAULT '[]',
returns TEXT NOT NULL DEFAULT '[]',
returns_optional INTEGER NOT NULL DEFAULT 0,
error_type TEXT NOT NULL DEFAULT '',
imports TEXT NOT NULL DEFAULT '[]',
example TEXT NOT NULL DEFAULT '',
tested INTEGER NOT NULL DEFAULT 0,
tests TEXT NOT NULL DEFAULT '[]',
test_file_path TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
-- Component fields
props TEXT NOT NULL DEFAULT '[]',
emits TEXT NOT NULL DEFAULT '[]',
has_state INTEGER,
framework TEXT NOT NULL DEFAULT '',
variant TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
lang TEXT NOT NULL,
domain TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '1.0.0',
algebraic TEXT NOT NULL CHECK(algebraic IN ('product','sum')),
definition TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
uses_types TEXT NOT NULL DEFAULT '[]',
file_path TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS functions_fts USING fts5(
id,
name,
description,
tags,
signature,
domain,
content='functions',
content_rowid='rowid'
);
CREATE VIRTUAL TABLE IF NOT EXISTS types_fts USING fts5(
id,
name,
description,
tags,
domain,
content='types',
content_rowid='rowid'
);
-- Triggers to keep FTS in sync
CREATE TRIGGER IF NOT EXISTS functions_ai AFTER INSERT ON functions BEGIN
INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain);
END;
CREATE TRIGGER IF NOT EXISTS functions_ad AFTER DELETE ON functions BEGIN
INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain);
END;
CREATE TRIGGER IF NOT EXISTS functions_au AFTER UPDATE ON functions BEGIN
INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain);
INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain);
END;
CREATE TRIGGER IF NOT EXISTS types_ai AFTER INSERT ON types BEGIN
INSERT INTO types_fts(rowid, id, name, description, tags, domain)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
END;
CREATE TRIGGER IF NOT EXISTS types_ad AFTER DELETE ON types BEGIN
INSERT INTO types_fts(types_fts, rowid, id, name, description, tags, domain)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
END;
CREATE TRIGGER IF NOT EXISTS types_au AFTER UPDATE ON types BEGIN
INSERT INTO types_fts(types_fts, rowid, id, name, description, tags, domain)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
INSERT INTO types_fts(rowid, id, name, description, tags, domain)
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
END;
`
// DB wraps a SQLite connection for the registry.
type DB struct {
conn *sql.DB
path string
}
// Open opens or creates the registry database at the given path.
func Open(path string) (*DB, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("creating db directory: %w", err)
}
conn, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
if _, err := conn.Exec(schemaSQL); err != nil {
conn.Close()
return nil, fmt.Errorf("applying schema: %w", err)
}
return &DB{conn: conn, path: path}, nil
}
// Close closes the database connection.
func (db *DB) Close() error {
return db.conn.Close()
}
// Drop removes the database file. Used by `fn index` to regenerate.
func (db *DB) Drop() error {
db.Close()
return os.Remove(db.path)
}
+93
View File
@@ -0,0 +1,93 @@
package registry
import "time"
// Kind classifies a registry entry.
type Kind string
const (
KindFunction Kind = "function"
KindPipeline Kind = "pipeline"
KindComponent Kind = "component"
)
// Purity indicates whether a function has side effects.
type Purity string
const (
PurityPure Purity = "pure"
PurityImpure Purity = "impure"
)
// Algebraic classifies a type.
type Algebraic string
const (
AlgebraicProduct Algebraic = "product"
AlgebraicSum Algebraic = "sum"
)
// Function represents an entry in the functions table.
// Covers kind: function, pipeline, and component.
type Function struct {
ID string `json:"id"`
Name string `json:"name"`
Kind Kind `json:"kind"`
Lang string `json:"lang"`
Domain string `json:"domain"`
Version string `json:"version"`
Purity Purity `json:"purity"`
Signature string `json:"signature"`
Description string `json:"description"`
Tags []string `json:"tags"`
UsesFunctions []string `json:"uses_functions"`
UsesTypes []string `json:"uses_types"`
Returns []string `json:"returns"`
ReturnsOptional bool `json:"returns_optional"`
ErrorType string `json:"error_type"`
Imports []string `json:"imports"`
Example string `json:"example"`
Tested bool `json:"tested"`
Tests []string `json:"tests"`
TestFilePath string `json:"test_file_path"`
FilePath string `json:"file_path"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Component-only fields (kind: component)
Props []PropDef `json:"props,omitempty"`
Emits []string `json:"emits,omitempty"`
HasState *bool `json:"has_state,omitempty"`
Framework string `json:"framework,omitempty"`
Variant []string `json:"variant,omitempty"`
}
// PropDef describes a component prop.
type PropDef struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
Description string `json:"description"`
}
// Type represents an entry in the types table.
type Type struct {
ID string `json:"id"`
Name string `json:"name"`
Lang string `json:"lang"`
Domain string `json:"domain"`
Version string `json:"version"`
Algebraic Algebraic `json:"algebraic"`
Definition string `json:"definition"`
Description string `json:"description"`
Tags []string `json:"tags"`
UsesTypes []string `json:"uses_types"`
FilePath string `json:"file_path"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// GenerateID builds the canonical ID: {name}_{lang}_{domain}
func GenerateID(name, lang, domain string) string {
return name + "_" + lang + "_" + domain
}
+307
View File
@@ -0,0 +1,307 @@
package registry
import (
"encoding/json"
"fmt"
"strings"
"time"
)
func marshalStrings(ss []string) string {
if ss == nil {
ss = []string{}
}
b, _ := json.Marshal(ss)
return string(b)
}
func unmarshalStrings(s string) []string {
var out []string
json.Unmarshal([]byte(s), &out)
if out == nil {
out = []string{}
}
return out
}
func marshalProps(ps []PropDef) string {
if ps == nil {
ps = []PropDef{}
}
b, _ := json.Marshal(ps)
return string(b)
}
func unmarshalProps(s string) []PropDef {
var out []PropDef
json.Unmarshal([]byte(s), &out)
return out
}
// InsertFunction inserts or replaces a function entry.
func (db *DB) InsertFunction(f *Function) error {
now := time.Now().UTC().Format(time.RFC3339)
if f.CreatedAt.IsZero() {
f.CreatedAt = time.Now().UTC()
}
f.UpdatedAt = time.Now().UTC()
if f.ID == "" {
f.ID = GenerateID(f.Name, f.Lang, f.Domain)
}
var hasState *int
if f.HasState != nil {
v := 0
if *f.HasState {
v = 1
}
hasState = &v
}
_, err := db.conn.Exec(`
INSERT OR REPLACE INTO functions (
id, name, kind, lang, domain, version, purity, signature,
description, tags, uses_functions, uses_types, returns,
returns_optional, error_type, imports, example, tested,
tests, test_file_path, file_path, created_at, updated_at,
props, emits, has_state, framework, variant
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?
)`,
f.ID, f.Name, string(f.Kind), f.Lang, f.Domain, f.Version, string(f.Purity), f.Signature,
f.Description, marshalStrings(f.Tags), marshalStrings(f.UsesFunctions), marshalStrings(f.UsesTypes), marshalStrings(f.Returns),
f.ReturnsOptional, f.ErrorType, marshalStrings(f.Imports), f.Example, f.Tested,
marshalStrings(f.Tests), f.TestFilePath, f.FilePath, f.CreatedAt.Format(time.RFC3339), now,
marshalProps(f.Props), marshalStrings(f.Emits), hasState, f.Framework, marshalStrings(f.Variant),
)
return err
}
// InsertType inserts or replaces a type entry.
func (db *DB) InsertType(t *Type) error {
now := time.Now().UTC().Format(time.RFC3339)
if t.CreatedAt.IsZero() {
t.CreatedAt = time.Now().UTC()
}
t.UpdatedAt = time.Now().UTC()
if t.ID == "" {
t.ID = GenerateID(t.Name, t.Lang, t.Domain)
}
_, err := db.conn.Exec(`
INSERT OR REPLACE INTO types (
id, name, lang, domain, version, algebraic,
definition, description, tags, uses_types,
file_path, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Lang, t.Domain, t.Version, string(t.Algebraic),
t.Definition, t.Description, marshalStrings(t.Tags), marshalStrings(t.UsesTypes),
t.FilePath, t.CreatedAt.Format(time.RFC3339), now,
)
return err
}
// SearchFunctions performs FTS search on functions with optional filters.
func (db *DB) SearchFunctions(query string, kind Kind, purity Purity, lang, domain string) ([]Function, error) {
where := []string{}
args := []any{}
if query != "" {
where = append(where, "f.id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH ?)")
args = append(args, query)
}
if kind != "" {
where = append(where, "f.kind = ?")
args = append(args, string(kind))
}
if purity != "" {
where = append(where, "f.purity = ?")
args = append(args, string(purity))
}
if lang != "" {
where = append(where, "f.lang = ?")
args = append(args, lang)
}
if domain != "" {
where = append(where, "f.domain = ?")
args = append(args, domain)
}
sql := "SELECT * FROM functions f"
if len(where) > 0 {
sql += " WHERE " + strings.Join(where, " AND ")
}
sql += " ORDER BY f.name"
rows, err := db.conn.Query(sql, args...)
if err != nil {
return nil, fmt.Errorf("search functions: %w", err)
}
defer rows.Close()
return scanFunctions(rows)
}
// SearchTypes performs FTS search on types with optional filters.
func (db *DB) SearchTypes(query string, lang, domain string) ([]Type, error) {
where := []string{}
args := []any{}
if query != "" {
where = append(where, "t.id IN (SELECT id FROM types_fts WHERE types_fts MATCH ?)")
args = append(args, query)
}
if lang != "" {
where = append(where, "t.lang = ?")
args = append(args, lang)
}
if domain != "" {
where = append(where, "t.domain = ?")
args = append(args, domain)
}
sql := "SELECT * FROM types t"
if len(where) > 0 {
sql += " WHERE " + strings.Join(where, " AND ")
}
sql += " ORDER BY t.name"
rows, err := db.conn.Query(sql, args...)
if err != nil {
return nil, fmt.Errorf("search types: %w", err)
}
defer rows.Close()
return scanTypes(rows)
}
// GetFunction returns a single function by ID.
func (db *DB) GetFunction(id string) (*Function, error) {
rows, err := db.conn.Query("SELECT * FROM functions WHERE id = ?", id)
if err != nil {
return nil, err
}
defer rows.Close()
fns, err := scanFunctions(rows)
if err != nil {
return nil, err
}
if len(fns) == 0 {
return nil, fmt.Errorf("function %q not found", id)
}
return &fns[0], nil
}
// GetType returns a single type by ID.
func (db *DB) GetType(id string) (*Type, error) {
rows, err := db.conn.Query("SELECT * FROM types WHERE id = ?", id)
if err != nil {
return nil, err
}
defer rows.Close()
ts, err := scanTypes(rows)
if err != nil {
return nil, err
}
if len(ts) == 0 {
return nil, fmt.Errorf("type %q not found", id)
}
return &ts[0], nil
}
// DeleteFunction removes a function by ID.
func (db *DB) DeleteFunction(id string) error {
_, err := db.conn.Exec("DELETE FROM functions WHERE id = ?", id)
return err
}
// DeleteType removes a type by ID.
func (db *DB) DeleteType(id string) error {
_, err := db.conn.Exec("DELETE FROM types WHERE id = ?", id)
return err
}
// Purge deletes all data from both tables. Used before re-indexing.
func (db *DB) Purge() error {
if _, err := db.conn.Exec("DELETE FROM functions"); err != nil {
return err
}
_, err := db.conn.Exec("DELETE FROM types")
return err
}
func scanFunctions(rows interface{ Next() bool; Scan(...any) error }) ([]Function, error) {
var result []Function
for rows.Next() {
var f Function
var tagsJSON, usesFnJSON, usesTypJSON, returnsJSON, importsJSON, testsJSON string
var propsJSON, emitsJSON, variantJSON string
var hasState *int
var createdAt, updatedAt string
err := rows.Scan(
&f.ID, &f.Name, &f.Kind, &f.Lang, &f.Domain, &f.Version, &f.Purity, &f.Signature,
&f.Description, &tagsJSON, &usesFnJSON, &usesTypJSON, &returnsJSON,
&f.ReturnsOptional, &f.ErrorType, &importsJSON, &f.Example, &f.Tested,
&testsJSON, &f.TestFilePath, &f.FilePath, &createdAt, &updatedAt,
&propsJSON, &emitsJSON, &hasState, &f.Framework, &variantJSON,
)
if err != nil {
return nil, fmt.Errorf("scanning function: %w", err)
}
f.Tags = unmarshalStrings(tagsJSON)
f.UsesFunctions = unmarshalStrings(usesFnJSON)
f.UsesTypes = unmarshalStrings(usesTypJSON)
f.Returns = unmarshalStrings(returnsJSON)
f.Imports = unmarshalStrings(importsJSON)
f.Tests = unmarshalStrings(testsJSON)
f.Props = unmarshalProps(propsJSON)
f.Emits = unmarshalStrings(emitsJSON)
f.Variant = unmarshalStrings(variantJSON)
f.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
f.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
if hasState != nil {
v := *hasState == 1
f.HasState = &v
}
result = append(result, f)
}
return result, nil
}
func scanTypes(rows interface{ Next() bool; Scan(...any) error }) ([]Type, error) {
var result []Type
for rows.Next() {
var t Type
var tagsJSON, usesTypJSON string
var createdAt, updatedAt string
err := rows.Scan(
&t.ID, &t.Name, &t.Lang, &t.Domain, &t.Version, &t.Algebraic,
&t.Definition, &t.Description, &tagsJSON, &usesTypJSON,
&t.FilePath, &createdAt, &updatedAt,
)
if err != nil {
return nil, fmt.Errorf("scanning type: %w", err)
}
t.Tags = unmarshalStrings(tagsJSON)
t.UsesTypes = unmarshalStrings(usesTypJSON)
t.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
result = append(result, t)
}
return result, nil
}
+168
View File
@@ -0,0 +1,168 @@
package registry
import (
"os"
"path/filepath"
"testing"
)
func tempDB(t *testing.T) *DB {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestInsertAndGetFunction(t *testing.T) {
db := tempDB(t)
f := &Function{
Name: "filter_slice",
Kind: KindFunction,
Lang: "go",
Domain: "core",
Version: "1.0.0",
Purity: PurityPure,
Signature: "func FilterSlice[T any](xs []T, pred func(T) bool) []T",
Description: "Filtra un slice con un predicado sin mutar el original",
Tags: []string{"slice", "functional", "generic"},
FilePath: "functions/core/filter_slice.go",
}
if err := db.InsertFunction(f); err != nil {
t.Fatal(err)
}
if f.ID != "filter_slice_go_core" {
t.Fatalf("expected ID filter_slice_go_core, got %s", f.ID)
}
got, err := db.GetFunction("filter_slice_go_core")
if err != nil {
t.Fatal(err)
}
if got.Name != "filter_slice" {
t.Errorf("name: got %q, want %q", got.Name, "filter_slice")
}
if got.Purity != PurityPure {
t.Errorf("purity: got %q, want %q", got.Purity, PurityPure)
}
if len(got.Tags) != 3 {
t.Errorf("tags: got %d, want 3", len(got.Tags))
}
}
func TestInsertAndGetType(t *testing.T) {
db := tempDB(t)
typ := &Type{
Name: "ohlcv",
Lang: "go",
Domain: "finance",
Version: "1.0.0",
Algebraic: AlgebraicProduct,
Definition: `type OHLCV struct {
Open, High, Low, Close, Volume float64
}`,
Description: "Vela de mercado con precios OHLCV",
Tags: []string{"finance", "market", "candle"},
FilePath: "types/finance/ohlcv.go",
}
if err := db.InsertType(typ); err != nil {
t.Fatal(err)
}
got, err := db.GetType("ohlcv_go_finance")
if err != nil {
t.Fatal(err)
}
if got.Algebraic != AlgebraicProduct {
t.Errorf("algebraic: got %q, want %q", got.Algebraic, AlgebraicProduct)
}
}
func TestSearchFunctionsFTS(t *testing.T) {
db := tempDB(t)
fns := []*Function{
{Name: "filter_slice", Kind: KindFunction, Lang: "go", Domain: "core", Purity: PurityPure, Description: "Filtra un slice con un predicado", Version: "1.0.0"},
{Name: "map_slice", Kind: KindFunction, Lang: "go", Domain: "core", Purity: PurityPure, Description: "Transforma cada elemento de un slice", Version: "1.0.0"},
{Name: "fetch_ticks", Kind: KindFunction, Lang: "go", Domain: "io", Purity: PurityImpure, Description: "Obtiene ticks de un exchange", ErrorType: "error_go_core", Version: "1.0.0"},
}
for _, f := range fns {
if err := db.InsertFunction(f); err != nil {
t.Fatal(err)
}
}
// FTS search
results, err := db.SearchFunctions("slice", "", "", "", "")
if err != nil {
t.Fatal(err)
}
if len(results) != 2 {
t.Errorf("FTS 'slice': got %d results, want 2", len(results))
}
// Filter by purity
results, err = db.SearchFunctions("", "", PurityImpure, "", "")
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Name != "fetch_ticks" {
t.Errorf("filter impure: unexpected results %v", results)
}
// Filter by domain
results, err = db.SearchFunctions("", "", "", "", "core")
if err != nil {
t.Fatal(err)
}
if len(results) != 2 {
t.Errorf("filter domain=core: got %d, want 2", len(results))
}
}
func TestPurge(t *testing.T) {
db := tempDB(t)
db.InsertFunction(&Function{Name: "test_fn", Kind: KindFunction, Lang: "go", Domain: "core", Purity: PurityPure, Description: "test", Version: "1.0.0"})
db.InsertType(&Type{Name: "test_type", Lang: "go", Domain: "core", Algebraic: AlgebraicProduct, Description: "test", Version: "1.0.0"})
if err := db.Purge(); err != nil {
t.Fatal(err)
}
fns, _ := db.SearchFunctions("", "", "", "", "")
if len(fns) != 0 {
t.Errorf("after purge: got %d functions, want 0", len(fns))
}
ts, _ := db.SearchTypes("", "", "")
if len(ts) != 0 {
t.Errorf("after purge: got %d types, want 0", len(ts))
}
}
func TestDrop(t *testing.T) {
path := filepath.Join(t.TempDir(), "drop.db")
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := db.Drop(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Error("db file should not exist after Drop")
}
}