Import of plugin's code
Change-Id: Ic773558145bd86d46f02151644f1dc435c7b23c2
This commit is contained in:
parent
97e4d12ca2
commit
983230fe86
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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.
|
|
@ -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.
|
|
@ -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
|
|
@ -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
|
11
deployment_scripts/puppet/files/embedded/lib/ruby/gems/2.3.0/gems/dentaku-2.0.9/.gitignore
vendored
Normal file
11
deployment_scripts/puppet/files/embedded/lib/ruby/gems/2.3.0/gems/dentaku-2.0.9/.gitignore
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
*.gem
|
||||
.bundle
|
||||
.rbenv-version
|
||||
Gemfile.lock
|
||||
bin/*
|
||||
pkg/*
|
||||
vendor/*
|
||||
|
||||
/.ruby-gemset
|
||||
/.ruby-version
|
||||
/.rspec
|
|
@ -0,0 +1,2 @@
|
|||
require "bundler"
|
||||
Bundler.require
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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.
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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'
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -0,0 +1,5 @@
|
|||
require_relative '../function'
|
||||
|
||||
Dentaku::AST::Function.register(:max, :numeric, ->(*args) {
|
||||
args.max
|
||||
})
|
|
@ -0,0 +1,5 @@
|
|||
require_relative '../function'
|
||||
|
||||
Dentaku::AST::Function.register(:min, :numeric, ->(*args) {
|
||||
args.min
|
||||
})
|
|
@ -0,0 +1,5 @@
|
|||
require_relative '../function'
|
||||
|
||||
Dentaku::AST::Function.register(:not, :logical, ->(logical) {
|
||||
! logical
|
||||
})
|
|
@ -0,0 +1,5 @@
|
|||
require_relative '../function'
|
||||
|
||||
Dentaku::AST::Function.register(:round, :numeric, ->(numeric, places=nil) {
|
||||
numeric.round(places || 0)
|
||||
})
|
|
@ -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
|
||||
})
|
|
@ -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
|
||||
})
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
|||
require_relative "./literal"
|
||||
|
||||
module Dentaku
|
||||
module AST
|
||||
class Logical < Literal
|
||||
end
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -0,0 +1,9 @@
|
|||
module Dentaku
|
||||
module AST
|
||||
class Nil < Node
|
||||
def value(*)
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
|||
require_relative "./literal"
|
||||
|
||||
module Dentaku
|
||||
module AST
|
||||
class Numeric < Literal
|
||||
end
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
|||
require_relative "./literal"
|
||||
|
||||
module Dentaku
|
||||
module AST
|
||||
class String < Literal
|
||||
end
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
module Dentaku
|
||||
VERSION = "2.0.9"
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
23
deployment_scripts/puppet/files/embedded/lib/ruby/gems/2.3.0/gems/influxdb-0.3.13/.gitignore
vendored
Normal file
23
deployment_scripts/puppet/files/embedded/lib/ruby/gems/2.3.0/gems/influxdb-0.3.13/.gitignore
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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.
|
|
@ -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.
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
Loading…
Reference in New Issue