Skip to content
This repository has been archived by the owner on Feb 15, 2023. It is now read-only.

[Major Change] Introduce types package for type handling in sqlgen #142

Merged
merged 14 commits into from
Aug 23, 2018

Conversation

berfarah
Copy link
Contributor

Goals

  1. Support protobuf struct (de)serialization
  2. Support custom scalars. For real. Like, types that aren't natively supported by SQL.
  3. Keep supporting scalar aliases (string -> custom string etc).

Changes

This PR adds a types package with the intention of replacing Scannables / TypeConverters. This unifies all of our logic in one place for determining how values are converted, while also supporting native interfaces.

Implementation

  1. Replace scannables with fields. Ensure that Empty/Interface is being called when scanning values.
  2. Add integration tests for both native and custom types.
  3. Deprecate MustRegisterCustomScalar and MustRegisterSimpleScalar.
  4. ???
  5. Profit

Usage

type Model struct {
  Foo ProtoModel `sql:",binary"` // (de)serializes to a binary blob via `Marshal`/`MarshalBinary`
  IP IP `sql:",primary,string"` // (de)serializes into a string via `MarshalText`
  UUID UUID // serializes via `sql/driver.Valuer` and deserializes via `sql.Scanner`
  Bar CustomString // (de)serializes the same way a native string would
  // int8, int16, int32 are converted to int64
  // uint8, uint16, uint32, uint64 are converted to int64
  // float32 is converted to float64
  // bool, float64, int64, string, []byte, time.Time and nil are natively supported
}

(but really, after this is implemented for SQL, look at where we could use it on the GraphQL side as well)

@berfarah berfarah requested a review from stephen August 14, 2018 19:59
@berfarah berfarah changed the title [proposal]: add types package for handling types [Major Change] Introduce types package for type handling in sqlgen Aug 15, 2018
Copy link
Contributor

@stephen stephen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall comments:

  • feel like there are package/high level comments needed in the early commits.
  • think the write path is not there yet.

types/field.go Outdated
// Blank creates a blank copy of a field. This _must_ be used in order to create a
// sql.Scanner from a field.
// Example:
// f := FieldFromValue(map[string]string{"sql": "binary", i)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: add indent so that this shows up as a code block

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, i think this example code is out of date

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't be needed any more

types/sql.go Outdated
if iface, ok := i.(encoding.BinaryMarshaler); ok {
return iface.MarshalBinary()
}
case "string", "text":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason to support both tags? can we support only one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea here is that you might have varchar or a text blob - though I'd be fine with changing it to just string

types/sql.go Outdated
}

// Handle sql type overrides as special cases.
switch f.TagType["sql"] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it safe to reuse the sql struct field tag in this way?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed this

types/sql.go Outdated
// Handle native types.
switch i.(type) {
case *[]byte:
fmt.Println("bytes")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: remove

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

types/field.go Outdated
"reflect"
)

type TagSet map[string]struct{}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add a comment for this

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

think all of the exported symbols in this file could use an explanation, and a package-level comment explaining how this is going to be used would be helpful. I have trouble understanding the higher level intent of this code by looking at this commit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

ptr := reflect.New(table.Type)
elem := ptr.Elem()

for i, column := range table.Columns {
value, _ := scannables[i].(driver.Valuer).Value()
// These values are all non-pointers and have been copied.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does this imply?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment here

types/field.go Outdated
@@ -68,5 +68,21 @@ func (f *Field) Blank() *Field {
return field
}

// CopyTo copies the reference onto the given value. Note: this uses pointers, so maybe this is a
// bad name?
func (f *Field) CopyTo(val reflect.Value) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe SetIn or SetValue would help?

return nil, err
}

return BuildStruct(table, scannables), nil
// Coerce fields into their actual type.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

almost think we should remove this comment. at first i thought it meant something more than "satisfy scanner.Scan" was happening

@@ -5,25 +5,28 @@ import (
"fmt"
)

type CustomType []byte
type CustomType [16]byte
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you explain this fixture change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep: I wanted this to no longer be a natively supported SQL type

types/field.go Outdated
IsNil bool
}

