Import of plugin's code

Change-Id: Ic773558145bd86d46f02151644f1dc435c7b23c2
This commit is contained in:
Danila Troschinsky 2017-07-25 11:19:30 +03:00 committed by Danila
parent 97e4d12ca2
commit 983230fe86
439 changed files with 26248 additions and 0 deletions

71
README.md Normal file
View File

@ -0,0 +1,71 @@
Sensu plugin for Fuel
=================================
Overview
--------
Sensu plugin for Fuel extends Mirantis OpenStack functionality by adding
Sensu monitoring. It can be deployed on hosts with Stacklight plugins roles.
Sensu plugin is 100% hot-pluggable.
Compatible Fuel versions
------------------------
9.0
User Guide
----------
1. Create an environment or open existing. In case of new environment select new nodes
with LMA Stacklights roles
2. Enable the plugin on the Settings/Other tab of the Fuel web UI and fill in form
fields:
* in development
3. Deploy the environment.
Installation Guide
==================
Sensu Plugin for Fuel installation
----------------------------------------------
To install Sensu plugin, follow these steps:
1. Download the plugin
git clone https://github.com/openstack/fuel-plugin-sensu
2. Copy the plugin on already installed Fuel Master node; ssh can be used for
that. If you do not have the Fuel Master node yet, see
[Quick Start Guide](https://software.mirantis.com/quick-start/):
# scp fuel-plugin-sensu-0.1.1-1.noarch.rpm root@<Fuel_master_ip>:/tmp
3. Log into the Fuel Master node. Install the plugin:
# cd /tmp
# fuel plugins --install fuel-plugin-sensu-0.1.1-1.noarch.rpm
4. Check if the plugin was installed successfully:
# fuel plugins
id | name | version | package_version
---|---------------------------------|---------|----------------
1 | fuel-plugin-sensu | 0.1.1 | 4.0.0
Requirements
------------
| Requirement | Version/Comment |
|:---------------------------------|:----------------|
| Mirantis OpenStack compatibility | 9.0 |
Limitations
-----------
This plugin can be used only with Stacklight LMA nodes

View File

@ -0,0 +1,22 @@
#!/opt/sensu/embedded/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'sensu-plugins-cpu-checks' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
version = ">= 0.a"
if ARGV.first
str = ARGV.first
str = str.dup.force_encoding("BINARY") if str.respond_to? :force_encoding
if str =~ /\A_(.*)_\z/ and Gem::Version.correct?($1) then
version = $1
ARGV.shift
end
end
load Gem.activate_bin_path('sensu-plugins-cpu-checks', 'check-cpu.rb', version)

View File

@ -0,0 +1,22 @@
#!/opt/sensu/embedded/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'sensu-plugins-influxdb' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
version = ">= 0.a"
if ARGV.first
str = ARGV.first
str = str.dup.force_encoding("BINARY") if str.respond_to? :force_encoding
if str =~ /\A_(.*)_\z/ and Gem::Version.correct?($1) then
version = $1
ARGV.shift
end
end
load Gem.activate_bin_path('sensu-plugins-influxdb', 'check-influxdb-query.rb', version)

View File

@ -0,0 +1,22 @@
#!/opt/sensu/embedded/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'sensu-plugins-influxdb' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
version = ">= 0.a"
if ARGV.first
str = ARGV.first
str = str.dup.force_encoding("BINARY") if str.respond_to? :force_encoding
if str =~ /\A_(.*)_\z/ and Gem::Version.correct?($1) then
version = $1
ARGV.shift
end
end
load Gem.activate_bin_path('sensu-plugins-influxdb', 'check-influxdb.rb', version)

View File

@ -0,0 +1,22 @@
#!/opt/sensu/embedded/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'jsonpath' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
version = ">= 0.a"
if ARGV.first
str = ARGV.first
str = str.dup.force_encoding("BINARY") if str.respond_to? :force_encoding
if str =~ /\A_(.*)_\z/ and Gem::Version.correct?($1) then
version = $1
ARGV.shift
end
end
load Gem.activate_bin_path('jsonpath', 'jsonpath', version)

View File

@ -0,0 +1,22 @@
#!/opt/sensu/embedded/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'sensu-plugins-cpu-checks' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
version = ">= 0.a"
if ARGV.first
str = ARGV.first
str = str.dup.force_encoding("BINARY") if str.respond_to? :force_encoding
if str =~ /\A_(.*)_\z/ and Gem::Version.correct?($1) then
version = $1
ARGV.shift
end
end
load Gem.activate_bin_path('sensu-plugins-cpu-checks', 'metrics-cpu-mpstat.rb', version)

View File

@ -0,0 +1,22 @@
#!/opt/sensu/embedded/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'sensu-plugins-cpu-checks' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
version = ">= 0.a"
if ARGV.first
str = ARGV.first
str = str.dup.force_encoding("BINARY") if str.respond_to? :force_encoding
if str =~ /\A_(.*)_\z/ and Gem::Version.correct?($1) then
version = $1
ARGV.shift
end
end
load Gem.activate_bin_path('sensu-plugins-cpu-checks', 'metrics-cpu-pcnt-usage.rb', version)

View File

@ -0,0 +1,22 @@
#!/opt/sensu/embedded/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'sensu-plugins-cpu-checks' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
version = ">= 0.a"
if ARGV.first
str = ARGV.first
str = str.dup.force_encoding("BINARY") if str.respond_to? :force_encoding
if str =~ /\A_(.*)_\z/ and Gem::Version.correct?($1) then
version = $1
ARGV.shift
end
end
load Gem.activate_bin_path('sensu-plugins-cpu-checks', 'metrics-cpu.rb', version)

View File

@ -0,0 +1,22 @@
#!/opt/sensu/embedded/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'sensu-plugins-influxdb' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
version = ">= 0.a"
if ARGV.first
str = ARGV.first
str = str.dup.force_encoding("BINARY") if str.respond_to? :force_encoding
if str =~ /\A_(.*)_\z/ and Gem::Version.correct?($1) then
version = $1
ARGV.shift
end
end
load Gem.activate_bin_path('sensu-plugins-influxdb', 'metrics-influxdb.rb', version)

View File

@ -0,0 +1,22 @@
#!/opt/sensu/embedded/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'sensu-plugins-cpu-checks' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
version = ">= 0.a"
if ARGV.first
str = ARGV.first
str = str.dup.force_encoding("BINARY") if str.respond_to? :force_encoding
if str =~ /\A_(.*)_\z/ and Gem::Version.correct?($1) then
version = $1
ARGV.shift
end
end
load Gem.activate_bin_path('sensu-plugins-cpu-checks', 'metrics-numastat.rb', version)

View File

@ -0,0 +1,22 @@
#!/opt/sensu/embedded/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'sensu-plugins-cpu-checks' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
version = ">= 0.a"
if ARGV.first
str = ARGV.first
str = str.dup.force_encoding("BINARY") if str.respond_to? :force_encoding
if str =~ /\A_(.*)_\z/ and Gem::Version.correct?($1) then
version = $1
ARGV.shift
end
end
load Gem.activate_bin_path('sensu-plugins-cpu-checks', 'metrics-user-pct-usage.rb', version)

View File

@ -0,0 +1,22 @@
#!/opt/sensu/embedded/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'sensu-plugins-influxdb' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
version = ">= 0.a"
if ARGV.first
str = ARGV.first
str = str.dup.force_encoding("BINARY") if str.respond_to? :force_encoding
if str =~ /\A_(.*)_\z/ and Gem::Version.correct?($1) then
version = $1
ARGV.shift
end
end
load Gem.activate_bin_path('sensu-plugins-influxdb', 'mutator-influxdb-line-protocol.rb', version)

View File

@ -0,0 +1,19 @@
Copyright (c) 2013 Conrad Irwin <conrad@bugsnag.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,32 @@
This is a back-port of Ruby 2.1.0's [`Exception#cause`](http://www.ruby-doc.org/core-2.1.0/Exception.html#method-i-cause) to older versions of Ruby.
Installation
------------
Add `gem 'cause'` to your `Gemfile`, then run `bundle install`. If you're not
using bundler, then just `gem install cause`.
Usage
-----
Just continue programming as normal. When you rescue from exceptions they'll
have a third property, cause, in addition to backtrace and message. The cause
is the exception object that was being handled when the error was raised.
While this is not directly useful in normal programming, it's very useful for
debugging. Exception trackers like [Bugsnag](https://bugsnag.com/) can then pick up
the cause of the exception to help you find out what went wrong.
Limitations
-----------
At the moment you cannot set the cause yourself. Overriding `raise` is hairy
business and I wrote this gem late at night, but with sufficient care it's
probably doable.
Meta-fu
-------
This gem is Copyright under the MIT licence. See LICENCE.MIT for details.
Contributions and bug-reports are welcome.

View File

@ -0,0 +1,17 @@
Gem::Specification.new do |gem|
gem.name = 'cause'
gem.version = '0.1'
gem.summary = 'A backport of Exception#cause from Ruby 2.1.0'
gem.description = "Allows you access to the error that was being handled when this exception was raised."
gem.authors = ['Conrad Irwin']
gem.email = %w(conrad@bugsnag.com)
gem.homepage = 'http://github.com/ConradIrwin/cause'
gem.license = 'MIT'
gem.required_ruby_version = '>= 1.8.7'
gem.files = `git ls-files`.split("\n")
end

View File

@ -0,0 +1,12 @@
class Exception
unless method_defined?(:cause)
attr_reader :cause
alias old_initialize initialize
def initialize(*a)
@cause = $!
old_initialize(*a)
end
end
end

View File

@ -0,0 +1,11 @@
*.gem
.bundle
.rbenv-version
Gemfile.lock
bin/*
pkg/*
vendor/*
/.ruby-gemset
/.ruby-version
/.rspec

View File

@ -0,0 +1,2 @@
require "bundler"
Bundler.require

View File

@ -0,0 +1,13 @@
language: ruby
sudo: false
rvm:
- 1.9.3
- 2.0.0
- 2.1.0
- 2.1.1
- 2.2.0
- 2.2.1
- 2.2.2
- 2.2.3
- 2.3.0
- rbx-2

View File

@ -0,0 +1,141 @@
# Change Log
## [v2.0.9] 2016-09-19
- namespace tokenization errors
- automatically coerce arguments to string functions as strings
- selectively disable or clear AST cache
## [v2.0.8] 2016-05-10
- numeric input validations
- fail with gem-specific error for invalid arithmetic operands
- add `LEFT`, `RIGHT`, `MID`, `LEN`, `FIND`, `SUBSTITUTE`, and `CONCAT` string functions
## [v2.0.7] 2016-02-25
- fail with gem-specific error for parsing issues
- support NULL literals and nil variables
- keep reference to variable that caused failure when bulk-solving
## [v2.0.6] 2016-01-26
- support array parameters for external functions
- support case statements
- support precision for `ROUNDUP` and `ROUNDDOWN` functions
- prevent errors from corrupting calculator memory
## [v2.0.5] 2015-09-03
- fix bug with detecting unbound nodes
- silence warnings
- allow registration of custom token scanners
## [v2.0.4] 2015-09-03
- fix BigDecimal conversion bug
- add caching for bulk expression solving dependency order
- allow for custom configuration for token scanners
## [v2.0.3] 2015-08-25
- bug fixes
- performance enhancements
- code cleanup
## [v2.0.1] 2015-08-15
- add support for boolean literals
- implement basic parse-time type checking
## [v2.0.0] 2015-08-07
- shunting-yard parser for performance enhancement and AST generation
- AST caching for performance enhancement
- support comments in formulas
- support all functions from the Ruby Math module
## [v1.2.6] 2015-05-30
- support custom error handlers for systems of formulas
## [v1.2.5] 2015-05-23
- fix memory leak
## [v1.2.2] 2014-12-19
- performance enhancements
- unary minus bug fixes
- preserve provided hash keys for systems of formulas
## [v1.2.0] 2014-10-21
- add dependency resolution to automatically solve systems of formulas
## [v1.1.0] 2014-07-30
- add strict evaluation mode to raise `UnboundVariableError` if not all variable values are provided
- return division results as `BigDecimal` values
## [v1.0.0] 2014-03-06
- cleanup and 1.0 release
## [v0.2.14] 2014-01-24
- add modulo operator
- add unary percentage operator
- support registration of custom functions at runtime
## [v0.2.10] 2012-12-10
- return integer result for exact division, decimal otherwise
## [v0.2.9] 2012-10-17
- add `ROUNDUP` / `ROUNDDOWN` functions
## [v0.2.8] 2012-09-30
- make function name matching case-insensitive
## [v0.2.7] 2012-09-26
- support passing arbitrary expressions as function arguments
## [v0.2.6] 2012-09-19
- add `NOT` function
## [v0.2.5] 2012-06-20
- add exponent operator
- add support for digits in variable identifiers
## [v0.2.4] 2012-02-29
- add support for `min < x < max` syntax for inequality ranges
## [v0.2.2] 2012-02-22
- support `ROUND` to arbitrary decimal place on older Rubies
- ensure case is preserved for string values
## [v0.2.1] 2012-02-12
- add `ROUND` function
## [v0.1.3] 2012-01-31
- add support for string datatype
## [v0.1.1] 2012-01-24
- change from square bracket to parentheses for top-level evaluation
- add `IF` function
## [v0.1.0] 2012-01-20
- initial release
[v2.0.9]: https://github.com/rubysolo/dentaku/compare/v2.0.8...v2.0.9
[v2.0.8]: https://github.com/rubysolo/dentaku/compare/v2.0.7...v2.0.8
[v2.0.7]: https://github.com/rubysolo/dentaku/compare/v2.0.6...v2.0.7
[v2.0.6]: https://github.com/rubysolo/dentaku/compare/v2.0.5...v2.0.6
[v2.0.5]: https://github.com/rubysolo/dentaku/compare/v2.0.4...v2.0.5
[v2.0.4]: https://github.com/rubysolo/dentaku/compare/v2.0.3...v2.0.4
[v2.0.3]: https://github.com/rubysolo/dentaku/compare/v2.0.1...v2.0.3
[v2.0.1]: https://github.com/rubysolo/dentaku/compare/v2.0.0...v2.0.1
[v2.0.0]: https://github.com/rubysolo/dentaku/compare/v1.2.6...v2.0.0
[v1.2.6]: https://github.com/rubysolo/dentaku/compare/v1.2.5...v1.2.6
[v1.2.5]: https://github.com/rubysolo/dentaku/compare/v1.2.2...v1.2.5
[v1.2.2]: https://github.com/rubysolo/dentaku/compare/v1.2.0...v1.2.2
[v1.2.0]: https://github.com/rubysolo/dentaku/compare/v1.1.0...v1.2.0
[v1.1.0]: https://github.com/rubysolo/dentaku/compare/v1.0.0...v1.1.0
[v1.0.0]: https://github.com/rubysolo/dentaku/compare/v0.2.14...v1.0.0
[v0.2.14]: https://github.com/rubysolo/dentaku/compare/v0.2.10...v0.2.14
[v0.2.10]: https://github.com/rubysolo/dentaku/compare/v0.2.9...v0.2.10
[v0.2.9]: https://github.com/rubysolo/dentaku/compare/v0.2.8...v0.2.9
[v0.2.8]: https://github.com/rubysolo/dentaku/compare/v0.2.7...v0.2.8
[v0.2.7]: https://github.com/rubysolo/dentaku/compare/v0.2.6...v0.2.7
[v0.2.6]: https://github.com/rubysolo/dentaku/compare/v0.2.5...v0.2.6
[v0.2.5]: https://github.com/rubysolo/dentaku/compare/v0.2.4...v0.2.5
[v0.2.4]: https://github.com/rubysolo/dentaku/compare/v0.2.2...v0.2.4
[v0.2.2]: https://github.com/rubysolo/dentaku/compare/v0.2.1...v0.2.2
[v0.2.1]: https://github.com/rubysolo/dentaku/compare/v0.1.3...v0.2.1
[v0.1.3]: https://github.com/rubysolo/dentaku/compare/v0.1.1...v0.1.3
[v0.1.1]: https://github.com/rubysolo/dentaku/compare/v0.1.0...v0.1.1
[v0.1.0]: https://github.com/rubysolo/dentaku/commit/68724fd9c8fa637baf7b9d5515df0caa31e226bd

View File

@ -0,0 +1,9 @@
source "http://rubygems.org"
# Specify your gem's dependencies in dentaku.gemspec
gemspec
if RUBY_VERSION.to_f >= 2.0 && RUBY_ENGINE == 'ruby'
gem 'pry-byebug'
gem 'pry-stack_explorer'
end

View File

@ -0,0 +1,297 @@
Dentaku
=======
[![Join the chat at https://gitter.im/rubysolo/dentaku](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/rubysolo/dentaku?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
[![Gem Version](https://badge.fury.io/rb/dentaku.png)](http://badge.fury.io/rb/dentaku)
[![Build Status](https://travis-ci.org/rubysolo/dentaku.png?branch=master)](https://travis-ci.org/rubysolo/dentaku)
[![Code Climate](https://codeclimate.com/github/rubysolo/dentaku.png)](https://codeclimate.com/github/rubysolo/dentaku)
[![Hakiri](https://hakiri.io/github/rubysolo/dentaku/master.svg)](https://hakiri.io/github/rubysolo/dentaku)
DESCRIPTION
-----------
Dentaku is a parser and evaluator for a mathematical and logical formula
language that allows run-time binding of values to variables referenced in the
formulas. It is intended to safely evaluate untrusted expressions without
opening security holes.
EXAMPLE
-------
This is probably simplest to illustrate in code:
```ruby
calculator = Dentaku::Calculator.new
calculator.evaluate('10 * 2')
#=> 20
```
Okay, not terribly exciting. But what if you want to have a reference to a
variable, and evaluate it at run-time? Here's how that would look:
```ruby
calculator.evaluate('kiwi + 5', kiwi: 2)
#=> 7
```
You can also store the variable values in the calculator's memory and then
evaluate expressions against those stored values:
```ruby
calculator.store(peaches: 15)
calculator.evaluate('peaches - 5')
#=> 10
calculator.evaluate('peaches >= 15')
#=> true
```
For maximum CS geekery, `bind` is an alias of `store`.
Dentaku understands precedence order and using parentheses to group expressions
to ensure proper evaluation:
```ruby
calculator.evaluate('5 + 3 * 2')
#=> 11
calculator.evaluate('(5 + 3) * 2')
#=> 16
```
The `evaluate` method will return `nil` if there is an error in the formula.
If this is not the desired behavior, use `evaluate!`, which will raise an
exception.
```ruby
calculator.evaluate('10 * x')
#=> nil
calculator.evaluate!('10 * x')
Dentaku::UnboundVariableError: Dentaku::UnboundVariableError
```
Dentaku has built-in functions (including `if`, `not`, `min`, `max`, and
`round`) and the ability to define custom functions (see below). Functions
generally work like their counterparts in Excel:
```ruby
calculator.evaluate('if (pears < 10, 10, 20)', pears: 5)
#=> 10
calculator.evaluate('if (pears < 10, 10, 20)', pears: 15)
#=> 20
```
`round` can be called with or without the number of decimal places:
```ruby
calculator.evaluate('round(8.2)')
#=> 8
calculator.evaluate('round(8.2759, 2)')
#=> 8.28
```
`round` follows rounding rules, while `roundup` and `rounddown` are `ceil` and
`floor`, respectively.
If you're too lazy to be building calculator objects, there's a shortcut just
for you:
```ruby
Dentaku('plums * 1.5', plums: 2)
#=> 3.0
```
PERFORMANCE
-----------
The flexibility and safety of Dentaku don't come without a price. Tokenizing a
string, parsing to an AST, and then evaluating that AST are about 2 orders of
magnitude slower than doing the same math in pure Ruby!
The good news is that most of the time is spent in the tokenization and parsing
phases, so if performance is a concern, you can enable AST caching:
```ruby
Dentaku.enable_ast_cache!
```
After this, Dentaku will cache the AST of each formula that it evaluates, so
subsequent evaluations (even with different values for variables) will be much
faster -- closer to 4x native Ruby speed. As usual, these benchmarks should be
considered rough estimates, and you should measure with representative formulas
from your application. Also, if new formulas are constantly introduced to your
application, AST caching will consume more memory with each new formula.
BUILT-IN OPERATORS AND FUNCTIONS
---------------------------------
Math: `+`, `-`, `*`, `/`, `%`
Logic: `<`, `>`, `<=`, `>=`, `<>`, `!=`, `=`, `AND`, `OR`
Functions: `IF`, `NOT`, `MIN`, `MAX`, `ROUND`, `ROUNDDOWN`, `ROUNDUP`
Selections: `CASE` (syntax see [spec](https://github.com/rubysolo/dentaku/blob/master/spec/calculator_spec.rb#L292))
Math: all functions from Ruby's Math module, including `SIN`, `COS`, `TAN`, etc.
String: `LEFT`, `RIGHT`, `MID`, `LEN`, `FIND`, `SUBSTITUTE`, `CONCAT`
RESOLVING DEPENDENCIES
----------------------
If your formulas rely on one another, they may need to be resolved in a
particular order. For example:
```ruby
calc = Dentaku::Calculator.new
calc.store(monthly_income: 50)
need_to_compute = {
income_taxes: "annual_income / 5",
annual_income: "monthly_income * 12"
}
```
In the example, `annual_income` needs to be computed (and stored) before
`income_taxes`.
Dentaku provides two methods to help resolve formulas in order:
#### Calculator.dependencies
Pass a (string) expression to Dependencies and get back a list of variables (as
`:symbols`) that are required for the expression. `Dependencies` also takes
into account variables already (explicitly) stored into the calculator.
```ruby
calc.dependencies("monthly_income * 12")
#=> []
# (since monthly_income is in memory)
calc.dependencies("annual_income / 5")
#=> [:annual_income]
```
#### Calculator.solve! / Calculator.solve
Have Dentaku figure out the order in which your formulas need to be evaluated.
Pass in a hash of `{eventual_variable_name: "expression"}` to `solve!` and
have Dentaku resolve dependencies (using `TSort`) for you.
Raises `TSort::Cyclic` when a valid expression order cannot be found.
```ruby
calc = Dentaku::Calculator.new
calc.store(monthly_income: 50)
need_to_compute = {
income_taxes: "annual_income / 5",
annual_income: "monthly_income * 12"
}
calc.solve!(need_to_compute)
#=> {annual_income: 600, income_taxes: 120}
calc.solve!(
make_money: "have_money",
have_money: "make_money"
}
#=> raises TSort::Cyclic
```
`solve!` will also raise an exception if any of the formulas in the set cannot
be evaluated (e.g. raise `ZeroDivisionError`). The non-bang `solve` method will
find as many solutions as possible and return the symbol `:undefined` for the
problem formulas.
INLINE COMMENTS
---------------------------------
If your expressions grow long or complex, you may add inline comments for future
reference. This is particularly useful if you save your expressions in a model.
```ruby
calculator.evaluate('kiwi + 5 /* This is a comment */', kiwi: 2)
#=> 7
```
Comments can be single or multi-line. The following are also valid.
```
/*
* This is a multi-line comment
*/
/*
This is another type of multi-line comment
*/
```
EXTERNAL FUNCTIONS
------------------
I don't know everything, so I might not have implemented all the functions you
need. Please implement your favorites and send a pull request! Okay, so maybe
that's not feasible because:
1. You can't be bothered to share
1. You can't wait for me to respond to a pull request, you need it `NOW()`
1. The formula is the secret sauce for your startup
Whatever your reasons, Dentaku supports adding functions at runtime. To add a
function, you'll need to specify a name, a return type, and a lambda that
accepts all function arguments and returns the result value.
Here's an example of adding a function named `POW` that implements
exponentiation.
```ruby
> c = Dentaku::Calculator.new
> c.add_function(:pow, :numeric, ->(mantissa, exponent) { mantissa ** exponent })
> c.evaluate('POW(3,2)')
#=> 9
> c.evaluate('POW(2,3)')
#=> 8
```
Here's an example of adding a variadic function:
```ruby
> c = Dentaku::Calculator.new
> c.add_function(:max, :numeric, ->(*args) { args.max })
> c.evaluate 'MAX(8,6,7,5,3,0,9)'
#=> 9
```
(However both of these are already built-in -- the `^` operator and the `MAX`
function)
Functions can be added individually using Calculator#add_function, or en masse
using Calculator#add_functions.
THANKS
------
Big thanks to [ElkStone Basements](http://www.elkstonebasements.com/) for
allowing me to extract and open source this code. Thanks also to all the
[contributors](https://github.com/rubysolo/dentaku/graphs/contributors)!
LICENSE
-------
(The MIT License)
Copyright © 2012-2016 Solomon White
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the Software), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,28 @@
require 'bundler/gem_tasks'
require 'rspec/core/rake_task'
desc "Run specs"
task :spec do
RSpec::Core::RakeTask.new(:spec) do |t|
t.rspec_opts = %w{--colour --format progress}
t.pattern = 'spec/**/*_spec.rb'
end
end
desc "Default: run specs."
task default: :spec
task :console do
begin
require 'pry'
console = Pry
rescue LoadError
require 'irb'
require 'irb/completion'
console = IRB
end
require 'dentaku'
ARGV.clear
console.start
end

View File

@ -0,0 +1,27 @@
# -*- encoding: utf-8 -*-
$:.push File.expand_path("../lib", __FILE__)
require "dentaku/version"
Gem::Specification.new do |s|
s.name = "dentaku"
s.version = Dentaku::VERSION
s.authors = ["Solomon White"]
s.email = ["rubysolo@gmail.com"]
s.homepage = "http://github.com/rubysolo/dentaku"
s.licenses = %w(MIT)
s.summary = %q{A formula language parser and evaluator}
s.description = <<-DESC
Dentaku is a parser and evaluator for mathematical formulas
DESC
s.rubyforge_project = "dentaku"
s.add_development_dependency('rake')
s.add_development_dependency('rspec')
s.add_development_dependency('pry')
s.files = `git ls-files`.split("\n")
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
s.require_paths = ["lib"]
end

View File

@ -0,0 +1,43 @@
require "bigdecimal"
require "dentaku/calculator"
require "dentaku/version"
module Dentaku
@enable_ast_caching = false
@enable_dependency_order_caching = false
def self.evaluate(expression, data={})
calculator.evaluate(expression, data)
end
def self.enable_caching!
enable_ast_cache!
enable_dependency_order_cache!
end
def self.enable_ast_cache!
@enable_ast_caching = true
end
def self.cache_ast?
@enable_ast_caching
end
def self.enable_dependency_order_cache!
@enable_dependency_order_caching = true
end
def self.cache_dependency_order?
@enable_dependency_order_caching
end
private
def self.calculator
@calculator ||= Dentaku::Calculator.new
end
end
def Dentaku(expression, data={})
Dentaku.evaluate(expression, data)
end

View File

@ -0,0 +1,21 @@
require_relative './ast/node'
require_relative './ast/nil'
require_relative './ast/numeric'
require_relative './ast/logical'
require_relative './ast/string'
require_relative './ast/identifier'
require_relative './ast/arithmetic'
require_relative './ast/negation'
require_relative './ast/comparators'
require_relative './ast/combinators'
require_relative './ast/grouping'
require_relative './ast/case'
require_relative './ast/functions/if'
require_relative './ast/functions/max'
require_relative './ast/functions/min'
require_relative './ast/functions/not'
require_relative './ast/functions/round'
require_relative './ast/functions/roundup'
require_relative './ast/functions/rounddown'
require_relative './ast/functions/ruby_math'
require_relative './ast/functions/string_functions'

View File

@ -0,0 +1,129 @@
require_relative './operation'
require 'bigdecimal'
require 'bigdecimal/util'
module Dentaku
module AST
class Arithmetic < Operation
def initialize(*)
super
unless valid_node?(left) && valid_node?(right)
fail ParseError, "#{ self.class } requires numeric operands"
end
end
def type
:numeric
end
def value(context={})
l = cast(left.value(context))
r = cast(right.value(context))
l.public_send(operator, r)
end
private
def cast(value, prefer_integer=true)
validate_numeric(value)
v = BigDecimal.new(value, Float::DIG+1)
v = v.to_i if prefer_integer && v.frac.zero?
v
end
def valid_node?(node)
node && (node.dependencies.any? || node.type == :numeric)
end
def validate_numeric(value)
Float(value)
rescue ::ArgumentError, ::TypeError
fail Dentaku::ArgumentError, "#{ self.class } requires numeric operands"
end
end
class Addition < Arithmetic
def operator
:+
end
def self.precedence
10
end
end
class Subtraction < Arithmetic
def operator
:-
end
def self.precedence
10
end
end
class Multiplication < Arithmetic
def operator
:*
end
def self.precedence
20
end
end
class Division < Arithmetic
def value(context={})
r = cast(right.value(context), false)
raise Dentaku::ZeroDivisionError if r.zero?
cast(cast(left.value(context)) / r)
end
def self.precedence
20
end
end
class Modulo < Arithmetic
def initialize(left, right)
@left = left
@right = right
unless (valid_node?(left) || left.nil?) && valid_node?(right)
fail ParseError, "#{ self.class } requires numeric operands"
end
end
def percent?
left.nil?
end
def value(context={})
if percent?
cast(right.value(context)) * 0.01
else
super
end
end
def operator
:%
end
def self.precedence
20
end
end
class Exponentiation < Arithmetic
def operator
:**
end
def self.precedence
30
end
end
end
end

View File

@ -0,0 +1,52 @@
require_relative './case/case_conditional'
require_relative './case/case_when'
require_relative './case/case_then'
require_relative './case/case_switch_variable'
require_relative './case/case_else'
module Dentaku
module AST
class Case < Node
def initialize(*nodes)
@switch = nodes.shift
unless @switch.is_a?(AST::CaseSwitchVariable)
raise 'Case missing switch variable'
end
@conditions = nodes
@else = @conditions.pop if @conditions.last.is_a?(AST::CaseElse)
@conditions.each do |condition|
unless condition.is_a?(AST::CaseConditional)
raise "#{condition} is not a CaseConditional"
end
end
end
def value(context={})
switch_value = @switch.value(context)
@conditions.each do |condition|
if condition.when.value(context) == switch_value
return condition.then.value(context)
end
end
if @else
return @else.value(context)
else
raise "No block matched the switch value '#{switch_value}'"
end
end
def dependencies(context={})
# TODO: should short-circuit
@switch.dependencies(context) +
@conditions.flat_map do |condition|
condition.dependencies(context)
end
end
end
end
end

View File

@ -0,0 +1,23 @@
module Dentaku
module AST
class CaseConditional < Node
attr_reader :when,
:then
def initialize(when_statement, then_statement)
@when = when_statement
unless @when.is_a?(AST::CaseWhen)
raise 'Expected first argument to be a CaseWhen'
end
@then = then_statement
unless @then.is_a?(AST::CaseThen)
raise 'Expected second argument to be a CaseThen'
end
end
def dependencies(context={})
@when.dependencies(context) + @then.dependencies(context)
end
end
end
end

View File

@ -0,0 +1,21 @@
module Dentaku
module AST
class CaseElse < Node
def self.arity
1
end
def initialize(node)
@node = node
end
def value(context={})
@node.value(context)
end
def dependencies(context={})
@node.dependencies(context)
end
end
end
end

View File

@ -0,0 +1,21 @@
module Dentaku
module AST
class CaseSwitchVariable < Node
def initialize(node)
@node = node
end
def value(context={})
@node.value(context)
end
def dependencies(context={})
@node.dependencies(context)
end
def self.arity
1
end
end
end
end

View File

@ -0,0 +1,21 @@
module Dentaku
module AST
class CaseThen < Node
def self.arity
1
end
def initialize(node)
@node = node
end
def value(context={})
@node.value(context)
end
def dependencies(context={})
@node.dependencies(context)
end
end
end
end

View File

@ -0,0 +1,21 @@
module Dentaku
module AST
class CaseWhen < Operation
def self.arity
1
end
def initialize(node)
@node = node
end
def value(context={})
@node.value(context)
end
def dependencies(context={})
@node.dependencies(context)
end
end
end
end

View File

@ -0,0 +1,36 @@
require_relative './operation'
module Dentaku
module AST
class Combinator < Operation
def initialize(*)
super
unless valid_node?(left) && valid_node?(right)
fail ParseError, "#{ self.class } requires logical operands"
end
end
def type
:logical
end
private
def valid_node?(node)
node.dependencies.any? || node.type == :logical
end
end
class And < Combinator
def value(context={})
left.value(context) && right.value(context)
end
end
class Or < Combinator
def value(context={})
left.value(context) || right.value(context)
end
end
end
end

View File

@ -0,0 +1,51 @@
require_relative './operation'
module Dentaku
module AST
class Comparator < Operation
def self.precedence
5
end
def type
:logical
end
end
class LessThan < Comparator
def value(context={})
left.value(context) < right.value(context)
end
end
class LessThanOrEqual < Comparator
def value(context={})
left.value(context) <= right.value(context)
end
end
class GreaterThan < Comparator
def value(context={})
left.value(context) > right.value(context)
end
end
class GreaterThanOrEqual < Comparator
def value(context={})
left.value(context) >= right.value(context)
end
end
class NotEqual < Comparator
def value(context={})
left.value(context) != right.value(context)
end
end
class Equal < Comparator
def value(context={})
left.value(context) == right.value(context)
end
end
end
end

View File

@ -0,0 +1,73 @@
require_relative 'node'
module Dentaku
module AST
class Function < Node
def initialize(*args)
@args = args
end
def dependencies(context={})
@args.flat_map { |a| a.dependencies(context) }
end
def self.get(name)
registry.fetch(function_name(name)) {
fail ParseError, "Undefined function #{ name }"
}
end
def self.register(name, type, implementation)
function = Class.new(self) do
def self.implementation=(impl)
@implementation = impl
end
def self.implementation
@implementation
end
def self.type=(type)
@type = type
end
def self.type
@type
end
def value(context={})
args = @args.map { |a| a.value(context) }
self.class.implementation.call(*args)
end
def type
self.class.type
end
end
function_class = name.to_s.capitalize
Dentaku::AST.send(:remove_const, function_class) if Dentaku::AST.const_defined?(function_class)
Dentaku::AST.const_set(function_class, function)
function.implementation = implementation
function.type = type
registry[function_name(name)] = function
end
def self.register_class(name, function_class)
registry[function_name(name)] = function_class
end
private
def self.function_name(name)
name.to_s.downcase
end
def self.registry
@registry ||= {}
end
end
end
end

View File

@ -0,0 +1,30 @@
require_relative '../function'
module Dentaku
module AST
class If < Function
attr_reader :predicate, :left, :right
def initialize(predicate, left, right)
@predicate = predicate
@left = left
@right = right
end
def value(context={})
predicate.value(context) ? left.value(context) : right.value(context)
end
def type
left.type
end
def dependencies(context={})
# TODO : short-circuit?
(predicate.dependencies(context) + left.dependencies(context) + right.dependencies(context)).uniq
end
end
end
end
Dentaku::AST::Function.register_class(:if, Dentaku::AST::If)

View File

@ -0,0 +1,5 @@
require_relative '../function'
Dentaku::AST::Function.register(:max, :numeric, ->(*args) {
args.max
})

View File

@ -0,0 +1,5 @@
require_relative '../function'
Dentaku::AST::Function.register(:min, :numeric, ->(*args) {
args.min
})

View File

@ -0,0 +1,5 @@
require_relative '../function'
Dentaku::AST::Function.register(:not, :logical, ->(logical) {
! logical
})

View File

@ -0,0 +1,5 @@
require_relative '../function'
Dentaku::AST::Function.register(:round, :numeric, ->(numeric, places=nil) {
numeric.round(places || 0)
})

View File

@ -0,0 +1,7 @@
require_relative '../function'
Dentaku::AST::Function.register(:rounddown, :numeric, ->(numeric, precision=0) {
tens = 10.0**precision
result = (numeric * tens).floor / tens
precision <= 0 ? result.to_i : result
})

View File

@ -0,0 +1,7 @@
require_relative '../function'
Dentaku::AST::Function.register(:roundup, :numeric, ->(numeric, precision=0) {
tens = 10.0**precision
result = (numeric * tens).ceil / tens
precision <= 0 ? result.to_i : result
})

View File

@ -0,0 +1,8 @@
# import all functions from Ruby's Math module
require_relative "../function"
Math.methods(false).each do |method|
Dentaku::AST::Function.register(method, :numeric, ->(*args) {
Math.send(method, *args)
})
end

View File

@ -0,0 +1,111 @@
require_relative '../function'
module Dentaku
module AST
module StringFunctions
class Left < Function
def initialize(string, length)
@string = string
@length = length
end
def value(context={})
string = @string.value(context).to_s
length = @length.value(context)
string[0, length]
end
end
class Right < Function
def initialize(string, length)
@string = string
@length = length
end
def value(context={})
string = @string.value(context).to_s
length = @length.value(context)
string[length * -1, length] || string
end
end
class Mid < Function
def initialize(string, offset, length)
@string = string
@offset = offset
@length = length
end
def value(context={})
string = @string.value(context).to_s
offset = @offset.value(context)
length = @length.value(context)
string[offset - 1, length].to_s
end
end
class Len < Function
def initialize(string)
@string = string
end
def value(context={})
string = @string.value(context).to_s
string.length
end
end
class Find < Function
def initialize(needle, haystack)
@needle = needle
@haystack = haystack
end
def value(context={})
needle = @needle.value(context)
needle = needle.to_s unless needle.is_a?(Regexp)
haystack = @haystack.value(context).to_s
pos = haystack.index(needle)
pos && pos + 1
end
end
class Substitute < Function
def initialize(original, search, replacement)
@original = original
@search = search
@replacement = replacement
end
def value(context={})
original = @original.value(context).to_s
search = @search.value(context)
search = search.to_s unless search.is_a?(Regexp)
replacement = @replacement.value(context).to_s
original.sub(search, replacement)
end
end
class Concat < Function
def initialize(left, right)
@left = left
@right = right
end
def value(context={})
left = @left.value(context).to_s
right = @right.value(context).to_s
left + right
end
end
end
end
end
Dentaku::AST::Function.register_class(:left, Dentaku::AST::StringFunctions::Left)
Dentaku::AST::Function.register_class(:right, Dentaku::AST::StringFunctions::Right)
Dentaku::AST::Function.register_class(:mid, Dentaku::AST::StringFunctions::Mid)
Dentaku::AST::Function.register_class(:len, Dentaku::AST::StringFunctions::Len)
Dentaku::AST::Function.register_class(:find, Dentaku::AST::StringFunctions::Find)
Dentaku::AST::Function.register_class(:substitute, Dentaku::AST::StringFunctions::Substitute)
Dentaku::AST::Function.register_class(:concat, Dentaku::AST::StringFunctions::Concat)

View File

@ -0,0 +1,21 @@
module Dentaku
module AST
class Grouping
def initialize(node)
@node = node
end
def value(context={})
@node.value(context)
end
def type
@node.type
end
def dependencies(context={})
@node.dependencies(context)
end
end
end
end

View File

@ -0,0 +1,36 @@
require_relative '../exceptions'
module Dentaku
module AST
class Identifier < Node
attr_reader :identifier
def initialize(token)
@identifier = token.value.downcase
end
def value(context={})
v = context.fetch(identifier) do
raise UnboundVariableError.new([identifier])
end
case v
when Node
v.value(context)
else
v
end
end
def dependencies(context={})
context.has_key?(identifier) ? dependencies_of(context[identifier]) : [identifier]
end
private
def dependencies_of(node)
node.respond_to?(:dependencies) ? node.dependencies : []
end
end
end
end

View File

@ -0,0 +1,20 @@
module Dentaku
module AST
class Literal < Node
attr_reader :type
def initialize(token)
@value = token.value
@type = token.category
end
def value(*)
@value
end
def dependencies(*)
[]
end
end
end
end

View File

@ -0,0 +1,8 @@
require_relative "./literal"
module Dentaku
module AST
class Logical < Literal
end
end
end

View File

@ -0,0 +1,40 @@
module Dentaku
module AST
class Negation < Operation
def initialize(node)
@node = node
fail ParseError, "Negation requires numeric operand" unless valid_node?(node)
end
def value(context={})
@node.value(context) * -1
end
def type
:numeric
end
def self.arity
1
end
def self.right_associative?
true
end
def self.precedence
40
end
def dependencies(context={})
@node.dependencies(context)
end
private
def valid_node?(node)
node && (node.dependencies.any? || node.type == :numeric)
end
end
end
end

View File

@ -0,0 +1,9 @@
module Dentaku
module AST
class Nil < Node
def value(*)
nil
end
end
end
end

View File

@ -0,0 +1,17 @@
module Dentaku
module AST
class Node
def self.precedence
0
end
def self.arity
nil
end
def dependencies(context={})
[]
end
end
end
end

View File

@ -0,0 +1,8 @@
require_relative "./literal"
module Dentaku
module AST
class Numeric < Literal
end
end
end

View File

@ -0,0 +1,22 @@
require_relative './node'
module Dentaku
module AST
class Operation < Node
attr_reader :left, :right
def initialize(left, right)
@left = left
@right = right
end
def dependencies(context={})
(left.dependencies(context) + right.dependencies(context)).uniq
end
def self.right_associative?
false
end
end
end
end

View File

@ -0,0 +1,8 @@
require_relative "./literal"
module Dentaku
module AST
class String < Literal
end
end
end

View File

@ -0,0 +1,99 @@
require 'dentaku/calculator'
require 'dentaku/dependency_resolver'
require 'dentaku/exceptions'
require 'dentaku/parser'
require 'dentaku/tokenizer'
module Dentaku
class BulkExpressionSolver
def initialize(expression_hash, calculator)
self.expression_hash = expression_hash
self.calculator = calculator
end
def solve!
solve(&raise_exception_handler)
end
def solve(&block)
error_handler = block || return_undefined_handler
results = load_results(&error_handler)
expression_hash.each_with_object({}) do |(k, _), r|
r[k] = results[k.to_s]
end
end
private
def self.dependency_cache
@dep_cache ||= {}
end
attr_accessor :expression_hash, :calculator
def return_undefined_handler
->(*) { :undefined }
end
def raise_exception_handler
->(ex) { raise ex }
end
def load_results(&block)
variables_in_resolve_order.each_with_object({}) do |var_name, r|
begin
value_from_memory = calculator.memory[var_name]
if value_from_memory.nil? &&
expressions[var_name].nil? &&
!calculator.memory.has_key?(var_name)
next
end
value = value_from_memory ||
evaluate!(expressions[var_name], expressions.merge(r))
r[var_name] = value
rescue Dentaku::UnboundVariableError, ZeroDivisionError => ex
ex.recipient_variable = var_name
r[var_name] = block.call(ex)
end
end
end
def expressions
@expressions ||= Hash[expression_hash.map { |k,v| [k.to_s, v] }]
end
def expression_dependencies
Hash[expressions.map { |var, expr| [var, calculator.dependencies(expr)] }].tap do |d|
d.values.each do |deps|
unresolved = deps.reject { |ud| d.has_key?(ud) }
unresolved.each { |u| add_dependencies(d, u) }
end
end
end
def add_dependencies(current_dependencies, variable)
node = calculator.memory[variable]
if node.respond_to?(:dependencies)
current_dependencies[variable] = node.dependencies
node.dependencies.each { |d| add_dependencies(current_dependencies, d) }
end
end
def variables_in_resolve_order
cache_key = expressions.keys.map(&:to_s).sort.join("|")
@ordered_deps ||= self.class.dependency_cache.fetch(cache_key) {
DependencyResolver.find_resolve_order(expression_dependencies).tap do |d|
self.class.dependency_cache[cache_key] = d if Dentaku.cache_dependency_order?
end
}
end
def evaluate!(expression, results)
calculator.evaluate!(expression, results)
end
end
end

View File

@ -0,0 +1,124 @@
require 'dentaku/bulk_expression_solver'
require 'dentaku/exceptions'
require 'dentaku/token'
require 'dentaku/dependency_resolver'
require 'dentaku/parser'
module Dentaku
class Calculator
attr_reader :result, :memory, :tokenizer
def initialize
clear
@tokenizer = Tokenizer.new
@ast_cache = {}
@disable_ast_cache = false
end
def add_function(name, type, body)
Dentaku::AST::Function.register(name, type, body)
self
end
def add_functions(fns)
fns.each { |(name, type, body)| add_function(name, type, body) }
self
end
def disable_cache
@disable_ast_cache = true
yield(self) if block_given?
ensure
@disable_ast_cache = false
end
def evaluate(expression, data={})
evaluate!(expression, data)
rescue UnboundVariableError, ArgumentError
yield expression if block_given?
end
def evaluate!(expression, data={})
store(data) do
node = expression
node = ast(node) unless node.is_a?(AST::Node)
node.value(memory)
end
end
def solve!(expression_hash)
BulkExpressionSolver.new(expression_hash, self).solve!
end
def solve(expression_hash, &block)
BulkExpressionSolver.new(expression_hash, self).solve(&block)
end
def dependencies(expression)
ast(expression).dependencies(memory)
end
def ast(expression)
@ast_cache.fetch(expression) {
Parser.new(tokenizer.tokenize(expression)).parse.tap do |node|
@ast_cache[expression] = node if cache_ast?
end
}
end
def clear_cache(pattern=:all)
case pattern
when :all
@ast_cache = {}
when String
@ast_cache.delete(pattern)
when Regexp
@ast_cache.delete_if { |k,_| k =~ pattern }
else
fail Dentaku::ArgumentError
end
end
def store(key_or_hash, value=nil)
restore = Hash[memory]
if value.nil?
key_or_hash.each do |key, val|
memory[key.to_s.downcase] = val
end
else
memory[key_or_hash.to_s.downcase] = value
end
if block_given?
begin
result = yield
@memory = restore
return result
rescue => e
@memory = restore
raise e
end
end
self
end
alias_method :bind, :store
def store_formula(key, formula)
store(key, ast(formula))
end
def clear
@memory = {}
end
def empty?
memory.empty?
end
def cache_ast?
Dentaku.cache_ast? && !@disable_ast_cache
end
end
end

View File

@ -0,0 +1,24 @@
require 'tsort'
module Dentaku
class DependencyResolver
include TSort
def self.find_resolve_order(vars_to_dependencies_hash)
self.new(vars_to_dependencies_hash).tsort
end
def initialize(vars_to_dependencies_hash)
# ensure variables are strings
@vars_to_deps = Hash[vars_to_dependencies_hash.map { |k, v| [k.to_s, v]}]
end
def tsort_each_node(&block)
@vars_to_deps.each_key(&block)
end
def tsort_each_child(node, &block)
@vars_to_deps.fetch(node.to_s, []).each(&block)
end
end
end

View File

@ -0,0 +1,25 @@
module Dentaku
class UnboundVariableError < StandardError
attr_accessor :recipient_variable
attr_reader :unbound_variables
def initialize(unbound_variables)
@unbound_variables = unbound_variables
super("no value provided for variables: #{ unbound_variables.join(', ') }")
end
end
class ParseError < StandardError
end
class TokenizerError < StandardError
end
class ArgumentError < ::ArgumentError
end
class ZeroDivisionError < ::ZeroDivisionError
attr_accessor :recipient_variable
end
end

View File

@ -0,0 +1,222 @@
require_relative './ast'
module Dentaku
class Parser
attr_reader :input, :output, :operations, :arities
def initialize(tokens, options={})
@input = tokens.dup
@output = []
@operations = options.fetch(:operations, [])
@arities = options.fetch(:arities, [])
end
def get_args(count)
Array.new(count) { output.pop }.reverse
end
def consume(count=2)
operator = operations.pop
output.push operator.new(*get_args(operator.arity || count))
end
def parse
return AST::Nil.new if input.empty?
while token = input.shift
case token.category
when :numeric
output.push AST::Numeric.new(token)
when :logical
output.push AST::Logical.new(token)
when :string
output.push AST::String.new(token)
when :identifier
output.push AST::Identifier.new(token)
when :operator, :comparator, :combinator
op_class = operation(token)
if op_class.right_associative?
while operations.last && operations.last < AST::Operation && op_class.precedence < operations.last.precedence
consume
end
operations.push op_class
else
while operations.last && operations.last < AST::Operation && op_class.precedence <= operations.last.precedence
consume
end
operations.push op_class
end
when :null
output.push AST::Nil.new
when :function
arities.push 0
operations.push function(token)
when :case
case token.value
when :open
# special handling for case nesting: strip out inner case
# statements and parse their AST segments recursively
if operations.include?(AST::Case)
last_case_close_index = nil
first_nested_case_close_index = nil
input.each_with_index do |token, index|
first_nested_case_close_index = last_case_close_index
if token.category == :case && token.value == :close
last_case_close_index = index
end
end
inner_case_inputs = input.slice!(0..first_nested_case_close_index)
subparser = Parser.new(
inner_case_inputs,
operations: [AST::Case],
arities: [0]
)
subparser.parse
output.concat(subparser.output)
else
operations.push AST::Case
arities.push(0)
end
when :close
if operations[1] == AST::CaseThen
while operations.last != AST::Case
consume
end
operations.push(AST::CaseConditional)
consume(2)
arities[-1] += 1
elsif operations[1] == AST::CaseElse
while operations.last != AST::Case
consume
end
arities[-1] += 1
end
unless operations.count == 1 && operations.last == AST::Case
fail ParseError, "Unprocessed token #{ token.value }"
end
consume(arities.pop.succ)
when :when
if operations[1] == AST::CaseThen
while ![AST::CaseWhen, AST::Case].include?(operations.last)
consume
end
operations.push(AST::CaseConditional)
consume(2)
arities[-1] += 1
elsif operations.last == AST::Case
operations.push(AST::CaseSwitchVariable)
consume
end
operations.push(AST::CaseWhen)
when :then
if operations[1] == AST::CaseWhen
while ![AST::CaseThen, AST::Case].include?(operations.last)
consume
end
end
operations.push(AST::CaseThen)
when :else
if operations[1] == AST::CaseThen
while operations.last != AST::Case
consume
end
operations.push(AST::CaseConditional)
consume(2)
arities[-1] += 1
end
operations.push(AST::CaseElse)
else
fail ParseError, "Unknown case token #{ token.value }"
end
when :grouping
case token.value
when :open
if input.first && input.first.value == :close
input.shift
consume(0)
else
operations.push AST::Grouping
end
when :close
while operations.any? && operations.last != AST::Grouping
consume
end
lparen = operations.pop
fail ParseError, "Unbalanced parenthesis" unless lparen == AST::Grouping
if operations.last && operations.last < AST::Function
consume(arities.pop.succ)
end
when :comma
arities[-1] += 1
while operations.any? && operations.last != AST::Grouping
consume
end
else
fail ParseError, "Unknown grouping token #{ token.value }"
end
else
fail ParseError, "Not implemented for tokens of category #{ token.category }"
end
end
while operations.any?
consume
end
unless output.count == 1
fail ParseError, "Invalid statement"
end
output.first
end
def operation(token)
{
add: AST::Addition,
subtract: AST::Subtraction,
multiply: AST::Multiplication,
divide: AST::Division,
pow: AST::Exponentiation,
negate: AST::Negation,
mod: AST::Modulo,
lt: AST::LessThan,
gt: AST::GreaterThan,
le: AST::LessThanOrEqual,
ge: AST::GreaterThanOrEqual,
ne: AST::NotEqual,
eq: AST::Equal,
and: AST::And,
or: AST::Or,
}.fetch(token.value)
end
def function(token)
Dentaku::AST::Function.get(token.value)
end
end
end

View File

@ -0,0 +1,32 @@
module Dentaku
class Token
attr_reader :category, :raw_value, :value
def initialize(category, value, raw_value=nil)
@category = category
@value = value
@raw_value = raw_value
end
def to_s
raw_value || value
end
def length
raw_value.to_s.length
end
def grouping?
is?(:grouping)
end
def is?(c)
category == c
end
def ==(other)
(category.nil? || other.category.nil? || category == other.category) &&
(value.nil? || other.value.nil? || value == other.value)
end
end
end

View File

@ -0,0 +1,137 @@
require 'dentaku/token'
module Dentaku
class TokenMatcher
attr_reader :children, :categories, :values
def initialize(categories=nil, values=nil, children=[])
# store categories and values as hash to optimize key lookup, h/t @jan-mangs
@categories = [categories].compact.flatten.each_with_object({}) { |c,h| h[c] = 1 }
@values = [values].compact.flatten.each_with_object({}) { |v,h| h[v] = 1 }
@children = children.compact
@invert = false
@min = 1
@max = 1
@range = (@min..@max)
end
def | (other_matcher)
self.class.new(:nomatch, :nomatch, leaf_matchers + other_matcher.leaf_matchers)
end
def invert
@invert = ! @invert
self
end
def ==(token)
leaf_matcher? ? matches_token?(token) : any_child_matches_token?(token)
end
def match(token_stream, offset=0)
matched_tokens = []
matched = false
while self == token_stream[matched_tokens.length + offset] && matched_tokens.length < @max
matched_tokens << token_stream[matched_tokens.length + offset]
end
if @range.cover?(matched_tokens.length)
matched = true
end
[matched, matched_tokens]
end
def caret
@caret = true
self
end
def caret?
@caret
end
def star
@min = 0
@max = Float::INFINITY
@range = (@min..@max)
self
end
def plus
@max = Float::INFINITY
@range = (@min..@max)
self
end
def leaf_matcher?
children.empty?
end
def leaf_matchers
leaf_matcher? ? [self] : children
end
private
def any_child_matches_token?(token)
children.any? { |child| child == token }
end
def matches_token?(token)
return false if token.nil?
(category_match(token.category) && value_match(token.value)) ^ @invert
end
def category_match(category)
@categories.empty? || @categories.key?(category)
end
def value_match(value)
@values.empty? || @values.key?(value)
end
def self.numeric; new(:numeric); end
def self.string; new(:string); end
def self.logical; new(:logical); end
def self.value
new(:numeric) | new(:string) | new(:logical)
end
def self.addsub; new(:operator, [:add, :subtract]); end
def self.subtract; new(:operator, :subtract); end
def self.anchored_minus; new(:operator, :subtract).caret; end
def self.muldiv; new(:operator, [:multiply, :divide]); end
def self.pow; new(:operator, :pow); end
def self.mod; new(:operator, :mod); end
def self.combinator; new(:combinator); end
def self.comparator; new(:comparator); end
def self.comp_gt; new(:comparator, [:gt, :ge]); end
def self.comp_lt; new(:comparator, [:lt, :le]); end
def self.open; new(:grouping, :open); end
def self.close; new(:grouping, :close); end
def self.comma; new(:grouping, :comma); end
def self.non_group; new(:grouping).invert; end
def self.non_group_star; new(:grouping).invert.star; end
def self.non_close_plus; new(:grouping, :close).invert.plus; end
def self.arguments; (value | comma).plus; end
def self.if; new(:function, :if); end
def self.round; new(:function, :round); end
def self.roundup; new(:function, :roundup); end
def self.rounddown; new(:function, :rounddown); end
def self.not; new(:function, :not); end
def self.method_missing(name, *args, &block)
new(:function, name)
end
def self.respond_to_missing?(name, include_priv)
true
end
end
end

View File

@ -0,0 +1,29 @@
module Dentaku
module TokenMatchers
def self.token_matchers(*symbols)
symbols.map { |s| matcher(s) }
end
def self.function_token_matchers(function_name, *symbols)
token_matchers(:open, *symbols, :close).unshift(
TokenMatcher.send(function_name)
)
end
def self.matcher(symbol)
@matchers ||= [
:numeric, :string, :addsub, :subtract, :muldiv, :pow, :mod,
:comparator, :comp_gt, :comp_lt, :open, :close, :comma,
:non_close_plus, :non_group, :non_group_star, :arguments,
:logical, :combinator, :if, :round, :roundup, :rounddown, :not,
:anchored_minus, :math_neg_pow, :math_neg_mul
].each_with_object({}) do |name, matchers|
matchers[name] = TokenMatcher.send(name)
end
@matchers.fetch(symbol) do
raise "Unknown token symbol #{ symbol }"
end
end
end
end

View File

@ -0,0 +1,142 @@
require 'bigdecimal'
require 'dentaku/token'
module Dentaku
class TokenScanner
def initialize(category, regexp, converter=nil, condition=nil)
@category = category
@regexp = %r{\A(#{ regexp })}i
@converter = converter
@condition = condition || ->(*) { true }
end
def scan(string, last_token=nil)
if (m = @regexp.match(string)) && @condition.call(last_token)
value = raw = m.to_s
value = @converter.call(raw) if @converter
return Array(value).map do |v|
Token === v ? v : Token.new(@category, v, raw)
end
end
false
end
class << self
def available_scanners
[
:null,
:whitespace,
:numeric,
:double_quoted_string,
:single_quoted_string,
:negate,
:operator,
:grouping,
:case_statement,
:comparator,
:combinator,
:boolean,
:function,
:identifier
]
end
def register_default_scanners
register_scanners(available_scanners)
end
def register_scanners(scanner_ids)
@scanners = scanner_ids.each_with_object({}) do |id, scanners|
scanners[id] = self.send(id)
end
end
def register_scanner(id, scanner)
@scanners[id] = scanner
end
def scanners=(scanner_ids)
@scanners.select! { |k,v| scanner_ids.include?(k) }
end
def scanners
@scanners.values
end
def whitespace
new(:whitespace, '\s+')
end
def null
new(:null, 'null\b')
end
def numeric
new(:numeric, '(\d+(\.\d+)?|\.\d+)\b', lambda { |raw| raw =~ /\./ ? BigDecimal.new(raw) : raw.to_i })
end
def double_quoted_string
new(:string, '"[^"]*"', lambda { |raw| raw.gsub(/^"|"$/, '') })
end
def single_quoted_string
new(:string, "'[^']*'", lambda { |raw| raw.gsub(/^'|'$/, '') })
end
def negate
new(:operator, '-', lambda { |raw| :negate }, lambda { |last_token|
last_token.nil? ||
last_token.is?(:operator) ||
last_token.is?(:comparator) ||
last_token.is?(:combinator) ||
last_token.value == :open ||
last_token.value == :comma
})
end
def operator
names = { pow: '^', add: '+', subtract: '-', multiply: '*', divide: '/', mod: '%' }.invert
new(:operator, '\^|\+|-|\*|\/|%', lambda { |raw| names[raw] })
end
def grouping
names = { open: '(', close: ')', comma: ',' }.invert
new(:grouping, '\(|\)|,', lambda { |raw| names[raw] })
end
def case_statement
names = { open: 'case', close: 'end', then: 'then', when: 'when', else: 'else' }.invert
new(:case, '(case|end|then|when|else)\b', lambda { |raw| names[raw.downcase] })
end
def comparator
names = { le: '<=', ge: '>=', ne: '!=', lt: '<', gt: '>', eq: '=' }.invert
alternate = { ne: '<>', eq: '==' }.invert
new(:comparator, '<=|>=|!=|<>|<|>|==|=', lambda { |raw| names[raw] || alternate[raw] })
end
def combinator
new(:combinator, '(and|or)\b', lambda { |raw| raw.strip.downcase.to_sym })
end
def boolean
new(:logical, '(true|false)\b', lambda { |raw| raw.strip.downcase == 'true' })
end
def function
new(:function, '\w+\s*\(', lambda do |raw|
function_name = raw.gsub('(', '')
[Token.new(:function, function_name.strip.downcase.to_sym, function_name), Token.new(:grouping, :open, '(')]
end)
end
def identifier
new(:identifier, '\w+\b', lambda { |raw| raw.strip.downcase })
end
end
register_default_scanners
end
end

View File

@ -0,0 +1,54 @@
require 'dentaku/token'
require 'dentaku/token_matcher'
require 'dentaku/token_scanner'
module Dentaku
class Tokenizer
LPAREN = TokenMatcher.new(:grouping, :open)
RPAREN = TokenMatcher.new(:grouping, :close)
def tokenize(string)
@nesting = 0
@tokens = []
input = strip_comments(string.to_s.dup)
until input.empty?
fail TokenizerError, "parse error at: '#{ input }'" unless TokenScanner.scanners.any? do |scanner|
scanned, input = scan(input, scanner)
scanned
end
end
fail TokenizerError, "too many opening parentheses" if @nesting > 0
@tokens
end
def last_token
@tokens.last
end
def scan(string, scanner)
if tokens = scanner.scan(string, last_token)
tokens.each do |token|
fail TokenizerError, "unexpected zero-width match (:#{ token.category }) at '#{ string }'" if token.length == 0
@nesting += 1 if LPAREN == token
@nesting -= 1 if RPAREN == token
fail TokenizerError, "too many closing parentheses" if @nesting < 0
@tokens << token unless token.is?(:whitespace)
end
match_length = tokens.map(&:length).reduce(:+)
[true, string[match_length..-1]]
else
[false, string]
end
end
def strip_comments(input)
input.gsub(/\/\*[^*]*\*+(?:[^*\/][^*]*\*+)*\//, '')
end
end
end

View File

@ -0,0 +1,3 @@
module Dentaku
VERSION = "2.0.9"
end

View File

@ -0,0 +1,29 @@
require 'spec_helper'
require 'dentaku/ast/arithmetic'
require 'dentaku/token'
describe Dentaku::AST::Addition do
let(:five) { Dentaku::AST::Logical.new Dentaku::Token.new(:numeric, 5) }
let(:six) { Dentaku::AST::Logical.new Dentaku::Token.new(:numeric, 6) }
let(:t) { Dentaku::AST::Numeric.new Dentaku::Token.new(:logical, true) }
it 'performs addition' do
node = described_class.new(five, six)
expect(node.value).to eq 11
end
it 'requires numeric operands' do
expect {
described_class.new(five, t)
}.to raise_error(Dentaku::ParseError, /requires numeric operands/)
expression = Dentaku::AST::Multiplication.new(five, five)
group = Dentaku::AST::Grouping.new(expression)
expect {
described_class.new(group, five)
}.not_to raise_error
end
end

View File

@ -0,0 +1,32 @@
require 'spec_helper'
require 'dentaku/ast/combinators'
require 'dentaku/token'
describe Dentaku::AST::And do
let(:t) { Dentaku::AST::Logical.new Dentaku::Token.new(:logical, true) }
let(:f) { Dentaku::AST::Logical.new Dentaku::Token.new(:logical, false) }
let(:five) { Dentaku::AST::Numeric.new Dentaku::Token.new(:numeric, 5) }
it 'performs logical AND' do
node = described_class.new(t, f)
expect(node.value).to eq false
end
it 'requires logical operands' do
expect {
described_class.new(t, five)
}.to raise_error(Dentaku::ParseError, /requires logical operands/)
expression = Dentaku::AST::LessThanOrEqual.new(five, five)
expect {
described_class.new(t, expression)
}.not_to raise_error
expression = Dentaku::AST::Or.new(t, f)
expect {
described_class.new(t, expression)
}.not_to raise_error
end
end

View File

@ -0,0 +1,80 @@
require 'spec_helper'
require 'dentaku/ast/operation'
require 'dentaku/ast/logical'
require 'dentaku/ast/identifier'
require 'dentaku/ast/arithmetic'
require 'dentaku/ast/case'
require 'dentaku/token'
describe Dentaku::AST::Case do
let!(:one) { Dentaku::AST::Logical.new Dentaku::Token.new(:numeric, 1) }
let!(:two) { Dentaku::AST::Logical.new Dentaku::Token.new(:numeric, 2) }
let!(:apple) do
Dentaku::AST::Logical.new Dentaku::Token.new(:string, 'apple')
end
let!(:banana) do
Dentaku::AST::Logical.new Dentaku::Token.new(:string, 'banana')
end
let!(:identifier) do
Dentaku::AST::Identifier.new(Dentaku::Token.new(:identifier, :fruit))
end
let!(:switch) { Dentaku::AST::CaseSwitchVariable.new(identifier) }
let!(:when1) { Dentaku::AST::CaseWhen.new(apple) }
let!(:then1) { Dentaku::AST::CaseThen.new(one) }
let!(:conditional1) { Dentaku::AST::CaseConditional.new(when1, then1) }
let!(:when2) { Dentaku::AST::CaseWhen.new(banana) }
let!(:then2) { Dentaku::AST::CaseThen.new(two) }
let!(:conditional2) { Dentaku::AST::CaseConditional.new(when2, then2) }
describe '#value' do
it 'raises an exception if there is no switch variable' do
expect { described_class.new(conditional1, conditional2) }
.to raise_error('Case missing switch variable')
end
it 'raises an exception if a non-conditional is passed' do
expect { described_class.new(switch, conditional1, when2) }
.to raise_error(/is not a CaseConditional/)
end
it 'tests each conditional against the switch variable' do
node = described_class.new(switch, conditional1, conditional2)
expect(node.value(fruit: 'banana')).to eq(2)
end
it 'raises an exception if the conditional is not matched' do
node = described_class.new(switch, conditional1, conditional2)
expect { node.value(fruit: 'orange') }
.to raise_error("No block matched the switch value 'orange'")
end
it 'uses the else value if provided and conditional is not matched' do
three = Dentaku::AST::Logical.new Dentaku::Token.new(:numeric, 3)
else_statement = Dentaku::AST::CaseElse.new(three)
node = described_class.new(
switch,
conditional1,
conditional2,
else_statement)
expect(node.value(fruit: 'orange')).to eq(3)
end
end
describe '#dependencies' do
let!(:tax) do
Dentaku::AST::Identifier.new(Dentaku::Token.new(:identifier, :tax))
end
let!(:addition) { Dentaku::AST::Addition.new(two, tax) }
let!(:when2) { Dentaku::AST::CaseWhen.new(banana) }
let!(:then2) { Dentaku::AST::CaseThen.new(addition) }
let!(:conditional2) { Dentaku::AST::CaseConditional.new(when2, then2) }
it 'gathers dependencies from switch and conditionals' do
node = described_class.new(switch, conditional1, conditional2)
expect(node.dependencies).to eq([:fruit, :tax])
end
end
end

View File

@ -0,0 +1,29 @@
require 'spec_helper'
require 'dentaku/ast/arithmetic'
require 'dentaku/token'
describe Dentaku::AST::Division do
let(:five) { Dentaku::AST::Logical.new Dentaku::Token.new(:numeric, 5) }
let(:six) { Dentaku::AST::Logical.new Dentaku::Token.new(:numeric, 6) }
let(:t) { Dentaku::AST::Numeric.new Dentaku::Token.new(:logical, true) }
it 'performs division' do
node = described_class.new(five, six)
expect(node.value.round(4)).to eq 0.8333
end
it 'requires numeric operands' do
expect {
described_class.new(five, t)
}.to raise_error(Dentaku::ParseError, /requires numeric operands/)
expression = Dentaku::AST::Multiplication.new(five, five)
group = Dentaku::AST::Grouping.new(expression)
expect {
described_class.new(group, five)
}.not_to raise_error
end
end

View File

@ -0,0 +1,21 @@
require 'spec_helper'
require 'dentaku/ast/function'
describe Dentaku::AST::Function do
it 'maintains a function registry' do
expect(described_class).to respond_to(:get)
end
it 'raises an exception when trying to access an undefined function' do
expect {
described_class.get("flarble")
}.to raise_error(Dentaku::ParseError, /undefined function/i)
end
it 'registers a custom function' do
described_class.register("flarble", :string, -> { "flarble" })
expect { described_class.get("flarble") }.not_to raise_error
function = described_class.get("flarble").new
expect(function.value).to eq "flarble"
end
end

View File

@ -0,0 +1,40 @@
require 'spec_helper'
require 'dentaku/ast/node'
require 'dentaku/tokenizer'
require 'dentaku/parser'
describe Dentaku::AST::Node do
it 'returns list of dependencies' do
node = make_node('x + 5')
expect(node.dependencies).to eq ['x']
node = make_node('5 < x')
expect(node.dependencies).to eq ['x']
node = make_node('5 < 7')
expect(node.dependencies).to eq []
node = make_node('(y * 7)')
expect(node.dependencies).to eq ['y']
node = make_node('if(x > 5, y, z)')
expect(node.dependencies).to eq ['x', 'y', 'z']
node = make_node('if(x > 5, y, z)')
expect(node.dependencies('x' => 7)).to eq ['y', 'z']
node = make_node('')
expect(node.dependencies).to eq []
end
it 'returns unique list of dependencies' do
node = make_node('x + x')
expect(node.dependencies).to eq ['x']
end
private
def make_node(expression)
Dentaku::Parser.new(Dentaku::Tokenizer.new.tokenize(expression)).parse
end
end

View File

@ -0,0 +1,16 @@
require 'spec_helper'
require 'dentaku/ast/numeric'
require 'dentaku/token'
describe Dentaku::AST::Numeric do
subject { described_class.new(Dentaku::Token.new(:numeric, 5)) }
it 'has numeric type' do
expect(subject.type).to eq :numeric
end
it 'has no dependencies' do
expect(subject.dependencies).to be_empty
end
end

View File

@ -0,0 +1,135 @@
require 'spec_helper'
require 'dentaku/ast/functions/string_functions'
describe Dentaku::AST::StringFunctions::Left do
let(:string) { identifier('string') }
let(:length) { identifier('length') }
subject { described_class.new(string, length) }
it 'returns the left N characters of the string' do
expect(subject.value('string' => 'ABCDEFG', 'length' => 4)).to eq 'ABCD'
end
it 'works correctly with literals' do
left = literal('ABCD')
len = literal(2)
fn = described_class.new(left, len)
expect(fn.value).to eq 'AB'
end
it 'handles an empty string correctly' do
expect(subject.value('string' => '', 'length' => 4)).to eq ''
end
it 'handles size greater than input string length correctly' do
expect(subject.value('string' => 'abcdefg', 'length' => 40)).to eq 'abcdefg'
end
end
describe Dentaku::AST::StringFunctions::Right do
it 'returns the right N characters of the string' do
subject = described_class.new(literal('ABCDEFG'), literal(4))
expect(subject.value).to eq 'DEFG'
end
it 'handles an empty string correctly' do
subject = described_class.new(literal(''), literal(4))
expect(subject.value).to eq ''
end
it 'handles size greater than input string length correctly' do
subject = described_class.new(literal('abcdefg'), literal(40))
expect(subject.value).to eq 'abcdefg'
end
end
describe Dentaku::AST::StringFunctions::Mid do
it 'returns a substring from the middle of the string' do
subject = described_class.new(literal('ABCDEFG'), literal(4), literal(2))
expect(subject.value).to eq 'DE'
end
it 'handles an empty string correctly' do
subject = described_class.new(literal(''), literal(4), literal(2))
expect(subject.value).to eq ''
end
it 'handles offset greater than input string length correctly' do
subject = described_class.new(literal('abcdefg'), literal(40), literal(4))
expect(subject.value).to eq ''
end
it 'handles size greater than input string length correctly' do
subject = described_class.new(literal('abcdefg'), literal(4), literal(40))
expect(subject.value).to eq 'defg'
end
end
describe Dentaku::AST::StringFunctions::Len do
it 'returns the length of a string' do
subject = described_class.new(literal('ABCDEFG'))
expect(subject.value).to eq 7
end
it 'handles an empty string correctly' do
subject = described_class.new(literal(''))
expect(subject.value).to eq 0
end
end
describe Dentaku::AST::StringFunctions::Find do
it 'returns the position of a substring within a string' do
subject = described_class.new(literal('DE'), literal('ABCDEFG'))
expect(subject.value).to eq 4
end
it 'handles an empty substring correctly' do
subject = described_class.new(literal(''), literal('ABCDEFG'))
expect(subject.value).to eq 1
end
it 'handles an empty string correctly' do
subject = described_class.new(literal('DE'), literal(''))
expect(subject.value).to be_nil
end
end
describe Dentaku::AST::StringFunctions::Substitute do
it 'replaces a substring within a string' do
subject = described_class.new(literal('ABCDEFG'), literal('DE'), literal('xy'))
expect(subject.value).to eq 'ABCxyFG'
end
it 'handles an empty search string correctly' do
subject = described_class.new(literal('ABCDEFG'), literal(''), literal('xy'))
expect(subject.value).to eq 'xyABCDEFG'
end
it 'handles an empty replacement string correctly' do
subject = described_class.new(literal('ABCDEFG'), literal('DE'), literal(''))
expect(subject.value).to eq 'ABCFG'
end
end
describe Dentaku::AST::StringFunctions::Concat do
it 'concatenates two strings' do
subject = described_class.new(literal('ABC'), literal('DEF'))
expect(subject.value).to eq 'ABCDEF'
end
it 'concatenates a string onto an empty string' do
subject = described_class.new(literal(''), literal('ABC'))
expect(subject.value).to eq 'ABC'
end
it 'concatenates an empty string onto a string' do
subject = described_class.new(literal('ABC'), literal(''))
expect(subject.value).to eq 'ABC'
end
it 'concatenates two empty strings' do
subject = described_class.new(literal(''), literal(''))
expect(subject.value).to eq ''
end
end

View File

@ -0,0 +1,70 @@
#!/usr/bin/env ruby
require 'dentaku'
require 'allocation_stats'
require 'benchmark'
puts "Dentaku version #{Dentaku::VERSION}"
puts "Ruby version #{RUBY_VERSION}"
with_duplicate_variables = [
"R1+R2+R3+R4+R5+R6",
{"R1"=>100000, "R2"=>0, "R3"=>200000, "R4"=>0, "R5"=>500000, "R6"=>0, "r1"=>100000, "r2"=>0, "r3"=>200000, "r4"=>0, "r5"=>500000, "r6"=>0}
]
without_duplicate_variables = [
"R1+R2+R3+R4+R5+R6",
{"R1"=>100000, "R2"=>0, "R3"=>200000, "R4"=>0, "R5"=>500000, "R6"=>0}
]
def test(args, custom_function: true)
calls = [ args ] * 100
10.times do |i|
stats = nil
bm = Benchmark.measure do
stats = AllocationStats.trace do
calls.each do |formula, bound|
calculator = Dentaku::Calculator.new
if custom_function
calculator.add_function(
:sum,
:numeric,
->(numbers) { numbers.inject(:+) }
)
end
calculator.evaluate(formula, bound)
end
end
end
puts " run #{i}: #{bm.total}"
puts stats.allocations(alias_paths: true).group_by(:sourcefile, :class).to_text
end
end
case ARGV[0]
when '1'
puts "with duplicate (downcased) variables, with a custom function:"
test(with_duplicate_variables, custom_function: true)
when '2'
puts "with duplicate (downcased) variables, without a custom function:"
test(with_duplicate_variables, custom_function: false)
when '3'
puts "without duplicate (downcased) variables, with a custom function:"
test(without_duplicate_variables, custom_function: true)
when '4'
puts "with duplicate (downcased) variables, without a custom function:"
test(without_duplicate_variables, custom_function: false)
else
puts "select a run option (1-4)"
end

View File

@ -0,0 +1,77 @@
require 'spec_helper'
require 'dentaku/bulk_expression_solver'
RSpec.describe Dentaku::BulkExpressionSolver do
let(:calculator) { Dentaku::Calculator.new }
describe "#solve!" do
it "evaluates properly with variables, even if some in memory" do
expressions = {
weekly_fruit_budget: "weekly_apple_budget + pear * 4",
weekly_apple_budget: "apples * 7",
pear: "1"
}
solver = described_class.new(expressions, calculator.store(apples: 3))
expect(solver.solve!)
.to eq(pear: 1, weekly_apple_budget: 21, weekly_fruit_budget: 25)
end
it "lets you know if a variable is unbound" do
expressions = {more_apples: "apples + 1"}
expect {
described_class.new(expressions, calculator).solve!
}.to raise_error(Dentaku::UnboundVariableError)
end
it "lets you know if the result is a div/0 error" do
expressions = {more_apples: "1/0"}
expect {
described_class.new(expressions, calculator).solve!
}.to raise_error(Dentaku::ZeroDivisionError)
end
it "does not require keys to be parseable" do
expressions = { "the value of x, incremented" => "x + 1" }
solver = described_class.new(expressions, calculator.store("x" => 3))
expect(solver.solve!).to eq({ "the value of x, incremented" => 4 })
end
end
describe "#solve" do
it "returns :undefined when variables are unbound" do
expressions = {more_apples: "apples + 1"}
expect(described_class.new(expressions, calculator).solve)
.to eq(more_apples: :undefined)
end
it "allows passing in a custom value to an error handler when a variable is unbound" do
expressions = {more_apples: "apples + 1"}
expect(described_class.new(expressions, calculator).solve { :foo })
.to eq(more_apples: :foo)
end
it "allows passing in a custom value to an error handler when there is a div/0 error" do
expressions = {more_apples: "1/0"}
expect(described_class.new(expressions, calculator).solve { :foo })
.to eq(more_apples: :foo)
end
it 'stores the recipient variable on the exception when there is a div/0 error' do
expressions = {more_apples: "1/0"}
exception = nil
described_class.new(expressions, calculator).solve do |ex|
exception = ex
end
expect(exception.recipient_variable).to eq('more_apples')
end
it 'stores the recipient variable on the exception when there is an unbound variable' do
expressions = {more_apples: "apples + 1"}
exception = nil
described_class.new(expressions, calculator).solve do |ex|
exception = ex
end
expect(exception.recipient_variable).to eq('more_apples')
end
end
end

View File

@ -0,0 +1,450 @@
require 'spec_helper'
require 'dentaku/calculator'
describe Dentaku::Calculator do
let(:calculator) { described_class.new }
let(:with_memory) { described_class.new.store(apples: 3) }
it 'evaluates an expression' do
expect(calculator.evaluate('7+3')).to eq(10)
expect(calculator.evaluate('2 -1')).to eq(1)
expect(calculator.evaluate('-1 + 2')).to eq(1)
expect(calculator.evaluate('1 - 2')).to eq(-1)
expect(calculator.evaluate('1 - - 2')).to eq(3)
expect(calculator.evaluate('-1 - - 2')).to eq(1)
expect(calculator.evaluate('1 - - - 2')).to eq(-1)
expect(calculator.evaluate('(-1 + 2)')).to eq(1)
expect(calculator.evaluate('-(1 + 2)')).to eq(-3)
expect(calculator.evaluate('2 ^ - 1')).to eq(0.5)
expect(calculator.evaluate('2 ^ -(3 - 2)')).to eq(0.5)
expect(calculator.evaluate('(2 + 3) - 1')).to eq(4)
expect(calculator.evaluate('(-2 + 3) - 1')).to eq(0)
expect(calculator.evaluate('(-2 - 3) - 1')).to eq(-6)
expect(calculator.evaluate('1 + -(2 ^ 2)')).to eq(-3)
expect(calculator.evaluate('3 + -num', num: 2)).to eq(1)
expect(calculator.evaluate('-num + 3', num: 2)).to eq(1)
expect(calculator.evaluate('10 ^ 2')).to eq(100)
expect(calculator.evaluate('0 * 10 ^ -5')).to eq(0)
expect(calculator.evaluate('3 + 0 * -3')).to eq(3)
expect(calculator.evaluate('3 + 0 / -3')).to eq(3)
expect(calculator.evaluate('15 % 8')).to eq(7)
expect(calculator.evaluate('(((695759/735000)^(1/(1981-1991)))-1)*1000').round(4)).to eq(5.5018)
expect(calculator.evaluate('0.253/0.253')).to eq(1)
expect(calculator.evaluate('0.253/d', d: 0.253)).to eq(1)
expect(calculator.evaluate('10 + x', x: 'abc')).to be_nil
end
describe 'memory' do
it { expect(calculator).to be_empty }
it { expect(with_memory).not_to be_empty }
it { expect(with_memory.clear).to be_empty }
it 'discards local values' do
expect(calculator.evaluate('pears * 2', pears: 5)).to eq(10)
expect(calculator).to be_empty
end
it 'can store the value `false`' do
calculator.store('i_am_false', false)
expect(calculator.evaluate!('i_am_false')).to eq false
end
it 'can store multiple values' do
calculator.store(first: 1, second: 2)
expect(calculator.evaluate!('first')).to eq 1
expect(calculator.evaluate!('second')).to eq 2
end
it 'stores formulas' do
calculator.store_formula('area', 'length * width')
expect(calculator.evaluate!('area', length: 5, width: 5)).to eq 25
end
end
describe 'dependencies' do
it "finds dependencies in a generic statement" do
expect(calculator.dependencies("bob + dole / 3")).to eq(['bob', 'dole'])
end
it "doesn't consider variables in memory as dependencies" do
expect(with_memory.dependencies("apples + oranges")).to eq(['oranges'])
end
end
describe 'solve!' do
it "evaluates properly with variables, even if some in memory" do
expect(with_memory.solve!(
weekly_fruit_budget: "weekly_apple_budget + pear * 4",
weekly_apple_budget: "apples * 7",
pear: "1"
)).to eq(pear: 1, weekly_apple_budget: 21, weekly_fruit_budget: 25)
end
it "preserves hash keys" do
expect(calculator.solve!(
'meaning_of_life' => 'age + kids',
'age' => 40,
'kids' => 2
)).to eq('age' => 40, 'kids' => 2, 'meaning_of_life' => 42)
end
it "lets you know about a cycle if one occurs" do
expect do
calculator.solve!(health: "happiness", happiness: "health")
end.to raise_error(TSort::Cyclic)
end
it 'is case-insensitive' do
result = with_memory.solve!(total_fruit: "Apples + pears", pears: 10)
expect(result[:total_fruit]).to eq 13
end
it "lets you know if a variable is unbound" do
expect {
calculator.solve!(more_apples: "apples + 1")
}.to raise_error(Dentaku::UnboundVariableError)
end
it 'can reference stored formulas' do
calculator.store_formula("base_area", "length * width")
calculator.store_formula("volume", "base_area * height")
result = calculator.solve!(
weight: "volume * 5.432",
height: "3",
length: "2",
width: "length * 2",
)
expect(result[:weight]).to eq 130.368
end
end
describe 'solve' do
it "returns :undefined when variables are unbound" do
expressions = {more_apples: "apples + 1"}
expect(calculator.solve(expressions)).to eq(more_apples: :undefined)
end
it "allows passing in a custom value to an error handler" do
expressions = {more_apples: "apples + 1"}
expect(calculator.solve(expressions) { :foo })
.to eq(more_apples: :foo)
end
it "solves remainder of expressions with unbound variable" do
calculator.store(peaches: 1, oranges: 1)
expressions = { more_apples: "apples + 1", more_peaches: "peaches + 1" }
result = calculator.solve(expressions)
expect(calculator.memory).to eq("peaches" => 1, "oranges" => 1)
expect(result).to eq(
more_apples: :undefined,
more_peaches: 2
)
end
it "solves remainder of expressions when one cannot be evaluated" do
result = calculator.solve(
conditional: "IF(d != 0, ratio, 0)",
ratio: "10/d",
d: 0,
)
expect(result).to eq(
conditional: 0,
ratio: :undefined,
d: 0,
)
end
end
it 'evaluates a statement with no variables' do
expect(calculator.evaluate('5+3')).to eq(8)
expect(calculator.evaluate('(1+1+1)/3*100')).to eq(100)
end
it 'fails to evaluate unbound statements' do
unbound = 'foo * 1.5'
expect { calculator.evaluate!(unbound) }.to raise_error(Dentaku::UnboundVariableError)
expect { calculator.evaluate!(unbound) }.to raise_error do |error|
expect(error.unbound_variables).to eq ['foo']
end
expect(calculator.evaluate(unbound)).to be_nil
expect(calculator.evaluate(unbound) { :bar }).to eq :bar
expect(calculator.evaluate(unbound) { |e| e }).to eq unbound
end
it 'evaluates unbound statements given a binding in memory' do
expect(calculator.evaluate('foo * 1.5', foo: 2)).to eq(3)
expect(calculator.bind(monkeys: 3).evaluate('monkeys < 7')).to be_truthy
expect(calculator.evaluate('monkeys / 1.5')).to eq(2)
end
it 'rebinds for each evaluation' do
expect(calculator.evaluate('foo * 2', foo: 2)).to eq(4)
expect(calculator.evaluate('foo * 2', foo: 4)).to eq(8)
end
it 'accepts strings or symbols for binding keys' do
expect(calculator.evaluate('foo * 2', foo: 2)).to eq(4)
expect(calculator.evaluate('foo * 2', 'foo' => 4)).to eq(8)
end
it 'accepts digits in identifiers' do
expect(calculator.evaluate('foo1 * 2', foo1: 2)).to eq(4)
expect(calculator.evaluate('foo1 * 2', 'foo1' => 4)).to eq(8)
expect(calculator.evaluate('1foo * 2', '1foo' => 2)).to eq(4)
expect(calculator.evaluate('fo1o * 2', fo1o: 4)).to eq(8)
end
it 'compares string literals with string variables' do
expect(calculator.evaluate('fruit = "apple"', fruit: 'apple')).to be_truthy
expect(calculator.evaluate('fruit = "apple"', fruit: 'pear')).to be_falsey
end
it 'performs case-sensitive comparison' do
expect(calculator.evaluate('fruit = "Apple"', fruit: 'apple')).to be_falsey
expect(calculator.evaluate('fruit = "Apple"', fruit: 'Apple')).to be_truthy
end
it 'allows binding logical values' do
expect(calculator.evaluate('some_boolean AND 7 > 5', some_boolean: true)).to be_truthy
expect(calculator.evaluate('some_boolean AND 7 < 5', some_boolean: true)).to be_falsey
expect(calculator.evaluate('some_boolean AND 7 > 5', some_boolean: false)).to be_falsey
expect(calculator.evaluate('some_boolean OR 7 > 5', some_boolean: true)).to be_truthy
expect(calculator.evaluate('some_boolean OR 7 < 5', some_boolean: true)).to be_truthy
expect(calculator.evaluate('some_boolean OR 7 < 5', some_boolean: false)).to be_falsey
end
describe 'functions' do
it 'include IF' do
expect(calculator.evaluate('if(foo < 8, 10, 20)', foo: 2)).to eq(10)
expect(calculator.evaluate('if(foo < 8, 10, 20)', foo: 9)).to eq(20)
expect(calculator.evaluate('if (foo < 8, 10, 20)', foo: 2)).to eq(10)
expect(calculator.evaluate('if (foo < 8, 10, 20)', foo: 9)).to eq(20)
end
it 'include ROUND' do
expect(calculator.evaluate('round(8.2)')).to eq(8)
expect(calculator.evaluate('round(8.8)')).to eq(9)
expect(calculator.evaluate('round(8.75, 1)')).to eq(BigDecimal.new('8.8'))
expect(calculator.evaluate('ROUND(apples * 0.93)', { apples: 10 })).to eq(9)
end
it 'include NOT' do
expect(calculator.evaluate('NOT(some_boolean)', some_boolean: true)).to be_falsey
expect(calculator.evaluate('NOT(some_boolean)', some_boolean: false)).to be_truthy
expect(calculator.evaluate('NOT(some_boolean) AND 7 > 5', some_boolean: true)).to be_falsey
expect(calculator.evaluate('NOT(some_boolean) OR 7 < 5', some_boolean: false)).to be_truthy
end
it 'evaluates functions with negative numbers' do
expect(calculator.evaluate('if (-1 < 5, -1, 5)')).to eq(-1)
expect(calculator.evaluate('if (-1 = -1, -1, 5)')).to eq(-1)
expect(calculator.evaluate('round(-1.23, 1)')).to eq(BigDecimal.new('-1.2'))
expect(calculator.evaluate('NOT(some_boolean) AND -1 > 3', some_boolean: true)).to be_falsey
end
it 'evaluates functions with stored variables' do
calculator.store("multi_color" => true, "number_of_sheets" => 5000, "sheets_per_minute_black" => 2000, "sheets_per_minute_color" => 1000)
result = calculator.evaluate('number_of_sheets / if(multi_color, sheets_per_minute_color, sheets_per_minute_black)')
expect(result).to eq(5)
end
describe 'roundup' do
it 'should work with one argument' do
expect(calculator.evaluate('roundup(1.234)')).to eq(2)
end
it 'should accept second precision argument like in Office formula' do
expect(calculator.evaluate('roundup(1.234, 2)')).to eq(1.24)
end
end
describe 'rounddown' do
it 'should work with one argument' do
expect(calculator.evaluate('rounddown(1.234)')).to eq(1)
end
it 'should accept second precision argument like in Office formula' do
expect(calculator.evaluate('rounddown(1.234, 2)')).to eq(1.23)
end
end
end
describe 'explicit NULL' do
it 'can be used in IF statements' do
expect(calculator.evaluate('IF(null, 1, 2)')).to eq(2)
end
it 'can be used in IF statements when passed in' do
expect(calculator.evaluate('IF(foo, 1, 2)', foo: nil)).to eq(2)
end
it 'nil values are carried across middle terms' do
results = calculator.solve!(
choice: 'IF(bar, 1, 2)',
bar: 'foo',
foo: nil)
expect(results).to eq(
choice: 2,
bar: nil,
foo: nil
)
end
it 'raises errors when used in arithmetic operation' do
expect {
calculator.solve!(more_apples: "apples + 1", apples: nil)
}.to raise_error(Dentaku::ArgumentError)
end
end
describe 'case statements' do
it 'handles complex then statements' do
formula = <<-FORMULA
CASE fruit
WHEN 'apple'
THEN (1 * quantity)
WHEN 'banana'
THEN (2 * quantity)
END
FORMULA
expect(calculator.evaluate(formula, quantity: 3, fruit: 'apple')).to eq(3)
expect(calculator.evaluate(formula, quantity: 3, fruit: 'banana')).to eq(6)
end
it 'handles complex when statements' do
formula = <<-FORMULA
CASE number
WHEN (2 * 2)
THEN 1
WHEN (2 * 3)
THEN 2
END
FORMULA
expect(calculator.evaluate(formula, number: 4)).to eq(1)
expect(calculator.evaluate(formula, number: 6)).to eq(2)
end
it 'throws an exception when no match and there is no default value' do
formula = <<-FORMULA
CASE number
WHEN 42
THEN 1
END
FORMULA
expect { calculator.evaluate(formula, number: 2) }
.to raise_error("No block matched the switch value '2'")
end
it 'handles a default else statement' do
formula = <<-FORMULA
CASE fruit
WHEN 'apple'
THEN 1 * quantity
WHEN 'banana'
THEN 2 * quantity
ELSE
3 * quantity
END
FORMULA
expect(calculator.evaluate(formula, quantity: 1, fruit: 'banana')).to eq(2)
expect(calculator.evaluate(formula, quantity: 1, fruit: 'orange')).to eq(3)
end
it 'handles nested case statements' do
formula = <<-FORMULA
CASE fruit
WHEN 'apple'
THEN 1 * quantity
WHEN 'banana'
THEN
CASE quantity
WHEN 1 THEN 2
WHEN 10 THEN
CASE type
WHEN 'organic' THEN 5
END
END
END
FORMULA
value = calculator.evaluate(
formula,
type: 'organic',
quantity: 10,
fruit: 'banana')
expect(value).to eq(5)
end
end
describe 'math functions' do
Math.methods(false).each do |method|
it method do
if Math.method(method).arity == 2
expect(calculator.evaluate("#{method}(1,2)")).to eq Math.send(method, 1, 2)
else
expect(calculator.evaluate("#{method}(1)")).to eq Math.send(method, 1)
end
end
end
end
describe 'disable_cache' do
before do
allow(Dentaku).to receive(:cache_ast?) { true }
end
it 'disables the AST cache' do
expect(calculator.disable_cache{ |c| c.cache_ast? }).to be false
end
it 'calculates normally' do
expect(calculator.disable_cache{ |c| c.evaluate("2 + 2") }).to eq(4)
end
end
describe 'clear_cache' do
before do
allow(Dentaku).to receive(:cache_ast?) { true }
calculator.ast("1+1")
calculator.ast("pineapples * 5")
calculator.ast("pi * radius ^ 2")
def calculator.ast_cache
@ast_cache
end
end
it 'clears all items from cache' do
expect(calculator.ast_cache.length).to eq 3
calculator.clear_cache
expect(calculator.ast_cache.keys).to be_empty
end
it 'clears one item from cache' do
calculator.clear_cache("1+1")
expect(calculator.ast_cache.keys.sort).to eq([
'pi * radius ^ 2',
'pineapples * 5',
])
end
it 'clears items matching regex from cache' do
calculator.clear_cache(/^pi/)
expect(calculator.ast_cache.keys.sort).to eq(['1+1'])
end
end
describe 'string functions' do
it 'concatenates two strings' do
expect(
calculator.evaluate('CONCAT(s1, s2)', 's1' => 'abc', 's2' => 'def')
).to eq 'abcdef'
end
end
end

View File

@ -0,0 +1,22 @@
require 'dentaku'
describe Dentaku do
it 'evaulates an expression' do
expect(Dentaku('5+3')).to eql(8)
end
it 'binds values to variables' do
expect(Dentaku('oranges > 7', oranges: 10)).to be_truthy
end
it 'evaulates a nested function' do
expect(Dentaku('roundup(roundup(3 * cherries) + raspberries)', cherries: 1.5, raspberries: 0.9)).to eql(6)
end
it 'treats variables as case-insensitive' do
expect(Dentaku('40 + N', 'n' => 2)).to eql(42)
expect(Dentaku('40 + N', 'N' => 2)).to eql(42)
expect(Dentaku('40 + n', 'N' => 2)).to eql(42)
expect(Dentaku('40 + n', 'n' => 2)).to eql(42)
end
end

View File

@ -0,0 +1,9 @@
require 'spec_helper'
require 'dentaku/exceptions'
describe Dentaku::UnboundVariableError do
it 'includes variable name(s) in message' do
exception = described_class.new(['length'])
expect(exception.message).to match /length/
end
end

View File

@ -0,0 +1,56 @@
require 'spec_helper'
require 'dentaku/calculator'
describe Dentaku::Calculator do
describe 'functions' do
describe 'external functions' do
let(:with_external_funcs) do
c = described_class.new
c.add_function(:now, :string, -> { Time.now.to_s })
fns = [
[:pow, :numeric, ->(mantissa, exponent) { mantissa ** exponent }],
[:biggest, :numeric, ->(*args) { args.max }],
[:smallest, :numeric, ->(*args) { args.min }],
]
c.add_functions(fns)
end
it 'includes NOW' do
now = with_external_funcs.evaluate('NOW()')
expect(now).not_to be_nil
expect(now).not_to be_empty
end
it 'includes POW' do
expect(with_external_funcs.evaluate('POW(2,3)')).to eq(8)
expect(with_external_funcs.evaluate('POW(3,2)')).to eq(9)
expect(with_external_funcs.evaluate('POW(mantissa,exponent)', mantissa: 2, exponent: 4)).to eq(16)
end
it 'includes BIGGEST' do
expect(with_external_funcs.evaluate('BIGGEST(8,6,7,5,3,0,9)')).to eq(9)
end
it 'includes SMALLEST' do
expect(with_external_funcs.evaluate('SMALLEST(8,6,7,5,3,0,9)')).to eq(0)
end
it 'supports array parameters' do
calculator = described_class.new
calculator.add_function(
:includes,
:logical,
->(haystack, needle) {
haystack.include?(needle)
}
)
expect(calculator.evaluate("INCLUDES(list, 2)", list: [1,2,3])).to eq(true)
end
end
end
end

View File

@ -0,0 +1,150 @@
require 'spec_helper'
require 'dentaku/token'
require 'dentaku/parser'
describe Dentaku::Parser do
it 'is constructed from a token' do
token = Dentaku::Token.new(:numeric, 5)
node = described_class.new([token]).parse
expect(node.value).to eq 5
end
it 'performs simple addition' do
five = Dentaku::Token.new(:numeric, 5)
plus = Dentaku::Token.new(:operator, :add)
four = Dentaku::Token.new(:numeric, 4)
node = described_class.new([five, plus, four]).parse
expect(node.value).to eq 9
end
it 'compares two numbers' do
five = Dentaku::Token.new(:numeric, 5)
lt = Dentaku::Token.new(:comparator, :lt)
four = Dentaku::Token.new(:numeric, 4)
node = described_class.new([five, lt, four]).parse
expect(node.value).to eq false
end
it 'calculates unary percentage' do
five = Dentaku::Token.new(:numeric, 5)
mod = Dentaku::Token.new(:operator, :mod)
node = described_class.new([five, mod]).parse
expect(node.value).to eq 0.05
end
it 'performs multiple operations in one stream' do
five = Dentaku::Token.new(:numeric, 5)
plus = Dentaku::Token.new(:operator, :add)
four = Dentaku::Token.new(:numeric, 4)
times = Dentaku::Token.new(:operator, :multiply)
three = Dentaku::Token.new(:numeric, 3)
node = described_class.new([five, plus, four, times, three]).parse
expect(node.value).to eq 17
end
it 'respects order of operations' do
five = Dentaku::Token.new(:numeric, 5)
times = Dentaku::Token.new(:operator, :multiply)
four = Dentaku::Token.new(:numeric, 4)
plus = Dentaku::Token.new(:operator, :add)
three = Dentaku::Token.new(:numeric, 3)
node = described_class.new([five, times, four, plus, three]).parse
expect(node.value).to eq 23
end
it 'respects grouping by parenthesis' do
lpar = Dentaku::Token.new(:grouping, :open)
five = Dentaku::Token.new(:numeric, 5)
plus = Dentaku::Token.new(:operator, :add)
four = Dentaku::Token.new(:numeric, 4)
rpar = Dentaku::Token.new(:grouping, :close)
times = Dentaku::Token.new(:operator, :multiply)
three = Dentaku::Token.new(:numeric, 3)
node = described_class.new([lpar, five, plus, four, rpar, times, three]).parse
expect(node.value).to eq 27
end
it 'evaluates functions' do
fn = Dentaku::Token.new(:function, :if)
fopen = Dentaku::Token.new(:grouping, :open)
five = Dentaku::Token.new(:numeric, 5)
lt = Dentaku::Token.new(:comparator, :lt)
four = Dentaku::Token.new(:numeric, 4)
comma = Dentaku::Token.new(:grouping, :comma)
three = Dentaku::Token.new(:numeric, 3)
two = Dentaku::Token.new(:numeric, 2)
rpar = Dentaku::Token.new(:grouping, :close)
node = described_class.new([fn, fopen, five, lt, four, comma, three, comma, two, rpar]).parse
expect(node.value).to eq 2
end
it 'represents formulas with variables' do
five = Dentaku::Token.new(:numeric, 5)
times = Dentaku::Token.new(:operator, :multiply)
x = Dentaku::Token.new(:identifier, :x)
node = described_class.new([five, times, x]).parse
expect { node.value }.to raise_error(Dentaku::UnboundVariableError)
expect(node.value(x: 3)).to eq 15
end
it 'evaluates boolean expressions' do
d_true = Dentaku::Token.new(:logical, true)
d_and = Dentaku::Token.new(:combinator, :and)
d_false = Dentaku::Token.new(:logical, false)
node = described_class.new([d_true, d_and, d_false]).parse
expect(node.value).to eq false
end
it 'evaluates a case statement' do
case_start = Dentaku::Token.new(:case, :open)
x = Dentaku::Token.new(:identifier, :x)
case_when1 = Dentaku::Token.new(:case, :when)
one = Dentaku::Token.new(:numeric, 1)
case_then1 = Dentaku::Token.new(:case, :then)
two = Dentaku::Token.new(:numeric, 2)
case_when2 = Dentaku::Token.new(:case, :when)
three = Dentaku::Token.new(:numeric, 3)
case_then2 = Dentaku::Token.new(:case, :then)
four = Dentaku::Token.new(:numeric, 4)
case_close = Dentaku::Token.new(:case, :close)
node = described_class.new(
[case_start,
x,
case_when1,
one,
case_then1,
two,
case_when2,
three,
case_then2,
four,
case_close]).parse
expect(node.value(x: 3)).to eq(4)
end
it 'raises an error on parse failure' do
five = Dentaku::Token.new(:numeric, 5)
times = Dentaku::Token.new(:operator, :multiply)
minus = Dentaku::Token.new(:operator, :subtract)
expect {
described_class.new([five, times, minus]).parse
}.to raise_error(Dentaku::ParseError)
end
it "evaluates explicit 'NULL' as a Nil" do
null = Dentaku::Token.new(:null, nil)
node = described_class.new([null]).parse
expect(node.value).to eq(nil)
end
end

View File

@ -0,0 +1,41 @@
require 'pry'
# automatically create a token stream from bare values
def token_stream(*args)
args.map do |value|
type = type_for(value)
Dentaku::Token.new(type, value)
end
end
# make a (hopefully intelligent) guess about type
def type_for(value)
case value
when Numeric
:numeric
when String
:string
when true, false
:logical
when :add, :subtract, :multiply, :divide, :mod, :pow
:operator
when :open, :close, :comma
:grouping
when :le, :ge, :ne, :ne, :lt, :gt, :eq
:comparator
when :and, :or
:combinator
when :if, :round, :roundup, :rounddown, :not
:function
else
:identifier
end
end
def identifier(name)
Dentaku::AST::Identifier.new(Dentaku::Token.new(:identifier, name))
end
def literal(value)
Dentaku::AST::Literal.new(Dentaku::Token.new(type_for(value), value))
end

View File

@ -0,0 +1,135 @@
require 'spec_helper'
require 'dentaku/token_matcher'
describe Dentaku::TokenMatcher do
it 'with single category matches token category' do
matcher = described_class.new(:numeric)
token = Dentaku::Token.new(:numeric, 5)
expect(matcher).to eq(token)
end
it 'with multiple categories matches any included token category' do
matcher = described_class.new([:comparator, :operator])
numeric = Dentaku::Token.new(:numeric, 5)
comparator = Dentaku::Token.new(:comparator, :lt)
operator = Dentaku::Token.new(:operator, :add)
expect(matcher).to eq(comparator)
expect(matcher).to eq(operator)
expect(matcher).not_to eq(numeric)
end
it 'with single category and value matches token category and value' do
matcher = described_class.new(:operator, :add)
addition = Dentaku::Token.new(:operator, :add)
subtraction = Dentaku::Token.new(:operator, :subtract)
expect(matcher).to eq(addition)
expect(matcher).not_to eq(subtraction)
end
it 'with multiple values matches any included token value' do
matcher = described_class.new(:operator, [:add, :subtract])
add = Dentaku::Token.new(:operator, :add)
sub = Dentaku::Token.new(:operator, :subtract)
mul = Dentaku::Token.new(:operator, :multiply)
div = Dentaku::Token.new(:operator, :divide)
expect(matcher).to eq(add)
expect(matcher).to eq(sub)
expect(matcher).not_to eq(mul)
expect(matcher).not_to eq(div)
end
it 'is invertible' do
matcher = described_class.new(:operator, [:add, :subtract]).invert
add = Dentaku::Token.new(:operator, :add)
mul = Dentaku::Token.new(:operator, :multiply)
cmp = Dentaku::Token.new(:comparator, :lt)
expect(matcher).not_to eq(add)
expect(matcher).to eq(mul)
expect(matcher).to eq(cmp)
end
describe 'combining multiple tokens' do
let(:numeric) { described_class.new(:numeric) }
let(:string) { described_class.new(:string) }
it 'matches either' do
either = numeric | string
expect(either).to eq(Dentaku::Token.new(:numeric, 5))
expect(either).to eq(Dentaku::Token.new(:string, 'rhubarb'))
end
it 'matches any value' do
value = described_class.value
expect(value).to eq(Dentaku::Token.new(:numeric, 8))
expect(value).to eq(Dentaku::Token.new(:string, 'apricot'))
expect(value).to eq(Dentaku::Token.new(:logical, false))
expect(value).not_to eq(Dentaku::Token.new(:function, :round))
expect(value).not_to eq(Dentaku::Token.new(:identifier, :hello))
end
end
describe 'stream matching' do
let(:stream) { token_stream(5, 11, 9, 24, :hello, 8) }
describe 'standard' do
let(:standard) { described_class.new(:numeric) }
it 'matches zero or more occurrences in a token stream' do
matched, substream = standard.match(stream)
expect(matched).to be_truthy
expect(substream.length).to eq 1
expect(substream.map(&:value)).to eq [5]
matched, substream = standard.match(stream, 4)
expect(substream).to be_empty
expect(matched).not_to be_truthy
end
end
describe 'star' do
let(:star) { described_class.new(:numeric).star }
it 'matches zero or more occurrences in a token stream' do
matched, substream = star.match(stream)
expect(matched).to be_truthy
expect(substream.length).to eq 4
expect(substream.map(&:value)).to eq [5, 11, 9, 24]
matched, substream = star.match(stream, 4)
expect(substream).to be_empty
expect(matched).to be_truthy
end
end
describe 'plus' do
let(:plus) { described_class.new(:numeric).plus }
it 'matches one or more occurrences in a token stream' do
matched, substream = plus.match(stream)
expect(matched).to be_truthy
expect(substream.length).to eq 4
expect(substream.map(&:value)).to eq [5, 11, 9, 24]
matched, substream = plus.match(stream, 4)
expect(substream).to be_empty
expect(matched).not_to be_truthy
end
end
describe 'arguments' do
it 'matches comma-separated values' do
stream = token_stream(1, :comma, 2, :comma, true, :comma, 'olive', :comma, :'(')
matched, substream = described_class.arguments.match(stream)
expect(matched).to be_truthy
expect(substream.length).to eq 8
expect(substream.map(&:value)).to eq [1, :comma, 2, :comma, true, :comma, 'olive', :comma]
end
end
end
end

View File

@ -0,0 +1,53 @@
require 'dentaku/token_scanner'
describe Dentaku::TokenScanner do
let(:whitespace) { described_class.new(:whitespace, '\s') }
let(:numeric) { described_class.new(:numeric, '(\d+(\.\d+)?|\.\d+)',
->(raw) { raw =~ /\./ ? BigDecimal.new(raw) : raw.to_i })
}
let(:custom) { described_class.new(:identifier, '#\w+\b',
->(raw) { raw.gsub('#', '').to_sym })
}
after { described_class.register_default_scanners }
it 'returns a token for a matching string' do
token = whitespace.scan(' ').first
expect(token.category).to eq(:whitespace)
expect(token.value).to eq(' ')
end
it 'returns falsy for a non-matching string' do
expect(whitespace.scan('A')).not_to be
end
it 'performs raw value conversion' do
token = numeric.scan('5').first
expect(token.category).to eq(:numeric)
expect(token.value).to eq(5)
end
it 'returns a list of all configured scanners' do
expect(described_class.scanners.length).to eq 14
end
it 'allows customizing available scanners' do
described_class.scanners = [:whitespace, :numeric]
expect(described_class.scanners.length).to eq 2
end
it 'ignores invalid scanners' do
described_class.scanners = [:whitespace, :numeric, :fake]
expect(described_class.scanners.length).to eq 2
end
it 'uses a custom scanner' do
described_class.scanners = [:whitespace, :numeric]
described_class.register_scanner(:custom, custom)
expect(described_class.scanners.length).to eq 3
token = custom.scan('#apple + #pear').first
expect(token.category).to eq(:identifier)
expect(token.value).to eq(:apple)
end
end

View File

@ -0,0 +1,10 @@
require 'dentaku/token'
describe Dentaku::Token do
it 'has a category and a value' do
token = Dentaku::Token.new(:numeric, 5)
expect(token.category).to eq(:numeric)
expect(token.value).to eq(5)
expect(token.is?(:numeric)).to be_truthy
end
end

View File

@ -0,0 +1,212 @@
require 'dentaku/tokenizer'
describe Dentaku::Tokenizer do
let(:tokenizer) { described_class.new }
it 'handles an empty expression' do
expect(tokenizer.tokenize('')).to be_empty
end
it 'tokenizes addition' do
tokens = tokenizer.tokenize('1+1')
expect(tokens.map(&:category)).to eq([:numeric, :operator, :numeric])
expect(tokens.map(&:value)).to eq([1, :add, 1])
end
it 'tokenizes unary minus' do
tokens = tokenizer.tokenize('-5')
expect(tokens.map(&:category)).to eq([:operator, :numeric])
expect(tokens.map(&:value)).to eq([:negate, 5])
tokens = tokenizer.tokenize('(-5)')
expect(tokens.map(&:category)).to eq([:grouping, :operator, :numeric, :grouping])
expect(tokens.map(&:value)).to eq([:open, :negate, 5, :close])
tokens = tokenizer.tokenize('if(-5 > x, -7, -8) - 9')
expect(tokens.map(&:category)).to eq([
:function, :grouping, # if(
:operator, :numeric, :comparator, :identifier, :grouping, # -5 > x,
:operator, :numeric, :grouping, # -7,
:operator, :numeric, :grouping, # -8)
:operator, :numeric # - 9
])
expect(tokens.map(&:value)).to eq([
:if, :open, # if(
:negate, 5, :gt, 'x', :comma, # -5 > x,
:negate, 7, :comma, # -7,
:negate, 8, :close, # -8)
:subtract, 9 # - 9
])
end
it 'tokenizes comparison with =' do
tokens = tokenizer.tokenize('number = 5')
expect(tokens.map(&:category)).to eq([:identifier, :comparator, :numeric])
expect(tokens.map(&:value)).to eq(['number', :eq, 5])
end
it 'tokenizes comparison with =' do
tokens = tokenizer.tokenize('number = 5')
expect(tokens.map(&:category)).to eq([:identifier, :comparator, :numeric])
expect(tokens.map(&:value)).to eq(['number', :eq, 5])
end
it 'tokenizes comparison with alternate ==' do
tokens = tokenizer.tokenize('number == 5')
expect(tokens.map(&:category)).to eq([:identifier, :comparator, :numeric])
expect(tokens.map(&:value)).to eq(['number', :eq, 5])
end
it 'ignores whitespace' do
tokens = tokenizer.tokenize('1 / 1 ')
expect(tokens.map(&:category)).to eq([:numeric, :operator, :numeric])
expect(tokens.map(&:value)).to eq([1, :divide, 1])
end
it 'tokenizes power operations' do
tokens = tokenizer.tokenize('10 ^ 2')
expect(tokens.map(&:category)).to eq([:numeric, :operator, :numeric])
expect(tokens.map(&:value)).to eq([10, :pow, 2])
end
it 'tokenizes power operations' do
tokens = tokenizer.tokenize('0 * 10 ^ -5')
expect(tokens.map(&:category)).to eq([:numeric, :operator, :numeric, :operator, :operator, :numeric])
expect(tokens.map(&:value)).to eq([0, :multiply, 10, :pow, :negate, 5])
end
it 'handles floating point' do
tokens = tokenizer.tokenize('1.5 * 3.7')
expect(tokens.map(&:category)).to eq([:numeric, :operator, :numeric])
expect(tokens.map(&:value)).to eq([1.5, :multiply, 3.7])
end
it 'does not require leading zero' do
tokens = tokenizer.tokenize('.5 * 3.7')
expect(tokens.map(&:category)).to eq([:numeric, :operator, :numeric])
expect(tokens.map(&:value)).to eq([0.5, :multiply, 3.7])
end
it 'accepts arbitrary identifiers' do
tokens = tokenizer.tokenize('sea_monkeys > 1500')
expect(tokens.map(&:category)).to eq([:identifier, :comparator, :numeric])
expect(tokens.map(&:value)).to eq(['sea_monkeys', :gt, 1500])
end
it 'recognizes double-quoted strings' do
tokens = tokenizer.tokenize('animal = "giraffe"')
expect(tokens.map(&:category)).to eq([:identifier, :comparator, :string])
expect(tokens.map(&:value)).to eq(['animal', :eq, 'giraffe'])
end
it 'recognizes single-quoted strings' do
tokens = tokenizer.tokenize("animal = 'giraffe'")
expect(tokens.map(&:category)).to eq([:identifier, :comparator, :string])
expect(tokens.map(&:value)).to eq(['animal', :eq, 'giraffe'])
end
it 'recognizes binary minus operator' do
tokens = tokenizer.tokenize('2 - 3')
expect(tokens.map(&:category)).to eq([:numeric, :operator, :numeric])
expect(tokens.map(&:value)).to eq([2, :subtract, 3])
end
it 'recognizes unary minus operator' do
tokens = tokenizer.tokenize('-2 + 3')
expect(tokens.map(&:category)).to eq([:operator, :numeric, :operator, :numeric])
expect(tokens.map(&:value)).to eq([:negate, 2, :add, 3])
end
it 'recognizes unary minus operator' do
tokens = tokenizer.tokenize('2 - -3')
expect(tokens.map(&:category)).to eq([:numeric, :operator, :operator, :numeric])
expect(tokens.map(&:value)).to eq([2, :subtract, :negate, 3])
end
it 'matches "<=" before "<"' do
tokens = tokenizer.tokenize('perimeter <= 7500')
expect(tokens.map(&:category)).to eq([:identifier, :comparator, :numeric])
expect(tokens.map(&:value)).to eq(['perimeter', :le, 7500])
end
it 'matches "and" for logical expressions' do
tokens = tokenizer.tokenize('octopi <= 7500 AND sharks > 1500')
expect(tokens.map(&:category)).to eq([:identifier, :comparator, :numeric, :combinator, :identifier, :comparator, :numeric])
expect(tokens.map(&:value)).to eq(['octopi', :le, 7500, :and, 'sharks', :gt, 1500])
end
it 'matches "or" for logical expressions' do
tokens = tokenizer.tokenize('size < 3 or admin = 1')
expect(tokens.map(&:category)).to eq([:identifier, :comparator, :numeric, :combinator, :identifier, :comparator, :numeric])
expect(tokens.map(&:value)).to eq(['size', :lt, 3, :or, 'admin', :eq, 1])
end
it 'detects unbalanced parentheses' do
expect { tokenizer.tokenize('(5+3') }.to raise_error(Dentaku::TokenizerError, /too many opening parentheses/)
expect { tokenizer.tokenize(')') }.to raise_error(Dentaku::TokenizerError, /too many closing parentheses/)
end
it 'recognizes identifiers that share initial substrings with combinators' do
tokens = tokenizer.tokenize('andover < 10')
expect(tokens.length).to eq(3)
expect(tokens.map(&:category)).to eq([:identifier, :comparator, :numeric])
expect(tokens.map(&:value)).to eq(['andover', :lt, 10])
end
it 'tokenizes TRUE and FALSE literals' do
tokens = tokenizer.tokenize('true and false')
expect(tokens.length).to eq(3)
expect(tokens.map(&:category)).to eq([:logical, :combinator, :logical])
expect(tokens.map(&:value)).to eq([true, :and, false])
tokens = tokenizer.tokenize('true_lies and falsehoods')
expect(tokens.length).to eq(3)
expect(tokens.map(&:category)).to eq([:identifier, :combinator, :identifier])
expect(tokens.map(&:value)).to eq(['true_lies', :and, 'falsehoods'])
end
describe 'functions' do
it 'include IF' do
tokens = tokenizer.tokenize('if(x < 10, y, z)')
expect(tokens.length).to eq(10)
expect(tokens.map(&:category)).to eq([:function, :grouping, :identifier, :comparator, :numeric, :grouping, :identifier, :grouping, :identifier, :grouping])
expect(tokens.map(&:value)).to eq([:if, :open, 'x', :lt, 10, :comma, 'y', :comma, 'z', :close])
end
it 'include ROUND/UP/DOWN' do
tokens = tokenizer.tokenize('round(8.2)')
expect(tokens.length).to eq(4)
expect(tokens.map(&:category)).to eq([:function, :grouping, :numeric, :grouping])
expect(tokens.map(&:value)).to eq([:round, :open, BigDecimal.new('8.2'), :close])
tokens = tokenizer.tokenize('round(8.75, 1)')
expect(tokens.length).to eq(6)
expect(tokens.map(&:category)).to eq([:function, :grouping, :numeric, :grouping, :numeric, :grouping])
expect(tokens.map(&:value)).to eq([:round, :open, BigDecimal.new('8.75'), :comma, 1, :close])
tokens = tokenizer.tokenize('ROUNDUP(8.2)')
expect(tokens.length).to eq(4)
expect(tokens.map(&:category)).to eq([:function, :grouping, :numeric, :grouping])
expect(tokens.map(&:value)).to eq([:roundup, :open, BigDecimal.new('8.2'), :close])
tokens = tokenizer.tokenize('RoundDown(8.2)')
expect(tokens.length).to eq(4)
expect(tokens.map(&:category)).to eq([:function, :grouping, :numeric, :grouping])
expect(tokens.map(&:value)).to eq([:rounddown, :open, BigDecimal.new('8.2'), :close])
end
it 'include NOT' do
tokens = tokenizer.tokenize('not(8 < 5)')
expect(tokens.length).to eq(6)
expect(tokens.map(&:category)).to eq([:function, :grouping, :numeric, :comparator, :numeric, :grouping])
expect(tokens.map(&:value)).to eq([:not, :open, 8, :lt, 5, :close])
end
it 'handles whitespace after function name' do
tokens = tokenizer.tokenize('not (8 < 5)')
expect(tokens.length).to eq(6)
expect(tokens.map(&:category)).to eq([:function, :grouping, :numeric, :comparator, :numeric, :grouping])
expect(tokens.map(&:value)).to eq([:not, :open, 8, :lt, 5, :close])
end
end
end

View File

@ -0,0 +1,23 @@
*.gem
*.rbc
.bundle
.config
coverage
InstalledFiles
lib/bundler/man
pkg
rdoc
spec/reports
test/tmp
test/version_tmp
tmp
Gemfile.lock
.rvmrc
.ruby-version
.ruby-gemset
# YARD artifacts
.yardoc
_yardoc
doc/
*.local

View File

@ -0,0 +1,35 @@
AllCops:
Include:
- 'Rakefile'
- '*.gemspec'
- 'lib/**/*.rb'
- 'spec/**/*.rb'
Exclude:
- 'bin/**/*'
- 'smoke/**/*'
DisplayCopNames: true
StyleGuideCopsOnly: false
Rails:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
Style/NumericPredicate:
Enabled: false
Style/StringLiterals:
Enabled: false
Style/RescueModifier:
Enabled: false
Metrics/LineLength:
Max: 100
Exclude:
- 'spec/**/*.rb'
Metrics/ModuleLength:
CountComments: false # count full line comments?
Max: 120

View File

@ -0,0 +1,52 @@
sudo: required
dist: trusty
language: ruby
before_install:
- gem install bundler
- gem update bundler
- smoke/provision.sh
rvm:
- 1.9.3
- 2.0.0
- 2.1.10
- 2.2.4
- 2.3.1
- ruby-head
env:
- TEST_TASK=spec
matrix:
allow_failures:
- rvm: jruby-head
- rvm: ruby-head
- rvm: jruby-9.0.5.0
- rvm: 2.3.1
env: TEST_TASK=smoke influx_version=nightly channel=nightlies
include:
- rvm: 2.3.1
env: TEST_TASK=rubocop
- rvm: jruby-9.0.5.0
env: JRUBY_OPTS='--client -J-XX:+TieredCompilation -J-XX:TieredStopAtLevel=1 -J-Xss2m -J-Xmx256M'
- rvm: jruby-head
env: JRUBY_OPTS='--client -J-XX:+TieredCompilation -J-XX:TieredStopAtLevel=1 -J-Xss2m -J-Xmx256M'
- rvm: 2.3.1
env: TEST_TASK=smoke influx_version=0.10.3-1 pkghash=96244557d9bb7485ddc9d084ff7ce783
- rvm: 2.3.1
env: TEST_TASK=smoke influx_version=0.11.1-1 pkghash=f4cf8363125038dff038ced6b16bcafd
- rvm: 2.3.1
env: TEST_TASK=smoke influx_version=0.12.2-1 pkghash=f28bb1c57d52dc1593dca45b86be5913
- rvm: 2.3.1
env: TEST_TASK=smoke influx_version=0.13.0 pkghash=4f0aa76fee22cf4c18e2a0779ba4f462
- rvm: 2.3.1
env: TEST_TASK=smoke influx_version=1.0.2 pkghash=3e4c349cb57507913d9abda1459bdbed
- rvm: 2.3.1
env: TEST_TASK=smoke influx_version=1.1.0 pkghash=682904c350ecfc2a60ec9c6c08453ef2
- rvm: 2.3.1
env: TEST_TASK=smoke influx_version=nightly channel=nightlies
fail_fast: true
addons:
apt:
packages:
- haveged
- libgmp-dev
script: bundle exec rake $TEST_TASK

View File

@ -0,0 +1,125 @@
# Changelog
For the full commit log, [see here](https://github.com/influxdata/influxdb-ruby/commits/master).
## Unreleased changes
- None.
## v0.3.13, released 2016-11-23
- You can now `InfluxDB::Client#query`, `#write_points`, `#write_point` and
`#write` now accept an additional parameter to override the database on
invokation time (#173, #176, @jfragoulis).
## v0.3.12, released 2016-11-15
- Bugfix for broken Unicode support (regression introduced in #169).
Please note, this is only properly tested on Ruby 2.1+ (#171).
## v0.3.11, released 2016-10-12
- Bugfix/Enhancement in `PointValue#escape`. Input strings are now scrubbed
of invalid UTF byte sequences (#169, @ton31337).
## v0.3.10, released 2016-10-03
- Bugfix in `Query::Builder#quote` (#168, @cthulhu666).
## v0.3.9, released 2016-09-20
- Changed retry behaviour slightly. When the server responds with an incomplete
response, we now assume a major server-side problem (insufficient resources,
e.g. out-of-memory) and cancel any retry attempts (#165, #166).
## v0.3.8, released 2016-08-31
- Added support for named and positional query parameters (#160, @retorquere).
## v0.3.7, released 2016-08-14
- Fixed `prefix` handling for `#ping` and `#version` (#157, @dimiii).
## v0.3.6, released 2016-07-24
- Added feature for JSON streaming response, via `"chunk_size"` parameter
(#155, @mhodson-qxbranch).
## v0.3.5, released 2016-06-09
- Reintroduced full dependency on "cause" (for Ruby 1.9 compat).
- Extended `Client#create_database` and `#delete_database` to fallback on `config.database` (#153, #154, @anthonator).
## v0.3.4, released 2016-06-07
- Added resample options to `Client#create_continuous_query` (#149).
- Fixed resample options to be Ruby 1.9 compatible (#150, @SebastianCoetzee).
- Mentioned in README, that 0.3.x series is the last one to support Ruby 1.9.
## v0.3.3, released 2016-06-06 (yanked)
- Added resample options to `Client#create_continuous_query` (#149).
## v0.3.2, released 2016-06-02
- Added config option to authenticate without credentials (#146, @pmenglund).
## v0.3.1, released 2016-05-26
- Fixed #130 (again). Integer values are now really written as Integers to InfluxDB.
## v0.3.0, released 2016-04-24
- Write queries are now checked against 204 No Content responses, in accordance with the official documentation (#128).
- Async options are now configurabe (#107).
## v0.2.6, released 2016-04-14
- Empty tag keys/values are now omitted (#124).
## v0.2.5, released 2016-04-14
- Async writer now behaves when stopping the client (#73).
- Update development dependencies and started enforcing Rubocop styles.
## v0.2.4, released 2016-04-12
- Added `InfluxDB::Client#version`, returning the server version (#117).
- Fixed escaping issues (#119, #121, #135).
- Integer values are now written as Integer, not as Float value (#131).
- Return all result series when querying multiple selects (#134).
- Made host cycling thread safe (#136).
## v0.2.3, released 2015-10-27
- Added `epoch` option to client constructor and write methods (#104).
- Added `#list_user_grants` (#111), `#grant_user_admin_privileges` (#112) and `#alter_retention_policy` (#114) methods.
## v0.2.2, released 2015-07-29
- Fixed issues with Async client (#101)
- Avoid usage of `gsub!` (#102)
## v0.2.1, released 2015-07-25
- Fix double quote tags escaping (#98)
## v0.2.0, released 2015-07-20
- Large library refactoring (#88, #90)
- Extract config from client
- Extract HTTP functionality to separate module
- Extract InfluxDB management functions to separate modules
- Add writer concept
- Refactor specs (add cases)
- Add 'denormalize' option to config
- Recognize SeriesNotFound error
- Update README
- Add Rubocop config
- Break support for Ruby < 2
- Added support for InfluxDB 0.9+ (#92)
## v0.1.9, released 2015-07-04
- last version to support InfluxDB 0.8.x

View File

@ -0,0 +1,14 @@
source "https://rubygems.org"
if RUBY_ENGINE != "jruby" && RUBY_VERSION < "2.0"
gem "json", "~> 1.8.3"
gem "public_suffix", "< 1.5"
end
gemspec
local_gemfile = 'Gemfile.local'
if File.exist?(local_gemfile)
eval(File.read(local_gemfile)) # rubocop:disable Lint/Eval
end

View File

@ -0,0 +1,22 @@
Copyright (c) 2013 Todd Persen
MIT License
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,687 @@
# influxdb-ruby
[![Build Status](https://travis-ci.org/influxdata/influxdb-ruby.svg?branch=master)](https://travis-ci.org/influxdata/influxdb-ruby)
The official Ruby client library for [InfluxDB](https://influxdata.com/time-series-platform/influxdb/).
Maintained by [@toddboom](https://github.com/toddboom) and [@dmke](https://github.com/dmke).
## Contents
- [Platform support](#platform-support)
- [Ruby support](#ruby-support)
- [Installation](#installation)
- [Usage](#usage)
- [Creating a client](#creating-a-client)
- [Administrative tasks](#administrative-tasks)
- [Continuous queries](#continuous-queries)
- [Retention policies](#retention-policies)
- [Writing data](#writing-data)
- [Reading data](#reading-data)
- [Querying](#querying)
- [De-normalization](#de--normalization)
- [Streaming response](#streaming-response)
- [Retry](#retry)
- [Testing](#testing)
- [Contributing](#contributing)
## Platform support
> **Support for InfluxDB v0.8.x is now deprecated**. The final version of this
> library that will support the older InfluxDB interface is `v0.1.9`, which is
> available as a gem and tagged on this repository.
>
> If you're reading this message, then you should only expect support for
> InfluxDB v0.9.1 and higher.
## Ruby support
This gem should work with Ruby 1.9+, but starting with v0.4, we'll likely drop
Ruby 1.9 support.
Please note that for Ruby 1.9, you'll need to install the JSON gem in version
1.8.x yourself, for example by pinning the version in your `Gemfile` (i.e.
`gem "json", "~> 1.8.3"`).
## Installation
```
$ [sudo] gem install influxdb
```
Or add it to your `Gemfile`, and run `bundle install`.
## Usage
### Creating a client
Connecting to a single host:
``` ruby
require 'influxdb'
influxdb = InfluxDB::Client.new host: "influxdb.domain.com"
# or
influxdb = InfluxDB::Client.new # no host given defaults connecting to localhost
```
Connecting to multiple hosts (with built-in load balancing and failover):
``` ruby
require 'influxdb'
influxdb = InfluxDB::Client.new hosts: ["influxdb1.domain.com", "influxdb2.domain.com"]
```
### Administrative tasks
Create a database:
``` ruby
database = 'site_development'
influxdb.create_database(database)
```
Delete a database:
``` ruby
database = 'site_development'
influxdb.delete_database(database)
```
List databases:
``` ruby
influxdb.list_databases
```
Create a user for a database:
``` ruby
database = 'site_development'
new_username = 'foo'
new_password = 'bar'
permission = :write
# with all permissions
influxdb.create_database_user(database, new_username, new_password)
# with specified permission - options are: :read, :write, :all
influxdb.create_database_user(database, new_username, new_password, permissions: permission)
```
Update a user password:
``` ruby
username = 'foo'
new_password = 'bar'
influxdb.update_user_password(username, new_password)
```
Grant user privileges on database:
``` ruby
username = 'foobar'
database = 'foo'
permission = :read # options are :read, :write, :all
influxdb.grant_user_privileges(username, database, permission)
```
Revoke user privileges from database:
``` ruby
username = 'foobar'
database = 'foo'
permission = :write # options are :read, :write, :all
influxdb.revoke_user_privileges(username, database, permission)
```
Delete a user:
``` ruby
username = 'foobar'
influxdb.delete_user(username)
```
List users:
``` ruby
influxdb.list_users
```
Create cluster admin:
``` ruby
username = 'foobar'
password = 'pwd'
influxdb.create_cluster_admin(username, password)
```
List cluster admins:
``` ruby
influxdb.list_cluster_admins
```
Revoke cluster admin privileges from user:
``` ruby
username = 'foobar'
influxdb.revoke_cluster_admin_privileges(username)
```
### Continuous Queries
List continuous queries of a database:
``` ruby
database = 'foo'
influxdb.list_continuous_queries(database)
```
Create a continuous query for a database:
``` ruby
database = 'foo'
name = 'clicks_count'
query = 'SELECT COUNT(name) INTO clicksCount_1h FROM clicks GROUP BY time(1h)'
influxdb.create_continuous_query(name, database, query)
```
Additionally, you can specify the resample interval and the time range over
which the CQ runs:
``` ruby
influxdb.create_continuous_query(name, database, query, resample_every: "10m", resample_for: "65m")
```
Delete a continuous query from a database:
``` ruby
database = 'foo'
name = 'clicks_count'
influxdb.delete_continuous_query(name, database)
```
### Retention Policies
List retention policies of a database:
``` ruby
database = 'foo'
influxdb.list_retention_policies(database)
```
Create a retention policy for a database:
``` ruby
database = 'foo'
name = '1h.cpu'
duration = '10m'
replication = 2
influxdb.create_retention_policy(name, database, duration, replication)
```
Delete a retention policy from a database:
``` ruby
database = 'foo'
name = '1h.cpu'
influxdb.delete_retention_policy(name, database)
```
Alter a retention policy for a database:
``` ruby
database = 'foo'
name = '1h.cpu'
duration = '10m'
replication = 2
influxdb.alter_retention_policy(name, database, duration, replication)
```
### Writing data
Write some data:
``` ruby
username = 'foo'
password = 'bar'
database = 'site_development'
name = 'foobar'
influxdb = InfluxDB::Client.new database, username: username, password: password
# Enumerator that emits a sine wave
Value = (0..360).to_a.map {|i| Math.send(:sin, i / 10.0) * 10 }.each
loop do
data = {
values: { value: Value.next },
tags: { wave: 'sine' } # tags are optional
}
influxdb.write_point(name, data)
sleep 1
end
```
Write data with time precision (precision can be set in 2 ways):
``` ruby
require 'influxdb'
username = 'foo'
password = 'bar'
database = 'site_development'
name = 'foobar'
time_precision = 's'
# either in the client initialization:
influxdb = InfluxDB::Client.new database,
username: username,
password: password,
time_precision: time_precision
data = {
values: { value: 0 },
timestamp: Time.now.to_i # timestamp is optional, if not provided point will be saved with current time
}
influxdb.write_point(name, data)
# or in a method call:
influxdb.write_point(name, data, time_precision)
```
Write data with a specific retention policy:
``` ruby
require 'influxdb'
username = 'foo'
password = 'bar'
database = 'site_development'
name = 'foobar'
precision = 's'
retention = '1h.cpu'
influxdb = InfluxDB::Client.new database,
username: username,
password: password
data = {
values: { value: 0 },
tags: { foo: 'bar', bar: 'baz' }
timestamp: Time.now.to_i
}
influxdb.write_point(name, data, precision, retention)
```
Write data while choosing the database:
``` ruby
require 'influxdb'
username = 'foo'
password = 'bar'
database = 'site_development'
name = 'foobar'
precision = 's'
retention = '1h.cpu'
influxdb = InfluxDB::Client.new {
username: username,
password: password
}
data = {
values: { value: 0 },
tags: { foo: 'bar', bar: 'baz' }
timestamp: Time.now.to_i
}
influxdb.write_point(name, data, precision, retention, database)
```
Write multiple points in a batch (performance boost):
``` ruby
data = [
{
series: 'cpu',
tags: { host: 'server_1', region: 'us' },
values: { internal: 5, external: 0.453345 }
},
{
series: 'gpu',
values: { value: 0.9999 },
}
]
influxdb.write_points(data)
# you can also specify precision in method call
precision = 'm'
influxdb.write_points(data, precision)
```
Write multiple points in a batch with a specific retention policy:
``` ruby
data = [
{
series: 'cpu',
tags: { host: 'server_1', region: 'us' },
values: { internal: 5, external: 0.453345 }
},
{
series: 'gpu',
values: { value: 0.9999 },
}
]
precision = 'm'
retention = '1h.cpu'
influxdb.write_points(data, precision, retention)
```
Write asynchronously (note that a retention policy cannot be specified for asynchronous writes):
``` ruby
require 'influxdb'
username = 'foo'
password = 'bar'
database = 'site_development'
name = 'foobar'
influxdb = InfluxDB::Client.new database,
username: username,
password: password,
async: true
data = {
values: { value: 0 },
tags: { foo: 'bar', bar: 'baz' },
timestamp: Time.now.to_i
}
influxdb.write_point(name, data)
```
Using `async: true` is a shortcut for the following:
``` ruby
async_options = {
# number of points to write to the server at once
max_post_points: 1000,
# queue capacity
max_queue_size: 10_000,
# number of threads
num_worker_threads: 3,
# max. time (in seconds) a thread sleeps before
# checking if there are new jobs in the queue
sleep_interval: 5
}
influxdb = InfluxDB::Client.new database,
username: username,
password: password,
async: async_options
```
Write data via UDP (note that a retention policy cannot be specified for UDP writes):
``` ruby
require 'influxdb'
host = '127.0.0.1'
port = 4444
influxdb = InfluxDB::Client.new udp: { host: host, port: port }
name = 'hitchhiker'
data = {
values: { value: 666 },
tags: { foo: 'bar', bar: 'baz' }
}
influxdb.write_point(name, data)
```
### Reading data
#### Querying
``` ruby
username = 'foo'
password = 'bar'
database = 'site_development'
influxdb = InfluxDB::Client.new database,
username: username,
password: password
# without a block:
influxdb.query 'select * from time_series_1 group by region'
# results are grouped by name, but also their tags:
#
# [
# {
# "name"=>"time_series_1",
# "tags"=>{"region"=>"uk"},
# "values"=>[
# {"time"=>"2015-07-09T09:03:31Z", "count"=>32, "value"=>0.9673},
# {"time"=>"2015-07-09T09:03:49Z", "count"=>122, "value"=>0.4444}
# ]
# },
# {
# "name"=>"time_series_1",
# "tags"=>{"region"=>"us"},
# "values"=>[
# {"time"=>"2015-07-09T09:02:54Z", "count"=>55, "value"=>0.4343}
# ]
# }
# ]
# with a block:
influxdb.query 'select * from time_series_1 group by region' do |name, tags, points|
puts "#{name} [ #{tags.inspect} ]"
points.each do |pt|
puts " -> #{pt.inspect}"
end
end
# result:
# time_series_1 [ {"region"=>"uk"} ]
# -> {"time"=>"2015-07-09T09:03:31Z", "count"=>32, "value"=>0.9673}
# -> {"time"=>"2015-07-09T09:03:49Z", "count"=>122, "value"=>0.4444}]
# time_series_1 [ {"region"=>"us"} ]
# -> {"time"=>"2015-07-09T09:02:54Z", "count"=>55, "value"=>0.4343}
```
If you would rather receive points with integer timestamp, it's possible to set
`epoch` parameter:
``` ruby
# globally, on client initialization:
influxdb = InfluxDB::Client.new database, epoch: 's'
influxdb.query 'select * from time_series group by region'
# [
# {
# "name"=>"time_series",
# "tags"=>{"region"=>"uk"},
# "values"=>[
# {"time"=>1438411376, "count"=>32, "value"=>0.9673}
# ]
# }
# ]
# or for a specific query call:
influxdb.query 'select * from time_series group by region', epoch: 'ms'
# [
# {
# "name"=>"time_series",
# "tags"=>{"region"=>"uk"},
# "values"=>[
# {"time"=>1438411376000, "count"=>32, "value"=>0.9673}
# ]
# }
# ]
```
Working with parameterized query strings works as expected:
``` ruby
influxdb = InfluxDB::Client.new database
named_parameter_query = "select * from time_series_0 where time > %{min_time}"
influxdb.query named_parameter_query, params: { min_time: 0 }
# compiles to:
# select * from time_series_0 where time > 0
positional_params_query = "select * from time_series_0 where f = %{1} and i < %{2}"
influxdb.query positional_params_query, params: ["foobar", 42]
# compiles to (note the automatic escaping):
# select * from time_series_0 where f = 'foobar' and i < 42
```
#### (De-) Normalization
By default, InfluxDB::Client will denormalize points (received from InfluxDB as
columns and rows). If you want to get *raw* data add `denormalize: false` to
the initialization options or to query itself:
``` ruby
influxdb.query 'select * from time_series_1 group by region', denormalize: false
# [
# {
# "name"=>"time_series_1",
# "tags"=>{"region"=>"uk"},
# "columns"=>["time", "count", "value"],
# "values"=>[
# ["2015-07-09T09:03:31Z", 32, 0.9673],
# ["2015-07-09T09:03:49Z", 122, 0.4444]
# ]
# },
# {
# "name"=>"time_series_1",
# "tags"=>{"region"=>"us"},
# "columns"=>["time", "count", "value"],
# "values"=>[
# ["2015-07-09T09:02:54Z", 55, 0.4343]
# ]
# }
# ]
influxdb.query 'select * from time_series_1 group by region', denormalize: false do |name, tags, points|
puts "#{name} [ #{tags.inspect} ]"
points.each do |key, values|
puts " #{key.inspect} -> #{values.inspect}"
end
end
# time_series_1 [ {"region"=>"uk"} ]
# columns -> ["time", "count", "value"]
# values -> [["2015-07-09T09:03:31Z", 32, 0.9673], ["2015-07-09T09:03:49Z", 122, 0.4444]]}
# time_series_1 [ {"region"=>"us"} ]
# columns -> ["time", "count", "value"]
# values -> [["2015-07-09T09:02:54Z", 55, 0.4343]]}
```
You can also pick the database to query from:
```
influxdb.query 'select * from time_series_1', database: 'database'
```
#### Streaming response
If you expect large quantities of data in a response, you may want to enable
JSON streaming by setting a `chunk_size`:
``` ruby
influxdb = InfluxDB::Client.new database,
username: username,
password: password,
chunk_size: 10000
```
See the [official documentation](http://docs.influxdata.com/influxdb/v0.13/guides/querying_data/#chunking)
for more details.
#### Retry
By default, InfluxDB::Client will keep trying (with exponential fall-off) to
connect to the database until it gets a connection. If you want to retry only
a finite number of times (or disable retries altogether), you can pass the
`:retry` option.
`:retry` can be either `true`, `false` or an `Integer` to retry infinite times,
disable retries or retry a finite number of times, respectively. Passing `0` is
equivalent to `false` and `-1` is equivalent to `true`.
```
$ irb -r influxdb
> influxdb = InfluxDB::Client.new 'database', retry: 8
=> #<InfluxDB::Client:0x00000002bb5ce0 ...>
> influxdb.query 'select * from serie limit 1'
E, [2016-08-31T23:55:18.287947 #23476] ERROR -- InfluxDB: Failed to contact host localhost: #<Errno::ECONNREFUSED: Failed to open TCP connection to localhost:8086 (Connection refused - connect(2) for "localhost" port 8086)> - retrying in 0.01s.
E, [2016-08-31T23:55:18.298455 #23476] ERROR -- InfluxDB: Failed to contact host localhost: #<Errno::ECONNREFUSED: Failed to open TCP connection to localhost:8086 (Connection refused - connect(2) for "localhost" port 8086)> - retrying in 0.02s.
E, [2016-08-31T23:55:18.319122 #23476] ERROR -- InfluxDB: Failed to contact host localhost: #<Errno::ECONNREFUSED: Failed to open TCP connection to localhost:8086 (Connection refused - connect(2) for "localhost" port 8086)> - retrying in 0.04s.
E, [2016-08-31T23:55:18.359785 #23476] ERROR -- InfluxDB: Failed to contact host localhost: #<Errno::ECONNREFUSED: Failed to open TCP connection to localhost:8086 (Connection refused - connect(2) for "localhost" port 8086)> - retrying in 0.08s.
E, [2016-08-31T23:55:18.440422 #23476] ERROR -- InfluxDB: Failed to contact host localhost: #<Errno::ECONNREFUSED: Failed to open TCP connection to localhost:8086 (Connection refused - connect(2) for "localhost" port 8086)> - retrying in 0.16s.
E, [2016-08-31T23:55:18.600936 #23476] ERROR -- InfluxDB: Failed to contact host localhost: #<Errno::ECONNREFUSED: Failed to open TCP connection to localhost:8086 (Connection refused - connect(2) for "localhost" port 8086)> - retrying in 0.32s.
E, [2016-08-31T23:55:18.921740 #23476] ERROR -- InfluxDB: Failed to contact host localhost: #<Errno::ECONNREFUSED: Failed to open TCP connection to localhost:8086 (Connection refused - connect(2) for "localhost" port 8086)> - retrying in 0.64s.
E, [2016-08-31T23:55:19.562428 #23476] ERROR -- InfluxDB: Failed to contact host localhost: #<Errno::ECONNREFUSED: Failed to open TCP connection to localhost:8086 (Connection refused - connect(2) for "localhost" port 8086)> - retrying in 1.28s.
InfluxDB::ConnectionError: Tried 8 times to reconnect but failed.
```
## Testing
```
git clone git@github.com:influxdata/influxdb-ruby.git
cd influxdb-ruby
bundle
bundle exec rake
```
## Contributing
- Fork this repository on GitHub.
- Make your changes.
- Add tests.
- Add an entry in the `CHANGELOG.md` in the "unreleased" section on top.
- Run the tests: `bundle exec rake`.
- Send a pull request.
- Please rebase against the master branch.
- If your changes look good, we'll merge them.

View File

@ -0,0 +1,51 @@
require "rake/testtask"
require "bundler/gem_tasks"
require "rubocop/rake_task"
RuboCop::RakeTask.new
targeted_files = ARGV.drop(1)
file_pattern = targeted_files.empty? ? "spec/**/*_spec.rb" : targeted_files
require "rspec/core"
require "rspec/core/rake_task"
RSpec::Core::RakeTask.new(:spec) do |t|
t.pattern = FileList[file_pattern]
end
Rake::TestTask.new(:smoke) do |t|
t.test_files = FileList["smoke/*.rb"]
end
task default: [:spec, :rubocop]
task :console do
lib = File.expand_path("../lib", __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require "influxdb"
begin
require "pry-byebug"
Pry.start
rescue LoadError
puts <<-TEXT.gsub(/^\s{6}([^ ])/, "\1"), ""
Could not load pry-byebug. Create a file Gemfile.local with
the following line, if you want to get rid of this message:
\tgem "pry-byebug"
(don't forget to run bundle afterwards). Falling back to IRB.
TEXT
require "irb"
ARGV.clear
IRB.start
end
end
if !ENV.key?("influx_version") || ENV["influx_version"] == ""
task default: :spec
elsif ENV["TRAVIS"] == "true"
task default: :smoke
end

View File

@ -0,0 +1,30 @@
# coding: utf-8
lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'influxdb/version'
# rubocop:disable Style/SpecialGlobalVars
Gem::Specification.new do |spec|
spec.name = "influxdb"
spec.version = InfluxDB::VERSION
spec.authors = ["Todd Persen"]
spec.email = ["influxdb@googlegroups.com"]
spec.description = "This is the official Ruby library for InfluxDB."
spec.summary = "Ruby library for InfluxDB."
spec.homepage = "http://influxdb.org"
spec.license = "MIT"
spec.files = `git ls-files`.split($/)
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
spec.test_files = spec.files.grep(%r{^(test|spec|features|smoke)/})
spec.require_paths = ["lib"]
spec.add_runtime_dependency "json"
spec.add_runtime_dependency "cause"
spec.add_development_dependency "rake"
spec.add_development_dependency "bundler", "~> 1.3"
spec.add_development_dependency "rspec", "~> 3.5.0"
spec.add_development_dependency "webmock", "~> 2.1.0"
spec.add_development_dependency "rubocop", "~> 0.41.2"
end

View File

@ -0,0 +1,19 @@
require "influxdb/version"
require "influxdb/errors"
require "influxdb/logging"
require "influxdb/max_queue"
require "influxdb/point_value"
require "influxdb/config"
require "influxdb/writer/async"
require "influxdb/writer/udp"
require "influxdb/query/core"
require "influxdb/query/cluster"
require "influxdb/query/database"
require "influxdb/query/user"
require "influxdb/query/continuous_query"
require "influxdb/query/retention_policy"
require "influxdb/client/http"
require "influxdb/client"

View File

@ -0,0 +1,82 @@
require 'json'
require 'cause' unless Exception.instance_methods.include?(:cause)
require 'thread'
module InfluxDB
# InfluxDB client class
class Client
attr_reader :config, :writer
include InfluxDB::Logging
include InfluxDB::HTTP
include InfluxDB::Query::Core
include InfluxDB::Query::Cluster
include InfluxDB::Query::Database
include InfluxDB::Query::User
include InfluxDB::Query::ContinuousQuery
include InfluxDB::Query::RetentionPolicy
# Initializes a new InfluxDB client
#
# === Examples:
#
# # connect to localhost using root/root
# # as the credentials and doesn't connect to a db
#
# InfluxDB::Client.new
#
# # connect to localhost using root/root
# # as the credentials and 'db' as the db name
#
# InfluxDB::Client.new 'db'
#
# # override username, other defaults remain unchanged
#
# InfluxDB::Client.new username: 'username'
#
# # override username, use 'db' as the db name
# Influxdb::Client.new 'db', username: 'username'
#
# === Valid options in hash
#
# +:host+:: the hostname to connect to
# +:port+:: the port to connect to
# +:prefix+:: the specified path prefix when building the url e.g.: /prefix/db/dbname...
# +:username+:: the username to use when executing commands
# +:password+:: the password associated with the username
# +:use_ssl+:: use ssl to connect
# +:verify_ssl+:: verify ssl server certificate?
# +:ssl_ca_cert+:: ssl CA certificate, chainfile or CA path.
# The system CA path is automatically included
def initialize(*args)
opts = args.last.is_a?(Hash) ? args.last : {}
opts[:database] = args.first if args.first.is_a? String
@config = InfluxDB::Config.new(opts)
@stopped = false
@writer = find_writer
at_exit { stop! } if config.retry > 0
end
def stop!
writer.worker.stop! if config.async?
@stopped = true
end
def stopped?
@stopped
end
private
def find_writer
if config.async?
InfluxDB::Writer::Async.new(self, config.async)
elsif config.udp?
InfluxDB::Writer::UDP.new(self, config.udp)
else
self
end
end
end
end

View File

@ -0,0 +1,134 @@
require 'uri'
require 'cgi'
require 'net/http'
require 'net/https'
module InfluxDB
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/AbcSize
module HTTP # :nodoc:
def get(url, options = {})
connect_with_retry do |http|
response = do_request http, Net::HTTP::Get.new(url)
case response
when Net::HTTPSuccess
handle_successful_response(response, options)
when Net::HTTPUnauthorized
raise InfluxDB::AuthenticationError, response.body
else
resolve_error(response.body)
end
end
end
def post(url, data)
headers = { "Content-Type" => "application/octet-stream" }
connect_with_retry do |http|
response = do_request http, Net::HTTP::Post.new(url, headers), data
case response
when Net::HTTPNoContent
return response
when Net::HTTPUnauthorized
raise InfluxDB::AuthenticationError, response.body
else
resolve_error(response.body)
end
end
end
private
def connect_with_retry
host = config.next_host
delay = config.initial_delay
retry_count = 0
begin
http = Net::HTTP.new(host, config.port)
http.open_timeout = config.open_timeout
http.read_timeout = config.read_timeout
http = setup_ssl(http)
yield http
rescue *InfluxDB::NON_RECOVERABLE_EXCEPTIONS => e
raise InfluxDB::ConnectionError, InfluxDB::NON_RECOVERABLE_MESSAGE
rescue Timeout::Error, *InfluxDB::RECOVERABLE_EXCEPTIONS => e
retry_count += 1
unless (config.retry == -1 || retry_count <= config.retry) && !stopped?
raise InfluxDB::ConnectionError, "Tried #{retry_count - 1} times to reconnect but failed."
end
log :error, "Failed to contact host #{host}: #{e.inspect} - retrying in #{delay}s."
sleep delay
delay = [config.max_delay, delay * 2].min
retry
ensure
http.finish if http.started?
end
end
def do_request(http, req, data = nil)
req.basic_auth config.username, config.password if basic_auth?
req.body = data if data
http.request(req)
end
def basic_auth?
config.auth_method == 'basic_auth'
end
def resolve_error(response)
if response =~ /Couldn\'t find series/
raise InfluxDB::SeriesNotFound, response
end
raise InfluxDB::Error, response
end
def handle_successful_response(response, options)
if options.fetch(:json_streaming, false)
parsed_response = response.body.each_line.with_object({}) do |line, parsed|
parsed.merge!(JSON.parse(line)) { |_key, oldval, newval| oldval + newval }
end
elsif response.body
parsed_response = JSON.parse(response.body)
end
errors = errors_from_response(parsed_response)
raise InfluxDB::QueryError, errors if errors
options.fetch(:parse, false) ? parsed_response : response
end
def errors_from_response(parsed_resp)
return unless parsed_resp.is_a?(Hash)
parsed_resp
.fetch('results', [])
.fetch(0, {})
.fetch('error', nil)
end
def setup_ssl(http)
http.use_ssl = config.use_ssl
http.verify_mode = OpenSSL::SSL::VERIFY_NONE unless config.verify_ssl
return http unless config.use_ssl
http.cert_store = generate_cert_store
http
end
def generate_cert_store
store = OpenSSL::X509::Store.new
store.set_default_paths
if config.ssl_ca_cert
if File.directory?(config.ssl_ca_cert)
store.add_path(config.ssl_ca_cert)
else
store.add_file(config.ssl_ca_cert)
end
end
store
end
end
end

View File

@ -0,0 +1,120 @@
require 'thread'
module InfluxDB
# InfluxDB client configuration
class Config
AUTH_METHODS = ["params".freeze, "basic_auth".freeze, "none".freeze].freeze
attr_accessor :port,
:username,
:password,
:database,
:time_precision,
:use_ssl,
:verify_ssl,
:ssl_ca_cert,
:auth_method,
:initial_delay,
:max_delay,
:open_timeout,
:read_timeout,
:retry,
:prefix,
:chunk_size,
:denormalize,
:epoch
attr_reader :async, :udp
def initialize(opts = {})
extract_http_options!(opts)
extract_ssl_options!(opts)
extract_database_options!(opts)
extract_writer_options!(opts)
extract_query_options!(opts)
configure_retry! opts.fetch(:retry, nil)
configure_hosts! opts[:hosts] || opts[:host] || "localhost".freeze
end
def udp?
udp != false
end
def async?
async != false
end
def next_host
host = @hosts_queue.pop
@hosts_queue.push(host)
host
end
def hosts
Array.new(@hosts_queue.length) do
host = @hosts_queue.pop
@hosts_queue.push(host)
host
end
end
private
# rubocop:disable Metrics/AbcSize
def extract_http_options!(opts)
@port = opts.fetch :port, 8086
@prefix = opts.fetch :prefix, "".freeze
@username = opts.fetch :username, "root".freeze
@password = opts.fetch :password, "root".freeze
@open_timeout = opts.fetch :write_timeout, 5
@read_timeout = opts.fetch :read_timeout, 300
@max_delay = opts.fetch :max_delay, 30
@initial_delay = opts.fetch :initial_delay, 0.01
auth = opts[:auth_method]
@auth_method = AUTH_METHODS.include?(auth) ? auth : "params".freeze
end
def extract_ssl_options!(opts)
@use_ssl = opts.fetch :use_ssl, false
@verify_ssl = opts.fetch :verify_ssl, true
@ssl_ca_cert = opts.fetch :ssl_ca_cert, false
end
# normalize retry option
def configure_retry!(value)
case value
when Integer
@retry = value
when true, nil
@retry = -1
when false
@retry = 0
end
end
# load the hosts into a Queue for thread safety
def configure_hosts!(hosts)
@hosts_queue = Queue.new
Array(hosts).each do |host|
@hosts_queue.push(host)
end
end
def extract_database_options!(opts)
@database = opts[:database]
@time_precision = opts.fetch :time_precision, "s".freeze
@denormalize = opts.fetch :denormalize, true
@epoch = opts.fetch :epoch, false
end
def extract_writer_options!(opts)
@async = opts.fetch :async, false
@udp = opts.fetch :udp, false
end
def extract_query_options!(opts)
@chunk_size = opts.fetch :chunk_size, nil
end
end
end

View File

@ -0,0 +1,52 @@
require "net/http"
require "zlib"
module InfluxDB # :nodoc:
class Error < StandardError
end
class AuthenticationError < Error
end
class ConnectionError < Error
end
class SeriesNotFound < Error
end
class JSONParserError < Error
end
class QueryError < Error
end
# When executing queries via HTTP, some errors can more or less safely
# be ignored and we can retry the query again. This following
# exception classes shall be deemed as "safe".
#
# Taken from: https://github.com/lostisland/faraday/blob/master/lib/faraday/adapter/net_http.rb
RECOVERABLE_EXCEPTIONS = [
Errno::ECONNABORTED,
Errno::ECONNREFUSED,
Errno::ECONNRESET,
Errno::EHOSTUNREACH,
Errno::EINVAL,
Errno::ENETUNREACH,
Net::HTTPBadResponse,
Net::HTTPHeaderSyntaxError,
Net::ProtocolError,
SocketError,
(OpenSSL::SSL::SSLError if defined?(OpenSSL))
].compact.freeze
# Exception classes which hint to a larger problem on the server side,
# like insuffient resources. If we encouter on of the following, wo
# _don't_ retry a query but escalate it upwards.
NON_RECOVERABLE_EXCEPTIONS = [
EOFError,
Zlib::Error
].freeze
NON_RECOVERABLE_MESSAGE = "The server has sent incomplete data" \
" (insufficient resources are a possible cause).".freeze
end

Some files were not shown because too many files have changed in this diff Show More