From 27a2bf4a21260e05067109705539e69a18cad7da Mon Sep 17 00:00:00 2001 From: Akash Anand Date: Wed, 1 Oct 2025 10:03:53 +0000 Subject: [PATCH 1/4] feat(ruby): add Ruby FFI wrapper for spannerlib --- spannerlib/.gitignore | 5 + .../.github/workflows/main.yml | 27 +++ .../wrappers/spannerlib-ruby/.gitignore | 16 ++ spannerlib/wrappers/spannerlib-ruby/.rspec | 3 + .../wrappers/spannerlib-ruby/.rubocop.yml | 8 + spannerlib/wrappers/spannerlib-ruby/Gemfile | 20 ++ spannerlib/wrappers/spannerlib-ruby/Rakefile | 12 + .../wrappers/spannerlib-ruby/bin/console | 11 + spannerlib/wrappers/spannerlib-ruby/bin/setup | 8 + .../spannerlib-ruby/lib/spanner_pb.rb | 41 ++++ .../lib/spannerlib/connection.rb | 82 +++++++ .../lib/spannerlib/exceptions.rb | 8 + .../spannerlib-ruby/lib/spannerlib/ffi.rb | 224 ++++++++++++++++++ .../spannerlib-ruby/lib/spannerlib/pool.rb | 52 ++++ .../spannerlib-ruby/lib/spannerlib/ruby.rb | 10 + .../lib/spannerlib/ruby/version.rb | 7 + .../spannerlib-ruby/sig/spannerlib/ruby.rbs | 6 + .../spannerlib-ruby/spannerlib-ruby.gemspec | 47 ++++ .../integration/connection_emulator_spec.rb | 206 ++++++++++++++++ .../spec/integration/pool_emulator_spec.rb | 32 +++ .../spec/spannerlib/connection_spec.rb | 38 +++ .../spec/spannerlib/pool_spec.rb | 39 +++ .../spec/spannerlib/ruby_spec.rb | 11 + .../spannerlib-ruby/spec/spec_helper.rb | 18 ++ 24 files changed, 931 insertions(+) create mode 100644 spannerlib/wrappers/spannerlib-ruby/.github/workflows/main.yml create mode 100644 spannerlib/wrappers/spannerlib-ruby/.gitignore create mode 100644 spannerlib/wrappers/spannerlib-ruby/.rspec create mode 100644 spannerlib/wrappers/spannerlib-ruby/.rubocop.yml create mode 100644 spannerlib/wrappers/spannerlib-ruby/Gemfile create mode 100644 spannerlib/wrappers/spannerlib-ruby/Rakefile create mode 100755 spannerlib/wrappers/spannerlib-ruby/bin/console create mode 100755 spannerlib/wrappers/spannerlib-ruby/bin/setup create mode 100644 spannerlib/wrappers/spannerlib-ruby/lib/spanner_pb.rb create mode 100644 spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/connection.rb create mode 100644 spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/exceptions.rb create mode 100644 spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb create mode 100644 spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/pool.rb create mode 100644 spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ruby.rb create mode 100644 spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ruby/version.rb create mode 100644 spannerlib/wrappers/spannerlib-ruby/sig/spannerlib/ruby.rbs create mode 100644 spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec create mode 100644 spannerlib/wrappers/spannerlib-ruby/spec/integration/connection_emulator_spec.rb create mode 100644 spannerlib/wrappers/spannerlib-ruby/spec/integration/pool_emulator_spec.rb create mode 100644 spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/connection_spec.rb create mode 100644 spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/pool_spec.rb create mode 100644 spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/ruby_spec.rb create mode 100644 spannerlib/wrappers/spannerlib-ruby/spec/spec_helper.rb diff --git a/spannerlib/.gitignore b/spannerlib/.gitignore index f6dbcd3d..29831fd5 100644 --- a/spannerlib/.gitignore +++ b/spannerlib/.gitignore @@ -1,3 +1,8 @@ spannerlib.h spannerlib.so grpc_server +vendor/bundle +shared/ +*.gem +.DS_Store +*.swp \ No newline at end of file diff --git a/spannerlib/wrappers/spannerlib-ruby/.github/workflows/main.yml b/spannerlib/wrappers/spannerlib-ruby/.github/workflows/main.yml new file mode 100644 index 00000000..c87a3444 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/.github/workflows/main.yml @@ -0,0 +1,27 @@ +name: Ruby + +on: + push: + branches: + - main + + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + name: Ruby ${{ matrix.ruby }} + strategy: + matrix: + ruby: + - '3.3.1' + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run the default task + run: bundle exec rake 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..537f3da0 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/.rubocop.yml @@ -0,0 +1,8 @@ +AllCops: + TargetRubyVersion: 3.1 + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + EnforcedStyle: double_quotes diff --git a/spannerlib/wrappers/spannerlib-ruby/Gemfile b/spannerlib/wrappers/spannerlib-ruby/Gemfile new file mode 100644 index 00000000..f0e22000 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/Gemfile @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in spannerlib-ruby.gemspec +gemspec + +gem "irb" +gem "rake", "~> 13.0" + +gem "rspec", "~> 3.0" + +gem "rubocop", "~> 1.21" + +gem 'ffi' +gem 'google-protobuf', '~> 3.19' +gem 'grpc', '~> 1.60' +gem 'googleapis-common-protos', '~> 1.0' + +gem 'google-cloud-spanner' \ No newline at end of file diff --git a/spannerlib/wrappers/spannerlib-ruby/Rakefile b/spannerlib/wrappers/spannerlib-ruby/Rakefile new file mode 100644 index 00000000..cca71754 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +require "rubocop/rake_task" + +RuboCop::RakeTask.new + +task default: %i[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/spanner_pb.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spanner_pb.rb new file mode 100644 index 00000000..007745d7 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spanner_pb.rb @@ -0,0 +1,41 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: spanner.proto + +require 'google/protobuf' + +Google::Protobuf::DescriptorPool.generated_pool.build do + add_file("spanner.proto", :syntax => :proto3) do + add_message "spanner_bench.Singer" do + optional :id, :int64, 1 + optional :first_name, :string, 2 + optional :last_name, :string, 3 + optional :singer_info, :string, 4 + end + add_message "spanner_bench.Album" do + optional :id, :int64, 1 + optional :singer_id, :int64, 2 + optional :album_title, :string, 3 + end + add_message "spanner_bench.ReadQuery" do + optional :query, :string, 1 + end + add_message "spanner_bench.InsertQuery" do + repeated :singers, :message, 1, "spanner_bench.Singer" + repeated :albums, :message, 2, "spanner_bench.Album" + end + add_message "spanner_bench.UpdateQuery" do + repeated :queries, :string, 1 + end + add_message "spanner_bench.EmptyResponse" do + end + end +end + +module SpannerBench + Singer = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spanner_bench.Singer").msgclass + Album = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spanner_bench.Album").msgclass + ReadQuery = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spanner_bench.ReadQuery").msgclass + InsertQuery = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spanner_bench.InsertQuery").msgclass + UpdateQuery = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spanner_bench.UpdateQuery").msgclass + EmptyResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spanner_bench.EmptyResponse").msgclass +end 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..2f26dfd4 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/connection.rb @@ -0,0 +1,82 @@ +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 && 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 = request.respond_to?(:to_proto) ? request.to_proto : request.is_a?(String) ? request : request.to_s + SpannerLib.execute(@pool_id, @conn_id, bytes) + end + + # Execute batch DML/DDL request. Returns ExecuteBatchDmlResponse bytes (or nil). + def execute_batch(request) + bytes = request.respond_to?(:to_proto) ? request.to_proto : request.is_a?(String) ? request : request.to_s + 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 \ No newline at end of file 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..130d354d --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/exceptions.rb @@ -0,0 +1,8 @@ +class SpannerLibException < StandardError + attr_reader :status + + def initialize(msg = nil, status = nil) + super(msg) + @status = status + end +end \ No newline at end of file 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..eea64ff1 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb @@ -0,0 +1,224 @@ +require 'rubygems' +require 'bundler/setup' + +require 'google/protobuf' +require 'google/rpc/status_pb' + +require 'ffi' + +module SpannerLib + extend FFI::Library + ffi_lib File.expand_path('../../shared/spannerlib.so', __dir__) + + 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, [: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, [:int64, :int64], Message.by_value + attach_function :Rollback, [: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, [:int64, :int64, :int64], Message.by_value + attach_function :Next, [:int64, :int64, :int64, :int32, :int32], Message.by_value + attach_function :ResultSetStats, [:int64, :int64, :int64], Message.by_value + attach_function :CloseRows, [: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") + nil + 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") + nil + 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 > 0 + + 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) + puts "--- #{func_name} response: code=#{message[:code]}, objectId=#{message[:objectId]}, length=#{message[:length]} ---" + ensure_release(message) do + puts "Message received from #{func_name}: code=#{message[:code]}, objectId=#{message[:objectId]}, length=#{message[:length]}" + 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 + end + + 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 > 0 && !ptr.null? + ptr.read_bytes(len) + else + "" + end + end + end + + def self.read_error_message(message) + len = message[:length] + ptr = message[:pointer] + if len > 0 && !ptr.null? + raw_bytes = ptr.read_bytes(len) + begin + status_proto = ::Google::Rpc::Status.decode(raw_bytes) + return "Status Proto { code: #{status_proto.code}, message: '#{status_proto.message}' }" + rescue => e + clean_string = raw_bytes.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?').strip + return "Failed to decode Status proto (code #{message[:code]}): #{e.class}: #{e.message} | Raw: #{clean_string}" + end + else + "No error message provided" + end + end + + 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") + nil + 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") + nil + end +end \ No newline at end of file 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..2415443f --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/pool.rb @@ -0,0 +1,52 @@ +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 => e + raise SpannerLibException.new(e.message) + end + + if pool_id.nil? || pool_id <= 0 + raise SpannerLibException.new('failed to create pool') + end + + 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.new('pool closed') if @closed + begin + conn_id = SpannerLib.create_connection(@id) + rescue => e + raise SpannerLibException.new(e.message) + end + + if conn_id.nil? || conn_id <= 0 + raise SpannerLibException.new('failed to create connection') + end + + 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..772c70b8 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ruby.rb @@ -0,0 +1,10 @@ +# 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..1dac58b7 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec @@ -0,0 +1,47 @@ +# 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 = ["Spannerlib Contributors"] + spec.email = ["spannerlib@example.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." + # Use an example homepage for local builds; replace with your project's URL. + spec.homepage = "https://example.com/spannerlib-ruby" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.1.0" + + spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" + + # Provide minimal valid URIs so `gem build` passes metadata validation. Replace + # these with the project's real URLs before publishing. + spec.metadata["homepage_uri"] = spec.homepage || "https://example.com" + spec.metadata["source_code_uri"] = "https://example.com/source" + spec.metadata["changelog_uri"] = "https://example.com/CHANGELOG.md" + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + gemspec = File.basename(__FILE__) + spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| + ls.readlines("\x0", chomp: true).reject do |f| + (f == gemspec) || + f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) + end + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + # Uncomment to register a new dependency of your gem + # spec.add_dependency "example-gem", "~> 1.0" + + spec.add_dependency "google-cloud-spanner", "~> 2.25" + spec.add_dependency "google-cloud-spanner-v1", "~> 1.7" + + # For more information and examples about making a new gem, check out our + # guide at: https://bundler.io/guides/creating_gem.html +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..677f2ec8 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/spec/integration/connection_emulator_spec.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require "spec_helper" +require 'google/cloud/spanner' + +RSpec.describe "Connection APIs against Spanner emulator", :integration do + before(:all) do + @emulator_host = ENV["SPANNER_EMULATOR_HOST"] + unless @emulator_host && !@emulator_host.empty? + skip "SPANNER_EMULATOR_HOST not set; skipping emulator integration tests" + end + + require "spannerlib/pool" + @dsn = "projects/your-project-id/instances/test-instance/databases/test-database?autoConfigEmulator=true" + end + + it "test creation of connection pool" do + pool = Pool.create_pool(@dsn) + conn = pool.create_connection + + expect(conn).not_to be_nil + expect(conn.conn_id).to be > 0 + + conn.close + pool.close + end + + it "test two connections from same pool" do + pool = Pool.create_pool(@dsn) + conn1 = pool.create_connection + conn2 = pool.create_connection + + expect(conn1.conn_id).not_to eq(conn2.conn_id) + expect(conn1.conn_id).to be > 0 + expect(conn2.conn_id).to be > 0 + + conn1.close + conn2.close + pool.close + end + + it "test write mutations with read-write transactions" do + 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.begin_transaction + + insert_data_req = Google::Spanner::V1::BatchWriteRequest::MutationGroup.new( + mutations: [ + Google::Spanner::V1::Mutation.new( + insert: Google::Spanner::V1::Mutation::Write.new( + table: "test_table", + columns: ["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) + expect(rows_id).to be > 0 + + 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[0].string_value).to eq("1") + expect(all_rows[0].values[1].string_value).to eq("Alice") + + expect(all_rows[1].values[0].string_value).to eq("2") + expect(all_rows[1].values[1].string_value).to eq("Bob") + + conn.close + pool.close + end + + it "test write mutations without transactions" do + 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_1" + ), + Google::Cloud::Spanner::V1::ExecuteBatchDmlRequest::Statement.new( + sql: "CREATE TABLE test_table_1 (id INT64 NOT NULL, name STRING(100)) PRIMARY KEY(id)" + ) + ] + ) + conn.execute_batch(ddl_batch_req) + + insert_data_req = Google::Spanner::V1::BatchWriteRequest::MutationGroup.new( + mutations: [ + Google::Spanner::V1::Mutation.new( + insert: Google::Spanner::V1::Mutation::Write.new( + table: "test_table_1", + columns: ["id", "name"], + values: [ + Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "1"), Google::Protobuf::Value.new(string_value: "Charlie")]), + Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "2"), 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_1 ORDER BY id" + ) + rows_id = conn.execute(select_req) + expect(rows_id).to be > 0 + + 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") + expect(all_rows[1].values[1].string_value).to eq("David") + + conn.close + pool.close + end + + it "raises an error when writing mutations in a read-only transaction" do + + 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_2" + ), + Google::Cloud::Spanner::V1::ExecuteBatchDmlRequest::Statement.new( + sql: "CREATE TABLE test_table_2 (id INT64 NOT NULL, name STRING(100)) PRIMARY KEY(id)" + ) + ] + ) + conn.execute_batch(ddl_batch_req) + + transaction_options = Google::Spanner::V1::TransactionOptions.new( + read_only: Google::Spanner::V1::TransactionOptions::ReadOnly.new( + strong: true + ) + ) + conn.begin_transaction(transaction_options) + + insert_data_req = Google::Spanner::V1::BatchWriteRequest::MutationGroup.new( + mutations: [ + Google::Spanner::V1::Mutation.new( + insert: Google::Spanner::V1::Mutation::Write.new( + table: "test_table_2", + columns: ["id", "name"], + values: [ + Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "1"), Google::Protobuf::Value.new(string_value: "Charlie")]), + Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "2"), Google::Protobuf::Value.new(string_value: "David")]) + ] + ) + ) + ] + ) + + expect { + conn.write_mutations(insert_data_req) + }.to raise_error(RuntimeError, /read-only transactions cannot write/) + + conn.rollback + conn.close + pool.close + end +end \ No newline at end of file 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..4875985a --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/spec/integration/pool_emulator_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Pool against Spanner emulator", :integration do + before(:all) do + @emulator_host = ENV["SPANNER_EMULATOR_HOST"] + unless @emulator_host && !@emulator_host.empty? + skip "SPANNER_EMULATOR_HOST not set; skipping emulator integration tests" + end + + 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).to be_a(Pool) + 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..b95935aa --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/connection_spec.rb @@ -0,0 +1,38 @@ +# 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_connection" do + it "creates a Connection associated with this Pool" do + allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(1) + expect(SpannerLib).to receive(:create_connection).with(1).and_return(2) + + pool = Pool.create_pool(dsn) + conn = pool.create_connection + + expect(conn).to be_a(Connection) + end + + it "raises a SpannerLibException when create_connection fails" do + allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(1) + allow(SpannerLib).to receive(:create_connection).with(1).and_raise(StandardError.new("boom")) + + pool = Pool.create_pool(dsn) + expect { pool.create_connection }.to raise_error(SpannerLibException) + end + + it "raises when create_connection returns nil or non-positive id" do + allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(1) + allow(SpannerLib).to receive(:create_connection).with(1).and_return(nil) + + pool = Pool.create_pool(dsn) + expect { pool.create_connection }.to raise_error(SpannerLibException) + end + end +end \ No newline at end of file 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..c851eac8 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/pool_spec.rb @@ -0,0 +1,39 @@ +# 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 = Pool.create_pool(dsn) + + expect(pool).to be_a(Pool) + 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 { Pool.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 { Pool.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 { Pool.create_pool(dsn) }.to raise_error(SpannerLibException) + end + end +end \ No newline at end of file diff --git a/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/ruby_spec.rb b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/ruby_spec.rb new file mode 100644 index 00000000..18ad9e39 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/ruby_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.describe Spannerlib::Ruby do + it "has a version number" do + expect(Spannerlib::Ruby::VERSION).not_to be nil + end + + it "does something useful" do + expect(false).to eq(true) + 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..32bf5294 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-ruby/spec/spec_helper.rb @@ -0,0 +1,18 @@ +# 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 From 12bc1bb60007ee467dd532c4f5ae2994fbb3688b Mon Sep 17 00:00:00 2001 From: Akash Anand Date: Wed, 1 Oct 2025 14:39:34 +0000 Subject: [PATCH 2/4] Apply RuboCop fixes --- .../wrappers/spannerlib-ruby/.rubocop.yml | 32 +++++- spannerlib/wrappers/spannerlib-ruby/Gemfile | 18 ++-- .../spannerlib-ruby/lib/spanner_pb.rb | 32 +++--- .../lib/spannerlib/connection.rb | 20 +++- .../lib/spannerlib/exceptions.rb | 4 +- .../spannerlib-ruby/lib/spannerlib/ffi.rb | 97 ++++++++++--------- .../spannerlib-ruby/lib/spannerlib/pool.rb | 29 +++--- .../spannerlib-ruby/spannerlib-ruby.gemspec | 3 +- .../integration/connection_emulator_spec.rb | 92 +++++++++--------- .../spec/integration/pool_emulator_spec.rb | 13 +-- .../spec/spannerlib/connection_spec.rb | 48 ++++----- .../spec/spannerlib/pool_spec.rb | 44 ++++----- .../spec/spannerlib/ruby_spec.rb | 11 --- .../spannerlib-ruby/spec/spec_helper.rb | 2 +- 14 files changed, 241 insertions(+), 204 deletions(-) delete mode 100644 spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/ruby_spec.rb diff --git a/spannerlib/wrappers/spannerlib-ruby/.rubocop.yml b/spannerlib/wrappers/spannerlib-ruby/.rubocop.yml index 537f3da0..40dda27b 100644 --- a/spannerlib/wrappers/spannerlib-ruby/.rubocop.yml +++ b/spannerlib/wrappers/spannerlib-ruby/.rubocop.yml @@ -1,8 +1,32 @@ AllCops: - TargetRubyVersion: 3.1 + NewCops: enable + SuggestExtensions: false + Exclude: + - 'lib/spanner_pb.rb' + - 'vendor/**/*' -Style/StringLiterals: - EnforcedStyle: double_quotes +plugins: + - rubocop-rspec + +Layout/LineLength: + Max: 150 + +Style/Documentation: + Enabled: false -Style/StringLiteralsInInterpolation: +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 index f0e22000..e284b010 100644 --- a/spannerlib/wrappers/spannerlib-ruby/Gemfile +++ b/spannerlib/wrappers/spannerlib-ruby/Gemfile @@ -8,13 +8,15 @@ gemspec gem "irb" gem "rake", "~> 13.0" -gem "rspec", "~> 3.0" +gem "ffi" +gem "googleapis-common-protos", "~> 1.0" +gem "google-protobuf", "~> 3.19" +gem "grpc", "~> 1.60" -gem "rubocop", "~> 1.21" +gem "google-cloud-spanner" -gem 'ffi' -gem 'google-protobuf', '~> 3.19' -gem 'grpc', '~> 1.60' -gem 'googleapis-common-protos', '~> 1.0' - -gem 'google-cloud-spanner' \ No newline at end of file +group :development, :test do + gem "rspec", "~> 3.0" + gem "rubocop", require: false + gem "rubocop-rspec", require: false +end diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spanner_pb.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spanner_pb.rb index 007745d7..e7cd61bc 100644 --- a/spannerlib/wrappers/spannerlib-ruby/lib/spanner_pb.rb +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spanner_pb.rb @@ -1,41 +1,43 @@ +# frozen_string_literal: true + # Generated by the protocol buffer compiler. DO NOT EDIT! # source: spanner.proto require 'google/protobuf' Google::Protobuf::DescriptorPool.generated_pool.build do - add_file("spanner.proto", :syntax => :proto3) do - add_message "spanner_bench.Singer" do + add_file('spanner.proto', syntax: :proto3) do + add_message 'spanner_bench.Singer' do optional :id, :int64, 1 optional :first_name, :string, 2 optional :last_name, :string, 3 optional :singer_info, :string, 4 end - add_message "spanner_bench.Album" do + add_message 'spanner_bench.Album' do optional :id, :int64, 1 optional :singer_id, :int64, 2 optional :album_title, :string, 3 end - add_message "spanner_bench.ReadQuery" do + add_message 'spanner_bench.ReadQuery' do optional :query, :string, 1 end - add_message "spanner_bench.InsertQuery" do - repeated :singers, :message, 1, "spanner_bench.Singer" - repeated :albums, :message, 2, "spanner_bench.Album" + add_message 'spanner_bench.InsertQuery' do + repeated :singers, :message, 1, 'spanner_bench.Singer' + repeated :albums, :message, 2, 'spanner_bench.Album' end - add_message "spanner_bench.UpdateQuery" do + add_message 'spanner_bench.UpdateQuery' do repeated :queries, :string, 1 end - add_message "spanner_bench.EmptyResponse" do + add_message 'spanner_bench.EmptyResponse' do end end end module SpannerBench - Singer = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spanner_bench.Singer").msgclass - Album = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spanner_bench.Album").msgclass - ReadQuery = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spanner_bench.ReadQuery").msgclass - InsertQuery = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spanner_bench.InsertQuery").msgclass - UpdateQuery = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spanner_bench.UpdateQuery").msgclass - EmptyResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("spanner_bench.EmptyResponse").msgclass + Singer = ::Google::Protobuf::DescriptorPool.generated_pool.lookup('spanner_bench.Singer').msgclass + Album = ::Google::Protobuf::DescriptorPool.generated_pool.lookup('spanner_bench.Album').msgclass + ReadQuery = ::Google::Protobuf::DescriptorPool.generated_pool.lookup('spanner_bench.ReadQuery').msgclass + InsertQuery = ::Google::Protobuf::DescriptorPool.generated_pool.lookup('spanner_bench.InsertQuery').msgclass + UpdateQuery = ::Google::Protobuf::DescriptorPool.generated_pool.lookup('spanner_bench.UpdateQuery').msgclass + EmptyResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup('spanner_bench.EmptyResponse').msgclass end diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/connection.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/connection.rb index 2f26dfd4..8dddb603 100644 --- a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/connection.rb +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/connection.rb @@ -1,4 +1,6 @@ -require_relative 'ffi' +# frozen_string_literal: true + +require_relative "ffi" class Connection attr_reader :pool_id, :conn_id @@ -29,7 +31,7 @@ def begin_transaction(transaction_options = nil) bytes = if transaction_options.respond_to?(:to_proto) transaction_options.to_proto else - transaction_options && transaction_options.is_a?(String) ? transaction_options : transaction_options&.to_s + transaction_options.is_a?(String) ? transaction_options : transaction_options&.to_s end SpannerLib.begin_transaction(@pool_id, @conn_id, bytes) end @@ -47,13 +49,21 @@ def rollback # Execute SQL request (expects a request object with to_proto or raw bytes). Returns message bytes (or nil). def execute(request) - bytes = request.respond_to?(:to_proto) ? request.to_proto : request.is_a?(String) ? request : request.to_s + 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 = request.respond_to?(:to_proto) ? request.to_proto : request.is_a?(String) ? request : request.to_s + 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 @@ -79,4 +89,4 @@ def close SpannerLib.close_connection(@pool_id, @conn_id) nil end -end \ No newline at end of file +end diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/exceptions.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/exceptions.rb index 130d354d..cc1e28a3 100644 --- a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/exceptions.rb +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/exceptions.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class SpannerLibException < StandardError attr_reader :status @@ -5,4 +7,4 @@ def initialize(msg = nil, status = nil) super(msg) @status = status end -end \ No newline at end of file +end diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb index eea64ff1..2339c842 100644 --- a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb @@ -1,21 +1,26 @@ -require 'rubygems' -require 'bundler/setup' +# frozen_string_literal: true -require 'google/protobuf' -require 'google/rpc/status_pb' +# rubocop:disable Metrics/ModuleLength -require 'ffi' +require "rubygems" +require "bundler/setup" + +require "google/protobuf" +require "google/rpc/status_pb" + +require "ffi" module SpannerLib extend FFI::Library - ffi_lib File.expand_path('../../shared/spannerlib.so', __dir__) + + ffi_lib File.expand_path("../../shared/spannerlib.so", __dir__) class GoString < FFI::Struct layout :p, :pointer, :len, :long end - # GoBytes is the Ruby representation of a Go byte slice + # GoBytes is the Ruby representation of a Go byte slice class GoBytes < FFI::Struct layout :p, :pointer, :len, :long, @@ -24,28 +29,28 @@ class GoBytes < FFI::Struct # 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 + 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, [:int64, :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, [:int64, :int64], Message.by_value - attach_function :Rollback, [:int64, :int64], 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, [:int64, :int64, :int64], Message.by_value - attach_function :Next, [:int64, :int64, :int64, :int32, :int32], Message.by_value - attach_function :ResultSetStats, [:int64, :int64, :int64], Message.by_value - attach_function :CloseRows, [:int64, :int64, :int64], 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 --- @@ -53,7 +58,7 @@ class Message < FFI::Struct 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 @@ -72,7 +77,7 @@ 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") @@ -84,19 +89,19 @@ def self.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 > 0 - - go_bytes = GoBytes.new - go_bytes[:p] = ptr - go_bytes[:len] = len - go_bytes[:cap] = len - - yield(go_bytes) -end - + 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 @@ -107,9 +112,7 @@ def self.ensure_release(message) end def self.handle_object_id_response(message, func_name) - puts "--- #{func_name} response: code=#{message[:code]}, objectId=#{message[:objectId]}, length=#{message[:length]} ---" ensure_release(message) do - puts "Message received from #{func_name}: code=#{message[:code]}, objectId=#{message[:objectId]}, length=#{message[:length]}" if message[:code] != 0 error_msg = read_error_message(message) raise "#{func_name} failed with code #{message[:code]}: #{error_msg}" @@ -127,6 +130,7 @@ def self.handle_status_response(message, func_name) end end + # rubocop:disable Metrics/MethodLength def self.handle_data_response(message, func_name) ensure_release(message) do if message[:code] != 0 @@ -137,30 +141,33 @@ def self.handle_data_response(message, func_name) len = message[:length] ptr = message[:pointer] - if len > 0 && !ptr.null? + 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 > 0 && !ptr.null? + if len.positive? && !ptr.null? raw_bytes = ptr.read_bytes(len) begin status_proto = ::Google::Rpc::Status.decode(raw_bytes) - return "Status Proto { code: #{status_proto.code}, message: '#{status_proto.message}' }" - rescue => e - clean_string = raw_bytes.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?').strip - return "Failed to decode Status proto (code #{message[:code]}): #{e.class}: #{e.message} | Raw: #{clean_string}" + "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| @@ -214,11 +221,13 @@ def self.next(pool_id, conn_id, rows_id, max_rows, fetch_size) 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 + end def self.close_rows(pool_id, conn_id, rows_id) message = CloseRows(pool_id, conn_id, rows_id) handle_status_response(message, "CloseRows") nil end -end \ No newline at end of file +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 index 2415443f..583cc9af 100644 --- a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/pool.rb +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/pool.rb @@ -1,5 +1,7 @@ -require_relative 'ffi' -require_relative 'connection' +# frozen_string_literal: true + +require_relative "ffi" +require_relative "connection" class Pool attr_reader :id @@ -13,13 +15,11 @@ def initialize(id) def self.create_pool(dsn) begin pool_id = SpannerLib.create_pool(dsn) - rescue => e - raise SpannerLibException.new(e.message) + rescue StandardError => e + raise SpannerLibException, e.message end - if pool_id.nil? || pool_id <= 0 - raise SpannerLibException.new('failed to create pool') - end + raise SpannerLibException, "failed to create pool" if pool_id.nil? || pool_id <= 0 Pool.new(pool_id) end @@ -27,26 +27,23 @@ def self.create_pool(dsn) # 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.new('pool closed') if @closed + raise SpannerLibException, "pool closed" if @closed + begin conn_id = SpannerLib.create_connection(@id) - rescue => e - raise SpannerLibException.new(e.message) + rescue StandardError => e + raise SpannerLibException, e.message end - if conn_id.nil? || conn_id <= 0 - raise SpannerLibException.new('failed to create connection') - 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/spannerlib-ruby.gemspec b/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec index 1dac58b7..1de4167c 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec +++ b/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec @@ -22,6 +22,7 @@ Gem::Specification.new do |spec| spec.metadata["homepage_uri"] = spec.homepage || "https://example.com" spec.metadata["source_code_uri"] = "https://example.com/source" spec.metadata["changelog_uri"] = "https://example.com/CHANGELOG.md" + spec.metadata["rubygems_mfa_required"] = "true" # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -38,7 +39,7 @@ Gem::Specification.new do |spec| # Uncomment to register a new dependency of your gem # spec.add_dependency "example-gem", "~> 1.0" - + spec.add_dependency "google-cloud-spanner", "~> 2.25" spec.add_dependency "google-cloud-spanner-v1", "~> 1.7" diff --git a/spannerlib/wrappers/spannerlib-ruby/spec/integration/connection_emulator_spec.rb b/spannerlib/wrappers/spannerlib-ruby/spec/integration/connection_emulator_spec.rb index 677f2ec8..86373c7b 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spec/integration/connection_emulator_spec.rb +++ b/spannerlib/wrappers/spannerlib-ruby/spec/integration/connection_emulator_spec.rb @@ -1,15 +1,13 @@ # frozen_string_literal: true require "spec_helper" -require 'google/cloud/spanner' +require "google/cloud/spanner" RSpec.describe "Connection APIs against Spanner emulator", :integration do before(:all) do - @emulator_host = ENV["SPANNER_EMULATOR_HOST"] - unless @emulator_host && !@emulator_host.empty? - skip "SPANNER_EMULATOR_HOST not set; skipping emulator integration tests" - end - + @emulator_host = ENV.fetch("SPANNER_EMULATOR_HOST", nil) + skip "SPANNER_EMULATOR_HOST not set; skipping emulator integration tests" unless @emulator_host && !@emulator_host.empty? + require "spannerlib/pool" @dsn = "projects/your-project-id/instances/test-instance/databases/test-database?autoConfigEmulator=true" end @@ -17,10 +15,10 @@ it "test creation of connection pool" do pool = Pool.create_pool(@dsn) conn = pool.create_connection - + expect(conn).not_to be_nil expect(conn.conn_id).to be > 0 - + conn.close pool.close end @@ -28,17 +26,17 @@ it "test two connections from same pool" do pool = Pool.create_pool(@dsn) conn1 = pool.create_connection - conn2 = pool.create_connection - - expect(conn1.conn_id).not_to eq(conn2.conn_id) + conn2 = pool.create_connection + + expect(conn1.conn_id).not_to eq(conn2.conn_id) expect(conn1.conn_id).to be > 0 expect(conn2.conn_id).to be > 0 - + conn1.close conn2.close pool.close end - + it "test write mutations with read-write transactions" do pool = Pool.create_pool(@dsn) conn = pool.create_connection @@ -56,23 +54,25 @@ conn.execute_batch(ddl_batch_req) conn.begin_transaction - + insert_data_req = Google::Spanner::V1::BatchWriteRequest::MutationGroup.new( mutations: [ Google::Spanner::V1::Mutation.new( insert: Google::Spanner::V1::Mutation::Write.new( table: "test_table", - columns: ["id", "name"], + 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")]) + 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" @@ -84,17 +84,17 @@ 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[0].string_value).to eq("1") expect(all_rows[0].values[1].string_value).to eq("Alice") - + expect(all_rows[1].values[0].string_value).to eq("2") expect(all_rows[1].values[1].string_value).to eq("Bob") @@ -123,17 +123,19 @@ Google::Spanner::V1::Mutation.new( insert: Google::Spanner::V1::Mutation::Write.new( table: "test_table_1", - columns: ["id", "name"], + columns: %w[id name], values: [ - Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "1"), Google::Protobuf::Value.new(string_value: "Charlie")]), - Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "2"), Google::Protobuf::Value.new(string_value: "David")]) + Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "1"), + Google::Protobuf::Value.new(string_value: "Charlie")]), + Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "2"), + 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_1 ORDER BY id" ) @@ -144,20 +146,20 @@ 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") expect(all_rows[1].values[1].string_value).to eq("David") - + conn.close pool.close end it "raises an error when writing mutations in a read-only transaction" do - pool = Pool.create_pool(@dsn) conn = pool.create_connection @@ -181,26 +183,28 @@ conn.begin_transaction(transaction_options) insert_data_req = Google::Spanner::V1::BatchWriteRequest::MutationGroup.new( - mutations: [ - Google::Spanner::V1::Mutation.new( - insert: Google::Spanner::V1::Mutation::Write.new( - table: "test_table_2", - columns: ["id", "name"], - values: [ - Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "1"), Google::Protobuf::Value.new(string_value: "Charlie")]), - Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "2"), Google::Protobuf::Value.new(string_value: "David")]) - ] - ) + mutations: [ + Google::Spanner::V1::Mutation.new( + insert: Google::Spanner::V1::Mutation::Write.new( + table: "test_table_2", + columns: %w[id name], + values: [ + Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "1"), + Google::Protobuf::Value.new(string_value: "Charlie")]), + Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "2"), + Google::Protobuf::Value.new(string_value: "David")]) + ] ) - ] - ) + ) + ] + ) - expect { + expect do conn.write_mutations(insert_data_req) - }.to raise_error(RuntimeError, /read-only transactions cannot write/) + end.to raise_error(RuntimeError, /read-only transactions cannot write/) conn.rollback conn.close pool.close end -end \ No newline at end of file +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 index 4875985a..b849c367 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spec/integration/pool_emulator_spec.rb +++ b/spannerlib/wrappers/spannerlib-ruby/spec/integration/pool_emulator_spec.rb @@ -2,12 +2,10 @@ require "spec_helper" -RSpec.describe "Pool against Spanner emulator", :integration do +RSpec.describe "Connection APIs against Spanner emulator", :integration do before(:all) do - @emulator_host = ENV["SPANNER_EMULATOR_HOST"] - unless @emulator_host && !@emulator_host.empty? - skip "SPANNER_EMULATOR_HOST not set; skipping emulator integration tests" - end + @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" @@ -18,8 +16,8 @@ end it "creates a pool and a connection against the emulator" do - pool = Pool.create_pool(@dsn) - expect(pool).to be_a(Pool) + pool = described_class.create_pool(@dsn) + expect(pool).to be_a(described_class) expect(pool.id).to be > 0 conn = pool.create_connection @@ -28,5 +26,4 @@ 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 index b95935aa..af57d9cf 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/connection_spec.rb +++ b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/connection_spec.rb @@ -3,36 +3,36 @@ require "spec_helper" require "spannerlib/pool" require "spannerlib/ffi" -require "spannerlib/exceptions" +require "spannerlib/exceptions" -RSpec.describe Pool do - let(:dsn) { "localhost:1234/projects/p/instances/i/databases/d?usePlainText=true" } +RSpec.describe Connection do + let(:dsn) { "localhost:1234/projects/p/instances/i/databases/d?usePlainText=true" } - describe "#create_connection" do - it "creates a Connection associated with this Pool" do - allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(1) - expect(SpannerLib).to receive(:create_connection).with(1).and_return(2) + describe "#create_connection" do + it "creates a Connection associated with this Pool" do + allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(1) + allow(SpannerLib).to receive(:create_connection).with(1).and_return(2) - pool = Pool.create_pool(dsn) - conn = pool.create_connection + pool = described_class.create_pool(dsn) + pool.create_connection - expect(conn).to be_a(Connection) - end + expect(SpannerLib).to have_received(:create_connection).with(1) + end - it "raises a SpannerLibException when create_connection fails" do - allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(1) - allow(SpannerLib).to receive(:create_connection).with(1).and_raise(StandardError.new("boom")) + it "raises a SpannerLibException when create_connection fails" do + allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(1) + allow(SpannerLib).to receive(:create_connection).with(1).and_raise(StandardError.new("boom")) - pool = Pool.create_pool(dsn) - expect { pool.create_connection }.to raise_error(SpannerLibException) - end + pool = described_class.create_pool(dsn) + expect { pool.create_connection }.to raise_error(SpannerLibException) + end - it "raises when create_connection returns nil or non-positive id" do - allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(1) - allow(SpannerLib).to receive(:create_connection).with(1).and_return(nil) + it "raises when create_connection returns nil or non-positive id" do + allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(1) + allow(SpannerLib).to receive(:create_connection).with(1).and_return(nil) - pool = Pool.create_pool(dsn) - expect { pool.create_connection }.to raise_error(SpannerLibException) - end + pool = described_class.create_pool(dsn) + expect { pool.create_connection }.to raise_error(SpannerLibException) end -end \ No newline at end of file + end +end diff --git a/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/pool_spec.rb b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/pool_spec.rb index c851eac8..91b8d664 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/pool_spec.rb +++ b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/pool_spec.rb @@ -3,37 +3,37 @@ require "spec_helper" require "spannerlib/pool" require "spannerlib/ffi" -require "spannerlib/exceptions" +require "spannerlib/exceptions" RSpec.describe Pool do - let(:dsn) { "localhost:1234/projects/p/instances/i/databases/d?usePlainText=true" } + 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) + 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 = Pool.create_pool(dsn) + pool = described_class.create_pool(dsn) - expect(pool).to be_a(Pool) - expect(pool.id).to be > 0 - end + 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")) + 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 { Pool.create_pool(dsn) }.to raise_error(SpannerLibException) - end + 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) + it "raises when create_pool returns nil" do + allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(nil) - expect { Pool.create_pool(dsn) }.to raise_error(SpannerLibException) - end + 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) + it "raises when create_pool returns a non-positive id" do + allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(0) - expect { Pool.create_pool(dsn) }.to raise_error(SpannerLibException) - end + expect { described_class.create_pool(dsn) }.to raise_error(SpannerLibException) end -end \ No newline at end of file + end +end diff --git a/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/ruby_spec.rb b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/ruby_spec.rb deleted file mode 100644 index 18ad9e39..00000000 --- a/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/ruby_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Spannerlib::Ruby do - it "has a version number" do - expect(Spannerlib::Ruby::VERSION).not_to be nil - end - - it "does something useful" do - expect(false).to eq(true) - end -end diff --git a/spannerlib/wrappers/spannerlib-ruby/spec/spec_helper.rb b/spannerlib/wrappers/spannerlib-ruby/spec/spec_helper.rb index 32bf5294..23e9cfc3 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spec/spec_helper.rb +++ b/spannerlib/wrappers/spannerlib-ruby/spec/spec_helper.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'rubygems' +require "rubygems" require "bundler/setup" require "spannerlib/ruby" From 164fad05fd521a0c52a6ead8a2e353c41e04e98e Mon Sep 17 00:00:00 2001 From: Akash Anand Date: Mon, 6 Oct 2025 11:16:35 +0000 Subject: [PATCH 3/4] Added Rakefile changes to load the native code based on OS arch --- .../integration-tests-on-emulator.yml | 38 +++- spannerlib/.gitignore | 4 +- .../.github/workflows/main.yml | 27 --- spannerlib/wrappers/spannerlib-ruby/Gemfile | 10 +- spannerlib/wrappers/spannerlib-ruby/Rakefile | 39 +++- .../lib/spannerlib/connection.rb | 14 ++ .../lib/spannerlib/exceptions.rb | 14 ++ .../spannerlib-ruby/lib/spannerlib/ffi.rb | 26 ++- .../spannerlib-ruby/lib/spannerlib/pool.rb | 14 ++ .../spannerlib-ruby/lib/spannerlib/ruby.rb | 14 ++ .../spannerlib-ruby/spannerlib-ruby.gemspec | 23 +- .../integration/connection_emulator_spec.rb | 213 +++++++----------- .../spec/integration/pool_emulator_spec.rb | 17 +- .../spec/spannerlib/connection_spec.rb | 43 ++-- .../spec/spannerlib/pool_spec.rb | 14 ++ .../spannerlib-ruby/spec/spec_helper.rb | 14 ++ 16 files changed, 311 insertions(+), 213 deletions(-) delete mode 100644 spannerlib/wrappers/spannerlib-ruby/.github/workflows/main.yml diff --git a/.github/workflows/integration-tests-on-emulator.yml b/.github/workflows/integration-tests-on-emulator.yml index 38a57e1c..ab19a7bc 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: @@ -14,15 +18,41 @@ jobs: - 9020:9020 steps: - name: Install Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v5 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@v4 + - 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 29831fd5..e3130ba8 100644 --- a/spannerlib/.gitignore +++ b/spannerlib/.gitignore @@ -5,4 +5,6 @@ vendor/bundle shared/ *.gem .DS_Store -*.swp \ No newline at end of file +*.swp +ext/ +Gemfile.lock diff --git a/spannerlib/wrappers/spannerlib-ruby/.github/workflows/main.yml b/spannerlib/wrappers/spannerlib-ruby/.github/workflows/main.yml deleted file mode 100644 index c87a3444..00000000 --- a/spannerlib/wrappers/spannerlib-ruby/.github/workflows/main.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Ruby - -on: - push: - branches: - - main - - pull_request: - -jobs: - build: - runs-on: ubuntu-latest - name: Ruby ${{ matrix.ruby }} - strategy: - matrix: - ruby: - - '3.3.1' - - steps: - - uses: actions/checkout@v4 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - - name: Run the default task - run: bundle exec rake diff --git a/spannerlib/wrappers/spannerlib-ruby/Gemfile b/spannerlib/wrappers/spannerlib-ruby/Gemfile index e284b010..a40cfd66 100644 --- a/spannerlib/wrappers/spannerlib-ruby/Gemfile +++ b/spannerlib/wrappers/spannerlib-ruby/Gemfile @@ -2,20 +2,12 @@ source "https://rubygems.org" -# Specify your gem's dependencies in spannerlib-ruby.gemspec gemspec -gem "irb" gem "rake", "~> 13.0" -gem "ffi" -gem "googleapis-common-protos", "~> 1.0" -gem "google-protobuf", "~> 3.19" -gem "grpc", "~> 1.60" - -gem "google-cloud-spanner" - group :development, :test do + gem "rake-compiler", "~> 1.0" gem "rspec", "~> 3.0" gem "rubocop", require: false gem "rubocop-rspec", require: false diff --git a/spannerlib/wrappers/spannerlib-ruby/Rakefile b/spannerlib/wrappers/spannerlib-ruby/Rakefile index cca71754..c6babf0a 100644 --- a/spannerlib/wrappers/spannerlib-ruby/Rakefile +++ b/spannerlib/wrappers/spannerlib-ruby/Rakefile @@ -1,12 +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) -require "rubocop/rake_task" - RuboCop::RakeTask.new -task default: %i[spec rubocop] +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/lib/spannerlib/connection.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/connection.rb index 8dddb603..ea824a7c 100644 --- a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/connection.rb +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/connection.rb @@ -1,3 +1,17 @@ +# 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" diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/exceptions.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/exceptions.rb index cc1e28a3..6331652d 100644 --- a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/exceptions.rb +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/exceptions.rb @@ -1,3 +1,17 @@ +# 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 diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb index 2339c842..f01059c8 100644 --- a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb @@ -1,3 +1,17 @@ +# 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 @@ -13,7 +27,12 @@ module SpannerLib extend FFI::Library - ffi_lib File.expand_path("../../shared/spannerlib.so", __dir__) + 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, @@ -70,7 +89,6 @@ def self.create_pool(dsn) def self.close_pool(pool_id) message = ClosePool(pool_id) handle_status_response(message, "ClosePool") - nil end def self.create_connection(pool_id) @@ -81,7 +99,6 @@ def self.create_connection(pool_id) def self.close_connection(pool_id, conn_id) message = CloseConnection(pool_id, conn_id) handle_status_response(message, "CloseConnection") - nil end def self.release(pinner) @@ -128,6 +145,7 @@ def self.handle_status_response(message, func_name) raise "#{func_name} failed with code #{message[:code]}: #{error_msg}" end end + nil end # rubocop:disable Metrics/MethodLength @@ -191,7 +209,6 @@ def self.commit(pool_id, conn_id) def self.rollback(pool_id, conn_id) message = Rollback(pool_id, conn_id) handle_status_response(message, "Rollback") - nil end def self.execute(pool_id, conn_id, proto_bytes) @@ -226,7 +243,6 @@ def self.result_set_stats(pool_id, conn_id, rows_id) def self.close_rows(pool_id, conn_id, rows_id) message = CloseRows(pool_id, conn_id, rows_id) handle_status_response(message, "CloseRows") - nil end end diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/pool.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/pool.rb index 583cc9af..33f7065a 100644 --- a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/pool.rb +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/pool.rb @@ -1,3 +1,17 @@ +# 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" diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ruby.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ruby.rb index 772c70b8..d206682b 100644 --- a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ruby.rb +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ruby.rb @@ -1,3 +1,17 @@ +# 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" diff --git a/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec b/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec index 1de4167c..dfdd987e 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec +++ b/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec @@ -12,37 +12,22 @@ Gem::Specification.new do |spec| spec.description = "Lightweight Ruby FFI bindings for the Spanner native library produced from the Go implementation." # Use an example homepage for local builds; replace with your project's URL. spec.homepage = "https://example.com/spannerlib-ruby" - spec.license = "MIT" + spec.license = "Apache 2.0 License" spec.required_ruby_version = ">= 3.1.0" spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" - # Provide minimal valid URIs so `gem build` passes metadata validation. Replace - # these with the project's real URLs before publishing. spec.metadata["homepage_uri"] = spec.homepage || "https://example.com" spec.metadata["source_code_uri"] = "https://example.com/source" spec.metadata["changelog_uri"] = "https://example.com/CHANGELOG.md" spec.metadata["rubygems_mfa_required"] = "true" - # Specify which files should be added to the gem when it is released. - # The `git ls-files -z` loads the files in the RubyGem that have been added into git. - gemspec = File.basename(__FILE__) - spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| - ls.readlines("\x0", chomp: true).reject do |f| - (f == gemspec) || - f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) - end - end spec.bindir = "exe" spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" - - spec.add_dependency "google-cloud-spanner", "~> 2.25" + spec.add_dependency "ffi" spec.add_dependency "google-cloud-spanner-v1", "~> 1.7" - - # For more information and examples about making a new gem, check out our - # guide at: https://bundler.io/guides/creating_gem.html + 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 index 86373c7b..1149b6a3 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spec/integration/connection_emulator_spec.rb +++ b/spannerlib/wrappers/spannerlib-ruby/spec/integration/connection_emulator_spec.rb @@ -1,64 +1,84 @@ +# 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" +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; skipping emulator integration tests" unless @emulator_host && !@emulator_host.empty? + 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" - end - - it "test creation of connection pool" do - pool = Pool.create_pool(@dsn) - conn = pool.create_connection - - expect(conn).not_to be_nil - expect(conn.conn_id).to be > 0 - - conn.close - pool.close - end - - it "test two connections from same pool" do - pool = Pool.create_pool(@dsn) - conn1 = pool.create_connection - conn2 = pool.create_connection - - expect(conn1.conn_id).not_to eq(conn2.conn_id) - expect(conn1.conn_id).to be > 0 - expect(conn2.conn_id).to be > 0 - conn1.close - conn2.close - pool.close - end - - it "test write mutations with read-write transactions" do 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: "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 - conn.begin_transaction + 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 - insert_data_req = Google::Spanner::V1::BatchWriteRequest::MutationGroup.new( + 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::Spanner::V1::Mutation.new( - insert: Google::Spanner::V1::Mutation::Write.new( + Google::Cloud::Spanner::V1::Mutation.new( + insert: Google::Cloud::Spanner::V1::Mutation::Write.new( table: "test_table", columns: %w[id name], values: [ @@ -71,140 +91,75 @@ ) ] ) - conn.write_mutations(insert_data_req) + @conn.write_mutations(insert_data_req) + @conn.commit - 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) - expect(rows_id).to be > 0 + 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) + 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) + @conn.close_rows(rows_id) expect(all_rows.length).to eq(2) - - expect(all_rows[0].values[0].string_value).to eq("1") expect(all_rows[0].values[1].string_value).to eq("Alice") - - expect(all_rows[1].values[0].string_value).to eq("2") - expect(all_rows[1].values[1].string_value).to eq("Bob") - - conn.close - pool.close end - it "test write mutations without transactions" do - 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_1" - ), - Google::Cloud::Spanner::V1::ExecuteBatchDmlRequest::Statement.new( - sql: "CREATE TABLE test_table_1 (id INT64 NOT NULL, name STRING(100)) PRIMARY KEY(id)" - ) - ] - ) - conn.execute_batch(ddl_batch_req) - - insert_data_req = Google::Spanner::V1::BatchWriteRequest::MutationGroup.new( + it "writes and reads data without an explicit transaction (autocommit)" do + insert_data_req = Google::Cloud::Spanner::V1::BatchWriteRequest::MutationGroup.new( mutations: [ - Google::Spanner::V1::Mutation.new( - insert: Google::Spanner::V1::Mutation::Write.new( - table: "test_table_1", + 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::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: "2"), + 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) + @conn.write_mutations(insert_data_req) - select_req = Google::Cloud::Spanner::V1::ExecuteSqlRequest.new( - sql: "SELECT id, name FROM test_table_1 ORDER BY id" - ) - rows_id = conn.execute(select_req) - expect(rows_id).to be > 0 + 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) + 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) + @conn.close_rows(rows_id) expect(all_rows.length).to eq(2) expect(all_rows[0].values[1].string_value).to eq("Charlie") - expect(all_rows[1].values[1].string_value).to eq("David") - - conn.close - pool.close end - it "raises an error when writing mutations in a read-only transaction" do - 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_2" - ), - Google::Cloud::Spanner::V1::ExecuteBatchDmlRequest::Statement.new( - sql: "CREATE TABLE test_table_2 (id INT64 NOT NULL, name STRING(100)) PRIMARY KEY(id)" - ) - ] + 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.execute_batch(ddl_batch_req) + @conn.begin_transaction(transaction_options) - transaction_options = Google::Spanner::V1::TransactionOptions.new( - read_only: Google::Spanner::V1::TransactionOptions::ReadOnly.new( - strong: true - ) - ) - conn.begin_transaction(transaction_options) - - insert_data_req = Google::Spanner::V1::BatchWriteRequest::MutationGroup.new( + insert_data_req = Google::Cloud::Spanner::V1::BatchWriteRequest::MutationGroup.new( mutations: [ - Google::Spanner::V1::Mutation.new( - insert: Google::Spanner::V1::Mutation::Write.new( - table: "test_table_2", - columns: %w[id name], - values: [ - Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "1"), - Google::Protobuf::Value.new(string_value: "Charlie")]), - Google::Protobuf::ListValue.new(values: [Google::Protobuf::Value.new(string_value: "2"), - Google::Protobuf::Value.new(string_value: "David")]) - ] - ) + 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/) - expect do - conn.write_mutations(insert_data_req) - end.to raise_error(RuntimeError, /read-only transactions cannot write/) - - conn.rollback - conn.close - pool.close + @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 index b849c367..4f7c0911 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spec/integration/pool_emulator_spec.rb +++ b/spannerlib/wrappers/spannerlib-ruby/spec/integration/pool_emulator_spec.rb @@ -1,3 +1,17 @@ +# 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" @@ -16,8 +30,7 @@ end it "creates a pool and a connection against the emulator" do - pool = described_class.create_pool(@dsn) - expect(pool).to be_a(described_class) + pool = Pool.create_pool(@dsn) expect(pool.id).to be > 0 conn = pool.create_connection diff --git a/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/connection_spec.rb b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/connection_spec.rb index af57d9cf..a9d92cbb 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/connection_spec.rb +++ b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/connection_spec.rb @@ -1,3 +1,16 @@ +# 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" @@ -7,31 +20,33 @@ RSpec.describe Connection do let(:dsn) { "localhost:1234/projects/p/instances/i/databases/d?usePlainText=true" } + let(:pool) { Pool.create_pool(dsn) } - describe "#create_connection" do - it "creates a Connection associated with this Pool" do - allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(1) - allow(SpannerLib).to receive(:create_connection).with(1).and_return(2) + before do + allow(SpannerLib).to receive(:create_pool).and_return(1) + end - pool = described_class.create_pool(dsn) - pool.create_connection + 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(SpannerLib).to have_received(:create_connection).with(1) + 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 create_connection fails" do - allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(1) + it "raises a SpannerLibException when the FFI call fails" do allow(SpannerLib).to receive(:create_connection).with(1).and_raise(StandardError.new("boom")) - pool = described_class.create_pool(dsn) expect { pool.create_connection }.to raise_error(SpannerLibException) end - it "raises when create_connection returns nil or non-positive id" do - allow(SpannerLib).to receive(:create_pool).with(dsn).and_return(1) - allow(SpannerLib).to receive(:create_connection).with(1).and_return(nil) + it "raises when the FFI call returns a non-positive id" do + allow(SpannerLib).to receive(:create_connection).with(1).and_return(0) - pool = described_class.create_pool(dsn) expect { pool.create_connection }.to raise_error(SpannerLibException) end end diff --git a/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/pool_spec.rb b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/pool_spec.rb index 91b8d664..f6d6a373 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/pool_spec.rb +++ b/spannerlib/wrappers/spannerlib-ruby/spec/spannerlib/pool_spec.rb @@ -1,3 +1,17 @@ +# 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" diff --git a/spannerlib/wrappers/spannerlib-ruby/spec/spec_helper.rb b/spannerlib/wrappers/spannerlib-ruby/spec/spec_helper.rb index 23e9cfc3..514ba076 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spec/spec_helper.rb +++ b/spannerlib/wrappers/spannerlib-ruby/spec/spec_helper.rb @@ -1,3 +1,17 @@ +# 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" From 4e7a1d945c836a9826a3ef035cfb1bc4f6aa5ee4 Mon Sep 17 00:00:00 2001 From: Akash Anand Date: Mon, 6 Oct 2025 13:54:34 +0000 Subject: [PATCH 4/4] Gemspec file cleanup --- .../integration-tests-on-emulator.yml | 4 +- .../spannerlib-ruby/lib/spanner_pb.rb | 43 ------------------- .../spannerlib-ruby/spannerlib-ruby.gemspec | 12 ++---- 3 files changed, 5 insertions(+), 54 deletions(-) delete mode 100644 spannerlib/wrappers/spannerlib-ruby/lib/spanner_pb.rb diff --git a/.github/workflows/integration-tests-on-emulator.yml b/.github/workflows/integration-tests-on-emulator.yml index ab19a7bc..acecd5fa 100644 --- a/.github/workflows/integration-tests-on-emulator.yml +++ b/.github/workflows/integration-tests-on-emulator.yml @@ -18,7 +18,7 @@ jobs: - 9020:9020 steps: - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: 1.25.x - name: Checkout code @@ -42,7 +42,7 @@ jobs: - 9020:9020 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spanner_pb.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spanner_pb.rb deleted file mode 100644 index e7cd61bc..00000000 --- a/spannerlib/wrappers/spannerlib-ruby/lib/spanner_pb.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -# Generated by the protocol buffer compiler. DO NOT EDIT! -# source: spanner.proto - -require 'google/protobuf' - -Google::Protobuf::DescriptorPool.generated_pool.build do - add_file('spanner.proto', syntax: :proto3) do - add_message 'spanner_bench.Singer' do - optional :id, :int64, 1 - optional :first_name, :string, 2 - optional :last_name, :string, 3 - optional :singer_info, :string, 4 - end - add_message 'spanner_bench.Album' do - optional :id, :int64, 1 - optional :singer_id, :int64, 2 - optional :album_title, :string, 3 - end - add_message 'spanner_bench.ReadQuery' do - optional :query, :string, 1 - end - add_message 'spanner_bench.InsertQuery' do - repeated :singers, :message, 1, 'spanner_bench.Singer' - repeated :albums, :message, 2, 'spanner_bench.Album' - end - add_message 'spanner_bench.UpdateQuery' do - repeated :queries, :string, 1 - end - add_message 'spanner_bench.EmptyResponse' do - end - end -end - -module SpannerBench - Singer = ::Google::Protobuf::DescriptorPool.generated_pool.lookup('spanner_bench.Singer').msgclass - Album = ::Google::Protobuf::DescriptorPool.generated_pool.lookup('spanner_bench.Album').msgclass - ReadQuery = ::Google::Protobuf::DescriptorPool.generated_pool.lookup('spanner_bench.ReadQuery').msgclass - InsertQuery = ::Google::Protobuf::DescriptorPool.generated_pool.lookup('spanner_bench.InsertQuery').msgclass - UpdateQuery = ::Google::Protobuf::DescriptorPool.generated_pool.lookup('spanner_bench.UpdateQuery').msgclass - EmptyResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup('spanner_bench.EmptyResponse').msgclass -end diff --git a/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec b/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec index dfdd987e..4f746b3d 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec +++ b/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec @@ -5,21 +5,15 @@ require_relative "lib/spannerlib/ruby/version" Gem::Specification.new do |spec| spec.name = "spannerlib-ruby" spec.version = Spannerlib::Ruby::VERSION - spec.authors = ["Spannerlib Contributors"] - spec.email = ["spannerlib@example.com"] + 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." - # Use an example homepage for local builds; replace with your project's URL. - spec.homepage = "https://example.com/spannerlib-ruby" + 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["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" - - spec.metadata["homepage_uri"] = spec.homepage || "https://example.com" - spec.metadata["source_code_uri"] = "https://example.com/source" - spec.metadata["changelog_uri"] = "https://example.com/CHANGELOG.md" spec.metadata["rubygems_mfa_required"] = "true" spec.bindir = "exe"