From ed6f7466dea521f51f4ded4ab566a2e1227a4365 Mon Sep 17 00:00:00 2001 From: Andrew Kane Date: Tue, 2 Feb 2021 01:46:47 -0800 Subject: [PATCH] First commit --- .github/workflows/build.yml | 29 ++++ .gitignore | 9 ++ CHANGELOG.md | 3 + Gemfile | 6 + LICENSE.txt | 202 ++++++++++++++++++++++++ README.md | 148 +++++++++++++++++ Rakefile | 9 ++ ignite-client.gemspec | 17 ++ lib/ignite.rb | 17 ++ lib/ignite/cache.rb | 306 ++++++++++++++++++++++++++++++++++++ lib/ignite/client.rb | 172 ++++++++++++++++++++ lib/ignite/op_codes.rb | 50 ++++++ lib/ignite/request.rb | 84 ++++++++++ lib/ignite/response.rb | 127 +++++++++++++++ lib/ignite/version.rb | 3 + test/cache_test.rb | 175 +++++++++++++++++++++ test/cache_types_test.rb | 40 +++++ test/client_test.rb | 8 + test/sql_test.rb | 37 +++++ test/sql_types_test.rb | 53 +++++++ test/test_helper.rb | 14 ++ 21 files changed, 1509 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Gemfile create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 Rakefile create mode 100644 ignite-client.gemspec create mode 100644 lib/ignite.rb create mode 100644 lib/ignite/cache.rb create mode 100644 lib/ignite/client.rb create mode 100644 lib/ignite/op_codes.rb create mode 100644 lib/ignite/request.rb create mode 100644 lib/ignite/response.rb create mode 100644 lib/ignite/version.rb create mode 100644 test/cache_test.rb create mode 100644 test/cache_types_test.rb create mode 100644 test/client_test.rb create mode 100644 test/sql_test.rb create mode 100644 test/sql_types_test.rb create mode 100644 test/test_helper.rb diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7ab2399 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,29 @@ +name: build +on: [push, pull_request] +jobs: + build: + if: "!contains(github.event.head_commit.message, '[skip ci]')" + runs-on: ubuntu-latest + env: + IGNITE_VERSION: 2.9.1 + steps: + - uses: actions/checkout@v2 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0 + bundler-cache: true + + - uses: actions/cache@v2 + with: + path: ~/ignite + key: ignite-${{ env.IGNITE_VERSION }} + id: cache-ignite + - name: Download Ignite + if: steps.cache-ignite.outputs.cache-hit != 'true' + run: | + wget -q https://apache.osuosl.org//ignite/$IGNITE_VERSION/apache-ignite-$IGNITE_VERSION-bin.zip + unzip -q apache-ignite-$IGNITE_VERSION-bin.zip + mv apache-ignite-$IGNITE_VERSION-bin ~/ignite + + - run: ~/ignite/bin/ignite.sh ~/ignite/examples/config/example-ignite.xml && sleep 20 + - run: bundle exec rake test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a596cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ +*.lock diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b082ca0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 (2021-02-02) + +- First release diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..4e6f9d9 --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gemspec + +gem "rake" +gem "minitest", ">= 5" diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + http://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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eeba394 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# Ignite Ruby + +:fire: Ruby client for [Apache Ignite](https://ignite.apache.org/) + +[![Build Status](https://github.com/ankane/ignite-ruby/workflows/build/badge.svg?branch=master)](https://github.com/ankane/ignite-ruby/actions) + +## Installation + +Add this line to your application’s Gemfile: + +```ruby +gem 'ignite-client' +``` + +## Getting Started + +Create a client + +```ruby +client = Ignite::Client.new +``` + +See [connection options](#connection-options) for more info + +## Key-Value API + +Create a cache + +```ruby +cache = client.get_or_create_cache("test") +``` + +Add data + +```ruby +cache.put("hello", "world") +cache.get("hello") +``` + +Supports these methods + +```ruby +cache.get(key) +cache.get_all(keys) +cache.put(key, value) +cache.put_all(objects) +cache.key?(key) +cache.keys?(keys) +cache.get_and_put(key, value) +cache.get_and_replace(key, value) +cache.get_and_remove(key) +cache.put_if_absent(key, value) +cache.get_and_put_if_absent(key, value) +cache.replace(key, value) +cache.replace_if_equals(key, compare, value) +cache.clear +cache.clear_key(key) +cache.clear_keys(keys) +cache.remove_key(key) +cache.remove_if_equals(key, compare) +cache.size +cache.remove_keys(keys) +cache.remove_all +``` + +## Scan Queries + +Scan objects + +```ruby +cache.scan do |k, v| + # ... +end +``` + +## SQL + +Execute SQL queries + +```ruby +client.query("SELECT * FROM users") +``` + +Pass arguments + +```ruby +client.query("SELECT * FROM products WHERE name = ?", ["Ignite"]) +``` + +## Connection Options + +Specify a host and port + +```ruby +Ignite::Client.new(host: "localhost", port: 10800) +``` + +## Authentication + +For [authentication](https://ignite.apache.org/docs/latest/security/authentication), use: + +```ruby +Ignite::Client.new(username: "ignite", password: "ignite") +``` + +SSL is automatically enabled when credentials are supplied. To disable, use: + +```ruby +Ignite::Client.new(username: "ignite", password: "ignite", use_ssl: false) +``` + +## SSL/TLS + +For [SSL/TLS](https://ignite.apache.org/docs/latest/security/ssl-tls#ssl-for-clients), use: + +```ruby +Ignite::Client.new( + use_ssl: true, + ssl_params: { + verify_mode: OpenSSL::SSL::VERIFY_PEER, + ca_file: "ca.pem" + } +) +``` + +Supports all OpenSSL params + +## History + +View the [changelog](https://github.com/ankane/ignite-ruby/blob/master/CHANGELOG.md) + +## Contributing + +Everyone is encouraged to help improve this project. Here are a few ways you can help: + +- [Report bugs](https://github.com/ankane/ignite-ruby/issues) +- Fix bugs and [submit pull requests](https://github.com/ankane/ignite-ruby/pulls) +- Write, clarify, or fix documentation +- Suggest or add new features + +To get started with development: + +```sh +git clone https://github.com/ankane/ignite-ruby.git +cd ignite-ruby +bundle install +bundle exec rake test +``` diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..1862bb6 --- /dev/null +++ b/Rakefile @@ -0,0 +1,9 @@ +require "bundler/gem_tasks" +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.test_files = FileList["test/**/*_test.rb"] +end + +task default: :test diff --git a/ignite-client.gemspec b/ignite-client.gemspec new file mode 100644 index 0000000..8f42655 --- /dev/null +++ b/ignite-client.gemspec @@ -0,0 +1,17 @@ +require_relative "lib/ignite/version" + +Gem::Specification.new do |spec| + spec.name = "ignite-client" + spec.version = Ignite::VERSION + spec.summary = "Ruby client for Apache Ignite" + spec.homepage = "https://github.com/ankane/ignite-ruby" + spec.license = "Apache-2.0" + + spec.author = "Andrew Kane" + spec.email = "andrew@ankane.org" + + spec.files = Dir["*.{md,txt}", "{lib}/**/*"] + spec.require_path = "lib" + + spec.required_ruby_version = ">= 2.6" +end diff --git a/lib/ignite.rb b/lib/ignite.rb new file mode 100644 index 0000000..f038f63 --- /dev/null +++ b/lib/ignite.rb @@ -0,0 +1,17 @@ +# stdlib +require "bigdecimal" +require "date" +require "openssl" +require "socket" + +# modules +require "ignite/cache" +require "ignite/op_codes" +require "ignite/request" +require "ignite/response" +require "ignite/version" + +module Ignite + class Error < StandardError; end + class HandshakeError < Error; end +end diff --git a/lib/ignite/cache.rb b/lib/ignite/cache.rb new file mode 100644 index 0000000..457b3c6 --- /dev/null +++ b/lib/ignite/cache.rb @@ -0,0 +1,306 @@ +module Ignite + class Cache + attr_reader :cache_id, :client, :name + + def initialize(client, name) + @client = client + @name = name + @cache_id = hash_code(name) + end + + def get(key) + req = Request.new(OP_CACHE_GET) + req.int cache_id + req.byte 0 + req.data_object key + + res = client.send_request(req) + res.read_data_object + end + + def get_all(keys) + req = Request.new(OP_CACHE_GET_ALL) + req.int cache_id + req.byte 0 + req.int keys.size + keys.each do |key| + req.data_object key + end + + res = client.send_request(req) + result = {} + res.read_int.times do + result[res.read_data_object] = res.read_data_object + end + result + end + + def put(key, value) + req = Request.new(OP_CACHE_PUT) + req.int cache_id + req.byte 0 + req.data_object key + req.data_object value + + client.send_request(req) + nil + end + + def put_all(objects) + req = Request.new(OP_CACHE_PUT_ALL) + req.int cache_id + req.byte 0 + req.int objects.size + objects.each do |key, value| + req.data_object key + req.data_object value + end + + client.send_request(req) + nil + end + + def key?(key) + req = Request.new(OP_CACHE_CONTAINS_KEY) + req.int cache_id + req.byte 0 + req.data_object key + + client.send_request(req).read_bool + end + alias_method :contains_key, :key? + + def keys?(keys) + req = Request.new(OP_CACHE_CONTAINS_KEYS) + req.int cache_id + req.byte 0 + req.int keys.size + keys.each do |key| + req.data_object key + end + + client.send_request(req).read_bool + end + alias_method :contains_keys, :keys? + + def get_and_put(key, value) + req = Request.new(OP_CACHE_GET_AND_PUT) + req.int cache_id + req.byte 0 + req.data_object key + req.data_object value + + client.send_request(req).read_data_object + end + + def get_and_replace(key, value) + req = Request.new(OP_CACHE_GET_AND_REPLACE) + req.int cache_id + req.byte 0 + req.data_object key + req.data_object value + + client.send_request(req).read_data_object + end + + def get_and_remove(key) + req = Request.new(OP_CACHE_GET_AND_REMOVE) + req.int cache_id + req.byte 0 + req.data_object key + + client.send_request(req).read_data_object + end + + def put_if_absent(key, value) + req = Request.new(OP_CACHE_PUT_IF_ABSENT) + req.int cache_id + req.byte 0 + req.data_object key + req.data_object value + + client.send_request(req).read_bool + end + + def get_and_put_if_absent(key, value) + req = Request.new(OP_CACHE_GET_AND_PUT_IF_ABSENT) + req.int cache_id + req.byte 0 + req.data_object key + req.data_object value + + client.send_request(req).read_data_object + end + + def replace(key, value) + req = Request.new(OP_CACHE_REPLACE) + req.int cache_id + req.byte 0 + req.data_object key + req.data_object value + + client.send_request(req).read_bool + end + + def replace_if_equals(key, compare, value) + req = Request.new(OP_CACHE_REPLACE_IF_EQUALS) + req.int cache_id + req.byte 0 + req.data_object key + req.data_object compare + req.data_object value + + client.send_request(req).read_bool + end + + def clear + req = Request.new(OP_CACHE_CLEAR) + req.int cache_id + req.byte 0 + + client.send_request(req) + nil + end + + def clear_key(key) + req = Request.new(OP_CACHE_CLEAR_KEY) + req.int cache_id + req.byte 0 + req.data_object key + + client.send_request(req) + nil + end + + def clear_keys(keys) + req = Request.new(OP_CACHE_CLEAR_KEYS) + req.int cache_id + req.byte 0 + req.int keys.size + keys.each do |key| + req.data_object key + end + + client.send_request(req) + nil + end + + def remove_key(key) + req = Request.new(OP_CACHE_REMOVE_KEY) + req.int cache_id + req.byte 0 + req.data_object key + + client.send_request(req).read_bool + end + + def remove_if_equals(key, compare) + req = Request.new(OP_CACHE_REMOVE_IF_EQUALS) + req.int cache_id + req.byte 0 + req.data_object key + req.data_object compare + + client.send_request(req).read_bool + end + + # TODO add arguments + def size + req = Request.new(OP_CACHE_GET_SIZE) + req.int cache_id + req.byte 0 + req.int 0 + req.byte 0 + + client.send_request(req).read_long + end + alias_method :get_size, :size + + def remove_keys(keys) + req = Request.new(OP_CACHE_REMOVE_KEYS) + req.int cache_id + req.byte 0 + req.int keys.size + keys.each do |key| + req.data_object key + end + + client.send_request(req) + nil + end + + def remove_all + req = Request.new(OP_CACHE_REMOVE_ALL) + req.int cache_id + req.byte 0 + + client.send_request(req) + nil + end + + def scan(page_size: 1000) + return to_enum(:scan, page_size: page_size) unless block_given? + + # TODO filter + filter = nil + + req = Request.new(OP_QUERY_SCAN) + req.int cache_id + req.byte 0 + req.data_object filter + req.byte 0 unless filter.nil? + req.int page_size + req.int(-1) + req.bool false + + res = client.send_request(req) + cursor_id = res.read_long + row_count = res.read_int + row_count.times do + yield res.read_data_object, res.read_data_object + end + more_results = res.read_bool + + while more_results + req = Request.new(OP_QUERY_SCAN_CURSOR_GET_PAGE) + req.long cursor_id + + # docs for OP_QUERY_SCAN_CURSOR_GET_PAGE response are incorrect + # 1. no cursor_id + # 2. row_count is int, not log + res = client.send_request(req) + row_count = res.read_int + row_count.times do + yield res.read_data_object, res.read_data_object + end + more_results = res.read_bool + end + end + + def get_or_create + req = Request.new(OP_CACHE_GET_OR_CREATE_WITH_NAME) + req.string name + client.send_request(req) + self + end + + def destroy + req = Request.new(OP_CACHE_DESTROY) + req.int cache_id + client.send_request(req) + nil + end + + private + + # same as Python + # https://ignite.apache.org/docs/latest/binary-client-protocol/data-format#hash-code + def hash_code(string) + result = 0 + string.each_byte do |char| + result = (((31 * result + char.ord) ^ 0x80000000) & 0xffffffff) - 0x80000000 + end + result + end + end +end diff --git a/lib/ignite/client.rb b/lib/ignite/client.rb new file mode 100644 index 0000000..aa6905c --- /dev/null +++ b/lib/ignite/client.rb @@ -0,0 +1,172 @@ +require "ignite" + +module Ignite + class Client + def initialize(host: "localhost", port: 10800, username: nil, password: nil, use_ssl: nil, ssl_params: {}) + @socket = TCPSocket.new(host, port) + + use_ssl = use_ssl.nil? ? (username || password) : use_ssl + if use_ssl + ssl_context = OpenSSL::SSL::SSLContext.new + + # very important!! + # call set_params so default params are applied + # (like min_version and verify_mode) + ssl_context.set_params(ssl_params) + + @socket = OpenSSL::SSL::SSLSocket.new(@socket, ssl_context) + @socket.sync_close = true + @socket.connect + end + + send_handshake(username, password) + end + + def close + @socket.close + end + + def cache(name) + Cache.new(self, name) + end + + def get_or_create_cache(name) + cache(name).get_or_create + end + + def caches + req = Request.new(OP_CACHE_GET_NAMES) + + res = send_request(req) + cache_count = res.read_int + cache_count.times.map { cache(res.read_string_object) } + end + + def query(statement, args = [], schema: "PUBLIC", page_size: 1000, max_rows: nil, statement_type: :any, timeout: nil) + statement_type = [:any, :select, :update].index(statement_type) + raise ArgumentError, "Invalid statement type" unless statement_type + + schema = get_or_create_cache(schema) + + req = Request.new(OP_QUERY_SQL_FIELDS) + req.int schema.cache_id + req.byte 0 + req.string schema.name + req.int page_size + req.int(max_rows || -1) + req.string statement + req.int args.size + args.each do |arg| + req.data_object arg + end + req.byte statement_type + req.bool false + req.bool false + req.bool false + req.bool false + req.bool false + req.bool false + req.long(timeout || 0) + req.bool true + + res = send_request(req) + cursor_id = res.read_long + field_count = res.read_int + field_names = [] + field_count.times do + field_names << res.read_string_object + end + + rows = [] + row_count = res.read_int + row_count.times do + row = {} + field_names.each do |field_name| + row[field_name] = res.read_data_object + end + rows << row + end + more_results = res.read_bool + + while more_results && (!max_rows || rows.size < max_rows) + req = Request.new(OP_QUERY_SQL_FIELDS_CURSOR_GET_PAGE) + req.long cursor_id + + res = send_request(req) + row_count = res.read_int + row_count.times do + row = {} + field_names.each do |field_name| + row[field_name] = res.read_data_object + end + rows << row + end + more_results = res.read_bool + end + + if max_rows && rows.size > max_rows + rows.pop(rows.size - max_rows) + end + + rows + end + + def close_resource(resource_id) + req = Request.new(OP_RESOURCE_CLOSE) + req.long resource_id + send_request(req) + nil + end + + # internal + def read(len) + @socket.read(len) + end + + # internal + def send_request(req) + @socket.write(req.to_bytes) + res = Response.new(self) + check_header res + res + end + + private + + def check_header(res) + _req_id = res.read_long + status = res.read_int + + if status != OP_SUCCESS + raise Error, res.read_string_object + end + end + + def send_handshake(username, password) + req = Request.new(OP_HANDSHAKE) + req.byte 1 + req.short 1 + req.short 2 + req.short 0 + req.byte 2 + if username || password + req.string username + req.string password + end + @socket.write(req.to_bytes) + + res = Response.new(self) + check_handshake res + end + + def check_handshake(res) + status = res.read_byte + if status != 1 + _server_version_major = res.read_short + _server_version_minor = res.read_short + _server_version_patch = res.read_short + raise HandshakeError, res.read_string_object + end + end + end +end diff --git a/lib/ignite/op_codes.rb b/lib/ignite/op_codes.rb new file mode 100644 index 0000000..d5a724c --- /dev/null +++ b/lib/ignite/op_codes.rb @@ -0,0 +1,50 @@ +# same as Python +module Ignite + OP_SUCCESS = 0 + + OP_RESOURCE_CLOSE = 0 + + OP_HANDSHAKE = 1 + + OP_CACHE_GET = 1000 + OP_CACHE_PUT = 1001 + OP_CACHE_PUT_IF_ABSENT = 1002 + OP_CACHE_GET_ALL = 1003 + OP_CACHE_PUT_ALL = 1004 + OP_CACHE_GET_AND_PUT = 1005 + OP_CACHE_GET_AND_REPLACE = 1006 + OP_CACHE_GET_AND_REMOVE = 1007 + OP_CACHE_GET_AND_PUT_IF_ABSENT = 1008 + OP_CACHE_REPLACE = 1009 + OP_CACHE_REPLACE_IF_EQUALS = 1010 + OP_CACHE_CONTAINS_KEY = 1011 + OP_CACHE_CONTAINS_KEYS = 1012 + OP_CACHE_CLEAR = 1013 + OP_CACHE_CLEAR_KEY = 1014 + OP_CACHE_CLEAR_KEYS = 1015 + OP_CACHE_REMOVE_KEY = 1016 + OP_CACHE_REMOVE_IF_EQUALS = 1017 + OP_CACHE_REMOVE_KEYS = 1018 + OP_CACHE_REMOVE_ALL = 1019 + OP_CACHE_GET_SIZE = 1020 + + OP_CACHE_GET_NAMES = 1050 + OP_CACHE_CREATE_WITH_NAME = 1051 + OP_CACHE_GET_OR_CREATE_WITH_NAME = 1052 + OP_CACHE_CREATE_WITH_CONFIGURATION = 1053 + OP_CACHE_GET_OR_CREATE_WITH_CONFIGURATION = 1054 + OP_CACHE_GET_CONFIGURATION = 1055 + OP_CACHE_DESTROY = 1056 + + OP_QUERY_SCAN = 2000 + OP_QUERY_SCAN_CURSOR_GET_PAGE = 2001 + OP_QUERY_SQL = 2002 + OP_QUERY_SQL_CURSOR_GET_PAGE = 2003 + OP_QUERY_SQL_FIELDS = 2004 + OP_QUERY_SQL_FIELDS_CURSOR_GET_PAGE = 2005 + + P_GET_BINARY_TYPE_NAME = 3000 + OP_REGISTER_BINARY_TYPE_NAME = 3001 + OP_GET_BINARY_TYPE = 3002 + OP_PUT_BINARY_TYPE = 3003 +end diff --git a/lib/ignite/request.rb b/lib/ignite/request.rb new file mode 100644 index 0000000..4338756 --- /dev/null +++ b/lib/ignite/request.rb @@ -0,0 +1,84 @@ +module Ignite + class Request + MIN_LONG = -9223372036854775808 # -2**63 + MAX_LONG = 9223372036854775807 # 2**63-1 + + def initialize(op_code) + @buffer = String.new + int 0 # length placeholder + + if op_code != OP_HANDSHAKE + short op_code + long rand(MIN_LONG..MAX_LONG) # request id + end + end + + def to_bytes + # update length + @buffer[0..3] = [@buffer.bytesize - 4].pack("i!<") + @buffer + end + + def bool(value) + byte(value ? 1 : 0) + end + + def byte(value) + [value].pack("C", buffer: @buffer) + end + + def short(value) + [value].pack("s!<", buffer: @buffer) + end + + def int(value) + [value].pack("i!<", buffer: @buffer) + end + + def long(value) + [value].pack("l!<", buffer: @buffer) + end + + def float(value) + [value].pack("e", buffer: @buffer) + end + + def double(value) + [value].pack("E", buffer: @buffer) + end + + def string(value) + byte 9 + int value.bytesize + @buffer << value + end + + def data_object(value) + case value + when Integer + byte 4 + long value + when Float + byte 6 + double value + when TrueClass, FalseClass + byte 8 + bool value + when String + string value + when Date + byte 11 + time = value.to_time + long(time.to_i * 1000 + (time.nsec / 1000000)) + when Time + byte 33 + long(value.to_i * 1000 + (value.nsec / 1000000)) + int value.nsec % 1000000 + when NilClass + byte 101 + else + raise "Unknown type: #{value.class.name}" + end + end + end +end diff --git a/lib/ignite/response.rb b/lib/ignite/response.rb new file mode 100644 index 0000000..7b100a0 --- /dev/null +++ b/lib/ignite/response.rb @@ -0,0 +1,127 @@ +module Ignite + class Response + attr_reader :client + + def initialize(client) + @client = client + + # use buffer so errors don't leave unread data on socket + len = client.read(4).unpack1("i!<") + @buffer = StringIO.new(client.read(len)) + end + + def read(len) + @buffer.read(len) + end + + def read_byte + read(1).unpack1("C") + end + + def read_short + read(2).unpack1("s!<") + end + + def read_int + read(4).unpack1("i!<") + end + + def read_long + read(8).unpack1("l!<") + end + + def read_float + read(4).unpack1("e") + end + + def read_double + read(8).unpack1("E") + end + + def read_char + read(1).unpack1("c") + end + + def read_bool + read_byte != 0 + end + + def read_string + len = read_int + read(len) + end + + def read_string_object + type = read_byte + raise "Expected string, not type #{type}" unless type == 9 + read_string + end + + def read_date + msecs_since_epoch = read_long + sec = msecs_since_epoch / 1000 + Time.at(sec).to_date + end + + # same as Python + def read_decimal + scale = read_int + length = read_int + data = read(length).unpack("C*") + + sign = (data[0] & 0x80) != 0 + data[0] = data[0] & 0x7f + + result = 0 + data.reverse.each_with_index do |v, i| + result += v * 0x100 ** i + end + + result = result / BigDecimal("10") ** BigDecimal(scale) + result = -result if sign + result + end + + def read_timestamp + msecs_since_epoch = read_long + msec_fraction_in_nsecs = read_int + sec = msecs_since_epoch / 1000 + nsec = (msecs_since_epoch % 1000) * 1000000 + msec_fraction_in_nsecs + Time.at(sec, nsec, :nanosecond) + end + + def read_data_object + type_code = read_byte + case type_code + when 1 + read_byte + when 2 + read_short + when 3 + read_int + when 4 + read_long + when 5 + read_float + when 6 + read_double + when 7 + read_char + when 8 + read_bool + when 9 + read_string + when 11 + read_date + when 30 + read_decimal + when 33 + read_timestamp + when 101 + nil + else + raise Error, "Type not supported yet: #{type_code}. Please create an issue." + end + end + end +end diff --git a/lib/ignite/version.rb b/lib/ignite/version.rb new file mode 100644 index 0000000..758686d --- /dev/null +++ b/lib/ignite/version.rb @@ -0,0 +1,3 @@ +module Ignite + VERSION = "0.1.0" +end diff --git a/test/cache_test.rb b/test/cache_test.rb new file mode 100644 index 0000000..7d4983a --- /dev/null +++ b/test/cache_test.rb @@ -0,0 +1,175 @@ +require_relative "test_helper" + +class CacheTest < Minitest::Test + def setup + cache.clear + end + + def test_get + cache.put("k1", "v1") + assert_equal "v1", cache.get("k1") + assert_nil cache.get("k2") + end + + def test_get_all + cache.put_all({"k1" => "v1", "k2" => "v2"}) + assert_equal({"k1" => "v1"}, cache.get_all(["k1"])) + end + + def test_put + cache.put("k1", "v1") + assert_equal "v1", cache.get("k1") + end + + def test_put_all + cache.put_all({"k1" => "v1", "k2" => "v2"}) + assert_equal({"k1" => "v1", "k2" => "v2"}, cache.get_all(["k1", "k2"])) + end + + def test_contains_key + cache.put("k1", "v1") + assert cache.key?("k1") + refute cache.key?("missing") + assert cache.contains_key("k1") + refute cache.contains_key("missing") + end + + def test_contains_keys + cache.put_all({"k1" => "v1", "k2" => "v2"}) + assert cache.keys?(["k1", "k2"]) + refute cache.keys?(["k1", "k3"]) + assert cache.contains_keys(["k1", "k2"]) + refute cache.contains_keys(["k1", "k3"]) + end + + def test_get_and_put + assert_nil cache.get_and_put("k1", "v1") + assert_equal "v1", cache.get_and_put("k1", "v2") + end + + def test_get_and_replace + assert_nil cache.get_and_replace("k1", "v1") + assert_nil cache.get("k1") + cache.put("k1", "v1") + assert_equal "v1", cache.get_and_replace("k1", "v2") + assert_equal "v2", cache.get("k1") + end + + def test_get_and_remove + assert_nil cache.get_and_remove("k1") + cache.put("k1", "v1") + assert_equal "v1", cache.get_and_remove("k1") + refute cache.contains_key("k1") + end + + def test_put_if_absent + assert cache.put_if_absent("k1", "v1") + refute cache.put_if_absent("k1", "v2") + assert_equal "v1", cache.get("k1") + end + + def test_get_and_put_if_absent + assert_nil cache.get_and_put_if_absent("k1", "v1") + assert_equal "v1", cache.get_and_put_if_absent("k1", "v2") + assert_equal "v1", cache.get("k1") + end + + def test_cache_replace + assert_equal false, cache.replace("k1", "v1") + assert_nil cache.get("k1") + cache.put("k1", "v1") + assert_equal true, cache.replace("k1", "v2") + assert_equal "v2", cache.get("k1") + end + + def test_cache_replace_if_equals + cache.put("k1", "v1") + assert_equal false, cache.replace_if_equals("k1", "not_equal", "v2") + assert_equal true, cache.replace_if_equals("k1", "v1", "v3") + assert_equal "v3", cache.get("k1") + end + + def test_clear + cache.put("k1", "v1") + cache.clear + refute cache.contains_key("k1") + end + + def test_clear_key + cache.put("k1", "v1") + cache.clear_key("k1") + refute cache.contains_key("k1") + end + + def test_clear_keys + cache.put_all({"k1" => "v1", "k2" => "v2", "k3" => "v3"}) + cache.clear_keys(["k1", "k2"]) + assert_equal({"k3" => "v3"}, cache.get_all(["k1", "k2", "k3"])) + end + + def test_remove_key + cache.put("k1", "v1") + assert_equal true, cache.remove_key("k1") + assert_equal false, cache.remove_key("k1") + end + + def test_remove_if_equals + cache.put("k1", "v1") + assert_equal false, cache.remove_if_equals("k1", "v2") + assert_equal true, cache.remove_if_equals("k1", "v1") + end + + def test_get_size + assert_equal 0, cache.size + assert_equal 0, cache.get_size + cache.put("k1", "v1") + assert_equal 1, cache.size + assert_equal 1, cache.get_size + end + + def test_remove_keys + cache.put_all({"k1" => "v1", "k2" => "v2", "k3" => "v3"}) + cache.remove_keys(["k1", "k2"]) + assert_equal({"k3" => "v3"}, cache.get_all(["k1", "k2", "k3"])) + end + + def test_remove_all + cache.put("k1", "v1") + cache.remove_all + refute cache.contains_key("k1") + end + + def test_scan + expected = {} + 20.times do |i| + expected["k#{i}"] = "v#{i}" + end + cache.put_all(expected) + actual = {} + result = cache.scan(page_size: 6) + result.each do |k, v| + actual[k] = v + end + assert_equal expected, actual + end + + def test_scan_block + expected = {} + 20.times do |i| + expected["k#{i}"] = "v#{i}" + end + cache.put_all(expected) + actual = {} + cache.scan do |k, v| + actual[k] = v + end + assert_equal expected, actual + end + + def test_destroy + cache = client.get_or_create_cache("ignite_test_destroy") + assert_includes client.caches.map(&:name), "ignite_test_destroy" + cache.destroy + refute_includes client.caches.map(&:name), "ignite_test_destroy" + end +end diff --git a/test/cache_types_test.rb b/test/cache_types_test.rb new file mode 100644 index 0000000..838efb5 --- /dev/null +++ b/test/cache_types_test.rb @@ -0,0 +1,40 @@ +require_relative "test_helper" + +class CacheTypesTest < Minitest::Test + def test_string + assert_caches "hello" + end + + def test_bool + assert_caches true + assert_caches false + end + + def test_integer + assert_caches 1 + end + + def test_float + assert_caches 1.5 + end + + def test_date + assert_caches Date.today + end + + def test_timestamp + assert_caches Time.now + end + + def test_nil + error = assert_raises(Ignite::Error) do + cache.put("k", nil) + end + assert_equal "Ouch! Argument cannot be null: val", error.message + end + + def assert_caches(value) + cache.put("k", value) + assert_equal value, cache.get("k") + end +end diff --git a/test/client_test.rb b/test/client_test.rb new file mode 100644 index 0000000..ac25380 --- /dev/null +++ b/test/client_test.rb @@ -0,0 +1,8 @@ +require_relative "test_helper" + +class ClientTest < Minitest::Test + def test_caches + client.get_or_create_cache("ignite_test_name") + assert_includes client.caches.map(&:name), "ignite_test_name" + end +end diff --git a/test/sql_test.rb b/test/sql_test.rb new file mode 100644 index 0000000..e24e095 --- /dev/null +++ b/test/sql_test.rb @@ -0,0 +1,37 @@ +require_relative "test_helper" + +class SqlTest < Minitest::Test + def test_queries + client.query("DROP TABLE IF EXISTS products") + client.query("CREATE TABLE products (id INTEGER PRIMARY KEY, name CHAR(255))") + + products = ["Test 1", "Test 2", "Test 3"] + products.each_with_index do |city, i| + client.query("INSERT INTO products (id, name) VALUES (?, ?)", [i, city]) + end + + expected = [{"NAME"=>"Test 1"}, {"NAME"=>"Test 2"}, {"NAME"=>"Test 3"}] + assert_equal expected, client.query("SELECT name FROM products ORDER BY name", page_size: 2) + + expected = [{"NAME"=>"Test 1"}, {"NAME"=>"Test 2"}] + assert_equal expected, client.query("SELECT name FROM products ORDER BY name", max_rows: 2) + end + + def test_args + assert_equal 1, client.query("SELECT ? AS value", [1]).first["VALUE"] + end + + def test_statement_type + error = assert_raises(Ignite::Error) do + client.query("CREATE TABLE users (id INTEGER PRIMARY KEY, name CHAR(255))", statement_type: :select) + end + assert_equal "Given statement type does not match that declared by JDBC driver", error.message + end + + def test_error + error = assert_raises(Ignite::Error) do + client.query("BAD") + end + assert_match "Failed to parse query", error.message + end +end diff --git a/test/sql_types_test.rb b/test/sql_types_test.rb new file mode 100644 index 0000000..e31c3ca --- /dev/null +++ b/test/sql_types_test.rb @@ -0,0 +1,53 @@ +require_relative "test_helper" + +class SqlTypesTest < Minitest::Test + def test_string + assert_type "world", "SELECT 'world'" + end + + def test_bool + assert_type true, "SELECT true" + end + + def test_smallint + assert_type 1, "SELECT CAST(1 AS SMALLINT)" + end + + def test_int + assert_type 1, "SELECT 1" + assert_type 1, "SELECT CAST(1 AS INT)" + end + + def test_bigint + assert_type 1, "SELECT CAST(1 AS BIGINT)" + end + + def test_float + assert_type 1.5, "SELECT CAST(1.5 AS FLOAT)" + end + + def test_double + assert_type 1.5, "SELECT CAST(1.5 AS DOUBLE)" + end + + def test_timestamp + assert_kind_of Time, client.query("SELECT CURRENT_TIMESTAMP AS value").first["VALUE"] + end + + def test_decimal + assert_type BigDecimal("1.5"), "SELECT CAST(1.5 AS DECIMAL)" + assert_type BigDecimal("-1.5"), "SELECT CAST(-1.5 AS DECIMAL)" + assert_type BigDecimal("1234567890.12345678901234567890"), "SELECT CAST(1234567890.12345678901234567890 AS DECIMAL)" + assert_type BigDecimal("0.0000000000123456789"), "SELECT CAST(0.0000000000123456789 AS DECIMAL)" + end + + def assert_type(expected, expression) + result = client.query("#{expression} AS value").first["VALUE"] + if expected.is_a?(Float) && expected.nan? + assert result.nan? + else + assert_equal expected, result + end + assert_equal expected.class, result.class + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..6b25425 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,14 @@ +require "bundler/setup" +Bundler.require(:default) +require "minitest/autorun" +require "minitest/pride" + +class Minitest::Test + def client + @client ||= Ignite::Client.new + end + + def cache + @cache ||= client.get_or_create_cache("ignite_test") + end +end