From 9cc811661a6445b2dc4fc977a98e126aec21766a Mon Sep 17 00:00:00 2001 From: Viacheslav Poturaev Date: Wed, 28 Apr 2021 21:48:05 +0200 Subject: [PATCH] Enable identifier quoting in mapper (#6) --- .github/workflows/bench.yml | 52 ++++++++++++++----- .github/workflows/golangci-lint.yml | 4 +- .github/workflows/test-unit.yml | 31 +++++++++--- .gitignore | 6 ++- .golangci.yml | 4 ++ Makefile | 10 ++-- README.md | 54 +++++++++++++++++++- dev_test.go | 2 +- doc.go | 2 +- example_mapper_test.go | 43 +++++++++++++++- example_test.go | 78 +++++++++++++++++++++++++++++ go.mod | 6 +-- go.sum | 20 ++++---- mapper.go | 9 +++- referencer.go | 24 ++++++++- referencer_test.go | 14 ++++-- storage.go | 49 +++++++++++++++--- storage_test.go | 37 ++++++++++++-- 18 files changed, 381 insertions(+), 64 deletions(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 88cef99..46100ea 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -1,14 +1,20 @@ name: bench on: - push: - tags: - - v* - branches: - - master - - main pull_request: + workflow_dispatch: + inputs: + old: + description: 'Old Ref' + required: false + default: 'master' + new: + description: 'New Ref' + required: true + env: GO111MODULE: "on" + CACHE_BENCHMARK: "off" # Enables benchmark result reuse between runs, may skew latency results. + RUN_BASE_BENCHMARK: "on" # Runs benchmark for PR base in case benchmark result is missing. jobs: bench: strategy: @@ -22,21 +28,28 @@ jobs: go-version: ${{ matrix.go-version }} - name: Checkout code uses: actions/checkout@v2 - - name: Restore vendor + with: + ref: ${{ (github.event.inputs.new != '') && github.event.inputs.new || github.event.ref }} + - name: Go cache uses: actions/cache@v2 with: + # In order: + # * Module download cache + # * Build cache (Linux) path: | - vendor - key: ${{ runner.os }}-go${{ matrix.go-version }}-vendor-${{ hashFiles('**/go.mod') }} - - name: Populate dependencies - run: | - (test -d vendor && echo vendor found) || (go mod vendor && du -sh vendor && du -sh ~/go/pkg/mod) + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-cache-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-cache - name: Restore benchstat uses: actions/cache@v2 with: path: ~/go/bin/benchstat key: ${{ runner.os }}-benchstat - name: Restore base benchmark result + if: env.CACHE_BENCHMARK == 'on' + id: benchmark-base uses: actions/cache@v2 with: path: | @@ -44,10 +57,23 @@ jobs: bench-main.txt # Use base sha for PR or new commit hash for master/main push in benchmark result key. key: ${{ runner.os }}-bench-${{ (github.event.pull_request.base.sha != github.event.after) && github.event.pull_request.base.sha || github.event.after }} + - name: Checkout base code + if: env.RUN_BASE_BENCHMARK == 'on' && steps.benchmark-base.outputs.cache-hit != 'true' && (github.event.pull_request.base.sha != '' || github.event.inputs.old != '') + uses: actions/checkout@v2 + with: + ref: ${{ (github.event.pull_request.base.sha != '' ) && github.event.pull_request.base.sha || github.event.inputs.old }} + path: __base + - name: Run base benchmark + if: env.RUN_BASE_BENCHMARK == 'on' && steps.benchmark-base.outputs.cache-hit != 'true' && (github.event.pull_request.base.sha != '' || github.event.inputs.old != '') + run: | + export REF_NAME=master + cd __base + BENCH_COUNT=5 make bench-run bench-stat + cp bench-master.txt ../bench-master.txt - name: Benchmark id: bench run: | - export REF_NAME=${GITHUB_REF##*/} + export REF_NAME=new BENCH_COUNT=5 make bench-run bench-stat OUTPUT=$(make bench-stat) OUTPUT="${OUTPUT//'%'/'%25'}" diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 8a23dae..b51abbb 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -14,10 +14,10 @@ jobs: steps: - uses: actions/checkout@v2 - name: golangci-lint - uses: golangci/golangci-lint-action@v2.4.0 + uses: golangci/golangci-lint-action@v2.5.2 with: # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. - version: v1.37.0 + version: v1.39.0 # Optional: working directory, useful for monorepos # working-directory: somedir diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index ba1b816..0eb5807 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -7,6 +7,7 @@ on: pull_request: env: GO111MODULE: "on" + RUN_BASE_COVERAGE: "on" # Runs test for PR base in case base test coverage is missing. jobs: test: strategy: @@ -20,22 +21,38 @@ jobs: go-version: ${{ matrix.go-version }} - name: Checkout code uses: actions/checkout@v2 + - name: Go cache + uses: actions/cache@v2 + with: + # In order: + # * Module download cache + # * Build cache (Linux) + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-cache-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-cache - name: Restore base test coverage + if: matrix.go-version == '1.16.x' uses: actions/cache@v2 with: path: | unit-base.txt # Use base sha for PR or new commit hash for master/main push in test result key. key: ${{ runner.os }}-unit-test-coverage-${{ (github.event.pull_request.base.sha != github.event.after) && github.event.pull_request.base.sha || github.event.after }} - - name: Restore vendor - uses: actions/cache@v2 + - name: Checkout base code + if: matrix.go-version == '1.16.x' && env.RUN_BASE_COVERAGE == 'on' && steps.benchmark-base.outputs.cache-hit != 'true' && github.event.pull_request.base.sha != '' + uses: actions/checkout@v2 with: - path: | - vendor - key: ${{ runner.os }}-go${{ matrix.go-version }}-vendor-${{ hashFiles('**/go.mod') }} - - name: Populate dependencies + ref: ${{ github.event.pull_request.base.sha }} + path: __base + - name: Run test for base code + if: matrix.go-version == '1.16.x' && env.RUN_BASE_COVERAGE == 'on' && steps.benchmark-base.outputs.cache-hit != 'true' && github.event.pull_request.base.sha != '' run: | - (test -d vendor && echo vendor found) || (go mod vendor && du -sh vendor && du -sh ~/go/pkg/mod) + cd __base + make test-unit + go tool cover -func=./unit.coverprofile | sed -e 's/.go:[0-9]*:\t/.go\t/g' | sed -e 's/\t\t*/\t/g' > ../unit-base.txt - name: Test id: test run: | diff --git a/.gitignore b/.gitignore index 135a0ea..513a049 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /.idea -/bench-* -/unit.coverprofile \ No newline at end of file +/*.coverprofile +/.vscode +/bench-*.txt +/vendor diff --git a/.golangci.yml b/.golangci.yml index 510fbbf..3c4c317 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -28,6 +28,10 @@ linters: - paralleltest - forbidigo - exhaustivestruct + - interfacer # deprecated + - forcetypeassert + - scopelint # deprecated + - ifshort # too many false positives issues: exclude-use-default: false diff --git a/Makefile b/Makefile index 3cabc1e..44ea5c9 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +#GOLANGCI_LINT_VERSION := "v1.39.0" # Optional configuration to pinpoint golangci-lint version. + # The head of Makefile determines location of dev-go to include standard targets. GO ?= go export GO111MODULE = on @@ -26,12 +28,12 @@ ifeq ($(DEVGO_PATH),) endif -include $(DEVGO_PATH)/makefiles/main.mk +-include $(DEVGO_PATH)/makefiles/lint.mk -include $(DEVGO_PATH)/makefiles/test-unit.mk -include $(DEVGO_PATH)/makefiles/bench.mk --include $(DEVGO_PATH)/makefiles/lint.mk --include $(DEVGO_PATH)/makefiles/github-actions.mk +-include $(DEVGO_PATH)/makefiles/reset-ci.mk + +# Add your custom targets here. ## Run tests test: test-unit - - diff --git a/README.md b/README.md index 4903d68..f984657 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,23 @@ This module integrates [`github.com/Masterminds/squirrel`](https://github.com/Ma and [`github.com/jmoiron/sqlx`](https://github.com/jmoiron/sqlx) to allow seamless operation based on field tags of row structure. +All three libraries collaborate with standard `database/sql` and do not take away low level control from user. + This library helps to eliminate literal string column references (e.g. `"created_at"`) and use field references -instead (e.g. `s.Col(&row, &row.CreatedAt)` and other mapping functions). +instead (e.g. `rf.Ref(&row.CreatedAt)` and other mapping functions). Field tags (`db` by default) act as a source of truth for column names to allow better maintainability and fewer errors. +## Components + +`Storage` is a high level service that provides query building, query executing and result fetching facilities +as easy to use facades. + +`Mapper` is a lower level tool that focuses on managing `squirrel` query builder with row structures. + +`Referencer` helps to build complex statements by providing fully qualified and properly escaped names for +participating columns. + ## Simple CRUD ```go @@ -135,3 +147,43 @@ fmt.Println(args) ## Omitting Zero Values +When building `WHERE` conditions from row structure it is often needed skip empty fields from condition. + +Behavior with empty fields (zero values) can be controlled via `omitempty` field tag flag and `sqluct.IgnoreOmitEmpty`, +`sqluct.SkipZeroValues` options. + +Please check example below to learn about behavior differences. + +```go +var s sqluct.Storage + +type Product struct { + ID int `db:"id,omitempty"` + Name string `db:"name,omitempty"` + Price int `db:"price"` +} + +query, args, err := s.SelectStmt("products", Product{}).Where(s.WhereEq(Product{ + ID: 123, + Price: 0, +})).ToSql() +fmt.Println(query, args, err) +// This query skips `name` in where condition for its zero value and `omitempty` flag. +// SELECT id, name, price FROM products WHERE id = $1 AND price = $2 [123 0] + +query, args, err = s.SelectStmt("products", Product{}).Where(s.WhereEq(Product{ + ID: 123, + Price: 0, +}, sqluct.IgnoreOmitEmpty)).ToSql() +fmt.Println(query, args, err) +// This query adds `name` in where condition because IgnoreOmitEmpty is applied and `omitempty` flag is ignored. +// SELECT id, name, price FROM products WHERE id = $1 AND name = $2 AND price = $3 [123 0] + +query, args, err = s.SelectStmt("products", Product{}).Where(s.WhereEq(Product{ + ID: 123, + Price: 0, +}, sqluct.SkipZeroValues)).ToSql() +fmt.Println(query, args, err) +// This query adds skips both price and name from where condition because SkipZeroValues option is applied. +// SELECT id, name, price FROM products WHERE id = $1 [123] +``` \ No newline at end of file diff --git a/dev_test.go b/dev_test.go index 80e7541..b13c426 100644 --- a/dev_test.go +++ b/dev_test.go @@ -1,3 +1,3 @@ package sqluct_test -import _ "github.com/bool64/dev" +import _ "github.com/bool64/dev" // Include CI/Dev scripts to project. diff --git a/doc.go b/doc.go index 90d8d52..a274c23 100644 --- a/doc.go +++ b/doc.go @@ -1,2 +1,2 @@ -// Package sqluct provides integration of sqlx and squirrel.v1 with Go structures. +// Package sqluct provides integration of sqlx and squirrel with Go structures. package sqluct diff --git a/example_mapper_test.go b/example_mapper_test.go index f138912..b107108 100644 --- a/example_mapper_test.go +++ b/example_mapper_test.go @@ -125,11 +125,50 @@ func ExampleMapper_WhereEq() { o.UserID = 123 q := sm. - Select(squirrel.Select(), o). + Select(squirrel.Select().From("orders"), o). Where(sm.WhereEq(o.OrderData)) query, args, err := q.ToSql() fmt.Println(query, args, err) - // Output: SELECT order_id, amount, user_id WHERE amount = ? AND user_id = ? [100 123] + // Output: SELECT order_id, amount, user_id FROM orders WHERE amount = ? AND user_id = ? [100 123] +} + +func ExampleMapper_WhereEq_columnsOf() { + sm := sqluct.Mapper{} + + type OrderData struct { + Amount int `db:"amount"` + UserID int `db:"user_id,omitempty"` + } + + type Order struct { + ID int `db:"id"` + OrderData + } + + type User struct { + ID int `db:"id"` + Name string `db:"name"` + } + + rf := sqluct.Referencer{} + o := &Order{} + u := &User{} + + rf.AddTableAlias(o, "orders") + rf.AddTableAlias(u, "users") + + q := sm. + Select(squirrel.Select().From(rf.Ref(o)), o, rf.ColumnsOf(o)). + Join(rf.Fmt("%s ON %s = %s", u, &o.UserID, &u.ID)). + Where(sm.WhereEq(OrderData{ + Amount: 100, + UserID: 123, + }, rf.ColumnsOf(o))) + + query, args, err := q.ToSql() + fmt.Println(query, args, err) + + // Output: SELECT orders.id, orders.amount, orders.user_id FROM orders JOIN users ON orders.user_id = users.id WHERE orders.amount = ? AND orders.user_id = ? [100 123] } diff --git a/example_test.go b/example_test.go index f363b11..2d47445 100644 --- a/example_test.go +++ b/example_test.go @@ -194,3 +194,81 @@ func ExampleStorage_UpdateStmt() { log.Fatal(err) } } + +func ExampleStorage_Select_join() { + var s sqluct.Storage + + type OrderData struct { + Amount int `db:"amount"` + UserID int `db:"user_id,omitempty"` + } + + type Order struct { + ID int `db:"id"` + OrderData + } + + type User struct { + ID int `db:"id"` + Name string `db:"name"` + } + + rf := s.Ref() + o := &Order{} + u := &User{} + + rf.AddTableAlias(o, "orders") + rf.AddTableAlias(u, "users") + + q := s.SelectStmt(rf.Ref(o), o, rf.ColumnsOf(o)). + Columns(rf.Ref(&u.Name)). + Join(rf.Fmt("%s ON %s = %s", u, &o.UserID, &u.ID)). + Where(s.WhereEq(OrderData{ + Amount: 100, + UserID: 123, + }, rf.ColumnsOf(o))) + + query, args, err := q.ToSql() + fmt.Println(query, args, err) + + // Output: SELECT orders.id, orders.amount, orders.user_id, users.name FROM orders JOIN users ON orders.user_id = users.id WHERE orders.amount = $1 AND orders.user_id = $2 [100 123] +} + +func ExampleSkipZeroValues() { + var s sqluct.Storage + + type Product struct { + ID int `db:"id,omitempty"` + Name string `db:"name,omitempty"` + Price int `db:"price"` + } + + query, args, err := s.SelectStmt("products", Product{}).Where(s.WhereEq(Product{ + ID: 123, + Price: 0, + })).ToSql() + fmt.Println(query, args, err) + // This query skips `name` in where condition for its zero value and `omitempty` flag. + // SELECT id, name, price FROM products WHERE id = $1 AND price = $2 [123 0] + + query, args, err = s.SelectStmt("products", Product{}).Where(s.WhereEq(Product{ + ID: 123, + Price: 0, + }, sqluct.IgnoreOmitEmpty)).ToSql() + fmt.Println(query, args, err) + // This query adds `name` in where condition because IgnoreOmitEmpty is applied and `omitempty` flag is ignored. + // SELECT id, name, price FROM products WHERE id = $1 AND name = $2 AND price = $3 [123 0] + + query, args, err = s.SelectStmt("products", Product{}).Where(s.WhereEq(Product{ + ID: 123, + Price: 0, + }, sqluct.SkipZeroValues)).ToSql() + fmt.Println(query, args, err) + // This query adds skips both price and name from where condition because SkipZeroValues option is applied. + // SELECT id, name, price FROM products WHERE id = $1 [123] + + // Output: + // SELECT id, name, price FROM products WHERE id = $1 AND price = $2 [123 0] + // SELECT id, name, price FROM products WHERE id = $1 AND name = $2 AND price = $3 [123 0] + // SELECT id, name, price FROM products WHERE id = $1 [123] +} diff --git a/go.mod b/go.mod index 74f8c7f..2176618 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.13 require ( github.com/DATA-DOG/go-sqlmock v1.5.0 github.com/Masterminds/squirrel v1.5.0 - github.com/bool64/ctxd v0.1.3 - github.com/bool64/dev v0.1.19 - github.com/jmoiron/sqlx v1.3.1 + github.com/bool64/ctxd v0.1.4 + github.com/bool64/dev v0.1.27 + github.com/jmoiron/sqlx v1.3.3 github.com/stretchr/testify v1.6.1 ) diff --git a/go.sum b/go.sum index 791f094..b191ec1 100644 --- a/go.sum +++ b/go.sum @@ -2,19 +2,19 @@ github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20O github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/Masterminds/squirrel v1.5.0 h1:JukIZisrUXadA9pl3rMkjhiamxiB0cXiu+HGp/Y8cY8= github.com/Masterminds/squirrel v1.5.0/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= -github.com/bool64/ctxd v0.1.3 h1:n+aQ6UdoZXlltETFXqIhZR2DoMxhMYE2CW9BQn8aNBY= -github.com/bool64/ctxd v0.1.3/go.mod h1:rhUkoNE4mKFSJmo9l+78u2j+FVQifRCj0MHRhyZ2GDA= -github.com/bool64/dev v0.1.0/go.mod h1:pn52JC52uSgpazChx9CeXyG+S3sW2V36HHoLNBbscdg= -github.com/bool64/dev v0.1.10/go.mod h1:pn52JC52uSgpazChx9CeXyG+S3sW2V36HHoLNBbscdg= -github.com/bool64/dev v0.1.19 h1:SLAOGmJIrbroYjkGuV0MBLtww2NddzPDne1C7ATJdJk= -github.com/bool64/dev v0.1.19/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU= +github.com/bool64/ctxd v0.1.4 h1:UxL3FCpnLP/h6cZFKBGxAL9TXMd45l8QZQdG35D7BlU= +github.com/bool64/ctxd v0.1.4/go.mod h1:vbCBsEfD4TGXGTPEEQwjB4M5Ny2MC8r+9N2JSP3yJPY= +github.com/bool64/dev v0.1.25/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU= +github.com/bool64/dev v0.1.26/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU= +github.com/bool64/dev v0.1.27 h1:Cx4g/QLtVVmmfKZfyxAfOGKqgT86tqUbFB1vRN6JbfM= +github.com/bool64/dev v0.1.27/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/jmoiron/sqlx v1.3.1 h1:aLN7YINNZ7cYOPK3QC83dbM6KT0NMqVMw961TqrejlE= -github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= +github.com/jmoiron/sqlx v1.3.3 h1:j82X0bf7oQ27XeqxicSZsTU5suPwKElg3oyxNn43iTk= +github.com/jmoiron/sqlx v1.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= @@ -30,8 +30,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/swaggest/usecase v0.0.0-20200928062416-27f47131b0f8 h1:XGJJai6ngqYpy/cTMvGreiO+jradIn1uq7Q5hY2OCF4= -github.com/swaggest/usecase v0.0.0-20200928062416-27f47131b0f8/go.mod h1:rcngDv7OaBXZyEXdEtimcDeNon7sq3iqLm9hxT06s3c= +github.com/swaggest/usecase v0.1.5 h1:xMDWXnYGysVaF2f3ZnmDsn2FlZ8fd3FJD+O+8wl4aNQ= +github.com/swaggest/usecase v0.1.5/go.mod h1:uubX4ZbjQK1Bnl0xX9hOYpb/IUiSoVKk/yQImawbNMU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/mapper.go b/mapper.go index 453db3e..16db1da 100644 --- a/mapper.go +++ b/mapper.go @@ -63,6 +63,9 @@ type Options struct { // OrderDesc instructs mapper to use DESC order in Product func. OrderDesc bool + + // PrepareColumn allows control of column quotation or aliasing. + PrepareColumn func(col string) string } // Insert adds struct value or slice of struct values to squirrel.InsertBuilder. @@ -316,7 +319,11 @@ func (sm *Mapper) ColumnsValues(v reflect.Value, options ...func(*Options)) ([]s values = append(values, val) } - columns = append(columns, fi.Name) + if o.PrepareColumn != nil { + columns = append(columns, o.PrepareColumn(fi.Name)) + } else { + columns = append(columns, fi.Name) + } } return columns, values diff --git a/referencer.go b/referencer.go index 9a691fe..f586ae5 100644 --- a/referencer.go +++ b/referencer.go @@ -8,7 +8,6 @@ import ( // QuoteANSI adds double quotes to symbols names. // // Suitable for PostgreSQL, MySQL in ANSI SQL_MODE, SQLite statements. -// Used in Referencer by default. func QuoteANSI(tableAndColumn ...string) string { res := strings.Builder{} @@ -45,6 +44,8 @@ func QuoteBackticks(tableAndColumn ...string) string { } // QuoteNoop does not add any quotes to symbol names. +// +// Used in Referencer by default. func QuoteNoop(tableAndColumn ...string) string { return strings.Join(tableAndColumn, ".") } @@ -60,6 +61,27 @@ type Referencer struct { refs map[interface{}]string } +// ColumnsOf makes a Mapper option to prefix columns with table alias. +// +// Argument is either a structure pointer or string alias. +func (r *Referencer) ColumnsOf(rowStructPtr interface{}) func(o *Options) { + table, isString := rowStructPtr.(string) + if !isString { + t, found := r.refs[rowStructPtr] + if !found { + panic("row structure pointer needs to be added first with AddTableAlias") + } + + table = t + } + + return func(o *Options) { + o.PrepareColumn = func(col string) string { + return r.Q(table, col) + } + } +} + // AddTableAlias creates string references for row pointer and all suitable field pointers in it. func (r *Referencer) AddTableAlias(rowStructPtr interface{}, alias string) { f, err := r.Mapper.FindColumnNames(rowStructPtr) diff --git a/referencer_test.go b/referencer_test.go index c59ff07..1c2d328 100644 --- a/referencer_test.go +++ b/referencer_test.go @@ -31,6 +31,8 @@ func TestReferencer_Fmt(t *testing.T) { dr := &DirectReport{} rf.AddTableAlias(dr, "dr") + m := sqluct.Mapper{} + // Find direct reports that share same last name and manager is not named John. qb := squirrel.StatementBuilder.Select(rf.Fmt("%s, %s", &dr.ManagerID, &dr.EmployeeID)). From(rf.Fmt("%s AS %s", rf.Q("users"), manager)). @@ -39,15 +41,17 @@ func TestReferencer_Fmt(t *testing.T) { &dr.ManagerID, &manager.ID, &dr.EmployeeID, &employee.ID)). Where(rf.Fmt("%s = %s", &manager.LastName, &employee.LastName)). - Where(rf.Fmt("%s != ?", &manager.FirstName), "John") + Where(rf.Fmt("%s != ?", &manager.FirstName), "John"). + Where(m.WhereEq(User{FirstName: "Larry", LastName: "Page"}, rf.ColumnsOf(employee), sqluct.SkipZeroValues)) stmt, args, err := qb.ToSql() assert.NoError(t, err) assert.Equal(t, `SELECT dr.manager_id, dr.employee_id `+ `FROM users AS manager `+ `INNER JOIN direct_reports AS dr ON dr.manager_id = manager.id AND dr.employee_id = employee.id `+ - `WHERE manager.last_name = employee.last_name AND manager.first_name != ?`, stmt) - assert.Equal(t, []interface{}{"John"}, args) + `WHERE manager.last_name = employee.last_name AND manager.first_name != ? `+ + `AND employee.first_name = ? AND employee.last_name = ?`, stmt) + assert.Equal(t, []interface{}{"John", "Larry", "Page"}, args) } func TestReferencer_Ref(t *testing.T) { @@ -55,7 +59,7 @@ func TestReferencer_Ref(t *testing.T) { rf.IdentifierQuoter = sqluct.QuoteANSI row := &struct { - ID int `db:"id"` + ID int `db:"id,omitempty"` }{} rf.AddTableAlias(row, "some_table") @@ -111,7 +115,7 @@ func BenchmarkReferencer_Fmt_full(b *testing.B) { rf := sqluct.Referencer{} type User struct { - ID int `db:"id"` + ID int `db:"id,omitempty"` FirstName string `db:"first_name"` LastName string `db:"last_name"` } diff --git a/storage.go b/storage.go index b83a061..2275075 100644 --- a/storage.go +++ b/storage.go @@ -27,8 +27,7 @@ func (s StringStatement) ToSql() (string, []interface{}, error) { // nolint // M // NewStorage creates an instance of Storage. func NewStorage(db *sqlx.DB) *Storage { return &Storage{ - db: db, - Format: squirrel.Dollar, + db: db, } } @@ -205,32 +204,68 @@ func (s *Storage) Select(ctx context.Context, qb ToSQL, dest interface{}) (err e // QueryBuilder returns query builder with placeholder format. func (s *Storage) QueryBuilder() squirrel.StatementBuilderType { - return squirrel.StatementBuilder.PlaceholderFormat(s.Format).RunWith(s.db) + format := s.Format + + if format == nil { + format = squirrel.Dollar + } + + return squirrel.StatementBuilder.PlaceholderFormat(format).RunWith(s.db) +} + +func (s *Storage) options(options []func(*Options)) []func(*Options) { + if s.IdentifierQuoter != nil { + options = append(options, func(options *Options) { + if options.PrepareColumn == nil { + options.PrepareColumn = func(col string) string { + return s.IdentifierQuoter(col) + } + } + }) + } + + return options } // SelectStmt makes a select query builder. func (s *Storage) SelectStmt(tableName string, columns interface{}, options ...func(*Options)) squirrel.SelectBuilder { + if s.IdentifierQuoter != nil { + tableName = s.IdentifierQuoter(tableName) + } + qb := s.QueryBuilder().Select().From(tableName) - return s.Mapper.Select(qb, columns, options...) + return s.Mapper.Select(qb, columns, s.options(options)...) } // InsertStmt makes an insert query builder. func (s *Storage) InsertStmt(tableName string, val interface{}, options ...func(*Options)) squirrel.InsertBuilder { + if s.IdentifierQuoter != nil { + tableName = s.IdentifierQuoter(tableName) + } + qb := s.QueryBuilder().Insert(tableName) - return s.Mapper.Insert(qb, val, options...) + return s.Mapper.Insert(qb, val, s.options(options)...) } // UpdateStmt makes an update query builder. func (s *Storage) UpdateStmt(tableName string, val interface{}, options ...func(*Options)) squirrel.UpdateBuilder { + if s.IdentifierQuoter != nil { + tableName = s.IdentifierQuoter(tableName) + } + qb := s.QueryBuilder().Update(tableName) - return s.Mapper.Update(qb, val, options...) + return s.Mapper.Update(qb, val, s.options(options)...) } // DeleteStmt makes a delete query builder. func (s *Storage) DeleteStmt(tableName string) squirrel.DeleteBuilder { + if s.IdentifierQuoter != nil { + tableName = s.IdentifierQuoter(tableName) + } + return s.QueryBuilder().Delete(tableName) } @@ -254,7 +289,7 @@ func (s *Storage) Ref() *Referencer { // WhereEq maps struct values as conditions to squirrel.Eq. func (s *Storage) WhereEq(conditions interface{}, options ...func(*Options)) squirrel.Eq { - return s.Mapper.WhereEq(conditions, options...) + return s.Mapper.WhereEq(conditions, s.options(options)...) } func (s *Storage) error(ctx context.Context, err error) error { diff --git a/storage_test.go b/storage_test.go index ac40137..699823e 100644 --- a/storage_test.go +++ b/storage_test.go @@ -251,14 +251,15 @@ func TestStorage_ExecContext(t *testing.T) { assert.True(t, traceFinished) } -func TestStorage_DeleteStmt(t *testing.T) { +func TestStorage_DeleteStmt_backticks(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) st := sqluct.NewStorage(sqlx.NewDb(db, "mock")) st.Format = squirrel.Dollar + st.IdentifierQuoter = sqluct.QuoteBackticks - mock.ExpectExec("DELETE FROM table"). + mock.ExpectExec("DELETE FROM `table`"). WillReturnResult(sqlmock.NewResult(0, 1)) _, err = st.DeleteStmt("table").Exec() @@ -266,14 +267,15 @@ func TestStorage_DeleteStmt(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -func TestStorage_InsertStmt(t *testing.T) { +func TestStorage_InsertStmt_backticks(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) st := sqluct.NewStorage(sqlx.NewDb(db, "mock")) st.Format = squirrel.Dollar + st.IdentifierQuoter = sqluct.QuoteBackticks - mock.ExpectExec("INSERT INTO table \\(order_id,amount\\) VALUES \\(\\$1,\\$2\\)"). + mock.ExpectExec("INSERT INTO `table` \\(`order_id`,`amount`\\) VALUES \\(\\$1,\\$2\\)"). WithArgs(10, 20).WillReturnResult(sqlmock.NewResult(1, 1)) _, err = st.InsertStmt("table", struct { @@ -298,6 +300,21 @@ func TestStorage_UpdateStmt(t *testing.T) { assert.Equal(t, []interface{}{10, 20}, args) } +func TestStorage_UpdateStmt_ansi(t *testing.T) { + st := sqluct.Storage{ + Format: squirrel.Dollar, + } + st.IdentifierQuoter = sqluct.QuoteANSI + + query, args, err := st.UpdateStmt("table", struct { + OrderID int `db:"order_id"` + Amount int `db:"amount"` + }{10, 20}).ToSql() + assert.NoError(t, err) + assert.Equal(t, query, `UPDATE "table" SET "order_id" = $1, "amount" = $2`) + assert.Equal(t, []interface{}{10, 20}, args) +} + func TestStorage_SelectStmt(t *testing.T) { st := sqluct.NewStorage(nil) @@ -308,3 +325,15 @@ func TestStorage_SelectStmt(t *testing.T) { assert.NoError(t, err) assert.Equal(t, query, "SELECT order_id, amount FROM table") } + +func TestStorage_SelectStmt_backticks(t *testing.T) { + st := sqluct.NewStorage(nil) + st.IdentifierQuoter = sqluct.QuoteBackticks + + query, _, err := st.SelectStmt("table", struct { + OrderID int `db:"order_id"` + Amount int `db:"amount"` + }{}).ToSql() + assert.NoError(t, err) + assert.Equal(t, query, "SELECT `order_id`, `amount` FROM `table`") +}