Permalink
Browse files

init commit

  • Loading branch information...
0 parents commit 66f443db9dd7efa9bbba4bb11528e63f0d3573b9 @prepor prepor committed Dec 5, 2011
2 .gitignore
@@ -0,0 +1,2 @@
+pkg
+benchmarks
14 Gemfile
@@ -0,0 +1,14 @@
+source "http://rubygems.org"
+
+gem "eventmachine"
+gem "em-synchrony"
+gem "activemodel"
+
+gem "rr"
+gem "yajl-ruby"
+gem "bson"
+gem "bson_ext"
+
+gem "ruby-debug19"
+
+gemspec
55 Gemfile.lock
@@ -0,0 +1,55 @@
+PATH
+ remote: .
+ specs:
+ tarantool (0.1)
+ activemodel (>= 3.1, < 4.0)
+ em-synchrony (>= 1.0.0, < 2.0)
+ eventmachine (>= 1.0.0.beta.4, < 2.0.0)
+
+GEM
+ remote: http://rubygems.org/
+ specs:
+ activemodel (3.1.1)
+ activesupport (= 3.1.1)
+ builder (~> 3.0.0)
+ i18n (~> 0.6)
+ activesupport (3.1.1)
+ multi_json (~> 1.0)
+ archive-tar-minitar (0.5.2)
+ bson (1.4.0)
+ bson_ext (1.4.0)
+ builder (3.0.0)
+ columnize (0.3.5)
+ em-synchrony (1.0.0)
+ eventmachine (>= 1.0.0.beta.1)
+ eventmachine (1.0.0.beta.4)
+ i18n (0.6.0)
+ linecache19 (0.5.12)
+ ruby_core_source (>= 0.1.4)
+ multi_json (1.0.3)
+ rr (1.0.4)
+ ruby-debug-base19 (0.11.25)
+ columnize (>= 0.3.1)
+ linecache19 (>= 0.5.11)
+ ruby_core_source (>= 0.1.4)
+ ruby-debug19 (0.11.6)
+ columnize (>= 0.3.1)
+ linecache19 (>= 0.5.11)
+ ruby-debug-base19 (>= 0.11.19)
+ ruby_core_source (0.1.5)
+ archive-tar-minitar (>= 0.5.2)
+ yajl-ruby (1.1.0)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ activemodel
+ bson
+ bson_ext
+ em-synchrony
+ eventmachine
+ rr
+ ruby-debug19
+ tarantool!
+ yajl-ruby
24 LICENSE
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2011 Mail.RU
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY AUTHOR AND CONTRIBUTORS ``AS IS'' AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+ * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+ * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+ * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+ * SUCH DAMAGE.
+ */
115 README
@@ -0,0 +1,115 @@
+# About
+
+Its asynchronyous EventMachine ruby client for [Tarantool Key-Value Storage](github.com/mailru/tarantool).
+
+# Install
+
+```bash
+gem install tarantool
+```
+
+```ruby
+require 'tarantool'
+# or
+require 'tarantool/synchrony'
+# or
+require 'tarantool/record'
+```
+
+# Usage
+
+Low level can work in tho mode: EM deferrables and EM-Synchrony.
+
+Before all requests you must configure client:
+
+```ruby
+Tarantool.configure host: 'locahost', port: 33013, space_no: 0
+```
+
+EM deferrables:
+
+```ruby
+req = Tarantool.insert 'prepor', 'Andrew', 'ceo@prepor.ru'
+req.callback do
+ req = Tarantool.select 'prepor'
+ req.callback do |res|
+ puts "Name: #{res.tuple[1].to_s}; Email: #{res.tuple[2].to_s}"
+ end
+end
+req.errback do |err|
+ puts "Error while insert: #{err}"
+end
+```
+
+Synchrony mode:
+
+```ruby
+require 'tarantool/synchrony'
+
+Tarantool.insert 'prepor', 'Andrew', 'ceo@prepor.ru'
+res = Tarantool.select 'prepor'
+puts "Name: #{res.tuple[1].to_s}; Email: #{res.tuple[2].to_s}"
+```
+
+High level part of client implements ActiveModel API: Callbacks, Validations, Serialization, Dirty. Its autocast types, choose right index for query and somthing more. So code looks like this:
+
+```ruby
+require 'tarantool/record'
+require 'tarantool/serializers/bson'
+class User < Tarantool::Record
+ field :login, :string
+ field :name, :string
+ field :email, :string
+ field :apples_count, :integer, default: 0
+ field :info, :bson
+ index :name, :email
+
+ validates_length_of(:login, minimum: 3)
+
+ after_create do
+ # after work!
+ end
+end
+
+# Now attribute positions are not important.
+User.create login: 'prepor', email: 'ceo@prepor.ru', name: 'Andrew'
+User.create login: 'ruden', name: 'Andrew', email: 'rudenkoco@gmail.com'
+
+# find by primary key login
+User.find 'prepor'
+# first 2 users with name Andrew
+User.where(name: 'Andrew').limit(2).all
+# second user with name Andrew
+User.where(name: 'Andrew').offset(1).limit(1).all
+# user with name Andrew and email ceo@prepor.ru
+User.where(name: 'Andrew', email: 'ceo@prepor.ru').first
+# raise exception, becouse we can't select from not first part of index
+begin
+ User.where(email: 'ceo@prepor.ru')
+rescue Tarantool::ArgumentError => e
+end
+# increment field apples_count by one. Its atomic operation vie native Tarantool interface
+User.find('prepor').increment :apples_count
+
+# update only dirty attributes
+user = User.find('prepor')
+user.name = "Petr"
+user.save
+
+# field serialization to bson
+user.info = { 'bio' => "hi!", 'age' => 23, 'hobbies' => ['mufa', 'tuka'] }
+user.save
+User.find('prepor').info['bio'] # => 'hi!'
+user.destroy
+```
+
+On definition of record step, fields order are important, in that order they will be store in Tarantool. By default primary key is first field. `index` method just mapping to your Tarantool schema, client doesn't modify schema for you.
+
+# TODO
+
+* `#first`, `#all` without keys, batches requests via box.select_range
+* `#where` chains
+* admin-socket protocol
+* safe to add fields to exist model
+* Hash, Array and lambdas as default values
+* timers to response, reconnect strategies
0 README.md
No changes.
131 Rakefile
@@ -0,0 +1,131 @@
+require 'rubygems'
+require 'rake'
+require 'date'
+
+#############################################################################
+#
+# Helper functions
+#
+#############################################################################
+
+def name
+ @name ||= Dir['*.gemspec'].first.split('.').first
+end
+
+def version
+ line = File.read("lib/#{name}.rb")[/^\s*VERSION\s*=\s*.*/]
+ line.match(/.*VERSION\s*=\s*['"](.*)['"]/)[1]
+end
+
+def date
+ Date.today.to_s
+end
+
+def rubyforge_project
+ name
+end
+
+def gemspec_file
+ "#{name}.gemspec"
+end
+
+def gem_file
+ "#{name}-#{version}.gem"
+end
+
+def replace_header(head, header_name)
+ head.sub!(/(\.#{header_name}\s*= ').*'/) { "#{$1}#{send(header_name)}'"}
+end
+
+#############################################################################
+#
+# Standard tasks
+#
+#############################################################################
+
+
+task :default => :spec
+require 'rake/testtask'
+Rake::TestTask.new(:spec) do |t|
+ t.libs << 'spec'
+ t.pattern = 'spec/**/*_spec.rb'
+ t.verbose = false
+end
+
+desc "Generate RCov test coverage and open in your browser"
+task :coverage do
+ require 'rcov'
+ sh "rm -fr coverage"
+ sh "rcov test/test_*.rb"
+ sh "open coverage/index.html"
+end
+
+desc "Open an irb session preloaded with this library"
+task :console do
+ sh "irb -rubygems -r ./lib/#{name}.rb"
+end
+
+
+#############################################################################
+#
+# Packaging tasks
+#
+#############################################################################
+
+desc "Create tag v#{version} and build and push #{gem_file} to Rubygems"
+task :release => :build do
+ unless `git branch` =~ /^\* master$/
+ puts "You must be on the master branch to release!"
+ exit!
+ end
+ sh "git commit --allow-empty -a -m 'Release #{version}'"
+ sh "git tag v#{version}"
+ sh "git push origin master"
+ sh "git push origin v#{version}"
+ sh "gem push pkg/#{name}-#{version}.gem"
+end
+
+desc "Build #{gem_file} into the pkg directory"
+task :build => :gemspec do
+ sh "mkdir -p pkg"
+ sh "gem build #{gemspec_file}"
+ sh "mv #{gem_file} pkg"
+end
+
+desc "Generate #{gemspec_file}"
+task :gemspec => :validate do
+ # read spec file and split out manifest section
+ spec = File.read(gemspec_file)
+ head, manifest, tail = spec.split(" # = MANIFEST =\n")
+
+ # replace name version and date
+ replace_header(head, :name)
+ replace_header(head, :version)
+ replace_header(head, :date)
+ #comment this out if your rubyforge_project has a different name
+ replace_header(head, :rubyforge_project)
+
+ # determine file list from git ls-files
+ files = `git ls-files`.
+ split("\n").
+ sort.
+ reject { |file| file =~ /^\./ }.
+ reject { |file| file =~ /^(rdoc|pkg)/ }.
+ map { |file| " #{file}" }.
+ join("\n")
+
+ # piece file back together and write
+ manifest = " s.files = %w[\n#{files}\n ]\n"
+ spec = [head, manifest, tail].join(" # = MANIFEST =\n")
+ File.open(gemspec_file, 'w') { |io| io.write(spec) }
+ puts "Updated #{gemspec_file}"
+end
+
+desc "Validate #{gemspec_file}"
+task :validate do
+ libfiles = Dir['lib/*'] - ["lib/#{name}.rb", "lib/#{name}"]
+ unless Dir['VERSION*'].empty?
+ puts "A `VERSION` file at root level violates Gem best practices."
+ exit!
+ end
+end
21 examples/em_simple.rb
@@ -0,0 +1,21 @@
+require 'bundler'
+ENV['BUNDLE_GEMFILE'] = File.expand_path('../../Gemfile', __FILE__)
+Bundler.setup
+
+require 'tarantool'
+
+EM.run do
+ Tarantool.configure host: 'localhost', port: 33013, space_no: 0
+ req = Tarantool.insert 'prepor', 'Andrew', 'ceo@prepor.ru'
+ req.callback do
+ req = Tarantool.select 'prepor'
+ req.callback do |res|
+ puts "Name: #{res.tuple[1].to_s}; Email: #{res.tuple[2].to_s}"
+ EM.stop
+ end
+ end
+ req.errback do |err|
+ puts "Error while insert: #{err}"
+ EM.stop
+ end
+end
56 examples/record.rb
@@ -0,0 +1,56 @@
+require 'bundler'
+ENV['BUNDLE_GEMFILE'] = File.expand_path('../../Gemfile', __FILE__)
+Bundler.setup
+
+require 'tarantool/record'
+require 'tarantool/serializers/bson'
+class User < Tarantool::Record
+ field :login, :string
+ field :name, :string
+ field :email, :string
+ field :apples_count, :integer, default: 0
+ field :info, :bson
+ index :name, :email
+
+ validates_length_of(:login, minimum: 3)
+
+ after_create do
+ # after work!
+ end
+end
+
+EM.synchrony do
+ Tarantool.configure host: 'localhost', port: 33013, space_no: 0
+ # Now attribute positions are not important.
+ User.create login: 'prepor', email: 'ceo@prepor.ru', name: 'Andrew'
+ User.create login: 'ruden', name: 'Andrew', email: 'rudenkoco@gmail.com'
+
+ # find by primary key login
+ User.find 'prepor'
+ # first 2 users with name Andrew
+ User.where(name: 'Andrew').limit(2).all
+ # second user with name Andrew
+ User.where(name: 'Andrew').offset(1).limit(1).all
+ # user with name Andrew and email ceo@prepor.ru
+ User.where(name: 'Andrew', email: 'ceo@prepor.ru').first
+ # raise exception, becouse we can't select from not first part of index
+ begin
+ User.where(email: 'ceo@prepor.ru')
+ rescue Tarantool::ArgumentError => e
+ end
+ # increment field apples_count by one. Its atomic operation vie native Tarantool interface
+ User.find('prepor').increment :apples_count
+
+ # update only dirty attributes
+ user = User.find('prepor')
+ user.name = "Petr"
+ user.save
+
+ # field serialization to bson
+ user.info = { 'bio' => "hi!", 'age' => 23, 'hobbies' => ['mufa', 'tuka'] }
+ user.save
+ User.find('prepor').info['bio'] # => 'hi!'
+ user.destroy
+ puts "Ok!"
+ EM.stop
+end
13 examples/synchrony_simple.rb
@@ -0,0 +1,13 @@
+require 'bundler'
+ENV['BUNDLE_GEMFILE'] = File.expand_path('../../Gemfile', __FILE__)
+Bundler.setup
+
+require 'tarantool/synchrony'
+
+EM.synchrony do
+ Tarantool.configure host: 'localhost', port: 33013, space_no: 0
+ Tarantool.insert 'prepor', 'Andrew', 'ceo@prepor.ru'
+ res = Tarantool.select 'prepor'
+ puts "Name: #{res.tuple[1].to_s}; Email: #{res.tuple[2].to_s}"
+ EM.stop
+end
67 lib/em/protocols/fixed_header_and_body.rb
@@ -0,0 +1,67 @@
+module EventMachine
+ module Protocols
+ module FixedHeaderAndBody
+
+ def self.included(base)
+ base.extend ClassMethods
+ end
+
+ module ClassMethods
+ def header_size(size = nil)
+ if size
+ @_header_size = size
+ else
+ @_header_size
+ end
+ end
+ end
+
+ attr_accessor :header, :body
+
+ def receive_data(data)
+ @buffer ||= ''
+ offset = 0
+ while (chunk = data[offset, _needed_size - @buffer.size]).size > 0 || _needed_size == 0
+ @buffer += chunk
+ offset += chunk.size
+ if @buffer.size == _needed_size
+ case _state
+ when :receive_header
+ @_state = :receive_body
+ receive_header @buffer
+ when :receive_body
+ @_state = :receive_header
+ receive_body @buffer
+ end
+ @buffer = ''
+ end
+ end
+ end
+
+ def receive_header(header)
+ # for override
+ end
+
+ def body_size
+ # for override
+ end
+
+ def receive_body(body)
+ # for override
+ end
+
+ def _needed_size
+ case _state
+ when :receive_header
+ self.class.header_size
+ when :receive_body
+ body_size
+ end
+ end
+
+ def _state
+ @_state ||= :receive_header
+ end
+ end
+ end
+end
44 lib/tarantool.rb
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+require 'eventmachine'
+require 'em-synchrony'
+
+module Tarantool
+ VERSION = '0.1'
+ extend self
+ require 'tarantool/space'
+ require 'tarantool/connection'
+ require 'tarantool/requests'
+ require 'tarantool/response'
+ require 'tarantool/exceptions'
+ require 'tarantool/serializers'
+
+ def singleton_space
+ @singleton_space ||= Space.new connection, @config[:space_no]
+ end
+
+ def connection
+ @connection ||= begin
+ raise "Tarantool.configure before connect" unless @config
+ EM.connect @config[:host], @config[:port], Tarantool::Connection
+ end
+ end
+
+ def space(no = nil)
+ Space.new connection, no || @config[:space_no]
+ end
+
+ def configure(config = {})
+ @config = config
+ end
+
+ def hexdump(string)
+ string.unpack('C*').map{ |c| "%02x" % c }.join(' ')
+ end
+
+ [:select, :update, :insert, :delete, :call, :ping].each do |v|
+ define_method v do |*params|
+ singleton_space.send v, *params
+ end
+ end
+
+end
54 lib/tarantool/connection.rb
@@ -0,0 +1,54 @@
+require 'em/protocols/fixed_header_and_body'
+module Tarantool
+ class Connection < EM::Connection
+ include EM::Protocols::FixedHeaderAndBody
+
+ header_size 12
+
+ def next_request_id
+ @request_id ||= 0
+ @request_id += 1
+ if @request_id > 0xffffffff
+ @request_id = 0
+ end
+ @request_id
+ end
+
+ def connection_completed
+ @connected = true
+ end
+
+ # begin FixedHeaderAndBody API
+ def body_size
+ @body_size
+ end
+
+ def receive_header(header)
+ @type, @body_size, @request_id = header.unpack('L3')
+ end
+
+ def receive_body(data)
+ clb = waiting_requests[@request_id]
+ raise UnexpectedResponse.new("For request id #{request_id}") unless clb
+ clb.call data
+ end
+ # end FixedHeaderAndBody API
+
+ def waiting_requests
+ @waiting_requests ||= {}
+ end
+
+ def send_packet(request_id, data, &clb)
+ send_data data
+ waiting_requests[request_id] = clb
+ end
+
+ def close_connection(*args)
+ super(*args)
+ end
+
+ def unbind
+ raise CouldNotConnect.new unless @connected
+ end
+ end
+end
11 lib/tarantool/exceptions.rb
@@ -0,0 +1,11 @@
+module Tarantool
+ class TarantoolError < StandardError; end
+ class UndefinedRequestType < TarantoolError; end
+ class CouldNotConnect < TarantoolError; end
+ class BadReturnCode < TarantoolError; end
+ class StringTooLong < TarantoolError; end
+ class ArgumentError < TarantoolError; end
+ class UnexpectedResponse < TarantoolError; end
+ class UndefinedSpace < TarantoolError; end
+ class ValueError < TarantoolError; end
+end
316 lib/tarantool/record.rb
@@ -0,0 +1,316 @@
+require 'active_model'
+require 'tarantool/synchrony'
+module Tarantool
+ class Select
+ include Enumerable
+ attr_reader :record
+ def initialize(record)
+ @record = record
+ end
+
+ def space_no
+ record.space_no
+ end
+
+ def each(&blk)
+ res = Tarantool.select(*@tuples, index_no: @index_no, limit: @limit, offset: @offset).tuples
+ res.each do |tuple|
+ blk.call record.from_server(tuple)
+ end
+ end
+
+ def limit(limit)
+ @limit = limit
+ self
+ end
+
+ def offset(offset)
+ @offset = offset
+ self
+ end
+
+ # id: 1
+ # id: [1, 2, 3]
+ # [{ name: 'a', email: 'a'}, { name: 'b', email: 'b'}]
+ def where(params)
+ raise SelectError.new('Where condition already setted') if @index_no # todo?
+ keys, @tuples = case params
+ when Hash
+ ordered_keys = record.ordered_keys params.keys
+ # name: ['a', 'b'], email: ['c', 'd'] => [['a', 'c'], ['b', 'd']]
+ if params.values.first.is_a?(Array)
+ [ordered_keys, params[ordered_keys.first].zip(*ordered_keys[1, ordered_keys.size].map { |k| params[k] })]
+ else
+ [ordered_keys, [record.hash_to_tuple(params)]]
+ end
+ when Array
+ [params.first.keys, params.map { |v| record.hash_to_tuple(v) }]
+ end
+ @index_no = detect_index_no keys
+ raise ArgumentError.new("Undefined index for keys #{keys}") unless @index_no
+ self
+ end
+
+ # # works fine only on TREE index
+ # def batches(count = 1000, &blk)
+ # raise ArgumentError.new("Only one tuple provided in batch selects") if @tuples.size > 1
+
+ # end
+
+ # def _batch_exec
+ # Tarantool.call proc_name: 'box.select_range', args: [space_no.to_s, @index_no.to_s, count.to_s] + @tuples.first.map(&:to_s), return_tuple: true
+ # end
+
+ def batches_each(&blk)
+ batches { |records| records.each(&blk) }
+ end
+
+ def all
+ to_a
+ end
+
+ def first
+ limit(1).all.first
+ end
+
+ def detect_index_no(keys)
+ index_no = nil
+ record.indexes.each_with_index do |v, i|
+ keys_inst = keys.dup
+ v.each do |index_part|
+ unless keys_inst.delete(index_part)
+ break
+ end
+ if keys_inst.size == 0
+ index_no = i
+ end
+ end
+ break if index_no
+ end
+ index_no
+ end
+ end
+ class Record
+ extend ActiveModel::Naming
+ include ActiveModel::AttributeMethods
+ include ActiveModel::Validations
+ include ActiveModel::Serialization
+ extend ActiveModel::Callbacks
+ include ActiveModel::Dirty
+
+ include ActiveModel::Serializers::JSON
+ include ActiveModel::Serializers::Xml
+
+ define_model_callbacks :save, :create, :update, :destroy
+
+ class_attribute :fields
+ self.fields = {}
+
+ class_attribute :default_values
+ self.default_values = {}
+
+ class_attribute :primary_index
+ class_attribute :indexes
+ self.indexes = []
+
+ class_attribute :space_no
+ define_attr_method :space_no do
+ original_space_no || 0
+ end
+ class << self
+ def field(name, type, params = {})
+ define_attribute_method name
+ self.fields = fields.merge name => { type: type, field_no: fields.size, params: params }
+ unless self.primary_index
+ self.primary_index = name
+ index name
+ end
+ if params[:default]
+ self.default_values = default_values.merge name => params[:default]
+ end
+ define_method name do
+ attributes[name]
+ end
+ define_method "#{name}=" do |v|
+ send("#{name}_will_change!") unless v == attributes[name]
+ attributes[name] = v
+ end
+ end
+
+ def index(*fields)
+ self.indexes = (indexes.dup << fields).sort_by { |v| v.size }
+ end
+
+ def find(*keys)
+ res = space.select(*keys)
+ if keys.size == 1
+ if res.tuple
+ from_server res.tuple
+ else
+ nil
+ end
+ else
+ res.tuples.map { |tuple| from_server tuple }
+ end
+ end
+
+ def select
+ Select.new(self)
+ end
+
+ %w{where limit offset}.each do |v|
+ define_method v do |*args|
+ select.send(v, *args)
+ end
+ end
+
+ def create(attribites = {})
+ new(attribites).tap { |o| o.save }
+ end
+
+ def from_server(tuple)
+ new(tuple_to_hash(tuple)).tap { |v| v.old_record! }
+ end
+
+ def space
+ @space ||= Tarantool.space space_no
+ end
+
+ def tuple_to_hash(tuple)
+ fields.keys.zip(tuple).inject({}) do |memo, (k, v)|
+ memo[k] = _cast(k, v) unless v.nil?
+ memo
+ end
+ end
+
+ def hash_to_tuple(hash, with_nils = false)
+ res = []
+ fields.keys.each do |k|
+ v = hash[k]
+ res << _cast(k, v) if with_nils || !v.nil?
+ end
+ res
+ end
+
+ def ordered_keys(keys)
+ fields.keys.inject([]) do |memo, k|
+ keys.each do |k2|
+ memo << k2 if k2 == k
+ end
+ memo
+ end
+ end
+
+ def _cast(name, value)
+ type = self.fields[name][:type]
+ serializer = _get_serializer(type)
+ if value.is_a?(Field)
+ return nil if value.data == "\0"
+ serializer.decode(value)
+ else
+ return "\0" if value.nil?
+ serializer.encode(value)
+ end
+ end
+
+ def _get_serializer(type)
+ Serializers::MAP[type] || raise(TarantoolError.new("Undefind serializer #{type}"))
+ end
+ end
+
+ attr_accessor :new_record
+ def initialize(attributes = {})
+ attributes.each do |k, v|
+ send("#{k}=", v)
+ end
+ @new_record = true
+ end
+
+ def id
+ attributes[self.class.primary_index]
+ end
+
+ def space
+ self.class.space
+ end
+
+ def new_record?
+ @new_record
+ end
+
+ def attributes
+ @attributes ||= self.class.default_values.dup
+ end
+
+ def new_record!
+ @new_record = true
+ end
+
+ def old_record!
+ @new_record = false
+ end
+
+ def save
+ def in_callbacks(&blk)
+ run_callbacks(:save) { run_callbacks(new_record? ? :create : :update, &blk)}
+ end
+ in_callbacks do
+ if valid?
+ if new_record?
+ space.insert(*to_tuple)
+ else
+ ops = changed.inject([]) do |memo, k|
+ k = k.to_sym
+ memo << [field_no(k), :set, self.class._cast(k, attributes[k])] if attributes[k]
+ memo
+ end
+ space.update id, ops: ops
+ end
+ @previously_changed = changes
+ @changed_attributes.clear
+ old_record!
+ true
+ else
+ false
+ end
+ end
+ end
+
+ def update_attribute(field, value)
+ self.send("#{field}=", value)
+ save
+ end
+
+ def update_attributes(attributes)
+ attributes.each do |k, v|
+ self.send("#{k}=", v)
+ end
+ save
+ end
+
+ def increment(field, by = 1)
+ space.update id, ops: [[field_no(field), :add, by]]
+ end
+
+ def destroy
+ run_callbacks :destroy do
+ space.delete id
+ true
+ end
+ end
+
+ def to_tuple
+ self.class.hash_to_tuple attributes, true
+ end
+
+ def field_no(name)
+ self.class.fields[name][:field_no]
+ end
+
+ # return new object, not reloading itself as AR-model
+ def reload
+ self.class.find(id)
+ end
+
+ end
+end
94 lib/tarantool/request.rb
@@ -0,0 +1,94 @@
+module Tarantool
+ class Request
+ include EM::Deferrable
+
+ class << self
+ def request_type(name = nil)
+ if name
+ @request_type = Tarantool::Requests::REQUEST_TYPES[name] || raise(UndefinedRequestType)
+ else
+ @request_type
+ end
+ end
+
+ def pack_tuple(*values)
+ [values.size].pack('L') + values.map { |v| pack_field(v) }.join
+ end
+
+ def pack_field(value)
+ if String === value
+ raise StringTooLong.new if value.bytesize > 1024 * 1024
+ [value.bytesize, value].pack('wa*')
+ elsif Integer === value
+ if value < 4294967296 # 2 ^ 32
+ [4, value].pack('wL')
+ else
+ [8, value].pack('wQ')
+ end
+ elsif value.is_a?(Tarantool::Field)
+ [value.data.bytesize].pack('w') + value.data
+ else
+ raise ArgumentError.new("Field should be integer or string")
+ end
+ end
+ end
+
+ attr_reader :space, :params, :args
+ attr_reader :space_no
+ def initialize(space, *args)
+ @space = space
+ @args = args
+ @params = if args.last.is_a? Hash
+ args.pop
+ else
+ {}
+ end
+ @space_no = params.delete(:space_no) || space.space_no || raise(UndefinedSpace.new)
+ parse_args
+ end
+
+ def perform
+ send_packet(make_packet(make_body))
+ self
+ end
+
+ def parse_args
+
+ end
+
+ def request_id
+ @request_id ||= connection.next_request_id
+ end
+
+ def make_packet(body)
+ [self.class.request_type, body.size, request_id].pack('LLL') +
+ body
+ end
+
+ def send_packet(packet)
+ connection.send_packet request_id, packet do |data|
+ make_response data
+ end
+ end
+
+ def make_response(data)
+ return_code, = data[0,4].unpack('L')
+ if return_code == 0
+ succeed Response.new(data[4, data.size], response_params)
+ else
+ msg = data[4, data.size].unpack('a*')
+ fail BadReturnCode.new("Error code #{return_code}: #{msg}")
+ end
+ end
+
+ def response_params
+ res = {}
+ res[:return_tuple] = true if params[:return_tuple]
+ res
+ end
+
+ def connection
+ space.connection
+ end
+ end
+end
19 lib/tarantool/requests.rb
@@ -0,0 +1,19 @@
+module Tarantool
+ require 'tarantool/request'
+ module Requests
+ REQUEST_TYPES = {
+ insert: 13,
+ select: 17,
+ update: 19,
+ delete: 21,
+ call: 22,
+ ping: 65280
+ }
+ BOX_RETURN_TUPLE = 1
+ BOX_ADD = 2
+
+ %w{insert select update delete call ping}.each do |v|
+ require "tarantool/requests/#{v}"
+ end
+ end
+end
20 lib/tarantool/requests/call.rb
@@ -0,0 +1,20 @@
+module Tarantool
+ module Requests
+ class Call < Request
+ request_type :call
+
+ attr_reader :flags, :proc_name, :tuple
+ def parse_args
+ @flags = params[:return_tuple] ? 1 : 0
+ @proc_name = params[:proc_name]
+ @tuple = params[:args] || []
+ end
+
+ def make_body
+ [flags].pack('L') +
+ self.class.pack_field(proc_name) +
+ self.class.pack_tuple(*tuple)
+ end
+ end
+ end
+end
18 lib/tarantool/requests/delete.rb
@@ -0,0 +1,18 @@
+module Tarantool
+ module Requests
+ class Delete < Request
+ request_type :delete
+
+ attr_reader :flags, :key
+ def parse_args
+ @flags = params[:return_tuple] ? 1 : 0
+ @key = params[:key] || args.first
+ end
+
+ def make_body
+ [space_no, flags].pack('LL') +
+ self.class.pack_tuple(key)
+ end
+ end
+ end
+end
19 lib/tarantool/requests/insert.rb
@@ -0,0 +1,19 @@
+module Tarantool
+ module Requests
+ class Insert < Request
+ request_type :insert
+
+ attr_reader :flags, :values
+ def parse_args
+ @flags = BOX_ADD
+ @flags |= BOX_RETURN_TUPLE if params[:return_tuple]
+ @values = params[:values] || args
+ end
+
+ def make_body
+ [space_no, flags].pack('LL') +
+ self.class.pack_tuple(*values)
+ end
+ end
+ end
+end
16 lib/tarantool/requests/ping.rb
@@ -0,0 +1,16 @@
+module Tarantool
+ module Requests
+ class Ping < Request
+ request_type :ping
+
+ def make_body
+ @start_time = Time.now
+ ''
+ end
+
+ def make_response(data)
+ succeed(Time.now - @start_time)
+ end
+ end
+ end
+end
22 lib/tarantool/requests/select.rb
@@ -0,0 +1,22 @@
+module Tarantool
+ module Requests
+ class Select < Request
+ request_type :select
+
+ attr_reader :index_no, :offset, :limit, :count, :tuples
+ def parse_args
+ @index_no = params[:index_no] || 0
+ @offset = params[:offset] || 0
+ @limit = params[:limit] || -1
+ @tuples = params[:values] || args
+ raise(ArgumentError.new('values are required')) if tuples.empty?
+ params[:return_tuple] = true
+ end
+
+ def make_body
+ [space_no, index_no, offset, limit, tuples.size].pack('LLLLL') +
+ tuples.map { |tuple| self.class.pack_tuple(*tuple) }.join
+ end
+ end
+ end
+end
35 lib/tarantool/requests/update.rb
@@ -0,0 +1,35 @@
+module Tarantool
+ module Requests
+ class Update < Request
+ request_type :update
+
+ OP_CODES = { set: 0, add: 1, and: 2, or: 3, xor: 4, splice: 5 }
+
+ def self.pack_ops(ops)
+ ops.map do |op|
+ raise ArgumentError.new('Operation should be array of size 3') unless op.size == 3
+
+ field_no, op_symbol, op_arg = op
+ op_code = OP_CODES[op_symbol] || raise(ArgumentError.new("Unsupported operation symbol '#{op_symbol}'"))
+
+ [field_no, op_code].pack('LC') + self.pack_field(op_arg)
+ end.join
+ end
+
+ attr_reader :flags, :key, :ops
+ def parse_args
+ @flags = params[:return_tuple] ? 1 : 0
+ @key = params[:key] || args.first
+ @ops = params[:ops]
+ raise ArgumentError.new('Key is required') unless key
+ end
+
+ def make_body
+ [space_no, flags].pack('LL') +
+ self.class.pack_tuple(key) +
+ [ops.size].pack('L') +
+ self.class.pack_ops(ops)
+ end
+ end
+ end
+end
58 lib/tarantool/response.rb
@@ -0,0 +1,58 @@
+module Tarantool
+ class Field
+ attr_reader :data
+ def initialize(data)
+ @data = data
+ end
+
+ def to_i
+ if data.bytesize == 4
+ data.unpack('L')[0]
+ elsif data.bytesize == 8
+ data.unpack('Q')[0]
+ else
+ raise ValueError.new("Unable to cast field to int: length must be 4 or 8 bytes, field length is #{data.size}")
+ end
+ end
+
+ def to_s
+ data.dup.force_encoding('utf-8')
+ end
+ end
+ class Response
+ attr_reader :tuples_affected, :offset, :tuples
+ def initialize(data, params = {})
+ @offset = 0
+ @tuples_affected, = data[0, 4].unpack('L')
+ @offset += 4
+ if params[:return_tuple]
+ @tuples = (1..tuples_affected).map do
+ unpack_tuple(data)
+ end
+ else
+ tuples_affected
+ end
+ end
+
+ # Only select request can return many tuples
+ def tuple
+ tuples.first
+ end
+
+ def unpack_tuple(data)
+ byte_size, cardinality = data[offset, 8].unpack("LL")
+ @offset += 8
+ tuple_data = data[offset, byte_size]
+ @offset += byte_size
+ (1..cardinality).map do
+ Field.new unpack_field(tuple_data)
+ end
+ end
+
+ def unpack_field(data)
+ byte_size, = data.unpack('w')
+ data.slice!(0, [byte_size].pack('w').bytesize) # ololo
+ data.slice!(0, byte_size)
+ end
+ end
+end
9 lib/tarantool/serializers.rb
@@ -0,0 +1,9 @@
+module Tarantool
+ module Serializers
+ MAP = {}
+ %w{string integer}.each do |v|
+ require "tarantool/serializers/#{v}"
+ end
+
+ end
+end
15 lib/tarantool/serializers/bson.rb
@@ -0,0 +1,15 @@
+require 'bson'
+module Tarantool
+ module Serializers
+ class BSON
+ Serializers::MAP[:bson] = self
+ def self.encode(value)
+ ::BSON.serialize(value).to_s
+ end
+
+ def self.decode(field)
+ ::BSON.deserialize(field.to_s)
+ end
+ end
+ end
+end
14 lib/tarantool/serializers/integer.rb
@@ -0,0 +1,14 @@
+module Tarantool
+ module Serializers
+ class Integer
+ Serializers::MAP[:integer] = self
+ def self.encode(value)
+ value.to_i
+ end
+
+ def self.decode(field)
+ field.to_i
+ end
+ end
+ end
+end
14 lib/tarantool/serializers/string.rb
@@ -0,0 +1,14 @@
+module Tarantool
+ module Serializers
+ class String
+ Serializers::MAP[:string] = self
+ def self.encode(value)
+ value.to_s
+ end
+
+ def self.decode(field)
+ field.to_s
+ end
+ end
+ end
+end
39 lib/tarantool/space.rb
@@ -0,0 +1,39 @@
+module Tarantool
+ class Space
+ attr_accessor :space_no
+ attr_reader :connection
+ def initialize(connection, space_no = nil)
+ @connection = connection
+ @space_no = space_no
+ end
+
+ def select(*args)
+ request Requests::Select, args
+ end
+
+ def call(*args)
+ request Requests::Call, args
+ end
+
+ def insert(*args)
+ request Requests::Insert, args
+ end
+
+
+ def delete(*args)
+ request Requests::Delete, args
+ end
+
+ def update(*args)
+ request Requests::Update, args
+ end
+
+ def ping(*args)
+ request Requests::Ping, args
+ end
+
+ def request(cls, args)
+ cls.new(self, *args).perform
+ end
+ end
+end
13 lib/tarantool/synchrony.rb
@@ -0,0 +1,13 @@
+require 'tarantool'
+require 'em-synchrony'
+
+module Tarantool
+ class Space
+ alias :deffered_request :request
+ def request(*args)
+ EM::Synchrony.sync(deffered_request(*args)).tap do |v|
+ raise v if v.is_a?(Exception)
+ end
+ end
+ end
+end
11 spec/helpers/let.rb
@@ -0,0 +1,11 @@
+module Helpers
+ module Let
+ def let(name, &blk)
+ define_method name do
+ @let_assigments ||= {}
+ @let_assigments[name] ||= send(:"original_#{name}")
+ end
+ define_method "original_#{name}", &blk
+ end
+ end
+end
12 spec/helpers/truncate.rb
@@ -0,0 +1,12 @@
+module Helpers
+ module Truncate
+ def teardown
+ while (res = Tarantool.call(proc_name: 'box.select_range', args: [Tarantool.singleton_space.space_no.to_s, '0', '100'], return_tuple: true)) && res.tuples.size > 0
+ res.tuples.each do |k, *_|
+ Tarantool.delete key: k
+ end
+ end
+ super
+ end
+ end
+end
28 spec/spec_helper.rb
@@ -0,0 +1,28 @@
+require 'bundler'
+ENV['BUNDLE_GEMFILE'] = File.expand_path('../../Gemfile', __FILE__)
+Bundler.setup
+
+require 'minitest/spec'
+
+require 'helpers/let'
+require 'helpers/truncate'
+require 'rr'
+
+require 'tarantool/synchrony'
+
+config = { host: '10.211.55.3', port: 33013, space_no: 0 }
+
+Tarantool.configure config
+
+class MiniTest::Unit::TestCase
+ extend Helpers::Let
+ include RR::Adapters::MiniTest
+end
+
+at_exit {
+ EM.synchrony do
+ exit_code = MiniTest::Unit.new.run(ARGV)
+ EM.stop
+ exit_code
+ end
+}
32 spec/tarantool.cfg
@@ -0,0 +1,32 @@
+slab_alloc_arena = 0.1
+pid_file = "box.pid"
+
+logger="cat - >> tarantool.log"
+
+primary_port = 33013
+secondary_port = 33014
+admin_port = 33015
+
+rows_per_wal = 50
+
+space[0].enabled = 1
+
+space[0].index[0].type = "HASH"
+space[0].index[0].unique = 1
+space[0].index[0].key_field[0].fieldno = 0
+space[0].index[0].key_field[0].type = "STR"
+
+space[0].index[1].type = "TREE"
+space[0].index[1].unique = 1
+space[0].index[1].key_field[0].fieldno = 1
+space[0].index[1].key_field[0].type = "STR"
+space[0].index[1].key_field[1].fieldno = 2
+space[0].index[1].key_field[1].type = "STR"
+
+
+space[1].enabled = 1
+
+space[1].index[0].type = "HASH"
+space[1].index[0].unique = 1
+space[1].index[0].key_field[0].fieldno = 0
+space[1].index[0].key_field[0].type = "NUM"
247 spec/tarantool/record_spec.rb
@@ -0,0 +1,247 @@
+# -*- coding: utf-8 -*-
+require 'spec_helper'
+require 'tarantool/record'
+require 'yajl'
+require 'tarantool/serializers/bson'
+describe Tarantool::Record do
+ include Helpers::Truncate
+ before do
+ Tarantool.singleton_space.space_no = 0
+ end
+ let(:user_class) do
+ Class.new(Tarantool::Record) do
+ def self.name # For naming
+ "User"
+ end
+
+ field :login, :string
+ field :name, :string
+ field :email, :string
+ field :apples_count, :integer, default: 0
+ index :name, :email
+ end
+ end
+
+ let(:user) { user_class.new }
+ it "should set and get attributes" do
+ user.name = 'Andrew'
+ user.name.must_equal 'Andrew'
+ end
+
+ describe "inheritance" do
+ let(:author_class) do
+ Class.new(user_class) do
+ field :best_book, Integer
+ end
+ end
+ let(:artist_class) do
+ Class.new(user_class) do
+ field :imdb_id, Integer
+ end
+ end
+
+ describe "Artist from User" do
+ it "should has only itself field" do
+ artist_class.fields.keys.must_equal [:login, :name, :email, :apples_count, :imdb_id]
+ end
+ end
+
+ it "should has same space no as parent" do
+ user_class.space_no = 1
+ artist_class.space_no.must_equal 1
+ end
+
+ it "should has different space no to parent if setted" do
+ artist_class.space_no = 1
+ user_class.space_no.must_equal 0
+ artist_class.space_no.must_equal 1
+ end
+ end
+
+ describe "detect_index_no" do
+ let(:select) { user_class.select }
+ it "should return 0 for :login" do
+ select.detect_index_no([:login]).must_equal 0
+ end
+ it "should return 1 for :name" do
+ select.detect_index_no([:name]).must_equal 1
+ end
+ it "should return 1 for :name, :email" do
+ select.detect_index_no([:name, :email]).must_equal 1
+ end
+ it "should return nil for :email" do
+ select.detect_index_no([:email]).must_be_nil
+ end
+ it "should return nil for :login, :name" do
+ select.detect_index_no([:login, :name]).must_be_nil
+ end
+ end
+
+ describe "save" do
+ it "should save and select record" do
+ u = user_class.new login: 'prepor', name: 'Andrew', email: 'ceo@prepor.ru'
+ u.save
+ u = user_class.find 'prepor'
+ u.id.must_equal 'prepor'
+ u.email.must_equal 'ceo@prepor.ru'
+ u.name.must_equal 'Andrew'
+ u.apples_count.must_equal 0
+ end
+
+ it "should update dirty attributes" do
+ u = user_class.create login: 'prepor', name: 'Andrew', email: 'ceo@prepor.ru'
+ u.name = 'Petr'
+ u.save
+ u = user_class.find 'prepor'
+ u.email.must_equal 'ceo@prepor.ru'
+ u.name.must_equal 'Petr'
+ end
+
+ describe "with nils" do
+ before do
+ user_class.field :info, :bson
+ end
+ it "should work properly with nils values" do
+ u = user_class.create login: 'prepor', name: 'Andrew', email: 'ceo@prepor.ru', apples_count: nil
+ u.info.must_be_nil
+ u.apples_count.must_be_nil
+ u = u.reload
+ u.info.must_be_nil
+ u.apples_count.must_be_nil
+ u.info = {'bio' => 'hi!'}
+ u.apples_count = 1
+ u.save
+ u = u.reload
+ u.info.must_equal({ 'bio' => 'hi!' })
+ u.apples_count.must_equal 1
+ end
+ end
+ end
+
+ describe "reload" do
+ it "should reload current record" do
+ u = user_class.create login: 'prepor', name: 'Andrew', email: 'ceo@prepor.ru'
+ u.name = 'Petr'
+ u.reload.name.must_equal 'Andrew'
+ end
+ end
+
+ describe "increment" do
+ let(:user) { user_class.create login: 'prepor', name: 'Andrew', email: 'ceo@prepor.ru' }
+ it "should increment apples count by 1" do
+ user.increment :apples_count
+ user.reload.apples_count.must_equal 1
+ end
+
+ it "should increment apples count by 3" do
+ user.increment :apples_count, 3
+ user.reload.apples_count.must_equal 3
+ end
+ end
+
+ describe "destroy" do
+ it "should destroy record" do
+ u = user_class.create login: 'prepor', name: 'Andrew', email: 'ceo@prepor.ru'
+ u.destroy
+ u.reload.must_be_nil
+ end
+ end
+
+ describe "validations" do
+ describe "with validator on login size" do
+ before do
+ user_class.validates_length_of(:login, minimum: 3)
+ end
+ it "should invalidate all records with login less then 3 chars" do
+ u = user_class.new login: 'pr', name: 'Andrew', email: 'ceo@prepor.ru'
+ u.save.must_equal false
+ u.valid?.must_equal false
+ u.errors.size.must_equal 1
+ u.login = 'prepor'
+ u.save.must_equal true
+ u.valid?.must_equal true
+ u.errors.size.must_equal 0
+ end
+ end
+ end
+
+ describe "callbacks" do
+ it "should run before / after create callbackss in right places" do
+ user_class.before_create :action_before_create
+ user_class.after_create :action_after_create
+
+ u = user_class.new login: 'prepor', name: 'Andrew', email: 'ceo@prepor.ru'
+ mock(u).action_before_create { u.new_record?.must_equal true }
+ mock(u).action_after_create { u.new_record?.must_equal false }
+ u.save
+ end
+ end
+
+ describe "serialization" do
+ it "should support AM serialization API" do
+ h = { login: 'prepor', name: 'Andrew', email: 'ceo@prepor.ru' }
+ u = user_class.create h
+ u.as_json.must_equal({ 'user' => h.merge(apples_count: 0) })
+ end
+
+ describe "fields serilizers" do
+ before do
+ user_class.field :info, :bson
+ end
+
+ it "should serialise and deserialize info field" do
+ info = { 'bio' => "hi!", 'age' => 23, 'hobbies' => ['mufa', 'tuka'] }
+ u = user_class.create login: 'prepor', name: 'Andrew', email: 'ceo@prepor.ru', info: info
+ u.info['hobbies'].must_equal ['mufa', 'tuka']
+ u = u.reload
+ u.info['hobbies'].must_equal ['mufa', 'tuka']
+ end
+ end
+ end
+
+ describe "select" do
+ describe "by name Andrew" do
+ let(:select) { user_class.where(name: 'Andrew') }
+ before do
+ user_class.create login: 'prepor', name: 'Andrew', email: 'ceo@prepor.ru'
+ user_class.create login: 'petro', name: 'Petr', email: 'petro@gmail.com'
+ user_class.create login: 'ruden', name: 'Andrew', email: 'rudenkoco@gmail.com'
+ end
+ it "should select all records with name == 'Andrew'" do
+ select.all.map(&:login).must_equal ['prepor', 'ruden']
+ end
+
+ it "should select first record with name == 'Andrew'" do
+ select.first.login.must_equal 'prepor'
+ end
+
+ it "should select 1 record by name and email" do
+ user_class.where(name: 'Andrew', email: 'rudenkoco@gmail.com').map(&:login).must_equal ['ruden']
+ end
+
+ it "should select 2 record by name and email" do
+ user_class.where(name: ['Andrew', 'Andrew'], email: ['ceo@prepor.ru', 'rudenkoco@gmail.com']).map(&:login).must_equal ['prepor', 'ruden']
+ end
+
+ it "should select 3 record by names" do
+ user_class.where(name: ['Andrew', 'Petr']).map(&:login).must_equal ['prepor', 'ruden', 'petro']
+ end
+
+ describe "with limit 1" do
+ let(:select) { super().limit(1) }
+ it "should select first record with name == 'Andrew'" do
+ select.map(&:login).must_equal ['prepor']
+ end
+
+ describe "with offset 1" do
+ let(:select) { super().offset(1) }
+ it "should select last record with name == 'Andrew'" do
+ select.map(&:login).must_equal ['ruden']
+ end
+ end
+ end
+
+ end
+ end
+
+end
114 spec/tarantool/request_spec.rb
@@ -0,0 +1,114 @@
+# -*- coding: utf-8 -*-
+require 'spec_helper'
+
+describe Tarantool::Request do
+ before do
+ Tarantool.singleton_space.space_no = 1
+ end
+ describe "pack method" do
+ describe "for field" do
+ it "should pack integer as 32 bit integer" do
+ size, value = Tarantool::Request.pack_field(5).unpack('wL')
+ value.must_equal 5
+ end
+
+ it "should pack string as arbitrary binary string" do
+ size, value = Tarantool::Request.pack_field("привет").unpack('wa*')
+ value.force_encoding('utf-8').must_equal 'привет'
+ end
+
+ it "should raise ArgumentError for other types" do
+ lambda { Tarantool::Request.pack_field(:foo) }.must_raise Tarantool::ArgumentError
+ end
+ end
+
+ describe "for tuple" do
+ it "should pack to fields with fields count" do
+ field1, field2 = 1, "привет"
+ expect = [2, 4, field1, field2.bytesize, field2].pack('LwLwa*')
+ Tarantool::Request.pack_tuple(field1, field2).must_equal expect
+ end
+ end
+ end
+
+ describe "instance" do
+ let(:request) { Tarantool::Requests::Insert.new Tarantool.space }
+
+ it "should make packet with right request type, body size and next request id + body" do
+ body = 'hi'
+ expect = [Tarantool::Requests::REQUEST_TYPES[:insert], body.bytesize, request.request_id].pack('LLL') + body
+ request.make_packet(body).must_equal expect
+ end
+
+ end
+
+ describe "requests" do
+ include Helpers::Truncate
+ describe "insert and select" do
+ it "should insert tuple and return it" do
+ Tarantool.insert 100, 'привет', return_tuple: true
+ res = Tarantool.select 100
+ int, string = res.tuple
+ int.to_i.must_equal 100
+ string.to_s.must_equal 'привет'
+ end
+
+ describe "with equal ids" do
+ it "should raise error" do
+ Tarantool.insert 100, 'lala'
+ lambda { Tarantool.insert 100, 'yo' }.must_raise(Tarantool::BadReturnCode)
+ end
+ end
+ end
+
+ describe "select" do
+ it "should select multiple tuples" do
+ Tarantool.insert 100, 'привет'
+ Tarantool.insert 101, 'hi'
+ res = Tarantool.select 100, 101
+ res.tuples.map { |v| v.last.to_s }.must_equal ['привет', 'hi']
+ end
+ end
+
+ describe "call" do
+ it "should call lua proc" do
+ res = Tarantool.call proc_name: 'box.pack', args: ['i', '100'], return_tuple: true
+ res.tuple[0].to_i.must_equal 100
+ end
+
+ it "should return batches via select_range" do
+ Tarantool.insert 100, 'привет'
+ Tarantool.insert 101, 'hi'
+ res = Tarantool.call proc_name: 'box.select_range', args: ['1', '0', '100'], return_tuple: true
+ res.tuples.size.must_equal 2
+ end
+ end
+
+ describe "update" do
+ it "should update tuple" do
+ Tarantool.insert 100, 'привет'
+ Tarantool.update 100, ops: [[1, :set, 'yo!']]
+ res = Tarantool.select 100
+ int, string = res.tuple
+ string.to_s.must_equal 'yo!'
+ end
+ end
+
+ describe "delete" do
+ it "should delete record" do
+ inserted = Tarantool.insert 100, 'привет', return_tuple: true
+ Tarantool.delete inserted.tuple[0], return_tuple: true
+ res = Tarantool.select 100
+ res.tuple.must_be_nil
+ end
+ end
+
+ describe "ping" do
+ it "should ping without exceptions" do
+ res = Tarantool.ping
+ res.must_be_kind_of Numeric
+ end
+ end
+
+ end
+end
36 tarantool.gemspec
@@ -0,0 +1,36 @@
+Gem::Specification.new do |s|
+ s.specification_version = 2 if s.respond_to? :specification_version=
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
+ s.rubygems_version = '1.3.5'
+
+ s.name = 'tarantool'
+ s.version = '0.1'
+ s.date = '2011-12-05'
+ s.rubyforge_project = 'tarantool'
+
+ s.summary = "Tarantool KV-storage client."
+ s.description = "Tarantool KV-storage client."
+
+ s.authors = ["Andrew Rudenko"]
+ s.email = 'ceo@prepor.ru'
+ s.homepage = 'http://github.com/mailru/tarantool-ruby'
+
+ s.require_paths = %w[lib]
+
+ s.rdoc_options = ["--charset=UTF-8"]
+ s.extra_rdoc_files = %w[README LICENSE]
+
+ s.add_dependency('eventmachine', [">= 1.0.0.beta.4", "< 2.0.0"])
+ s.add_dependency('activemodel', [">= 3.1", "< 4.0"])
+ s.add_dependency('em-synchrony', [">= 1.0.0", "< 2.0"])
+
+ # = MANIFEST =
+ s.files = %w[
+
+ ]
+ # = MANIFEST =
+
+ ## Test files will be grabbed from the file list. Make sure the path glob
+ ## matches what you actually use.
+ s.test_files = s.files.select { |path| path =~ /^spec\/.*_spec\.rb/ }
+end

0 comments on commit 66f443d

Please sign in to comment.