diff --git a/api/dgraphtypes/dgraphtypes.go b/api/dgraphtypes/dgraphtypes.go index b418f68..b17b75b 100644 --- a/api/dgraphtypes/dgraphtypes.go +++ b/api/dgraphtypes/dgraphtypes.go @@ -13,7 +13,9 @@ import ( "github.com/dgraph-io/dgo/v240/protos/api" "github.com/hypermodeinc/dgraph/v24/protos/pb" "github.com/hypermodeinc/dgraph/v24/types" + modusapi "github.com/hypermodeinc/modusdb/api" "github.com/hypermodeinc/modusdb/api/structreflect" + "github.com/pkg/errors" "github.com/twpayne/go-geom" "github.com/twpayne/go-geom/encoding/wkb" ) @@ -70,15 +72,16 @@ func ValueToPosting_ValType(v any) (pb.Posting_ValType, error) { return pb.Posting_BINARY, nil case time.Time: return pb.Posting_DATETIME, nil - case geom.Point: + case modusapi.Point, modusapi.Polygon: return pb.Posting_GEO, nil case []float32, []float64: return pb.Posting_VFLOAT, nil default: - return pb.Posting_DEFAULT, fmt.Errorf("unsupported type %T", v) + return pb.Posting_DEFAULT, fmt.Errorf("value to posting, unsupported type %T", v) } } +// ValueToApiVal converts a value to an api.Value. Note the result can be nil for empty non-scalar types func ValueToApiVal(v any) (*api.Value, error) { switch val := v.(type) { case string: @@ -124,17 +127,44 @@ func ValueToApiVal(v any) (*api.Value, error) { if err != nil { return nil, err } - return &api.Value{Val: &api.Value_DateVal{DateVal: bytes}}, nil - case geom.Point: - bytes, err := wkb.Marshal(&val, binary.LittleEndian) + return &api.Value{Val: &api.Value_DatetimeVal{DatetimeVal: bytes}}, nil + case modusapi.Point: + if len(val.Coordinates) == 0 { + return nil, nil + } + point, err := geom.NewPoint(geom.XY).SetCoords(val.Coordinates) if err != nil { - return nil, err + return nil, errors.Wrap(err, "converting point to api value") + } + bytes, err := wkb.Marshal(point, binary.LittleEndian) + if err != nil { + return nil, errors.Wrap(err, "marshalling point to wkb") + } + return &api.Value{Val: &api.Value_GeoVal{GeoVal: bytes}}, nil + case modusapi.Polygon: + if len(val.Coordinates) == 0 { + return nil, nil + } + coords := make([][]geom.Coord, len(val.Coordinates)) + for i, polygon := range val.Coordinates { + coords[i] = make([]geom.Coord, len(polygon)) + for j, point := range polygon { + coords[i][j] = geom.Coord{point[0], point[1]} + } + } + polygon, err := geom.NewPolygon(geom.XY).SetCoords(coords) + if err != nil { + return nil, errors.Wrap(err, "converting polygon to api value") + } + bytes, err := wkb.Marshal(polygon, binary.LittleEndian) + if err != nil { + return nil, errors.Wrap(err, "marshalling polygon to wkb") } return &api.Value{Val: &api.Value_GeoVal{GeoVal: bytes}}, nil case uint: return &api.Value{Val: &api.Value_DefaultVal{DefaultVal: fmt.Sprint(v)}}, nil default: - return nil, fmt.Errorf("unsupported type %T", v) + return nil, fmt.Errorf("convert value to api value, unsupported type %T", v) } } diff --git a/api/mutations/mutations.go b/api/mutations/mutations.go index 92b6d70..2beab62 100644 --- a/api/mutations/mutations.go +++ b/api/mutations/mutations.go @@ -49,6 +49,7 @@ func CreateNQuadAndSchema(value any, gid uint64, jsonName string, t reflect.Type return nil, nil, err } + // val can be null here for "empty" no-scalar types val, err := dgraphtypes.ValueToApiVal(value) if err != nil { return nil, nil, err @@ -68,7 +69,7 @@ func CreateNQuadAndSchema(value any, gid uint64, jsonName string, t reflect.Type if valType == pb.Posting_UID { nquad.ObjectId = fmt.Sprint(value) u.Directive = pb.SchemaUpdate_REVERSE - } else { + } else if val != nil { nquad.ObjectValue = val } diff --git a/api/structreflect/structreflect.go b/api/structreflect/structreflect.go index 0dc1540..1134d3f 100644 --- a/api/structreflect/structreflect.go +++ b/api/structreflect/structreflect.go @@ -9,7 +9,9 @@ import ( "fmt" "reflect" "strconv" + "time" + "github.com/hypermodeinc/modusdb/api" "github.com/hypermodeinc/modusdb/api/apiutils" ) @@ -43,12 +45,48 @@ func GetFieldTags(t reflect.Type) (*TagMaps, error) { return tags, nil } +var skipProcessStructTypes = []reflect.Type{ + reflect.TypeOf(api.Point{}), + reflect.TypeOf(api.Polygon{}), + reflect.TypeOf(api.MultiPolygon{}), + reflect.TypeOf(time.Time{}), +} + +func IsDgraphType(value any) bool { + valueType := reflect.TypeOf(value) + if valueType.Kind() == reflect.Ptr { + valueType = valueType.Elem() + } + for _, t := range skipProcessStructTypes { + if valueType == t { + return true + } + } + return false +} + +func IsStructAndNotDgraphType(field reflect.StructField) bool { + fieldType := field.Type + if fieldType.Kind() == reflect.Ptr { + fieldType = fieldType.Elem() + } + if fieldType.Kind() != reflect.Struct { + return false + } + for _, t := range skipProcessStructTypes { + if fieldType == t { + return false + } + } + return true +} + func CreateDynamicStruct(t reflect.Type, fieldToJson map[string]string, depth int) reflect.Type { fields := make([]reflect.StructField, 0, len(fieldToJson)) for fieldName, jsonName := range fieldToJson { field, _ := t.FieldByName(fieldName) if fieldName != "Gid" { - if field.Type.Kind() == reflect.Struct { + if IsStructAndNotDgraphType(field) { if depth <= 1 { tagMaps, _ := GetFieldTags(field.Type) nestedType := CreateDynamicStruct(field.Type, tagMaps.FieldToJson, depth+1) @@ -59,7 +97,7 @@ func CreateDynamicStruct(t reflect.Type, fieldToJson map[string]string, depth in }) } } else if field.Type.Kind() == reflect.Ptr && - field.Type.Elem().Kind() == reflect.Struct { + IsStructAndNotDgraphType(field) { tagMaps, _ := GetFieldTags(field.Type.Elem()) nestedType := CreateDynamicStruct(field.Type.Elem(), tagMaps.FieldToJson, depth+1) fields = append(fields, reflect.StructField{ @@ -132,13 +170,14 @@ func MapDynamicToFinal(dynamic any, final any, isNested bool) (uint64, error) { } else { finalField = vFinal.FieldByName(dynamicField.Name) } - if dynamicFieldType.Kind() == reflect.Struct { + //if dynamicFieldType.Kind() == reflect.Struct { + if IsStructAndNotDgraphType(dynamicField) { _, err := MapDynamicToFinal(dynamicValue.Addr().Interface(), finalField.Addr().Interface(), true) if err != nil { return 0, err } } else if dynamicFieldType.Kind() == reflect.Ptr && - dynamicFieldType.Elem().Kind() == reflect.Struct { + IsStructAndNotDgraphType(dynamicField) { // if field is a pointer, find if the underlying is a struct _, err := MapDynamicToFinal(dynamicValue.Interface(), finalField.Interface(), true) if err != nil { diff --git a/api/types.go b/api/types.go new file mode 100644 index 0000000..e524381 --- /dev/null +++ b/api/types.go @@ -0,0 +1,29 @@ +package api + +type Point struct { + Type string `json:"type,omitempty"` + Coordinates []float64 `json:"coordinates,omitempty"` +} + +type Polygon struct { + Type string `json:"type,omitempty"` + Coordinates [][][]float64 `json:"coordinates,omitempty"` +} + +type MultiPolygon = Polygon + +func NewPolygon(coordinates [][]float64) *Polygon { + polygon := &Polygon{ + Type: "Polygon", + Coordinates: [][][]float64{coordinates}, + } + return polygon +} + +func NewMultiPolygon(coordinates [][][]float64) *MultiPolygon { + multiPolygon := &MultiPolygon{ + Type: "MultiPolygon", + Coordinates: coordinates, + } + return multiPolygon +} diff --git a/api/types_test.go b/api/types_test.go new file mode 100644 index 0000000..dd229c7 --- /dev/null +++ b/api/types_test.go @@ -0,0 +1,46 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewPolygon(t *testing.T) { + coordinates := [][]float64{ + {-122.083506, 37.4259518}, // Northwest + {-122.081506, 37.4259518}, // Northeast + {-122.081506, 37.4239518}, // Southeast + {-122.083506, 37.4239518}, // Southwest + {-122.083506, 37.4259518}, // Close the polygon + } + + polygon := NewPolygon(coordinates) + require.NotNil(t, polygon) + require.Len(t, polygon.Coordinates, 1) + require.Equal(t, coordinates, polygon.Coordinates[0]) +} + +func TestNewMultiPolygon(t *testing.T) { + coordinates := [][][]float64{ + { + {-122.083506, 37.4259518}, + {-122.081506, 37.4259518}, + {-122.081506, 37.4239518}, + {-122.083506, 37.4239518}, + {-122.083506, 37.4259518}, + }, + { + {-122.073506, 37.4359518}, + {-122.071506, 37.4359518}, + {-122.071506, 37.4339518}, + {-122.073506, 37.4339518}, + {-122.073506, 37.4359518}, + }, + } + + multiPolygon := NewMultiPolygon(coordinates) + require.NotNil(t, multiPolygon) + require.Equal(t, "MultiPolygon", multiPolygon.Type) + require.Equal(t, coordinates, multiPolygon.Coordinates) +} diff --git a/api_mutation_gen.go b/api_mutation_gen.go index e7095da..7f2e63c 100644 --- a/api_mutation_gen.go +++ b/api_mutation_gen.go @@ -100,6 +100,12 @@ func generateSetDqlMutationsAndSchema[T any](ctx context.Context, n *Namespace, } sch.Preds = append(sch.Preds, u) + // Handle nil object values - only skip geo types with nil values + if nquad.ObjectValue == nil && (strings.Contains(nquad.Predicate, ".multiArea") || + strings.Contains(nquad.Predicate, ".area") || + strings.Contains(nquad.Predicate, ".loc")) { + continue + } nquads = append(nquads, nquad) } if !uniqueConstraintFound { diff --git a/api_mutation_helpers.go b/api_mutation_helpers.go index a12d239..da63c56 100644 --- a/api_mutation_helpers.go +++ b/api_mutation_helpers.go @@ -20,7 +20,7 @@ import ( ) func processStructValue(ctx context.Context, value any, ns *Namespace) (any, error) { - if reflect.TypeOf(value).Kind() == reflect.Struct { + if !structreflect.IsDgraphType(value) && reflect.TypeOf(value).Kind() == reflect.Struct { value = reflect.ValueOf(value).Interface() newGid, err := getUidOrMutate(ctx, ns.engine, ns, value) if err != nil { diff --git a/unit_test/api_test.go b/unit_test/api_test.go index a404547..e032a0e 100644 --- a/unit_test/api_test.go +++ b/unit_test/api_test.go @@ -9,10 +9,12 @@ import ( "context" "fmt" "testing" + "time" "github.com/stretchr/testify/require" "github.com/hypermodeinc/modusdb" + "github.com/hypermodeinc/modusdb/api" "github.com/hypermodeinc/modusdb/api/apiutils" ) @@ -876,3 +878,220 @@ func TestVectorIndexSearchWithQuery(t *testing.T) { require.Equal(t, "fox", docs[3].Text) require.Equal(t, "gorilla", docs[4].Text) } + +type Alltypes struct { + Gid uint64 `json:"gid,omitempty"` + Name string `json:"name,omitempty"` + Age int `json:"age,omitempty"` + Count int64 `json:"count,omitempty"` + Married bool `json:"married,omitempty"` + FloatVal float32 `json:"floatVal,omitempty"` + Float64Val float64 `json:"float64Val,omitempty"` + //Loc geom.Point `json:"loc,omitempty"` + DoB time.Time `json:"dob,omitempty"` +} + +func TestAllSchemaTypes(t *testing.T) { + ctx := context.Background() + engine, err := modusdb.NewEngine(modusdb.NewDefaultConfig(t.TempDir())) + require.NoError(t, err) + defer engine.Close() + + require.NoError(t, engine.DropAll(ctx)) + + //loc := geom.NewPoint(geom.XY).MustSetCoords(geom.Coord{-122.082506, 37.4249518}) + dob := time.Date(1965, 6, 24, 0, 0, 0, 0, time.UTC) + _, omnibus, err := modusdb.Create(context.Background(), engine, Alltypes{ + Name: "John Doe", + Age: 30, + Count: 100, + Married: true, + FloatVal: 3.14159, + Float64Val: 123.456789, + //Loc: *loc, + DoB: dob, + }) + + require.NoError(t, err) + require.NotZero(t, omnibus.Gid) + require.Equal(t, "John Doe", omnibus.Name) + require.Equal(t, 30, omnibus.Age) + require.Equal(t, true, omnibus.Married) + require.Equal(t, int64(100), omnibus.Count) + require.Equal(t, float32(3.14159), omnibus.FloatVal) + require.InDelta(t, 123.456789, omnibus.Float64Val, 0.000001) + //require.Equal(t, loc, omnibus.Loc) + require.Equal(t, dob, omnibus.DoB) +} + +type TimeStruct struct { + Name string `json:"name,omitempty" db:"constraint=unique"` + Time time.Time `json:"time,omitempty"` + TimePtr *time.Time `json:"timePtr,omitempty"` +} + +func TestTime(t *testing.T) { + ctx := context.Background() + engine, err := modusdb.NewEngine(modusdb.NewDefaultConfig(t.TempDir())) + require.NoError(t, err) + defer engine.Close() + + d := time.Date(1965, 6, 24, 12, 0, 0, 0, time.UTC) + gid, justTime, err := modusdb.Create(ctx, engine, TimeStruct{ + Name: "John Doe", + Time: d, + TimePtr: &d, + }) + require.NoError(t, err) + + require.Equal(t, "John Doe", justTime.Name) + require.Equal(t, d, justTime.Time) + require.Equal(t, d, *justTime.TimePtr) + + _, justTime, err = modusdb.Get[TimeStruct](ctx, engine, gid) + require.NoError(t, err) + require.Equal(t, "John Doe", justTime.Name) + require.Equal(t, d, justTime.Time) + require.Equal(t, d, *justTime.TimePtr) + + // Add another time entry + d2 := time.Date(1965, 6, 24, 11, 59, 59, 0, time.UTC) + _, _, err = modusdb.Create(ctx, engine, TimeStruct{ + Name: "Jane Doe", + Time: d2, + TimePtr: &d2, + }) + require.NoError(t, err) + + _, entries, err := modusdb.Query[TimeStruct](ctx, engine, modusdb.QueryParams{ + Filter: &modusdb.Filter{ + Field: "time", + String: modusdb.StringPredicate{ + // TODO: Not too crazy about this. Thinking we should add XXXPredicate definitions for all scalars -MM + GreaterOrEqual: fmt.Sprintf("\"%s\"", d.Format(time.RFC3339)), + }, + }, + }) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, "John Doe", entries[0].Name) + require.Equal(t, d, entries[0].Time) + require.Equal(t, d, *entries[0].TimePtr) +} + +type GeomStruct struct { + Gid uint64 `json:"gid,omitempty"` + Name string `json:"name,omitempty" db:"constraint=unique"` + Point api.Point `json:"loc,omitempty"` + Area api.Polygon `json:"area,omitempty"` + MultiArea api.MultiPolygon `json:"multiArea,omitempty"` +} + +func TestPoint(t *testing.T) { + ctx := context.Background() + engine, err := modusdb.NewEngine(modusdb.NewDefaultConfig(t.TempDir())) + //engine, err := modusdb.NewEngine(modusdb.NewDefaultConfig("./foo")) + require.NoError(t, err) + defer engine.Close() + + loc := api.Point{ + Coordinates: []float64{-122.082506, 37.4249518}, + } + gid, geomStruct, err := modusdb.Create(ctx, engine, GeomStruct{ + Name: "John Doe", + Point: loc, + }) + require.NoError(t, err) + require.Equal(t, "John Doe", geomStruct.Name) + require.Equal(t, loc.Coordinates, geomStruct.Point.Coordinates) + + _, geomStruct, err = modusdb.Get[GeomStruct](ctx, engine, gid) + require.NoError(t, err) + require.Equal(t, "John Doe", geomStruct.Name) + require.Equal(t, loc.Coordinates, geomStruct.Point.Coordinates) + + query := ` + { + geomStruct(func: type(GeomStruct)) { + GeomStruct.name + } + }` + resp, err := engine.GetDefaultNamespace().Query(ctx, query) + require.NoError(t, err) + require.JSONEq(t, `{ + "geomStruct":[ + {"GeomStruct.name":"John Doe"} + ] + }`, string(resp.GetJson())) +} + +func TestPolygon(t *testing.T) { + ctx := context.Background() + engine, err := modusdb.NewEngine(modusdb.NewDefaultConfig(t.TempDir())) + require.NoError(t, err) + defer engine.Close() + + polygon := api.NewPolygon([][]float64{ + {-122.083506, 37.4259518}, // Northwest + {-122.081506, 37.4259518}, // Northeast + {-122.081506, 37.4239518}, // Southeast + {-122.083506, 37.4239518}, // Southwest + {-122.083506, 37.4259518}, // Close the polygon by repeating first point + }) + _, geomStruct, err := modusdb.Create(ctx, engine, GeomStruct{ + Name: "Jane Doe", + Area: *polygon, + }) + require.NoError(t, err) + require.Equal(t, "Jane Doe", geomStruct.Name) + require.Equal(t, polygon.Coordinates, geomStruct.Area.Coordinates) +} + +func TestMultiPolygon(t *testing.T) { + ctx := context.Background() + engine, err := modusdb.NewEngine(modusdb.NewDefaultConfig(t.TempDir())) + require.NoError(t, err) + defer engine.Close() + + multiPolygon := api.NewMultiPolygon([][][]float64{ + { + {-122.083506, 37.4259518}, // Northwest + {-122.081506, 37.4259518}, // Northeast + {-122.081506, 37.4239518}, // Southeast + {-122.083506, 37.4239518}, // Southwest + {-122.083506, 37.4259518}, // Close the polygon by repeating first point + }, + { + {-122.073506, 37.4359518}, // Northwest + {-122.071506, 37.4359518}, // Northeast + {-122.071506, 37.4339518}, // Southeast + {-122.073506, 37.4339518}, // Southwest + {-122.073506, 37.4359518}, // Close the polygon by repeating first point + }, + }) + _, geomStruct, err := modusdb.Create(ctx, engine, GeomStruct{ + Name: "Jane Doe", + MultiArea: *multiPolygon, + }) + require.NoError(t, err) + require.Equal(t, "Jane Doe", geomStruct.Name) + require.Equal(t, multiPolygon.Coordinates, geomStruct.MultiArea.Coordinates) +} + +func TestUserStore(t *testing.T) { + ctx := context.Background() + //engine, err := modusdb.NewEngine(modusdb.NewDefaultConfig(t.TempDir())) + engine, err := modusdb.NewEngine(modusdb.NewDefaultConfig("./foo")) + require.NoError(t, err) + defer engine.Close() + + user := User{ + Name: "John Doe", + Age: 30, + } + gid, user, err := modusdb.Create(ctx, engine, user) + require.NoError(t, err) + require.NotZero(t, gid) + require.Equal(t, "John Doe", user.Name) + require.Equal(t, 30, user.Age) +}