diff --git a/db.go b/db.go index 14865240..456307ed 100644 --- a/db.go +++ b/db.go @@ -34,42 +34,42 @@ // package main // // import ( -// "log" +// "log" // -// "upper.io/db.v3/postgresql" // Imports the postgresql adapter. +// "upper.io/db.v3/postgresql" // Imports the postgresql adapter. // ) // // var settings = postgresql.ConnectionURL{ -// Database: `booktown`, -// Host: `demo.upper.io`, -// User: `demouser`, -// Password: `demop4ss`, +// Database: `booktown`, +// Host: `demo.upper.io`, +// User: `demouser`, +// Password: `demop4ss`, // } // // // Book represents a book. // type Book struct { -// ID uint `db:"id"` -// Title string `db:"title"` -// AuthorID uint `db:"author_id"` -// SubjectID uint `db:"subject_id"` +// ID uint `db:"id"` +// Title string `db:"title"` +// AuthorID uint `db:"author_id"` +// SubjectID uint `db:"subject_id"` // } // // func main() { -// sess, err := postgresql.Open(settings) -// if err != nil { -// log.Fatal(err) -// } -// defer sess.Close() -// -// var books []Book -// if err := sess.Collection("books").Find().OrderBy("title").All(&books); err != nil { -// log.Fatal(err) -// } -// -// log.Println("Books:") -// for _, book := range books { -// log.Printf("%q (ID: %d)\n", book.Title, book.ID) -// } +// sess, err := postgresql.Open(settings) +// if err != nil { +// log.Fatal(err) +// } +// defer sess.Close() +// +// var books []Book +// if err := sess.Collection("books").Find().OrderBy("title").All(&books); err != nil { +// log.Fatal(err) +// } +// +// log.Println("Books:") +// for _, book := range books { +// log.Printf("%q (ID: %d)\n", book.Title, book.ID) +// } // } // // See more usage examples and documentation for users at @@ -184,7 +184,7 @@ type Unmarshaler interface { // // // Where age equals 18. // db.Cond{"age": 18} -// // // Where age is greater than or equal to 18. +// // // Where age is greater than or equal to 18. // db.Cond{"age >=": 18} // // // Where id is in a list of ids. @@ -241,6 +241,9 @@ func (c Cond) Empty() bool { return true } +// Relation represents a relation between columns or tables. +type Relation map[string]interface{} + type rawValue struct { v string a *[]interface{} // This may look ugly but allows us to use db.Raw() as keys for db.Cond{}. @@ -429,17 +432,17 @@ func NewConstraint(key interface{}, value interface{}) Constraint { // // Examples: // -// // MOD(29, 9) -// db.Func("MOD", 29, 9) +// // MOD(29, 9) +// db.Func("MOD", 29, 9) // -// // CONCAT("foo", "bar") -// db.Func("CONCAT", "foo", "bar") +// // CONCAT("foo", "bar") +// db.Func("CONCAT", "foo", "bar") // -// // NOW() -// db.Func("NOW") +// // NOW() +// db.Func("NOW") // -// // RTRIM("Hello ") -// db.Func("RTRIM", "Hello ") +// // RTRIM("Hello ") +// db.Func("RTRIM", "Hello ") func Func(name string, args ...interface{}) Function { if len(args) == 1 { if reflect.TypeOf(args[0]).Kind() == reflect.Slice { @@ -471,20 +474,20 @@ func (f *dbFunc) Name() string { // // Examples: // -// // name = "Peter" AND last_name = "Parker" -// db.And( -// db.Cond{"name": "Peter"}, -// db.Cond{"last_name": "Parker "}, -// ) -// -// // (name = "Peter" OR name = "Mickey") AND last_name = "Mouse" -// db.And( -// db.Or( -// db.Cond{"name": "Peter"}, -// db.Cond{"name": "Mickey"}, -// ), -// db.Cond{"last_name": "Mouse"}, -// ) +// // name = "Peter" AND last_name = "Parker" +// db.And( +// db.Cond{"name": "Peter"}, +// db.Cond{"last_name": "Parker "}, +// ) +// +// // (name = "Peter" OR name = "Mickey") AND last_name = "Mouse" +// db.And( +// db.Or( +// db.Cond{"name": "Peter"}, +// db.Cond{"name": "Mickey"}, +// ), +// db.Cond{"last_name": "Mouse"}, +// ) func And(conds ...Compound) *Intersection { return &Intersection{newCompound(conds...)} } @@ -494,11 +497,11 @@ func And(conds ...Compound) *Intersection { // // Example: // -// // year = 2012 OR year = 1987 -// db.Or( -// db.Cond{"year": 2012}, -// db.Cond{"year": 1987}, -// ) +// // year = 2012 OR year = 1987 +// db.Or( +// db.Cond{"year": 2012}, +// db.Cond{"year": 1987}, +// ) func Or(conds ...Compound) *Union { return &Union{newCompound(defaultJoin(conds...)...)} } @@ -508,8 +511,8 @@ func Or(conds ...Compound) *Union { // // Example: // -// // SOUNDEX('Hello') -// Raw("SOUNDEX('Hello')") +// // SOUNDEX('Hello') +// Raw("SOUNDEX('Hello')") // // Raw returns a value that satifies the db.RawValue interface. func Raw(value string, args ...interface{}) RawValue { @@ -659,6 +662,21 @@ type Result interface { // or columns. Group(...interface{}) Result + // Preload direct one-to-one relations with other tables. It takes a + // db.Relation argument, which is a map of all relations you want to preload. + // + // Example: + // + // q := publicationCollection.Find().Preload(db.Relation{ + // "artist": artistCollection.Find( + // "artist.id = publication.author_id", + // ), + // }) + // + // Preload returns all records from the left collection and only matching + // records on the right (left join). + Preload(Relation) Result + // Delete deletes all items within the result set. `Offset()` and `Limit()` are // not honoured by `Delete()`. Delete() error @@ -747,7 +765,7 @@ type Result interface { // // You can define the pagination order and add constraints to your result: // - // cursor = q.Where(...).OrderBy("id").Paginate(10).Cursor("id") + // cursor = q.Where(...).OrderBy("id").Paginate(10).Cursor("id") // res = cursor.NextPage(lowerBound) NextPage(cursorValue interface{}) Result diff --git a/internal/sqladapter/collection.go b/internal/sqladapter/collection.go index f5aef2e5..b8d3609c 100644 --- a/internal/sqladapter/collection.go +++ b/internal/sqladapter/collection.go @@ -54,6 +54,9 @@ type BaseCollection interface { // PrimaryKeys returns the table's primary keys. PrimaryKeys() []string + + // Columns returns the table's columns. + Columns() []string } type condsFilter interface { @@ -65,7 +68,9 @@ type collection struct { BaseCollection PartialCollection - pk []string + primaryKeys []string + columns []string + err error } @@ -76,22 +81,27 @@ var ( // NewBaseCollection returns a collection with basic methods. func NewBaseCollection(p PartialCollection) BaseCollection { c := &collection{PartialCollection: p} - c.pk, c.err = c.Database().PrimaryKeys(c.Name()) + c.primaryKeys, c.columns, c.err = c.Database().Columns(c.Name()) return c } // PrimaryKeys returns the collection's primary keys, if any. func (c *collection) PrimaryKeys() []string { - return c.pk + return c.primaryKeys +} + +// PrimaryKeys returns the collection's columns, if any. +func (c *collection) Columns() []string { + return c.columns } func (c *collection) filterConds(conds ...interface{}) []interface{} { if tr, ok := c.PartialCollection.(condsFilter); ok { return tr.FilterConds(conds...) } - if len(conds) == 1 && len(c.pk) == 1 { + if len(conds) == 1 && len(c.primaryKeys) == 1 { if id := conds[0]; IsKeyValue(id) { - conds[0] = db.Cond{c.pk[0]: id} + conds[0] = db.Cond{c.primaryKeys[0]: id} } } return conds diff --git a/internal/sqladapter/database.go b/internal/sqladapter/database.go index 120a33c8..7ac42730 100644 --- a/internal/sqladapter/database.go +++ b/internal/sqladapter/database.go @@ -58,8 +58,8 @@ type PartialDatabase interface { // LookupName returns the name of the database. LookupName() (string, error) - // PrimaryKeys returns all primary keys on the table. - PrimaryKeys(name string) ([]string, error) + // Columns returns all columns on the table. + Columns(name string) (primaryKeys []string, columns []string, err error) // NewCollection allocates a new collection by name. NewCollection(name string) db.Collection diff --git a/internal/sqladapter/result.go b/internal/sqladapter/result.go index 1d0955b7..62690241 100644 --- a/internal/sqladapter/result.go +++ b/internal/sqladapter/result.go @@ -22,6 +22,8 @@ package sqladapter import ( + "errors" + "fmt" "sync" "sync/atomic" @@ -30,6 +32,10 @@ import ( "upper.io/db.v3/lib/sqlbuilder" ) +type hasColumns interface { + Columns(table string) ([]string, []string, error) +} + type Result struct { builder sqlbuilder.SQLBuilder @@ -42,6 +48,11 @@ type Result struct { fn func(*result) error } +type assocOne struct { + res db.Result + alias string +} + // result represents a delimited set of items bound by a condition. type result struct { table string @@ -60,8 +71,12 @@ type result struct { orderBy []interface{} groupBy []interface{} conds [][]interface{} + + preloadOne []assocOne } +type preloadAllFn func(db.Cond) db.Result + func filter(conds []interface{}) []interface{} { return conds } @@ -298,6 +313,19 @@ func (r *Result) Update(values interface{}) error { return r.setErr(err) } +func (r *Result) Preload(relation db.Relation) db.Result { + return r.frame(func(res *result) error { + for key, val := range relation { + if finder, ok := val.(db.Result); ok { + res.preloadOne = append(res.preloadOne, assocOne{res: finder, alias: key}) + continue + } + return fmt.Errorf("expecting a relation with db.Result value, got %T", val) + } + return nil + }) +} + func (r *Result) TotalPages() (uint, error) { query, err := r.buildPaginator() if err != nil { @@ -383,13 +411,60 @@ func (r *Result) buildPaginator() (sqlbuilder.Paginator, error) { return nil, err } - sel := r.SQLBuilder().Select(res.fields...). - From(res.table). + sel := r.SQLBuilder().SelectFrom(res.table). Limit(res.limit). Offset(res.offset). GroupBy(res.groupBy...). OrderBy(res.orderBy...) + if len(res.fields) > 0 { + sel = sel.Columns(res.fields...) + } + + if len(res.preloadOne) > 0 { + sess, ok := r.SQLBuilder().(hasColumns) + if !ok { + return nil, errors.New("Could not create join") + } + + columns := []interface{}{} + + _, cs, err := sess.Columns(res.table) + if err != nil { + return nil, err + } + + for _, c := range cs { + columns = append(columns, fmt.Sprintf("%s.%s AS %s", res.table, c, c)) + } + sel = sel.Columns(columns...) + + for _, assocOne := range res.preloadOne { + + ff, err := assocOne.res.(*Result).fastForward() + if err != nil { + return nil, r.setErr(err) + } + + ffConds := []interface{}{} + for i := range ff.conds { + ffConds = append(ffConds, filter(ff.conds[i])...) + } + sel = sel.LeftJoin(ff.table).On(ffConds...) + + _, cs, err := sess.Columns(ff.table) + if err != nil { + return nil, r.setErr(err) + } + + columns := []interface{}{} + for _, c := range cs { + columns = append(columns, fmt.Sprintf("%s.%s AS %s.%s", ff.table, c, assocOne.alias, c)) + } + sel = sel.Columns(columns...) + } + } + for i := range res.conds { sel = sel.And(filter(res.conds[i])...) } diff --git a/internal/sqladapter/testing/adapter.go.tpl b/internal/sqladapter/testing/adapter.go.tpl index 5ccec40d..87842443 100644 --- a/internal/sqladapter/testing/adapter.go.tpl +++ b/internal/sqladapter/testing/adapter.go.tpl @@ -1959,4 +1959,89 @@ func TestCustomType(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "foo: some name", string(bar.Custom.Val)) + + assert.NoError(t, cleanUpCheck(sess)) + assert.NoError(t, sess.Close()) +} + +func TestPreload(t *testing.T) { + type artistModel struct { + ID uint64 `db:"id,omitempty"` + Name string `db:"name"` + } + + type publicationModel struct { + ID uint64 `db:"id,omitempty"` + Title string `db:"title"` + AuthorID uint64 `db:"author_id"` + + Author *artistModel `db:"author,omitempty"` + } + + sess := mustOpen() + + artistCollection := sess.Collection("artist") + publicationCollection := sess.Collection("publication") + + err := publicationCollection.Truncate() + assert.NoError(t, err) + + err = artistCollection.Truncate() + assert.NoError(t, err) + + // Insert an artist + artist := artistModel{ + Name: "Artist one", + } + err = artistCollection.InsertReturning(&artist) + assert.NoError(t, err) + + // Insert a publication related to the artist above. + publication := publicationModel{ + Title: "Publication 1", + AuthorID: artist.ID, + } + err = publicationCollection.InsertReturning(&publication) + assert.NoError(t, err) + + q := publicationCollection.Find().Preload(db.Relation{ + "author": artistCollection.Find( + "artist.id = publication.author_id", + ), + }) + + publicationsMap := []map[string]interface{}{} + err = q.All(&publicationsMap) + assert.NoError(t, err) + + assert.Equal(t, 1, len(publicationsMap)) + assert.Equal(t, "Artist one", publicationsMap[0]["author.name"]) + + publicationsArray := []publicationModel{} + err = q.All(&publicationsArray) + assert.NoError(t, err) + + assert.Equal(t, 1, len(publicationsArray)) + assert.NotNil(t, publicationsArray[0].Author) + assert.Equal(t, "Artist one", publicationsArray[0].Author.Name) + + publication = publicationModel{} + err = q.One(&publication) + assert.NoError(t, err) + + assert.NotNil(t, publication.Author) + assert.Equal(t, "Artist one", publication.Author.Name) + + _, err = publicationCollection.Insert(publication) + assert.Error(t, err) + + err = publicationCollection.Find(publication.ID).Update(publication) + assert.NoError(t, err) + + publication.ID = 0 + _, err = publicationCollection.Insert(publication) + assert.NoError(t, err) + + assert.NoError(t, cleanUpCheck(sess)) + assert.NoError(t, sess.Close()) } diff --git a/lib/reflectx/reflect.go b/lib/reflectx/reflect.go index c9faa6aa..759e0a84 100644 --- a/lib/reflectx/reflect.go +++ b/lib/reflectx/reflect.go @@ -241,7 +241,11 @@ func ValidFieldByIndexes(v reflect.Value, indexes []int) reflect.Value { // going to be used for reading and not setting. func FieldByIndexesReadOnly(v reflect.Value, indexes []int) reflect.Value { for _, i := range indexes { - v = reflect.Indirect(v).Field(i) + v = reflect.Indirect(v) + if !v.IsValid() { + return reflect.Value{} + } + v = v.Field(i) } return v } diff --git a/lib/sqlbuilder/builder.go b/lib/sqlbuilder/builder.go index ed0a65f4..b788b856 100644 --- a/lib/sqlbuilder/builder.go +++ b/lib/sqlbuilder/builder.go @@ -27,7 +27,6 @@ import ( "database/sql" "errors" "fmt" - "log" "reflect" "regexp" "sort" @@ -44,6 +43,7 @@ import ( type MapOptions struct { IncludeZeroed bool IncludeNil bool + OmitEmbedded bool } var defaultMapOptions = MapOptions{ @@ -272,6 +272,39 @@ func Map(item interface{}, options *MapOptions) ([]string, []interface{}, error) fv.fields = make([]string, 0, nfields) for _, fi := range fieldMap { + fieldName := fi.Name + + if len(fi.Children) > 1 { + hasDBTags := false + for _, child := range fi.Children { + if child != nil && child.Name != "" { + hasDBTags = true + break + } + } + if hasDBTags && options.OmitEmbedded { + // Omit fields with structs that contain db tags. + continue + } + } + + embedded := false + if len(fi.Index) > 1 { + // Field within a struct. + for parent := fi.Parent; parent != nil; parent = parent.Parent { + if len(parent.Index) < 1 { + break + } + if parent.Name != "" { + embedded = true + fieldName = parent.Name + "." + fieldName + } + } + } + + if options.OmitEmbedded && embedded { + continue + } // Check for deprecated JSONB tag if _, hasJSONBTag := fi.Options["jsonb"]; hasJSONBTag { @@ -286,7 +319,7 @@ func Map(item interface{}, options *MapOptions) ([]string, []interface{}, error) if tagOmitEmpty && !options.IncludeNil { continue } - fv.fields = append(fv.fields, fi.Name) + fv.fields = append(fv.fields, fieldName) if tagOmitEmpty { fv.values = append(fv.values, sqlDefault) } else { @@ -295,6 +328,9 @@ func Map(item interface{}, options *MapOptions) ([]string, []interface{}, error) continue } + if !fld.IsValid() { + continue + } value := fld.Interface() isZero := false @@ -314,7 +350,7 @@ func Map(item interface{}, options *MapOptions) ([]string, []interface{}, error) continue } - fv.fields = append(fv.fields, fi.Name) + fv.fields = append(fv.fields, fieldName) v, err := marshal(value) if err != nil { return nil, nil, err @@ -562,7 +598,6 @@ func newSqlgenProxy(db *sql.DB, t *exql.Template) *exprProxy { } func (p *exprProxy) Context() context.Context { - log.Printf("Missing context") return context.Background() } diff --git a/lib/sqlbuilder/insert.go b/lib/sqlbuilder/insert.go index 9e9bfc79..eae34f7d 100644 --- a/lib/sqlbuilder/insert.go +++ b/lib/sqlbuilder/insert.go @@ -19,21 +19,31 @@ type inserterQuery struct { amendFn func(string) string } +var insertManyMapOptions = &MapOptions{ + IncludeZeroed: true, + IncludeNil: true, +} + +var insertOneMapOptions = &MapOptions{ + OmitEmbedded: true, +} + func (iq *inserterQuery) processValues() ([]*exql.Values, []interface{}, error) { var values []*exql.Values var arguments []interface{} - var mapOptions *MapOptions + mappingOptions := insertOneMapOptions if len(iq.enqueuedValues) > 1 { - mapOptions = &MapOptions{IncludeZeroed: true, IncludeNil: true} + mappingOptions = insertManyMapOptions } for _, enqueuedValue := range iq.enqueuedValues { if len(enqueuedValue) == 1 { // If and only if we passed one argument to Values. - ff, vv, err := Map(enqueuedValue[0], mapOptions) + ff, vv, err := Map(enqueuedValue[0], mappingOptions) if err == nil { + // If we didn't have any problem with mapping we can convert it into // columns and values. columns, vals, args, _ := toColumnsValuesAndArguments(ff, vv) diff --git a/lib/sqlbuilder/update.go b/lib/sqlbuilder/update.go index e53d3af1..92aaa9eb 100644 --- a/lib/sqlbuilder/update.go +++ b/lib/sqlbuilder/update.go @@ -24,6 +24,10 @@ type updaterQuery struct { amendFn func(string) string } +var updateMapOptions = MapOptions{ + OmitEmbedded: true, +} + func (uq *updaterQuery) and(b *sqlBuilder, terms ...interface{}) error { where, whereArgs := b.t.toWhereWithArguments(terms) @@ -109,7 +113,7 @@ func (upd *updater) Set(terms ...interface{}) Updater { } if len(terms) == 1 { - ff, vv, err := Map(terms[0], nil) + ff, vv, err := Map(terms[0], &updateMapOptions) if err == nil && len(ff) > 0 { cvs := make([]exql.Fragment, 0, len(ff)) args := make([]interface{}, 0, len(vv)) diff --git a/mysql/Makefile b/mysql/Makefile index 4b140744..18b46208 100644 --- a/mysql/Makefile +++ b/mysql/Makefile @@ -7,6 +7,8 @@ DB_USERNAME ?= upperio_tests DB_PASSWORD ?= upperio_secret DB_NAME ?= upperio_tests +TEST_FLAGS ?= + export DB_HOST export DB_NAME export DB_PASSWORD @@ -35,4 +37,4 @@ reset-db: require-client test: reset-db generate #go test -tags generated -v -race # race: limit on 8192 simultaneously alive goroutines is exceeded, dying - go test -tags generated -v + go test -tags generated -v $(TEST_FLAGS) diff --git a/mysql/database.go b/mysql/database.go index 2b4b886f..1df6c952 100644 --- a/mysql/database.go +++ b/mysql/database.go @@ -26,6 +26,7 @@ package mysql import ( "context" + "log" "strings" "sync" @@ -247,33 +248,38 @@ func (d *database) TableExists(name string) error { return db.ErrCollectionDoesNotExist } -// PrimaryKeys returns the names of all the primary keys on the table. -func (d *database) PrimaryKeys(tableName string) ([]string, error) { - q := d.Select("k.column_name"). - From("information_schema.table_constraints AS t"). - Join("information_schema.key_column_usage AS k"). - Using("constraint_name", "table_schema", "table_name"). - Where(` - t.constraint_type = 'primary key' - AND t.table_schema = ? - AND t.table_name = ? - `, d.BaseDatabase.Name(), tableName). - OrderBy("k.ordinal_position") - - iter := q.Iterator() +// Columns returns the names of all the primary keys and columns on the table. +func (d *database) Columns(tableName string) (primaryKeys []string, columns []string, err error) { + log.Printf("columns") + iter := d.Iterator(` + SELECT column_name, constraint_name + FROM information_schema.columns AS c + LEFT JOIN information_schema.key_column_usage AS k + USING(table_schema, table_name, column_name) + WHERE + c.table_schema = ? and c.table_name = ? + `, d.Name(), tableName) defer iter.Close() - pk := []string{} - for iter.Next() { - var k string - if err := iter.Scan(&k); err != nil { - return nil, err + var column string + var isPrimary bool + if err := iter.Scan(&column, &isPrimary); err != nil { + log.Printf("err: %v", err) + return nil, nil, err + } + if isPrimary { + primaryKeys = append(primaryKeys, column) } - pk = append(pk, k) + columns = append(columns, column) + } + if err := iter.Err(); err != nil { + log.Printf("err: %v", err) + return nil, nil, err } + log.Printf("coumns: %#v, primaryKeys: %#v", primaryKeys, columns) - return pk, nil + return primaryKeys, columns, nil } // WithContext creates a copy of the session on the given context. diff --git a/postgresql/Makefile b/postgresql/Makefile index 44f9c400..d195c8a6 100644 --- a/postgresql/Makefile +++ b/postgresql/Makefile @@ -7,7 +7,7 @@ DB_USERNAME ?= upperio_tests DB_PASSWORD ?= upperio_secret DB_NAME ?= upperio_tests -TEST_FLAGS ?= +TEST_FLAGS ?= export DB_HOST export DB_NAME diff --git a/postgresql/database.go b/postgresql/database.go index 17854eef..cb778904 100644 --- a/postgresql/database.go +++ b/postgresql/database.go @@ -306,35 +306,44 @@ func quotedTableName(s string) string { return strings.Join(chunks, ".") } -// PrimaryKeys returns the names of all the primary keys on the table. -func (d *database) PrimaryKeys(tableName string) ([]string, error) { - q := d.Select("pg_attribute.attname AS pkey"). - From("pg_index", "pg_class", "pg_attribute"). - Where(` - pg_class.oid = '` + quotedTableName(tableName) + `'::regclass - AND indrelid = pg_class.oid - AND pg_attribute.attrelid = pg_class.oid - AND pg_attribute.attnum = ANY(pg_index.indkey) - AND indisprimary - `).OrderBy("pkey") - - iter := q.Iterator() +// Columns returns the names of all the primary keys and columns on the table. +func (d *database) Columns(tableName string) (primaryKeys []string, columns []string, err error) { + iter := d.Iterator(` + SELECT + pga.attname, + (CASE + WHEN pgi.indisprimary IS NOT NULL + THEN + pgi.indisprimary + ELSE + false + END) AS indisprimary + FROM pg_attribute pga + INNER JOIN pg_class pgc + ON pga.attrelid = pgc.oid + LEFT JOIN pg_index pgi + ON pgi.indrelid = pgc.oid AND pga.attnum = ANY(pgi.indkey) + WHERE pga.attrelid = '` + quotedTableName(tableName) + `'::regclass AND pga.attnum > 0 AND NOT pga.attisdropped + ORDER BY pga.attnum + `) defer iter.Close() - pk := []string{} - for iter.Next() { - var k string - if err := iter.Scan(&k); err != nil { - return nil, err + var column string + var isPrimary bool + if err := iter.Scan(&column, &isPrimary); err != nil { + return nil, nil, err + } + if isPrimary { + primaryKeys = append(primaryKeys, column) } - pk = append(pk, k) + columns = append(columns, column) } if err := iter.Err(); err != nil { - return nil, err + return nil, nil, err } - return pk, nil + return primaryKeys, columns, nil } // WithContext creates a copy of the session on the given context.