DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,515 @@
package datatable
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/csv"
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
var errXLSXPartNotFound = errors.New("xlsx part not found")
type GenerateOptions struct {
Input string `json:"input"`
Output string `json:"output"`
Format string `json:"format,omitempty"`
Version string `json:"version,omitempty"`
ManifestOut string `json:"manifest_out,omitempty"`
Pretty bool `json:"pretty,omitempty"`
}
type GeneratedTable struct {
Name string `json:"name"`
Source string `json:"source"`
Output string `json:"output"`
Rows int `json:"rows"`
Bytes int64 `json:"bytes"`
Checksum string `json:"checksum"`
}
type GenerateReport struct {
Input string `json:"input"`
Output string `json:"output"`
Version string `json:"version,omitempty"`
Tables []GeneratedTable `json:"tables"`
Manifest Manifest `json:"manifest"`
GeneratedAt time.Time `json:"generated_at"`
}
func GenerateTables(ctx context.Context, options GenerateOptions) (GenerateReport, error) {
if err := contextErr(ctx); err != nil {
return GenerateReport{}, err
}
options.Input = strings.TrimSpace(options.Input)
options.Output = strings.TrimSpace(options.Output)
options.Format = strings.ToLower(strings.TrimSpace(options.Format))
options.Version = strings.TrimSpace(options.Version)
options.ManifestOut = strings.TrimSpace(options.ManifestOut)
if options.Input == "" {
return GenerateReport{}, ErrDirectoryRequired
}
if options.Output == "" {
return GenerateReport{}, fmt.Errorf("data table output is required")
}
info, err := os.Stat(options.Input)
if err != nil {
return GenerateReport{}, err
}
sources, err := generatorSources(options.Input, info)
if err != nil {
return GenerateReport{}, err
}
if len(sources) == 0 {
return GenerateReport{}, fmt.Errorf("no data table source files found in %s", options.Input)
}
if err := os.MkdirAll(options.Output, 0o750); err != nil {
return GenerateReport{}, err
}
report := GenerateReport{
Input: options.Input,
Output: options.Output,
Version: options.Version,
GeneratedAt: time.Now().UTC(),
}
tableSources := make(map[string]string, len(sources))
for _, source := range sources {
if err := contextErr(ctx); err != nil {
return GenerateReport{}, err
}
tableName := tableNameFromFile(source)
tableKey := strings.ToLower(tableName)
if previous, ok := tableSources[tableKey]; ok {
return GenerateReport{}, fmt.Errorf("duplicate generated data table name %q from %s and %s", tableName, previous, source)
}
tableSources[tableKey] = source
rows, err := loadGeneratedRows(source, options.Format)
if err != nil {
return GenerateReport{}, fmt.Errorf("generate table %s: %w", source, err)
}
data, err := marshalGeneratedRows(rows, options.Pretty)
if err != nil {
return GenerateReport{}, err
}
outPath := filepath.Join(options.Output, tableName+".json")
if err := os.WriteFile(outPath, data, 0o600); err != nil {
return GenerateReport{}, err
}
checksum := sha256.Sum256(data)
report.Tables = append(report.Tables, GeneratedTable{
Name: tableName,
Source: source,
Output: outPath,
Rows: len(rows),
Bytes: int64(len(data)),
Checksum: hex.EncodeToString(checksum[:]),
})
}
sort.Slice(report.Tables, func(i, j int) bool { return report.Tables[i].Name < report.Tables[j].Name })
registry := NewRegistry(options.Output, options.Version)
if err := registry.Load(ctx); err != nil {
return GenerateReport{}, err
}
report.Manifest = registry.Manifest()
if options.ManifestOut != "" {
manifestData, err := json.MarshalIndent(report.Manifest, "", " ")
if err != nil {
return GenerateReport{}, err
}
if err := os.MkdirAll(filepath.Dir(options.ManifestOut), 0o750); err != nil && filepath.Dir(options.ManifestOut) != "." {
return GenerateReport{}, err
}
if err := os.WriteFile(options.ManifestOut, append(manifestData, '\n'), 0o600); err != nil {
return GenerateReport{}, err
}
}
return report, nil
}
func generatorSources(input string, info os.FileInfo) ([]string, error) {
if !info.IsDir() {
if genExtSupported(input) {
return []string{input}, nil
}
return nil, fmt.Errorf("unsupported data table source %s", input)
}
var sources []string
if err := filepath.WalkDir(input, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
if strings.HasPrefix(filepath.Base(path), ".") {
return nil
}
if genExtSupported(path) {
sources = append(sources, path)
}
return nil
}); err != nil {
return nil, err
}
sort.Strings(sources)
return sources, nil
}
func genExtSupported(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".json", ".csv", ".xlsx":
return true
default:
return false
}
}
func loadGeneratedRows(path, format string) ([]map[string]any, error) {
if format == "" || format == "auto" {
format = strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
}
switch format {
case "json":
return loadGeneratedJSON(path)
case "csv":
return loadGeneratedCSV(path)
case "xlsx":
return loadGeneratedXLSX(path)
default:
return nil, fmt.Errorf("unsupported data table format %q", format)
}
}
func loadGeneratedJSON(path string) ([]map[string]any, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
if err != nil {
return nil, err
}
var rows []map[string]any
if err := json.Unmarshal(data, &rows); err == nil {
return rows, nil
}
var wrapped struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(data, &wrapped); err != nil {
return nil, err
}
if wrapped.Items == nil {
return nil, fmt.Errorf("json table must be an array or object with items")
}
return wrapped.Items, nil
}
func loadGeneratedCSV(path string) ([]map[string]any, error) {
file, err := os.Open(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
reader.FieldsPerRecord = -1
records, err := reader.ReadAll()
if err != nil {
return nil, err
}
return recordsToRows(records)
}
func loadGeneratedXLSX(path string) ([]map[string]any, error) {
reader, err := zip.OpenReader(path)
if err != nil {
return nil, err
}
defer func() { _ = reader.Close() }()
shared, err := readXLSXStrings(&reader.Reader)
if err != nil {
return nil, err
}
sheet, err := readZipFile(&reader.Reader, "xl/worksheets/sheet1.xml")
if err != nil {
return nil, err
}
records, err := parseXLSXSheet(sheet, shared)
if err != nil {
return nil, err
}
return recordsToRows(records)
}
func recordsToRows(records [][]string) ([]map[string]any, error) {
if len(records) == 0 {
return nil, nil
}
headers := normalizeHeaders(records[0])
if len(headers) == 0 {
return nil, fmt.Errorf("data table header row is empty")
}
rows := make([]map[string]any, 0, len(records)-1)
for _, record := range records[1:] {
if emptyRecord(record) {
continue
}
row := make(map[string]any, len(headers))
for idx, header := range headers {
value := ""
if idx < len(record) {
value = strings.TrimSpace(record[idx])
}
row[header] = inferGeneratedValue(value)
}
rows = append(rows, row)
}
return rows, nil
}
func normalizeHeaders(headers []string) []string {
out := make([]string, 0, len(headers))
seen := make(map[string]struct{}, len(headers))
for idx, header := range headers {
header = strings.TrimSpace(header)
if header == "" {
header = fmt.Sprintf("col_%d", idx+1)
}
base := header
for suffix := 2; ; suffix++ {
if _, ok := seen[header]; !ok {
break
}
header = fmt.Sprintf("%s_%d", base, suffix)
}
seen[header] = struct{}{}
out = append(out, header)
}
return out
}
func emptyRecord(record []string) bool {
for _, value := range record {
if strings.TrimSpace(value) != "" {
return false
}
}
return true
}
func inferGeneratedValue(value string) any {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
switch strings.ToLower(value) {
case "true", "false":
parsed, _ := strconv.ParseBool(strings.ToLower(value))
return parsed
}
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
return parsed
}
if parsed, err := strconv.ParseFloat(value, 64); err == nil {
return parsed
}
return value
}
func marshalGeneratedRows(rows []map[string]any, pretty bool) ([]byte, error) {
var data []byte
var err error
if pretty {
data, err = json.MarshalIndent(rows, "", " ")
} else {
data, err = json.Marshal(rows)
}
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}
func tableNameFromFile(path string) string {
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
name = strings.TrimSpace(name)
if name == "" {
return "table"
}
return name
}
func readZipFile(reader *zip.Reader, name string) ([]byte, error) {
for _, file := range reader.File {
if filepath.ToSlash(file.Name) != name {
continue
}
rc, err := file.Open()
if err != nil {
return nil, err
}
defer func() { _ = rc.Close() }()
return io.ReadAll(rc)
}
return nil, fmt.Errorf("%w: %s", errXLSXPartNotFound, name)
}
func readXLSXStrings(reader *zip.Reader) ([]string, error) {
data, err := readZipFile(reader, "xl/sharedStrings.xml")
if err != nil {
if errors.Is(err, errXLSXPartNotFound) {
return nil, nil
}
return nil, err
}
decoder := xml.NewDecoder(bytes.NewReader(data))
var values []string
var current strings.Builder
inText := false
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
switch t := token.(type) {
case xml.StartElement:
if t.Name.Local == "si" {
current.Reset()
}
if t.Name.Local == "t" {
inText = true
}
case xml.EndElement:
if t.Name.Local == "t" {
inText = false
}
if t.Name.Local == "si" {
values = append(values, current.String())
}
case xml.CharData:
if inText {
current.Write([]byte(t))
}
}
}
return values, nil
}
func parseXLSXSheet(data []byte, shared []string) ([][]string, error) {
decoder := xml.NewDecoder(bytes.NewReader(data))
var records [][]string
var row []string
var cellRef string
var cellType string
var value strings.Builder
inValue := false
inInlineText := false
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
switch t := token.(type) {
case xml.StartElement:
switch t.Name.Local {
case "row":
row = nil
case "c":
cellRef = ""
cellType = ""
value.Reset()
for _, attr := range t.Attr {
switch attr.Name.Local {
case "r":
cellRef = attr.Value
case "t":
cellType = attr.Value
}
}
case "v":
inValue = true
case "t":
if cellType == "inlineStr" {
inInlineText = true
}
}
case xml.EndElement:
switch t.Name.Local {
case "v":
inValue = false
case "t":
inInlineText = false
case "c":
text := decodeXLSXCell(value.String(), cellType, shared)
idx := xlsxCellIndex(cellRef)
if idx < 0 {
idx = len(row)
}
for len(row) <= idx {
row = append(row, "")
}
row[idx] = text
case "row":
records = append(records, append([]string(nil), row...))
}
case xml.CharData:
if inValue || inInlineText {
value.Write([]byte(t))
}
}
}
return records, nil
}
func decodeXLSXCell(raw, cellType string, shared []string) string {
raw = strings.TrimSpace(raw)
if cellType != "s" {
return raw
}
idx, err := strconv.Atoi(raw)
if err != nil || idx < 0 || idx >= len(shared) {
return raw
}
return shared[idx]
}
func xlsxCellIndex(ref string) int {
ref = strings.TrimSpace(ref)
if ref == "" {
return -1
}
idx := 0
found := false
for _, r := range ref {
if r >= 'A' && r <= 'Z' {
idx = idx*26 + int(r-'A'+1)
found = true
continue
}
if r >= 'a' && r <= 'z' {
idx = idx*26 + int(r-'a'+1)
found = true
continue
}
break
}
if !found {
return -1
}
return idx - 1
}
+151
View File
@@ -0,0 +1,151 @@
package datatable
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strconv"
"strings"
)
const DefaultPageLimit = 100
type PageOptions struct {
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
Cursor string `json:"cursor,omitempty"`
}
type Page[T any] struct {
Table Table `json:"table"`
Manifest Manifest `json:"manifest"`
Rows []T `json:"rows,omitempty"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Total int `json:"total"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
func LoadRowsPage[T any](ctx context.Context, source RowSource, name string, options PageOptions) (Page[T], error) {
if err := contextErr(ctx); err != nil {
return Page[T]{}, err
}
rows, err := LoadRows[T](source, name)
if err != nil {
return Page[T]{}, err
}
return PageRows(rows, options)
}
func PageRows[T any](set RowSet[T], options PageOptions) (Page[T], error) {
offset, limit, err := normalizePageOptions(options, len(set.Rows))
if err != nil {
return Page[T]{}, err
}
end := offset + limit
if end > len(set.Rows) {
end = len(set.Rows)
}
pageRows := append([]T(nil), set.Rows[offset:end]...)
next := ""
hasMore := end < len(set.Rows)
if hasMore {
next = EncodePageCursor(end)
}
return Page[T]{
Table: set.Table,
Manifest: set.Manifest,
Rows: pageRows,
Offset: offset,
Limit: limit,
Total: len(set.Rows),
NextCursor: next,
HasMore: hasMore,
}, nil
}
func (s RowSet[T]) Page(options PageOptions) (Page[T], error) {
return PageRows(s, options)
}
func (v *TableView[T]) Page(options PageOptions) (Page[T], error) {
if v == nil {
return Page[T]{}, ErrTableRequired
}
return v.Snapshot().Page(options)
}
func EncodePageCursor(offset int) string {
if offset <= 0 {
return ""
}
payload, _ := json.Marshal(map[string]int{"offset": offset})
return base64.RawURLEncoding.EncodeToString(payload)
}
func DecodePageCursor(cursor string) (int, error) {
cursor = strings.TrimSpace(cursor)
if cursor == "" {
return 0, nil
}
data, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return 0, fmt.Errorf("decode page cursor: %w", err)
}
var payload struct {
Offset int `json:"offset"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return 0, fmt.Errorf("parse page cursor: %w", err)
}
if payload.Offset < 0 {
return 0, fmt.Errorf("page cursor offset must be non-negative")
}
return payload.Offset, nil
}
func normalizePageOptions(options PageOptions, total int) (int, int, error) {
offset := options.Offset
if strings.TrimSpace(options.Cursor) != "" {
decoded, err := DecodePageCursor(options.Cursor)
if err != nil {
return 0, 0, err
}
offset = decoded
}
if offset < 0 {
return 0, 0, fmt.Errorf("page offset must be non-negative")
}
if offset > total {
offset = total
}
limit := options.Limit
if limit < 0 {
return 0, 0, fmt.Errorf("page limit must be positive")
}
if limit == 0 {
limit = DefaultPageLimit
}
return offset, limit, nil
}
func ParsePageOptions(offsetText, limitText, cursor string) (PageOptions, error) {
options := PageOptions{Cursor: strings.TrimSpace(cursor)}
if strings.TrimSpace(offsetText) != "" {
offset, err := strconv.Atoi(strings.TrimSpace(offsetText))
if err != nil {
return PageOptions{}, fmt.Errorf("parse page offset: %w", err)
}
options.Offset = offset
}
if strings.TrimSpace(limitText) != "" {
limit, err := strconv.Atoi(strings.TrimSpace(limitText))
if err != nil {
return PageOptions{}, fmt.Errorf("parse page limit: %w", err)
}
options.Limit = limit
}
return options, nil
}
@@ -0,0 +1,132 @@
package datatable
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
var ErrPipelineInvalid = errors.New("data table pipeline is invalid")
type PipelinePlan struct {
Name string `json:"name,omitempty"`
Baseline RegionInput `json:"baseline"`
Candidate RegionInput `json:"candidate"`
Regions []RegionInput `json:"regions,omitempty"`
RequireVersionChange bool `json:"require_version_change,omitempty"`
MaxAdded *int `json:"max_added,omitempty"`
MaxChanged *int `json:"max_changed,omitempty"`
MaxRemoved *int `json:"max_removed,omitempty"`
RequireRegionClean bool `json:"require_region_clean,omitempty"`
}
type PipelineReport struct {
Name string `json:"name,omitempty"`
Baseline RegionSnapshot `json:"baseline"`
Candidate RegionSnapshot `json:"candidate"`
Diff Diff `json:"diff"`
RegionReport *RegionReport `json:"region_report,omitempty"`
Accepted bool `json:"accepted"`
Violations []string `json:"violations,omitempty"`
GeneratedAt time.Time `json:"generated_at"`
}
func ValidatePipeline(ctx context.Context, plan PipelinePlan) (PipelineReport, error) {
if err := contextErr(ctx); err != nil {
return PipelineReport{}, err
}
if ctx == nil {
ctx = context.Background()
}
plan = normPipelinePlan(plan)
if plan.Baseline.Root == "" || plan.Candidate.Root == "" {
return PipelineReport{}, fmt.Errorf("%w: baseline and candidate roots are required", ErrPipelineInvalid)
}
baseline, err := loadDirectory(ctx, plan.Baseline.Root, plan.Baseline.Version)
if err != nil {
return PipelineReport{}, fmt.Errorf("load baseline data tables: %w", err)
}
candidate, err := loadDirectory(ctx, plan.Candidate.Root, plan.Candidate.Version)
if err != nil {
return PipelineReport{}, fmt.Errorf("load candidate data tables: %w", err)
}
diff := diffTables(baseline.tables, candidate.tables)
report := PipelineReport{
Name: plan.Name,
Baseline: RegionSnapshot{
Name: plan.Baseline.Name,
Root: plan.Baseline.Root,
Version: plan.Baseline.Version,
Manifest: baseline.Manifest(),
},
Candidate: RegionSnapshot{
Name: plan.Candidate.Name,
Root: plan.Candidate.Root,
Version: plan.Candidate.Version,
Manifest: candidate.Manifest(),
},
Diff: cloneDiff(diff),
GeneratedAt: time.Now().UTC(),
}
report.Violations = append(report.Violations, validatePipelineDiff(plan, baseline, candidate, diff)...)
if len(plan.Regions) > 0 {
regionReport, err := CompareRegions(ctx, plan.Regions, plan.Baseline.Name)
if err != nil {
return PipelineReport{}, err
}
report.RegionReport = &regionReport
if plan.RequireRegionClean && !regionReport.Summary.Consistent {
report.Violations = append(report.Violations, "region diff must be clean")
}
}
report.Accepted = len(report.Violations) == 0
return report, nil
}
func normPipelinePlan(plan PipelinePlan) PipelinePlan {
plan.Name = strings.TrimSpace(plan.Name)
plan.Baseline.Name = firstPipelineString(strings.TrimSpace(plan.Baseline.Name), "baseline")
plan.Baseline.Root = strings.TrimSpace(plan.Baseline.Root)
plan.Baseline.Version = strings.TrimSpace(plan.Baseline.Version)
plan.Candidate.Name = firstPipelineString(strings.TrimSpace(plan.Candidate.Name), "candidate")
plan.Candidate.Root = strings.TrimSpace(plan.Candidate.Root)
plan.Candidate.Version = strings.TrimSpace(plan.Candidate.Version)
for idx := range plan.Regions {
plan.Regions[idx].Name = strings.TrimSpace(plan.Regions[idx].Name)
plan.Regions[idx].Root = strings.TrimSpace(plan.Regions[idx].Root)
plan.Regions[idx].Version = strings.TrimSpace(plan.Regions[idx].Version)
}
return plan
}
func validatePipelineDiff(plan PipelinePlan, baseline, candidate Dataset, diff Diff) []string {
var violations []string
if plan.RequireVersionChange && baseline.Manifest().Version == candidate.Manifest().Version {
violations = append(violations, "candidate version must differ from baseline")
}
if plan.MaxAdded != nil && len(diff.Added) > *plan.MaxAdded {
violations = append(violations, fmt.Sprintf("added tables %d exceeds max %d", len(diff.Added), *plan.MaxAdded))
}
if plan.MaxChanged != nil && len(diff.Changed) > *plan.MaxChanged {
violations = append(violations, fmt.Sprintf("changed tables %d exceeds max %d", len(diff.Changed), *plan.MaxChanged))
}
if plan.MaxRemoved != nil && len(diff.Removed) > *plan.MaxRemoved {
violations = append(violations, fmt.Sprintf("removed tables %d exceeds max %d", len(diff.Removed), *plan.MaxRemoved))
}
return violations
}
func firstPipelineString(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
@@ -0,0 +1,162 @@
package datatable
import (
"context"
"errors"
"fmt"
"path/filepath"
"sort"
"strings"
"time"
)
var (
ErrRegionRequired = errors.New("data table region is required")
ErrBaselineNotFound = errors.New("data table baseline region not found")
ErrRegionDiffRequires2 = errors.New("data table region diff requires at least two regions")
)
type RegionInput struct {
Name string `json:"name"`
Root string `json:"root"`
Version string `json:"version,omitempty"`
}
type RegionSnapshot struct {
Name string `json:"name"`
Root string `json:"root"`
Version string `json:"version,omitempty"`
Manifest Manifest `json:"manifest"`
}
type RegionDiff struct {
Region string `json:"region"`
Baseline string `json:"baseline"`
Manifest Manifest `json:"manifest"`
BaselineManifest Manifest `json:"baseline_manifest"`
Diff Diff `json:"diff"`
}
type RegionDiffSummary struct {
Regions int `json:"regions"`
Compared int `json:"compared"`
Baseline string `json:"baseline"`
BaselineChecksum string `json:"baseline_checksum,omitempty"`
Consistent bool `json:"consistent"`
DriftedRegions []string `json:"drifted_regions,omitempty"`
Added int `json:"added"`
Changed int `json:"changed"`
Removed int `json:"removed"`
Unchanged int `json:"unchanged"`
}
type RegionReport struct {
Baseline string `json:"baseline"`
Regions []RegionSnapshot `json:"regions"`
Diffs []RegionDiff `json:"diffs"`
Summary RegionDiffSummary `json:"summary"`
GeneratedAt time.Time `json:"generated_at"`
}
func CompareRegions(ctx context.Context, regions []RegionInput, baseline string) (RegionReport, error) {
if err := contextErr(ctx); err != nil {
return RegionReport{}, err
}
if ctx == nil {
ctx = context.Background()
}
if len(regions) == 0 {
return RegionReport{}, ErrRegionRequired
}
if len(regions) < 2 {
return RegionReport{}, ErrRegionDiffRequires2
}
datasets := make(map[string]Dataset, len(regions))
snapshots := make([]RegionSnapshot, 0, len(regions))
for _, input := range regions {
normalized, err := normalizeRegionInput(input)
if err != nil {
return RegionReport{}, err
}
if _, exists := datasets[normalized.Name]; exists {
return RegionReport{}, fmt.Errorf("%w: duplicate region %s", ErrRegionRequired, normalized.Name)
}
dataset, err := loadDirectory(ctx, normalized.Root, normalized.Version)
if err != nil {
return RegionReport{}, fmt.Errorf("load data table region %s: %w", normalized.Name, err)
}
datasets[normalized.Name] = dataset
snapshots = append(snapshots, RegionSnapshot{
Name: normalized.Name,
Root: normalized.Root,
Version: normalized.Version,
Manifest: dataset.Manifest(),
})
if strings.TrimSpace(baseline) == "" {
baseline = normalized.Name
}
}
baseline = strings.TrimSpace(baseline)
base, ok := datasets[baseline]
if !ok {
return RegionReport{}, fmt.Errorf("%w: %s", ErrBaselineNotFound, baseline)
}
sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].Name < snapshots[j].Name })
report := RegionReport{
Baseline: baseline,
Regions: snapshots,
GeneratedAt: time.Now().UTC(),
}
report.Summary = RegionDiffSummary{
Regions: len(snapshots),
Baseline: baseline,
BaselineChecksum: base.Manifest().Checksum,
Consistent: true,
}
for _, snapshot := range snapshots {
if snapshot.Name == baseline {
continue
}
dataset := datasets[snapshot.Name]
diff := diffTables(base.tables, dataset.tables)
regionDiff := RegionDiff{
Region: snapshot.Name,
Baseline: baseline,
Manifest: dataset.Manifest(),
BaselineManifest: base.Manifest(),
Diff: cloneDiff(diff),
}
report.Diffs = append(report.Diffs, regionDiff)
report.Summary.Compared++
report.Summary.Added += len(diff.Added)
report.Summary.Changed += len(diff.Changed)
report.Summary.Removed += len(diff.Removed)
report.Summary.Unchanged += len(diff.Unchanged)
if diff.HasChanges() {
report.Summary.Consistent = false
report.Summary.DriftedRegions = append(report.Summary.DriftedRegions, snapshot.Name)
}
}
sort.Strings(report.Summary.DriftedRegions)
return report, nil
}
func normalizeRegionInput(input RegionInput) (RegionInput, error) {
input.Name = strings.TrimSpace(input.Name)
input.Root = strings.TrimSpace(input.Root)
input.Version = strings.TrimSpace(input.Version)
if input.Root == "" {
return RegionInput{}, ErrDirectoryRequired
}
if input.Name == "" {
input.Name = filepath.Base(filepath.Clean(input.Root))
}
if input.Name == "" || input.Name == "." || input.Name == string(filepath.Separator) {
return RegionInput{}, ErrRegionRequired
}
return input, nil
}
@@ -0,0 +1,384 @@
package datatable
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
var (
ErrDirectoryRequired = errors.New("data table directory is required")
ErrRegistryRequired = errors.New("data table registry is required")
ErrTableRequired = errors.New("data table name is required")
ErrTableNotFound = errors.New("data table not found")
)
type Table struct {
Name string `json:"name"`
Path string `json:"path"`
Version string `json:"version"`
Checksum string `json:"checksum"`
Rows int `json:"rows"`
Bytes int64 `json:"bytes"`
LoadedAt time.Time `json:"loaded_at"`
}
type Registry struct {
root string
version string
mu sync.RWMutex
tables map[string]Table
raw map[string][]byte
manifest Manifest
watchers map[chan LoadResult]struct{}
}
func NewRegistry(root, version string) *Registry {
root = strings.TrimSpace(root)
version = strings.TrimSpace(version)
return &Registry{
root: root,
version: version,
tables: make(map[string]Table),
raw: make(map[string][]byte),
manifest: Manifest{Root: root, Version: version},
watchers: make(map[chan LoadResult]struct{}),
}
}
func (r *Registry) Load(ctx context.Context) error {
if r == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
r.mu.RLock()
root := strings.TrimSpace(r.root)
version := r.version
r.mu.RUnlock()
_, err := r.ReloadDir(ctx, root, version, ReloadOptions{})
return err
}
func (r *Registry) LoadDir(ctx context.Context, root, version string) error {
_, err := r.ReloadDir(ctx, root, version, ReloadOptions{})
return err
}
func (r *Registry) Reload(ctx context.Context, options ReloadOptions) (LoadResult, error) {
if r == nil {
return LoadResult{}, nil
}
if ctx == nil {
ctx = context.Background()
}
r.mu.RLock()
root := strings.TrimSpace(r.root)
version := r.version
r.mu.RUnlock()
return r.ReloadDir(ctx, root, version, options)
}
func (r *Registry) ReloadDir(ctx context.Context, root, version string, options ReloadOptions) (LoadResult, error) {
if r == nil {
return LoadResult{}, nil
}
if ctx == nil {
ctx = context.Background()
}
dataset, err := loadDirectory(ctx, root, version)
if err != nil {
return LoadResult{}, err
}
if err := options.validate(ctx, dataset); err != nil {
return LoadResult{}, err
}
r.mu.Lock()
diff := diffTables(r.tables, dataset.tables)
result := LoadResult{
Manifest: dataset.manifest,
Diff: diff,
LoadedAt: dataset.manifest.LoadedAt,
}
r.root = dataset.root
r.version = dataset.version
r.tables = cloneTableMap(dataset.tables)
r.raw = cloneRawMap(dataset.raw)
r.manifest = dataset.manifest
for ch := range r.watchers {
select {
case ch <- cloneLoadResult(result):
default:
}
}
r.mu.Unlock()
return cloneLoadResult(result), nil
}
func (r *Registry) Configure(root, version string) {
if r == nil {
return
}
r.mu.Lock()
r.root = strings.TrimSpace(root)
r.version = strings.TrimSpace(version)
if len(r.tables) == 0 {
r.manifest = Manifest{Root: r.root, Version: r.version}
}
r.mu.Unlock()
}
func (r *Registry) Clear() {
if r == nil {
return
}
r.mu.Lock()
r.tables = make(map[string]Table)
r.raw = make(map[string][]byte)
r.manifest = Manifest{Root: r.root, Version: r.version}
r.mu.Unlock()
}
func (r *Registry) Snapshot() []Table {
if r == nil {
return nil
}
r.mu.RLock()
out := make([]Table, 0, len(r.tables))
for _, table := range r.tables {
out = append(out, table)
}
r.mu.RUnlock()
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func (r *Registry) Manifest() Manifest {
if r == nil {
return Manifest{}
}
r.mu.RLock()
manifest := r.manifest
r.mu.RUnlock()
return manifest
}
func (r *Registry) Decode(name string, out any) error {
if r == nil {
return ErrTableNotFound
}
name = strings.TrimSpace(name)
if name == "" {
return ErrTableRequired
}
if out == nil {
return fmt.Errorf("decode target for data table %q is nil", name)
}
r.mu.RLock()
data, ok := r.raw[name]
data = append([]byte(nil), data...)
r.mu.RUnlock()
if !ok {
return fmt.Errorf("%w: %s", ErrTableNotFound, name)
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("decode data table %q: %w", name, err)
}
return nil
}
func (r *Registry) Get(name string) (Table, bool) {
if r == nil {
return Table{}, false
}
name = strings.TrimSpace(name)
r.mu.RLock()
table, ok := r.tables[name]
r.mu.RUnlock()
return table, ok
}
func (r *Registry) Root() string {
if r == nil {
return ""
}
r.mu.RLock()
root := r.root
r.mu.RUnlock()
return root
}
func (r *Registry) Version() string {
if r == nil {
return ""
}
r.mu.RLock()
version := r.version
r.mu.RUnlock()
return version
}
func (r *Registry) Watch(ctx context.Context, buffer int) (<-chan LoadResult, error) {
if ctxErr := contextErr(ctx); ctxErr != nil {
return nil, ctxErr
}
if ctx == nil {
ctx = context.Background()
}
if r == nil {
return nil, ErrRegistryRequired
}
if buffer < 0 {
buffer = 0
}
ch := make(chan LoadResult, buffer)
r.mu.Lock()
if r.watchers == nil {
r.watchers = make(map[chan LoadResult]struct{})
}
r.watchers[ch] = struct{}{}
r.mu.Unlock()
go func() {
<-ctx.Done()
r.mu.Lock()
if _, ok := r.watchers[ch]; ok {
delete(r.watchers, ch)
close(ch)
}
r.mu.Unlock()
}()
return ch, nil
}
func loadDirectory(ctx context.Context, root, version string) (Dataset, error) {
if ctx == nil {
ctx = context.Background()
}
root = strings.TrimSpace(root)
version = strings.TrimSpace(version)
if root == "" {
return Dataset{}, ErrDirectoryRequired
}
info, err := os.Stat(root)
if err != nil {
return Dataset{}, fmt.Errorf("stat data table directory %q: %w", root, err)
}
if !info.IsDir() {
return Dataset{}, fmt.Errorf("data table directory %q is not a directory", root)
}
loadedAt := time.Now().UTC()
tables := make(map[string]Table)
raw := make(map[string][]byte)
if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if entry.IsDir() {
return nil
}
if !isJSONTable(entry.Name()) {
return nil
}
table, data, err := loadOne(root, version, path, loadedAt)
if err != nil {
return err
}
if existing, ok := tables[table.Name]; ok {
return fmt.Errorf("duplicate data table name %q from %q and %q", table.Name, existing.Path, table.Path)
}
tables[table.Name] = table
raw[table.Name] = data
return nil
}); err != nil {
return Dataset{}, err
}
manifest := buildManifest(root, version, tables, loadedAt)
return Dataset{
root: root,
version: version,
tables: tables,
raw: raw,
manifest: manifest,
}, nil
}
func loadOne(root, version, path string, loadedAt time.Time) (Table, []byte, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
if err != nil {
return Table{}, nil, fmt.Errorf("read data table %q: %w", path, err)
}
rows, err := countRows(data)
if err != nil {
return Table{}, nil, fmt.Errorf("parse data table %q: %w", path, err)
}
rel, err := filepath.Rel(root, path)
if err != nil {
return Table{}, nil, fmt.Errorf("resolve data table path %q: %w", path, err)
}
rel = filepath.ToSlash(rel)
name := strings.TrimSuffix(rel, filepath.Ext(rel))
sum := sha256.Sum256(data)
return Table{
Name: name,
Path: rel,
Version: version,
Checksum: hex.EncodeToString(sum[:]),
Rows: rows,
Bytes: int64(len(data)),
LoadedAt: loadedAt,
}, append([]byte(nil), data...), nil
}
func countRows(data []byte) (int, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return 0, err
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err == nil {
return 0, fmt.Errorf("data table contains more than one JSON value")
}
return 0, err
}
switch typed := value.(type) {
case []any:
return len(typed), nil
case map[string]any:
if items, ok := typed["items"].([]any); ok {
return len(items), nil
}
return 1, nil
default:
return 1, nil
}
}
func isJSONTable(name string) bool {
name = strings.TrimSpace(name)
if name == "" || name == ".DS_Store" || strings.HasPrefix(name, "._") {
return false
}
return strings.EqualFold(filepath.Ext(name), ".json")
}
@@ -0,0 +1,294 @@
package datatable
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"time"
)
type Manifest struct {
Root string `json:"root,omitempty"`
Version string `json:"version,omitempty"`
Tables int `json:"tables"`
Rows int `json:"rows"`
Bytes int64 `json:"bytes"`
Checksum string `json:"checksum,omitempty"`
LoadedAt time.Time `json:"loaded_at,omitempty"`
}
type ChangeType string
const (
ChangeAdded ChangeType = "added"
ChangeChanged ChangeType = "changed"
ChangeRemoved ChangeType = "removed"
ChangeUnchanged ChangeType = "unchanged"
)
type TableChange struct {
Type ChangeType `json:"type"`
Name string `json:"name"`
Before *Table `json:"before,omitempty"`
After *Table `json:"after,omitempty"`
}
type Diff struct {
Added []TableChange `json:"added,omitempty"`
Changed []TableChange `json:"changed,omitempty"`
Removed []TableChange `json:"removed,omitempty"`
Unchanged []TableChange `json:"unchanged,omitempty"`
}
func (d Diff) HasChanges() bool {
return len(d.Added) > 0 || len(d.Changed) > 0 || len(d.Removed) > 0
}
type LoadResult struct {
Manifest Manifest `json:"manifest"`
Diff Diff `json:"diff"`
LoadedAt time.Time `json:"loaded_at,omitempty"`
}
type ReloadOptions struct {
Validators []Validator
}
func (o ReloadOptions) validate(ctx context.Context, dataset Dataset) error {
if ctx == nil {
ctx = context.Background()
}
for _, validator := range o.Validators {
if validator == nil {
continue
}
if err := validator.ValidateDataTables(ctx, dataset); err != nil {
return err
}
}
return nil
}
type Validator interface {
ValidateDataTables(context.Context, Dataset) error
}
type ValidatorFunc func(context.Context, Dataset) error
func (f ValidatorFunc) ValidateDataTables(ctx context.Context, dataset Dataset) error {
if f == nil {
return nil
}
return f(ctx, dataset)
}
type Dataset struct {
root string
version string
tables map[string]Table
raw map[string][]byte
manifest Manifest
}
func (d Dataset) Root() string {
return d.root
}
func (d Dataset) Version() string {
return d.version
}
func (d Dataset) Manifest() Manifest {
return d.manifest
}
func (d Dataset) Snapshot() []Table {
return sortedTables(d.tables)
}
func (d Dataset) Get(name string) (Table, bool) {
name = strings.TrimSpace(name)
table, ok := d.tables[name]
return table, ok
}
func (d Dataset) Decode(name string, out any) error {
name = strings.TrimSpace(name)
if name == "" {
return ErrTableRequired
}
if out == nil {
return fmt.Errorf("decode target for data table %q is nil", name)
}
data, ok := d.raw[name]
if !ok {
return fmt.Errorf("%w: %s", ErrTableNotFound, name)
}
if err := json.Unmarshal(append([]byte(nil), data...), out); err != nil {
return fmt.Errorf("decode data table %q: %w", name, err)
}
return nil
}
func (d Dataset) Raw(name string) ([]byte, bool) {
name = strings.TrimSpace(name)
data, ok := d.raw[name]
if !ok {
return nil, false
}
return append([]byte(nil), data...), true
}
func buildManifest(root, version string, tables map[string]Table, loadedAt time.Time) Manifest {
snapshot := sortedTables(tables)
manifest := Manifest{
Root: root,
Version: version,
Tables: len(snapshot),
LoadedAt: loadedAt.UTC(),
}
hash := sha256.New()
for _, table := range snapshot {
manifest.Rows += table.Rows
manifest.Bytes += table.Bytes
_, _ = io.WriteString(hash, table.Name)
hash.Write([]byte{0})
_, _ = io.WriteString(hash, table.Path)
hash.Write([]byte{0})
_, _ = io.WriteString(hash, table.Checksum)
hash.Write([]byte{0})
_, _ = io.WriteString(hash, fmt.Sprintf("%d:%d", table.Rows, table.Bytes))
hash.Write([]byte{0})
}
manifest.Checksum = hex.EncodeToString(hash.Sum(nil))
return manifest
}
func diffTables(before, after map[string]Table) Diff {
names := make(map[string]struct{}, len(before)+len(after))
for name := range before {
names[name] = struct{}{}
}
for name := range after {
names[name] = struct{}{}
}
ordered := make([]string, 0, len(names))
for name := range names {
ordered = append(ordered, name)
}
sort.Strings(ordered)
var diff Diff
for _, name := range ordered {
oldTable, hadOld := before[name]
newTable, hasNew := after[name]
switch {
case !hadOld && hasNew:
diff.Added = append(diff.Added, TableChange{Type: ChangeAdded, Name: name, After: tableRef(newTable)})
case hadOld && !hasNew:
diff.Removed = append(diff.Removed, TableChange{Type: ChangeRemoved, Name: name, Before: tableRef(oldTable)})
case sameTableContent(oldTable, newTable):
diff.Unchanged = append(diff.Unchanged, TableChange{Type: ChangeUnchanged, Name: name, Before: tableRef(oldTable), After: tableRef(newTable)})
default:
diff.Changed = append(diff.Changed, TableChange{Type: ChangeChanged, Name: name, Before: tableRef(oldTable), After: tableRef(newTable)})
}
}
return diff
}
func sameTableContent(left, right Table) bool {
return left.Name == right.Name &&
left.Path == right.Path &&
left.Checksum == right.Checksum &&
left.Rows == right.Rows &&
left.Bytes == right.Bytes
}
func sortedTables(tables map[string]Table) []Table {
out := make([]Table, 0, len(tables))
for _, table := range tables {
out = append(out, table)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func cloneTableMap(in map[string]Table) map[string]Table {
out := make(map[string]Table, len(in))
for name, table := range in {
out[name] = table
}
return out
}
func cloneRawMap(in map[string][]byte) map[string][]byte {
out := make(map[string][]byte, len(in))
for name, data := range in {
out[name] = append([]byte(nil), data...)
}
return out
}
func cloneLoadResult(in LoadResult) LoadResult {
return LoadResult{
Manifest: in.Manifest,
Diff: cloneDiff(in.Diff),
LoadedAt: in.LoadedAt,
}
}
func cloneDiff(in Diff) Diff {
return Diff{
Added: cloneTableChanges(in.Added),
Changed: cloneTableChanges(in.Changed),
Removed: cloneTableChanges(in.Removed),
Unchanged: cloneTableChanges(in.Unchanged),
}
}
func cloneTableChanges(in []TableChange) []TableChange {
if len(in) == 0 {
return nil
}
out := make([]TableChange, len(in))
for i, change := range in {
out[i] = TableChange{
Type: change.Type,
Name: change.Name,
Before: cloneTableRef(change.Before),
After: cloneTableRef(change.After),
}
}
return out
}
func tableRef(table Table) *Table {
return &Table{
Name: table.Name,
Path: table.Path,
Version: table.Version,
Checksum: table.Checksum,
Rows: table.Rows,
Bytes: table.Bytes,
LoadedAt: table.LoadedAt,
}
}
func cloneTableRef(table *Table) *Table {
if table == nil {
return nil
}
return tableRef(*table)
}
func contextErr(ctx context.Context) error {
if ctx == nil {
return nil
}
return ctx.Err()
}
+501
View File
@@ -0,0 +1,501 @@
package datatable
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"sync"
)
var (
ErrDataSourceRequired = errors.New("data table source is required")
ErrIndexKeyRequired = errors.New("data table index key function is required")
ErrDuplicateIndexKey = errors.New("data table index key is duplicated")
ErrMismatchedTableView = errors.New("data table view name does not match row set")
ErrPreparedViewInvalid = errors.New("prepared data table view is invalid")
)
type RowSource interface {
Decode(name string, out any) error
Get(name string) (Table, bool)
Manifest() Manifest
}
type KeyFunc[T any, K comparable] func(T) (K, bool)
type ViewBinding interface {
TableName() string
Prepare(context.Context, RowSource) (PreparedView, error)
}
type PreparedView struct {
Table string `json:"table"`
Rows int `json:"rows"`
apply func()
commit func() error
rollback func()
}
func NewPreparedView(table string, rows int, apply func()) (PreparedView, error) {
return NewViewWithRollback(table, rows, apply, nil)
}
func NewViewWithRollback(table string, rows int, apply func(), rollback func()) (PreparedView, error) {
var commit func() error
if apply != nil {
commit = func() error { apply(); return nil }
}
view := PreparedView{
Table: strings.TrimSpace(table),
Rows: rows,
apply: apply,
commit: commit,
rollback: rollback,
}
if err := view.Validate(); err != nil {
return PreparedView{}, err
}
return view, nil
}
func (p PreparedView) Validate() error {
if strings.TrimSpace(p.Table) == "" || p.commit == nil {
return ErrPreparedViewInvalid
}
return nil
}
func (p PreparedView) Commit() error {
if err := p.Validate(); err != nil {
return err
}
return p.commit()
}
func (p PreparedView) Rollback() {
if p.rollback != nil {
p.rollback()
}
}
type RowSet[T any] struct {
Table Table `json:"table"`
Manifest Manifest `json:"manifest"`
Rows []T `json:"rows,omitempty"`
}
func (s RowSet[T]) Clone() RowSet[T] {
s.Rows = cloneRows(s.Rows)
return s
}
type IndexedRowSet[T any, K comparable] struct {
RowSet[T]
Index map[K]T `json:"-"`
}
func (s IndexedRowSet[T, K]) Clone() IndexedRowSet[T, K] {
out := IndexedRowSet[T, K]{
RowSet: s.RowSet.Clone(),
Index: make(map[K]T, len(s.Index)),
}
for key, row := range s.Index {
out.Index[key] = cloneValue(row)
}
return out
}
func LoadRows[T any](source RowSource, name string) (RowSet[T], error) {
name = strings.TrimSpace(name)
if source == nil {
return RowSet[T]{}, ErrDataSourceRequired
}
if name == "" {
return RowSet[T]{}, ErrTableRequired
}
table, ok := source.Get(name)
if !ok {
return RowSet[T]{}, fmt.Errorf("%w: %s", ErrTableNotFound, name)
}
var rows []T
if err := source.Decode(name, &rows); err != nil {
var wrapped struct {
Items []T `json:"items"`
}
if wrappedErr := source.Decode(name, &wrapped); wrappedErr != nil || wrapped.Items == nil {
return RowSet[T]{}, err
}
rows = wrapped.Items
}
return RowSet[T]{
Table: table,
Manifest: source.Manifest(),
Rows: append([]T(nil), rows...),
}, nil
}
func UniqueIndex[T any, K comparable](rows []T, keyFn KeyFunc[T, K]) (map[K]T, error) {
if keyFn == nil {
return nil, ErrIndexKeyRequired
}
index := make(map[K]T, len(rows))
for _, row := range rows {
key, ok := keyFn(row)
if !ok {
continue
}
if _, exists := index[key]; exists {
return nil, fmt.Errorf("%w: %v", ErrDuplicateIndexKey, key)
}
index[key] = row
}
return index, nil
}
func MultiIndex[T any, K comparable](rows []T, keyFn KeyFunc[T, K]) (map[K][]T, error) {
if keyFn == nil {
return nil, ErrIndexKeyRequired
}
index := make(map[K][]T)
for _, row := range rows {
key, ok := keyFn(row)
if !ok {
continue
}
index[key] = append(index[key], row)
}
return cloneMultiIndex(index), nil
}
type TableView[T any] struct {
name string
mu sync.RWMutex
set RowSet[T]
}
func NewTableView[T any](name string) *TableView[T] {
return &TableView[T]{name: strings.TrimSpace(name)}
}
func (v *TableView[T]) Name() string {
if v == nil {
return ""
}
return v.name
}
func (v *TableView[T]) TableName() string {
return v.Name()
}
func (v *TableView[T]) Prepare(ctx context.Context, source RowSource) (PreparedView, error) {
if err := contextErr(ctx); err != nil {
return PreparedView{}, err
}
if v == nil {
return PreparedView{}, ErrTableRequired
}
next, err := LoadRows[T](source, v.name)
if err != nil {
return PreparedView{}, err
}
v.mu.RLock()
previous := v.set.Clone()
v.mu.RUnlock()
return NewViewWithRollback(v.name, len(next.Rows), func() {
v.mu.Lock()
v.set = next.Clone()
v.mu.Unlock()
}, func() {
v.mu.Lock()
v.set = previous.Clone()
v.mu.Unlock()
})
}
func (v *TableView[T]) Reload(ctx context.Context, source RowSource) (RowSet[T], error) {
if err := contextErr(ctx); err != nil {
return RowSet[T]{}, err
}
if v == nil {
return RowSet[T]{}, ErrTableRequired
}
next, err := LoadRows[T](source, v.name)
if err != nil {
return RowSet[T]{}, err
}
v.mu.Lock()
v.set = next.Clone()
v.mu.Unlock()
return next.Clone(), nil
}
func (v *TableView[T]) Replace(set RowSet[T]) error {
if v == nil {
return ErrTableRequired
}
if strings.TrimSpace(v.name) != strings.TrimSpace(set.Table.Name) {
return fmt.Errorf("%w: view=%s rows=%s", ErrMismatchedTableView, v.name, set.Table.Name)
}
v.mu.Lock()
v.set = set.Clone()
v.mu.Unlock()
return nil
}
func (v *TableView[T]) Snapshot() RowSet[T] {
if v == nil {
return RowSet[T]{}
}
v.mu.RLock()
set := v.set.Clone()
v.mu.RUnlock()
return set
}
func (v *TableView[T]) Rows() []T {
return v.Snapshot().Rows
}
func (v *TableView[T]) Clear() {
if v == nil {
return
}
v.mu.Lock()
v.set = RowSet[T]{Table: Table{Name: v.name}}
v.mu.Unlock()
}
type UniqueIndexView[T any, K comparable] struct {
name string
keyFn KeyFunc[T, K]
mu sync.RWMutex
set IndexedRowSet[T, K]
}
func NewUniqueIndexView[T any, K comparable](name string, keyFn KeyFunc[T, K]) *UniqueIndexView[T, K] {
return &UniqueIndexView[T, K]{
name: strings.TrimSpace(name),
keyFn: keyFn,
}
}
func (v *UniqueIndexView[T, K]) Name() string {
if v == nil {
return ""
}
return v.name
}
func (v *UniqueIndexView[T, K]) TableName() string {
return v.Name()
}
func (v *UniqueIndexView[T, K]) Prepare(ctx context.Context, source RowSource) (PreparedView, error) {
if err := contextErr(ctx); err != nil {
return PreparedView{}, err
}
if v == nil {
return PreparedView{}, ErrTableRequired
}
next, err := v.build(source)
if err != nil {
return PreparedView{}, err
}
v.mu.RLock()
previous := v.set.Clone()
v.mu.RUnlock()
return NewViewWithRollback(v.name, len(next.Rows), func() {
v.mu.Lock()
v.set = next.Clone()
v.mu.Unlock()
}, func() {
v.mu.Lock()
v.set = previous.Clone()
v.mu.Unlock()
})
}
func (v *UniqueIndexView[T, K]) Reload(ctx context.Context, source RowSource) (IndexedRowSet[T, K], error) {
if err := contextErr(ctx); err != nil {
return IndexedRowSet[T, K]{}, err
}
if v == nil {
return IndexedRowSet[T, K]{}, ErrTableRequired
}
next, err := v.build(source)
if err != nil {
return IndexedRowSet[T, K]{}, err
}
v.mu.Lock()
v.set = next.Clone()
v.mu.Unlock()
return next.Clone(), nil
}
func (v *UniqueIndexView[T, K]) Snapshot() IndexedRowSet[T, K] {
if v == nil {
return IndexedRowSet[T, K]{}
}
v.mu.RLock()
set := v.set.Clone()
v.mu.RUnlock()
return set
}
func (v *UniqueIndexView[T, K]) Rows() []T {
return v.Snapshot().Rows
}
func (v *UniqueIndexView[T, K]) Clear() {
if v == nil {
return
}
v.mu.Lock()
v.set = IndexedRowSet[T, K]{
RowSet: RowSet[T]{Table: Table{Name: v.name}},
Index: make(map[K]T),
}
v.mu.Unlock()
}
func (v *UniqueIndexView[T, K]) Get(key K) (T, bool) {
var zero T
if v == nil {
return zero, false
}
v.mu.RLock()
row, ok := v.set.Index[key]
v.mu.RUnlock()
if !ok {
return zero, false
}
return cloneValue(row), true
}
func (v *UniqueIndexView[T, K]) build(source RowSource) (IndexedRowSet[T, K], error) {
rows, err := LoadRows[T](source, v.name)
if err != nil {
return IndexedRowSet[T, K]{}, err
}
index, err := UniqueIndex(rows.Rows, v.keyFn)
if err != nil {
return IndexedRowSet[T, K]{}, err
}
return IndexedRowSet[T, K]{
RowSet: rows.Clone(),
Index: index,
}, nil
}
func cloneMultiIndex[T any, K comparable](in map[K][]T) map[K][]T {
out := make(map[K][]T, len(in))
for key, rows := range in {
out[key] = cloneRows(rows)
}
return out
}
func cloneRows[T any](rows []T) []T {
if len(rows) == 0 {
return nil
}
out := make([]T, len(rows))
for i, row := range rows {
out[i] = cloneValue(row)
}
return out
}
func cloneValue[T any](value T) T {
cloned := cloneReflect(reflect.ValueOf(value))
if !cloned.IsValid() {
return value
}
out, ok := cloned.Interface().(T)
if !ok {
return value
}
return out
}
func cloneReflect(v reflect.Value) reflect.Value {
if !v.IsValid() {
return v
}
switch v.Kind() {
case reflect.Pointer:
if v.IsNil() {
return v
}
out := reflect.New(v.Type().Elem())
elem := cloneReflect(v.Elem())
if elem.IsValid() && elem.Type().AssignableTo(v.Type().Elem()) {
out.Elem().Set(elem)
} else {
out.Elem().Set(v.Elem())
}
return out
case reflect.Slice:
if v.IsNil() {
return v
}
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
for i := 0; i < v.Len(); i++ {
setClonedValue(out.Index(i), v.Index(i))
}
return out
case reflect.Map:
if v.IsNil() {
return v
}
out := reflect.MakeMapWithSize(v.Type(), v.Len())
iter := v.MapRange()
for iter.Next() {
value := cloneReflect(iter.Value())
if value.IsValid() && value.Type().AssignableTo(v.Type().Elem()) {
out.SetMapIndex(iter.Key(), value)
} else {
out.SetMapIndex(iter.Key(), iter.Value())
}
}
return out
case reflect.Struct:
out := reflect.New(v.Type()).Elem()
for i := 0; i < v.NumField(); i++ {
if !out.Field(i).CanSet() {
return v
}
setClonedValue(out.Field(i), v.Field(i))
}
return out
case reflect.Interface:
if v.IsNil() {
return v
}
elem := cloneReflect(v.Elem())
if elem.IsValid() && elem.Type().AssignableTo(v.Type()) {
return elem
}
out := reflect.New(v.Type()).Elem()
if elem.IsValid() && elem.Type().AssignableTo(out.Type()) {
out.Set(elem)
return out
}
return v
default:
return v
}
}
func setClonedValue(dst, src reflect.Value) {
value := cloneReflect(src)
if value.IsValid() && value.Type().AssignableTo(dst.Type()) {
dst.Set(value)
return
}
dst.Set(src)
}
@@ -0,0 +1,317 @@
package datatable
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
)
var (
ErrViewRequired = errors.New("data table view binding is required")
)
type ViewRefreshResult struct {
Manifest Manifest `json:"manifest"`
Tables []string `json:"tables,omitempty"`
Views int `json:"views"`
Skipped []string `json:"skipped,omitempty"`
At time.Time `json:"at"`
Error string `json:"error,omitempty"`
}
type ViewManagerSnapshot struct {
Tables []string `json:"tables,omitempty"`
Views int `json:"views"`
LastResult ViewRefreshResult `json:"last_result"`
}
type ViewManager struct {
mu sync.RWMutex
views map[string][]ViewBinding
last ViewRefreshResult
}
func NewViewManager(bindings ...ViewBinding) (*ViewManager, error) {
manager := &ViewManager{
views: make(map[string][]ViewBinding),
}
if err := manager.Register(bindings...); err != nil {
return nil, err
}
return manager, nil
}
func (m *ViewManager) Register(bindings ...ViewBinding) error {
if m == nil {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
if m.views == nil {
m.views = make(map[string][]ViewBinding)
}
for _, binding := range bindings {
if binding == nil {
return ErrViewRequired
}
name := strings.TrimSpace(binding.TableName())
if name == "" {
return ErrTableRequired
}
m.views[name] = append(m.views[name], binding)
}
return nil
}
func (m *ViewManager) Refresh(ctx context.Context, source RowSource, load LoadResult) (ViewRefreshResult, error) {
return m.refresh(ctx, source, load.Manifest, affectedTableNames(load.Diff), false)
}
func (m *ViewManager) RefreshAll(ctx context.Context, source RowSource) (ViewRefreshResult, error) {
manifest := Manifest{}
if source != nil {
manifest = source.Manifest()
}
return m.refresh(ctx, source, manifest, nil, true)
}
func (m *ViewManager) ValidateDataTables(ctx context.Context, dataset Dataset) error {
if err := contextErr(ctx); err != nil {
return err
}
if ctx == nil {
ctx = context.Background()
}
if m == nil {
return nil
}
bindings, _, _ := m.selectBindings(nil, true)
for _, binding := range bindings {
view, err := binding.Prepare(ctx, dataset)
if err != nil {
return fmt.Errorf("prepare data table view %q: %w", binding.TableName(), err)
}
if err := view.Validate(); err != nil {
return fmt.Errorf("validate data table view %q: %w", view.Table, err)
}
}
return nil
}
func (m *ViewManager) Clear() ViewRefreshResult {
if m == nil {
return ViewRefreshResult{}
}
bindings, skipped, tables := m.selectBindings(nil, true)
result := ViewRefreshResult{
Tables: tables,
Skipped: skipped,
At: time.Now().UTC(),
}
for _, binding := range bindings {
clearer, ok := binding.(interface{ Clear() })
if !ok {
result.Skipped = append(result.Skipped, binding.TableName())
continue
}
clearer.Clear()
result.Views++
}
result.Skipped = normalizeTableNames(result.Skipped)
m.record(result)
return cloneViewRefresh(result)
}
func (m *ViewManager) Run(ctx context.Context, source RowSource, changes <-chan LoadResult, onError func(ViewRefreshResult, error)) <-chan struct{} {
if ctx == nil {
ctx = context.Background()
}
done := make(chan struct{})
if changes == nil {
close(done)
return done
}
go func() {
defer close(done)
for {
select {
case <-ctx.Done():
return
case change, ok := <-changes:
if !ok {
return
}
result, err := m.Refresh(ctx, source, change)
if err != nil && onError != nil {
onError(result, err)
}
}
}
}()
return done
}
func (m *ViewManager) Snapshot() ViewManagerSnapshot {
if m == nil {
return ViewManagerSnapshot{}
}
m.mu.RLock()
tables := make([]string, 0, len(m.views))
views := 0
for table, bindings := range m.views {
tables = append(tables, table)
views += len(bindings)
}
last := cloneViewRefresh(m.last)
m.mu.RUnlock()
sort.Strings(tables)
return ViewManagerSnapshot{
Tables: tables,
Views: views,
LastResult: last,
}
}
func (m *ViewManager) refresh(ctx context.Context, source RowSource, manifest Manifest, names []string, all bool) (ViewRefreshResult, error) {
if err := contextErr(ctx); err != nil {
return ViewRefreshResult{}, err
}
if ctx == nil {
ctx = context.Background()
}
if m == nil {
return ViewRefreshResult{}, nil
}
if source == nil {
return ViewRefreshResult{}, ErrDataSourceRequired
}
names = normalizeTableNames(names)
bindings, missing, selected := m.selectBindings(names, all)
result := ViewRefreshResult{
Manifest: manifest,
Tables: selected,
Skipped: missing,
At: time.Now().UTC(),
}
if len(bindings) == 0 {
m.record(result)
return result, nil
}
prepared := make([]PreparedView, 0, len(bindings))
for _, binding := range bindings {
view, err := binding.Prepare(ctx, source)
if err != nil {
return m.fail(result, fmt.Errorf("prepare data table view %q: %w", binding.TableName(), err))
}
prepared = append(prepared, view)
}
for _, view := range prepared {
if err := view.Validate(); err != nil {
return m.fail(result, fmt.Errorf("validate data table view %q: %w", view.Table, err))
}
}
committed := make([]PreparedView, 0, len(prepared))
for _, view := range prepared {
if err := view.Commit(); err != nil {
for i := len(committed) - 1; i >= 0; i-- {
committed[i].Rollback()
}
return m.fail(result, fmt.Errorf("commit data table view %q: %w", view.Table, err))
}
committed = append(committed, view)
}
result.Views = len(prepared)
m.record(result)
return cloneViewRefresh(result), nil
}
func (m *ViewManager) selectBindings(names []string, all bool) ([]ViewBinding, []string, []string) {
m.mu.RLock()
defer m.mu.RUnlock()
if all {
tables := make([]string, 0, len(m.views))
for table := range m.views {
tables = append(tables, table)
}
sort.Strings(tables)
out := make([]ViewBinding, 0)
for _, table := range tables {
out = append(out, m.views[table]...)
}
return out, nil, tables
}
if len(names) == 0 {
return nil, nil, nil
}
out := make([]ViewBinding, 0)
missing := make([]string, 0)
for _, name := range names {
bindings := m.views[name]
if len(bindings) == 0 {
missing = append(missing, name)
continue
}
out = append(out, bindings...)
}
return out, missing, append([]string(nil), names...)
}
func (m *ViewManager) record(result ViewRefreshResult) {
m.mu.Lock()
m.last = cloneViewRefresh(result)
m.mu.Unlock()
}
func (m *ViewManager) fail(result ViewRefreshResult, err error) (ViewRefreshResult, error) {
if err != nil {
result.Error = err.Error()
}
m.record(result)
return cloneViewRefresh(result), err
}
func affectedTableNames(diff Diff) []string {
names := make([]string, 0, len(diff.Added)+len(diff.Changed)+len(diff.Removed))
for _, change := range diff.Added {
names = append(names, change.Name)
}
for _, change := range diff.Changed {
names = append(names, change.Name)
}
for _, change := range diff.Removed {
names = append(names, change.Name)
}
return normalizeTableNames(names)
}
func normalizeTableNames(names []string) []string {
if len(names) == 0 {
return nil
}
seen := make(map[string]struct{}, len(names))
out := make([]string, 0, len(names))
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
sort.Strings(out)
return out
}
func cloneViewRefresh(result ViewRefreshResult) ViewRefreshResult {
result.Tables = append([]string(nil), result.Tables...)
result.Skipped = append([]string(nil), result.Skipped...)
return result
}