diff --git a/CHANGELOG.md b/CHANGELOG.md index 66513517b..bab92f802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +* Added support for YDB type annotations in struct field tags for runtime type validation + - Struct tags now support optional type annotations: `sql:"column,type:YDBType"` + - Runtime validation ensures database column types match annotations during `ScanStruct()` + - Supports all YDB types: primitives, `List`, `Optional`, `Dict`, and nested combinations + - Type annotations are completely optional and backward compatible + - Works with both query client and database/sql APIs + * Fixed `context` checking in `ydb.Open` ## v3.118.2 diff --git a/examples/.gitignore b/examples/.gitignore index 96ae21d80..6f2c709f6 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -1 +1 @@ -/.golangci.yml \ No newline at end of file +/.golangci.yml diff --git a/examples/type_annotations/.gitignore b/examples/type_annotations/.gitignore new file mode 100644 index 000000000..ea8a8cc19 --- /dev/null +++ b/examples/type_annotations/.gitignore @@ -0,0 +1 @@ +type_annotations diff --git a/examples/type_annotations/README.md b/examples/type_annotations/README.md new file mode 100644 index 000000000..083c5341b --- /dev/null +++ b/examples/type_annotations/README.md @@ -0,0 +1,78 @@ +# Type Annotations Example + +This example demonstrates how to use YDB type annotations in struct field tags to validate column types and improve code documentation. + +## Features Demonstrated + +1. **Basic Type Annotations**: Annotate struct fields with YDB types like `Uint64`, `Text`, `Double` +2. **Complex Type Annotations**: Use `List`, `Optional`, and `Dict` annotations +3. **Type Validation**: Automatic validation that database column types match your struct annotations +4. **Error Detection**: Clear error messages when types don't match + +## Struct Tag Syntax + +The type annotation is added to the struct tag using the `type:` prefix: + +```go +type Product struct { + // Column name: product_id, YDB type: Uint64 + ProductID uint64 `sql:"product_id,type:Uint64"` + + // Column name: tags, YDB type: List + Tags []string `sql:"tags,type:List"` + + // Column name: rating, YDB type: Optional + Rating *float64 `sql:"rating,type:Optional"` +} +``` + +## Supported YDB Types + +### Primitive Types +- `Bool`, `Int8`, `Int16`, `Int32`, `Int64` +- `Uint8`, `Uint16`, `Uint32`, `Uint64` +- `Float`, `Double` +- `Date`, `Datetime`, `Timestamp` +- `Text` (UTF-8), `Bytes` (binary) +- `JSON`, `YSON`, `UUID` + +### Complex Types +- `List` - List of items of type T +- `Optional` - Optional (nullable) value of type T +- `Dict` - Dictionary with key type K and value type V + +### Nested Types +You can nest complex types: +- `List>` - List of optional text values +- `Optional>` - Optional list of unsigned integers +- `Dict>` - Dictionary mapping text to lists of integers + +## Benefits + +1. **Documentation**: Type annotations serve as inline documentation of expected database schema +2. **Validation**: Runtime validation ensures your Go types match the database schema +3. **Error Prevention**: Catch type mismatches early before they cause runtime errors +4. **Code Clarity**: Makes it explicit what YDB types you expect from the database + +## Running the Example + +```bash +# Set your YDB connection string +export YDB_CONNECTION_STRING="grpc://localhost:2136/local" + +# Run the example +go run main.go +``` + +## When to Use Type Annotations + +Type annotations are **optional** but recommended when: + +- Working with complex types (Lists, Dicts, Optional values) +- Building a strict API where type safety is critical +- Documenting expected database schema in code +- Working in a team where schema changes need to be validated + +## Backward Compatibility + +Type annotations are completely optional. Existing code without type annotations continues to work exactly as before. You can add annotations gradually to your codebase. diff --git a/examples/type_annotations/main.go b/examples/type_annotations/main.go new file mode 100644 index 000000000..e814f4595 --- /dev/null +++ b/examples/type_annotations/main.go @@ -0,0 +1,206 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "path" + "time" + + environ "github.com/ydb-platform/ydb-go-sdk-auth-environ" + ydb "github.com/ydb-platform/ydb-go-sdk/v3" + "github.com/ydb-platform/ydb-go-sdk/v3/query" + "github.com/ydb-platform/ydb-go-sdk/v3/sugar" +) + +var connectionString = flag.String("ydb", os.Getenv("YDB_CONNECTION_STRING"), "YDB connection string") + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + flag.Parse() + + if *connectionString == "" { + log.Fatal("YDB_CONNECTION_STRING environment variable or -ydb flag must be set") + } + + db, err := ydb.Open(ctx, *connectionString, + environ.WithEnvironCredentials(), + ) + if err != nil { + log.Fatalf("failed to connect to YDB: %v", err) + } + defer func() { _ = db.Close(ctx) }() + + prefix := path.Join(db.Name(), "type_annotations_example") + + // Clean up any existing data + _ = sugar.RemoveRecursive(ctx, db, prefix) + + // Create table + if err := createTable(ctx, db.Query(), prefix); err != nil { + log.Fatalf("failed to create table: %v", err) + } + + // Insert data + if err := insertData(ctx, db.Query(), prefix); err != nil { + log.Fatalf("failed to insert data: %v", err) + } + + // Read data with type annotations + if err := readDataWithTypeAnnotations(ctx, db.Query(), prefix); err != nil { + log.Fatalf("failed to read data: %v", err) + } + + // Demonstrate type mismatch detection + if err := demonstrateTypeMismatch(ctx, db.Query(), prefix); err != nil { + log.Printf("Expected error (type mismatch): %v", err) + } + + // Clean up + _ = sugar.RemoveRecursive(ctx, db, prefix) + + fmt.Println("\nExample completed successfully!") +} + +func createTable(ctx context.Context, c query.Client, prefix string) error { + return c.Exec(ctx, fmt.Sprintf(` + CREATE TABLE %s ( + product_id Uint64, + name Text, + description Text, + price Uint64, + tags List, + rating Optional, + metadata Dict, + PRIMARY KEY (product_id) + )`, "`"+path.Join(prefix, "products")+"`"), + query.WithTxControl(query.NoTx()), + ) +} + +func insertData(ctx context.Context, c query.Client, prefix string) error { + return c.Exec(ctx, fmt.Sprintf(` + INSERT INTO %s (product_id, name, description, price, tags, rating) + VALUES ( + 1, + "Laptop", + "High-performance laptop", + 999, + ["electronics", "computer", "portable"], + 4.5 + ); + `, "`"+path.Join(prefix, "products")+"`")) +} + +// Product demonstrates struct with YDB type annotations +type Product struct { + // Basic types with annotations + ProductID uint64 `sql:"product_id,type:Uint64"` + Name string `sql:"name,type:Text"` + Description string `sql:"description,type:Text"` + Price uint64 `sql:"price,type:Uint64"` + + // List type annotation + Tags []string `sql:"tags,type:List"` + + // Optional type annotation (can be NULL) + Rating *float64 `sql:"rating,type:Optional"` +} + +func readDataWithTypeAnnotations(ctx context.Context, c query.Client, prefix string) error { + fmt.Println("\n=== Reading data with type annotations ===") + + return c.Do(ctx, func(ctx context.Context, s query.Session) error { + result, err := s.Query(ctx, fmt.Sprintf(` + SELECT product_id, name, description, price, tags, rating + FROM %s + `, "`"+path.Join(prefix, "products")+"`"), + query.WithTxControl(query.TxControl(query.BeginTx(query.WithSnapshotReadOnly()))), + ) + if err != nil { + return err + } + defer result.Close(ctx) + + for rs, err := range result.ResultSets(ctx) { + if err != nil { + return err + } + + for row, err := range rs.Rows(ctx) { + if err != nil { + return err + } + + var product Product + + // ScanStruct will validate that database column types match the annotations + if err := row.ScanStruct(&product); err != nil { + return fmt.Errorf("scan error: %w", err) + } + + fmt.Printf("\nProduct ID: %d\n", product.ProductID) + fmt.Printf("Name: %s\n", product.Name) + fmt.Printf("Description: %s\n", product.Description) + fmt.Printf("Price: $%d\n", product.Price) + fmt.Printf("Tags: %v\n", product.Tags) + if product.Rating != nil { + fmt.Printf("Rating: %.1f/5.0\n", *product.Rating) + } else { + fmt.Println("Rating: Not rated") + } + } + } + + return nil + }) +} + +// ProductWrongType demonstrates what happens when type annotations don't match +type ProductWrongType struct { + ProductID uint64 `sql:"product_id,type:Text"` // Wrong! Should be Uint64 + Name string `sql:"name,type:Text"` +} + +func demonstrateTypeMismatch(ctx context.Context, c query.Client, prefix string) error { + fmt.Println("\n=== Demonstrating type mismatch detection ===") + + return c.Do(ctx, func(ctx context.Context, s query.Session) error { + result, err := s.Query(ctx, fmt.Sprintf(` + SELECT product_id, name + FROM %s + `, "`"+path.Join(prefix, "products")+"`"), + query.WithTxControl(query.TxControl(query.BeginTx(query.WithSnapshotReadOnly()))), + ) + if err != nil { + return err + } + defer result.Close(ctx) + + for rs, err := range result.ResultSets(ctx) { + if err != nil { + return err + } + + for row, err := range rs.Rows(ctx) { + if err != nil { + return err + } + + var product ProductWrongType + + // This will fail because product_id is Uint64 in the database + // but the annotation says it should be Text + if err := row.ScanStruct(&product); err != nil { + return fmt.Errorf("type mismatch detected: %w", err) + } + } + } + + return nil + }) +} diff --git a/internal/query/scanner/example_test.go b/internal/query/scanner/example_test.go new file mode 100644 index 000000000..4ab8e3d34 --- /dev/null +++ b/internal/query/scanner/example_test.go @@ -0,0 +1,113 @@ +package scanner_test + +import ( + "context" + "fmt" + "log" + + "github.com/ydb-platform/ydb-go-sdk/v3/query" +) + +// Example demonstrates using YDB type annotations in struct fields. +// The type annotation helps validate that the database column types match your expectations +// and can simplify code by making type conversions explicit in the struct definition. +func Example_typeAnnotations() { + // Define a struct with YDB type annotations + type Series struct { + // Column name: series_id, YDB type: Bytes + SeriesID []byte `sql:"series_id,type:Bytes"` + + // Column name: title, YDB type: Text (UTF-8 string) + Title string `sql:"title,type:Text"` + + // Column name: release_date, YDB type: Date + ReleaseDate string `sql:"release_date,type:Date"` + + // Column name: tags, YDB type: List + // This annotation ensures the column is a list of text values + Tags []string `sql:"tags,type:List"` + + // Column name: rating, YDB type: Optional + // This annotation indicates the column can be NULL + Rating *uint64 `sql:"rating,type:Optional"` + } + + // In your query code, you would use it like this: + _ = func(ctx context.Context, s query.Session) error { + result, err := s.Query(ctx, ` + SELECT + series_id, + title, + release_date, + tags, + rating + FROM series + `) + if err != nil { + return err + } + defer result.Close(ctx) + + for rs, err := range result.ResultSets(ctx) { + if err != nil { + return err + } + + for row, err := range rs.Rows(ctx) { + if err != nil { + return err + } + + var s Series + if err := row.ScanStruct(&s); err != nil { + // If the database column type doesn't match the annotation, + // you'll get a clear error message like: + // "type mismatch for field 'title': expected Text, got Uint64" + return err + } + + // Use the scanned data + log.Printf("Series: %s (ID: %v)", s.Title, s.SeriesID) + } + } + + return nil + } + + fmt.Println("Type annotations provide compile-time documentation and runtime validation") + // Output: Type annotations provide compile-time documentation and runtime validation +} + +// Example_withoutTypeAnnotations shows scanning without type annotations. +// This still works, but provides less validation and documentation. +func Example_withoutTypeAnnotations() { + type Series struct { + SeriesID []byte `sql:"series_id"` + Title string `sql:"title"` + ReleaseDate string `sql:"release_date"` + Tags []string `sql:"tags"` + Rating *uint64 `sql:"rating"` + } + + // This works the same way, but doesn't validate types + _ = Series{} + + fmt.Println("Scanning works with or without type annotations") + // Output: Scanning works with or without type annotations +} + +// Example_complexTypes shows using complex nested type annotations. +func Example_complexTypes() { + type Product struct { + // List of optional values + AlternateNames []*string `sql:"alternate_names,type:List>"` + + // Optional list + RelatedIDs *[]uint64 `sql:"related_ids,type:Optional>"` + } + + _ = Product{} + + fmt.Println("Complex nested types are supported") + // Output: Complex nested types are supported +} diff --git a/internal/query/scanner/struct.go b/internal/query/scanner/struct.go index fed023cd2..ceb2eedb7 100644 --- a/internal/query/scanner/struct.go +++ b/internal/query/scanner/struct.go @@ -5,6 +5,7 @@ import ( "reflect" "strings" + "github.com/ydb-platform/ydb-go-sdk/v3/internal/types" "github.com/ydb-platform/ydb-go-sdk/v3/internal/value" "github.com/ydb-platform/ydb-go-sdk/v3/internal/xerrors" ) @@ -26,13 +27,22 @@ func Struct(data *Data) StructScanner { } func fieldName(f reflect.StructField, tagName string) string { //nolint:gocritic - if name, has := f.Tag.Lookup(tagName); has { - return name + if tagValue, has := f.Tag.Lookup(tagName); has { + tag := parseFieldTag(tagValue) + return tag.columnName } return f.Name } +func fieldTag(f reflect.StructField, tagName string) structFieldTag { //nolint:gocritic + if tagValue, has := f.Tag.Lookup(tagName); has { + return parseFieldTag(tagValue) + } + + return structFieldTag{columnName: f.Name} +} + func (s StructScanner) ScanStruct(dst interface{}, opts ...ScanStructOption) (err error) { settings := scanStructSettings{ TagName: "sql", @@ -55,19 +65,36 @@ func (s StructScanner) ScanStruct(dst interface{}, opts ...ScanStructOption) (er missingColumns := make([]string, 0, len(s.data.columns)) existingFields := make(map[string]struct{}, tt.NumField()) for i := 0; i < tt.NumField(); i++ { - name := fieldName(tt.Field(i), settings.TagName) - if name == "-" { + tag := fieldTag(tt.Field(i), settings.TagName) + if tag.columnName == "-" { continue } - v, err := s.data.seekByName(name) + v, err := s.data.seekByName(tag.columnName) if err != nil { - missingColumns = append(missingColumns, name) + missingColumns = append(missingColumns, tag.columnName) } else { + // Validate type if type annotation is present + if tag.ydbType != "" { + expectedType, err := parseYDBType(tag.ydbType) + if err != nil { + return xerrors.WithStackTrace(fmt.Errorf("invalid type annotation for field '%s': %w", tag.columnName, err)) + } + actualType := types.TypeFromYDB(v.Type().ToYDB()) + if !types.Equal(expectedType, actualType) { + return xerrors.WithStackTrace(fmt.Errorf( + "type mismatch for field '%s': expected %s, got %s", + tag.columnName, + expectedType.String(), + actualType.String(), + )) + } + } + if err = value.CastTo(v, ptr.Elem().Field(i).Addr().Interface()); err != nil { - return xerrors.WithStackTrace(fmt.Errorf("scan error on struct field name '%s': %w", name, err)) + return xerrors.WithStackTrace(fmt.Errorf("scan error on struct field name '%s': %w", tag.columnName, err)) } - existingFields[name] = struct{}{} + existingFields[tag.columnName] = struct{}{} } } diff --git a/internal/query/scanner/struct_test.go b/internal/query/scanner/struct_test.go index 8fa23b03b..2eb59cc42 100644 --- a/internal/query/scanner/struct_test.go +++ b/internal/query/scanner/struct_test.go @@ -1005,3 +1005,389 @@ func TestScannerDecimalBigDecimal(t *testing.T) { require.NoError(t, err) require.Equal(t, expectedVal, row.A) } + +func TestStructWithTypeAnnotation(t *testing.T) { + scanner := Struct(NewData( + []*Ydb.Column{ + { + Name: "A", + Type: &Ydb.Type{ + Type: &Ydb.Type_TypeId{ + TypeId: Ydb.Type_UTF8, + }, + }, + }, + { + Name: "B", + Type: &Ydb.Type{ + Type: &Ydb.Type_TypeId{ + TypeId: Ydb.Type_UINT64, + }, + }, + }, + }, + []*Ydb.Value{ + { + Value: &Ydb.Value_TextValue{ + TextValue: "test-value", + }, + }, + { + Value: &Ydb.Value_Uint64Value{ + Uint64Value: 42, + }, + }, + }, + )) + var row struct { + A string `sql:"A,type:Text"` + B uint64 `sql:"B,type:Uint64"` + } + err := scanner.ScanStruct(&row) + require.NoError(t, err) + require.Equal(t, "test-value", row.A) + require.Equal(t, uint64(42), row.B) +} + +func TestStructWithListTypeAnnotation(t *testing.T) { + scanner := Struct(NewData( + []*Ydb.Column{ + { + Name: "A", + Type: &Ydb.Type{ + Type: &Ydb.Type_ListType{ + ListType: &Ydb.ListType{ + Item: &Ydb.Type{ + Type: &Ydb.Type_TypeId{ + TypeId: Ydb.Type_UTF8, + }, + }, + }, + }, + }, + }, + }, + []*Ydb.Value{ + { + Items: []*Ydb.Value{ + { + Value: &Ydb.Value_TextValue{ + TextValue: "item1", + }, + }, + { + Value: &Ydb.Value_TextValue{ + TextValue: "item2", + }, + }, + }, + }, + }, + )) + var row struct { + A []string `sql:"A,type:List"` + } + err := scanner.ScanStruct(&row) + require.NoError(t, err) + require.Equal(t, []string{"item1", "item2"}, row.A) +} + +func TestStructWithOptionalTypeAnnotation(t *testing.T) { + scanner := Struct(NewData( + []*Ydb.Column{ + { + Name: "A", + Type: &Ydb.Type{ + Type: &Ydb.Type_OptionalType{ + OptionalType: &Ydb.OptionalType{ + Item: &Ydb.Type{ + Type: &Ydb.Type_TypeId{ + TypeId: Ydb.Type_UTF8, + }, + }, + }, + }, + }, + }, + }, + []*Ydb.Value{ + { + Value: &Ydb.Value_NestedValue{ + NestedValue: &Ydb.Value{ + Value: &Ydb.Value_TextValue{ + TextValue: "optional-value", + }, + }, + }, + }, + }, + )) + var row struct { + A *string `sql:"A,type:Optional"` + } + err := scanner.ScanStruct(&row) + require.NoError(t, err) + require.NotNil(t, row.A) + require.Equal(t, "optional-value", *row.A) +} + +func TestStructWithTypeMismatch(t *testing.T) { + scanner := Struct(NewData( + []*Ydb.Column{ + { + Name: "A", + Type: &Ydb.Type{ + Type: &Ydb.Type_TypeId{ + TypeId: Ydb.Type_UTF8, + }, + }, + }, + }, + []*Ydb.Value{ + { + Value: &Ydb.Value_TextValue{ + TextValue: "test", + }, + }, + }, + )) + var row struct { + A string `sql:"A,type:Uint64"` // Wrong type annotation + } + err := scanner.ScanStruct(&row) + require.Error(t, err) + require.Contains(t, err.Error(), "type mismatch") +} + +func TestStructWithInvalidTypeAnnotation(t *testing.T) { + scanner := Struct(NewData( + []*Ydb.Column{ + { + Name: "A", + Type: &Ydb.Type{ + Type: &Ydb.Type_TypeId{ + TypeId: Ydb.Type_UTF8, + }, + }, + }, + }, + []*Ydb.Value{ + { + Value: &Ydb.Value_TextValue{ + TextValue: "test", + }, + }, + }, + )) + var row struct { + A string `sql:"A,type:InvalidType"` + } + err := scanner.ScanStruct(&row) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid type annotation") +} + +// Tests for backward compatibility - ensure old behavior works without type annotations + +func TestStructWithoutTypeAnnotation(t *testing.T) { + // Test that struct without type annotations still works (backward compatibility) + scanner := Struct(NewData( + []*Ydb.Column{ + { + Name: "A", + Type: &Ydb.Type{ + Type: &Ydb.Type_TypeId{ + TypeId: Ydb.Type_UTF8, + }, + }, + }, + { + Name: "B", + Type: &Ydb.Type{ + Type: &Ydb.Type_TypeId{ + TypeId: Ydb.Type_UINT64, + }, + }, + }, + }, + []*Ydb.Value{ + { + Value: &Ydb.Value_TextValue{ + TextValue: "test-value", + }, + }, + { + Value: &Ydb.Value_Uint64Value{ + Uint64Value: 42, + }, + }, + }, + )) + var row struct { + A string `sql:"A"` // No type annotation + B uint64 `sql:"B"` // No type annotation + } + err := scanner.ScanStruct(&row) + require.NoError(t, err) + require.Equal(t, "test-value", row.A) + require.Equal(t, uint64(42), row.B) +} + +func TestStructMixedTypeAnnotations(t *testing.T) { + // Test that mixing fields with and without type annotations works + scanner := Struct(NewData( + []*Ydb.Column{ + { + Name: "A", + Type: &Ydb.Type{ + Type: &Ydb.Type_TypeId{ + TypeId: Ydb.Type_UTF8, + }, + }, + }, + { + Name: "B", + Type: &Ydb.Type{ + Type: &Ydb.Type_TypeId{ + TypeId: Ydb.Type_UINT64, + }, + }, + }, + { + Name: "C", + Type: &Ydb.Type{ + Type: &Ydb.Type_TypeId{ + TypeId: Ydb.Type_BOOL, + }, + }, + }, + }, + []*Ydb.Value{ + { + Value: &Ydb.Value_TextValue{ + TextValue: "test", + }, + }, + { + Value: &Ydb.Value_Uint64Value{ + Uint64Value: 100, + }, + }, + { + Value: &Ydb.Value_BoolValue{ + BoolValue: true, + }, + }, + }, + )) + var row struct { + A string `sql:"A,type:Text"` // With type annotation + B uint64 `sql:"B"` // Without type annotation + C bool `sql:"C,type:Bool"` // With type annotation + } + err := scanner.ScanStruct(&row) + require.NoError(t, err) + require.Equal(t, "test", row.A) + require.Equal(t, uint64(100), row.B) + require.Equal(t, true, row.C) +} + +func TestStructListWithoutTypeAnnotation(t *testing.T) { + // Test that List type works without type annotation (backward compatibility) + scanner := Struct(NewData( + []*Ydb.Column{ + { + Name: "A", + Type: &Ydb.Type{ + Type: &Ydb.Type_ListType{ + ListType: &Ydb.ListType{ + Item: &Ydb.Type{ + Type: &Ydb.Type_TypeId{ + TypeId: Ydb.Type_UTF8, + }, + }, + }, + }, + }, + }, + }, + []*Ydb.Value{ + { + Items: []*Ydb.Value{ + { + Value: &Ydb.Value_TextValue{ + TextValue: "item1", + }, + }, + { + Value: &Ydb.Value_TextValue{ + TextValue: "item2", + }, + }, + }, + }, + }, + )) + var row struct { + A []string `sql:"A"` // No type annotation for List + } + err := scanner.ScanStruct(&row) + require.NoError(t, err) + require.Equal(t, []string{"item1", "item2"}, row.A) +} + +func TestStructOptionalWithoutTypeAnnotation(t *testing.T) { + // Test that Optional type works without type annotation (backward compatibility) + scanner := Struct(NewData( + []*Ydb.Column{ + { + Name: "A", + Type: &Ydb.Type{ + Type: &Ydb.Type_OptionalType{ + OptionalType: &Ydb.OptionalType{ + Item: &Ydb.Type{ + Type: &Ydb.Type_TypeId{ + TypeId: Ydb.Type_UTF8, + }, + }, + }, + }, + }, + }, + }, + []*Ydb.Value{ + { + Value: &Ydb.Value_NestedValue{ + NestedValue: &Ydb.Value{ + Value: &Ydb.Value_TextValue{ + TextValue: "optional-value", + }, + }, + }, + }, + }, + )) + var row struct { + A *string `sql:"A"` // No type annotation for Optional + } + err := scanner.ScanStruct(&row) + require.NoError(t, err) + require.NotNil(t, row.A) + require.Equal(t, "optional-value", *row.A) +} + +func TestStructWithDictTypeAnnotation(t *testing.T) { + // Test that Dict type annotation with comma in the type is parsed correctly + // This validates the fix for parseFieldTag to use findTopLevelComma + + // First verify the tag parsing works for Dict types + tag := parseFieldTag("metadata,type:Dict") + require.Equal(t, "metadata", tag.columnName) + require.Equal(t, "Dict", tag.ydbType) + + // Verify the type can be parsed + dictType, err := parseYDBType("Dict") + require.NoError(t, err) + require.NotNil(t, dictType) + require.Equal(t, "Dict", dictType.String()) +} diff --git a/internal/query/scanner/tag.go b/internal/query/scanner/tag.go new file mode 100644 index 000000000..a69e8228b --- /dev/null +++ b/internal/query/scanner/tag.go @@ -0,0 +1,215 @@ +package scanner + +import ( + "fmt" + "strings" + + "github.com/ydb-platform/ydb-go-sdk/v3/internal/types" + "github.com/ydb-platform/ydb-go-sdk/v3/internal/xerrors" +) + +// YDB Type Annotations +// +// This package supports type annotations in struct field tags to validate that database +// column types match the expected types. Type annotations are optional but provide: +// - Runtime validation of type compatibility +// - Better code documentation +// - Early detection of schema changes +// +// Syntax: +// type MyStruct struct { +// Name string `sql:"column_name,type:Text"` +// Age uint64 `sql:"age,type:Uint64"` +// } +// +// Supported type formats: +// - Primitive types: Bool, Int8, Int16, Int32, Int64, Uint8, Uint16, Uint32, Uint64, +// Float, Double, Date, Datetime, Timestamp, Text, Bytes, JSON, UUID, etc. +// - List types: List where T is any valid type +// - Optional types: Optional where T is any valid type +// - Dict types: Dict where K and V are valid types +// - Nested types: List>, Optional>, etc. +// +// Example: +// type Product struct { +// ID uint64 `sql:"product_id,type:Uint64"` +// Name string `sql:"name,type:Text"` +// Tags []string `sql:"tags,type:List"` +// Rating *float64 `sql:"rating,type:Optional"` +// } + +// structFieldTag represents parsed struct field tag information +type structFieldTag struct { + columnName string + ydbType string // YDB type annotation, e.g., "List", "Optional" +} + +// parseFieldTag parses a struct field tag value and extracts column name and type annotation +// Supported formats: +// - "column_name" - just the column name +// - "column_name,type:List" - column name with type annotation +// - "-" - skip this field +func parseFieldTag(tagValue string) structFieldTag { + if tagValue == "" || tagValue == "-" { + return structFieldTag{columnName: tagValue} + } + + // Find first top-level comma (outside angle brackets) + commaPos := findTopLevelComma(tagValue) + + var columnName, typeAnnotation string + if commaPos == -1 { + // No comma found, just column name + columnName = strings.TrimSpace(tagValue) + } else { + columnName = strings.TrimSpace(tagValue[:commaPos]) + // Parse options after the comma + options := strings.TrimSpace(tagValue[commaPos+1:]) + if strings.HasPrefix(options, "type:") { + typeAnnotation = strings.TrimSpace(strings.TrimPrefix(options, "type:")) + } + } + + return structFieldTag{ + columnName: columnName, + ydbType: typeAnnotation, + } +} + +// parseYDBType parses a YDB type string and returns the corresponding types.Type +// Examples: "Text", "Uint64", "List", "Optional", "List>" +func parseYDBType(typeStr string) (types.Type, error) { + typeStr = strings.TrimSpace(typeStr) + if typeStr == "" { + return nil, xerrors.WithStackTrace(fmt.Errorf("empty type string")) + } + + // Handle Optional<...> + if strings.HasPrefix(typeStr, "Optional<") && strings.HasSuffix(typeStr, ">") { + innerTypeStr := typeStr[len("Optional<") : len(typeStr)-1] + innerType, err := parseYDBType(innerTypeStr) + if err != nil { + return nil, err + } + return types.NewOptional(innerType), nil + } + + // Handle List<...> + if strings.HasPrefix(typeStr, "List<") && strings.HasSuffix(typeStr, ">") { + itemTypeStr := typeStr[len("List<") : len(typeStr)-1] + itemType, err := parseYDBType(itemTypeStr) + if err != nil { + return nil, err + } + return types.NewList(itemType), nil + } + + // Handle Dict<...,...> + if strings.HasPrefix(typeStr, "Dict<") && strings.HasSuffix(typeStr, ">") { + innerStr := typeStr[len("Dict<") : len(typeStr)-1] + // Find the comma that separates key and value types + // Need to handle nested types properly + commaPos := findTopLevelComma(innerStr) + if commaPos == -1 { + return nil, xerrors.WithStackTrace(fmt.Errorf("invalid Dict type format: %s", typeStr)) + } + keyTypeStr := strings.TrimSpace(innerStr[:commaPos]) + valueTypeStr := strings.TrimSpace(innerStr[commaPos+1:]) + keyType, err := parseYDBType(keyTypeStr) + if err != nil { + return nil, err + } + valueType, err := parseYDBType(valueTypeStr) + if err != nil { + return nil, err + } + return types.NewDict(keyType, valueType), nil + } + + // Handle primitive types + return parsePrimitiveYDBType(typeStr) +} + +// findTopLevelComma finds the position of a comma that is not inside angle brackets +func findTopLevelComma(s string) int { + depth := 0 + for i, ch := range s { + switch ch { + case '<': + depth++ + case '>': + depth-- + case ',': + if depth == 0 { + return i + } + } + } + return -1 +} + +// parsePrimitiveYDBType parses primitive YDB types +func parsePrimitiveYDBType(typeStr string) (types.Type, error) { + switch typeStr { + case "Bool": + return types.Bool, nil + case "Int8": + return types.Int8, nil + case "Uint8": + return types.Uint8, nil + case "Int16": + return types.Int16, nil + case "Uint16": + return types.Uint16, nil + case "Int32": + return types.Int32, nil + case "Uint32": + return types.Uint32, nil + case "Int64": + return types.Int64, nil + case "Uint64": + return types.Uint64, nil + case "Float": + return types.Float, nil + case "Double": + return types.Double, nil + case "Date": + return types.Date, nil + case "Date32": + return types.Date32, nil + case "Datetime": + return types.Datetime, nil + case "Datetime64": + return types.Datetime64, nil + case "Timestamp": + return types.Timestamp, nil + case "Timestamp64": + return types.Timestamp64, nil + case "Interval": + return types.Interval, nil + case "Interval64": + return types.Interval64, nil + case "TzDate": + return types.TzDate, nil + case "TzDatetime": + return types.TzDatetime, nil + case "TzTimestamp": + return types.TzTimestamp, nil + case "String", "Bytes": + return types.Bytes, nil + case "Utf8", "Text": + return types.Text, nil + case "Yson", "YSON": + return types.YSON, nil + case "Json", "JSON": + return types.JSON, nil + case "Uuid", "UUID": + return types.UUID, nil + case "JsonDocument", "JSONDocument": + return types.JSONDocument, nil + case "DyNumber": + return types.DyNumber, nil + default: + return nil, xerrors.WithStackTrace(fmt.Errorf("unknown YDB type: %s", typeStr)) + } +} diff --git a/internal/query/scanner/tag_test.go b/internal/query/scanner/tag_test.go new file mode 100644 index 000000000..fe45127a3 --- /dev/null +++ b/internal/query/scanner/tag_test.go @@ -0,0 +1,233 @@ +package scanner + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ydb-platform/ydb-go-sdk/v3/internal/types" +) + +func TestParseFieldTag(t *testing.T) { + tests := []struct { + name string + tagValue string + want structFieldTag + }{ + { + name: "just column name", + tagValue: "my_column", + want: structFieldTag{columnName: "my_column", ydbType: ""}, + }, + { + name: "column name with type annotation", + tagValue: "my_column,type:List", + want: structFieldTag{columnName: "my_column", ydbType: "List"}, + }, + { + name: "skip field marker", + tagValue: "-", + want: structFieldTag{columnName: "-", ydbType: ""}, + }, + { + name: "empty tag", + tagValue: "", + want: structFieldTag{columnName: "", ydbType: ""}, + }, + { + name: "with spaces", + tagValue: "my_column , type:List ", + want: structFieldTag{columnName: "my_column", ydbType: "List"}, + }, + { + name: "optional type", + tagValue: "id,type:Optional", + want: structFieldTag{columnName: "id", ydbType: "Optional"}, + }, + { + name: "dict type with comma", + tagValue: "metadata,type:Dict", + want: structFieldTag{columnName: "metadata", ydbType: "Dict"}, + }, + { + name: "dict type with spaces", + tagValue: "metadata , type:Dict", + want: structFieldTag{columnName: "metadata", ydbType: "Dict"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseFieldTag(tt.tagValue) + require.Equal(t, tt.want, got) + }) + } +} + +func TestParseYDBType(t *testing.T) { + tests := []struct { + name string + typeStr string + want types.Type + wantErr bool + }{ + { + name: "Bool", + typeStr: "Bool", + want: types.Bool, + }, + { + name: "Int64", + typeStr: "Int64", + want: types.Int64, + }, + { + name: "Uint64", + typeStr: "Uint64", + want: types.Uint64, + }, + { + name: "Text", + typeStr: "Text", + want: types.Text, + }, + { + name: "Utf8 alias", + typeStr: "Utf8", + want: types.Text, + }, + { + name: "String as Bytes", + typeStr: "String", + want: types.Bytes, + }, + { + name: "Bytes", + typeStr: "Bytes", + want: types.Bytes, + }, + { + name: "Date", + typeStr: "Date", + want: types.Date, + }, + { + name: "Timestamp", + typeStr: "Timestamp", + want: types.Timestamp, + }, + { + name: "List", + typeStr: "List", + want: types.NewList(types.Text), + }, + { + name: "List", + typeStr: "List", + want: types.NewList(types.Uint64), + }, + { + name: "Optional", + typeStr: "Optional", + want: types.NewOptional(types.Text), + }, + { + name: "Optional", + typeStr: "Optional", + want: types.NewOptional(types.Uint64), + }, + { + name: "List>", + typeStr: "List>", + want: types.NewList(types.NewOptional(types.Text)), + }, + { + name: "Optional>", + typeStr: "Optional>", + want: types.NewOptional(types.NewList(types.Text)), + }, + { + name: "Dict", + typeStr: "Dict", + want: types.NewDict(types.Text, types.Uint64), + }, + { + name: "Dict with spaces", + typeStr: "Dict", + want: types.NewDict(types.Text, types.Uint64), + }, + { + name: "Dict>", + typeStr: "Dict>", + want: types.NewDict(types.Text, types.NewList(types.Uint64)), + }, + { + name: "empty string error", + typeStr: "", + wantErr: true, + }, + { + name: "unknown type error", + typeStr: "UnknownType", + wantErr: true, + }, + { + name: "invalid Dict format", + typeStr: "Dict", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseYDBType(tt.typeStr) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.True(t, tt.want.String() == got.String(), "expected %v, got %v", tt.want, got) + } + }) + } +} + +func TestFindTopLevelComma(t *testing.T) { + tests := []struct { + name string + s string + want int + }{ + { + name: "simple comma", + s: "Text,Uint64", + want: 4, + }, + { + name: "no comma", + s: "Text", + want: -1, + }, + { + name: "comma inside brackets", + s: "List,Uint64", + want: 10, + }, + { + name: "nested brackets with comma", + s: "List>,Uint64", + want: 22, + }, + { + name: "multiple top-level commas", + s: "Text,Uint64,Bool", + want: 4, // Returns first one + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findTopLevelComma(tt.s) + require.Equal(t, tt.want, got) + }) + } +}