Initial commit

This commit is contained in:
Senad Uka
2020-01-28 13:31:56 +01:00
parent 7f7c6e95bc
commit 2749c53aac
56 changed files with 6516 additions and 1 deletions

26
Gemfile Normal file
View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
source 'https://rubygems.org'
gem 'activesupport', '~> 4.2', require: false
# backports left_joins from rails 5 to rails 4
gem 'squeel', '~> 1.2', require: false
# Add dependencies to develop your gem here.
# Include everything needed to run rake, tests, features, etc.
group :development do
gem 'activerecord', '~> 4.2', require: false
# gem used by juwelier/github_api. Needs to be version locked to work on Ruby 2.1
gem 'addressable', '~> 2.4', '< 3.0', require: false
gem 'bundler', '~> 1.0', require: false
gem 'database_cleaner', '~> 1.7', require: false
gem 'dotenv', '~> 2.7', require: false
gem 'juwelier', '~> 2.1', require: false
gem 'pg', '~> 0.15', require: false
gem 'pry', '~> 0.12', require: false
gem 'rdoc', '~> 3.12', require: false
gem 'rspec', '~> 3.8', require: false
gem 'rubocop', '~> 0.54', require: false
gem 'rubocop-rspec', '~> 1.23', require: false
gem 'simplecov', '~> 0.16', require: false
end

143
Gemfile.lock Normal file
View File

@@ -0,0 +1,143 @@
GEM
remote: https://rubygems.org/
specs:
activemodel (4.2.11.1)
activesupport (= 4.2.11.1)
builder (~> 3.1)
activerecord (4.2.11.1)
activemodel (= 4.2.11.1)
activesupport (= 4.2.11.1)
arel (~> 6.0)
activesupport (4.2.11.1)
i18n (~> 0.7)
minitest (~> 5.1)
thread_safe (~> 0.3, >= 0.3.4)
tzinfo (~> 1.1)
addressable (2.6.0)
public_suffix (>= 2.0.2, < 4.0)
arel (6.0.4)
ast (2.4.0)
builder (3.2.3)
coderay (1.1.2)
concurrent-ruby (1.1.5)
database_cleaner (1.7.0)
descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1)
diff-lcs (1.3)
docile (1.3.2)
dotenv (2.7.2)
faraday (0.15.4)
multipart-post (>= 1.2, < 3)
git (1.5.0)
github_api (0.18.2)
addressable (~> 2.4)
descendants_tracker (~> 0.0.4)
faraday (~> 0.8)
hashie (~> 3.5, >= 3.5.2)
oauth2 (~> 1.0)
hashie (3.6.0)
highline (2.0.2)
i18n (0.9.5)
concurrent-ruby (~> 1.0)
json (1.8.6)
juwelier (2.1.3)
builder
bundler (>= 1.13)
git (>= 1.2.5)
github_api
highline (>= 1.6.15)
nokogiri (>= 1.5.10)
rake
rdoc
semver
jwt (2.2.1)
method_source (0.9.2)
mini_portile2 (2.4.0)
minitest (5.11.3)
multi_json (1.13.1)
multi_xml (0.6.0)
multipart-post (2.1.1)
nokogiri (1.9.1)
mini_portile2 (~> 2.4.0)
oauth2 (1.4.1)
faraday (>= 0.8, < 0.16.0)
jwt (>= 1.0, < 3.0)
multi_json (~> 1.3)
multi_xml (~> 0.5)
rack (>= 1.2, < 3)
parallel (1.12.1)
parser (2.5.1.0)
ast (~> 2.4.0)
pg (0.18.4)
polyamorous (1.1.0)
activerecord (>= 3.0)
powerpack (0.1.1)
pry (0.12.2)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
public_suffix (3.1.1)
rack (1.6.11)
rainbow (3.0.0)
rake (12.3.2)
rdoc (3.12.2)
json (~> 1.4)
rspec (3.8.0)
rspec-core (~> 3.8.0)
rspec-expectations (~> 3.8.0)
rspec-mocks (~> 3.8.0)
rspec-core (3.8.1)
rspec-support (~> 3.8.0)
rspec-expectations (3.8.4)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.8.0)
rspec-mocks (3.8.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.8.0)
rspec-support (3.8.2)
rubocop (0.54.0)
parallel (~> 1.10)
parser (>= 2.5)
powerpack (~> 0.1)
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
rubocop-rspec (1.23.0)
rubocop (>= 0.52.1)
ruby-progressbar (1.9.0)
semver (1.0.1)
simplecov (0.16.1)
docile (~> 1.1)
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
squeel (1.2.3)
activerecord (>= 3.0)
activesupport (>= 3.0)
polyamorous (~> 1.1.0)
thread_safe (0.3.6)
tzinfo (1.2.5)
thread_safe (~> 0.1)
unicode-display_width (1.3.3)
PLATFORMS
ruby
DEPENDENCIES
activerecord (~> 4.2)
activesupport (~> 4.2)
addressable (~> 2.4, < 3.0)
bundler (~> 1.0)
database_cleaner (~> 1.7)
dotenv (~> 2.7)
juwelier (~> 2.1)
pg (~> 0.15)
pry (~> 0.12)
rdoc (~> 3.12)
rspec (~> 3.8)
rubocop (~> 0.54)
rubocop-rspec (~> 1.23)
simplecov (~> 0.16)
squeel (~> 1.2)
BUNDLED WITH
1.17.2

62
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,62 @@
@Library('jenkins-common') _
utils.initProperties()
def appName = 'pg_searchable'
def databaseName = 'pg_searchable'
def databaseHost = 'localhost'
def databasePort = '5432'
def databasePool = '10'
def databaseUser = 'postgres'
def databasePass = 'Q99d5Aikh2VR'
def nexusUser = 'jenkins'
def nexusPass = 'ASTrIAnDIdEONVan'
utils.onNode(nodeLabel: 'ubuntu-ruby-215') {
String version
version = utils.stagesGitCheckout()
stage('Save version') {
writeFile file: 'VERSION', text: version
}
stage('Run tests') {
withEnv([
"DATABASE_NAME=${databaseName}",
"DATABASE_HOST=${databaseHost}",
"DATABASE_PORT=${databasePort}",
"DATABASE_POOL=${databasePool}",
"DATABASE_USERNAME=${databaseUser}",
"DATABASE_PASSWORD=${databasePass}"
]) {
sh '''
apt-get update
apt-get -y install postgresql-client libcurl3
docker run --name postgres-pg-searchable -p${DATABASE_PORT}:5432 -e POSTGRES_PASSWORD=${DATABASE_PASSWORD} -d postgres:9.5
gem install bundler --version=`sed -e '$!d' Gemfile.lock | xargs` --no-ri --no-rdoc
gem install nexus --no-ri --no-rdoc
bundle install --path vendor --without production --with test development
bundle exec rake spec
'''.stripIndent()
}
ruby.stepsRcovPublish()
}
stage('Run rubocop') {
sh '''
bundle exec rubocop
'''
}
stage('Build gem') {
sh '''
bundle exec rake build
'''
}
stage('Publish gem') {
sh "gem nexus --url https://nexus.onsmartdev.media/repository/rubygems-hosted --credential ${nexusUser}:${nexusPass} pkg/*.gem"
}
}

121
README.md
View File

@@ -1,2 +1,121 @@
# gem
## DESCRIPTION
This ruby gem adds a PgSearchable Model Concern to be included into Rails models that allows easy (simple and limited) usage of english like queries using postgres's builtin fulltext search capabilities.
## REQUIREMENTS
- Ruby 2.1+
- ActiveRecord 4.2+
- Postgresql 9.2+
## INSTALL
Add this to your Gemfile:
```ruby
gem 'pg_searchable'
```
## USAGE
To add PgSearch to an Active Record model, include the PgSearchable module and call the added `pg_search` method:
```ruby
class Model < ActiveRecord::Base
include PgSearchable
pg_search fields: %i[name description]
end
Model.scope_search('cats or dogs') # Will find models that have either 'cat' or 'dog' keyword in their name or description
```
The pg_search method accepts the following properties:
- `fields`: An array of fields that are gonna be matched by the query. The field type needs to able to converted into text (using psql `field::text`)
- `scope`: The name of the scope method that will be added to the model. Defaults to `scope_search`
- `cache`: If present, then instead of dynamically calculating the tsvector during runtime, it will use the tsvector field provided instead. See "USING CACHE FIELD" below
- `skip_callback`: When a cache field is defined and this value is true, the cache field will not be updated automatically
- `language`: language of the dictionary to be used to generate the lexemes. Defaults to `english`, which uses lexemes comparison. Another useful value is `simple` which only removes stopwords but does lower case exact word comparison
- `wildcard`: enables or disabled wildcard search feature for the keywords. Defaults to true
- `external_cache_data`: a method that returns a String or an Array of Strings of extra data to be added to the cache value when updating the cache value.
- `joins`: specifies an array of relation names, or a hash (to do multiple associations on) and does a left outer join with them, allowing those fields to be dynamically searched upon. Can only be used without a cache field, and if relation data is required while using the cache use `external_cache_data` instead to populate the cache field. When using joins, its advisable to prefix the field names in the field options with the table names to avoid collisions (which would result in errors). Examples.:
```ruby
class Product < ActiveRecord::Base
include PgSearchable
pg_search fields: %i[products.name tags.value categories.name sections.name],
joins: [{tags: :category}, :sections]
has_many :tags
has_many :sections, through: :tags
end
class Tag < ActiveRecord::Base
belongs_to :product
belongs_to :category
has_many :sections
end
class Section < ActiveRecord::Base
belongs_to :tag
end
class Category < ActiveRecord::Base
has_many :tags
end
```
Do note that if you specify the table names on the fields property, they should refer after the table names (usually pluralized in rails), while on the joins property it follows the relation name (which can be singular or plural depending on the relation type)
## USING CACHE FIELD
For perfomance improvements, a cache field can be added and configured for pg_search to use it instead of dynamically generating it during runtime.
To use it, first add the vector field to the model with a migration:
```ruby
class AddSearchVectorToModels < ActiveRecord::Migration
def change
add_column :models, :search_cache, :tsvector
add_index :models, :search_cache, using: :gin
end
end
```
Then set the `cache` property on the `pg_search` call:
```ruby
pg_search fields: %i[name description], cache: :search_cache
```
This will add an after_save callback to the model which will automatically update the cache field with the new values everytime the record is saved. If you wanna search the vector field but manually update the cache, you can do so by passing `skip_callback` to false, and then manually running the `update_pg_search_cache` method on a model instance.
If you required to index data of external relationships, this can be accomplished by using the cache field with the `external_cache_data` option, passing the name of an instance method for the model that retrieves the external data.
For example, considering a Tag model that has a value column and a 1:N relation with the Product model, this can be achieved by doing:
```ruby
class Product < ActiveRecord::Base
include PgSearchable
pg_search fields: %i[name], cache: :search_cache, external_cache_data: :tag_values
has_many :tags
def tag_values
tags.pluck(:value)
end
end
class Tag < ActiveRecord::Base
belongs_to :product
after_save { product.update_pg_search_cache if product.present? }
end
```
Since its an external data, everytime the external data has changed you need to make sure to call `update_pg_search_cache` method or a save/update that will trigger the update method in order for that value to be cached and searchable.
## DEVELOPING
To run the test suite create `.env.test.local` file containing the same entries as with `.env.test` but with the correct local settings to a postgres database, run `bundle install` to download dependencies and run `rake`
## CONTRIBUTING
Make sure the test coverage remains at 100%, there are no rubocop complaints (`bundle exec rubocop`) and make a Pull Request.

46
Rakefile Normal file
View File

@@ -0,0 +1,46 @@
# frozen_string_literal: true
require 'rubygems'
require 'bundler'
begin
Bundler.setup(:default, :development)
rescue Bundler::BundlerError => e
$stderr.warn e.message
$stderr.warn 'Run `bundle install` to install missing gems'
exit e.status_code
end
require 'rake'
require 'juwelier'
require_relative './version'
Juwelier::Tasks.new do |gem|
# gem is a Gem::Specification... see http://guides.rubygems.org/specification-reference/ for more options
gem.name = 'pg_searchable_regex'
gem.homepage = ''
gem.license = 'Nonstandard'
gem.summary = 'A simple ActiveModel concern that can be added to do fulltext search with postgres databases'
gem.description = 'A simple ActiveModel concern that can be added to do fulltext search with postgres databases'
gem.email = 'dev@outfrontmedia.com'
gem.authors = ['Outfront Media']
gem.files.include('lib/**/*.rb')
gem.version = Version.to_s
# dependencies defined in Gemfile
end
Juwelier::RubygemsDotOrgTasks.new
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec) do |t|
t.pattern = Dir.glob('spec/**/*_spec.rb')
t.rspec_opts = '--format documentation'
end
task default: :spec
require 'rdoc/task'
Rake::RDocTask.new do |rdoc|
version = File.exist?('VERSION') ? File.read('VERSION') : ''
rdoc.rdoc_dir = 'rdoc'
rdoc.title = "pg_searchable #{version}"
rdoc.rdoc_files.include('README*')
rdoc.rdoc_files.include('lib/**/*.rb')
end

5
coverage/.last_run.json Normal file
View File

@@ -0,0 +1,5 @@
{
"result": {
"covered_percent": 66.92
}
}

254
coverage/.resultset.json Normal file
View File

