marskal-search 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/CHANGELOG.md +58 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +69 -0
- data/Rakefile +9 -0
- data/app/assets/javascripts/marskal-search.js +36 -0
- data/app/controllers/marskal_search_controller.rb +73 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/config/routes.rb +6 -0
- data/lib/marskal/search.rb +11 -0
- data/lib/marskal/search/version.rb +3 -0
- data/lib/marskal_search/marskal_active_record_extensions.rb +32 -0
- data/lib/marskal_search/marskal_search.rb +766 -0
- data/lib/tasks/filter_shortcuts.rake +35 -0
- data/marskal-search-0.2.0.gem +0 -0
- data/marskal-search.gemspec +35 -0
- data/supplimental_documentation/DETAILED_README.md +180 -0
- data/supplimental_documentation/SHORTCUTS.md +34 -0
- data/supplimental_documentation/TODO.md +10 -0
- metadata +139 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA1:
|
|
3
|
+
metadata.gz: 98252e8c93c9678ba9b36acb72549d53a6ab5a7a
|
|
4
|
+
data.tar.gz: 65c256facc14d5e3a0457673a161b2c397a945d5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 23e29c69783e25375aca36b6610224c951e995ccd02dae54b3cffcf44d62185c63b279b05eee9ee75db5731a64f588eec29d8cbc4a30708a6f239b0ac1f7a3d1
|
|
7
|
+
data.tar.gz: c535f97e7faf7c0b9a118d0298aab6b1585b7185a183255536f894b554295f6f9f38e94585ee9fd0a25780413aa4a9b4d1a8e57a25b0b119548623cf022203c0
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
### Change log `(Revision History)`
|
|
2
|
+
---
|
|
3
|
+
|
|
4
|
+
###### v0.2.4 [`2015-12-14 by MAU`]
|
|
5
|
+
* snapshot before major cleanup for marksla api and search tools
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
###### v0.2.3 [`2015-12-13 by MAU`]
|
|
9
|
+
* started beginning of transition options || to options.has_key?
|
|
10
|
+
* add some tuning for unfiltered counts and regular counts as well
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
###### v0.2.2 [`2015-12-11 by MAU`]
|
|
14
|
+
* Added a concat for queries
|
|
15
|
+
---
|
|
16
|
+
###### v0.2.1 [`2015-12-10 by MAU`]
|
|
17
|
+
* Many Bug Fixes and changes to accommodate marskal-api
|
|
18
|
+
* Bug Fixes
|
|
19
|
+
* Added COLUMN_DELIMTER or ` to wrap column names to prevent myssql erros. This was discoverd because 'primary' is a mysaql reserved word and also a field ion the database
|
|
20
|
+
* fixed the way total pages are calculated
|
|
21
|
+
* spelled :marskal-api correctly
|
|
22
|
+
* fixed problem with single column searches truncating the returned value from DB
|
|
23
|
+
* Marskal-api Integration
|
|
24
|
+
* Added page to paramaters as an alternative to offset
|
|
25
|
+
* Enhanced the return has results for marskal and default
|
|
26
|
+
* Now Includes total page counts, page number and more
|
|
27
|
+
---
|
|
28
|
+
###### v0.2.0 [`2015-12-09 by MAU`]
|
|
29
|
+
* Added Integration with marskal-api
|
|
30
|
+
* tweaked handling of pass_back option
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
###### v0.1.5 [`2015-11-04 by MAU`]
|
|
34
|
+
* Fixed A Missing Function Call
|
|
35
|
+
* Removed a console.log line from js file
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
###### v0.1.4 [`2015-10-22 by MAU`]
|
|
39
|
+
* Fixed/cleaned up some [README.md](README.md) issues
|
|
40
|
+
* Removed completed items from [TODO.md](supplimental_documentation/TODO.md)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
###### v0.1.3 [`2015-10-21 by MAU`]
|
|
44
|
+
* Fixed problem where IN was overtaking CONTAINS filters..beacuse "IN" is part of the word contaINs
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
###### v0.1.2 [`2015-10-21 by MAU`]
|
|
48
|
+
* Added IN/NOT IN short codes `^` and `!^`
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
###### v0.1.1 [`2015-10-21 by MAU`]
|
|
52
|
+
* Added Contains/not contains short codes `~` and `!`
|
|
53
|
+
* Added [Shortcut Quick Reference](SHORTCUTS.md)
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
###### v0.1.0 [`2015-10-21 by MAU`]
|
|
57
|
+
* Initial Commit
|
|
58
|
+
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2015 Mike Urban
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# MarskalSearch
|
|
2
|
+
|
|
3
|
+
This Ruby Rails gem allows a robust nested SQL text search. To date is has only been tested on MariaDB/MySQL.
|
|
4
|
+
|
|
5
|
+
This search gem can output data in 3 formats:
|
|
6
|
+
|
|
7
|
+
* ActiveRecord
|
|
8
|
+
* jQuery [jqGrid](http://www.trirand.com/blog/)
|
|
9
|
+
* jQuery [DataTables](https://www.datatables.net/)
|
|
10
|
+
* Refer to [TODO.md](supplimental_documentation/TODO.md) for information of proposed features and fixes
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Add this line to your application's Gemfile:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem 'marskal-search'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
And then execute:
|
|
21
|
+
|
|
22
|
+
$ bundle
|
|
23
|
+
|
|
24
|
+
Or install it yourself as:
|
|
25
|
+
|
|
26
|
+
$ gem install marskal-search
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
#### Classes ####
|
|
31
|
+
##### class `MarskalSearch`
|
|
32
|
+
```
|
|
33
|
+
Initialize:
|
|
34
|
+
MarskalSearch.new (p_class, p_search_text, options)
|
|
35
|
+
|
|
36
|
+
p_class: ActiveRecord Model
|
|
37
|
+
examples: User Contact Book
|
|
38
|
+
|
|
39
|
+
p_search: String to search for
|
|
40
|
+
examples: "admin" "williams" "poe"
|
|
41
|
+
|
|
42
|
+
options: See Below for a list of configurable options
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
##### Options:
|
|
46
|
+
There are a several configurable options for this class. See [Options and Examples](supplimental_documentation/DETAILED_README.md) for a list of options and examples
|
|
47
|
+
|
|
48
|
+
## Rake tasks
|
|
49
|
+
To get a list of available shortcuts for jqgrid and datable filters
|
|
50
|
+
```ruby
|
|
51
|
+
rake marskal_search:shortcuts
|
|
52
|
+
```
|
|
53
|
+
Note: A list of shortcuts is also available in this repository at [supplimental_documentation/SHORTCUTS.md](supplimental_documentation/SHORTCUTS.md)
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
58
|
+
|
|
59
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
60
|
+
|
|
61
|
+
## Contributing
|
|
62
|
+
|
|
63
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/MarskalGroup/marskal-search.
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
|
69
|
+
|
data/Rakefile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Created by mikeu on 10/22/2015.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
var SQL_MANUAL_SHORT_CODES = ['<', '>', '!=', '=', '>=', '<=', '::', '!::', '!', ':', '%', '~', '!~', '^', '~^']
|
|
6
|
+
|
|
7
|
+
function anyIncompleteManualFilters(p_fields_selector) {
|
|
8
|
+
var l_incomplete = false;
|
|
9
|
+
p_fields_selector.each(function( index ) {
|
|
10
|
+
if ($(this).val() && isManualSqlFilter($(this).val())) {
|
|
11
|
+
l_incomplete = true;
|
|
12
|
+
return( false );
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
return l_incomplete;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isManualSqlFilter(p_value) {
|
|
19
|
+
var betweens = ['::', '!::'];
|
|
20
|
+
var ins = ['^', '!^'];
|
|
21
|
+
var l_found = false;
|
|
22
|
+
//console.log('in isManualSqlFilter');
|
|
23
|
+
if (betweens.indexOf(p_value.split(' ')[0]) >= 0) {
|
|
24
|
+
var l_range = p_value.split('&&'); //the && seperates the range values in the between
|
|
25
|
+
l_found = (l_range.length <= 1 || l_range[1]==''); //if the have not completed the range, then we set found to false to prevent calling program from executing search
|
|
26
|
+
}
|
|
27
|
+
else if (ins.indexOf(p_value.split(' ')[0]) >= 0) {
|
|
28
|
+
var l_range = p_value.split(','); //the && seperates the range values in the between
|
|
29
|
+
l_found = (l_range.length <= 1 || l_range[1]==''); //if the have not completed the range, then we set found to false to prevent calling program from executing search
|
|
30
|
+
}
|
|
31
|
+
else
|
|
32
|
+
l_found = SQL_MANUAL_SHORT_CODES.indexOf(p_value.trim())>= 0;
|
|
33
|
+
|
|
34
|
+
return l_found
|
|
35
|
+
}
|
|
36
|
+
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#this controller will output the appropriate data in a jqgird firendly format
|
|
2
|
+
#Note there are some custom requirements to be included ion the jqgrid process
|
|
3
|
+
# marskal_params is part of the jqgrid structure to pass neededed for this function
|
|
4
|
+
# see routes.rb for the appropriate route to call to access this controller and its methods
|
|
5
|
+
|
|
6
|
+
# TODO: This needs to be well documented. Right now it is not.
|
|
7
|
+
# TODO: Need to add a datables option as well
|
|
8
|
+
|
|
9
|
+
class MarskalSearchController < ApplicationController
|
|
10
|
+
def jqgrid
|
|
11
|
+
|
|
12
|
+
#clean up the variables customized for MarskalSearch
|
|
13
|
+
marskal_params = ActiveSupport::JSON.decode( params[:marskal_params] )
|
|
14
|
+
|
|
15
|
+
#futher transform marskal parameters and standard jqgrid provided parameters (e.g. rows, page, sidx) for use in MarskalSearch
|
|
16
|
+
marskal_params[:select_columns] = MarskalSearch.prepare_jqgrid_column_names(marskal_params)
|
|
17
|
+
marskal_params[:limit] = params['rows'].to_i
|
|
18
|
+
marskal_params[:offset] = (params['page'].to_i - 1) * marskal_params[:limit]
|
|
19
|
+
# marskal_params[:order_string] = params['sidx'].blank? ? marskal_params['default_order'] : "#{params['sidx']} #{params['sord']}"
|
|
20
|
+
marskal_params[:order_string] = sort_order_hack(params, marskal_params)
|
|
21
|
+
|
|
22
|
+
marskal_params[:default_where] = marskal_params['default_where'].to_s
|
|
23
|
+
marskal_params[:where_string] = MarskalSearch.append_sql_where_if_true(marskal_params['where_string'].to_s, 'AND', marskal_params['starting_filter'].to_s )
|
|
24
|
+
|
|
25
|
+
#this is for a single field search among all columns or table...this has not been implemented in jqgrid yet
|
|
26
|
+
marskal_params[:search_text] ='' # params[:search_text] Just leave blank for now
|
|
27
|
+
|
|
28
|
+
ms = MarskalSearch.new(marskal_params['model'].constantize,
|
|
29
|
+
marskal_params[:search_text],
|
|
30
|
+
select_columns: marskal_params[:select_columns],
|
|
31
|
+
joins: marskal_params[:joins],
|
|
32
|
+
includes_for_select_and_search: marskal_params[:includes_for_select_and_search],
|
|
33
|
+
includes_for_search_only: marskal_params[:includes_for_search_only],
|
|
34
|
+
default_where: marskal_params[:default_where],
|
|
35
|
+
where_string: marskal_params[:where_string],
|
|
36
|
+
individual_column_filters: MarskalSearch.prep_jqgrid_column_filter(params, space_to_equal_fields: ['symbol']),
|
|
37
|
+
search_only_these_fields: marskal_params[:search_only_these_fields],
|
|
38
|
+
order_string: marskal_params[:order_string],
|
|
39
|
+
offset: marskal_params[:offset].to_i,
|
|
40
|
+
limit: marskal_params[:limit].to_i,
|
|
41
|
+
pass_back: { page: params['page']}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
sql = ms.to_sql #for debugging if you wanna see what the sql being generated is
|
|
45
|
+
# puts sql.split(' WHERE ').last
|
|
46
|
+
# puts JSON.pretty_generate(params)
|
|
47
|
+
# puts sql.count
|
|
48
|
+
render json: ms.results(format: :jqgrid)
|
|
49
|
+
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def sort_order_hack(params, marskal_params)
|
|
53
|
+
marskal_columns = MarskalSearch.prepare_jqgrid_column_names(marskal_params, 'index')
|
|
54
|
+
l_sort_columns = "#{params['sidx']} #{params['sord']}".split(',')
|
|
55
|
+
l_sort_column_index_order = params['sort_column_order']
|
|
56
|
+
|
|
57
|
+
l_order_str = ''
|
|
58
|
+
Array(l_sort_column_index_order).each do |l_column|
|
|
59
|
+
l_index_name = marskal_columns[l_column.to_i]
|
|
60
|
+
l_sort_columns.each do |sort|
|
|
61
|
+
if sort.include?(l_index_name)
|
|
62
|
+
l_order_str += ',' unless l_order_str.blank?
|
|
63
|
+
l_order_str += " #{sort} "
|
|
64
|
+
break
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return l_order_str.blank? ? marskal_params['default_order']: l_order_str
|
|
70
|
+
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
end
|
data/bin/console
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "marskal/search"
|
|
5
|
+
|
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
8
|
+
|
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
|
10
|
+
# require "pry"
|
|
11
|
+
# Pry.start
|
|
12
|
+
|
|
13
|
+
require "irb"
|
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/config/routes.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
require "marskal/search/version"
|
|
2
|
+
require 'marskal_search/marskal_active_record_extensions' #contains some needed functions
|
|
3
|
+
require "marskal_search/marskal_search"
|
|
4
|
+
|
|
5
|
+
module Marskal
|
|
6
|
+
class Engine < Rails::Engine
|
|
7
|
+
initializer 'marskal-search.setup', group: :all do |app|
|
|
8
|
+
app.config.assets.paths << ::Rails.root.join('app', 'assets', 'javascripts')
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require 'active_record'
|
|
2
|
+
|
|
3
|
+
#thes extensions were originall developed for use with the MarskalSearch class
|
|
4
|
+
#but they are generic and can be used with any ActiveRecord object
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
|
|
7
|
+
class Reflection::AssociationReflection
|
|
8
|
+
#get class name from the association symbol
|
|
9
|
+
#ex: Contact.contact_notes.derive_class_from_association()
|
|
10
|
+
def derive_class_from_association()
|
|
11
|
+
eval self.class_name||self.name.to_s.classify
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class Base
|
|
17
|
+
|
|
18
|
+
#find the association within the current class
|
|
19
|
+
#ex: Contact.marskal_find_association(:contact_notes)
|
|
20
|
+
def self.marskal_find_association(p_association_symbol)
|
|
21
|
+
l_ret = nil
|
|
22
|
+
self.reflect_on_all_associations.each do |l_association|
|
|
23
|
+
if l_association.name == p_association_symbol
|
|
24
|
+
l_ret = l_association
|
|
25
|
+
break
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
l_ret
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
end
|
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
#Call MarskalSearch
|
|
2
|
+
#Used to search for a string through tables and sub tables and return the results
|
|
3
|
+
#Can be used as an enhancement to ActiveRecord. Was originall developed to support the jquery plugins datatables and jqgrid, but can be used with out them
|
|
4
|
+
#Usage MarskalSearch.new (p_class, p_search_text, options)
|
|
5
|
+
# p_class: ActiveRecord Model
|
|
6
|
+
# examples: User Contact Book
|
|
7
|
+
# p_search: String to search for
|
|
8
|
+
# examples: "admin" "williams" "poe"
|
|
9
|
+
#
|
|
10
|
+
# options:
|
|
11
|
+
# **NOTE because of complicated queries, always include the table name with the field name for all parameters that allow fields.
|
|
12
|
+
# for example pass 'contacts.last_name' instead of just 'last_name'
|
|
13
|
+
# Also: this has only been tested for Mysql
|
|
14
|
+
# :select_columns => A List of columns to be selected
|
|
15
|
+
# **ex: "['contacts.last_name', 'contacts.first_name']"
|
|
16
|
+
# **joined table example: "['contacts.last_name', 'contacts.first_name', 'contact_phone_numbers.phone_number']"
|
|
17
|
+
# default: If blank, all fields will be selected from the primary table only unless otherwise changed by other options
|
|
18
|
+
# :not_distinct => Duplicates Allowed? Note this does not just look at any one field..it looks at the entire selected fieldset
|
|
19
|
+
# Default: false (no duplicates will be returned)
|
|
20
|
+
# :joins => Equivalent to the .joins option of an ActiveRecord relation. This parameters is simple passed on to that
|
|
21
|
+
# Example Single association ==> joins: :contact_phone_numbers
|
|
22
|
+
# Example Array association ==> joins: [:contact_phone_numbers, :contact_addresses, :contact_notes]
|
|
23
|
+
# IMPORTANT NOTE: These are LEFT JOINS, also These fields can be used in the select statement, the fields in these sub-tables will be searched unless excluded by other options
|
|
24
|
+
# Default: if blank, the no joins will be added. However, if join(s) is provided, the default will be to search all sub-table fields unless otherwise specified by other options
|
|
25
|
+
# :includes_for_select_and_search => Similar to :joins, except these are sql INNER JOINS
|
|
26
|
+
# IMPORTANT NOTE: These fields can be used in the select statement, the fields in these sub-tables will be searched unless excluded by other options
|
|
27
|
+
# Example Single association ==> includes_for_select_and_search: :contact_phone_numbers
|
|
28
|
+
# Example Array association ==> includes_for_select_and_search: [:contact_phone_numbers, :contact_addresses, :contact_notes]
|
|
29
|
+
# Default: if blank, no joins will be applied
|
|
30
|
+
# :includes_for_search_only => Similar to :joins, except these are sql LEFT OUTER JOINS and are only used for searching. If search_text is blank, this option is ignored
|
|
31
|
+
# IMPORTANT NOTE: These fields should NOT select statement, the fields in these sub-tables will be searched unless excluded by other options
|
|
32
|
+
# Example Single association ==> includes_for_search_only: :contact_phone_numbers
|
|
33
|
+
# Example Array association ==> includes_for_search_only: [:contact_phone_numbers, :contact_addresses, :contact_notes]
|
|
34
|
+
# Default: if blank, no joins will be applied
|
|
35
|
+
# :default_where => The where statement that will be applied in all cases. These are not excluded from counts.
|
|
36
|
+
# this is useful, when the entire set is actually just a subset of the overall data set
|
|
37
|
+
# for example: a database may have 1000's of contacts, but any particular user will only be allowed access to a subset of that data
|
|
38
|
+
# in this case the default_scope would be something like "user_id = 100"
|
|
39
|
+
# :where_string => This is simple an additional where clause to be added to the search
|
|
40
|
+
# Example: where_string: "contacts.contact_type = 'Investors'"
|
|
41
|
+
# Default: if blank, no additional conditions will be added
|
|
42
|
+
# :individual_column_filters => Used to apply search to an individual or columns
|
|
43
|
+
# This was originally developed to support column filters for jquery datatables, but can be used separately.
|
|
44
|
+
# It expects an array of hashes with as of now 3 values
|
|
45
|
+
# { name: name of db column
|
|
46
|
+
# operator: sql operator such as LIKE, =, IS NOT NULL IN, etc.
|
|
47
|
+
# value: what value are we searching for
|
|
48
|
+
# }
|
|
49
|
+
# IMPORTANT NOTE: if no operator is provided, it is assumed we are doing a LIKE '%value%' query
|
|
50
|
+
# See method self.prep_datatables_column_filter if you are using jquery datatables
|
|
51
|
+
# Example: [{ name: 'contacts.last_name', operator: '=', value: "'williams'"} => contacts.last_name = "williams" },
|
|
52
|
+
# { name: 'contacts.first_name', operator: 'IN', value: "('Wilma', 'Sam')"},
|
|
53
|
+
# { name: 'contacts.comments', value: "some note text"}
|
|
54
|
+
# ]
|
|
55
|
+
# For Datatables example:
|
|
56
|
+
# individual_column_filters: MarskalSearch.prep_datatables_column_filter(params)
|
|
57
|
+
# :search_only_these_data_types => Search only the datatypes specified
|
|
58
|
+
# Example: datatypes [:string, :text]
|
|
59
|
+
# Default: if blank, all data types will be searched unless excluded by other options
|
|
60
|
+
# :search_only_these_fields ==> used to limit the search to a particular field or fields. Very useful for a single column search, but can allow multiple as well
|
|
61
|
+
# Example Single Field: search_only_these_fields: "contacts.last_name"
|
|
62
|
+
# Example Multiple Fields: search_only_these_fields: ["contacts.last_name", "contacts.first_name_name", "contacts.company_name"]
|
|
63
|
+
# :do_not_search_these_fields => Exclude these specific fields from the search
|
|
64
|
+
# Example Single Field: do_not_search_these_fields: "contacts.salary"
|
|
65
|
+
# Example Multiple Fields: do_not_search_these_fields: ["contacts.salary", "contacts.birthday"]
|
|
66
|
+
# :ignore_default_search_field_exclusions => by default certain fields are excluded (such as id fields and rails timestamps) from the text search
|
|
67
|
+
# IF this is passed as true, these default excluded fields will NOT be excluded and will be searched
|
|
68
|
+
# Example: ignore_default_search_field_exclusions: true
|
|
69
|
+
# IMPORTANT NOTE: see the method default_field_excluded? for details on what fields get excluded from the search
|
|
70
|
+
# Default: false (meaning the special fields WILL be excluded from search)
|
|
71
|
+
# :case_sensitive ==> Determines whether the search is case sensitive
|
|
72
|
+
# IMPORTANT NOTE: This was tested, using the default setting for mysql which ignores case, so in this case the option has no value, but I left it in for other configurations
|
|
73
|
+
# Example: case_sensitive: true
|
|
74
|
+
# Default: true (case must match if your db is configured to allow case sensitivity )
|
|
75
|
+
# :order_string => This is simply the order string for the sql statement
|
|
76
|
+
# Example: order_string: order by "contacts.last_name, contacts.first_name"
|
|
77
|
+
# Default: if blank, no order will be added
|
|
78
|
+
# :offset ==> For larger queries we may want to get a chunk at a time, offset is the starting point for that chunk..to be used in conjunction with limit useful for pagination
|
|
79
|
+
# offset: 10 (start at the 11th record)
|
|
80
|
+
# offset: 0 (start at the 1st record)
|
|
81
|
+
# default: no offset is set, so search will start from beginning
|
|
82
|
+
# :limit ==> The maximum number of records to get..regardless of the amount of records that would be returned
|
|
83
|
+
# limit: 50 (retrive no more than 50 records)
|
|
84
|
+
# default: no limit, all records are retrieved
|
|
85
|
+
# :page ==> The page number to go to. NOTE: IF offset and page are both provided, the page parameter is ignored
|
|
86
|
+
# if there are greater than max pages, then the last page will be used
|
|
87
|
+
# page: 5 (goto page 5)
|
|
88
|
+
# default: no limit, all records are retrieved
|
|
89
|
+
# :pass_back ==> when results are requested this hash is simply passed back to the calling program as is, no changes
|
|
90
|
+
# IMPORTANT NOTE: :offset and :limit have not effect on the count and count_filtered methods, these methods will consider the entire data set
|
|
91
|
+
|
|
92
|
+
class MarskalSearch
|
|
93
|
+
COLUMN_WRAPPER_CHAR = "`"
|
|
94
|
+
MAX_LIMIT = 18446744073709551615 #mysql max to be used when an offset is given with no limit
|
|
95
|
+
EXCLUDE_SEARCHABLE_COLUMN_LIST = ['id','created_at', 'updated_at'] #by default eliminate these as 'searchable' columns
|
|
96
|
+
EXCLUDE_SEARCHABLE_COLUMN_ENDING_IN = '_id' # also fields like , user_id, contact_id
|
|
97
|
+
EXCLUDE_SEARCHABLE_COLUMN_DATATYPES = [:boolean] # exclude boolean fields from the text searches
|
|
98
|
+
DATATABLES = :datatables
|
|
99
|
+
JQGRID = :jqgrid
|
|
100
|
+
MARSKAL_API = :marskal_api
|
|
101
|
+
|
|
102
|
+
JQGRID_OPERATORS =[{ op: "eq", newop: '=', mask: '' },
|
|
103
|
+
{ op: "ne", newop: '!=', mask: '' },
|
|
104
|
+
{ op: "lt", newop: '<', mask: '' },
|
|
105
|
+
{ op: "le", newop: '<=', mask: '' },
|
|
106
|
+
{ op: "gt", newop: '>', mask: '' },
|
|
107
|
+
{ op: "ge", newop: '>=', mask: '' },
|
|
108
|
+
{ op: "in", newop: 'IN', mask: "([fld])" },
|
|
109
|
+
{ op: "ni", newop: 'NOT IN', mask: "([fld])" },
|
|
110
|
+
{ op: "bw", newop: 'LIKE', mask: "'[fld]%'" },
|
|
111
|
+
{ op: "bn", newop: '"NOT LIKE', mask: "'[fld]%'" },
|
|
112
|
+
{ op: "ew", newop: 'LIKE', mask: "'%[fld]'" },
|
|
113
|
+
{ op: "en", newop: 'NOT LIKE', mask: "'%[fld]'" },
|
|
114
|
+
{ op: "cn", newop: 'LIKE', mask: "'%[fld]%'" },
|
|
115
|
+
{ op: "nc", newop: 'NOT LIKE', mask: "'%[fld]%'" },
|
|
116
|
+
{ op: "nu", newop: 'IS NULL', mask: nil },
|
|
117
|
+
{ op: "nn", newop: 'IS NOT NULL', mask: nil }
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
MANUAL_SQL_SHORT_CODES = ['<', '>', '!=', '=', '>=', '<=', '::', '!::', '%', '!%','~', '!~', '^', '!^']
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#these are the available options
|
|
124
|
+
VARIABLES = <<-eos
|
|
125
|
+
:select_columns,
|
|
126
|
+
:not_distinct,
|
|
127
|
+
:joins, :includes_for_select_and_search, :includes_for_search_only,
|
|
128
|
+
:default_where, :where_string, :search_only_these_data_types,
|
|
129
|
+
:individual_column_filters,
|
|
130
|
+
:search_only_these_fields, :do_not_search_these_fields,
|
|
131
|
+
:ignore_default_search_field_exclusions, :case_sensitive,
|
|
132
|
+
:order_string,
|
|
133
|
+
:offset, :limit, :page,
|
|
134
|
+
:pass_back
|
|
135
|
+
eos
|
|
136
|
+
|
|
137
|
+
eval "attr_accessor #{VARIABLES}"
|
|
138
|
+
attr_accessor :search_text
|
|
139
|
+
attr_reader :klass
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
#intialize class
|
|
143
|
+
def initialize(p_class, p_search_text, options = {})
|
|
144
|
+
eval "options.assert_valid_keys(#{VARIABLES})" #only allow legit options
|
|
145
|
+
|
|
146
|
+
@klass = p_class.is_a?(String) ? (eval p_class.classify) : p_class #if string convert to a class
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
#Select parameters
|
|
150
|
+
self.select_columns = (options[:select_columns]||[]).empty? ? @klass.column_names : options[:select_columns]
|
|
151
|
+
@not_distinct = options.has_key?(:not_distinct)
|
|
152
|
+
|
|
153
|
+
#joins and include parameters
|
|
154
|
+
self.joins = options[:joins]
|
|
155
|
+
self.includes_for_select_and_search = options[:includes_for_select_and_search]
|
|
156
|
+
self.includes_for_search_only = options[:includes_for_search_only]
|
|
157
|
+
|
|
158
|
+
#where parameters
|
|
159
|
+
@default_where = options[:default_where]|| ''
|
|
160
|
+
@where_string = options[:where_string]|| ''
|
|
161
|
+
@search_text = p_search_text||''
|
|
162
|
+
self.individual_column_filters = options[:individual_column_filters]
|
|
163
|
+
self.search_only_these_fields = options[:search_only_these_fields]
|
|
164
|
+
self.do_not_search_these_fields = options[:do_not_search_these_fields]
|
|
165
|
+
self.search_only_these_data_types = options[:search_only_these_data_types]
|
|
166
|
+
@case_sensitive = options[:case_sensitive] || true
|
|
167
|
+
@ignore_default_search_field_exclusions = options[:ignore_default_search_field_exclusions] || false
|
|
168
|
+
|
|
169
|
+
#order parameters
|
|
170
|
+
@order_string = options[:order_string]|| ''
|
|
171
|
+
@limit = options[:limit]
|
|
172
|
+
|
|
173
|
+
#sql retrieval parameters
|
|
174
|
+
if options[:offset].nil?
|
|
175
|
+
@offset = options[:page].nil? ? options[:offset] : ((options[:page].to_i * @limit) - @limit)
|
|
176
|
+
else
|
|
177
|
+
@offset = options[:offset]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
#other parameters
|
|
182
|
+
@pass_back = options.has_key?(:pass_back) ? options[:pass_back] : nil #simply stores a hash that will be passed back as is..no changes
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def valid_limit
|
|
188
|
+
@limit <= 0 ? MAX_LIMIT : @limit
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
#make sure an array is returned
|
|
192
|
+
def select_columns=(p_columns)
|
|
193
|
+
@select_columns = Array(p_columns)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def select_string(p_prepare_for_count = false)
|
|
197
|
+
l_select_string = ''
|
|
198
|
+
unless @select_columns.blank?
|
|
199
|
+
#ran into an issue with counting matching the actual result set...when you do a count, null values are not considered, so
|
|
200
|
+
#to ensure we consider all fields, we apply an IFNULL(field, '') to get around this problem mau 10/2014
|
|
201
|
+
if p_prepare_for_count && @not_distinct
|
|
202
|
+
l_select_string = '*'
|
|
203
|
+
elsif p_prepare_for_count
|
|
204
|
+
l_select_string = wrap_columns(@select_columns).sql_null_to_blank.to_string_no_brackets_or_quotes
|
|
205
|
+
else
|
|
206
|
+
l_select_string = wrap_columns(@select_columns).to_string_no_brackets_or_quotes
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
l_select_string #return resulting string
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
#make sure an array is returned
|
|
213
|
+
def search_only_these_fields=(p_fields)
|
|
214
|
+
@search_only_these_fields= Array(p_fields).uniq
|
|
215
|
+
end
|
|
216
|
+
#make sure an array is returned
|
|
217
|
+
def do_not_search_these_fields=(p_fields)
|
|
218
|
+
@do_not_search_these_fields= Array(p_fields).uniq
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
#make sure an array is returned
|
|
222
|
+
def joins=(p_joins)
|
|
223
|
+
@joins = Array(p_joins).uniq
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
#make sure an array is returned
|
|
227
|
+
def includes_for_select_and_search=(p_includes)
|
|
228
|
+
@includes_for_select_and_search = Array(p_includes).uniq
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
#make sure an array is returned
|
|
232
|
+
def includes_for_search_only=(p_includes)
|
|
233
|
+
@includes_for_search_only = Array(p_includes).uniq
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
#make sure an array is returned
|
|
237
|
+
def search_only_these_data_types=(p_search_only_these_data_types)
|
|
238
|
+
@search_only_these_data_types = Array(p_search_only_these_data_types).uniq
|
|
239
|
+
end
|
|
240
|
+
#make sure an array is returned
|
|
241
|
+
def individual_column_filters=(p_individual_column_filters)
|
|
242
|
+
@individual_column_filters = Array(p_individual_column_filters).uniq
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
#get the searchable fields based on the current settings
|
|
246
|
+
def searchable_fields
|
|
247
|
+
@search_only_these_fields.empty? ? marskal_searchable_fields(@klass, combine_joins) : @search_only_these_fields
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# take all the associations as passed via the options and build them into more usable joins for mysql, while maintaining them in rails format
|
|
251
|
+
def combine_joins
|
|
252
|
+
l_combine_joins = [] #start with empty array this eventually contain an array of hashes
|
|
253
|
+
# hash will contain :klass => ActiveRecord class of the table to be joined
|
|
254
|
+
# :join_sql => if needed The sql required to properly process the join, if not the the join association name (ex :contact_phone_numbers) will remain in tact
|
|
255
|
+
# :alias => beacue the same table may be included in the inner join and outer joins, we provide for an alias to prevent ambiguous column errors
|
|
256
|
+
|
|
257
|
+
#TODO: This will likely fall apart if two select_and_search includes come from same table, in that case we probably will have to resort to an alias as we did above, need to test sep/2014 MAU
|
|
258
|
+
#first process the standard rails joins as is
|
|
259
|
+
@joins.each_with_index do |l_association_symbol|
|
|
260
|
+
l_association_symbol = (eval l_association_symbol) unless l_association_symbol.is_a?(Symbol) #get the symbol for the sub-table class
|
|
261
|
+
l_association = @klass.marskal_find_association(l_association_symbol) #get the association
|
|
262
|
+
next if l_association.nil? #if we cant find it, then we just move on
|
|
263
|
+
l_combine_joins << { klass: l_association.derive_class_from_association, #otherwise we store it in our hash array
|
|
264
|
+
join_sql: l_association_symbol,
|
|
265
|
+
alias: nil
|
|
266
|
+
}
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
#TODO: This will likely fall apart if two select_and_search includes come from same table, in that case we probably will have to resort to an alias as we did above, need to test sep/2014 MAU
|
|
270
|
+
@includes_for_select_and_search.each_with_index do |l_association_symbol|
|
|
271
|
+
l_association_symbol = (eval l_association_symbol) unless l_association_symbol.is_a?(Symbol) #get the symbol for the sub-table class
|
|
272
|
+
l_association = @klass.marskal_find_association(l_association_symbol) #get the association
|
|
273
|
+
next if l_association.nil? #if we cant find it, then we just move on
|
|
274
|
+
l_join_hash = { klass: nil, join_sql: '', alias: nil } #set defaults
|
|
275
|
+
l_join_hash[:klass] = l_association.derive_class_from_association #get class
|
|
276
|
+
l_join_hash[:join_sql] = "LEFT JOIN #{@klass .joins(l_association_symbol).to_sql.split('INNER JOIN').last}" #convert from INNER JOIN TO LEFT JOIN
|
|
277
|
+
l_combine_joins << l_join_hash
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
#if there is text to search, then lets setup our outer join(s), otherwise no point to outer join and would cause perfomance degradation to include it
|
|
282
|
+
unless @search_text.blank?
|
|
283
|
+
@includes_for_search_only.each_with_index do |l_association_symbol, l_alias_ctr|
|
|
284
|
+
l_association_symbol = (eval l_association_symbol) unless l_association_symbol.is_a?(Symbol) #get the symbol for the sub-table class
|
|
285
|
+
l_association = @klass.marskal_find_association(l_association_symbol) #get the association
|
|
286
|
+
next if l_association.nil? #if we cant find it, then we just move on
|
|
287
|
+
|
|
288
|
+
l_join_hash = { klass: nil, join_sql: '', alias: nil } #set defaults
|
|
289
|
+
l_join_hash[:klass] = l_association.derive_class_from_association #get class
|
|
290
|
+
l_join_hash[:alias] = "alias#{l_alias_ctr}" #assign an alias to avoid ambiguous column errors
|
|
291
|
+
|
|
292
|
+
l_aliased_join_conditions =@klass.joins(l_association_symbol).to_sql.split('INNER JOIN').last.split(' ON ').last.gsub(l_join_hash[:klass].table_name, l_join_hash[:alias]) #replace table names with alis name
|
|
293
|
+
l_join_hash[:join_sql] = "LEFT OUTER JOIN `#{l_join_hash[:klass].table_name}` `#{l_join_hash[:alias]}` ON #{l_aliased_join_conditions}" #create an outer join
|
|
294
|
+
|
|
295
|
+
l_combine_joins << l_join_hash
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
l_combine_joins
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
#display only: completed where clause
|
|
303
|
+
#options: (The main and probably only reason for these options, is so the query can do a unfiltered count, probably for pagination and results output)
|
|
304
|
+
# :exclude_where_string ==> if true, the @where_string variable is not considered...default is false
|
|
305
|
+
# :exclude_search_text ==> if true, the @search_text variable is not considered...default is false
|
|
306
|
+
def complete_where_clause(p_options = {})
|
|
307
|
+
p_exclude_where = p_options[:exclude_where_string]||false
|
|
308
|
+
p_exclude_search_text = p_options[:exclude_search_text]||false
|
|
309
|
+
|
|
310
|
+
#are we excluding the where clause (probably for a count)
|
|
311
|
+
if p_exclude_where
|
|
312
|
+
l_where_clause = ''
|
|
313
|
+
else
|
|
314
|
+
l_where_clause = @where_string || '' #start with anything the caller passed if provided
|
|
315
|
+
l_col_where = combine_individual_column_filters
|
|
316
|
+
unless l_col_where.blank?
|
|
317
|
+
l_where_clause += ' AND ' unless l_where_clause.blank?
|
|
318
|
+
l_where_clause += l_col_where
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
#if we have text to search then build where clause and we are not excluding it
|
|
323
|
+
unless @search_text.blank? || p_exclude_search_text
|
|
324
|
+
fields = searchable_fields
|
|
325
|
+
text_condition = unless @case_sensitive
|
|
326
|
+
fields.collect { |f| "UCASE(#{f}) LIKE #{sanitize('%'+@search_text.upcase+'%')}" }.join " OR "
|
|
327
|
+
else
|
|
328
|
+
fields.collect { |f| "#{f} LIKE #{sanitize('%'+@search_text+'%')}" }.join " OR "
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
unless text_condition.blank?
|
|
332
|
+
l_where_clause += ' AND ' unless l_where_clause.blank?
|
|
333
|
+
l_where_clause += "( #{text_condition } ) " unless text_condition.blank?
|
|
334
|
+
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
return l_where_clause
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
#display the resulting sql (does not execute the query)
|
|
341
|
+
def to_sql
|
|
342
|
+
active_record_relation.to_sql
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
#retyurn tru if there is a where clause and it has vales
|
|
346
|
+
def has_where?
|
|
347
|
+
!complete_where_clause().blank?
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
#count completed unfiltered, no where_string, no search_text
|
|
351
|
+
#IMPORTANT_NOTE: The default scope will NEVER be excluded for counting purposes. It is essentially a fixed part of the query
|
|
352
|
+
#this is useful, when the entire set is actually just a subset of the overall data set
|
|
353
|
+
#for example: a database may have 1000's of contacts, but any particular user will only be allowed access to a subset of that data
|
|
354
|
+
#in this case the default_scope would be something like "user_id = 100"
|
|
355
|
+
def count_all
|
|
356
|
+
active_record_relation(exclude_order: true, exclude_search_text: true, exclude_where_string: true, exclude_offset_and_limit: true, prepare_select_for_count: true).count
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
#count partially filtered, apply where_string, do not apply search_text
|
|
360
|
+
def count_without_search_text
|
|
361
|
+
active_record_relation(exclude_order: true, exclude_search_text: true, exclude_offset_and_limit: true, prepare_select_for_count: true).count
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
#count the result sets with all filters applied
|
|
365
|
+
def count
|
|
366
|
+
active_record_relation(exclude_order: true, exclude_offset_and_limit: true, prepare_select_for_count: true).count
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
#do an active record 'pluck'
|
|
370
|
+
def pluck
|
|
371
|
+
@select_columns.empty? ? active_record_relation.pluck : active_record_relation.pluck(select_string)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
#do an active record 'pluck'
|
|
375
|
+
def pluck_with_names
|
|
376
|
+
l_records = select_string.blank? ? active_record_relation.pluck : active_record_relation.pluck(select_string)
|
|
377
|
+
l_data = []
|
|
378
|
+
l_records.each do |l_record|
|
|
379
|
+
l_hash = {}
|
|
380
|
+
@select_columns.each_with_index do |l_column, index|
|
|
381
|
+
l_hash[l_column.to_sym] = l_record.is_a?(Array) ? l_record[index] : l_record #if only one field, an Array is not returned, just the string
|
|
382
|
+
end
|
|
383
|
+
l_data << l_hash
|
|
384
|
+
end
|
|
385
|
+
l_data
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
#:format => default is an Array of ActiveRecord Objects
|
|
389
|
+
#:format => :datatables return in a compatible format with the jquery datatables plugin
|
|
390
|
+
def results(p_options = {})
|
|
391
|
+
l_relation = active_record_relation
|
|
392
|
+
p_options.assert_valid_keys :format #allow only legit options
|
|
393
|
+
|
|
394
|
+
#now execute and return data in requested format
|
|
395
|
+
if p_options[:format] == DATATABLES
|
|
396
|
+
l_results = { recordsTotal: count_all, recordsFiltered: count, data: pluck }.merge(@pass_back || {})
|
|
397
|
+
elsif p_options[:format] == JQGRID
|
|
398
|
+
l_results = { total: calc_pages(), records: count, rows: pluck_with_names }.merge(@pass_back || {})
|
|
399
|
+
elsif p_options[:format] == MARSKAL_API
|
|
400
|
+
l_results = full_page_vars(pluck_with_names)
|
|
401
|
+
else
|
|
402
|
+
l_results = full_page_vars(l_relation.to_a)
|
|
403
|
+
end
|
|
404
|
+
return l_results
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def full_page_vars(p_rows)
|
|
408
|
+
l_result = {
|
|
409
|
+
filteredCount: count
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
l_result[:unfilteredRowCount] = has_where? ? count_all() : l_result[:filteredCount]
|
|
413
|
+
|
|
414
|
+
l_result[:pageTotal] = calc_pages(l_result[:filteredCount])
|
|
415
|
+
l_result[:limit] = self.limit unless self.limit.nil?
|
|
416
|
+
|
|
417
|
+
if !p_rows.empty? && (l_result[:offset].to_i < l_result[:filteredCount])
|
|
418
|
+
l_result[:offset] = self.offset unless self.offset.nil?
|
|
419
|
+
l_result[:currentPage] = calc_current_page(l_result[:filteredCount])
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
l_result[:pass_back] = @pass_back unless @pass_back.nil?
|
|
423
|
+
l_result[:rows] = p_rows
|
|
424
|
+
|
|
425
|
+
l_result
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def attach_pass_back(p_results)
|
|
429
|
+
p_results.merge!({ pass_back: @pass_back }) unless @pass_back.nil?
|
|
430
|
+
p_results
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
#display only: completed where clause
|
|
434
|
+
#options: (The main and probably only reason for these options, is so the query can do a unfiltered count, probably for pagination and results output)
|
|
435
|
+
# :exclude_where_string ==> if true, the @where_string variable is not considered...default is false
|
|
436
|
+
# :exclude_search_text ==> if true, the @search_text variable is not considered...default is false
|
|
437
|
+
# :exclude_order ==> if true, the @order_string variable is not considered...default is false
|
|
438
|
+
def active_record_relation(p_options = {})
|
|
439
|
+
#p_options[:exclude_where_string] ==> gets passed along to complete_where_clause
|
|
440
|
+
#p_options[:exclude_search_text] ==> gets passed along to complete_where_clause
|
|
441
|
+
p_exclude_order = p_options[:exclude_order]||false
|
|
442
|
+
p_exclude_offset_and_limit = p_options[:exclude_offset_and_limit]||false
|
|
443
|
+
p_prepare_select_for_count = p_options[:prepare_select_for_count]||false
|
|
444
|
+
|
|
445
|
+
l_relation = @klass.where(@default_where||'')
|
|
446
|
+
l_relation.merge! @klass.distinct unless @not_distinct #establish a starting point to the relation
|
|
447
|
+
l_relation.merge!(@klass.select(select_string(p_prepare_select_for_count))) unless @select_columns.empty? #apply select if available
|
|
448
|
+
|
|
449
|
+
joins = combine_joins.map { |join| join[:join_sql]} #build all the joins
|
|
450
|
+
l_relation.merge!(@klass.joins(joins)) unless joins.blank? #apply joins if available
|
|
451
|
+
|
|
452
|
+
where_clause = complete_where_clause(p_options) #build WHERE CLAUSE AND SEARCH TEXT
|
|
453
|
+
l_relation.merge!(@klass.where(where_clause)) unless where_clause.blank? #apply if available
|
|
454
|
+
l_relation.merge!(@klass.order(@order_string)) unless @order_string.blank? || p_exclude_order #apply order if available
|
|
455
|
+
|
|
456
|
+
unless p_exclude_offset_and_limit
|
|
457
|
+
if @offset #apply offset and/or limit as requested
|
|
458
|
+
l_relation.merge!(@klass.offset(@offset).limit(valid_limit))
|
|
459
|
+
elsif @limit
|
|
460
|
+
l_relation.merge!(@klass.limit(valid_limit))
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
return l_relation
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
private
|
|
468
|
+
# Return the default set of fields to search on
|
|
469
|
+
def marskal_searchable_fields(p_class, p_join_hash, p_table_alias = nil)
|
|
470
|
+
fields = []
|
|
471
|
+
p_join_hash ||= []
|
|
472
|
+
p_class.columns.each do |col| #get all the columns for this class (table)
|
|
473
|
+
p_include_field = true #assume we can use this in the search until it is explicitly excluded
|
|
474
|
+
unless @search_only_these_data_types.empty?
|
|
475
|
+
p_include_field = false unless @search_only_these_data_types.include?(col.type) #exclude if the data_types is to be excluded
|
|
476
|
+
end
|
|
477
|
+
unless @ignore_default_search_field_exclusions
|
|
478
|
+
p_include_field = false if default_field_excluded?(col)
|
|
479
|
+
end
|
|
480
|
+
if p_include_field #if we still plan to include this field, then lets run past our exclusion list
|
|
481
|
+
p_include_field = false if @do_not_search_these_fields.include?("#{p_class.table_name}.#{col.name}")
|
|
482
|
+
end
|
|
483
|
+
fields << "#{p_table_alias||p_class.table_name}.#{col.name}" if p_include_field
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
l_aliased_tables = p_join_hash.reject { |j| j[:alias].nil? }.map {|j| j[:klass].table_name}
|
|
487
|
+
p_join_hash.each do |p_join_info|
|
|
488
|
+
next if p_join_info[:alias].nil? && l_aliased_tables.include?(p_join_info[:klass].table_name) #dont process if we are going to process as an alias, thats just duplicating the fields
|
|
489
|
+
klass = p_join_info[:klass]
|
|
490
|
+
fields += marskal_searchable_fields(klass, [], p_join_info[:alias])
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
return fields
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def default_field_excluded?(p_column)
|
|
497
|
+
l_approved = false
|
|
498
|
+
if !p_column.name.ends_with?(EXCLUDE_SEARCHABLE_COLUMN_ENDING_IN) && !(@do_not_search_these_fields + EXCLUDE_SEARCHABLE_COLUMN_LIST).include?(p_column.name) && !EXCLUDE_SEARCHABLE_COLUMN_DATATYPES.include?(p_column.type)
|
|
499
|
+
l_approved = true
|
|
500
|
+
end
|
|
501
|
+
!l_approved
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def sanitize(l_sql_string)
|
|
505
|
+
@klass.sanitize(l_sql_string)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def testme
|
|
509
|
+
# create User and Contact models with at least these fields
|
|
510
|
+
# User => id, last_name, first_name
|
|
511
|
+
# Contact => id, last_name, first_name, user_id
|
|
512
|
+
#
|
|
513
|
+
# The connect them in the models
|
|
514
|
+
# In User.rb => has_many :contacts
|
|
515
|
+
# In Contact.rb => belongs_to_user
|
|
516
|
+
#
|
|
517
|
+
# Populate some data and then you can experiment with these examples
|
|
518
|
+
#
|
|
519
|
+
|
|
520
|
+
#find where a user must have at least one contact, and 'mike' is found in either the users or the contacts table
|
|
521
|
+
#return the names from both tables and the user_id
|
|
522
|
+
#order by contacts last_name then first
|
|
523
|
+
#get the first 10 records
|
|
524
|
+
#Note 'new' cause the the creation not the execution, basically just prepares for other methods show further below
|
|
525
|
+
m = MarskalSearch.new(User, 'mike',
|
|
526
|
+
joins: :contacts,
|
|
527
|
+
select_columns: "users.last_name, users.first_name, users.id, contacts.last_name, contacts.first_name",
|
|
528
|
+
where_string: 'active = true',
|
|
529
|
+
order_string: 'contacts.last_name, contacts.first_name',
|
|
530
|
+
offset: 0,
|
|
531
|
+
limit: 10
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
#output method examples
|
|
535
|
+
m.count # how many records were found in search
|
|
536
|
+
m.count_without_search_text #apply the where_string if not blank, but exclude the search_text from count
|
|
537
|
+
m.count_all #how may records total without any filters or where clauses
|
|
538
|
+
m.to_sql #the sql statement as it will be passed to mysql or other sql client
|
|
539
|
+
m.results #execute query and return the results into an array of ActiveRecord Objects
|
|
540
|
+
m.pluck #execute query, but return the selected fields in a two dimensional array, just the values, not field names
|
|
541
|
+
m.complete_where_clause #just show me only the resulting qhere cluase based on current settings, (nothing is executed, display only)
|
|
542
|
+
m.combine_joins #show me the details of how the joins and includes options will be processed (nothing is executed, display only)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
#find where a user MAY or MAY NOT have at least one contact, and 'mike' is found in either the users or the contacts table
|
|
546
|
+
#return the names from both tables and the user_id
|
|
547
|
+
#order by contacts last_name then first
|
|
548
|
+
#get the first 10 records
|
|
549
|
+
m1 = MarskalSearch.new(User, 'mike',
|
|
550
|
+
includes_for_select_and_search: :contacts,
|
|
551
|
+
select_columns: "users.last_name, users.first_name, users.id, contacts.last_name, contacts.first_name",
|
|
552
|
+
where_string: 'active = true',
|
|
553
|
+
order_string: 'contacts.last_name, contacts.first_name',
|
|
554
|
+
offset: 0,
|
|
555
|
+
limit: 10
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
#find where a user MAY or MAY NOT have at least one contact, and 'mike' is found in either the users or the contacts table
|
|
560
|
+
#return the names from ONLY the users table and the user id
|
|
561
|
+
#order by users last_name then first
|
|
562
|
+
#get the first 10 records
|
|
563
|
+
#NOTE this is basically like saying, give me all the users that have mike in the user record or the related detail records in contacts
|
|
564
|
+
# even if the contacts table has 100 mikes connected with user, only a single record would be returned for teh user
|
|
565
|
+
#nothing from the contact record is return..it is simply there for search needs
|
|
566
|
+
m2 = MarskalSearch.new(User, 'mike',
|
|
567
|
+
includes_for_search_only: :contacts,
|
|
568
|
+
select_columns: "users.last_name, users.first_name, users.id",
|
|
569
|
+
where_string: 'active = true',
|
|
570
|
+
order_string: 'users.last_name, users.first_name',
|
|
571
|
+
offset: 0,
|
|
572
|
+
limit: 10
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
#these can be daisy chained as well
|
|
576
|
+
MarskalSearch.new(User, 'mike', includes_for_search_only: :contacts).count
|
|
577
|
+
MarskalSearch.new(User, 'mike', includes_for_search_only: :contacts).results
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
#the format expected is and array of hashes ex:
|
|
583
|
+
# { name: 'contacts.last_name', operator: '=', value: "'williams'"} => contacts.last_name = "williams"
|
|
584
|
+
# { name: 'contacts.last_name', operator: 'IN', value: "('williams', 'jones')"} => contacts.last_name IN ('williams', 'jones')
|
|
585
|
+
# { name: 'contacts.last_name', operator: nil, value: "jone"} => contacts.last_name LIKE ('%jones%') #DO NOT INCLUDE the SINGLE quotes for default processing, just the text itself
|
|
586
|
+
# NOTE: when operator is nil, then we default to a LIKE with % on both sides
|
|
587
|
+
#
|
|
588
|
+
# { name: 'contacts.last_name', operator: 'LIKE', value: "'jone%'"} => contacts.last_name LIKE ('jones%')
|
|
589
|
+
# NOTE: when operator is explicity defined as LIKE, then we do not apply the %, that is up to the caller of the function
|
|
590
|
+
#
|
|
591
|
+
# { name: 'contacts.last_name', operator: 'IS NOT NULL', value: nil} => contacts.last_name IS NOT NULL
|
|
592
|
+
#
|
|
593
|
+
# IMPORTANT NOTE: This does not use the @case_sensitive function, it is up to the caller to do that for now 10/2014
|
|
594
|
+
# TODO: Add 'OR' capabilities and grouping capabilities
|
|
595
|
+
#
|
|
596
|
+
def combine_individual_column_filters
|
|
597
|
+
l_where = ''
|
|
598
|
+
@individual_column_filters.each do |l_col_hash|
|
|
599
|
+
next if l_col_hash[:name].blank? or (l_col_hash[:operator].blank? && l_col_hash[:value].blank?) #if we dont have what we need just continue on
|
|
600
|
+
|
|
601
|
+
l_where += ' AND ' unless l_where.blank? #append existing query
|
|
602
|
+
|
|
603
|
+
if l_col_hash[:operator].blank? #if no operator is given, then the default will be used
|
|
604
|
+
l_condition = "LIKE '%#{l_col_hash[:value]}%' " #we assume this is a LIKE %searchtext% query
|
|
605
|
+
else
|
|
606
|
+
l_condition = "#{l_col_hash[:operator]} #{l_col_hash[:value]}" #otherwise, just use what the user passed to us
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
l_where += "( #{l_col_hash[:name]} #{l_condition} )" #build the statement
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
l_where.blank? ? '' : "( #{l_where} )"
|
|
613
|
+
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
#prepares jquery datatables column filters into a MarskalSearch compatible format
|
|
617
|
+
#Ex: MarskalSearch.individual_column_filters = MarskalSearch.prep_datatables_column_filter(params)
|
|
618
|
+
def self.prep_datatables_column_filter(p_params)
|
|
619
|
+
#
|
|
620
|
+
l_cols_with_values= []
|
|
621
|
+
p_params[:columns].each do |l_colptr|
|
|
622
|
+
l_cols_with_values << { name: l_colptr[1][:name], value: l_colptr[1][:search][:value] } unless l_colptr[1][:search][:value].blank?
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
((p_params[:marskal_params][:extra_data]||{}).try(:[], :column_filters)||{}).each do |k,v|
|
|
626
|
+
l_cols_with_values << { name: v[:name], operator: v[:operator], value: v[:value] } unless v.blank? || !v.is_a?(Hash)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
l_cols_with_values
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
#prepares jquery jqgrid column filters into a MarskalSearch compatible format
|
|
633
|
+
#Ex: MarskalSearch.individual_column_filters = MarskalSearch.prep_jqgrid_column_filter(params)
|
|
634
|
+
def self.prep_jqgrid_column_filter(p_params, p_options = {})
|
|
635
|
+
p_options.assert_valid_keys(:space_to_equal_fields)
|
|
636
|
+
l_filters = p_params['filters'].nil? ? [] : ActiveSupport::JSON.decode( p_params['filters'] )
|
|
637
|
+
|
|
638
|
+
#"{"groupOp":"AND","rules":[{"field":"security_role","op":"eq","data":"100,500"}]}" #sample of what is expected
|
|
639
|
+
l_cols_with_values= []
|
|
640
|
+
unless l_filters.empty? #if we do have filters
|
|
641
|
+
l_filters['rules'].each do |l_hash| #then we pull from rules params['rules'] sent by jqgrid
|
|
642
|
+
l_cols_with_values << jqgrid_operators(l_hash, p_options) #then we format for MarskalSearch
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
l_cols_with_values #return array of conditions
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
#prepares a hash friendly to MarskalSearch column filter handling routines
|
|
651
|
+
#expects a hash as sent by the jqgrid plugin for column filters
|
|
652
|
+
def self.jqgrid_operators(p_hash, p_options = {})
|
|
653
|
+
p_options.assert_valid_keys(:space_to_equal_fields)
|
|
654
|
+
|
|
655
|
+
l_value = ''
|
|
656
|
+
l_new_op = 'LIKE'
|
|
657
|
+
|
|
658
|
+
#check for manual short cuts or else just return values provided
|
|
659
|
+
l_sql_op, l_filter_value, l_found = check_for_manual_short_code(p_hash['op'], p_hash['data'])
|
|
660
|
+
|
|
661
|
+
if l_found #we have a manual override
|
|
662
|
+
l_value = l_filter_value #then lets set the values
|
|
663
|
+
l_new_op = l_sql_op #to be used and skip the normal process
|
|
664
|
+
else
|
|
665
|
+
JQGRID_OPERATORS.each do |l_ops| #loop through all the various operators
|
|
666
|
+
if l_ops[:op] == l_sql_op #if we found the operator in either jqgrid or sql format then we use the proper sql operator
|
|
667
|
+
l_new_op = l_ops[:newop] #then save the mysql equivalent
|
|
668
|
+
if l_new_op.include?('IN') #for 'IN' and 'NOT IN' we need to prepare the data properly
|
|
669
|
+
l_value = l_filter_value.split(',').prepare_for_sql_in_clause
|
|
670
|
+
elsif l_ops[:mask].nil?
|
|
671
|
+
l_value = '' #else we simply replace the mas [fld] with our field value from jqgrid filter
|
|
672
|
+
elsif Array(p_options[:space_to_equal_fields]).include?(p_hash['field']) && l_ops[:op] == "bw" && l_filter_value.slice(-1,1) == ' '
|
|
673
|
+
l_new_op = '='
|
|
674
|
+
l_value = "'#{l_filter_value.strip}'"
|
|
675
|
+
else
|
|
676
|
+
l_value = l_ops[:mask].blank? ? l_filter_value : l_ops[:mask].gsub('[fld]',l_filter_value) #else we simply replace the mas [fld] with our field value from jqgrid filter
|
|
677
|
+
end
|
|
678
|
+
break #we found our operator, so no need to look any further
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
{ name: p_hash['field'], operator: l_new_op, value: l_value } #return MarskalSearch filter hash
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def self.check_for_manual_short_code(p_default_op, p_val)
|
|
687
|
+
l_op = p_default_op
|
|
688
|
+
l_val = p_val
|
|
689
|
+
l_found = false
|
|
690
|
+
if MANUAL_SQL_SHORT_CODES.include?(p_val.split.first)
|
|
691
|
+
idx = p_val.index(' ')
|
|
692
|
+
unless idx.nil? || idx >= p_val.length
|
|
693
|
+
l_val = "'#{p_val[idx+1..p_val.length]}'"
|
|
694
|
+
l_op = p_val.split.first.sub('!%', 'NOT LIKE').sub('%', 'LIKE').sub("!^", 'NOT IN').sub("^", 'IN').sub('!::', 'NOT BETWEEN').sub('::', 'BETWEEN').sub("!~", "NOT CONTAINS").sub("~", "CONTAINS")
|
|
695
|
+
if l_op == 'BETWEEN'
|
|
696
|
+
l_val.gsub!('&&', "' AND '")
|
|
697
|
+
elsif l_op.include?("CONTAINS")
|
|
698
|
+
l_op.sub!('CONTAINS', 'LIKE')
|
|
699
|
+
l_val = "'%#{p_val[idx+1..p_val.length]}%'"
|
|
700
|
+
elsif l_op.include?("IN")
|
|
701
|
+
l_val = "( #{l_val.gsub(',', "','")} )".gsub(",''", '') #buil;d in list and then clean out any empty strings
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
l_found = true
|
|
705
|
+
end
|
|
706
|
+
puts "==> #{l_op}, #{l_val}, #{l_found}"
|
|
707
|
+
return l_op, l_val, l_found
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def self.prepare_jqgrid_column_names(p_marskal_params, p_hash_key = 'name')
|
|
711
|
+
x = p_marskal_params['colModel']
|
|
712
|
+
(p_marskal_params['colModel']||[]).collect { |l_col_hash| "#{l_col_hash[p_hash_key]}" }.reject { |c| not_db_column?(c) }
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
#return true if if not a db column, for now thats 'cb' (checkbox in jqgrid)
|
|
716
|
+
# or any field that has the text 'nodb_' in the name (prefable should start with nodb_, e.g. nodb_display_field)
|
|
717
|
+
def self.not_db_column?(p_col)
|
|
718
|
+
p_col.downcase == 'cb' || p_col.downcase.include?("nodb_")
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def calc_pages(p_count = self.count)
|
|
722
|
+
#if no limit was set, then it has to be pages 1
|
|
723
|
+
if self.limit.nil?
|
|
724
|
+
l_pages = 1
|
|
725
|
+
else
|
|
726
|
+
l_extra_page = ((p_count % self.limit)) == 0 ? 0 : 1 #if we have an exact count dont add page, other wise add one more page
|
|
727
|
+
l_pages = (p_count / self.limit).to_i + l_extra_page
|
|
728
|
+
end
|
|
729
|
+
l_pages
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def calc_current_page(p_total_pages = calc_pages())
|
|
733
|
+
#if offset == 0, then it has to be pages 1
|
|
734
|
+
(self.offset.to_i == 0) ? 1 : (self.offset/self.limit).to_i + 1
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
#concatenate a bunch of queries together sepearted by AND
|
|
739
|
+
def self.concat_query( *args )
|
|
740
|
+
l_args_not_empty = args.delete_if {|x| x.blank?}
|
|
741
|
+
|
|
742
|
+
return l_args_not_empty.first unless l_args_not_empty.length > 1 #if only one string, just return it
|
|
743
|
+
|
|
744
|
+
l_query = ""
|
|
745
|
+
l_args_not_empty.each do |l_append_query|
|
|
746
|
+
l_query = self.append_sql_where_if_true(l_query, true, "( #{l_append_query} )" )
|
|
747
|
+
end
|
|
748
|
+
l_query
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def self.append_sql_where_if_true(p_where, p_condition, p_sql_to_append )
|
|
752
|
+
p_condition = false if p_condition.nil?
|
|
753
|
+
if p_condition
|
|
754
|
+
return p_where + (p_where.blank? ? '' : ' AND ') + p_sql_to_append
|
|
755
|
+
else
|
|
756
|
+
return p_where
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def wrap_columns(p_columns)
|
|
761
|
+
p_columns.map {|p_column| "#{COLUMN_WRAPPER_CHAR}#{p_column}#{COLUMN_WRAPPER_CHAR}"}
|
|
762
|
+
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
end
|