2026-05-14 22:36:04 +08:00
|
|
|
package tableDB
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"apigo.cc/go/cast"
|
|
|
|
|
"apigo.cc/go/db"
|
|
|
|
|
"apigo.cc/go/id"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Table provides an AI-friendly interface for interacting with structured data or schema.
|
|
|
|
|
type Table struct {
|
|
|
|
|
Name string
|
|
|
|
|
userID string
|
|
|
|
|
db *db.DB
|
|
|
|
|
app *TableDB
|
|
|
|
|
isMeta bool // True if Name ends with ":Schema"
|
|
|
|
|
constraint map[string]any
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewTable creates a new Table instance. Handles ":Schema" and ":Field" suffix.
|
|
|
|
|
func NewTable(name string, app *TableDB) *Table {
|
|
|
|
|
isMeta := false
|
|
|
|
|
actualName := name
|
|
|
|
|
var constraint map[string]any
|
|
|
|
|
|
|
|
|
|
if strings.HasSuffix(name, ":Schema") {
|
|
|
|
|
isMeta = true
|
|
|
|
|
actualName = strings.TrimSuffix(name, ":Schema")
|
|
|
|
|
} else if strings.HasSuffix(name, ":Field") {
|
|
|
|
|
tableName := strings.TrimSuffix(name, ":Field")
|
|
|
|
|
actualName = "_Field"
|
|
|
|
|
// lookup table_id
|
|
|
|
|
res := app.base.Query("SELECT id FROM `_Table` WHERE name = ? LIMIT 1", tableName)
|
|
|
|
|
if res.Error == nil {
|
|
|
|
|
rec := res.MapOnR1()
|
|
|
|
|
if len(rec) > 0 {
|
|
|
|
|
constraint = map[string]any{"tableId": cast.String(rec["id"])}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &Table{
|
|
|
|
|
Name: actualName,
|
|
|
|
|
userID: app.userID,
|
|
|
|
|
db: app.base,
|
|
|
|
|
app: app,
|
|
|
|
|
isMeta: isMeta,
|
|
|
|
|
constraint: constraint,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (t *Table) checkAuth(id string) error {
|
2026-05-14 23:39:37 +08:00
|
|
|
if t.userID == "_system" {
|
2026-05-14 22:36:04 +08:00
|
|
|
return nil
|
|
|
|
|
}
|
2026-05-14 23:39:37 +08:00
|
|
|
|
|
|
|
|
// Check if table has 'creator' field
|
|
|
|
|
hasCreator := false
|
|
|
|
|
tableRec := GlobalCache.GetTable(t.Name)
|
|
|
|
|
if tableRec != nil {
|
|
|
|
|
tid := cast.String(tableRec["id"])
|
|
|
|
|
fields := GlobalCache.GetFields(tid)
|
|
|
|
|
for _, f := range fields {
|
|
|
|
|
if f.Name == "creator" {
|
|
|
|
|
hasCreator = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !hasCreator && !strings.HasPrefix(t.Name, "_") {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 22:36:04 +08:00
|
|
|
if t.Name == "_Policy" || t.Name == "_Backup" {
|
|
|
|
|
return fmt.Errorf("permission denied for %s", t.Name)
|
|
|
|
|
}
|
|
|
|
|
// Check existing record
|
|
|
|
|
res := t.db.Query(fmt.Sprintf("SELECT creator FROM `%s` WHERE id = ?", t.Name), id)
|
|
|
|
|
rec := res.MapOnR1()
|
|
|
|
|
if len(rec) > 0 {
|
|
|
|
|
if cast.String(rec["creator"]) == t.userID {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
// Check _Policy
|
|
|
|
|
pol := t.db.Query("SELECT 1 FROM _Policy WHERE subject = ? AND resource = ? AND effect = 'allow' LIMIT 1", t.userID, id)
|
|
|
|
|
if len(pol.MapOnR1()) > 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("permission denied for %s record %s", t.Name, id)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (t *Table) appendAuthAndConstraint(whereStr string, args []any) (string, []any, error) {
|
|
|
|
|
if t.constraint != nil {
|
|
|
|
|
for k, v := range t.constraint {
|
|
|
|
|
if whereStr != "" {
|
|
|
|
|
whereStr += fmt.Sprintf(" AND %s = ?", k)
|
|
|
|
|
} else {
|
|
|
|
|
whereStr = fmt.Sprintf("%s = ?", k)
|
|
|
|
|
}
|
|
|
|
|
args = append(args, v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 23:39:37 +08:00
|
|
|
if t.userID == "_system" {
|
|
|
|
|
return whereStr, args, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if table has 'creator' field
|
|
|
|
|
hasCreator := false
|
|
|
|
|
tableRec := GlobalCache.GetTable(t.Name)
|
|
|
|
|
if tableRec != nil {
|
|
|
|
|
tid := cast.String(tableRec["id"])
|
|
|
|
|
fields := GlobalCache.GetFields(tid)
|
|
|
|
|
for _, f := range fields {
|
|
|
|
|
if f.Name == "creator" {
|
|
|
|
|
hasCreator = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if hasCreator || strings.HasPrefix(t.Name, "_") {
|
2026-05-14 22:36:04 +08:00
|
|
|
if t.Name == "_Policy" || t.Name == "_Backup" {
|
|
|
|
|
return "", nil, fmt.Errorf("permission denied for %s", t.Name)
|
|
|
|
|
}
|
|
|
|
|
authWhere := fmt.Sprintf("(creator = '%s' OR id IN (SELECT resource FROM _Policy WHERE subject = '%s' AND effect = 'allow'))", t.userID, t.userID)
|
|
|
|
|
if whereStr != "" {
|
|
|
|
|
whereStr = "(" + whereStr + ") AND " + authWhere
|
|
|
|
|
} else {
|
|
|
|
|
whereStr = authWhere
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return whereStr, args, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (t *Table) reconstructAndSyncSchema() error {
|
|
|
|
|
tables := t.db.Query("SELECT * FROM `_Table`").MapResults()
|
|
|
|
|
fields := t.db.Query("SELECT * FROM `_Field` ORDER BY tableId").MapResults()
|
|
|
|
|
|
|
|
|
|
fieldMap := make(map[string][]map[string]any)
|
|
|
|
|
for _, f := range fields {
|
|
|
|
|
tid := cast.String(f["tableId"])
|
|
|
|
|
fieldMap[tid] = append(fieldMap[tid], f)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var sb strings.Builder
|
|
|
|
|
for _, tbl := range tables {
|
|
|
|
|
name := cast.String(tbl["name"])
|
|
|
|
|
if name == "" { continue }
|
2026-05-14 23:39:37 +08:00
|
|
|
|
|
|
|
|
tid := cast.String(tbl["id"])
|
|
|
|
|
tblFields := fieldMap[tid]
|
|
|
|
|
if len(tblFields) == 0 {
|
|
|
|
|
continue // Skip tables with no fields to avoid SQL errors
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 22:36:04 +08:00
|
|
|
memo := cast.String(tbl["memo"])
|
|
|
|
|
sb.WriteString(name + " SD")
|
|
|
|
|
if memo != "" {
|
|
|
|
|
sb.WriteString(" //" + strings.ReplaceAll(memo, "\n", " "))
|
|
|
|
|
}
|
|
|
|
|
sb.WriteString("\n")
|
|
|
|
|
|
2026-05-14 23:39:37 +08:00
|
|
|
hasID := false
|
|
|
|
|
for _, f := range tblFields {
|
|
|
|
|
if cast.String(f["name"]) == "id" {
|
|
|
|
|
hasID = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !hasID {
|
|
|
|
|
sb.WriteString(" id c10 PK\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, f := range tblFields {
|
2026-05-14 22:36:04 +08:00
|
|
|
fname := cast.String(f["name"])
|
|
|
|
|
ftype := cast.String(f["type"])
|
|
|
|
|
if ftype == "" { ftype = "v255" }
|
|
|
|
|
isIndex := cast.Int(f["isIndex"]) == 1
|
|
|
|
|
fmemo := cast.String(f["memo"])
|
|
|
|
|
|
|
|
|
|
sb.WriteString(" " + fname + " " + ftype)
|
|
|
|
|
if isIndex {
|
|
|
|
|
sb.WriteString(" I")
|
|
|
|
|
}
|
|
|
|
|
if fmemo != "" {
|
|
|
|
|
sb.WriteString(" //" + strings.ReplaceAll(fmemo, "\n", " "))
|
|
|
|
|
}
|
|
|
|
|
sb.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
sb.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return t.db.Sync(sb.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set performs an upsert.
|
|
|
|
|
func (t *Table) Set(data any) error {
|
|
|
|
|
if t.userID == "" {
|
|
|
|
|
return fmt.Errorf("no permission")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
record := make(map[string]any)
|
|
|
|
|
j, _ := cast.ToJSON(data)
|
|
|
|
|
_ = cast.UnmarshalJSON([]byte(j), &record)
|
|
|
|
|
|
|
|
|
|
if t.isMeta {
|
|
|
|
|
dsl, ok := record["dsl"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return fmt.Errorf("schema dsl is required")
|
|
|
|
|
}
|
|
|
|
|
return t.app.SyncSchema(dsl)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if t.constraint != nil {
|
|
|
|
|
for k, v := range t.constraint {
|
|
|
|
|
record[k] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if t.app.Hooks.OnUpdatingRow != nil && t.Name != "_Table" && t.Name != "_Field" {
|
|
|
|
|
if err := t.app.Hooks.OnUpdatingRow(t.Name, record); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
idVal := record["id"]
|
|
|
|
|
var isInsert bool
|
|
|
|
|
if idVal == nil || cast.String(idVal) == "" {
|
|
|
|
|
record["id"] = id.MakeID(10)
|
|
|
|
|
isInsert = true
|
|
|
|
|
} else {
|
|
|
|
|
idStr := cast.String(idVal)
|
|
|
|
|
res := t.db.Query(fmt.Sprintf("SELECT id FROM `%s` WHERE id = ? LIMIT 1", t.Name), idStr)
|
|
|
|
|
rec := res.MapOnR1()
|
|
|
|
|
if rec != nil && len(rec) > 0 {
|
|
|
|
|
if err := t.checkAuth(idStr); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
isInsert = false
|
|
|
|
|
} else {
|
|
|
|
|
isInsert = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var err error
|
|
|
|
|
if isInsert {
|
2026-05-14 23:39:37 +08:00
|
|
|
if t.userID != "_system" {
|
|
|
|
|
// Check if table has 'creator' field
|
|
|
|
|
hasCreator := false
|
|
|
|
|
tableRec := GlobalCache.GetTable(t.Name)
|
|
|
|
|
if tableRec != nil {
|
|
|
|
|
tid := cast.String(tableRec["id"])
|
|
|
|
|
fields := GlobalCache.GetFields(tid)
|
|
|
|
|
for _, f := range fields {
|
|
|
|
|
if f.Name == "creator" {
|
|
|
|
|
hasCreator = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if hasCreator || strings.HasPrefix(t.Name, "_") {
|
|
|
|
|
if t.Name == "_Policy" || t.Name == "_Backup" {
|
|
|
|
|
return fmt.Errorf("permission denied for %s", t.Name)
|
|
|
|
|
}
|
|
|
|
|
record["creator"] = t.userID
|
2026-05-14 22:36:04 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
err = t.db.Insert(t.Name, record).Error
|
|
|
|
|
} else {
|
|
|
|
|
err = t.db.Update(t.Name, record, "id = ?", cast.String(record["id"])).Error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
if t.Name == "_Table" {
|
|
|
|
|
_ = t.reconstructAndSyncSchema()
|
|
|
|
|
if isInsert && t.app.Hooks.OnCreatedTable != nil {
|
|
|
|
|
t.app.Hooks.OnCreatedTable(cast.String(record["name"]), record)
|
|
|
|
|
}
|
|
|
|
|
} else if t.Name == "_Field" {
|
|
|
|
|
_ = t.reconstructAndSyncSchema()
|
|
|
|
|
if t.app.Hooks.OnUpdatedField != nil {
|
|
|
|
|
t.app.Hooks.OnUpdatedField(cast.String(record["tableId"]), cast.String(record["name"]), record)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if t.app.Hooks.OnUpdatedRows != nil {
|
|
|
|
|
t.app.Hooks.OnUpdatedRows(t.Name, 1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get retrieves a single record.
|
|
|
|
|
func (t *Table) Get(id string) (map[string]any, error) {
|
|
|
|
|
if t.isMeta {
|
|
|
|
|
return map[string]any{"name": t.Name, "type": "table"}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := t.checkAuth(id); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
query := fmt.Sprintf("SELECT * FROM `%s` WHERE id = ? LIMIT 1", t.Name)
|
|
|
|
|
res := t.db.Query(query, id)
|
|
|
|
|
if res.Error != nil {
|
|
|
|
|
return nil, res.Error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
record := res.MapOnR1()
|
|
|
|
|
if len(record) == 0 {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
return record, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove deletes a record.
|
|
|
|
|
func (t *Table) Remove(id string) error {
|
|
|
|
|
if t.isMeta {
|
|
|
|
|
return fmt.Errorf("schema removal not allowed via Table API")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := t.checkAuth(id); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var record map[string]any
|
|
|
|
|
if t.Name == "_Table" || t.Name == "_Field" {
|
|
|
|
|
res := t.db.Query(fmt.Sprintf("SELECT * FROM `%s` WHERE id = ?", t.Name), id)
|
|
|
|
|
record = res.MapOnR1()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res := t.db.Delete(t.Name, "id = ?", id)
|
|
|
|
|
if res.Error == nil {
|
|
|
|
|
if t.Name == "_Table" {
|
|
|
|
|
_ = t.reconstructAndSyncSchema()
|
|
|
|
|
if record != nil && t.app.Hooks.OnRemovedTable != nil {
|
|
|
|
|
t.app.Hooks.OnRemovedTable(cast.String(record["name"]))
|
|
|
|
|
}
|
|
|
|
|
} else if t.Name == "_Field" {
|
|
|
|
|
_ = t.reconstructAndSyncSchema()
|
|
|
|
|
if record != nil && t.app.Hooks.OnRemovedField != nil {
|
|
|
|
|
t.app.Hooks.OnRemovedField(cast.String(record["tableId"]), cast.String(record["name"]))
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if t.app.Hooks.OnRemovedRows != nil {
|
|
|
|
|
t.app.Hooks.OnRemovedRows(t.Name, []string{id})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return res.Error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// List retrieves multiple records.
|
|
|
|
|
func (t *Table) List(where any, args ...any) ([]map[string]any, error) {
|
|
|
|
|
if t.isMeta {
|
|
|
|
|
return nil, fmt.Errorf("list not supported on schema tables")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
query := fmt.Sprintf("SELECT * FROM `%s` ", t.Name)
|
|
|
|
|
whereStr := ""
|
|
|
|
|
if where != nil {
|
|
|
|
|
switch v := where.(type) {
|
|
|
|
|
case string:
|
|
|
|
|
whereStr = v
|
|
|
|
|
case map[string]any:
|
|
|
|
|
whereStr, args = buildWhere(v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var err error
|
|
|
|
|
whereStr, args, err = t.appendAuthAndConstraint(whereStr, args)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if whereStr != "" {
|
|
|
|
|
query += " WHERE " + whereStr
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res := t.db.Query(query, args...)
|
|
|
|
|
if res.Error != nil {
|
|
|
|
|
return nil, res.Error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return res.MapResults(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Count returns the number of records.
|
|
|
|
|
func (t *Table) Count(where any, args ...any) (int64, error) {
|
|
|
|
|
query := fmt.Sprintf("SELECT COUNT(*) FROM `%s` ", t.Name)
|
|
|
|
|
whereStr := ""
|
|
|
|
|
if where != nil {
|
|
|
|
|
switch v := where.(type) {
|
|
|
|
|
case string:
|
|
|
|
|
whereStr = v
|
|
|
|
|
case map[string]any:
|
|
|
|
|
w, a := buildWhere(v)
|
|
|
|
|
whereStr = w
|
|
|
|
|
args = a
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var err error
|
|
|
|
|
whereStr, args, err = t.appendAuthAndConstraint(whereStr, args)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if whereStr != "" {
|
|
|
|
|
query += " WHERE " + whereStr
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res := t.db.Query(query, args...)
|
|
|
|
|
if res.Error != nil {
|
|
|
|
|
return 0, res.Error
|
|
|
|
|
}
|
|
|
|
|
return res.IntOnR1C1(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CountBy returns counts grouped by a field.
|
|
|
|
|
func (t *Table) CountBy(field string) (map[any]int64, error) {
|
|
|
|
|
query := fmt.Sprintf("SELECT `%s`, COUNT(*) as cnt FROM `%s` ", field, t.Name)
|
|
|
|
|
|
|
|
|
|
whereStr, args, err := t.appendAuthAndConstraint("", nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if whereStr != "" {
|
|
|
|
|
query += " WHERE " + whereStr
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
query += fmt.Sprintf(" GROUP BY `%s` ", field)
|
|
|
|
|
|
|
|
|
|
res := t.db.Query(query, args...)
|
|
|
|
|
if res.Error != nil {
|
|
|
|
|
return nil, res.Error
|
|
|
|
|
}
|
|
|
|
|
rows := res.MapResults()
|
|
|
|
|
result := make(map[any]int64)
|
|
|
|
|
for _, row := range rows {
|
|
|
|
|
result[row[field]] = cast.Int64(row["cnt"])
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fields returns field metadata.
|
|
|
|
|
func (t *Table) Fields() ([]FieldSchema, error) {
|
|
|
|
|
tid := ""
|
|
|
|
|
tableRecord := GlobalCache.GetTable(t.Name)
|
|
|
|
|
if tableRecord != nil {
|
|
|
|
|
tid = cast.String(tableRecord["id"])
|
|
|
|
|
} else {
|
|
|
|
|
return nil, fmt.Errorf("table metadata not found in cache: %s", t.Name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return GlobalCache.GetFields(tid), nil
|
|
|
|
|
}
|