diff --git a/.github/workflows/integration-tests-on-emulator.yml b/.github/workflows/integration-tests-on-emulator.yml index 38a57e1c..acecd5fa 100644 --- a/.github/workflows/integration-tests-on-emulator.yml +++ b/.github/workflows/integration-tests-on-emulator.yml @@ -2,9 +2,13 @@ on: push: branches: [ main ] pull_request: + name: Integration tests on emulator + jobs: - test: + # This is the original job, renamed for clarity + test-go: + name: Go Integration Tests runs-on: ubuntu-latest services: emulator: @@ -18,11 +22,37 @@ jobs: with: go-version: 1.25.x - name: Checkout code - uses: actions/checkout@v5 - - name: Run integration tests on emulator + uses: actions/checkout@v4 + - name: Run Go integration tests on emulator run: go test -race env: JOB_TYPE: test SPANNER_EMULATOR_HOST: localhost:9010 SPANNER_TEST_PROJECT: emulator-test-project SPANNER_TEST_INSTANCE: test-instance + + test-ruby: + name: Ruby Integration Tests + runs-on: ubuntu-latest + services: + emulator: + image: gcr.io/cloud-spanner-emulator/emulator:latest + ports: + - 9010:9010 + - 9020:9020 + steps: + - name: Checkout code + uses: actions/checkout@v5 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3.1' + working-directory: spannerlib/wrappers/spannerlib-ruby + bundler-cache: true + - name: Compile and Run Ruby Integration Tests + working-directory: spannerlib/wrappers/spannerlib-ruby + env: + SPANNER_EMULATOR_HOST: localhost:9010 + run: | + bundle exec rake compile + bundle exec rspec spec/integration/ diff --git a/spannerlib/.gitignore b/spannerlib/.gitignore index f6dbcd3d..e3130ba8 100644 --- a/spannerlib/.gitignore +++ b/spannerlib/.gitignore @@ -1,3 +1,10 @@ spannerlib.h spannerlib.so grpc_server +vendor/bundle +shared/ +*.gem +.DS_Store +*.swp +ext/ +Gemfile.lock diff --git a/spannerlib/wrappers/spannerlib-ruby/.gitignore b/spannerlib/wrappers/spannerlib-ruby/.gitignore new file mode 100644 index 00000000..c458d7fd --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/.gitignore @@ -0,0 +1,16 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +.rspec_status +/vendor/bundle +/shared/ +*.gem + +.DS_Store +*.swp \ No newline at end of file diff --git a/spannerlib/wrappers/spannerlib-ruby/.rspec b/spannerlib/wrappers/spannerlib-ruby/.rspec new file mode 100644 index 00000000..34c5164d --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/spannerlib/wrappers/spannerlib-ruby/.rubocop.yml b/spannerlib/wrappers/spannerlib-ruby/.rubocop.yml new file mode 100644 index 00000000..40dda27b --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/.rubocop.yml @@ -0,0 +1,32 @@ +AllCops: + NewCops: enable + SuggestExtensions: false + Exclude: + - 'lib/spanner_pb.rb' + - 'vendor/**/*' + +plugins: + - rubocop-rspec + +Layout/LineLength: + Max: 150 + +Style/Documentation: + Enabled: false + +RSpec/ExampleLength: + Enabled: false +RSpec/MultipleExpectations: + Enabled: false + +# Add this block to disable the 'let' rule +RSpec/InstanceVariable: + Enabled: false +RSpec/BeforeAfterAll: + Enabled: false +RSpec/DescribeClass: + Exclude: + - 'spec/integration/**/*' + +Style/StringLiterals: + EnforcedStyle: double_quotes diff --git a/spannerlib/wrappers/spannerlib-ruby/Gemfile b/spannerlib/wrappers/spannerlib-ruby/Gemfile new file mode 100644 index 00000000..a40cfd66 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/Gemfile @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +gem "rake", "~> 13.0" + +group :development, :test do + gem "rake-compiler", "~> 1.0" + gem "rspec", "~> 3.0" + gem "rubocop", require: false + gem "rubocop-rspec", require: false +end diff --git a/spannerlib/wrappers/spannerlib-ruby/Rakefile b/spannerlib/wrappers/spannerlib-ruby/Rakefile new file mode 100644 index 00000000..c6babf0a --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/Rakefile @@ -0,0 +1,45 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" +require "rubocop/rake_task" +require "rbconfig" + +RSpec::Core::RakeTask.new(:spec) + +RuboCop::RakeTask.new + +task :compile do + go_source_dir = File.expand_path("../../shared", __dir__) + target_dir = File.expand_path("lib/spannerlib/#{RbConfig::CONFIG['arch']}", __dir__) + output_file = File.join(target_dir, "spannerlib.#{RbConfig::CONFIG['SOEXT']}") + + mkdir_p target_dir + + command = [ + "go", "build", + "-buildmode=c-shared", + "-o", output_file, + go_source_dir + ].join(" ") + + puts command + sh command +end + +task default: %i[compile spec rubocop] + diff --git a/spannerlib/wrappers/spannerlib-ruby/bin/console b/spannerlib/wrappers/spannerlib-ruby/bin/console new file mode 100755 index 00000000..951cbd1b --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/bin/console @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "spannerlib/ruby" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +require "irb" +IRB.start(__FILE__) diff --git a/spannerlib/wrappers/spannerlib-ruby/bin/setup b/spannerlib/wrappers/spannerlib-ruby/bin/setup new file mode 100755 index 00000000..dce67d86 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/connection.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/connection.rb new file mode 100644 index 00000000..ea824a7c --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/connection.rb @@ -0,0 +1,106 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require_relative "ffi" + +class Connection + attr_reader :pool_id, :conn_id + + def initialize(pool_id, conn_id) + @pool_id = pool_id + @conn_id = conn_id + end + + # Accepts either an object that responds to `to_proto` or a raw string/bytes + # containing the serialized mutation proto. We avoid requiring the protobuf + # definitions at load time so specs that don't need them can run. + def write_mutations(mutation_group) + req_bytes = if mutation_group.respond_to?(:to_proto) + mutation_group.to_proto + elsif mutation_group.is_a?(String) + mutation_group + else + mutation_group.to_s + end + + SpannerLib.write_mutations(@pool_id, @conn_id, req_bytes) + end + + # Begin a read/write transaction on this connection. Accepts TransactionOptions proto or bytes. + # Returns message bytes (or nil) — higher-level parsing not implemented here. + def begin_transaction(transaction_options = nil) + bytes = if transaction_options.respond_to?(:to_proto) + transaction_options.to_proto + else + transaction_options.is_a?(String) ? transaction_options : transaction_options&.to_s + end + SpannerLib.begin_transaction(@pool_id, @conn_id, bytes) + end + + # Commit the current transaction. Returns CommitResponse bytes or nil. + def commit + SpannerLib.commit(@pool_id, @conn_id) + end + + # Rollback the current transaction. + def rollback + SpannerLib.rollback(@pool_id, @conn_id) + nil + end + + # Execute SQL request (expects a request object with to_proto or raw bytes). Returns message bytes (or nil). + def execute(request) + bytes = if request.respond_to?(:to_proto) + request.to_proto + else + request.is_a?(String) ? request : request.to_s + end + SpannerLib.execute(@pool_id, @conn_id, bytes) + end + + # Execute batch DML/DDL request. Returns ExecuteBatchDmlResponse bytes (or nil). + def execute_batch(request) + bytes = if request.respond_to?(:to_proto) + request.to_proto + else + request.is_a?(String) ? request : request.to_s + end + SpannerLib.execute_batch(@pool_id, @conn_id, bytes) + end + + # Rows helpers — return raw message bytes (caller should parse them). + def metadata(rows_id) + SpannerLib.metadata(@pool_id, @conn_id, rows_id) + end + + def next_rows(rows_id, num_rows, encoding = 0) + SpannerLib.next(@pool_id, @conn_id, rows_id, num_rows, encoding) + end + + def result_set_stats(rows_id) + SpannerLib.result_set_stats(@pool_id, @conn_id, rows_id) + end + + def close_rows(rows_id) + SpannerLib.close_rows(@pool_id, @conn_id, rows_id) + end + + # Closes this connection. Any active transaction on the connection is rolled back. + def close + SpannerLib.close_connection(@pool_id, @conn_id) + nil + end +end diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/exceptions.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/exceptions.rb new file mode 100644 index 00000000..6331652d --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/exceptions.rb @@ -0,0 +1,24 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +class SpannerLibException < StandardError + attr_reader :status + + def initialize(msg = nil, status = nil) + super(msg) + @status = status + end +end diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb new file mode 100644 index 00000000..f01059c8 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb @@ -0,0 +1,249 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +# rubocop:disable Metrics/ModuleLength + +require "rubygems" +require "bundler/setup" + +require "google/protobuf" +require "google/rpc/status_pb" + +require "ffi" + +module SpannerLib + extend FFI::Library + + def self.library_path + lib_dir = File.expand_path(__dir__) + Dir.glob(File.join(lib_dir, "*/spannerlib.#{FFI::Platform::LIBSUFFIX}")).first + end + + ffi_lib library_path + + class GoString < FFI::Struct + layout :p, :pointer, + :len, :long + end + + # GoBytes is the Ruby representation of a Go byte slice + class GoBytes < FFI::Struct + layout :p, :pointer, + :len, :long, + :cap, :long + end + + # Message is the common return type for all native functions. + class Message < FFI::Struct + layout :pinner, :long_long, + :code, :int, + :objectId, :long_long, + :length, :int, + :pointer, :pointer + end + + # --- Native Function Signatures --- + attach_function :CreatePool, [GoString.by_value], Message.by_value + attach_function :ClosePool, [:int64], Message.by_value + attach_function :CreateConnection, [:int64], Message.by_value + attach_function :CloseConnection, %i[int64 int64], Message.by_value + attach_function :WriteMutations, [:int64, :int64, GoBytes.by_value], Message.by_value + attach_function :BeginTransaction, [:int64, :int64, GoBytes.by_value], Message.by_value + attach_function :Commit, %i[int64 int64], Message.by_value + attach_function :Rollback, %i[int64 int64], Message.by_value + attach_function :Execute, [:int64, :int64, GoBytes.by_value], Message.by_value + attach_function :ExecuteBatch, [:int64, :int64, GoBytes.by_value], Message.by_value + attach_function :Metadata, %i[int64 int64 int64], Message.by_value + attach_function :Next, %i[int64 int64 int64 int32 int32], Message.by_value + attach_function :ResultSetStats, %i[int64 int64 int64], Message.by_value + attach_function :CloseRows, %i[int64 int64 int64], Message.by_value + attach_function :Release, [:int64], :void + + # --- Ruby-friendly Wrappers --- + + def self.create_pool(dsn) + dsn_str = dsn.to_s.dup + dsn_ptr = FFI::MemoryPointer.from_string(dsn_str) + + go_dsn = GoString.new + go_dsn[:p] = dsn_ptr + go_dsn[:len] = dsn_str.bytesize + + message = CreatePool(go_dsn) + handle_object_id_response(message, "CreatePool") + end + + def self.close_pool(pool_id) + message = ClosePool(pool_id) + handle_status_response(message, "ClosePool") + end + + def self.create_connection(pool_id) + message = CreateConnection(pool_id) + handle_object_id_response(message, "CreateConnection") + end + + def self.close_connection(pool_id, conn_id) + message = CloseConnection(pool_id, conn_id) + handle_status_response(message, "CloseConnection") + end + + def self.release(pinner) + Release(pinner) + end + + def self.with_gobytes(bytes) + bytes ||= "" + len = bytes.bytesize + ptr = FFI::MemoryPointer.new(len) + ptr.write_bytes(bytes, 0, len) if len.positive? + + go_bytes = GoBytes.new + go_bytes[:p] = ptr + go_bytes[:len] = len + go_bytes[:cap] = len + + yield(go_bytes) + end + + def self.ensure_release(message) + pinner = message[:pinner] + begin + yield + ensure + release(pinner) if pinner != 0 + end + end + + def self.handle_object_id_response(message, func_name) + ensure_release(message) do + if message[:code] != 0 + error_msg = read_error_message(message) + raise "#{func_name} failed with code #{message[:code]}: #{error_msg}" + end + message[:objectId] + end + end + + def self.handle_status_response(message, func_name) + ensure_release(message) do + if message[:code] != 0 + error_msg = read_error_message(message) + raise "#{func_name} failed with code #{message[:code]}: #{error_msg}" + end + end + nil + end + + # rubocop:disable Metrics/MethodLength + def self.handle_data_response(message, func_name) + ensure_release(message) do + if message[:code] != 0 + error_msg = read_error_message(message) + raise "#{func_name} failed with code #{message[:code]}: #{error_msg}" + end + + len = message[:length] + ptr = message[:pointer] + + if len.positive? && !ptr.null? + ptr.read_bytes(len) + else + "" + end + end + end + # rubocop:enable Metrics/MethodLength + + # rubocop:disable Metrics/MethodLength + def self.read_error_message(message) + len = message[:length] + ptr = message[:pointer] + if len.positive? && !ptr.null? + raw_bytes = ptr.read_bytes(len) + begin + status_proto = ::Google::Rpc::Status.decode(raw_bytes) + "Status Proto { code: #{status_proto.code}, message: '#{status_proto.message}' }" + rescue StandardError => e + clean_string = raw_bytes.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?").strip + "Failed to decode Status proto (code #{message[:code]}): #{e.class}: #{e.message} | Raw: #{clean_string}" + end + else + "No error message provided" + end + end + # rubocop:enable Metrics/MethodLength + + def self.write_mutations(pool_id, conn_id, proto_bytes) + with_gobytes(proto_bytes) do |gobytes| + message = WriteMutations(pool_id, conn_id, gobytes) + handle_data_response(message, "WriteMutations") + end + end + + def self.begin_transaction(pool_id, conn_id, proto_bytes) + with_gobytes(proto_bytes) do |gobytes| + message = BeginTransaction(pool_id, conn_id, gobytes) + handle_data_response(message, "BeginTransaction") + end + end + + def self.commit(pool_id, conn_id) + message = Commit(pool_id, conn_id) + handle_data_response(message, "Commit") + end + + def self.rollback(pool_id, conn_id) + message = Rollback(pool_id, conn_id) + handle_status_response(message, "Rollback") + end + + def self.execute(pool_id, conn_id, proto_bytes) + with_gobytes(proto_bytes) do |gobytes| + message = Execute(pool_id, conn_id, gobytes) + handle_object_id_response(message, "Execute") + end + end + + def self.execute_batch(pool_id, conn_id, proto_bytes) + with_gobytes(proto_bytes) do |gobytes| + message = ExecuteBatch(pool_id, conn_id, gobytes) + handle_data_response(message, "ExecuteBatch") + end + end + + def self.metadata(pool_id, conn_id, rows_id) + message = Metadata(pool_id, conn_id, rows_id) + handle_data_response(message, "Metadata") + end + + def self.next(pool_id, conn_id, rows_id, max_rows, fetch_size) + message = Next(pool_id, conn_id, rows_id, max_rows, fetch_size) + handle_data_response(message, "Next") + end + + def self.result_set_stats(pool_id, conn_id, rows_id) + message = ResultSetStats(pool_id, conn_id, rows_id) + handle_data_response(message, "ResultSetStats") + end + + def self.close_rows(pool_id, conn_id, rows_id) + message = CloseRows(pool_id, conn_id, rows_id) + handle_status_response(message, "CloseRows") + end +end + +# rubocop:enable Metrics/ModuleLength diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/pool.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/pool.rb new file mode 100644 index 00000000..33f7065a --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/pool.rb @@ -0,0 +1,63 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require_relative "ffi" +require_relative "connection" + +class Pool + attr_reader :id + + def initialize(id) + @id = id + @closed = false + end + + # Create a new Pool given a DSN string. Raises SpannerLibException on failure. + def self.create_pool(dsn) + begin + pool_id = SpannerLib.create_pool(dsn) + rescue StandardError => e + raise SpannerLibException, e.message + end + + raise SpannerLibException, "failed to create pool" if pool_id.nil? || pool_id <= 0 + + Pool.new(pool_id) + end + + # Close this pool and free native resources. + def close + return if @closed + + SpannerLib.close_pool(@id) + @closed = true + end + + # Create a new Connection associated with this Pool. + def create_connection + raise SpannerLibException, "pool closed" if @closed + + begin + conn_id = SpannerLib.create_connection(@id) + rescue StandardError => e + raise SpannerLibException, e.message + end + + raise SpannerLibException, "failed to create connection" if conn_id.nil? || conn_id <= 0 + + Connection.new(@id, conn_id) + end +end diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ruby.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ruby.rb new file mode 100644 index 00000000..d206682b --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ruby.rb @@ -0,0 +1,24 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require_relative "ruby/version" + +module Spannerlib + module Ruby + class Error < StandardError; end + # Your code goes here... + end +end diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ruby/version.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ruby/version.rb new file mode 100644 index 00000000..100ee429 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ruby/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Spannerlib + module Ruby + VERSION = "0.1.0" + end +end diff --git a/spannerlib/wrappers/spannerlib-ruby/sig/spannerlib/ruby.rbs b/spannerlib/wrappers/spannerlib-ruby/sig/spannerlib/ruby.rbs new file mode 100644 index 00000000..e76e73dd --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/sig/spannerlib/ruby.rbs @@ -0,0 +1,6 @@ +module Spannerlib + module Ruby + VERSION: String + # See the writing guide of rbs: https://github.com/ruby/rbs#guides + end +end diff --git a/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec b/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec new file mode 100644 index 00000000..4f746b3d --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "lib/spannerlib/ruby/version" + +Gem::Specification.new do |spec| + spec.name = "spannerlib-ruby" + spec.version = Spannerlib::Ruby::VERSION + spec.authors = ["Google LLC"] + spec.email = ["cloud-spanner-developers@googlegroups.com"] + + spec.summary = "Ruby wrapper for the Spanner native library" + spec.description = "Lightweight Ruby FFI bindings for the Spanner native library produced from the Go implementation." + spec.homepage = "https://github.com/googleapis/go-sql-spanner/tree/main/spannerlib/wrappers/spannerlib-ruby" + spec.license = "Apache 2.0 License" + spec.required_ruby_version = ">= 3.1.0" + + spec.metadata["rubygems_mfa_required"] = "true" + + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency "ffi" + spec.add_dependency "google-cloud-spanner-v1", "~> 1.7" + spec.add_dependency "google-protobuf", "~> 3.19" + spec.add_dependency "grpc", "~> 1.60" +end diff --git a/spannerlib/wrappers/spannerlib-ruby/spec/integration/connection_emulator_spec.rb b/spannerlib/wrappers/spannerlib-ruby/spec/integration/connection_emulator_spec.rb new file mode 100644 index 00000000..1149b6a3 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/spec/integration/connection_emulator_spec.rb @@ -0,0 +1,165 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require "spec_helper" +require "google/cloud/spanner/v1" + +RSpec.describe "Connection APIs against Spanner emulator", :integration do + before(:all) do + @emulator_host = ENV.fetch("SPANNER_EMULATOR_HOST", nil) + skip "SPANNER_EMULATOR_HOST not set" unless @emulator_host && !@emulator_host.empty? + + require "spannerlib/pool" + @dsn = "projects/your-project-id/instances/test-instance/databases/test-database?autoConfigEmulator=true" + + pool = Pool.create_pool(@dsn) + conn = pool.create_connection + ddl_batch_req = Google::Cloud::Spanner::V1::ExecuteBatchDmlRequest.new( + statements: [ + Google::Cloud::Spanner::V1::ExecuteBatchDmlRequest::Statement.new(sql: "DROP TABLE IF EXISTS test_table"), + Google::Cloud::Spanner::V1::ExecuteBatchDmlRequest::Statement.new( + sql: "CREATE TABLE test_table (id INT64 NOT NULL, name STRING(100)) PRIMARY KEY(id)" + ) + ] + ) + conn.execute_batch(ddl_batch_req) + conn.close + pool.close + end + + before do + @pool = Pool.create_pool(@dsn) + @conn = @pool.create_connection + delete_req = Google::Cloud::Spanner::V1::BatchWriteRequest::MutationGroup.new( + mutations: [ + Google::Cloud::Spanner::V1::Mutation.new( + delete: Google::Cloud::Spanner::V1::Mutation::Delete.new( + table: "test_table", + key_set: Google::Cloud::Spanner::V1::KeySet.new(all: true) + ) + ) + ] + ) + @conn.write_mutations(delete_req) + end + + after do + @conn.close + @pool.close + end + + it "creates a connection pool" do + expect(@pool.id).to be > 0 + expect(@conn.conn_id).to be > 0 + end + + it "creates two connections from the same pool" do + conn2 = @pool.create_connection + expect(@conn.conn_id).not_to eq(conn2.conn_id) + expect(conn2.conn_id).to be > 0 + conn2.close + end + + it "writes and reads data in a read-write transaction" do + @conn.begin_transaction + insert_data_req = Google::Cloud::Spanner::V1::BatchWriteRequest::MutationGroup.new( + mutations: [ + Google::Cloud::Spanner::V1::Mutation.new( + insert: Google::Cloud::Spanner::V1::Mutation::Write.new( + table: "test_table", + columns: %w[id name], + values: [ + Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "1"), + Google::Protobuf::Value.new(string_value: "Alice")]), + Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "2"), + Google::Protobuf::Value.new(string_value: "Bob")]) + ] + ) + ) + ] + ) + @conn.write_mutations(insert_data_req) + @conn.commit + + select_req = Google::Cloud::Spanner::V1::ExecuteSqlRequest.new(sql: "SELECT id, name FROM test_table ORDER BY id") + rows_id = @conn.execute(select_req) + + all_rows = [] + loop do + row_bytes = @conn.next_rows(rows_id, 1) + break if row_bytes.nil? || row_bytes.empty? + + all_rows << Google::Protobuf::ListValue.decode(row_bytes) + end + @conn.close_rows(rows_id) + + expect(all_rows.length).to eq(2) + expect(all_rows[0].values[1].string_value).to eq("Alice") + end + + it "writes and reads data without an explicit transaction (autocommit)" do + insert_data_req = Google::Cloud::Spanner::V1::BatchWriteRequest::MutationGroup.new( + mutations: [ + Google::Cloud::Spanner::V1::Mutation.new( + insert: Google::Cloud::Spanner::V1::Mutation::Write.new( + table: "test_table", + columns: %w[id name], + values: [ + Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "3"), + Google::Protobuf::Value.new(string_value: "Charlie")]), + Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "4"), + Google::Protobuf::Value.new(string_value: "David")]) + ] + ) + ) + ] + ) + @conn.write_mutations(insert_data_req) + + select_req = Google::Cloud::Spanner::V1::ExecuteSqlRequest.new(sql: "SELECT id, name FROM test_table ORDER BY id") + rows_id = @conn.execute(select_req) + + all_rows = [] + loop do + row_bytes = @conn.next_rows(rows_id, 1) + break if row_bytes.nil? || row_bytes.empty? + + all_rows << Google::Protobuf::ListValue.decode(row_bytes) + end + @conn.close_rows(rows_id) + + expect(all_rows.length).to eq(2) + expect(all_rows[0].values[1].string_value).to eq("Charlie") + end + + it "raises an error when writing in a read-only transaction" do + transaction_options = Google::Cloud::Spanner::V1::TransactionOptions.new( + read_only: Google::Cloud::Spanner::V1::TransactionOptions::ReadOnly.new(strong: true) + ) + @conn.begin_transaction(transaction_options) + + insert_data_req = Google::Cloud::Spanner::V1::BatchWriteRequest::MutationGroup.new( + mutations: [ + Google::Cloud::Spanner::V1::Mutation.new( + insert: Google::Cloud::Spanner::V1::Mutation::Write.new(table: "test_table") + ) + ] + ) + expect { @conn.write_mutations(insert_data_req) }.to raise_error(RuntimeError, /read-only transactions cannot write/) + + @conn.rollback + end +end diff --git a/spannerlib/wrappers/spannerlib-ruby/spec/integration/pool_emulator_spec.rb b/spannerlib/wrappers/spannerlib-ruby/spec/integration/pool_emulator_spec.rb new file mode 100644 index 00000000..4f7c0911 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/spec/integration/pool_emulator_spec.rb @@ -0,0 +1,42 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Connection APIs against Spanner emulator", :integration do + before(:all) do + @emulator_host = ENV.fetch("SPANNER_EMULATOR_HOST", nil) + skip "SPANNER_EMULATOR_HOST not set; skipping emulator integration tests" unless @emulator_host && !@emulator_host.empty? + + begin + require "spannerlib/pool" + rescue LoadError, StandardError => e + skip "Could not load native spanner library; skipping emulator integration tests: #{e.class}: #{e.message}" + end + @dsn = "projects/your-project-id/instances/test-instance/databases/test-database?autoConfigEmulator=true" + end + + it "creates a pool and a connection against the emulator" do + pool = Pool.create_pool(@dsn) + expect(pool.id).to be > 0 + + conn = pool.create_connection + expect(conn).to respond_to(:pool_id) + expect(conn).to respond_to(:conn_id) + + expect { pool.close }.not_to raise_error + end +end diff --git a/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/connection_spec.rb b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/connection_spec.rb new file mode 100644 index 00000000..a9d92cbb --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/connection_spec.rb @@ -0,0 +1,53 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language gove + +# frozen_string_literal: true + +require "spec_helper" +require "spannerlib/pool" +require "spannerlib/ffi" +require "spannerlib/exceptions" + +RSpec.describe Connection do + let(:dsn) { "localhost:1234/projects/p/instances/i/databases/d?usePlainText=true" } + let(:pool) { Pool.create_pool(dsn) } + + before do + allow(SpannerLib).to receive(:create_pool).and_return(1) + end + + describe "creation" do + it "is created by a Pool" do + allow(SpannerLib).to receive(:create_connection).with(1).and_return(2) + + # The object under test is the one returned by `pool.create_connection` + conn = pool.create_connection + + expect(conn).to be_a(Connection) + expect(conn.conn_id).to eq(2) + expect(conn.pool_id).to eq(1) + end + + it "raises a SpannerLibException when the FFI call fails" do + allow(SpannerLib).to receive(:create_connection).with(1).and_raise(StandardError.new("boom")) + + expect { pool.create_connection }.to raise_error(SpannerLibException) + end + + it "raises when the FFI call returns a non-positive id" do + allow(SpannerLib).to receive(:create_connection).with(1).and_return(0) + + expect { pool.create_connection }.to raise_error(SpannerLibException) + end + end +end diff --git a/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/pool_spec.rb b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/pool_spec.rb new file mode 100644 index 00000000..f6d6a373 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/pool_spec.rb @@ -0,0 +1,53 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require "spec_helper" +require "spannerlib/pool" +require "spannerlib/ffi" +require "spannerlib/exceptions" + +RSpec.describe Pool do + let(:dsn) { "localhost:1234/projects/p/instances/i/databases/d?usePlainText=true" } + + describe ".create_pool" do + it "creates a pool and returns an object with id > 0" do + allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(42) + + pool = described_class.create_pool(dsn) + + expect(pool).to be_a(described_class) + expect(pool.id).to be > 0 + end + + it "raises a SpannerLibException when create_session/create_pool fails" do + allow(SpannerLib).to receive(:create_pool).with(dsn).and_raise(StandardError.new("Not allowed")) + + expect { described_class.create_pool(dsn) }.to raise_error(SpannerLibException) + end + + it "raises when create_pool returns nil" do + allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(nil) + + expect { described_class.create_pool(dsn) }.to raise_error(SpannerLibException) + end + + it "raises when create_pool returns a non-positive id" do + allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(0) + + expect { described_class.create_pool(dsn) }.to raise_error(SpannerLibException) + end + end +end diff --git a/spannerlib/wrappers/spannerlib-ruby/spec/spec_helper.rb b/spannerlib/wrappers/spannerlib-ruby/spec/spec_helper.rb new file mode 100644 index 00000000..514ba076 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/spec/spec_helper.rb @@ -0,0 +1,32 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require "rubygems" +require "bundler/setup" + +require "spannerlib/ruby" + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end