@@ -0,0 +1,254 @@
{
"RSpec": {
"coverage": {
"/home/hamo/projects/toptal/outfrontmedia/pg_searchable/lib/pg_searchable_regex.rb": [
null,
null,
1,
1,
1,
1,
null,
1,
1,
null,
1,
12,
null,
null,
null,
null,
null,
1,
1,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
null,
null,
1,
12,
43,
null,
null,
null,
1,
31,
30,
null,
null,
1,
0,
null,
null,
1,
0,
null,
null,
1,
12,
null,
null,
1,
0,
null,
null,
1,
0,
0,
0,
null,
null,
null
],
"/home/hamo/projects/toptal/outfrontmedia/pg_searchable/lib/text_to_tsquery.rb": [
null,
null,
null,
null,
1,
1,
null,
1,
0,
0,
0,
0,
null,
null,
1,
null,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
null,
null,
1,
0,
null,
null,
1,
null,
0,
null,
0,
0,
null,
0,
null,
0,
null,
0,
null,
null,
1,
0,
null,
null,
1,
0,
null,
null,
1,
0,
null,
null,
null,
1,
0,
null,
null,
null,
1,
0,
null,
null,
1,
0,
null,
null,
1,
0,
null,
null,
null,
1,
0,
0,
null,
null,
null,
1,
0,
null,
null,
null,
1,
0,
null,
null,
1,
0,
null,
null
],
"/home/hamo/projects/toptal/outfrontmedia/pg_searchable/lib/text_to_regex_query.rb": [
null,
null,
null,
null,
null,
1,
1,
35,
35,
35,
35,
37,
37,
7,
null,
null,
null,
1,
5,
5,
5,
5,
5,
5,
null,
null,
1,
null,
1,
5,
null,
null,
1,
5,
5,
5,
null,
null,
1,
5,
null,
5,
7,
7,
5,
5,
null,
5,
3,
null,
5,
null,
null,
1,
5,
13,
13,
null,
5,
8,
null,
5,
null,
5,
null,
null
]
},
"timestamp": 1579269179
}
}

View File

View File

