From 2882a9a557d556562ef4a21fedc4c32775db99e0 Mon Sep 17 00:00:00 2001 From: Andy Pfister Date: Fri, 1 Aug 2025 13:39:30 +0200 Subject: [PATCH 01/10] Bump version to v4 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 18091983..fcdb2e10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.4.0 +4.0.0 From e1fc85771311414c3a204c6462b2abdea846c8b7 Mon Sep 17 00:00:00 2001 From: Andy Pfister Date: Mon, 8 Sep 2025 15:00:30 +0200 Subject: [PATCH 02/10] Drop support for Ruby < v3.2 --- .devcontainer/Dockerfile | 4 +- .github/workflows/ci.yml | 138 +----------------- CHANGELOG.md | 2 + README.md | 2 +- Rakefile | 1 - ext/tiny_tds/extconf.rb | 16 +- .../1.00.27/0001-mingw_missing_inet_pton.diff | 34 ----- .../0002-Don-t-use-MSYS2-file-libws2_32.diff | 28 ---- tasks/native_gem.rake | 2 +- tiny_tds.gemspec | 2 +- 10 files changed, 14 insertions(+), 215 deletions(-) delete mode 100644 patches/freetds/1.00.27/0001-mingw_missing_inet_pton.diff delete mode 100644 patches/freetds/1.00.27/0002-Don-t-use-MSYS2-file-libws2_32.diff diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6fab8c1c..e394897b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/devcontainers/ruby:2.7 +FROM mcr.microsoft.com/devcontainers/ruby:3.2 # Install the SQL Server command-line tools and the Artistic Style code formatter RUN curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc \ @@ -8,7 +8,7 @@ RUN curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/t ENV PATH=$PATH:/opt/mssql-tools18/bin # Install FreeTDS -ENV FREETDS_VERSION=1.5.1 +ENV FREETDS_VERSION=1.5.4 COPY test/bin/install-freetds.sh /tmp/ RUN /tmp/install-freetds.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c98156f6..a9cfd4f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,6 @@ jobs: fail-fast: false matrix: platform: - - "x64-mingw32" - "x64-mingw-ucrt" - "x86_64-linux-gnu" - "x86_64-linux-musl" @@ -26,7 +25,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: "2.7" + ruby-version: "3.2" - name: "Install dependencies" run: bundle install @@ -53,119 +52,6 @@ jobs: name: gem-${{ matrix.platform }} path: pkg/*.gem - install-windows-mingw: - needs: - - cross-compile - strategy: - fail-fast: false - matrix: - ruby-version: - - "3.0" - - name: install-windows-mingw - runs-on: windows-latest - steps: - - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby-version }} - bundler-cache: true - - - name: Download precompiled gem - uses: actions/download-artifact@v4 - with: - name: gem-x64-mingw32 - - - name: Install native gem - shell: pwsh - run: gem install "tiny_tds-*.gem" - - - name: Test if TinyTDS loads - shell: pwsh - run: | - ruby -e "require 'tiny_tds'; puts TinyTds::Gem.root_path" - exit $LASTEXITCODE - - - name: Test if tsql wrapper works - shell: pwsh - run: | - tsql-ttds -C - exit $LASTEXITCODE - - - name: Test if defncopy wrapper works - shell: pwsh - run: | - defncopy-ttds -v - exit $LASTEXITCODE - - test-windows-mingw: - needs: - - cross-compile - strategy: - fail-fast: false - matrix: - force-encryption: - - false - - true - mssql-version: - - 2017 - - 2019 - - 2022 - ruby-version: - - "3.0" - - name: test-windows-mingw - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby-version }} - bundler-cache: true - - - name: Download precompiled gem - uses: actions/download-artifact@v4 - with: - name: gem-x64-mingw32 - - - name: Install native gem and restore cross-compiled code from it - shell: pwsh - run: "& ./test/bin/restore-from-native-gem.ps1" - env: - RUBY_ARCHITECTURE: "x64-mingw32" - - - name: Setup MSSQL - uses: rails-sqlserver/setup-mssql@v1 - with: - components: sqlcmd,sqlengine - version: ${{ matrix.mssql-version }} - sa-password: c0MplicatedP@ssword - force-encryption: ${{ matrix.force-encryption }} - - - name: Setup MSSQL database - shell: pwsh - run: | - & sqlcmd -S localhost -U sa -P "c0MplicatedP@ssword" -i ./test/sql/db-create.sql - & sqlcmd -S localhost -U sa -P "c0MplicatedP@ssword" -i ./test/sql/db-login.sql - - - name: Install toxiproxy-server - shell: pwsh - run: | - choco install toxiproxy-server --version=2.5.0 -y - Start-Process toxiproxy-server - - - name: Test gem - shell: pwsh - run: bundle exec rake test - env: - TOXIPROXY_HOST: "localhost" - - - name: Test Summary - uses: test-summary/action@v2 - with: - paths: "test/reports/TEST-*.xml" - if: always() - install-windows-ucrt: needs: - cross-compile @@ -173,7 +59,6 @@ jobs: fail-fast: false matrix: ruby-version: - - "3.1" - "3.2" - "3.3" - "3.4" @@ -228,7 +113,6 @@ jobs: - 2019 - 2022 ruby-version: - - "3.1" - "3.2" - "3.3" - "3.4" @@ -292,12 +176,6 @@ jobs: fail-fast: false matrix: ruby-version: - # currently fails with a dependency resolution - # looking for conflicting packages... - # :: installing mingw-w64-x86_64-gcc-libs (15.1.0-8) breaks dependency 'mingw-w64-x86_64-gcc-libs=14.2.0-3' required by mingw-w64-x86_64-gcc - # - "2.7" - # - "3.0" - # - "3.1" - "3.2" - "3.3" - "3.4" @@ -354,8 +232,6 @@ jobs: - "aarch64-linux-musl" ruby-version: - - "3.0" - - "3.1" - "3.2" - "3.3" - "3.4" @@ -364,7 +240,6 @@ jobs: include: - platform: x86_64-linux-musl docker_tag: "-alpine" - bootstrap: "apk add -U build-base &&" # required to compile bigdecimal on Ruby 2.7 - platform: aarch64-linux-gnu docker_platform: "--platform=linux/arm64" @@ -372,7 +247,6 @@ jobs: - platform: aarch64-linux-musl docker_platform: "--platform=linux/arm64" docker_tag: "-alpine" - bootstrap: "apk add -U build-base &&" name: install-linux runs-on: ubuntu-22.04 @@ -415,8 +289,6 @@ jobs: - 2022 ruby-version: - - "3.0" - - "3.1" - "3.2" - "3.3" - "3.4" @@ -477,9 +349,6 @@ jobs: fail-fast: false matrix: ruby-version: - - "2.7" - - "3.0" - - "3.1" - "3.2" - "3.3" - "3.4" @@ -524,9 +393,6 @@ jobs: fail-fast: false matrix: ruby-version: - - "2.7" - - "3.0" - - "3.1" - "3.2" - "3.3" - "3.4" @@ -569,7 +435,7 @@ jobs: - uses: ruby/setup-ruby@v1 with: - ruby-version: "2.7" + ruby-version: "3.2" bundler-cache: true - name: Check standardrb diff --git a/CHANGELOG.md b/CHANGELOG.md index 373e3695..a493d31f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## (unreleased) +* Drop support for Ruby < 3.2 + ## 3.4.0 * Add Ruby 4.0 to the cross compile list diff --git a/README.md b/README.md index 04bb2bf6..da0c4bc8 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The API is simple and consists of these classes: ## Install -tiny_tds is tested with Ruby v2.7 and upwards. +tiny_tds is tested with Ruby v3.2 and upwards. ### Windows and Linux (64-bit) diff --git a/Rakefile b/Rakefile index f1c7ed61..d2348e53 100644 --- a/Rakefile +++ b/Rakefile @@ -8,7 +8,6 @@ SPEC = Gem::Specification.load(File.expand_path("../tiny_tds.gemspec", __FILE__) CrossLibrary = Struct.new :platform, :openssl_config CrossLibraries = [ ["x64-mingw-ucrt", "mingw64"], - ["x64-mingw32", "mingw64"], ["x86_64-linux-gnu", "linux-x86_64"], ["x86_64-linux-musl", "linux-x86_64"], ["aarch64-linux-gnu", "linux-aarch64"], diff --git a/ext/tiny_tds/extconf.rb b/ext/tiny_tds/extconf.rb index ec155c14..e0585048 100644 --- a/ext/tiny_tds/extconf.rb +++ b/ext/tiny_tds/extconf.rb @@ -140,17 +140,11 @@ def configure_defaults /usr/local ] - if /darwin/i.match?(RbConfig::CONFIG["host_os"]) - # Ruby below 2.7 seems to label the host CPU on Apple Silicon as aarch64 - # 2.7 and above print is as ARM64 - target_host_cpu = (Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2.7")) ? "aarch64" : "arm64" - - if RbConfig::CONFIG["host_cpu"] == target_host_cpu - # Homebrew on Apple Silicon installs into /opt/hombrew - # https://docs.brew.sh/Installation - # On Intel Macs, it is /usr/local, so no changes necessary to DIRS - DIRS.unshift("/opt/homebrew") - end + # Homebrew on Apple Silicon installs into /opt/hombrew + # https://docs.brew.sh/Installation + # On Intel Macs, it is /usr/local, so no changes necessary to DIRS + if /darwin/i.match?(RbConfig::CONFIG["host_os"]) && RbConfig::CONFIG["host_cpu"] == "arm64" + DIRS.unshift("/opt/homebrew") end if ENV["RI_DEVKIT"] && ENV["MINGW_PREFIX"] # RubyInstaller Support diff --git a/patches/freetds/1.00.27/0001-mingw_missing_inet_pton.diff b/patches/freetds/1.00.27/0001-mingw_missing_inet_pton.diff deleted file mode 100644 index ffefc8df..00000000 --- a/patches/freetds/1.00.27/0001-mingw_missing_inet_pton.diff +++ /dev/null @@ -1,34 +0,0 @@ -diff --git a/src/tds/tls.c b/src/tds/tls.c -index 09e7fa0..1da18f6 100644 ---- a/src/tds/tls.c -+++ b/src/tds/tls.c -@@ -101,6 +101,29 @@ - #define SSL_PTR BIO_get_data(bio) - #endif - -+/* -+ * Add a workaround for older Mingw versions without inet_pton(). -+ * This means RubyInstallers DevKit-4.7.2 in particular. -+ */ -+#if defined(__MINGW32__) && !defined(InetPtonA) -+ #include -+ -+ static HMODULE ws2_32 = NULL; -+ typedef INT (WINAPI * __inet_pton)(INT Family, LPCWSTR pStringBuf, PVOID pAddr); -+ static __inet_pton _inet_pton = NULL; -+ -+ INT WINAPI inet_pton(INT Family, LPCWSTR pStringBuf, PVOID pAddr) -+ { -+ if (_inet_pton == NULL) { -+ ws2_32 = LoadLibraryEx("Ws2_32.dll", NULL, 0); -+ -+ _inet_pton = (__inet_pton)GetProcAddress(ws2_32, "inet_pton"); -+ } -+ -+ return (_inet_pton)(Family, pStringBuf, pAddr); -+ } -+#endif -+ - static SSL_RET - tds_pull_func_login(SSL_PULL_ARGS) - { diff --git a/patches/freetds/1.00.27/0002-Don-t-use-MSYS2-file-libws2_32.diff b/patches/freetds/1.00.27/0002-Don-t-use-MSYS2-file-libws2_32.diff deleted file mode 100644 index 13b874cd..00000000 --- a/patches/freetds/1.00.27/0002-Don-t-use-MSYS2-file-libws2_32.diff +++ /dev/null @@ -1,28 +0,0 @@ -From 56e8972f66c3e948e2ad6885595c58fd23dcdb37 Mon Sep 17 00:00:00 2001 -From: Lars Kanis -Date: Thu, 6 Jul 2017 17:09:40 +0200 -Subject: [PATCH] Don't use MSYS2 file libws2_32.a for MINGW build - -This file is intended for MSYS2/cygwin builds and blocks OpenSSL -detection of freetds on i686. ---- - configure | 2 -- - configure.ac | 2 -- - 2 files changed, 4 deletions(-) - -diff --git a/configure b/configure -index 9495a49..31eb01d 100644 ---- a/configure -+++ b/configure -@@ -15915,8 +15915,6 @@ case $host in - tds_mingw=yes - if test "$host_cpu" = "x86_64"; then - LIBS="-lws2_32" -- elif test -r /usr/lib/w32api/libws2_32.a; then -- LIBS="-L/usr/lib/w32api -lws2_32" - else - LIBS="-lws2_32" - fi --- -2.6.2.windows.1 - diff --git a/tasks/native_gem.rake b/tasks/native_gem.rake index f3c59baa..ea5e0a58 100644 --- a/tasks/native_gem.rake +++ b/tasks/native_gem.rake @@ -7,7 +7,7 @@ CrossLibraries.each do |xlib| RakeCompilerDock.sh <<-EOT, platform: platform bundle install && - rake native:#{platform} pkg/#{SPEC.full_name}-#{platform}.gem MAKEOPTS=-j`nproc` RUBY_CC_VERSION=#{RakeCompilerDock.set_ruby_cc_version("~> 3.0", "~> 4.0")} MAKEFLAGS="V=1" + rake native:#{platform} pkg/#{SPEC.full_name}-#{platform}.gem MAKEOPTS=-j`nproc` RUBY_CC_VERSION=#{RakeCompilerDock.set_ruby_cc_version("~> 3.2", "~> 4.0")} MAKEFLAGS="V=1" EOT end diff --git a/tiny_tds.gemspec b/tiny_tds.gemspec index cf101402..4990ad56 100644 --- a/tiny_tds.gemspec +++ b/tiny_tds.gemspec @@ -16,7 +16,7 @@ Gem::Specification.new do |s| s.rdoc_options = ["--charset=UTF-8"] s.extensions = ["ext/tiny_tds/extconf.rb"] s.license = "MIT" - s.required_ruby_version = ">= 2.7.0" + s.required_ruby_version = ">= 3.2.0" s.metadata["msys2_mingw_dependencies"] = "freetds" s.add_dependency "bigdecimal", ">= 2.0.0" s.add_development_dependency "mini_portile2", "~> 2.8.0" From 007636cd23ae2cc01a06da8be98b13fcab10ec37 Mon Sep 17 00:00:00 2001 From: Andy Pfister Date: Mon, 8 Sep 2025 15:01:35 +0200 Subject: [PATCH 03/10] Remove `.rubocop.yml` --- .rubocop.yml | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 .rubocop.yml diff --git a/.rubocop.yml b/.rubocop.yml deleted file mode 100644 index e20a0d8c..00000000 --- a/.rubocop.yml +++ /dev/null @@ -1,31 +0,0 @@ - -AllCops: - Include: - - '**/Rakefile' - Exclude: - - 'ext/**/*' - - 'ports/**/*' - - 'test/**/*' - - 'tmp/**/*' - -# Redefined - -Metrics/LineLength: - Max: 120 - -Metrics/ClassLength: - Max: 200 - -# Disabled - -Style/Documentation: - Enabled: false - -Style/EmptyLinesAroundClassBody: - Enabled: false - -Style/EmptyLinesAroundModuleBody: - Enabled: false - -Style/EmptyLinesAroundBlockBody: - Enabled: false From 6f2bc3e2d27de1c76e7be522ae2194552a82826f Mon Sep 17 00:00:00 2001 From: Andy Pfister Date: Mon, 8 Sep 2025 15:08:34 +0200 Subject: [PATCH 04/10] Update gems --- test/schema_test.rb | 4 ++-- tiny_tds.gemspec | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/schema_test.rb b/test/schema_test.rb index 4e8456d5..34087f69 100644 --- a/test/schema_test.rb +++ b/test/schema_test.rb @@ -120,8 +120,8 @@ class SchemaTest < TinyTds::TestCase it "casts numeric" do assert_instance_of BigDecimal, find_value(191, :numeric_18_0) - assert_equal BigDecimal("191"), find_value(191, :numeric_18_0) - assert_equal BigDecimal("123456789012345678"), find_value(192, :numeric_18_0) + assert_equal BigDecimal(191), find_value(191, :numeric_18_0) + assert_equal BigDecimal(123456789012345678), find_value(192, :numeric_18_0) assert_equal BigDecimal("12345678901234567890.01"), find_value(193, :numeric_36_2) assert_equal BigDecimal("123.46"), find_value(194, :numeric_36_2) end diff --git a/tiny_tds.gemspec b/tiny_tds.gemspec index 4990ad56..2301cbd6 100644 --- a/tiny_tds.gemspec +++ b/tiny_tds.gemspec @@ -24,10 +24,10 @@ Gem::Specification.new do |s| s.add_development_dependency "rake-compiler", "~> 1.2" s.add_development_dependency "rake-compiler-dock", "~> 1.11.0" s.add_development_dependency "minitest", "~> 5.25" - s.add_development_dependency "minitest-reporters", "~> 1.6.1" - s.add_development_dependency "connection_pool", "~> 2.2.0" + s.add_development_dependency "minitest-reporters", "~> 1.7.1" + s.add_development_dependency "connection_pool", "~> 2.5.0" s.add_development_dependency "toxiproxy", "~> 2.0.0" - s.add_development_dependency "standard", "~> 1.31.0" + s.add_development_dependency "standard", "~> 1.50.0" # ostruct can be dropped when updating to Rubocop 1.65+ s.add_development_dependency "ostruct" s.add_development_dependency "benchmark" From 87ff53c81932442ca808dd064c22ef6c76f3fc05 Mon Sep 17 00:00:00 2001 From: Andy Pfister Date: Fri, 1 Aug 2025 13:53:55 +0200 Subject: [PATCH 05/10] Mention to compile `tiny_tds` --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index da0c4bc8..bef605eb 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,7 @@ From within the container, you can run the tests using the following command: ```shell bundle install +bundle exec rake compile bundle exec rake test ``` From 0f8c3fb99eee15309057b2831e660471ba428ec2 Mon Sep 17 00:00:00 2001 From: Andy Pfister Date: Mon, 8 Sep 2025 15:18:07 +0200 Subject: [PATCH 06/10] Drop support for SQL Server 2017 --- .github/workflows/ci.yml | 5 ++--- CHANGELOG.md | 1 + README.md | 2 +- test/schema/{sqlserver_2017.sql => sqlserver_2019.sql} | 0 test/test_helper.rb | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) rename test/schema/{sqlserver_2017.sql => sqlserver_2019.sql} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9cfd4f0..37a9d71f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,7 +109,6 @@ jobs: - false - true mssql-version: - - 2017 - 2019 - 2022 ruby-version: @@ -140,7 +139,7 @@ jobs: RUBY_ARCHITECTURE: "x64-mingw-ucrt" - name: Setup MSSQL - uses: rails-sqlserver/setup-mssql@v1 + uses: rails-sqlserver/setup-mssql@v2 with: components: sqlcmd,sqlengine version: ${{ matrix.mssql-version }} @@ -315,7 +314,7 @@ jobs: RUBY_ARCHITECTURE: "x86_64-linux-gnu" - name: Setup MSSQL - uses: rails-sqlserver/setup-mssql@v1 + uses: rails-sqlserver/setup-mssql@v2 with: components: sqlcmd,sqlengine version: ${{ matrix.mssql-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a493d31f..a7bd83bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## (unreleased) * Drop support for Ruby < 3.2 +* Drop support for SQL Server < 2019 ## 3.4.0 diff --git a/README.md b/README.md index bef605eb..d4929032 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,7 @@ bundle exec rake test You can customize the environment variables to run the tests against a different environment ```shell -rake test TINYTDS_UNIT_DATASERVER=mydbserver TINYTDS_SCHEMA=sqlserver_2017 +rake test TINYTDS_UNIT_DATASERVER=mydbserver TINYTDS_SCHEMA=sqlserver_2019 rake test TINYTDS_UNIT_HOST=mydb.host.net TINYTDS_SCHEMA=sqlserver_azure ``` diff --git a/test/schema/sqlserver_2017.sql b/test/schema/sqlserver_2019.sql similarity index 100% rename from test/schema/sqlserver_2017.sql rename to test/schema/sqlserver_2019.sql diff --git a/test/test_helper.rb b/test/test_helper.rb index f5ac46d3..aab1f3e9 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -7,13 +7,13 @@ require "minitest/reporters" Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new, Minitest::Reporters::JUnitReporter.new] -TINYTDS_SCHEMAS = ["sqlserver_2017", "sqlserver_azure"].freeze +TINYTDS_SCHEMAS = ["sqlserver_2019", "sqlserver_azure"].freeze module TinyTds class TestCase < Minitest::Spec class << self def current_schema - ENV["TINYTDS_SCHEMA"] || "sqlserver_2017" + ENV["TINYTDS_SCHEMA"] || "sqlserver_2019" end TINYTDS_SCHEMAS.each do |schema| From c51d50aa7a87c595b33fab028dd06db21cae9c57 Mon Sep 17 00:00:00 2001 From: Andy Pfister Date: Tue, 9 Sep 2025 13:02:21 +0200 Subject: [PATCH 07/10] v4: Refactor `tiny_tds` to avoid sharing `DBPROCESS` (#595) * Move `insert` to client class * Move `do` to client class * Refactor `execute` to fetch an entire result object * Ensure test database data is loaded before running tests * Update `CHANGELOG` * Use `int64_t` instead of custom `LONG_LONG_FORMAT` macro --- CHANGELOG.md | 5 + README.md | 172 +++----- ext/tiny_tds/client.c | 645 +++++++++++++++++++++++++++--- ext/tiny_tds/result.c | 755 ------------------------------------ ext/tiny_tds/result.h | 32 -- ext/tiny_tds/tiny_tds_ext.c | 2 - ext/tiny_tds/tiny_tds_ext.h | 1 - lib/tiny_tds/client.rb | 7 +- lib/tiny_tds/result.rb | 6 + test/client_test.rb | 75 +++- test/result_test.rb | 430 +++++++------------- test/schema_test.rb | 2 +- test/test_helper.rb | 43 +- test/thread_test.rb | 2 +- 14 files changed, 883 insertions(+), 1294 deletions(-) delete mode 100644 ext/tiny_tds/result.c delete mode 100644 ext/tiny_tds/result.h diff --git a/CHANGELOG.md b/CHANGELOG.md index a7bd83bf..2360cdca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ * Drop support for Ruby < 3.2 * Drop support for SQL Server < 2019 +* Removed lazy-loading of results for `execute` +* Moved `#do`, `#insert` and `#execute` methods to the `TinyTds::Client` class + * `TinyTds::Result` is now a pure Ruby class +* `#execute`: Replaced `opts` hash with keyword arguments +* Removed `symbolize_keys` and `cache_rows` from `#default_query_options` ## 3.4.0 diff --git a/README.md b/README.md index d4929032..7bc697d1 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,8 @@ opts[:message_handler] = Proc.new { |m| puts m.message } client = TinyTds::Client.new opts # => Changed database context to 'master'. # => Changed language setting to us_english. -client.execute("print 'hello world!'").do -# => hello world! +client.do("print 'hello world!'") +# => -1 (no affected rows) ``` Use the `#active?` method to determine if a connection is good. The implementation of this method may change but it should always guarantee that a connection is good. Current it checks for either a closed or dead connection. @@ -153,169 +153,99 @@ Send a SQL string to the database and return a TinyTds::Result object. result = client.execute("SELECT * FROM [datatypes]") ``` +## Sending queries and receiving results -## TinyTds::Result Usage +The client implements three different methods to send queries to a SQL server. -A result object is returned by the client's execute command. It is important that you either return the data from the query, most likely with the #each method, or that you cancel the results before asking the client to execute another SQL batch. Failing to do so will yield an error. - -Calling #each on the result will lazily load each row from the database. +`client.insert` will execute the query and return the last identifier. ```ruby -result.each do |row| - # By default each row is a hash. - # The keys are the fields, as you'd expect. - # The values are pre-built Ruby primitives mapped from their corresponding types. -end +client.insert("INSERT INTO [datatypes] ([varchar_50]) VALUES ('text')") +# => 363 ``` -A result object has a `#fields` accessor. It can be called before the result rows are iterated over. Even if no rows are returned, #fields will still return the column names you expected. Any SQL that does not return columned data will always return an empty array for `#fields`. It is important to remember that if you access the `#fields` before iterating over the results, the columns will always follow the default query option's `:symbolize_keys` setting at the client's level and will ignore the query options passed to each. +`client.do` will execute the query and tell you how many rows were affected. ```ruby -result = client.execute("USE [tinytdstest]") -result.fields # => [] -result.do - -result = client.execute("SELECT [id] FROM [datatypes]") -result.fields # => ["id"] -result.cancel -result = client.execute("SELECT [id] FROM [datatypes]") -result.each(:symbolize_keys => true) -result.fields # => [:id] +client.do("DELETE FROM [datatypes] WHERE [varchar_50] = 'text'") +# 1 ``` -You can cancel a result object's data from being loading by the server. +Both `do` and `insert` will not serialize any results sent by the SQL server, making them extremely fast and memory-efficient for large operations. + +`client.execute` will execute the query and return you a `TinyTds::Result` object. ```ruby -result = client.execute("SELECT * FROM [super_big_table]") -result.cancel +client.execute("SELECT [id] FROM [datatypes]") +# => +# #11}, +# {"id"=>12}, +# {"id"=>21}, +# {"id"=>31}, ``` -You can use results cancelation in conjunction with results lazy loading, no problem. +A result object has a `fields` accessor. Even if no rows are returned, `fields` will still return the column names you expected. Any SQL that does not return columned data will always return an empty array for `fields`. ```ruby -result = client.execute("SELECT * FROM [super_big_table]") -result.each_with_index do |row, i| - break if row > 10 -end -result.cancel +result = client.execute("USE [tinytdstest]") +result.fields # => [] + +result = client.execute("SELECT [id] FROM [datatypes]") +result.fields # => ["id"] ``` -If the SQL executed by the client returns affected rows, you can easily find out how many. +You can retrieve the results by accessing the `rows` property on the result. ```ruby -result.each -result.affected_rows # => 24 +result.rows +# => +# [{"id"=>11}, +# {"id"=>12}, +# {"id"=>21}, +# ... ``` -This pattern is so common for UPDATE and DELETE statements that the #do method cancels any need for loading the result data and returns the `#affected_rows`. +The result object also has `affected_rows`, which usually also corresponds to the length of items in `rows`. But if you execute a `DELETE` statement with `execute, `rows` is likely empty but `affected_rows` will still list a couple of items. ```ruby result = client.execute("DELETE FROM [datatypes]") -result.do # => 72 +# # +result.count +# 0 +result.affected_rows +# 75 ``` -Likewise for `INSERT` statements, the #insert method cancels any need for loading the result data and executes a `SCOPE_IDENTITY()` for the primary key. - -```ruby -result = client.execute("INSERT INTO [datatypes] ([xml]) VALUES ('
')") -result.insert # => 420 -``` +But as mentioned earlier, best use `do` when you are only interested in the `affected_rows`. -The result object can handle multiple result sets form batched SQL or stored procedures. It is critical to remember that when calling each with a block for the first time will return each "row" of each result set. Calling each a second time with a block will yield each "set". +The result object can handle multiple result sets form batched SQL or stored procedures. ```ruby sql = ["SELECT TOP (1) [id] FROM [datatypes]", "SELECT TOP (2) [bigint] FROM [datatypes] WHERE [bigint] IS NOT NULL"].join(' ') -set1, set2 = client.execute(sql).each +set1, set2 = client.execute(sql).rows set1 # => [{"id"=>11}] set2 # => [{"bigint"=>-9223372036854775807}, {"bigint"=>9223372036854775806}] - -result = client.execute(sql) - -result.each do |rowset| - # First time data loading, yields each row from each set. - # 1st: {"id"=>11} - # 2nd: {"bigint"=>-9223372036854775807} - # 3rd: {"bigint"=>9223372036854775806} -end - -result.each do |rowset| - # Second time over (if columns cached), yields each set. - # 1st: [{"id"=>11}] - # 2nd: [{"bigint"=>-9223372036854775807}, {"bigint"=>9223372036854775806}] -end -``` - -Use the `#sqlsent?` and `#canceled?` query methods on the client to determine if an active SQL batch still needs to be processed and or if data results were canceled from the last result object. These values reset to true and false respectively for the client at the start of each `#execute` and new result object. Or if all rows are processed normally, `#sqlsent?` will return false. To demonstrate, lets assume we have 100 rows in the result object. - -```ruby -client.sqlsent? # = false -client.canceled? # = false - -result = client.execute("SELECT * FROM [super_big_table]") - -client.sqlsent? # = true -client.canceled? # = false - -result.each do |row| - # Assume we break after 20 rows with 80 still pending. - break if row["id"] > 20 -end - -client.sqlsent? # = true -client.canceled? # = false - -result.cancel - -client.sqlsent? # = false -client.canceled? # = true -``` - -It is possible to get the return code after executing a stored procedure from either the result or client object. - -```ruby -client.return_code # => nil - -result = client.execute("EXEC tinytds_TestReturnCodes") -result.do -result.return_code # => 420 -client.return_code # => 420 ``` - ## Query Options -Every `TinyTds::Result` object can pass query options to the #each method. The defaults are defined and configurable by setting options in the `TinyTds::Client.default_query_options` hash. The default values are: - -* :as => :hash - Object for each row yielded. Can be set to :array. -* :symbolize_keys => false - Row hash keys. Defaults to shared/frozen string keys. -* :cache_rows => true - Successive calls to #each returns the cached rows. -* :timezone => :local - Local to the Ruby client or :utc for UTC. -* :empty_sets => true - Include empty results set in queries that return multiple result sets. +You can pass query options to `execute`. The defaults are defined and configurable by setting options in the `TinyTds::Client.default_query_options` hash. The default values are: -Each result gets a copy of the default options you specify at the client level and can be overridden by passing an options hash to the #each method. For example +* `as: :hash` - Object for each row yielded. Can be set to :array. +* `empty_sets: true` - Include empty results set in queries that return multiple result sets. +* `timezone: :local` - Local to the Ruby client or :utc for UTC. ```ruby -result.each(:as => :array, :cache_rows => false) do |row| - # Each row is now an array of values ordered by #fields. - # Rows are yielded and forgotten about, freeing memory. -end +result = client.execute("SELECT [datetime2_2] FROM [datatypes] WHERE [id] = 74", as: :array, timezone: :utc, empty_sets: true) +# => # ``` -Besides the standard query options, the result object can take one additional option. Using `:first => true` will only load the first row of data and cancel all remaining results. - -```ruby -result = client.execute("SELECT * FROM [super_big_table]") -result.each(:first => true) # => [{'id' => 24}] -``` - - -## Row Caching - -By default row caching is turned on because the SQL Server adapter for ActiveRecord would not work without it. I hope to find some time to create some performance patches for ActiveRecord that would allow it to take advantages of lazily created yielded rows from result objects. Currently only TinyTDS and the Mysql2 gem allow such a performance gain. - - ## Encoding Error Handling TinyTDS takes an opinionated stance on how we handle encoding errors. First, we treat errors differently on reads vs. writes. Our opinion is that if you are reading bad data due to your client's encoding option, you would rather just find `?` marks in your strings vs being blocked with exceptions. This is how things wold work via ODBC or SMS. On the other hand, writes will raise an exception. In this case we raise the SYBEICONVO/2402 error message which has a description of `Error converting characters into server's character set. Some character(s) could not be converted.`. Even though the severity of this message is only a `4` and TinyTDS will automatically strip/ignore unknown characters, we feel you should know that you are inserting bad encodings. In this way, a transaction can be rolled back, etc. Remember, any database write that has bad characters due to the client encoding will still be written to the database, but it is up to you rollback said write if needed. Most ORMs like ActiveRecord handle this scenario just fine. diff --git a/ext/tiny_tds/client.c b/ext/tiny_tds/client.c index 5f98dee4..5e6b2191 100644 --- a/ext/tiny_tds/client.c +++ b/ext/tiny_tds/client.c @@ -1,5 +1,6 @@ #include #include +#include VALUE cTinyTdsClient; extern VALUE mTinyTds, cTinyTdsError; @@ -8,6 +9,13 @@ static ID intern_source_eql, intern_severity_eql, intern_db_error_number_eql, in static ID intern_new, intern_dup, intern_transpose_iconv_encoding, intern_local_offset, intern_gsub, intern_call; VALUE opt_escape_regex, opt_escape_dblquote; +static ID id_ivar_fields, id_ivar_rows, id_ivar_return_code, id_ivar_affected_rows, id_ivar_default_query_options, intern_bigd, intern_divide; +static ID sym_as, sym_array, sym_timezone, sym_empty_sets, sym_local, sym_utc, intern_utc, intern_local, intern_as, intern_empty_sets, intern_timezone; +static VALUE cTinyTdsResult, cKernel, cDate; + +rb_encoding *binaryEncoding; +VALUE opt_onek, opt_onebil, opt_float_zero, opt_four, opt_tenk; + static void rb_tinytds_client_mark(void *ptr) { tinytds_client_wrapper *cwrap = (tinytds_client_wrapper *)ptr; @@ -57,12 +65,16 @@ static const rb_data_type_t tinytds_client_wrapper_type = { tinytds_client_wrapper *cwrap; \ TypedData_Get_Struct(self, tinytds_client_wrapper, &tinytds_client_wrapper_type, cwrap) -#define REQUIRE_OPEN_CLIENT(cwrap) \ - if (cwrap->closed || cwrap->userdata->closed) { \ - rb_raise(cTinyTdsError, "closed connection"); \ - return Qnil; \ - } - +#define ENCODED_STR_NEW(_data, _len) ({ \ + VALUE _val = rb_str_new((char *)_data, (long)_len); \ + rb_enc_associate(_val, cwrap->encoding); \ + _val; \ +}) +#define ENCODED_STR_NEW2(_data2) ({ \ + VALUE _val = rb_str_new2((char *)_data2); \ + rb_enc_associate(_val, cwrap->encoding); \ + _val; \ +}) // Lib Backend (Helpers) @@ -107,6 +119,175 @@ VALUE rb_tinytds_raise_error(DBPROCESS *dbproc, tinytds_errordata error) return Qnil; } +static void rb_tinytds_client_reset_userdata(tinytds_client_userdata *userdata) +{ + userdata->timing_out = 0; + userdata->dbsql_sent = 0; + userdata->dbsqlok_sent = 0; + userdata->dbcancel_sent = 0; + userdata->nonblocking = 0; + // the following is mainly done for consistency since the values are reset accordingly in nogvl_setup/cleanup. + // the nonblocking_errors array does not need to be freed here. That is done as part of nogvl_cleanup. + userdata->nonblocking_errors_length = 0; + userdata->nonblocking_errors_size = 0; +} + +// code part used to invoke FreeTDS functions with releasing the Ruby GVL +// basically, while FreeTDS is interacting with the SQL server, other Ruby code can be executed +#define NOGVL_DBCALL(_dbfunction, _client) ( \ + (RETCODE)(intptr_t)rb_thread_call_without_gvl( \ + (void *(*)(void *))_dbfunction, _client, \ + (rb_unblock_function_t*)dbcancel_ubf, _client ) \ +) + +static void dbcancel_ubf(DBPROCESS *client) +{ + GET_CLIENT_USERDATA(client); + dbcancel(client); + userdata->dbcancel_sent = 1; +} + +static void nogvl_setup(DBPROCESS *client) +{ + GET_CLIENT_USERDATA(client); + userdata->nonblocking = 1; + userdata->nonblocking_errors_length = 0; + userdata->nonblocking_errors = malloc(ERRORS_STACK_INIT_SIZE * sizeof(tinytds_errordata)); + userdata->nonblocking_errors_size = ERRORS_STACK_INIT_SIZE; +} + +static void nogvl_cleanup(DBPROCESS *client) +{ + GET_CLIENT_USERDATA(client); + userdata->nonblocking = 0; + userdata->timing_out = 0; + /* + Now that the blocking operation is done, we can finally throw any + exceptions based on errors from SQL Server. + */ + short int i; + + for (i = 0; i < userdata->nonblocking_errors_length; i++) { + tinytds_errordata error = userdata->nonblocking_errors[i]; + + // lookahead to drain any info messages ahead of raising error + if (!error.is_message) { + short int j; + + for (j = i; j < userdata->nonblocking_errors_length; j++) { + tinytds_errordata msg_error = userdata->nonblocking_errors[j]; + + if (msg_error.is_message) { + rb_tinytds_raise_error(client, msg_error); + } + } + } + + rb_tinytds_raise_error(client, error); + } + + free(userdata->nonblocking_errors); + userdata->nonblocking_errors_length = 0; + userdata->nonblocking_errors_size = 0; +} + +static RETCODE nogvl_dbnextrow(DBPROCESS * client) +{ + int retcode = FAIL; + nogvl_setup(client); + retcode = NOGVL_DBCALL(dbnextrow, client); + nogvl_cleanup(client); + return retcode; +} + +static RETCODE nogvl_dbresults(DBPROCESS *client) +{ + int retcode = FAIL; + nogvl_setup(client); + retcode = NOGVL_DBCALL(dbresults, client); + nogvl_cleanup(client); + return retcode; +} + +static RETCODE nogvl_dbsqlexec(DBPROCESS *client) +{ + int retcode = FAIL; + nogvl_setup(client); + retcode = NOGVL_DBCALL(dbsqlexec, client); + nogvl_cleanup(client); + return retcode; +} + +static RETCODE nogvl_dbsqlok(DBPROCESS *client) +{ + int retcode = FAIL; + GET_CLIENT_USERDATA(client); + nogvl_setup(client); + retcode = NOGVL_DBCALL(dbsqlok, client); + nogvl_cleanup(client); + userdata->dbsqlok_sent = 1; + return retcode; +} + +// some additional helpers interacting with the SQL server +static void rb_tinytds_send_sql_to_server(tinytds_client_wrapper *cwrap, VALUE sql) +{ + rb_tinytds_client_reset_userdata(cwrap->userdata); + + if (cwrap->closed || cwrap->userdata->closed) { + rb_raise(cTinyTdsError, "closed connection"); + } + + dbcmd(cwrap->client, StringValueCStr(sql)); + + if (dbsqlsend(cwrap->client) == FAIL) { + rb_raise(cTinyTdsError, "failed dbsqlsend() function"); + } + + cwrap->userdata->dbsql_sent = 1; +} + +static RETCODE rb_tiny_tds_client_ok_helper(DBPROCESS *client) +{ + GET_CLIENT_USERDATA(client); + + if (userdata->dbsqlok_sent == 0) { + userdata->dbsqlok_retcode = nogvl_dbsqlok(client); + } + + return userdata->dbsqlok_retcode; +} + +static void rb_tinytds_client_cancel_results(DBPROCESS * client) +{ + GET_CLIENT_USERDATA(client); + dbcancel(client); + userdata->dbcancel_sent = 1; + userdata->dbsql_sent = 0; +} + +static void rb_tinytds_result_exec_helper(DBPROCESS *client) +{ + RETCODE dbsqlok_rc = rb_tiny_tds_client_ok_helper(client); + + if (dbsqlok_rc == SUCCEED) { + /* + This is to just process each result set. Commands such as backup and + restore are not done when the first result set is returned, so we need to + exhaust the result sets before it is complete. + */ + while (nogvl_dbresults(client) == SUCCEED) { + /* + If we don't loop through each row for calls to TinyTds::Client.do that + actually do return result sets, we will trigger error 20019 about trying + to execute a new command with pending results. Oh well. + */ + while (dbnextrow(client) != NO_MORE_ROWS); + } + } + + rb_tinytds_client_cancel_results(client); +} // Lib Backend (Memory Management & Handlers) static void push_userdata_error(tinytds_client_userdata *userdata, tinytds_errordata error) @@ -273,19 +454,6 @@ static int handle_interrupt(void *ptr) return INT_CONTINUE; } -static void rb_tinytds_client_reset_userdata(tinytds_client_userdata *userdata) -{ - userdata->timing_out = 0; - userdata->dbsql_sent = 0; - userdata->dbsqlok_sent = 0; - userdata->dbcancel_sent = 0; - userdata->nonblocking = 0; - // the following is mainly done for consistency since the values are reset accordingly in nogvl_setup/cleanup. - // the nonblocking_errors array does not need to be freed here. That is done as part of nogvl_cleanup. - userdata->nonblocking_errors_length = 0; - userdata->nonblocking_errors_size = 0; -} - static VALUE allocate(VALUE klass) { VALUE obj; @@ -346,28 +514,382 @@ static VALUE rb_tinytds_sqlsent(VALUE self) return cwrap->userdata->dbsql_sent ? Qtrue : Qfalse; } -static VALUE rb_tinytds_execute(VALUE self, VALUE sql) +static VALUE rb_tinytds_result_fetch_value(VALUE self, ID timezone, unsigned int number_of_fields, int field_index) +{ + GET_CLIENT_WRAPPER(self); + + VALUE val = Qnil; + + int col = field_index + 1; + int coltype = dbcoltype(cwrap->client, col); + BYTE *data = dbdata(cwrap->client, col); + DBINT data_len = dbdatlen(cwrap->client, col); + int null_val = ((data == NULL) && (data_len == 0)); + + if (!null_val) { + switch(coltype) { + case SYBINT1: + val = INT2FIX(*(DBTINYINT *)data); + break; + + case SYBINT2: + val = INT2FIX(*(DBSMALLINT *)data); + break; + + case SYBINT4: + val = INT2NUM(*(DBINT *)data); + break; + + case SYBINT8: + val = LL2NUM(*(DBBIGINT *)data); + break; + + case SYBBIT: + val = *(int *)data ? Qtrue : Qfalse; + break; + + case SYBNUMERIC: + case SYBDECIMAL: { + DBTYPEINFO *data_info = dbcoltypeinfo(cwrap->client, col); + int data_slength = (int)data_info->precision + (int)data_info->scale + 1; + char converted_decimal[data_slength]; + dbconvert(cwrap->client, coltype, data, data_len, SYBVARCHAR, (BYTE *)converted_decimal, -1); + val = rb_funcall(cKernel, intern_bigd, 1, rb_str_new2((char *)converted_decimal)); + break; + } + + case SYBFLT8: { + double col_to_double = *(double *)data; + val = (col_to_double == 0.000000) ? opt_float_zero : rb_float_new(col_to_double); + break; + } + + case SYBREAL: { + float col_to_float = *(float *)data; + val = (col_to_float == 0.0) ? opt_float_zero : rb_float_new(col_to_float); + break; + } + + case SYBMONEY: { + DBMONEY *money = (DBMONEY *)data; + char converted_money[25]; + int64_t money_value = ((int64_t)money->mnyhigh << 32) | money->mnylow; + + sprintf(converted_money, "%" PRId64, money_value); + + val = rb_funcall(cKernel, intern_bigd, 2, rb_str_new2(converted_money), opt_four); + val = rb_funcall(val, intern_divide, 1, opt_tenk); + break; + } + + case SYBMONEY4: { + DBMONEY4 *money = (DBMONEY4 *)data; + char converted_money[20]; + sprintf(converted_money, "%f", money->mny4 / 10000.0); + val = rb_funcall(cKernel, intern_bigd, 1, rb_str_new2(converted_money)); + break; + } + + case SYBBINARY: + case SYBIMAGE: + val = rb_str_new((char *)data, (long)data_len); + rb_enc_associate(val, binaryEncoding); + break; + + case 36: { // SYBUNIQUE + char converted_unique[37]; + dbconvert(cwrap->client, coltype, data, 37, SYBVARCHAR, (BYTE *)converted_unique, -1); + val = ENCODED_STR_NEW2(converted_unique); + break; + } + + case SYBDATETIME4: { + DBDATETIME new_data; + dbconvert(cwrap->client, coltype, data, data_len, SYBDATETIME, (BYTE *)&new_data, sizeof(new_data)); + data = (BYTE *)&new_data; + data_len = sizeof(new_data); + } + + case SYBDATETIME: { + DBDATEREC dr; + dbdatecrack(cwrap->client, &dr, (DBDATETIME *)data); + + if (dr.year + dr.month + dr.day + dr.hour + dr.minute + dr.second + dr.millisecond != 0) { + val = rb_funcall(rb_cTime, timezone, 7, INT2NUM(dr.year), INT2NUM(dr.month), INT2NUM(dr.day), INT2NUM(dr.hour), INT2NUM(dr.minute), INT2NUM(dr.second), INT2NUM(dr.millisecond*1000)); + } + + break; + } + + case SYBMSDATE: + case SYBMSTIME: + case SYBMSDATETIME2: + case SYBMSDATETIMEOFFSET: { + DBDATEREC2 dr2; + dbanydatecrack(cwrap->client, &dr2, coltype, data); + + switch(coltype) { + case SYBMSDATE: { + val = rb_funcall(cDate, intern_new, 3, INT2NUM(dr2.year), INT2NUM(dr2.month), INT2NUM(dr2.day)); + break; + } + + case SYBMSTIME: { + VALUE rational_nsec = rb_Rational(INT2NUM(dr2.nanosecond), opt_onek); + val = rb_funcall(rb_cTime, timezone, 7, INT2NUM(1900), INT2NUM(1), INT2NUM(1), INT2NUM(dr2.hour), INT2NUM(dr2.minute), INT2NUM(dr2.second), rational_nsec); + break; + } + + case SYBMSDATETIME2: { + VALUE rational_nsec = rb_Rational(INT2NUM(dr2.nanosecond), opt_onek); + val = rb_funcall(rb_cTime, timezone, 7, INT2NUM(dr2.year), INT2NUM(dr2.month), INT2NUM(dr2.day), INT2NUM(dr2.hour), INT2NUM(dr2.minute), INT2NUM(dr2.second), rational_nsec); + break; + } + + case SYBMSDATETIMEOFFSET: { + long long numerator = ((long)dr2.second * (long long)1000000000) + (long long)dr2.nanosecond; + VALUE rational_sec = rb_Rational(LL2NUM(numerator), opt_onebil); + val = rb_funcall(rb_cTime, intern_new, 7, INT2NUM(dr2.year), INT2NUM(dr2.month), INT2NUM(dr2.day), INT2NUM(dr2.hour), INT2NUM(dr2.minute), rational_sec, INT2NUM(dr2.tzone*60)); + break; + } + } + + break; + } + + case SYBCHAR: + case SYBTEXT: + val = ENCODED_STR_NEW(data, data_len); + break; + + case 98: { // SYBVARIANT + if (data_len == 4) { + val = INT2NUM(*(DBINT *)data); + break; + } else { + val = ENCODED_STR_NEW(data, data_len); + break; + } + } + + default: + val = ENCODED_STR_NEW(data, data_len); + break; + } + } + + return val; +} + +static VALUE get_default_query_option(VALUE key) { - VALUE result; + return rb_hash_aref(rb_ivar_get(cTinyTdsClient, id_ivar_default_query_options), key); +} +static VALUE rb_tinytds_return_code(VALUE self) +{ GET_CLIENT_WRAPPER(self); - rb_tinytds_client_reset_userdata(cwrap->userdata); - REQUIRE_OPEN_CLIENT(cwrap); - dbcmd(cwrap->client, StringValueCStr(sql)); - if (dbsqlsend(cwrap->client) == FAIL) { - rb_raise(cTinyTdsError, "failed dbsqlsend() function"); + if (cwrap->client && dbhasretstat(cwrap->client)) { + return LONG2NUM((long)dbretstatus(cwrap->client)); + } else { + return Qnil; } +} - cwrap->userdata->dbsql_sent = 1; - result = rb_tinytds_new_result_obj(cwrap); - rb_iv_set(result, "@query_options", rb_funcall(rb_iv_get(self, "@query_options"), intern_dup, 0)); - { - GET_RESULT_WRAPPER(result); - rwrap->local_offset = rb_funcall(cTinyTdsClient, intern_local_offset, 0); - rwrap->encoding = cwrap->encoding; - return result; +static VALUE rb_tinytds_affected_rows(DBPROCESS * client) +{ + return LONG2NUM((long)dbcount(client)); +} + +static VALUE rb_tinytds_execute(int argc, VALUE *argv, VALUE self) +{ + VALUE sql; // The required argument (non-keyword) + VALUE kwds; // A hash to store keyword arguments + ID kw_table[3]; // ID array to hold keys for keyword arguments + VALUE kw_values[3]; // VALUE array to hold values of keyword arguments + + // Define the keyword argument names + kw_table[0] = intern_as; + kw_table[1] = intern_empty_sets; + kw_table[2] = intern_timezone; + + // Extract the SQL argument (1st argument) and keyword arguments (kwargs) + rb_scan_args(argc, argv, "1:", &sql, &kwds); + rb_get_kwargs(kwds, kw_table, 0, 3, kw_values); + + kw_values[0] = kw_values[0] == Qundef ? get_default_query_option(sym_as) : kw_values[0]; + kw_values[1] = kw_values[1] == Qundef ? get_default_query_option(sym_empty_sets) : kw_values[1]; + kw_values[2] = kw_values[2] == Qundef ? get_default_query_option(sym_timezone) : kw_values[2]; + + unsigned int as_array = 0; + + if (kw_values[0] == sym_array) { + as_array = 1; } + + unsigned int empty_sets = 0; + + if (kw_values[1] == Qtrue) { + empty_sets = 1; + } + + VALUE timezone; + + if (kw_values[2] == sym_local) { + timezone = intern_local; + } else if (kw_values[2] == sym_utc) { + timezone = intern_utc; + } else { + rb_warn(":timezone option must be :utc or :local - defaulting to :local"); + timezone = intern_local; + } + + GET_CLIENT_WRAPPER(self); + rb_tinytds_send_sql_to_server(cwrap, sql); + + VALUE result = rb_obj_alloc(cTinyTdsResult); + VALUE rows = rb_ary_new(); + rb_ivar_set(result, id_ivar_rows, rows); + + unsigned int field_index; + unsigned int number_of_result_sets = 0; + + VALUE key; + + unsigned int number_of_fields = 0; + + // if a user makes a nested query (e.g. "SELECT 1 as [one]; SELECT 2 as [two];") + // this will loop multiple times + // our fields data structure then will get to be an array of arrays + // and rows will be an array of arrays or hashes + // we track this loop using number_of_result_sets + while ((rb_tiny_tds_client_ok_helper(cwrap->client) == SUCCEED) && (dbresults(cwrap->client) == SUCCEED)) { + unsigned int has_rows = (DBROWS(cwrap->client) == SUCCEED) ? 1 : 0; + + if (has_rows || empty_sets || number_of_result_sets == 0) { + number_of_fields = dbnumcols(cwrap->client); + VALUE fields = rb_ary_new2(number_of_fields); + + for (field_index = 0; field_index < number_of_fields; field_index++) { + char *colname = dbcolname(cwrap->client, field_index+1); + VALUE field = rb_obj_freeze(ENCODED_STR_NEW2(colname)); + rb_ary_store(fields, field_index, field); + } + + if (number_of_result_sets == 0) { + rb_ivar_set(result, id_ivar_fields, fields); + } else if (number_of_result_sets == 1) { + // we encounter our second loop, so we shuffle the fields around + VALUE multi_result_sets_fields = rb_ary_new(); + + rb_ary_store(multi_result_sets_fields, 0, rb_ivar_get(result, id_ivar_fields)); + rb_ary_store(multi_result_sets_fields, 1, fields); + + rb_ivar_set(result, id_ivar_fields, multi_result_sets_fields); + } else { + rb_ary_push(rb_ivar_get(result, id_ivar_fields), fields); + } + } else { + // it could be that + // there are no rows to be processed + // the user does not want empty sets to be included in their results (our default actually) + // or we are not in the first iteration of the result loop (we always want to fill out fields on the first iteration) + // in any case, through number_of_fields we signal the next loop that we do not want to fetch results + number_of_fields = 0; + } + + if ((has_rows || empty_sets) && number_of_fields > 0) { + VALUE rows = rb_ary_new(); + + while (nogvl_dbnextrow(cwrap->client) != NO_MORE_ROWS) { + VALUE row = as_array ? rb_ary_new2(number_of_fields) : rb_hash_new(); + + for (field_index = 0; field_index < number_of_fields; field_index++) { + VALUE val = rb_tinytds_result_fetch_value(self, timezone, number_of_fields, field_index); + + if (as_array) { + rb_ary_store(row, field_index, val); + } else { + if (number_of_result_sets > 0) { + key = rb_ary_entry(rb_ary_entry(rb_ivar_get(result, id_ivar_fields), number_of_result_sets), field_index); + } else { + key = rb_ary_entry(rb_ivar_get(result, id_ivar_fields), field_index); + } + + // for our current row, add a pair with the field name from our fields array and the parsed value + rb_hash_aset(row, key, val); + } + } + + rb_ary_push(rows, row); + } + + // if we have only one set of results, we overwrite @rows with our rows object here + if (number_of_result_sets == 0) { + rb_ivar_set(result, id_ivar_rows, rows); + } else if (number_of_result_sets == 1) { + // when encountering the second result set, we have to adjust @rows to be an array of arrays + VALUE multi_result_set_results = rb_ary_new(); + + rb_ary_store(multi_result_set_results, 0, rb_ivar_get(result, id_ivar_rows)); + rb_ary_store(multi_result_set_results, 1, rows); + + rb_ivar_set(result, id_ivar_rows, multi_result_set_results); + } else { + // when encountering two or more results sets, the structure of @rows has already been adjusted + // to be an array of arrays (with the previous condition) + rb_ary_push(rb_ivar_get(result, id_ivar_rows), rows); + } + + number_of_result_sets++; + } + } + + rb_ivar_set(result, id_ivar_affected_rows, rb_tinytds_affected_rows(cwrap->client)); + rb_ivar_set(result, id_ivar_return_code, rb_tinytds_return_code(self)); + rb_tinytds_client_cancel_results(cwrap->client); + + return result; +} + +static VALUE rb_tiny_tds_insert(VALUE self, VALUE sql) +{ + VALUE identity = Qnil; + GET_CLIENT_WRAPPER(self); + rb_tinytds_send_sql_to_server(cwrap, sql); + rb_tinytds_result_exec_helper(cwrap->client); + + // prepare second query to fetch last identity + dbcmd(cwrap->client, cwrap->identity_insert_sql); + + if ( + nogvl_dbsqlexec(cwrap->client) != FAIL + && nogvl_dbresults(cwrap->client) != FAIL + && DBROWS(cwrap->client) != FAIL + ) { + while (nogvl_dbnextrow(cwrap->client) != NO_MORE_ROWS) { + int col = 1; + BYTE *data = dbdata(cwrap->client, col); + DBINT data_len = dbdatlen(cwrap->client, col); + int null_val = ((data == NULL) && (data_len == 0)); + + if (!null_val) { + identity = LL2NUM(*(DBBIGINT *)data); + } + } + } + + return identity; +} + +static VALUE rb_tiny_tds_do(VALUE self, VALUE sql) +{ + GET_CLIENT_WRAPPER(self); + rb_tinytds_send_sql_to_server(cwrap, sql); + rb_tinytds_result_exec_helper(cwrap->client); + + return rb_tinytds_affected_rows(cwrap->client); } static VALUE rb_tinytds_charset(VALUE self) @@ -393,18 +915,6 @@ static VALUE rb_tinytds_escape(VALUE self, VALUE string) return new_string; } -/* Duplicated in result.c */ -static VALUE rb_tinytds_return_code(VALUE self) -{ - GET_CLIENT_WRAPPER(self); - - if (cwrap->client && dbhasretstat(cwrap->client)) { - return LONG2NUM((long)dbretstatus(cwrap->client)); - } else { - return Qnil; - } -} - static VALUE rb_tinytds_identity_sql(VALUE self) { GET_CLIENT_WRAPPER(self); @@ -549,7 +1059,9 @@ void init_tinytds_client() rb_define_method(cTinyTdsClient, "canceled?", rb_tinytds_canceled, 0); rb_define_method(cTinyTdsClient, "dead?", rb_tinytds_dead, 0); rb_define_method(cTinyTdsClient, "sqlsent?", rb_tinytds_sqlsent, 0); - rb_define_method(cTinyTdsClient, "execute", rb_tinytds_execute, 1); + rb_define_method(cTinyTdsClient, "execute", rb_tinytds_execute, -1); + rb_define_method(cTinyTdsClient, "insert", rb_tiny_tds_insert, 1); + rb_define_method(cTinyTdsClient, "do", rb_tiny_tds_do, 1); rb_define_method(cTinyTdsClient, "charset", rb_tinytds_charset, 0); rb_define_method(cTinyTdsClient, "encoding", rb_tinytds_encoding, 0); rb_define_method(cTinyTdsClient, "escape", rb_tinytds_escape, 1); @@ -586,6 +1098,45 @@ void init_tinytds_client() /* Escape Regexp Global */ opt_escape_regex = rb_funcall(rb_cRegexp, intern_new, 1, rb_str_new2("\\\'")); opt_escape_dblquote = rb_str_new2("''"); + rb_global_variable(&opt_escape_regex); rb_global_variable(&opt_escape_dblquote); + + intern_bigd = rb_intern("BigDecimal"); + intern_divide = rb_intern("/"); + id_ivar_fields = rb_intern("@fields"); + id_ivar_rows = rb_intern("@rows"); + id_ivar_default_query_options = rb_intern("@default_query_options"); + id_ivar_return_code = rb_intern("@return_code"); + id_ivar_affected_rows = rb_intern("@affected_rows"); + + intern_as = rb_intern("as"); + intern_empty_sets = rb_intern("empty_sets"); + intern_timezone = rb_intern("timezone"); + intern_utc = rb_intern("utc"); + intern_local = rb_intern("local"); + + cTinyTdsClient = rb_const_get(mTinyTds, rb_intern("Client")); + cTinyTdsResult = rb_const_get(mTinyTds, rb_intern("Result")); + cKernel = rb_const_get(rb_cObject, rb_intern("Kernel")); + cDate = rb_const_get(rb_cObject, rb_intern("Date")); + + opt_float_zero = rb_float_new((double)0); + opt_four = INT2NUM(4); + opt_onek = INT2NUM(1000); + opt_tenk = INT2NUM(10000); + opt_onebil = INT2NUM(1000000000); + + binaryEncoding = rb_enc_find("binary"); + + rb_global_variable(&cTinyTdsResult); + rb_global_variable(&opt_float_zero); + + /* Symbol Helpers */ + sym_as = ID2SYM(intern_as); + sym_array = ID2SYM(rb_intern("array")); + sym_timezone = ID2SYM(intern_timezone); + sym_empty_sets = ID2SYM(intern_empty_sets); + sym_local = ID2SYM(intern_local); + sym_utc = ID2SYM(intern_utc); } diff --git a/ext/tiny_tds/result.c b/ext/tiny_tds/result.c deleted file mode 100644 index 449f01c7..00000000 --- a/ext/tiny_tds/result.c +++ /dev/null @@ -1,755 +0,0 @@ - -#include -#include - -// File Types/Vars - -VALUE cTinyTdsResult; -extern VALUE mTinyTds, cTinyTdsClient, cTinyTdsError; -VALUE cKernel, cDate; -VALUE opt_decimal_zero, opt_float_zero, opt_one, opt_zero, opt_four, opt_19hdr, opt_onek, opt_tenk, opt_onemil, opt_onebil; -static ID intern_new, intern_utc, intern_local, intern_localtime, intern_merge, - intern_civil, intern_new_offset, intern_plus, intern_divide, intern_bigd; -static ID sym_symbolize_keys, sym_as, sym_array, sym_cache_rows, sym_first, sym_timezone, sym_local, sym_utc, sym_empty_sets; - - -// Lib Macros - -rb_encoding *binaryEncoding; -#define ENCODED_STR_NEW(_data, _len) ({ \ - VALUE _val = rb_str_new((char *)_data, (long)_len); \ - rb_enc_associate(_val, rwrap->encoding); \ - _val; \ -}) -#define ENCODED_STR_NEW2(_data2) ({ \ - VALUE _val = rb_str_new2((char *)_data2); \ - rb_enc_associate(_val, rwrap->encoding); \ - _val; \ -}) - -#ifdef _WIN32 - #define LONG_LONG_FORMAT "I64d" -#else - #define LONG_LONG_FORMAT "lld" -#endif - - -// Lib Backend (Memory Management) -static void rb_tinytds_result_mark(void *ptr) -{ - tinytds_result_wrapper *rwrap = (tinytds_result_wrapper *)ptr; - - if (rwrap) { - rb_gc_mark(rwrap->local_offset); - rb_gc_mark(rwrap->fields); - rb_gc_mark(rwrap->fields_processed); - rb_gc_mark(rwrap->results); - rb_gc_mark(rwrap->dbresults_retcodes); - } -} - -static void rb_tinytds_result_free(void *ptr) -{ - xfree(ptr); -} - -static size_t tinytds_result_wrapper_size(const void* data) -{ - return sizeof(tinytds_result_wrapper); -} - -const rb_data_type_t tinytds_result_wrapper_type = { - .wrap_struct_name = "tinytds_result_wrapper", - .function = { - .dmark = rb_tinytds_result_mark, - .dfree = rb_tinytds_result_free, - .dsize = tinytds_result_wrapper_size, - }, - .flags = RUBY_TYPED_FREE_IMMEDIATELY, -}; - -VALUE rb_tinytds_new_result_obj(tinytds_client_wrapper *cwrap) -{ - VALUE obj; - tinytds_result_wrapper *rwrap; - obj = TypedData_Make_Struct(cTinyTdsResult, tinytds_result_wrapper, &tinytds_result_wrapper_type, rwrap); - rwrap->cwrap = cwrap; - rwrap->client = cwrap->client; - rwrap->local_offset = Qnil; - rwrap->fields = rb_ary_new(); - rwrap->fields_processed = rb_ary_new(); - rwrap->results = Qnil; - rwrap->dbresults_retcodes = rb_ary_new(); - rwrap->number_of_results = 0; - rwrap->number_of_fields = 0; - rwrap->number_of_rows = 0; - rb_obj_call_init(obj, 0, NULL); - return obj; -} - -// No GVL Helpers - -#define NOGVL_DBCALL(_dbfunction, _client) ( \ - (RETCODE)(intptr_t)rb_thread_call_without_gvl( \ - (void *(*)(void *))_dbfunction, _client, \ - (rb_unblock_function_t*)dbcancel_ubf, _client ) \ -) - -static void dbcancel_ubf(DBPROCESS *client) -{ - GET_CLIENT_USERDATA(client); - dbcancel(client); - userdata->dbcancel_sent = 1; -} - -static void nogvl_setup(DBPROCESS *client) -{ - GET_CLIENT_USERDATA(client); - userdata->nonblocking = 1; - userdata->nonblocking_errors_length = 0; - userdata->nonblocking_errors = malloc(ERRORS_STACK_INIT_SIZE * sizeof(tinytds_errordata)); - userdata->nonblocking_errors_size = ERRORS_STACK_INIT_SIZE; -} - -static void nogvl_cleanup(DBPROCESS *client) -{ - GET_CLIENT_USERDATA(client); - userdata->nonblocking = 0; - userdata->timing_out = 0; - /* - Now that the blocking operation is done, we can finally throw any - exceptions based on errors from SQL Server. - */ - short int i; - - for (i = 0; i < userdata->nonblocking_errors_length; i++) { - tinytds_errordata error = userdata->nonblocking_errors[i]; - - // lookahead to drain any info messages ahead of raising error - if (!error.is_message) { - short int j; - - for (j = i; j < userdata->nonblocking_errors_length; j++) { - tinytds_errordata msg_error = userdata->nonblocking_errors[j]; - - if (msg_error.is_message) { - rb_tinytds_raise_error(client, msg_error); - } - } - } - - rb_tinytds_raise_error(client, error); - } - - free(userdata->nonblocking_errors); - userdata->nonblocking_errors_length = 0; - userdata->nonblocking_errors_size = 0; -} - -static RETCODE nogvl_dbsqlok(DBPROCESS *client) -{ - int retcode = FAIL; - GET_CLIENT_USERDATA(client); - nogvl_setup(client); - retcode = NOGVL_DBCALL(dbsqlok, client); - nogvl_cleanup(client); - userdata->dbsqlok_sent = 1; - return retcode; -} - -static RETCODE nogvl_dbsqlexec(DBPROCESS *client) -{ - int retcode = FAIL; - nogvl_setup(client); - retcode = NOGVL_DBCALL(dbsqlexec, client); - nogvl_cleanup(client); - return retcode; -} - -static RETCODE nogvl_dbresults(DBPROCESS *client) -{ - int retcode = FAIL; - nogvl_setup(client); - retcode = NOGVL_DBCALL(dbresults, client); - nogvl_cleanup(client); - return retcode; -} - -static RETCODE nogvl_dbnextrow(DBPROCESS * client) -{ - int retcode = FAIL; - nogvl_setup(client); - retcode = NOGVL_DBCALL(dbnextrow, client); - nogvl_cleanup(client); - return retcode; -} - -// Lib Backend (Helpers) - -static RETCODE rb_tinytds_result_dbresults_retcode(VALUE self) -{ - VALUE ruby_rc; - RETCODE db_rc; - GET_RESULT_WRAPPER(self); - ruby_rc = rb_ary_entry(rwrap->dbresults_retcodes, rwrap->number_of_results); - - if (NIL_P(ruby_rc)) { - db_rc = nogvl_dbresults(rwrap->client); - ruby_rc = INT2FIX(db_rc); - rb_ary_store(rwrap->dbresults_retcodes, rwrap->number_of_results, ruby_rc); - } else { - db_rc = FIX2INT(ruby_rc); - } - - return db_rc; -} - -static RETCODE rb_tinytds_result_ok_helper(DBPROCESS *client) -{ - GET_CLIENT_USERDATA(client); - - if (userdata->dbsqlok_sent == 0) { - userdata->dbsqlok_retcode = nogvl_dbsqlok(client); - } - - return userdata->dbsqlok_retcode; -} - -static void rb_tinytds_result_exec_helper(DBPROCESS *client) -{ - RETCODE dbsqlok_rc = rb_tinytds_result_ok_helper(client); - GET_CLIENT_USERDATA(client); - - if (dbsqlok_rc == SUCCEED) { - /* - This is to just process each result set. Commands such as backup and - restore are not done when the first result set is returned, so we need to - exhaust the result sets before it is complete. - */ - while (nogvl_dbresults(client) == SUCCEED) { - /* - If we don't loop through each row for calls to TinyTds::Result.do that - actually do return result sets, we will trigger error 20019 about trying - to execute a new command with pending results. Oh well. - */ - while (dbnextrow(client) != NO_MORE_ROWS); - } - } - - dbcancel(client); - userdata->dbcancel_sent = 1; - userdata->dbsql_sent = 0; -} - -static VALUE rb_tinytds_result_fetch_row(VALUE self, ID timezone, int symbolize_keys, int as_array) -{ - VALUE row; - /* Storing Values */ - unsigned int i; - /* Wrapper And Local Vars */ - GET_RESULT_WRAPPER(self); - /* Create Empty Row */ - row = as_array ? rb_ary_new2(rwrap->number_of_fields) : rb_hash_new(); - - for (i = 0; i < rwrap->number_of_fields; i++) { - VALUE val = Qnil; - int col = i+1; - int coltype = dbcoltype(rwrap->client, col); - BYTE *data = dbdata(rwrap->client, col); - DBINT data_len = dbdatlen(rwrap->client, col); - int null_val = ((data == NULL) && (data_len == 0)); - - if (!null_val) { - switch(coltype) { - case SYBINT1: - val = INT2FIX(*(DBTINYINT *)data); - break; - - case SYBINT2: - val = INT2FIX(*(DBSMALLINT *)data); - break; - - case SYBINT4: - val = INT2NUM(*(DBINT *)data); - break; - - case SYBINT8: - val = LL2NUM(*(DBBIGINT *)data); - break; - - case SYBBIT: - val = *(int *)data ? Qtrue : Qfalse; - break; - - case SYBNUMERIC: - case SYBDECIMAL: { - DBTYPEINFO *data_info = dbcoltypeinfo(rwrap->client, col); - int data_slength = (int)data_info->precision + (int)data_info->scale + 1; - char converted_decimal[data_slength]; - dbconvert(rwrap->client, coltype, data, data_len, SYBVARCHAR, (BYTE *)converted_decimal, -1); - val = rb_funcall(cKernel, intern_bigd, 1, rb_str_new2((char *)converted_decimal)); - break; - } - - case SYBFLT8: { - double col_to_double = *(double *)data; - val = (col_to_double == 0.000000) ? opt_float_zero : rb_float_new(col_to_double); - break; - } - - case SYBREAL: { - float col_to_float = *(float *)data; - val = (col_to_float == 0.0) ? opt_float_zero : rb_float_new(col_to_float); - break; - } - - case SYBMONEY: { - DBMONEY *money = (DBMONEY *)data; - char converted_money[25]; - long long money_value = ((long long)money->mnyhigh << 32) | money->mnylow; - sprintf(converted_money, "%" LONG_LONG_FORMAT, money_value); - val = rb_funcall(cKernel, intern_bigd, 2, rb_str_new2(converted_money), opt_four); - val = rb_funcall(val, intern_divide, 1, opt_tenk); - break; - } - - case SYBMONEY4: { - DBMONEY4 *money = (DBMONEY4 *)data; - char converted_money[20]; - sprintf(converted_money, "%f", money->mny4 / 10000.0); - val = rb_funcall(cKernel, intern_bigd, 1, rb_str_new2(converted_money)); - break; - } - - case SYBBINARY: - case SYBIMAGE: - val = rb_str_new((char *)data, (long)data_len); - #ifdef HAVE_RUBY_ENCODING_H - rb_enc_associate(val, binaryEncoding); - #endif - break; - - case 36: { // SYBUNIQUE - char converted_unique[37]; - dbconvert(rwrap->client, coltype, data, 37, SYBVARCHAR, (BYTE *)converted_unique, -1); - val = ENCODED_STR_NEW2(converted_unique); - break; - } - - case SYBDATETIME4: { - DBDATETIME new_data; - dbconvert(rwrap->client, coltype, data, data_len, SYBDATETIME, (BYTE *)&new_data, sizeof(new_data)); - data = (BYTE *)&new_data; - data_len = sizeof(new_data); - } - - case SYBDATETIME: { - DBDATEREC dr; - dbdatecrack(rwrap->client, &dr, (DBDATETIME *)data); - - if (dr.year + dr.month + dr.day + dr.hour + dr.minute + dr.second + dr.millisecond != 0) { - val = rb_funcall(rb_cTime, timezone, 7, INT2NUM(dr.year), INT2NUM(dr.month), INT2NUM(dr.day), INT2NUM(dr.hour), INT2NUM(dr.minute), INT2NUM(dr.second), INT2NUM(dr.millisecond*1000)); - } - - break; - } - - case SYBMSDATE: - case SYBMSTIME: - case SYBMSDATETIME2: - case SYBMSDATETIMEOFFSET: { - DBDATEREC2 dr2; - dbanydatecrack(rwrap->client, &dr2, coltype, data); - - switch(coltype) { - case SYBMSDATE: { - val = rb_funcall(cDate, intern_new, 3, INT2NUM(dr2.year), INT2NUM(dr2.month), INT2NUM(dr2.day)); - break; - } - - case SYBMSTIME: { - VALUE rational_nsec = rb_Rational(INT2NUM(dr2.nanosecond), opt_onek); - val = rb_funcall(rb_cTime, timezone, 7, INT2NUM(1900), INT2NUM(1), INT2NUM(1), INT2NUM(dr2.hour), INT2NUM(dr2.minute), INT2NUM(dr2.second), rational_nsec); - break; - } - - case SYBMSDATETIME2: { - VALUE rational_nsec = rb_Rational(INT2NUM(dr2.nanosecond), opt_onek); - val = rb_funcall(rb_cTime, timezone, 7, INT2NUM(dr2.year), INT2NUM(dr2.month), INT2NUM(dr2.day), INT2NUM(dr2.hour), INT2NUM(dr2.minute), INT2NUM(dr2.second), rational_nsec); - break; - } - - case SYBMSDATETIMEOFFSET: { - long long numerator = ((long)dr2.second * (long long)1000000000) + (long long)dr2.nanosecond; - VALUE rational_sec = rb_Rational(LL2NUM(numerator), opt_onebil); - val = rb_funcall(rb_cTime, intern_new, 7, INT2NUM(dr2.year), INT2NUM(dr2.month), INT2NUM(dr2.day), INT2NUM(dr2.hour), INT2NUM(dr2.minute), rational_sec, INT2NUM(dr2.tzone*60)); - break; - } - } - - break; - } - - case SYBCHAR: - case SYBTEXT: - val = ENCODED_STR_NEW(data, data_len); - break; - - case 98: { // SYBVARIANT - if (data_len == 4) { - val = INT2NUM(*(DBINT *)data); - break; - } else { - val = ENCODED_STR_NEW(data, data_len); - break; - } - } - - default: - val = ENCODED_STR_NEW(data, data_len); - break; - } - } - - if (as_array) { - rb_ary_store(row, i, val); - } else { - VALUE key; - - if (rwrap->number_of_results == 0) { - key = rb_ary_entry(rwrap->fields, i); - } else { - key = rb_ary_entry(rb_ary_entry(rwrap->fields, rwrap->number_of_results), i); - } - - rb_hash_aset(row, key, val); - } - } - - return row; -} - - -// TinyTds::Client (public) - -static VALUE rb_tinytds_result_fields(VALUE self) -{ - RETCODE dbsqlok_rc, dbresults_rc; - VALUE fields_processed; - GET_RESULT_WRAPPER(self); - dbsqlok_rc = rb_tinytds_result_ok_helper(rwrap->client); - dbresults_rc = rb_tinytds_result_dbresults_retcode(self); - fields_processed = rb_ary_entry(rwrap->fields_processed, rwrap->number_of_results); - - if ((dbsqlok_rc == SUCCEED) && (dbresults_rc == SUCCEED) && (fields_processed == Qnil)) { - /* Default query options. */ - int symbolize_keys = 0; - VALUE qopts = rb_iv_get(self, "@query_options"); - - if (rb_hash_aref(qopts, sym_symbolize_keys) == Qtrue) { - symbolize_keys = 1; - } - - /* Set number_of_fields count for this result set. */ - rwrap->number_of_fields = dbnumcols(rwrap->client); - - if (rwrap->number_of_fields > 0) { - /* Create fields for this result set. */ - unsigned int fldi = 0; - VALUE fields = rb_ary_new2(rwrap->number_of_fields); - - for (fldi = 0; fldi < rwrap->number_of_fields; fldi++) { - char *colname = dbcolname(rwrap->client, fldi+1); - VALUE field = symbolize_keys ? rb_str_intern(ENCODED_STR_NEW2(colname)) : rb_obj_freeze(ENCODED_STR_NEW2(colname)); - rb_ary_store(fields, fldi, field); - } - - /* Store the fields. */ - if (rwrap->number_of_results == 0) { - rwrap->fields = fields; - } else if (rwrap->number_of_results == 1) { - VALUE multi_rs_fields = rb_ary_new(); - rb_ary_store(multi_rs_fields, 0, rwrap->fields); - rb_ary_store(multi_rs_fields, 1, fields); - rwrap->fields = multi_rs_fields; - } else { - rb_ary_store(rwrap->fields, rwrap->number_of_results, fields); - } - } - - rb_ary_store(rwrap->fields_processed, rwrap->number_of_results, Qtrue); - } - - return rwrap->fields; -} - -static VALUE rb_tinytds_result_each(int argc, VALUE * argv, VALUE self) -{ - /* Local Vars */ - VALUE qopts, opts, block; - ID timezone; - int symbolize_keys = 0, as_array = 0, cache_rows = 0, first = 0, empty_sets = 0; - tinytds_client_userdata *userdata; - GET_RESULT_WRAPPER(self); - userdata = (tinytds_client_userdata *)dbgetuserdata(rwrap->client); - /* Merge Options Hash To Query Options. Populate Opts & Block Var. */ - qopts = rb_iv_get(self, "@query_options"); - - if (rb_scan_args(argc, argv, "01&", &opts, &block) == 1) { - qopts = rb_funcall(qopts, intern_merge, 1, opts); - } - - rb_iv_set(self, "@query_options", qopts); - - /* Locals From Options */ - if (rb_hash_aref(qopts, sym_first) == Qtrue) { - first = 1; - } - - if (rb_hash_aref(qopts, sym_symbolize_keys) == Qtrue) { - symbolize_keys = 1; - } - - if (rb_hash_aref(qopts, sym_as) == sym_array) { - as_array = 1; - } - - if (rb_hash_aref(qopts, sym_cache_rows) == Qtrue) { - cache_rows = 1; - } - - if (rb_hash_aref(qopts, sym_timezone) == sym_local) { - timezone = intern_local; - } else if (rb_hash_aref(qopts, sym_timezone) == sym_utc) { - timezone = intern_utc; - } else { - rb_warn(":timezone option must be :utc or :local - defaulting to :local"); - timezone = intern_local; - } - - if (rb_hash_aref(qopts, sym_empty_sets) == Qtrue) { - empty_sets = 1; - } - - /* Make The Results Or Yield Existing */ - if (NIL_P(rwrap->results)) { - RETCODE dbsqlok_rc, dbresults_rc; - rwrap->results = rb_ary_new(); - dbsqlok_rc = rb_tinytds_result_ok_helper(rwrap->client); - dbresults_rc = rb_tinytds_result_dbresults_retcode(self); - - while ((dbsqlok_rc == SUCCEED) && (dbresults_rc == SUCCEED)) { - int has_rows = (DBROWS(rwrap->client) == SUCCEED) ? 1 : 0; - - if (has_rows || empty_sets || (rwrap->number_of_results == 0)) { - rb_tinytds_result_fields(self); - } - - if ((has_rows || empty_sets) && rwrap->number_of_fields > 0) { - /* Create rows for this result set. */ - unsigned long rowi = 0; - VALUE result = rb_ary_new(); - - while (nogvl_dbnextrow(rwrap->client) != NO_MORE_ROWS) { - VALUE row = rb_tinytds_result_fetch_row(self, timezone, symbolize_keys, as_array); - - if (cache_rows) { - rb_ary_store(result, rowi, row); - } - - if (!NIL_P(block)) { - rb_yield(row); - } - - if (first) { - dbcanquery(rwrap->client); - userdata->dbcancel_sent = 1; - } - - rowi++; - } - - rwrap->number_of_rows = rowi; - - /* Store the result. */ - if (cache_rows) { - if (rwrap->number_of_results == 0) { - rwrap->results = result; - } else if (rwrap->number_of_results == 1) { - VALUE multi_resultsets = rb_ary_new(); - rb_ary_store(multi_resultsets, 0, rwrap->results); - rb_ary_store(multi_resultsets, 1, result); - rwrap->results = multi_resultsets; - } else { - rb_ary_store(rwrap->results, rwrap->number_of_results, result); - } - } - - // If we find results increment the counter that helpers use and setup the next loop. - rwrap->number_of_results = rwrap->number_of_results + 1; - dbresults_rc = rb_tinytds_result_dbresults_retcode(self); - rb_ary_store(rwrap->fields_processed, rwrap->number_of_results, Qnil); - } else { - // If we do not find results, side step the rb_tinytds_result_dbresults_retcode helper and - // manually populate its memoized array while nullifing any memoized fields too before loop. - dbresults_rc = nogvl_dbresults(rwrap->client); - rb_ary_store(rwrap->dbresults_retcodes, rwrap->number_of_results, INT2FIX(dbresults_rc)); - rb_ary_store(rwrap->fields_processed, rwrap->number_of_results, Qnil); - } - } - - if (dbresults_rc == FAIL) { - rb_warn("TinyTDS: Something in the dbresults() while loop set the return code to FAIL.\n"); - } - - userdata->dbsql_sent = 0; - } else if (!NIL_P(block)) { - unsigned long i; - - for (i = 0; i < rwrap->number_of_rows; i++) { - rb_yield(rb_ary_entry(rwrap->results, i)); - } - } - - return rwrap->results; -} - -static VALUE rb_tinytds_result_cancel(VALUE self) -{ - tinytds_client_userdata *userdata; - GET_RESULT_WRAPPER(self); - userdata = (tinytds_client_userdata *)dbgetuserdata(rwrap->client); - - if (rwrap->client && !userdata->dbcancel_sent) { - rb_tinytds_result_ok_helper(rwrap->client); - dbcancel(rwrap->client); - userdata->dbcancel_sent = 1; - userdata->dbsql_sent = 0; - } - - return Qtrue; -} - -static VALUE rb_tinytds_result_do(VALUE self) -{ - GET_RESULT_WRAPPER(self); - - if (rwrap->client) { - rb_tinytds_result_exec_helper(rwrap->client); - return LONG2NUM((long)dbcount(rwrap->client)); - } else { - return Qnil; - } -} - -static VALUE rb_tinytds_result_affected_rows(VALUE self) -{ - GET_RESULT_WRAPPER(self); - - if (rwrap->client) { - return LONG2NUM((long)dbcount(rwrap->client)); - } else { - return Qnil; - } -} - -/* Duplicated in client.c */ -static VALUE rb_tinytds_result_return_code(VALUE self) -{ - GET_RESULT_WRAPPER(self); - - if (rwrap->client && dbhasretstat(rwrap->client)) { - return LONG2NUM((long)dbretstatus(rwrap->client)); - } else { - return Qnil; - } -} - -static VALUE rb_tinytds_result_insert(VALUE self) -{ - GET_RESULT_WRAPPER(self); - - if (rwrap->client) { - VALUE identity = Qnil; - rb_tinytds_result_exec_helper(rwrap->client); - dbcmd(rwrap->client, rwrap->cwrap->identity_insert_sql); - - if (nogvl_dbsqlexec(rwrap->client) != FAIL - && nogvl_dbresults(rwrap->client) != FAIL - && DBROWS(rwrap->client) != FAIL) { - while (nogvl_dbnextrow(rwrap->client) != NO_MORE_ROWS) { - int col = 1; - BYTE *data = dbdata(rwrap->client, col); - DBINT data_len = dbdatlen(rwrap->client, col); - int null_val = ((data == NULL) && (data_len == 0)); - - if (!null_val) { - identity = LL2NUM(*(DBBIGINT *)data); - } - } - } - - return identity; - } else { - return Qnil; - } -} - - -// Lib Init - -void init_tinytds_result() -{ - /* Data Classes */ - cKernel = rb_const_get(rb_cObject, rb_intern("Kernel")); - cDate = rb_const_get(rb_cObject, rb_intern("Date")); - /* Define TinyTds::Result */ - cTinyTdsResult = rb_define_class_under(mTinyTds, "Result", rb_cObject); - rb_undef_alloc_func(cTinyTdsResult); - /* Define TinyTds::Result Public Methods */ - rb_define_method(cTinyTdsResult, "fields", rb_tinytds_result_fields, 0); - rb_define_method(cTinyTdsResult, "each", rb_tinytds_result_each, -1); - rb_define_method(cTinyTdsResult, "cancel", rb_tinytds_result_cancel, 0); - rb_define_method(cTinyTdsResult, "do", rb_tinytds_result_do, 0); - rb_define_method(cTinyTdsResult, "affected_rows", rb_tinytds_result_affected_rows, 0); - rb_define_method(cTinyTdsResult, "return_code", rb_tinytds_result_return_code, 0); - rb_define_method(cTinyTdsResult, "insert", rb_tinytds_result_insert, 0); - /* Intern String Helpers */ - intern_new = rb_intern("new"); - intern_utc = rb_intern("utc"); - intern_local = rb_intern("local"); - intern_merge = rb_intern("merge"); - intern_localtime = rb_intern("localtime"); - intern_civil = rb_intern("civil"); - intern_new_offset = rb_intern("new_offset"); - intern_plus = rb_intern("+"); - intern_divide = rb_intern("/"); - intern_bigd = rb_intern("BigDecimal"); - /* Symbol Helpers */ - sym_symbolize_keys = ID2SYM(rb_intern("symbolize_keys")); - sym_as = ID2SYM(rb_intern("as")); - sym_array = ID2SYM(rb_intern("array")); - sym_cache_rows = ID2SYM(rb_intern("cache_rows")); - sym_first = ID2SYM(rb_intern("first")); - sym_local = ID2SYM(intern_local); - sym_utc = ID2SYM(intern_utc); - sym_timezone = ID2SYM(rb_intern("timezone")); - sym_empty_sets = ID2SYM(rb_intern("empty_sets")); - /* Data Conversion Options */ - opt_decimal_zero = rb_str_new2("0.0"); - rb_global_variable(&opt_decimal_zero); - opt_float_zero = rb_float_new((double)0); - rb_global_variable(&opt_float_zero); - opt_one = INT2NUM(1); - opt_zero = INT2NUM(0); - opt_four = INT2NUM(4); - opt_19hdr = INT2NUM(1900); - opt_onek = INT2NUM(1000); - opt_tenk = INT2NUM(10000); - opt_onemil = INT2NUM(1000000); - opt_onebil = INT2NUM(1000000000); - /* Encoding */ - #ifdef HAVE_RUBY_ENCODING_H - binaryEncoding = rb_enc_find("binary"); - #endif -} diff --git a/ext/tiny_tds/result.h b/ext/tiny_tds/result.h deleted file mode 100644 index 0a5ced11..00000000 --- a/ext/tiny_tds/result.h +++ /dev/null @@ -1,32 +0,0 @@ - -#ifndef TINYTDS_RESULT_H -#define TINYTDS_RESULT_H - -void init_tinytds_result(); -VALUE rb_tinytds_new_result_obj(tinytds_client_wrapper *cwrap); - -typedef struct { - tinytds_client_wrapper *cwrap; - DBPROCESS *client; - VALUE local_offset; - VALUE fields; - VALUE fields_processed; - VALUE results; - rb_encoding *encoding; - VALUE dbresults_retcodes; - unsigned int number_of_results; - unsigned int number_of_fields; - unsigned long number_of_rows; -} tinytds_result_wrapper; - -extern const rb_data_type_t tinytds_result_wrapper_type; - -// Lib Macros -#define GET_RESULT_WRAPPER(self) \ - tinytds_result_wrapper *rwrap; \ - TypedData_Get_Struct(self, tinytds_result_wrapper, &tinytds_result_wrapper_type, rwrap) - - - - -#endif diff --git a/ext/tiny_tds/tiny_tds_ext.c b/ext/tiny_tds/tiny_tds_ext.c index 2ef4dd9a..e6bf7cb0 100644 --- a/ext/tiny_tds/tiny_tds_ext.c +++ b/ext/tiny_tds/tiny_tds_ext.c @@ -11,6 +11,4 @@ void Init_tiny_tds() mTinyTds = rb_define_module("TinyTds"); cTinyTdsError = rb_const_get(mTinyTds, rb_intern("Error")); init_tinytds_client(); - init_tinytds_result(); } - diff --git a/ext/tiny_tds/tiny_tds_ext.h b/ext/tiny_tds/tiny_tds_ext.h index 55494981..9e799221 100644 --- a/ext/tiny_tds/tiny_tds_ext.h +++ b/ext/tiny_tds/tiny_tds_ext.h @@ -12,6 +12,5 @@ #include #include -#include #endif diff --git a/lib/tiny_tds/client.rb b/lib/tiny_tds/client.rb index 92a195b7..3631a51a 100644 --- a/lib/tiny_tds/client.rb +++ b/lib/tiny_tds/client.rb @@ -2,10 +2,8 @@ module TinyTds class Client @default_query_options = { as: :hash, - symbolize_keys: false, - cache_rows: true, - timezone: :local, - empty_sets: true + empty_sets: true, + timezone: :local } attr_reader :query_options @@ -42,7 +40,6 @@ def initialize(opts = {}) end opts[:username] = parse_username(opts) - @query_options = self.class.default_query_options.dup opts[:password] = opts[:password].to_s if opts[:password] && opts[:password].to_s.strip != "" opts[:appname] ||= "TinyTds" opts[:tds_version] = tds_versions_setter(opts) diff --git a/lib/tiny_tds/result.rb b/lib/tiny_tds/result.rb index c0683b90..92c332bd 100644 --- a/lib/tiny_tds/result.rb +++ b/lib/tiny_tds/result.rb @@ -1,5 +1,11 @@ module TinyTds class Result + attr_reader :affected_rows, :fields, :rows, :return_code + include Enumerable + + def each(&bk) + rows.each(&bk) + end end end diff --git a/test/client_test.rb b/test/client_test.rb index 4662f9c7..5d3ef7f5 100644 --- a/test/client_test.rb +++ b/test/client_test.rb @@ -1,6 +1,10 @@ require "test_helper" class ClientTest < TinyTds::TestCase + before do + @@current_schema_loaded ||= load_current_schema + end + describe "with valid credentials" do before do @client = new_connection @@ -97,7 +101,7 @@ class ClientTest < TinyTds::TestCase it "raises TinyTds exception with long query past :timeout option" do client = new_connection timeout: 1 - action = lambda { client.execute("WaitFor Delay '00:00:02'").do } + action = lambda { client.do("WaitFor Delay '00:00:02'") } assert_raise_tinytds_error(action) do |e| assert_equal 20003, e.db_error_number assert_equal 6, e.severity @@ -110,21 +114,21 @@ class ClientTest < TinyTds::TestCase it "must not timeout per sql batch when not under transaction" do client = new_connection timeout: 2 - client.execute("WaitFor Delay '00:00:01'").do - client.execute("WaitFor Delay '00:00:01'").do - client.execute("WaitFor Delay '00:00:01'").do + client.do("WaitFor Delay '00:00:01'") + client.do("WaitFor Delay '00:00:01'") + client.do("WaitFor Delay '00:00:01'") close_client(client) end it "must not timeout per sql batch when under transaction" do client = new_connection timeout: 2 begin - client.execute("BEGIN TRANSACTION").do - client.execute("WaitFor Delay '00:00:01'").do - client.execute("WaitFor Delay '00:00:01'").do - client.execute("WaitFor Delay '00:00:01'").do + client.do("BEGIN TRANSACTION") + client.do("WaitFor Delay '00:00:01'") + client.do("WaitFor Delay '00:00:01'") + client.do("WaitFor Delay '00:00:01'") ensure - client.execute("COMMIT TRANSACTION").do + client.do("COMMIT TRANSACTION") close_client(client) end end @@ -132,7 +136,7 @@ class ClientTest < TinyTds::TestCase it "raises TinyTds exception with tcp socket network failure" do client = new_connection timeout: 2, port: 1234, host: ENV["TOXIPROXY_HOST"] assert_client_works(client) - action = lambda { client.execute("waitfor delay '00:00:05'").do } + action = lambda { client.do("waitfor delay '00:00:05'") } # Use toxiproxy to close the TCP socket after 1 second. # We want TinyTds to execute the statement, hit the timeout configured above, and then not be able to use the network to cancel @@ -154,7 +158,7 @@ class ClientTest < TinyTds::TestCase begin client = new_connection timeout: 2, port: 1234, host: ENV["TOXIPROXY_HOST"] assert_client_works(client) - action = lambda { client.execute("waitfor delay '00:00:05'").do } + action = lambda { client.do("waitfor delay '00:00:05'") } # Use toxiproxy to close the network connection after 1 second. # We want TinyTds to execute the statement, hit the timeout configured above, and then not be able to use the network to cancel @@ -263,4 +267,53 @@ class ClientTest < TinyTds::TestCase ).must_equal "user" end end + + describe "#insert" do + before do + @client = new_connection + end + + it "has an #insert method that cancels result rows and returns IDENTITY natively" do + rollback_transaction(@client) do + text = "test scope identity rows native" + @client.do("DELETE FROM [datatypes] WHERE [varchar_50] = '#{text}'") + @client.do("INSERT INTO [datatypes] ([varchar_50]) VALUES ('#{text}')") + sql_identity = @client.execute(@client.identity_sql).first["Ident"] + native_identity = @client.insert("INSERT INTO [datatypes] ([varchar_50]) VALUES ('#{text}')") + + assert_equal(sql_identity + 1, native_identity) + assert_client_works(@client) + end + end + + it "returns bigint for #insert when needed" do + return if sqlserver_azure? # We can not alter clustered index like this test does. + # 'CREATE TABLE' command is not allowed within a multi-statement transaction + # and and sp_helpindex creates a temporary table #spindtab. + + rollback_transaction(@client) do + seed = 9223372036854775805 + @client.do("DELETE FROM [datatypes]") + id_constraint_name = @client.execute("EXEC sp_helpindex [datatypes]").detect { |row| row["index_keys"] == "id" }["index_name"] + @client.do("ALTER TABLE [datatypes] DROP CONSTRAINT [#{id_constraint_name}]") + @client.do("ALTER TABLE [datatypes] DROP COLUMN [id]") + @client.do("ALTER TABLE [datatypes] ADD [id] [bigint] NOT NULL IDENTITY(1,1) PRIMARY KEY") + @client.do("DBCC CHECKIDENT ('datatypes', RESEED, #{seed})") + identity = @client.insert("INSERT INTO [datatypes] ([varchar_50]) VALUES ('something')") + + assert_equal(seed, identity) + assert_client_works(@client) + end + end + + it "throws an error if client is closed" do + @client.close + assert @client.closed? + + action = lambda { @client.insert("SELECT 1 as [one]") } + assert_raise_tinytds_error(action) do |e| + assert_match %r{closed connection}i, e.message + end + end + end end diff --git a/test/result_test.rb b/test/result_test.rb index 6f9bb109..1f8916f1 100644 --- a/test/result_test.rb +++ b/test/result_test.rb @@ -17,15 +17,6 @@ class ResultTest < TinyTds::TestCase assert result.respond_to?(:each) end - it "returns all results for #each with no block" do - result = @client.execute(@query1) - data = result.each - row = data.first - assert_instance_of Array, data - assert_equal 1, data.size - assert_instance_of Hash, row, "hash is the default query option" - end - it "returns all results for #each with a block yielding a row at a time" do result = @client.execute(@query1) data = result.each do |row| @@ -34,84 +25,28 @@ class ResultTest < TinyTds::TestCase assert_instance_of Array, data end - it "allows successive calls to each returning the same data" do - result = @client.execute(@query1) - data = result.each - result.each - assert_equal data.object_id, result.each.object_id - assert_equal data.first.object_id, result.each.first.object_id - end - - it "returns hashes with string keys" do - result = @client.execute(@query1) - row = result.each(as: :hash, symbolize_keys: false).first - assert_instance_of Hash, row - assert_equal ["one"], row.keys - assert_equal ["one"], result.fields - end - - it "returns hashes with symbol keys" do - result = @client.execute(@query1) - row = result.each(as: :hash, symbolize_keys: true).first - assert_instance_of Hash, row - assert_equal [:one], row.keys - assert_equal [:one], result.fields - end - - it "returns arrays with string fields" do - result = @client.execute(@query1) - row = result.each(as: :array, symbolize_keys: false).first + it "returns arrays" do + results = @client.execute(@query1, as: :array) + row = results.first assert_instance_of Array, row - assert_equal ["one"], result.fields - end - - it "returns arrays with symbol fields" do - result = @client.execute(@query1) - row = result.each(as: :array, symbolize_keys: true).first - assert_instance_of Array, row - assert_equal [:one], result.fields + assert_equal ["one"], results.fields end it "allows sql concat + to work" do rollback_transaction(@client) do - @client.execute("DELETE FROM [datatypes]").do - @client.execute("INSERT INTO [datatypes] ([char_10], [varchar_50]) VALUES ('1', '2')").do - result = @client.execute("SELECT TOP (1) [char_10] + 'test' + [varchar_50] AS [test] FROM [datatypes]").each.first["test"] + @client.do("DELETE FROM [datatypes]") + @client.do("INSERT INTO [datatypes] ([char_10], [varchar_50]) VALUES ('1', '2')") + result = @client.execute("SELECT TOP (1) [char_10] + 'test' + [varchar_50] AS [test] FROM [datatypes]").first["test"] _(result).must_equal "1 test2" end end - it "must be able to turn :cache_rows option off" do - result = @client.execute(@query1) - local = [] - result.each(cache_rows: false) do |row| - local << row - end - assert local.first, "should have iterated over each row" - assert_equal [], result.each, "should not have been cached" - assert_equal ["one"], result.fields, "should still cache field names" - end - - it "must be able to get the first result row only" do - load_current_schema - big_query = "SELECT [id] FROM [datatypes]" - one = @client.execute(big_query).each(first: true) - many = @client.execute(big_query).each - assert many.size > 1 - assert one.size == 1 - end - - it "copes with no results when using first option" do - data = @client.execute("SELECT [id] FROM [datatypes] WHERE [id] = -1").each(first: true) - assert_equal [], data - end - it "must delete, insert and find data" do rollback_transaction(@client) do text = "test insert and delete" - @client.execute("DELETE FROM [datatypes] WHERE [varchar_50] IS NOT NULL").do - @client.execute("INSERT INTO [datatypes] ([varchar_50]) VALUES ('#{text}')").do - row = @client.execute("SELECT [varchar_50] FROM [datatypes] WHERE [varchar_50] IS NOT NULL").each.first + @client.do("DELETE FROM [datatypes] WHERE [varchar_50] IS NOT NULL") + @client.do("INSERT INTO [datatypes] ([varchar_50]) VALUES ('#{text}')") + row = @client.execute("SELECT [varchar_50] FROM [datatypes] WHERE [varchar_50] IS NOT NULL").first assert row assert_equal text, row["varchar_50"] end @@ -120,9 +55,9 @@ class ResultTest < TinyTds::TestCase it "must insert and find unicode data" do rollback_transaction(@client) do text = "😍" - @client.execute("DELETE FROM [datatypes] WHERE [nvarchar_50] IS NOT NULL").do - @client.execute("INSERT INTO [datatypes] ([nvarchar_50]) VALUES (N'#{text}')").do - row = @client.execute("SELECT [nvarchar_50] FROM [datatypes] WHERE [nvarchar_50] IS NOT NULL").each.first + @client.do("DELETE FROM [datatypes] WHERE [nvarchar_50] IS NOT NULL") + @client.do("INSERT INTO [datatypes] ([nvarchar_50]) VALUES (N'#{text}')") + row = @client.execute("SELECT [nvarchar_50] FROM [datatypes] WHERE [nvarchar_50] IS NOT NULL").first assert_equal text, row["nvarchar_50"] end end @@ -130,14 +65,14 @@ class ResultTest < TinyTds::TestCase it "must delete and update with affected rows support and insert with identity support in native sql" do rollback_transaction(@client) do text = "test affected rows sql" - @client.execute("DELETE FROM [datatypes]").do - afrows = @client.execute("SELECT @@ROWCOUNT AS AffectedRows").each.first["AffectedRows"] + @client.do("DELETE FROM [datatypes]") + afrows = @client.execute("SELECT @@ROWCOUNT AS AffectedRows").first["AffectedRows"] _(["Fixnum", "Integer"]).must_include afrows.class.name - @client.execute("INSERT INTO [datatypes] ([varchar_50]) VALUES ('#{text}')").do - pk1 = @client.execute(@client.identity_sql).each.first["Ident"] + @client.do("INSERT INTO [datatypes] ([varchar_50]) VALUES ('#{text}')") + pk1 = @client.execute(@client.identity_sql).first["Ident"] _(["Fixnum", "Integer"]).must_include pk1.class.name, "we it be able to CAST to bigint" - @client.execute("UPDATE [datatypes] SET [varchar_50] = NULL WHERE [varchar_50] = '#{text}'").do - afrows = @client.execute("SELECT @@ROWCOUNT AS AffectedRows").each.first["AffectedRows"] + @client.do("UPDATE [datatypes] SET [varchar_50] = NULL WHERE [varchar_50] = '#{text}'") + afrows = @client.execute("SELECT @@ROWCOUNT AS AffectedRows").first["AffectedRows"] assert_equal 1, afrows end end @@ -145,12 +80,12 @@ class ResultTest < TinyTds::TestCase it "has a #do method that cancels result rows and returns affected rows natively" do rollback_transaction(@client) do text = "test affected rows native" - count = @client.execute("SELECT COUNT(*) AS [count] FROM [datatypes]").each.first["count"] - deleted_rows = @client.execute("DELETE FROM [datatypes]").do + count = @client.execute("SELECT COUNT(*) AS [count] FROM [datatypes]").first["count"] + deleted_rows = @client.do("DELETE FROM [datatypes]") assert_equal count, deleted_rows, "should have deleted rows equal to count" - inserted_rows = @client.execute("INSERT INTO [datatypes] ([varchar_50]) VALUES ('#{text}')").do + inserted_rows = @client.do("INSERT INTO [datatypes] ([varchar_50]) VALUES ('#{text}')") assert_equal 1, inserted_rows, "should have inserted row for one above" - updated_rows = @client.execute("UPDATE [datatypes] SET [varchar_50] = NULL WHERE [varchar_50] = '#{text}'").do + updated_rows = @client.do("UPDATE [datatypes] SET [varchar_50] = NULL WHERE [varchar_50] = '#{text}'") assert_equal 1, updated_rows, "should have updated row for one above" end end @@ -158,59 +93,31 @@ class ResultTest < TinyTds::TestCase it "allows native affected rows using #do to work under transaction" do rollback_transaction(@client) do text = "test affected rows native in transaction" - @client.execute("BEGIN TRANSACTION").do - @client.execute("DELETE FROM [datatypes]").do - inserted_rows = @client.execute("INSERT INTO [datatypes] ([varchar_50]) VALUES ('#{text}')").do + @client.do("BEGIN TRANSACTION") + @client.do("DELETE FROM [datatypes]") + inserted_rows = @client.do("INSERT INTO [datatypes] ([varchar_50]) VALUES ('#{text}')") assert_equal 1, inserted_rows, "should have inserted row for one above" - updated_rows = @client.execute("UPDATE [datatypes] SET [varchar_50] = NULL WHERE [varchar_50] = '#{text}'").do + updated_rows = @client.do("UPDATE [datatypes] SET [varchar_50] = NULL WHERE [varchar_50] = '#{text}'") assert_equal 1, updated_rows, "should have updated row for one above" end end - it "has an #insert method that cancels result rows and returns IDENTITY natively" do - rollback_transaction(@client) do - text = "test scope identity rows native" - @client.execute("DELETE FROM [datatypes] WHERE [varchar_50] = '#{text}'").do - @client.execute("INSERT INTO [datatypes] ([varchar_50]) VALUES ('#{text}')").do - sql_identity = @client.execute(@client.identity_sql).each.first["Ident"] - native_identity = @client.execute("INSERT INTO [datatypes] ([varchar_50]) VALUES ('#{text}')").insert - assert_equal sql_identity + 1, native_identity - end - end - - it "returns bigint for #insert when needed" do - return if sqlserver_azure? # We can not alter clustered index like this test does. - # 'CREATE TABLE' command is not allowed within a multi-statement transaction - # and and sp_helpindex creates a temporary table #spindtab. - rollback_transaction(@client) do - seed = 9223372036854775805 - @client.execute("DELETE FROM [datatypes]").do - id_constraint_name = @client.execute("EXEC sp_helpindex [datatypes]").detect { |row| row["index_keys"] == "id" }["index_name"] - @client.execute("ALTER TABLE [datatypes] DROP CONSTRAINT [#{id_constraint_name}]").do - @client.execute("ALTER TABLE [datatypes] DROP COLUMN [id]").do - @client.execute("ALTER TABLE [datatypes] ADD [id] [bigint] NOT NULL IDENTITY(1,1) PRIMARY KEY").do - @client.execute("DBCC CHECKIDENT ('datatypes', RESEED, #{seed})").do - identity = @client.execute("INSERT INTO [datatypes] ([varchar_50]) VALUES ('something')").insert - assert_equal seed, identity - end - end - it "must be able to begin/commit transactions with raw sql" do rollback_transaction(@client) do - @client.execute("BEGIN TRANSACTION").do - @client.execute("DELETE FROM [datatypes]").do - @client.execute("COMMIT TRANSACTION").do - count = @client.execute("SELECT COUNT(*) AS [count] FROM [datatypes]").each.first["count"] + @client.do("BEGIN TRANSACTION") + @client.do("DELETE FROM [datatypes]") + @client.do("COMMIT TRANSACTION") + count = @client.execute("SELECT COUNT(*) AS [count] FROM [datatypes]").first["count"] assert_equal 0, count end end it "must be able to begin/rollback transactions with raw sql" do load_current_schema - @client.execute("BEGIN TRANSACTION").do - @client.execute("DELETE FROM [datatypes]").do - @client.execute("ROLLBACK TRANSACTION").do - count = @client.execute("SELECT COUNT(*) AS [count] FROM [datatypes]").each.first["count"] + @client.do("BEGIN TRANSACTION") + @client.do("DELETE FROM [datatypes]") + @client.do("ROLLBACK TRANSACTION") + count = @client.execute("SELECT COUNT(*) AS [count] FROM [datatypes]").first["count"] _(count).wont_equal 0 end @@ -224,8 +131,6 @@ class ResultTest < TinyTds::TestCase it "always returns an array for fields for all sql" do result = @client.execute("USE [tinytdstest]") _(result.fields).must_equal [] - result.do - _(result.fields).must_equal [] end it "returns fields even when no results are found" do @@ -233,18 +138,6 @@ class ResultTest < TinyTds::TestCase # Fields before each. result = @client.execute(no_results_query) _(result.fields).must_equal ["id", "varchar_50"] - result.each - _(result.fields).must_equal ["id", "varchar_50"] - # Each then fields - result = @client.execute(no_results_query) - result.each - _(result.fields).must_equal ["id", "varchar_50"] - end - - it "allows the result to be canceled before reading" do - result = @client.execute(@query1) - result.cancel - @client.execute(@query1).each end it "works in tandem with the client when needing to find out if client has sql sent and result is canceled or not" do @@ -253,26 +146,24 @@ class ResultTest < TinyTds::TestCase _(@client.sqlsent?).must_equal false _(@client.canceled?).must_equal false # With active result before and after cancel. - result = @client.execute(@query1) - _(@client.sqlsent?).must_equal true - _(@client.canceled?).must_equal false - result.cancel + @client.execute(@query1) _(@client.sqlsent?).must_equal false _(@client.canceled?).must_equal true - assert result.cancel, "must be safe to call again" # With each and no block. @client.execute(@query1).each _(@client.sqlsent?).must_equal false - _(@client.canceled?).must_equal false + _(@client.canceled?).must_equal true # With each and block. @client.execute(@query1).each do |row| - _(@client.sqlsent?).must_equal true, "when iterating over each row in a block" - _(@client.canceled?).must_equal false + _(@client.sqlsent?).must_equal false + _(@client.canceled?).must_equal true end + _(@client.sqlsent?).must_equal false - _(@client.canceled?).must_equal false + _(@client.canceled?).must_equal true + # With each and block canceled half way thru. - count = @client.execute("SELECT COUNT([id]) AS [count] FROM [datatypes]").each[0]["count"] + count = @client.execute("SELECT COUNT([id]) AS [count] FROM [datatypes]").first["count"] assert count > 10, "since we want to cancel early for test" result = @client.execute("SELECT [id] FROM [datatypes]") index = 0 @@ -280,57 +171,22 @@ class ResultTest < TinyTds::TestCase break if index > 10 index += 1 end - _(@client.sqlsent?).must_equal true - _(@client.canceled?).must_equal false - result.cancel _(@client.sqlsent?).must_equal false _(@client.canceled?).must_equal true # With do method. - @client.execute(@query1).do - _(@client.sqlsent?).must_equal false - _(@client.canceled?).must_equal true - # With insert method. - rollback_transaction(@client) do - @client.execute("INSERT INTO [datatypes] ([varchar_50]) VALUES ('test')").insert - _(@client.sqlsent?).must_equal false - _(@client.canceled?).must_equal true - end - # With first - @client.execute("SELECT [id] FROM [datatypes]").each(first: true) + @client.do(@query1) _(@client.sqlsent?).must_equal false _(@client.canceled?).must_equal true end - it "use same string object for hash keys" do - data = @client.execute("SELECT [id], [bigint] FROM [datatypes]").each - assert_equal data.first.keys.map { |r| r.object_id }, data.last.keys.map { |r| r.object_id } - end - - it "has properly encoded column names with symbol keys" do - col_name = "öäüß" - begin - @client.execute("DROP TABLE [test_encoding]").do - rescue - nil - end - @client.execute("CREATE TABLE [dbo].[test_encoding] ( [id] int NOT NULL IDENTITY(1,1) PRIMARY KEY, [#{col_name}] [nvarchar](10) NOT NULL )").do - @client.execute("INSERT INTO [test_encoding] ([#{col_name}]) VALUES (N'#{col_name}')").do - result = @client.execute("SELECT [#{col_name}] FROM [test_encoding]") - row = result.each(as: :hash, symbolize_keys: true).first - assert_instance_of Symbol, result.fields.first - assert_equal col_name.to_sym, result.fields.first - assert_instance_of Symbol, row.keys.first - assert_equal col_name.to_sym, row.keys.first - end - it "allows #return_code to work with stored procedures and reset per sql batch" do assert_nil @client.return_code result = @client.execute("EXEC tinytds_TestReturnCodes") - assert_equal [{"one" => 1}], result.each + assert_equal [{"one" => 1}], result.rows assert_equal 420, @client.return_code assert_equal 420, result.return_code + result = @client.execute("SELECT 1 as [one]") - result.each assert_nil @client.return_code assert_nil result.return_code end @@ -358,31 +214,31 @@ class ResultTest < TinyTds::TestCase it "handles a command buffer with double selects" do result = @client.execute(@double_select) - result_sets = result.each - assert_equal 2, result_sets.size - assert_equal [{"rs1" => 1}], result_sets.first - assert_equal [{"rs2" => 2}], result_sets.last + assert_equal 2, result.count + assert_equal [{"rs1" => 1}], result.rows.first + assert_equal [{"rs2" => 2}], result.rows.last assert_equal [["rs1"], ["rs2"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" + # As array - result = @client.execute(@double_select) - result_sets = result.each(as: :array) - assert_equal 2, result_sets.size - assert_equal [[1]], result_sets.first - assert_equal [[2]], result_sets.last + result = @client.execute(@double_select, as: :array) + assert_equal 2, result.count + assert_equal [[1]], result.rows.first + assert_equal [[2]], result.rows.last assert_equal [["rs1"], ["rs2"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" end it "yields each row for each result set" do data = [] - result_sets = @client.execute(@double_select).each { |row| data << row } - assert_equal data.first, result_sets.first[0] - assert_equal data.last, result_sets.last[0] + + result = @client.execute(@double_select) + result.each { |row| data << row } + + assert_equal data.first, result.rows.first + assert_equal data.last, result.rows.last end it "works from a stored procedure" do - results1, results2 = @client.execute("EXEC sp_helpconstraint '[datatypes]'").each + results1, results2 = @client.execute("EXEC sp_helpconstraint '[datatypes]'").rows assert_equal [{"Object Name" => "[datatypes]"}], results1 constraint_info = results2.first assert constraint_info.key?("constraint_keys") @@ -404,68 +260,59 @@ class ResultTest < TinyTds::TestCase it "handles a basic empty result set" do result = @client.execute(@empty_select) - assert_equal [], result.each + assert_equal [], result.to_a assert_equal ["rs1"], result.fields end it "includes empty result sets by default - using 1st empty buffer" do result = @client.execute(@triple_select_1st_empty) - result_sets = result.each - assert_equal 3, result_sets.size - assert_equal [], result_sets[0] - assert_equal [{"rs2" => 2}], result_sets[1] - assert_equal [{"rs3" => 3}], result_sets[2] + assert_equal 3, result.count + assert_equal [], result.rows[0] + assert_equal [{"rs2" => 2}], result.rows[1] + assert_equal [{"rs3" => 3}], result.rows[2] assert_equal [["rs1"], ["rs2"], ["rs3"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" + # As array - result = @client.execute(@triple_select_1st_empty) - result_sets = result.each(as: :array) - assert_equal 3, result_sets.size - assert_equal [], result_sets[0] - assert_equal [[2]], result_sets[1] - assert_equal [[3]], result_sets[2] + result = @client.execute(@triple_select_1st_empty, as: :array) + assert_equal 3, result.count + assert_equal [], result.rows[0] + assert_equal [[2]], result.rows[1] + assert_equal [[3]], result.rows[2] assert_equal [["rs1"], ["rs2"], ["rs3"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" end it "includes empty result sets by default - using 2nd empty buffer" do result = @client.execute(@triple_select_2nd_empty) - result_sets = result.each - assert_equal 3, result_sets.size - assert_equal [{"rs1" => 1}], result_sets[0] - assert_equal [], result_sets[1] - assert_equal [{"rs3" => 3}], result_sets[2] + assert_equal 3, result.count + assert_equal [{"rs1" => 1}], result.rows[0] + assert_equal [], result.rows[1] + assert_equal [{"rs3" => 3}], result.rows[2] assert_equal [["rs1"], ["rs2"], ["rs3"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" + # As array - result = @client.execute(@triple_select_2nd_empty) - result_sets = result.each(as: :array) - assert_equal 3, result_sets.size - assert_equal [[1]], result_sets[0] - assert_equal [], result_sets[1] - assert_equal [[3]], result_sets[2] + result = @client.execute(@triple_select_2nd_empty, as: :array) + assert_equal 3, result.count + assert_equal [[1]], result.rows[0] + assert_equal [], result.rows[1] + assert_equal [[3]], result.rows[2] assert_equal [["rs1"], ["rs2"], ["rs3"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" end it "includes empty result sets by default - using 3rd empty buffer" do result = @client.execute(@triple_select_3rd_empty) - result_sets = result.each - assert_equal 3, result_sets.size - assert_equal [{"rs1" => 1}], result_sets[0] - assert_equal [{"rs2" => 2}], result_sets[1] - assert_equal [], result_sets[2] + assert_equal 3, result.count + assert_equal [{"rs1" => 1}], result.rows[0] + assert_equal [{"rs2" => 2}], result.rows[1] + assert_equal [], result.rows[2] assert_equal [["rs1"], ["rs2"], ["rs3"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" + # As array - result = @client.execute(@triple_select_3rd_empty) - result_sets = result.each(as: :array) - assert_equal 3, result_sets.size - assert_equal [[1]], result_sets[0] - assert_equal [[2]], result_sets[1] - assert_equal [], result_sets[2] + result = @client.execute(@triple_select_3rd_empty, as: :array) + assert_equal 3, result.count + assert_equal [[1]], result.rows[0] + assert_equal [[2]], result.rows[1] + assert_equal [], result.rows[2] assert_equal [["rs1"], ["rs2"], ["rs3"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" end end @@ -483,62 +330,53 @@ class ResultTest < TinyTds::TestCase it "handles a basic empty result set" do result = @client.execute(@empty_select) - assert_equal [], result.each + assert_equal [], result.rows assert_equal ["rs1"], result.fields end it "must not include empty result sets by default - using 1st empty buffer" do result = @client.execute(@triple_select_1st_empty) - result_sets = result.each - assert_equal 2, result_sets.size - assert_equal [{"rs2" => 2}], result_sets[0] - assert_equal [{"rs3" => 3}], result_sets[1] + assert_equal 2, result.count + assert_equal [{"rs2" => 2}], result.rows[0] + assert_equal [{"rs3" => 3}], result.rows[1] assert_equal [["rs2"], ["rs3"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" + # As array - result = @client.execute(@triple_select_1st_empty) - result_sets = result.each(as: :array) - assert_equal 2, result_sets.size - assert_equal [[2]], result_sets[0] - assert_equal [[3]], result_sets[1] + result = @client.execute(@triple_select_1st_empty, as: :array) + assert_equal 2, result.count + assert_equal [[2]], result.rows[0] + assert_equal [[3]], result.rows[1] assert_equal [["rs2"], ["rs3"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" end it "must not include empty result sets by default - using 2nd empty buffer" do result = @client.execute(@triple_select_2nd_empty) - result_sets = result.each - assert_equal 2, result_sets.size - assert_equal [{"rs1" => 1}], result_sets[0] - assert_equal [{"rs3" => 3}], result_sets[1] + assert_equal 2, result.count + assert_equal [{"rs1" => 1}], result.rows[0] + assert_equal [{"rs3" => 3}], result.rows[1] assert_equal [["rs1"], ["rs3"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" + # As array - result = @client.execute(@triple_select_2nd_empty) - result_sets = result.each(as: :array) - assert_equal 2, result_sets.size - assert_equal [[1]], result_sets[0] - assert_equal [[3]], result_sets[1] + result = @client.execute(@triple_select_2nd_empty, as: :array) + assert_equal 2, result.count + assert_equal [[1]], result.rows[0] + assert_equal [[3]], result.rows[1] assert_equal [["rs1"], ["rs3"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" end it "must not include empty result sets by default - using 3rd empty buffer" do result = @client.execute(@triple_select_3rd_empty) - result_sets = result.each - assert_equal 2, result_sets.size - assert_equal [{"rs1" => 1}], result_sets[0] - assert_equal [{"rs2" => 2}], result_sets[1] + assert_equal 2, result.count + assert_equal [{"rs1" => 1}], result.rows[0] + assert_equal [{"rs2" => 2}], result.rows[1] assert_equal [["rs1"], ["rs2"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" + # As array - result = @client.execute(@triple_select_3rd_empty) - result_sets = result.each(as: :array) - assert_equal 2, result_sets.size - assert_equal [[1]], result_sets[0] - assert_equal [[2]], result_sets[1] + result = @client.execute(@triple_select_3rd_empty, as: :array) + assert_equal 2, result.count + assert_equal [[1]], result.rows[0] + assert_equal [[2]], result.rows[1] assert_equal [["rs1"], ["rs2"]], result.fields - assert_equal result.each.object_id, result.each.object_id, "same cached rows" end end end @@ -550,19 +388,19 @@ class ResultTest < TinyTds::TestCase after { File.delete(backup_file) if File.exist?(backup_file) } it "must not cancel the query until complete" do - @client.execute("BACKUP DATABASE tinytdstest TO DISK = '#{backup_file}'").do + @client.do("BACKUP DATABASE tinytdstest TO DISK = '#{backup_file}'") end end end describe "when casting to native ruby values" do it "returns fixnum for 1" do - value = @client.execute("SELECT 1 AS [fixnum]").each.first["fixnum"] + value = @client.execute("SELECT 1 AS [fixnum]").first["fixnum"] assert_equal 1, value end it "returns nil for NULL" do - value = @client.execute("SELECT NULL AS [null]").each.first["null"] + value = @client.execute("SELECT NULL AS [null]").first["null"] assert_nil value end end @@ -571,8 +409,8 @@ class ResultTest < TinyTds::TestCase describe "char max" do before do @big_text = "x" * 2_000_000 - @old_textsize = @client.execute("SELECT @@TEXTSIZE AS [textsize]").each.first["textsize"].inspect - @client.execute("SET TEXTSIZE #{(@big_text.length * 2) + 1}").do + @old_textsize = @client.execute("SELECT @@TEXTSIZE AS [textsize]").first["textsize"].inspect + @client.do("SET TEXTSIZE #{(@big_text.length * 2) + 1}") end it "must insert and select large varchar_max" do @@ -588,7 +426,7 @@ class ResultTest < TinyTds::TestCase describe "when shit happens" do it "copes with nil or empty buffer" do assert_raises(TypeError) { @client.execute(nil) } - assert_equal [], @client.execute("").each + assert_equal [], @client.execute("").rows end describe "using :message_handler option" do @@ -612,7 +450,7 @@ class ResultTest < TinyTds::TestCase messages.clear msg = "Test #{severity} severity" state = rand(1..255) - @client.execute("RAISERROR(N'#{msg}', #{severity}, #{state})").do + @client.do("RAISERROR(N'#{msg}', #{severity}, #{state})") m = messages.first assert_equal 1, messages.length, "there should be one message after one raiserror" assert_equal msg, m.message, "message text" @@ -624,7 +462,7 @@ class ResultTest < TinyTds::TestCase it "calls the provided message handler for `print` messages" do messages.clear msg = "hello" - @client.execute("PRINT '#{msg}'").do + @client.do("PRINT '#{msg}'") m = messages.first assert_equal 1, messages.length, "there should be one message after one print statement" assert_equal msg, m.message, "message text" @@ -632,7 +470,7 @@ class ResultTest < TinyTds::TestCase it "must raise an error preceded by a `print` message" do messages.clear - action = lambda { @client.execute("EXEC tinytds_TestPrintWithError").do } + action = lambda { @client.do("EXEC tinytds_TestPrintWithError") } assert_raise_tinytds_error(action) do |e| assert_equal "hello", messages.first.message, "message text" @@ -644,13 +482,13 @@ class ResultTest < TinyTds::TestCase it "calls the provided message handler for each of a series of `print` messages" do messages.clear - @client.execute("EXEC tinytds_TestSeveralPrints").do + @client.do("EXEC tinytds_TestSeveralPrints") assert_equal ["hello 1", "hello 2", "hello 3"], messages.map { |e| e.message }, "message list" end it "should flush info messages before raising error in cases of timeout" do @client = new_connection timeout: 1, message_handler: proc { |m| messages << m } - action = lambda { @client.execute("print 'hello'; waitfor delay '00:00:02'").do } + action = lambda { @client.do("print 'hello'; waitfor delay '00:00:02'") } messages.clear assert_raise_tinytds_error(action) do |e| assert_match %r{timed out}i, e.message, "ignore if non-english test run" @@ -662,7 +500,7 @@ class ResultTest < TinyTds::TestCase it "should print info messages before raising error in cases of timeout" do @client = new_connection timeout: 1, message_handler: proc { |m| messages << m } - action = lambda { @client.execute("raiserror('hello', 1, 1) with nowait; waitfor delay '00:00:02'").do } + action = lambda { @client.do("raiserror('hello', 1, 1) with nowait; waitfor delay '00:00:02'") } messages.clear assert_raise_tinytds_error(action) do |e| assert_match %r{timed out}i, e.message, "ignore if non-english test run" @@ -675,12 +513,12 @@ class ResultTest < TinyTds::TestCase it "must not raise an error when severity is 10 or less" do (1..10).to_a.each do |severity| - @client.execute("RAISERROR(N'Test #{severity} severity', #{severity}, 1)").do + @client.do("RAISERROR(N'Test #{severity} severity', #{severity}, 1)") end end it "raises an error when severity is greater than 10" do - action = lambda { @client.execute("RAISERROR(N'Test 11 severity', 11, 1)").do } + action = lambda { @client.do("RAISERROR(N'Test 11 severity', 11, 1)") } assert_raise_tinytds_error(action) do |e| assert_equal "Test 11 severity", e.message assert_equal 11, e.severity @@ -694,13 +532,13 @@ class ResultTest < TinyTds::TestCase def assert_followup_query result = @client.execute(@query1) - assert_equal 1, result.each.first["one"] + assert_equal 1, result.first["one"] end def insert_and_select_datatype(datatype) rollback_transaction(@client) do - @client.execute("DELETE FROM [datatypes] WHERE [#{datatype}] IS NOT NULL").do - id = @client.execute("INSERT INTO [datatypes] ([#{datatype}]) VALUES (N'#{@big_text}')").insert + @client.do("DELETE FROM [datatypes] WHERE [#{datatype}] IS NOT NULL") + id = @client.insert("INSERT INTO [datatypes] ([#{datatype}]) VALUES (N'#{@big_text}')") found_text = find_value id, datatype flunk "Large #{datatype} data with a length of #{@big_text.length} did not match found text with length of #{found_text.length}" unless @big_text == found_text end diff --git a/test/schema_test.rb b/test/schema_test.rb index 34087f69..70d11bd1 100644 --- a/test/schema_test.rb +++ b/test/schema_test.rb @@ -114,7 +114,7 @@ class SchemaTest < TinyTds::TestCase assert_utf8_encoding find_value(182, :ntext) # If this test fails, try setting the "text size" in your freetds.conf. See: http://www.freetds.org/faq.html#textdata large_value = "x" * 5000 - large_value_id = @client.execute("INSERT INTO [datatypes] ([ntext]) VALUES (N'#{large_value}')").insert + large_value_id = @client.insert("INSERT INTO [datatypes] ([ntext]) VALUES (N'#{large_value}')") assert_equal large_value, find_value(large_value_id, :ntext) end diff --git a/test/test_helper.rb b/test/test_helper.rb index aab1f3e9..88982531 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -44,20 +44,20 @@ def close_client(client = @client) def new_connection(options = {}) client = TinyTds::Client.new(connection_options(options)) if sqlserver_azure? - client.execute("SET ANSI_NULLS ON").do - client.execute("SET CURSOR_CLOSE_ON_COMMIT OFF").do - client.execute("SET ANSI_NULL_DFLT_ON ON").do - client.execute("SET IMPLICIT_TRANSACTIONS OFF").do - client.execute("SET ANSI_PADDING ON").do - client.execute("SET QUOTED_IDENTIFIER ON").do - client.execute("SET ANSI_WARNINGS ON").do + client.do("SET ANSI_NULLS ON") + client.do("SET CURSOR_CLOSE_ON_COMMIT OFF") + client.do("SET ANSI_NULL_DFLT_ON ON") + client.do("SET IMPLICIT_TRANSACTIONS OFF") + client.do("SET ANSI_PADDING ON") + client.do("SET QUOTED_IDENTIFIER ON") + client.do("SET ANSI_WARNINGS ON") else - client.execute("SET ANSI_DEFAULTS ON").do - client.execute("SET CURSOR_CLOSE_ON_COMMIT OFF").do - client.execute("SET IMPLICIT_TRANSACTIONS OFF").do + client.do("SET ANSI_DEFAULTS ON") + client.do("SET CURSOR_CLOSE_ON_COMMIT OFF") + client.do("SET IMPLICIT_TRANSACTIONS OFF") end - client.execute("SET TEXTSIZE 2147483647").do - client.execute("SET CONCAT_NULL_YIELDS_NULL ON").do + client.do("SET TEXTSIZE 2147483647") + client.do("SET CONCAT_NULL_YIELDS_NULL ON") client end @@ -82,7 +82,7 @@ def connection_timeout end def assert_client_works(client) - _(client.execute("SELECT 'client_works' as [client_works]").each).must_equal [{"client_works" => "client_works"}] + _(client.execute("SELECT 'client_works' as [client_works]").rows).must_equal [{"client_works" => "client_works"}] end def assert_new_connections_work @@ -137,11 +137,11 @@ def load_current_schema loader = new_connection schema_file = File.expand_path File.join(File.dirname(__FILE__), "schema", "#{current_schema}.sql") schema_sql = File.open(schema_file, "rb:UTF-8") { |f| f.read } - loader.execute(drop_sql).do - loader.execute(schema_sql).do - loader.execute(sp_sql).do - loader.execute(sp_error_sql).do - loader.execute(sp_several_prints_sql).do + loader.do(drop_sql) + loader.do(schema_sql) + loader.do(sp_sql) + loader.do(sp_error_sql) + loader.do(sp_several_prints_sql) loader.close true end @@ -191,9 +191,8 @@ def sp_several_prints_sql end def find_value(id, column, query_options = {}) - query_options[:timezone] ||= :utc sql = "SELECT [#{column}] FROM [datatypes] WHERE [id] = #{id}" - @client.execute(sql).each(query_options).first[column.to_s] + @client.execute(sql, timezone: query_options[:timezone] || :utc).first[column.to_s] end def local_offset @@ -205,10 +204,10 @@ def utc_offset end def rollback_transaction(client) - client.execute("BEGIN TRANSACTION").do + client.do("BEGIN TRANSACTION") yield ensure - client.execute("ROLLBACK TRANSACTION").do + client.do("ROLLBACK TRANSACTION") end def init_toxiproxy diff --git a/test/thread_test.rb b/test/thread_test.rb index 692030c9..d03d2796 100644 --- a/test/thread_test.rb +++ b/test/thread_test.rb @@ -20,7 +20,7 @@ class ThreadTest < TinyTds::TestCase threads = [] @numthreads.times do |i| threads << Thread.new do - @pool.with { |c| c.execute(@query).do } + @pool.with { |c| c.do(@query) } end end threads.each { |t| t.join } From 603093910858d8f38acdffa841cdf828a8a6e956 Mon Sep 17 00:00:00 2001 From: Andy Pfister Date: Wed, 10 Sep 2025 21:22:58 +0200 Subject: [PATCH 08/10] v4: Refactor `#new` and `#connect` (#596) * `TinyTds::Client.new` now accepts keyword arguments instead of a hash * Separate `#new` and `#connect` * Rename `encoding` keyword argument to `charset` `encoding` denotes the actual `Encoding` then used to associate strings. --- CHANGELOG.md | 4 ++ README.md | 4 +- ext/tiny_tds/client.c | 139 +++++++++++++++++------------------------ lib/tiny_tds/client.rb | 60 ++++++++---------- test/client_test.rb | 55 +++++++--------- test/result_test.rb | 3 +- test/test_helper.rb | 7 ++- 7 files changed, 114 insertions(+), 158 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2360cdca..218de026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ * `TinyTds::Result` is now a pure Ruby class * `#execute`: Replaced `opts` hash with keyword arguments * Removed `symbolize_keys` and `cache_rows` from `#default_query_options` +* `TinyTds::Client.new` now accepts keyword arguments instead of a hash +* Renamed `tds_version` and `tds_version_info` to `server_version` and `server_version_info` +* Separate `#new` and `#connect` + * Instead, before running `#do`, `#execute` or `#insert`, `tiny_tds` will check if the connection is active and re-connect if needed. ## 3.4.0 diff --git a/README.md b/README.md index 7bc697d1..ce86ecad 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Connect to a database. client = TinyTds::Client.new username: 'sa', password: 'secret', host: 'mydb.host.net' ``` -Creating a new client takes a hash of options. For valid iconv encoding options, see the output of `iconv -l`. Only a few have been tested, and are highly recommended to leave blank for the UTF-8 default. +Creating a new client takes keyword arguments. For valid iconv encoding options, see the output of `iconv -l`. Only a few have been tested, and are highly recommended to leave blank for the UTF-8 default. * :username - The database server user. * :password - The user password. @@ -106,7 +106,7 @@ Creating a new client takes a hash of options. For valid iconv encoding options, * :host - Used if :dataserver blank. Can be an host name or IP. * :port - Defaults to 1433. Only used if :host is used. * :database - The default database to use. -* :appname - Short string seen in SQL Servers process/activity window. +* :app_name - Short string seen in SQL Servers process/activity window. * :tds_version - TDS version. Defaults to "7.3". * :login_timeout - Seconds to wait for login. Default to 60 seconds. * :timeout - Seconds to wait for a response to a SQL command. Default 5 seconds. Timeouts caused by network failure will raise a timeout error 1 second after the configured timeout limit is hit (see [#481](https://github.com/rails-sqlserver/tiny_tds/pull/481) for details). diff --git a/ext/tiny_tds/client.c b/ext/tiny_tds/client.c index 5e6b2191..f336e180 100644 --- a/ext/tiny_tds/client.c +++ b/ext/tiny_tds/client.c @@ -4,9 +4,8 @@ VALUE cTinyTdsClient; extern VALUE mTinyTds, cTinyTdsError; -static ID sym_username, sym_password, sym_dataserver, sym_database, sym_appname, sym_tds_version, sym_login_timeout, sym_timeout, sym_encoding, sym_azure, sym_contained, sym_use_utf16, sym_message_handler; static ID intern_source_eql, intern_severity_eql, intern_db_error_number_eql, intern_os_error_number_eql; -static ID intern_new, intern_dup, intern_transpose_iconv_encoding, intern_local_offset, intern_gsub, intern_call; +static ID intern_new, intern_dup, intern_local_offset, intern_gsub, intern_call, intern_active, intern_connect; VALUE opt_escape_regex, opt_escape_dblquote; static ID id_ivar_fields, id_ivar_rows, id_ivar_return_code, id_ivar_affected_rows, id_ivar_default_query_options, intern_bigd, intern_divide; @@ -16,15 +15,6 @@ static VALUE cTinyTdsResult, cKernel, cDate; rb_encoding *binaryEncoding; VALUE opt_onek, opt_onebil, opt_float_zero, opt_four, opt_tenk; -static void rb_tinytds_client_mark(void *ptr) -{ - tinytds_client_wrapper *cwrap = (tinytds_client_wrapper *)ptr; - - if (cwrap) { - rb_gc_mark(cwrap->charset); - } -} - static void rb_tinytds_client_free(void *ptr) { tinytds_client_wrapper *cwrap = (tinytds_client_wrapper *)ptr; @@ -52,7 +42,7 @@ static size_t tinytds_client_wrapper_size(const void* data) static const rb_data_type_t tinytds_client_wrapper_type = { .wrap_struct_name = "tinytds_client_wrapper", .function = { - .dmark = rb_tinytds_client_mark, + .dmark = NULL, .dfree = rb_tinytds_client_free, .dsize = tinytds_client_wrapper_size, }, @@ -460,7 +450,6 @@ static VALUE allocate(VALUE klass) tinytds_client_wrapper *cwrap; obj = TypedData_Make_Struct(klass, tinytds_client_wrapper, &tinytds_client_wrapper_type, cwrap); cwrap->closed = 1; - cwrap->charset = Qnil; cwrap->userdata = malloc(sizeof(tinytds_client_userdata)); cwrap->userdata->closed = 1; rb_tinytds_client_reset_userdata(cwrap->userdata); @@ -469,11 +458,15 @@ static VALUE allocate(VALUE klass) // TinyTds::Client (public) - -static VALUE rb_tinytds_tds_version(VALUE self) +static VALUE rb_tinytds_server_version(VALUE self) { GET_CLIENT_WRAPPER(self); - return INT2FIX(dbtds(cwrap->client)); + + if (rb_funcall(self, intern_active, 0) == Qtrue) { + return INT2FIX(dbtds(cwrap->client)); + } else { + return Qnil; + } } static VALUE rb_tinytds_close(VALUE self) @@ -746,6 +739,11 @@ static VALUE rb_tinytds_execute(int argc, VALUE *argv, VALUE self) } GET_CLIENT_WRAPPER(self); + + if (rb_funcall(self, intern_active, 0) == Qfalse) { + rb_funcall(self, intern_connect, 0); + } + rb_tinytds_send_sql_to_server(cwrap, sql); VALUE result = rb_obj_alloc(cTinyTdsResult); @@ -857,6 +855,11 @@ static VALUE rb_tiny_tds_insert(VALUE self, VALUE sql) { VALUE identity = Qnil; GET_CLIENT_WRAPPER(self); + + if (rb_funcall(self, intern_active, 0) == Qfalse) { + rb_funcall(self, intern_connect, 0); + } + rb_tinytds_send_sql_to_server(cwrap, sql); rb_tinytds_result_exec_helper(cwrap->client); @@ -886,18 +889,17 @@ static VALUE rb_tiny_tds_insert(VALUE self, VALUE sql) static VALUE rb_tiny_tds_do(VALUE self, VALUE sql) { GET_CLIENT_WRAPPER(self); + + if (rb_funcall(self, intern_active, 0) == Qfalse) { + rb_funcall(self, intern_connect, 0); + } + rb_tinytds_send_sql_to_server(cwrap, sql); rb_tinytds_result_exec_helper(cwrap->client); return rb_tinytds_affected_rows(cwrap->client); } -static VALUE rb_tinytds_charset(VALUE self) -{ - GET_CLIENT_WRAPPER(self); - return cwrap->charset; -} - static VALUE rb_tinytds_encoding(VALUE self) { GET_CLIENT_WRAPPER(self); @@ -925,25 +927,25 @@ static VALUE rb_tinytds_identity_sql(VALUE self) // TinyTds::Client (protected) -static VALUE rb_tinytds_connect(VALUE self, VALUE opts) +static VALUE rb_tinytds_connect(VALUE self) { /* Parsing options hash to local vars. */ - VALUE user, pass, dataserver, database, app, version, ltimeout, timeout, charset, azure, contained, use_utf16; + VALUE username, password, dataserver, database, app_name, tds_version, login_timeout, timeout, charset, azure, contained, use_utf16; GET_CLIENT_WRAPPER(self); - user = rb_hash_aref(opts, sym_username); - pass = rb_hash_aref(opts, sym_password); - dataserver = rb_hash_aref(opts, sym_dataserver); - database = rb_hash_aref(opts, sym_database); - app = rb_hash_aref(opts, sym_appname); - version = rb_hash_aref(opts, sym_tds_version); - ltimeout = rb_hash_aref(opts, sym_login_timeout); - timeout = rb_hash_aref(opts, sym_timeout); - charset = rb_hash_aref(opts, sym_encoding); - azure = rb_hash_aref(opts, sym_azure); - contained = rb_hash_aref(opts, sym_contained); - use_utf16 = rb_hash_aref(opts, sym_use_utf16); - cwrap->userdata->message_handler = rb_hash_aref(opts, sym_message_handler); + app_name = rb_iv_get(self, "@app_name"); + azure = rb_iv_get(self, "@azure"); + contained = rb_iv_get(self, "@contained"); + database = rb_iv_get(self, "@database"); + dataserver = rb_iv_get(self, "@dataserver"); + charset = rb_iv_get(self, "@charset"); + login_timeout = rb_iv_get(self, "@login_timeout"); + password = rb_iv_get(self, "@password"); + tds_version = rb_iv_get(self, "@tds_version"); + timeout = rb_iv_get(self, "@timeout"); + username = rb_iv_get(self, "@username"); + use_utf16 = rb_iv_get(self, "@use_utf16"); + cwrap->userdata->message_handler = rb_iv_get(self, "@message_handler"); /* Dealing with options. */ if (dbinit() == FAIL) { @@ -955,24 +957,24 @@ static VALUE rb_tinytds_connect(VALUE self, VALUE opts) dbmsghandle(tinytds_msg_handler); cwrap->login = dblogin(); - if (!NIL_P(version)) { - dbsetlversion(cwrap->login, NUM2INT(version)); + if (!NIL_P(tds_version)) { + dbsetlversion(cwrap->login, NUM2INT(tds_version)); } - if (!NIL_P(user)) { - dbsetluser(cwrap->login, StringValueCStr(user)); + if (!NIL_P(username)) { + dbsetluser(cwrap->login, StringValueCStr(username)); } - if (!NIL_P(pass)) { - dbsetlpwd(cwrap->login, StringValueCStr(pass)); + if (!NIL_P(password)) { + dbsetlpwd(cwrap->login, StringValueCStr(password)); } - if (!NIL_P(app)) { - dbsetlapp(cwrap->login, StringValueCStr(app)); + if (!NIL_P(app_name)) { + dbsetlapp(cwrap->login, StringValueCStr(app_name)); } - if (!NIL_P(ltimeout)) { - dbsetlogintime(NUM2INT(ltimeout)); + if (!NIL_P(login_timeout)) { + dbsetlogintime(NUM2INT(login_timeout)); } if (!NIL_P(charset)) { @@ -981,19 +983,7 @@ static VALUE rb_tinytds_connect(VALUE self, VALUE opts) if (!NIL_P(database)) { if (azure == Qtrue || contained == Qtrue) { - #ifdef DBSETLDBNAME DBSETLDBNAME(cwrap->login, StringValueCStr(database)); - #else - - if (azure == Qtrue) { - rb_warn("TinyTds: :azure option is not supported in this version of FreeTDS.\n"); - } - - if (contained == Qtrue) { - rb_warn("TinyTds: :contained option is not supported in this version of FreeTDS.\n"); - } - - #endif } } @@ -1015,10 +1005,9 @@ static VALUE rb_tinytds_connect(VALUE self, VALUE opts) VALUE transposed_encoding, timeout_string; cwrap->closed = 0; - cwrap->charset = charset; - if (!NIL_P(version)) { - dbsetversion(NUM2INT(version)); + if (!NIL_P(tds_version)) { + dbsetversion(NUM2INT(tds_version)); } if (!NIL_P(timeout)) { @@ -1037,8 +1026,7 @@ static VALUE rb_tinytds_connect(VALUE self, VALUE opts) dbuse(cwrap->client, StringValueCStr(database)); } - transposed_encoding = rb_funcall(cTinyTdsClient, intern_transpose_iconv_encoding, 1, charset); - cwrap->encoding = rb_enc_find(StringValueCStr(transposed_encoding)); + cwrap->encoding = rb_enc_find(StringValueCStr(charset)); cwrap->identity_insert_sql = "SELECT CAST(SCOPE_IDENTITY() AS bigint) AS Ident"; } @@ -1053,7 +1041,7 @@ void init_tinytds_client() cTinyTdsClient = rb_define_class_under(mTinyTds, "Client", rb_cObject); rb_define_alloc_func(cTinyTdsClient, allocate); /* Define TinyTds::Client Public Methods */ - rb_define_method(cTinyTdsClient, "tds_version", rb_tinytds_tds_version, 0); + rb_define_method(cTinyTdsClient, "server_version", rb_tinytds_server_version, 0); rb_define_method(cTinyTdsClient, "close", rb_tinytds_close, 0); rb_define_method(cTinyTdsClient, "closed?", rb_tinytds_closed, 0); rb_define_method(cTinyTdsClient, "canceled?", rb_tinytds_canceled, 0); @@ -1062,27 +1050,11 @@ void init_tinytds_client() rb_define_method(cTinyTdsClient, "execute", rb_tinytds_execute, -1); rb_define_method(cTinyTdsClient, "insert", rb_tiny_tds_insert, 1); rb_define_method(cTinyTdsClient, "do", rb_tiny_tds_do, 1); - rb_define_method(cTinyTdsClient, "charset", rb_tinytds_charset, 0); rb_define_method(cTinyTdsClient, "encoding", rb_tinytds_encoding, 0); rb_define_method(cTinyTdsClient, "escape", rb_tinytds_escape, 1); rb_define_method(cTinyTdsClient, "return_code", rb_tinytds_return_code, 0); rb_define_method(cTinyTdsClient, "identity_sql", rb_tinytds_identity_sql, 0); - /* Define TinyTds::Client Protected Methods */ - rb_define_protected_method(cTinyTdsClient, "connect", rb_tinytds_connect, 1); - /* Symbols For Connect */ - sym_username = ID2SYM(rb_intern("username")); - sym_password = ID2SYM(rb_intern("password")); - sym_dataserver = ID2SYM(rb_intern("dataserver")); - sym_database = ID2SYM(rb_intern("database")); - sym_appname = ID2SYM(rb_intern("appname")); - sym_tds_version = ID2SYM(rb_intern("tds_version")); - sym_login_timeout = ID2SYM(rb_intern("login_timeout")); - sym_timeout = ID2SYM(rb_intern("timeout")); - sym_encoding = ID2SYM(rb_intern("encoding")); - sym_azure = ID2SYM(rb_intern("azure")); - sym_contained = ID2SYM(rb_intern("contained")); - sym_use_utf16 = ID2SYM(rb_intern("use_utf16")); - sym_message_handler = ID2SYM(rb_intern("message_handler")); + rb_define_method(cTinyTdsClient, "connect", rb_tinytds_connect, 0); /* Intern TinyTds::Error Accessors */ intern_source_eql = rb_intern("source="); intern_severity_eql = rb_intern("severity="); @@ -1091,7 +1063,6 @@ void init_tinytds_client() /* Intern Misc */ intern_new = rb_intern("new"); intern_dup = rb_intern("dup"); - intern_transpose_iconv_encoding = rb_intern("transpose_iconv_encoding"); intern_local_offset = rb_intern("local_offset"); intern_gsub = rb_intern("gsub"); intern_call = rb_intern("call"); @@ -1115,6 +1086,8 @@ void init_tinytds_client() intern_timezone = rb_intern("timezone"); intern_utc = rb_intern("utc"); intern_local = rb_intern("local"); + intern_active = rb_intern("active?"); + intern_connect = rb_intern("connect"); cTinyTdsClient = rb_const_get(mTinyTds, rb_intern("Client")); cTinyTdsResult = rb_const_get(mTinyTds, rb_intern("Result")); diff --git a/lib/tiny_tds/client.rb b/lib/tiny_tds/client.rb index 3631a51a..29626503 100644 --- a/lib/tiny_tds/client.rb +++ b/lib/tiny_tds/client.rb @@ -1,5 +1,7 @@ module TinyTds class Client + attr_reader :app_name, :charset, :contained, :database, :dataserver, :message_handler, :login_timeout, :password, :port, :tds_version, :timeout, :username, :use_utf16 + @default_query_options = { as: :hash, empty_sets: true, @@ -7,19 +9,10 @@ class Client } attr_reader :query_options - attr_reader :message_handler class << self attr_reader :default_query_options - # Most, if not all, iconv encoding names can be found by ruby. Just in case, you can - # overide this method to return a string name that Encoding.find would work with. Default - # is to return the passed encoding. - # - def transpose_iconv_encoding(encoding) - encoding - end - def local_offset ::Time.local(2010).utc_offset.to_r / 86_400 end @@ -29,37 +22,36 @@ def local_offset # rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/CyclomaticComplexity # rubocop:disable Metrics/PerceivedComplexity - def initialize(opts = {}) - if opts[:dataserver].to_s.empty? && opts[:host].to_s.empty? + def initialize(app_name: "TinyTds", azure: false, charset: "UTF-8", contained: false, database: nil, dataserver: nil, message_handler: nil, host: nil, login_timeout: 60, password: nil, port: 1433, tds_version: nil, timeout: 5, username: nil, use_utf16: true) + if dataserver.to_s.empty? && host.to_s.empty? raise ArgumentError, "missing :host option if no :dataserver given" end - @message_handler = opts[:message_handler] - if @message_handler && !@message_handler.respond_to?(:call) + if message_handler && !message_handler.respond_to?(:call) raise ArgumentError, ":message_handler must implement `call` (eg, a Proc or a Method)" + else + @message_handler = message_handler end - opts[:username] = parse_username(opts) - opts[:password] = opts[:password].to_s if opts[:password] && opts[:password].to_s.strip != "" - opts[:appname] ||= "TinyTds" - opts[:tds_version] = tds_versions_setter(opts) - opts[:use_utf16] = opts[:use_utf16].nil? || ["true", "1", "yes"].include?(opts[:use_utf16].to_s) - opts[:login_timeout] ||= 60 - opts[:timeout] ||= 5 - opts[:encoding] = (opts[:encoding].nil? || opts[:encoding].casecmp("utf8").zero?) ? "UTF-8" : opts[:encoding].upcase - opts[:port] ||= 1433 - opts[:dataserver] = "#{opts[:host]}:#{opts[:port]}" if opts[:dataserver].to_s.empty? - forced_integer_keys = [:login_timeout, :port, :timeout] - forced_integer_keys.each { |k| opts[k] = opts[k].to_i if opts[k] } - connect(opts) + @app_name = app_name + @charset = (charset.nil? || charset.casecmp("utf8").zero?) ? "UTF-8" : charset.upcase + @database = database + @dataserver = dataserver || "#{host}:#{port}" + @login_timeout = login_timeout.to_i + @password = password if password && password.to_s.strip != "" + @port = port.to_i + @timeout = timeout.to_i + @tds_version = tds_versions_setter(tds_version:) + @username = parse_username(azure:, host:, username:) + @use_utf16 = use_utf16.nil? || ["true", "1", "yes"].include?(use_utf16.to_s) end def tds_73? - tds_version >= 11 + server_version >= 11 end - def tds_version_info - info = TDS_VERSIONS_GETTERS[tds_version] + def server_version_info + info = TDS_VERSIONS_GETTERS[server_version] "#{info[:name]} - #{info[:description]}" if info end @@ -69,18 +61,16 @@ def active? private - def parse_username(opts) - host = opts[:host] - username = opts[:username] - return username if username.nil? || !opts[:azure] + def parse_username(username:, azure: false, host: nil) + return username if username.nil? || !azure return username if username.include?("@") && !username.include?("database.windows.net") user, domain = username.split("@") domain ||= host "#{user}@#{domain.split(".").first}" end - def tds_versions_setter(opts = {}) - v = opts[:tds_version] || ENV["TDSVER"] || "7.3" + def tds_versions_setter(tds_version:) + v = tds_version || ENV["TDSVER"] || "7.3" TDS_VERSIONS_SETTERS[v.to_s] end diff --git a/test/client_test.rb b/test/client_test.rb index 5d3ef7f5..a7ae7179 100644 --- a/test/client_test.rb +++ b/test/client_test.rb @@ -10,9 +10,18 @@ class ClientTest < TinyTds::TestCase @client = new_connection end - it "must not be closed" do - assert !@client.closed? - assert @client.active? + it "is considered close without a connection" do + client = TinyTds::Client.new(**connection_options) + + assert client.closed? + assert !client.active? + end + + it "returns nil values for server version without a connection" do + client = TinyTds::Client.new(**connection_options) + + assert_nil client.server_version + assert_nil client.server_version_info end it "allows client connection to be closed" do @@ -20,20 +29,11 @@ class ClientTest < TinyTds::TestCase assert @client.closed? assert !@client.active? assert @client.dead? - action = lambda { @client.execute("SELECT 1 as [one]").each } - assert_raise_tinytds_error(action) do |e| - assert_match %r{closed connection}i, e.message, "ignore if non-english test run" - end end - it "has getters for the tds version information (brittle since conf takes precedence)" do - if @client.tds_73? - assert_equal 11, @client.tds_version - assert_equal "DBTDS_7_3 - Microsoft SQL Server 2008", @client.tds_version_info - else - assert_equal 9, @client.tds_version - assert_equal "DBTDS_7_1/DBTDS_8_0 - Microsoft SQL Server 2000", @client.tds_version_info - end + it "has getters for the server version information (brittle since conf takes precedence)" do + assert_equal 11, @client.server_version + assert_equal "DBTDS_7_3 - Microsoft SQL Server 2008", @client.server_version_info end it "uses UTF-8 client charset/encoding by default" do @@ -45,11 +45,11 @@ class ClientTest < TinyTds::TestCase assert_equal "''hello''", @client.escape("'hello'") end - ["CP850", "CP1252", "ISO-8859-1"].each do |encoding| - it "allows valid iconv character set - #{encoding}" do - client = new_connection(encoding: encoding) - assert_equal encoding, client.charset - assert_equal Encoding.find(encoding), client.encoding + ["CP850", "CP1252", "ISO-8859-1"].each do |charset| + it "allows valid iconv character set - #{charset}" do + client = new_connection(charset:) + assert_equal charset, client.charset + assert_equal Encoding.find(charset), client.encoding ensure client&.close end @@ -82,8 +82,7 @@ class ClientTest < TinyTds::TestCase end it "raises TinyTds exception with undefined :dataserver" do - options = connection_options login_timeout: 1, dataserver: "DOESNOTEXIST" - action = lambda { new_connection(options) } + action = lambda { new_connection(login_timeout: 1, dataserver: "DOESNOTEXIST") } assert_raise_tinytds_error(action) do |e| # Not sure why tese are different. if ruby_darwin? @@ -193,7 +192,7 @@ class ClientTest < TinyTds::TestCase it "raises TinyTds exception with wrong :username" do skip if ENV["CI"] && sqlserver_azure? # Some issue with db_error_number. options = connection_options username: "willnotwork" - action = lambda { new_connection(options) } + action = lambda { new_connection(**options) } assert_raise_tinytds_error(action) do |e| assert_equal 18456, e.db_error_number assert_equal 14, e.severity @@ -305,15 +304,5 @@ class ClientTest < TinyTds::TestCase assert_client_works(@client) end end - - it "throws an error if client is closed" do - @client.close - assert @client.closed? - - action = lambda { @client.insert("SELECT 1 as [one]") } - assert_raise_tinytds_error(action) do |e| - assert_match %r{closed connection}i, e.message - end - end end end diff --git a/test/result_test.rb b/test/result_test.rb index 1f8916f1..6babe06c 100644 --- a/test/result_test.rb +++ b/test/result_test.rb @@ -142,9 +142,8 @@ class ResultTest < TinyTds::TestCase it "works in tandem with the client when needing to find out if client has sql sent and result is canceled or not" do # Default state. - @client = TinyTds::Client.new(connection_options) _(@client.sqlsent?).must_equal false - _(@client.canceled?).must_equal false + _(@client.canceled?).must_equal true # With active result before and after cancel. @client.execute(@query1) _(@client.sqlsent?).must_equal false diff --git a/test/test_helper.rb b/test/test_helper.rb index 88982531..854b6733 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -41,8 +41,9 @@ def close_client(client = @client) client.close if defined?(client) && client.is_a?(TinyTds::Client) end - def new_connection(options = {}) - client = TinyTds::Client.new(connection_options(options)) + def new_connection(**options) + client = TinyTds::Client.new(**connection_options(options)) + if sqlserver_azure? client.do("SET ANSI_NULLS ON") client.do("SET CURSOR_CLOSE_ON_COMMIT OFF") @@ -71,7 +72,7 @@ def connection_options(options = {}) username: username, password: password, database: ENV["TINYTDS_UNIT_DATABASE"] || "tinytdstest", - appname: "TinyTds Dev", + app_name: "TinyTds Dev", login_timeout: 5, timeout: connection_timeout, azure: sqlserver_azure?}.merge(options) From 1b9767c96ad4a96f4535f5c87fdce873a87b9dac Mon Sep 17 00:00:00 2001 From: Andy Pfister Date: Fri, 12 Sep 2025 21:07:13 +0200 Subject: [PATCH 09/10] v4: Hand-off GVL during `dbuse` (#597) --- ext/tiny_tds/client.c | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/ext/tiny_tds/client.c b/ext/tiny_tds/client.c index f336e180..42781c1e 100644 --- a/ext/tiny_tds/client.c +++ b/ext/tiny_tds/client.c @@ -923,9 +923,19 @@ static VALUE rb_tinytds_identity_sql(VALUE self) return rb_str_new2(cwrap->identity_insert_sql); } +// connect function, with some additions to enable handing off the GVL +struct dbuse_args { + DBPROCESS * dbproc; + const char * name; +}; +static void *dbuse_without_gvl(void *ptr) +{ + struct dbuse_args *args = (struct dbuse_args *)ptr; + dbuse(args->dbproc, args->name); + return NULL; +} -// TinyTds::Client (protected) static VALUE rb_tinytds_connect(VALUE self) { @@ -1023,7 +1033,20 @@ static VALUE rb_tinytds_connect(VALUE self) cwrap->userdata->closed = 0; if (!NIL_P(database) && (azure != Qtrue)) { - dbuse(cwrap->client, StringValueCStr(database)); + struct dbuse_args use_args; + use_args.dbproc = cwrap->client; + use_args.name = StringValueCStr(database); + + // in case of any errors, the tinytds_err_handler will be called + // so we do not have to check the return code here + nogvl_setup(cwrap->client); + rb_thread_call_without_gvl( + dbuse_without_gvl, + &use_args, + NULL, + NULL + ); + nogvl_cleanup(cwrap->client); } cwrap->encoding = rb_enc_find(StringValueCStr(charset)); From cbce2109188f87957331e48d44e6b9d8635450a0 Mon Sep 17 00:00:00 2001 From: Andy Pfister Date: Sat, 13 Dec 2025 19:55:41 +0100 Subject: [PATCH 10/10] Add missing test for `dbopen` (#600) --- test/client_test.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/client_test.rb b/test/client_test.rb index a7ae7179..8a38a4dd 100644 --- a/test/client_test.rb +++ b/test/client_test.rb @@ -200,6 +200,18 @@ class ClientTest < TinyTds::TestCase end assert_new_connections_work end + + it "raises TinyTds exception with invalid database name" do + action = lambda { new_connection(database: "DOESNOTEXIST") } + + assert_raise_tinytds_error(action) do |e| + assert_equal 911, e.db_error_number + assert_equal 16, e.severity + assert_equal "Database 'DOESNOTEXIST' does not exist. Make sure that the name is entered correctly.", e.message + end + + assert_new_connections_work + end end describe "#parse_username" do