chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
168
platform/internal/schema/ddl.go
Normal file
168
platform/internal/schema/ddl.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"aijianzhan/platform/internal/blueprint"
|
||||
)
|
||||
|
||||
// BuildPostgresDDL 仅拼接白名单标识符与固定类型映射。
|
||||
func BuildPostgresDDL(bp *blueprint.Blueprint) ([]string, error) {
|
||||
if bp.Storage.SchemaName == "" {
|
||||
return nil, fmt.Errorf("schema_name empty")
|
||||
}
|
||||
schema := bp.Storage.SchemaName
|
||||
stmts := []string{
|
||||
fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", quoteIdent(schema)),
|
||||
}
|
||||
|
||||
for _, e := range bp.Entities {
|
||||
cols := make([]string, 0, len(e.Fields)+4)
|
||||
hasTenant := false
|
||||
hasOrgUnit := false
|
||||
hasCreatedAt := false
|
||||
hasUpdatedAt := false
|
||||
hasCreatedBy := false
|
||||
|
||||
for _, f := range e.Fields {
|
||||
sqlType, err := mapType(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var col string
|
||||
switch {
|
||||
case f.Name == e.PrimaryKey && f.Type == "bigint":
|
||||
col = fmt.Sprintf("%s BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY", quoteIdent(f.Name))
|
||||
case f.Name == e.PrimaryKey && f.Type == "int":
|
||||
col = fmt.Sprintf("%s INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY", quoteIdent(f.Name))
|
||||
default:
|
||||
nullSQL := "NULL"
|
||||
if !blueprint.BoolOr(f.Nullable, true) {
|
||||
nullSQL = "NOT NULL"
|
||||
}
|
||||
col = fmt.Sprintf("%s %s %s", quoteIdent(f.Name), sqlType, nullSQL)
|
||||
if f.Name == e.PrimaryKey {
|
||||
col += " PRIMARY KEY"
|
||||
} else if f.Unique {
|
||||
col += " UNIQUE"
|
||||
}
|
||||
if f.Type == "enum" && len(f.EnumValues) > 0 {
|
||||
// 枚举值仅作 UI 提示,不写死 CHECK,避免导入新值被拒
|
||||
}
|
||||
}
|
||||
cols = append(cols, col)
|
||||
switch f.Name {
|
||||
case "tenant_id":
|
||||
hasTenant = true
|
||||
case "org_unit_id":
|
||||
hasOrgUnit = true
|
||||
case "created_at":
|
||||
hasCreatedAt = true
|
||||
case "updated_at":
|
||||
hasUpdatedAt = true
|
||||
case "created_by":
|
||||
hasCreatedBy = true
|
||||
}
|
||||
}
|
||||
|
||||
// 系统强制列
|
||||
if !hasTenant {
|
||||
cols = append(cols, "tenant_id BIGINT NOT NULL")
|
||||
}
|
||||
if !hasOrgUnit {
|
||||
cols = append(cols, "org_unit_id BIGINT")
|
||||
}
|
||||
if !hasCreatedBy {
|
||||
cols = append(cols, "created_by BIGINT")
|
||||
}
|
||||
if !hasCreatedAt {
|
||||
cols = append(cols, "created_at TIMESTAMPTZ NOT NULL DEFAULT now()")
|
||||
}
|
||||
if !hasUpdatedAt {
|
||||
cols = append(cols, "updated_at TIMESTAMPTZ NOT NULL DEFAULT now()")
|
||||
}
|
||||
|
||||
create := fmt.Sprintf(
|
||||
"CREATE TABLE IF NOT EXISTS %s.%s (\n %s\n)",
|
||||
quoteIdent(schema),
|
||||
quoteIdent(e.Table),
|
||||
strings.Join(cols, ",\n "),
|
||||
)
|
||||
stmts = append(stmts, create)
|
||||
|
||||
for _, idx := range e.Indexes {
|
||||
unique := ""
|
||||
if idx.Unique {
|
||||
unique = "UNIQUE "
|
||||
}
|
||||
colsQuoted := make([]string, 0, len(idx.Columns))
|
||||
for _, c := range idx.Columns {
|
||||
colsQuoted = append(colsQuoted, quoteIdent(c))
|
||||
}
|
||||
stmts = append(stmts, fmt.Sprintf(
|
||||
"CREATE %sINDEX IF NOT EXISTS %s ON %s.%s (%s)",
|
||||
unique,
|
||||
quoteIdent(idx.Name),
|
||||
quoteIdent(schema),
|
||||
quoteIdent(e.Table),
|
||||
strings.Join(colsQuoted, ", "),
|
||||
))
|
||||
}
|
||||
}
|
||||
return stmts, nil
|
||||
}
|
||||
|
||||
func mapType(f blueprint.Field) (string, error) {
|
||||
switch f.Type {
|
||||
case "string":
|
||||
n := f.MaxLength
|
||||
if n <= 0 {
|
||||
n = 255
|
||||
}
|
||||
return fmt.Sprintf("VARCHAR(%d)", n), nil
|
||||
case "text":
|
||||
return "TEXT", nil
|
||||
case "int":
|
||||
return "INTEGER", nil
|
||||
case "bigint":
|
||||
return "BIGINT", nil
|
||||
case "decimal":
|
||||
p, s := f.Precision, f.Scale
|
||||
if p <= 0 {
|
||||
p = 18
|
||||
}
|
||||
if s < 0 {
|
||||
s = 2
|
||||
}
|
||||
return fmt.Sprintf("NUMERIC(%d,%d)", p, s), nil
|
||||
case "boolean":
|
||||
return "BOOLEAN", nil
|
||||
case "date":
|
||||
return "DATE", nil
|
||||
case "datetime":
|
||||
return "TIMESTAMPTZ", nil
|
||||
case "enum":
|
||||
return "VARCHAR(64)", nil
|
||||
case "json":
|
||||
return "JSONB", nil
|
||||
case "file_ref":
|
||||
return "VARCHAR(512)", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown field type: %s", f.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func enumCheck(col string, values []string) string {
|
||||
quoted := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
quoted = append(quoted, "'"+strings.ReplaceAll(v, "'", "''")+"'")
|
||||
}
|
||||
return fmt.Sprintf("CHECK (%s IN (%s))", quoteIdent(col), strings.Join(quoted, ", "))
|
||||
}
|
||||
|
||||
func quoteIdent(name string) string {
|
||||
// 调用方已白名单校验;仍用双引号包裹防止关键字冲突
|
||||
return `"` + strings.ReplaceAll(name, `"`, ``) + `"`
|
||||
}
|
||||
38
platform/internal/schema/ddl_test.go
Normal file
38
platform/internal/schema/ddl_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package schema_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"aijianzhan/platform/internal/blueprint"
|
||||
"aijianzhan/platform/internal/schema"
|
||||
)
|
||||
|
||||
func TestBuildDDLFromExample(t *testing.T) {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
|
||||
example := filepath.Join(root, "blueprint", "examples", "inventory-ledger.blueprint.json")
|
||||
raw, err := os.ReadFile(example)
|
||||
if err != nil {
|
||||
t.Fatalf("read example: %v", err)
|
||||
}
|
||||
bp, err := blueprint.Parse(json.RawMessage(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bp.Validate("inventory_ledger"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bp.AssignSchemaName(1)
|
||||
stmts, err := schema.BuildPostgresDDL(bp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stmts) < 2 {
|
||||
t.Fatalf("expected schema+table ddl, got %d", len(stmts))
|
||||
}
|
||||
t.Logf("ddl count=%d first=%s", len(stmts), stmts[0])
|
||||
}
|
||||
92
platform/internal/schema/runner.go
Normal file
92
platform/internal/schema/runner.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var dbNameRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,47}$`)
|
||||
|
||||
type Runner interface {
|
||||
ExecDDL(ctx context.Context, stmts []string) error
|
||||
EnsureDatabase(ctx context.Context, dbName string) error
|
||||
}
|
||||
|
||||
type NoopRunner struct{}
|
||||
|
||||
func (NoopRunner) ExecDDL(context.Context, []string) error { return nil }
|
||||
func (NoopRunner) EnsureDatabase(context.Context, string) error { return nil }
|
||||
|
||||
type PostgresRunner struct {
|
||||
DB *sql.DB
|
||||
AdminDSN string // 用于 CREATE DATABASE(连到 postgres 库)
|
||||
}
|
||||
|
||||
func (r *PostgresRunner) ExecDDL(ctx context.Context, stmts []string) error {
|
||||
tx, err := r.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
for _, s := range stmts {
|
||||
if _, err := tx.ExecContext(ctx, s); err != nil {
|
||||
return fmt.Errorf("ddl failed: %w\nsql: %s", err, s)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *PostgresRunner) EnsureDatabase(ctx context.Context, dbName string) error {
|
||||
if !dbNameRe.MatchString(dbName) {
|
||||
return fmt.Errorf("invalid database name: %s", dbName)
|
||||
}
|
||||
admin := r.DB
|
||||
var err error
|
||||
if r.AdminDSN != "" {
|
||||
admin, err = sql.Open("postgres", r.AdminDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer admin.Close()
|
||||
}
|
||||
var exists bool
|
||||
if err := admin.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname=$1)`, dbName,
|
||||
).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
// CREATE DATABASE 不能在事务中
|
||||
_, err = admin.ExecContext(ctx, fmt.Sprintf(`CREATE DATABASE %s`, quoteIdent(dbName)))
|
||||
return err
|
||||
}
|
||||
|
||||
// DSNForDatabase 把原 DSN 的库名替换为目标库。
|
||||
func DSNForDatabase(baseDSN, dbName string) (string, error) {
|
||||
if !dbNameRe.MatchString(dbName) {
|
||||
return "", fmt.Errorf("invalid database name")
|
||||
}
|
||||
u, err := url.Parse(baseDSN)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u.Path = "/" + dbName
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func QuoteIdentExport(name string) string { return quoteIdent(name) }
|
||||
|
||||
func SanitizeDBName(tenantID int64, slug string) string {
|
||||
name := fmt.Sprintf("appdb_t%d_%s", tenantID, slug)
|
||||
name = strings.ToLower(name)
|
||||
if len(name) > 48 {
|
||||
name = name[:48]
|
||||
}
|
||||
return name
|
||||
}
|
||||
Reference in New Issue
Block a user