Nachdem ich sowieso eine Suchfunktion für mehrere Tabellen brauchte, kam mir die Idee diese gleich mit einer Fehlerkorrektur zu implementieren. Herausgekommen ist die unten aufgeführte Komponente. Sie kann im Controller wie folgt aufgerufen werden:
// im Kopf des Controllers
var $components = array(
'Search' => array(
'model' => 'Person',
'fields' => array('surname', 'given_names')
)
);
/* ... */
function search() {
/* Suchstring abfragen und von ungültigen Zeichen befreien ... */
if (($search = $this->Search->search($searchString)) !== false) {
$this->set($search);
}
}
Im View stehen dann die folgenden Variablen zur Verfügung:
$peoplegefundene Einträge – wurden mit paginate gesucht$searchStringder ursprüngliche Suchstring$didYouMeandie Verbesserung des ursprünglichen Strings$newSearchStringder neue Suchstring
Der Name der Variable mit den Einträgen wird entweder aus dem Wert für $model hergeleitet oder kann mit $table direkt angegeben werden.
Alle weiteren Fragen sollten sich aus dem Code beantworten.
Datei search.php:
<?php
/**
* SearchComponent
* Stellt eine fehlertolerante Suche zur Verfügung.
*
* @author ferdinand
* @license MIT
*
* Copyright (c) 2011 Ferdinand Keil
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge,
* publish, distribute, sublicense, and/or sell copies of the Software,´
* and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* ncluded in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
*/
class SearchComponent extends Object {
var $name = 'Search';
/*
* Model, das die Suche verwenden soll
*/
var $model = '';
/*
* Name der Tabelle für die Rückgabe (optional)
*/
var $table = '';
/*
* Felder die für Vorschläge durchsucht werden sollen
*/
var $fields = array();
/*
* Controller
*/
var $controller = null;
/*
* Cache mit allen Einträgen
*/
var $_allEntries = array();
/*
* Maximale Anzahl Begriffe die verbessert werden
*/
var $_limit = 2;
/*
* Maximale Längendifferenz zwischen Strings bei der Suche nach Alternativen
*/
var $_maxLenDiff = 4;
/*
* Minimale Länge für Strings bei der Suche nach Alternativen
*/
var $_minLen = 3;
/*
* Maximale Levenshtein Distanz zwischen String bei der Suche nach Alternativen
*/
var $_maxLevDist = 3;
/**
* Initialisiert die Component
*
* @param $controller Controller
* @param $settings Einstellungen
*/
function initialize(&$controller, $settings) {
foreach ($settings as $name => $value) {
if (empty($this->{$name})) {
$this->{$name} = $value;
}
}
if (empty($this->table)) {
$this->table = Inflector::tableize($this->model);
}
$this->controller =& $controller;
}
/**
* Fehlertolerante Such Funktion
* Tippfehler werden toleriert und ein alternativer Suchstring
* erzeugt. Auf dessen Basis wird die Suche erneut durchgeführt.
*
* @return array
*/
function search($searchString = '') {
if (empty($searchString)) {
return false;
}
$searchData = explode(' ', $searchString);
$conditions = array();
foreach ($searchData as $entry) {
$c = array();
foreach ($this->fields as $field) {
$c["{$this->model}.{$field} LIKE"] = "%{$entry}%";
}
$conditions['AND'][] = array(
'OR' => $c
);
}
${$this->table} = array();
$didYouMean = '';
$newSearchString = '';
$modifiedSearchData = array();
// Schritt 1:
// wenn kein Eintrag mit dem Suchstring gefunden wird,
// Alternativen für die Suchbegriffe suchen
if ($this->controller->{$this->model}->find('count', array('conditions' => $conditions)) == 0) {
// Schritt 1a:
// Array mit Begriffen durchlaufen
for ($i = 0; $i < min($this->_limit, count($searchData)); $i++) {
$currString = $searchData[$i];
$conditions = array('OR' => array());
foreach ($this->fields as $field) {
$conditions['OR']["{$this->model}.{$field} LIKE"] = "%{$currString}%";
}
$proposal = '';
// Schritt 2:
// prüfen ob die Anfragen an diesem Begriff scheitert
if ($this->controller->{$this->model}->find('count', array('conditions' => $conditions)) == 0) {
// Schritt 2a:
// wenn ja, Alternative für den Begriff suchen
$proposal = $this->_findProposal($currString);
if (!empty($proposal)) {
// wenn Alternative gefunden wurde, diese verwenden und
// weitere Suche abbrechen
$modifiedSearchData[] = $proposal;
$modifiedSearchData = array_merge($modifiedSearchData, array_slice($searchData, $i+1));
$didYouMean .= "<b>{$proposal}</b> ". implode(' ', array_slice($searchData, $i+1));
break;
} else {
// wenn keine Alternative gefunden wurde, Begriff
// durchstreichen
$didYouMean .= "<del>{$currString}</del> ";
}
} else {
// Schritt 2b:
// wenn Abfrage nicht an dem Begriff scheitert ihn
// weiter verwenden
$modifiedSearchData[] = $currString;
$didYouMean .= "{$currString} ";
}
}
// Schritt 3:
// prüfen ob eine funktionierende Abfrage zusammengekommen ist
if (!empty($modifiedSearchData)) {
// Schritt 3a:
// Abfrage ist ok, Daten abfragen
$conditions = array();
foreach ($modifiedSearchData as $entry) {
$c = array();
foreach ($this->fields as $field) {
$c["{$this->model}.{$field} LIKE"] = "%{$entry}%";
}
$conditions['AND'][] = array(
'OR' => $c
);
}
$this->{$this->model}->recursive = 0;
${$this->table} = $this->controller->paginate($this->model, $conditions);
$newSearchString = implode(' ', $modifiedSearchData);
if (count($searchData) > count($modifiedSearchData)) {
$didYouMean .= '<del>'. implode('</del> <del>', array_slice($searchData, $this->_limit)) .'</del>';
}
$didYouMean = trim($didYouMean);
} else {
// Schritt 3b:
// Abfrage ist nicht ok, Vorschläge verwerfen
$didYouMean = '';
$modifiedSearchData = array();
// paginate muss einmal aufgerufen werden
${$this->table} = $this->controller->paginate($this->model, array('0 = 1'));
}
} else {
// Schritt 1b:
// Abfrage ausführen
$this->{$this->model}->recursive = 0;
${$this->table} = $this->controller->paginate($this->model, $conditions);
}
// Variablen zurückgeben
return compact($this->table, 'searchString', 'didYouMean', 'newSearchString');
}
/**
* Suche nach Alternativbegriff
* Durchsucht die Datenbank nach einem alternativen Begriff.
*
* @param $needle Suchbegriff
* @return String
*/
function _findProposal($needle = null) {
if (empty($needle)) {
return '';
}
$proposal = '';
// Schritt 1:
// Für das Wort aus dem Such-String in der Datenbank nach Alternativen suchen.
// Caching
if (empty($this->_allEntries)) {
$this->_allEntries = $this->controller->{$this->model}->find('all', array('fields' => $this->fields, 'recursive' => -1));
}
$proposals = array();
$umlaute = array('ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss');
foreach ($this->_allEntries as $entry) {
foreach ($entry[$this->model] as $field) {
if (!empty($field)) {
// alle nicht Buchstaben Zeichen durch Leerzeichen ersetzen
$field = preg_replace('/[^a-zöäüß]/i', ' ', $field);
$words = explode(' ', $field);
foreach ($words as $word) {
// Word ignorieren falls
// * gefundenes und gesuchtes Wort gleich sind (nicht plausibel)
// * gefundenes Wort kürzer als 3 Zeichen ist
// * die Wörter um mehr als 4 Zeichen in der Länge abweichen
if (($word != $needle) && (strlen($word) > $this->_minLen) && (strlen($word) - strlen($needle) <= $this->_maxLenDiff)) {
$levenshteinDist = levenshtein(strtr($needle, $umlaute), strtr($word, $umlaute));
if (($levenshteinDist <= $this->_maxLevDist) && !isset($proposals[$word])) {
$proposals[$word] = $levenshteinDist;
}
}
}
}
}
}
// Schritt 2:
// Wenn eine Alternative gefunden wurde, diese verwenden, sonst die
// beste alternative anhand eines phonetischen Algorithmus suchen.
if (count($proposals) == 1) {
$proposal = current(array_keys($proposals));
} elseif (count($proposals) > 1) {
$best = '';
foreach (array_keys($proposals) as $word) {
if (levenshtein(soundex(strtr($needle, $umlaute)), soundex(strtr($word, $umlaute))) < levenshtein(soundex(strtr($needle, $umlaute)), soundex(strtr($best, $umlaute)))) {
$best = $word;
}
}
$proposal = $best;
}
// Vorschlag zurückgeben
return $proposal;
}
}
?>