diff --git a/generator/metadata/column_meta_data.go b/generator/metadata/column_meta_data.go index ecd61e2..5b6ce8a 100644 --- a/generator/metadata/column_meta_data.go +++ b/generator/metadata/column_meta_data.go @@ -28,4 +28,5 @@ type DataType struct { Name string Kind DataTypeKind IsUnsigned bool + Dimensions int // The number of array dimensions } diff --git a/generator/postgres/query_set.go b/generator/postgres/query_set.go index fc4135a..a60c01a 100644 --- a/generator/postgres/query_set.go +++ b/generator/postgres/query_set.go @@ -66,6 +66,7 @@ select not attr.attnotnull as "column.isNullable", attr.attgenerated = 's' as "column.isGenerated", attr.atthasdef as "column.hasDefault", + attr.attndims as "dataType.dimensions", (case when tp.typtype = 'b' AND tp.typcategory <> 'A' then 'base' when tp.typtype = 'b' AND tp.typcategory = 'A' then 'array' diff --git a/generator/template/model_template.go b/generator/template/model_template.go index cd5c5f1..b941c05 100644 --- a/generator/template/model_template.go +++ b/generator/template/model_template.go @@ -2,16 +2,15 @@ package template import ( "fmt" + "github.com/go-jet/jet/v2/generator/metadata" + "github.com/go-jet/jet/v2/internal/utils/dbidentifier" + "github.com/google/uuid" + "github.com/jackc/pgtype" + "github.com/lib/pq" "path/filepath" "reflect" "strings" "time" - - "github.com/google/uuid" - "github.com/jackc/pgtype" - - "github.com/go-jet/jet/v2/generator/metadata" - "github.com/go-jet/jet/v2/internal/utils/dbidentifier" ) // Model is template for model files generation @@ -260,7 +259,7 @@ func getUserDefinedType(column metadata.Column) string { switch column.DataType.Kind { case metadata.EnumType: return dbidentifier.ToGoIdentifier(column.DataType.Name) - case metadata.UserDefinedType, metadata.ArrayType: + case metadata.UserDefinedType: return "string" } @@ -279,6 +278,11 @@ func getGoType(column metadata.Column) interface{} { // toGoType returns model type for column info. func toGoType(column metadata.Column) interface{} { + // We don't support multi-dimensional arrays + if column.DataType.Dimensions > 1 { + return "" + } + switch strings.ToLower(column.DataType.Name) { case "user-defined", "enum": return "" @@ -344,6 +348,14 @@ func toGoType(column metadata.Column) interface{} { return pgtype.Int8range{} case "numrange": return pgtype.Numrange{} + case "bool[]": + return pq.BoolArray{} + case "integer[]", "int4[]": + return pq.Int32Array{} + case "bigint[]": + return pq.Int64Array{} + case "text[]", "jsonb[]", "json[]": + return pq.StringArray{} default: fmt.Println("- [Model ] Unsupported sql column '" + column.Name + " " + column.DataType.Name + "', using string instead.") return "" diff --git a/generator/template/sql_builder_template.go b/generator/template/sql_builder_template.go index a06faed..6b56b0f 100644 --- a/generator/template/sql_builder_template.go +++ b/generator/template/sql_builder_template.go @@ -163,11 +163,42 @@ func DefaultTableSQLBuilderColumn(columnMetaData metadata.Column) TableSQLBuilde // getSqlBuilderColumnType returns type of jet sql builder column func getSqlBuilderColumnType(columnMetaData metadata.Column) string { if columnMetaData.DataType.Kind != metadata.BaseType && - columnMetaData.DataType.Kind != metadata.RangeType { + columnMetaData.DataType.Kind != metadata.RangeType && + columnMetaData.DataType.Kind != metadata.ArrayType { return "String" } - switch strings.ToLower(columnMetaData.DataType.Name) { + typeName := columnMetaData.DataType.Name + columnName := columnMetaData.Name + + if columnMetaData.DataType.Kind == metadata.ArrayType { + if columnMetaData.DataType.Dimensions > 1 { + fmt.Println("- [SQL Builder] Unsupported sql array with multiple dimensions column '" + columnName + " " + typeName + "', using StringColumn instead.") + return "String" + } + + c := sqlToColumnType(strings.TrimSuffix(typeName, "[]")) + + // These are the supported array types + if slices.Index([]string{"Bool", "String", "Integer"}, c) == -1 { + fmt.Println("- [SQL Builder] Unsupported sql array column '" + columnName + " " + typeName + "', using StringColumn instead.") + return "String" + } + + return c + "Array" + } + + columnType := sqlToColumnType(typeName) + if columnType == "" { + fmt.Println("- [SQL Builder] Unsupported sql column '" + columnName + " " + typeName + "', using StringColumn instead.") + return "String" + } + + return columnType +} + +func sqlToColumnType(typeName string) string { + switch strings.ToLower(typeName) { case "boolean", "bool": return "Bool" case "smallint", "integer", "bigint", "int2", "int4", "int8", @@ -212,8 +243,7 @@ func getSqlBuilderColumnType(columnMetaData metadata.Column) string { case "numrange": return "NumericRange" default: - fmt.Println("- [SQL Builder] Unsupported sql column '" + columnMetaData.Name + " " + columnMetaData.DataType.Name + "', using StringColumn instead.") - return "String" + return "" } } diff --git a/internal/jet/array_expression.go b/internal/jet/array_expression.go new file mode 100644 index 0000000..66cc005 --- /dev/null +++ b/internal/jet/array_expression.go @@ -0,0 +1,93 @@ +package jet + +// ArrayExpression interface +type ArrayExpression[E Expression] interface { + Expression + + EQ(rhs ArrayExpression[E]) BoolExpression + NOT_EQ(rhs ArrayExpression[E]) BoolExpression + LT(rhs ArrayExpression[E]) BoolExpression + GT(rhs ArrayExpression[E]) BoolExpression + LT_EQ(rhs ArrayExpression[E]) BoolExpression + GT_EQ(rhs ArrayExpression[E]) BoolExpression + + CONTAINS(rhs ArrayExpression[E]) BoolExpression + IS_CONTAINED_BY(rhs ArrayExpression[E]) BoolExpression + OVERLAP(rhs ArrayExpression[E]) BoolExpression + CONCAT(rhs ArrayExpression[E]) ArrayExpression[E] + CONCAT_ELEMENT(E) ArrayExpression[E] + + AT(expression IntegerExpression) Expression +} + +type arrayInterfaceImpl[E Expression] struct { + parent ArrayExpression[E] +} + +type BinaryBoolOp func(Expression, Expression) BoolExpression + +func (a arrayInterfaceImpl[E]) EQ(rhs ArrayExpression[E]) BoolExpression { + return Eq(a.parent, rhs) +} + +func (a arrayInterfaceImpl[E]) NOT_EQ(rhs ArrayExpression[E]) BoolExpression { + return NotEq(a.parent, rhs) +} + +func (a arrayInterfaceImpl[E]) LT(rhs ArrayExpression[E]) BoolExpression { + return Lt(a.parent, rhs) +} + +func (a arrayInterfaceImpl[E]) GT(rhs ArrayExpression[E]) BoolExpression { + return Gt(a.parent, rhs) +} + +func (a arrayInterfaceImpl[E]) LT_EQ(rhs ArrayExpression[E]) BoolExpression { + return LtEq(a.parent, rhs) +} + +func (a arrayInterfaceImpl[E]) GT_EQ(rhs ArrayExpression[E]) BoolExpression { + return GtEq(a.parent, rhs) +} + +func (a arrayInterfaceImpl[E]) CONTAINS(rhs ArrayExpression[E]) BoolExpression { + return Contains(a.parent, rhs) +} + +func (a arrayInterfaceImpl[E]) IS_CONTAINED_BY(rhs ArrayExpression[E]) BoolExpression { + return IsContainedBy(a.parent, rhs) +} + +func (a arrayInterfaceImpl[E]) OVERLAP(rhs ArrayExpression[E]) BoolExpression { + return Overlap(a.parent, rhs) +} + +func (a arrayInterfaceImpl[E]) CONCAT(rhs ArrayExpression[E]) ArrayExpression[E] { + return ArrayExp[E](NewBinaryOperatorExpression(a.parent, rhs, "||")) +} + +func (a arrayInterfaceImpl[E]) CONCAT_ELEMENT(rhs E) ArrayExpression[E] { + return ArrayExp[E](NewBinaryOperatorExpression(a.parent, rhs, "||")) +} + +func (a arrayInterfaceImpl[E]) AT(expression IntegerExpression) Expression { + return arraySubscriptExpr(a.parent, expression) +} + +type arrayExpressionWrapper[E Expression] struct { + arrayInterfaceImpl[E] + Expression +} + +func newArrayExpressionWrap[E Expression](expression Expression) ArrayExpression[E] { + arrayExpressionWrapper := arrayExpressionWrapper[E]{Expression: expression} + arrayExpressionWrapper.arrayInterfaceImpl.parent = &arrayExpressionWrapper + return &arrayExpressionWrapper +} + +// ArrayExp is array expression wrapper around arbitrary expression. +// Allows go compiler to see any expression as array expression. +// Does not add sql cast to generated sql builder output. +func ArrayExp[E Expression](expression Expression) ArrayExpression[E] { + return newArrayExpressionWrap[E](expression) +} diff --git a/internal/jet/array_expression_test.go b/internal/jet/array_expression_test.go new file mode 100644 index 0000000..ed7b565 --- /dev/null +++ b/internal/jet/array_expression_test.go @@ -0,0 +1,59 @@ +package jet + +import ( + "github.com/lib/pq" + "testing" +) + +func TestArrayExpressionEQ(t *testing.T) { + assertClauseSerialize(t, table1ColStringArray.EQ(table2ColArray), "(table1.col_array_string = table2.col_array_string)") +} + +func TestArrayExpressionNOT_EQ(t *testing.T) { + assertClauseSerialize(t, table1ColStringArray.NOT_EQ(table2ColArray), "(table1.col_array_string != table2.col_array_string)") + assertClauseSerialize(t, table1ColStringArray.NOT_EQ(StringArray([]string{"x"})), "(table1.col_array_string != $1)", pq.StringArray{"x"}) +} + +func TestArrayExpressionLT(t *testing.T) { + assertClauseSerialize(t, table1ColStringArray.LT(table2ColArray), "(table1.col_array_string < table2.col_array_string)") +} + +func TestArrayExpressionGT(t *testing.T) { + assertClauseSerialize(t, table1ColStringArray.GT(table2ColArray), "(table1.col_array_string > table2.col_array_string)") +} + +func TestArrayExpressionLT_EQ(t *testing.T) { + assertClauseSerialize(t, table1ColStringArray.LT_EQ(table2ColArray), "(table1.col_array_string <= table2.col_array_string)") +} + +func TestArrayExpressionGT_EQ(t *testing.T) { + assertClauseSerialize(t, table1ColStringArray.GT_EQ(table2ColArray), "(table1.col_array_string >= table2.col_array_string)") +} + +func TestArrayExpressionCONTAINS(t *testing.T) { + assertClauseSerialize(t, table1ColStringArray.CONTAINS(table2ColArray), "(table1.col_array_string @> table2.col_array_string)") + assertClauseSerialize(t, table1ColStringArray.CONTAINS(StringArray([]string{"x"})), "(table1.col_array_string @> $1)", pq.StringArray{"x"}) +} + +func TestArrayExpressionCONTAINED_BY(t *testing.T) { + assertClauseSerialize(t, table1ColStringArray.IS_CONTAINED_BY(table2ColArray), "(table1.col_array_string <@ table2.col_array_string)") + assertClauseSerialize(t, table1ColStringArray.IS_CONTAINED_BY(StringArray([]string{"x"})), "(table1.col_array_string <@ $1)", pq.StringArray{"x"}) +} + +func TestArrayExpressionOVERLAP(t *testing.T) { + assertClauseSerialize(t, table1ColStringArray.OVERLAP(table2ColArray), "(table1.col_array_string && table2.col_array_string)") +} + +func TestArrayExpressionCONCAT(t *testing.T) { + assertClauseSerialize(t, table1ColStringArray.CONCAT(table2ColArray), "(table1.col_array_string || table2.col_array_string)") + assertClauseSerialize(t, table1ColStringArray.CONCAT(StringArray([]string{"x"})), "(table1.col_array_string || $1)", pq.StringArray{"x"}) +} + +func TestArrayExpressionCONCAT_ELEMENT(t *testing.T) { + assertClauseSerialize(t, table1ColStringArray.CONCAT_ELEMENT(StringExp(table2ColArray.AT(Int(1)))), "(table1.col_array_string || (table2.col_array_string[$1]))", int64(1)) + assertClauseSerialize(t, table1ColStringArray.CONCAT_ELEMENT(String("x")), "(table1.col_array_string || $1)", "x") +} + +func TestArrayExpressionAT(t *testing.T) { + assertClauseSerialize(t, table1ColStringArray.AT(Int(1)), "(table1.col_array_string[$1])", int64(1)) +} diff --git a/internal/jet/column_types.go b/internal/jet/column_types.go index 68a5042..2f19eaf 100644 --- a/internal/jet/column_types.go +++ b/internal/jet/column_types.go @@ -133,6 +133,46 @@ func IntegerColumn(name string) ColumnInteger { //------------------------------------------------------// +type ColumnArray[E Expression] interface { + ArrayExpression[E] + Column + + From(subQuery SelectTable) ColumnArray[E] + SET(stringExp ArrayExpression[E]) ColumnAssigment +} + +type arrayColumnImpl[E Expression] struct { + arrayInterfaceImpl[E] + + ColumnExpressionImpl +} + +func (a arrayColumnImpl[E]) From(subQuery SelectTable) ColumnArray[E] { + newArrayColumn := ArrayColumn[E](a.name) + newArrayColumn.setTableName(a.tableName) + newArrayColumn.setSubQuery(subQuery) + + return newArrayColumn +} + +func (a *arrayColumnImpl[E]) SET(stringExp ArrayExpression[E]) ColumnAssigment { + return columnAssigmentImpl{ + column: a, + expression: stringExp, + } +} + +// StringColumn creates named string column. +func ArrayColumn[E Expression](name string) ColumnArray[E] { + arrayColumn := &arrayColumnImpl[E]{} + arrayColumn.arrayInterfaceImpl.parent = arrayColumn + arrayColumn.ColumnExpressionImpl = NewColumnImpl(name, "", arrayColumn) + + return arrayColumn +} + +//------------------------------------------------------// + // ColumnString is interface for SQL text, character, character varying // uuid columns and enums types. type ColumnString interface { diff --git a/internal/jet/column_types_test.go b/internal/jet/column_types_test.go index 059d722..38d9e96 100644 --- a/internal/jet/column_types_test.go +++ b/internal/jet/column_types_test.go @@ -1,6 +1,7 @@ package jet import ( + "github.com/lib/pq" "testing" ) @@ -8,6 +9,42 @@ var subQuery = &selectTableImpl{ alias: "sub_query", } +func TestNewArrayColumnString(t *testing.T) { + stringArrayColumn := ArrayColumn[StringExpression]("colArray").From(subQuery) + assertClauseSerialize(t, stringArrayColumn, `sub_query."colArray"`) + assertClauseSerialize(t, stringArrayColumn.EQ(StringArray([]string{"X"})), `(sub_query."colArray" = $1)`, pq.StringArray{"X"}) + assertProjectionSerialize(t, stringArrayColumn, `sub_query."colArray" AS "colArray"`) + + arrayColumn2 := table1ColStringArray.From(subQuery) + assertClauseSerialize(t, arrayColumn2, `sub_query."table1.col_array_string"`) + assertClauseSerialize(t, arrayColumn2.EQ(StringArray([]string{"X"})), `(sub_query."table1.col_array_string" = $1)`, pq.StringArray{"X"}) + assertProjectionSerialize(t, arrayColumn2, `sub_query."table1.col_array_string" AS "table1.col_array_string"`) +} + +func TestNewArrayColumnBool(t *testing.T) { + boolArrayColumn := ArrayColumn[BoolExpression]("colArrayBool").From(subQuery) + assertClauseSerialize(t, boolArrayColumn, `sub_query."colArrayBool"`) + assertClauseSerialize(t, boolArrayColumn.EQ(BoolArray([]bool{true})), `(sub_query."colArrayBool" = $1)`, pq.BoolArray{true}) + assertProjectionSerialize(t, boolArrayColumn, `sub_query."colArrayBool" AS "colArrayBool"`) + + arrayColumn2 := table1ColBoolArray.From(subQuery) + assertClauseSerialize(t, arrayColumn2, `sub_query."table1.col_array_bool"`) + assertClauseSerialize(t, arrayColumn2.EQ(BoolArray([]bool{true})), `(sub_query."table1.col_array_bool" = $1)`, pq.BoolArray{true}) + assertProjectionSerialize(t, arrayColumn2, `sub_query."table1.col_array_bool" AS "table1.col_array_bool"`) +} + +func TestNewArrayColumnInteger(t *testing.T) { + intArrayColumn := ArrayColumn[IntegerExpression]("colArrayInt").From(subQuery) + assertClauseSerialize(t, intArrayColumn, `sub_query."colArrayInt"`) + assertClauseSerialize(t, intArrayColumn.EQ(Int32Array([]int32{42})), `(sub_query."colArrayInt" = $1)`, pq.Int32Array{42}) + assertProjectionSerialize(t, intArrayColumn, `sub_query."colArrayInt" AS "colArrayInt"`) + + arrayColumn2 := table1ColIntArray.From(subQuery) + assertClauseSerialize(t, arrayColumn2, `sub_query."table1.col_array_int"`) + assertClauseSerialize(t, arrayColumn2.EQ(Int32Array([]int32{42})), `(sub_query."table1.col_array_int" = $1)`, pq.Int32Array{42}) + assertProjectionSerialize(t, arrayColumn2, `sub_query."table1.col_array_int" AS "table1.col_array_int"`) +} + func TestNewBoolColumn(t *testing.T) { boolColumn := BoolColumn("colBool").From(subQuery) assertClauseSerialize(t, boolColumn, `sub_query."colBool"`) diff --git a/internal/jet/expression.go b/internal/jet/expression.go index 62e5aff..af4851e 100644 --- a/internal/jet/expression.go +++ b/internal/jet/expression.go @@ -341,3 +341,42 @@ func (s *complexExpression) serialize(statement StatementType, out *SQLBuilder, func wrap(expressions ...Expression) Expression { return NewFunc("", expressions, nil) } + +type arraySubscriptExpression struct { + ExpressionInterfaceImpl + array Expression + subscript IntegerExpression +} + +func (a arraySubscriptExpression) serialize(statement StatementType, out *SQLBuilder, options ...SerializeOption) { + if !contains(options, NoWrap) { + out.WriteString("(") + } + a.array.serialize(statement, out, FallTrough(options)...) // FallTrough here because complexExpression is just a wrapper + out.WriteString("[") + a.subscript.serialize(statement, out, FallTrough(options)...) // FallTrough here because complexExpression is just a wrapper + out.WriteString("]") + if !contains(options, NoWrap) { + out.WriteString(")") + } +} + +func arraySubscriptExpr(array Expression, subscript IntegerExpression) Expression { + arraySubscriptExpression := &arraySubscriptExpression{array: array, subscript: subscript} + arraySubscriptExpression.ExpressionInterfaceImpl.Parent = arraySubscriptExpression + + return arraySubscriptExpression +} + +type skipParenthesisWrap struct { + Expression +} + +func skipWrap(expression Expression) Expression { + return &skipParenthesisWrap{expression} +} + +// since the expression is a function parameter, there is no need to wrap it in parentheses +func (s *skipParenthesisWrap) serialize(statement StatementType, out *SQLBuilder, options ...SerializeOption) { + s.Expression.serialize(statement, out, append(options, NoWrap)...) +} diff --git a/internal/jet/literal_expression.go b/internal/jet/literal_expression.go index b8baa51..2a33fa5 100644 --- a/internal/jet/literal_expression.go +++ b/internal/jet/literal_expression.go @@ -2,6 +2,7 @@ package jet import ( "fmt" + "github.com/lib/pq" "time" ) @@ -160,6 +161,66 @@ func Decimal(value string) FloatExpression { return &floatLiteral } +// ---------------------------------------------------// + +type boolArrayLiteral struct { + arrayInterfaceImpl[BoolExpression] + literalExpressionImpl +} + +func BoolArray(values []bool) ArrayExpression[BoolExpression] { + l := boolArrayLiteral{} + l.literalExpressionImpl = *literal(pq.BoolArray(values)) + l.arrayInterfaceImpl.parent = &l + + return &l +} + +type integerArrayLiteral struct { + arrayInterfaceImpl[IntegerExpression] + literalExpressionImpl +} + +func Int64Array(values []int64) ArrayExpression[IntegerExpression] { + l := integerArrayLiteral{} + l.literalExpressionImpl = *literal(pq.Int64Array(values)) + l.arrayInterfaceImpl.parent = &l + return &l +} + +func Int32Array(values []int32) ArrayExpression[IntegerExpression] { + l := integerArrayLiteral{} + l.literalExpressionImpl = *literal(pq.Int32Array(values)) + l.arrayInterfaceImpl.parent = &l + return &l +} + +type stringArrayLiteral struct { + arrayInterfaceImpl[StringExpression] + literalExpressionImpl +} + +func StringArray(values []string) ArrayExpression[StringExpression] { + l := stringArrayLiteral{} + l.literalExpressionImpl = *literal(pq.StringArray(values)) + l.arrayInterfaceImpl.parent = &l + + return &l +} + +type unsafeArrayLiteral[E Expression] struct { + arrayInterfaceImpl[E] + literalExpressionImpl +} + +func UnsafeArray[E LiteralExpression](values []interface{}) ArrayExpression[E] { + l := unsafeArrayLiteral[E]{} + l.literalExpressionImpl = *literal(pq.Array(values)) + l.arrayInterfaceImpl.parent = &l + + return &l +} + // ---------------------------------------------------// type stringLiteral struct { stringInterfaceImpl diff --git a/internal/jet/operators.go b/internal/jet/operators.go index 8c7cc16..fa764b4 100644 --- a/internal/jet/operators.go +++ b/internal/jet/operators.go @@ -22,6 +22,15 @@ func BIT_NOT(expr IntegerExpression) IntegerExpression { return newPrefixIntegerOperatorExpression(expr, "~") } +// ----------- Array operators -------------- // +func Any(lhs Expression, op BinaryBoolOp, rhs Expression) BoolExpression { + return op(lhs, Func("ANY", rhs)) +} + +func All(lhs Expression, op BinaryBoolOp, rhs Expression) BoolExpression { + return op(lhs, Func("ALL", rhs)) +} + //----------- Comparison operators ---------------// // EXISTS checks for existence of the rows in subQuery @@ -74,6 +83,11 @@ func Contains(lhs Expression, rhs Expression) BoolExpression { return newBinaryBoolOperatorExpression(lhs, rhs, "@>") } +// IsContainedBy returns a representation of "a <@ b" +func IsContainedBy(lhs Expression, rhs Expression) BoolExpression { + return newBinaryBoolOperatorExpression(lhs, rhs, "<@") +} + // Overlap returns a representation of "a && b" func Overlap(lhs, rhs Expression) BoolExpression { return newBinaryBoolOperatorExpression(lhs, rhs, "&&") diff --git a/internal/jet/sql_builder.go b/internal/jet/sql_builder.go index 8764dd7..c52b9b2 100644 --- a/internal/jet/sql_builder.go +++ b/internal/jet/sql_builder.go @@ -93,11 +93,11 @@ func (s *SQLBuilder) write(data []byte) { } func isPreSeparator(b byte) bool { - return b == ' ' || b == '.' || b == ',' || b == '(' || b == '\n' || b == ':' + return b == ' ' || b == '.' || b == ',' || b == '(' || b == '\n' || b == ':' || b == '[' } func isPostSeparator(b byte) bool { - return b == ' ' || b == '.' || b == ',' || b == ')' || b == '\n' || b == ':' + return b == ' ' || b == '.' || b == ',' || b == ')' || b == '\n' || b == ':' || b == '[' || b == ']' } // WriteAlias is used to add alias to output SQL @@ -249,6 +249,8 @@ func (s *SQLBuilder) argToString(value interface{}) string { case string: return stringQuote(bindVal) + case []string: + return stringArrayQuote(bindVal) case []byte: return stringQuote(string(bindVal)) case uuid.UUID: @@ -276,6 +278,19 @@ func (s *SQLBuilder) argToString(value interface{}) string { } } +func stringArrayQuote(val []string) string { + var sb strings.Builder + sb.WriteString(`'{`) + for i := 0; i < len(val); i++ { + if i > 0 { + sb.WriteString(`, `) + } + sb.WriteString(stringDoubleQuote(val[i])) + } + sb.WriteString(`}'`) + return sb.String() +} + func integerTypesToString(value interface{}) string { switch bindVal := value.(type) { case int: @@ -324,3 +339,7 @@ func shouldQuoteIdentifier(identifier string) bool { func stringQuote(value string) string { return `'` + strings.Replace(value, "'", "''", -1) + `'` } + +func stringDoubleQuote(value string) string { + return `"` + strings.Replace(value, `"`, `""`, -1) + `"` +} diff --git a/internal/jet/string_expression.go b/internal/jet/string_expression.go index 5d373ca..569aa3b 100644 --- a/internal/jet/string_expression.go +++ b/internal/jet/string_expression.go @@ -17,6 +17,9 @@ type StringExpression interface { BETWEEN(min, max StringExpression) BoolExpression NOT_BETWEEN(min, max StringExpression) BoolExpression + ANY_EQ(rhs ArrayExpression[StringExpression]) BoolExpression + ALL_EQ(rhs ArrayExpression[StringExpression]) BoolExpression + CONCAT(rhs Expression) StringExpression LIKE(pattern StringExpression) BoolExpression @@ -72,6 +75,14 @@ func (s *stringInterfaceImpl) NOT_BETWEEN(min, max StringExpression) BoolExpress return NewBetweenOperatorExpression(s.root, min, max, true) } +func (i *stringInterfaceImpl) ANY_EQ(rhs ArrayExpression[StringExpression]) BoolExpression { + return Any(i.parent, Eq, rhs) +} + +func (i *stringInterfaceImpl) ALL_EQ(rhs ArrayExpression[StringExpression]) BoolExpression { + return All(i.parent, Eq, rhs) +} + func (s *stringInterfaceImpl) CONCAT(rhs Expression) StringExpression { return newBinaryStringOperatorExpression(s.root, rhs, StringConcatOperator) } diff --git a/internal/jet/string_expression_test.go b/internal/jet/string_expression_test.go index 0f461ac..830937f 100644 --- a/internal/jet/string_expression_test.go +++ b/internal/jet/string_expression_test.go @@ -76,6 +76,14 @@ func TestStringNOT_REGEXP_LIKE(t *testing.T) { assertClauseSerialize(t, table3StrCol.NOT_REGEXP_LIKE(String("JOHN"), true), "(table3.col2 NOT REGEXP $1)", "JOHN") } +func TestStringANY_EQ(t *testing.T) { + assertClauseSerialize(t, table2ColStr.ANY_EQ(table1ColStringArray), "(table2.col_str = ANY(table1.col_array_string))") +} + +func TestStringALL_EQ(t *testing.T) { + assertClauseSerialize(t, table2ColStr.ALL_EQ(table1ColStringArray), "(table2.col_str = ALL(table1.col_array_string))") +} + func TestStringExp(t *testing.T) { assertClauseSerialize(t, StringExp(table2ColFloat), "table2.col_float") assertClauseSerialize(t, StringExp(table2ColFloat).NOT_LIKE(String("abc")), "(table2.col_float NOT LIKE $1)", "abc") diff --git a/internal/jet/testutils.go b/internal/jet/testutils.go index 866048a..667cc64 100644 --- a/internal/jet/testutils.go +++ b/internal/jet/testutils.go @@ -18,19 +18,22 @@ var defaultDialect = NewDialect(DialectParams{ // just for tests }) var ( - table1Col1 = IntegerColumn("col1") - table1ColInt = IntegerColumn("col_int") - table1ColFloat = FloatColumn("col_float") - table1Col3 = IntegerColumn("col3") - table1ColTime = TimeColumn("col_time") - table1ColTimez = TimezColumn("col_timez") - table1ColTimestamp = TimestampColumn("col_timestamp") - table1ColTimestampz = TimestampzColumn("col_timestampz") - table1ColBool = BoolColumn("col_bool") - table1ColDate = DateColumn("col_date") - table1ColRange = RangeColumn[Int8Expression]("col_range") + table1Col1 = IntegerColumn("col1") + table1ColInt = IntegerColumn("col_int") + table1ColFloat = FloatColumn("col_float") + table1Col3 = IntegerColumn("col3") + table1ColTime = TimeColumn("col_time") + table1ColTimez = TimezColumn("col_timez") + table1ColTimestamp = TimestampColumn("col_timestamp") + table1ColTimestampz = TimestampzColumn("col_timestampz") + table1ColBool = BoolColumn("col_bool") + table1ColDate = DateColumn("col_date") + table1ColRange = RangeColumn[Int8Expression]("col_range") + table1ColStringArray = ArrayColumn[StringExpression]("col_array_string") + table1ColBoolArray = ArrayColumn[BoolExpression]("col_array_bool") + table1ColIntArray = ArrayColumn[IntegerExpression]("col_array_int") ) -var table1 = NewTable("db", "table1", "", table1Col1, table1ColInt, table1ColFloat, table1Col3, table1ColTime, table1ColTimez, table1ColBool, table1ColDate, table1ColRange, table1ColTimestamp, table1ColTimestampz) +var table1 = NewTable("db", "table1", "", table1Col1, table1ColInt, table1ColFloat, table1Col3, table1ColTime, table1ColTimez, table1ColBool, table1ColDate, table1ColRange, table1ColTimestamp, table1ColTimestampz, table1ColStringArray, table1ColBoolArray, table1ColIntArray) var ( table2Col3 = IntegerColumn("col3") @@ -45,8 +48,9 @@ var ( table2ColTimestampz = TimestampzColumn("col_timestampz") table2ColDate = DateColumn("col_date") table2ColRange = RangeColumn[Int8Expression]("col_range") + table2ColArray = ArrayColumn[StringExpression]("col_array_string") ) -var table2 = NewTable("db", "table2", "", table2Col3, table2Col4, table2ColInt, table2ColFloat, table2ColStr, table2ColBool, table2ColTime, table2ColTimez, table2ColDate, table2ColRange, table2ColTimestamp, table2ColTimestampz) +var table2 = NewTable("db", "table2", "", table2Col3, table2Col4, table2ColInt, table2ColFloat, table2ColStr, table2ColBool, table2ColTime, table2ColTimez, table2ColDate, table2ColRange, table2ColTimestamp, table2ColTimestampz, table2ColArray) var ( table3Col1 = IntegerColumn("col1") diff --git a/postgres/columns.go b/postgres/columns.go index 01af0d7..5943246 100644 --- a/postgres/columns.go +++ b/postgres/columns.go @@ -112,3 +112,21 @@ type ColumnInt8Range jet.ColumnRange[jet.Int8Expression] // Int8RangeColumn creates named range with range column var Int8RangeColumn = jet.RangeColumn[jet.Int8Expression] + +// ColumnStringArray is interface of column +type ColumnStringArray jet.ColumnArray[jet.StringExpression] + +// StringArrayColumn creates named string array column +var StringArrayColumn = jet.ArrayColumn[jet.StringExpression] + +// ColumnIntegerArray is interface of column +type ColumnIntegerArray jet.ColumnArray[jet.IntegerExpression] + +// IntegerArrayColumn creates named integer array column +var IntegerArrayColumn = jet.ArrayColumn[jet.IntegerExpression] + +// ColumnBoolArray is interface of column +type ColumnBoolArray jet.ColumnArray[jet.BoolExpression] + +// BoolArrayColumn creates named bool array column +var BoolArrayColumn = jet.ArrayColumn[jet.BoolExpression] diff --git a/postgres/expressions.go b/postgres/expressions.go index f4fbb13..5ee75cd 100644 --- a/postgres/expressions.go +++ b/postgres/expressions.go @@ -9,17 +9,26 @@ type Expression = jet.Expression // BoolExpression interface type BoolExpression = jet.BoolExpression +// BoolArrayExpression interface +type BoolArrayExpression = jet.ArrayExpression[BoolExpression] + // StringExpression interface type StringExpression = jet.StringExpression type ByteaExpression = jet.BlobExpression +// StringArrayExpression interface +type StringArrayExpression = jet.ArrayExpression[StringExpression] + // NumericExpression interface type NumericExpression = jet.NumericExpression // IntegerExpression interface type IntegerExpression = jet.IntegerExpression +// IntegerArrayExpression interface +type IntegerArrayExpression = jet.ArrayExpression[IntegerExpression] + // FloatExpression is interface type FloatExpression = jet.FloatExpression diff --git a/postgres/insert_statement_test.go b/postgres/insert_statement_test.go index 5ace301..bb4bbd8 100644 --- a/postgres/insert_statement_test.go +++ b/postgres/insert_statement_test.go @@ -175,27 +175,30 @@ RETURNING table1.col1 AS "table1.col1", } func TestInsert_ON_CONFLICT_ON_CONSTRAINT(t *testing.T) { - stmt := table1.INSERT(table1Col1, table1ColBool). - VALUES("one", "two"). - VALUES("1", "2"). - ON_CONFLICT().ON_CONSTRAINT("idk_primary_key"). - DO_UPDATE( - SET(table1ColBool.SET(Bool(false)), - table2ColInt.SET(Int(1)), - ColumnList{table1Col1, table1ColBool}.SET(ROW(Int(2), String("two"))), - ).WHERE(table1Col1.GT(Int(2)))). - RETURNING(table1Col1, table1ColBool) + stmt := table1.INSERT(table1Col1, table1ColBool, table1ColStringArray). + VALUES("one", "two", "three"). + VALUES("1", "2", "3"). + ON_CONFLICT().ON_CONSTRAINT("idk_primary_key").DO_UPDATE( + SET(table1ColBool.SET(Bool(false)), + table2ColInt.SET(Int(1)), + table1ColStringArray.SET(StringArray([]string{"one"})), + ColumnList{table1Col1, table1ColBool, table1ColStringArray}.SET(jet.ROW(Int(2), String("two"), StringArray([]string{"two"}))), + ).WHERE(table1Col1.GT(Int(2))), + ). + RETURNING(table1Col1, table1ColBool, table1ColStringArray) assertDebugStatementSql(t, stmt, ` -INSERT INTO db.table1 (col1, col_bool) -VALUES ('one', 'two'), - ('1', '2') +INSERT INTO db.table1 (col1, col_bool, col_string_array) +VALUES ('one', 'two', 'three'), + ('1', '2', '3') ON CONFLICT ON CONSTRAINT idk_primary_key DO UPDATE SET col_bool = FALSE::boolean, col_int = 1, - (col1, col_bool) = ROW(2, 'two'::text) + col_string_array = '{"one"}', + (col1, col_bool, col_string_array) = ROW(2, 'two'::text, '{"two"}') WHERE table1.col1 > 2 RETURNING table1.col1 AS "table1.col1", - table1.col_bool AS "table1.col_bool"; + table1.col_bool AS "table1.col_bool", + table1.col_string_array AS "table1.col_string_array"; `) } diff --git a/postgres/literal.go b/postgres/literal.go index a6a1618..40e4fc3 100644 --- a/postgres/literal.go +++ b/postgres/literal.go @@ -11,6 +11,11 @@ func Bool(value bool) BoolExpression { return CAST(jet.Bool(value)).AS_BOOL() } +// BoolArray creates new bool array literal expression +func BoolArray(elements []bool) BoolArrayExpression { + return jet.BoolArray(elements) +} + // Int is constructor for 64 bit signed integer expressions literals. var Int = jet.Int @@ -80,6 +85,11 @@ func String(value string) StringExpression { return CAST(jet.String(value)).AS_TEXT() } +// StringArray creates new string array literal expression +func StringArray(elements []string) StringArrayExpression { + return jet.StringArray(elements) +} + // Text is a parameter constructor for the PostgreSQL text type. This constructor also adds an // explicit placeholder type cast to text in the generated query, such as `$3::text`. // Example usage: diff --git a/postgres/utils_test.go b/postgres/utils_test.go index 96bb13b..d89b17b 100644 --- a/postgres/utils_test.go +++ b/postgres/utils_test.go @@ -18,6 +18,8 @@ var table1ColBool = BoolColumn("col_bool") var table1ColDate = DateColumn("col_date") var table1ColInterval = IntervalColumn("col_interval") var table1ColRange = Int8RangeColumn("col_range") +var table1ColStringArray = StringArrayColumn("col_string_array") +var table1ColIntArray = IntegerArrayColumn("col_int_array") var table1 = NewTable( "db", @@ -34,6 +36,8 @@ var table1 = NewTable( table1ColTimestampz, table1ColInterval, table1ColRange, + table1ColStringArray, + table1ColIntArray, ) var table2Col3 = IntegerColumn("col3") @@ -49,8 +53,10 @@ var table2ColTimestampz = TimestampzColumn("col_timestampz") var table2ColDate = DateColumn("col_date") var table2ColInterval = IntervalColumn("col_interval") var table2ColRange = Int8RangeColumn("col_range") +var table2ColStringArray = StringArrayColumn("col_string_array") +var table2ColIntArray = IntegerArrayColumn("col_int_array") -var table2 = NewTable("db", "table2", "", table2Col3, table2Col4, table2ColInt, table2ColFloat, table2ColStr, table2ColBool, table2ColTime, table2ColTimez, table2ColDate, table2ColTimestamp, table2ColTimestampz, table2ColInterval, table2ColRange) +var table2 = NewTable("db", "table2", "", table2Col3, table2Col4, table2ColInt, table2ColFloat, table2ColStr, table2ColBool, table2ColTime, table2ColTimez, table2ColDate, table2ColTimestamp, table2ColTimestampz, table2ColInterval, table2ColRange, table2ColStringArray, table2ColIntArray) var table3Col1 = IntegerColumn("col1") var table3ColInt = IntegerColumn("col_int") diff --git a/tests/docker-compose.yaml b/tests/docker-compose.yaml index 8c0d2a2..6375cc4 100644 --- a/tests/docker-compose.yaml +++ b/tests/docker-compose.yaml @@ -1,4 +1,3 @@ -version: '3' services: postgres: image: postgres:14.1 @@ -13,7 +12,7 @@ services: - ./testdata/init/postgres:/docker-entrypoint-initdb.d mysql: - image: mysql:8.0.27 + image: mysql/mysql-server:8.0.27 command: ['--default-authentication-plugin=mysql_native_password', '--log_bin_trust_function_creators=1'] restart: always environment: diff --git a/tests/postgres/alltypes_test.go b/tests/postgres/alltypes_test.go index 36c8d62..994e21b 100644 --- a/tests/postgres/alltypes_test.go +++ b/tests/postgres/alltypes_test.go @@ -1,6 +1,8 @@ package postgres import ( + "database/sql" + "github.com/lib/pq" "encoding/base64" "fmt" "github.com/go-jet/jet/v2/internal/utils/ptr" @@ -2243,12 +2245,12 @@ var allTypesRow0 = model.AllTypes{ JSON: `{"a": 1, "b": 3}`, JsonbPtr: ptr.Of(`{"a": 1, "b": 3}`), Jsonb: `{"a": 1, "b": 3}`, - IntegerArrayPtr: ptr.Of("{1,2,3}"), - IntegerArray: "{1,2,3}", - TextArrayPtr: ptr.Of("{breakfast,consulting}"), - TextArray: "{breakfast,consulting}", - JsonbArray: `{"{\"a\": 1, \"b\": 2}","{\"a\": 3, \"b\": 4}"}`, - TextMultiDimArrayPtr: ptr.Of("{{meeting,lunch},{training,presentation}}"), + IntegerArrayPtr: &pq.Int32Array{1, 2, 3}, + IntegerArray: pq.Int32Array{1, 2, 3}, + TextArrayPtr: &pq.StringArray{"breakfast", "consulting"}, + TextArray: pq.StringArray{"breakfast", "consulting"}, + JsonbArray: pq.StringArray{`{"a": 1, "b": 2}`, `{"a": 3, "b": 4}`}, + TextMultiDimArrayPtr: testutils.StringPtr("{{meeting,lunch},{training,presentation}}"), TextMultiDimArray: "{{meeting,lunch},{training,presentation}}", MoodPtr: &moodSad, Mood: model.Mood_Happy, @@ -2312,10 +2314,10 @@ var allTypesRow1 = model.AllTypes{ JsonbPtr: nil, Jsonb: `{"a": 1, "b": 3}`, IntegerArrayPtr: nil, - IntegerArray: "{1,2,3}", + IntegerArray: pq.Int32Array{1, 2, 3}, TextArrayPtr: nil, - TextArray: "{breakfast,consulting}", - JsonbArray: `{"{\"a\": 1, \"b\": 2}","{\"a\": 3, \"b\": 4}"}`, + TextArray: pq.StringArray{"breakfast", "consulting"}, + JsonbArray: pq.StringArray{`{"a": 1, "b": 2}`, `{"a": 3, "b": 4}`}, TextMultiDimArrayPtr: nil, TextMultiDimArray: "{{meeting,lunch},{training,presentation}}", MoodPtr: nil, diff --git a/tests/postgres/generator_test.go b/tests/postgres/generator_test.go index d8f468c..7c787b8 100644 --- a/tests/postgres/generator_test.go +++ b/tests/postgres/generator_test.go @@ -844,6 +844,7 @@ package model import ( "github.com/google/uuid" + "github.com/lib/pq" "time" ) @@ -902,11 +903,11 @@ type AllTypes struct { JSON string JsonbPtr *string Jsonb string - IntegerArrayPtr *string - IntegerArray string - TextArrayPtr *string - TextArray string - JsonbArray string + IntegerArrayPtr *pq.Int32Array + IntegerArray pq.Int32Array + TextArrayPtr *pq.StringArray + TextArray pq.StringArray + JsonbArray pq.StringArray TextMultiDimArrayPtr *string TextMultiDimArray string MoodPtr *Mood @@ -1007,11 +1008,11 @@ type allTypesTable struct { JSON postgres.ColumnString JsonbPtr postgres.ColumnString Jsonb postgres.ColumnString - IntegerArrayPtr postgres.ColumnString - IntegerArray postgres.ColumnString - TextArrayPtr postgres.ColumnString - TextArray postgres.ColumnString - JsonbArray postgres.ColumnString + IntegerArrayPtr postgres.ColumnIntegerArray + IntegerArray postgres.ColumnIntegerArray + TextArrayPtr postgres.ColumnStringArray + TextArray postgres.ColumnStringArray + JsonbArray postgres.ColumnStringArray TextMultiDimArrayPtr postgres.ColumnString TextMultiDimArray postgres.ColumnString MoodPtr postgres.ColumnString @@ -1111,11 +1112,11 @@ func newAllTypesTableImpl(schemaName, tableName, alias string) allTypesTable { JSONColumn = postgres.StringColumn("json") JsonbPtrColumn = postgres.StringColumn("jsonb_ptr") JsonbColumn = postgres.StringColumn("jsonb") - IntegerArrayPtrColumn = postgres.StringColumn("integer_array_ptr") - IntegerArrayColumn = postgres.StringColumn("integer_array") - TextArrayPtrColumn = postgres.StringColumn("text_array_ptr") - TextArrayColumn = postgres.StringColumn("text_array") - JsonbArrayColumn = postgres.StringColumn("jsonb_array") + IntegerArrayPtrColumn = postgres.IntegerArrayColumn("integer_array_ptr") + IntegerArrayColumn = postgres.IntegerArrayColumn("integer_array") + TextArrayPtrColumn = postgres.StringArrayColumn("text_array_ptr") + TextArrayColumn = postgres.StringArrayColumn("text_array") + JsonbArrayColumn = postgres.StringArrayColumn("jsonb_array") TextMultiDimArrayPtrColumn = postgres.StringColumn("text_multi_dim_array_ptr") TextMultiDimArrayColumn = postgres.StringColumn("text_multi_dim_array") MoodPtrColumn = postgres.StringColumn("mood_ptr") diff --git a/tests/postgres/scan_test.go b/tests/postgres/scan_test.go index 37cbd8a..e130abc 100644 --- a/tests/postgres/scan_test.go +++ b/tests/postgres/scan_test.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "github.com/lib/pq" "github.com/go-jet/jet/v2/internal/utils/ptr" "github.com/volatiletech/null/v8" "testing" @@ -1006,6 +1007,7 @@ func TestScanIntoCustomBaseTypes(t *testing.T) { type MyFloat32 float32 type MyFloat64 float64 type MyString string + type MyStringArray pq.StringArray type MyTime = time.Time type film struct { @@ -1020,26 +1022,25 @@ func TestScanIntoCustomBaseTypes(t *testing.T) { ReplacementCost MyFloat64 Rating *model.MpaaRating LastUpdate MyTime - SpecialFeatures *MyString + SpecialFeatures MyStringArray Fulltext MyString } + // We'll skip special features, because it's a slice and it does not implement sql.Scanner stmt := SELECT( - Film.AllColumns, + Film.AllColumns.Except(Film.SpecialFeatures), ).FROM( Film, ).ORDER_BY( Film.FilmID.ASC(), ).LIMIT(3) - var films []model.Film - - err := stmt.Query(db, &films) + var myFilms []film + err := stmt.Query(db, &myFilms) require.NoError(t, err) - var myFilms []film - - err = stmt.Query(db, &myFilms) + var films []model.Film + err = stmt.Query(db, &films) require.NoError(t, err) require.Equal(t, testutils.ToJSON(films), testutils.ToJSON(myFilms)) @@ -1254,7 +1255,7 @@ var film1 = model.Film{ ReplacementCost: 20.99, Rating: &pgRating, LastUpdate: *testutils.TimestampWithoutTimeZone("2013-05-26 14:50:58.951", 3), - SpecialFeatures: ptr.Of("{\"Deleted Scenes\",\"Behind the Scenes\"}"), + SpecialFeatures: &pq.StringArray{"Deleted Scenes", "Behind the Scenes"}, Fulltext: "'academi':1 'battl':15 'canadian':20 'dinosaur':2 'drama':5 'epic':4 'feminist':8 'mad':11 'must':14 'rocki':21 'scientist':12 'teacher':17", } @@ -1270,7 +1271,7 @@ var film2 = model.Film{ ReplacementCost: 12.99, Rating: &gRating, LastUpdate: *testutils.TimestampWithoutTimeZone("2013-05-26 14:50:58.951", 3), - SpecialFeatures: ptr.Of(`{Trailers,"Deleted Scenes"}`), + SpecialFeatures: &pq.StringArray{"Trailers", "Deleted Scenes"}, Fulltext: `'ace':1 'administr':9 'ancient':19 'astound':4 'car':17 'china':20 'databas':8 'epistl':5 'explor':12 'find':15 'goldfing':2 'must':14`, } diff --git a/tests/postgres/select_test.go b/tests/postgres/select_test.go index 6997223..6203a13 100644 --- a/tests/postgres/select_test.go +++ b/tests/postgres/select_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "github.com/go-jet/jet/v2/internal/utils/ptr" + "github.com/lib/pq" "testing" "time" @@ -1852,7 +1853,7 @@ ORDER BY film.film_id ASC; Rating: &gRating, RentalDuration: 3, LastUpdate: *testutils.TimestampWithoutTimeZone("2013-05-26 14:50:58.951", 3), - SpecialFeatures: ptr.Of("{Trailers,\"Deleted Scenes\"}"), + SpecialFeatures: &pq.StringArray{"Trailers", "Deleted Scenes"}, Fulltext: "'ace':1 'administr':9 'ancient':19 'astound':4 'car':17 'china':20 'databas':8 'epistl':5 'explor':12 'find':15 'goldfing':2 'must':14", }) } @@ -2783,7 +2784,6 @@ ORDER BY actor.actor_id ASC, film.film_id ASC; require.NoError(t, err) //testutils.SaveJSONFile(dest, "./testdata/results/postgres/quick-start-dest.json") - testutils.AssertJSONFile(t, dest, "./testdata/results/postgres/quick-start-dest.json") var dest2 []struct { @@ -2798,7 +2798,7 @@ ORDER BY actor.actor_id ASC, film.film_id ASC; require.NoError(t, err) }) - //testutils.SaveJSONFile(dest2, "./testdata/results/postgres/quick-start-dest2.json") + //testutils.SaveJSONFile(dest, "./testdata/results/postgres/quick-start-dest2.json") testutils.AssertJSONFile(t, dest2, "./testdata/results/postgres/quick-start-dest2.json") } @@ -3389,7 +3389,10 @@ func TestSelectRecursionScanNxM(t *testing.T) { "ReplacementCost": 20.99, "Rating": "PG", "LastUpdate": "2013-05-26T14:50:58.951Z", - "SpecialFeatures": "{\"Deleted Scenes\",\"Behind the Scenes\"}", + "SpecialFeatures": [ + "Deleted Scenes", + "Behind the Scenes" + ], "Fulltext": "'academi':1 'battl':15 'canadian':20 'dinosaur':2 'drama':5 'epic':4 'feminist':8 'mad':11 'must':14 'rocki':21 'scientist':12 'teacher':17", "Actors": [ { @@ -3413,7 +3416,10 @@ func TestSelectRecursionScanNxM(t *testing.T) { "ReplacementCost": 9.99, "Rating": "R", "LastUpdate": "2013-05-26T14:50:58.951Z", - "SpecialFeatures": "{Trailers,\"Deleted Scenes\"}", + "SpecialFeatures": [ + "Trailers", + "Deleted Scenes" + ], "Fulltext": "'anaconda':1 'australia':18 'confess':2 'dentist':8,11 'display':5 'fight':14 'girl':16 'lacklustur':4 'must':13", "Actors": [ { @@ -3461,7 +3467,10 @@ func TestSelectRecursionScanNxM(t *testing.T) { "ReplacementCost": 20.99, "Rating": "PG", "LastUpdate": "2013-05-26T14:50:58.951Z", - "SpecialFeatures": "{\"Deleted Scenes\",\"Behind the Scenes\"}", + "SpecialFeatures": [ + "Deleted Scenes", + "Behind the Scenes" + ], "Fulltext": "'academi':1 'battl':15 'canadian':20 'dinosaur':2 'drama':5 'epic':4 'feminist':8 'mad':11 'must':14 'rocki':21 'scientist':12 'teacher':17", "Actors": null }, @@ -3477,7 +3486,10 @@ func TestSelectRecursionScanNxM(t *testing.T) { "ReplacementCost": 9.99, "Rating": "R", "LastUpdate": "2013-05-26T14:50:58.951Z", - "SpecialFeatures": "{Trailers,\"Deleted Scenes\"}", + "SpecialFeatures": [ + "Trailers", + "Deleted Scenes" + ], "Fulltext": "'anaconda':1 'australia':18 'confess':2 'dentist':8,11 'display':5 'fight':14 'girl':16 'lacklustur':4 'must':13", "Actors": null }