diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6b4e5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.project +ruby_snowflake_client.h +ruby_snowflake_client.so +/.rakeTasks +.idea/* + +# ruby gems +*.gem +/.DS_Store diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..0748779 --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +# Specify your gem's dependencies in scatter.gemspec +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..aeefa63 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,22 @@ +PATH + remote: . + specs: + ruby_snowflake_client (0.2.4-x86_64-darwin-18) + ffi + +GEM + remote: https://rubygems.org/ + specs: + ffi (1.11.3) + rake (13.0.1) + +PLATFORMS + ruby + +DEPENDENCIES + bundler + rake + ruby_snowflake_client! + +BUNDLED WITH + 1.17.3 diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..e3c3ff6 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Dotan Nahum + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2760d5 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Snowflake Connector for Ruby + +Uses [gosnowflake](https://github.com/snowflakedb/gosnowflake/) to more efficiently query snowflake than ODBC. We found +at least 2 significant problems with ODBC which this resolves: +1. For large result sets, ODBC would get progressively slower per row as it would retrieve all the preceding +pages in order to figure out the offset. This new gem uses a streaming interface alleviating the need for +offsets and limit when paging through result sets. +2. ODBC mangled timezone information. + +In addition, this gem is a lot faster for all but the most trivial queries. + +## Tech Overview + +This gem works by deserializing each row into an array of strings in Go. It then converts it to an array +of C strings (`**C.Char`) which it passes back through the FFI (foreign function interface) to Ruby. +There's a slight penalty for the 4 time type conversion (from the db type to Go string, from Go string +to C string, from C string to the Ruby string, and then from Ruby string to your intended type). + +## How to use + +Look at [examples](https://github.com/dmitchell/go-ruby-snowflake-connector/blob/master/examples) + +1. add as gem to your project (`gem 'ruby_snowflake_client', '~> 0.2.2'`) +2. put `require 'go_snowflake_client'` at the top of your files which use it +3. following the pattern of the [example connect](https://github.com/dmitchell/go-ruby-snowflake-connector/blob/master/examples/table_crud.rb), +call `GoSnowflakeClient.connect` with your database information and credentials. +4. use `GoSnowflakeClient.exec` to execute create, update, delete, and insert queries. If it +returns `nil`, call `GoSnowflakeClient.last_error` to get the error. Otherwise, it will return +the number of affected rows. +5. use `GoSnowflakeClient.select` with a block to execute on each row to query the database. This +will return either `nil` or an error string. +9. and finally, call `GoSnowflakeClient.close(db_pointer)` to close the database connection + +### Our use pattern + +In our application, we've wrapped this library with query generators and model definitions somewhat ala +Rails but with less dynamic introspection although we could add it by using +``` ruby +GoSnowflakeClient.select(db, 'describe table my_table') do |col_name, col_type, _, nullable, *_| + my_table.add_column_description(col_name, col_type, nullable) +end +``` + +Each snowflake model class inherits from an abstract class which instantiates model instances +from each query by a pattern like +``` ruby + GoSnowflakeClient.select(db, query) do |row| + entry = self.new(fields.zip(row).map {|field, value| cast(field, value)}.to_h) + yield entry + end + + def cast(field_name, value) + if value.nil? + [field_name, value] + elsif column_name_to_cast.include?(field_name) + cast_method = column_name_to_cast[field_name] + if cast_method == :to_time + [field_name, value.to_time(:local)] + elsif cast_method == :to_utc + [field_name, value.to_time(:utc)] + elsif cast_method == :to_date + [field_name, value.to_date] + elsif cast_method == :to_utc_date + [field_name, value.to_time(:utc).to_date] + else + [field_name, value.public_send(cast_method)] + end + else + [field_name, value] + end + end + +# where each model declares column_name_to_cast ala + COLUMN_NAME_TO_CAST = { + id: :to_i, + ad_text_id: :to_i, + is_mobile: :to_bool, + is_full_site: :to_bool, + action_element_count: :to_i, + created_at: :to_time, + session_idx: :to_i, + log_idx: :to_i, + log_date: :to_utc_date}.with_indifferent_access.freeze + + def self.column_name_to_cast + COLUMN_NAME_TO_CAST + end +``` + +Of course, instantiating an object for each row adds expense and gc stress; so, it may not always +be a good approach. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..7398a90 --- /dev/null +++ b/Rakefile @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require 'bundler/gem_tasks' diff --git a/examples/common_sample_interface.rb b/examples/common_sample_interface.rb new file mode 100644 index 0000000..84f16ee --- /dev/null +++ b/examples/common_sample_interface.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +$LOAD_PATH << File.expand_path('..', __dir__) +require 'lib/go_snowflake_client' +require 'logger' + +class CommonSampleInterface + attr_reader :db_pointer + + def initialize(database) + @logger = Logger.new(STDERR) + + @db_pointer = GoSnowflakeClient.connect( + ENV['SNOWFLAKE_TEST_ACCOUNT'], + ENV['SNOWFLAKE_TEST_WAREHOUSE'], + database, + ENV['SNOWFLAKE_TEST_SCHEMA'] || 'TPCDS_SF10TCL', + ENV['SNOWFLAKE_TEST_USER'], + ENV['SNOWFLAKE_TEST_PASSWORD'], + ENV['SNOWFLAKE_TEST_ROLE'] || 'PUBLIC' + ) + + log_error unless @db_pointer + end + + def close_db + GoSnowflakeClient.close(@db_pointer) if @db_pointer + end + + def log_error + @logger ||= Logger.new(STDERR) + @logger.error(GoSnowflakeClient.last_error) + end +end diff --git a/examples/snowflake_sample_data.rb b/examples/snowflake_sample_data.rb new file mode 100644 index 0000000..0ba6b8e --- /dev/null +++ b/examples/snowflake_sample_data.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative 'common_sample_interface.rb' # Creates/uses test_data table in the db you point to +# Assumes you have access to snowflake_sample_data https://docs.snowflake.net/manuals/user-guide/sample-data.html +# Set env vars: SNOWFLAKE_TEST_ACCOUNT, SNOWFLAKE_TEST_USER, SNOWFLAKE_TEST_PASSWORD, SNOWFLAKE_TEST_WAREHOUSE +# optionally set SNOWFLAKE_TEST_SCHEMA, SNOWFLAKE_TEST_ROLE + +class SnowflakeSampleData < CommonSampleInterface + def initialize + super('SNOWFLAKE_SAMPLE_DATA') + end + + def get_customer_names(where = "c_last_name = 'Flowers'") + raise('db not connected') unless @db_pointer + + query = 'select c_first_name, c_last_name from "CUSTOMER"' + query += " where #{where}" if where + + GoSnowflakeClient.select(@db_pointer, query) { |row| @logger.info("#{row[0]} #{row[1]}") } + end + + # @example process_unshipped_web_sales {|row| check_shipping_queue(row)} + def process_unshipped_web_sales(limit = 1_000, &block) + raise('db not connected') unless @db_pointer + + query = <<~QUERY + select c_first_name, c_last_name, ws_sold_date_sk, ws_list_price + from "CUSTOMER" + inner join "WEB_SALES" + ON c_customer_sk = ws_bill_customer_sk + where ws_ship_date_sk is null + #{"limit #{limit}" if limit} + QUERY + + GoSnowflakeClient.select(@db_pointer, query, &block) + end +end diff --git a/examples/table_crud.rb b/examples/table_crud.rb new file mode 100644 index 0000000..3774a2d --- /dev/null +++ b/examples/table_crud.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative 'common_sample_interface.rb' # Creates/uses test_data table in the db you point to +# Set env vars: SNOWFLAKE_TEST_ACCOUNT, SNOWFLAKE_TEST_USER, SNOWFLAKE_TEST_PASSWORD, SNOWFLAKE_TEST_WAREHOUSE, SNOWFLAKE_TEST_DATABASE +# optionally set SNOWFLAKE_TEST_SCHEMA, SNOWFLAKE_TEST_ROLE +# use GoSnowflakeClient.select(c.db_pointer, 'select * from test_table', field_count: 3).to_a to see the db contents +class TableCRUD < CommonSampleInterface + TEST_TABLE_NAME = 'TEST_TABLE' + + def initialize + super(ENV['SNOWFLAKE_TEST_DATABASE']) + end + + def create_test_table + command = <<~COMMAND + CREATE TEMP TABLE IF NOT EXISTS #{TEST_TABLE_NAME} + (id int AUTOINCREMENT NOT NULL, + some_timestamp TIMESTAMP_TZ DEFAULT CURRENT_TIMESTAMP(), + a_string string(20)) + COMMAND + result = GoSnowflakeClient.exec(@db_pointer, command) + result || log_error + end + + # @example insert_test_table([['2019-07-04 04:12:31 +0000', 'foo'],['2019-07-04 04:12:31 -0600', 'bar'],[Time.now, 'quux']]) + def insert_test_table(time_string_pairs) + command = <<~COMMAND + INSERT INTO #{TEST_TABLE_NAME} (some_timestamp, a_string) + VALUES #{time_string_pairs.map { |time, text| "('#{time}', '#{text}')" }.join(', ')} + COMMAND + result = GoSnowflakeClient.exec(@db_pointer, command) + result || log_error + end + + # @example update_test_table([[1, 'foo'],[99, 'bar'],[31, 'quux']]) + def update_test_table(id_string_pairs) + id_string_pairs.map do |id, text| + command = <<~COMMAND + UPDATE #{TEST_TABLE_NAME} + SET a_string = '#{text}' + WHERE id = #{id} + COMMAND + result = GoSnowflakeClient.exec(@db_pointer, command) + result || log_error + end + end +end diff --git a/ext/Makefile b/ext/Makefile new file mode 100644 index 0000000..1278c19 --- /dev/null +++ b/ext/Makefile @@ -0,0 +1,6 @@ +build: + go build -buildmode=c-shared -o ruby_snowflake_client.so ruby_snowflake.go +clean: +install: + +.PHONY: build diff --git a/ext/extconf.rb b/ext/extconf.rb new file mode 100644 index 0000000..e69de29 diff --git a/ext/ruby_snowflake.go b/ext/ruby_snowflake.go new file mode 100644 index 0000000..647f030 --- /dev/null +++ b/ext/ruby_snowflake.go @@ -0,0 +1,205 @@ +package main + +/* +#include +*/ +import "C" +import ( + "database/sql" + "errors" + gopointer "github.com/mattn/go-pointer" + sf "github.com/snowflakedb/gosnowflake" + "io" + "unsafe" + // "fmt" +) + +// Lazy coding: storing last error and connection as global vars bc don't want to figure out how to pkg and pass them +// back and forth to ruby +var last_error error + +//export LastError +func LastError() *C.char { + if last_error == nil { + return nil + } else { + return C.CString(last_error.Error()) + } +} + +// @returns db pointer +// ugh, ruby and go were disagreeing about the length of `int` so I had to be particular here and in the ffi +//export Connect +func Connect(account *C.char, warehouse *C.char, database *C.char, schema *C.char, + user *C.char, password *C.char, role *C.char, port int64) unsafe.Pointer { + // other optional parms: Application, Host, and alt auth schemes + cfg := &sf.Config{ + Account: C.GoString(account), + Warehouse: C.GoString(warehouse), + Database: C.GoString(database), + Schema: C.GoString(schema), + User: C.GoString(user), + Password: C.GoString(password), + Role: C.GoString(role), + Port: int(port), + } + + dsn, last_error := sf.DSN(cfg) + if last_error != nil { + return nil + } + + var db *sql.DB + db, last_error = sql.Open("snowflake", dsn) + if db == nil { + return nil + } else { + return gopointer.Save(db) + } +} + +//export Close +func Close(db_pointer unsafe.Pointer) { + db := decodeDbPointer(db_pointer) + if db != nil { + db.Close() + } +} + +// @return number of rows affected or -1 for error +//export Exec +func Exec(db_pointer unsafe.Pointer, statement *C.char) int64 { + db := decodeDbPointer(db_pointer) + var res sql.Result + res, last_error = db.Exec(C.GoString(statement)) + if res != nil { + rows, _ := res.RowsAffected() + return rows + } + return -1 +} + +//export Fetch +func Fetch(db_pointer unsafe.Pointer, statement *C.char) unsafe.Pointer { + db := decodeDbPointer(db_pointer) + var rows *sql.Rows + rows, last_error = db.Query(C.GoString(statement)) + if rows != nil { + result := gopointer.Save(rows) + return result + } else { + return nil + } +} + +// @return column names[List] for the given query. +//export QueryColumns +func QueryColumns(rows_pointer unsafe.Pointer) **C.char { + rows := decodeRowsPointer(rows_pointer) + if rows == nil { + return nil + } + + columns, _ := rows.Columns() + rowLength := len(columns) + + // See `NextRow` for why this pattern + pointerSize := unsafe.Sizeof(rows_pointer) + // Allocate an array for the string pointers. + var out **C.char + out = (**C.char)(C.malloc(C.ulong(rowLength) * C.ulong(pointerSize))) + + pointer := out + for _, raw := range columns { + // Find where to store the address of the next string. + // Copy each output string to a C string, and add it to the array. + // C.CString uses malloc to allocate memory. + *pointer = C.CString(string(raw)) + // inc pointer to next array ele + pointer = (**C.char)(unsafe.Pointer(uintptr(unsafe.Pointer(pointer)) + pointerSize)) + } + return out +} + +// @return column names[List] for the given query. +//export QueryColumnCount +func QueryColumnCount(rows_pointer unsafe.Pointer) int32 { + rows := decodeRowsPointer(rows_pointer) + if rows == nil { + return 0 + } + + columns, _ := rows.Columns() + return int32(len(columns)) +} + +// NOTE: gc's the rows_pointer object on EOF and returns nil. LastError is set to EOF +//export NextRow +func NextRow(rows_pointer unsafe.Pointer) **C.char { + rows := decodeRowsPointer(rows_pointer) + if rows == nil { + return nil + } + + if rows.Next() { + columns, _ := rows.Columns() + rowLength := len(columns) + + rawResult := make([][]byte, rowLength) + rawData := make([]interface{}, rowLength) + for i, _ := range rawResult { // found in stackoverflow, fwiw + rawData[i] = &rawResult[i] // Put pointers to each string in the interface slice + } + + // https://stackoverflow.com/questions/58866962/how-to-pass-an-array-of-strings-and-get-an-array-of-strings-in-ruby-using-go-sha + pointerSize := unsafe.Sizeof(rows_pointer) + // Allocate an array for the string pointers. + var out **C.char + out = (**C.char)(C.malloc(C.ulong(rowLength) * C.ulong(pointerSize))) + + last_error = rows.Scan(rawData...) + if last_error != nil { + return nil + } + pointer := out + for _, raw := range rawResult { + // Find where to store the address of the next string. + // Copy each output string to a C string, and add it to the array. + // C.CString uses malloc to allocate memory. + if raw == nil { + *pointer = nil + } else { + *pointer = C.CString(string(raw)) + } + pointer = (**C.char)(unsafe.Pointer(uintptr(unsafe.Pointer(pointer)) + pointerSize)) + } + return out + } else if rows.Err() == io.EOF { + gopointer.Unref(rows_pointer) // free up for gc + } + return nil +} + +func decodeDbPointer(db_pointer unsafe.Pointer) *sql.DB { + if db_pointer == nil { + last_error = errors.New("db_pointer is null. Cannot process command.") + return nil + } + return gopointer.Restore(db_pointer).(*sql.DB) +} + +func decodeRowsPointer(rows_pointer unsafe.Pointer) *sql.Rows { + if rows_pointer == nil { + last_error = errors.New("rows_pointer null: cannot fetch") + return nil + } + var rows *sql.Rows + rows = gopointer.Restore(rows_pointer).(*sql.Rows) + + if rows == nil { + last_error = errors.New("rows_pointer invalid: Restore returned nil") + } + return rows +} + +func main() {} diff --git a/lib/go_snowflake_client.rb b/lib/go_snowflake_client.rb new file mode 100644 index 0000000..c74753a --- /dev/null +++ b/lib/go_snowflake_client.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +$LOAD_PATH << File.dirname(__FILE__) +require 'ruby_snowflake_client/version' +require 'ffi' + +# Note: this library is not thread safe as it caches the last error +# The call pattern expectation is to call last_error after any call which may have gotten an error. If last_error is +# `nil`, there was no error. +module GoSnowflakeClient + module_function + + # @return String last error or nil. May be end of file which is not really an error + def last_error + error, cptr = GoSnowflakeClientBinding.last_error + LibC.free(cptr) if error + error + end + + # @param account[String] should include everything in the db url ahead of 'snowflakecomputing.com' + # @param port[Integer] + # @return query_object[Pointer] a pointer to use for subsequent calls not inspectable nor viewable by Ruby + def connect(account, warehouse, database, schema, user, password, role, port = 443) + GoSnowflakeClientBinding.connect(account, warehouse, database, schema, user, password, role, port || 443) + end + + # @param db_pointer[Pointer] the pointer which `connect` returned. + def close(db_pointer) + GoSnowflakeClientBinding.close(db_pointer) + end + + # @param db_pointer[Pointer] the pointer which `connect` returned. + # @param statement[String] an executable query which should return number of rows affected + # @return rowcount[Number] number of rows or nil if there was an error + def exec(db_pointer, statement) + count = GoSnowflakeClientBinding.exec(db_pointer, statement) # returns -1 for error + count >= 0 ? count : nil + end + + # Send a query and then yield each row as an array of strings to the given block + # @param db_pointer[Pointer] the pointer which `connect` returned. + # @param query[String] a select query to run. + # @return error_string + # @yield List + def select(db_pointer, sql, field_count: nil) + return 'db_pointer not initialized' unless db_pointer + return to_enum(__method__, db_pointer, sql) unless block_given? + + query_pointer = fetch(db_pointer, sql) + return last_error if query_pointer.nil? || query_pointer == FFI::Pointer::NULL + + field_count ||= column_count(query_pointer) + loop do + row = get_next_row(query_pointer, field_count) + return last_error unless row + + yield row + end + nil + end + + # @param db_pointer[Pointer] the pointer which `connect` returned. + # @param query[String] a select query to run. + # @return query_object[Pointer] a pointer to use for subsequent calls not inspectable nor viewable by Ruby; however, + # if it's `nil`, check `last_error` + def fetch(db_pointer, query) + GoSnowflakeClientBinding.fetch(db_pointer, query) + end + + # @param query_object[Pointer] the pointer which `fetch` returned. Go will gc this object when the query is done; so, + # don't expect to reference it after the call which returned `nil` + # @param field_count[Integer] column count: it will seg fault if you provide a number greater than the actual number. + # Using code should use wrap this in something like + # + # @return [List] the column values in order + def get_next_row(query_object, field_count) + raw_row = GoSnowflakeClientBinding.next_row(query_object) + return nil if raw_row.nil? || raw_row == FFI::Pointer::NULL + + raw_row.get_array_of_pointer(0, field_count).map do |cstr| + if cstr == FFI::Pointer::NULL || cstr.nil? + nil + else + str = cstr.read_string + LibC.free(cstr) + str + end + end + ensure + LibC.free(raw_row) if raw_row + end + + # @param query_object[Pointer] the pointer which `fetch` returned. + # @return [List] the column values in order + def column_names(query_object, field_count = nil) + raw_row = GoSnowflakeClientBinding.query_columns(query_object) + return nil if raw_row.nil? || raw_row == FFI::Pointer::NULL + + raw_row.get_array_of_pointer(0, field_count).map do |cstr| + if cstr == FFI::Pointer::NULL || cstr.nil? + nil + else + str = cstr.read_string + LibC.free(cstr) + str + end + end + ensure + LibC.free(raw_row) if raw_row + end + + # @param query_object[Pointer] the pointer which `fetch` returned. + def column_count(query_object) + GoSnowflakeClientBinding.query_column_count(query_object) + end + + module LibC + extend FFI::Library + ffi_lib(FFI::Library::LIBC) + + attach_function(:free, [:pointer], :void) + end + + module GoSnowflakeClientBinding + extend FFI::Library + + POINTER_SIZE = FFI.type_size(:pointer) + + ffi_lib(File.expand_path('../ext/ruby_snowflake_client.so', __dir__)) + attach_function(:last_error, 'LastError', [], :strptr) + # ugh, `port` in gosnowflake is just :int; however, ruby - ffi -> go is passing 32bit int if I just decl :int. + attach_function(:connect, 'Connect', %i[string string string string string string string int64], :pointer) + attach_function(:close, 'Close', [:pointer], :void) + attach_function(:exec, 'Exec', %i[pointer string], :int64) + attach_function(:fetch, 'Fetch', %i[pointer string], :pointer) + attach_function(:next_row, 'NextRow', [:pointer], :pointer) + attach_function(:query_columns, 'QueryColumns', [:pointer], :pointer) + attach_function(:query_column_count, 'QueryColumnCount', [:pointer], :int32) + end +end diff --git a/lib/ruby_snowflake_client/version.rb b/lib/ruby_snowflake_client/version.rb new file mode 100644 index 0000000..76c17d8 --- /dev/null +++ b/lib/ruby_snowflake_client/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module GoSnowflakeClient + VERSION = '0.2.4' +end diff --git a/ruby_snowflake_client.gemspec b/ruby_snowflake_client.gemspec new file mode 100644 index 0000000..ecda7b1 --- /dev/null +++ b/ruby_snowflake_client.gemspec @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'ruby_snowflake_client/version' + +Gem::Specification.new do |s| + s.name = 'ruby_snowflake_client' + s.version = GoSnowflakeClient::VERSION + s.summary = 'Snowflake connect for Ruby' + s.author = 'CarGurus' + s.email = ['dmitchell@cargurus.com', 'sabbott@cargurus.com'] + s.platform = Gem::Platform::CURRENT + s.description = <<~DESC + Uses gosnowflake to connect to and communicate with Snowflake. + This library is much faster than using ODBC especially for large result sets and avoids ODBC butchering of timezones. + DESC + s.license = 'MIT' # TODO: double check + + s.files = ['ext/ruby_snowflake_client.h', + 'ext/ruby_snowflake_client.so', + 'lib/go_snowflake_client.rb', + 'lib/ruby_snowflake_client/version.rb'] + + # perhaps nothing and figure out how to build and pkg the platform specific .so, or .a, or ... + # s.extensions << "ext/ruby_snowflake_client/extconf.rb" + + s.add_dependency 'ffi' + s.add_development_dependency 'bundler' + s.add_development_dependency 'rake' +end