func FieldFromValue(in interface{}, tags []string) *Field {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, having read the whole PR now, I think I'd like to see this in _test.go only - it doesn't seem like there is a reason to use this outside of test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed this entirely

@berfarah
Copy link
Contributor Author

Discounting tests, comments and test data, this only adds ~200 LOC. 😬

@berfarah berfarah force-pushed the bf/types-package branch 5 times, most recently from 49c2702 to 7c32ad3 Compare August 17, 2018 01:38
@coveralls
Copy link

coveralls commented Aug 17, 2018

Pull Request Test Coverage Report for Build 922

  • 207 of 279 (74.19%) changed or added relevant lines in 6 files are covered.
  • 2 unchanged lines in 1 file lost coverage.
  • Overall coverage increased (+0.4%) to 64.289%

Changes Missing Coverage Covered Lines Changed/Added Lines %
sqlgen/reflect.go 38 39 97.44%
fields/descriptor.go 27 41 65.85%
sqlgen/deprecated.go 0 25 0.0%
fields/sql.go 127 159 79.87%
Files with Coverage Reduction New Missed Lines %
sqlgen/reflect.go 2 78.49%
Totals Coverage Status
Change from base Build 918: 0.4%
Covered Lines: 3244
Relevant Lines: 5046

💛 - Coveralls

fields/sql.go Outdated
if iface, ok := i.(encoding.BinaryMarshaler); ok {
return iface.MarshalBinary()
}
case f.Tags.Contains("string"), f.Tags.Contains("text"):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: think one of your comment responses mentions removing text. i think having only one here would be nice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

fields/sql.go Outdated
func (f Valuer) Value() (driver.Value, error) {
// Ideally, all the interfaces below would be implemented as normal methods, not indirect
// methods. However, in order to be safe we take the address of pointers where we can.
// This specifically supports protobuf.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this comment referring to the next line? i am having trouble understanding what this implies for my read here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should move this into the descriptor.Valuer method

// }
switch {
case f.Tags.Contains("binary"):
if iface, ok := i.(marshaler); ok {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you help me understand the difference between marshaler and binary marshaler? why would one implement one vs. the other?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've attempted to clarify this by the interface. This specifically supports protobuf and other libraries that implement Marshal() (I know I've seen it before but I can't remember one off the top of my head)

fields/sql.go Outdated
case f.Tags.Contains("json"):
if b, ok := i.([]byte); ok {
return b, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why would one use the json tag if the value is a string? i.e. there is nothing for the marshaler to do

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can remove this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed

@@ -0,0 +1,9 @@
package fields

type marshaler interface {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you point to some places in the standard library where this is used but not exported?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are similar but not exactly the same patterns in the stdlib. We looked at gogo/protobuf#28 together. I'm still not clear on why it wasn't implemented - the issue was fairly confusing and from over 3 years ago.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

github.com/golang/protobuf/proto.Marshaler and github.com/golang/protobuf/proto.Unmarshaler?

s.value.Set(reflect.ValueOf(src))
return nil
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you comment on why these need to be handled in this codepath, but not in the valuer path?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these are native types, we don't need to do any special handling. Because scanner returns an error for unhandled types, we need to explicitly handle them (as opposed to how valuer works where SQL will pick up our slack)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be handled before Scanner

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I think that helps.

@@ -6,6 +6,7 @@ import (
"net/http"
"sort"

_ "github.com/go-sql-driver/mysql"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this for?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is a lot going on in this last commit. it'd be great to split it up into a few different changes - maybe by valuer/scanner/livesql.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in order to "install" the mysql driver.

@berfarah berfarah force-pushed the bf/types-package branch 4 times, most recently from 996d78c to f73398c Compare August 20, 2018 20:28
@berfarah
Copy link
Contributor Author

@stephen Hopefully these commits are a little cleaner

Copy link
Contributor

@stephen stephen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think one other thing that would have helped my understanding here is a high level comment about the API surface area, i.e. this work does help in cases where:

  • we control the package and can add additional interfaces to a type
  • the package itself implements standard text/binary marshalling interfaces

it doesn't help with things like:

  • types outside of our controlled packages that don't support marshalling interfaces, e.g. arbitrary conversion for time.Time

@@ -319,7 +332,7 @@ func makeWhere(table *Table, filter Filter) (*SimpleWhere, error) {
return nil, fmt.Errorf("unknown column %s", name)
}

l = append(l, whereElem{column: column, value: value})
l = append(l, whereElem{column: column, value: column.Descriptor.Valuer(reflect.ValueOf(value))})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to verify - this is a departure from the existing behavior because the filters values are now converted vs. used directly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

}) {
t.Error("bad select")
}
assert.NoError(t, err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did the tests from here down in this file change due to api changes, or as cleanup? if the latter, it'd be nice to have them separate/noted that way for reviewing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😬 it was admittedly cleanup. I tried to extract these all into a separate PR but some of them got away from me

if iface, ok := i.(encoding.TextMarshaler); ok {
return iface.MarshalText()
}
case f.Tags.Contains("json"):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mentioned irl, but i think it would have been nice to see string/json support as a follow up vs. in this initial implementation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah :|

fields/sql.go Outdated
type Scanner struct {
*Descriptor
value reflect.Value
IsValid bool
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does IsValid need to be persisted on the struct? I see that it is only referenced within Scan

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, since we treat the value as a non-pointer value, IsValid tells us whether or not something is nil

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you point to where that is used outside of Scan?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point - this should be internal API

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(it's used specifically in CopyTo)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, thanks i missed that. making it package private sounds good

s.value.Set(reflect.ValueOf(src))
return nil
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I think that helps.

}
if !ok {
return nil, fmt.Errorf("bad type %s: field %s has unsupported type %s", typ, field.Name, field.Type)
d := fields.New(field.Type, tags[1:])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it safe for us to reuse the sql tag in this manner? should we use a different tag?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sql tag is explicitly being used by only us in sqlgen. I think we should just use sql everywhere.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, yep, that makes sense. i had it in my head that other software used that tag too

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other ORMs do AFAIK, but nothing in the go stdlib (and the driver doesn't either)

@berfarah berfarah force-pushed the bf/types-package branch 7 times, most recently from 77cfe65 to 9ce377a Compare August 21, 2018 22:42
@berfarah
Copy link
Contributor Author

Includes #150

Allocations went up:

goos: darwin
goarch: amd64
pkg: github.com/samsarahq/thunder/sqlgen
Benchmark/Read-8         	    5000	    291292 ns/op	    2239 B/op	      60 allocs/op
Benchmark/Create-8       	    2000	    683282 ns/op	    1568 B/op	      39 allocs/op
Benchmark/Update-8       	    2000	    576854 ns/op	    1976 B/op	      49 allocs/op
Benchmark/Delete-8       	    2000	    643999 ns/op	    1000 B/op	      28 allocs/op

@@ -0,0 +1,9 @@
package fields

type marshaler interface {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

github.com/golang/protobuf/proto.Marshaler and github.com/golang/protobuf/proto.Unmarshaler?

// bool
// []byte
// string
// time.Time
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@berfarah time.Time is annoying here because mysql timestamp stores 6 digits after decimal point (microseconds), but time.Time stores nanoseconds. This means I can't write a time.Now() and expect to read back the same value.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's where we can use Scan/Value to convert stuff more effectively

fields/sql.go Outdated
// time.Time
// nil - for NULL values
func (f Valuer) Value() (driver.Value, error) {
i := f.value.Interface()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we check f.value.IsValid() first?
reflect.ValueOf(interface{}(nil)).Interface() panics, although I don't see if we can even define a sqlgen model struct with interface{} type field

// If our interface supports driver.Valuer we can immediately short-circuit as this is what the
// MySQL driver would do.
if valuer, ok := i.(driver.Valuer); ok {
return valuer.Value()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we have Valuer handle nil case itself?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For valuer, the nil case will panic (I checked). In scanner we can and do handle it.

}

var _ driver.Valuer = Valuer{}
var _ driver.Valuer = &Valuer{}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, but it's nice to know that we are fulfilling the interface and the Go stdlib introduces this pattern. It only runs once so allocations are constant time (and presumably garbage collected)

fields/sql.go Outdated
// time.Time
// nil - for NULL values
func (s *Scanner) Scan(src interface{}) error {
// Get a value of the pointer of the pointer of our type. The Scanner and Unmarshalers should
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why this is returning pointer to pointer? For a field like type User struct { Name string }, I think it's type.Kind() is reflect.String?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this comment is actually just wrong. of the pointer should be there once. Will update.

type Scanner struct {
*Descriptor
value reflect.Value
isValid bool
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this? Can we make it never write valid reflect.Value if not isValid?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isValid is more following the style of NullBytes/NullBool/etc where it means "is not nil"

@berfarah berfarah force-pushed the bf/types-package branch 2 times, most recently from 1a21b03 to e4aaee1 Compare August 23, 2018 16:34
The fields package abstracts away dealing with and coercing types for
struct fields.

A descriptor is responsible for keeping track of all type information
associated with a struct field.

It automatically dereferences the value and remembers that we're dealing
with pointers in order to reduce the permutations of value assignment in
dealing with the reflected values.

Now rather than having to check both values if they are pointers when
assigning (2*2 permutations, 4 code branches) you can simply know that
this side is always dereferenced and only assign pointers when
It seems like this is used by a lot of packages but not explicitly
referenced in the standard library. Adding it to this package as a
native interface for handling it.
Fields can be converted to SQL by creating a Valuer from their
descriptor.
SQL values can be serialized into fields by creating a Scanner from
their corresponding field's descriptor.
This is a registration-time validation that the type is in fact a valid
SQL type.
Ensure we fully support Protobuf by adding a test for it.
This is for more pleasant debugging/diff output
MySQL needs to be imported in order for the driver to be registered
(AFAIK it's registered via `init()`)
@berfarah
Copy link
Contributor Author

@stephen since this is a pretty big breaking change, I'm thinking we should tag this with a 1.0 release. What do you think?

@berfarah berfarah merged commit 4d17995 into master Aug 23, 2018
@berfarah berfarah deleted the bf/types-package branch August 23, 2018 20:56
{Out: float32(5), In: float64(5)},
{Out: likeFloat(5), In: float64(5)},
// Interfaces without tags:
{Out: ifaceScanner{[]byte("scan me")}, In: []byte("scan me")},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pointer ifaceScanner test case?

@changpingc changpingc mentioned this pull request Sep 12, 2018
This pull request was closed.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants