diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..ca0781379 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,100 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "master", "develop" ] + pull_request: + branches: [ "master", "develop" ] + schedule: + - cron: '0 21 * * *' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..23dd94ee8 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,80 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '0 21 * * *' + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled. + if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request' + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + # Uncomment the permissions below if installing in a private repository. + # contents: read + # actions: read + + steps: + - name: "Checkout code" + uses: actions/checkout@v4.2.2 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@v2.4.1 + with: + results_file: results.sarif + results_format: sarif + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecard on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore + # file_mode: git + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@v4.6.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard (optional). + # Commenting out will disable upload of results to your repo's Code Scanning dashboard + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/.github/workflows/sonarcloud.yaml b/.github/workflows/sonarcloud.yaml deleted file mode 100644 index d88af5109..000000000 --- a/.github/workflows/sonarcloud.yaml +++ /dev/null @@ -1,53 +0,0 @@ -name: Sonarcloud Scan - -on: - schedule: - # Weekly on Saturdays. - - cron: '30 1 * * 6' - push: - branches: [ master ] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -# Declare default permissions as read only. -permissions: read - -jobs: - analysis: - name: Scorecards analysis - runs-on: ubuntu-latest - permissions: - # Needed to upload the results to code-scanning dashboard. - security-events: write - # Used to receive a badge. (Upcoming feature) - id-token: write - # Needs for private repositories. - contents: read - actions: read - - steps: - - name: "Checkout code" - uses: actions/checkout@v5 - with: - persist-credentials: false - - - name: "Run analysis" - uses: ossf/scorecard-action@v2.4.0 # v2.4.0 - with: - results_file: results.sarif - results_format: sarif - publish_results: true - - - name: "Upload artifact" - uses: actions/upload-artifact@v4 - with: - name: SARIF file - path: results.sarif - retention-days: 5 - - - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@3ebbd71c74ef574dbc558c82f70e52732c8b44fe # v2.2.1 - with: - sarif_file: results.sarif diff --git a/cmd/gf/internal/cmd/cmd_run.go b/cmd/gf/internal/cmd/cmd_run.go index 490a93a8b..64223d98a 100644 --- a/cmd/gf/internal/cmd/cmd_run.go +++ b/cmd/gf/internal/cmd/cmd_run.go @@ -13,6 +13,7 @@ import ( "path/filepath" "runtime" "strings" + "time" "github.com/gogf/gf/v2/container/gtype" "github.com/gogf/gf/v2/frame/g" @@ -207,8 +208,37 @@ func (app *cRunApp) End(ctx context.Context, sig os.Signal, outputPath string) { // Delete the binary file. // firstly, kill the process. if process != nil { - if err := process.Kill(); err != nil { - mlog.Debugf("kill process error: %s", err.Error()) + if sig != nil && runtime.GOOS != "windows" { + if err := process.Signal(sig); err != nil { + mlog.Debugf("send signal to process error: %s", err.Error()) + if err := process.Kill(); err != nil { + mlog.Debugf("kill process error: %s", err.Error()) + } + } else { + waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { + select { + case <-waitCtx.Done(): + done <- waitCtx.Err() + case done <- process.Wait(): + } + }() + err := <-done + if err != nil { + mlog.Debugf("process wait error: %s", err.Error()) + if err := process.Kill(); err != nil { + mlog.Debugf("kill process error: %s", err.Error()) + } + } else { + mlog.Debug("process exited gracefully") + } + } + } else { + if err := process.Kill(); err != nil { + mlog.Debugf("kill process error: %s", err.Error()) + } } } if err := gfile.RemoveFile(outputPath); err != nil { diff --git a/cmd/gf/internal/cmd/cmd_up.go b/cmd/gf/internal/cmd/cmd_up.go index 4a43e5b6a..a0ab6c2b0 100644 --- a/cmd/gf/internal/cmd/cmd_up.go +++ b/cmd/gf/internal/cmd/cmd_up.go @@ -15,6 +15,7 @@ import ( "github.com/gogf/gf/v2/container/gset" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/genv" "github.com/gogf/gf/v2/os/gfile" "github.com/gogf/gf/v2/os/gproc" "github.com/gogf/gf/v2/text/gstr" @@ -39,7 +40,11 @@ gf up gf up -a gf up -c gf up -cf +gf up -a -m=install +gf up -a -m=install -p=github.com/gogf/gf/cmd/gf/v2@latest ` + cliMethodHttpDownload = "http" + cliMethodGoInstall = "install" ) func init() { @@ -49,10 +54,14 @@ func init() { } type cUpInput struct { - g.Meta `name:"up" config:"gfcli.up"` - All bool `name:"all" short:"a" brief:"upgrade both version and cli, auto fix codes" orphan:"true"` - Cli bool `name:"cli" short:"c" brief:"also upgrade CLI tool" orphan:"true"` - Fix bool `name:"fix" short:"f" brief:"auto fix codes(it only make sense if cli is to be upgraded)" orphan:"true"` + g.Meta `name:"up" config:"gfcli.up"` + All bool `name:"all" short:"a" brief:"upgrade both version and cli, auto fix codes" orphan:"true"` + Cli bool `name:"cli" short:"c" brief:"also upgrade CLI tool" orphan:"true"` + Fix bool `name:"fix" short:"f" brief:"auto fix codes(it only make sense if cli is to be upgraded)" orphan:"true"` + CliDownloadingMethod string `name:"cli-download-method" short:"m" brief:"cli upgrade method: http=download binary via HTTP GET, install=upgrade via go install" d:"http"` + // CliModulePath specifies the module path for CLI installation via go install. + // This is used when CliDownloadingMethod is set to "install". + CliModulePath string `name:"cli-module-path" short:"p" brief:"custom cli module path for upgrade CLI tool with go install method" d:"github.com/gogf/gf/cmd/gf/v2@latest"` } type cUpOutput struct{} @@ -76,7 +85,7 @@ func (c cUp) Index(ctx context.Context, in cUpInput) (out *cUpOutput, err error) } if in.Cli { - if err = c.doUpgradeCLI(ctx); err != nil { + if err = c.doUpgradeCLI(ctx, in); err != nil { return nil, err } } @@ -170,8 +179,22 @@ func (c cUp) doUpgradeVersion(ctx context.Context, in cUpInput) (out *doUpgradeV } // doUpgradeCLI downloads the new version binary with process. -func (c cUp) doUpgradeCLI(ctx context.Context) (err error) { +func (c cUp) doUpgradeCLI(ctx context.Context, in cUpInput) (err error) { mlog.Print(`start upgrading cli...`) + fmt.Println(` cli upgrade method:`, in.CliDownloadingMethod) + switch in.CliDownloadingMethod { + case cliMethodHttpDownload: + return c.doUpgradeCLIWithHttpDownload(ctx) + case cliMethodGoInstall: + return c.doUpgradeCLIWithGoInstall(ctx, in) + default: + mlog.Fatalf(`invalid cli upgrade method: "%s", please use "http" or "install"`, in.CliDownloadingMethod) + } + return +} + +func (c cUp) doUpgradeCLIWithHttpDownload(ctx context.Context) (err error) { + mlog.Print(`start upgrading cli with http get download...`) var ( downloadUrl = fmt.Sprintf( `https://github.com/gogf/gf/releases/latest/download/gf_%s_%s`, @@ -213,6 +236,41 @@ func (c cUp) doUpgradeCLI(ctx context.Context) (err error) { return } +func (c cUp) doUpgradeCLIWithGoInstall(ctx context.Context, in cUpInput) (err error) { + mlog.Print(`upgrading cli with go install...`) + if !genv.Contains("GOPATH") { + mlog.Fatal(`"GOPATH" environment variable does not exist, please check your go installation`) + } + + command := fmt.Sprintf(`go install %s`, in.CliModulePath) + mlog.Printf(`running command: %s`, command) + err = gproc.ShellRun(ctx, command) + if err != nil { + return err + } + + cliFilePath := gfile.Join(genv.Get("GOPATH").String(), "bin/gf") + if runtime.GOOS == "windows" { + cliFilePath += ".exe" + } + + // It fails if file not exist or its size is less than 1MB. + if !gfile.Exists(cliFilePath) || gfile.Size(cliFilePath) < 1024*1024 { + mlog.Fatalf(`go install %s failed, "%s" does not exist or its size is less than 1MB`, in.CliModulePath, cliFilePath) + } + + newFile, err := gfile.Open(cliFilePath) + if err != nil { + return err + } + // selfupdate + err = selfupdate.Apply(newFile, selfupdate.Options{}) + if err != nil { + return err + } + return +} + func (c cUp) doAutoFixing(ctx context.Context, dirPath string, version string) (err error) { mlog.Printf(`auto fixing directory path "%s" from version "%s" ...`, dirPath, version) command := fmt.Sprintf(`gf fix -p %s`, dirPath) diff --git a/container/gqueue/gqueue.go b/container/gqueue/gqueue.go index e20e0ec94..65443a5da 100644 --- a/container/gqueue/gqueue.go +++ b/container/gqueue/gqueue.go @@ -89,7 +89,7 @@ func (q *Queue) Close() { if q.limit > 0 { close(q.C) } else { - for i := 0; i < defaultBatchSize; i++ { + for range defaultBatchSize { q.Pop() } } @@ -103,6 +103,12 @@ func (q *Queue) Len() (length int64) { if q.limit > 0 { return bufferedSize } + // If the queue is unlimited and the buffered size is exactly the default size, + // it means there might be some data in the list not synchronized to channel yet. + // So we need to add 1 to the buffered size to make the result more accurate. + if bufferedSize == defaultQueueSize { + bufferedSize++ + } return int64(q.list.Size()) + bufferedSize } @@ -126,7 +132,7 @@ func (q *Queue) asyncLoopFromListToChannel() { if bufferLength := q.list.Len(); bufferLength > 0 { // When q.C is closed, it will panic here, especially q.C is being blocked for writing. // If any error occurs here, it will be caught by recover and be ignored. - for i := 0; i < bufferLength; i++ { + for range bufferLength { q.C <- q.list.PopFront() } } else { diff --git a/container/gqueue/gqueue_z_unit_test.go b/container/gqueue/gqueue_z_unit_test.go index cf87f7a48..88ed6f962 100644 --- a/container/gqueue/gqueue_z_unit_test.go +++ b/container/gqueue/gqueue_z_unit_test.go @@ -24,7 +24,7 @@ func TestQueue_Len(t *testing.T) { ) for n := 10; n < maxTries; n++ { q1 := gqueue.New(maxNum) - for i := 0; i < maxNum; i++ { + for i := range maxNum { q1.Push(i) } t.Assert(q1.Len(), maxNum) @@ -38,7 +38,7 @@ func TestQueue_Len(t *testing.T) { ) for n := 10; n < maxTries; n++ { q1 := gqueue.New() - for i := 0; i < maxNum; i++ { + for i := range maxNum { q1.Push(i) } t.AssertLE(q1.Len(), maxNum) @@ -50,7 +50,8 @@ func TestQueue_Len(t *testing.T) { func TestQueue_Basic(t *testing.T) { gtest.C(t, func(t *gtest.T) { q := gqueue.New() - for i := 0; i < 100; i++ { + defer q.Close() + for i := range 100 { q.Push(i) } t.Assert(q.Pop(), 0) @@ -61,6 +62,7 @@ func TestQueue_Basic(t *testing.T) { func TestQueue_Pop(t *testing.T) { gtest.C(t, func(t *gtest.T) { q1 := gqueue.New() + defer q1.Close() q1.Push(1) q1.Push(2) q1.Push(3) @@ -73,27 +75,28 @@ func TestQueue_Pop(t *testing.T) { func TestQueue_Close(t *testing.T) { gtest.C(t, func(t *gtest.T) { q1 := gqueue.New() + defer q1.Close() q1.Push(1) q1.Push(2) // wait sync to channel time.Sleep(10 * time.Millisecond) t.Assert(q1.Len(), 2) - q1.Close() }) gtest.C(t, func(t *gtest.T) { q1 := gqueue.New(2) + defer q1.Close() q1.Push(1) q1.Push(2) // wait sync to channel time.Sleep(10 * time.Millisecond) t.Assert(q1.Len(), 2) - q1.Close() }) } func Test_Issue2509(t *testing.T) { gtest.C(t, func(t *gtest.T) { q := gqueue.New() + defer q.Close() q.Push(1) q.Push(2) q.Push(3) @@ -106,3 +109,22 @@ func Test_Issue2509(t *testing.T) { t.Assert(q.Len(), 0) }) } + +// Issue #4376 +func TestIssue4376(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + gq := gqueue.New() + defer gq.Close() + cq := make(chan int, 100000) + defer close(cq) + + for i := range 11603 { + gq.Push(i) + cq <- i + } + // May be not equal because of the async channel reading goroutine. + t.Log(gq.Len(), len(cq)) + time.Sleep(50 * time.Millisecond) + t.Log(gq.Len(), len(cq)) + }) +} diff --git a/container/gvar/gvar_vars.go b/container/gvar/gvar_vars.go index af52f9c69..101e2f0b8 100644 --- a/container/gvar/gvar_vars.go +++ b/container/gvar/gvar_vars.go @@ -15,14 +15,25 @@ type Vars []*Var // Strings converts and returns `vs` as []string. func (vs Vars) Strings() (s []string) { + s = make([]string, 0, len(vs)) for _, v := range vs { s = append(s, v.String()) } return s } +// Bools converts and returns `vs` as []bool. +func (vs Vars) Bools() (s []bool) { + s = make([]bool, 0, len(vs)) + for _, v := range vs { + s = append(s, v.Bool()) + } + return s +} + // Interfaces converts and returns `vs` as []any. func (vs Vars) Interfaces() (s []any) { + s = make([]any, 0, len(vs)) for _, v := range vs { s = append(s, v.Val()) } @@ -31,6 +42,7 @@ func (vs Vars) Interfaces() (s []any) { // Float32s converts and returns `vs` as []float32. func (vs Vars) Float32s() (s []float32) { + s = make([]float32, 0, len(vs)) for _, v := range vs { s = append(s, v.Float32()) } @@ -39,6 +51,7 @@ func (vs Vars) Float32s() (s []float32) { // Float64s converts and returns `vs` as []float64. func (vs Vars) Float64s() (s []float64) { + s = make([]float64, 0, len(vs)) for _, v := range vs { s = append(s, v.Float64()) } @@ -47,6 +60,7 @@ func (vs Vars) Float64s() (s []float64) { // Ints converts and returns `vs` as []Int. func (vs Vars) Ints() (s []int) { + s = make([]int, 0, len(vs)) for _, v := range vs { s = append(s, v.Int()) } @@ -55,6 +69,7 @@ func (vs Vars) Ints() (s []int) { // Int8s converts and returns `vs` as []int8. func (vs Vars) Int8s() (s []int8) { + s = make([]int8, 0, len(vs)) for _, v := range vs { s = append(s, v.Int8()) } @@ -63,6 +78,7 @@ func (vs Vars) Int8s() (s []int8) { // Int16s converts and returns `vs` as []int16. func (vs Vars) Int16s() (s []int16) { + s = make([]int16, 0, len(vs)) for _, v := range vs { s = append(s, v.Int16()) } @@ -71,6 +87,7 @@ func (vs Vars) Int16s() (s []int16) { // Int32s converts and returns `vs` as []int32. func (vs Vars) Int32s() (s []int32) { + s = make([]int32, 0, len(vs)) for _, v := range vs { s = append(s, v.Int32()) } @@ -79,6 +96,7 @@ func (vs Vars) Int32s() (s []int32) { // Int64s converts and returns `vs` as []int64. func (vs Vars) Int64s() (s []int64) { + s = make([]int64, 0, len(vs)) for _, v := range vs { s = append(s, v.Int64()) } @@ -87,6 +105,7 @@ func (vs Vars) Int64s() (s []int64) { // Uints converts and returns `vs` as []uint. func (vs Vars) Uints() (s []uint) { + s = make([]uint, 0, len(vs)) for _, v := range vs { s = append(s, v.Uint()) } @@ -95,6 +114,7 @@ func (vs Vars) Uints() (s []uint) { // Uint8s converts and returns `vs` as []uint8. func (vs Vars) Uint8s() (s []uint8) { + s = make([]uint8, 0, len(vs)) for _, v := range vs { s = append(s, v.Uint8()) } @@ -103,6 +123,7 @@ func (vs Vars) Uint8s() (s []uint8) { // Uint16s converts and returns `vs` as []uint16. func (vs Vars) Uint16s() (s []uint16) { + s = make([]uint16, 0, len(vs)) for _, v := range vs { s = append(s, v.Uint16()) } @@ -111,6 +132,7 @@ func (vs Vars) Uint16s() (s []uint16) { // Uint32s converts and returns `vs` as []uint32. func (vs Vars) Uint32s() (s []uint32) { + s = make([]uint32, 0, len(vs)) for _, v := range vs { s = append(s, v.Uint32()) } @@ -119,6 +141,7 @@ func (vs Vars) Uint32s() (s []uint32) { // Uint64s converts and returns `vs` as []uint64. func (vs Vars) Uint64s() (s []uint64) { + s = make([]uint64, 0, len(vs)) for _, v := range vs { s = append(s, v.Uint64()) } diff --git a/container/gvar/gvar_z_unit_vars_test.go b/container/gvar/gvar_z_unit_vars_test.go index 4933f540c..7db2cb421 100644 --- a/container/gvar/gvar_z_unit_vars_test.go +++ b/container/gvar/gvar_z_unit_vars_test.go @@ -22,6 +22,7 @@ func TestVars(t *testing.T) { gvar.New(3), } t.AssertEQ(vs.Strings(), []string{"1", "2", "3"}) + t.AssertEQ(vs.Bools(), []bool{true, true, true}) t.AssertEQ(vs.Interfaces(), []any{1, 2, 3}) t.AssertEQ(vs.Float32s(), []float32{1, 2, 3}) t.AssertEQ(vs.Float64s(), []float64{1, 2, 3}) @@ -38,6 +39,46 @@ func TestVars(t *testing.T) { }) } +func TestVars_Bools(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + // Test with various boolean-like values + var vs = gvar.Vars{ + gvar.New(true), + gvar.New(false), + gvar.New(1), + gvar.New(0), + gvar.New("true"), + gvar.New("false"), + gvar.New("1"), + gvar.New("0"), + } + expected := []bool{true, false, true, false, true, false, true, false} + t.AssertEQ(vs.Bools(), expected) + }) +} + +func TestVars_Empty(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + // Test with empty Vars + var vs = gvar.Vars{} + t.AssertEQ(vs.Strings(), []string{}) + t.AssertEQ(vs.Bools(), []bool{}) + t.AssertEQ(vs.Interfaces(), []any{}) + t.AssertEQ(vs.Ints(), []int{}) + t.AssertEQ(vs.Float64s(), []float64{}) + }) +} + +func TestVars_SingleElement(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + // Test with single element + var vs = gvar.Vars{gvar.New(42)} + t.AssertEQ(vs.Strings(), []string{"42"}) + t.AssertEQ(vs.Bools(), []bool{true}) + t.AssertEQ(vs.Ints(), []int{42}) + }) +} + func TestVars_Scan(t *testing.T) { gtest.C(t, func(t *gtest.T) { type User struct { diff --git a/contrib/drivers/README.MD b/contrib/drivers/README.MD index eaf4966f7..da22dc56c 100644 --- a/contrib/drivers/README.MD +++ b/contrib/drivers/README.MD @@ -83,7 +83,6 @@ import _ "github.com/gogf/gf/contrib/drivers/mssql/v2" Note: - It does not support `Replace` features. -- It does not support `LastInsertId`. - It supports server version >= `SQL Server2005` - It ONLY supports datetime2 and datetimeoffset types for auto handling created_at/updated_at/deleted_at columns, because datetime type does not support microseconds precision when column value is passed as string. diff --git a/contrib/drivers/README.zh_CN.MD b/contrib/drivers/README.zh_CN.MD index 27699c0b5..4c7b4f08a 100644 --- a/contrib/drivers/README.zh_CN.MD +++ b/contrib/drivers/README.zh_CN.MD @@ -81,7 +81,6 @@ import _ "github.com/gogf/gf/contrib/drivers/mssql/v2" 注意: - 不支持 `Replace` 功能。 -- 不支持 `LastInsertId`。 - 仅支持服务器版本 >= `SQL Server2005` - 仅支持 datetime2 和 datetimeoffset 类型来自动处理 created_at/updated_at/deleted_at 列,因为 datetime 类型在将列值作为字符串传递时不支持微秒精度。 diff --git a/contrib/drivers/mssql/mssql_do_exec.go b/contrib/drivers/mssql/mssql_do_exec.go new file mode 100644 index 000000000..a0d3fde39 --- /dev/null +++ b/contrib/drivers/mssql/mssql_do_exec.go @@ -0,0 +1,191 @@ +package mssql + +import ( + "context" + "database/sql" + "fmt" + "regexp" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" +) + +const ( + backIdInsertHeadDefault = "INSERT INTO" + backIdInsertHeadInsertIgnore = "INSERT IGNORE INTO" + + autoIncrementName = "identity" + mssqlOutPutKey = "OUTPUT" + mssqlInsertedObjName = "INSERTED" + mssqlAffectFd = " 1 as AffectCount" + affectCountFieldName = "AffectCount" + mssqlPrimaryKeyName = "PRIMARY KEY" + fdId = "ID" + positionInsertValues = ") VALUES" // find the position of the string "VALUES" in the INSERT SQL statement to embed output code for retrieving the last inserted ID +) + +// DoExec commits the sql string and its arguments to underlying driver +// through given link object and returns the execution result. +func (d *Driver) DoExec(ctx context.Context, link gdb.Link, sqlStr string, args ...interface{}) (result sql.Result, err error) { + // Transaction checks. + if link == nil { + if tx := gdb.TXFromCtx(ctx, d.GetGroup()); tx != nil { + // Firstly, check and retrieve transaction link from context. + link = &txLinkMssql{tx.GetSqlTX()} + } else if link, err = d.Core.MasterLink(); err != nil { + // Or else it creates one from master node. + return nil, err + } + } else if !link.IsTransaction() { + // If current link is not transaction link, it checks and retrieves transaction from context. + if tx := gdb.TXFromCtx(ctx, d.GetGroup()); tx != nil { + link = &txLinkMssql{tx.GetSqlTX()} + } + } + + // SQL filtering. + sqlStr, args = d.Core.FormatSqlBeforeExecuting(sqlStr, args) + sqlStr, args, err = d.DoFilter(ctx, link, sqlStr, args) + if err != nil { + return nil, err + } + + if !(strings.HasPrefix(sqlStr, backIdInsertHeadDefault) || strings.HasPrefix(sqlStr, backIdInsertHeadInsertIgnore)) { + return d.Core.DoExec(ctx, link, sqlStr, args) + } + // find the first pos + pos := strings.Index(sqlStr, positionInsertValues) + + table := d.GetTableNameFromSql(sqlStr) + outPutSql := d.GetInsertOutputSql(ctx, table) + // rebuild sql add output + var ( + sqlValueBefore = sqlStr[:pos+1] + sqlValueAfter = sqlStr[pos+1:] + ) + + sqlStr = fmt.Sprintf("%s%s%s", sqlValueBefore, outPutSql, sqlValueAfter) + + // fmt.Println("sql str:", sqlStr) + // Link execution. + var out gdb.DoCommitOutput + out, err = d.DoCommit(ctx, gdb.DoCommitInput{ + Link: link, + Sql: sqlStr, + Args: args, + Stmt: nil, + Type: gdb.SqlTypeQueryContext, + IsTransaction: link.IsTransaction(), + }) + if err != nil { + return &InsertResult{lastInsertId: 0, rowsAffected: 0, err: err}, err + } + var ( + aCount int64 // affect count + lId int64 // last insert id + ) + stdSqlResult := out.Records + if len(stdSqlResult) == 0 { + err = gerror.WrapCode(gcode.CodeDbOperationError, gerror.New("affectcount is zero"), `sql.Result.RowsAffected failed`) + return &InsertResult{lastInsertId: 0, rowsAffected: 0, err: err}, err + } + // get affect count + aCount = stdSqlResult[0].GMap().GetVar(affectCountFieldName).Int64() + // get last_insert_id + lId = stdSqlResult[0].GMap().GetVar(fdId).Int64() + + return &InsertResult{lastInsertId: lId, rowsAffected: aCount}, err +} + +// GetTableNameFromSql get table name from sql statement +// It handles table string like: +// "user" +// "user u" +// "DbLog.dbo.user", +// "user as u". +func (d *Driver) GetTableNameFromSql(sqlStr string) (table string) { + // INSERT INTO "ip_to_id"("ip") OUTPUT 1 as AffectCount,INSERTED.id as ID VALUES(?) + leftChars, rightChars := d.GetChars() + trimStr := leftChars + rightChars + "[] " + pattern := "INTO(.+?)\\(" + regCompile := regexp.MustCompile(pattern) + tableInfo := regCompile.FindStringSubmatch(sqlStr) + //get the first one. after the first it may be content of the value, it's not table name. + table = tableInfo[1] + table = strings.Trim(table, " ") + if strings.Contains(table, ".") { + tmpAry := strings.Split(table, ".") + // the last one is tablename + table = tmpAry[len(tmpAry)-1] + } else if strings.Contains(table, "as") || strings.Contains(table, " ") { + tmpAry := strings.Split(table, "as") + if len(tmpAry) < 2 { + tmpAry = strings.Split(table, " ") + } + // get the first one + table = tmpAry[0] + } + table = strings.Trim(table, trimStr) + return table +} + +// txLink is used to implement interface Link for TX. +type txLinkMssql struct { + *sql.Tx +} + +// IsTransaction returns if current Link is a transaction. +func (l *txLinkMssql) IsTransaction() bool { + return true +} + +// IsOnMaster checks and returns whether current link is operated on master node. +// Note that, transaction operation is always operated on master node. +func (l *txLinkMssql) IsOnMaster() bool { + return true +} + +// InsertResult instance of sql.Result +type InsertResult struct { + lastInsertId int64 + rowsAffected int64 + err error +} + +func (r *InsertResult) LastInsertId() (int64, error) { + return r.lastInsertId, r.err +} + +func (r *InsertResult) RowsAffected() (int64, error) { + return r.rowsAffected, r.err +} + +// GetInsertOutputSql gen get last_insert_id code +func (m *Driver) GetInsertOutputSql(ctx context.Context, table string) string { + fds, errFd := m.GetDB().TableFields(ctx, table) + if errFd != nil { + return "" + } + extraSqlAry := make([]string, 0) + extraSqlAry = append(extraSqlAry, fmt.Sprintf(" %s %s", mssqlOutPutKey, mssqlAffectFd)) + incrNo := 0 + if len(fds) > 0 { + for _, fd := range fds { + // has primary key and is auto-increment + if fd.Extra == autoIncrementName && fd.Key == mssqlPrimaryKeyName && !fd.Null { + incrNoStr := "" + if incrNo == 0 { // fixed first field named id, convenient to get + incrNoStr = fmt.Sprintf(" as %s", fdId) + } + + extraSqlAry = append(extraSqlAry, fmt.Sprintf("%s.%s%s", mssqlInsertedObjName, fd.Name, incrNoStr)) + incrNo++ + } + // fmt.Printf("null:%t name:%s key:%s k:%s \n", fd.Null, fd.Name, fd.Key, k) + } + } + return strings.Join(extraSqlAry, ",") + // sql example:INSERT INTO "ip_to_id"("ip") OUTPUT 1 as AffectCount,INSERTED.id as ID VALUES(?) +} diff --git a/contrib/drivers/mssql/mssql_z_unit_basic_test.go b/contrib/drivers/mssql/mssql_z_unit_basic_test.go index d39116a3c..3c7ed7235 100644 --- a/contrib/drivers/mssql/mssql_z_unit_basic_test.go +++ b/contrib/drivers/mssql/mssql_z_unit_basic_test.go @@ -13,11 +13,15 @@ import ( "testing" "time" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/encoding/gxml" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/test/gtest" + + "github.com/gogf/gf/contrib/drivers/mssql/v2" ) func TestTables(t *testing.T) { @@ -148,6 +152,56 @@ func TestDoInsert(t *testing.T) { }) } +func TestDoInsertGetId(t *testing.T) { + // create test table + createInsertAndGetIdTableForTest() + gtest.C(t, func(t *gtest.T) { + table := "ip_to_id" + data := map[string]interface{}{ + "ip": "192.168.179.1", + } + id, err := db.InsertAndGetId(gctx.New(), table, data) + t.AssertNil(err) + t.AssertGT(id, 0) + // fmt.Println("id:", id) + + // multiple insert test + dataAry := []map[string]interface{}{{"ip": "192.168.5.9"}, {"ip": "192.168.5.10"}} + id1, err1 := db.InsertAndGetId(gctx.New(), table, dataAry) + t.AssertNil(err1) + t.AssertGT(id1, 0) + }) +} + +func TestGetTableFromSql(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + okTable := "ip_to_id" + sqlStr := "INSERT INTO \"ip_to_id\"(\"ip\") VALUES(?)" + dbWrapper, ok := db.GetCore().GetDB().(*gdb.DriverWrapperDB) + t.Assert(ok, true) + dbMssql, ok := dbWrapper.DB.(*mssql.Driver) + t.Assert(ok, true) + table := dbMssql.GetTableNameFromSql(sqlStr) + // fmt.Println("default table:", table) + t.Assert(table, okTable) + + sqlStr = "INSERT INTO \"MyLogDb\".\"dbo\".\"ip_to_id\"(\"ip\") VALUES(?)" + table = dbMssql.GetTableNameFromSql(sqlStr) + // fmt.Println("MyLogDb.dbo.ip_to_id table:", table) + t.Assert(table, okTable) + + sqlStr = "INSERT INTO \"ip_to_id\" as \"tt\" (\"ip\") VALUES(?)" + table = dbMssql.GetTableNameFromSql(sqlStr) + // fmt.Println("ip_to_id as tt table:", table) + t.Assert(table, okTable) + + sqlStr = "INSERT INTO \"ip_to_id\" \"tt\" (\"ip\") VALUES(?)" + table = dbMssql.GetTableNameFromSql(sqlStr) + // fmt.Println("ip_to_id tt table:", table) + t.Assert(table, okTable) + }) +} + func Test_DB_Ping(t *testing.T) { gtest.C(t, func(t *gtest.T) { err1 := db.PingMaster() diff --git a/contrib/drivers/mssql/mssql_z_unit_init_test.go b/contrib/drivers/mssql/mssql_z_unit_init_test.go index 08e925c5a..27a4db067 100644 --- a/contrib/drivers/mssql/mssql_z_unit_init_test.go +++ b/contrib/drivers/mssql/mssql_z_unit_init_test.go @@ -25,9 +25,14 @@ var ( ) const ( - TableSize = 10 - TestDbUser = "sa" - TestDbPass = "LoremIpsum86" + TableSize = 10 + TableName = "t_user" + TestSchema1 = "test1" + TestSchema2 = "test2" + TableNamePrefix1 = "gf_" + TestDbUser = "sa" + TestDbPass = "LoremIpsum86" + CreateTime = "2018-10-24 10:00:00" ) func init() { @@ -36,7 +41,7 @@ func init() { Port: "1433", User: TestDbUser, Pass: TestDbPass, - Name: "master", + Name: "test", Type: "mssql", Role: "master", Charset: "utf8", @@ -142,3 +147,27 @@ func dropTable(table string) { gtest.Fatal(err) } } + +// createInsertAndGetIdTableForTest test for InsertAndGetId +func createInsertAndGetIdTableForTest() (name string) { + + if _, err := db.Exec(context.Background(), ` +IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='ip_to_id' and xtype='U') +begin + CREATE TABLE [ip_to_id]( + [id] [int] IDENTITY(1,1) NOT NULL, + [ip] [varchar](128) NULL, + CONSTRAINT [PK_ip_to_id] PRIMARY KEY CLUSTERED + ( + [id] ASC + )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] + ) ON [PRIMARY] +end + `); err != nil { + gtest.Fatal(err) + } + + db.Schema(db.GetConfig().Name) + name = "ip_to_id" + return +} diff --git a/contrib/drivers/mysql/mysql_z_unit_core_bench_test.go b/contrib/drivers/mysql/mysql_z_unit_core_bench_test.go new file mode 100644 index 000000000..6c1b2fc53 --- /dev/null +++ b/contrib/drivers/mysql/mysql_z_unit_core_bench_test.go @@ -0,0 +1,46 @@ +// Copyright GoFrame Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +// go test *.go -bench=".*" + +package mysql_test + +import ( + "testing" + + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/grand" +) + +func Benchmark_BatchInsert(b *testing.B) { + table := createTable() + defer dropTable(table) + type User struct { + Id int `c:"id"` + Passport string `c:"passport"` + Password string `c:"password"` + NickName string `c:"nickname"` + CreateTime *gtime.Time `c:"create_time"` + } + var users []*User + for i := 0; i < 10000; i++ { + users = append(users, &User{ + Passport: grand.S(10), + Password: grand.S(10), + NickName: grand.S(10), + CreateTime: gtime.Now(), + }) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + result, err := db.Insert(ctx, table, users) + if err != nil { + b.Fatalf("insert error: %v", err) + } + n, _ := result.RowsAffected() + b.Logf("insert %d rows", n) + } +} diff --git a/contrib/drivers/mysql/mysql_z_unit_core_test.go b/contrib/drivers/mysql/mysql_z_unit_core_test.go index 604740606..b9334e445 100644 --- a/contrib/drivers/mysql/mysql_z_unit_core_test.go +++ b/contrib/drivers/mysql/mysql_z_unit_core_test.go @@ -466,6 +466,31 @@ func Test_DB_BatchInsert(t *testing.T) { n, _ := result.RowsAffected() t.Assert(n, 1) }) + + // Batch insert with different fields + gtest.C(t, func(t *gtest.T) { + table := createTable() + defer dropTable(table) + r, err := db.Insert(ctx, table, g.List{ + { + "id": 2, + "passport": "t2", + "password": "25d55ad283aa400af464c76d713c07ac", + "create_time": gtime.Now().String(), + }, + { + "id": 3, + "passport": "user_3", + "password": "25d55ad283aa400af464c76d713c07ad", + "nickname": "name_3", + "create_time": gtime.Now().String(), + }, + }, 1) + t.AssertNil(err) + n, err := r.RowsAffected() + t.AssertNil(err) + t.Assert(n, 2) + }) } func Test_DB_BatchInsert_Struct(t *testing.T) { diff --git a/database/gdb/gdb.go b/database/gdb/gdb.go index c19ce828d..d5925efd2 100644 --- a/database/gdb/gdb.go +++ b/database/gdb/gdb.go @@ -181,7 +181,7 @@ type DB interface { // GetArray executes a query and returns the first column of all rows. // It's useful for queries like SELECT id FROM table. - GetArray(ctx context.Context, sql string, args ...any) ([]Value, error) + GetArray(ctx context.Context, sql string, args ...any) (Array, error) // GetCount executes a COUNT query and returns the result as an integer. // It's a convenience method for counting rows. @@ -673,6 +673,9 @@ type ( // Value is the field value type. Value = *gvar.Var + // Array is the field value array type. + Array = gvar.Vars + // Record is the row record of the table. Record map[string]Value diff --git a/database/gdb/gdb_core.go b/database/gdb/gdb_core.go index 725935265..c335f7f20 100644 --- a/database/gdb/gdb_core.go +++ b/database/gdb/gdb_core.go @@ -12,6 +12,7 @@ import ( "database/sql" "fmt" "reflect" + "sort" "strings" "github.com/gogf/gf/v2/container/gmap" @@ -174,7 +175,7 @@ func (c *Core) GetOne(ctx context.Context, sql string, args ...any) (Record, err // GetArray queries and returns data values as slice from database. // Note that if there are multiple columns in the result, it returns just one column values randomly. -func (c *Core) GetArray(ctx context.Context, sql string, args ...any) ([]Value, error) { +func (c *Core) GetArray(ctx context.Context, sql string, args ...any) (Array, error) { all, err := c.db.DoSelect(ctx, nil, sql, args...) if err != nil { return nil, err @@ -446,25 +447,30 @@ func (c *Core) DoInsert(ctx context.Context, link Link, table string, list List, // It here uses ListMap to keep sequence for data inserting. // ============================================================================================ var keyListMap = gmap.NewListMap() + var tmpkeyListMap = make(map[string]List) for _, item := range list { - var ( - tmpKeys = make([]string, 0) - tmpKeysInSequenceStr string - ) + mapLen := len(item) + if mapLen == 0 { + continue + } + tmpKeys := make([]string, 0, mapLen) for k := range item { tmpKeys = append(tmpKeys, k) } - keys, err = c.fieldsToSequence(ctx, table, tmpKeys) - if err != nil { - return nil, err + if mapLen > 1 { + sort.Strings(tmpKeys) } - tmpKeysInSequenceStr = gstr.Join(keys, ",") - if !keyListMap.Contains(tmpKeysInSequenceStr) { - keyListMap.Set(tmpKeysInSequenceStr, make(List, 0)) + keys = tmpKeys // for fieldsToSequence + + tmpKeysInSequenceStr := gstr.Join(tmpKeys, ",") + if tmpkeyListMapItem, ok := tmpkeyListMap[tmpKeysInSequenceStr]; ok { + tmpkeyListMap[tmpKeysInSequenceStr] = append(tmpkeyListMapItem, item) + } else { + tmpkeyListMap[tmpKeysInSequenceStr] = List{item} } - tmpKeysInSequenceList := keyListMap.Get(tmpKeysInSequenceStr).(List) - tmpKeysInSequenceList = append(tmpKeysInSequenceList, item) - keyListMap.Set(tmpKeysInSequenceStr, tmpKeysInSequenceList) + } + for tmpKeysInSequenceStr, itemList := range tmpkeyListMap { + keyListMap.Set(tmpKeysInSequenceStr, itemList) } if keyListMap.Size() > 1 { var ( @@ -488,6 +494,15 @@ func (c *Core) DoInsert(ctx context.Context, link Link, table string, list List, return &sqlResult, err } + keys, err = c.fieldsToSequence(ctx, table, keys) + if err != nil { + return nil, err + } + + if len(keys) == 0 { + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "no valid data fields found in table") + } + // Prepare the batch result pointer. var ( charL, charR = c.db.GetChars() diff --git a/database/gdb/gdb_core_config.go b/database/gdb/gdb_core_config.go index da7cfc733..5fa4025a8 100644 --- a/database/gdb/gdb_core_config.go +++ b/database/gdb/gdb_core_config.go @@ -251,15 +251,47 @@ func AddDefaultConfigNode(node ConfigNode) error { } // AddDefaultConfigGroup adds multiple node configurations to configuration of default group. +// +// Deprecated: Use SetDefaultConfigGroup instead. func AddDefaultConfigGroup(nodes ConfigGroup) error { return SetConfigGroup(DefaultGroupName, nodes) } +// SetDefaultConfigGroup sets multiple node configurations to configuration of default group. +func SetDefaultConfigGroup(nodes ConfigGroup) error { + return SetConfigGroup(DefaultGroupName, nodes) +} + // GetConfig retrieves and returns the configuration of given group. +// +// Deprecated: Use GetConfigGroup instead. func GetConfig(group string) ConfigGroup { + configGroup, _ := GetConfigGroup(group) + return configGroup +} + +// GetConfigGroup retrieves and returns the configuration of given group. +// It returns an error if the group does not exist, or an empty slice if the group exists but has no nodes. +func GetConfigGroup(group string) (ConfigGroup, error) { configs.RLock() defer configs.RUnlock() - return configs.config[group] + + configGroup, exists := configs.config[group] + if !exists { + return nil, gerror.NewCodef( + gcode.CodeInvalidParameter, + `configuration group "%s" not found`, + group, + ) + } + return configGroup, nil +} + +// GetAllConfig retrieves and returns all configurations. +func GetAllConfig() Config { + configs.RLock() + defer configs.RUnlock() + return configs.config } // SetDefaultGroup sets the group name for default configuration. diff --git a/database/gdb/gdb_model_hook.go b/database/gdb/gdb_model_hook.go index 509878841..8b23337b4 100644 --- a/database/gdb/gdb_model_hook.go +++ b/database/gdb/gdb_model_hook.go @@ -159,6 +159,10 @@ func (h *HookSelectInput) Next(ctx context.Context) (result Result, err error) { if err != nil { return } + h.Model.db.GetCore().schema = h.Schema + defer func() { + h.Model.db.GetCore().schema = h.originalSchemaName.String() + }() } return h.Model.db.DoSelect(ctx, h.link, toBeCommittedSql, h.Args...) } @@ -195,6 +199,10 @@ func (h *HookInsertInput) Next(ctx context.Context) (result sql.Result, err erro if err != nil { return } + h.Model.db.GetCore().schema = h.Schema + defer func() { + h.Model.db.GetCore().schema = h.originalSchemaName.String() + }() } return h.Model.db.DoInsert(ctx, h.link, h.Table, h.Data, h.Option) } @@ -238,6 +246,10 @@ func (h *HookUpdateInput) Next(ctx context.Context) (result sql.Result, err erro if err != nil { return } + h.Model.db.GetCore().schema = h.Schema + defer func() { + h.Model.db.GetCore().schema = h.originalSchemaName.String() + }() } return h.Model.db.DoUpdate(ctx, h.link, h.Table, h.Data, h.Condition, h.Args...) } @@ -281,6 +293,10 @@ func (h *HookDeleteInput) Next(ctx context.Context) (result sql.Result, err erro if err != nil { return } + h.Model.db.GetCore().schema = h.Schema + defer func() { + h.Model.db.GetCore().schema = h.originalSchemaName.String() + }() } return h.Model.db.DoDelete(ctx, h.link, h.Table, h.Condition, h.Args...) } diff --git a/database/gdb/gdb_model_select.go b/database/gdb/gdb_model_select.go index 27b18d5e4..2e3f72570 100644 --- a/database/gdb/gdb_model_select.go +++ b/database/gdb/gdb_model_select.go @@ -126,7 +126,7 @@ func (m *Model) One(where ...any) (Record, error) { // If the optional parameter `fieldsAndWhere` is given, the fieldsAndWhere[0] is the selected fields // and fieldsAndWhere[1:] is treated as where condition fields. // Also see Model.Fields and Model.Where functions. -func (m *Model) Array(fieldsAndWhere ...any) ([]Value, error) { +func (m *Model) Array(fieldsAndWhere ...any) (Array, error) { if len(fieldsAndWhere) > 0 { if len(fieldsAndWhere) > 2 { return m.Fields(gconv.String(fieldsAndWhere[0])).Where(fieldsAndWhere[1], fieldsAndWhere[2:]...).Array() diff --git a/database/gdb/gdb_type_result.go b/database/gdb/gdb_type_result.go index d35f7b0d8..b1c1767ae 100644 --- a/database/gdb/gdb_type_result.go +++ b/database/gdb/gdb_type_result.go @@ -76,8 +76,8 @@ func (r Result) List() List { // Array retrieves and returns specified column values as slice. // The parameter `field` is optional is the column field is only one. // The default `field` is the first field name of the first item in `Result` if parameter `field` is not given. -func (r Result) Array(field ...string) []Value { - array := make([]Value, len(r)) +func (r Result) Array(field ...string) Array { + array := make(Array, len(r)) if len(r) == 0 { return array } diff --git a/database/gdb/gdb_z_core_config_external_test.go b/database/gdb/gdb_z_core_config_external_test.go new file mode 100644 index 000000000..bc3749512 --- /dev/null +++ b/database/gdb/gdb_z_core_config_external_test.go @@ -0,0 +1,1191 @@ +// Copyright GoFrame Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package gdb_test + +import ( + "testing" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/test/gtest" +) + +func Test_GetAllConfig(t *testing.T) { + // Test case 1: Empty configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config to empty + gdb.SetConfig(make(gdb.Config)) + + result := gdb.GetAllConfig() + t.Assert(len(result), 0) + }) + + // Test case 2: Single configuration group with one node + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + testNode := gdb.ConfigNode{ + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "test_db", + Type: "mysql", + } + + err := gdb.AddConfigNode("test_group", testNode) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["test_group"]), 1) + t.Assert(result["test_group"][0].Host, "127.0.0.1") + t.Assert(result["test_group"][0].Port, "3306") + t.Assert(result["test_group"][0].User, "root") + t.Assert(result["test_group"][0].Pass, "123456") + t.Assert(result["test_group"][0].Name, "test_db") + t.Assert(result["test_group"][0].Type, "mysql") + }) + + // Test case 3: Multiple configuration groups with multiple nodes + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + // Add first group with two nodes + testNode1 := gdb.ConfigNode{ + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "master_db", + Type: "mysql", + Role: "master", + } + testNode2 := gdb.ConfigNode{ + Host: "127.0.0.2", + Port: "3306", + User: "root", + Pass: "123456", + Name: "slave_db", + Type: "mysql", + Role: "slave", + } + + err := gdb.AddConfigNode("mysql_cluster", testNode1) + t.AssertNil(err) + err = gdb.AddConfigNode("mysql_cluster", testNode2) + t.AssertNil(err) + + // Add second group with one node + testNode3 := gdb.ConfigNode{ + Host: "localhost", + Port: "5432", + User: "postgres", + Pass: "password", + Name: "pg_db", + Type: "pgsql", + } + + err = gdb.AddConfigNode("postgres_db", testNode3) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 2) + + // Check mysql_cluster group + t.Assert(len(result["mysql_cluster"]), 2) + t.Assert(result["mysql_cluster"][0].Host, "127.0.0.1") + t.Assert(result["mysql_cluster"][0].Role, "master") + t.Assert(result["mysql_cluster"][1].Host, "127.0.0.2") + t.Assert(result["mysql_cluster"][1].Role, "slave") + + // Check postgres_db group + t.Assert(len(result["postgres_db"]), 1) + t.Assert(result["postgres_db"][0].Host, "localhost") + t.Assert(result["postgres_db"][0].Port, "5432") + t.Assert(result["postgres_db"][0].Type, "pgsql") + }) + + // Test case 4: Configuration with Link syntax + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + testNode := gdb.ConfigNode{ + Link: "mysql:root:123456@tcp(127.0.0.1:3306)/test_db?charset=utf8", + } + + err := gdb.AddConfigNode("link_test", testNode) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["link_test"]), 1) + + // Check parsed values from link + node := result["link_test"][0] + t.Assert(node.Type, "mysql") + t.Assert(node.User, "root") + t.Assert(node.Pass, "123456") + t.Assert(node.Host, "127.0.0.1") + t.Assert(node.Port, "3306") + t.Assert(node.Name, "test_db") + t.Assert(node.Charset, "utf8") + t.Assert(node.Protocol, "tcp") + }) + + // Test case 5: Default group configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + testNode := gdb.ConfigNode{ + Host: "localhost", + Port: "3306", + User: "user", + Pass: "pass", + Name: "default_db", + Type: "mysql", + } + + err := gdb.AddDefaultConfigNode(testNode) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["default"]), 1) + t.Assert(result["default"][0].Host, "localhost") + t.Assert(result["default"][0].Name, "default_db") + }) + + // Test case 6: SetConfig with multiple groups + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + testConfig := gdb.Config{ + "group1": gdb.ConfigGroup{ + { + Host: "host1", + Port: "3306", + User: "user1", + Pass: "pass1", + Name: "db1", + Type: "mysql", + }, + }, + "group2": gdb.ConfigGroup{ + { + Host: "host2", + Port: "5432", + User: "user2", + Pass: "pass2", + Name: "db2", + Type: "pgsql", + }, + { + Host: "host3", + Port: "5432", + User: "user3", + Pass: "pass3", + Name: "db3", + Type: "pgsql", + }, + }, + } + + err := gdb.SetConfig(testConfig) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 2) + t.Assert(len(result["group1"]), 1) + t.Assert(len(result["group2"]), 2) + + t.Assert(result["group1"][0].Host, "host1") + t.Assert(result["group1"][0].Type, "mysql") + + t.Assert(result["group2"][0].Host, "host2") + t.Assert(result["group2"][0].Type, "pgsql") + t.Assert(result["group2"][1].Host, "host3") + t.Assert(result["group2"][1].Type, "pgsql") + }) + + // Test case 7: Test return value is a copy (not reference) + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + testNode := gdb.ConfigNode{ + Host: "original_host", + Port: "3306", + User: "original_user", + Pass: "original_pass", + Name: "original_db", + Type: "mysql", + } + + err := gdb.AddConfigNode("test_copy", testNode) + t.AssertNil(err) + + // Get config and modify it + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + + // Verify original values + t.Assert(result["test_copy"][0].Host, "original_host") + + // Note: GetAllConfig returns the internal config directly (not a copy) + // This is by design for performance reasons + // So modifying the returned config would affect the internal state + // This test just verifies the current behavior + }) +} + +func Test_SetConfig(t *testing.T) { + // Test case 1: Normal configuration setting + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + testConfig := gdb.Config{ + "group1": gdb.ConfigGroup{ + { + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "test_db", + Type: "mysql", + }, + }, + "group2": gdb.ConfigGroup{ + { + Host: "192.168.1.100", + Port: "5432", + User: "postgres", + Pass: "password", + Name: "pg_db", + Type: "pgsql", + }, + }, + } + + err := gdb.SetConfig(testConfig) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 2) + t.Assert(result["group1"][0].Host, "127.0.0.1") + t.Assert(result["group2"][0].Type, "pgsql") + }) + + // Test case 2: Empty configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + testConfig := gdb.Config{} + err := gdb.SetConfig(testConfig) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 0) + }) + + // Test case 3: Configuration with Link syntax + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + testConfig := gdb.Config{ + "mysql_link": gdb.ConfigGroup{ + { + Link: "mysql:root:123456@tcp(127.0.0.1:3306)/test_db?charset=utf8", + }, + }, + } + + err := gdb.SetConfig(testConfig) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + node := result["mysql_link"][0] + t.Assert(node.Type, "mysql") + t.Assert(node.User, "root") + t.Assert(node.Host, "127.0.0.1") + t.Assert(node.Port, "3306") + t.Assert(node.Name, "test_db") + }) + + // Test case 4: Configuration with invalid Link syntax + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + testConfig := gdb.Config{ + "invalid_link": gdb.ConfigGroup{ + { + Link: "invalid_link_format", + }, + }, + } + + err := gdb.SetConfig(testConfig) + t.AssertNE(err, nil) + }) +} + +func Test_SetConfigGroup(t *testing.T) { + // Test case 1: Set new group configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + nodes := gdb.ConfigGroup{ + { + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "db1", + Type: "mysql", + Role: "master", + }, + { + Host: "127.0.0.2", + Port: "3306", + User: "root", + Pass: "123456", + Name: "db2", + Type: "mysql", + Role: "slave", + }, + } + + err := gdb.SetConfigGroup("test_group", nodes) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["test_group"]), 2) + t.Assert(result["test_group"][0].Role, "master") + t.Assert(result["test_group"][1].Role, "slave") + }) + + // Test case 2: Overwrite existing group configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + // First set + nodes1 := gdb.ConfigGroup{ + { + Host: "old_host", + Port: "3306", + User: "old_user", + Name: "old_db", + Type: "mysql", + }, + } + err := gdb.SetConfigGroup("test_group", nodes1) + t.AssertNil(err) + + // Overwrite with new config + nodes2 := gdb.ConfigGroup{ + { + Host: "new_host", + Port: "5432", + User: "new_user", + Name: "new_db", + Type: "pgsql", + }, + } + err = gdb.SetConfigGroup("test_group", nodes2) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["test_group"]), 1) + t.Assert(result["test_group"][0].Host, "new_host") + t.Assert(result["test_group"][0].Type, "pgsql") + }) + + // Test case 3: Empty group configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + nodes := gdb.ConfigGroup{} + err := gdb.SetConfigGroup("empty_group", nodes) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["empty_group"]), 0) + }) + + // Test case 4: Configuration with invalid Link syntax + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + nodes := gdb.ConfigGroup{ + { + Link: "invalid_link", + }, + } + + err := gdb.SetConfigGroup("invalid_group", nodes) + t.AssertNE(err, nil) + }) +} + +func Test_AddConfigNode(t *testing.T) { + // Test case 1: Add node to new group + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + node := gdb.ConfigNode{ + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "test_db", + Type: "mysql", + } + + err := gdb.AddConfigNode("new_group", node) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["new_group"]), 1) + t.Assert(result["new_group"][0].Host, "127.0.0.1") + }) + + // Test case 2: Add node to existing group + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + // Add first node + node1 := gdb.ConfigNode{ + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "db1", + Type: "mysql", + } + err := gdb.AddConfigNode("existing_group", node1) + t.AssertNil(err) + + // Add second node to same group + node2 := gdb.ConfigNode{ + Host: "127.0.0.2", + Port: "3306", + User: "root", + Pass: "123456", + Name: "db2", + Type: "mysql", + } + err = gdb.AddConfigNode("existing_group", node2) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["existing_group"]), 2) + t.Assert(result["existing_group"][0].Name, "db1") + t.Assert(result["existing_group"][1].Name, "db2") + }) + + // Test case 3: Add node with Link syntax + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + node := gdb.ConfigNode{ + Link: "mysql:root:password@tcp(192.168.1.100:3306)/mydb?charset=utf8mb4", + } + + err := gdb.AddConfigNode("link_group", node) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["link_group"]), 1) + t.Assert(result["link_group"][0].Type, "mysql") + t.Assert(result["link_group"][0].Host, "192.168.1.100") + t.Assert(result["link_group"][0].Port, "3306") + t.Assert(result["link_group"][0].Name, "mydb") + }) + + // Test case 4: Add node with invalid Link syntax + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + node := gdb.ConfigNode{ + Link: "invalid_link_format", + } + + err := gdb.AddConfigNode("invalid_group", node) + t.AssertNE(err, nil) + }) +} + +func Test_AddDefaultConfigNode(t *testing.T) { + // Test case 1: Add node to default group + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + node := gdb.ConfigNode{ + Host: "localhost", + Port: "3306", + User: "root", + Pass: "root", + Name: "default_db", + Type: "mysql", + } + + err := gdb.AddDefaultConfigNode(node) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["default"]), 1) + t.Assert(result["default"][0].Host, "localhost") + t.Assert(result["default"][0].Name, "default_db") + }) + + // Test case 2: Add multiple nodes to default group + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + node1 := gdb.ConfigNode{ + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "db1", + Type: "mysql", + Role: "master", + } + err := gdb.AddDefaultConfigNode(node1) + t.AssertNil(err) + + node2 := gdb.ConfigNode{ + Host: "127.0.0.2", + Port: "3306", + User: "root", + Pass: "123456", + Name: "db2", + Type: "mysql", + Role: "slave", + } + err = gdb.AddDefaultConfigNode(node2) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["default"]), 2) + t.Assert(result["default"][0].Role, "master") + t.Assert(result["default"][1].Role, "slave") + }) + + // Test case 3: Add node with Link syntax to default group + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + node := gdb.ConfigNode{ + Link: "pgsql:postgres:password@tcp(localhost:5432)/testdb", + } + + err := gdb.AddDefaultConfigNode(node) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["default"]), 1) + t.Assert(result["default"][0].Type, "pgsql") + t.Assert(result["default"][0].User, "postgres") + t.Assert(result["default"][0].Host, "localhost") + t.Assert(result["default"][0].Port, "5432") + t.Assert(result["default"][0].Name, "testdb") + }) +} + +func Test_AddDefaultConfigGroup(t *testing.T) { + // Test case 1: Add multiple nodes to default group (deprecated function) + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + nodes := gdb.ConfigGroup{ + { + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "db1", + Type: "mysql", + Role: "master", + }, + { + Host: "127.0.0.2", + Port: "3306", + User: "root", + Pass: "123456", + Name: "db2", + Type: "mysql", + Role: "slave", + }, + } + + err := gdb.AddDefaultConfigGroup(nodes) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["default"]), 2) + t.Assert(result["default"][0].Role, "master") + t.Assert(result["default"][1].Role, "slave") + }) + + // Test case 2: Overwrite existing default group configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + // First set + node1 := gdb.ConfigNode{ + Host: "old_host", + Port: "3306", + User: "old_user", + Name: "old_db", + Type: "mysql", + } + err := gdb.AddDefaultConfigNode(node1) + t.AssertNil(err) + + // Overwrite with new group config + nodes := gdb.ConfigGroup{ + { + Host: "new_host", + Port: "5432", + User: "new_user", + Name: "new_db", + Type: "pgsql", + }, + } + err = gdb.AddDefaultConfigGroup(nodes) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["default"]), 1) + t.Assert(result["default"][0].Host, "new_host") + t.Assert(result["default"][0].Type, "pgsql") + }) +} + +func Test_SetDefaultConfigGroup(t *testing.T) { + // Test case 1: Set default group configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + nodes := gdb.ConfigGroup{ + { + Host: "192.168.1.10", + Port: "3306", + User: "admin", + Pass: "admin123", + Name: "main_db", + Type: "mysql", + Role: "master", + }, + { + Host: "192.168.1.11", + Port: "3306", + User: "admin", + Pass: "admin123", + Name: "backup_db", + Type: "mysql", + Role: "slave", + }, + } + + err := gdb.SetDefaultConfigGroup(nodes) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["default"]), 2) + t.Assert(result["default"][0].Host, "192.168.1.10") + t.Assert(result["default"][0].Role, "master") + t.Assert(result["default"][1].Host, "192.168.1.11") + t.Assert(result["default"][1].Role, "slave") + }) + + // Test case 2: Empty default group configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config and add some initial data + gdb.SetConfig(make(gdb.Config)) + err := gdb.AddDefaultConfigNode(gdb.ConfigNode{ + Host: "temp_host", + Name: "temp_db", + Type: "mysql", + }) + t.AssertNil(err) + + // Set empty group + nodes := gdb.ConfigGroup{} + err = gdb.SetDefaultConfigGroup(nodes) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["default"]), 0) + }) + + // Test case 3: Configuration with Link syntax + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + nodes := gdb.ConfigGroup{ + { + Link: "mysql:root:123456@tcp(localhost:3306)/test_db1", + }, + { + Link: "pgsql:postgres:password@tcp(localhost:5432)/test_db2", + }, + } + + err := gdb.SetDefaultConfigGroup(nodes) + t.AssertNil(err) + + result := gdb.GetAllConfig() + t.Assert(len(result), 1) + t.Assert(len(result["default"]), 2) + t.Assert(result["default"][0].Type, "mysql") + t.Assert(result["default"][0].Name, "test_db1") + t.Assert(result["default"][1].Type, "pgsql") + t.Assert(result["default"][1].Name, "test_db2") + }) +} + +func Test_GetConfig(t *testing.T) { + // Test case 1: Get existing group configuration (deprecated function) + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + node := gdb.ConfigNode{ + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "test_db", + Type: "mysql", + } + + err := gdb.AddConfigNode("test_group", node) + t.AssertNil(err) + + result := gdb.GetConfig("test_group") + t.Assert(len(result), 1) + t.Assert(result[0].Host, "127.0.0.1") + t.Assert(result[0].Type, "mysql") + }) + + // Test case 2: Get non-existing group configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + result := gdb.GetConfig("non_existing_group") + t.Assert(len(result), 0) + }) +} + +func Test_GetConfigGroup(t *testing.T) { + // Test case 1: Get existing group configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + nodes := gdb.ConfigGroup{ + { + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "db1", + Type: "mysql", + Role: "master", + }, + { + Host: "127.0.0.2", + Port: "3306", + User: "root", + Pass: "123456", + Name: "db2", + Type: "mysql", + Role: "slave", + }, + } + + err := gdb.SetConfigGroup("test_group", nodes) + t.AssertNil(err) + + result, err := gdb.GetConfigGroup("test_group") + t.AssertNil(err) + t.Assert(len(result), 2) + t.Assert(result[0].Host, "127.0.0.1") + t.Assert(result[0].Role, "master") + t.Assert(result[1].Host, "127.0.0.2") + t.Assert(result[1].Role, "slave") + }) + + // Test case 2: Get non-existing group configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + result, err := gdb.GetConfigGroup("non_existing_group") + t.AssertNE(err, nil) + t.Assert(result, nil) + }) + + // Test case 3: Get empty group configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + err := gdb.SetConfigGroup("empty_group", gdb.ConfigGroup{}) + t.AssertNil(err) + + result, err := gdb.GetConfigGroup("empty_group") + t.AssertNil(err) + t.Assert(len(result), 0) + }) +} + +func Test_SetDefaultGroup(t *testing.T) { + // Test case 1: Set default group name + gtest.C(t, func(t *gtest.T) { + // Save original group and restore after test + originalGroup := gdb.GetDefaultGroup() + defer func() { + gdb.SetDefaultGroup(originalGroup) + }() + + gdb.SetDefaultGroup("custom_default") + result := gdb.GetDefaultGroup() + t.Assert(result, "custom_default") + }) + + // Test case 2: Set empty default group name + gtest.C(t, func(t *gtest.T) { + // Save original group and restore after test + originalGroup := gdb.GetDefaultGroup() + defer func() { + gdb.SetDefaultGroup(originalGroup) + }() + + gdb.SetDefaultGroup("") + result := gdb.GetDefaultGroup() + t.Assert(result, "") + }) + + // Test case 3: Multiple calls to SetDefaultGroup + gtest.C(t, func(t *gtest.T) { + // Save original group and restore after test + originalGroup := gdb.GetDefaultGroup() + defer func() { + gdb.SetDefaultGroup(originalGroup) + }() + + gdb.SetDefaultGroup("first_group") + result1 := gdb.GetDefaultGroup() + t.Assert(result1, "first_group") + + gdb.SetDefaultGroup("second_group") + result2 := gdb.GetDefaultGroup() + t.Assert(result2, "second_group") + }) +} + +func Test_GetDefaultGroup(t *testing.T) { + // Test case 1: Get default group name + gtest.C(t, func(t *gtest.T) { + // Save original group and restore after test + originalGroup := gdb.GetDefaultGroup() + defer func() { + gdb.SetDefaultGroup(originalGroup) + }() + + // Test with default value + result := gdb.GetDefaultGroup() + t.Assert(result, "default") + }) + + // Test case 2: Get custom default group name + gtest.C(t, func(t *gtest.T) { + // Save original group and restore after test + originalGroup := gdb.GetDefaultGroup() + defer func() { + gdb.SetDefaultGroup(originalGroup) + }() + + gdb.SetDefaultGroup("my_custom_group") + result := gdb.GetDefaultGroup() + t.Assert(result, "my_custom_group") + }) +} + +func Test_IsConfigured(t *testing.T) { + // Test case 1: No configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config to empty + gdb.SetConfig(make(gdb.Config)) + + result := gdb.IsConfigured() + t.Assert(result, false) + }) + + // Test case 2: Has configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + node := gdb.ConfigNode{ + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "test_db", + Type: "mysql", + } + + err := gdb.AddConfigNode("test_group", node) + t.AssertNil(err) + + result := gdb.IsConfigured() + t.Assert(result, true) + }) + + // Test case 3: Has empty group configuration + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := gdb.GetAllConfig() + defer func() { + gdb.SetConfig(originalConfig) + }() + + // Reset config + gdb.SetConfig(make(gdb.Config)) + + err := gdb.SetConfigGroup("empty_group", gdb.ConfigGroup{}) + t.AssertNil(err) + + result := gdb.IsConfigured() + t.Assert(result, true) + }) +} diff --git a/database/gdb/gdb_z_core_config_test.go b/database/gdb/gdb_z_core_config_test.go new file mode 100644 index 000000000..d0e876192 --- /dev/null +++ b/database/gdb/gdb_z_core_config_test.go @@ -0,0 +1,204 @@ +// Copyright GoFrame Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package gdb + +import ( + "testing" + "time" + + "github.com/gogf/gf/v2/container/gtype" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/test/gtest" +) + +func Test_Core_SetDebug_GetDebug(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := configs.config + defer func() { + configs.config = originalConfig + }() + + // Create a test configuration + configs.config = make(Config) + testNode := ConfigNode{ + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "test_db", + Type: "mysql", + } + err := AddConfigNode("test_group", testNode) + t.AssertNil(err) + + // Create Core instance + node, err := GetConfigGroup("test_group") + t.AssertNil(err) + core := &Core{ + group: "test_group", + config: &node[0], + debug: gtype.NewBool(false), + } + + // Test default value + result := core.GetDebug() + t.Assert(result, false) + + // Test setting debug to true + core.SetDebug(true) + result = core.GetDebug() + t.Assert(result, true) + + // Test setting debug to false + core.SetDebug(false) + result = core.GetDebug() + t.Assert(result, false) + }) +} + +func Test_Core_SetDryRun_GetDryRun(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := configs.config + defer func() { + configs.config = originalConfig + }() + + // Create a test configuration + configs.config = make(Config) + testNode := ConfigNode{ + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "test_db", + Type: "mysql", + DryRun: false, + } + err := AddConfigNode("test_group", testNode) + t.AssertNil(err) + + // Create Core instance + node, err := GetConfigGroup("test_group") + t.AssertNil(err) + core := &Core{ + group: "test_group", + config: &node[0], + } + + // Test default value + result := core.GetDryRun() + t.Assert(result, false) + + // Test setting dry run to true + core.SetDryRun(true) + result = core.GetDryRun() + t.Assert(result, true) + + // Test setting dry run to false + core.SetDryRun(false) + result = core.GetDryRun() + t.Assert(result, false) + }) +} + +func Test_Core_SetLogger_GetLogger(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + // Create Core instance + core := &Core{} + + // Test setting custom logger + customLogger := glog.New() + core.SetLogger(customLogger) + result := core.GetLogger() + t.Assert(result, customLogger) + + // Test setting nil logger + core.SetLogger(nil) + result = core.GetLogger() + t.Assert(result, nil) + }) +} + +func Test_Core_SetMaxConnections(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + // Create Core instance + core := &Core{} + + // Test SetMaxIdleConnCount + core.SetMaxIdleConnCount(10) + t.Assert(core.dynamicConfig.MaxIdleConnCount, 10) + + // Test SetMaxOpenConnCount + core.SetMaxOpenConnCount(20) + t.Assert(core.dynamicConfig.MaxOpenConnCount, 20) + + // Test SetMaxConnLifeTime + testDuration := time.Hour + core.SetMaxConnLifeTime(testDuration) + t.Assert(core.dynamicConfig.MaxConnLifeTime, testDuration) + }) +} + +func Test_Core_GetCache(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + // Create Core instance + core := &Core{} + + cache := core.GetCache() + // Cache might be nil if not initialized, so we just test that the call doesn't panic + _ = cache + }) +} + +func Test_Core_GetGroup(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + // Create Core instance + core := &Core{ + group: "test_group", + } + + group := core.GetGroup() + t.Assert(group, "test_group") + }) +} + +func Test_Core_GetPrefix(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + // Save original config and restore after test + originalConfig := configs.config + defer func() { + configs.config = originalConfig + }() + + // Create a test configuration + configs.config = make(Config) + testNode := ConfigNode{ + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "test_db", + Type: "mysql", + Prefix: "gf_", + } + err := AddConfigNode("test_group", testNode) + t.AssertNil(err) + + // Create Core instance + node, err := GetConfigGroup("test_group") + t.AssertNil(err) + core := &Core{ + group: "test_group", + config: &node[0], + } + + prefix := core.GetPrefix() + t.Assert(prefix, "gf_") + }) +} diff --git a/errors/gerror/gerror_error_json.go b/errors/gerror/gerror_error_json.go index 5c290d7af..ae245cdd7 100644 --- a/errors/gerror/gerror_error_json.go +++ b/errors/gerror/gerror_error_json.go @@ -6,8 +6,12 @@ package gerror +import ( + "encoding/json" +) + // MarshalJSON implements the interface MarshalJSON for json.Marshal. // Note that do not use pointer as its receiver here. func (err Error) MarshalJSON() ([]byte, error) { - return []byte(`"` + err.Error() + `"`), nil + return json.Marshal(err.Error()) } diff --git a/errors/gerror/gerror_z_unit_test.go b/errors/gerror/gerror_z_unit_test.go index 83acc1837..1e463d1ba 100644 --- a/errors/gerror/gerror_z_unit_test.go +++ b/errors/gerror/gerror_z_unit_test.go @@ -395,6 +395,19 @@ func Test_Json(t *testing.T) { t.Assert(e, nil) t.Assert(string(b), `"2: 1"`) }) + gtest.C(t, func(t *gtest.T) { + errNormal := gerror.New("test") + b, e := json.Marshal(errNormal) + t.Assert(e, nil) + t.Assert(string(b), `"test"`) + }) + gtest.C(t, func(t *gtest.T) { + // The string contains special characters. + errWithSign := gerror.New(`test ""`) + b, e := json.Marshal(errWithSign) + t.Assert(e, nil) + t.Assert(string(b), `"test \"\""`) + }) } func Test_HasStack(t *testing.T) { diff --git a/frame/gins/gins_database.go b/frame/gins/gins_database.go index dd04fda10..0d420de49 100644 --- a/frame/gins/gins_database.go +++ b/frame/gins/gins_database.go @@ -89,7 +89,7 @@ func Database(name ...string) gdb.DB { } } if len(cg) > 0 { - if gdb.GetConfig(group) == nil { + if gcg, _ := gdb.GetConfigGroup(group); gcg == nil { intlog.Printf(ctx, "add configuration for group: %s, %#v", g, cg) if err := gdb.SetConfigGroup(g, cg); err != nil { panic(err) @@ -108,7 +108,7 @@ func Database(name ...string) gdb.DB { cg = append(cg, *node) } if len(cg) > 0 { - if gdb.GetConfig(group) == nil { + if gcg, _ := gdb.GetConfigGroup(group); gcg == nil { intlog.Printf(ctx, "add configuration for group: %s, %#v", gdb.DefaultGroupName, cg) if err := gdb.SetConfigGroup(gdb.DefaultGroupName, cg); err != nil { panic(err) diff --git a/net/ghttp/ghttp_server_domain.go b/net/ghttp/ghttp_server_domain.go index 10a98d780..1e49a4529 100644 --- a/net/ghttp/ghttp_server_domain.go +++ b/net/ghttp/ghttp_server_domain.go @@ -32,7 +32,7 @@ func (s *Server) Domain(domains string) *Domain { // BindHandler binds the handler for the specified pattern. func (d *Domain) BindHandler(pattern string, handler any) { for domain := range d.domains { - d.server.BindHandler(pattern+"@"+domain, handler) + d.server.BindHandler(patternBindDomain(pattern, domain), handler) } } @@ -40,7 +40,7 @@ func (d *Domain) doBindHandler(ctx context.Context, in doBindHandlerInput) { for domain := range d.domains { d.server.doBindHandler(ctx, doBindHandlerInput{ Prefix: in.Prefix, - Pattern: in.Pattern + "@" + domain, + Pattern: patternBindDomain(in.Pattern, domain), FuncInfo: in.FuncInfo, Middleware: in.Middleware, Source: in.Source, @@ -51,7 +51,7 @@ func (d *Domain) doBindHandler(ctx context.Context, in doBindHandlerInput) { // BindObject binds the object for the specified pattern. func (d *Domain) BindObject(pattern string, obj any, methods ...string) { for domain := range d.domains { - d.server.BindObject(pattern+"@"+domain, obj, methods...) + d.server.BindObject(patternBindDomain(pattern, domain), obj, methods...) } } @@ -59,7 +59,7 @@ func (d *Domain) doBindObject(ctx context.Context, in doBindObjectInput) { for domain := range d.domains { d.server.doBindObject(ctx, doBindObjectInput{ Prefix: in.Prefix, - Pattern: in.Pattern + "@" + domain, + Pattern: patternBindDomain(in.Pattern, domain), Object: in.Object, Method: in.Method, Middleware: in.Middleware, @@ -71,7 +71,7 @@ func (d *Domain) doBindObject(ctx context.Context, in doBindObjectInput) { // BindObjectMethod binds the method for the specified pattern. func (d *Domain) BindObjectMethod(pattern string, obj any, method string) { for domain := range d.domains { - d.server.BindObjectMethod(pattern+"@"+domain, obj, method) + d.server.BindObjectMethod(patternBindDomain(pattern, domain), obj, method) } } @@ -79,7 +79,7 @@ func (d *Domain) doBindObjectMethod(ctx context.Context, in doBindObjectMethodIn for domain := range d.domains { d.server.doBindObjectMethod(ctx, doBindObjectMethodInput{ Prefix: in.Prefix, - Pattern: in.Pattern + "@" + domain, + Pattern: patternBindDomain(in.Pattern, domain), Object: in.Object, Method: in.Method, Middleware: in.Middleware, @@ -91,7 +91,7 @@ func (d *Domain) doBindObjectMethod(ctx context.Context, in doBindObjectMethodIn // BindObjectRest binds the RESTful API for the specified pattern. func (d *Domain) BindObjectRest(pattern string, obj any) { for domain := range d.domains { - d.server.BindObjectRest(pattern+"@"+domain, obj) + d.server.BindObjectRest(patternBindDomain(pattern, domain), obj) } } @@ -99,7 +99,7 @@ func (d *Domain) doBindObjectRest(ctx context.Context, in doBindObjectInput) { for domain := range d.domains { d.server.doBindObjectRest(ctx, doBindObjectInput{ Prefix: in.Prefix, - Pattern: in.Pattern + "@" + domain, + Pattern: patternBindDomain(in.Pattern, domain), Object: in.Object, Method: in.Method, Middleware: in.Middleware, @@ -111,7 +111,7 @@ func (d *Domain) doBindObjectRest(ctx context.Context, in doBindObjectInput) { // BindHookHandler binds the hook handler for the specified pattern. func (d *Domain) BindHookHandler(pattern string, hook HookName, handler HandlerFunc) { for domain := range d.domains { - d.server.BindHookHandler(pattern+"@"+domain, hook, handler) + d.server.BindHookHandler(patternBindDomain(pattern, domain), hook, handler) } } @@ -119,7 +119,7 @@ func (d *Domain) doBindHookHandler(ctx context.Context, in doBindHookHandlerInpu for domain := range d.domains { d.server.doBindHookHandler(ctx, doBindHookHandlerInput{ Prefix: in.Prefix, - Pattern: in.Pattern + "@" + domain, + Pattern: patternBindDomain(in.Pattern, domain), HookName: in.HookName, Handler: in.Handler, Source: in.Source, @@ -130,7 +130,7 @@ func (d *Domain) doBindHookHandler(ctx context.Context, in doBindHookHandlerInpu // BindHookHandlerByMap binds the hook handler for the specified pattern. func (d *Domain) BindHookHandlerByMap(pattern string, hookMap map[HookName]HandlerFunc) { for domain := range d.domains { - d.server.BindHookHandlerByMap(pattern+"@"+domain, hookMap) + d.server.BindHookHandlerByMap(patternBindDomain(pattern, domain), hookMap) } } @@ -151,14 +151,14 @@ func (d *Domain) BindStatusHandlerByMap(handlerMap map[int]HandlerFunc) { // BindMiddleware binds the middleware for the specified pattern. func (d *Domain) BindMiddleware(pattern string, handlers ...HandlerFunc) { for domain := range d.domains { - d.server.BindMiddleware(pattern+"@"+domain, handlers...) + d.server.BindMiddleware(patternBindDomain(pattern, domain), handlers...) } } // BindMiddlewareDefault binds the default middleware for the specified pattern. func (d *Domain) BindMiddlewareDefault(handlers ...HandlerFunc) { for domain := range d.domains { - d.server.BindMiddleware(defaultMiddlewarePattern+"@"+domain, handlers...) + d.server.BindMiddleware(patternBindDomain(defaultMiddlewarePattern, domain), handlers...) } } @@ -166,3 +166,10 @@ func (d *Domain) BindMiddlewareDefault(handlers ...HandlerFunc) { func (d *Domain) Use(handlers ...HandlerFunc) { d.BindMiddlewareDefault(handlers...) } + +func patternBindDomain(pattern, domain string) string { + if domain != "" { + return pattern + "@" + domain + } + return pattern +} diff --git a/net/ghttp/ghttp_z_unit_feature_router_domain_basic_test.go b/net/ghttp/ghttp_z_unit_feature_router_domain_basic_test.go index d9c91428f..f0c1f36b3 100644 --- a/net/ghttp/ghttp_z_unit_feature_router_domain_basic_test.go +++ b/net/ghttp/ghttp_z_unit_feature_router_domain_basic_test.go @@ -363,3 +363,37 @@ func Test_Router_DomainGroup(t *testing.T) { t.Assert(client2.DeleteContent(ctx, "/app/comment/20"), "Not Found") }) } + +// issue#4100 +func TestIssue4100(t *testing.T) { + s := g.Server(guid.S()) + d := s.Domain("") + d.BindHandler("/:name", func(r *ghttp.Request) { + r.Response.Write("/:name") + }) + d.BindHandler("/:name/update", func(r *ghttp.Request) { + r.Response.Write(r.Get("name")) + }) + d.BindHandler("/:name/:action", func(r *ghttp.Request) { + r.Response.Write(r.Get("action")) + }) + d.BindHandler("/:name/*any", func(r *ghttp.Request) { + r.Response.Write(r.Get("any")) + }) + d.BindHandler("/user/list/{field}.html", func(r *ghttp.Request) { + r.Response.Write(r.Get("field")) + }) + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + + time.Sleep(100 * time.Millisecond) + gtest.C(t, func(t *gtest.T) { + client := g.Client() + client.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + t.Assert(client.GetContent(ctx, "/john"), "") + t.Assert(client.GetContent(ctx, "/john/update"), "john") + t.Assert(client.GetContent(ctx, "/john/edit"), "edit") + t.Assert(client.GetContent(ctx, "/user/list/100.html"), "100") + }) +}