515 lines
15 KiB
Go
515 lines
15 KiB
Go
package blueprint
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
var identRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,47}$`)
|
||
var nonIdentRe = regexp.MustCompile(`[^a-z0-9_]+`)
|
||
var multiUnderRe = regexp.MustCompile(`_+`)
|
||
|
||
var reserved = map[string]struct{}{
|
||
"select": {}, "insert": {}, "update": {}, "delete": {}, "drop": {},
|
||
"create": {}, "alter": {}, "table": {}, "schema": {}, "user": {},
|
||
"where": {}, "from": {}, "join": {}, "grant": {}, "revoke": {},
|
||
}
|
||
|
||
type Blueprint struct {
|
||
Version string `json:"version"`
|
||
Meta Meta `json:"meta"`
|
||
Storage Storage `json:"storage"`
|
||
Entities []Entity `json:"entities"`
|
||
Apis Apis `json:"apis"`
|
||
Pages []Page `json:"pages"`
|
||
Security Security `json:"security"`
|
||
Seed *Seed `json:"seed,omitempty"`
|
||
}
|
||
|
||
type Meta struct {
|
||
Name string `json:"name"`
|
||
Slug string `json:"slug"`
|
||
Description string `json:"description,omitempty"`
|
||
Locale string `json:"locale"`
|
||
Confidence float64 `json:"confidence,omitempty"`
|
||
UIPreset string `json:"ui_preset,omitempty"`
|
||
PlatformTitle string `json:"platform_title,omitempty"`
|
||
ProjectContext string `json:"project_context,omitempty"`
|
||
UI json.RawMessage `json:"ui,omitempty"`
|
||
Source json.RawMessage `json:"source,omitempty"`
|
||
}
|
||
|
||
type Storage struct {
|
||
Mode string `json:"mode"`
|
||
Engine string `json:"engine"`
|
||
SchemaName string `json:"schema_name,omitempty"`
|
||
}
|
||
|
||
type Entity struct {
|
||
Name string `json:"name"`
|
||
Table string `json:"table"`
|
||
Label string `json:"label"`
|
||
PrimaryKey string `json:"primary_key"`
|
||
Fields []Field `json:"fields"`
|
||
Indexes []Index `json:"indexes,omitempty"`
|
||
}
|
||
|
||
type Field struct {
|
||
Name string `json:"name"`
|
||
Label string `json:"label"`
|
||
Type string `json:"type"`
|
||
Nullable *bool `json:"nullable,omitempty"`
|
||
Unique bool `json:"unique,omitempty"`
|
||
Default any `json:"default,omitempty"`
|
||
MaxLength int `json:"max_length,omitempty"`
|
||
Precision int `json:"precision,omitempty"`
|
||
Scale int `json:"scale,omitempty"`
|
||
EnumValues []string `json:"enum_values,omitempty"`
|
||
UI json.RawMessage `json:"ui,omitempty"`
|
||
}
|
||
|
||
type Index struct {
|
||
Name string `json:"name"`
|
||
Columns []string `json:"columns"`
|
||
Unique bool `json:"unique,omitempty"`
|
||
}
|
||
|
||
type Apis struct {
|
||
BasePath string `json:"base_path"`
|
||
Resources []APIResource `json:"resources"`
|
||
}
|
||
|
||
type APIResource struct {
|
||
Entity string `json:"entity"`
|
||
Path string `json:"path"`
|
||
Operations []string `json:"operations"`
|
||
List *ListOpt `json:"list,omitempty"`
|
||
}
|
||
|
||
type ListOpt struct {
|
||
DefaultPageSize int `json:"default_page_size,omitempty"`
|
||
MaxPageSize int `json:"max_page_size,omitempty"`
|
||
AllowedFilters []string `json:"allowed_filters,omitempty"`
|
||
AllowedSorts []string `json:"allowed_sorts,omitempty"`
|
||
}
|
||
|
||
type Page struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
Route string `json:"route"`
|
||
Type string `json:"type"`
|
||
Entity string `json:"entity"`
|
||
Layout *PageLayout `json:"layout,omitempty"`
|
||
}
|
||
|
||
type PageLayout struct {
|
||
Preset string `json:"preset,omitempty"`
|
||
Columns []string `json:"columns,omitempty"`
|
||
Filters []string `json:"filters,omitempty"`
|
||
Actions []string `json:"actions,omitempty"`
|
||
ActionLabels map[string]string `json:"action_labels,omitempty"`
|
||
FilterStyle string `json:"filter_style,omitempty"`
|
||
FormFields []string `json:"form_fields,omitempty"`
|
||
Widgets []PageWidget `json:"widgets,omitempty"`
|
||
}
|
||
|
||
type PageWidget struct {
|
||
Type string `json:"type"`
|
||
Title string `json:"title,omitempty"`
|
||
Metric string `json:"metric,omitempty"`
|
||
Metrics []string `json:"metrics,omitempty"`
|
||
GroupBy string `json:"group_by,omitempty"`
|
||
XField string `json:"x_field,omitempty"`
|
||
Entity string `json:"entity,omitempty"`
|
||
Columns []string `json:"columns,omitempty"`
|
||
LabelField string `json:"label_field,omitempty"`
|
||
ValueField string `json:"value_field,omitempty"`
|
||
SecondaryField string `json:"secondary_field,omitempty"`
|
||
WarnField string `json:"warn_field,omitempty"`
|
||
FilterField string `json:"filter_field,omitempty"`
|
||
FilterOp string `json:"filter_op,omitempty"`
|
||
FilterValue any `json:"filter_value,omitempty"`
|
||
YUnit string `json:"y_unit,omitempty"`
|
||
Variant string `json:"variant,omitempty"`
|
||
CycleDays int `json:"cycle_days,omitempty"`
|
||
}
|
||
|
||
type Security struct {
|
||
Visibility string `json:"visibility"`
|
||
Roles []Role `json:"roles"`
|
||
RowPolicies []RowPolicy `json:"row_policies"`
|
||
}
|
||
|
||
type Role struct {
|
||
Name string `json:"name"`
|
||
Permissions []string `json:"permissions"`
|
||
}
|
||
|
||
type RowPolicy struct {
|
||
Entity string `json:"entity"`
|
||
Rule string `json:"rule"`
|
||
}
|
||
|
||
type Seed struct {
|
||
ImportExcel bool `json:"import_excel,omitempty"`
|
||
MaxRows int `json:"max_rows,omitempty"`
|
||
}
|
||
|
||
func Parse(raw json.RawMessage) (*Blueprint, error) {
|
||
var bp Blueprint
|
||
if err := json.Unmarshal(raw, &bp); err != nil {
|
||
return nil, fmt.Errorf("invalid blueprint json: %w", err)
|
||
}
|
||
return &bp, nil
|
||
}
|
||
|
||
func (bp *Blueprint) Validate(pathSlug string) error {
|
||
if bp.Version != "1.0" {
|
||
return fmt.Errorf("unsupported version: %s", bp.Version)
|
||
}
|
||
bp.SanitizeIdentifiers()
|
||
// 连字符等非法字符规范为下划线(schema/表名不能含 -)
|
||
bp.Meta.Slug = NormalizeIdent(bp.Meta.Slug)
|
||
pathSlug = NormalizeIdent(pathSlug)
|
||
if err := checkIdent("meta.slug", bp.Meta.Slug); err != nil {
|
||
return err
|
||
}
|
||
if bp.Meta.Slug != pathSlug {
|
||
return fmt.Errorf("meta.slug(%s) != path slug(%s)", bp.Meta.Slug, pathSlug)
|
||
}
|
||
if bp.Meta.Name == "" {
|
||
return fmt.Errorf("meta.name required")
|
||
}
|
||
if bp.Storage.Mode != "schema_per_app" && bp.Storage.Mode != "database_per_app" {
|
||
return fmt.Errorf("unsupported storage.mode: %s", bp.Storage.Mode)
|
||
}
|
||
if bp.Storage.Engine != "postgres" && bp.Storage.Engine != "mysql" {
|
||
return fmt.Errorf("unsupported storage.engine: %s", bp.Storage.Engine)
|
||
}
|
||
if len(bp.Entities) == 0 {
|
||
return fmt.Errorf("entities required")
|
||
}
|
||
|
||
entityNames := map[string]Entity{}
|
||
for i, e := range bp.Entities {
|
||
prefix := fmt.Sprintf("entities[%d]", i)
|
||
if err := checkIdent(prefix+".name", e.Name); err != nil {
|
||
return err
|
||
}
|
||
if err := checkIdent(prefix+".table", e.Table); err != nil {
|
||
return err
|
||
}
|
||
if err := checkIdent(prefix+".primary_key", e.PrimaryKey); err != nil {
|
||
return err
|
||
}
|
||
if len(e.Fields) == 0 {
|
||
return fmt.Errorf("%s.fields required", prefix)
|
||
}
|
||
fields := map[string]struct{}{}
|
||
pkFound := false
|
||
for j, f := range e.Fields {
|
||
fp := fmt.Sprintf("%s.fields[%d]", prefix, j)
|
||
if err := checkIdent(fp+".name", f.Name); err != nil {
|
||
return err
|
||
}
|
||
if !validFieldType(f.Type) {
|
||
return fmt.Errorf("%s.type invalid: %s", fp, f.Type)
|
||
}
|
||
f.Type = NormalizeFieldType(f.Type)
|
||
if _, ok := fields[f.Name]; ok {
|
||
return fmt.Errorf("%s duplicate field %s", prefix, f.Name)
|
||
}
|
||
fields[f.Name] = struct{}{}
|
||
if f.Name == e.PrimaryKey {
|
||
pkFound = true
|
||
}
|
||
}
|
||
if !pkFound {
|
||
return fmt.Errorf("%s primary_key %s not in fields", prefix, e.PrimaryKey)
|
||
}
|
||
for j, idx := range e.Indexes {
|
||
ip := fmt.Sprintf("%s.indexes[%d]", prefix, j)
|
||
if err := checkIdent(ip+".name", idx.Name); err != nil {
|
||
return err
|
||
}
|
||
if !strings.HasPrefix(idx.Name, "idx_") {
|
||
return fmt.Errorf("%s.name must start with idx_", ip)
|
||
}
|
||
for _, col := range idx.Columns {
|
||
if _, ok := fields[col]; !ok {
|
||
return fmt.Errorf("%s unknown column %s", ip, col)
|
||
}
|
||
}
|
||
}
|
||
entityNames[e.Name] = e
|
||
}
|
||
|
||
if len(bp.Apis.Resources) == 0 {
|
||
return fmt.Errorf("apis.resources required")
|
||
}
|
||
for i, r := range bp.Apis.Resources {
|
||
prefix := fmt.Sprintf("apis.resources[%d]", i)
|
||
if _, ok := entityNames[r.Entity]; !ok {
|
||
return fmt.Errorf("%s unknown entity %s", prefix, r.Entity)
|
||
}
|
||
path := strings.TrimPrefix(r.Path, "/")
|
||
if err := checkIdent(prefix+".path", path); err != nil {
|
||
return err
|
||
}
|
||
if len(r.Operations) == 0 {
|
||
return fmt.Errorf("%s.operations required", prefix)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// AssignSchemaName 由平台重写 schema;database_per_app 时使用 public。
|
||
func (bp *Blueprint) AssignSchemaName(tenantID int64) string {
|
||
if bp.Storage.Mode == "database_per_app" {
|
||
bp.Storage.SchemaName = "public"
|
||
return "public"
|
||
}
|
||
name := fmt.Sprintf("app_t%d_%s", tenantID, bp.Meta.Slug)
|
||
if len(name) > 48 {
|
||
name = name[:48]
|
||
}
|
||
bp.Storage.SchemaName = name
|
||
return name
|
||
}
|
||
|
||
// AssignDatabaseName database_per_app 时返回独立库名,否则空。
|
||
func (bp *Blueprint) AssignDatabaseName(tenantID int64) string {
|
||
if bp.Storage.Mode != "database_per_app" {
|
||
return ""
|
||
}
|
||
name := fmt.Sprintf("appdb_t%d_%s", tenantID, bp.Meta.Slug)
|
||
if len(name) > 48 {
|
||
name = name[:48]
|
||
}
|
||
return name
|
||
}
|
||
|
||
func checkIdent(field, v string) error {
|
||
if !identRe.MatchString(v) {
|
||
return fmt.Errorf("%s invalid identifier: %s", field, v)
|
||
}
|
||
if _, bad := reserved[v]; bad {
|
||
return fmt.Errorf("%s reserved identifier: %s", field, v)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SanitizeIdentifiers 将 camelCase / 点号等统一为 snake_case,并同步页面与 API 引用。
|
||
func (bp *Blueprint) SanitizeIdentifiers() {
|
||
entityRename := map[string]string{}
|
||
for i := range bp.Entities {
|
||
e := &bp.Entities[i]
|
||
oldName := e.Name
|
||
e.Name = NormalizeIdent(e.Name)
|
||
e.Table = NormalizeIdent(e.Table)
|
||
e.PrimaryKey = NormalizeIdent(e.PrimaryKey)
|
||
if oldName != "" {
|
||
entityRename[oldName] = e.Name
|
||
entityRename[NormalizeIdent(oldName)] = e.Name
|
||
}
|
||
fieldRename := map[string]string{}
|
||
used := map[string]int{}
|
||
for j := range e.Fields {
|
||
f := &e.Fields[j]
|
||
old := f.Name
|
||
next := NormalizeIdent(old)
|
||
if n, ok := used[next]; ok {
|
||
used[next] = n + 1
|
||
next = fmt.Sprintf("%s_%d", next, n+1)
|
||
} else {
|
||
used[next] = 1
|
||
}
|
||
f.Name = next
|
||
f.Type = NormalizeFieldType(f.Type)
|
||
if old != "" {
|
||
fieldRename[old] = next
|
||
}
|
||
}
|
||
e.PrimaryKey = renameOrSelf(fieldRename, e.PrimaryKey)
|
||
for j := range e.Indexes {
|
||
idx := &e.Indexes[j]
|
||
idx.Name = NormalizeIdent(idx.Name)
|
||
if !strings.HasPrefix(idx.Name, "idx_") {
|
||
idx.Name = "idx_" + idx.Name
|
||
}
|
||
for k, col := range idx.Columns {
|
||
idx.Columns[k] = renameOrSelf(fieldRename, col)
|
||
}
|
||
}
|
||
// pages / apis that reference this entity's fields
|
||
for pi := range bp.Pages {
|
||
p := &bp.Pages[pi]
|
||
entRef := NormalizeIdent(p.Entity)
|
||
if p.Entity == oldName || p.Entity == e.Name || entRef == e.Name || entRef == NormalizeIdent(oldName) {
|
||
p.Entity = e.Name
|
||
if p.Layout != nil {
|
||
p.Layout.Columns = renameSlice(fieldRename, p.Layout.Columns)
|
||
p.Layout.Filters = renameSlice(fieldRename, p.Layout.Filters)
|
||
p.Layout.FormFields = renameSlice(fieldRename, p.Layout.FormFields)
|
||
for wi := range p.Layout.Widgets {
|
||
w := &p.Layout.Widgets[wi]
|
||
w.Metric = renameOrSelf(fieldRename, w.Metric)
|
||
w.GroupBy = renameOrSelf(fieldRename, w.GroupBy)
|
||
w.XField = renameOrSelf(fieldRename, w.XField)
|
||
w.LabelField = renameOrSelf(fieldRename, w.LabelField)
|
||
w.ValueField = renameOrSelf(fieldRename, w.ValueField)
|
||
w.WarnField = renameOrSelf(fieldRename, w.WarnField)
|
||
w.FilterField = renameOrSelf(fieldRename, w.FilterField)
|
||
w.Metrics = renameSlice(fieldRename, w.Metrics)
|
||
w.Columns = renameSlice(fieldRename, w.Columns)
|
||
if w.Entity == oldName || NormalizeIdent(w.Entity) == e.Name {
|
||
w.Entity = e.Name
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for ri := range bp.Apis.Resources {
|
||
r := &bp.Apis.Resources[ri]
|
||
entRef := NormalizeIdent(r.Entity)
|
||
if r.Entity == oldName || r.Entity == e.Name || entRef == e.Name || entRef == NormalizeIdent(oldName) {
|
||
r.Entity = e.Name
|
||
if r.List != nil {
|
||
r.List.AllowedFilters = renameSlice(fieldRename, r.List.AllowedFilters)
|
||
r.List.AllowedSorts = renameSlice(fieldRename, r.List.AllowedSorts)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for i := range bp.Pages {
|
||
p := &bp.Pages[i]
|
||
p.Entity = renameOrSelf(entityRename, p.Entity)
|
||
}
|
||
for i := range bp.Apis.Resources {
|
||
r := &bp.Apis.Resources[i]
|
||
r.Entity = renameOrSelf(entityRename, r.Entity)
|
||
path := strings.TrimPrefix(r.Path, "/")
|
||
r.Path = "/" + NormalizeIdent(path)
|
||
}
|
||
for i := range bp.Security.RowPolicies {
|
||
rp := &bp.Security.RowPolicies[i]
|
||
rp.Entity = renameOrSelf(entityRename, rp.Entity)
|
||
}
|
||
}
|
||
|
||
func renameOrSelf(m map[string]string, v string) string {
|
||
if v == "" {
|
||
return v
|
||
}
|
||
if n, ok := m[v]; ok {
|
||
return n
|
||
}
|
||
return NormalizeIdent(v)
|
||
}
|
||
|
||
func renameSlice(m map[string]string, in []string) []string {
|
||
if len(in) == 0 {
|
||
return in
|
||
}
|
||
out := make([]string, len(in))
|
||
for i, v := range in {
|
||
out[i] = renameOrSelf(m, v)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// NormalizeSlug 兼容旧名。
|
||
func NormalizeSlug(s string) string { return NormalizeIdent(s) }
|
||
|
||
// NormalizeIdent 驼峰/点号/连字符 → snake_case,符合 ^[a-z][a-z0-9_]{1,47}$
|
||
func NormalizeIdent(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return "field"
|
||
}
|
||
var b strings.Builder
|
||
b.Grow(len(s) + 8)
|
||
prevUnder := false
|
||
for i, r := range s {
|
||
switch {
|
||
case r >= 'A' && r <= 'Z':
|
||
if i > 0 && !prevUnder {
|
||
b.WriteByte('_')
|
||
}
|
||
b.WriteByte(byte(r - 'A' + 'a'))
|
||
prevUnder = false
|
||
case r >= 'a' && r <= 'z' || r >= '0' && r <= '9':
|
||
b.WriteByte(byte(r))
|
||
prevUnder = false
|
||
default:
|
||
// '.', '-', 空格等
|
||
if !prevUnder && b.Len() > 0 {
|
||
b.WriteByte('_')
|
||
prevUnder = true
|
||
}
|
||
}
|
||
}
|
||
s = strings.Trim(b.String(), "_")
|
||
s = multiUnderRe.ReplaceAllString(s, "_")
|
||
if s == "" {
|
||
return "field"
|
||
}
|
||
if s[0] < 'a' || s[0] > 'z' {
|
||
s = "f_" + s
|
||
}
|
||
if len(s) > 48 {
|
||
s = strings.TrimRight(s[:48], "_")
|
||
}
|
||
if s == "" {
|
||
return "field"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// NormalizeFieldType 将常见别名收成蓝图允许的 type。
|
||
func NormalizeFieldType(t string) string {
|
||
t = strings.ToLower(strings.TrimSpace(t))
|
||
switch t {
|
||
case "float", "float32", "float64", "double", "number", "numeric", "real", "money":
|
||
return "decimal"
|
||
case "integer", "int32", "int64", "long", "serial":
|
||
if t == "int64" || t == "long" || t == "serial" {
|
||
return "bigint"
|
||
}
|
||
return "int"
|
||
case "bool":
|
||
return "boolean"
|
||
case "varchar", "str", "char":
|
||
return "string"
|
||
case "timestamp", "timestamptz", "time":
|
||
return "datetime"
|
||
case "file", "attachment", "blob":
|
||
return "file_ref"
|
||
case "string", "text", "int", "bigint", "decimal", "boolean", "date", "datetime", "enum", "json", "file_ref":
|
||
return t
|
||
default:
|
||
if t == "" {
|
||
return "string"
|
||
}
|
||
return t
|
||
}
|
||
}
|
||
|
||
func validFieldType(t string) bool {
|
||
switch NormalizeFieldType(t) {
|
||
case "string", "text", "int", "bigint", "decimal", "boolean", "date", "datetime", "enum", "json", "file_ref":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func BoolOr(p *bool, def bool) bool {
|
||
if p == nil {
|
||
return def
|
||
}
|
||
return *p
|
||
}
|