From 5588d0ae84a1162522c8721ec85b86648b83227f Mon Sep 17 00:00:00 2001 From: Huan Du Date: Sat, 6 Jul 2024 23:58:26 +0800 Subject: [PATCH 1/2] implement CTEBuilder --- README.md | 1 + cte.go | 120 +++++++++++++++++++++++++++++++++++++++++++++++++ cte_test.go | 65 +++++++++++++++++++++++++++ flavor.go | 7 +++ select.go | 15 +++++++ struct_test.go | 4 +- union.go | 20 ++++++--- 7 files changed, 222 insertions(+), 10 deletions(-) create mode 100644 cte.go create mode 100644 cte_test.go diff --git a/README.md b/README.md index 9471742..7f25052 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ This package includes following pre-defined builders so far. API document and ex - [UpdateBuilder](https://pkg.go.dev/github.com/huandu/go-sqlbuilder#UpdateBuilder): Builder for UPDATE. - [DeleteBuilder](https://pkg.go.dev/github.com/huandu/go-sqlbuilder#DeleteBuilder): Builder for DELETE. - [UnionBuilder](https://pkg.go.dev/github.com/huandu/go-sqlbuilder#UnionBuilder): Builder for UNION and UNION ALL. +- [CTEBuilder](https://pkg.go.dev/github.com/huandu/go-sqlbuilder#CTEBuilder): Builder for Common Table Expression (CTE), e.g. `WITH name (col1, col2) AS (SELECT ...)`. - [Buildf](https://pkg.go.dev/github.com/huandu/go-sqlbuilder#Buildf): Freestyle builder using `fmt.Sprintf`-like syntax. - [Build](https://pkg.go.dev/github.com/huandu/go-sqlbuilder#Build): Advanced freestyle builder using special syntax defined in [Args#Compile](https://pkg.go.dev/github.com/huandu/go-sqlbuilder#Args.Compile). - [BuildNamed](https://pkg.go.dev/github.com/huandu/go-sqlbuilder#BuildNamed): Advanced freestyle builder using `${key}` to refer the value of a map by key. diff --git a/cte.go b/cte.go new file mode 100644 index 0000000..ebc530f --- /dev/null +++ b/cte.go @@ -0,0 +1,120 @@ +// Copyright 2024 Huan Du. All rights reserved. +// Licensed under the MIT license that can be found in the LICENSE file. + +package sqlbuilder + +const ( + cteMarkerInit injectionMarker = iota + cteMarkerAfterWith + cteMarkerAfterAs +) + +// With creates a new CTE builder with default flavor. +func With(name string, cols ...string) *CTEBuilder { + return DefaultFlavor.NewCTEBuilder().With(name, cols...) +} + +func newCTEBuilder() *CTEBuilder { + return &CTEBuilder{ + args: &Args{}, + injection: newInjection(), + } +} + +// CTEBuilder is a CTE (Common Table Expression) builder. +type CTEBuilder struct { + name string + cols []string + builderVar string + + args *Args + + injection *injection + marker injectionMarker +} + +var _ Builder = new(CTEBuilder) + +// With sets the CTE name and columns. +func (cteb *CTEBuilder) With(name string, cols ...string) *CTEBuilder { + cteb.name = name + cteb.cols = cols + cteb.marker = cteMarkerAfterWith + return cteb +} + +// As sets the builder to select data. +func (cteb *CTEBuilder) As(builder Builder) *CTEBuilder { + cteb.builderVar = cteb.args.Add(builder) + cteb.marker = cteMarkerAfterAs + return cteb +} + +// Select creates a new SelectBuilder to build a SELECT statement using this CTE. +func (cteb *CTEBuilder) Select(col ...string) *SelectBuilder { + sb := cteb.args.Flavor.NewSelectBuilder() + return sb.With(cteb).Select(col...) +} + +// String returns the compiled CTE string. +func (cteb *CTEBuilder) String() string { + sql, _ := cteb.Build() + return sql +} + +// Build returns compiled CTE string and args. +func (cteb *CTEBuilder) Build() (sql string, args []interface{}) { + return cteb.BuildWithFlavor(cteb.args.Flavor) +} + +// BuildWithFlavor builds a CTE with the specified flavor and initial arguments. +func (cteb *CTEBuilder) BuildWithFlavor(flavor Flavor, initialArg ...interface{}) (sql string, args []interface{}) { + buf := newStringBuilder() + cteb.injection.WriteTo(buf, cteMarkerInit) + + if cteb.name != "" { + buf.WriteLeadingString("WITH ") + buf.WriteString(cteb.name) + + if len(cteb.cols) > 0 { + buf.WriteLeadingString("(") + buf.WriteStrings(cteb.cols, ", ") + buf.WriteString(")") + } + + cteb.injection.WriteTo(buf, cteMarkerAfterWith) + } + + if cteb.builderVar != "" { + buf.WriteLeadingString("AS (") + buf.WriteString(cteb.builderVar) + buf.WriteRune(')') + + cteb.injection.WriteTo(buf, cteMarkerAfterAs) + } + + return cteb.args.CompileWithFlavor(buf.String(), flavor, initialArg...) +} + +// SetFlavor sets the flavor of compiled sql. +func (cteb *CTEBuilder) SetFlavor(flavor Flavor) (old Flavor) { + old = cteb.args.Flavor + cteb.args.Flavor = flavor + return +} + +// Var returns a placeholder for value. +func (cteb *CTEBuilder) Var(arg interface{}) string { + return cteb.args.Add(arg) +} + +// SQL adds an arbitrary sql to current position. +func (cteb *CTEBuilder) SQL(sql string) *CTEBuilder { + cteb.injection.SQL(cteb.marker, sql) + return cteb +} + +// TableName returns the CTE table name. +func (cteb *CTEBuilder) TableName() string { + return cteb.name +} diff --git a/cte_test.go b/cte_test.go new file mode 100644 index 0000000..8852921 --- /dev/null +++ b/cte_test.go @@ -0,0 +1,65 @@ +// Copyright 2024 Huan Du. All rights reserved. +// Licensed under the MIT license that can be found in the LICENSE file. + +package sqlbuilder + +import ( + "fmt" + "testing" + + "github.com/huandu/go-assert" +) + +func ExampleWith() { + sb := With("users", "id", "name").As( + Select("id", "name").From("users").Where("name IS NOT NULL"), + ).Select("users.id", "orders.id").Join("orders", "users.id = orders.user_id") + + fmt.Println(sb) + + // Output: + // WITH users (id, name) AS (SELECT id, name FROM users WHERE name IS NOT NULL) SELECT users.id, orders.id FROM users JOIN orders ON users.id = orders.user_id +} + +func ExampleCTEBuilder() { + usersBuilder := Select("id", "name", "level").From("users") + usersBuilder.Where( + usersBuilder.GreaterEqualThan("level", 10), + ) + cteb := With("valid_users").As(usersBuilder) + fmt.Println(cteb) + + sb := Select("valid_users.id", "valid_users.name", "orders.id").With(cteb) + sb.Join("orders", "valid_users.id = orders.user_id") + sb.Where( + sb.LessEqualThan("orders.price", 200), + "valid_users.level < orders.min_level", + ).OrderBy("orders.price").Desc() + + sql, args := sb.Build() + fmt.Println(sql) + fmt.Println(args) + + // Output: + // WITH valid_users AS (SELECT id, name, level FROM users WHERE level >= ?) + // WITH valid_users AS (SELECT id, name, level FROM users WHERE level >= ?) SELECT valid_users.id, valid_users.name, orders.id FROM valid_users JOIN orders ON valid_users.id = orders.user_id WHERE orders.price <= ? AND valid_users.level < orders.min_level ORDER BY orders.price DESC + // [10 200] +} + +func TestCTEBuilder(t *testing.T) { + a := assert.New(t) + cteb := newCTEBuilder() + cteb.SQL("/* init */") + cteb.With("t", "a", "b") + cteb.SQL("/* after with */") + + // Make sure that calling Var() will not affect the As(). + cteb.Var(123) + + cteb.As(Select("a", "b").From("t")) + cteb.SQL("/* after as */") + + sql, args := cteb.Build() + a.Equal(sql, "/* init */ WITH t (a, b) /* after with */ AS (SELECT a, b FROM t) /* after as */") + a.Assert(args == nil) +} diff --git a/flavor.go b/flavor.go index b39edd0..3e52616 100644 --- a/flavor.go +++ b/flavor.go @@ -141,6 +141,13 @@ func (f Flavor) NewUnionBuilder() *UnionBuilder { return b } +// NewCTEBuilder creates a new CTE builder with flavor. +func (f Flavor) NewCTEBuilder() *CTEBuilder { + b := newCTEBuilder() + b.SetFlavor(f) + return b +} + // Quote adds quote for name to make sure the name can be used safely // as table name or field name. // diff --git a/select.go b/select.go index 4e449e1..a1dac32 100644 --- a/select.go +++ b/select.go @@ -11,6 +11,7 @@ import ( const ( selectMarkerInit injectionMarker = iota + selectMarkerAfterWith selectMarkerAfterSelect selectMarkerAfterFrom selectMarkerAfterJoin @@ -65,6 +66,7 @@ type SelectBuilder struct { whereClauseProxy *whereClauseProxy whereClauseExpr string + cteBuilder string distinct bool tables []string selectCols []string @@ -92,6 +94,14 @@ func Select(col ...string) *SelectBuilder { return DefaultFlavor.NewSelectBuilder().Select(col...) } +// With sets WITH clause (the Common Table Expression) before SELECT. +func (sb *SelectBuilder) With(builder *CTEBuilder) *SelectBuilder { + sb.marker = selectMarkerAfterWith + sb.cteBuilder = sb.Var(builder) + sb.tables = []string{builder.TableName()} + return sb +} + // Select sets columns in SELECT. func (sb *SelectBuilder) Select(col ...string) *SelectBuilder { sb.selectCols = col @@ -269,6 +279,11 @@ func (sb *SelectBuilder) BuildWithFlavor(flavor Flavor, initialArg ...interface{ oraclePage := flavor == Oracle && (sb.limit >= 0 || sb.offset >= 0) + if sb.cteBuilder != "" { + buf.WriteLeadingString(sb.cteBuilder) + sb.injection.WriteTo(buf, selectMarkerAfterWith) + } + if len(sb.selectCols) > 0 { buf.WriteLeadingString("SELECT ") diff --git a/struct_test.go b/struct_test.go index 38db38c..68a5306 100644 --- a/struct_test.go +++ b/struct_test.go @@ -638,9 +638,7 @@ func ExampleStruct_buildDELETE() { // Prepare DELETE query. user := &User{ - ID: 1234, - Name: "Huan Du", - Status: 1, + ID: 1234, } b := userStruct.DeleteFrom("user") b.Where(b.Equal("id", user.ID)) diff --git a/union.go b/union.go index 0c5b331..fe559c4 100644 --- a/union.go +++ b/union.go @@ -37,7 +37,7 @@ func newUnionBuilder() *UnionBuilder { // UnionBuilder is a builder to build UNION. type UnionBuilder struct { opt string - builders []Builder + builderVars []string orderByCols []string order string limit int @@ -72,8 +72,14 @@ func (ub *UnionBuilder) UnionAll(builders ...Builder) *UnionBuilder { } func (ub *UnionBuilder) union(opt string, builders ...Builder) *UnionBuilder { + builderVars := make([]string, 0, len(builders)) + + for _, b := range builders { + builderVars = append(builderVars, ub.Var(b)) + } + ub.opt = opt - ub.builders = builders + ub.builderVars = builderVars ub.marker = unionMarkerAfterUnion return ub } @@ -131,25 +137,25 @@ func (ub *UnionBuilder) BuildWithFlavor(flavor Flavor, initialArg ...interface{} buf := newStringBuilder() ub.injection.WriteTo(buf, unionMarkerInit) - if len(ub.builders) > 0 { + if len(ub.builderVars) > 0 { needParen := flavor != SQLite if needParen { buf.WriteLeadingString("(") - buf.WriteString(ub.Var(ub.builders[0])) + buf.WriteString(ub.builderVars[0]) buf.WriteRune(')') } else { - buf.WriteLeadingString(ub.Var(ub.builders[0])) + buf.WriteLeadingString(ub.builderVars[0]) } - for _, b := range ub.builders[1:] { + for _, b := range ub.builderVars[1:] { buf.WriteString(ub.opt) if needParen { buf.WriteRune('(') } - buf.WriteString(ub.Var(b)) + buf.WriteString(b) if needParen { buf.WriteRune(')') From a0af5e4368db74dbc907f70c5cc4ca4821ec3e63 Mon Sep 17 00:00:00 2001 From: Huan Du Date: Wed, 24 Jul 2024 16:54:41 +0800 Subject: [PATCH 2/2] support multiple tables in a WITH clause --- cte.go | 63 +++++++++++-------------------- cte_test.go | 38 +++++++++++++------ ctetable.go | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++ flavor.go | 7 ++++ select.go | 2 +- 5 files changed, 162 insertions(+), 54 deletions(-) create mode 100644 ctetable.go diff --git a/cte.go b/cte.go index ebc530f..8599aba 100644 --- a/cte.go +++ b/cte.go @@ -6,12 +6,11 @@ package sqlbuilder const ( cteMarkerInit injectionMarker = iota cteMarkerAfterWith - cteMarkerAfterAs ) // With creates a new CTE builder with default flavor. -func With(name string, cols ...string) *CTEBuilder { - return DefaultFlavor.NewCTEBuilder().With(name, cols...) +func With(tables ...*CTETableBuilder) *CTEBuilder { + return DefaultFlavor.NewCTEBuilder().With(tables...) } func newCTEBuilder() *CTEBuilder { @@ -23,9 +22,8 @@ func newCTEBuilder() *CTEBuilder { // CTEBuilder is a CTE (Common Table Expression) builder. type CTEBuilder struct { - name string - cols []string - builderVar string + tableNames []string + tableBuilderVars []string args *Args @@ -36,17 +34,18 @@ type CTEBuilder struct { var _ Builder = new(CTEBuilder) // With sets the CTE name and columns. -func (cteb *CTEBuilder) With(name string, cols ...string) *CTEBuilder { - cteb.name = name - cteb.cols = cols - cteb.marker = cteMarkerAfterWith - return cteb -} +func (cteb *CTEBuilder) With(tables ...*CTETableBuilder) *CTEBuilder { + tableNames := make([]string, 0, len(tables)) + tableBuilderVars := make([]string, 0, len(tables)) -// As sets the builder to select data. -func (cteb *CTEBuilder) As(builder Builder) *CTEBuilder { - cteb.builderVar = cteb.args.Add(builder) - cteb.marker = cteMarkerAfterAs + for _, table := range tables { + tableNames = append(tableNames, table.TableName()) + tableBuilderVars = append(tableBuilderVars, cteb.args.Add(table)) + } + + cteb.tableNames = tableNames + cteb.tableBuilderVars = tableBuilderVars + cteb.marker = cteMarkerAfterWith return cteb } @@ -72,27 +71,12 @@ func (cteb *CTEBuilder) BuildWithFlavor(flavor Flavor, initialArg ...interface{} buf := newStringBuilder() cteb.injection.WriteTo(buf, cteMarkerInit) - if cteb.name != "" { + if len(cteb.tableBuilderVars) > 0 { buf.WriteLeadingString("WITH ") - buf.WriteString(cteb.name) - - if len(cteb.cols) > 0 { - buf.WriteLeadingString("(") - buf.WriteStrings(cteb.cols, ", ") - buf.WriteString(")") - } - - cteb.injection.WriteTo(buf, cteMarkerAfterWith) - } - - if cteb.builderVar != "" { - buf.WriteLeadingString("AS (") - buf.WriteString(cteb.builderVar) - buf.WriteRune(')') - - cteb.injection.WriteTo(buf, cteMarkerAfterAs) + buf.WriteStrings(cteb.tableBuilderVars, ", ") } + cteb.injection.WriteTo(buf, cteMarkerAfterWith) return cteb.args.CompileWithFlavor(buf.String(), flavor, initialArg...) } @@ -103,18 +87,13 @@ func (cteb *CTEBuilder) SetFlavor(flavor Flavor) (old Flavor) { return } -// Var returns a placeholder for value. -func (cteb *CTEBuilder) Var(arg interface{}) string { - return cteb.args.Add(arg) -} - // SQL adds an arbitrary sql to current position. func (cteb *CTEBuilder) SQL(sql string) *CTEBuilder { cteb.injection.SQL(cteb.marker, sql) return cteb } -// TableName returns the CTE table name. -func (cteb *CTEBuilder) TableName() string { - return cteb.name +// TableNames returns all table names in a CTE. +func (cteb *CTEBuilder) TableNames() []string { + return cteb.tableNames } diff --git a/cte_test.go b/cte_test.go index 8852921..6ffc3c4 100644 --- a/cte_test.go +++ b/cte_test.go @@ -11,14 +11,23 @@ import ( ) func ExampleWith() { - sb := With("users", "id", "name").As( - Select("id", "name").From("users").Where("name IS NOT NULL"), - ).Select("users.id", "orders.id").Join("orders", "users.id = orders.user_id") + sb := With( + CTETable("users", "id", "name").As( + Select("id", "name").From("users").Where("name IS NOT NULL"), + ), + CTETable("devices").As( + Select("device_id").From("devices"), + ), + ).Select("users.id", "orders.id", "devices.device_id").Join( + "orders", + "users.id = orders.user_id", + "devices.device_id = orders.device_id", + ) fmt.Println(sb) // Output: - // WITH users (id, name) AS (SELECT id, name FROM users WHERE name IS NOT NULL) SELECT users.id, orders.id FROM users JOIN orders ON users.id = orders.user_id + // WITH users (id, name) AS (SELECT id, name FROM users WHERE name IS NOT NULL), devices AS (SELECT device_id FROM devices) SELECT users.id, orders.id, devices.device_id FROM users, devices JOIN orders ON users.id = orders.user_id AND devices.device_id = orders.device_id } func ExampleCTEBuilder() { @@ -26,7 +35,9 @@ func ExampleCTEBuilder() { usersBuilder.Where( usersBuilder.GreaterEqualThan("level", 10), ) - cteb := With("valid_users").As(usersBuilder) + cteb := With( + CTETable("valid_users").As(usersBuilder), + ) fmt.Println(cteb) sb := Select("valid_users.id", "valid_users.name", "orders.id").With(cteb) @@ -49,17 +60,22 @@ func ExampleCTEBuilder() { func TestCTEBuilder(t *testing.T) { a := assert.New(t) cteb := newCTEBuilder() + ctetb := newCTETableBuilder() cteb.SQL("/* init */") - cteb.With("t", "a", "b") + cteb.With(ctetb) cteb.SQL("/* after with */") - // Make sure that calling Var() will not affect the As(). - cteb.Var(123) + ctetb.SQL("/* table init */") + ctetb.Table("t", "a", "b") + ctetb.SQL("/* after table */") - cteb.As(Select("a", "b").From("t")) - cteb.SQL("/* after as */") + ctetb.As(Select("a", "b").From("t")) + ctetb.SQL("/* after table as */") sql, args := cteb.Build() - a.Equal(sql, "/* init */ WITH t (a, b) /* after with */ AS (SELECT a, b FROM t) /* after as */") + a.Equal(sql, "/* init */ WITH /* table init */ t (a, b) /* after table */ AS (SELECT a, b FROM t) /* after table as */ /* after with */") a.Assert(args == nil) + + sql = ctetb.String() + a.Equal(sql, "/* table init */ t (a, b) /* after table */ AS (SELECT a, b FROM t) /* after table as */") } diff --git a/ctetable.go b/ctetable.go new file mode 100644 index 0000000..8fbd70a --- /dev/null +++ b/ctetable.go @@ -0,0 +1,106 @@ +// Copyright 2024 Huan Du. All rights reserved. +// Licensed under the MIT license that can be found in the LICENSE file. + +package sqlbuilder + +const ( + cteTableMarkerInit injectionMarker = iota + cteTableMarkerAfterTable + cteTableMarkerAfterAs +) + +// CTETable creates a new CTE table builder with default flavor. +func CTETable(name string, cols ...string) *CTETableBuilder { + return DefaultFlavor.NewCTETableBuilder().Table(name, cols...) +} + +func newCTETableBuilder() *CTETableBuilder { + return &CTETableBuilder{ + args: &Args{}, + injection: newInjection(), + } +} + +// CTETableBuilder is a builder to build one table in CTE (Common Table Expression). +type CTETableBuilder struct { + name string + cols []string + builderVar string + + args *Args + + injection *injection + marker injectionMarker +} + +// Table sets the table name and columns in a CTE table. +func (ctetb *CTETableBuilder) Table(name string, cols ...string) *CTETableBuilder { + ctetb.name = name + ctetb.cols = cols + ctetb.marker = cteTableMarkerAfterTable + return ctetb +} + +// As sets the builder to select data. +func (ctetb *CTETableBuilder) As(builder Builder) *CTETableBuilder { + ctetb.builderVar = ctetb.args.Add(builder) + ctetb.marker = cteTableMarkerAfterAs + return ctetb +} + +// String returns the compiled CTE string. +func (ctetb *CTETableBuilder) String() string { + sql, _ := ctetb.Build() + return sql +} + +// Build returns compiled CTE string and args. +func (ctetb *CTETableBuilder) Build() (sql string, args []interface{}) { + return ctetb.BuildWithFlavor(ctetb.args.Flavor) +} + +// BuildWithFlavor builds a CTE with the specified flavor and initial arguments. +func (ctetb *CTETableBuilder) BuildWithFlavor(flavor Flavor, initialArg ...interface{}) (sql string, args []interface{}) { + buf := newStringBuilder() + ctetb.injection.WriteTo(buf, cteTableMarkerInit) + + if ctetb.name != "" { + buf.WriteLeadingString(ctetb.name) + + if len(ctetb.cols) > 0 { + buf.WriteLeadingString("(") + buf.WriteStrings(ctetb.cols, ", ") + buf.WriteString(")") + } + + ctetb.injection.WriteTo(buf, cteTableMarkerAfterTable) + } + + if ctetb.builderVar != "" { + buf.WriteLeadingString("AS (") + buf.WriteString(ctetb.builderVar) + buf.WriteRune(')') + + ctetb.injection.WriteTo(buf, cteTableMarkerAfterAs) + } + + return ctetb.args.CompileWithFlavor(buf.String(), flavor, initialArg...) +} + +// SetFlavor sets the flavor of compiled sql. +func (ctetb *CTETableBuilder) SetFlavor(flavor Flavor) (old Flavor) { + old = ctetb.args.Flavor + ctetb.args.Flavor = flavor + return +} + +// SQL adds an arbitrary sql to current position. +func (ctetb *CTETableBuilder) SQL(sql string) *CTETableBuilder { + ctetb.injection.SQL(ctetb.marker, sql) + return ctetb +} + +// TableName returns the CTE table name. +func (ctetb *CTETableBuilder) TableName() string { + return ctetb.name +} diff --git a/flavor.go b/flavor.go index 3e52616..c5dc63a 100644 --- a/flavor.go +++ b/flavor.go @@ -148,6 +148,13 @@ func (f Flavor) NewCTEBuilder() *CTEBuilder { return b } +// NewCTETableBuilder creates a new CTE table builder with flavor. +func (f Flavor) NewCTETableBuilder() *CTETableBuilder { + b := newCTETableBuilder() + b.SetFlavor(f) + return b +} + // Quote adds quote for name to make sure the name can be used safely // as table name or field name. // diff --git a/select.go b/select.go index a1dac32..4c2ff47 100644 --- a/select.go +++ b/select.go @@ -98,7 +98,7 @@ func Select(col ...string) *SelectBuilder { func (sb *SelectBuilder) With(builder *CTEBuilder) *SelectBuilder { sb.marker = selectMarkerAfterWith sb.cteBuilder = sb.Var(builder) - sb.tables = []string{builder.TableName()} + sb.tables = builder.TableNames() return sb }