How to implement nested composite types with pgx/pgtype

Viewed 143

To avoid complex 1-to-1 mappings between Postgres tables, I thought it would be a good idea to use composite types instead. So I created these two types, and a table to hold them:

CREATE TYPE my_point AS (x INTEGER, y INTEGER);
CREATE TYPE my_rectangle AS (p1 my_point, p2 my_point);
CREATE TABLE zoo (id INTEGER, r my_rectangle, p my_point);

(The types and tables are just for the example. In the real project, they are quite different, so there's no point in proposing alternative solutions, such as using the built-in Postgres point type)

So far so good. It works as expected when doing manual queries in psql etc. Now to the problem:

I'm trying to access these types using go, pgx and sqlx. For the my_point type, I got a working solution quite easily by mostly copying the point implementation from pgtype. But no such luck for the my_rectangle type. I've tried to come up with multiple solutions, but run into different problems with each. The error I get with the code below for example is:

Unknown OID for model.MyPointType{Point:model.MyPoint{X:20, Y:21}, Status:0x2}

Here's my test function:


func TestCompositeTypes(t *testing.T) {
    // Setup database connection ...
    conn := ...
    ci := conn.ConnInfo()

    // Get OID for "my_point"
    var myPointOID uint32
    err = conn.QueryRow(context.Background(), `select 'my_point'::regtype::oid`).Scan(&myPointOID)
    require.NoError(t, err)

    // Get OID for "my_rectangle"
    var myRectangleOID uint32
    err = conn.QueryRow(context.Background(), `select 'my_rectangle'::regtype::oid`).Scan(&myRectangleOID)
    require.NoError(t, err)

    ci.RegisterDataType(pgtype.DataType{
        Value: &MyPointType{},
        Name: "my_point",
        OID: myPointOID,
    })

    ci.RegisterDataType(pgtype.DataType{
        Value: &MyRectangleType{},
        Name: "my_rectangle",
        OID: myRectangleOID,
    })

    p := MyPointType{
        Point:  MyPoint{50, 100},
        Status: pgtype.Present,
    }

    // This works!
    _, err = db.Connection.Exec(
        "INSERT INTO zoo (id, p) VALUES ($1, $2::my_point)",
        100,
        p
    )
    require.NoError(t, err)

    r := MyRectangleType{
        P1:     MyPointType{Point: MyPoint{20, 21}, Status: pgtype.Present},
        P2:     MyPointType{Point: MyPoint{30, 31}, Status: pgtype.Present},
        Status: pgtype.Present,
    }

    // This does *not* work! (see error above)
    _, err = db.Connection.Exec(
        "INSERT INTO zoo (id, r) VALUES ($1, $2::my_rectangle)",
        100,
        r
    )
    require.NoError(t, err)
}

Here's the implementation of MyPoint:

type MyPoint struct {
    X int
    Y int
}

type MyPointType struct {
    Point  MyPoint
    Status pgtype.Status
}

func (p *MyPointType) AssignTo(dst interface{}) error {
    return fmt.Errorf("cannot assign %v to %T", p, dst)
}

func (p MyPointType) Get() interface{} {
    switch p.Status {
    case pgtype.Present:
        return p
    case pgtype.Null:
        return nil
    default:
        return p.Status
    }
}

func parsePoint(src []byte) (*MyPointType, error) {
    if src == nil || bytes.Compare(src, []byte("null")) == 0 {
        return &MyPointType{Status: pgtype.Null}, nil
    }

    if len(src) < 5 {
        return nil, fmt.Errorf("invalid length for my_point: %v", len(src))
    }

    if src[0] == '"' && src[len(src)-1] == '"' {
        src = src[1 : len(src)-1]
    }

    parts := strings.SplitN(string(src[1:len(src)-1]), ",", 2)
    if len(parts) < 2 {
        return nil, fmt.Errorf("invalid format for my_point")
    }

    x, err := strconv.ParseInt(parts[0], 10, 32)
    if err != nil {
        return nil, err
    }

    y, err := strconv.ParseInt(parts[1], 10, 32)
    if err != nil {
        return nil, err
    }

    return &MyPointType{Point: MyPoint{int(x), int(y)}, Status: pgtype.Present}, nil
}

func (p *MyPointType) Set(src interface{}) error {
    if src == nil {
        p.Status = pgtype.Null
        return nil
    }
    err := fmt.Errorf("cannot convert %v to MyPointType", src)
    var parsed *MyPointType
    switch value := src.(type) {
    case string:
        parsed, err = parsePoint([]byte(value))
    case []byte:
        parsed, err = parsePoint(value)
    default:
        return err
    }
    if err != nil {
        return err
    }
    *p = *parsed
    return nil
}

func (p MyPointType) EncodeBinary(ci *pgtype.ConnInfo, buf []byte) ([]byte, error) {
    switch p.Status {
    case pgtype.Null:
        return nil, nil
    case pgtype.Undefined:
        return nil, errUndefined
    }

    // This is how pgtype.Point does it, but I think my implementation below
    // works similarly and is more safe...?
    //
    // buf = pgio.AppendUint64(buf, math.Float64bits(src.P.X))
    // buf = pgio.AppendUint64(buf, math.Float64bits(src.P.Y))
    // return buf, nil

    return (pgtype.CompositeFields{int32(p.Point.X), int32(p.Point.Y)}).EncodeBinary(ci, buf)
}

And here's the implementation of MyRectangleType:

type MyRectangleType struct {
    P1     MyPointType
    P2     MyPointType
    Status pgtype.Status
}

func (r *MyRectangleType) AssignTo(dst interface{}) error {
    return fmt.Errorf("cannot assign %v to %T", r, dst)
}

func (r MyRectangleType) Get() interface{} {
    switch r.Status {
    case pgtype.Present:
        return r
    case pgtype.Null:
        return nil
    default:
        return r.Status
    }
}


func parseRectangle(src []byte) (*MyRectangleType, error) {
    if src == nil || bytes.Compare(src, []byte("null")) == 0 {
        return &MyRectangleType{Status: pgtype.Null}, nil
    }

    if len(src) < 5 {
        return nil, fmt.Errorf("invalid length for my_rectangle: %v", len(src))
    }

    if src[0] == '"' && src[len(src)-1] == '"' {
        src = src[1 : len(src)-1]
    }

    parts := strings.SplitN(string(src[1:len(src)-1]), ",", 2)
    if len(parts) < 2 {
        return nil, fmt.Errorf("invalid format for my_rectangle")
    }

    p1, err := parsePoint([]byte(parts[0]))
    if err != nil {
        return nil, err
    }

    p2, err := parsePoint([]byte(parts[1]))
    if err != nil {
        return nil, err
    }

    return &MyRectangleType{P1: *p1, P2: *p2, Status: pgtype.Present}, nil
}

func (r *MyRectangleType) Set(src interface{}) error {
    if src == nil {
        r.Status = pgtype.Null
        return nil
    }
    err := fmt.Errorf("cannot convert %v to MyRectangleType", src)
    var parsed *MyRectangleType
    switch value := src.(type) {
    case string:
        parsed, err = parseRectangle([]byte(value))
    case []byte:
        parsed, err = parseRectangle(value)
    default:
        return err
    }
    if err != nil {
        return err
    }
    *r = *parsed
    return nil
}

func (r MyRectangleType) EncodeBinary(ci *pgtype.ConnInfo, buf []byte) ([]byte, error) {
    switch r.Status {
    case pgtype.Null:
        return nil, nil
    case pgtype.Undefined:
        return nil, errUndefined
    }

    return (pgtype.CompositeFields{
        r.P1,
        r.P2,
    }).EncodeBinary(ci, buf)
}

What do I do wrong? What can be improved? I'm more concerned about readability than performance.

0 Answers
Related