Feed Rss



Jun 20 2011

SearchComponent für fehlertolerante Suche

category: Komponenten,PHP author:

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:

  • $people gefundene Einträge – wurden mit paginate gesucht
  • $searchString der ursprüngliche Suchstring
  • $didYouMean die Verbesserung des ursprünglichen Strings
  • $newSearchString der 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;
	}

}

?>

tag: , , ,


Nov 15 2010

date und die Zeitumstellung

category: Allgemein,PHP author:

Ich hatte folgendes Problem: ich will einen Kalender ausgeben. Dazu bestimme ich den ersten Tag des Monats und laufe dann Woche für Woche durch. Die date Funktion liefert dazu alle nötigen Informationen: Anzahl der Tage in einem Monat, Tag des Monats und den Wochentag als Zahl. Der Code sieht so ähnlich aus:

$startTime = mktime(0, 0, 0, $month, $day, $year);
$endTime = ...;

$curWeek = $startTime;
while (date('m', $curWeek) == date('m', $endTime) {
	$timeStep = min(
		8 - date('N', $curWeek), // eine Woche vor
		date('t', $curWeek) - date('j', $curWeek) + 1 // Ende des Monats
	);
	$nextWeek = $curWeek + 24 * 3600 * $timeStep;

	...
}

Das funktioniert soweit auch wunderbar, nur im Oktober gibt es ein Problem. Dank der Zeitumstellung ist der nämlich genau eine Stunde länger. Das berücksichtigt die Variable timeStep natürlich nicht. Die Alternative ist die Verwendung von strtotime. Beispiel:

$startTime = mktime(0, 0, 0, $month, $day, $year);
$endTime = ...;

$curWeek = $startTime;
while (date('m', $curWeek) == date('m', $endTime) {
	$timeStep = min(
		8 - date('N', $curWeek), // eine Woche vor
		date('t', $curWeek) - date('j', $curWeek) + 1 // Ende des Monats
	);
	$nextWeek = strtotime("+{$timeStep} day", $curWeek);

	...
}

Nov 11 2010

GROUP BY HAVING Statements

category: Datenbanken author:

Um GROUP BY HAVING Statements zu formulieren ist in CakePHP bis Version 1.3 leider noch ein Hack erforderlich (ob HAVING Teil von Version 2 wird weiß ich allerdings nicht). Ein SQL GROUP BY kann einfach durch den Parameter group bei einem beliebigen Query angegeben werden (3.7.3.1 find). HAVING kann dabei wie folgt verwendet werden:

$result = $this->Model->find('all', array(
	'group' => array('Model.parameter HAVING COUNT(*) >= 3'),
	... // weitere Optionen
};

Dieser Aufruf resultiert in folgendem Query:

SELECT `Model`.* FROM `model` AS `Model` GROUP BY `Model`.`parameter` HAVING COUNT(*) >= 3

Nov 11 2010

Model Callbacks und HABTM Beziehungen

category: Allgemein author:

Die Callback Funktionen im Model ermöglichen es Daten vor und nach dem Abfragen oder Abspeichern zu verändern (CakePHP Book: 3.7.7 Callback Methods). Das kann zum Beispiel nützlich sein um Ausgaben für einen Benutzer je nach Rolle anzupassen. Die Daten werden erst gar nicht abgefragt indem das Query vorher entsprechend verändert wird. Somit wird auf unterster Ebene sichergestellt, dass jeder nur die Daten sieht die er sehen darf.
Callbacks funktionieren allerdings nicht, wenn die Daten über eine has and belongs to many Relation abgerufen werden.

Beispiel:
Folgende Datenbank sei gegeben

+--------+      +-------------------+      +-------------+
| people | <--> | department_people | <--> | departments |
+--------+      +-------------------+      +-------------+

department_people verbindet people und department über eine HABTM Relation.

Model person.php:

class Person extends AppModel {

	var $name = 'Person';

	// beforeFind Callback
	function beforeFind($queryData) {
		echo 'beforeFind';
		$queryData = ... // queryData anpassen;
		return $queryData;
	}

	// find überschreiben
	function find($conditions = null, $fields = array(), $order = null, $recursive = null) {
		echo 'find';
		return parent::find($conditions, $fields, $order, $recursive);
	}

}

Controller departments_people_controller.php:

class DepartmentsPeopleController extends AppController {

	var $name = 'DepartmentsPeople';

	function index() {
		$res = $this->DepartmentsPeople->find('all');
		// Gibt beforeFind und find aus.
		$this->set(compact('res'));
	}

}

Controller departments_controller.php:

class DepartmentsController extends AppController {

	var $name = 'Departments';

	function index() {
		$res = $this->Departments->find('all');
		// Keine Ausgabe
		$this->set(compact('res'));
	}

}

Das Problem ist also, dass weder das Callback beforeFind noch die überschriebene Methode find aufgerufen werden, wenn eine HABTM Verknüpfung vorliegt. Die Folge ist, dass die Änderungen an queryData folgenlos bleiben. Es gibt allerdings eine Möglichkeit auf die Abfrage Einfluss zu nehmen: der conditions Parameter im Model department.

Model department.php:

class Department extends AppModel {

	var $name = 'Department';

	var $hasAndBelongsToMany = array(
			'Person' => array(
						'className' => 'Person',
						'joinTable' => 'departments_people',
						'conditions' => array( ... ) // beliebige SQL Befehle als Bedingung für die Abfrage; auch Substatements
			)
	);

}

Allerdings muss diese Bedingung in jedes Model das mit Person über eine HABTM Relation verknüpft ist eingefügt werden.

Warum CakePHP allerdings das Model bei der Abfrage der Verknüpften Modelle nicht berücksichtigt ist mir ein Rätsel. Möglicherweise aus Performanz Gründen.


Apr 28 2010

UPDATE statt INSERT

category: Datenbanken author:

Im Sinne von Fat Models – Skinny Controllers wollte ich folgendes Problem im Model lösen:
bevor ein Eintrag gespeichert wird, wird die Datenbank nach einem Eintrag mit gleichem Inhalt durchsucht und gegebenenfalls dieser verändert.

Ich ging davon aus, dass diese Aufgabe am besten im beforeSave() Callback aufgehoben sei. Cake entscheidet anhand des Felds Model->id ob ein Eintrag erstellt oder verändert werden soll. Also muss man nur die Id des bereits vorhandenen Eintrags suchen und das Feld entsprechend setzen.
Das funktioniert allerdings nicht – Cake erstellt trotzdem stur neue Einträge. Eine Google Suche brachte die Lösung. Cake prüft die Existenz von Einträgen während des Validierens. Der richtige Callback für solche Aufgaben ist also beforeValidate().

Mit folgendem Code funktioniert das jetzt einwandfrei:

function beforeValidate() {
	if (isset($this->data['Difference']) && !empty($this->data['Difference'])) {
		// conditions anpassen
		$conditions = $this->data['Difference'];
		unset($conditions['value_a'], $conditions['value_b']);
		// nach bereits existierendem Eintrag suchen
		$entry = $this->find('first', array(
			'conditions' => $conditions,
			'fields' => array('id', 'flag')
		));
		// falls Eintrag schon existiert UPDATE,
		// sonst INSERT
		if (!empty($entry)) {
			$this->id = $entry['Difference']['id'];
			$this->data['Difference']['flag'] = $entry['Difference']['flag'];
		} else {
			$this->data['Difference']['flag'] = 'new';
		}
		return true;
	}
	return false;
}

Apr 26 2010

Probleme mit der Wizard Component

category: Komponenten author:

Ich nutze für die Anwendung an der ich arbeite die Wizard Component mit der sich Formulare über mehrere Seiten verteilen lassen.
Das Tutorial schlägt folgenden Code für den View vor um das Formular auf den Wizard verweisen zu lassen:

<?=$form->create('Signup', array('id' => 'SignupForm', 'url' => $this->here));?>
...
<?=$form->end();?>

Das funktioniert allerdings nur so lange, wie CakePHP im Wurzelverzeichnis eines Webservers abgelegt ist. Liegt die Installtion beispielsweise im Verzeichnis foobar erzeugt der Code oben eine Formular mit dem Ziel /foobar/foobar/controller/wizard/step. Cake interpretiert das als Aktion foobar des Controllers foobar und zielt damit ins Leere.
Um diesen Fehler zu umgehen sollte die URL wie auch sonst bei CakePHP als Array angegeben werden:

<?=$form->create('Signup', array('id' => 'SignupForm', 'url' => array('controller' => 'controller', 'action' => 'wizard', 'signup')));?>
...
<?=$form->end();?>

Dabei ist controller der Controller der die Komponente enthält und signup der aktuelle Schritt.


Mrz 30 2010

Transaktionen unter CakePHP 1.2.x

category: Datenbanken author:

Für einen Wizard der eine Reihe von Einträgen aus einer CSV Datei in die Datenbank importiert wollte ich die INSERT-Statements in einer Transaktion vereinen. An verschiedenen Stellen (z.B. hier) liest man, das würde mit

$this->Model->transactional = true;
$this->Model->begin();
...
$this->Model->commit();

funktionieren. Ich hab alles versucht, aber so lies sich das nicht zum laufen bringen.

Den entscheidenden Hinweis hab ich dann im CakePHP Bugtracker gefunden. Mit

$this->Model->getDataSource()->begin($this->Model);

wird jetzt endlich eine Transaktion gestartet. Den Parameter transactional ignoriert CakePHP dabei allerdings, es funktioniert mit und ohne.


Nov 03 2009

$this->set(‘foobar’, ‘Hallo Welt!’);

category: Allgemein author:

Ich habe vor etwas mehr als einem Jahr angefangen an einem kleinen Projekt für die Universität Würzburg zu entwickeln. Da ich bei vorigen Projekten gemerkt hatte wie anstrengend es sein kann jedes Mal das Rad neu erfinden zu müssen habe ich mich dafür entschieden ein Framework zu verwenden. Unter allen PHP-Frameworks gefiel mir dabei CakePHP am besten (CodeIgniter kam auf den zweiten Platz). Die “Magie” von CakePHP ist einfach genau meins :) .
Da ich schon bei einigen Problemen mit CakePHP lange suchen musste bis ich die Lösung gefunden habe, dachte ich mir, ich schreibe doch selber mal was über Fallstricke und Tricks in CakePHP. Ich hoffe ich komme regelmäßig dazu hier etwas neues zu veröffentlichen. Man darf gespannt bleiben.