├── .gitignore ├── composer.json ├── Console └── Command │ ├── IndexerShell.php │ └── TrueShell.php ├── Controller └── Component │ └── SearcherComponent.php ├── readme.md └── Model └── Behavior └── SearchableBehavior.php /.gitignore: -------------------------------------------------------------------------------- 1 | gitty*.sh 2 | .gitup.dat 3 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kvz/elasticsearch", 3 | "type": "cakephp-plugin", 4 | "keywords": ["cakephp", "Elastic", "Search", "ElasticSearch", "ElasticPlugin"], 5 | "description": "CakePHP Plugin for ElasticSearch", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Kevin van Zonneveld", 10 | "email": "kevin@transloadit.com" 11 | } 12 | ], 13 | "require": { 14 | "composer/installers": "*" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Console/Command/IndexerShell.php: -------------------------------------------------------------------------------- 1 | out($str, 0); 8 | } 9 | 10 | public function help () { 11 | $this->info('Usage: '); 12 | $this->info(' fill [modelname]'); 13 | $this->info(' search (|_all) '); 14 | $this->info(''); 15 | } 16 | 17 | public function fill ($modelName = null) { 18 | $modelName = @$this->args[0]; 19 | if ($modelName === '_all' || !$modelName) { 20 | $Models = $this->allModels(true); 21 | // Purge all 22 | $x = $Models[0]->Behaviors->Searchable->execute($Models[0], 'DELETE', '', array('fullIndex' => true, )); 23 | } else { 24 | $Models = array(ClassRegistry::init($modelName)); 25 | } 26 | 27 | $cbProgress = array($this, 'nout'); 28 | 29 | foreach ($Models as $Model) { 30 | $this->info('> Indexing %s', $Model->name); 31 | if (false === ($count = $Model->elastic_fill($cbProgress))) { 32 | return $this->err( 33 | 'Error indexing model: %s. errors: %s', 34 | $Model->name, 35 | $Model->Behaviors->Searchable->errors 36 | ); 37 | } 38 | 39 | $this->out(''); 40 | 41 | $this->info( 42 | '%7s %18s have been added to the Elastic index', 43 | $count, 44 | $Model->name 45 | ); 46 | 47 | $this->out('', 2); 48 | } 49 | } 50 | 51 | public function search ($modelName = null, $query = null) { 52 | $modelName = @$this->args[0]; 53 | if ($modelName === '_all' || !$modelName) { 54 | $models = $this->allModels(); 55 | } else { 56 | $models = array($modelName); 57 | } 58 | 59 | foreach ($models as $modelName) { 60 | if ($query === null && !($query = @$this->args[1])) { 61 | return $this->err('Need to specify: $query'); 62 | } 63 | if (!($Model = ClassRegistry::init($modelName))) { 64 | return $this->err('Can\'t instantiate model: %s', $modelName); 65 | } 66 | 67 | $raw_results = $Model->elastic_search($query); 68 | if (is_string($raw_results)) { 69 | $this->crit($raw_results); 70 | } 71 | 72 | pr(compact('raw_results')); 73 | } 74 | } 75 | 76 | public function allModels ($instantiated = false) { 77 | App::uses('ModelBehavior', 'Model'); 78 | App::uses('SearchableBehavior', 'Elasticsearch.Model/Behavior'); 79 | return SearchableBehavior::allModels($instantiated); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Console/Command/TrueShell.php: -------------------------------------------------------------------------------- 1 | $val) { 25 | if (is_array($val)) { 26 | $val = json_encode($val); 27 | } elseif (is_object($val)) { 28 | $val = get_class($val); 29 | } elseif (!is_numeric($val) && !is_bool($val)) { 30 | $val = "'" . $val . "'"; 31 | } 32 | 33 | if (strlen($val) > 33) { 34 | $val = substr($val, 0, 30) . '...'; 35 | } 36 | 37 | $arr[] = $key . ': ' . $val; 38 | } 39 | return join(', ', $arr); 40 | } 41 | 42 | protected function _log ($level, $format, $arg1 = null, $arg2 = null, $arg3 = null) { 43 | $arguments = func_get_args(); 44 | $level = array_shift($arguments); 45 | $format = array_shift($arguments); 46 | $str = $format; 47 | 48 | if (count($arguments)) { 49 | foreach($arguments as $k => $v) { 50 | $arguments[$k] = $this->sensible($v, ''); 51 | } 52 | $str = vsprintf($str, $arguments); 53 | } 54 | 55 | $this->out($level . ': ' . $str); 56 | return $str; 57 | } 58 | 59 | /** 60 | * Logging methods 61 | * 62 | * Altought it says only accepts $message and $newlines it actually supports sprintf syntax 63 | * 64 | * @param string $format Valid sprintf format 65 | * @param mixed $arg1 Argument one 66 | * @param mixed $arg2 Argument two 67 | * @param mixed $argX Argument X 68 | */ 69 | public function crit ($message = null, $newlines = 1) { 70 | $args = func_get_args(); array_unshift($args, __FUNCTION__); 71 | $str = call_user_func_array(array($this, '_log'), $args); 72 | trigger_error($str, E_USER_ERROR); 73 | exit(1); 74 | } 75 | public function err ($message = null, $newlines = 1) { 76 | $args = func_get_args(); array_unshift($args, __FUNCTION__); 77 | $str = call_user_func_array(array($this, '_log'), $args); 78 | trigger_error($str, E_USER_ERROR); 79 | return false; 80 | } 81 | public function warn ($message = null, $newlines = 1) { 82 | $args = func_get_args(); array_unshift($args, __FUNCTION__); 83 | $str = call_user_func_array(array($this, '_log'), $args); 84 | return false; 85 | } 86 | public function info ($message = null, $newlines = 1) { 87 | $args = func_get_args(); array_unshift($args, __FUNCTION__); 88 | $str = call_user_func_array(array($this, '_log'), $args); 89 | return true; 90 | } 91 | 92 | public function humanize ($str) { 93 | return Inflector::humanize(trim(str_replace('/', ' ', $str))); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Controller/Component/SearcherComponent.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | class SearcherComponent extends Component { 8 | public $Controller; 9 | public $LeadModel; 10 | public $settings = array(); 11 | public $serializer; 12 | protected $_default = array( 13 | 14 | ); 15 | 16 | public function __construct(ComponentCollection $collection, $settings = array()) { 17 | $settings = Set::merge($this->_default, $settings); 18 | 19 | parent::__construct($collection, $settings); 20 | } 21 | 22 | public function initialize (Controller $Controller) { 23 | $this->Controller = $Controller; 24 | } 25 | 26 | public function searchAction ($ajax) { 27 | if ($this->opt('model') === '_all') { 28 | $this->LeadingModel = ClassRegistry::init($this->opt('leading_model')); 29 | $this->LeadingModel->fullIndex = true; 30 | } else { 31 | $this->LeadingModel = $this->isEnabled($this->Controller); 32 | $this->LeadingModel->fullIndex = false; 33 | } 34 | 35 | if (!$this->LeadingModel) { 36 | return null; 37 | } 38 | 39 | if ($this->Controller->action !== $this->mOpt($this->LeadingModel, 'searcher_action')) { 40 | return null; 41 | } 42 | 43 | 44 | 45 | if (!($query = @$this->Controller->passedArgs[$this->mOpt($this->LeadingModel, 'searcher_param')])) { 46 | if (!($query = @$this->Controller->data[$this->mOpt($this->LeadingModel, 'searcher_param')])) { 47 | return $this->err( 48 | 'No search query. ' 49 | ); 50 | } 51 | } 52 | 53 | $queryParams = array(); 54 | 55 | if ($ajax) { 56 | $queryParams['limit'] = 100; 57 | } 58 | 59 | $response = $this->search($query, $queryParams); 60 | 61 | if ($ajax) { 62 | return $this->respond($response); 63 | } 64 | $this->Controller->set('results', $response); 65 | $this->Controller->render('searcher'); 66 | } 67 | 68 | public function search ($query, $queryParams) { 69 | $raw_results = $this->LeadingModel->elastic_search($query, $queryParams); 70 | 71 | if (is_string($raw_results)) { 72 | return $this->err('Error while doing search: %s', $raw_results); 73 | } 74 | if (!is_array($raw_results)) { 75 | return $this->err('Received invalid raw_results: %s', $raw_results); 76 | } 77 | if (empty($raw_results)) { 78 | return array(); 79 | } 80 | 81 | $i = 0; 82 | $cats = array(); 83 | $results = array(); 84 | 85 | foreach ($raw_results as $result) { 86 | $this->_enrich($result); 87 | 88 | // Add te response 89 | $results[$i] = $result; 90 | $cats[$i] = $result['category']; 91 | 92 | $i++; 93 | } 94 | 95 | $response = Set::sort($results, '/category', 'asc'); 96 | 97 | return $response; 98 | } 99 | 100 | protected function _enrich(&$result) { 101 | $result['label'] = @$result['data']['_label']; 102 | $result['descr'] = @$result['data']['_descr']; 103 | $result['url'] = @$result['data']['_url']; 104 | $result['model'] = @$result['data']['_model']; 105 | $result['category'] = @$result['data']['_model_title']; 106 | 107 | if (($html = @$result['highlights']['_label'][0])) { 108 | $result['html'] = $html; 109 | } else { 110 | $result['html'] = $result['label']; 111 | } 112 | } 113 | 114 | public function err ($format, $arg1 = null, $arg2 = null, $arg3 = null) { 115 | $arguments = func_get_args(); 116 | $format = array_shift($arguments); 117 | 118 | $str = $format; 119 | if (count($arguments)) { 120 | foreach($arguments as $k => $v) { 121 | $arguments[$k] = is_scalar($v) ? $v : json_encode($v); 122 | } 123 | $str = vsprintf($str, $arguments); 124 | } 125 | 126 | return $this->respond(array( 127 | 'errors' => explode('; ', $str), 128 | )); 129 | } 130 | 131 | public function respond ($response) { 132 | Configure::write('debug', 0); 133 | 134 | if (!headers_sent()) { 135 | header('Content-type: application/json'); 136 | header('Cache-Control: no-cache, must-revalidate'); 137 | header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); 138 | } 139 | 140 | $serializer = $this->mOpt($this->LeadingModel, 'searcher_serializer'); 141 | 142 | if (@$GLOBALS['XHPROF_ON'] && @$GLOBALS['XHPROF_NAMESPACE'] && @$GLOBALS['TIME_START']) { 143 | $xhprof_data = xhprof_disable(); 144 | $xhprof_runs = new XHProfRuns_Default(); 145 | $run_id = $xhprof_runs->save_run($xhprof_data, $GLOBALS['XHPROF_NAMESPACE']); 146 | $parsetime = number_format(getmicrotime() - $GLOBALS['TIME_START'], 3); 147 | $xhprof = sprintf( 148 | 'http://%s%s/xhprof/xhprof_html/index.php?run=%s&source=%s', 149 | $_SERVER['HTTP_HOST'], 150 | Configure::read('App.urlpath'), 151 | $run_id, 152 | $GLOBALS['XHPROF_NAMESPACE'] 153 | ); 154 | $response['@' . $parsetime] = $xhprof; 155 | } 156 | 157 | if (!is_callable($serializer)) { 158 | echo json_encode(array( 159 | 'errors' => array('Serializer ' . $serializer . ' was not callable', ), 160 | )); 161 | } else { 162 | echo call_user_func($serializer, $response); 163 | } 164 | 165 | die(); 166 | } 167 | 168 | /** 169 | * Returns appropriate Model or false on not active 170 | * 171 | * @return mixed Object or false on failure 172 | */ 173 | public function isEnabled ($Controller) { 174 | if (!isset($Controller)) { 175 | return false; 176 | } 177 | if (!isset($Controller->modelClass)) { 178 | return false; 179 | } 180 | 181 | $modelName = $Controller->modelClass; 182 | if (!isset($Controller->$modelName)) { 183 | return false; 184 | } 185 | if (!is_object($Controller->$modelName)) { 186 | return false; 187 | } 188 | 189 | $Model = $Controller->$modelName; 190 | 191 | return ($Model->Behaviors->attached('Searchable') && $Model->elastic_enabled()); 192 | } 193 | 194 | public function mOpt ($Model, $key) { 195 | return @$Model->Behaviors->Searchable->settings[$Model->alias][$key]; 196 | } 197 | 198 | public function opt ($key) { 199 | return @$this->settings[$key]; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## About 2 | 3 | Elastic search [was recently used to index the Firefox4 twitter stream](http://pedroalves-bi.blogspot.com/2011/03/firefox-4-twitter-and-nosql.html) 4 | and make it searchable. It's based on Lucene and has a simple JSON based interface 5 | that you can use to store objects and search through them (for instance even with CURL). 6 | 7 | This also makes it easy to have your search indexes be updated in realtime 8 | whenever your CakePHP models change data. Cause basically all we'd have to 9 | do is do a Curl PUT, DELETE, etc to also make the change in Elastisearch 10 | with every afterSave and afterDelete. 11 | 12 | This plugin provides 13 | 14 | - a behavior to automatically update your indexes 15 | - a shell task to do full index fills 16 | - a generic search component that you can attach to your AppController and will intercept 17 | search actions on enabled models. Will return results in JSON format for easy 18 | AJAX integration. 19 | 20 | ## Installation 21 | 22 | You'll need the PHP curl libraries installed. 23 | 24 | ### Server 25 | 26 | On [Debian/Ubuntu](http://www.elasticsearch.org/tutorials/2010/07/02/setting-up-elasticsearch-on-debian.html) 27 | 28 | ### CakePHP Plugin 29 | 30 | As a fake submodule 31 | 32 | ```bash 33 | cd ${YOURAPP}/Plugin 34 | git clone git://github.com/kvz/cakephp-elasticsearch-plugin.git Elasticsearch 35 | ``` 36 | 37 | As a real submodule 38 | 39 | ```bash 40 | cd ${REPO_ROOT} 41 | git submodule add git://github.com/kvz/cakephp-elasticsearch-plugin.git ${YOURAPP}/Plugin/Elasticsearch 42 | ``` 43 | 44 | Using composer 45 | 46 | ```javascript 47 | "require": { 48 | "kvz/elasticsearch": "dev-master" 49 | } 50 | ``` 51 | 52 | ## Integration 53 | 54 | ### Database 55 | 56 | `Config/database.php` 57 | 58 | ```php 59 | '127.0.0.1', 63 | 'port' => '9200', 64 | ); 65 | // ... etc 66 | ?> 67 | ``` 68 | 69 | ### Model 70 | 71 | `Models/Ticket.php` (minimal example) 72 | 73 | ```php 74 | array( 77 | 78 | ), 79 | // ... etc 80 | ); 81 | ?> 82 | ``` 83 | 84 | `Models/Ticket.php` (with raw sql for huge datasets) 85 | 86 | ```php 87 | array( 90 | 'index_chunksize' => 1000, // per row, not per parent object anymore.. 91 | 'index_find_params' => ' 92 | SELECT 93 | `tickets`.`cc` AS \'Ticket/cc\', 94 | `tickets`.`id` AS \'Ticket/id\', 95 | `tickets`.`subject` AS \'Ticket/subject\', 96 | `tickets`.`from` AS \'Ticket/from\', 97 | `tickets`.`created` AS \'Ticket/created\', 98 | `customers`.`customer_id` AS \'Customer/customer_id\', 99 | `customers`.`name` AS \'Customer/name\', 100 | `ticket_responses`.`id` AS \'TicketResponse/{n}/id\', 101 | `ticket_responses`.`from` AS \'TicketResponse/{n}/from\', 102 | `ticket_responses`.`created` AS \'TicketResponse/{n}/created\' 103 | FROM `tickets` 104 | LEFT JOIN `ticket_responses` ON `ticket_responses`.`ticket_id` = `tickets.id` 105 | LEFT JOIN `customers` ON `customers`.`customer_id` = `tickets`.`customer_id` 106 | WHERE 1=1 107 | {single_placeholder} 108 | {offset_limit_placeholder} 109 | ', 110 | ), 111 | // ... etc 112 | ); 113 | ?> 114 | ``` 115 | 116 | 117 | `Models/Ticket.php` (full example) 118 | 119 | ```php 120 | array( 123 | 'debug_traces' => false, 124 | 'searcher_enabled' => false, 125 | 'searcher_action' => 'searcher', 126 | 'searcher_param' => 'q', 127 | 'searcher_serializer' => 'json_encode', 128 | 'fake_fields' => array( 129 | '_label' => array('Product/description', 'BasketItem/description'), 130 | ), 131 | 'index_name' => 'main', 132 | 'index_chunksize' => 10000, 133 | 'index_find_params' => array( 134 | 'limit' => 1, 135 | 'fields' => array( 136 | // It's important you name your fields. 137 | 'subject', 138 | 'from', 139 | ), 140 | 'contain' => array( 141 | 'Customer' => array( 142 | // It's important you name your fields. 143 | 'fields' => array( 144 | 'id', 145 | 'name', 146 | ), 147 | ), 148 | 'TicketResponse' => array( 149 | // It's important you name your fields. 150 | 'fields' => array( 151 | 'id', 152 | 'content', 153 | 'created', 154 | ), 155 | ), 156 | 'TicketObjectLink' => array( 157 | // It's important you name your fields. 158 | 'fields' => array( 159 | 'foreign_model', 160 | 'foreign_id', 161 | ), 162 | ), 163 | 'TicketPriority' => array( 164 | // It's important you name your fields. 165 | 'fields' => array( 166 | 'code', 167 | 'from', 168 | ), 169 | ), 170 | 'TicketQueue' => array( 171 | // It's important you name your fields. 172 | 'fields' => array( 173 | 'name', 174 | ), 175 | ), 176 | ), 177 | 'order' => array( 178 | 'Ticket.id' => 'DESC', 179 | ), 180 | ), 181 | 'highlight' => array( 182 | 'pre_tags' => array(''), 183 | 'post_tags' => array(''), 184 | 'fields' => array( 185 | '_all' => array( 186 | 'fragment_size' => 200, 187 | 'number_of_fragments' => 1, 188 | ), 189 | ), 190 | ), 191 | 'realtime_update' => false, 192 | 'error_handler' => 'php', 193 | 'static_url_generator' => array('{model}', 'url'), 194 | 'enforce' => array( 195 | 'Customer/id' => 123, 196 | // or a callback: '#Customer/id' => array('LiveUser', 'id'), 197 | ), 198 | 'highlight_excludes' => array( 199 | // if you're always restricting results by customer, that 200 | // query should probably not be part of your highlight 201 | // instead of dumping _all and going over all fields except Customer/id, 202 | // you can also exclude it: 203 | 'Customer/id', 204 | ), 205 | ), 206 | ); 207 | ?> 208 | ``` 209 | 210 | ### Controller 211 | 212 | To automatically enable a `//searcher` url on all models 213 | that have elastic search enabled, use: 214 | 215 | `Controller/AppController.php` 216 | 217 | ```php 218 | 224 | ``` 225 | 226 | This component will only actually fire when the Controller->modelClass 227 | has the searchable behavior attached. 228 | 229 | I chose for this method (vs a dedicated SearchesController) so ACLing is easier. 230 | e.g. You may already have an ACL for /tickets/*, so /tickets/search will automatically 231 | be restricted the same way. 232 | 233 | #### Generic search 234 | 235 | If you want to search on all models, you could make a dedicated search controller 236 | and instruct to search on everything like so: 237 | 238 | ```php 239 | array( 243 | 'model' => '_all', 244 | 'leading_model' => 'Ticket', 245 | ), 246 | // ... etc 247 | ); 248 | 249 | public function searcher () { 250 | $this->Searcher->searchAction($this->RequestHandler->isAjax()); 251 | } 252 | } 253 | ?> 254 | ``` 255 | 256 | One known limitation is that the Elasticsearch plugin will only look at the 257 | first configured Model for configuration parameters like `searcher_param` 258 | and `searcher_action`. 259 | 260 | 261 | ## Try it 262 | 263 | From your shell: 264 | 265 | ```bash 266 | # Fill all indexes 267 | ./cake Elasticsearch.indexer fill _all 268 | 269 | # Fill index with tickets 270 | ./cake Elasticsearch.indexer fill Ticket 271 | 272 | # Try a ticket search from commandline 273 | ./cake Elasticsearch.indexer search Ticket Hello 274 | ``` 275 | 276 | From your browser 277 | 278 | ```bash 279 | http://www.example.com/tickets/searcher/q:*kevin* 280 | ``` 281 | 282 | ## jQuery integration 283 | 284 | Let's look at an integration example that uses 285 | [jQuery UI's autocomplete](http://docs.jquery.com/UI/Autocomplete). 286 | 287 | Assuming you have included that library, and have an input field with attributes 288 | `id="main-search"` and `target="/tickets/searcher/q:*{query}*"`: 289 | 290 | ```javascript 291 | // Main-search 292 | $(document).ready(function () { 293 | $("#main-search").autocomplete({ 294 | source: function(request, response) { 295 | $.getJSON($("#main-search").attr('target').replace('{query}', request.term), null, response); 296 | }, 297 | delay: 100, 298 | select: function(event, ui) { 299 | var id = 0; 300 | if ((id = ui.item.id)) { 301 | location.href = ui.item.url; 302 | alert('Selected: #' + id + ': ' + ui.item.url); 303 | } 304 | return false; 305 | } 306 | }).data( "autocomplete" )._renderItem = function( ul, item ) { 307 | return $("
  • ") 308 | .data("item.autocomplete", item) 309 | .append("" + item.html + "
    " + item.descr + "
    ") 310 | .appendTo(ul); 311 | }; 312 | }); 313 | ``` 314 | 315 | ## Note 316 | 317 | - There also is an unmaintained legacy [cakephp 1.3 branch](https://github.com/kvz/cakephp-elasticsearch-plugin/tree/cake-1.3) 318 | 319 | ## Useful commands 320 | 321 | ```bash 322 | # Get Status 323 | curl -XGET 'http://127.0.0.1:9200/_all/_status?pretty=true' 324 | 325 | # Dangerous: Delete an entire index 326 | curl -XDELETE 'http://127.0.0.1:9200/main' 327 | 328 | # Dangerous: Delete an entire type 329 | curl -XDELETE 'http://127.0.0.1:9200/main/ticket' 330 | 331 | # Get all tickets 332 | curl -XGET http://127.0.0.1:9200/main/ticket/_search -d '{ 333 | "query" : { 334 | "field" : { 335 | "_all" : "**" 336 | } 337 | } 338 | }' 339 | 340 | # Get everything 341 | curl -XGET http://127.0.0.1:9200/main/_search?pretty=true -d '{ 342 | "query" : { 343 | "field" : { 344 | "_all" : "**" 345 | } 346 | }, 347 | "size" : 1000000 348 | }' 349 | 350 | # Dangerous: Delete an entire type 351 | curl -XDELETE 'http://127.0.0.1:9200/main/ticket' 352 | 353 | # Refresh index 354 | curl -XPOST 'http://127.0.0.1:9200/main/_refresh' 355 | ``` 356 | -------------------------------------------------------------------------------- /Model/Behavior/SearchableBehavior.php: -------------------------------------------------------------------------------- 1 | 'opt', 15 | '/elastic_search/' => 'search', 16 | '/elastic_fill/' => 'fill', 17 | '/elastic_enabled/' => 'enabled', 18 | ); 19 | 20 | protected $_default = array( 21 | 'highlight' => array( 22 | 'pre_tags' => array(''), 23 | 'post_tags' => array(''), 24 | 'fields' => array( 25 | '_all' => array( 26 | 'fragment_size' => 60, 27 | 'number_of_fragments' => 1, 28 | ), 29 | ), 30 | ), 31 | 'highlight_excludes' => array( 32 | ), 33 | 'curl_connect_timeout' => 3, 34 | 'curl_total_timeout' => 0, 35 | 'fake_fields' => array(), 36 | 'debug_traces' => false, 37 | 'searcher_enabled' => true, 38 | 'searcher_action' => 'searcher', 39 | 'searcher_param' => 'q', 40 | 'searcher_serializer' => 'json_encode', 41 | 'realtime_update' => true, 42 | 'cb_progress' => false, 43 | 'limit' => 1000, 44 | 'index_find_params' => array(), 45 | 'index_name' => 'main', 46 | 'index_chunksize' => 10000, 47 | 'static_url_generator' => array('{model}', 'modelUrl'), 48 | 'error_handler' => 'disklog', 49 | 'enforce' => array(), 50 | 'fields' => '_all', 51 | 'fields_excludes' => array( 52 | '_url', 53 | '_model', 54 | '_label', 55 | ), 56 | ); 57 | 58 | public $localFields = array( 59 | '_label', 60 | '_descr', 61 | '_model', 62 | '_model_title', 63 | '_model_titles', 64 | '_url', 65 | ); 66 | 67 | protected $_Client; 68 | protected $_fields = array(); 69 | public $settings = array(); 70 | public $errors = array(); 71 | 72 | protected static $_autoLoaderPrefix = ''; 73 | public static function createAutoloader ($prefix = '', $path = null) { 74 | self::$_autoLoaderPrefix = $prefix; 75 | if ($path) { 76 | set_include_path(get_include_path() . PATH_SEPARATOR . $path); 77 | } 78 | spl_autoload_register(array('SearchableBehavior', 'autoloader')); 79 | } 80 | public static function autoloader ($className) { 81 | if (substr($className, 0, strlen(self::$_autoLoaderPrefix)) !== self::$_autoLoaderPrefix) { 82 | // Only autoload stuff for which we created this loader 83 | #echo 'Not trying to autoload ' . $className. "\n"; 84 | return; 85 | } 86 | 87 | $path = str_replace('_', '/', $className) . '.php'; 88 | include($path); 89 | } 90 | 91 | /** 92 | * Goes through filesystem and returns all models that have 93 | * elasticsearch enabled. 94 | * 95 | * @return 96 | */ 97 | public static function allModels ($instantiated = false) { 98 | $models = array(); 99 | foreach (glob(APP . 'Model' . DS . '*.php') as $filePath) { 100 | $models[] = self::_getModel($filePath, $instantiated); 101 | } 102 | foreach (CakePlugin::loaded() as $plugin) { 103 | foreach (glob(CakePlugin::path($plugin) . 'Model' . DS . '*.php') as $filePath) { 104 | $models[] = self::_getModel($filePath, $instantiated, $plugin); 105 | } 106 | } 107 | return array_values(array_filter($models)); 108 | } 109 | 110 | protected static function _getModel($filePath, $instantiated, $plugin = null) { 111 | // Hacky, but still better than instantiating all Models: 112 | $buf = file_get_contents($filePath); 113 | if (false === stripos($buf, 'Elasticsearch.Searchable')) { 114 | return false; 115 | } 116 | 117 | if ($plugin) { 118 | $plugin .= '.'; 119 | } 120 | $base = basename($filePath, '.php'); 121 | $modelName = Inflector::classify($base); 122 | $Model = ClassRegistry::init($plugin . $modelName); 123 | if (!$Model->Behaviors->attached('Searchable') || !$Model->elastic_enabled()) { 124 | return false; 125 | } 126 | 127 | if ($instantiated) { 128 | return $Model; 129 | } 130 | return $plugin . $modelName; 131 | } 132 | 133 | public function afterSave (Model $Model, $created, $options = array()) { 134 | if (!$this->opt($Model, 'realtime_update')) { 135 | return true; 136 | } 137 | if (!($data = @$Model->data[$Model->alias])) { 138 | return true; 139 | } 140 | 141 | if (!($id = @$data[$Model->primaryKey])) { 142 | $id = $Model->id; 143 | } 144 | 145 | $res = $this->_fillChunk($Model, null, null, $id); 146 | 147 | // Index needs a moment to be updated 148 | $this->execute($Model, 'POST', '_refresh'); 149 | 150 | return !!$res; 151 | } 152 | 153 | public function fill () { 154 | $args = func_get_args(); 155 | 156 | // Strip model from args if needed 157 | if (is_object(@$args[0])) { 158 | $Model = array_shift($args); 159 | } else { 160 | return $this->err('First argument needs to be a model'); 161 | } 162 | 163 | // Strip method from args if needed (e.g. when called via $Model->mappedMethod()) 164 | if (is_string(@$args[0])) { 165 | foreach ($this->mapMethods as $pattern => $meth) { 166 | if (preg_match($pattern, $args[0])) { 167 | $method = array_shift($args); 168 | break; 169 | } 170 | } 171 | } 172 | 173 | // cbProgress 174 | $cbProgress = array_key_exists(0, $args) ? array_shift($args) : null; 175 | if (is_callable($cbProgress)) { 176 | $this->opt($Model, 'cb_progress', $cbProgress); 177 | } 178 | 179 | // Create index 180 | $u = $this->execute($Model, 'PUT', '', array('fullIndex' => true, )); 181 | $d = $this->execute($Model, 'DELETE', ''); 182 | $o = $this->execute($Model, 'POST', '_refresh', array('fullIndex' => true, )); 183 | 184 | // Get records 185 | $offset = 0; 186 | $limit = $this->opt($Model, 'index_chunksize'); 187 | $count = 0; 188 | while (true) { 189 | $curCount = $this->_fillChunk($Model, $offset, $limit); 190 | $count += $curCount; 191 | 192 | if ($curCount < $limit) { 193 | $this->progress($Model, 'Reached curCount ' . $curCount . ' < ' . $limit); 194 | break; 195 | } 196 | $offset += $limit; 197 | } 198 | 199 | // Index needs a moment to be updated 200 | $this->execute($Model, 'POST', '_refresh', array('fullIndex' => true, )); 201 | 202 | return $count; 203 | } 204 | 205 | public function progress ($Model, $str) { 206 | $cbProgress = $this->opt($Model, 'cb_progress'); 207 | if (!is_callable($cbProgress)) { 208 | return; 209 | } 210 | 211 | return call_user_func($cbProgress, $str); 212 | } 213 | 214 | protected function _fillChunk ($Model, $offset, $limit, $id = null) { 215 | // Set params 216 | if (!($params = $this->opt($Model, 'index_find_params'))) { 217 | $params = array(); 218 | } 219 | 220 | $index_name = $this->opt($Model, 'index_name'); 221 | $type = $this->opt($Model, 'type'); 222 | 223 | 224 | $primKeyPath = $Model->alias . '/' . $Model->primaryKey; 225 | $labelKeyPath = $Model->alias . '/' . $Model->displayField; 226 | if (!empty($Model->labelField)) { 227 | $labelKeyPath = $Model->alias . '/' . $Model->labelField; 228 | } 229 | 230 | $descKeyPath = false; 231 | if (@$Model->descripField) { 232 | $descKeyPath = $Model->alias . '/' . @$Model->descripField; 233 | } 234 | 235 | $isQuery = is_string($params); 236 | if ($isQuery) { 237 | $sqlLimit = ''; 238 | 239 | if ($limit) { 240 | $sqlLimit = 'LIMIT ' . $limit; 241 | } 242 | if ($offset) { 243 | $sqlLimit = str_replace('LIMIT ', 'LIMIT ' . $offset .',', $sqlLimit); 244 | } 245 | 246 | $sql = $params; 247 | $sql = str_replace('{offset_limit_placeholder}', $sqlLimit, $sql); 248 | 249 | if ($id !== null) { 250 | $singleSql = 'AND `' . $Model->useTable . '`.`' . $Model->primaryKey . '` = "' . addslashes($id) . '"'; 251 | $sql = str_replace('{single_placeholder}', $singleSql, $sql); 252 | } else { 253 | $sql = str_replace('{single_placeholder}', '', $sql); 254 | } 255 | 256 | // Directly addressing datasource cause we don't want 257 | // any of Cake's array restructuring. We're going for raw 258 | // performance here, and we're flattening everything to go 259 | // into Elasticsearch anyway 260 | $DB = ConnectionManager::getDataSource($Model->useDbConfig); 261 | $this->progress($Model, '(select_start: ' . $offset . '-' . ($offset+$limit) . ')'); 262 | if (!($rawRes = $DB->execute($sql))) { 263 | return $this->err($Model, 'Error in query: %s. %s', $sql, mysql_error()); 264 | } 265 | 266 | $sqlCount = $rawRes->rowCount(); 267 | 268 | $results = array(); 269 | while ($row = $rawRes->fetch()) { 270 | $id = $row[$primKeyPath]; 271 | if (empty($results[$id])) { 272 | $childCnt = 0; 273 | } 274 | foreach ($row as $key => $val) { 275 | if ($key != 'queryString') { 276 | $results[$id][str_replace('{n}', $childCnt, $key)] = $val; 277 | } 278 | } 279 | $childCnt++; 280 | } 281 | } else { 282 | if ($id) { 283 | $params['conditions'][$Model->alias . '.' . $Model->primaryKey] = $id; 284 | } else { 285 | $params['offset'] = $offset; 286 | if (empty($params['limit'])) { 287 | $params['limit'] = $limit; 288 | } 289 | $this->progress($Model, '(select_start: ' . $params['offset'] . '-' . ($params['offset']+$params['limit']) . ')'); 290 | } 291 | 292 | if (!$Model->Behaviors->attached('Containable')) { 293 | $Model->Behaviors->attach('Containable'); 294 | } 295 | $results = $Model->find('all', $params); 296 | } 297 | 298 | // $sources = ConnectionManager::sourceList(); 299 | // $logs = array(); 300 | // foreach ($sources as $source): 301 | // $db =& ConnectionManager::getDataSource($source); 302 | // if (!$db->isInterfaceSupported('getLog')): 303 | // continue; 304 | // endif; 305 | // $logs[$source] = $db->getLog(); 306 | // endforeach; 307 | // prd(compact('logs')); 308 | 309 | if (empty($results)) { 310 | return array(); 311 | } 312 | 313 | // Add documents 314 | $urlCb = $this->opt($Model, 'static_url_generator'); 315 | if ($urlCb[0] === '{model}') { 316 | $urlCb[0] = $Model->name; 317 | } 318 | if (!method_exists($urlCb[0], $urlCb[1])) { 319 | $urlCb = false; 320 | } 321 | $commands = ""; 322 | $fake_fields = $this->opt($Model, 'fake_fields'); 323 | 324 | $docCount = 0; 325 | foreach ($results as $result) { 326 | if ($isQuery) { 327 | $doc = $result; 328 | } else { 329 | $doc = Set::flatten($result, '/'); 330 | } 331 | 332 | if (empty($doc[$primKeyPath])) { 333 | return $this->err( 334 | $Model, 335 | 'I need at least primary key: %s->%s inside the index data. Please include in the index_find_params', 336 | $Model->alias, 337 | $Model->primaryKey 338 | ); 339 | } 340 | 341 | $meta = array( 342 | '_index' => $index_name, 343 | '_type' => $type, 344 | '_id' => $doc[$primKeyPath], 345 | ); 346 | 347 | //$doc['_id'] = $doc[$primKeyPath]; 348 | 349 | if (!($meta['_id'] % 100)) { 350 | $this->progress($Model, '(compile: @' . $meta['_id'] . ')'); 351 | } 352 | 353 | $doc['_label'] = ''; 354 | if (array_key_exists($labelKeyPath, $doc)) { 355 | $doc['_label'] = $doc[$labelKeyPath]; 356 | } 357 | 358 | // FakeFields 359 | if (is_array(reset($fake_fields))) { 360 | foreach ($fake_fields as $fake_field => $xPaths) { 361 | $concats = array(); 362 | foreach ($xPaths as $xPath) { 363 | if (array_key_exists($xPath, $doc)) { 364 | $concats[] = $doc[$xPath]; 365 | } else { 366 | $concats[] = $xPath; 367 | } 368 | } 369 | 370 | $doc[$fake_field] = join(' ', $concats); 371 | } 372 | } 373 | 374 | $doc['_descr'] = ''; 375 | if ($descKeyPath && array_key_exists($descKeyPath, $doc)) { 376 | $doc['_descr'] = $doc[$descKeyPath]; 377 | } 378 | 379 | 380 | $doc['_model'] = $Model->name; 381 | 382 | if (!@$Model->title) { 383 | $Model->title = Inflector::humanize(Inflector::underscore($doc['_model'])); 384 | } 385 | $doc['_model_title'] = $Model->title; 386 | 387 | if (!@$Model->titlePlu) { 388 | $Model->titlePlu = Inflector::pluralize($Model->title); 389 | } 390 | $doc['_model_titles'] = $Model->titlePlu; 391 | 392 | $doc['_url'] = ''; 393 | if (is_array($urlCb)) { 394 | $doc['_url'] = call_user_func($urlCb, $meta['_id'], $doc['_model']); 395 | } 396 | 397 | $commands .= json_encode(array('create' => $meta)) . "\n"; 398 | $commands .= $this->_serializeDocument($Model, $doc) . "\n"; 399 | $docCount++; 400 | } 401 | 402 | $this->progress($Model, '(store)' . "\n"); 403 | 404 | if ($docCount == 1) { 405 | $res = $this->execute($Model, 'PUT', $meta['_id'], $doc); 406 | } else { 407 | $res = $this->execute($Model, 'PUT', '_bulk', $commands, array('prefix' => '', )); 408 | } 409 | 410 | if (is_string($res)) { 411 | return $this->err( 412 | $Model, 413 | 'Unable to add items. %s', 414 | $res 415 | ); 416 | } else { 417 | //$this->progress($Model, json_encode($res). "\n"); 418 | } 419 | // } else if (is_array(@$res['items'])) { 420 | // foreach ($res['items'] as $i => $payback) { 421 | // if (@$payback['create']['error']) { 422 | // printf( 423 | // 'Unable to create %s #%s. %s' . "\n", 424 | // $Model->alias, 425 | // @$payback['create']['_id'], 426 | // @$payback['create']['error'] 427 | // ); 428 | // } 429 | // } 430 | 431 | return @$sqlCount ? @$sqlCount : $docCount; 432 | } 433 | 434 | protected function _serializeDocument ($Model, $content) { 435 | $serializer = $this->opt($Model, 'searcher_serializer'); 436 | if (!is_callable($serializer)) { 437 | $content = json_encode($content); 438 | } else { 439 | $content = call_user_func($serializer, $content); 440 | } 441 | return $content; 442 | } 443 | 444 | protected function _queryParams ($Model, $queryParams, $keys) { 445 | foreach ($keys as $key) { 446 | if (array_key_exists($key, $queryParams)) { 447 | continue; 448 | } 449 | 450 | if (($opt = $this->opt($Model, $key))) { 451 | $queryParams[$key] = $opt; 452 | } else { 453 | $queryParams[$key] = null; 454 | } 455 | } 456 | 457 | return $queryParams; 458 | } 459 | 460 | public function execute ($Model, $method, $path, $payload = array(), $options = array()) { 461 | if (!array_key_exists('prefix', $options)) $options['prefix'] = null; 462 | if (!array_key_exists('fullIndex', $options)) $options['fullIndex'] = $Model->fullIndex; 463 | 464 | $conn = curl_init(); 465 | 466 | if ($options['prefix'] !== null) { 467 | $prefix = $options['prefix']; 468 | } else { 469 | $prefix = $this->opt($Model, 'index_name'); 470 | if (!$options['fullIndex']) { 471 | $prefix .= '/' . $this->opt($Model, 'type'); 472 | } 473 | $prefix .= '/'; 474 | } 475 | 476 | $path = $prefix . $path; 477 | 478 | $uri = sprintf( 479 | 'http://%s:%s/%s', 480 | $this->opt($Model, 'host'), 481 | $this->opt($Model, 'port'), 482 | $path 483 | ); 484 | 485 | // pr(compact('uri', 'method', 'payload')); 486 | curl_setopt($conn, CURLOPT_URL, $uri); 487 | curl_setopt($conn, CURLOPT_CONNECTTIMEOUT, $this->opt($Model, 'curl_connect_timeout')); 488 | curl_setopt($conn, CURLOPT_TIMEOUT, $this->opt($Model, 'curl_total_timeout')); 489 | curl_setopt($conn, CURLOPT_PORT, $this->opt($Model, 'port')); 490 | curl_setopt($conn, CURLOPT_RETURNTRANSFER, 1); 491 | curl_setopt($conn, CURLOPT_CUSTOMREQUEST, $method); 492 | 493 | if (!empty($payload)) { 494 | if (is_array($payload)) { 495 | $content = $this->_serializeDocument($Model, $payload); 496 | } else { 497 | $content = $payload; 498 | } 499 | 500 | // Escaping of / not necessary. Causes problems in base64 encoding of files 501 | $content = str_replace('\/', '/', $content); 502 | curl_setopt($conn, CURLOPT_POSTFIELDS, $content); 503 | } 504 | 505 | $json = curl_exec($conn); 506 | $response = json_decode($json, true); 507 | 508 | if (($e = curl_error($conn))) { 509 | return sprintf('Error from elasticsearch server while contacting %s (%s)', $uri, $e); 510 | } 511 | if (false === $response) { 512 | return sprintf('Invalid response from elasticsearch server while contacting %s (%s)', $uri, $json); 513 | } 514 | if (@$response['error']) { 515 | if ($response['error'] === 'ActionRequestValidationException[Validation Failed: 1: no requests added;]') { 516 | return $response; 517 | } 518 | return sprintf('Error from elasticsearch server while contacting %s (%s)', $uri, @$response['error']); 519 | } 520 | 521 | return $response; 522 | } 523 | 524 | public function query ($Model, $query, $queryParams) { 525 | $queryParams = $this->_queryParams($Model, $queryParams, array( 526 | 'enforce', 527 | 'highlight', 528 | 'limit', 529 | 'fields', 530 | )); 531 | 532 | $payload = array(); 533 | 534 | if ($queryParams['highlight']) { 535 | $payload['highlight'] = $queryParams['highlight']; 536 | } 537 | if ($queryParams['limit']) { 538 | $payload['size'] = $queryParams['limit']; 539 | } 540 | if (@$queryParams['sort']) { 541 | $payload['sort'] = $queryParams['sort']; 542 | } 543 | 544 | $payload['query']['bool']['must'][0]['query_string'] = array( 545 | 'query' => $query, 546 | 'use_dis_max' => true, 547 | ); 548 | if (is_array($queryParams['fields'])) { 549 | $payload['query']['bool']['must'][0]['query_string']['fields'] = $queryParams['fields']; 550 | } 551 | 552 | if ($queryParams['enforce']) { 553 | $i = count ($payload['query']['bool']['must']); 554 | $payload['query']['bool']['must'][$i]['term'] = $queryParams['enforce']; 555 | } 556 | 557 | return $payload; 558 | } 559 | 560 | /** 561 | * Search. Arguments can be different wether the call is made like 562 | * - $Model->elastic_search, or 563 | * - $this->search 564 | * that's why I eat&check away arguments with array_shift 565 | * 566 | * @return string 567 | */ 568 | public function search () { 569 | $args = func_get_args(); 570 | 571 | // Strip model from args if needed 572 | if (is_object(@$args[0])) { 573 | $LeadingModel = array_shift($args); 574 | } else if (is_string(@$args[0])) { 575 | $LeadingModel = ClassRegistry::init(array_shift($args)); 576 | } 577 | if (empty($LeadingModel)) { 578 | return $this->err('First argument needs to be a valid model'); 579 | } 580 | 581 | // Strip method from args if needed (e.g. when called via $Model->mappedMethod()) 582 | if (is_string(@$args[0])) { 583 | foreach ($this->mapMethods as $pattern => $meth) { 584 | if (preg_match($pattern, $args[0])) { 585 | $method = array_shift($args); 586 | break; 587 | } 588 | } 589 | } 590 | 591 | // No query! 592 | if (!($query = array_shift($args))) { 593 | return; 594 | } 595 | 596 | // queryParams 597 | $queryParams = array_key_exists(0, $args) ? array_shift($args) : array(); 598 | 599 | // Build Query 600 | $payload = $this->query($LeadingModel, $query, $queryParams); 601 | 602 | // Custom Elasticsearch CuRL Job 603 | $r = $this->execute($LeadingModel, 'GET', '_search', $payload); 604 | 605 | // String means error 606 | if (is_string($r)) { 607 | return $r; 608 | } 609 | 610 | $results = array(); 611 | foreach ($r['hits']['hits'] as $hit) { 612 | $results[] = array( 613 | 'data' => $hit['_source'], 614 | 'score' => $hit['_score'], 615 | 'id' => $hit['_id'], 616 | 'type' => $hit['_type'], 617 | 'highlights' => @$hit['highlight'], 618 | ); 619 | } 620 | 621 | return $results; 622 | } 623 | 624 | /** 625 | * Caching wrapper 626 | * 627 | * @todo: implement :) 628 | * 629 | * @param $Model 630 | * @return 631 | */ 632 | protected function _allFields ($Model, $unsetFields = null) { 633 | $key = join(',', array( 634 | $Model->name, 635 | $Model->fullIndex, 636 | )); 637 | 638 | if (!array_key_exists($key, $this->_fields)) { 639 | // @todo Persist 640 | $this->_fields[$key] = $this->__allFields($Model); 641 | } 642 | 643 | $fields = $this->_fields[$key]; 644 | 645 | // Filter 646 | if (is_array($unsetFields)) { 647 | $fields = array_diff($fields, $unsetFields); 648 | 649 | // Re-order nummerically so this will be a js array != object 650 | $fields = array_values($fields); 651 | } 652 | 653 | return $fields; 654 | } 655 | 656 | protected function __allFields ($Model) { 657 | $fields = $this->localFields; 658 | 659 | if ($Model->fullIndex === true) { 660 | $Models = SearchableBehavior::allModels(true); 661 | } else { 662 | $Models = array($Model); 663 | } 664 | 665 | foreach ($Models as $Model) { 666 | $modelAlias = $Model->alias; 667 | $modelFields = array(); 668 | $params = $this->opt($Model, 'index_find_params'); 669 | 670 | // If params is a custom query (possible for indexing speed) 671 | if (is_string($params)) { 672 | $pattern = '/\sAS\s\'(([a-z0-9_\{\}]+)(\/([a-z0-9_\{\}]+))+)\'/i'; 673 | if (preg_match_all($pattern, $params, $matches)) { 674 | $modelFields = $matches[1]; 675 | } 676 | } else { 677 | $flats = Set::flatten($params, '/'); 678 | foreach ($flats as $flat => $field) { 679 | $flat = '/' . $flat; 680 | if (false !== ($pos = strpos($flat, '/fields'))) { 681 | $flat = substr($flat, 0, $pos); 682 | $prefix = str_replace(array('/contain', '/fields', '/limit'), '' , $flat); 683 | 684 | if ($prefix === '') { 685 | $prefix = $modelAlias; 686 | } 687 | 688 | $field = $prefix . '/' . $field; 689 | 690 | if (substr($field, 0, 1) === '/') { 691 | $field = substr($field, 1); 692 | } 693 | 694 | $modelFields[] = $field; 695 | } 696 | } 697 | } 698 | 699 | // Merge model fields in overall fields, make unique 700 | $fields = array_unique(array_merge($fields, $modelFields)); 701 | 702 | // Replace {n} with range 0-3. May need to be configurable later on 703 | foreach ($fields as $i => $field) { 704 | if (false !== strpos($field, '{n}')) { 705 | for ($j = 0; $j <= 3; $j++) { 706 | $fields[] = str_replace('{n}', $j, $field); 707 | } 708 | unset($fields[$i]); 709 | } 710 | } 711 | 712 | // Re-order nummerically so this will be a js array != object 713 | $fields = array_values($fields); 714 | } 715 | 716 | return $fields; 717 | } 718 | 719 | protected function _filter_fields ($Model, $val) { 720 | if ($val === '_all' || empty($val)) { 721 | if (!$this->opt($Model, 'fields_excludes')) { 722 | $val = '_all'; 723 | } else { 724 | $val = $this->_allFields( 725 | $Model, 726 | $this->opt($Model, 'fields_excludes') 727 | ); 728 | } 729 | } 730 | 731 | return $val; 732 | } 733 | 734 | protected function _filter_enforce ($Model, $val) { 735 | foreach ($val as $k => $v) { 736 | if (substr($k, 0 ,1) === '#' && is_array($v)) { 737 | $args = $v; 738 | $Class = array_shift($args); 739 | $method = array_shift($args); 740 | 741 | $v = call_user_func_array(array($Class, $method), $args); 742 | // If null is returned, effictively remove key from enforce 743 | // params 744 | if ($v !== null) { 745 | $val[substr($k, 1)] = $v; 746 | } 747 | unset($val[$k]); 748 | } 749 | } 750 | 751 | return $val; 752 | } 753 | 754 | /** 755 | * Hack so you can now do highlights on '_all'. 756 | * Elasticsearch does not support that syntax for highlights yet, 757 | * just for queries. 758 | * 759 | * @param object $Model 760 | * @param array $val 761 | * 762 | * @return array 763 | */ 764 | protected function _filter_highlight ($Model, $val) { 765 | $val = Set::normalize($val); 766 | 767 | if (($params = @$val['fields']['_all'])) { 768 | unset($val['fields']['_all']); 769 | if (false !== ($k = array_search('_no_all', $val['fields'], true))) { 770 | return $val; 771 | } 772 | 773 | $fields = $this->_allFields( 774 | $Model, 775 | $this->opt($Model, 'highlight_excludes') 776 | ); 777 | 778 | // Copy original parameters to expanded fields 779 | if (is_array($fields)) { 780 | foreach ($fields as $field) { 781 | $val['fields'][$field] = $params; 782 | } 783 | } 784 | 785 | // If we exclude fields, exclude them for highlights as well 786 | foreach ($this->opt($Model, 'fields_excludes') as $field_exclude) { 787 | unset($val['fields'][$field_exclude]); 788 | } 789 | } 790 | 791 | return $val; 792 | } 793 | 794 | public function enabled ($Model, $method) { 795 | if ($this->opt($Model, 'searcher_enabled') === false) { 796 | return false; 797 | } 798 | return true; 799 | } 800 | 801 | public function setup (Model $Model, $settings = array()) { 802 | $this->settings[$Model->alias] = array_merge( 803 | $this->_default, 804 | $settings 805 | ); 806 | 807 | $DB = ConnectionManager::enumConnectionObjects(); 808 | 809 | $this->settings[$Model->alias]['host'] = $DB['elastic']['host']; 810 | $this->settings[$Model->alias]['port'] = $DB['elastic']['port']; 811 | 812 | //$this->settings[$Model->alias]['index_name'] = $this->opt($Model, 'index_name'); 813 | $this->settings[$Model->alias]['type'] = Inflector::underscore($Model->alias); 814 | } 815 | 816 | public function err ($Model, $format, $arg1 = null, $arg2 = null, $arg3 = null) { 817 | $arguments = func_get_args(); 818 | $Model = array_shift($arguments); 819 | $format = array_shift($arguments); 820 | 821 | $str = $format; 822 | if (count($arguments)) { 823 | foreach($arguments as $k => $v) { 824 | $arguments[$k] = $this->sensible($v); 825 | } 826 | $str = vsprintf($str, $arguments); 827 | } 828 | 829 | $this->errors[] = $str; 830 | 831 | if (@$this->settings[$Model->alias]['error_handler'] === 'php') { 832 | trigger_error($str, E_USER_ERROR); 833 | } else if (@$this->settings[$Model->alias]['error_handler'] === 'disklog') { 834 | CakeLog::error($str); 835 | } 836 | 837 | return false; 838 | } 839 | 840 | public function sensible ($arguments) { 841 | if (is_object($arguments)) { 842 | return get_class($arguments); 843 | } 844 | if (!is_array($arguments)) { 845 | if (!is_numeric($arguments) && !is_bool($arguments)) { 846 | $arguments = "'" . $arguments . "'"; 847 | } 848 | return $arguments; 849 | } 850 | $arr = array(); 851 | foreach ($arguments as $key => $val) { 852 | if (is_array($val)) { 853 | $val = json_encode($val); 854 | } elseif (is_object($val)) { 855 | $val = get_class($val); 856 | } elseif (!is_numeric($val) && !is_bool($val)) { 857 | $val = "'" . $val . "'"; 858 | } 859 | 860 | if (strlen($val) > 33) { 861 | $val = substr($val, 0, 30) . '...'; 862 | } 863 | 864 | $arr[] = $key . ': ' . $val; 865 | } 866 | return join(', ', $arr); 867 | } 868 | 869 | public function opt () { 870 | $args = func_get_args(); 871 | 872 | // Strip model from args if needed 873 | if (is_object($args[0])) { 874 | $Model = array_shift($args); 875 | } else { 876 | return $this->err('First argument needs to be a model'); 877 | } 878 | 879 | // Strip method from args if needed (e.g. when called via $Model->mappedMethod()) 880 | if (is_string($args[0])) { 881 | foreach ($this->mapMethods as $pattern => $meth) { 882 | if (preg_match($pattern, $args[0])) { 883 | $method = array_shift($args); 884 | break; 885 | } 886 | } 887 | } 888 | 889 | $count = count($args); 890 | $key = @$args[0]; 891 | $val = @$args[1]; 892 | if ($count > 1) { 893 | $this->settings[$Model->alias][$key] = $val; 894 | } else if ($count > 0) { 895 | if (!array_key_exists($key, $this->settings[$Model->alias])) { 896 | return $this->err( 897 | $Model, 898 | 'Option %s was not set', 899 | $key 900 | ); 901 | } 902 | 903 | $val = $this->settings[$Model->alias][$key]; 904 | 905 | // Filter with callback 906 | $cb = array($this, '_filter_' . $key); 907 | if (method_exists($cb[0], $cb[1])) { 908 | $val = call_user_func($cb, $Model, $val); 909 | } 910 | 911 | return $val; 912 | } else { 913 | return $this->err( 914 | $Model, 915 | 'Found remaining arguments: %s Opt needs more arguments (1 for Model; 1 more for getting, 2 more for setting)', 916 | $args 917 | ); 918 | } 919 | } 920 | } 921 | SearchableBehavior::createAutoloader('Elastica_'); 922 | --------------------------------------------------------------------------------