@@ -0,0 +1,799 @@
/* -----------------------------------------------------------------------
Blueprint CSS Framework 0.9
http://blueprintcss.org
* Copyright (c) 2007-Present. See LICENSE for more info.
* See README for instructions on how to use Blueprint.
* For credits and origins, see AUTHORS.
* This is a compressed file. See the sources in the 'src' directory.
----------------------------------------------------------------------- */
/* reset.css */
html, body, div, span, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, code, del, dfn, em, img, q, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, dialog, figure, footer, header, hgroup, nav, section {margin:0;padding:0;border:0;font-weight:inherit;font-style:inherit;font-size:100%;font-family:inherit;vertical-align:baseline;}
article, aside, dialog, figure, footer, header, hgroup, nav, section {display:block;}
body {line-height:1.5;}
table {border-collapse:separate;border-spacing:0;}
caption, th, td {text-align:left;font-weight:normal;}
table, td, th {vertical-align:middle;}
blockquote:before, blockquote:after, q:before, q:after {content:"";}
blockquote, q {quotes:"" "";}
a img {border:none;}
/* typography.css */
html {font-size:100.01%;}
body {font-size:82%;color:#222;background:#fff;font-family:"Helvetica Neue", Arial, Helvetica, sans-serif;}
h1, h2, h3, h4, h5, h6 {font-weight:normal;color:#111;}
h1 {font-size:3em;line-height:1;margin-bottom:0.5em;}
h2 {font-size:2em;margin-bottom:0.75em;}
h3 {font-size:1.5em;line-height:1;margin-bottom:1em;}
h4 {font-size:1.2em;line-height:1.25;margin-bottom:1.25em;}
h5 {font-size:1em;font-weight:bold;margin-bottom:1.5em;}
h6 {font-size:1em;font-weight:bold;}
h1 img, h2 img, h3 img, h4 img, h5 img, h6 img {margin:0;}
p {margin:0 0 1.5em;}
p img.left {float:left;margin:1.5em 1.5em 1.5em 0;padding:0;}
p img.right {float:right;margin:1.5em 0 1.5em 1.5em;}
a:focus, a:hover {color:#000;}
a {color:#009;text-decoration:underline;}
blockquote {margin:1.5em;color:#666;font-style:italic;}
strong {font-weight:bold;}
em, dfn {font-style:italic;}
dfn {font-weight:bold;}
sup, sub {line-height:0;}
abbr, acronym {border-bottom:1px dotted #666;}
address {margin:0 0 1.5em;font-style:italic;}
del {color:#666;}
pre {margin:1.5em 0;white-space:pre;}
pre, code, tt {font:1em 'andale mono', 'lucida console', monospace;line-height:1.5;}
li ul, li ol {margin:0;}
ul, ol {margin:0 1.5em 1.5em 0;padding-left:3.333em;}
ul {list-style-type:disc;}
ol {list-style-type:decimal;}
dl {margin:0 0 1.5em 0;}
dl dt {font-weight:bold;}
dd {margin-left:1.5em;}
table {margin-bottom:1.4em;width:100%;}
th {font-weight:bold;}
thead th {background:#c3d9ff;}
th, td, caption {padding:4px 10px 4px 5px;}
tr.even td {background:#efefef;}
tfoot {font-style:italic;}
caption {background:#eee;}
.small {font-size:.8em;margin-bottom:1.875em;line-height:1.875em;}
.large {font-size:1.2em;line-height:2.5em;margin-bottom:1.25em;}
.hide {display:none;}
.quiet {color:#666;}
.loud {color:#000;}
.highlight {background:#ff0;}
.added {background:#060;color:#fff;}
.removed {background:#900;color:#fff;}
.first {margin-left:0;padding-left:0;}
.last {margin-right:0;padding-right:0;}
.top {margin-top:0;padding-top:0;}
.bottom {margin-bottom:0;padding-bottom:0;}
/* forms.css */
label {font-weight:bold;}
fieldset {padding:1.4em;margin:0 0 1.5em 0;border:1px solid #ccc;}
legend {font-weight:bold;font-size:1.2em;}
input[type=text], input[type=password], input.text, input.title, textarea, select {background-color:#fff;border:1px solid #bbb;}
input[type=text]:focus, input[type=password]:focus, input.text:focus, input.title:focus, textarea:focus, select:focus {border-color:#666;}
input[type=text], input[type=password], input.text, input.title, textarea, select {margin:0.5em 0;}
input.text, input.title {width:300px;padding:5px;}
input.title {font-size:1.5em;}
textarea {width:390px;height:250px;padding:5px;}
input[type=checkbox], input[type=radio], input.checkbox, input.radio {position:relative;top:.25em;}
form.inline {line-height:3;}
form.inline p {margin-bottom:0;}
.error, .notice, .success {padding:.8em;margin-bottom:1em;border:2px solid #ddd;}
.error {background:#FBE3E4;color:#8a1f11;border-color:#FBC2C4;}
.notice {background:#FFF6BF;color:#514721;border-color:#FFD324;}
.success {background:#E6EFC2;color:#264409;border-color:#C6D880;}
.error a {color:#8a1f11;}
.notice a {color:#514721;}
.success a {color:#264409;}
.box {padding:1.5em;margin-bottom:1.5em;background:#E5ECF9;}
hr {background:#ddd;color:#ddd;clear:both;float:none;width:100%;height:.1em;margin:0 0 1.45em;border:none;}
hr.space {background:#fff;color:#fff;visibility:hidden;}
.clearfix:after, .container:after {content:"\0020";display:block;height:0;clear:both;visibility:hidden;overflow:hidden;}
.clearfix, .container {display:block;}
.clear {clear:both;}
/*
github.com style (c) Vasily Polovnyov <vast@whiteants.net>
*/
pre code {
}
pre .comment,
pre .template_comment,
pre .diff .header,
pre .javadoc {
color: #998;
font-style: italic
}
pre .keyword,
pre .css .rule .keyword,
pre .winutils,
pre .javascript .title,
pre .lisp .title {
color: #000;
font-weight: bold
}
pre .number,
pre .hexcolor {
color: #458
}
pre .string,
pre .tag .value,
pre .phpdoc,
pre .tex .formula {
color: #d14
}
pre .subst {
color: #712;
}
pre .constant,
pre .title,
pre .id {
color: #900;
font-weight: bold
}
pre .javascript .title,
pre .lisp .title,
pre .subst {
font-weight: normal
}
pre .class .title,
pre .haskell .label,
pre .tex .command {
color: #458;
font-weight: bold
}
pre .tag,
pre .tag .title,
pre .rules .property,
pre .django .tag .keyword {
color: #000080;
font-weight: normal
}
pre .attribute,
pre .variable,
pre .instancevar,
pre .lisp .body {
color: #008080
}
pre .regexp {
color: #009926
}
pre .class {
color: #458;
font-weight: bold
}
pre .symbol,
pre .ruby .symbol .string,
pre .ruby .symbol .keyword,
pre .ruby .symbol .keymethods,
pre .lisp .keyword,
pre .tex .special,
pre .input_number {
color: #990073
}
pre .builtin,
pre .built_in,
pre .lisp .title {
color: #0086b3
}
pre .preprocessor,
pre .pi,
pre .doctype,
pre .shebang,
pre .cdata {
color: #999;
font-weight: bold
}
pre .deletion {
background: #fdd
}
pre .addition {
background: #dfd
}
pre .diff .change {
background: #0086b3
}
pre .chunk {
color: #aaa
}
pre .tex .formula {
opacity: 0.5;
}
/*
* jQuery UI CSS Framework @VERSION
*
* Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Theming/API
*/
/* Layout helpers
----------------------------------*/
.ui-helper-hidden { display: none; }
.ui-helper-hidden-accessible { position: absolute; left: -99999999px; }
.ui-helper-reset { margin: 0; padding: 0; border: 0; outline: 0; line-height: 1.3; text-decoration: none; font-size: 100%; list-style: none; }
.ui-helper-clearfix:after { content: "."; display: block; height: 0; clear: both; visibility: hidden; }
.ui-helper-clearfix { display: inline-block; }
/* required comment for clearfix to work in Opera \*/
* html .ui-helper-clearfix { height:1%; }
.ui-helper-clearfix { display:block; }
/* end clearfix */
.ui-helper-zfix { width: 100%; height: 100%; top: 0; left: 0; position: absolute; opacity: 0; filter:Alpha(Opacity=0); }
/* Interaction Cues
----------------------------------*/
.ui-state-disabled { cursor: default !important; }
/* Icons
----------------------------------*/
/* states and images */
.ui-icon { display: block; text-indent: -99999px; overflow: hidden; background-repeat: no-repeat; }
/* Misc visuals
----------------------------------*/
/* Overlays */
.ui-widget-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
/*
* jQuery UI CSS Framework @VERSION
*
* Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Theming/API
*
* To view and modify this theme, visit http://jqueryui.com/themeroller/?ffDefault=Verdana,Arial,sans-serif&fwDefault=normal&fsDefault=1.1em&cornerRadius=4px&bgColorHeader=cccccc&bgTextureHeader=03_highlight_soft.png&bgImgOpacityHeader=75&borderColorHeader=aaaaaa&fcHeader=222222&iconColorHeader=222222&bgColorContent=ffffff&bgTextureContent=01_flat.png&bgImgOpacityContent=75&borderColorContent=aaaaaa&fcContent=222222&iconColorContent=222222&bgColorDefault=e6e6e6&bgTextureDefault=02_glass.png&bgImgOpacityDefault=75&borderColorDefault=d3d3d3&fcDefault=555555&iconColorDefault=888888&bgColorHover=dadada&bgTextureHover=02_glass.png&bgImgOpacityHover=75&borderColorHover=999999&fcHover=212121&iconColorHover=454545&bgColorActive=ffffff&bgTextureActive=02_glass.png&bgImgOpacityActive=65&borderColorActive=aaaaaa&fcActive=212121&iconColorActive=454545&bgColorHighlight=fbf9ee&bgTextureHighlight=02_glass.png&bgImgOpacityHighlight=55&borderColorHighlight=fcefa1&fcHighlight=363636&iconColorHighlight=2e83ff&bgColorError=fef1ec&bgTextureError=02_glass.png&bgImgOpacityError=95&borderColorError=cd0a0a&fcError=cd0a0a&iconColorError=cd0a0a&bgColorOverlay=aaaaaa&bgTextureOverlay=01_flat.png&bgImgOpacityOverlay=0&opacityOverlay=30&bgColorShadow=aaaaaa&bgTextureShadow=01_flat.png&bgImgOpacityShadow=0&opacityShadow=30&thicknessShadow=8px&offsetTopShadow=-8px&offsetLeftShadow=-8px&cornerRadiusShadow=8px
*/
/* Component containers
----------------------------------*/
.ui-widget { font-family: Verdana,Arial,sans-serif; font-size: 1.1em; }
.ui-widget .ui-widget { font-size: 1em; }
.ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button { font-family: Verdana,Arial,sans-serif; font-size: 1em; }
.ui-widget-content { border: 1px solid #aaaaaa; background: #ffffff url(images/ui-bg_flat_75_ffffff_40x100.png) 50% 50% repeat-x; color: #222222; }
.ui-widget-content a { color: #222222; }
.ui-widget-header { border: 1px solid #aaaaaa; background: #cccccc url(images/ui-bg_highlight-soft_75_cccccc_1x100.png) 50% 50% repeat-x; color: #222222; font-weight: bold; }
.ui-widget-header a { color: #222222; }
/* Interaction states
----------------------------------*/
.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #d3d3d3; background: #e6e6e6 url(images/ui-bg_glass_75_e6e6e6_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #555555; }
.ui-state-default a, .ui-state-default a:link, .ui-state-default a:visited { color: #555555; text-decoration: none; }
.ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { border: 1px solid #999999; background: #dadada url(images/ui-bg_glass_75_dadada_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #212121; }
.ui-state-hover a, .ui-state-hover a:hover { color: #212121; text-decoration: none; }
.ui-state-active, .ui-widget-content .ui-state-active, .ui-widget-header .ui-state-active { border: 1px solid #aaaaaa; background: #ffffff url(images/ui-bg_glass_65_ffffff_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #212121; }
.ui-state-active a, .ui-state-active a:link, .ui-state-active a:visited { color: #212121; text-decoration: none; }
.ui-widget :active { outline: none; }
/* Interaction Cues
----------------------------------*/
.ui-state-highlight, .ui-widget-content .ui-state-highlight, .ui-widget-header .ui-state-highlight {border: 1px solid #fcefa1; background: #fbf9ee url(images/ui-bg_glass_55_fbf9ee_1x400.png) 50% 50% repeat-x; color: #363636; }
.ui-state-highlight a, .ui-widget-content .ui-state-highlight a,.ui-widget-header .ui-state-highlight a { color: #363636; }
.ui-state-error, .ui-widget-content .ui-state-error, .ui-widget-header .ui-state-error {border: 1px solid #cd0a0a; background: #fef1ec url(images/ui-bg_glass_95_fef1ec_1x400.png) 50% 50% repeat-x; color: #cd0a0a; }
.ui-state-error a, .ui-widget-content .ui-state-error a, .ui-widget-header .ui-state-error a { color: #cd0a0a; }
.ui-state-error-text, .ui-widget-content .ui-state-error-text, .ui-widget-header .ui-state-error-text { color: #cd0a0a; }
.ui-priority-primary, .ui-widget-content .ui-priority-primary, .ui-widget-header .ui-priority-primary { font-weight: bold; }
.ui-priority-secondary, .ui-widget-content .ui-priority-secondary, .ui-widget-header .ui-priority-secondary { opacity: .7; filter:Alpha(Opacity=70); font-weight: normal; }
.ui-state-disabled, .ui-widget-content .ui-state-disabled, .ui-widget-header .ui-state-disabled { opacity: .35; filter:Alpha(Opacity=35); background-image: none; }
/* Icons
----------------------------------*/
/* states and images */
.ui-icon { width: 16px; height: 16px; background-image: url(images/ui-icons_222222_256x240.png); }
.ui-widget-content .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); }
.ui-widget-header .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); }
.ui-state-default .ui-icon { background-image: url(images/ui-icons_888888_256x240.png); }
.ui-state-hover .ui-icon, .ui-state-focus .ui-icon {background-image: url(images/ui-icons_454545_256x240.png); }
.ui-state-active .ui-icon {background-image: url(images/ui-icons_454545_256x240.png); }
.ui-state-highlight .ui-icon {background-image: url(images/ui-icons_2e83ff_256x240.png); }
.ui-state-error .ui-icon, .ui-state-error-text .ui-icon {background-image: url(images/ui-icons_cd0a0a_256x240.png); }
/* positioning */
.ui-icon-carat-1-n { background-position: 0 0; }
.ui-icon-carat-1-ne { background-position: -16px 0; }
.ui-icon-carat-1-e { background-position: -32px 0; }
.ui-icon-carat-1-se { background-position: -48px 0; }
.ui-icon-carat-1-s { background-position: -64px 0; }
.ui-icon-carat-1-sw { background-position: -80px 0; }
.ui-icon-carat-1-w { background-position: -96px 0; }
.ui-icon-carat-1-nw { background-position: -112px 0; }
.ui-icon-carat-2-n-s { background-position: -128px 0; }
.ui-icon-carat-2-e-w { background-position: -144px 0; }
.ui-icon-triangle-1-n { background-position: 0 -16px; }
.ui-icon-triangle-1-ne { background-position: -16px -16px; }
.ui-icon-triangle-1-e { background-position: -32px -16px; }
.ui-icon-triangle-1-se { background-position: -48px -16px; }
.ui-icon-triangle-1-s { background-position: -64px -16px; }
.ui-icon-triangle-1-sw { background-position: -80px -16px; }
.ui-icon-triangle-1-w { background-position: -96px -16px; }
.ui-icon-triangle-1-nw { background-position: -112px -16px; }
.ui-icon-triangle-2-n-s { background-position: -128px -16px; }
.ui-icon-triangle-2-e-w { background-position: -144px -16px; }
.ui-icon-arrow-1-n { background-position: 0 -32px; }
.ui-icon-arrow-1-ne { background-position: -16px -32px; }
.ui-icon-arrow-1-e { background-position: -32px -32px; }
.ui-icon-arrow-1-se { background-position: -48px -32px; }
.ui-icon-arrow-1-s { background-position: -64px -32px; }
.ui-icon-arrow-1-sw { background-position: -80px -32px; }
.ui-icon-arrow-1-w { background-position: -96px -32px; }
.ui-icon-arrow-1-nw { background-position: -112px -32px; }
.ui-icon-arrow-2-n-s { background-position: -128px -32px; }
.ui-icon-arrow-2-ne-sw { background-position: -144px -32px; }
.ui-icon-arrow-2-e-w { background-position: -160px -32px; }
.ui-icon-arrow-2-se-nw { background-position: -176px -32px; }
.ui-icon-arrowstop-1-n { background-position: -192px -32px; }
.ui-icon-arrowstop-1-e { background-position: -208px -32px; }
.ui-icon-arrowstop-1-s { background-position: -224px -32px; }
.ui-icon-arrowstop-1-w { background-position: -240px -32px; }
.ui-icon-arrowthick-1-n { background-position: 0 -48px; }
.ui-icon-arrowthick-1-ne { background-position: -16px -48px; }
.ui-icon-arrowthick-1-e { background-position: -32px -48px; }
.ui-icon-arrowthick-1-se { background-position: -48px -48px; }
.ui-icon-arrowthick-1-s { background-position: -64px -48px; }
.ui-icon-arrowthick-1-sw { background-position: -80px -48px; }
.ui-icon-arrowthick-1-w { background-position: -96px -48px; }
.ui-icon-arrowthick-1-nw { background-position: -112px -48px; }
.ui-icon-arrowthick-2-n-s { background-position: -128px -48px; }
.ui-icon-arrowthick-2-ne-sw { background-position: -144px -48px; }
.ui-icon-arrowthick-2-e-w { background-position: -160px -48px; }
.ui-icon-arrowthick-2-se-nw { background-position: -176px -48px; }
.ui-icon-arrowthickstop-1-n { background-position: -192px -48px; }
.ui-icon-arrowthickstop-1-e { background-position: -208px -48px; }
.ui-icon-arrowthickstop-1-s { background-position: -224px -48px; }
.ui-icon-arrowthickstop-1-w { background-position: -240px -48px; }
.ui-icon-arrowreturnthick-1-w { background-position: 0 -64px; }
.ui-icon-arrowreturnthick-1-n { background-position: -16px -64px; }
.ui-icon-arrowreturnthick-1-e { background-position: -32px -64px; }
.ui-icon-arrowreturnthick-1-s { background-position: -48px -64px; }
.ui-icon-arrowreturn-1-w { background-position: -64px -64px; }
.ui-icon-arrowreturn-1-n { background-position: -80px -64px; }
.ui-icon-arrowreturn-1-e { background-position: -96px -64px; }
.ui-icon-arrowreturn-1-s { background-position: -112px -64px; }
.ui-icon-arrowrefresh-1-w { background-position: -128px -64px; }
.ui-icon-arrowrefresh-1-n { background-position: -144px -64px; }
.ui-icon-arrowrefresh-1-e { background-position: -160px -64px; }
.ui-icon-arrowrefresh-1-s { background-position: -176px -64px; }
.ui-icon-arrow-4 { background-position: 0 -80px; }
.ui-icon-arrow-4-diag { background-position: -16px -80px; }
.ui-icon-extlink { background-position: -32px -80px; }
.ui-icon-newwin { background-position: -48px -80px; }
.ui-icon-refresh { background-position: -64px -80px; }
.ui-icon-shuffle { background-position: -80px -80px; }
.ui-icon-transfer-e-w { background-position: -96px -80px; }
.ui-icon-transferthick-e-w { background-position: -112px -80px; }
.ui-icon-folder-collapsed { background-position: 0 -96px; }
.ui-icon-folder-open { background-position: -16px -96px; }
.ui-icon-document { background-position: -32px -96px; }
.ui-icon-document-b { background-position: -48px -96px; }
.ui-icon-note { background-position: -64px -96px; }
.ui-icon-mail-closed { background-position: -80px -96px; }
.ui-icon-mail-open { background-position: -96px -96px; }
.ui-icon-suitcase { background-position: -112px -96px; }
.ui-icon-comment { background-position: -128px -96px; }
.ui-icon-person { background-position: -144px -96px; }
.ui-icon-print { background-position: -160px -96px; }
.ui-icon-trash { background-position: -176px -96px; }
.ui-icon-locked { background-position: -192px -96px; }
.ui-icon-unlocked { background-position: -208px -96px; }
.ui-icon-bookmark { background-position: -224px -96px; }
.ui-icon-tag { background-position: -240px -96px; }
.ui-icon-home { background-position: 0 -112px; }
.ui-icon-flag { background-position: -16px -112px; }
.ui-icon-calendar { background-position: -32px -112px; }
.ui-icon-cart { background-position: -48px -112px; }
.ui-icon-pencil { background-position: -64px -112px; }
.ui-icon-clock { background-position: -80px -112px; }
.ui-icon-disk { background-position: -96px -112px; }
.ui-icon-calculator { background-position: -112px -112px; }
.ui-icon-zoomin { background-position: -128px -112px; }
.ui-icon-zoomout { background-position: -144px -112px; }
.ui-icon-search { background-position: -160px -112px; }
.ui-icon-wrench { background-position: -176px -112px; }
.ui-icon-gear { background-position: -192px -112px; }
.ui-icon-heart { background-position: -208px -112px; }
.ui-icon-star { background-position: -224px -112px; }
.ui-icon-link { background-position: -240px -112px; }
.ui-icon-cancel { background-position: 0 -128px; }
.ui-icon-plus { background-position: -16px -128px; }
.ui-icon-plusthick { background-position: -32px -128px; }
.ui-icon-minus { background-position: -48px -128px; }
.ui-icon-minusthick { background-position: -64px -128px; }
.ui-icon-close { background-position: -80px -128px; }
.ui-icon-closethick { background-position: -96px -128px; }
.ui-icon-key { background-position: -112px -128px; }
.ui-icon-lightbulb { background-position: -128px -128px; }
.ui-icon-scissors { background-position: -144px -128px; }
.ui-icon-clipboard { background-position: -160px -128px; }
.ui-icon-copy { background-position: -176px -128px; }
.ui-icon-contact { background-position: -192px -128px; }
.ui-icon-image { background-position: -208px -128px; }
.ui-icon-video { background-position: -224px -128px; }
.ui-icon-script { background-position: -240px -128px; }
.ui-icon-alert { background-position: 0 -144px; }
.ui-icon-info { background-position: -16px -144px; }
.ui-icon-notice { background-position: -32px -144px; }
.ui-icon-help { background-position: -48px -144px; }
.ui-icon-check { background-position: -64px -144px; }
.ui-icon-bullet { background-position: -80px -144px; }
.ui-icon-radio-off { background-position: -96px -144px; }
.ui-icon-radio-on { background-position: -112px -144px; }
.ui-icon-pin-w { background-position: -128px -144px; }
.ui-icon-pin-s { background-position: -144px -144px; }
.ui-icon-play { background-position: 0 -160px; }
.ui-icon-pause { background-position: -16px -160px; }
.ui-icon-seek-next { background-position: -32px -160px; }
.ui-icon-seek-prev { background-position: -48px -160px; }
.ui-icon-seek-end { background-position: -64px -160px; }
.ui-icon-seek-start { background-position: -80px -160px; }
/* ui-icon-seek-first is deprecated, use ui-icon-seek-start instead */
.ui-icon-seek-first { background-position: -80px -160px; }
.ui-icon-stop { background-position: -96px -160px; }
.ui-icon-eject { background-position: -112px -160px; }
.ui-icon-volume-off { background-position: -128px -160px; }
.ui-icon-volume-on { background-position: -144px -160px; }
.ui-icon-power { background-position: 0 -176px; }
.ui-icon-signal-diag { background-position: -16px -176px; }
.ui-icon-signal { background-position: -32px -176px; }
.ui-icon-battery-0 { background-position: -48px -176px; }
.ui-icon-battery-1 { background-position: -64px -176px; }
.ui-icon-battery-2 { background-position: -80px -176px; }
.ui-icon-battery-3 { background-position: -96px -176px; }
.ui-icon-circle-plus { background-position: 0 -192px; }
.ui-icon-circle-minus { background-position: -16px -192px; }
.ui-icon-circle-close { background-position: -32px -192px; }
.ui-icon-circle-triangle-e { background-position: -48px -192px; }
.ui-icon-circle-triangle-s { background-position: -64px -192px; }
.ui-icon-circle-triangle-w { background-position: -80px -192px; }
.ui-icon-circle-triangle-n { background-position: -96px -192px; }
.ui-icon-circle-arrow-e { background-position: -112px -192px; }
.ui-icon-circle-arrow-s { background-position: -128px -192px; }
.ui-icon-circle-arrow-w { background-position: -144px -192px; }
.ui-icon-circle-arrow-n { background-position: -160px -192px; }
.ui-icon-circle-zoomin { background-position: -176px -192px; }
.ui-icon-circle-zoomout { background-position: -192px -192px; }
.ui-icon-circle-check { background-position: -208px -192px; }
.ui-icon-circlesmall-plus { background-position: 0 -208px; }
.ui-icon-circlesmall-minus { background-position: -16px -208px; }
.ui-icon-circlesmall-close { background-position: -32px -208px; }
.ui-icon-squaresmall-plus { background-position: -48px -208px; }
.ui-icon-squaresmall-minus { background-position: -64px -208px; }
.ui-icon-squaresmall-close { background-position: -80px -208px; }
.ui-icon-grip-dotted-vertical { background-position: 0 -224px; }
.ui-icon-grip-dotted-horizontal { background-position: -16px -224px; }
.ui-icon-grip-solid-vertical { background-position: -32px -224px; }
.ui-icon-grip-solid-horizontal { background-position: -48px -224px; }
.ui-icon-gripsmall-diagonal-se { background-position: -64px -224px; }
.ui-icon-grip-diagonal-se { background-position: -80px -224px; }
/* Misc visuals
----------------------------------*/
/* Corner radius */
.ui-corner-tl { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; border-top-left-radius: 4px; }
.ui-corner-tr { -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; border-top-right-radius: 4px; }
.ui-corner-bl { -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; border-bottom-left-radius: 4px; }
.ui-corner-br { -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; border-bottom-right-radius: 4px; }
.ui-corner-top { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; border-top-left-radius: 4px; -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; border-top-right-radius: 4px; }
.ui-corner-bottom { -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; border-bottom-left-radius: 4px; -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; border-bottom-right-radius: 4px; }
.ui-corner-right { -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; border-top-right-radius: 4px; -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; border-bottom-right-radius: 4px; }
.ui-corner-left { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; border-top-left-radius: 4px; -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; border-bottom-left-radius: 4px; }
.ui-corner-all { -moz-border-radius: 4px; -webkit-border-radius: 4px; border-radius: 4px; }
/* Overlays */
.ui-widget-overlay { background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .30;filter:Alpha(Opacity=30); }
.ui-widget-shadow { margin: -8px 0 0 -8px; padding: 8px; background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .30;filter:Alpha(Opacity=30); -moz-border-radius: 8px; -webkit-border-radius: 8px; border-radius: 8px; }
/*
ColorBox Core Style:
The following CSS is consistent between example themes and should not be altered.
*/
#colorbox, #cboxOverlay, #cboxWrapper{position:absolute; top:0; left:0; z-index:9999; overflow:hidden;}
#cboxOverlay{position:fixed; width:100%; height:100%;}
#cboxMiddleLeft, #cboxBottomLeft{clear:left;}
#cboxContent{position:relative;}
#cboxLoadedContent{overflow:auto;}
#cboxTitle{margin:0;}
#cboxLoadingOverlay, #cboxLoadingGraphic{position:absolute; top:0; left:0; width:100%; height:100%;}
#cboxPrevious, #cboxNext, #cboxClose, #cboxSlideshow{cursor:pointer;}
.cboxPhoto{float:left; margin:auto; border:0; display:block; max-width:none;}
.cboxIframe{width:100%; height:100%; display:block; border:0;}
#colorbox, #cboxContent, #cboxLoadedContent{box-sizing:content-box;}
/*
User Style:
Change the following styles to modify the appearance of ColorBox. They are
ordered & tabbed in a way that represents the nesting of the generated HTML.
*/
#cboxOverlay{background:#000;}
#colorbox{}
#cboxTopLeft{width:14px; height:14px; background:url(colorbox/controls.png) no-repeat 0 0;}
#cboxTopCenter{height:14px; background:url(colorbox/border.png) repeat-x top left;}
#cboxTopRight{width:14px; height:14px; background:url(colorbox/controls.png) no-repeat -36px 0;}
#cboxBottomLeft{width:14px; height:43px; background:url(colorbox/controls.png) no-repeat 0 -32px;}
#cboxBottomCenter{height:43px; background:url(colorbox/border.png) repeat-x bottom left;}
#cboxBottomRight{width:14px; height:43px; background:url(colorbox/controls.png) no-repeat -36px -32px;}
#cboxMiddleLeft{width:14px; background:url(colorbox/controls.png) repeat-y -175px 0;}
#cboxMiddleRight{width:14px; background:url(colorbox/controls.png) repeat-y -211px 0;}
#cboxContent{background:#fff; overflow:visible;}
.cboxIframe{background:#fff;}
#cboxError{padding:50px; border:1px solid #ccc;}
#cboxLoadedContent{margin-bottom:5px;}
#cboxLoadingOverlay{background:url(colorbox/loading_background.png) no-repeat center center;}
#cboxLoadingGraphic{background:url(colorbox/loading.gif) no-repeat center center;}
#cboxTitle{position:absolute; bottom:-25px; left:0; text-align:center; width:100%; font-weight:bold; color:#7C7C7C;}
#cboxCurrent{position:absolute; bottom:-25px; left:58px; font-weight:bold; color:#7C7C7C;}
#cboxPrevious, #cboxNext, #cboxClose, #cboxSlideshow{position:absolute; bottom:-29px; background:url(colorbox/controls.png) no-repeat 0px 0px; width:23px; height:23px; text-indent:-9999px;}
#cboxPrevious{left:0px; background-position: -51px -25px;}
#cboxPrevious:hover{background-position:-51px 0px;}
#cboxNext{left:27px; background-position:-75px -25px;}
#cboxNext:hover{background-position:-75px 0px;}
#cboxClose{right:0; background-position:-100px -25px;}
#cboxClose:hover{background-position:-100px 0px;}
.cboxSlideshow_on #cboxSlideshow{background-position:-125px 0px; right:27px;}
.cboxSlideshow_on #cboxSlideshow:hover{background-position:-150px 0px;}
.cboxSlideshow_off #cboxSlideshow{background-position:-150px -25px; right:27px;}
.cboxSlideshow_off #cboxSlideshow:hover{background-position:-125px 0px;}
#loading {
position: fixed;
left: 40%;
top: 50%; }
a {
color: #333333;
text-decoration: none; }
a:hover {
color: black;
text-decoration: underline; }
body {
font-family: "Lucida Grande", Helvetica, "Helvetica Neue", Arial, sans-serif;
padding: 12px;
background-color: #333333; }
h1, h2, h3, h4 {
color: #1c2324;
margin: 0;
padding: 0;
margin-bottom: 12px; }
table {
width: 100%; }
#content {
clear: left;
background-color: white;
border: 2px solid #dddddd;
border-top: 8px solid #dddddd;
padding: 18px;
-webkit-border-bottom-left-radius: 5px;
-webkit-border-bottom-right-radius: 5px;
-webkit-border-top-right-radius: 5px;
-moz-border-radius-bottomleft: 5px;
-moz-border-radius-bottomright: 5px;
-moz-border-radius-topright: 5px;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
border-top-right-radius: 5px; }
.dataTables_filter, .dataTables_info {
padding: 2px 6px; }
abbr.timeago {
text-decoration: none;
border: none;
font-weight: bold; }
.timestamp {
float: right;
color: #dddddd; }
.group_tabs {
list-style: none;
float: left;
margin: 0;
padding: 0; }
.group_tabs li {
display: inline;
float: left; }
.group_tabs li a {
font-family: Helvetica, Arial, sans-serif;
display: block;
float: left;
text-decoration: none;
padding: 4px 8px;
background-color: #aaaaaa;
background: -webkit-gradient(linear, 0 0, 0 bottom, from(#dddddd), to(#aaaaaa));
background: -moz-linear-gradient(#dddddd, #aaaaaa);
background: linear-gradient(#dddddd, #aaaaaa);
text-shadow: #e5e5e5 1px 1px 0px;
border-bottom: none;
color: #333333;
font-weight: bold;
margin-right: 8px;
border-top: 1px solid #efefef;
-webkit-border-top-left-radius: 2px;
-webkit-border-top-right-radius: 2px;
-moz-border-radius-topleft: 2px;
-moz-border-radius-topright: 2px;
border-top-left-radius: 2px;
border-top-right-radius: 2px; }
.group_tabs li a:hover {
background-color: #cccccc;
background: -webkit-gradient(linear, 0 0, 0 bottom, from(#eeeeee), to(#aaaaaa));
background: -moz-linear-gradient(#eeeeee, #aaaaaa);
background: linear-gradient(#eeeeee, #aaaaaa); }
.group_tabs li a:active {
padding-top: 5px;
padding-bottom: 3px; }
.group_tabs li.active a {
color: black;
text-shadow: white 1px 1px 0px;
background-color: #dddddd;
background: -webkit-gradient(linear, 0 0, 0 bottom, from(white), to(#dddddd));
background: -moz-linear-gradient(white, #dddddd);
background: linear-gradient(white, #dddddd); }
.file_list {
margin-bottom: 18px; }
a.src_link {
background: url("./magnify.png") no-repeat left 50%;
padding-left: 18px; }
tr, td {
margin: 0;
padding: 0; }
th {
white-space: nowrap; }
th.ui-state-default {
cursor: pointer; }
th span.ui-icon {
float: left; }
td {
padding: 4px 8px; }
td.strong {
font-weight: bold; }
.source_table h3, .source_table h4 {
padding: 0;
margin: 0;
margin-bottom: 4px; }
.source_table .header {
padding: 10px; }
.source_table pre {
margin: 0;
padding: 0;
white-space: normal;
color: black;
font-family: "Monaco", "Inconsolata", "Consolas", monospace; }
.source_table code {
color: black;
font-family: "Monaco", "Inconsolata", "Consolas", monospace; }
.source_table pre {
background-color: #333333; }
.source_table pre ol {
margin: 0px;
padding: 0px;
margin-left: 45px;
font-size: 12px;
color: white; }
.source_table pre li {
margin: 0px;
padding: 2px 6px;
border-left: 5px solid white; }
.source_table pre li code {
white-space: pre;
white-space: pre-wrap; }
.source_table pre .hits {
float: right;
margin-left: 10px;
padding: 2px 4px;
background-color: #444444;
background: -webkit-gradient(linear, 0 0, 0 bottom, from(#222222), to(#666666));
background: -moz-linear-gradient(#222222, #666666);
background: linear-gradient(#222222, #666666);
color: white;
font-family: Helvetica, "Helvetica Neue", Arial, sans-serif;
font-size: 10px;
font-weight: bold;
text-align: center;
border-radius: 6px; }
#footer {
color: #dddddd;
font-size: 12px;
font-weight: bold;
margin-top: 12px;
text-align: right; }
#footer a {
color: #eeeeee;
text-decoration: underline; }
#footer a:hover {
color: white;
text-decoration: none; }
.green {
color: #009900; }
.red {
color: #990000; }
.yellow {
color: #ddaa00; }
.source_table .covered {
border-color: #009900; }
.source_table .missed {
border-color: #990000; }
.source_table .never {
border-color: black; }
.source_table .skipped {
border-color: #ffcc00; }
.source_table .covered:nth-child(odd) {
background-color: #cdf2cd; }
.source_table .covered:nth-child(even) {
background-color: #dbf2db; }
.source_table .missed:nth-child(odd) {
background-color: #f7c0c0; }
.source_table .missed:nth-child(even) {
background-color: #f7cfcf; }
.source_table .never:nth-child(odd) {
background-color: #efefef; }
.source_table .never:nth-child(even) {
background-color: #f4f4f4; }
.source_table .skipped:nth-child(odd) {
background-color: #fbf0c0; }
.source_table .skipped:nth-child(even) {
background-color: #fbffcf; }

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

1605
coverage/index.html Normal file

File diff suppressed because it is too large Load Diff

26
lib/grammar.y Normal file
View File

@@ -0,0 +1,26 @@
class Query
prechigh
left OPERATOR_NOT
left OPERATOR_AND
left OPERATOR_OR
preclow
rule
target: expression
| /* none */ { result = 0 }
expression: TERM_WITHOUT_QUOTES { result = {:DEFAULT_COLUMN => val[0]} }
| TERM_WITH_QUOTES { result = {:DEFAULT_COLUMN => val[0]} }
| TERM_WITHOUT_QUOTES COLON TERM_WITHOUT_QUOTES { result = {val[0] => val[2]} }
| TERM_WITHOUT_QUOTES COLON TERM_WITH_QUOTES { result = {val[0] => val[2]} }
| expression OPERATOR_OR expression { result = {:OPERATOR_OR => [val[0], val[2]]} }
| expression OPERATOR_AND expression { result = {:OPERATOR_AND => [val[0], val[2]]} }
| L_BRACKET expression R_BRACKET { result = val[1] }
end
---- header
require_relative 'lexer'
---- inner
def parse(input)
scan_str(input)
end

4
lib/parser-parser-part/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.idea
lexer.rb
parser.rb

View File

@@ -0,0 +1,19 @@
# parser
### Prerequisites
* Rexical (rex)
* Racc
### Available commands
* `rake lexer` - generates `lexer.rb` file based on `specification.rex` file
* `rake parser` - generates `parser.rb` file based on `grammar.y` file
* `rake generate` - generates `lexer.rb` and `parser.rb` files
### Testing
To run only `lexer` tests, execute : `rspec spec/query_lexer_spec.rb`
To run only `parser` tests, execute : `rspec spec/query_parser_spec.rb`
To run all tests, execute : `rake spec`

View File

@@ -0,0 +1,20 @@
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new do |c|
options = ['--color']
options += %w[--format documentation]
c.rspec_opts = options
end
desc 'Generate Lexer'
task :lexer do
`rex specification.rex -o lexer.rb`
end
desc 'Generate Parser'
task :parser do
`racc grammar.y -o parser.rb`
end
desc 'Generate Lexer and Parser'
task generate: %i[lexer parser]

View File

@@ -0,0 +1,26 @@
class Query
prechigh
left OPERATOR_NOT
left OPERATOR_AND
left OPERATOR_OR
preclow
rule
target: expression
| /* none */ { result = 0 }
expression: TERM_WITHOUT_QUOTES { result = {:DEFAULT_COLUMN => val[0]} }
| TERM_WITH_QUOTES { result = {:DEFAULT_COLUMN => val[0]} }
| TERM_WITHOUT_QUOTES COLON TERM_WITHOUT_QUOTES { result = {val[0] => val[2]} }
| TERM_WITHOUT_QUOTES COLON TERM_WITH_QUOTES { result = {val[0] => val[2]} }
| expression OPERATOR_OR expression { result = {:OPERATOR_OR => [val[0], val[2]]} }
| expression OPERATOR_AND expression { result = {:OPERATOR_AND => [val[0], val[2]]} }
| L_BRACKET expression R_BRACKET { result = val[1] }
end
---- header
require_relative 'lexer'
---- inner
def parse(input)
scan_str(input)
end

View File

@@ -0,0 +1,259 @@
require './lexer'
class QueryLexerTester
describe 'Testing the Lexer' do
before do
@evaluator = Query.new
end
it 'tests bracket expression' do
@result = @evaluator.tokenize('()')
expect(@result.length).to eq 2
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :R_BRACKET
end
it 'tests bracket expression with spaces' do
@result = @evaluator.tokenize(' ( ) ')
expect(@result.length).to eq 2
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :R_BRACKET
end
it 'tests expression with OR operator' do
@result = @evaluator.tokenize('() or () OR ()')
expect(@result.length).to eq 8
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :R_BRACKET
expect(@result[2][0]).to eq :OPERATOR_OR
expect(@result[3][0]).to eq :L_BRACKET
expect(@result[4][0]).to eq :R_BRACKET
expect(@result[5][0]).to eq :OPERATOR_OR
expect(@result[6][0]).to eq :L_BRACKET
expect(@result[7][0]).to eq :R_BRACKET
end
it 'tests expression with AND operator' do
@result = @evaluator.tokenize('() AND () and ()')
expect(@result.length).to eq 8
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :R_BRACKET
expect(@result[2][0]).to eq :OPERATOR_AND
expect(@result[3][0]).to eq :L_BRACKET
expect(@result[4][0]).to eq :R_BRACKET
expect(@result[5][0]).to eq :OPERATOR_AND
expect(@result[6][0]).to eq :L_BRACKET
expect(@result[7][0]).to eq :R_BRACKET
end
it 'tests expression with NOT OR and NOT AND operator' do
@result = @evaluator.tokenize('() NOT or () not AND ()')
expect(@result.length).to eq 10
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :R_BRACKET
expect(@result[2][0]).to eq :OPERATOR_NOT
expect(@result[3][0]).to eq :OPERATOR_OR
expect(@result[4][0]).to eq :L_BRACKET
expect(@result[5][0]).to eq :R_BRACKET
expect(@result[6][0]).to eq :OPERATOR_NOT
expect(@result[7][0]).to eq :OPERATOR_AND
expect(@result[8][0]).to eq :L_BRACKET
expect(@result[9][0]).to eq :R_BRACKET
end
it 'tests search term under quotes' do
@result = @evaluator.tokenize('"123-456"')
expect(@result.length).to eq 1
expect(@result[0][0]).to eq :TERM_WITH_QUOTES
expect(@result[0][1]).to eq '"123-456"'
end
it 'tests term without quotes' do
@result = @evaluator.tokenize('device_id')
expect(@result.length).to eq 1
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq 'device_id'
end
it 'tests integer term without quotes' do
@result = @evaluator.tokenize('123')
expect(@result.length).to eq 1
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq '123'
end
it 'tests multiple terms without quotes' do
@result = @evaluator.tokenize('device_id tag 123-456 name123')
expect(@result.length).to eq 4
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq 'device_id'
expect(@result[1][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[1][1]).to eq 'tag'
expect(@result[2][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[2][1]).to eq '123-456'
expect(@result[3][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[3][1]).to eq 'name123'
end
it 'tests simple query with column name and search term without quotes' do
@result = @evaluator.tokenize('name:JF')
expect(@result.length).to eq 3
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq 'name'
expect(@result[1][0]).to eq :COLON
expect(@result[2][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[2][1]).to eq 'JF'
end
it 'tests simple query with two columns with name and search terms without quotes' do
@result = @evaluator.tokenize('name:JF tag:mta')
expect(@result.length).to eq 6
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq 'name'
expect(@result[1][0]).to eq :COLON
expect(@result[2][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[2][1]).to eq 'JF'
expect(@result[3][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[3][1]).to eq 'tag'
expect(@result[4][0]).to eq :COLON
expect(@result[5][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[5][1]).to eq 'mta'
end
it 'tests simple query with column name and search term with quotes' do
@result = @evaluator.tokenize('name:"name with space"')
expect(@result.length).to eq 3
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq 'name'
expect(@result[1][0]).to eq :COLON
expect(@result[2][0]).to eq :TERM_WITH_QUOTES
expect(@result[2][1]).to eq '"name with space"'
end
it 'tests search term with quotes containing non alphanumerical characters' do
@result = @evaluator.tokenize('"|*|/\()#-!=<>&$"')
expect(@result.length).to eq 1
expect(@result[0][0]).to eq :TERM_WITH_QUOTES
expect(@result[0][1]).to eq '"|*|/\()#-!=<>&$"'
end
it 'tests simple query in brackets' do
@result = @evaluator.tokenize('(name:"name with space")')
expect(@result.length).to eq 5
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[1][1]).to eq 'name'
expect(@result[2][0]).to eq :COLON
expect(@result[3][0]).to eq :TERM_WITH_QUOTES
expect(@result[3][1]).to eq '"name with space"'
expect(@result[4][0]).to eq :R_BRACKET
end
it 'tests multiple query wtih brackets' do
@result = @evaluator.tokenize('(name:"name with space") or (tag:mta)')
expect(@result.length).to eq 11
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[1][1]).to eq 'name'
expect(@result[2][0]).to eq :COLON
expect(@result[3][0]).to eq :TERM_WITH_QUOTES
expect(@result[3][1]).to eq '"name with space"'
expect(@result[4][0]).to eq :R_BRACKET
expect(@result[5][0]).to eq :OPERATOR_OR
expect(@result[6][0]).to eq :L_BRACKET
expect(@result[7][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[7][1]).to eq 'tag'
expect(@result[8][0]).to eq :COLON
expect(@result[9][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[9][1]).to eq 'mta'
expect(@result[10][0]).to eq :R_BRACKET
end
it 'tests complex query' do
@result = @evaluator.tokenize('(device-id:"with space" tag:mta no-quotes-id-123)'\
'or "id with quotes-5" and ( ("id with q 10" or "id with q 20")'\
'and ("id with Q 30" "id with Q 40") and not id-without-Q-50)')
expect(@result.length).to eq 27
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[1][1]).to eq 'device-id'
expect(@result[2][0]).to eq :COLON
expect(@result[3][0]).to eq :TERM_WITH_QUOTES
expect(@result[3][1]).to eq '"with space"'
expect(@result[4][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[4][1]).to eq 'tag'
expect(@result[5][0]).to eq :COLON
expect(@result[6][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[6][1]).to eq 'mta'
expect(@result[7][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[7][1]).to eq 'no-quotes-id-123'
expect(@result[8][0]).to eq :R_BRACKET
expect(@result[9][0]).to eq :OPERATOR_OR
expect(@result[10][0]).to eq :TERM_WITH_QUOTES
expect(@result[10][1]).to eq '"id with quotes-5"'
expect(@result[11][0]).to eq :OPERATOR_AND
expect(@result[12][0]).to eq :L_BRACKET
expect(@result[13][0]).to eq :L_BRACKET
expect(@result[14][0]).to eq :TERM_WITH_QUOTES
expect(@result[14][1]).to eq '"id with q 10"'
expect(@result[15][0]).to eq :OPERATOR_OR
expect(@result[16][0]).to eq :TERM_WITH_QUOTES
expect(@result[16][1]).to eq '"id with q 20"'
expect(@result[17][0]).to eq :R_BRACKET
expect(@result[18][0]).to eq :OPERATOR_AND
expect(@result[19][0]).to eq :L_BRACKET
expect(@result[20][0]).to eq :TERM_WITH_QUOTES
expect(@result[20][1]).to eq '"id with Q 30"'
expect(@result[21][0]).to eq :TERM_WITH_QUOTES
expect(@result[21][1]).to eq '"id with Q 40"'
expect(@result[22][0]).to eq :R_BRACKET
expect(@result[23][0]).to eq :OPERATOR_AND
expect(@result[24][0]).to eq :OPERATOR_NOT
expect(@result[25][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[25][1]).to eq 'id-without-Q-50'
expect(@result[26][0]).to eq :R_BRACKET
end
it 'tests query with -or-, -and- and -not- words inside quoted expression' do
@result = @evaluator.tokenize('tag:"tag with or and not inside"')
expect(@result.length).to eq 3
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq 'tag'
expect(@result[1][0]).to eq :COLON
expect(@result[2][0]).to eq :TERM_WITH_QUOTES
expect(@result[2][1]).to eq '"tag with or and not inside"'
end
end
end

View File

@@ -0,0 +1,164 @@
require './parser'
class QueryParserTester
describe 'Testing the Parser' do
before do
@evaluator = Query.new
end
it 'tests query with only one search term without quotes and without column name' do
@result = @evaluator.parse('-123')
expect(@result[:DEFAULT_COLUMN]).to eq '-123'
end
it 'tests query with only one search term with quotes and without column name' do
@result = @evaluator.parse('"OR 128"')
expect(@result[:DEFAULT_COLUMN]).to eq '"OR 128"'
end
it 'tests query with one column and search term without quotes' do
@result = @evaluator.parse('tag:mta')
expect(@result['tag']).to eq 'mta'
end
it 'tests query with one column and search term with quotes' do
@result = @evaluator.parse('tag:"tag 120"')
expect(@result['tag']).to eq '"tag 120"'
end
it 'tests query with two columns connected with OR and search terms without quotes' do
@result = @evaluator.parse('tag:mta OR tag:12')
@expected_array = [
{ 'tag' => 'mta' },
{ 'tag' => '12' }
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_OR]).to eq @expected_array
end
it 'tests query with two columns connected with OR and search terms with quotes' do
@result = @evaluator.parse('tag:mta OR tag:"tag 12"')
@expected_array = [
{ 'tag' => 'mta' },
{ 'tag' => '"tag 12"' }
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_OR]).to eq @expected_array
end
it 'tests query with two columns connected with AND and search terms without quotes' do
@result = @evaluator.parse('tag:mta AND tag:12')
@expected_array = [
{ 'tag' => 'mta' },
{ 'tag' => '12' }
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_AND]).to eq @expected_array
end
it 'tests query with two columns connected with AND and search terms with quotes' do
@result = @evaluator.parse('tag:mta and tag:"tag 12"')
@expected_array = [
{ 'tag' => 'mta' },
{ 'tag' => '"tag 12"' }
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_AND]).to eq @expected_array
end
it 'tests simple query with brackets' do
@result = @evaluator.parse('(123)')
expect(@result.count).to eq 1
expect(@result[:DEFAULT_COLUMN]).to eq '123'
end
it 'tests simple query with brackets and with a column name' do
@result = @evaluator.parse('(name:JF)')
expect(@result.count).to eq 1
expect(@result['name']).to eq 'JF'
end
it 'tests query with OR operator in brackets' do
@result = @evaluator.parse('(name:JF or tag:mta)')
@expected_array = [
{ 'name' => 'JF' },
{ 'tag' => 'mta' }
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_OR]).to eq @expected_array
end
it 'tests query with two simple brackets expressions' do
@result = @evaluator.parse('(name:JF) and (-456)')
@expected_array = [
{ 'name' => 'JF' },
{ :DEFAULT_COLUMN => '-456' }
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_AND]).to eq @expected_array
end
it 'tests query with two brackets expressions' do
@result = @evaluator.parse('(name:JF or tag:"tag 0") and (-456)')
@expected_array_part_1 = [
{ 'name' => 'JF' },
{ 'tag' => '"tag 0"' }
]
@expected_array_total = [
{:OPERATOR_OR => @expected_array_part_1},
{:DEFAULT_COLUMN => '-456'}
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_AND]).to eq @expected_array_total
end
it 'tests operator precedence' do
@result1 = @evaluator.parse('tag:mta or name:JF and 12_4')
@result2 = @evaluator.parse('tag:mta or (name:JF and 12_4)')
expect(@result1).to eq @result2
expect(@result1.length).to eq 1
@expected_array_part_2 = [
{'name' => 'JF'},
{:DEFAULT_COLUMN => '12_4'}
]
@expected_array_total = [
{'tag' => 'mta'},
{:OPERATOR_AND => @expected_array_part_2}
]
expect(@result1[:OPERATOR_OR]).to eq @expected_array_total
end
# Tests to write :
# * query with multiple column names and search terms without logical operators
# * AND NOT, OR NOT tests
end
end

View File

@@ -0,0 +1,35 @@
class Query
macro
L_BRACKET \(
R_BRACKET \)
SPACE \ + # Space char
OPERATOR_OR (?i)or
OPERATOR_AND (?i)and
OPERATOR_NOT (?i)not
TERM_WITH_QUOTES "([^"]*)"
TERM_WITHOUT_QUOTES [a-zA-Z0-9-_]+
COLON \:
rule
{SPACE} # No action
{L_BRACKET} { return [:L_BRACKET, text] }
{R_BRACKET} { return [:R_BRACKET, text] }
{OPERATOR_OR} { return [:OPERATOR_OR, text] }
{OPERATOR_AND} { return [:OPERATOR_AND, text] }
{OPERATOR_NOT} { return [:OPERATOR_NOT, text] }
{TERM_WITH_QUOTES} { return [:TERM_WITH_QUOTES, text] }
{TERM_WITHOUT_QUOTES} { return [:TERM_WITHOUT_QUOTES, text] }
{COLON} { return [:COLON, text] }
inner
def tokenize(code)
scan_setup(code)
tokens = []
while token = next_token
tokens << token
end
tokens
end
end

View File

@@ -0,0 +1,76 @@
# frozen_string_literal: true
require 'active_support'
require 'squeel'
require_relative './text_to_tsquery'
require_relative './text_to_regex_query'
module PgSearchable
extend ActiveSupport::Concern
included do
def update_pg_search_cache
# kept just for compatibility with pg_searchable
# noop in this implementation
end
end
class_methods do
def pg_search(
fields: [],
fields_mappings: {},
cache: nil,
language: 'english',
scope: 'scope_search',
skip_callback: false,
wildcard: true,
external_cache_data: nil,
joins: [],
default_field: ""
)
@ts_search_fields = fields
@ts_search_fields_mappings = fields_mappings
@ts_cache_field = cache
@ts_language = language
@ts_scope_method = scope
@ts_skip_cache_update = skip_callback
@ts_wildcard = wildcard
@ts_joins = joins
@default_field = (default_field.to_sym || fields.first)
ts_add_scope
end
def ts_add_scope
class_eval do
scope ts_scope_method, ->(value) { ts_search(value) }
end
end
def ts_search(value)
return if @ts_search_fields.blank? || value.blank?
TextToRegexQuery.new(value, @ts_search_fields, @default_field, @ts_search_fields_mappings).where_clause( includes(@ts_joins).references(:all))
end
def should_update_cache_field?
!@ts_skip_cache_update && @ts_cache_field.present?
end
def ts_cache_field
@ts_cache_field
end
def ts_scope_method
@ts_scope_method
end
def ts_cache_method
@ts_cache_method
end
def ts_fields_to_vector(extra_data = [])
field_to_vector = ->(field) { "to_tsvector('#{@ts_language}', coalesce(#{field}::text, ''))" }
data_to_vector = ->(data) { "to_tsvector('#{@ts_language}', '#{data}')" }
(@ts_search_fields.map(&field_to_vector) + extra_data.map(&data_to_vector)).join(' || ')
end
end
end

35
lib/specification.rex Normal file
View File

@@ -0,0 +1,35 @@
class Query
macro
L_BRACKET \(
R_BRACKET \)
SPACE \ + # Space char
OPERATOR_OR (?i)or
OPERATOR_AND (?i)and
OPERATOR_NOT (?i)not
TERM_WITH_QUOTES "([^"]*)"
TERM_WITHOUT_QUOTES [a-zA-Z0-9-_]+
COLON \:
rule
{SPACE} # No action
{L_BRACKET} { return [:L_BRACKET, text] }
{R_BRACKET} { return [:R_BRACKET, text] }
{OPERATOR_OR} { return [:OPERATOR_OR, text] }
{OPERATOR_AND} { return [:OPERATOR_AND, text] }
{OPERATOR_NOT} { return [:OPERATOR_NOT, text] }
{TERM_WITH_QUOTES} { return [:TERM_WITH_QUOTES, text] }
{TERM_WITHOUT_QUOTES} { return [:TERM_WITHOUT_QUOTES, text] }
{COLON} { return [:COLON, text] }
inner
def tokenize(code)
scan_setup(code)
tokens = []
while token = next_token
tokens << token
end
tokens
end
end

View File

@@ -0,0 +1,67 @@
# frozen_string_literal: true
# transforms "english like" text queries into a where clause with regex
# https://www.postgresql.org/docs/9.5/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES
class TextToRegexQuery
def initialize(text, fields, default_field, fields_mappings = {})
@text = text.to_s.strip
@fields = fields.map(&:to_sym)
@default_field = default_field.to_sym
@fields_mappings = fields_mappings.merge(@fields.reduce({}) do |mappings, field|
table_name, field_name = field.to_s.split(".")
mappings[field_name.to_sym] = field
mappings
end)
end
def where_clause(query)
@cleared_text = @text.dup
@column_chunks = []
remove_duplicated_spaces
extract_columns
escape_special_characters
generate_where_clause(query)
end
private
def remove_duplicated_spaces
@cleared_text.gsub!(/\s+/, ' ')
end
def escape_special_characters
@cleared_text.gsub!(/\_/, '\_')
@cleared_text.tr!('\\', '\\')
@cleared_text.gsub!(/%/, '\%')
end
def extract_columns
column_search_term_pairs = @cleared_text.scan(/([A-Za-z0-9_]+:[\w\_-]+)/)
@column_chunks = (column_search_term_pairs.flatten.map do |pair|
column, term = pair.split(':')
next unless @fields_mappings.include?(column.to_sym)
@cleared_text.gsub!(pair, '')
{ @fields_mappings[column.to_sym] => term }
end).compact
unless @cleared_text.strip.empty?
@column_chunks = [{ @default_field.to_s => @cleared_text.strip }] + @column_chunks
end
@column_chunks
end
def generate_where_clause(query)
where_clause = ''
columns = @column_chunks.map { |c| c.keys.first }
values = @column_chunks.map { |c| c.values.first }
columns.each do |column|
quoted_column = '"' + column.to_s.gsub(".",'"."') + '"'
where_clause += "#{quoted_column} ILIKE ? OR "
end
where_clause += " 1<>1 "
regexed_values = values.map { |v| "%#{v}%" }
query.where([where_clause] + regexed_values)
end
end

99
lib/text_to_tsquery.rb Normal file
View File

@@ -0,0 +1,99 @@
# frozen_string_literal: true
# transforms "english like" text queries into a tsquery operation
# https://www.postgresql.org/docs/9.5/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES
class TextToTsquery
attr_reader :text
def initialize(text, wildcard: true)
@text = text.to_s.strip
@wildcard = wildcard
@exact_matches = []
validate!
end
EXACT_WORD_CHAR = '§'.freeze
def tsquery
@tsquery = @text
strip_exact_words
remove_duplicated_spaces
transform_or_into_operator
transform_and_into_operator
strip_spaces_from_parenthesis
transform_remaining_spaces_into_and_operator
transform_keywords
join_operators_with_and
remove_partial_match_from_not_keywords
add_exact_words
@tsquery
end
def validate!
parenthesis_error unless self.class.valid_search_parenthesis?(@text)
end
def self.valid_search_parenthesis?(text)
text.split('').reduce(0) do |acc, char|
return false if acc < 0
if char == '('
acc + 1
elsif char == ')'
acc - 1
else
acc
end
end.zero?
end
def parenthesis_error
raise ArgumentError, "incorrect number/order of parenthesis in search query: '#{@text}'"
end
def strip_exact_words
@exact_matches << Regexp.last_match(1) while @tsquery.sub!(/"(.*?)"/, EXACT_WORD_CHAR)
end
def remove_duplicated_spaces
@tsquery = @tsquery.gsub(/\s+/, ' ')
end
# transforms or/OR/|/|| into | operator
def transform_or_into_operator
@tsquery = @tsquery.gsub(/ ((or|\|+) )+/i, '|').gsub(/ *\|+ */, '|')
end
# transforms and/AND/&/&& into & operator
def transform_and_into_operator
@tsquery = @tsquery.gsub(/ ((and|\&+) )+/i, '&')
end
def strip_spaces_from_parenthesis
@tsquery = @tsquery.gsub(/ *([()]) */, '\1')
end
def transform_remaining_spaces_into_and_operator
@tsquery = @tsquery.tr(' ', '&')
end
# adds :* for partial match of words
def transform_keywords
keyword = @wildcard ? '\1:*' : '\1:'
@tsquery = @tsquery.gsub(/([^#{EXACT_WORD_CHAR}|&!())]+)/, keyword)
end
# adds & between operations
def join_operators_with_and
@tsquery = @tsquery.gsub(/:(\**)\!/, ':\1&!').gsub(/:(\**)\(/, ':\1&(').gsub(/\&+/, '&')
end
# removes partial match from NOT operations
def remove_partial_match_from_not_keywords
@tsquery = @tsquery.gsub(/\!([^|&!())]+):\**/, '!\1')
end
def add_exact_words
@exact_matches.each { |phrase| @tsquery = @tsquery.sub(EXACT_WORD_CHAR, "'#{phrase}'") }
end
end

Binary file not shown.

View File

@@ -0,0 +1,115 @@
# frozen_string_literal: true
describe PgSearchable do
include_examples 'pg_search', VectorModel
include_examples 'pg_search', VectorWithCustomPrimaryKeyModel
include_examples 'pg_search', VectorWithCustomCallback
include_examples 'pg_search', SimpleVectorModel
include_examples 'pg_search', VectorWithoutWildcardModel
include_examples 'pg_search', VectorModelWithCustomSearchScope, 'fulltext'
include_examples 'pg_search', VectorModelWithTagValues
include_examples 'pg_search', DynamicModel
include_examples 'pg_search', DynamicModelWithTagValues
include_examples 'pg_search', DynamicModelWithCategory
include_examples 'pg_search', DynamicModelWithSectionsTrhough
describe 'pg_search' do
describe 'joins' do
it 'can dynamically query compound relation' do
record = DynamicModelWithCategory.create(name: 'something', value: 'amazing')
category = Category.create(name: 'searchable')
Tag.create(value: 'impressive', category: category, taggable: record)
expect(DynamicModelWithCategory.scope_search('searchable')).to include(record)
end
it 'can use has_many :through relation' do
record = DynamicModelWithSectionsTrhough.create(name: 'something', value: 'amazing')
tag = Tag.create(value: 'impressive', taggable: record)
Section.create(name: 'searchable', tag: tag)
expect(DynamicModelWithSectionsTrhough.scope_search('searchable')).to include(record)
end
end
describe 'properties' do
describe 'skip_callback' do
context 'when enabled' do
let(:record) { VectorModel.create(name: 'something', value: 'amazing') }
it 'can find the record after it updates' do
record.update(name: 'cookie')
expect(VectorModel.scope_search('cookie')).to include(record)
end
end
context 'when disabled' do
let(:record) { VectorModelWithoutCallback.create(name: 'something', value: 'amazing') }
it 'cannot find the record after it updates' do
record.update(name: 'cookie')
expect(VectorModelWithoutCallback.scope_search('cookie')).not_to include(record)
end
it 'can find the record after manually calling .update_pg_search_cache' do
record.update(name: 'cookie')
record.update_pg_search_cache
expect(VectorModelWithoutCallback.scope_search('cookie')).to include(record)
end
end
end
describe 'scope' do
it 'defaults to "scope_search"' do
expect(VectorModel).to respond_to(:scope_search)
end
it 'can use a different scope name' do
expect(VectorModelWithCustomSearchScope).to respond_to(:fulltext)
end
it 'doesnt pollutes the default method name if customized' do
expect(VectorModelWithCustomSearchScope).not_to respond_to(:scope_search)
end
end
describe 'language' do
it 'defaults to english lexemes' do
record = VectorModel.create name: 'something', value: 'amazing'
expect(VectorModel.scope_search('amaz')).to include(record)
end
it 'can be changed to simple to avoid lexeme truncation' do
record = SimpleVectorModel.create name: 'something', value: 'amazing'
expect(SimpleVectorModel.scope_search('amazings')).not_to include(record)
end
end
describe 'wildcard' do
it 'by default uses it' do
record = VectorModel.create name: '12345', value: 'amazing'
expect(VectorModel.scope_search('123')).to include(record)
end
it 'can be set to false' do
record = VectorWithoutWildcardModel.create name: '12345', value: 'amazing'
expect(VectorWithoutWildcardModel.scope_search('123')).not_to include(record)
end
end
describe 'tags' do
it 'allow indexing fields of other associations' do
record = DynamicModelWithTagValues.create name: 'something', value: 'amazing'
Tag.create(taggable: record, value: 'red')
expect(DynamicModelWithTagValues.scope_search('red')).to include(record)
end
end
describe 'external_cache_data' do
it 'can index external data using a method' do
record = VectorModelWithTagValues.create name: 'something', value: 'amazing'
Tag.create(taggable: record, value: 'red')
expect(VectorModelWithTagValues.scope_search('red')).to include(record)
end
end
end
end
end

View File

@@ -0,0 +1,259 @@
require './lexer'
class QueryLexerTester
describe 'Testing the Lexer' do
before do
@evaluator = Query.new
end
it 'tests bracket expression' do
@result = @evaluator.tokenize('()')
expect(@result.length).to eq 2
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :R_BRACKET
end
it 'tests bracket expression with spaces' do
@result = @evaluator.tokenize(' ( ) ')
expect(@result.length).to eq 2
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :R_BRACKET
end
it 'tests expression with OR operator' do
@result = @evaluator.tokenize('() or () OR ()')
expect(@result.length).to eq 8
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :R_BRACKET
expect(@result[2][0]).to eq :OPERATOR_OR
expect(@result[3][0]).to eq :L_BRACKET
expect(@result[4][0]).to eq :R_BRACKET
expect(@result[5][0]).to eq :OPERATOR_OR
expect(@result[6][0]).to eq :L_BRACKET
expect(@result[7][0]).to eq :R_BRACKET
end
it 'tests expression with AND operator' do
@result = @evaluator.tokenize('() AND () and ()')
expect(@result.length).to eq 8
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :R_BRACKET
expect(@result[2][0]).to eq :OPERATOR_AND
expect(@result[3][0]).to eq :L_BRACKET
expect(@result[4][0]).to eq :R_BRACKET
expect(@result[5][0]).to eq :OPERATOR_AND
expect(@result[6][0]).to eq :L_BRACKET
expect(@result[7][0]).to eq :R_BRACKET
end
it 'tests expression with NOT OR and NOT AND operator' do
@result = @evaluator.tokenize('() NOT or () not AND ()')
expect(@result.length).to eq 10
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :R_BRACKET
expect(@result[2][0]).to eq :OPERATOR_NOT
expect(@result[3][0]).to eq :OPERATOR_OR
expect(@result[4][0]).to eq :L_BRACKET
expect(@result[5][0]).to eq :R_BRACKET
expect(@result[6][0]).to eq :OPERATOR_NOT
expect(@result[7][0]).to eq :OPERATOR_AND
expect(@result[8][0]).to eq :L_BRACKET
expect(@result[9][0]).to eq :R_BRACKET
end
it 'tests search term under quotes' do
@result = @evaluator.tokenize('"123-456"')
expect(@result.length).to eq 1
expect(@result[0][0]).to eq :TERM_WITH_QUOTES
expect(@result[0][1]).to eq '"123-456"'
end
it 'tests term without quotes' do
@result = @evaluator.tokenize('device_id')
expect(@result.length).to eq 1
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq 'device_id'
end
it 'tests integer term without quotes' do
@result = @evaluator.tokenize('123')
expect(@result.length).to eq 1
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq '123'
end
it 'tests multiple terms without quotes' do
@result = @evaluator.tokenize('device_id tag 123-456 name123')
expect(@result.length).to eq 4
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq 'device_id'
expect(@result[1][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[1][1]).to eq 'tag'
expect(@result[2][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[2][1]).to eq '123-456'
expect(@result[3][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[3][1]).to eq 'name123'
end
it 'tests simple query with column name and search term without quotes' do
@result = @evaluator.tokenize('name:JF')
expect(@result.length).to eq 3
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq 'name'
expect(@result[1][0]).to eq :COLON
expect(@result[2][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[2][1]).to eq 'JF'
end
it 'tests simple query with two columns with name and search terms without quotes' do
@result = @evaluator.tokenize('name:JF tag:mta')
expect(@result.length).to eq 6
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq 'name'
expect(@result[1][0]).to eq :COLON
expect(@result[2][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[2][1]).to eq 'JF'
expect(@result[3][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[3][1]).to eq 'tag'
expect(@result[4][0]).to eq :COLON
expect(@result[5][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[5][1]).to eq 'mta'
end
it 'tests simple query with column name and search term with quotes' do
@result = @evaluator.tokenize('name:"name with space"')
expect(@result.length).to eq 3
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq 'name'
expect(@result[1][0]).to eq :COLON
expect(@result[2][0]).to eq :TERM_WITH_QUOTES
expect(@result[2][1]).to eq '"name with space"'
end
it 'tests search term with quotes containing non alphanumerical characters' do
@result = @evaluator.tokenize('"|*|/\()#-!=<>&$"')
expect(@result.length).to eq 1
expect(@result[0][0]).to eq :TERM_WITH_QUOTES
expect(@result[0][1]).to eq '"|*|/\()#-!=<>&$"'
end
it 'tests simple query in brackets' do
@result = @evaluator.tokenize('(name:"name with space")')
expect(@result.length).to eq 5
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[1][1]).to eq 'name'
expect(@result[2][0]).to eq :COLON
expect(@result[3][0]).to eq :TERM_WITH_QUOTES
expect(@result[3][1]).to eq '"name with space"'
expect(@result[4][0]).to eq :R_BRACKET
end
it 'tests multiple query wtih brackets' do
@result = @evaluator.tokenize('(name:"name with space") or (tag:mta)')
expect(@result.length).to eq 11
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[1][1]).to eq 'name'
expect(@result[2][0]).to eq :COLON
expect(@result[3][0]).to eq :TERM_WITH_QUOTES
expect(@result[3][1]).to eq '"name with space"'
expect(@result[4][0]).to eq :R_BRACKET
expect(@result[5][0]).to eq :OPERATOR_OR
expect(@result[6][0]).to eq :L_BRACKET
expect(@result[7][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[7][1]).to eq 'tag'
expect(@result[8][0]).to eq :COLON
expect(@result[9][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[9][1]).to eq 'mta'
expect(@result[10][0]).to eq :R_BRACKET
end
it 'tests complex query' do
@result = @evaluator.tokenize('(device-id:"with space" tag:mta no-quotes-id-123)'\
'or "id with quotes-5" and ( ("id with q 10" or "id with q 20")'\
'and ("id with Q 30" "id with Q 40") and not id-without-Q-50)')
expect(@result.length).to eq 27
expect(@result[0][0]).to eq :L_BRACKET
expect(@result[1][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[1][1]).to eq 'device-id'
expect(@result[2][0]).to eq :COLON
expect(@result[3][0]).to eq :TERM_WITH_QUOTES
expect(@result[3][1]).to eq '"with space"'
expect(@result[4][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[4][1]).to eq 'tag'
expect(@result[5][0]).to eq :COLON
expect(@result[6][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[6][1]).to eq 'mta'
expect(@result[7][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[7][1]).to eq 'no-quotes-id-123'
expect(@result[8][0]).to eq :R_BRACKET
expect(@result[9][0]).to eq :OPERATOR_OR
expect(@result[10][0]).to eq :TERM_WITH_QUOTES
expect(@result[10][1]).to eq '"id with quotes-5"'
expect(@result[11][0]).to eq :OPERATOR_AND
expect(@result[12][0]).to eq :L_BRACKET
expect(@result[13][0]).to eq :L_BRACKET
expect(@result[14][0]).to eq :TERM_WITH_QUOTES
expect(@result[14][1]).to eq '"id with q 10"'
expect(@result[15][0]).to eq :OPERATOR_OR
expect(@result[16][0]).to eq :TERM_WITH_QUOTES
expect(@result[16][1]).to eq '"id with q 20"'
expect(@result[17][0]).to eq :R_BRACKET
expect(@result[18][0]).to eq :OPERATOR_AND
expect(@result[19][0]).to eq :L_BRACKET
expect(@result[20][0]).to eq :TERM_WITH_QUOTES
expect(@result[20][1]).to eq '"id with Q 30"'
expect(@result[21][0]).to eq :TERM_WITH_QUOTES
expect(@result[21][1]).to eq '"id with Q 40"'
expect(@result[22][0]).to eq :R_BRACKET
expect(@result[23][0]).to eq :OPERATOR_AND
expect(@result[24][0]).to eq :OPERATOR_NOT
expect(@result[25][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[25][1]).to eq 'id-without-Q-50'
expect(@result[26][0]).to eq :R_BRACKET
end
it 'tests query with -or-, -and- and -not- words inside quoted expression' do
@result = @evaluator.tokenize('tag:"tag with or and not inside"')
expect(@result.length).to eq 3
expect(@result[0][0]).to eq :TERM_WITHOUT_QUOTES
expect(@result[0][1]).to eq 'tag'
expect(@result[1][0]).to eq :COLON
expect(@result[2][0]).to eq :TERM_WITH_QUOTES
expect(@result[2][1]).to eq '"tag with or and not inside"'
end
end
end

View File

@@ -0,0 +1,164 @@
require './parser'
class QueryParserTester
describe 'Testing the Parser' do
before do
@evaluator = Query.new
end
it 'tests query with only one search term without quotes and without column name' do
@result = @evaluator.parse('-123')
expect(@result[:DEFAULT_COLUMN]).to eq '-123'
end
it 'tests query with only one search term with quotes and without column name' do
@result = @evaluator.parse('"OR 128"')
expect(@result[:DEFAULT_COLUMN]).to eq '"OR 128"'
end
it 'tests query with one column and search term without quotes' do
@result = @evaluator.parse('tag:mta')
expect(@result['tag']).to eq 'mta'
end
it 'tests query with one column and search term with quotes' do
@result = @evaluator.parse('tag:"tag 120"')
expect(@result['tag']).to eq '"tag 120"'
end
it 'tests query with two columns connected with OR and search terms without quotes' do
@result = @evaluator.parse('tag:mta OR tag:12')
@expected_array = [
{ 'tag' => 'mta' },
{ 'tag' => '12' }
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_OR]).to eq @expected_array
end
it 'tests query with two columns connected with OR and search terms with quotes' do
@result = @evaluator.parse('tag:mta OR tag:"tag 12"')
@expected_array = [
{ 'tag' => 'mta' },
{ 'tag' => '"tag 12"' }
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_OR]).to eq @expected_array
end
it 'tests query with two columns connected with AND and search terms without quotes' do
@result = @evaluator.parse('tag:mta AND tag:12')
@expected_array = [
{ 'tag' => 'mta' },
{ 'tag' => '12' }
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_AND]).to eq @expected_array
end
it 'tests query with two columns connected with AND and search terms with quotes' do
@result = @evaluator.parse('tag:mta and tag:"tag 12"')
@expected_array = [
{ 'tag' => 'mta' },
{ 'tag' => '"tag 12"' }
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_AND]).to eq @expected_array
end
it 'tests simple query with brackets' do
@result = @evaluator.parse('(123)')
expect(@result.count).to eq 1
expect(@result[:DEFAULT_COLUMN]).to eq '123'
end
it 'tests simple query with brackets and with a column name' do
@result = @evaluator.parse('(name:JF)')
expect(@result.count).to eq 1
expect(@result['name']).to eq 'JF'
end
it 'tests query with OR operator in brackets' do
@result = @evaluator.parse('(name:JF or tag:mta)')
@expected_array = [
{ 'name' => 'JF' },
{ 'tag' => 'mta' }
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_OR]).to eq @expected_array
end
it 'tests query with two simple brackets expressions' do
@result = @evaluator.parse('(name:JF) and (-456)')
@expected_array = [
{ 'name' => 'JF' },
{ :DEFAULT_COLUMN => '-456' }
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_AND]).to eq @expected_array
end
it 'tests query with two brackets expressions' do
@result = @evaluator.parse('(name:JF or tag:"tag 0") and (-456)')
@expected_array_part_1 = [
{ 'name' => 'JF' },
{ 'tag' => '"tag 0"' }
]
@expected_array_total = [
{:OPERATOR_OR => @expected_array_part_1},
{:DEFAULT_COLUMN => '-456'}
]
expect(@result.count).to eq 1
expect(@result[:OPERATOR_AND]).to eq @expected_array_total
end
it 'tests operator precedence' do
@result1 = @evaluator.parse('tag:mta or name:JF and 12_4')
@result2 = @evaluator.parse('tag:mta or (name:JF and 12_4)')
expect(@result1).to eq @result2
expect(@result1.length).to eq 1
@expected_array_part_2 = [
{'name' => 'JF'},
{:DEFAULT_COLUMN => '12_4'}
]
@expected_array_total = [
{'tag' => 'mta'},
{:OPERATOR_AND => @expected_array_part_2}
]
expect(@result1[:OPERATOR_OR]).to eq @expected_array_total
end
# Tests to write :
# * query with multiple column names and search terms without logical operators
# * AND NOT, OR NOT tests
end
end

View File

@@ -0,0 +1,22 @@
# frozen_string_literal: true
describe TextToRegexQuery do
include_examples 'pg_search', SimpleVectorModel
describe '.new' do
# just default
it { expect(described_class.new('some-default-value', [:"players.name"], :"players.name").where_clause(SimpleVectorModel)).to eq([' players.name like ? OR 1<>1', '%some-default-value%']) }
# default and named
it { expect(described_class.new('name:hamo id:1', [:"players.name"], :"players.name").where_clause(SimpleVectorModel)).to eq([' players.name like ? OR players.name like ? OR 1<>1', '%id:1%', '%hamo%']) }
# escape characters
it { expect(described_class.new('name:hamo id:1',[:"players.name"], :"players.name").where_clause(SimpleVectorModel)).to eq([' players.name like ? OR players.name like ? OR 1<>1', '%id:1%', '%hamo%']) }
# default and explicit with underscore
it { expect(described_class.new('device_id:-123', [:"players.name", :"players.device_id"], :"players.device_id").where_clause(SimpleVectorModel)).to eq([" players.device_id like ? OR 1<>1", "%-123%"]) }
# default and explicit with underscore with multiple columns
it { expect(described_class.new('name:bla device_id:-123', [:"players.name", :"players.device_id"], :"players.device_id").where_clause(SimpleVectorModel)).to eq([" players.name like ? OR players.device_id like ? OR 1<>1", "%bla%", "%-123%"]) }
end
end

View File

@@ -0,0 +1,80 @@
# frozen_string_literal: true
describe TextToTsquery do
describe '.new' do
# partial match
it { expect(described_class.new('A').tsquery).to eq('A:*') }
it { expect(described_class.new('A', wildcard: false).tsquery).to eq('A:') }
it { expect(described_class.new(' A ').tsquery).to eq('A:*') }
# AND operations
it { expect(described_class.new('A B').tsquery).to eq('A:*&B:*') }
it { expect(described_class.new('A B C').tsquery).to eq('A:*&B:*&C:*') }
it { expect(described_class.new('A and B').tsquery).to eq('A:*&B:*') }
it { expect(described_class.new('A AND B').tsquery).to eq('A:*&B:*') }
it { expect(described_class.new('A & B').tsquery).to eq('A:*&B:*') }
it { expect(described_class.new('A && B').tsquery).to eq('A:*&B:*') }
it { expect(described_class.new('A & B && C and D AND E F').tsquery).to eq('A:*&B:*&C:*&D:*&E:*&F:*') }
# OR operations
it { expect(described_class.new('A or B').tsquery).to eq('A:*|B:*') }
it { expect(described_class.new('A or B', wildcard: false).tsquery).to eq('A:|B:') }
it { expect(described_class.new('A OR B').tsquery).to eq('A:*|B:*') }
it { expect(described_class.new('A OR B', wildcard: false).tsquery).to eq('A:|B:') }
it { expect(described_class.new('A | B').tsquery).to eq('A:*|B:*') }
it { expect(described_class.new('A | B', wildcard: false).tsquery).to eq('A:|B:') }
it { expect(described_class.new('A || B').tsquery).to eq('A:*|B:*') }
it { expect(described_class.new('A || B', wildcard: false).tsquery).to eq('A:|B:') }
it { expect(described_class.new('A or or B').tsquery).to eq('A:*|B:*') }
it { expect(described_class.new('A or or B', wildcard: false).tsquery).to eq('A:|B:') }
it { expect(described_class.new('A | B || C or D OR E').tsquery).to eq('A:*|B:*|C:*|D:*|E:*') }
it { expect(described_class.new('A | B || C or D OR E', wildcard: false).tsquery).to eq('A:|B:|C:|D:|E:') }
# () Precedence
it { expect(described_class.new('(A)').tsquery).to eq('(A:*)') }
it { expect(described_class.new('(A)', wildcard: false).tsquery).to eq('(A:)') }
it { expect(described_class.new('(A B)').tsquery).to eq('(A:*&B:*)') }
it { expect(described_class.new('(A B)', wildcard: false).tsquery).to eq('(A:&B:)') }
it { expect(described_class.new('A (B !C)').tsquery).to eq('A:*&(B:*&!C)') }
it { expect(described_class.new('A (B !C)', wildcard: false).tsquery).to eq('A:&(B:&!C)') }
it { expect(described_class.new('(A AND B) OR C').tsquery).to eq('(A:*&B:*)|C:*') }
it { expect(described_class.new('(A AND B) OR C', wildcard: false).tsquery).to eq('(A:&B:)|C:') }
it { expect(described_class.new('A AND (B OR C)').tsquery).to eq('A:*&(B:*|C:*)') }
it { expect(described_class.new('A AND (B OR C)', wildcard: false).tsquery).to eq('A:&(B:|C:)') }
it { expect(described_class.new('(A & B) || C').tsquery).to eq('(A:*&B:*)|C:*') }
it { expect(described_class.new('(A & B) || C', wildcard: false).tsquery).to eq('(A:&B:)|C:') }
it { expect(described_class.new('A && (B | C)').tsquery).to eq('A:*&(B:*|C:*)') }
it { expect(described_class.new('A && (B | C)', wildcard: false).tsquery).to eq('A:&(B:|C:)') }
it { expect(described_class.new('A && !D (B | C | !E)').tsquery).to eq('A:*&!D&(B:*|C:*|!E)') }
it { expect(described_class.new('A && !D (B | C | !E)', wildcard: false).tsquery).to eq('A:&!D&(B:|C:|!E)') }
# Exact Matches
it { expect(described_class.new('"A"').tsquery).to eq("'A'") }
it { expect(described_class.new('"A B"').tsquery).to eq("'A B'") }
it { expect(described_class.new('"A&B"').tsquery).to eq("'A&B'") }
it { expect(described_class.new('"-A|B"').tsquery).to eq("'-A|B'") }
it { expect(described_class.new('"A-B"').tsquery).to eq("'A-B'") }
it { expect(described_class.new('"A" B').tsquery).to eq("'A'&B:*") }
it { expect(described_class.new('"A" B', wildcard: false).tsquery).to eq("'A'&B:") }
it { expect(described_class.new('"A B" C').tsquery).to eq("'A B'&C:*") }
it { expect(described_class.new('"A B" C', wildcard: false).tsquery).to eq("'A B'&C:") }
it { expect(described_class.new('("A B" or C) and D').tsquery).to eq("('A B'|C:*)&D:*") }
it { expect(described_class.new('("A B" or C) and D', wildcard: false).tsquery).to eq("('A B'|C:)&D:") }
describe 'validations' do
it { expect { described_class.new('(') }.to raise_error(ArgumentError, /parenthesis/) }
end
end
describe '.valid_search_parenthesis?' do
it { expect(described_class.valid_search_parenthesis?('')).to eq true }
it { expect(described_class.valid_search_parenthesis?('()')).to eq true }
it { expect(described_class.valid_search_parenthesis?('()()')).to eq true }
it { expect(described_class.valid_search_parenthesis?('(()())')).to eq true }
it { expect(described_class.valid_search_parenthesis?('((())())')).to eq true }
it { expect(described_class.valid_search_parenthesis?('(')).to eq false }
it { expect(described_class.valid_search_parenthesis?(')(')).to eq false }
it { expect(described_class.valid_search_parenthesis?('())')).to eq false }
it { expect(described_class.valid_search_parenthesis?('((()())')).to eq false }
end
end

45
spec/schema.rb Normal file
View File

@@ -0,0 +1,45 @@
# frozen_string_literal: true
ActiveRecord::Schema.define do
create_table :vector_models, force: true do |t|
t.string :name
t.string :value
t.tsvector :search_cache
t.timestamps null: false
end
add_index :vector_models, :search_cache, using: :gin
create_table :vector_with_custom_primary_key_models, id: false, force: true do |t|
t.uuid :uuid, null: false
t.string :name
t.string :value
t.tsvector :search_vector
t.timestamps null: false
end
add_index :vector_with_custom_primary_key_models, :uuid, using: :btree
add_index :vector_with_custom_primary_key_models, :search_vector, using: :gin
create_table :dynamic_models, force: true do |t|
t.string :name
t.string :value
t.timestamps null: false
end
create_table :tags, force: true do |t|
t.string :value
t.references :category, index: true
t.references :taggable, polymorphic: true, index: true
t.timestamps null: false
end
create_table :categories, force: true do |t|
t.string :name
t.timestamps null: false
end
create_table :sections, force: true do |t|
t.references :tag
t.string :name
t.timestamps null: false
end
end

52
spec/spec_helper.rb Normal file
View File

@@ -0,0 +1,52 @@
# frozen_string_literal: true
require 'bundler'
Bundler.require
require 'simplecov'
SimpleCov.start do
add_filter '/spec/'
end
require 'active_record'
require 'database_cleaner'
require 'dotenv/load'
require 'pg_searchable_regex'
require 'pry'
# requiring support files
Dir[__dir__ + '/support/**/*.rb'].each { |f| require f }
RSpec.configure do |config|
env = Dotenv.parse('.env.test', '.env.test.local')
db_config = {
adapter: 'postgresql',
host: ENV['DATABASE_HOST'] || env['DATABASE_HOST'],
username: ENV['DATABASE_USERNAME'] || env['DATABASE_USERNAME'],
password: ENV['DATABASE_PASSWORD'] || env['DATABASE_PASSWORD'],
database: ENV['DATABASE_NAME'] || env['DATABASE_NAME'],
pool: ENV['DATABASE_POOL'] || env['DATABASE_POOL'],
port: ENV['DATABASE_PORT'] || env['DATABASE_PORT']
}
db_config_admin = db_config.merge(database: 'postgres', schema_search_path: 'public')
config.before(:suite) do
ActiveRecord::Base.establish_connection(db_config_admin)
ActiveRecord::Base.connection.create_database(db_config[:database])
ActiveRecord::Base.establish_connection(db_config)
ActiveRecord::Tasks::DatabaseTasks.load_schema_for(db_config, :ruby, "#{__dir__}/schema.rb")
DatabaseCleaner.strategy = :transaction
DatabaseCleaner.clean_with(:truncation)
end
config.around do |example|
DatabaseCleaner.cleaning do
example.run
end
end
config.after(:suite) do
ActiveRecord::Base.establish_connection(db_config_admin)
ActiveRecord::Base.connection.drop_database(db_config[:database])
end
end

103
spec/support/models.rb Executable file
View File

@@ -0,0 +1,103 @@
# frozen_string_literal: true
class VectorModel < ActiveRecord::Base
include PgSearchable
pg_search fields: %i[id name value], cache: :search_cache
end
class VectorModelWithoutCallback < ActiveRecord::Base
self.table_name = :vector_models
include PgSearchable
pg_search fields: %i[id name value], cache: :search_cache, skip_callback: true
end
class VectorWithCustomCallback < ActiveRecord::Base
self.table_name = :vector_models
include PgSearchable
pg_search fields: %i[id name value], cache: :search_cache, skip_callback: true, external_cache_data: :colors
after_save :update_pg_search_cache
def colors
%w[orange blue]
end
end
class VectorWithCustomPrimaryKeyModel < ActiveRecord::Base
include PgSearchable
pg_search fields: %i[uuid name value]
self.primary_key = :uuid
before_save { self.uuid = SecureRandom.uuid }
end
class SimpleVectorModel < ActiveRecord::Base
self.table_name = :vector_models
include PgSearchable
pg_search fields: %i[id name value], cache: :search_cache, language: :simple
end
class VectorWithoutWildcardModel < ActiveRecord::Base
self.table_name = :vector_models
include PgSearchable
pg_search fields: %i[id name value], cache: :search_cache, wildcard: false
end
class VectorModelWithCustomSearchScope < ActiveRecord::Base
self.table_name = :vector_models
include PgSearchable
pg_search fields: %i[id name value], cache: :search_cache, scope: 'fulltext'
end
class VectorModelWithTagValues < ActiveRecord::Base
self.table_name = :vector_models
include PgSearchable
pg_search fields: %i[id name value], cache: :search_cache, external_cache_data: :tag_values
has_many :tags, as: :taggable
def tag_values
tags.pluck(:value)
end
end
class DynamicModel < ActiveRecord::Base
include PgSearchable
pg_search fields: %i[id name value]
end
class DynamicModelWithTagValues < ActiveRecord::Base
self.table_name = :dynamic_models
include PgSearchable
pg_search fields: %i[dynamic_models.id dynamic_models.name dynamic_models.value tags.value], joins: [:tags]
has_many :tags, as: :taggable
end
class DynamicModelWithCategory < ActiveRecord::Base
self.table_name = :dynamic_models
include PgSearchable
pg_search fields: %i[dynamic_models.id dynamic_models.name dynamic_models.value tags.value categories.name],
joins: { tags: :category }
has_many :tags, as: :taggable
end
class DynamicModelWithSectionsTrhough < ActiveRecord::Base
self.table_name = :dynamic_models
include PgSearchable
pg_search fields: %i[dynamic_models.id dynamic_models.name dynamic_models.value tags.value sections.name],
joins: [{ tags: :category }, :sections]
has_many :tags, as: :taggable
has_many :sections, through: :tags
end
class Tag < ActiveRecord::Base
belongs_to :category
has_many :sections
belongs_to :taggable, polymorphic: true
after_save { taggable.update_pg_search_cache if taggable.class.ts_cache_field.present? }
end
class Section < ActiveRecord::Base
belongs_to :tag
end
class Category < ActiveRecord::Base
has_many :tags
end

View File

@@ -0,0 +1,68 @@
# frozen_string_literal: true
shared_examples 'pg_search' do |mock_class, scope_method = 'scope_search'|
describe 'searchable fields' do
let!(:record) { mock_class.create name: 'something', value: 'amazing' }
it { expect(mock_class.send(scope_method, record.id)).to include(record) }
it { expect(mock_class.send(scope_method, 'something')).to include(record) }
it { expect(mock_class.send(scope_method, 'amazing')).to include(record) }
it { expect(mock_class.send(scope_method, 'candy')).not_to include(record) }
end
describe 'operators' do
before do
mock_class.create(name: 'uno', value: 'one')
mock_class.create(name: 'dos', value: 'two')
mock_class.create(name: 'tres', value: 'three')
mock_class.create(name: 'cuatro', value: 'four')
mock_class.create(name: 'cuatro', value: 'five')
mock_class.create(name: 'cinco', value: 'one-two-three')
end
it 'returns all records if search query is blank' do
expect(mock_class.send(scope_method, ' ').first).not_to be_blank
end
describe 'AND searches' do
it { expect(mock_class.send(scope_method, 'uno one').count).to eq(1) }
it { expect(mock_class.send(scope_method, 'uno&one').count).to eq(1) }
it { expect(mock_class.send(scope_method, 'uno&&one').count).to eq(1) }
it { expect(mock_class.send(scope_method, 'uno & one').count).to eq(1) }
it { expect(mock_class.send(scope_method, 'uno one').count).to eq(1) }
it { expect(mock_class.send(scope_method, 'uno and one').count).to eq(1) }
it { expect(mock_class.send(scope_method, 'dos AND two').count).to eq(1) }
it { expect(mock_class.send(scope_method, 'uno dos').count).to eq(0) }
end
describe 'OR searches' do
it { expect(mock_class.send(scope_method, 'uno or dos').count).to eq(2) }
it { expect(mock_class.send(scope_method, 'uno or dos').count).to eq(2) }
it { expect(mock_class.send(scope_method, 'uno or or dos').count).to eq(2) }
it { expect(mock_class.send(scope_method, 'uno or or dos').count).to eq(2) }
it { expect(mock_class.send(scope_method, 'one OR two').count).to eq(3) }
it { expect(mock_class.send(scope_method, 'uno or two').count).to eq(3) }
end
describe 'AND and OR searches' do
it { expect(mock_class.send(scope_method, 'uno or dos two').count).to eq(2) }
it { expect(mock_class.send(scope_method, 'uno or dos one').count).to eq(1) }
end
describe 'NOT searches' do
it { expect(mock_class.send(scope_method, '!cuatro').count).to eq(4) }
it { expect(mock_class.send(scope_method, 'cuatro !four').count).to eq(1) }
end
describe 'exact match' do
it { expect(mock_class.send(scope_method, '"cinco"').count).to eq(1) }
it { expect(mock_class.send(scope_method, '"one-two-three"').count).to eq(1) }
it { expect(mock_class.send(scope_method, '"one two"').count).to eq(1) }
it { expect(mock_class.send(scope_method, '"one&two"').count).to eq(1) }
it { expect(mock_class.send(scope_method, '"one|two"').count).to eq(1) }
it { expect(mock_class.send(scope_method, '"one-two-three" or two').count).to eq(2) }
it { expect(mock_class.send(scope_method, '"one-two"').count).to eq(0) }
it { expect(mock_class.send(scope_method, '"cinco one"').count).to eq(1) }
end
end
end

11
version.rb Normal file
View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
module Version
MAJOR = 1
MINOR = 0
PATCH = 13
def self.to_s
[MAJOR, MINOR, PATCH].compact.join('.')
end
end