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 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
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ /.idea
11
+ /.idea/*.*
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.1.7
4
+ before_install: gem install bundler -v 1.10.6
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
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in marskal-search.gemspec
4
+ gemspec
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,9 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
7
+
8
+ import "./lib/tasks/filter_shortcuts.rake"
9
+
@@ -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
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ Rails.application.routes.draw do
2
+
3
+ post 'marskal_jqgrid' => 'marskal_search#jqgrid'
4
+ get 'marskal_jqgrid' => 'marskal_search#jqgrid'
5
+
6
+ end
@@ -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,3 @@
1
+ class MarskalSearch
2
+ VERSION = "0.2.4"
3
+ 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