From 7b16e432ff9dc714f795cf462f11a6e5da1916ab Mon Sep 17 00:00:00 2001 From: go-jet Date: Fri, 21 Feb 2025 19:55:01 +0100 Subject: [PATCH] Add support for SELECT_JSON statements. --- .circleci/config.yml | 4 +- README.md | 2 +- cmd/jet/version.go | 2 +- internal/3rdparty/snaker/snaker.go | 13 +- internal/3rdparty/snaker/snaker_test.go | 2 + internal/jet/alias.go | 5 + internal/jet/clause.go | 8 +- internal/jet/column.go | 16 +- internal/jet/column_list.go | 6 + internal/jet/expression.go | 9 +- internal/jet/projection.go | 12 + internal/jet/raw_statement.go | 4 +- internal/jet/serializer.go | 22 +- internal/jet/sql_builder.go | 4 + internal/jet/statement.go | 97 ++- internal/jet/utils.go | 17 + internal/jet/with_statement.go | 4 +- internal/testutils/test_utils.go | 35 +- internal/utils/datetime/duration.go | 38 +- internal/utils/min/min.go | 9 - mysql/functions.go | 5 + mysql/select_json.go | 79 +++ mysql/select_statement.go | 6 +- mysql/table.go | 2 +- postgres/select_json.go | 131 ++++ postgres/select_statement.go | 22 +- postgres/set_statement.go | 16 +- postgres/table.go | 2 +- qrm/internal/null_types.go | 46 +- qrm/qrm.go | 173 ++++- qrm/scan_context.go | 28 +- qrm/utill.go | 20 +- tests/docker-compose.yaml | 2 +- tests/mysql/alltypes_test.go | 50 +- tests/mysql/bench_test.go | 138 ++++ tests/mysql/select_json_test.go | 437 ++++++++++++ tests/mysql/select_test.go | 84 ++- tests/postgres/alltypes_test.go | 100 ++- tests/postgres/chinook_db_test.go | 156 +++- tests/postgres/main_test.go | 12 +- tests/postgres/northwind_test.go | 169 ++++- tests/postgres/sample_test.go | 57 +- tests/postgres/scan_test.go | 2 +- tests/postgres/select_json_test.go | 908 ++++++++++++++++++++++++ tests/postgres/select_test.go | 83 ++- tests/testdata | 2 +- 46 files changed, 2732 insertions(+), 307 deletions(-) delete mode 100644 internal/utils/min/min.go create mode 100644 mysql/select_json.go create mode 100644 postgres/select_json.go create mode 100644 tests/mysql/bench_test.go create mode 100644 tests/mysql/select_json_test.go create mode 100644 tests/postgres/select_json_test.go diff --git a/.circleci/config.yml b/.circleci/config.yml index a36737b..f74334f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,7 +8,7 @@ jobs: build_and_tests: docker: # specify the version - - image: cimg/go:1.22.8 + - image: cimg/go:1.23.6 # Please keep the version in sync with test/docker-compose.yaml - image: cimg/postgres:14.10 @@ -29,7 +29,7 @@ jobs: MYSQL_TCP_PORT: 50902 # Please keep the version in sync with test/docker-compose.yaml - - image: circleci/mariadb:10.3 + - image: circleci/mariadb:11.7 command: [ '--default-authentication-plugin=mysql_native_password', '--port=50903' ] environment: MYSQL_ROOT_PASSWORD: jet diff --git a/README.md b/README.md index 3d5f57d..70e29e9 100644 --- a/README.md +++ b/README.md @@ -579,5 +579,5 @@ To run the tests, additional dependencies are required: ## License -Copyright 2019-2024 Goran Bjelanovic +Copyright 2019-2025 Goran Bjelanovic Licensed under the Apache License, Version 2.0. diff --git a/cmd/jet/version.go b/cmd/jet/version.go index 3300008..f5371e4 100644 --- a/cmd/jet/version.go +++ b/cmd/jet/version.go @@ -1,3 +1,3 @@ package main -const version = "v2.11.1" +const version = "v2.12.0" diff --git a/internal/3rdparty/snaker/snaker.go b/internal/3rdparty/snaker/snaker.go index 32a19e6..4177e1c 100644 --- a/internal/3rdparty/snaker/snaker.go +++ b/internal/3rdparty/snaker/snaker.go @@ -40,14 +40,23 @@ func snakeToCamel(s string, upperCase bool) string { if upperCase || i > 0 { result += camelizeWord(word, len(words) > 1) - } else { - result += word + } else { // lowerCase and i == 0 + result += toLowerFirstLetter(word) } } return result } +func toLowerFirstLetter(s string) string { + if s == "" { + return s + } + runes := []rune(s) + runes[0] = unicode.ToLower(runes[0]) + return string(runes) +} + func camelizeWord(word string, force bool) string { runes := []rune(word) diff --git a/internal/3rdparty/snaker/snaker_test.go b/internal/3rdparty/snaker/snaker_test.go index f828a91..9cc5ef7 100644 --- a/internal/3rdparty/snaker/snaker_test.go +++ b/internal/3rdparty/snaker/snaker_test.go @@ -8,6 +8,8 @@ import ( func TestSnakeToCamel(t *testing.T) { require.Equal(t, SnakeToCamel(""), "") require.Equal(t, SnakeToCamel("potato_"), "Potato") + require.Equal(t, SnakeToCamel("potato_", false), "potato") + require.Equal(t, SnakeToCamel("Potato_", false), "potato") require.Equal(t, SnakeToCamel("this_has_to_be_uppercased"), "ThisHasToBeUppercased") require.Equal(t, SnakeToCamel("this_is_an_id"), "ThisIsAnID") require.Equal(t, SnakeToCamel("this_is_an_identifier"), "ThisIsAnIdentifier") diff --git a/internal/jet/alias.go b/internal/jet/alias.go index 8693b13..5374031 100644 --- a/internal/jet/alias.go +++ b/internal/jet/alias.go @@ -30,3 +30,8 @@ func (a *alias) serializeForProjection(statement StatementType, out *SQLBuilder) out.WriteString("AS") out.WriteAlias(a.alias) } + +func (a *alias) serializeForJsonObj(statement StatementType, out *SQLBuilder) { + out.WriteJsonObjKey(a.alias) + a.expression.serialize(statement, out) +} diff --git a/internal/jet/clause.go b/internal/jet/clause.go index 533d223..68d3fee 100644 --- a/internal/jet/clause.go +++ b/internal/jet/clause.go @@ -52,6 +52,10 @@ func (s *ClauseSelect) Projections() ProjectionList { // Serialize serializes clause into SQLBuilder func (s *ClauseSelect) Serialize(statementType StatementType, out *SQLBuilder, options ...SerializeOption) { + if len(s.ProjectionList) == 0 { + panic("jet: SELECT clause has to have at least one projection") + } + out.NewLine() out.WriteString("SELECT") s.OptimizerHints.Serialize(statementType, out, options...) @@ -66,10 +70,6 @@ func (s *ClauseSelect) Serialize(statementType StatementType, out *SQLBuilder, o out.WriteByte(')') } - if len(s.ProjectionList) == 0 { - panic("jet: SELECT clause has to have at least one projection") - } - out.WriteProjections(statementType, s.ProjectionList) } diff --git a/internal/jet/column.go b/internal/jet/column.go index 2b1b930..702518a 100644 --- a/internal/jet/column.go +++ b/internal/jet/column.go @@ -2,6 +2,10 @@ package jet +import ( + "github.com/go-jet/jet/v2/internal/3rdparty/snaker" +) + // Column is common column interface for all types of columns. type Column interface { Name() string @@ -97,7 +101,17 @@ func (c ColumnExpressionImpl) serializeForProjection(statement StatementType, ou c.serialize(statement, out) out.WriteString("AS") - out.WriteAlias(c.defaultAlias()) + + if statement.IsSelectJSON() { + out.WriteAlias(snaker.SnakeToCamel(c.name, false)) + } else { + out.WriteAlias(c.defaultAlias()) + } +} + +func (c ColumnExpressionImpl) serializeForJsonObj(statement StatementType, out *SQLBuilder) { + out.WriteJsonObjKey(snaker.SnakeToCamel(c.name, false)) + c.serialize(statement, out) } func (c ColumnExpressionImpl) serialize(statement StatementType, out *SQLBuilder, options ...SerializeOption) { diff --git a/internal/jet/column_list.go b/internal/jet/column_list.go index a07b9ba..18acd81 100644 --- a/internal/jet/column_list.go +++ b/internal/jet/column_list.go @@ -78,6 +78,12 @@ func (cl ColumnList) serializeForProjection(statement StatementType, out *SQLBui SerializeProjectionList(statement, projections, out) } +func (cl ColumnList) serializeForJsonObj(statement StatementType, out *SQLBuilder) { + projections := ColumnListToProjectionList(cl) + + SerializeProjectionListJsonObj(statement, projections, out) +} + // dummy column interface implementation // Name is placeholder for ColumnList to implement Column interface diff --git a/internal/jet/expression.go b/internal/jet/expression.go index 9999803..5c152ce 100644 --- a/internal/jet/expression.go +++ b/internal/jet/expression.go @@ -2,7 +2,7 @@ package jet import "fmt" -// Expression is common interface for all expressions. +// Expression is a common interface for all expressions. // Can be Bool, Int, Float, String, Date, Time, Timez, Timestamp or Timestampz expressions. type Expression interface { Serializer @@ -89,9 +89,16 @@ func (e *ExpressionInterfaceImpl) serializeForGroupBy(statement StatementType, o } func (e *ExpressionInterfaceImpl) serializeForProjection(statement StatementType, out *SQLBuilder) { + if statement.IsSelectJSON() { + panic("jet: expression need to be aliased when used as SELECT JSON projection.") + } e.Parent.serialize(statement, out, NoWrap) } +func (e *ExpressionInterfaceImpl) serializeForJsonObj(statement StatementType, out *SQLBuilder) { + panic("jet: expression need to be aliased when used as SELECT JSON projection.") +} + func (e *ExpressionInterfaceImpl) serializeForOrderBy(statement StatementType, out *SQLBuilder) { e.Parent.serialize(statement, out, NoWrap) } diff --git a/internal/jet/projection.go b/internal/jet/projection.go index 3b2ccd8..647c1ba 100644 --- a/internal/jet/projection.go +++ b/internal/jet/projection.go @@ -3,6 +3,7 @@ package jet // Projection is interface for all projection types. Types that can be part of, for instance SELECT clause. type Projection interface { serializeForProjection(statement StatementType, out *SQLBuilder) + serializeForJsonObj(statement StatementType, out *SQLBuilder) fromImpl(subQuery SelectTable) Projection } @@ -28,6 +29,10 @@ func (pl ProjectionList) serializeForProjection(statement StatementType, out *SQ SerializeProjectionList(statement, pl, out) } +func (pl ProjectionList) serializeForJsonObj(statement StatementType, out *SQLBuilder) { + SerializeProjectionListJsonObj(statement, pl, out) +} + // As will create new projection list where each column is wrapped with a new table alias. // tableAlias should be in the form 'name' or 'name.*', or it can be an empty string, which will remove existing table alias. // For instance: If projection list has a column 'Artist.Name', and tableAlias is 'Musician.*', returned projection list will @@ -79,3 +84,10 @@ func (pl ProjectionList) Except(toExclude ...Column) ProjectionList { return ret } + +// JsonProjectionList redefines []Projection so projections can be serialized as json object key/values +type JsonProjectionList []Projection + +func (j JsonProjectionList) serialize(statement StatementType, out *SQLBuilder, options ...SerializeOption) { + SerializeProjectionListJsonObj(statement, j, out) +} diff --git a/internal/jet/raw_statement.go b/internal/jet/raw_statement.go index 99fb8eb..427b3c8 100644 --- a/internal/jet/raw_statement.go +++ b/internal/jet/raw_statement.go @@ -1,7 +1,7 @@ package jet type rawStatementImpl struct { - serializerStatementInterfaceImpl + statementInterfaceImpl RawQuery string NamedArguments map[string]interface{} @@ -10,7 +10,7 @@ type rawStatementImpl struct { // RawStatement creates new sql statements from raw query and optional map of named arguments func RawStatement(dialect Dialect, rawQuery string, namedArgument ...map[string]interface{}) SerializerStatement { newRawStatement := rawStatementImpl{ - serializerStatementInterfaceImpl: serializerStatementInterfaceImpl{ + statementInterfaceImpl: statementInterfaceImpl{ dialect: dialect, statementType: "", parent: nil, diff --git a/internal/jet/serializer.go b/internal/jet/serializer.go index 9d36de4..9d457a9 100644 --- a/internal/jet/serializer.go +++ b/internal/jet/serializer.go @@ -22,16 +22,22 @@ func (s SerializeOption) WithFallTrough(options []SerializeOption) []SerializeOp // StatementType is type of the SQL statement type StatementType string +func (s StatementType) IsSelectJSON() bool { + return s == SelectJsonObjStatementType || s == SelectJsonArrStatementType +} + // Statement types const ( - SelectStatementType StatementType = "SELECT" - InsertStatementType StatementType = "INSERT" - UpdateStatementType StatementType = "UPDATE" - DeleteStatementType StatementType = "DELETE" - SetStatementType StatementType = "SET" - LockStatementType StatementType = "LOCK" - UnLockStatementType StatementType = "UNLOCK" - WithStatementType StatementType = "WITH" + SelectStatementType StatementType = "SELECT" + SelectJsonObjStatementType StatementType = "SELECT_JSON_OBJ" + SelectJsonArrStatementType StatementType = "SELECT_JSON_ARR" + InsertStatementType StatementType = "INSERT" + UpdateStatementType StatementType = "UPDATE" + DeleteStatementType StatementType = "DELETE" + SetStatementType StatementType = "SET" + LockStatementType StatementType = "LOCK" + UnLockStatementType StatementType = "UNLOCK" + WithStatementType StatementType = "WITH" ) // Serializer interface diff --git a/internal/jet/sql_builder.go b/internal/jet/sql_builder.go index 46f47ad..87a5814 100644 --- a/internal/jet/sql_builder.go +++ b/internal/jet/sql_builder.go @@ -99,6 +99,10 @@ func (s *SQLBuilder) WriteString(str string) { s.write([]byte(str)) } +func (s *SQLBuilder) WriteJsonObjKey(key string) { + s.WriteString(fmt.Sprintf(`'%s', `, key)) +} + // WriteIdentifier adds identifier to output SQL func (s *SQLBuilder) WriteIdentifier(name string, alwaysQuote ...bool) { if s.shouldQuote(name, alwaysQuote...) { diff --git a/internal/jet/statement.go b/internal/jet/statement.go index 2ca229d..20e4665 100644 --- a/internal/jet/statement.go +++ b/internal/jet/statement.go @@ -7,25 +7,49 @@ import ( "time" ) -// Statement is common interface for all statements(SELECT, INSERT, UPDATE, DELETE, LOCK) +// Statement is a common interface for all SQL statements, including SELECT, SELECT_JSON_ARR, SELECT_JSON_OBJ, INSERT, +// UPDATE, DELETE, and LOCK. type Statement interface { - // Sql returns parametrized sql query with list of arguments. + // Sql returns a parameterized SQL query along with its list of arguments. Sql() (query string, args []interface{}) - // DebugSql returns debug query where every parametrized placeholder is replaced with its argument string representation. - // Do not use it in production. Use it only for debug purposes. + + // DebugSql returns a debug-friendly SQL query where all parameterized placeholders + // are replaced with their respective argument string representations. + // + // Warning: This method should only be used for debugging purposes. + // Do not use it in production, as it may lead to security risks such as SQL injection. DebugSql() (query string) - // Query executes statement over database connection/transaction db and stores row results in destination. - // Destination can be either pointer to struct or pointer to a slice. - // If destination is pointer to struct and query result set is empty, method returns qrm.ErrNoRows. + + // Query executes statement on the provided database connection or transaction (db), + // storing the retrieved row results in the given destination. + // Destination must be a pointer to either a struct or a slice. + // If the destination is a pointer to a struct and the query returns no rows, Query returns qrm.ErrNoRows. Query(db qrm.Queryable, destination interface{}) error - // QueryContext executes statement with a context over database connection/transaction db and stores row result in destination. - // Destination can be either pointer to struct or pointer to a slice. - // If destination is pointer to struct and query result set is empty, method returns qrm.ErrNoRows. + + // QueryContext executes statement with a context over database connection/transaction db, + // storing the retrieved row results in the given destination. + // Destination must be a pointer to either a struct or a slice. + // If the destination is a pointer to a struct and the query returns no rows, Query returns qrm.ErrNoRows. QueryContext(ctx context.Context, db qrm.Queryable, destination interface{}) error + + // QueryJSON executes the given statement within the provided context on the database connection/transaction (db) + // and unmarshals the JSON result into the destination. + // If the statement is created as SELECT_JSON_ARR, the destination must be a pointer to a slice of structs or a + // pointer to []map[string]any. + // If the statement is created as SELECT_JSON_OBJ, the destination must be a pointer to a struct or a pointer to + // map[string]any. + // QueryJSON can also be used by other SQL statements that generate JSON on the server. The only requirement is + // that the query must return exactly one row with a single column; otherwise, an error is returned. + // If the destination is a pointer to a struct (or []map[string]any) and the query result set is empty, the method + // returns qrm.ErrNoRows. + QueryJSON(ctx context.Context, db qrm.Queryable, destination interface{}) error + // Exec executes statement over db connection/transaction without returning any rows. Exec(db qrm.Executable) (sql.Result, error) + // ExecContext executes statement with context over db connection/transaction without returning any rows. ExecContext(ctx context.Context, db qrm.Executable) (sql.Result, error) + // Rows executes statements over db connection/transaction and returns rows Rows(ctx context.Context, db qrm.Queryable) (*Rows, error) } @@ -60,14 +84,14 @@ type SerializerHasProjections interface { HasProjections } -// serializerStatementInterfaceImpl struct -type serializerStatementInterfaceImpl struct { +// statementInterfaceImpl struct +type statementInterfaceImpl struct { dialect Dialect statementType StatementType parent SerializerStatement } -func (s *serializerStatementInterfaceImpl) Sql() (query string, args []interface{}) { +func (s *statementInterfaceImpl) Sql() (query string, args []interface{}) { queryData := &SQLBuilder{Dialect: s.dialect} @@ -77,7 +101,7 @@ func (s *serializerStatementInterfaceImpl) Sql() (query string, args []interface return } -func (s *serializerStatementInterfaceImpl) DebugSql() (query string) { +func (s *statementInterfaceImpl) DebugSql() (query string) { sqlBuilder := &SQLBuilder{Dialect: s.dialect, Debug: true} s.parent.serialize(s.statementType, sqlBuilder, NoWrap) @@ -86,11 +110,29 @@ func (s *serializerStatementInterfaceImpl) DebugSql() (query string) { return } -func (s *serializerStatementInterfaceImpl) Query(db qrm.Queryable, destination interface{}) error { +func (s *statementInterfaceImpl) Query(db qrm.Queryable, destination interface{}) error { return s.QueryContext(context.Background(), db, destination) } -func (s *serializerStatementInterfaceImpl) QueryContext(ctx context.Context, db qrm.Queryable, destination interface{}) error { +func (s *statementInterfaceImpl) QueryContext(ctx context.Context, db qrm.Queryable, destination interface{}) error { + return s.query(ctx, func(query string, args []interface{}) (int64, error) { + return qrm.Query(ctx, db, query, args, destination) + }) +} + +func (s *statementInterfaceImpl) QueryJSON(ctx context.Context, db qrm.Queryable, destination interface{}) error { + return s.query(ctx, func(query string, args []interface{}) (int64, error) { + if s.statementType == SelectJsonObjStatementType { + return qrm.QueryJsonObj(ctx, db, query, args, destination) + } + return qrm.QueryJsonArr(ctx, db, query, args, destination) + }) +} + +func (s *statementInterfaceImpl) query( + ctx context.Context, + queryFunc func(query string, args []interface{}) (int64, error), +) error { query, args := s.Sql() callLogger(ctx, s) @@ -99,7 +141,7 @@ func (s *serializerStatementInterfaceImpl) QueryContext(ctx context.Context, db var err error duration := duration(func() { - rowsProcessed, err = qrm.Query(ctx, db, query, args, destination) + rowsProcessed, err = queryFunc(query, args) }) callQueryLoggerFunc(ctx, QueryInfo{ @@ -112,11 +154,11 @@ func (s *serializerStatementInterfaceImpl) QueryContext(ctx context.Context, db return err } -func (s *serializerStatementInterfaceImpl) Exec(db qrm.Executable) (res sql.Result, err error) { +func (s *statementInterfaceImpl) Exec(db qrm.Executable) (res sql.Result, err error) { return s.ExecContext(context.Background(), db) } -func (s *serializerStatementInterfaceImpl) ExecContext(ctx context.Context, db qrm.Executable) (res sql.Result, err error) { +func (s *statementInterfaceImpl) ExecContext(ctx context.Context, db qrm.Executable) (res sql.Result, err error) { query, args := s.Sql() callLogger(ctx, s) @@ -141,7 +183,7 @@ func (s *serializerStatementInterfaceImpl) ExecContext(ctx context.Context, db q return res, err } -func (s *serializerStatementInterfaceImpl) Rows(ctx context.Context, db qrm.Queryable) (*Rows, error) { +func (s *statementInterfaceImpl) Rows(ctx context.Context, db qrm.Queryable) (*Rows, error) { query, args := s.Sql() callLogger(ctx, s) @@ -191,11 +233,15 @@ type ExpressionStatement interface { } // NewExpressionStatementImpl creates new expression statement -func NewExpressionStatementImpl(Dialect Dialect, statementType StatementType, parent ExpressionStatement, clauses ...Clause) ExpressionStatement { +func NewExpressionStatementImpl(Dialect Dialect, + statementType StatementType, + parent ExpressionStatement, + clauses ...Clause) ExpressionStatement { + return &expressionStatementImpl{ ExpressionInterfaceImpl{Parent: parent}, statementImpl{ - serializerStatementInterfaceImpl: serializerStatementInterfaceImpl{ + statementInterfaceImpl: statementInterfaceImpl{ parent: parent, dialect: Dialect, statementType: statementType, @@ -211,13 +257,16 @@ type expressionStatementImpl struct { } func (s *expressionStatementImpl) serializeForProjection(statement StatementType, out *SQLBuilder) { + if statement.IsSelectJSON() { + panic("jet: SELECT JSON statements need to be aliased when used as a projection.") + } s.serialize(statement, out) } // NewStatementImpl creates new statementImpl func NewStatementImpl(Dialect Dialect, statementType StatementType, parent SerializerStatement, clauses ...Clause) SerializerStatement { return &statementImpl{ - serializerStatementInterfaceImpl: serializerStatementInterfaceImpl{ + statementInterfaceImpl: statementInterfaceImpl{ parent: parent, dialect: Dialect, statementType: statementType, @@ -227,7 +276,7 @@ func NewStatementImpl(Dialect Dialect, statementType StatementType, parent Seria } type statementImpl struct { - serializerStatementInterfaceImpl + statementInterfaceImpl Clauses []Clause } diff --git a/internal/jet/utils.go b/internal/jet/utils.go index 466f2a5..5278605 100644 --- a/internal/jet/utils.go +++ b/internal/jet/utils.go @@ -58,6 +58,23 @@ func SerializeProjectionList(statement StatementType, projections []Projection, } } +// SerializeProjectionListJsonObj serializes a list of projections for JSON object +func SerializeProjectionListJsonObj(statement StatementType, projections []Projection, out *SQLBuilder) { + + for i, p := range projections { + if i > 0 { + out.WriteString(",") + out.NewLine() + } + + if p == nil { + panic("jet: Projection is nil") + } + + p.serializeForJsonObj(statement, out) + } +} + // SerializeColumnNames func func SerializeColumnNames(columns []Column, out *SQLBuilder) { for i, col := range columns { diff --git a/internal/jet/with_statement.go b/internal/jet/with_statement.go index 03330e6..d507072 100644 --- a/internal/jet/with_statement.go +++ b/internal/jet/with_statement.go @@ -7,7 +7,7 @@ func WITH(dialect Dialect, recursive bool, cte ...*CommonTableExpression) func(s newWithImpl := &withImpl{ recursive: recursive, ctes: cte, - serializerStatementInterfaceImpl: serializerStatementInterfaceImpl{ + statementInterfaceImpl: statementInterfaceImpl{ dialect: dialect, statementType: WithStatementType, }, @@ -25,7 +25,7 @@ func WITH(dialect Dialect, recursive bool, cte ...*CommonTableExpression) func(s } type withImpl struct { - serializerStatementInterfaceImpl + statementInterfaceImpl recursive bool ctes []*CommonTableExpression primaryStatement SerializerStatement diff --git a/internal/testutils/test_utils.go b/internal/testutils/test_utils.go index 817de32..2e2fc44 100644 --- a/internal/testutils/test_utils.go +++ b/internal/testutils/test_utils.go @@ -115,6 +115,16 @@ func AssertJSON(t *testing.T, data interface{}, expectedJSON string) { require.Equal(t, dataJson, expectedJSON) } +// AssertJsonEqual checks if actual and expected json representation are the same +func AssertJsonEqual(t require.TestingT, actual, expected interface{}, option ...cmp.Option) { + actualJsonData, err := json.MarshalIndent(actual, "", "\t") + require.NoError(t, err) + expectedJsonData, err := json.MarshalIndent(expected, "", "\t") + require.NoError(t, err) + + require.Equal(t, actualJsonData, expectedJsonData) +} + // SaveJSONFile saves v as json at testRelativePath // nolint:unused func SaveJSONFile(v interface{}, testRelativePath string) { @@ -127,7 +137,10 @@ func SaveJSONFile(v interface{}, testRelativePath string) { } // AssertJSONFile check if data json representation is the same as json at testRelativePath -func AssertJSONFile(t *testing.T, data interface{}, testRelativePath string) { +func AssertJSONFile(t require.TestingT, data interface{}, testRelativePath string) { + if _, ok := t.(*testing.B); ok { + return // skip assert for benchmarks + } filePath := getFullPath(testRelativePath) fileJSONData, err := os.ReadFile(filePath) // #nosec G304 @@ -145,7 +158,11 @@ func AssertJSONFile(t *testing.T, data interface{}, testRelativePath string) { } // AssertStatementSql check if statement Sql() is the same as expectedQuery and expectedArgs -func AssertStatementSql(t *testing.T, query jet.PrintableStatement, expectedQuery string, expectedArgs ...interface{}) { +func AssertStatementSql(t require.TestingT, query jet.PrintableStatement, expectedQuery string, expectedArgs ...interface{}) { + if _, ok := t.(*testing.B); ok { + return // skip assert for benchmarks + } + queryStr, args := query.Sql() assertQueryString(t, queryStr, expectedQuery) @@ -255,6 +272,16 @@ func AssertQueryPanicErr(t *testing.T, stmt jet.Statement, db qrm.DB, dest inter _ = stmt.Query(db, dest) } +// AssertQueryJsonPanicErr check if statement QueryJSON execution panics with error errString +func AssertQueryJsonPanicErr(t *testing.T, stmt jet.Statement, db qrm.DB, dest interface{}, errString string) { + defer func() { + r := recover() + require.Equal(t, r, errString) + }() + + _ = stmt.QueryJSON(context.Background(), db, dest) +} + // AssertFileContent check if file content at filePath contains expectedContent text. func AssertFileContent(t *testing.T, filePath string, expectedContent string) { enumFileData, err := os.ReadFile(filePath) // #nosec G304 @@ -283,14 +310,14 @@ func AssertFileNamesEqual(t *testing.T, dirPath string, fileNames ...string) { } // AssertDeepEqual checks if actual and expected objects are deeply equal. -func AssertDeepEqual(t *testing.T, actual, expected interface{}, option ...cmp.Option) { +func AssertDeepEqual(t require.TestingT, actual, expected interface{}, option ...cmp.Option) { if !assert.True(t, cmp.Equal(actual, expected, option...)) { printDiff(actual, expected, option...) t.FailNow() } } -func assertQueryString(t *testing.T, actual, expected string) { +func assertQueryString(t require.TestingT, actual, expected string) { if !assert.Equal(t, actual, expected) { printDiff(actual, expected) t.FailNow() diff --git a/internal/utils/datetime/duration.go b/internal/utils/datetime/duration.go index 11cc57f..a702623 100644 --- a/internal/utils/datetime/duration.go +++ b/internal/utils/datetime/duration.go @@ -1,6 +1,9 @@ package datetime -import "time" +import ( + //"github.com/go-jet/jet/v2/internal/utils/min" + "time" +) // ExtractTimeComponents extracts number of days, hours, minutes, seconds, microseconds from duration func ExtractTimeComponents(duration time.Duration) (days, hours, minutes, seconds, microseconds int64) { @@ -20,3 +23,36 @@ func ExtractTimeComponents(duration time.Duration) (days, hours, minutes, second return } + +// TryParseAsTime attempts to parse the provided value as a time using one of the given formats. +// +// The function iterates over the provided formats and tries to parse the value into a time.Time object. +// It returns the parsed time and a boolean indicating whether the parsing was successful. +func TryParseAsTime(value interface{}, formats []string) (time.Time, bool) { + + var timeStr string + + switch v := value.(type) { + case string: + timeStr = v + case []byte: + timeStr = string(v) + case int64: + return time.Unix(v, 0), true // sqlite + default: + return time.Time{}, false + } + + for _, format := range formats { + formatLen := min(len(format), len(timeStr)) + t, err := time.Parse(format[:formatLen], timeStr) + + if err != nil { + continue + } + + return t, true + } + + return time.Time{}, false +} diff --git a/internal/utils/min/min.go b/internal/utils/min/min.go deleted file mode 100644 index 0e92146..0000000 --- a/internal/utils/min/min.go +++ /dev/null @@ -1,9 +0,0 @@ -package min - -// Int returns minimum of two int values -func Int(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/mysql/functions.go b/mysql/functions.go index ceec7ab..3eb16da 100644 --- a/mysql/functions.go +++ b/mysql/functions.go @@ -148,6 +148,11 @@ var NTH_VALUE = jet.NTH_VALUE //--------------------- String functions ------------------// +// HEX function in MySQL takes an input and returns its equivalent hexadecimal representation +func HEX(expression Expression) StringExpression { + return StringExp(Func("HEX", expression)) +} + // BIT_LENGTH returns number of bits in string expression var BIT_LENGTH = jet.BIT_LENGTH diff --git a/mysql/select_json.go b/mysql/select_json.go new file mode 100644 index 0000000..990e4ea --- /dev/null +++ b/mysql/select_json.go @@ -0,0 +1,79 @@ +package mysql + +import ( + "github.com/go-jet/jet/v2/internal/jet" +) + +// SelectJsonStatement is an interface for MySQL statements that generate JSON on the server. +type SelectJsonStatement interface { + Statement + jet.Serializer + + AS(alias string) Projection + + FROM(table ReadableTable) SelectJsonStatement + WHERE(condition BoolExpression) SelectJsonStatement + ORDER_BY(orderByClauses ...OrderByClause) SelectJsonStatement + LIMIT(limit int64) SelectJsonStatement + OFFSET(offset int64) SelectJsonStatement +} + +// SELECT_JSON_ARR creates a new SelectJsonStatement with a list of projections. +func SELECT_JSON_ARR(projections ...Projection) SelectJsonStatement { + return newSelectStatementJson(projections, jet.SelectJsonArrStatementType) +} + +// SELECT_JSON_OBJ creates a new SelectJsonStatement with a list of projections. +func SELECT_JSON_OBJ(projections ...Projection) SelectJsonStatement { + return newSelectStatementJson(projections, jet.SelectJsonObjStatementType) +} + +type selectJsonStatement struct { + *selectStatementImpl +} + +func newSelectStatementJson(projections []Projection, statementType jet.StatementType) SelectJsonStatement { + newSelect := &selectJsonStatement{ + selectStatementImpl: newSelectStatement(statementType, nil, nil), + } + + newSelect.Select.ProjectionList = ProjectionList{constructJsonFunc(projections, statementType).AS("json")} + + return newSelect +} + +func constructJsonFunc(projections []Projection, statementType jet.StatementType) Expression { + jsonObj := Func("JSON_OBJECT", CustomExpression(jet.JsonProjectionList(projections))) + + if statementType == jet.SelectJsonArrStatementType { + return Func("JSON_ARRAYAGG", jsonObj) + } + + return jsonObj +} + +func (s *selectJsonStatement) FROM(table ReadableTable) SelectJsonStatement { + s.From.Tables = []jet.Serializer{table} + + return s +} + +func (s *selectJsonStatement) WHERE(condition BoolExpression) SelectJsonStatement { + s.Where.Condition = condition + return s +} + +func (s *selectJsonStatement) ORDER_BY(orderByClauses ...OrderByClause) SelectJsonStatement { + s.OrderBy.List = orderByClauses + return s +} + +func (s *selectJsonStatement) LIMIT(limit int64) SelectJsonStatement { + s.Limit.Count = limit + return s +} + +func (s *selectJsonStatement) OFFSET(offset int64) SelectJsonStatement { + s.Offset.Count = Int(offset) + return s +} diff --git a/mysql/select_statement.go b/mysql/select_statement.go index aaeff9a..915fe93 100644 --- a/mysql/select_statement.go +++ b/mysql/select_statement.go @@ -62,12 +62,12 @@ type SelectStatement interface { // SELECT creates new SelectStatement with list of projections func SELECT(projection Projection, projections ...Projection) SelectStatement { - return newSelectStatement(nil, append([]Projection{projection}, projections...)) + return newSelectStatement(jet.SelectStatementType, nil, append([]Projection{projection}, projections...)) } -func newSelectStatement(table ReadableTable, projections []Projection) SelectStatement { +func newSelectStatement(stmtType jet.StatementType, table ReadableTable, projections []Projection) *selectStatementImpl { newSelect := &selectStatementImpl{} - newSelect.ExpressionStatement = jet.NewExpressionStatementImpl(Dialect, jet.SelectStatementType, newSelect, + newSelect.ExpressionStatement = jet.NewExpressionStatementImpl(Dialect, stmtType, newSelect, &newSelect.Select, &newSelect.From, &newSelect.Where, diff --git a/mysql/table.go b/mysql/table.go index 0ae7ee8..ebc67e2 100644 --- a/mysql/table.go +++ b/mysql/table.go @@ -50,7 +50,7 @@ type readableTableInterfaceImpl struct { // Generates a select query on the current tableName. func (r readableTableInterfaceImpl) SELECT(projection1 Projection, projections ...Projection) SelectStatement { - return newSelectStatement(r.parent, append([]Projection{projection1}, projections...)) + return newSelectStatement(jet.SelectStatementType, r.parent, append([]Projection{projection1}, projections...)) } // Creates a inner join tableName Expression using onCondition. diff --git a/postgres/select_json.go b/postgres/select_json.go new file mode 100644 index 0000000..5d23277 --- /dev/null +++ b/postgres/select_json.go @@ -0,0 +1,131 @@ +package postgres + +import ( + "github.com/go-jet/jet/v2/internal/jet" + "strings" +) + +// SELECT_JSON_ARR creates a new SelectJsonStatement with a list of projections. +func SELECT_JSON_ARR(projections ...Projection) SelectStatement { + return newSelectStatementJson(projections, jet.SelectJsonArrStatementType) +} + +// SELECT_JSON_OBJ creates a new SelectJsonStatement with a list of projections. +func SELECT_JSON_OBJ(projections ...Projection) SelectStatement { + return newSelectStatementJson(projections, jet.SelectJsonObjStatementType) +} + +type selectJsonStatement struct { + *selectStatementImpl + + subQuery *selectStatementImpl + statementType jet.StatementType +} + +func (s *selectJsonStatement) AS(alias string) Projection { + s.setSubQueryAlias(strings.ToLower(alias) + "_") + + return s.selectStatementImpl.AS(alias) +} + +func (s *selectJsonStatement) FROM(table ...ReadableTable) SelectStatement { + s.subQuery.From.Tables = readableTablesToSerializerList(table) + + return s +} + +func (s *selectJsonStatement) DISTINCT(on ...jet.ColumnExpression) SelectStatement { + s.subQuery.Select.Distinct = true + s.subQuery.Select.DistinctOnColumns = on + return s +} + +func (s *selectJsonStatement) WHERE(condition BoolExpression) SelectStatement { + s.subQuery.Where.Condition = condition + return s +} + +func (s *selectJsonStatement) GROUP_BY(groupByClauses ...GroupByClause) SelectStatement { + s.subQuery.GroupBy.List = groupByClauses + return s +} + +func (s *selectJsonStatement) HAVING(boolExpression BoolExpression) SelectStatement { + s.subQuery.Having.Condition = boolExpression + return s +} + +func (s *selectJsonStatement) WINDOW(name string) windowExpand { + s.subQuery.Window.Definitions = append(s.subQuery.Window.Definitions, jet.WindowDefinition{Name: name}) + return windowExpand{ + selectStatement: s.subQuery, + rootStmt: s, + } +} + +func (s *selectJsonStatement) ORDER_BY(orderByClauses ...OrderByClause) SelectStatement { + s.subQuery.OrderBy.List = orderByClauses + return s +} + +func (s *selectJsonStatement) LIMIT(limit int64) SelectStatement { + s.subQuery.Limit.Count = limit + return s +} + +func (s *selectJsonStatement) OFFSET(offset int64) SelectStatement { + s.subQuery.Offset.Count = Int(offset) + return s +} + +func (s *selectJsonStatement) OFFSET_e(offset IntegerExpression) SelectStatement { + s.subQuery.Offset.Count = offset + return s +} + +func (s *selectJsonStatement) FETCH_FIRST(count IntegerExpression) fetchExpand { + s.subQuery.Fetch.Count = count + + return fetchExpand{ + selectStatement: s.subQuery, + rootStmt: s, + } +} + +func (s *selectJsonStatement) FOR(lock RowLock) SelectStatement { + s.subQuery.For.Lock = lock + return s +} + +func newSelectStatementJson(projections []Projection, statementType jet.StatementType) SelectStatement { + newSelectJson := &selectJsonStatement{ + selectStatementImpl: newSelectStatement(statementType, nil, nil), + subQuery: newSelectStatement(statementType, nil, projections), + statementType: statementType, + } + + newSelectJson.setOperatorsImpl.stmtRoot = newSelectJson + + newSelectJson.setSubQueryAlias("") + + return newSelectJson +} + +func (s *selectJsonStatement) setSubQueryAlias(alias string) { + subQueryAlias := alias + "records" + jsonAlias := alias + "json" + + s.Select.ProjectionList = ProjectionList{constructJsonFunc(s.statementType, subQueryAlias).AS(jsonAlias)} + + s.From.Tables = []jet.Serializer{newSelectTable(s.subQuery, subQueryAlias, nil)} +} + +func constructJsonFunc(statementType jet.StatementType, subQueryAlias string) Expression { + rowToJson := Func("row_to_json", CustomExpression(Token(subQueryAlias))) + + if statementType == jet.SelectJsonArrStatementType { + return Func("json_agg", rowToJson) + } + + return rowToJson +} diff --git a/postgres/select_statement.go b/postgres/select_statement.go index 2a48fc5..f2427af 100644 --- a/postgres/select_statement.go +++ b/postgres/select_statement.go @@ -70,12 +70,12 @@ type SelectStatement interface { // SELECT creates new SelectStatement with list of projections func SELECT(projection Projection, projections ...Projection) SelectStatement { - return newSelectStatement(nil, append([]Projection{projection}, projections...)) + return newSelectStatement(jet.SelectStatementType, nil, append([]Projection{projection}, projections...)) } -func newSelectStatement(table ReadableTable, projections []Projection) SelectStatement { +func newSelectStatement(stmtType jet.StatementType, table ReadableTable, projections []Projection) *selectStatementImpl { newSelect := &selectStatementImpl{} - newSelect.ExpressionStatement = jet.NewExpressionStatementImpl(Dialect, jet.SelectStatementType, newSelect, + newSelect.ExpressionStatement = jet.NewExpressionStatementImpl(Dialect, stmtType, newSelect, &newSelect.Select, &newSelect.From, &newSelect.Where, @@ -94,7 +94,7 @@ func newSelectStatement(table ReadableTable, projections []Projection) SelectSta } newSelect.Limit.Count = -1 - newSelect.setOperatorsImpl.parent = newSelect + newSelect.setOperatorsImpl.stmtRoot = newSelect return newSelect } @@ -144,7 +144,10 @@ func (s *selectStatementImpl) HAVING(boolExpression BoolExpression) SelectStatem func (s *selectStatementImpl) WINDOW(name string) windowExpand { s.Window.Definitions = append(s.Window.Definitions, jet.WindowDefinition{Name: name}) - return windowExpand{selectStatement: s} + return windowExpand{ + selectStatement: s, + rootStmt: s, + } } func (s *selectStatementImpl) ORDER_BY(orderByClauses ...OrderByClause) SelectStatement { @@ -172,6 +175,7 @@ func (s *selectStatementImpl) FETCH_FIRST(count IntegerExpression) fetchExpand { return fetchExpand{ selectStatement: s, + rootStmt: s, } } @@ -188,6 +192,7 @@ func (s *selectStatementImpl) AsTable(alias string) SelectTable { type windowExpand struct { selectStatement *selectStatementImpl + rootStmt SelectStatement } func (w windowExpand) AS(window ...jet.Window) SelectStatement { @@ -196,7 +201,7 @@ func (w windowExpand) AS(window ...jet.Window) SelectStatement { } windowsDefinition := w.selectStatement.Window.Definitions windowsDefinition[len(windowsDefinition)-1].Window = window[0] - return w.selectStatement + return w.rootStmt } func toJetFrameOffset(offset int64) jet.Serializer { @@ -216,16 +221,17 @@ func readableTablesToSerializerList(tables []ReadableTable) []jet.Serializer { type fetchExpand struct { selectStatement *selectStatementImpl + rootStmt SelectStatement } func (f fetchExpand) ROWS_ONLY() SelectStatement { f.selectStatement.Fetch.WithTies = false - return f.selectStatement + return f.rootStmt } func (f fetchExpand) ROWS_WITH_TIES() SelectStatement { f.selectStatement.Fetch.WithTies = true - return f.selectStatement + return f.rootStmt } diff --git a/postgres/set_statement.go b/postgres/set_statement.go index 0dee00d..5f553cb 100644 --- a/postgres/set_statement.go +++ b/postgres/set_statement.go @@ -65,31 +65,31 @@ type setOperators interface { } type setOperatorsImpl struct { - parent setOperators + stmtRoot setOperators } func (s *setOperatorsImpl) UNION(rhs SelectStatement) setStatement { - return UNION(s.parent, rhs) + return UNION(s.stmtRoot, rhs) } func (s *setOperatorsImpl) UNION_ALL(rhs SelectStatement) setStatement { - return UNION_ALL(s.parent, rhs) + return UNION_ALL(s.stmtRoot, rhs) } func (s *setOperatorsImpl) INTERSECT(rhs SelectStatement) setStatement { - return INTERSECT(s.parent, rhs) + return INTERSECT(s.stmtRoot, rhs) } func (s *setOperatorsImpl) INTERSECT_ALL(rhs SelectStatement) setStatement { - return INTERSECT_ALL(s.parent, rhs) + return INTERSECT_ALL(s.stmtRoot, rhs) } func (s *setOperatorsImpl) EXCEPT(rhs SelectStatement) setStatement { - return EXCEPT(s.parent, rhs) + return EXCEPT(s.stmtRoot, rhs) } func (s *setOperatorsImpl) EXCEPT_ALL(rhs SelectStatement) setStatement { - return EXCEPT_ALL(s.parent, rhs) + return EXCEPT_ALL(s.stmtRoot, rhs) } type setStatementImpl struct { @@ -110,7 +110,7 @@ func newSetStatementImpl(operator string, all bool, selects []jet.SerializerStat newSetStatement.setOperator.Selects = selects newSetStatement.setOperator.Limit.Count = -1 - newSetStatement.setOperatorsImpl.parent = newSetStatement + newSetStatement.setOperatorsImpl.stmtRoot = newSetStatement return newSetStatement } diff --git a/postgres/table.go b/postgres/table.go index f90c114..aa54213 100644 --- a/postgres/table.go +++ b/postgres/table.go @@ -55,7 +55,7 @@ type readableTableInterfaceImpl struct { // Generates a select query on the current tableName. func (r readableTableInterfaceImpl) SELECT(projection1 Projection, projections ...Projection) SelectStatement { - return newSelectStatement(r.parent, append([]Projection{projection1}, projections...)) + return newSelectStatement(jet.SelectStatementType, r.parent, append([]Projection{projection1}, projections...)) } // Creates a inner join tableName Expression using onCondition. diff --git a/qrm/internal/null_types.go b/qrm/internal/null_types.go index 85d9c68..6f7a270 100644 --- a/qrm/internal/null_types.go +++ b/qrm/internal/null_types.go @@ -4,10 +4,9 @@ import ( "database/sql" "database/sql/driver" "fmt" - "github.com/go-jet/jet/v2/internal/utils/min" + "github.com/go-jet/jet/v2/internal/utils/datetime" "reflect" "strconv" - "time" ) var ( @@ -64,7 +63,12 @@ func (nt *NullTime) Scan(value interface{}) error { // Some of the drivers (pgx, mysql) are not parsing all of the time formats(date, time with time zone,...) and are just forwarding string value. // At this point we try to parse those values using some of the predefined formats - nt.Time, nt.Valid = tryParseAsTime(value) + nt.Time, nt.Valid = datetime.TryParseAsTime(value, []string{ + "2006-01-02 15:04:05-07:00", // sqlite + "2006-01-02 15:04:05.999999", // go-sql-driver/mysql + "15:04:05-07", // pgx + "15:04:05.999999", // pgx + }) if !nt.Valid { return fmt.Errorf("can't scan time.Time from %q", value) @@ -73,42 +77,6 @@ func (nt *NullTime) Scan(value interface{}) error { return nil } -var formats = []string{ - "2006-01-02 15:04:05-07:00", // sqlite - "2006-01-02 15:04:05.999999", // go-sql-driver/mysql - "15:04:05-07", // pgx - "15:04:05.999999", // pgx -} - -func tryParseAsTime(value interface{}) (time.Time, bool) { - - var timeStr string - - switch v := value.(type) { - case string: - timeStr = v - case []byte: - timeStr = string(v) - case int64: - return time.Unix(v, 0), true // sqlite - default: - return time.Time{}, false - } - - for _, format := range formats { - formatLen := min.Int(len(format), len(timeStr)) - t, err := time.Parse(format[:formatLen], timeStr) - - if err != nil { - continue - } - - return t, true - } - - return time.Time{}, false -} - // NullUInt64 struct type NullUInt64 struct { UInt64 uint64 diff --git a/qrm/qrm.go b/qrm/qrm.go index edd9387..f1ef6ad 100644 --- a/qrm/qrm.go +++ b/qrm/qrm.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "github.com/go-jet/jet/v2/internal/3rdparty/json" "github.com/go-jet/jet/v2/internal/utils/must" "reflect" ) @@ -12,10 +13,130 @@ import ( // ErrNoRows is returned by Query when query result set is empty var ErrNoRows = errors.New("qrm: no rows in result set") -// Query executes Query Result Mapping (QRM) of `query` with list of parametrized arguments `arg` over database connection `db` -// using context `ctx` into destination `destPtr`. -// Destination can be either pointer to struct or pointer to slice of structs. -// If destination is pointer to struct and query result set is empty, method returns qrm.ErrNoRows. +// QueryJsonObj executes a SQL query that returns a JSON object, unmarshals the result into the provided destination, +// and returns the number of rows processed. +// +// The query must return exactly one row with a single column; otherwise, an error is returned. +// +// Parameters: +// +// ctx - The context for managing query execution (timeouts, cancellations). +// db - The database connection or transaction that implements the Queryable interface. +// query - The SQL query string to be executed. +// args - A slice of arguments to be used with the query. +// destPtr - A pointer to the variable where the unmarshaled JSON result will be stored. +// The destination should be a pointer to a struct or map[string]any. +// +// Returns: +// +// rowsProcessed - The number of rows processed by the query execution. +// err - An error if query execution or unmarshaling fails. +func QueryJsonObj(ctx context.Context, db Queryable, query string, args []interface{}, destPtr interface{}) (rowsProcessed int64, err error) { + must.BeInitializedPtr(destPtr, "jet: destination is nil") + must.BeTypeKind(destPtr, reflect.Ptr, jsonDestObjErr) + destType := reflect.TypeOf(destPtr).Elem() + must.BeTrue(destType.Kind() == reflect.Struct || destType.Kind() == reflect.Map, jsonDestObjErr) + + return queryJson(ctx, db, query, args, destPtr) +} + +// QueryJsonArr executes a SQL query that returns a JSON array, unmarshals the result into the provided destination, +// and returns the number of rows processed. +// +// The query must return exactly one row with a single column; otherwise, an error is returned. +// +// Parameters: +// +// ctx - The context for managing query execution (timeouts, cancellations). +// db - The database connection or transaction that implements the Queryable interface. +// query - The SQL query string to be executed. +// args - A slice of arguments to be used with the query. +// destPtr - A pointer to the variable where the unmarshaled JSON array will be stored. +// The destination should be a pointer to a slice of structs or []map[string]any. +// +// Returns: +// +// rowsProcessed - The number of rows processed by the query execution. +// err - An error if query execution or unmarshaling fails. +func QueryJsonArr(ctx context.Context, db Queryable, query string, args []interface{}, destPtr interface{}) (rowsProcessed int64, err error) { + must.BeInitializedPtr(destPtr, "jet: destination is nil") + must.BeTypeKind(destPtr, reflect.Ptr, jsonDestArrErr) + destType := reflect.TypeOf(destPtr).Elem() + must.BeTrue(destType.Kind() == reflect.Slice, jsonDestArrErr) + + return queryJson(ctx, db, query, args, destPtr) +} + +var jsonDestObjErr = "jet: destination has to be a pointer to struct or pointer to map[string]any" +var jsonDestArrErr = "jet: destination has to be a pointer to slice of struct or pointer to []map[string]any" + +func queryJson(ctx context.Context, db Queryable, query string, args []interface{}, destPtr interface{}) (rowsProcessed int64, err error) { + must.BeInitializedPtr(db, "jet: db is nil") + + var rows *sql.Rows + rows, err = db.QueryContext(ctx, query, args...) + + if err != nil { + return 0, err + } + + defer rows.Close() + + if !rows.Next() { + err = rows.Err() + if err != nil { + return 0, err + } + return 0, ErrNoRows + } + + var jsonData []byte + err = rows.Scan(&jsonData) + + if err != nil { + return 1, err + } + + if jsonData == nil { + return 1, nil + } + + err = json.Unmarshal(jsonData, &destPtr) + + if err != nil { + return 1, err + } + + if rows.Next() { + return 1, fmt.Errorf("jet: query returned more then one row") + } + + err = rows.Close() + if err != nil { + return 1, err + } + + return 1, nil +} + +// Query executes a Query Result Mapping (QRM) of the provided SQL `query` with a list of parameterized arguments `args` +// over the database connection `db` using the provided context `ctx` and stores the result in the destination `destPtr`. +// +// The destination must be a pointer to either a struct or a slice of structs +// If the destination is a pointer to a struct and no rows are returned, the method returns qrm.ErrNoRows. +// +// Parameters: +// +// ctx - The context for managing query execution (timeouts, cancellations). +// db - The database connection or transaction implementing the Queryable interface. +// query - The SQL query string to be executed. +// args - A slice of arguments to be used with the query. +// destPtr - A pointer to the variable where the query result will be stored. This can be a pointer to a struct or a slice of structs. +// +// Returns: +// +// rowsProcessed - The number of rows processed by the query execution. +// err - An error if query execution or result mapping fails, or if no rows are found when a struct is expected. func Query(ctx context.Context, db Queryable, query string, args []interface{}, destPtr interface{}) (rowsProcessed int64, err error) { must.BeInitializedPtr(db, "jet: db is nil") @@ -185,7 +306,7 @@ func mapRowToSlice( func mapRowToBaseTypeSlice(scanContext *ScanContext, slicePtrValue reflect.Value, field *reflect.StructField) (updated bool, err error) { index := 0 if field != nil { - typeName, columnName := getTypeAndFieldName("", *field) + typeName, columnName, _ := getTypeAndFieldName("", *field) if index = scanContext.typeToColumnIndex(typeName, columnName); index < 0 { return } @@ -233,9 +354,11 @@ func mapRowToStruct( continue } - fieldMap := typeInf.fieldMappings[i] + fieldMappingInfo := typeInf.fieldMappings[i] - if fieldMap.complexType { + switch fieldMappingInfo.Type { + + case complexType: var changed bool changed, err = mapRowToDestinationValue(scanContext, concat(groupKey, ":", field.Name), fieldValue, &field) @@ -246,13 +369,12 @@ func mapRowToStruct( if changed { updated = true } - - } else { - if mapOnlySlices || fieldMap.rowIndex == -1 { + default: + if mapOnlySlices || fieldMappingInfo.rowIndex == -1 { continue } - scannedValue := scanContext.rowElemValue(fieldMap.rowIndex) + scannedValue := scanContext.rowElemValue(fieldMappingInfo.rowIndex) if !scannedValue.IsValid() { setZeroValue(fieldValue) // scannedValue is nil, destination should be set to zero value @@ -261,7 +383,8 @@ func mapRowToStruct( updated = true - if fieldMap.implementsScanner { + switch fieldMappingInfo.Type { + case implementsScanner: initializeValueIfNilPtr(fieldValue) fieldScanner := getScanner(fieldValue) @@ -270,14 +393,27 @@ func mapRowToStruct( err := fieldScanner.Scan(value) if err != nil { - return updated, fmt.Errorf(`can't scan %T(%q) to '%s %s': %w`, value, value, field.Name, field.Type.String(), err) + return updated, qrmAssignError(scannedValue, field, err) } - } else { + case jsonUnmarshal: + value, ok := scannedValue.Interface().([]byte) + + if !ok { + return updated, qrmAssignError(scannedValue, field, fmt.Errorf("value not convertable to []byte")) + } + + fieldInterface := fieldValue.Addr().Interface() + + err := json.Unmarshal(value, fieldInterface) + + if err != nil { + return updated, qrmAssignError(scannedValue, field, err) + } + default: // simple type err := assign(scannedValue, fieldValue) if err != nil { - return updated, fmt.Errorf(`can't assign %T(%q) to '%s %s': %w`, scannedValue.Interface(), scannedValue.Interface(), - field.Name, field.Type.String(), err) + return updated, qrmAssignError(scannedValue, field, err) } } } @@ -286,6 +422,11 @@ func mapRowToStruct( return } +func qrmAssignError(scannedValue reflect.Value, field reflect.StructField, err error) error { + return fmt.Errorf(`can't assign %T(%q) to '%s %s': %w`, scannedValue.Interface(), scannedValue.Interface(), + field.Name, field.Type.String(), err) +} + func mapRowToDestinationValue( scanContext *ScanContext, groupKey string, diff --git a/qrm/scan_context.go b/qrm/scan_context.go index 7a3c538..e28c4f1 100644 --- a/qrm/scan_context.go +++ b/qrm/scan_context.go @@ -75,10 +75,18 @@ type typeInfo struct { fieldMappings []fieldMapping } +type fieldMappingType int + +const ( + simpleType fieldMappingType = iota + complexType // slice and struct are complex types supported + implementsScanner + jsonUnmarshal +) + type fieldMapping struct { - complexType bool // slice and struct are complex types - rowIndex int // index in ScanContext.row - implementsScanner bool + rowIndex int // index in ScanContext.row + Type fieldMappingType } func (s *ScanContext) getTypeInfo(structType reflect.Type, parentField *reflect.StructField) typeInfo { @@ -100,17 +108,21 @@ func (s *ScanContext) getTypeInfo(structType reflect.Type, parentField *reflect. for i := 0; i < structType.NumField(); i++ { field := structType.Field(i) - newTypeName, fieldName := getTypeAndFieldName(typeName, field) + newTypeName, fieldName, jsonUnmarshaler := getTypeAndFieldName(typeName, field) columnIndex := s.typeToColumnIndex(newTypeName, fieldName) fieldMap := fieldMapping{ rowIndex: columnIndex, } - if implementsScannerType(field.Type) { - fieldMap.implementsScanner = true + if jsonUnmarshaler { + fieldMap.Type = jsonUnmarshal + } else if implementsScannerType(field.Type) { + fieldMap.Type = implementsScanner } else if !isSimpleModelType(field.Type) { - fieldMap.complexType = true + fieldMap.Type = complexType + } else { + fieldMap.Type = simpleType } newTypeInfo.fieldMappings = append(newTypeInfo.fieldMappings, fieldMap) @@ -188,7 +200,7 @@ func (s *ScanContext) getGroupKeyInfo( fieldType := indirectType(field.Type) if isPrimaryKey(field, primaryKeyOverwrites) { - newTypeName, fieldName := getTypeAndFieldName(typeName, field) + newTypeName, fieldName, _ := getTypeAndFieldName(typeName, field) pkIndex := s.typeToColumnIndex(newTypeName, fieldName) diff --git a/qrm/utill.go b/qrm/utill.go index b43ee29..1775fda 100644 --- a/qrm/utill.go +++ b/qrm/utill.go @@ -107,20 +107,26 @@ func getTypeName(structType reflect.Type, parentField *reflect.StructField) stri return toCommonIdentifier(aliasParts[0]) } -func getTypeAndFieldName(structType string, field reflect.StructField) (string, string) { +func getTypeAndFieldName(structType string, field reflect.StructField) (string, string, bool) { aliasTag := field.Tag.Get("alias") - if aliasTag == "" { - return structType, field.Name + if aliasTag != "" { + aliasParts := strings.Split(aliasTag, ".") + + if len(aliasParts) == 1 { + return structType, toCommonIdentifier(aliasParts[0]), false + } + + return toCommonIdentifier(aliasParts[0]), toCommonIdentifier(aliasParts[1]), false } - aliasParts := strings.Split(aliasTag, ".") + jsonColumnTag := field.Tag.Get("json_column") - if len(aliasParts) == 1 { - return structType, toCommonIdentifier(aliasParts[0]) + if jsonColumnTag != "" { + return "", toCommonIdentifier(jsonColumnTag), true } - return toCommonIdentifier(aliasParts[0]), toCommonIdentifier(aliasParts[1]) + return structType, field.Name, false } var replacer = strings.NewReplacer(" ", "", "-", "", "_", "") diff --git a/tests/docker-compose.yaml b/tests/docker-compose.yaml index bcbbb25..3bef6b7 100644 --- a/tests/docker-compose.yaml +++ b/tests/docker-compose.yaml @@ -26,7 +26,7 @@ services: - ./testdata/init/mysql:/docker-entrypoint-initdb.d mariadb: - image: mariadb:10.3 + image: mariadb:11.7 command: ['--default-authentication-plugin=mysql_native_password', '--log_bin_trust_function_creators=1'] restart: always environment: diff --git a/tests/mysql/alltypes_test.go b/tests/mysql/alltypes_test.go index 61bc7f2..2440339 100644 --- a/tests/mysql/alltypes_test.go +++ b/tests/mysql/alltypes_test.go @@ -23,19 +23,63 @@ func TestAllTypes(t *testing.T) { var dest []model.AllTypes - err := AllTypes. - SELECT(AllTypes.AllColumns). + err := SELECT(AllTypes.AllColumns). + FROM(AllTypes). LIMIT(2). Query(db, &dest) require.NoError(t, err) - require.Equal(t, len(dest), 2) //testutils.PrintJson(dest) testutils.AssertJSON(t, dest, allTypesJson) } +func TestAllTypesJSON(t *testing.T) { + + stmt := SELECT_JSON_ARR( + AllTypes.AllColumns.Except( + AllTypes.JSON, + AllTypes.JSONPtr, + AllTypes.Bit, + AllTypes.BitPtr, + AllTypes.Blob, + AllTypes.BlobPtr, + AllTypes.Binary, + AllTypes.BinaryPtr, + AllTypes.VarBinary, + AllTypes.VarBinaryPtr, + ), + CAST(AllTypes.JSON).AS_CHAR().AS("Json"), + CAST(AllTypes.JSONPtr).AS_CHAR().AS("JsonPtr"), + CAST(AllTypes.Bit).AS_CHAR().AS("Bit"), + CAST(AllTypes.BitPtr).AS_CHAR().AS("BitPtr"), + + // TODO: remove when binary string is implemented + CONCAT(String("\\x"), HEX(AllTypes.Blob)).AS("Blob"), + CONCAT(String("\\x"), HEX(AllTypes.BlobPtr)).AS("BlobPtr"), + + CONCAT(String("\\x"), HEX(AllTypes.Binary)).AS("Binary"), + CONCAT(String("\\x"), HEX(AllTypes.BinaryPtr)).AS("BinaryPtr"), + + CONCAT(String("\\x"), HEX(AllTypes.VarBinary)).AS("VarBinary"), + CONCAT(String("\\x"), HEX(AllTypes.VarBinaryPtr)).AS("VarBinaryPtr"), + ).FROM(AllTypes) + + var dest []model.AllTypes + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + + // fix float rounding lost before comparison + dest[0].Float = 3.33 + dest[0].FloatPtr = ptr.Of(3.33) + dest[1].Float = 3.33 + + //fmt.Println(allTypesJson) + testutils.AssertJSON(t, dest, allTypesJson) +} + func TestAllTypesViewSelect(t *testing.T) { type AllTypesView model.AllTypes diff --git a/tests/mysql/bench_test.go b/tests/mysql/bench_test.go new file mode 100644 index 0000000..4595ea9 --- /dev/null +++ b/tests/mysql/bench_test.go @@ -0,0 +1,138 @@ +//go:build bench +// +build bench + +package mysql + +import ( + "github.com/go-jet/jet/v2/internal/testutils" + . "github.com/go-jet/jet/v2/mysql" + "github.com/go-jet/jet/v2/tests/.gentestdata/mysql/dvds/model" + . "github.com/go-jet/jet/v2/tests/.gentestdata/mysql/dvds/table" + "github.com/stretchr/testify/require" + "testing" +) + +type allInfo []struct { + model.Actor + + Films []struct { + model.Film + + Language model.Language + Categories []model.Category + + Inventories []struct { + model.Inventory + + Rentals []struct { + model.Rental + + Customer model.Customer + } + } + } +} + +func BenchmarkTestDVDsJoinEverything(b *testing.B) { + for i := 0; i < b.N; i++ { + testDVDsJoinEverything(b) + } +} + +func TestDVDsJoinEverything(t *testing.T) { + testDVDsJoinEverything(t) +} + +func testDVDsJoinEverything(t require.TestingT) { + stmt := SELECT( + Actor.AllColumns, + Film.AllColumns, + Language.AllColumns, + Category.AllColumns, + Inventory.AllColumns, + Rental.AllColumns, + Customer.AllColumns, + ).FROM( + Actor. + LEFT_JOIN(FilmActor, Actor.ActorID.EQ(FilmActor.ActorID)). + LEFT_JOIN(Film, Film.FilmID.EQ(FilmActor.FilmID)). + LEFT_JOIN(Language, Language.LanguageID.EQ(Film.LanguageID)). + LEFT_JOIN(FilmCategory, FilmCategory.FilmID.EQ(Film.FilmID)). + LEFT_JOIN(Category, Category.CategoryID.EQ(FilmCategory.CategoryID)). + LEFT_JOIN(Inventory, Inventory.FilmID.EQ(Film.FilmID)). + LEFT_JOIN(Rental, Rental.InventoryID.EQ(Inventory.InventoryID)). + LEFT_JOIN(Customer, Customer.CustomerID.EQ(Rental.CustomerID)), + ).ORDER_BY( + Actor.ActorID.ASC(), + Film.FilmID.ASC(), + Category.CategoryID.ASC(), + Inventory.InventoryID.ASC(), + Rental.RentalID.ASC(), + ) + + var dest allInfo + + err := stmt.Query(db, &dest) + require.NoError(t, err) + + //testutils.SaveJSONFile(dest, "./testdata/results/mysql/dvds_join_everything.json") + testutils.AssertJSONFile(t, dest, "./testdata/results/mysql/dvds_join_everything.json") +} + +func BenchmarkTestDVDsJoinEverythingJSON(b *testing.B) { + for i := 0; i < b.N; i++ { + testDVDsJoinEverythingJSON(b) + } +} + +func TestDVDsJoinEverythingJSON(t *testing.T) { + testDVDsJoinEverythingJSON(t) +} + +func testDVDsJoinEverythingJSON(t require.TestingT) { + stmt := SELECT_JSON_ARR( + Actor.ActorID, Actor.FirstName, Actor.LastName, Actor.LastUpdate, + + SELECT_JSON_ARR( + Film.AllColumns, + + SELECT_JSON_OBJ(Language.AllColumns). + FROM(Language). + WHERE(Language.LanguageID.EQ(Film.LanguageID)).AS("Language"), + + SELECT_JSON_ARR(Category.AllColumns). + FROM(Category.INNER_JOIN(FilmCategory, FilmCategory.CategoryID.EQ(Category.CategoryID))). + WHERE(FilmCategory.FilmID.EQ(Film.FilmID)).AS("Categories"), + + SELECT_JSON_ARR( + Inventory.AllColumns, + + SELECT_JSON_ARR( + Rental.AllColumns, + + SELECT_JSON_OBJ(Customer.AllColumns). + FROM(Customer). + WHERE(Customer.CustomerID.EQ(Rental.CustomerID)).AS("Customer"), + ).FROM(Rental). + WHERE(Rental.InventoryID.EQ(Inventory.InventoryID)). + ORDER_BY(Rental.RentalID).AS("Rentals"), + ).FROM(Inventory). + WHERE(Inventory.FilmID.EQ(Film.FilmID)). + ORDER_BY(Inventory.InventoryID).AS("Inventories"), + ).FROM(Film. + INNER_JOIN(FilmActor, FilmActor.FilmID.EQ(Film.FilmID)), + ).WHERE(FilmActor.ActorID.EQ(Actor.ActorID)). + ORDER_BY(Film.FilmID.ASC()).AS("Films"), + ).FROM(Actor). + ORDER_BY(Actor.ActorID.ASC()) + + //fmt.Println(stmt.DebugSql()) + + var dest allInfo + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + + //testutils.SaveJSONFile(dest, "./testdata/results/mysql/dvds_join_everything2.json") + testutils.AssertJSONFile(t, dest, "./testdata/results/mysql/dvds_join_everything.json") +} diff --git a/tests/mysql/select_json_test.go b/tests/mysql/select_json_test.go new file mode 100644 index 0000000..2d0ccef --- /dev/null +++ b/tests/mysql/select_json_test.go @@ -0,0 +1,437 @@ +package mysql + +import ( + "context" + "fmt" + "github.com/go-jet/jet/v2/qrm" + "strings" + "testing" + + "github.com/go-jet/jet/v2/internal/testutils" + . "github.com/go-jet/jet/v2/mysql" + "github.com/go-jet/jet/v2/tests/.gentestdata/mysql/dvds/model" + . "github.com/go-jet/jet/v2/tests/.gentestdata/mysql/dvds/table" + + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +func TestSelectJsonObj(t *testing.T) { + stmt := SELECT_JSON_OBJ(Actor.AllColumns). + FROM(Actor). + WHERE(Actor.ActorID.EQ(Int(2))) + + testutils.AssertStatementSql(t, stmt, ` +SELECT JSON_OBJECT('actorID', actor.actor_id, + 'firstName', actor.first_name, + 'lastName', actor.last_name, + 'lastUpdate', actor.last_update) AS "json" +FROM dvds.actor +WHERE actor.actor_id = ?; +`, int64(2)) + + var dest model.Actor + + err := stmt.QueryJSON(ctx, db, &dest) + require.Nil(t, err) + + testutils.AssertDeepEqual(t, dest, actor2) + requireLogged(t, stmt) + requireQueryLogged(t, stmt, 1) +} + +func TestSelectJsonObj_NestedObj(t *testing.T) { + stmt := SELECT_JSON_OBJ( + Actor.AllColumns, + + SELECT_JSON_OBJ(Film.AllColumns). + FROM(FilmActor.INNER_JOIN(Film, Film.FilmID.EQ(FilmActor.FilmID))). + WHERE(Actor.ActorID.EQ(FilmActor.ActorID)). + ORDER_BY(Film.Length.DESC()). + LIMIT(1).AS("LongestFilm"), + ).FROM( + Actor, + ).WHERE( + Actor.ActorID.EQ(Int(2)), + ) + + testutils.AssertStatementSql(t, stmt, ` +SELECT JSON_OBJECT('actorID', actor.actor_id, + 'firstName', actor.first_name, + 'lastName', actor.last_name, + 'lastUpdate', actor.last_update, + 'LongestFilm', ( + SELECT JSON_OBJECT('filmID', film.film_id, + 'title', film.title, + 'description', film.description, + 'releaseYear', film.release_year, + 'languageID', film.language_id, + 'originalLanguageID', film.original_language_id, + 'rentalDuration', film.rental_duration, + 'rentalRate', film.rental_rate, + 'length', film.length, + 'replacementCost', film.replacement_cost, + 'rating', film.rating, + 'specialFeatures', film.special_features, + 'lastUpdate', film.last_update) AS "json" + FROM dvds.film_actor + INNER JOIN dvds.film ON (film.film_id = film_actor.film_id) + WHERE actor.actor_id = film_actor.actor_id + ORDER BY film.length DESC + LIMIT ? + )) AS "json" +FROM dvds.actor +WHERE actor.actor_id = ?; +`) + + var dest struct { + model.Actor + + LongestFilm model.Film + } + + err := stmt.QueryJSON(ctx, db, &dest) + require.Nil(t, err) + testutils.AssertJSON(t, dest, ` +{ + "ActorID": 2, + "FirstName": "NICK", + "LastName": "WAHLBERG", + "LastUpdate": "2006-02-15T04:34:33Z", + "LongestFilm": { + "FilmID": 958, + "Title": "WARDROBE PHANTOM", + "Description": "A Action-Packed Display of a Mad Cow And a Astronaut who must Kill a Car in Ancient India", + "ReleaseYear": 2006, + "LanguageID": 1, + "OriginalLanguageID": null, + "RentalDuration": 6, + "RentalRate": 2.99, + "Length": 178, + "ReplacementCost": 19.99, + "Rating": "G", + "SpecialFeatures": "Trailers,Commentaries", + "LastUpdate": "2006-02-15T05:03:42Z" + } +} +`) + +} + +func TestSelectJsonArr(t *testing.T) { + stmt := SELECT_JSON_ARR(Actor.AllColumns). + FROM(Actor). + ORDER_BY(Actor.ActorID) + + testutils.AssertDebugStatementSql(t, stmt, ` +SELECT JSON_ARRAYAGG(JSON_OBJECT('actorID', actor.actor_id, + 'firstName', actor.first_name, + 'lastName', actor.last_name, + 'lastUpdate', actor.last_update)) AS "json" +FROM dvds.actor +ORDER BY actor.actor_id; +`) + + var dest []model.Actor + + err := stmt.QueryJSON(ctx, db, &dest) + require.Nil(t, err) + + testutils.AssertJSONFile(t, dest, "./testdata/results/mysql/all_actors.json") + requireLogged(t, stmt) + requireQueryLogged(t, stmt, 1) +} + +func TestSelectJsonArr_NestedArr(t *testing.T) { + stmt := SELECT_JSON_ARR( + Actor.AllColumns, + + SELECT_JSON_ARR( + Film.AllColumns, + ).FROM( + FilmActor.INNER_JOIN( + Film, + Film.FilmID.EQ(FilmActor.FilmID).AND( + Actor.ActorID.EQ(FilmActor.ActorID)), + ), + ).WHERE( + Film.FilmID.MOD(Int(17)).EQ(Int(0)), + ).ORDER_BY( + Film.Length.DESC(), + ).AS("Films"), + ).FROM( + Actor, + ).WHERE( + Actor.ActorID.BETWEEN(Int(1), Int(3)), + ).ORDER_BY( + Actor.ActorID, + ) + + testutils.AssertDebugStatementSql(t, stmt, ` +SELECT JSON_ARRAYAGG(JSON_OBJECT('actorID', actor.actor_id, + 'firstName', actor.first_name, + 'lastName', actor.last_name, + 'lastUpdate', actor.last_update, + 'Films', ( + SELECT JSON_ARRAYAGG(JSON_OBJECT('filmID', film.film_id, + 'title', film.title, + 'description', film.description, + 'releaseYear', film.release_year, + 'languageID', film.language_id, + 'originalLanguageID', film.original_language_id, + 'rentalDuration', film.rental_duration, + 'rentalRate', film.rental_rate, + 'length', film.length, + 'replacementCost', film.replacement_cost, + 'rating', film.rating, + 'specialFeatures', film.special_features, + 'lastUpdate', film.last_update)) AS "json" + FROM dvds.film_actor + INNER JOIN dvds.film ON ((film.film_id = film_actor.film_id) AND (actor.actor_id = film_actor.actor_id)) + WHERE (film.film_id % 17) = 0 + ORDER BY film.length DESC + ))) AS "json" +FROM dvds.actor +WHERE actor.actor_id BETWEEN 1 AND 3 +ORDER BY actor.actor_id; +`) + + var dest []struct { + model.Actor + + Films []model.Film + } + + err := stmt.QueryJSON(ctx, db, &dest) + fmt.Println(err) + require.Nil(t, err) + testutils.AssertJSON(t, dest, ` +[ + { + "ActorID": 1, + "FirstName": "PENELOPE", + "LastName": "GUINESS", + "LastUpdate": "2006-02-15T04:34:33Z", + "Films": null + }, + { + "ActorID": 2, + "FirstName": "NICK", + "LastName": "WAHLBERG", + "LastUpdate": "2006-02-15T04:34:33Z", + "Films": [ + { + "FilmID": 357, + "Title": "GILBERT PELICAN", + "Description": "A Fateful Tale of a Man And a Feminist who must Conquer a Crocodile in A Manhattan Penthouse", + "ReleaseYear": 2006, + "LanguageID": 1, + "OriginalLanguageID": null, + "RentalDuration": 7, + "RentalRate": 0.99, + "Length": 114, + "ReplacementCost": 13.99, + "Rating": "G", + "SpecialFeatures": "Trailers,Commentaries", + "LastUpdate": "2006-02-15T05:03:42Z" + }, + { + "FilmID": 561, + "Title": "MASK PEACH", + "Description": "A Boring Character Study of a Student And a Robot who must Meet a Woman in California", + "ReleaseYear": 2006, + "LanguageID": 1, + "OriginalLanguageID": null, + "RentalDuration": 6, + "RentalRate": 2.99, + "Length": 123, + "ReplacementCost": 26.99, + "Rating": "NC-17", + "SpecialFeatures": "Commentaries,Deleted Scenes", + "LastUpdate": "2006-02-15T05:03:42Z" + } + ] + }, + { + "ActorID": 3, + "FirstName": "ED", + "LastName": "CHASE", + "LastUpdate": "2006-02-15T04:34:33Z", + "Films": [ + { + "FilmID": 17, + "Title": "ALONE TRIP", + "Description": "A Fast-Paced Character Study of a Composer And a Dog who must Outgun a Boat in An Abandoned Fun House", + "ReleaseYear": 2006, + "LanguageID": 1, + "OriginalLanguageID": null, + "RentalDuration": 3, + "RentalRate": 0.99, + "Length": 82, + "ReplacementCost": 14.99, + "Rating": "R", + "SpecialFeatures": "Trailers,Behind the Scenes", + "LastUpdate": "2006-02-15T05:03:42Z" + }, + { + "FilmID": 289, + "Title": "EVE RESURRECTION", + "Description": "A Awe-Inspiring Yarn of a Pastry Chef And a Database Administrator who must Challenge a Teacher in A Baloon", + "ReleaseYear": 2006, + "LanguageID": 1, + "OriginalLanguageID": null, + "RentalDuration": 5, + "RentalRate": 4.99, + "Length": 66, + "ReplacementCost": 25.99, + "Rating": "G", + "SpecialFeatures": "Trailers,Commentaries,Deleted Scenes", + "LastUpdate": "2006-02-15T05:03:42Z" + } + ] + } +] +`) + +} + +func TestSelectJson_GroupBy(t *testing.T) { + skipForMariaDB(t) // scope issues with select without FROM + + subQuery := SELECT( + Customer.AllColumns, + + SUM(Payment.Amount).AS("sum"), + AVG(Payment.Amount).AS("avg"), + MAX(Payment.Amount).AS("max"), + MIN(Payment.Amount).AS("min"), + COUNT(Payment.Amount).AS("count"), + ).FROM( + Payment. + INNER_JOIN(Customer, Customer.CustomerID.EQ(Payment.CustomerID)), + ).GROUP_BY( + Customer.CustomerID, + ).HAVING( + SUMf(Payment.Amount).GT(Float(125)), + ).ORDER_BY( + Customer.CustomerID, SUM(Payment.Amount).ASC(), + ).AsTable("customers_info") + + stmt := SELECT_JSON_ARR( + subQuery.AllColumns().Except( // TODO: remove when ColumnList.From() is implemented + FloatColumn("sum"), + FloatColumn("avg"), + FloatColumn("max"), + FloatColumn("min"), + FloatColumn("count"), + ), + + SELECT_JSON_OBJ( + FloatColumn("sum").From(subQuery), + FloatColumn("avg").From(subQuery), + FloatColumn("max").From(subQuery), + FloatColumn("min").From(subQuery), + FloatColumn("count").From(subQuery), + ).AS("amount"), + ).FROM(subQuery) + + testutils.AssertDebugStatementSql(t, stmt, strings.ReplaceAll(` +SELECT JSON_ARRAYAGG(JSON_OBJECT('customerID', customers_info.""customer.customer_id"", + 'storeID', customers_info.""customer.store_id"", + 'firstName', customers_info.""customer.first_name"", + 'lastName', customers_info.""customer.last_name"", + 'email', customers_info.""customer.email"", + 'addressID', customers_info.""customer.address_id"", + 'active', customers_info.""customer.active"", + 'createDate', customers_info.""customer.create_date"", + 'lastUpdate', customers_info.""customer.last_update"", + 'amount', ( + SELECT JSON_OBJECT('sum', customers_info.sum, + 'avg', customers_info.avg, + 'max', customers_info.max, + 'min', customers_info.min, + 'count', customers_info.count) AS "json" + ))) AS "json" +FROM ( + SELECT customer.customer_id AS "customer.customer_id", + customer.store_id AS "customer.store_id", + customer.first_name AS "customer.first_name", + customer.last_name AS "customer.last_name", + customer.email AS "customer.email", + customer.address_id AS "customer.address_id", + customer.active AS "customer.active", + customer.create_date AS "customer.create_date", + customer.last_update AS "customer.last_update", + SUM(payment.amount) AS "sum", + AVG(payment.amount) AS "avg", + MAX(payment.amount) AS "max", + MIN(payment.amount) AS "min", + COUNT(payment.amount) AS "count" + FROM dvds.payment + INNER JOIN dvds.customer ON (customer.customer_id = payment.customer_id) + GROUP BY customer.customer_id + HAVING SUM(payment.amount) > 125 + ORDER BY customer.customer_id, SUM(payment.amount) ASC + ) AS customers_info; +`, `""`, "`")) + + var dest []struct { + model.Customer + + Amount struct { + Sum float64 + Avg float64 + Max float64 + Min float64 + Count int64 + } + } + + err := stmt.QueryJSON(ctx, db, &dest) + fmt.Println(err) + require.Nil(t, err) + + testutils.AssertJSONFile(t, dest, "./testdata/results/mysql/customer_payment_sum.json") + requireLogged(t, stmt) +} + +func TestSelectJsonObject_EmptyResult(t *testing.T) { + + t.Run("json obj", func(t *testing.T) { + stmt := SELECT_JSON_OBJ(Actor.AllColumns). + FROM(Actor). + WHERE(Actor.FirstName.EQ(String("Kowalski"))) + + var dest model.Actor + + err := stmt.QueryJSON(ctx, db, &dest) + require.ErrorIs(t, err, qrm.ErrNoRows) + }) + + t.Run("json arr", func(t *testing.T) { + stmt := SELECT_JSON_ARR(Actor.AllColumns). + FROM(Actor). + WHERE(Actor.FirstName.EQ(String("Kowalski"))) + + var dest []model.Actor + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + require.Empty(t, dest) + }) +} + +func TestSelectJson_ProjectionNotAliased(t *testing.T) { + + t.Run("expression not aliased", func(t *testing.T) { + testutils.AssertPanicErr(t, func() { + stmt := SELECT_JSON_ARR( + Int(2).ADD(Customer.CustomerID), + ).FROM(Customer) + + stmt.DebugSql() + + }, "jet: expression need to be aliased when used as SELECT JSON projection.") + }) +} diff --git a/tests/mysql/select_test.go b/tests/mysql/select_test.go index 6e4507d..e27c683 100644 --- a/tests/mysql/select_test.go +++ b/tests/mysql/select_test.go @@ -19,9 +19,9 @@ import ( ) func TestSelect_ScanToStruct(t *testing.T) { - query := Actor. - SELECT(Actor.AllColumns). + query := SELECT(Actor.AllColumns). DISTINCT(). + FROM(Actor). WHERE(Actor.ActorID.EQ(Int(2))) testutils.AssertStatementSql(t, query, ` @@ -50,9 +50,56 @@ var actor2 = model.Actor{ LastUpdate: *testutils.TimestampWithoutTimeZone("2006-02-15 04:34:33", 2), } +func TestSelect_NestedObject(t *testing.T) { + stmt := SELECT( + Actor.AllColumns, + Film.AllColumns, + ).FROM( + Actor. + LEFT_JOIN(FilmActor, FilmActor.ActorID.EQ(Actor.ActorID)). + LEFT_JOIN(Film, Film.FilmID.EQ(FilmActor.FilmID)), + ).WHERE( + Actor.ActorID.EQ(Int(2)), + ).ORDER_BY( + Film.LastUpdate.DESC(), + ).LIMIT(1) + + var dest struct { + model.Actor + + LatestFilm model.Film + } + + err := stmt.Query(db, &dest) + require.NoError(t, err) + testutils.AssertJSON(t, dest, ` +{ + "ActorID": 2, + "FirstName": "NICK", + "LastName": "WAHLBERG", + "LastUpdate": "2006-02-15T04:34:33Z", + "LatestFilm": { + "FilmID": 3, + "Title": "ADAPTATION HOLES", + "Description": "A Astounding Reflection of a Lumberjack And a Car who must Sink a Lumberjack in A Baloon Factory", + "ReleaseYear": 2006, + "LanguageID": 1, + "OriginalLanguageID": null, + "RentalDuration": 7, + "RentalRate": 2.99, + "Length": 50, + "ReplacementCost": 18.99, + "Rating": "NC-17", + "SpecialFeatures": "Trailers,Deleted Scenes", + "LastUpdate": "2006-02-15T05:03:42Z" + } +} +`) +} + func TestSelect_ScanToSlice(t *testing.T) { - query := Actor. - SELECT(Actor.AllColumns). + query := SELECT(Actor.AllColumns). + FROM(Actor). ORDER_BY(Actor.ActorID) testutils.AssertStatementSql(t, query, ` @@ -107,19 +154,20 @@ GROUP BY payment.customer_id HAVING SUM(payment.amount) > 125.6 ORDER BY payment.customer_id, SUM(payment.amount) ASC; ` - query := Payment. - INNER_JOIN(Customer, Customer.CustomerID.EQ(Payment.CustomerID)). - SELECT( - Customer.AllColumns, + query := SELECT( + Customer.AllColumns, - SUMf(Payment.Amount).AS("amount.sum"), - AVG(Payment.Amount).AS("amount.avg"), - MAX(Payment.PaymentDate).AS("amount.max_date"), - MAXf(Payment.Amount).AS("amount.max"), - MIN(Payment.PaymentDate).AS("amount.min_date"), - MINf(Payment.Amount).AS("amount.min"), - COUNT(Payment.Amount).AS("amount.count"), - ). + SUMf(Payment.Amount).AS("amount.sum"), + AVG(Payment.Amount).AS("amount.avg"), + MAX(Payment.PaymentDate).AS("amount.max_date"), + MAXf(Payment.Amount).AS("amount.max"), + MIN(Payment.PaymentDate).AS("amount.min_date"), + MINf(Payment.Amount).AS("amount.min"), + COUNT(Payment.Amount).AS("amount.count"), + ).FROM( + Payment. + INNER_JOIN(Customer, Customer.CustomerID.EQ(Payment.CustomerID)), + ). GROUP_BY(Payment.CustomerID). HAVING( SUMf(Payment.Amount).GT(Float(125.6)), @@ -1122,7 +1170,7 @@ WHERE payment.payment_id < ? WINDOW w1 AS (PARTITION BY payment.payment_date), w2 AS (w1), w3 AS (w2 ORDER BY payment.customer_id) ORDER BY payment.customer_id; ` - query := Payment.SELECT( + query := SELECT( AVG(Payment.Amount).OVER(), AVG(Payment.Amount).OVER(Window("w1")), AVG(Payment.Amount).OVER( @@ -1131,7 +1179,7 @@ ORDER BY payment.customer_id; RANGE(PRECEDING(UNBOUNDED), FOLLOWING(UNBOUNDED)), ), AVG(Payment.Amount).OVER(Window("w3").RANGE(PRECEDING(UNBOUNDED), FOLLOWING(UNBOUNDED))), - ). + ).FROM(Payment). WHERE(Payment.PaymentID.LT(Int(10))). WINDOW("w1").AS(PARTITION_BY(Payment.PaymentDate)). WINDOW("w2").AS(Window("w1")). diff --git a/tests/postgres/alltypes_test.go b/tests/postgres/alltypes_test.go index 6c3755a..e28322d 100644 --- a/tests/postgres/alltypes_test.go +++ b/tests/postgres/alltypes_test.go @@ -35,6 +35,58 @@ func TestAllTypesSelect(t *testing.T) { testutils.AssertDeepEqual(t, dest[1], allTypesRow1) } +func TestAllTypesSelectJson(t *testing.T) { + + stmt := SELECT_JSON_ARR( + AllTypesAllColumns.Except( + AllTypes.JSON, AllTypes.JSONPtr, + AllTypes.Jsonb, AllTypes.JsonbPtr, + AllTypes.TextArray, AllTypes.TextArrayPtr, + AllTypes.JsonbArray, AllTypes.IntegerArray, AllTypes.IntegerArrayPtr, + AllTypes.TextMultiDimArray, AllTypes.TextMultiDimArrayPtr, + ), + CAST(AllTypes.JSONPtr).AS_TEXT().AS("jsonPtr"), + CAST(AllTypes.JSON).AS_TEXT().AS("JSON"), + CAST(AllTypes.JsonbPtr).AS_TEXT().AS("jsonbPtr"), + CAST(AllTypes.Jsonb).AS_TEXT().AS("Jsonb"), + CAST(AllTypes.TextArrayPtr).AS_TEXT().AS("TextArrayPtr"), + CAST(AllTypes.TextArray).AS_TEXT().AS("TextArray"), + CAST(AllTypes.JsonbArray).AS_TEXT().AS("JsonbArray"), + CAST(AllTypes.IntegerArray).AS_TEXT().AS("IntegerArray"), + CAST(AllTypes.IntegerArrayPtr).AS_TEXT().AS("IntegerArrayPtr"), + CAST(AllTypes.TextMultiDimArray).AS_TEXT().AS("TextMultiDimArray"), + CAST(AllTypes.TextMultiDimArrayPtr).AS_TEXT().AS("TextMultiDimArrayPtr"), + ).FROM(AllTypes) + + //fmt.Println(stmt.DebugSql()) + + var dest []model.AllTypes + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + + // fix inconsistencies between postgres and cockroachdb. + // cockroachdb returns char[N] columns with trailing whitespaces trimmed + if sourceIsCockroachDB() { + dest[0].Char = allTypesRow0.Char + dest[0].CharPtr = allTypesRow0.CharPtr + + dest[1].Char = allTypesRow1.Char + dest[1].CharPtr = allTypesRow1.CharPtr + } + + // set time local before comparison + dest[0].Timestampz = dest[0].Timestampz.Local() + + if dest[0].TimestampzPtr != nil { + dest[0].TimestampzPtr = ptr.Of(dest[0].TimestampzPtr.Local()) + } + dest[1].Timestampz = dest[1].Timestampz.Local() + + require.Equal(t, dest[0], allTypesRow0) + require.Equal(t, dest[1], allTypesRow1) +} + func TestAllTypesViewSelect(t *testing.T) { type AllTypesView model.AllTypes var dest []AllTypesView @@ -146,40 +198,42 @@ RETURNING all_types.bytea AS "all_types.bytea", all_types.bytea_ptr AS "all_types.bytea_ptr"; `, byteArrHex, byteArrBin) - var inserted model.AllTypes - err := insertStmt.Query(db, &inserted) - require.NoError(t, err) + testutils.ExecuteInTxAndRollback(t, db, func(tx qrm.DB) { + var inserted model.AllTypes + err := insertStmt.Query(tx, &inserted) + require.NoError(t, err) - require.Equal(t, string(*inserted.ByteaPtr), "Hello Gopher!") - // It is not possible to initiate bytea column using hex format '\xDEADBEEF' with pq driver. - // pq driver always encodes parameter string if destination column is of type bytea. - // Probably pq driver error. - // require.Equal(t, string(inserted.Bytea), "Hello Gopher!") + require.Equal(t, string(*inserted.ByteaPtr), "Hello Gopher!") + // It is not possible to initiate bytea column using hex format '\xDEADBEEF' with pq driver. + // pq driver always encodes parameter string if destination column is of type bytea. + // Probably pq driver error. + // require.Equal(t, string(inserted.Bytea), "Hello Gopher!") - stmt := SELECT( - AllTypes.Bytea, - AllTypes.ByteaPtr, - ).FROM( - AllTypes, - ).WHERE( - AllTypes.ByteaPtr.EQ(Bytea(byteArrBin)), - ) + stmt := SELECT( + AllTypes.Bytea, + AllTypes.ByteaPtr, + ).FROM( + AllTypes, + ).WHERE( + AllTypes.ByteaPtr.EQ(Bytea(byteArrBin)), + ) - testutils.AssertStatementSql(t, stmt, ` + testutils.AssertStatementSql(t, stmt, ` SELECT all_types.bytea AS "all_types.bytea", all_types.bytea_ptr AS "all_types.bytea_ptr" FROM test_sample.all_types WHERE all_types.bytea_ptr = $1::bytea; `, byteArrBin) - var dest model.AllTypes + var dest model.AllTypes - err = stmt.Query(db, &dest) - require.NoError(t, err) + err = stmt.Query(tx, &dest) + require.NoError(t, err) - require.Equal(t, string(*dest.ByteaPtr), "Hello Gopher!") - // Probably pq driver error. - // require.Equal(t, string(dest.Bytea), "Hello Gopher!") + require.Equal(t, string(*dest.ByteaPtr), "Hello Gopher!") + // Probably pq driver error. + // require.Equal(t, string(dest.Bytea), "Hello Gopher!") + }) } func TestAllTypesFromSubQuery(t *testing.T) { diff --git a/tests/postgres/chinook_db_test.go b/tests/postgres/chinook_db_test.go index 349a4ba..a001ab5 100644 --- a/tests/postgres/chinook_db_test.go +++ b/tests/postgres/chinook_db_test.go @@ -188,7 +188,130 @@ ORDER BY "Artist"."ArtistId", "Album"."AlbumId", "Track"."TrackId"; `) } +type AllArtistDetails []struct { //list of all artist + model.Artist + + Albums []struct { // list of albums per artist + model.Album + + Tracks []struct { // list of tracks per album + model.Track + + Genre model.Genre // track genre + MediaType model.MediaType // track media type + + Playlists []model.Playlist // list of playlist where track is used + + Invoices []struct { // list of invoices where track occurs + model.Invoice + + Customer struct { // customer data for invoice + model.Customer + + Employee *struct { // employee data for customer if exists + model.Employee + + Manager *model.Employee `alias:"Manager"` + } + } + } + } + } +} + +func BenchmarkJoinEverythingJSON(b *testing.B) { + for i := 0; i < b.N; i++ { + testJoinEverythingJSON(b) + } +} + +func TestJoinEverythingJSON(t *testing.T) { + testJoinEverythingJSON(t) +} + +func testJoinEverythingJSON(t require.TestingT) { + + manager := Employee.AS("Manager") + + stmt := SELECT_JSON_ARR( + Artist.AllColumns, + + SELECT_JSON_ARR( + Album.AllColumns, + + SELECT_JSON_ARR( + Track.AllColumns, + + SELECT_JSON_OBJ(Genre.AllColumns). + FROM(Genre). + WHERE(Genre.GenreId.EQ(Track.GenreId)).AS("Genre"), + + SELECT_JSON_OBJ(MediaType.AllColumns). + FROM(MediaType). + WHERE(MediaType.MediaTypeId.EQ(Track.MediaTypeId)).AS("MediaType"), + + SELECT_JSON_ARR(Playlist.AllColumns). + FROM(Playlist.INNER_JOIN( + PlaylistTrack, + Playlist.PlaylistId.EQ(PlaylistTrack.PlaylistId))). + WHERE(PlaylistTrack.TrackId.EQ(Track.TrackId)). + ORDER_BY(Playlist.PlaylistId).AS("Playlists"), + + SELECT_JSON_ARR( + Invoice.AllColumns, + + SELECT_JSON_OBJ( + Customer.AllColumns, + + SELECT_JSON_OBJ( + Employee.AllColumns, + + SELECT_JSON_OBJ(manager.AllColumns). + FROM(manager). + WHERE(manager.EmployeeId.EQ(Employee.ReportsTo)).AS("Manager"), + ).FROM(Employee). + WHERE(Employee.EmployeeId.EQ(Customer.SupportRepId)).AS("Employee"), + ).FROM(Customer). + WHERE(Customer.CustomerId.EQ(Invoice.CustomerId)).AS("Customer"), + ).FROM(Invoice.INNER_JOIN( + InvoiceLine, + InvoiceLine.InvoiceId.EQ(Invoice.InvoiceId)), + ).WHERE(InvoiceLine.TrackId.EQ(Track.TrackId)). + ORDER_BY(Invoice.InvoiceId).AS("Invoices"), + ).FROM(Track). + WHERE(Track.AlbumId.EQ(Album.AlbumId)). + ORDER_BY(Track.TrackId).AS("Tracks"), + ).FROM(Album). + WHERE(Album.ArtistId.EQ(Artist.ArtistId)). + ORDER_BY(Album.AlbumId).AS("Albums"), + ).FROM(Artist). + ORDER_BY(Artist.ArtistId) + + //fmt.Println(stmt.DebugSql()) + + var dest AllArtistDetails + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + + require.Equal(t, len(dest), 275) + //testutils.SaveJSONFile(dest, "./testdata/results/postgres/joined_everything2.json") + testutils.AssertJSONFile(t, dest, "./testdata/results/postgres/joined_everything.json") + requireLogged(t, stmt) + requireQueryLogged(t, stmt, 1) +} + +func BenchmarkJoinEverything(b *testing.B) { + for i := 0; i < b.N; i++ { + testJoinEverything(b) + } +} + func TestJoinEverything(t *testing.T) { + testJoinEverything(t) +} + +func testJoinEverything(t require.TestingT) { manager := Employee.AS("Manager") @@ -223,37 +346,6 @@ func TestJoinEverything(t *testing.T) { Invoice.InvoiceId, Customer.CustomerId, ) - var dest []struct { //list of all artist - model.Artist - - Albums []struct { // list of albums per artist - model.Album - - Tracks []struct { // list of tracks per album - model.Track - - Genre model.Genre // track genre - MediaType model.MediaType // track media type - - Playlists []model.Playlist // list of playlist where track is used - - Invoices []struct { // list of invoices where track occurs - model.Invoice - - Customer struct { // customer data for invoice - model.Customer - - Employee *struct { // employee data for customer if exists - model.Employee - - Manager *model.Employee `alias:"Manager"` - } - } - } - } - } - } - testutils.AssertStatementSql(t, stmt, ` SELECT "Artist"."ArtistId" AS "Artist.ArtistId", "Artist"."Name" AS "Artist.Name", @@ -344,7 +436,7 @@ FROM chinook."Artist" LEFT JOIN chinook."Employee" AS "Manager" ON ("Manager"."EmployeeId" = "Employee"."ReportsTo") ORDER BY "Artist"."ArtistId", "Album"."AlbumId", "Track"."TrackId", "Genre"."GenreId", "MediaType"."MediaTypeId", "Playlist"."PlaylistId", "Invoice"."InvoiceId", "Customer"."CustomerId"; `) - + var dest AllArtistDetails err := stmt.QueryContext(context.Background(), db, &dest) require.NoError(t, err) diff --git a/tests/postgres/main_test.go b/tests/postgres/main_test.go index 46dfd94..a179bd4 100644 --- a/tests/postgres/main_test.go +++ b/tests/postgres/main_test.go @@ -119,14 +119,22 @@ func init() { }) } -func requireLogged(t *testing.T, statement postgres.Statement) { +func requireLogged(t require.TestingT, statement postgres.Statement) { + if _, ok := t.(*testing.B); ok { + return // skip assert for benchmarks + } + query, args := statement.Sql() require.Equal(t, loggedSQL, query) require.Equal(t, loggedSQLArgs, args) require.Equal(t, loggedDebugSQL, statement.DebugSql()) } -func requireQueryLogged(t *testing.T, statement postgres.Statement, rowsProcessed int64) { +func requireQueryLogged(t require.TestingT, statement postgres.Statement, rowsProcessed int64) { + if _, ok := t.(*testing.B); ok { + return // skip assert for benchmarks + } + query, args := statement.Sql() queryLogged, argsLogged := queryInfo.Statement.Sql() diff --git a/tests/postgres/northwind_test.go b/tests/postgres/northwind_test.go index 7aad0d4..63e1b1d 100644 --- a/tests/postgres/northwind_test.go +++ b/tests/postgres/northwind_test.go @@ -9,7 +9,50 @@ import ( "testing" ) -func TestNorthwindJoinEverything(t *testing.T) { +type Dest []struct { + model.Customers + + Demographics model.CustomerDemographics + + Orders []struct { + model.Orders + + Shipper model.Shippers + + Employee struct { + model.Employees + + Territories []struct { + model.Territories + + Region model.Region + } + } + + Details []struct { + model.OrderDetails + + Products struct { + model.Products + + Category model.Categories + Supplier model.Suppliers + } + } + } +} + +func BenchmarkTestNorthwindJoinEverything(b *testing.B) { + for i := 0; i < b.N; i++ { + testNorthwindJoinEverything(b) + } +} + +func TestTestNorthwindJoinEverything(t *testing.T) { + testNorthwindJoinEverything(t) +} + +func testNorthwindJoinEverything(t require.TestingT) { stmt := SELECT( @@ -21,6 +64,9 @@ func TestNorthwindJoinEverything(t *testing.T) { Products.AllColumns, Categories.AllColumns, Suppliers.AllColumns, + Employees.AllColumns, + Territories.AllColumns, + Region.AllColumns, ).FROM( Customers. LEFT_JOIN(CustomerCustomerDemo, Customers.CustomerID.EQ(CustomerCustomerDemo.CustomerID)). @@ -35,35 +81,110 @@ func TestNorthwindJoinEverything(t *testing.T) { LEFT_JOIN(EmployeeTerritories, EmployeeTerritories.EmployeeID.EQ(Employees.EmployeeID)). LEFT_JOIN(Territories, EmployeeTerritories.TerritoryID.EQ(Territories.TerritoryID)). LEFT_JOIN(Region, Territories.RegionID.EQ(Region.RegionID)), - ).ORDER_BY(Customers.CustomerID, Orders.OrderID, Products.ProductID) + ).ORDER_BY( + Customers.CustomerID, + Orders.OrderID, + Products.ProductID, + Territories.TerritoryID, + ) - var dest []struct { - model.Customers + //fmt.Println(stmt.DebugSql()) - Demographics model.CustomerDemographics - - Orders []struct { - model.Orders - - Shipper model.Shippers - - Details struct { - model.OrderDetails - - Products []struct { - model.Products - - Category model.Categories - Supplier model.Suppliers - } - } - } - } + var dest Dest err := stmt.Query(db, &dest) require.NoError(t, err) - //jsonSave("./testdata/northwind-all.json", dest) + //testutils.SaveJSONFile(dest, "./testdata/results/postgres/northwind-all.json") testutils.AssertJSONFile(t, dest, "./testdata/results/postgres/northwind-all.json") requireLogged(t, stmt) } + +func BenchmarkTestNorthwindJoinEverythingJson(b *testing.B) { + for i := 0; i < b.N; i++ { + testNorthwindJoinEverythingJson(b) + } +} + +func TestNorthwindJoinEverythingJson(t *testing.T) { + testNorthwindJoinEverythingJson(t) +} + +func testNorthwindJoinEverythingJson(t require.TestingT) { + + stmt := SELECT_JSON_ARR( + Customers.AllColumns, + + SELECT_JSON_OBJ(CustomerDemographics.AllColumns). + FROM(CustomerDemographics.INNER_JOIN(CustomerCustomerDemo, CustomerCustomerDemo.CustomerTypeID.EQ(CustomerDemographics.CustomerTypeID))). + WHERE(CustomerCustomerDemo.CustomerID.EQ(Customers.CustomerID)).AS("Demographics"), + + SELECT_JSON_ARR( + Orders.AllColumns, + + SELECT_JSON_OBJ(Shippers.AllColumns). + FROM(Shippers). + WHERE(Shippers.ShipperID.EQ(Orders.ShipVia)).AS("Shipper"), + + SELECT_JSON_OBJ( + Employees.AllColumns, + SELECT_JSON_ARR( + Territories.AllColumns, + + SELECT_JSON_OBJ(Region.AllColumns). + FROM(Region). + WHERE(Region.RegionID.EQ(Territories.RegionID)).AS("Region"), + ).FROM( + EmployeeTerritories.LEFT_JOIN( + Territories, + EmployeeTerritories.TerritoryID.EQ(Territories.TerritoryID)), + ).WHERE( + EmployeeTerritories.EmployeeID.EQ(Employees.EmployeeID), // TODO: move to join + ).AS("Territories"), + ).FROM(Employees). + WHERE(Orders.EmployeeID.EQ(Employees.EmployeeID)).AS("Employee"), + + SELECT_JSON_ARR( + OrderDetails.AllColumns, + + SELECT_JSON_OBJ( + Products.AllColumns, + + SELECT_JSON_OBJ( + Categories.AllColumns, + ).FROM(Categories). + WHERE(Categories.CategoryID.EQ(Products.CategoryID)).AS("Category"), + + SELECT_JSON_OBJ(Suppliers.AllColumns). + FROM(Suppliers). + WHERE(Suppliers.SupplierID.EQ(Products.SupplierID)).AS("Supplier"), + ).FROM(Products). + WHERE(Products.ProductID.EQ(OrderDetails.ProductID)).AS("Products"), + ).FROM( + OrderDetails, + ).WHERE( + OrderDetails.OrderID.EQ(Orders.OrderID), + ).AS("Details"), + ).FROM( + Orders, + ).WHERE( + Orders.CustomerID.EQ(Customers.CustomerID), + ).ORDER_BY( + Orders.OrderID, + ).AS("Orders"), + ).FROM( + Customers, + ).ORDER_BY( + Customers.CustomerID, + ) + + //fmt.Println(stmt.DebugSql()) + + var dest Dest + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + + //testutils.SaveJSONFile(dest, "./testdata/results/postgres/northwind-all2.json") + testutils.AssertJSONFile(t, dest, "./testdata/results/postgres/northwind-all.json") +} diff --git a/tests/postgres/sample_test.go b/tests/postgres/sample_test.go index 0a252db..45eb7f3 100644 --- a/tests/postgres/sample_test.go +++ b/tests/postgres/sample_test.go @@ -220,20 +220,7 @@ func TestUUIDComplex(t *testing.T) { requireLogged(t, query) }) - t.Run("slice of structs left join", func(t *testing.T) { - leftQuery := Person.LEFT_JOIN(PersonPhone, PersonPhone.PersonID.EQ(Person.PersonID)). - SELECT(Person.AllColumns, PersonPhone.AllColumns). - ORDER_BY(Person.PersonID.ASC(), PersonPhone.PhoneID.ASC()) - var dest []struct { - model.Person - Phones []struct { - model.PersonPhone - } - } - err := leftQuery.Query(db, &dest) - - require.NoError(t, err) - testutils.AssertJSON(t, dest, ` + var expectedSliceOfStructsLeftJoin = ` [ { "PersonID": "b68dbff4-a87d-11e9-a7f2-98ded00c39c6", @@ -274,10 +261,50 @@ func TestUUIDComplex(t *testing.T) { ] } ] -`) +` + + t.Run("slice of structs left join", func(t *testing.T) { + leftQuery := Person.LEFT_JOIN(PersonPhone, PersonPhone.PersonID.EQ(Person.PersonID)). + SELECT(Person.AllColumns, PersonPhone.AllColumns). + ORDER_BY(Person.PersonID.ASC(), PersonPhone.PhoneID.ASC()) + var dest []struct { + model.Person + Phones []struct { + model.PersonPhone + } + } + err := leftQuery.Query(db, &dest) + + require.NoError(t, err) + testutils.AssertJSON(t, dest, expectedSliceOfStructsLeftJoin) requireLogged(t, leftQuery) }) + t.Run("select json", func(t *testing.T) { + jsonQuery := SELECT_JSON_ARR( + Person.AllColumns, + SELECT_JSON_ARR(PersonPhone.AllColumns). + FROM(PersonPhone). + WHERE(PersonPhone.PersonID.EQ(Person.PersonID)). + ORDER_BY(PersonPhone.PhoneID).AS("Phones"), + ).FROM( + Person, + ).ORDER_BY( + Person.PersonID.ASC(), + ) + + var dest []struct { + model.Person + Phones []struct { + model.PersonPhone + } + } + + err := jsonQuery.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + testutils.AssertJSON(t, dest, expectedSliceOfStructsLeftJoin) + }) + } func TestEnumType(t *testing.T) { query := Person. diff --git a/tests/postgres/scan_test.go b/tests/postgres/scan_test.go index 321fc38..a630145 100644 --- a/tests/postgres/scan_test.go +++ b/tests/postgres/scan_test.go @@ -209,7 +209,7 @@ func TestScanToStruct(t *testing.T) { err := query.Query(db, &dest) require.Error(t, err) - require.EqualError(t, err, "jet: can't scan int64('\\x01') to 'InventoryID uuid.UUID': Scan: unable to scan type int64 into UUID") + require.EqualError(t, err, "jet: can't assign int64('\\x01') to 'InventoryID uuid.UUID': Scan: unable to scan type int64 into UUID") }) t.Run("type mismatch base type", func(t *testing.T) { diff --git a/tests/postgres/select_json_test.go b/tests/postgres/select_json_test.go new file mode 100644 index 0000000..517df9a --- /dev/null +++ b/tests/postgres/select_json_test.go @@ -0,0 +1,908 @@ +package postgres + +import ( + "context" + "github.com/go-jet/jet/v2/internal/testutils" + "github.com/go-jet/jet/v2/internal/utils/ptr" + "github.com/go-jet/jet/v2/qrm" + "github.com/go-jet/jet/v2/tests/.gentestdata/jetdb/dvds/view" + "github.com/stretchr/testify/require" + "testing" + "time" + + . "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/tests/.gentestdata/jetdb/dvds/model" + . "github.com/go-jet/jet/v2/tests/.gentestdata/jetdb/dvds/table" +) + +var ctx = context.Background() + +func TestSelectJsonObject(t *testing.T) { + stmt := SELECT_JSON_OBJ(Actor.AllColumns). + FROM(Actor). + WHERE(Actor.ActorID.EQ(Int32(2))) + + testutils.AssertStatementSql(t, stmt, ` +SELECT row_to_json(records) AS "json" +FROM ( + SELECT actor.actor_id AS "actorID", + actor.first_name AS "firstName", + actor.last_name AS "lastName", + actor.last_update AS "lastUpdate" + FROM dvds.actor + WHERE actor.actor_id = $1::integer + ) AS records; +`, int32(2)) + + var dest model.Actor + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + testutils.AssertDeepEqual(t, dest, actor2) + requireLogged(t, stmt) + + t.Run("scan to map", func(t *testing.T) { + var dest2 map[string]interface{} + + err = stmt.QueryJSON(ctx, db, &dest2) + require.NoError(t, err) + testutils.PrintJson(dest2) + testutils.AssertDeepEqual(t, dest2, map[string]interface{}{ + "actorID": float64(2), + "firstName": "Nick", + "lastName": "Wahlberg", + "lastUpdate": "2013-05-26T14:47:57.62", + }) + }) +} + +func TestSelectJsonArr(t *testing.T) { + stmt := SELECT_JSON_ARR( + Rental.StaffID, + Rental.CustomerID, + Rental.RentalID, + ).DISTINCT( + Rental.StaffID, + Rental.CustomerID, + ).FROM( + Rental, + ).WHERE( + Rental.CustomerID.LT(Int(2)), + ).ORDER_BY( + Rental.StaffID.ASC(), + Rental.CustomerID.ASC(), + Rental.RentalID.ASC(), + ) + + testutils.AssertStatementSql(t, stmt, ` +SELECT json_agg(row_to_json(records)) AS "json" +FROM ( + SELECT DISTINCT ON (rental.staff_id, rental.customer_id) rental.staff_id AS "staffID", + rental.customer_id AS "customerID", + rental.rental_id AS "rentalID" + FROM dvds.rental + WHERE rental.customer_id < $1 + ORDER BY rental.staff_id ASC, rental.customer_id ASC, rental.rental_id ASC + ) AS records; +`, int64(2)) + + var dest []model.Rental + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + testutils.AssertJSON(t, dest, ` +[ + { + "RentalID": 573, + "RentalDate": "0001-01-01T00:00:00Z", + "InventoryID": 0, + "CustomerID": 1, + "ReturnDate": null, + "StaffID": 1, + "LastUpdate": "0001-01-01T00:00:00Z" + }, + { + "RentalID": 76, + "RentalDate": "0001-01-01T00:00:00Z", + "InventoryID": 0, + "CustomerID": 1, + "ReturnDate": null, + "StaffID": 2, + "LastUpdate": "0001-01-01T00:00:00Z" + } +] +`) + t.Run("scan to array map", func(t *testing.T) { + var dest2 []map[string]interface{} + + err := stmt.QueryJSON(ctx, db, &dest2) + require.NoError(t, err) + testutils.AssertDeepEqual(t, dest2, []map[string]interface{}{ + { + "rentalID": 573., + "customerID": 1., + "staffID": 1., + }, + { + "rentalID": 76., + "customerID": 1., + "staffID": 2., + }, + }) + }) +} + +func TestSelectJsonArr_NestedArr(t *testing.T) { + + stmt := SELECT_JSON_ARR( + Customer.AllColumns, + + SELECT_JSON_ARR(Rental.AllColumns). + FROM(Rental). + WHERE(Rental.CustomerID.EQ(Customer.CustomerID)). + ORDER_BY(Rental.RentalID). + OFFSET_e(Int(1)).LIMIT(3).AS("Rentals"), + ).FROM( + Customer, + ).ORDER_BY( + Customer.CustomerID, + ).LIMIT(2).OFFSET(1) + + testutils.AssertStatementSql(t, stmt, ` +SELECT json_agg(row_to_json(records)) AS "json" +FROM ( + SELECT customer.customer_id AS "customerID", + customer.store_id AS "storeID", + customer.first_name AS "firstName", + customer.last_name AS "lastName", + customer.email AS "email", + customer.address_id AS "addressID", + customer.activebool AS "activebool", + customer.create_date AS "createDate", + customer.last_update AS "lastUpdate", + customer.active AS "active", + ( + SELECT json_agg(row_to_json(rentals_records)) AS "rentals_json" + FROM ( + SELECT rental.rental_id AS "rentalID", + rental.rental_date AS "rentalDate", + rental.inventory_id AS "inventoryID", + rental.customer_id AS "customerID", + rental.return_date AS "returnDate", + rental.staff_id AS "staffID", + rental.last_update AS "lastUpdate" + FROM dvds.rental + WHERE rental.customer_id = customer.customer_id + ORDER BY rental.rental_id + LIMIT $1 + OFFSET $2 + ) AS rentals_records + ) AS "Rentals" + FROM dvds.customer + ORDER BY customer.customer_id + LIMIT $3 + OFFSET $4 + ) AS records; +`) + + var dest []struct { + model.Customer + + Rentals []model.Rental + } + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + + t.Run("partial select json", func(t *testing.T) { + + stmt := SELECT( + Customer.AllColumns, + + SELECT_JSON_ARR(Rental.AllColumns). + FROM(Rental). + WHERE(Rental.CustomerID.EQ(Customer.CustomerID)). + ORDER_BY(Rental.RentalID). + OFFSET_e(Int(1)).LIMIT(3).AS("Rentals"), + ).FROM( + Customer, + ).ORDER_BY( + Customer.CustomerID, + ).OFFSET(1).LIMIT(2) + + testutils.AssertStatementSql(t, stmt, ` +SELECT customer.customer_id AS "customer.customer_id", + customer.store_id AS "customer.store_id", + customer.first_name AS "customer.first_name", + customer.last_name AS "customer.last_name", + customer.email AS "customer.email", + customer.address_id AS "customer.address_id", + customer.activebool AS "customer.activebool", + customer.create_date AS "customer.create_date", + customer.last_update AS "customer.last_update", + customer.active AS "customer.active", + ( + SELECT json_agg(row_to_json(rentals_records)) AS "rentals_json" + FROM ( + SELECT rental.rental_id AS "rentalID", + rental.rental_date AS "rentalDate", + rental.inventory_id AS "inventoryID", + rental.customer_id AS "customerID", + rental.return_date AS "returnDate", + rental.staff_id AS "staffID", + rental.last_update AS "lastUpdate" + FROM dvds.rental + WHERE rental.customer_id = customer.customer_id + ORDER BY rental.rental_id + LIMIT $1 + OFFSET $2 + ) AS rentals_records + ) AS "Rentals" +FROM dvds.customer +ORDER BY customer.customer_id +LIMIT $3 +OFFSET $4; +`) + + var dest2 []struct { + model.Customer + + Rentals []model.Rental `json_column:"Rentals"` + } + + err := stmt.Query(db, &dest2) + require.NoError(t, err) + testutils.AssertJsonEqual(t, dest, dest2) + + var dest3 []struct { + model.Customer + + Rentals *[]model.Rental `json_column:"rentals"` + } + + err = stmt.Query(db, &dest3) + require.NoError(t, err) + testutils.AssertJsonEqual(t, dest, dest3) + + var dest4 []struct { + model.Customer + + Rentals []*model.Rental `json_column:"rentals"` + } + + err = stmt.Query(db, &dest4) + require.NoError(t, err) + testutils.AssertJsonEqual(t, dest, dest4) + }) +} + +func TestSelectJson_GroupByHaving(t *testing.T) { + stmt := SELECT_JSON_ARR( + Customer.AllColumns, + + SELECT_JSON_OBJ( + SUM(Payment.Amount).AS("sum"), + AVG(Payment.Amount).AS("avg"), + MAX(Payment.PaymentDate).AS("max_date"), + MAX(Payment.Amount).AS("max"), + MIN(Payment.PaymentDate).AS("min_date"), + MIN(Payment.Amount).AS("min"), + COUNT(Payment.Amount).AS("count"), + ).AS("amount"), + ).FROM( + Payment. + INNER_JOIN(Customer, Customer.CustomerID.EQ(Payment.CustomerID)), + ).GROUP_BY( + Customer.CustomerID, + ).HAVING( + SUMf(Payment.Amount).GT(Real(125)), + ).ORDER_BY( + Customer.CustomerID, SUM(Payment.Amount).ASC(), + ) + + testutils.AssertDebugStatementSql(t, stmt, ` +SELECT json_agg(row_to_json(records)) AS "json" +FROM ( + SELECT customer.customer_id AS "customerID", + customer.store_id AS "storeID", + customer.first_name AS "firstName", + customer.last_name AS "lastName", + customer.email AS "email", + customer.address_id AS "addressID", + customer.activebool AS "activebool", + customer.create_date AS "createDate", + customer.last_update AS "lastUpdate", + customer.active AS "active", + ( + SELECT row_to_json(amount_records) AS "amount_json" + FROM ( + SELECT SUM(payment.amount) AS "sum", + AVG(payment.amount) AS "avg", + MAX(payment.payment_date) AS "max_date", + MAX(payment.amount) AS "max", + MIN(payment.payment_date) AS "min_date", + MIN(payment.amount) AS "min", + COUNT(payment.amount) AS "count" + ) AS amount_records + ) AS "amount" + FROM dvds.payment + INNER JOIN dvds.customer ON (customer.customer_id = payment.customer_id) + GROUP BY customer.customer_id + HAVING SUM(payment.amount) > 125::real + ORDER BY customer.customer_id, SUM(payment.amount) ASC + ) AS records; +`) + + var dest []struct { + model.Customer + + Amount struct { + Sum float64 + Avg float64 + Max float64 + Min float64 + Count int64 + } `alias:"amount"` + } + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + + if sourceIsCockroachDB() { + return // small precision difference in result + } + + testutils.AssertJSONFile(t, dest, "./testdata/results/postgres/customer_payment_sum.json") +} + +func TestSelectQuickStartJSON(t *testing.T) { + + stmt := SELECT_JSON_ARR( + Actor.ActorID, Actor.FirstName, Actor.LastName, Actor.LastUpdate, + + SELECT_JSON_ARR( + Film.AllColumns.Except(Film.SpecialFeatures), + CAST(Film.SpecialFeatures).AS_TEXT().AS("SpecialFeatures"), + + SELECT_JSON_OBJ( + Language.AllColumns, + ).FROM( + Language, + ).WHERE( + Language.LanguageID.EQ(Film.LanguageID).AND( + Language.Name.EQ(Char(20)("English")), + ), + ).AS("Language"), + + SELECT_JSON_ARR( + Category.AllColumns, + ).FROM( + Category. + INNER_JOIN(FilmCategory, FilmCategory.CategoryID.EQ(Category.CategoryID)), + ).WHERE( + FilmCategory.FilmID.EQ(Film.FilmID).AND( + Category.Name.NOT_EQ(Text("Action")), + ), + ).AS("Categories"), + ).FROM( + Film. + INNER_JOIN(FilmActor, FilmActor.FilmID.EQ(Film.FilmID)), + ).WHERE( + FilmActor.ActorID.EQ(Actor.ActorID).AND(Film.Length.GT(Int32(180))), + ).ORDER_BY( + Film.FilmID.ASC(), + ).AS("Films"), + ).FROM( + Actor, + ).ORDER_BY( + Actor.ActorID.ASC(), + ) + + testutils.AssertDebugStatementSql(t, stmt, ` +SELECT json_agg(row_to_json(records)) AS "json" +FROM ( + SELECT actor.actor_id AS "actorID", + actor.first_name AS "firstName", + actor.last_name AS "lastName", + actor.last_update AS "lastUpdate", + ( + SELECT json_agg(row_to_json(films_records)) AS "films_json" + FROM ( + SELECT film.film_id AS "filmID", + film.title AS "title", + film.description AS "description", + film.release_year AS "releaseYear", + film.language_id AS "languageID", + film.rental_duration AS "rentalDuration", + film.rental_rate AS "rentalRate", + film.length AS "length", + film.replacement_cost AS "replacementCost", + film.rating AS "rating", + film.last_update AS "lastUpdate", + film.fulltext AS "fulltext", + film.special_features::text AS "SpecialFeatures", + ( + SELECT row_to_json(language_records) AS "language_json" + FROM ( + SELECT language.language_id AS "languageID", + language.name AS "name", + language.last_update AS "lastUpdate" + FROM dvds.language + WHERE (language.language_id = film.language_id) AND (language.name = 'English'::char(20)) + ) AS language_records + ) AS "Language", + ( + SELECT json_agg(row_to_json(categories_records)) AS "categories_json" + FROM ( + SELECT category.category_id AS "categoryID", + category.name AS "name", + category.last_update AS "lastUpdate" + FROM dvds.category + INNER JOIN dvds.film_category ON (film_category.category_id = category.category_id) + WHERE (film_category.film_id = film.film_id) AND (category.name != 'Action'::text) + ) AS categories_records + ) AS "Categories" + FROM dvds.film + INNER JOIN dvds.film_actor ON (film_actor.film_id = film.film_id) + WHERE (film_actor.actor_id = actor.actor_id) AND (film.length > 180::integer) + ORDER BY film.film_id ASC + ) AS films_records + ) AS "Films" + FROM dvds.actor + ORDER BY actor.actor_id ASC + ) AS records; +`) + + var dest []struct { + model.Actor + + Films []struct { + model.Film + + Language model.Language + Categories []model.Category + } + } + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + require.Len(t, dest, 200) + + if sourceIsCockroachDB() { + return // char[n] columns whitespaces are trimmed when returned as json in cockroachdb + } + + //testutils.SaveJSONFile(dest, "./testdata/results/postgres/quick-start-json-dest2.json") + //testutils.AssertJSONFile(t, dest, "./testdata/results/postgres/quick-start-json-dest.json") +} + +func TestSelectJsonInReturning(t *testing.T) { + + stmt := Rental. + UPDATE(Rental.ReturnDate). + MODEL(model.Rental{ + ReturnDate: ptr.Of(time.Date(2010, 2, 4, 5, 6, 7, 8, time.UTC)), + }). + WHERE( + Rental.RentalID.EQ(Int(11496)), + ). + RETURNING( + Rental.AllColumns.Except(Rental.LastUpdate), + + SELECT_JSON_OBJ( + Customer.AllColumns, + ).FROM( + Customer, + ).WHERE( + Customer.CustomerID.EQ(Rental.CustomerID), + ).AS("Customer"), + ) + + testutils.AssertStatementSql(t, stmt, ` +UPDATE dvds.rental +SET return_date = $1 +WHERE rental.rental_id = $2 +RETURNING rental.rental_id AS "rental.rental_id", + rental.rental_date AS "rental.rental_date", + rental.inventory_id AS "rental.inventory_id", + rental.customer_id AS "rental.customer_id", + rental.return_date AS "rental.return_date", + rental.staff_id AS "rental.staff_id", + ( + SELECT row_to_json(customer_records) AS "customer_json" + FROM ( + SELECT customer.customer_id AS "customerID", + customer.store_id AS "storeID", + customer.first_name AS "firstName", + customer.last_name AS "lastName", + customer.email AS "email", + customer.address_id AS "addressID", + customer.activebool AS "activebool", + customer.create_date AS "createDate", + customer.last_update AS "lastUpdate", + customer.active AS "active" + FROM dvds.customer + WHERE customer.customer_id = rental.customer_id + ) AS customer_records + ) AS "Customer"; +`) + + testutils.ExecuteInTxAndRollback(t, db, func(tx qrm.DB) { + var dest struct { + model.Rental + + Customer model.Customer `json_column:"Customer"` + } + + err := stmt.Query(tx, &dest) + require.NoError(t, err) + testutils.AssertJSON(t, dest, ` +{ + "RentalID": 11496, + "RentalDate": "2006-02-14T15:16:03Z", + "InventoryID": 2047, + "CustomerID": 155, + "ReturnDate": "2010-02-04T05:06:07Z", + "StaffID": 1, + "LastUpdate": "0001-01-01T00:00:00Z", + "Customer": { + "CustomerID": 155, + "StoreID": 1, + "FirstName": "Gail", + "LastName": "Knight", + "Email": "gail.knight@sakilacustomer.org", + "AddressID": 159, + "Activebool": true, + "CreateDate": "2006-02-14T00:00:00Z", + "LastUpdate": "2013-05-26T14:49:45.738Z", + "Active": 1 + } +} +`) + }) +} + +func TestSelectJson_FetchFirst(t *testing.T) { + stmt := SELECT_JSON_ARR(Actor.AllColumns). + FROM(Actor). + ORDER_BY(Actor.ActorID). + OFFSET(2). + FETCH_FIRST(Int(3)).ROWS_ONLY() + + testutils.AssertDebugStatementSql(t, stmt, ` +SELECT json_agg(row_to_json(records)) AS "json" +FROM ( + SELECT actor.actor_id AS "actorID", + actor.first_name AS "firstName", + actor.last_name AS "lastName", + actor.last_update AS "lastUpdate" + FROM dvds.actor + ORDER BY actor.actor_id + OFFSET 2 + FETCH FIRST 3 ROWS ONLY + ) AS records; +`) + + var dest []model.Actor + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + testutils.AssertJSON(t, dest, ` +[ + { + "ActorID": 3, + "FirstName": "Ed", + "LastName": "Chase", + "LastUpdate": "2013-05-26T14:47:57.62Z" + }, + { + "ActorID": 4, + "FirstName": "Jennifer", + "LastName": "Davis", + "LastUpdate": "2013-05-26T14:47:57.62Z" + }, + { + "ActorID": 5, + "FirstName": "Johnny", + "LastName": "Lollobrigida", + "LastUpdate": "2013-05-26T14:47:57.62Z" + } +] +`) +} + +func TestSelectJson_RowLock(t *testing.T) { + + stmt := SELECT_JSON_OBJ(Actor.AllColumns). + FROM(Actor). + WHERE(Actor.ActorID.EQ(Int(200))). + FOR(UPDATE().NOWAIT()) + + testutils.AssertDebugStatementSql(t, stmt, ` +SELECT row_to_json(records) AS "json" +FROM ( + SELECT actor.actor_id AS "actorID", + actor.first_name AS "firstName", + actor.last_name AS "lastName", + actor.last_update AS "lastUpdate" + FROM dvds.actor + WHERE actor.actor_id = 200 + FOR UPDATE NOWAIT + ) AS records; +`) + + testutils.ExecuteInTxAndRollback(t, db, func(tx qrm.DB) { + var dest model.Actor + + err := stmt.QueryJSON(ctx, tx, &dest) + require.NoError(t, err) + testutils.AssertJSON(t, dest, ` +{ + "ActorID": 200, + "FirstName": "Thora", + "LastName": "Temple", + "LastUpdate": "2013-05-26T14:47:57.62Z" +} +`) + }) + +} + +func TestSelectJson_UNION(t *testing.T) { + + stmt := UNION_ALL( + SELECT_JSON_OBJ(Actor.AllColumns). + FROM(Actor). + WHERE(Actor.ActorID.EQ(Int(20))), + + SELECT_JSON_OBJ(Actor.AllColumns). + FROM(Actor). + WHERE(Actor.ActorID.EQ(Int(21))), + ) + + testutils.AssertDebugStatementSql(t, stmt, ` +( + SELECT row_to_json(records) AS "json" + FROM ( + SELECT actor.actor_id AS "actorID", + actor.first_name AS "firstName", + actor.last_name AS "lastName", + actor.last_update AS "lastUpdate" + FROM dvds.actor + WHERE actor.actor_id = 20 + ) AS records +) +UNION ALL +( + SELECT row_to_json(records) AS "json" + FROM ( + SELECT actor.actor_id AS "actorID", + actor.first_name AS "firstName", + actor.last_name AS "lastName", + actor.last_update AS "lastUpdate" + FROM dvds.actor + WHERE actor.actor_id = 21 + ) AS records +); +`) + + var dest []struct { + model.Actor `json_column:"json"` + } + + err := stmt.Query(db, &dest) + require.NoError(t, err) + testutils.AssertJSON(t, dest, ` +[ + { + "ActorID": 20, + "FirstName": "Lucille", + "LastName": "Tracy", + "LastUpdate": "2013-05-26T14:47:57.62Z" + }, + { + "ActorID": 21, + "FirstName": "Kirsten", + "LastName": "Paltrow", + "LastUpdate": "2013-05-26T14:47:57.62Z" + } +] +`) +} + +func TestSelectJson_Window(t *testing.T) { + stmt := SELECT_JSON_ARR( + AVG(Payment.Amount).OVER().AS("avgOver"), + AVG(Payment.Amount).OVER(Window("w1")).AS("avgOverW1"), + AVG(Payment.Amount).OVER( + Window("w2"). + ORDER_BY(Payment.CustomerID). + RANGE(PRECEDING(UNBOUNDED), FOLLOWING(UNBOUNDED)), + ).AS("avgOverW2"), + AVG(Payment.Amount).OVER(Window("w3").RANGE(PRECEDING(UNBOUNDED), FOLLOWING(UNBOUNDED))).AS("avgOverW3"), + ).FROM( + Payment, + ).WINDOW("w1").AS(PARTITION_BY(Payment.PaymentDate)). + WINDOW("w2").AS(Window("w1")). + WINDOW("w3").AS(Window("w2").ORDER_BY(Payment.CustomerID)). + ORDER_BY(Payment.CustomerID). + LIMIT(4) + + testutils.AssertDebugStatementSql(t, stmt, ` +SELECT json_agg(row_to_json(records)) AS "json" +FROM ( + SELECT AVG(payment.amount) OVER () AS "avgOver", + AVG(payment.amount) OVER (w1) AS "avgOverW1", + AVG(payment.amount) OVER (w2 ORDER BY payment.customer_id RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS "avgOverW2", + AVG(payment.amount) OVER (w3 RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS "avgOverW3" + FROM dvds.payment + WINDOW w1 AS (PARTITION BY payment.payment_date), w2 AS (w1), w3 AS (w2 ORDER BY payment.customer_id) + ORDER BY payment.customer_id + LIMIT 4 + ) AS records; +`) + + var dest []struct { + AvgOver float64 + AvgOverW1 float64 + AvgOverW2 float64 + AvgOverW3 float64 + } + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) +} + +func TestSelectJson_QueryWithoutUnMarshaling(t *testing.T) { + stmt := SELECT_JSON_ARR( + view.CustomerList.AllColumns, + + SELECT_JSON_ARR(Rental.AllColumns). + FROM(Rental). + WHERE(view.CustomerList.ID.EQ(Rental.CustomerID)). + ORDER_BY(Rental.CustomerID). + AS("Rentals"), + ).FROM( + view.CustomerList, + ).WHERE( + view.CustomerList.ID.LT_EQ(Int(2)), + ).ORDER_BY( + view.CustomerList.ID, + ) + + //fmt.Println(stmt.DebugSql()) + + testutils.AssertDebugStatementSql(t, stmt, ` +SELECT json_agg(row_to_json(records)) AS "json" +FROM ( + SELECT customer_list.id AS "id", + customer_list.name AS "name", + customer_list.address AS "address", + customer_list."zip code" AS "zip code", + customer_list.phone AS "phone", + customer_list.city AS "city", + customer_list.country AS "country", + customer_list.notes AS "notes", + customer_list.sid AS "sid", + ( + SELECT json_agg(row_to_json(rentals_records)) AS "rentals_json" + FROM ( + SELECT rental.rental_id AS "rentalID", + rental.rental_date AS "rentalDate", + rental.inventory_id AS "inventoryID", + rental.customer_id AS "customerID", + rental.return_date AS "returnDate", + rental.staff_id AS "staffID", + rental.last_update AS "lastUpdate" + FROM dvds.rental + WHERE customer_list.id = rental.customer_id + ORDER BY rental.customer_id + ) AS rentals_records + ) AS "Rentals" + FROM dvds.customer_list + WHERE customer_list.id <= 2 + ORDER BY customer_list.id + ) AS records; +`) + + var dest struct { + Json []byte + } + + err := stmt.Query(db, &dest) + require.NoError(t, err) + + if sourceIsCockroachDB() { + require.Equal(t, string(dest.Json), `[{"Rentals": [{"customerID": 1, "inventoryID": 3021, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-05-25T11:30:37", "rentalID": 76, "returnDate": "2005-06-03T12:00:37", "staffID": 2}, {"customerID": 1, "inventoryID": 4020, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-05-28T10:35:23", "rentalID": 573, "returnDate": "2005-06-03T06:32:23", "staffID": 1}, {"customerID": 1, "inventoryID": 2785, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-06-15T00:54:12", "rentalID": 1185, "returnDate": "2005-06-23T02:42:12", "staffID": 2}, {"customerID": 1, "inventoryID": 1021, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-06-15T18:02:53", "rentalID": 1422, "returnDate": "2005-06-19T15:54:53", "staffID": 2}, {"customerID": 1, "inventoryID": 1407, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-06-15T21:08:46", "rentalID": 1476, "returnDate": "2005-06-25T02:26:46", "staffID": 1}, {"customerID": 1, "inventoryID": 726, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-06-16T15:18:57", "rentalID": 1725, "returnDate": "2005-06-17T21:05:57", "staffID": 1}, {"customerID": 1, "inventoryID": 197, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-06-18T08:41:48", "rentalID": 2308, "returnDate": "2005-06-22T03:36:48", "staffID": 2}, {"customerID": 1, "inventoryID": 3497, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-06-18T13:33:59", "rentalID": 2363, "returnDate": "2005-06-19T17:40:59", "staffID": 1}, {"customerID": 1, "inventoryID": 4566, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-06-21T06:24:45", "rentalID": 3284, "returnDate": "2005-06-28T03:28:45", "staffID": 1}, {"customerID": 1, "inventoryID": 1443, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-08T03:17:05", "rentalID": 4526, "returnDate": "2005-07-14T01:19:05", "staffID": 2}, {"customerID": 1, "inventoryID": 3486, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-08T07:33:56", "rentalID": 4611, "returnDate": "2005-07-12T13:25:56", "staffID": 2}, {"customerID": 1, "inventoryID": 3726, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-09T13:24:07", "rentalID": 5244, "returnDate": "2005-07-14T14:01:07", "staffID": 2}, {"customerID": 1, "inventoryID": 797, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-09T16:38:01", "rentalID": 5326, "returnDate": "2005-07-13T18:02:01", "staffID": 1}, {"customerID": 1, "inventoryID": 1330, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-11T10:13:46", "rentalID": 6163, "returnDate": "2005-07-19T13:15:46", "staffID": 2}, {"customerID": 1, "inventoryID": 2465, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-27T11:31:22", "rentalID": 7273, "returnDate": "2005-07-31T06:50:22", "staffID": 1}, {"customerID": 1, "inventoryID": 1092, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-28T09:04:45", "rentalID": 7841, "returnDate": "2005-07-30T12:37:45", "staffID": 2}, {"customerID": 1, "inventoryID": 4268, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-28T16:18:23", "rentalID": 8033, "returnDate": "2005-07-30T17:56:23", "staffID": 1}, {"customerID": 1, "inventoryID": 1558, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-28T17:33:39", "rentalID": 8074, "returnDate": "2005-07-29T20:17:39", "staffID": 1}, {"customerID": 1, "inventoryID": 4497, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-28T19:20:07", "rentalID": 8116, "returnDate": "2005-07-29T22:54:07", "staffID": 1}, {"customerID": 1, "inventoryID": 108, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-29T03:58:49", "rentalID": 8326, "returnDate": "2005-08-01T05:16:49", "staffID": 2}, {"customerID": 1, "inventoryID": 2219, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-31T02:42:18", "rentalID": 9571, "returnDate": "2005-08-02T23:26:18", "staffID": 2}, {"customerID": 1, "inventoryID": 14, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-01T08:51:04", "rentalID": 10437, "returnDate": "2005-08-10T12:12:04", "staffID": 1}, {"customerID": 1, "inventoryID": 3232, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-02T15:36:52", "rentalID": 11299, "returnDate": "2005-08-10T16:40:52", "staffID": 2}, {"customerID": 1, "inventoryID": 1440, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-02T18:01:38", "rentalID": 11367, "returnDate": "2005-08-04T13:19:38", "staffID": 1}, {"customerID": 1, "inventoryID": 2639, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-17T12:37:54", "rentalID": 11824, "returnDate": "2005-08-19T10:11:54", "staffID": 2}, {"customerID": 1, "inventoryID": 921, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-18T03:57:29", "rentalID": 12250, "returnDate": "2005-08-22T23:05:29", "staffID": 1}, {"customerID": 1, "inventoryID": 3019, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-19T09:55:16", "rentalID": 13068, "returnDate": "2005-08-20T14:44:16", "staffID": 2}, {"customerID": 1, "inventoryID": 2269, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-19T13:56:54", "rentalID": 13176, "returnDate": "2005-08-23T08:50:54", "staffID": 2}, {"customerID": 1, "inventoryID": 4249, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-21T23:33:57", "rentalID": 14762, "returnDate": "2005-08-23T01:30:57", "staffID": 1}, {"customerID": 1, "inventoryID": 1449, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-22T01:27:57", "rentalID": 14825, "returnDate": "2005-08-27T07:01:57", "staffID": 2}, {"customerID": 1, "inventoryID": 1446, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-22T19:41:37", "rentalID": 15298, "returnDate": "2005-08-28T22:49:37", "staffID": 1}, {"customerID": 1, "inventoryID": 312, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-22T20:03:46", "rentalID": 15315, "returnDate": "2005-08-30T01:51:46", "staffID": 2}], "address": "1913 Hanoi Way", "city": "Sasebo", "country": "Japan", "id": 1, "name": "Mary Smith", "notes": "active", "phone": "28303384290", "sid": 1, "zip code": "35200"}, {"Rentals": [{"customerID": 2, "inventoryID": 1090, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-05-27T00:09:24", "rentalID": 320, "returnDate": "2005-05-28T04:30:24", "staffID": 2}, {"customerID": 2, "inventoryID": 352, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-06-17T20:54:58", "rentalID": 2128, "returnDate": "2005-06-24T00:41:58", "staffID": 2}, {"customerID": 2, "inventoryID": 4116, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-10T06:31:24", "rentalID": 5636, "returnDate": "2005-07-13T02:36:24", "staffID": 1}, {"customerID": 2, "inventoryID": 2760, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-10T12:38:56", "rentalID": 5755, "returnDate": "2005-07-19T17:02:56", "staffID": 1}, {"customerID": 2, "inventoryID": 741, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-27T14:30:42", "rentalID": 7346, "returnDate": "2005-08-02T16:48:42", "staffID": 1}, {"customerID": 2, "inventoryID": 488, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-27T15:23:02", "rentalID": 7376, "returnDate": "2005-08-04T10:35:02", "staffID": 2}, {"customerID": 2, "inventoryID": 2053, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-27T18:40:20", "rentalID": 7459, "returnDate": "2005-08-02T21:07:20", "staffID": 2}, {"customerID": 2, "inventoryID": 1937, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-29T00:12:59", "rentalID": 8230, "returnDate": "2005-08-06T19:52:59", "staffID": 2}, {"customerID": 2, "inventoryID": 626, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-29T12:56:59", "rentalID": 8598, "returnDate": "2005-08-01T08:39:59", "staffID": 2}, {"customerID": 2, "inventoryID": 4038, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-29T17:14:29", "rentalID": 8705, "returnDate": "2005-08-02T16:01:29", "staffID": 1}, {"customerID": 2, "inventoryID": 2377, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-30T06:06:10", "rentalID": 9031, "returnDate": "2005-08-04T10:45:10", "staffID": 2}, {"customerID": 2, "inventoryID": 4030, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-30T13:47:43", "rentalID": 9236, "returnDate": "2005-08-08T18:52:43", "staffID": 1}, {"customerID": 2, "inventoryID": 1382, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-30T14:14:11", "rentalID": 9248, "returnDate": "2005-08-05T11:19:11", "staffID": 1}, {"customerID": 2, "inventoryID": 4088, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-30T16:21:13", "rentalID": 9296, "returnDate": "2005-08-08T11:57:13", "staffID": 1}, {"customerID": 2, "inventoryID": 3084, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-30T22:39:53", "rentalID": 9465, "returnDate": "2005-08-06T16:43:53", "staffID": 2}, {"customerID": 2, "inventoryID": 3142, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-07-31T21:58:56", "rentalID": 10136, "returnDate": "2005-08-03T19:44:56", "staffID": 1}, {"customerID": 2, "inventoryID": 138, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-01T09:45:26", "rentalID": 10466, "returnDate": "2005-08-06T06:28:26", "staffID": 1}, {"customerID": 2, "inventoryID": 3418, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-02T02:10:56", "rentalID": 10918, "returnDate": "2005-08-02T21:23:56", "staffID": 1}, {"customerID": 2, "inventoryID": 654, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-02T07:41:41", "rentalID": 11087, "returnDate": "2005-08-10T10:37:41", "staffID": 2}, {"customerID": 2, "inventoryID": 1149, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-02T10:43:48", "rentalID": 11177, "returnDate": "2005-08-10T10:55:48", "staffID": 2}, {"customerID": 2, "inventoryID": 2060, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-02T13:44:53", "rentalID": 11256, "returnDate": "2005-08-04T16:39:53", "staffID": 1}, {"customerID": 2, "inventoryID": 805, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-17T03:52:18", "rentalID": 11614, "returnDate": "2005-08-20T07:04:18", "staffID": 1}, {"customerID": 2, "inventoryID": 1521, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-19T06:26:04", "rentalID": 12963, "returnDate": "2005-08-23T11:37:04", "staffID": 2}, {"customerID": 2, "inventoryID": 3164, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-21T13:24:32", "rentalID": 14475, "returnDate": "2005-08-27T08:59:32", "staffID": 2}, {"customerID": 2, "inventoryID": 4570, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-21T22:41:56", "rentalID": 14743, "returnDate": "2005-08-29T00:18:56", "staffID": 1}, {"customerID": 2, "inventoryID": 2179, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-22T13:53:04", "rentalID": 15145, "returnDate": "2005-08-31T15:51:04", "staffID": 1}, {"customerID": 2, "inventoryID": 2898, "lastUpdate": "2006-02-16T02:30:53", "rentalDate": "2005-08-23T17:39:35", "rentalID": 15907, "returnDate": "2005-08-25T23:23:35", "staffID": 1}], "address": "1121 Loja Avenue", "city": "San Bernardino", "country": "United States", "id": 2, "name": "Patricia Johnson", "notes": "active", "phone": "838635286649", "sid": 1, "zip code": "17886"}]`) + } else { + require.Equal(t, string(dest.Json), `[{"id":1,"name":"Mary Smith","address":"1913 Hanoi Way","zip code":"35200","phone":"28303384290","city":"Sasebo","country":"Japan","notes":"active","sid":1,"Rentals":[{"rentalID":76,"rentalDate":"2005-05-25T11:30:37","inventoryID":3021,"customerID":1,"returnDate":"2005-06-03T12:00:37","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":573,"rentalDate":"2005-05-28T10:35:23","inventoryID":4020,"customerID":1,"returnDate":"2005-06-03T06:32:23","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":1185,"rentalDate":"2005-06-15T00:54:12","inventoryID":2785,"customerID":1,"returnDate":"2005-06-23T02:42:12","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":1422,"rentalDate":"2005-06-15T18:02:53","inventoryID":1021,"customerID":1,"returnDate":"2005-06-19T15:54:53","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":1476,"rentalDate":"2005-06-15T21:08:46","inventoryID":1407,"customerID":1,"returnDate":"2005-06-25T02:26:46","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":1725,"rentalDate":"2005-06-16T15:18:57","inventoryID":726,"customerID":1,"returnDate":"2005-06-17T21:05:57","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":2308,"rentalDate":"2005-06-18T08:41:48","inventoryID":197,"customerID":1,"returnDate":"2005-06-22T03:36:48","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":2363,"rentalDate":"2005-06-18T13:33:59","inventoryID":3497,"customerID":1,"returnDate":"2005-06-19T17:40:59","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":3284,"rentalDate":"2005-06-21T06:24:45","inventoryID":4566,"customerID":1,"returnDate":"2005-06-28T03:28:45","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":4526,"rentalDate":"2005-07-08T03:17:05","inventoryID":1443,"customerID":1,"returnDate":"2005-07-14T01:19:05","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":4611,"rentalDate":"2005-07-08T07:33:56","inventoryID":3486,"customerID":1,"returnDate":"2005-07-12T13:25:56","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":5244,"rentalDate":"2005-07-09T13:24:07","inventoryID":3726,"customerID":1,"returnDate":"2005-07-14T14:01:07","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":5326,"rentalDate":"2005-07-09T16:38:01","inventoryID":797,"customerID":1,"returnDate":"2005-07-13T18:02:01","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":6163,"rentalDate":"2005-07-11T10:13:46","inventoryID":1330,"customerID":1,"returnDate":"2005-07-19T13:15:46","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":7273,"rentalDate":"2005-07-27T11:31:22","inventoryID":2465,"customerID":1,"returnDate":"2005-07-31T06:50:22","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":7841,"rentalDate":"2005-07-28T09:04:45","inventoryID":1092,"customerID":1,"returnDate":"2005-07-30T12:37:45","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":8033,"rentalDate":"2005-07-28T16:18:23","inventoryID":4268,"customerID":1,"returnDate":"2005-07-30T17:56:23","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":8074,"rentalDate":"2005-07-28T17:33:39","inventoryID":1558,"customerID":1,"returnDate":"2005-07-29T20:17:39","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":8116,"rentalDate":"2005-07-28T19:20:07","inventoryID":4497,"customerID":1,"returnDate":"2005-07-29T22:54:07","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":8326,"rentalDate":"2005-07-29T03:58:49","inventoryID":108,"customerID":1,"returnDate":"2005-08-01T05:16:49","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":9571,"rentalDate":"2005-07-31T02:42:18","inventoryID":2219,"customerID":1,"returnDate":"2005-08-02T23:26:18","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":10437,"rentalDate":"2005-08-01T08:51:04","inventoryID":14,"customerID":1,"returnDate":"2005-08-10T12:12:04","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":11299,"rentalDate":"2005-08-02T15:36:52","inventoryID":3232,"customerID":1,"returnDate":"2005-08-10T16:40:52","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":11367,"rentalDate":"2005-08-02T18:01:38","inventoryID":1440,"customerID":1,"returnDate":"2005-08-04T13:19:38","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":11824,"rentalDate":"2005-08-17T12:37:54","inventoryID":2639,"customerID":1,"returnDate":"2005-08-19T10:11:54","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":12250,"rentalDate":"2005-08-18T03:57:29","inventoryID":921,"customerID":1,"returnDate":"2005-08-22T23:05:29","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":13068,"rentalDate":"2005-08-19T09:55:16","inventoryID":3019,"customerID":1,"returnDate":"2005-08-20T14:44:16","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":13176,"rentalDate":"2005-08-19T13:56:54","inventoryID":2269,"customerID":1,"returnDate":"2005-08-23T08:50:54","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":14762,"rentalDate":"2005-08-21T23:33:57","inventoryID":4249,"customerID":1,"returnDate":"2005-08-23T01:30:57","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":14825,"rentalDate":"2005-08-22T01:27:57","inventoryID":1449,"customerID":1,"returnDate":"2005-08-27T07:01:57","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":15298,"rentalDate":"2005-08-22T19:41:37","inventoryID":1446,"customerID":1,"returnDate":"2005-08-28T22:49:37","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":15315,"rentalDate":"2005-08-22T20:03:46","inventoryID":312,"customerID":1,"returnDate":"2005-08-30T01:51:46","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}]}, {"id":2,"name":"Patricia Johnson","address":"1121 Loja Avenue","zip code":"17886","phone":"838635286649","city":"San Bernardino","country":"United States","notes":"active","sid":1,"Rentals":[{"rentalID":320,"rentalDate":"2005-05-27T00:09:24","inventoryID":1090,"customerID":2,"returnDate":"2005-05-28T04:30:24","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":2128,"rentalDate":"2005-06-17T20:54:58","inventoryID":352,"customerID":2,"returnDate":"2005-06-24T00:41:58","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":5636,"rentalDate":"2005-07-10T06:31:24","inventoryID":4116,"customerID":2,"returnDate":"2005-07-13T02:36:24","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":5755,"rentalDate":"2005-07-10T12:38:56","inventoryID":2760,"customerID":2,"returnDate":"2005-07-19T17:02:56","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":7346,"rentalDate":"2005-07-27T14:30:42","inventoryID":741,"customerID":2,"returnDate":"2005-08-02T16:48:42","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":7376,"rentalDate":"2005-07-27T15:23:02","inventoryID":488,"customerID":2,"returnDate":"2005-08-04T10:35:02","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":7459,"rentalDate":"2005-07-27T18:40:20","inventoryID":2053,"customerID":2,"returnDate":"2005-08-02T21:07:20","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":8230,"rentalDate":"2005-07-29T00:12:59","inventoryID":1937,"customerID":2,"returnDate":"2005-08-06T19:52:59","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":8598,"rentalDate":"2005-07-29T12:56:59","inventoryID":626,"customerID":2,"returnDate":"2005-08-01T08:39:59","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":8705,"rentalDate":"2005-07-29T17:14:29","inventoryID":4038,"customerID":2,"returnDate":"2005-08-02T16:01:29","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":9031,"rentalDate":"2005-07-30T06:06:10","inventoryID":2377,"customerID":2,"returnDate":"2005-08-04T10:45:10","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":9236,"rentalDate":"2005-07-30T13:47:43","inventoryID":4030,"customerID":2,"returnDate":"2005-08-08T18:52:43","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":9248,"rentalDate":"2005-07-30T14:14:11","inventoryID":1382,"customerID":2,"returnDate":"2005-08-05T11:19:11","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":9296,"rentalDate":"2005-07-30T16:21:13","inventoryID":4088,"customerID":2,"returnDate":"2005-08-08T11:57:13","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":9465,"rentalDate":"2005-07-30T22:39:53","inventoryID":3084,"customerID":2,"returnDate":"2005-08-06T16:43:53","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":10136,"rentalDate":"2005-07-31T21:58:56","inventoryID":3142,"customerID":2,"returnDate":"2005-08-03T19:44:56","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":10466,"rentalDate":"2005-08-01T09:45:26","inventoryID":138,"customerID":2,"returnDate":"2005-08-06T06:28:26","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":10918,"rentalDate":"2005-08-02T02:10:56","inventoryID":3418,"customerID":2,"returnDate":"2005-08-02T21:23:56","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":11087,"rentalDate":"2005-08-02T07:41:41","inventoryID":654,"customerID":2,"returnDate":"2005-08-10T10:37:41","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":11177,"rentalDate":"2005-08-02T10:43:48","inventoryID":1149,"customerID":2,"returnDate":"2005-08-10T10:55:48","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":11256,"rentalDate":"2005-08-02T13:44:53","inventoryID":2060,"customerID":2,"returnDate":"2005-08-04T16:39:53","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":11614,"rentalDate":"2005-08-17T03:52:18","inventoryID":805,"customerID":2,"returnDate":"2005-08-20T07:04:18","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":12963,"rentalDate":"2005-08-19T06:26:04","inventoryID":1521,"customerID":2,"returnDate":"2005-08-23T11:37:04","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":14475,"rentalDate":"2005-08-21T13:24:32","inventoryID":3164,"customerID":2,"returnDate":"2005-08-27T08:59:32","staffID":2,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":14743,"rentalDate":"2005-08-21T22:41:56","inventoryID":4570,"customerID":2,"returnDate":"2005-08-29T00:18:56","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":15145,"rentalDate":"2005-08-22T13:53:04","inventoryID":2179,"customerID":2,"returnDate":"2005-08-31T15:51:04","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}, {"rentalID":15907,"rentalDate":"2005-08-23T17:39:35","inventoryID":2898,"customerID":2,"returnDate":"2005-08-25T23:23:35","staffID":1,"lastUpdate":"2006-02-16T02:30:53"}]}]`) + } +} + +func TestSelectJsonObject_EmptyResult(t *testing.T) { + t.Run("json obj", func(t *testing.T) { + stmt := SELECT_JSON_OBJ(Actor.AllColumns). + FROM(Actor). + WHERE(Actor.FirstName.EQ(Text("Kowalski"))) + + var dest model.Actor + + err := stmt.QueryJSON(ctx, db, &dest) + require.ErrorIs(t, err, qrm.ErrNoRows) + }) + + t.Run("json arr", func(t *testing.T) { + stmt := SELECT_JSON_ARR(Actor.AllColumns). + FROM(Actor). + WHERE(Actor.FirstName.EQ(Text("Kowalski"))) + + var dest []model.Actor + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + require.Empty(t, dest) + }) +} + +func TestSelectJson_InvalidDestination(t *testing.T) { + t.Run("json obj", func(t *testing.T) { + stmt := SELECT_JSON_OBJ(Actor.AllColumns). + FROM(Actor) + + testutils.AssertQueryJsonPanicErr(t, stmt, db, &[]model.Actor{}, "jet: destination has to be a pointer to struct or pointer to map[string]any") + testutils.AssertQueryJsonPanicErr(t, stmt, db, model.Actor{}, "jet: destination has to be a pointer to struct or pointer to map[string]any") + testutils.AssertQueryJsonPanicErr(t, stmt, nil, &model.Actor{}, "jet: db is nil") + testutils.AssertQueryJsonPanicErr(t, stmt, db, nil, "jet: destination is nil") + }) + + t.Run("json arr", func(t *testing.T) { + stmt := SELECT_JSON_ARR(Actor.AllColumns). + FROM(Actor) + + testutils.AssertQueryJsonPanicErr(t, stmt, db, &model.Actor{}, "jet: destination has to be a pointer to slice of struct or pointer to []map[string]any") + testutils.AssertQueryJsonPanicErr(t, stmt, db, []model.Actor{}, "jet: destination has to be a pointer to slice of struct or pointer to []map[string]any") + testutils.AssertQueryJsonPanicErr(t, stmt, nil, &[]model.Actor{}, "jet: db is nil") + testutils.AssertQueryJsonPanicErr(t, stmt, db, nil, "jet: destination is nil") + }) +} + +func TestSelectJson_ProjectionNotAliased(t *testing.T) { + t.Run("statement not aliased", func(t *testing.T) { + testutils.AssertPanicErr(t, func() { + stmt := SELECT_JSON_ARR( + Customer.AllColumns, + + SELECT_JSON_ARR(Rental.AllColumns). + FROM(Rental). + WHERE(Rental.CustomerID.EQ(Customer.CustomerID)), + ).FROM(Customer) + + stmt.DebugSql() + + }, "jet: SELECT JSON statements need to be aliased when used as a projection.") + }) + + t.Run("expression not aliased", func(t *testing.T) { + testutils.AssertPanicErr(t, func() { + stmt := SELECT_JSON_ARR( + Int(2).ADD(Customer.CustomerID), + ).FROM(Customer) + + stmt.DebugSql() + + }, "jet: expression need to be aliased when used as SELECT JSON projection.") + }) +} + +func TestSelectJson_MoreThenOneRowErr(t *testing.T) { + actors := SELECT_JSON_ARR(Actor.AllColumns). + FROM(Actor). + WHERE(Actor.ActorID.BETWEEN(Int(20), Int(30))) + + stmt := UNION_ALL(actors, actors) + + var dest []model.Actor + + err := stmt.QueryJSON(ctx, db, &dest) + require.ErrorContains(t, err, "jet: query returned more then one row") +} diff --git a/tests/postgres/select_test.go b/tests/postgres/select_test.go index d39987d..ca46fc7 100644 --- a/tests/postgres/select_test.go +++ b/tests/postgres/select_test.go @@ -21,36 +21,36 @@ import ( ) func TestSelect_ScanToStruct(t *testing.T) { - expectedSQL := ` + + t.Run("standard", func(t *testing.T) { + stmt := SELECT(Actor.AllColumns). + DISTINCT(). + FROM(Actor). + WHERE(Actor.ActorID.EQ(Int(2))) + + testutils.AssertDebugStatementSql(t, stmt, ` SELECT DISTINCT actor.actor_id AS "actor.actor_id", actor.first_name AS "actor.first_name", actor.last_name AS "actor.last_name", actor.last_update AS "actor.last_update" FROM dvds.actor WHERE actor.actor_id = 2; -` +`, int64(2)) - query := SELECT(Actor.AllColumns). - DISTINCT(). - FROM(Actor). - WHERE(Actor.ActorID.EQ(Int(2))) + var dest model.Actor + err := stmt.Query(db, &dest) - testutils.AssertDebugStatementSql(t, query, expectedSQL, int64(2)) + require.NoError(t, err) + testutils.AssertDeepEqual(t, dest, actor2) + requireLogged(t, stmt) + }) +} - actor := model.Actor{} - err := query.Query(db, &actor) - - require.NoError(t, err) - - expectedActor := model.Actor{ - ActorID: 2, - FirstName: "Nick", - LastName: "Wahlberg", - LastUpdate: *testutils.TimestampWithoutTimeZone("2013-05-26 14:47:57.62", 2), - } - - testutils.AssertDeepEqual(t, actor, expectedActor) - requireLogged(t, query) +var actor2 = model.Actor{ + ActorID: 2, + FirstName: "Nick", + LastName: "Wahlberg", + LastUpdate: *testutils.TimestampWithoutTimeZone("2013-05-26 14:47:57.62", 2), } func TestSelectDistinctOn(t *testing.T) { @@ -85,7 +85,6 @@ ORDER BY rental.staff_id ASC, rental.customer_id ASC, rental.rental_id ASC; err := stmt.Query(db, &dest) require.NoError(t, err) - testutils.AssertJSON(t, dest, ` [ { @@ -187,6 +186,21 @@ ORDER BY customer.customer_id ASC; testutils.AssertDeepEqual(t, lastCustomer, customers[598]) requireLogged(t, query) + + t.Run("select json", func(t *testing.T) { + stmt := SELECT_JSON_ARR( + Customer.AllColumns, + ).FROM( + Customer, + ).ORDER_BY(Customer.CustomerID.ASC()) + + var dest []model.Customer + + err := stmt.QueryJSON(ctx, db, &dest) + require.NoError(t, err) + + testutils.AssertDeepEqual(t, customers, dest) + }) } func TestSelectAndUnionInProjection(t *testing.T) { @@ -217,15 +231,14 @@ FROM dvds.payment LIMIT 12; ` - query := Payment. - SELECT( - Payment.PaymentID, - Customer.SELECT(Customer.CustomerID).LIMIT(1), - UNION( - Payment.SELECT(Payment.PaymentID).LIMIT(1).OFFSET(10), - Payment.SELECT(Payment.PaymentID).LIMIT(1).OFFSET(2), - ).LIMIT(1), - ). + query := SELECT( + Payment.PaymentID, + Customer.SELECT(Customer.CustomerID).LIMIT(1), + UNION( + Payment.SELECT(Payment.PaymentID).LIMIT(1).OFFSET(10), + Payment.SELECT(Payment.PaymentID).LIMIT(1).OFFSET(2), + ).LIMIT(1), + ).FROM(Payment). LIMIT(12) //fmt.Println(query.DebugSql()) @@ -2771,7 +2784,8 @@ ORDER BY actor.actor_id ASC, film.film_id ASC; err := stmt.Query(db, &dest) require.NoError(t, err) - //jsonSave("./testdata/quick-start-dest.json", dest) + //testutils.SaveJSONFile(dest, "./testdata/results/postgres/quick-start-dest.json") + testutils.AssertJSONFile(t, dest, "./testdata/results/postgres/quick-start-dest.json") var dest2 []struct { @@ -2784,7 +2798,7 @@ ORDER BY actor.actor_id ASC, film.film_id ASC; err = stmt.Query(db, &dest2) require.NoError(t, err) - //jsonSave("./testdata/quick-start-dest2.json", dest2) + //testutils.SaveJSONFile(dest2, "./testdata/results/postgres/quick-start-dest2.json") testutils.AssertJSONFile(t, dest2, "./testdata/results/postgres/quick-start-dest2.json") } @@ -2966,7 +2980,7 @@ WHERE payment.payment_id < $1 WINDOW w1 AS (PARTITION BY payment.payment_date), w2 AS (w1), w3 AS (w2 ORDER BY payment.customer_id) ORDER BY payment.customer_id; ` - query := Payment.SELECT( + query := SELECT( AVG(Payment.Amount).OVER(), AVG(Payment.Amount).OVER(Window("w1")), AVG(Payment.Amount).OVER( @@ -2976,6 +2990,7 @@ ORDER BY payment.customer_id; ), AVG(Payment.Amount).OVER(Window("w3").RANGE(PRECEDING(UNBOUNDED), FOLLOWING(UNBOUNDED))), ). + FROM(Payment). WHERE(Payment.PaymentID.LT(Int(10))). WINDOW("w1").AS(PARTITION_BY(Payment.PaymentDate)). WINDOW("w2").AS(Window("w1")). diff --git a/tests/testdata b/tests/testdata index 1c501ac..0997c82 160000 --- a/tests/testdata +++ b/tests/testdata @@ -1 +1 @@ -Subproject commit 1c501acb72bea389788404988ef0130b733f9cee +Subproject commit 0997c825e6569fc49b69ffbef959eadab9013e00