From 6a7ac248e4f652697c7fef67a0d4f68b7f6627a9 Mon Sep 17 00:00:00 2001 From: Ludwig Lehnert Date: Sat, 21 Sep 2024 22:22:15 +0200 Subject: [PATCH] first commit --- README.md | 17 ++++ go.mod | 3 + id.go | 38 ++++++++ ldb.go | 34 +++++++ migration.go | 0 schema.go | 256 +++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 348 insertions(+) create mode 100644 README.md create mode 100644 go.mod create mode 100644 id.go create mode 100644 ldb.go create mode 100644 migration.go create mode 100644 schema.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..bff6ecb --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# LDB (lehnert-db) Framework + +SQLite3 DBMS wrapper as-a-library inspired by [pocketbase](https://pocketbase.io/). + +## Key Features + +- [ ] Schema-First approach with migrations directly in golang +- [ ] Consistency for relations **at DB level** +- [ ] Fine grained user based access control +- [ ] Builtin REST & GraphQL APIs +- [ ] File storage using Google & AWS storage buckets (or your hard drive) +- [ ] Portability as an API frontend for PostgreSQL (maybe also MySQL) + +## Planned client SDKs + +- [ ] Dart/Flutter +- [ ] JS/TS diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9e51f37 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module lehnert.dev/ldb + +go 1.22.7 diff --git a/id.go b/id.go new file mode 100644 index 0000000..1fb03f9 --- /dev/null +++ b/id.go @@ -0,0 +1,38 @@ +package ldb + +import ( + "crypto/rand" + "fmt" + "strings" + "time" +) + +func GenerateId() string { + // MYSQL: CONCAT(UNHEX(CONV(ROUND(UNIX_TIMESTAMP(CURTIME(4))*1000), 10, 16)), RANDOM_BYTES(10)) + + timestamp := int64(time.Now().UnixMilli() * 1000) + + entropy := make([]byte, 8) + rand.Read(entropy) + + return fmt.Sprintf("%x%x", timestamp, entropy) +} + +func ValidateId(value any) error { + str, ok := value.(string) + if !ok { + return fmt.Errorf("invalid id, expected string value") + } + + const requiredLen = 31 + if len(str) != requiredLen { + return fmt.Errorf("invalid id, expected string of length %v", requiredLen) + } + + str = strings.ToLower(str) + if len(strings.Trim(str, "0123456789abcdef")) != 0 { + return fmt.Errorf("invalid id, expcted hex string") + } + + return nil +} diff --git a/ldb.go b/ldb.go new file mode 100644 index 0000000..fc8e3dd --- /dev/null +++ b/ldb.go @@ -0,0 +1,34 @@ +package ldb + +import "fmt" + +type App struct { + Migrations map[string]*Migration + DatabaseService *DatabaseService + HttpService *HttpService +} + +type Migration struct { + Up func() error + Down func() error +} + +type DatabaseService interface { + CreateCollection(name string, schema CollectionSchema) error + DropCollection(name string) error +} + +type HttpService interface { +} + +func (app *App) RegisterMigration(name string, migration Migration) { + if app.Migrations == nil { + app.Migrations = map[string]*Migration{} + } + + app.Migrations[name] = &migration +} + +func (app *App) Start() { + +} diff --git a/migration.go b/migration.go new file mode 100644 index 0000000..e69de29 diff --git a/schema.go b/schema.go new file mode 100644 index 0000000..86b4f29 --- /dev/null +++ b/schema.go @@ -0,0 +1,256 @@ +package ldb + +import ( + "fmt" + "regexp" + "slices" + "strings" + "time" +) + +type CollectionSchema struct { + Name string + Fields []*SchemaField +} + +type SchemaField struct { + Name string + Type SchemaFieldType +} + +type SchemaFieldType interface { + GetName() string + ValidateValue(value any) (any, error) +} + +func validateNullable(nullable bool, value any) error { + if value == nil && !nullable { + return fmt.Errorf("invalid value, expected non-null") + } + + return nil +} + +type TextFieldType struct { + Nullable bool + DefaultValue *string + MaxLength *int + MinLength *int + Pattern *string +} + +func (fieldType TextFieldType) ValidateValue(value any) (any, error) { + if err := validateNullable(fieldType.Nullable, value); err != nil { + return nil, err + } + + if value == nil { + return fieldType.DefaultValue, nil + } + + str, ok := value.(string) + if !ok { + return nil, fmt.Errorf("invalid value, expected string") + } + + if fieldType.MaxLength != nil && len(str) > *fieldType.MaxLength { + return nil, fmt.Errorf("value too long, max length is %v", *fieldType.MaxLength) + } + + if fieldType.MinLength != nil && len(str) < *fieldType.MaxLength { + return nil, fmt.Errorf("value too short, min length is %v", *fieldType.MinLength) + } + + if fieldType.Pattern != nil { + if _, err := regexp.MatchString(*fieldType.Pattern, str); err != nil { + return nil, fmt.Errorf("value does not match pattern, pattern is %v", *fieldType.Pattern) + } + } + + return &str, nil +} + +type IntFieldType struct { + Nullable bool + DefaultValue *int64 + MinValue *int64 + MaxValue *int64 +} + +func (fieldType IntFieldType) ValidateValue(value any) (any, error) { + if err := validateNullable(fieldType.Nullable, value); err != nil { + return nil, err + } + + if value == nil { + return fieldType.DefaultValue, nil + } + + i, ok := value.(int64) + if !ok { + return nil, fmt.Errorf("invalid value, expected integer") + } + + if fieldType.MinValue != nil && i < *fieldType.MinValue { + return nil, fmt.Errorf("value too small, min value is %v", *fieldType.MinValue) + } + + if fieldType.MaxValue != nil && i > *fieldType.MaxValue { + return nil, fmt.Errorf("value too big, max value is %v", *fieldType.MaxValue) + } + + return &i, nil +} + +type FloatFieldType struct { + Nullable bool + DefaultValue *float64 + MinValue *float64 + MaxValue *float64 +} + +func (fieldType FloatFieldType) ValidateValue(value any) (any, error) { + if err := validateNullable(fieldType.Nullable, value); err != nil { + return nil, err + } + + if value == nil { + return fieldType.DefaultValue, nil + } + + f, ok := value.(float64) + if !ok { + return nil, fmt.Errorf("invalid value, expected float") + } + + if fieldType.MinValue != nil && f < *fieldType.MinValue { + return nil, fmt.Errorf("value too small, min value is %v", *fieldType.MinValue) + } + + if fieldType.MaxValue != nil && f > *fieldType.MaxValue { + return nil, fmt.Errorf("value too big, max value is %v", *fieldType.MaxValue) + } + + return &f, nil +} + +type BoolFieldType struct { + Nullable bool + DefaultValue *bool +} + +func (fieldType BoolFieldType) ValidateValue(value any) (any, error) { + if err := validateNullable(fieldType.Nullable, value); err != nil { + return nil, err + } + + if value == nil { + return fieldType.DefaultValue, nil + } + + b, ok := value.(bool) + if !ok { + return nil, fmt.Errorf("invalid value, expected bool") + } + + return &b, nil +} + +type DateTimeFieldType struct { + Nullable bool + CreateDefaultValue func() time.Time + CreateMinValue func() time.Time + CreateMaxValue func() time.Time +} + +func (fieldType DateTimeFieldType) ValidateValue(value any) (any, error) { + if err := validateNullable(fieldType.Nullable, value); err != nil { + return nil, err + } + + if value == nil { + if fieldType.CreateDefaultValue != nil { + defaultValue := fieldType.CreateDefaultValue() + return &defaultValue, nil + } + + return nil, nil + } + + const timeFormat = time.RFC3339 + const timeFormatName = "RFC-3339" + + d, ok := value.(time.Time) + if !ok { + str, _ := value.(string) + + var err error + if d, err = time.Parse(timeFormat, str); err != nil { + return nil, fmt.Errorf("invalid value, expected datetime or %s datetime string", timeFormatName) + } + } + + if fieldType.CreateMinValue != nil { + minValue := fieldType.CreateMinValue() + if d.Before(minValue) { + return nil, fmt.Errorf("value too early, min value is %s", d.Format(timeFormat)) + } + } + + if fieldType.CreateMaxValue != nil { + maxValue := fieldType.CreateMaxValue() + if d.After(maxValue) { + return nil, fmt.Errorf("value too late, max value is %s", d.Format(timeFormat)) + } + } + + return &d, nil +} + +type EnumFieldType struct { + Nullable bool + DefaultValue *string + EnumValues []string +} + +func (fieldType EnumFieldType) ValidateValue(value any) (any, error) { + if fieldType.DefaultValue != nil && !slices.Contains(fieldType.EnumValues, *fieldType.DefaultValue) { + return nil, fmt.Errorf("configuration error, invalid default value") + } + + if err := validateNullable(fieldType.Nullable, value); err != nil { + return nil, err + } + + if value == nil { + return fieldType.DefaultValue, nil + } + + str, ok := value.(string) + if !ok || !slices.Contains(fieldType.EnumValues, str) { + return nil, fmt.Errorf("invalid value, expected one of [%s]", strings.Join(fieldType.EnumValues, ", ")) + } + + return str, nil +} + +type SingleRelationFieldType struct { + Nullable bool + Collection string +} + +func (fieldType SingleRelationFieldType) ValidateValue(value any) (any, error) { + if err := validateNullable(fieldType.Nullable, value); err != nil { + return nil, err + } + + if value == nil { + return nil, nil + } + + if err := ValidateId(value); err != nil { + return nil, err + } + + return value, nil +}