├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── Gemfile ├── README.md ├── Rakefile ├── bin └── console ├── lib ├── sonic-ruby.rb ├── sonic.rb └── sonic │ ├── channels.rb │ ├── channels │ ├── base.rb │ ├── control.rb │ ├── ingest.rb │ └── search.rb │ ├── client.rb │ ├── connection.rb │ ├── errors.rb │ └── version.rb ├── sonic-ruby.gemspec └── spec ├── sonic ├── channels │ ├── control_spec.rb │ ├── ingest_spec.rb │ └── search_spec.rb └── client_spec.rb ├── spec_helper.rb └── support ├── channel_examples.rb ├── client_context.rb └── config.cfg /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | pkg/ 3 | tmp/ 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Style/Documentation: 3 | Enabled: false 4 | 5 | Naming/FileName: 6 | Exclude: 7 | - lib/sonic-ruby.rb 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | - docker 4 | 5 | language: ruby 6 | rvm: 7 | - 2.6 8 | 9 | before_install: 10 | - docker pull valeriansaliou/sonic:v1.1.9 11 | - docker run -d -p 1491:1491 -v $TRAVIS_BUILD_DIR/spec/support/config.cfg:/etc/sonic.cfg valeriansaliou/sonic:v1.1.9 12 | 13 | script: 14 | - bundle exec rake rubocop 15 | - bundle exec rake spec 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sonic-ruby 2 | 3 | [![Gem Version](https://badge.fury.io/rb/sonic-ruby.svg)](https://badge.fury.io/rb/sonic-ruby) 4 | [![Build Status](https://travis-ci.com/atipugin/sonic-ruby.svg?branch=master)](https://travis-ci.com/atipugin/sonic-ruby) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/dcbb9919a64c96fb629c/maintainability)](https://codeclimate.com/github/atipugin/sonic-ruby/maintainability) 6 | 7 | A Ruby client for [Sonic search backend](https://github.com/valeriansaliou/sonic). 8 | 9 | ## Installation 10 | 11 | Add following line to your Gemfile: 12 | 13 | ```ruby 14 | gem 'sonic-ruby' 15 | ``` 16 | 17 | And then execute: 18 | 19 | ```shell 20 | $ bundle 21 | ``` 22 | 23 | Or install it system-wide: 24 | 25 | ```shell 26 | $ gem install sonic-ruby 27 | ``` 28 | 29 | ## Usage 30 | 31 | Start with creating new client: 32 | 33 | ```ruby 34 | client = Sonic::Client.new('localhost', 1491, 'SecretPassword') 35 | ``` 36 | 37 | Now you can use `#channel` method in order to connect to specific channels: 38 | 39 | ```ruby 40 | control = client.channel(:control) 41 | ingest = client.channel(:ingest) 42 | search = client.channel(:search) 43 | ``` 44 | 45 | [Learn more about Sonic Channels](https://github.com/valeriansaliou/sonic/blob/master/PROTOCOL.md). 46 | 47 | ## Indexing 48 | 49 | ```ruby 50 | # Init `ingest` channel 51 | ingest = client.channel(:ingest) 52 | 53 | # Add data to index 54 | ingest.push('users', 'all', 1, 'Alexander Tipugin') 55 | # => true 56 | 57 | # Remove data from index 58 | ingest.pop('users', 'all', 1, 'Alexander Tipugin') 59 | # => 2 60 | 61 | # Count collection/bucket/object items 62 | ingest.count('users', 'all', 1) 63 | # => 1 64 | 65 | # Flush entire collection 66 | ingest.flushc('users') 67 | # => 1 68 | 69 | # Flush entire bucket inside collection 70 | ingest.flushb('users', 'all') 71 | # => 1 72 | 73 | # Flush specific object inside bucket 74 | ingest.flusho('users', 'all', 1) 75 | # => 2 76 | ``` 77 | 78 | ## Searching 79 | 80 | ```ruby 81 | # Init `search` channel 82 | search = client.channel(:search) 83 | 84 | # Find indexed object by term 85 | search.query('users', 'all', 'tipugin') 86 | # => 1 87 | 88 | # Auto-complete word 89 | search.suggest('users', 'all', 'alex') 90 | # => alexander 91 | ``` 92 | 93 | ## TODO 94 | 95 | - [ ] Take into account maximum buffer size. 96 | - [ ] Consider using connection pool. 97 | - [x] Return more meaningful responses from commands (i.e. bool `true` instead of string `OK`, int `1` instead of string `RESULT 1` etc). 98 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'bundler/setup' 3 | require 'rubocop/rake_task' 4 | require 'rspec/core/rake_task' 5 | 6 | RuboCop::RakeTask.new(:rubocop) do |task| 7 | task.patterns = %w[lib/**/*.rb] 8 | end 9 | 10 | RSpec::Core::RakeTask.new(:spec) 11 | 12 | task default: :spec 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'bundler/setup' 3 | require 'pry' 4 | 5 | require File.expand_path('../../lib/sonic', __FILE__) 6 | 7 | Pry.start 8 | -------------------------------------------------------------------------------- /lib/sonic-ruby.rb: -------------------------------------------------------------------------------- 1 | require 'sonic' 2 | -------------------------------------------------------------------------------- /lib/sonic.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | 3 | require 'sonic/version' 4 | require 'sonic/client' 5 | require 'sonic/channels' 6 | require 'sonic/connection' 7 | require 'sonic/errors' 8 | -------------------------------------------------------------------------------- /lib/sonic/channels.rb: -------------------------------------------------------------------------------- 1 | require 'sonic/channels/base' 2 | require 'sonic/channels/control' 3 | require 'sonic/channels/ingest' 4 | require 'sonic/channels/search' 5 | -------------------------------------------------------------------------------- /lib/sonic/channels/base.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | module Channels 3 | class Base 4 | attr_reader :connection 5 | 6 | def initialize(connection) 7 | @connection = connection 8 | end 9 | 10 | def ping 11 | execute('PING') 12 | end 13 | 14 | def help(manual) 15 | execute('HELP', manual) 16 | end 17 | 18 | def quit 19 | execute('QUIT') 20 | connection.disconnect 21 | end 22 | 23 | private 24 | 25 | def execute(*args) 26 | connection.write(*args.join(' ')) 27 | yield if block_given? 28 | type_cast_response(connection.read) 29 | end 30 | 31 | def normalize(value) 32 | quote(sanitize(value)) 33 | end 34 | 35 | def sanitize(value) 36 | value.gsub('"', '\\"').gsub(/[\r\n]+/, ' ') 37 | end 38 | 39 | def quote(value) 40 | "\"#{value}\"" 41 | end 42 | 43 | def type_cast_response(value) 44 | if value == 'OK' 45 | true 46 | elsif value.start_with?('RESULT ') 47 | value.split(' ').last.to_i 48 | elsif value.start_with?('EVENT ') 49 | value.split(' ')[3..-1].join(' ') 50 | else 51 | value 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/sonic/channels/control.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | module Channels 3 | class Control < Base 4 | def trigger(action) 5 | execute('TRIGGER', action) 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/sonic/channels/ingest.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | module Channels 3 | class Ingest < Base 4 | def push(collection, bucket, object, text, lang = nil) 5 | arr = [collection, bucket, object, quote(text)] 6 | arr << "LANG(#{lang})" if lang 7 | 8 | execute('PUSH', *arr) 9 | end 10 | 11 | def pop(collection, bucket, object, text) 12 | execute('POP', collection, bucket, object, quote(text)) 13 | end 14 | 15 | def count(collection, bucket = nil, object = nil) 16 | execute('COUNT', *[collection, bucket, object].compact) 17 | end 18 | 19 | def flushc(collection) 20 | execute('FLUSHC', collection) 21 | end 22 | 23 | def flushb(collection, bucket) 24 | execute('FLUSHB', collection, bucket) 25 | end 26 | 27 | def flusho(collection, bucket, object) 28 | execute('FLUSHO', collection, bucket, object) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/sonic/channels/search.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | module Channels 3 | class Search < Base 4 | def query(collection, bucket, terms, limit = nil, offset = nil, lang = nil) # rubocop:disable Metrics/ParameterLists, Metrics/LineLength 5 | arr = [collection, bucket, quote(terms)] 6 | arr << "LIMIT(#{limit})" if limit 7 | arr << "OFFSET(#{offset})" if offset 8 | arr << "LANG(#{lang})" if lang 9 | 10 | execute('QUERY', *arr) do 11 | connection.read # ... 12 | end 13 | end 14 | 15 | def suggest(collection, bucket, word, limit = nil) 16 | arr = [collection, bucket, quote(word)] 17 | arr << "LIMIT(#{limit})" if limit 18 | 19 | execute('SUGGEST', *arr) do 20 | connection.read # ... 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/sonic/client.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | class Client 3 | def initialize(host, port, password = nil) 4 | @host = host 5 | @port = port 6 | @password = password 7 | end 8 | 9 | def channel(type) 10 | channel_class(type).new(Connection.connect(@host, @port, type, @password)) 11 | end 12 | 13 | private 14 | 15 | def channel_class(type) 16 | case type.to_sym 17 | when :control then Channels::Control 18 | when :ingest then Channels::Ingest 19 | when :search then Channels::Search 20 | else raise ArgumentError, "`#{type}` channel type is not supported" 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/sonic/connection.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | class Connection 3 | def self.connect(*args) 4 | connection = new(*args) 5 | connection if connection.connect 6 | end 7 | 8 | def initialize(host, port, channel_type, password = nil) 9 | @host = host 10 | @port = port 11 | @channel_type = channel_type 12 | @password = password 13 | end 14 | 15 | def connect 16 | read # ... 17 | write(['START', @channel_type, @password].compact.join(' ')) 18 | read.start_with?('STARTED ') 19 | end 20 | 21 | def disconnect 22 | socket.close 23 | end 24 | 25 | def read 26 | data = socket.gets.chomp 27 | raise ServerError, data if data.start_with?('ERR ') 28 | 29 | data 30 | end 31 | 32 | def write(data) 33 | socket.puts(data) 34 | end 35 | 36 | private 37 | 38 | def socket 39 | @socket ||= TCPSocket.open(@host, @port) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/sonic/errors.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | class Error < StandardError; end 3 | class ServerError < Error; end 4 | end 5 | -------------------------------------------------------------------------------- /lib/sonic/version.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | VERSION = '0.2.3'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /sonic-ruby.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | 4 | require 'sonic/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'sonic-ruby' 8 | spec.version = Sonic::VERSION 9 | spec.summary = 'Ruby client for Sonic' 10 | spec.authors = ['Alexander Tipugin'] 11 | spec.files = `git ls-files -z`.split("\x0").reject { |f| 12 | f.match(%r{^(test|spec|features)/}) 13 | } - ['.rubocop.yml', '.travis.yml', 'Gemfile.lock', '.gitignore', '.rspec'] 14 | 15 | spec.add_development_dependency 'pry' 16 | spec.add_development_dependency 'rake' 17 | spec.add_development_dependency 'rspec' 18 | spec.add_development_dependency 'rubocop', '~> 0.66.0' 19 | end 20 | -------------------------------------------------------------------------------- /spec/sonic/channels/control_spec.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | module Channels 3 | RSpec.describe Control do 4 | include_examples 'channel' 5 | 6 | subject { client.channel(:control) } 7 | 8 | describe '#trigger' do 9 | let(:action) { 'consolidate' } 10 | 11 | it 'returns true' do 12 | expect(subject.trigger(action)).to eq(true) 13 | end 14 | 15 | context 'when action is invalid' do 16 | let(:action) { 'invalid' } 17 | 18 | it 'raises error' do 19 | expect { subject.trigger(action) }.to raise_error(ServerError) 20 | end 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/sonic/channels/ingest_spec.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | module Channels 3 | RSpec.describe Ingest do 4 | include_examples 'channel' 5 | 6 | subject { client.channel(:ingest) } 7 | 8 | let(:collection) { 'collection' } 9 | let(:bucket) { 'bucket' } 10 | let(:object) { 'object' } 11 | let(:text) { 'text' } 12 | 13 | describe '#push' do 14 | it 'returns true' do 15 | expect(subject.push(collection, bucket, object, text)).to eq(true) 16 | end 17 | end 18 | 19 | describe '#pop' do 20 | it 'returns a number' do 21 | expect(subject.pop(collection, bucket, object, text)) 22 | .to be_an(Integer) 23 | end 24 | end 25 | 26 | describe '#count' do 27 | it 'returns a number' do 28 | expect(subject.count(collection, bucket, object)).to be_an(Integer) 29 | end 30 | end 31 | 32 | describe '#flushc' do 33 | it 'returns a number' do 34 | expect(subject.flushc(collection)).to be_an(Integer) 35 | end 36 | end 37 | 38 | describe '#flushb' do 39 | it 'returns a number' do 40 | expect(subject.flushb(collection, bucket)).to be_an(Integer) 41 | end 42 | end 43 | 44 | describe '#flusho' do 45 | it 'returns a number' do 46 | expect(subject.flusho(collection, bucket, object)).to be_an(Integer) 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/sonic/channels/search_spec.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | module Channels 3 | RSpec.describe Search do 4 | include_examples 'channel' 5 | 6 | subject { client.channel(:search) } 7 | 8 | let(:collection) { 'collection' } 9 | let(:bucket) { 'bucket' } 10 | let(:object) { 'object' } 11 | let(:terms) { 'terms' } 12 | 13 | before do 14 | client.channel(:ingest).push(collection, bucket, object, terms) 15 | client.channel(:control).trigger('consolidate') 16 | end 17 | 18 | describe '#query' do 19 | it 'returns proper object' do 20 | expect(subject.query(collection, bucket, terms)).to eq(object) 21 | end 22 | end 23 | 24 | describe '#suggest' do 25 | it 'suggest proper terms' do 26 | expect(subject.suggest(collection, bucket, terms[0..-2])).to eq(terms) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/sonic/client_spec.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | RSpec.describe Client do 3 | include_context 'client' 4 | 5 | subject { client } 6 | 7 | describe '#channel' do 8 | let(:type) { :control } 9 | 10 | it 'returns instance of channel' do 11 | expect(subject.channel(type)).to be 12 | end 13 | 14 | context 'when type is invalid' do 15 | let(:type) { :invalid } 16 | 17 | it 'raises error' do 18 | expect { subject.channel(type) }.to raise_error(ArgumentError) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'sonic' 2 | 3 | Dir['./spec/support/**/*.rb'].each { |f| require f } 4 | -------------------------------------------------------------------------------- /spec/support/channel_examples.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | RSpec.shared_examples 'channel' do 3 | include_context 'client' 4 | 5 | describe '#ping' do 6 | it 'pongs' do 7 | expect(subject.ping).to eq('PONG') 8 | end 9 | end 10 | 11 | describe '#quit' do 12 | it 'closes connection' do 13 | subject.quit 14 | expect { subject.connection.read }.to raise_error(IOError) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/client_context.rb: -------------------------------------------------------------------------------- 1 | module Sonic 2 | RSpec.shared_context 'client' do 3 | let(:client) { Client.new(host, port, password) } 4 | let(:host) { 'localhost' } 5 | let(:port) { 1491 } 6 | let(:password) { 'SecretPassword' } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/config.cfg: -------------------------------------------------------------------------------- 1 | # Sonic 2 | # Fast, lightweight and schema-less search backend 3 | # Configuration file 4 | # Example: https://github.com/valeriansaliou/sonic/blob/master/config.cfg 5 | 6 | 7 | [server] 8 | 9 | log_level = "error" 10 | 11 | 12 | [channel] 13 | 14 | inet = "0.0.0.0:1491" 15 | tcp_timeout = 300 16 | 17 | auth_password = "SecretPassword" 18 | 19 | [channel.search] 20 | 21 | query_limit_default = 10 22 | query_limit_maximum = 100 23 | query_alternates_try = 4 24 | 25 | suggest_limit_default = 5 26 | suggest_limit_maximum = 20 27 | 28 | 29 | [store] 30 | 31 | [store.kv] 32 | 33 | path = "./data/store/kv/" 34 | 35 | retain_word_objects = 1000 36 | 37 | [store.kv.pool] 38 | 39 | inactive_after = 1800 40 | 41 | [store.kv.database] 42 | 43 | compress = true 44 | parallelism = 2 45 | max_files = 100 46 | max_compactions = 1 47 | max_flushes = 1 48 | 49 | [store.fst] 50 | 51 | path = "./data/store/fst/" 52 | 53 | [store.fst.pool] 54 | 55 | inactive_after = 300 56 | 57 | [store.fst.graph] 58 | 59 | consolidate_after = 180 60 | --------------------------------------------------------------------------------