From 654585256ceb4fae9d5860b3916dc7c0af2f8676 Mon Sep 17 00:00:00 2001 From: wispem-wantex Date: Fri, 4 Jul 2025 19:42:30 -0700 Subject: [PATCH] Initial commit: create table, column and schemaparse --- .gitea/workflows/CI.yaml | 21 ++++++ pkg/sqlgenerate/schema_parse.go | 66 ++++++++++++++++++ pkg/sqlgenerate/schema_parse_test.go | 50 ++++++++++++++ pkg/sqlgenerate/table.go | 40 +++++++++++ pkg/sqlgenerate/test_schemas/food.sql | 99 +++++++++++++++++++++++++++ 5 files changed, 276 insertions(+) create mode 100644 .gitea/workflows/CI.yaml create mode 100644 pkg/sqlgenerate/schema_parse.go create mode 100644 pkg/sqlgenerate/schema_parse_test.go create mode 100644 pkg/sqlgenerate/table.go create mode 100644 pkg/sqlgenerate/test_schemas/food.sql diff --git a/.gitea/workflows/CI.yaml b/.gitea/workflows/CI.yaml new file mode 100644 index 0000000..700d50d --- /dev/null +++ b/.gitea/workflows/CI.yaml @@ -0,0 +1,21 @@ +name: CI + +on: [push] + +jobs: + release-test: + container: + image: offline-twitter/go + volumes: + - woodpecker-gocache:/go-cache-volume + env: + GOPATH: /go-cache-volume + GOCACHE: /go-cache-volume/build-cache + steps: + - name: checkout + run: | + GOBIN=/usr/local/go/bin go install git.offline-twitter.com/offline-labs/gocheckout@0.0.1 + gocheckout + - name: test + run: | + go test ./... diff --git a/pkg/sqlgenerate/schema_parse.go b/pkg/sqlgenerate/schema_parse.go new file mode 100644 index 0000000..f824458 --- /dev/null +++ b/pkg/sqlgenerate/schema_parse.go @@ -0,0 +1,66 @@ +package sqlgenerate + +import ( + _ "embed" + "strings" + + "github.com/jinzhu/inflection" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func InitDB(sql_schema string) *sqlx.DB { + db := sqlx.MustOpen("sqlite3", ":memory:") + db.MustExec(sql_schema) + db.MustExec(` + create temporary view tables as + select l.schema, l.name, l.type, l.ncol, l.wr, l.strict + from sqlite_schema s + left join pragma_table_list l on s.name = l.name + where s.type = 'table'; + + create temporary view columns as + select tables.name as table_name, + table_info.name as column_name, + lower(table_info.type) as column_type, + "notnull", + dflt_value is not null as has_default_value, + ifnull(dflt_value, 0) dflt_value, + pk as is_primary_key, + fk."table" is not null as is_foreign_key, + ifnull(fk."table", '') as fk_target_table, + ifnull(fk."to", '') as fk_target_column + from tables + join pragma_table_info(tables.name) as table_info + left join pragma_foreign_key_list(tables.name) as fk on fk."from" = column_name; + `) + return db +} + +// SchemaFromDB takes a DB connection, checks its schema metadata tables, and returns a Schema. +func SchemaFromDB(db *sqlx.DB) Schema { + return ParseSchema(db) +} +func ParseSchema(db *sqlx.DB) Schema { + ret := Schema{} + + var table_list []string + err := db.Select(&table_list, `select name from tables`) + if err != nil { + panic(err) + } + + for _, table_name := range table_list { + tbl := Table{TableName: table_name} + tbl.TypeName = snakeToCamel(inflection.Singular(table_name)) + tbl.TypeIDName = tbl.TypeName + "ID" + tbl.VarName = strings.ToLower(string(table_name[0])) + + err := db.Select(&tbl.Columns, `select * from columns where table_name = ?`, table_name) + if err != nil { + panic(err) + } + ret[tbl.TableName] = tbl + } + return ret +} diff --git a/pkg/sqlgenerate/schema_parse_test.go b/pkg/sqlgenerate/schema_parse_test.go new file mode 100644 index 0000000..26d1bb4 --- /dev/null +++ b/pkg/sqlgenerate/schema_parse_test.go @@ -0,0 +1,50 @@ +package sqlgenerate_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gas_stack/pkg/sqlgenerate" +) + +func TestParseSchema(t *testing.T) { + assert := assert.New(t) + schema_sql, err := os.ReadFile("test_schemas/food.sql") + require.NoError(t, err) + + db := sqlgenerate.InitDB(string(schema_sql)) + schema := sqlgenerate.ParseSchema(db) + expected_tbls := []string{"food_types", "foods", "units", "ingredients", "recipes", "iterations", "db_version"} + for _, tbl_name := range expected_tbls { + _, is_ok := schema[tbl_name] + assert.True(is_ok) + } + + foods := schema["foods"] + assert.Len(foods.Columns, 20) + assert.Equal(foods.Columns[0].Name, "rowid") + assert.Equal(foods.Columns[0].Type, "integer") + assert.Equal(foods.Columns[0].IsNotNull, false) // Explicit not-null + assert.Equal(foods.Columns[0].IsPrimaryKey, true) + assert.Equal(foods.Columns[0].IsForeignKey, false) + assert.Equal(foods.Columns[1].Name, "name") + assert.Equal(foods.Columns[1].Type, "text") + assert.Equal(foods.Columns[1].IsNotNull, true) + assert.Equal(foods.Columns[1].HasDefaultValue, false) + assert.Equal(foods.Columns[1].IsPrimaryKey, false) + assert.Equal(foods.Columns[16].Name, "mass") + assert.Equal(foods.Columns[16].Type, "real") + assert.Equal(foods.Columns[16].HasDefaultValue, true) + assert.Equal(foods.Columns[16].DefaultValue, "100") + + ingredients := schema["ingredients"] + assert.Equal(ingredients.Columns[0].Name, "rowid") + assert.Equal(ingredients.Columns[0].IsPrimaryKey, true) + assert.Equal(ingredients.Columns[1].Name, "food_id") + assert.Equal(ingredients.Columns[1].IsForeignKey, true) + assert.Equal(ingredients.Columns[1].ForeignKeyTargetTable, "foods") + assert.Equal(ingredients.Columns[1].ForeignKeyTargetColumn, "rowid") +} diff --git a/pkg/sqlgenerate/table.go b/pkg/sqlgenerate/table.go new file mode 100644 index 0000000..0baabcc --- /dev/null +++ b/pkg/sqlgenerate/table.go @@ -0,0 +1,40 @@ +package sqlgenerate + +import ( + _ "embed" + + _ "github.com/mattn/go-sqlite3" +) + +// Column represents a single column in a table. +type Column struct { + TableName string `db:"table_name"` + Name string `db:"column_name"` + Type string `db:"column_type"` + IsNotNull bool `db:"notnull"` + HasDefaultValue bool `db:"has_default_value"` + DefaultValue string `db:"dflt_value"` + IsPrimaryKey bool `db:"is_primary_key"` + IsForeignKey bool `db:"is_foreign_key"` + ForeignKeyTargetTable string `db:"fk_target_table"` + ForeignKeyTargetColumn string `db:"fk_target_column"` +} + +// IsNullableForeignKey is a helper function. +func (c Column) IsNullableForeignKey() bool { + return !c.IsNotNull && !c.IsPrimaryKey && c.IsForeignKey +} + +// Table is a single SQLite table. +type Table struct { + TableName string `db:"name"` + IsStrict bool `db:"strict"` + Columns []Column + + TypeIDName string + VarName string + TypeName string +} + +// Schema is a container for a bunch of Tables, indexed by table name. +type Schema map[string]Table diff --git a/pkg/sqlgenerate/test_schemas/food.sql b/pkg/sqlgenerate/test_schemas/food.sql new file mode 100644 index 0000000..aa01e51 --- /dev/null +++ b/pkg/sqlgenerate/test_schemas/food.sql @@ -0,0 +1,99 @@ +PRAGMA foreign_keys = on; + +-- ======= +-- DB meta +-- ======= + +create table db_version ( + version integer primary key +) strict; +insert into db_version values(0); + +create table food_types (rowid integer primary key, + name text not null unique check(length(name) != 0) +) strict; +insert into food_types (name) values + ('grocery'), + ('recipe'), + ('daily log'); + +create table foods (rowid integer primary key, + name text not null check(length(name) != 0), + + cals real not null, + carbs real not null, + protein real not null, + fat real not null, + + sugar real not null, + alcohol real not null default 0, + water real not null default 0, + + potassium real not null default 0, + sodium real not null default 0, + calcium real not null default 0, + magnesium real not null default 0, + phosphorus real not null default 0, + iron real not null default 0, + zinc real not null default 0, + + mass real not null default 100, + price real not null default 0, + density real not null default 1, + cook_ratio real not null default 1 +) strict; + + +create table units (rowid integer primary key, + name text not null unique check(length(name) != 0), + abbreviation text not null unique check(length(abbreviation) != 0) + -- is_metric integer not null check(is_metric in (0, 1)) +) strict; +insert into units(rowid, name, abbreviation) values + -- Count + (1, 'count', 'ct'), + -- Mass + (2, 'grams', 'g'), + (3, 'pounds', 'lbs'), + (4, 'ounces', 'oz'), + -- Volume + (5, 'milliliters', 'mL'), + (6, 'cups', 'cups'), + (7, 'teaspoons', 'tsp'), + (8, 'tablespoons', 'tbsp'), + (9, 'fluid ounces', 'fl-oz'); + + +create table ingredients (rowid integer primary key, + food_id integer references foods(rowid), + recipe_id integer references recipes(rowid), + + quantity real not null default 1, + units integer not null default 0, -- Display purposes only + + in_recipe_id integer references recipes(rowid) on delete cascade not null, + list_order integer not null, + is_hidden integer not null default false, + unique (in_recipe_id, list_order) + check((food_id is null) + (recipe_id is null) = 1) -- Exactly one should be active +) strict; + +create table recipes (rowid integer primary key, + name text not null check(length(name) != 0), + blurb text not null, + instructions text not null, + + computed_food_id integer references foods(rowid) not null +) strict; + +create table iterations (rowid integer primary key, + original_recipe_id integer references recipes(rowid), + -- original_author integer not null, -- For azimuth integration + derived_recipe_id integer references recipes(rowid), + unique(derived_recipe_id) +) strict; + +-- create table daily_logs ( +-- date integer not null unique, +-- computed_food_id integer references foods(rowid) not null +-- );