before merge to main
This commit is contained in:
4900
data/perf-benchmark/perf_items.jsonl
Normal file
4900
data/perf-benchmark/perf_items.jsonl
Normal file
File diff suppressed because it is too large
Load Diff
2
data/secret_users.jsonl
Normal file
2
data/secret_users.jsonl
Normal file
@@ -0,0 +1,2 @@
|
||||
4jywagSLv0grz1G1ZoRu484Abu5GXaNYA9LsMmRQIxmTMuFczm4jRGYZm4JwQ+8YuWhjsOs0CRyOtB+dg0skg176sz9ES85DPGGiemJB9bZBKORMB+O4UL9dH5j9
|
||||
poBf6u/pAsjCFu0twwoSHspgUXXNGliXHoJ8eqtL82demTjpTo9+SXzbUSaAUsUgdy1XQJZlncOIWeTlxgfOJXuqpXPpqNJtxyZd1E/9+jOgRreQXOmyMg==
|
||||
2
data/secret_users.jsonl.bak
Normal file
2
data/secret_users.jsonl.bak
Normal file
@@ -0,0 +1,2 @@
|
||||
4jywagSLv0grz1G1ZoRu484Abu5GXaNYA9LsMmRQIxmTMuFczm4jRGYZm4JwQ+8YuWhjsOs0CRyOtB+dg0skg176sz9ES85DPGGiemJB9bZBKORMB+O4UL9dH5j9
|
||||
poBf6u/pAsjCFu0twwoSHspgUXXNGliXHoJ8eqtL82demTjpTo9+SXzbUSaAUsUgdy1XQJZlncOIWeTlxgfOJXuqpXPpqNJtxyZd1E/9+jOgRreQXOmyMg==
|
||||
4
data/test-alloc-overflow/tiny.jsonl
Normal file
4
data/test-alloc-overflow/tiny.jsonl
Normal file
@@ -0,0 +1,4 @@
|
||||
{"id":1,"x":"short"}
|
||||
{"id":1,"x":"short"}
|
||||
{"id":1,"x":"short"}
|
||||
{"id":1,"x":"short"}
|
||||
@@ -1 +0,0 @@
|
||||
{"createdAt":1755088501,"email":"test@example.com","id":1,"isActive":true,"role":"user","username":"testuser"}
|
||||
@@ -1,2 +1,2 @@
|
||||
{"age":31,"created":"2026-03-04T09:07:22+06:00","email":"john@example.com","id":1,"name":"John Doe"}
|
||||
{"age":35,"created":"2026-03-04T09:07:22+06:00","email":"bob@example.com","id":3,"name":"Bob Johnson"}
|
||||
{"age":31,"created":"2026-03-04T13:58:25+06:00","email":"john@example.com","id":1,"name":"John Doe"}
|
||||
{"age":35,"created":"2026-03-04T13:58:25+06:00","email":"bob@example.com","id":3,"name":"Bob Johnson"}
|
||||
|
||||
74
examples/custom-serializer/main.go
Normal file
74
examples/custom-serializer/main.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Пример использования KeyOrder в опциях коллекции — встроенная сортировка ключей при сериализации.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"linedb/pkg/linedb"
|
||||
)
|
||||
|
||||
// Item — структура для вставки (LineDB поддерживает struct и map)
|
||||
type Item struct {
|
||||
Name string `json:"name"`
|
||||
Value int `json:"value"`
|
||||
Active bool `json:"active"`
|
||||
Atime string `json:"atime"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.RemoveAll("./data")
|
||||
|
||||
// KeyOrder в опциях коллекции — при задании используется кастомная сериализация с порядком ключей
|
||||
initOptions := &linedb.LineDbInitOptions{
|
||||
CacheSize: 100,
|
||||
CacheTTL: time.Minute,
|
||||
DBFolder: "./data",
|
||||
Collections: []linedb.JSONLFileOptions{
|
||||
{
|
||||
CollectionName: "items",
|
||||
AllocSize: 256,
|
||||
KeyOrder: []linedb.KeyOrder{
|
||||
{Key: "id", Order: 0},
|
||||
{Key: "atime", Order: -1},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
db := linedb.NewLineDb(nil)
|
||||
if err := db.Init(false, initOptions); err != nil {
|
||||
log.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
opts := linedb.LineDbAdapterOptions{}
|
||||
|
||||
// Инсерты: map и структуры (LineDB принимает оба)
|
||||
items := []any{
|
||||
map[string]any{"Value": 42, "Name": "Test", "Active": true, "atime": "2024-01-01"},
|
||||
map[string]any{"Active": false, "Name": "Alice", "Value": 100, "atime": "2024-01-02"},
|
||||
map[string]any{"Name": "Bob", "Value": 0, "Active": true, "atime": "2024-01-03"},
|
||||
Item{Name: "Charlie", Value: 7, Active: true, Atime: "2024-01-04"},
|
||||
Item{Name: "Diana", Value: 99, Active: false, Atime: "2024-01-05"},
|
||||
}
|
||||
if err := db.Insert(items, "items", opts); err != nil {
|
||||
log.Fatalf("Insert failed: %v", err)
|
||||
}
|
||||
|
||||
// Read
|
||||
all, err := db.Read("items", opts)
|
||||
if err != nil {
|
||||
log.Fatalf("Read failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Records: %d\n", len(all))
|
||||
for _, r := range all {
|
||||
fmt.Printf(" %+v\n", r)
|
||||
}
|
||||
|
||||
raw, _ := os.ReadFile("./data/items.jsonl")
|
||||
fmt.Printf("\nRaw file:\n%s\n", string(raw))
|
||||
}
|
||||
61
examples/encode/main.go
Normal file
61
examples/encode/main.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Пример использования Encode — шифрование записей в коллекции (AES-256-GCM).
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"linedb/pkg/linedb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.RemoveAll("./data")
|
||||
|
||||
initOptions := &linedb.LineDbInitOptions{
|
||||
CacheSize: 100,
|
||||
CacheTTL: time.Minute,
|
||||
DBFolder: "./data",
|
||||
Collections: []linedb.JSONLFileOptions{
|
||||
{
|
||||
CollectionName: "secret_users",
|
||||
AllocSize: 256,
|
||||
Encode: true,
|
||||
EncodeKey: "my-secret-password-12345",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
db := linedb.NewLineDb(nil)
|
||||
if err := db.Init(false, initOptions); err != nil {
|
||||
log.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
opts := linedb.LineDbAdapterOptions{}
|
||||
|
||||
// Вставка
|
||||
users := []any{
|
||||
map[string]any{"name": "alice", "email": "alice@secret.com", "role": "admin"},
|
||||
map[string]any{"name": "bob", "email": "bob@secret.com", "role": "user"},
|
||||
}
|
||||
if err := db.Insert(users, "secret_users", opts); err != nil {
|
||||
log.Fatalf("Insert failed: %v", err)
|
||||
}
|
||||
fmt.Println("Insert OK")
|
||||
|
||||
// Чтение
|
||||
all, err := db.Read("secret_users", opts)
|
||||
if err != nil {
|
||||
log.Fatalf("Read failed: %v", err)
|
||||
}
|
||||
fmt.Printf("Records: %d\n", len(all))
|
||||
for _, r := range all {
|
||||
fmt.Printf(" %+v\n", r)
|
||||
}
|
||||
|
||||
// Файл содержит зашифрованные строки (base64)
|
||||
raw, _ := os.ReadFile("./data/secret_users.jsonl")
|
||||
fmt.Printf("\nRaw file (encrypted base64):\n%s\n", string(raw))
|
||||
}
|
||||
206
examples/perf/main.go
Normal file
206
examples/perf/main.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// Пример комплексного performance-теста LineDb.
|
||||
// Запуск:
|
||||
//
|
||||
// go run ./examples/perf/main.go
|
||||
//
|
||||
// Сценарий:
|
||||
// 1. Вставка 5000 записей, каждая ~1800 символов.
|
||||
// 2. 10 случайных обновлений, замер среднего времени.
|
||||
// 3. 100 случайных удалений, замер среднего времени.
|
||||
// 4. Замеры памяти процесса через runtime.MemStats.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"linedb/pkg/linedb"
|
||||
)
|
||||
|
||||
const (
|
||||
recordsCount = 5000
|
||||
payloadSize = 1800
|
||||
updateOps = 100
|
||||
deleteOps = 100
|
||||
collectionName = "perf_items"
|
||||
dbDir = "./data/perf-benchmark"
|
||||
allocSizeEstimate = 2048
|
||||
)
|
||||
|
||||
func main() {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||
log.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
initOptions := &linedb.LineDbInitOptions{
|
||||
CacheSize: 1000,
|
||||
CacheTTL: time.Minute,
|
||||
DBFolder: dbDir,
|
||||
Collections: []linedb.JSONLFileOptions{
|
||||
{
|
||||
CollectionName: collectionName,
|
||||
AllocSize: allocSizeEstimate,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
db := linedb.NewLineDb(nil)
|
||||
if err := db.Init(true, initOptions); err != nil {
|
||||
log.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
printMem("Before insert")
|
||||
|
||||
fmt.Printf("1) Insert %d records (payload ~%d chars)...\n", recordsCount, payloadSize)
|
||||
start := time.Now()
|
||||
if err := insertRecords(db); err != nil {
|
||||
log.Fatalf("InsertRecords failed: %v", err)
|
||||
}
|
||||
elapsedInsert := time.Since(start)
|
||||
fmt.Printf(" Total insert time: %v, per record: %v\n",
|
||||
elapsedInsert, elapsedInsert/time.Duration(recordsCount))
|
||||
printMem("After insert")
|
||||
|
||||
all, err := db.Read(collectionName, linedb.LineDbAdapterOptions{})
|
||||
if err != nil {
|
||||
log.Fatalf("Read after insert failed: %v", err)
|
||||
}
|
||||
fmt.Printf(" Records in collection: %d\n", len(all))
|
||||
|
||||
ids := collectIDs(all)
|
||||
if len(ids) == 0 {
|
||||
log.Fatalf("No IDs collected, cannot continue")
|
||||
}
|
||||
|
||||
fmt.Printf("\n2) Random update of %d records...\n", updateOps)
|
||||
avgUpdate := benchmarkUpdates(db, ids, updateOps)
|
||||
fmt.Printf(" Average update time: %v\n", avgUpdate)
|
||||
printMem("After updates")
|
||||
|
||||
fmt.Printf("\n3) Random delete of %d records...\n", deleteOps)
|
||||
avgDelete := benchmarkDeletes(db, ids, deleteOps)
|
||||
fmt.Printf(" Average delete time: %v\n", avgDelete)
|
||||
printMem("After deletes")
|
||||
|
||||
final, err := db.Read(collectionName, linedb.LineDbAdapterOptions{})
|
||||
if err != nil {
|
||||
log.Fatalf("Final read failed: %v", err)
|
||||
}
|
||||
fmt.Printf("\nFinal records in collection: %d\n", len(final))
|
||||
fmt.Printf("Data directory: %s\n", dbDir)
|
||||
}
|
||||
|
||||
func insertRecords(db *linedb.LineDb) error {
|
||||
// Сгенерируем базовый payload
|
||||
base := strings.Repeat("X", payloadSize)
|
||||
|
||||
batch := make([]any, 0, 100)
|
||||
for i := 0; i < recordsCount; i++ {
|
||||
rec := map[string]any{
|
||||
"index": i,
|
||||
"payload": base,
|
||||
"created": time.Now().Format(time.RFC3339Nano),
|
||||
}
|
||||
batch = append(batch, rec)
|
||||
if len(batch) >= cap(batch) {
|
||||
if err := db.Insert(batch, collectionName, linedb.LineDbAdapterOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
if err := db.Insert(batch, collectionName, linedb.LineDbAdapterOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectIDs(all []any) []any {
|
||||
ids := make([]any, 0, len(all))
|
||||
for _, r := range all {
|
||||
if m, ok := r.(map[string]any); ok {
|
||||
if id, ok := m["id"]; ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func benchmarkUpdates(db *linedb.LineDb, ids []any, ops int) time.Duration {
|
||||
if len(ids) == 0 {
|
||||
return 0
|
||||
}
|
||||
var total time.Duration
|
||||
for i := 0; i < ops; i++ {
|
||||
id := ids[rand.Intn(len(ids))]
|
||||
update := map[string]any{
|
||||
"updatedAt": time.Now().Format(time.RFC3339Nano),
|
||||
}
|
||||
start := time.Now()
|
||||
_, err := db.Update(update, collectionName, map[string]any{"id": id}, linedb.LineDbAdapterOptions{})
|
||||
dur := time.Since(start)
|
||||
if err != nil {
|
||||
log.Printf("Update error (id=%v): %v", id, err)
|
||||
continue
|
||||
}
|
||||
total += dur
|
||||
}
|
||||
if ops == 0 {
|
||||
return 0
|
||||
}
|
||||
return total / time.Duration(ops)
|
||||
}
|
||||
|
||||
func benchmarkDeletes(db *linedb.LineDb, ids []any, ops int) time.Duration {
|
||||
if len(ids) == 0 {
|
||||
return 0
|
||||
}
|
||||
var total time.Duration
|
||||
used := make(map[int]struct{})
|
||||
for i := 0; i < ops; i++ {
|
||||
// чтобы не удалять по одному и тому же id постоянно, постараемся брать разные индексы
|
||||
var idx int
|
||||
for tries := 0; tries < 10; tries++ {
|
||||
idx = rand.Intn(len(ids))
|
||||
if _, ok := used[idx]; !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
used[idx] = struct{}{}
|
||||
id := ids[idx]
|
||||
|
||||
start := time.Now()
|
||||
_, err := db.Delete(map[string]any{"id": id}, collectionName, linedb.LineDbAdapterOptions{})
|
||||
dur := time.Since(start)
|
||||
if err != nil {
|
||||
log.Printf("Delete error (id=%v): %v", id, err)
|
||||
continue
|
||||
}
|
||||
total += dur
|
||||
}
|
||||
if ops == 0 {
|
||||
return 0
|
||||
}
|
||||
return total / time.Duration(ops)
|
||||
}
|
||||
|
||||
func printMem(label string) {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
fmt.Printf("\n=== %s ===\n", label)
|
||||
fmt.Printf(" Alloc: %.2f MB\n", float64(m.Alloc)/1024.0/1024.0)
|
||||
fmt.Printf(" TotalAlloc: %.2f MB\n", float64(m.TotalAlloc)/1024.0/1024.0)
|
||||
fmt.Printf(" Sys: %.2f MB\n", float64(m.Sys)/1024.0/1024.0)
|
||||
fmt.Printf(" NumGC: %d\n", m.NumGC)
|
||||
}
|
||||
95
examples/test-alloc-overflow/main.go
Normal file
95
examples/test-alloc-overflow/main.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Тест: ошибка при записи, превышающей allocSize-1.
|
||||
// Запуск: go run ./examples/test-alloc-overflow/main.go
|
||||
// Файлы остаются в ./data/test-alloc-overflow/
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"linedb/pkg/linedb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dataDir := "./data/test-alloc-overflow"
|
||||
os.MkdirAll(dataDir, 0755)
|
||||
|
||||
initOptions := &linedb.LineDbInitOptions{
|
||||
CacheSize: 100,
|
||||
CacheTTL: time.Minute,
|
||||
DBFolder: dataDir,
|
||||
Collections: []linedb.JSONLFileOptions{
|
||||
{CollectionName: "tiny", AllocSize: 64},
|
||||
},
|
||||
}
|
||||
|
||||
db := linedb.NewLineDb(nil)
|
||||
if err := db.Init(false, initOptions); err != nil {
|
||||
log.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
opts := linedb.LineDbAdapterOptions{}
|
||||
|
||||
// Короткая запись — должна пройти
|
||||
fmt.Println("1. Insert короткой записи...")
|
||||
if err := db.Insert(map[string]any{"x": "short"}, "tiny", opts); err != nil {
|
||||
log.Fatalf("Insert short failed: %v", err)
|
||||
}
|
||||
fmt.Println(" OK")
|
||||
|
||||
// Длинная — ожидаем ошибку
|
||||
fmt.Println("2. Insert длинной записи (ожидаем ошибку)...")
|
||||
longStr := strings.Repeat("a", 80)
|
||||
err := db.Insert(map[string]any{"x": longStr}, "tiny", opts)
|
||||
if err == nil {
|
||||
log.Fatal("Ожидалась ошибка, но Insert прошёл")
|
||||
}
|
||||
fmt.Printf(" Ошибка (ожидаемо): %v\n", err)
|
||||
if !strings.Contains(err.Error(), "exceeds") {
|
||||
log.Fatalf("Ожидалось 'exceeds' в ошибке, получено: %v", err)
|
||||
}
|
||||
fmt.Println(" OK — ошибка корректная")
|
||||
|
||||
// Итог
|
||||
all, _ := db.Read("tiny", opts)
|
||||
fmt.Printf("\nЗаписей в коллекции после insert-теста: %d\n", len(all))
|
||||
|
||||
//короткий update
|
||||
fmt.Println("\n3. Update с коротким значением...")
|
||||
_, err = db.Update(
|
||||
map[string]any{"x": "short"},
|
||||
"tiny",
|
||||
map[string]any{"x": "short"},
|
||||
opts,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Update short failed: %v", err)
|
||||
}
|
||||
fmt.Println(" OK")
|
||||
|
||||
// Длинный update — ожидаем ошибку при переписывании файла
|
||||
fmt.Println("\n4. Update с длинным значением (ожидаем ошибку)...")
|
||||
longUpdate := strings.Repeat("b", 80)
|
||||
_, err = db.Update(
|
||||
map[string]any{"x": longUpdate},
|
||||
"tiny",
|
||||
map[string]any{"x": "short"},
|
||||
opts,
|
||||
)
|
||||
if err == nil {
|
||||
log.Fatal("Ожидалась ошибка, но Update прошёл")
|
||||
}
|
||||
fmt.Printf(" Ошибка (ожидаемо): %v\n", err)
|
||||
if !strings.Contains(err.Error(), "exceeds") {
|
||||
log.Fatalf("Ожидалось 'exceeds' в ошибке, получено: %v", err)
|
||||
}
|
||||
fmt.Println(" OK — ошибка при update корректная")
|
||||
|
||||
all, _ = db.Read("tiny", opts)
|
||||
fmt.Printf("\nЗаписей в коллекции после update-теста: %d\n", len(all))
|
||||
fmt.Printf("Файл сохранён: %s/tiny.jsonl\n", dataDir)
|
||||
}
|
||||
57
examples/test-init-normalize/main.go
Normal file
57
examples/test-init-normalize/main.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Тест: нормализация существующего файла при Init (приведение записей к allocSize-1).
|
||||
// Запуск: go run ./examples/test-init-normalize/main.go
|
||||
// Файлы остаются в ./data/test-init-normalize/
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"linedb/pkg/linedb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dataDir := "./data/test-init-normalize"
|
||||
os.MkdirAll(dataDir, 0755)
|
||||
|
||||
// Создаём файл с записями разной длины ДО Init
|
||||
filePath := dataDir + "/norm.jsonl"
|
||||
content := `{"id":1,"a":"x"}
|
||||
{"id":2,"a":"yy"}
|
||||
{"id":3,"a":"zzz"} `
|
||||
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
|
||||
log.Fatalf("Write file: %v", err)
|
||||
}
|
||||
fmt.Printf("Создан файл с записями разной длины:\n%s\n", content)
|
||||
|
||||
initOptions := &linedb.LineDbInitOptions{
|
||||
CacheSize: 100,
|
||||
CacheTTL: time.Minute,
|
||||
DBFolder: dataDir,
|
||||
Collections: []linedb.JSONLFileOptions{
|
||||
{CollectionName: "norm", AllocSize: 128},
|
||||
},
|
||||
}
|
||||
|
||||
db := linedb.NewLineDb(nil)
|
||||
fmt.Println("\nInit (нормализация записей к allocSize-1=99)...")
|
||||
if err := db.Init(false, initOptions); err != nil {
|
||||
log.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
all, err := db.Read("norm", linedb.LineDbAdapterOptions{})
|
||||
if err != nil {
|
||||
log.Fatalf("Read failed: %v", err)
|
||||
}
|
||||
fmt.Printf("Прочитано записей: %d\n", len(all))
|
||||
for i, r := range all {
|
||||
fmt.Printf(" [%d] %+v\n", i+1, r)
|
||||
}
|
||||
|
||||
raw, _ := os.ReadFile(filePath)
|
||||
fmt.Printf("\nФайл после нормализации:\n%s\n", string(raw))
|
||||
fmt.Printf("Файл сохранён: %s\n", filePath)
|
||||
}
|
||||
1
pkg/linedb/Untitled
Normal file
1
pkg/linedb/Untitled
Normal file
@@ -0,0 +1 @@
|
||||
д
|
||||
@@ -2,11 +2,17 @@ package linedb
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -23,6 +29,151 @@ func defaultJSONUnmarshal(data []byte, v any) error {
|
||||
return goccyjson.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
// makeKeyOrderMarshal создаёт JSONMarshal с заданным порядком ключей
|
||||
func makeKeyOrderMarshal(keyOrder []KeyOrder) func(any) ([]byte, error) {
|
||||
return func(v any) ([]byte, error) {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
return marshalMapSorted(m, keyOrder)
|
||||
}
|
||||
return goccyjson.Marshal(v)
|
||||
}
|
||||
}
|
||||
|
||||
func marshalMapSorted(m map[string]any, keyOrder []KeyOrder) ([]byte, error) {
|
||||
orderMap := make(map[string]int)
|
||||
for _, ko := range keyOrder {
|
||||
orderMap[ko.Key] = ko.Order
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
oi, hasI := orderMap[keys[i]]
|
||||
oj, hasJ := orderMap[keys[j]]
|
||||
group := func(o int, has bool) int {
|
||||
if !has {
|
||||
return 1
|
||||
}
|
||||
if o >= 0 {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
gi, gj := group(oi, hasI), group(oj, hasJ)
|
||||
if gi != gj {
|
||||
return gi < gj
|
||||
}
|
||||
switch gi {
|
||||
case 0:
|
||||
return oi < oj
|
||||
case 1:
|
||||
return keys[i] < keys[j]
|
||||
default:
|
||||
return oi < oj
|
||||
}
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
keyEscaped, _ := goccyjson.Marshal(k)
|
||||
buf.Write(keyEscaped)
|
||||
buf.WriteByte(':')
|
||||
val := m[k]
|
||||
if nested, ok := val.(map[string]any); ok {
|
||||
valBytes, err := marshalMapSorted(nested, keyOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf.Write(valBytes)
|
||||
} else {
|
||||
valBytes, err := goccyjson.Marshal(val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf.Write(valBytes)
|
||||
}
|
||||
}
|
||||
buf.WriteByte('}')
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// encodeKeyBytes превращает строку ключа в 32 байта (SHA256) для AES-256
|
||||
func encodeKeyBytes(keyStr string) []byte {
|
||||
h := sha256.Sum256([]byte(keyStr))
|
||||
return h[:]
|
||||
}
|
||||
|
||||
// aesGCMEncrypt — встроенное AES-256-GCM шифрование
|
||||
func aesGCMEncrypt(plaintext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, aesgcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aesgcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// aesGCMDecrypt — встроенная расшифровка AES-256-GCM
|
||||
func aesGCMDecrypt(ciphertext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonceSize := aesgcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
return aesgcm.Open(nil, nonce, ciphertext, nil)
|
||||
}
|
||||
|
||||
// makeEncodedMarshal оборачивает marshal в шифрование: marshal -> encrypt -> base64
|
||||
func makeEncodedMarshal(marshal func(any) ([]byte, error), encFn func([]byte, []byte) ([]byte, error), key []byte) func(any) ([]byte, error) {
|
||||
return func(v any) ([]byte, error) {
|
||||
jsonData, err := marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encrypted, err := encFn(jsonData, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(base64.StdEncoding.EncodeToString(encrypted)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// makeEncodedUnmarshal оборачивает unmarshal в дешифрование: base64 -> decrypt -> unmarshal
|
||||
func makeEncodedUnmarshal(unmarshal func([]byte, any) error, decFn func([]byte, []byte) ([]byte, error), key []byte) func([]byte, any) error {
|
||||
return func(data []byte, v any) error {
|
||||
encrypted, err := base64.StdEncoding.DecodeString(string(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plaintext, err := decFn(encrypted, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return unmarshal(plaintext, v)
|
||||
}
|
||||
}
|
||||
|
||||
// JSONLFile представляет адаптер для работы с JSONL файлами
|
||||
type JSONLFile struct {
|
||||
filename string
|
||||
@@ -61,14 +212,31 @@ func NewJSONLFile(filename string, cypherKey string, options JSONLFileOptions) *
|
||||
jsonMarshal := defaultJSONMarshal
|
||||
jsonUnmarshal := defaultJSONUnmarshal
|
||||
|
||||
// Используем пользовательские функции если они предоставлены
|
||||
if options.JSONMarshal != nil {
|
||||
jsonMarshal = options.JSONMarshal
|
||||
} else if len(options.KeyOrder) > 0 {
|
||||
jsonMarshal = makeKeyOrderMarshal(options.KeyOrder)
|
||||
}
|
||||
|
||||
if options.JSONUnmarshal != nil {
|
||||
jsonUnmarshal = options.JSONUnmarshal
|
||||
}
|
||||
|
||||
// Encode: оборачиваем marshal/unmarshal в шифрование
|
||||
if options.Encode && options.EncodeKey != "" {
|
||||
key := encodeKeyBytes(options.EncodeKey)
|
||||
encFn := options.EncryptFn
|
||||
decFn := options.DecryptFn
|
||||
if encFn == nil {
|
||||
encFn = aesGCMEncrypt
|
||||
}
|
||||
if decFn == nil {
|
||||
decFn = aesGCMDecrypt
|
||||
}
|
||||
jsonMarshal = makeEncodedMarshal(jsonMarshal, encFn, key)
|
||||
jsonUnmarshal = makeEncodedUnmarshal(jsonUnmarshal, decFn, key)
|
||||
}
|
||||
|
||||
return &JSONLFile{
|
||||
filename: filename,
|
||||
cypherKey: cypherKey,
|
||||
@@ -99,18 +267,87 @@ func (j *JSONLFile) Init(force bool, options LineDbAdapterOptions) error {
|
||||
}
|
||||
|
||||
// Создаем файл если не существует
|
||||
if _, err := os.Stat(j.filename); os.IsNotExist(err) {
|
||||
if st, err := os.Stat(j.filename); os.IsNotExist(err) {
|
||||
file, err := os.Create(j.filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
file.Close()
|
||||
} else if err == nil && st.Size() > 0 {
|
||||
// Файл существует и не пустой — проверяем и нормализуем записи
|
||||
if err := j.normalizeExistingFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
j.initialized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeExistingFile проверяет размеры записей и приводит к allocSize-1
|
||||
func (j *JSONLFile) normalizeExistingFile() error {
|
||||
data, err := os.ReadFile(j.filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
|
||||
var nonEmpty []string
|
||||
for _, l := range lines {
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
nonEmpty = append(nonEmpty, l)
|
||||
}
|
||||
if len(nonEmpty) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Максимальная длина среди записей
|
||||
maxLen := 0
|
||||
for _, l := range nonEmpty {
|
||||
if len(l) > maxLen {
|
||||
maxLen = len(l)
|
||||
}
|
||||
}
|
||||
|
||||
// Приводим короткие к maxLen (добавляем пробелы)
|
||||
for i, l := range nonEmpty {
|
||||
if len(l) < maxLen {
|
||||
nonEmpty[i] = l + strings.Repeat(" ", maxLen-len(l))
|
||||
}
|
||||
}
|
||||
|
||||
targetLen := j.allocSize - 1
|
||||
if targetLen < 1 {
|
||||
targetLen = 1
|
||||
}
|
||||
|
||||
if targetLen < maxLen {
|
||||
// Уменьшаем: можно только если данные (без trailing spaces) помещаются
|
||||
for i := range nonEmpty {
|
||||
trimmed := strings.TrimRight(nonEmpty[i], " ")
|
||||
if len(trimmed) > targetLen {
|
||||
return fmt.Errorf("init failed: record data size %d exceeds configured allocSize-1 (%d), cannot reduce without data loss",
|
||||
len(trimmed), targetLen)
|
||||
}
|
||||
nonEmpty[i] = trimmed + strings.Repeat(" ", targetLen-len(trimmed))
|
||||
}
|
||||
} else if targetLen > maxLen {
|
||||
// Расширяем
|
||||
for i := range nonEmpty {
|
||||
nonEmpty[i] = nonEmpty[i] + strings.Repeat(" ", targetLen-len(nonEmpty[i]))
|
||||
}
|
||||
}
|
||||
|
||||
// Перезаписываем файл
|
||||
var buf bytes.Buffer
|
||||
for _, l := range nonEmpty {
|
||||
buf.WriteString(l)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
return os.WriteFile(j.filename, buf.Bytes(), 0644)
|
||||
}
|
||||
|
||||
// Read читает все записи из файла
|
||||
func (j *JSONLFile) Read(options LineDbAdapterOptions) ([]any, error) {
|
||||
j.mutex.RLock()
|
||||
@@ -135,8 +372,8 @@ func (j *JSONLFile) Read(options LineDbAdapterOptions) ([]any, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Расшифровываем если нужно
|
||||
if j.cypherKey != "" {
|
||||
// Расшифровываем если нужно (только cypherKey; Encode обрабатывается в jsonUnmarshal)
|
||||
if j.cypherKey != "" && !j.options.Encode {
|
||||
decoded, err := base64.StdEncoding.DecodeString(line)
|
||||
if err != nil {
|
||||
if j.options.SkipInvalidLines {
|
||||
@@ -189,14 +426,18 @@ func (j *JSONLFile) Write(data any, options LineDbAdapterOptions) error {
|
||||
|
||||
line := string(jsonData)
|
||||
|
||||
// Шифруем если нужно
|
||||
if j.cypherKey != "" {
|
||||
// Шифруем если нужно (только cypherKey; Encode уже в jsonMarshal)
|
||||
if j.cypherKey != "" && !j.options.Encode {
|
||||
line = base64.StdEncoding.EncodeToString([]byte(line))
|
||||
}
|
||||
|
||||
// Дополняем до allocSize
|
||||
if len(line) < j.allocSize {
|
||||
line += strings.Repeat(" ", j.allocSize-len(line)-1)
|
||||
maxLineLen := j.allocSize - 1
|
||||
if len(line) > maxLineLen {
|
||||
return fmt.Errorf("record size %d exceeds allocSize-1 (%d)", len(line), maxLineLen)
|
||||
}
|
||||
// Дополняем до allocSize-1
|
||||
if len(line) < maxLineLen {
|
||||
line += strings.Repeat(" ", maxLineLen-len(line))
|
||||
}
|
||||
|
||||
if _, err := file.WriteString(line + "\n"); err != nil {
|
||||
@@ -406,14 +647,18 @@ func (j *JSONLFile) rewriteFile(records []any) error {
|
||||
|
||||
line := string(jsonData)
|
||||
|
||||
// Шифруем если нужно
|
||||
if j.cypherKey != "" {
|
||||
// Шифруем если нужно (только cypherKey; Encode уже в jsonMarshal)
|
||||
if j.cypherKey != "" && !j.options.Encode {
|
||||
line = base64.StdEncoding.EncodeToString([]byte(line))
|
||||
}
|
||||
|
||||
// Дополняем до allocSize
|
||||
if len(line) < j.allocSize {
|
||||
line += strings.Repeat(" ", j.allocSize-len(line)-1)
|
||||
maxLineLen := j.allocSize - 1
|
||||
if len(line) > maxLineLen {
|
||||
return fmt.Errorf("record size %d exceeds allocSize-1 (%d)", len(line), maxLineLen)
|
||||
}
|
||||
// Дополняем до allocSize-1
|
||||
if len(line) < maxLineLen {
|
||||
line += strings.Repeat(" ", maxLineLen-len(line))
|
||||
}
|
||||
|
||||
if _, err := file.WriteString(line + "\n"); err != nil {
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
goccyjson "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// LineDb представляет основную базу данных
|
||||
@@ -159,9 +161,9 @@ func (db *LineDb) Insert(data any, collectionName string, options LineDbAdapterO
|
||||
resultDataArray := make([]any, 0, len(dataArray))
|
||||
|
||||
for _, item := range dataArray {
|
||||
itemMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid data format")
|
||||
itemMap, err := db.toMap(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid data format: %w", err)
|
||||
}
|
||||
|
||||
// Генерируем ID если отсутствует
|
||||
@@ -220,7 +222,12 @@ func (db *LineDb) Insert(data any, collectionName string, options LineDbAdapterO
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем уникальность полей из UniqueFields
|
||||
// Проверяем обязательные поля (required)
|
||||
if err := db.checkRequiredFieldsInsert(itemMap, collectionName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Проверяем уникальность полей
|
||||
if err := db.checkUniqueFieldsInsert(itemMap, collectionName, resultDataArray, options); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -292,24 +299,32 @@ func (db *LineDb) Update(data any, collectionName string, filter any, options Li
|
||||
collectionName = db.getFirstCollection()
|
||||
}
|
||||
|
||||
// Конвертируем data в map (struct или map)
|
||||
dataMap, err := db.toMap(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid update data format: %w", err)
|
||||
}
|
||||
|
||||
// Проверяем конфликт ID
|
||||
if dataMap, ok := data.(map[string]any); ok {
|
||||
if filterMap, ok := filter.(map[string]any); ok {
|
||||
if dataMap["id"] != nil && filterMap["id"] != nil {
|
||||
if !db.compareIDs(dataMap["id"], filterMap["id"]) {
|
||||
return nil, fmt.Errorf("you can not update record id with filter by another id. Use delete and insert instead")
|
||||
}
|
||||
if filterMap, ok := filter.(map[string]any); ok {
|
||||
if dataMap["id"] != nil && filterMap["id"] != nil {
|
||||
if !db.compareIDs(dataMap["id"], filterMap["id"]) {
|
||||
return nil, fmt.Errorf("you can not update record id with filter by another id. Use delete and insert instead")
|
||||
}
|
||||
}
|
||||
// Проверяем уникальность полей из UniqueFields
|
||||
if err := db.checkUniqueFieldsUpdate(dataMap, filter, collectionName, options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Проверяем обязательные поля (required)
|
||||
if err := db.checkRequiredFieldsUpdate(dataMap, collectionName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Проверяем уникальность полей
|
||||
if err := db.checkUniqueFieldsUpdate(dataMap, filter, collectionName, options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Проверяем партиционирование
|
||||
if db.isCollectionPartitioned(collectionName) {
|
||||
return db.updatePartitioned(data, collectionName, filter, options)
|
||||
return db.updatePartitioned(dataMap, collectionName, filter, options)
|
||||
}
|
||||
|
||||
// Обычное обновление
|
||||
@@ -318,7 +333,7 @@ func (db *LineDb) Update(data any, collectionName string, filter any, options Li
|
||||
return nil, fmt.Errorf("collection %s not found", collectionName)
|
||||
}
|
||||
|
||||
return adapter.Update(data, filter, options)
|
||||
return adapter.Update(dataMap, filter, options)
|
||||
}
|
||||
|
||||
// Delete удаляет записи из коллекции
|
||||
@@ -499,7 +514,7 @@ func (db *LineDb) getCollectionOptions(collectionName string) *JSONLFileOptions
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValueEmpty проверяет, считается ли значение "пустым" (пропускаем проверку уникальности для пустых)
|
||||
// isValueEmpty проверяет, считается ли значение "пустым" (nil или "" для string) — для обратной совместимости
|
||||
func (db *LineDb) isValueEmpty(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
@@ -510,20 +525,119 @@ func (db *LineDb) isValueEmpty(v any) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isEmptyByMode проверяет, пусто ли значение по заданному режиму
|
||||
func (db *LineDb) isEmptyByMode(m map[string]any, key string, mode EmptyValueMode) bool {
|
||||
if mode == 0 {
|
||||
mode = DefaultEmptyModeForUnique
|
||||
}
|
||||
v, exists := m[key]
|
||||
if mode&EmptyModeAbsentKey != 0 && !exists {
|
||||
return true
|
||||
}
|
||||
if mode&EmptyModeNil != 0 && v == nil {
|
||||
return true
|
||||
}
|
||||
if mode&EmptyModeZeroValue != 0 {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val == ""
|
||||
case int:
|
||||
return val == 0
|
||||
case int64:
|
||||
return val == 0
|
||||
case float64:
|
||||
return val == 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getUniqueFieldConstraints возвращает поля с unique и их EmptyMode (из FieldConstraints или UniqueFields)
|
||||
func (db *LineDb) getUniqueFieldConstraints(collectionName string) []FieldConstraint {
|
||||
opts := db.getCollectionOptions(collectionName)
|
||||
if opts == nil {
|
||||
return nil
|
||||
}
|
||||
if len(opts.FieldConstraints) > 0 {
|
||||
var out []FieldConstraint
|
||||
for _, fc := range opts.FieldConstraints {
|
||||
if fc.Unique {
|
||||
mode := fc.EmptyMode
|
||||
if mode == 0 {
|
||||
mode = DefaultEmptyModeForUnique
|
||||
}
|
||||
out = append(out, FieldConstraint{Name: fc.Name, Unique: true, EmptyMode: mode})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Legacy UniqueFields
|
||||
var out []FieldConstraint
|
||||
for _, name := range opts.UniqueFields {
|
||||
out = append(out, FieldConstraint{Name: name, Unique: true, EmptyMode: DefaultEmptyModeForUnique})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getRequiredFieldConstraints возвращает required поля и их EmptyMode
|
||||
func (db *LineDb) getRequiredFieldConstraints(collectionName string) []FieldConstraint {
|
||||
opts := db.getCollectionOptions(collectionName)
|
||||
if opts == nil {
|
||||
return nil
|
||||
}
|
||||
var out []FieldConstraint
|
||||
for _, fc := range opts.FieldConstraints {
|
||||
if fc.Required {
|
||||
mode := fc.EmptyMode
|
||||
if mode == 0 {
|
||||
mode = DefaultEmptyModeForRequired
|
||||
}
|
||||
out = append(out, FieldConstraint{Name: fc.Name, Required: true, EmptyMode: mode})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkRequiredFieldsInsert проверяет обязательные поля при вставке
|
||||
func (db *LineDb) checkRequiredFieldsInsert(itemMap map[string]any, collectionName string) error {
|
||||
required := db.getRequiredFieldConstraints(collectionName)
|
||||
for _, fc := range required {
|
||||
if db.isEmptyByMode(itemMap, fc.Name, fc.EmptyMode) {
|
||||
return fmt.Errorf("required field %q is empty in collection %q", fc.Name, collectionName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkRequiredFieldsUpdate проверяет, что при Update не устанавливаются пустые значения в required полях
|
||||
func (db *LineDb) checkRequiredFieldsUpdate(data map[string]any, collectionName string) error {
|
||||
required := db.getRequiredFieldConstraints(collectionName)
|
||||
for _, fc := range required {
|
||||
if _, inData := data[fc.Name]; !inData {
|
||||
continue // поле не обновляется — не проверяем
|
||||
}
|
||||
if db.isEmptyByMode(data, fc.Name, fc.EmptyMode) {
|
||||
return fmt.Errorf("required field %q cannot be set to empty in collection %q", fc.Name, collectionName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkUniqueFieldsInsert проверяет уникальность полей при вставке
|
||||
func (db *LineDb) checkUniqueFieldsInsert(itemMap map[string]any, collectionName string, resultDataArray []any, options LineDbAdapterOptions) error {
|
||||
if options.SkipCheckExistingForWrite {
|
||||
return nil
|
||||
}
|
||||
opts := db.getCollectionOptions(collectionName)
|
||||
if opts == nil || len(opts.UniqueFields) == 0 {
|
||||
uniqueFields := db.getUniqueFieldConstraints(collectionName)
|
||||
if len(uniqueFields) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, fieldName := range opts.UniqueFields {
|
||||
value := itemMap[fieldName]
|
||||
if db.isValueEmpty(value) {
|
||||
for _, fc := range uniqueFields {
|
||||
fieldName := fc.Name
|
||||
if db.isEmptyByMode(itemMap, fieldName, fc.EmptyMode) {
|
||||
continue
|
||||
}
|
||||
value := itemMap[fieldName]
|
||||
// Проверяем в batch (уже добавляемые записи)
|
||||
for _, resultItem := range resultDataArray {
|
||||
if resultMap, ok := resultItem.(map[string]any); ok {
|
||||
@@ -552,8 +666,8 @@ func (db *LineDb) checkUniqueFieldsUpdate(data map[string]any, filter any, colle
|
||||
if options.SkipCheckExistingForWrite {
|
||||
return nil
|
||||
}
|
||||
opts := db.getCollectionOptions(collectionName)
|
||||
if opts == nil || len(opts.UniqueFields) == 0 {
|
||||
uniqueFields := db.getUniqueFieldConstraints(collectionName)
|
||||
if len(uniqueFields) == 0 {
|
||||
return nil
|
||||
}
|
||||
recordsToUpdate, err := db.ReadByFilter(filter, collectionName, LineDbAdapterOptions{InTransaction: true})
|
||||
@@ -566,9 +680,10 @@ func (db *LineDb) checkUniqueFieldsUpdate(data map[string]any, filter any, colle
|
||||
updatingIDs[m["id"]] = true
|
||||
}
|
||||
}
|
||||
for _, fieldName := range opts.UniqueFields {
|
||||
for _, fc := range uniqueFields {
|
||||
fieldName := fc.Name
|
||||
value, inData := data[fieldName]
|
||||
if !inData || db.isValueEmpty(value) {
|
||||
if !inData || db.isEmptyByMode(data, fieldName, fc.EmptyMode) {
|
||||
continue
|
||||
}
|
||||
existing, err := db.ReadByFilter(map[string]any{fieldName: value}, collectionName, LineDbAdapterOptions{InTransaction: true})
|
||||
@@ -707,6 +822,19 @@ func (db *LineDb) toNumber(value any) (float64, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// toMap конвертирует struct или map в map[string]any (для Insert/Update)
|
||||
func (db *LineDb) toMap(v any) (map[string]any, error) {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
return m, nil
|
||||
}
|
||||
data, err := goccyjson.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result map[string]any
|
||||
return result, goccyjson.Unmarshal(data, &result)
|
||||
}
|
||||
|
||||
func (db *LineDb) normalizeDataArray(data any) []any {
|
||||
switch v := data.(type) {
|
||||
case []any:
|
||||
|
||||
@@ -54,13 +54,50 @@ type LineDbInitOptions struct {
|
||||
Partitions []PartitionCollection `json:"partitions,omitempty"`
|
||||
}
|
||||
|
||||
// EmptyValueMode определяет, что считать "пустым" при проверке required/unique
|
||||
type EmptyValueMode uint
|
||||
|
||||
const (
|
||||
EmptyModeAbsentKey EmptyValueMode = 1 << iota // ключ отсутствует в записи
|
||||
EmptyModeNil // значение nil
|
||||
EmptyModeZeroValue // "" для string, 0 для int/float; для bool не используется
|
||||
)
|
||||
|
||||
// DefaultEmptyModeForRequired — режим по умолчанию для required (все виды пустоты)
|
||||
const DefaultEmptyModeForRequired = EmptyModeAbsentKey | EmptyModeNil | EmptyModeZeroValue
|
||||
|
||||
// DefaultEmptyModeForUnique — режим по умолчанию для unique (nil и "" — пропускаем проверку)
|
||||
const DefaultEmptyModeForUnique = EmptyModeNil | EmptyModeZeroValue
|
||||
|
||||
// FieldConstraint — конфигурация ограничения на поле (required/unique)
|
||||
type FieldConstraint struct {
|
||||
Name string `json:"name"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Unique bool `json:"unique,omitempty"`
|
||||
EmptyMode EmptyValueMode `json:"emptyMode,omitempty"` // для required: что считать пустым; для unique: когда пропускать проверку
|
||||
}
|
||||
|
||||
// KeyOrder задаёт порядок ключей при сериализации JSON.
|
||||
// Order >= 0: с начала (0, 1, 2...). Order < 0: с конца (-1 = последний, -2 = предпоследний).
|
||||
// Ключи не из списка — в середине, по алфавиту.
|
||||
type KeyOrder struct {
|
||||
Key string `json:"key"`
|
||||
Order int `json:"order"`
|
||||
}
|
||||
|
||||
// JSONLFileOptions представляет опции для JSONL файла
|
||||
// Соответствует TypeScript интерфейсу JSONLFileOptions
|
||||
type JSONLFileOptions struct {
|
||||
CollectionName string `json:"collectionName,omitempty"`
|
||||
AllocSize int `json:"allocSize,omitempty"`
|
||||
IndexedFields []string `json:"indexedFields,omitempty"`
|
||||
UniqueFields []string `json:"uniqueFields,omitempty"` // Поля с ограничением уникальности
|
||||
CollectionName string `json:"collectionName,omitempty"`
|
||||
AllocSize int `json:"allocSize,omitempty"`
|
||||
IndexedFields []string `json:"indexedFields,omitempty"`
|
||||
FieldConstraints []FieldConstraint `json:"fieldConstraints,omitempty"` // Ограничения полей (required/unique)
|
||||
UniqueFields []string `json:"uniqueFields,omitempty"` // Устаревшее: поля с уникальностью (совместимость)
|
||||
KeyOrder []KeyOrder `json:"keyOrder,omitempty"` // Порядок ключей при сериализации (если задан — используется кастомная сериализация)
|
||||
Encode bool `json:"encode,omitempty"` // Шифровать записи после сериализации
|
||||
EncodeKey string `json:"encodeKey,omitempty"` // Ключ (строка -> SHA256 для байтового ключа), требуется при Encode
|
||||
EncryptFn func(plaintext []byte, key []byte) ([]byte, error) `json:"-"` // Кастомное шифрование (если nil — встроенное AES-GCM)
|
||||
DecryptFn func(ciphertext []byte, key []byte) ([]byte, error) `json:"-"` // Кастомная расшифровка (если nil — встроенное AES-GCM)
|
||||
EncryptKeyForLineDb string `json:"encryptKeyForLineDb,omitempty"`
|
||||
SkipInvalidLines bool `json:"skipInvalidLines,omitempty"`
|
||||
DecryptKey string `json:"decryptKey,omitempty"`
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -472,3 +474,280 @@ func TestLineDbUniqueFieldsNoUniqueFields(t *testing.T) {
|
||||
t.Fatalf("Expected 2 records when no unique fields, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLineDbEncode проверяет шифрование записей при Encode
|
||||
func TestLineDbEncode(t *testing.T) {
|
||||
os.RemoveAll("./testdata")
|
||||
initOptions := &linedb.LineDbInitOptions{
|
||||
CacheSize: 100,
|
||||
CacheTTL: time.Minute,
|
||||
DBFolder: "./testdata",
|
||||
Collections: []linedb.JSONLFileOptions{
|
||||
{
|
||||
CollectionName: "secret",
|
||||
AllocSize: 512,
|
||||
Encode: true,
|
||||
EncodeKey: "test-secret-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
db := linedb.NewLineDb(nil)
|
||||
if err := db.Init(false, initOptions); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
defer func() { db.Close(); os.RemoveAll("./testdata") }()
|
||||
|
||||
opts := linedb.LineDbAdapterOptions{}
|
||||
if err := db.Insert(map[string]any{"name": "secret", "value": 42}, "secret", opts); err != nil {
|
||||
t.Fatalf("Insert failed: %v", err)
|
||||
}
|
||||
|
||||
all, err := db.Read("secret", opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Read failed: %v", err)
|
||||
}
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("Expected 1 record, got %d", len(all))
|
||||
}
|
||||
r := all[0].(map[string]any)
|
||||
if r["name"] != "secret" || r["value"] != float64(42) {
|
||||
t.Fatalf("Expected name=secret value=42, got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLineDbAllocSizeOverflow проверяет ошибку при записи, превышающей allocSize-1
|
||||
func TestLineDbAllocSizeOverflow(t *testing.T) {
|
||||
os.RemoveAll("./testdata")
|
||||
initOptions := &linedb.LineDbInitOptions{
|
||||
CacheSize: 100,
|
||||
CacheTTL: time.Minute,
|
||||
DBFolder: "./testdata",
|
||||
Collections: []linedb.JSONLFileOptions{
|
||||
{CollectionName: "tiny", AllocSize: 64},
|
||||
},
|
||||
}
|
||||
db := linedb.NewLineDb(nil)
|
||||
if err := db.Init(false, initOptions); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
defer func() { db.Close(); os.RemoveAll("./testdata") }()
|
||||
|
||||
opts := linedb.LineDbAdapterOptions{}
|
||||
// Короткая запись — поместится (allocSize-1=63)
|
||||
if err := db.Insert(map[string]any{"x": "short"}, "tiny", opts); err != nil {
|
||||
t.Fatalf("Insert short failed: %v", err)
|
||||
}
|
||||
// Длинная — не поместится (JSON > 63 символов)
|
||||
longStr := strings.Repeat("a", 80)
|
||||
err := db.Insert(map[string]any{"x": longStr}, "tiny", opts)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for record exceeding allocSize-1")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("Expected 'exceeds' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLineDbInitNormalizeExistingFile проверяет нормализацию при Init существующего файла
|
||||
func TestLineDbInitNormalizeExistingFile(t *testing.T) {
|
||||
os.RemoveAll("./testdata")
|
||||
os.MkdirAll("./testdata", 0755)
|
||||
// Файл с записями разной длины
|
||||
content := `{"id":1,"a":"x"}
|
||||
{"id":2,"a":"yy"}
|
||||
{"id":3,"a":"zzz"}`
|
||||
if err := os.WriteFile("./testdata/norm.jsonl", []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Write file: %v", err)
|
||||
}
|
||||
defer os.RemoveAll("./testdata")
|
||||
|
||||
initOptions := &linedb.LineDbInitOptions{
|
||||
CacheSize: 100,
|
||||
CacheTTL: time.Minute,
|
||||
DBFolder: "./testdata",
|
||||
Collections: []linedb.JSONLFileOptions{
|
||||
{CollectionName: "norm", AllocSize: 100},
|
||||
},
|
||||
}
|
||||
db := linedb.NewLineDb(nil)
|
||||
if err := db.Init(false, initOptions); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
all, err := db.Read("norm", linedb.LineDbAdapterOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Read failed: %v", err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("Expected 3 records, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// setupCollectionWithFieldConstraints создаёт БД с FieldConstraints (required/unique)
|
||||
func setupCollectionWithFieldConstraints(t *testing.T, constraints []linedb.FieldConstraint) (*linedb.LineDb, func()) {
|
||||
t.Helper()
|
||||
os.RemoveAll("./testdata")
|
||||
initOptions := &linedb.LineDbInitOptions{
|
||||
CacheSize: 100,
|
||||
CacheTTL: time.Minute,
|
||||
DBFolder: "./testdata",
|
||||
Collections: []linedb.JSONLFileOptions{
|
||||
{
|
||||
CollectionName: "items",
|
||||
AllocSize: 256,
|
||||
FieldConstraints: constraints,
|
||||
},
|
||||
},
|
||||
}
|
||||
db := linedb.NewLineDb(nil)
|
||||
if err := db.Init(false, initOptions); err != nil {
|
||||
t.Fatalf("Failed to init database: %v", err)
|
||||
}
|
||||
return db, func() { db.Close(); os.RemoveAll("./testdata") }
|
||||
}
|
||||
|
||||
// TestLineDbRequiredFieldsInsert проверяет, что required поля обязательны при Insert
|
||||
func TestLineDbRequiredFieldsInsert(t *testing.T) {
|
||||
db, cleanup := setupCollectionWithFieldConstraints(t, []linedb.FieldConstraint{
|
||||
{Name: "email", Required: true},
|
||||
{Name: "name", Required: true},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
opts := linedb.LineDbAdapterOptions{}
|
||||
|
||||
// Успех — оба поля заполнены
|
||||
if err := db.Insert(map[string]any{"name": "alice", "email": "a@test.com"}, "items", opts); err != nil {
|
||||
t.Fatalf("Insert with required fields failed: %v", err)
|
||||
}
|
||||
|
||||
// Ошибка — отсутствует email (AbsentKey)
|
||||
err := db.Insert(map[string]any{"name": "bob"}, "items", opts)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing required field email")
|
||||
} else {
|
||||
log.Println("Error: ", err)
|
||||
}
|
||||
|
||||
// Ошибка — email = nil
|
||||
err = db.Insert(map[string]any{"name": "bob", "email": nil}, "items", opts)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for nil required field email")
|
||||
} else {
|
||||
log.Println("Error: ", err)
|
||||
}
|
||||
|
||||
// Ошибка — email = ""
|
||||
err = db.Insert(map[string]any{"name": "bob", "email": ""}, "items", opts)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for empty string in required field email")
|
||||
} else {
|
||||
log.Println("Error: ", err)
|
||||
}
|
||||
|
||||
all, _ := db.Read("items", opts)
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("Expected 1 record, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLineDbRequiredFieldsEmptyMode проверяет разные режимы интерпретации пустоты
|
||||
func TestLineDbRequiredFieldsEmptyMode(t *testing.T) {
|
||||
// Режим: только отсутствие ключа — nil и "" допустимы
|
||||
db, cleanup := setupCollectionWithFieldConstraints(t, []linedb.FieldConstraint{
|
||||
{Name: "title", Required: true, EmptyMode: linedb.EmptyModeAbsentKey},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
opts := linedb.LineDbAdapterOptions{}
|
||||
|
||||
// nil — допустимо (только AbsentKey проверяется)
|
||||
if err := db.Insert(map[string]any{"title": nil}, "items", opts); err != nil {
|
||||
t.Fatalf("Insert with nil (AbsentKey only) failed: %v", err)
|
||||
}
|
||||
// "" — допустимо
|
||||
if err := db.Insert(map[string]any{"title": ""}, "items", opts); err != nil {
|
||||
t.Fatalf("Insert with empty string (AbsentKey only) failed: %v", err)
|
||||
}
|
||||
// Отсутствие ключа — ошибка (поле не в записи)
|
||||
err := db.Insert(map[string]any{"other": "x"}, "items", opts)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for absent required key")
|
||||
}
|
||||
|
||||
// Режим: только nil — AbsentKey и "" допустимы? Нет: AbsentKey при отсутствии — пусто.
|
||||
// При EmptyModeNil: пусто только когда v==nil. AbsentKey при отсутствии ключа: exists=false.
|
||||
// isEmptyByMode: if mode&EmptyModeAbsentKey && !exists return true. So if we use only EmptyModeNil,
|
||||
// AbsentKey is not in the mask, so we don't return true for !exists. Good.
|
||||
db2, cleanup2 := setupCollectionWithFieldConstraints(t, []linedb.FieldConstraint{
|
||||
{Name: "flag", Required: true, EmptyMode: linedb.EmptyModeNil},
|
||||
})
|
||||
defer cleanup2()
|
||||
|
||||
// Ключ отсутствует — при EmptyModeNil мы не проверяем AbsentKey, так что !exists не даёт return true.
|
||||
// isEmptyByMode: проверяет mode&EmptyModeAbsentKey - если нет, то !exists не приводит к true.
|
||||
// Потом mode&EmptyModeNil && v==nil - но v при !exists будет нулевым при v, exists := m[key]. v = nil, exists = false.
|
||||
// Но мы заходим в v, exists := m[key]. Если !exists, v будет nil (zero value). So v==nil is true.
|
||||
// And mode&EmptyModeNil - if we have only Nil, we'd return true. So absent key would still be "empty" if we access v.
|
||||
// Actually no - we first check mode&EmptyModeAbsentKey && !exists - if that's not in mode, we skip.
|
||||
// Then mode&EmptyModeNil && v==nil - for absent key, v is nil. So we'd return true. So absent key + Nil mode would still fail.
|
||||
// The logic is: we return true (empty) if ANY of the conditions matching the mode are true. So:
|
||||
// - AbsentKey + !exists -> empty
|
||||
// - Nil + v==nil -> empty
|
||||
// - ZeroValue + ("" or 0) -> empty
|
||||
// For EmptyModeNil only: we don't check AbsentKey. We check Nil - and for absent key, v is nil (zero value from map lookup). So we'd return true. So absent key would still be empty. Good.
|
||||
// For EmptyModeZeroValue only: we'd only check ZeroValue. Absent key: v is nil, we don't have a case for nil in the switch (we'd fall through and return false). So absent key would NOT be empty. And nil would not be empty. Only "" and 0 would be.
|
||||
// Let me add a test for EmptyModeZeroValue: required field, value 0 should fail, value "" should fail, but absent key... would not fail? That might be surprising. Let me document it.
|
||||
if err := db2.Insert(map[string]any{"flag": false}, "items", opts); err != nil {
|
||||
t.Fatalf("Insert with false (bool, Nil mode) failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLineDbRequiredFieldsUpdate проверяет, что Update не может установить пустое в required
|
||||
func TestLineDbRequiredFieldsUpdate(t *testing.T) {
|
||||
db, cleanup := setupCollectionWithFieldConstraints(t, []linedb.FieldConstraint{
|
||||
{Name: "email", Required: true},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
opts := linedb.LineDbAdapterOptions{}
|
||||
|
||||
if err := db.Insert(map[string]any{"name": "a", "email": "a@test.com"}, "items", opts); err != nil {
|
||||
t.Fatalf("Insert failed: %v", err)
|
||||
}
|
||||
|
||||
// Ошибка — пытаемся установить email в ""
|
||||
_, err := db.Update(map[string]any{"email": ""}, "items", map[string]any{"email": "a@test.com"}, opts)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when setting required field to empty")
|
||||
}
|
||||
|
||||
// Успех — обновляем другое поле
|
||||
_, err = db.Update(map[string]any{"name": "alice"}, "items", map[string]any{"email": "a@test.com"}, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Update non-required field failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLineDbRequiredAndUniqueCombined проверяет комбинацию required и unique
|
||||
func TestLineDbRequiredAndUniqueCombined(t *testing.T) {
|
||||
db, cleanup := setupCollectionWithFieldConstraints(t, []linedb.FieldConstraint{
|
||||
{Name: "email", Required: true, Unique: true},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
opts := linedb.LineDbAdapterOptions{}
|
||||
|
||||
if err := db.Insert(map[string]any{"email": "x@test.com"}, "items", opts); err != nil {
|
||||
t.Fatalf("Insert failed: %v", err)
|
||||
}
|
||||
err := db.Insert(map[string]any{"email": "x@test.com"}, "items", opts)
|
||||
if err == nil {
|
||||
t.Fatal("Expected unique violation")
|
||||
}
|
||||
err = db.Insert(map[string]any{"email": ""}, "items", opts)
|
||||
if err == nil {
|
||||
t.Fatal("Expected required violation for empty email")
|
||||
}
|
||||
}
|
||||
|
||||
639
tools/realloc/main.go
Normal file
639
tools/realloc/main.go
Normal file
@@ -0,0 +1,639 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type fileStats struct {
|
||||
Path string
|
||||
LineCount int
|
||||
NonEmptyCount int
|
||||
Lengths map[int]int
|
||||
MinLen int
|
||||
MaxLen int
|
||||
MaxDataLen int
|
||||
ProbablyBase64 bool
|
||||
|
||||
// JSON-object stats (only when ProbablyBase64 == false)
|
||||
JSONObjects int
|
||||
JSONParseErrors int
|
||||
MinFieldCount int
|
||||
MaxFieldCount int
|
||||
AllKeysSame bool
|
||||
AllFieldCountsSame bool
|
||||
SigOverflow bool
|
||||
SigCounts map[string]int
|
||||
SigExampleKeys map[string][]string
|
||||
}
|
||||
|
||||
const (
|
||||
colorReset = "\x1b[0m"
|
||||
colorRed = "\x1b[31m"
|
||||
colorGreen = "\x1b[32m"
|
||||
colorYellow = "\x1b[33m"
|
||||
colorCyan = "\x1b[36m"
|
||||
colorBold = "\x1b[1m"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Printf("%sUsage:%s realloc <directory-with-jsonl-files>\n", colorCyan, colorReset)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dir := os.Args[1]
|
||||
if err := run(dir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%sError:%s %v\n", colorRed, colorReset, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(dir string) error {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat dir: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", dir)
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*.jsonl"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("glob: %w", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
fmt.Printf("%sNo *.jsonl files found in%s %s\n", colorYellow, colorReset, dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%sFound JSONL files:%s\n", colorCyan, colorReset)
|
||||
for i, f := range files {
|
||||
fmt.Printf(" [%d] %s\n", i+1, filepath.Base(f))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Printf("\n%sEnter file index or name (empty to cancel): %s", colorCyan, colorReset)
|
||||
input, _ := reader.ReadString('\n')
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
fmt.Printf("%sCancelled.%s\n", colorYellow, colorReset)
|
||||
return nil
|
||||
}
|
||||
|
||||
var path string
|
||||
if idx, err := strconv.Atoi(input); err == nil && idx >= 1 && idx <= len(files) {
|
||||
path = files[idx-1]
|
||||
} else {
|
||||
// try match by name
|
||||
for _, f := range files {
|
||||
if filepath.Base(f) == input {
|
||||
path = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("file %q not found in %s", input, dir)
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := analyzeFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Если файл похож на закодированный — спросим ключ и попробуем расшифровать для статистики по полям
|
||||
if stats.ProbablyBase64 {
|
||||
fmt.Printf("\n%sFile looks encoded. If it was encrypted with Encode/EncodeKey, you can enter the key to analyze JSON fields.%s\n", colorYellow, colorReset)
|
||||
fmt.Printf("%sEncode key (press Enter to skip): %s", colorCyan, colorReset)
|
||||
keyLine, _ := reader.ReadString('\n')
|
||||
keyLine = strings.TrimSpace(keyLine)
|
||||
if keyLine != "" {
|
||||
if err := fillDecryptedJSONStats(path, keyLine, stats); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%sDecrypt/JSON analyze error:%s %v\n", colorRed, colorReset, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Плоский JSON — сразу считаем статистику полей
|
||||
if err := fillDecryptedJSONStats(path, "", stats); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%sJSON analyze error:%s %v\n", colorRed, colorReset, err)
|
||||
}
|
||||
}
|
||||
|
||||
printStats(stats)
|
||||
|
||||
// Suggest allocSize
|
||||
recommendedAlloc := stats.MaxLen + 1
|
||||
fmt.Printf("\n%sSuggested allocSize%s (from max line length %d): %s%d%s\n",
|
||||
colorCyan, colorReset, stats.MaxLen, colorGreen, recommendedAlloc, colorReset)
|
||||
fmt.Printf("%sEnter new allocSize%s (empty to skip, 0 to use suggested): ", colorCyan, colorReset)
|
||||
line, _ := reader.ReadString('\n')
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
fmt.Printf("%sSkipped realloc.%s\n", colorYellow, colorReset)
|
||||
return nil
|
||||
}
|
||||
|
||||
var targetAlloc int
|
||||
if line == "0" {
|
||||
targetAlloc = recommendedAlloc
|
||||
} else {
|
||||
val, err := strconv.Atoi(line)
|
||||
if err != nil || val < 2 {
|
||||
return fmt.Errorf("invalid allocSize: %q", line)
|
||||
}
|
||||
targetAlloc = val
|
||||
}
|
||||
|
||||
if err := reallocFile(path, stats, targetAlloc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%sRealloc completed.%s File updated: %s\n", colorGreen, colorReset, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func analyzeFile(path string) (*fileStats, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
stats := &fileStats{
|
||||
Path: path,
|
||||
LineCount: len(lines),
|
||||
Lengths: make(map[int]int),
|
||||
MinLen: 0,
|
||||
MaxLen: 0,
|
||||
MaxDataLen: 0,
|
||||
SigCounts: make(map[string]int),
|
||||
SigExampleKeys: make(map[string][]string),
|
||||
}
|
||||
|
||||
var nonEmpty []string
|
||||
for _, l := range lines {
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
nonEmpty = append(nonEmpty, l)
|
||||
}
|
||||
stats.NonEmptyCount = len(nonEmpty)
|
||||
if len(nonEmpty) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// эвристика: если у значащих данных нет { ... } по краям, считаем, что это закодированное содержимое
|
||||
encodedCandidates := 0
|
||||
|
||||
for _, l := range nonEmpty {
|
||||
n := len(l)
|
||||
stats.Lengths[n]++
|
||||
if stats.MinLen == 0 || n < stats.MinLen {
|
||||
stats.MinLen = n
|
||||
}
|
||||
if n > stats.MaxLen {
|
||||
stats.MaxLen = n
|
||||
}
|
||||
trimmed := strings.TrimRight(l, " ")
|
||||
if len(trimmed) > stats.MaxDataLen {
|
||||
stats.MaxDataLen = len(trimmed)
|
||||
}
|
||||
// Значащие данные без { } по краям — считаем как "encoded"
|
||||
core := strings.TrimSpace(l)
|
||||
if core != "" && !(strings.HasPrefix(core, "{") && strings.HasSuffix(core, "}")) {
|
||||
encodedCandidates++
|
||||
}
|
||||
}
|
||||
if stats.NonEmptyCount > 0 && encodedCandidates >= stats.NonEmptyCount/2 {
|
||||
stats.ProbablyBase64 = true
|
||||
}
|
||||
|
||||
// Доп.статистика по JSON-полям — только если это не похоже на закодированные строки
|
||||
if !stats.ProbablyBase64 {
|
||||
stats.MinFieldCount = 0
|
||||
stats.MaxFieldCount = 0
|
||||
stats.AllKeysSame = true
|
||||
stats.AllFieldCountsSame = true
|
||||
|
||||
var refSig string
|
||||
var refCount int
|
||||
|
||||
for _, l := range nonEmpty {
|
||||
core := strings.TrimSpace(strings.TrimRight(l, " "))
|
||||
if core == "" {
|
||||
continue
|
||||
}
|
||||
// ожидаем JSON object
|
||||
if !(strings.HasPrefix(core, "{") && strings.HasSuffix(core, "}")) {
|
||||
stats.JSONParseErrors++
|
||||
continue
|
||||
}
|
||||
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal([]byte(core), &obj); err != nil {
|
||||
stats.JSONParseErrors++
|
||||
continue
|
||||
}
|
||||
|
||||
stats.JSONObjects++
|
||||
cnt := len(obj)
|
||||
if stats.MinFieldCount == 0 || cnt < stats.MinFieldCount {
|
||||
stats.MinFieldCount = cnt
|
||||
}
|
||||
if cnt > stats.MaxFieldCount {
|
||||
stats.MaxFieldCount = cnt
|
||||
}
|
||||
|
||||
keys := make([]string, 0, cnt)
|
||||
for k := range obj {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sortStrings(keys)
|
||||
sig := strings.Join(keys, "\x1f") // case-sensitive
|
||||
|
||||
if refSig == "" {
|
||||
refSig = sig
|
||||
refCount = cnt
|
||||
} else {
|
||||
if sig != refSig {
|
||||
stats.AllKeysSame = false
|
||||
}
|
||||
if cnt != refCount {
|
||||
stats.AllFieldCountsSame = false
|
||||
}
|
||||
}
|
||||
|
||||
// collect limited signature stats
|
||||
if _, ok := stats.SigCounts[sig]; ok || len(stats.SigCounts) < 10 {
|
||||
stats.SigCounts[sig]++
|
||||
if _, ok := stats.SigExampleKeys[sig]; !ok {
|
||||
stats.SigExampleKeys[sig] = keys
|
||||
}
|
||||
} else {
|
||||
stats.SigOverflow = true
|
||||
}
|
||||
}
|
||||
|
||||
// If no objects parsed, keep flags meaningful
|
||||
if stats.JSONObjects == 0 {
|
||||
stats.AllKeysSame = false
|
||||
stats.AllFieldCountsSame = false
|
||||
} else if stats.MinFieldCount != stats.MaxFieldCount {
|
||||
stats.AllFieldCountsSame = false
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// encodeKeyBytes превращает строку ключа в 32 байта (SHA256) для AES-256
|
||||
func encodeKeyBytes(keyStr string) []byte {
|
||||
h := sha256.Sum256([]byte(keyStr))
|
||||
return h[:]
|
||||
}
|
||||
|
||||
// aesGCMDecrypt — расшифровка AES-256-GCM (nonce||ciphertext)
|
||||
func aesGCMDecrypt(ciphertext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonceSize := aesgcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
nonce, data := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
return aesgcm.Open(nil, nonce, data, nil)
|
||||
}
|
||||
|
||||
// fillDecryptedJSONStats заполняет JSON-статистику.
|
||||
// Если keyStr == "", предполагается, что строки содержат обычный JSON.
|
||||
// Если keyStr != "", строки считаются base64(AES-GCM(JSON)).
|
||||
func fillDecryptedJSONStats(path, keyStr string, stats *fileStats) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
|
||||
|
||||
// сбрасываем предыдущие JSON-статы
|
||||
stats.JSONObjects = 0
|
||||
stats.JSONParseErrors = 0
|
||||
stats.MinFieldCount = 0
|
||||
stats.MaxFieldCount = 0
|
||||
stats.AllKeysSame = true
|
||||
stats.AllFieldCountsSame = true
|
||||
stats.SigOverflow = false
|
||||
stats.SigCounts = make(map[string]int)
|
||||
stats.SigExampleKeys = make(map[string][]string)
|
||||
|
||||
var key []byte
|
||||
if keyStr != "" {
|
||||
key = encodeKeyBytes(keyStr)
|
||||
}
|
||||
|
||||
var refSig string
|
||||
var refCount int
|
||||
|
||||
for _, l := range lines {
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
raw := strings.TrimRight(l, " ")
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var payload []byte
|
||||
if key != nil {
|
||||
enc, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
stats.JSONParseErrors++
|
||||
continue
|
||||
}
|
||||
plain, err := aesGCMDecrypt(enc, key)
|
||||
if err != nil {
|
||||
stats.JSONParseErrors++
|
||||
continue
|
||||
}
|
||||
payload = plain
|
||||
} else {
|
||||
payload = []byte(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
core := strings.TrimSpace(string(payload))
|
||||
if core == "" {
|
||||
continue
|
||||
}
|
||||
if !(strings.HasPrefix(core, "{") && strings.HasSuffix(core, "}")) {
|
||||
stats.JSONParseErrors++
|
||||
continue
|
||||
}
|
||||
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal([]byte(core), &obj); err != nil {
|
||||
stats.JSONParseErrors++
|
||||
continue
|
||||
}
|
||||
|
||||
stats.JSONObjects++
|
||||
cnt := len(obj)
|
||||
if stats.MinFieldCount == 0 || cnt < stats.MinFieldCount {
|
||||
stats.MinFieldCount = cnt
|
||||
}
|
||||
if cnt > stats.MaxFieldCount {
|
||||
stats.MaxFieldCount = cnt
|
||||
}
|
||||
|
||||
keys := make([]string, 0, cnt)
|
||||
for k := range obj {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sortStrings(keys)
|
||||
sig := strings.Join(keys, "\x1f")
|
||||
|
||||
if refSig == "" {
|
||||
refSig = sig
|
||||
refCount = cnt
|
||||
} else {
|
||||
if sig != refSig {
|
||||
stats.AllKeysSame = false
|
||||
}
|
||||
if cnt != refCount {
|
||||
stats.AllFieldCountsSame = false
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := stats.SigCounts[sig]; ok || len(stats.SigCounts) < 10 {
|
||||
stats.SigCounts[sig]++
|
||||
if _, ok := stats.SigExampleKeys[sig]; !ok {
|
||||
stats.SigExampleKeys[sig] = keys
|
||||
}
|
||||
} else {
|
||||
stats.SigOverflow = true
|
||||
}
|
||||
}
|
||||
|
||||
if stats.JSONObjects == 0 {
|
||||
stats.AllKeysSame = false
|
||||
stats.AllFieldCountsSame = false
|
||||
} else if stats.MinFieldCount != stats.MaxFieldCount {
|
||||
stats.AllFieldCountsSame = false
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printStats(s *fileStats) {
|
||||
fmt.Printf("\n%sStats for%s %s%s%s\n", colorBold, colorReset, colorCyan, s.Path, colorReset)
|
||||
fmt.Printf(" Total lines: %d\n", s.LineCount)
|
||||
fmt.Printf(" Non-empty lines: %d\n", s.NonEmptyCount)
|
||||
fmt.Printf(" Min line length: %d\n", s.MinLen)
|
||||
fmt.Printf(" Max line length: %d\n", s.MaxLen)
|
||||
fmt.Printf(" Max data length: %d (without trailing spaces)\n", s.MaxDataLen)
|
||||
fmt.Printf(" Unique lengths: ")
|
||||
first := true
|
||||
for l, c := range s.Lengths {
|
||||
if !first {
|
||||
fmt.Print(", ")
|
||||
}
|
||||
first = false
|
||||
fmt.Printf("%d(%d)", l, c)
|
||||
}
|
||||
fmt.Println()
|
||||
if s.ProbablyBase64 {
|
||||
fmt.Printf(" Encoded: %slikely encoded%s (no { } around data)\n", colorYellow, colorReset)
|
||||
// Если удалось расшифровать/распарсить JSON — покажем статистику по полям
|
||||
if s.JSONObjects > 0 || s.JSONParseErrors > 0 {
|
||||
fmt.Printf(" Decoded JSON: %s%s%s\n", colorCyan, "analysis", colorReset)
|
||||
fmt.Printf(" JSON objects: %d\n", s.JSONObjects)
|
||||
if s.JSONParseErrors > 0 {
|
||||
fmt.Printf(" JSON errors: %s%d%s\n", colorYellow, s.JSONParseErrors, colorReset)
|
||||
} else {
|
||||
fmt.Printf(" JSON errors: %s0%s\n", colorGreen, colorReset)
|
||||
}
|
||||
if s.JSONObjects > 0 {
|
||||
fmt.Printf(" Fields per rec: min=%d max=%d\n", s.MinFieldCount, s.MaxFieldCount)
|
||||
if s.AllFieldCountsSame {
|
||||
fmt.Printf(" Field counts: %sall equal%s\n", colorGreen, colorReset)
|
||||
} else {
|
||||
fmt.Printf(" Field counts: %sNOT equal%s\n", colorRed, colorReset)
|
||||
}
|
||||
if s.AllKeysSame {
|
||||
fmt.Printf(" Field names: %sall equal%s\n", colorGreen, colorReset)
|
||||
} else {
|
||||
fmt.Printf(" Field names: %sNOT equal%s\n", colorRed, colorReset)
|
||||
}
|
||||
if len(s.SigCounts) > 0 {
|
||||
schemaCount := len(s.SigCounts)
|
||||
extra := ""
|
||||
if s.SigOverflow {
|
||||
extra = " (showing first 10)"
|
||||
}
|
||||
color := colorGreen
|
||||
if schemaCount > 1 {
|
||||
color = colorYellow
|
||||
}
|
||||
fmt.Printf(" Schemas: %s%d%s%s\n", color, schemaCount, colorReset, extra)
|
||||
|
||||
type kv struct {
|
||||
sig string
|
||||
count int
|
||||
}
|
||||
var list []kv
|
||||
for sig, c := range s.SigCounts {
|
||||
list = append(list, kv{sig: sig, count: c})
|
||||
}
|
||||
if len(list) > 0 {
|
||||
ref := list[0]
|
||||
refKeys := s.SigExampleKeys[ref.sig]
|
||||
fmt.Printf(" Ref schema keys (%s%d%s recs): %v\n", colorCyan, ref.count, colorReset, refKeys)
|
||||
for _, kv := range list[1:] {
|
||||
if kv.sig != ref.sig {
|
||||
diffKeys := s.SigExampleKeys[kv.sig]
|
||||
fmt.Printf(" Diff schema keys (%s%d%s recs): %v\n", colorYellow, kv.count, colorReset, diffKeys)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" Encoded: %slooks like plain JSON%s\n", colorGreen, colorReset)
|
||||
fmt.Printf(" JSON objects: %d\n", s.JSONObjects)
|
||||
if s.JSONParseErrors > 0 {
|
||||
fmt.Printf(" JSON errors: %s%d%s\n", colorYellow, s.JSONParseErrors, colorReset)
|
||||
} else {
|
||||
fmt.Printf(" JSON errors: %s0%s\n", colorGreen, colorReset)
|
||||
}
|
||||
if s.JSONObjects > 0 {
|
||||
fmt.Printf(" Fields per rec: min=%d max=%d\n", s.MinFieldCount, s.MaxFieldCount)
|
||||
if s.AllFieldCountsSame {
|
||||
fmt.Printf(" Field counts: %sall equal%s\n", colorGreen, colorReset)
|
||||
} else {
|
||||
fmt.Printf(" Field counts: %sNOT equal%s\n", colorRed, colorReset)
|
||||
}
|
||||
if s.AllKeysSame {
|
||||
fmt.Printf(" Field names: %sall equal%s\n", colorGreen, colorReset)
|
||||
} else {
|
||||
fmt.Printf(" Field names: %sNOT equal%s\n", colorRed, colorReset)
|
||||
}
|
||||
if len(s.SigCounts) > 0 {
|
||||
schemaCount := len(s.SigCounts)
|
||||
extra := ""
|
||||
if s.SigOverflow {
|
||||
extra = " (showing first 10)"
|
||||
}
|
||||
color := colorGreen
|
||||
if schemaCount > 1 {
|
||||
color = colorYellow
|
||||
}
|
||||
fmt.Printf(" Schemas: %s%d%s%s\n", color, schemaCount, colorReset, extra)
|
||||
|
||||
// Показать пример эталонной и отличающейся схем
|
||||
type kv struct {
|
||||
sig string
|
||||
count int
|
||||
}
|
||||
var list []kv
|
||||
for sig, c := range s.SigCounts {
|
||||
list = append(list, kv{sig: sig, count: c})
|
||||
}
|
||||
// не сортируем по алфавиту, просто первый будет эталоном
|
||||
if len(list) > 0 {
|
||||
ref := list[0]
|
||||
refKeys := s.SigExampleKeys[ref.sig]
|
||||
fmt.Printf(" Ref schema keys (%s%d%s recs): %v\n", colorCyan, ref.count, colorReset, refKeys)
|
||||
|
||||
// найдём первую отличающуюся схему
|
||||
for _, kv := range list[1:] {
|
||||
if kv.sig != ref.sig {
|
||||
diffKeys := s.SigExampleKeys[kv.sig]
|
||||
fmt.Printf(" Diff schema keys (%s%d%s recs): %v\n", colorYellow, kv.count, colorReset, diffKeys)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sortStrings(a []string) {
|
||||
// simple insertion sort to avoid extra deps
|
||||
for i := 1; i < len(a); i++ {
|
||||
j := i
|
||||
for j > 0 && a[j-1] > a[j] {
|
||||
a[j-1], a[j] = a[j], a[j-1]
|
||||
j--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reallocFile(path string, stats *fileStats, newAlloc int) error {
|
||||
if stats.NonEmptyCount == 0 {
|
||||
fmt.Printf("%sFile is empty, nothing to realloc.%s\n", colorYellow, colorReset)
|
||||
return nil
|
||||
}
|
||||
|
||||
targetLen := newAlloc - 1
|
||||
if targetLen < 1 {
|
||||
return fmt.Errorf("target allocSize too small")
|
||||
}
|
||||
|
||||
if stats.MaxDataLen > targetLen {
|
||||
return fmt.Errorf("cannot set allocSize=%d: max data length %d would be truncated", newAlloc, stats.MaxDataLen)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
|
||||
|
||||
var buf bytes.Buffer
|
||||
for _, l := range lines {
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimRight(l, " ")
|
||||
if trimmed == "" {
|
||||
// строка содержит только пробелы — считаем удалённой и пропускаем
|
||||
continue
|
||||
}
|
||||
if len(trimmed) > targetLen {
|
||||
return fmt.Errorf("line data length %d exceeds targetLen %d", len(trimmed), targetLen)
|
||||
}
|
||||
padded := trimmed + strings.Repeat(" ", targetLen-len(trimmed))
|
||||
buf.WriteString(padded)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
|
||||
// backup
|
||||
backup := path + ".bak"
|
||||
if err := os.WriteFile(backup, data, 0644); err != nil {
|
||||
return fmt.Errorf("write backup: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
|
||||
return fmt.Errorf("write new file: %w", err)
|
||||
}
|
||||
fmt.Printf("%sBackup saved%s to %s\n", colorGreen, colorReset, backup)
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user