Od kiedy programiści stron internetowych [i nie tylko] dostali w swoje ręce pierwsze biblioteki wspomagające technikę ORM – mapowania obiektowo-relacyjnego, stało się jasne, że w bardzo wielu przypadkach oszczędzą nam one samodzielnego pisania zapytań SQL i związanych z tym problemów i błędów. Za bardzo przyjemną abstrakcją tychże bibliotek kryje się jednak jeden problem – ze względu na fakt, iż próbuje ona zastąpić nam całkowicie leżącą niżej warstwę bazy danych, bardzo często nie mamy możliwości skorzystania z natywnych funkcji SQLa. W niniejszym wpisie pokażę, jak sobie z tym poradzić wykorzystując bibliotekę ORM Doctrine.
Fotografia: juokaz, CC-BY-SA.
Doctrine: Dosłowne wyrażenia i funkcje SQL.
Mówiąc o Doctrine nie ucieknę oczywiście od powoływania się na mechanizmy obecne we frameworku symfony, ponieważ jest to główny ekosystem, w jakim pracuję z tą biblioteką. Załóżmy zatem milcząco, że mamy jakieś pojęcie o tym, co się mniej więcej w symfony dzieje, aczkolwiek postaram się jednak za bardzo tej kwestii nie komplikować.
Weźmy pod uwagę następujący model opisany fragmentem pliku YAML:
CustomModel:
tableName: customModels
columns:
id:
type: integer(4)
primary: true
notnull: true
autoincrement: true
name:
type: string(255)
description:
type: clob(65535)
createdAt:
type: timestamp
Wychodząc na przeciw pytaniu „co my tu mamy”, załóżmy, że naszym CustomModelem jest produkt, który posiada:
- identyfikator i klucz główny id,
- nazwę name,
- opis description,
- datę i czas dodania do bazy danych.
Spróbujmy zatem dodać rekord do bazy danych, korzystając z klas wygenerowanych poprzez narzędzie symfony CLI. Klasa bazowa będzie wyglądała mniej więcej tak:
<?php
abstract class BaseCustomModel extends sfDoctrineRecord
{
public function setTableDefinition()
{
$this->setTableName('customModels');
$this->hasColumn('id', 'integer', 4, array(
'type' => 'integer',
'primary' => true,
'autoincrement' => true,
'length' => 4,
));
$this->hasColumn('name', 'string', 255, array(
'type' => 'string',
'length' => 255,
));
$this->hasColumn('description', 'clob', 65535, array(
'type' => 'clob',
'length' => 65535,
));
$this->hasColumn('createdAt', 'timestamp', null, array(
'type' => 'timestamp',
));
$this->option('collate', 'utf8_general_ci');
$this->option('charset', 'utf8');
$this->option('type', 'InnoDB');
}
public function setUp()
{
parent::setUp();
}
}
Oraz sama [jakże skomplikowana!] klasa modelu:
class CustomModel extends BaseCustomModel
{
}
A kod, którego potencjalnie użyjemy będzie prezentował się następująco:
// (...)
$customModel = new CustomModel();
$customModel->name = 'such a beautiful name';
$customModel->description = 'very good, solid piece of hardware';
$customModel->timestamp = date("Y-m-d H:i:s");
$customModel->save();
Jak się zapewne domyślacie, problematyczne jest pole typu TIMESTAMP, któremu trzeba podać odpowiednie dane do aktualizacji / wstawienia danego rekordu. Skoro jednak jest funkcja date(), to w czym problem? Nie jest to może jakiś błąd w sztuce programistycznej, aczkolwiek w kwestii daty / czasu o wiele lepiej jest polegać na danych pochodzących bezpośrednio z serwera SQL. Lepiej będzie, jeśli wykorzystamy jego wewnętrzne mechanizmy pobierania tych informacji. Dlatego też zamiast podawać bezpośrednio gotowy string z danymi, lepiej byłoby skorzystać z jednego z wariantów aktualizacji pola czasowego w MySQL:
- funkcji NOW(),
- funkcji CURRENT_TIMESTAMP(),
- funkcji UNIX_TIMESTAMP().
Co jednak się stanie, jeśli zamiast rzeczonej linijki:
$customModel->timestamp = date("Y-m-d H:i:s");
wstawimy tą:
$customModel->timestamp = 'NOW()';
? Nasz skrypt wykona wtedy dosyć nieprzyjemne w skutkach zapytanie:
INSERT INTO customModels (id, name, description, createdAt) VALUES (NULL, 'such a beautiful name', 'very good, solid piece of hardware', 'NOW()')
Co w konfrontacji z polem TIMESTAMP zapisze w rekordzie wartość 0000-00-00 00:00:00. Okazuje się, że wstawienie własnej, literalnej wartości / fragmentu kodu do zapytania SQL nie jest możliwe w ramach zazwyczaj używanych elementów biblioteki Doctrine.
Okazuje się jednak, że można to zrobić w relatywnie prosty sposób – trzeba tylko wiedzieć jak. ;] Mamy tu kolejny klasyczny przykład problemu związanego z „wiedzą”, bowiem za wstawianie literałów do zapytania odpowiada klasa Doctrine_Expression. Aby z niej skorzystać, należy podmienić błędną linijkę na następującą:
$customModel->timestamp = new Doctrine_Expression('NOW()');
W tym momencie w zapytaniu pojawi się już klasyczna funkcja NOW() nie zamknięta w broniących przed SQL Injection ciapkach, dzięki czemu w wynikowym rekordzie otrzymamy takie dane, jakich oczekiwaliśmy. Oczywiście w ten sposób można umieścić dowolną inną funkcję SQL w zapytaniu do bazy danych, aczkolwiek problem z NOW() wydaje mi się najczęstszym, jaki pojawia się w programowaniu stron internetowych.
Dziękuję za miło spędzony czas i zapraszam do dyskusji na temat innych sposobów na osiągnięcie tego celu. Biblioteka Doctrine jest na tyle wielkim tworem, że na pewno można sobie poradzić z tym problemem wykorzystując inne rozwiązania. Przy okazji – mała niespodzianka – kolejnym wpisem nie będzie Linkdump, ale coś nieco innego – zapraszam do zgadywania. ;]
Warto przeczytać.
Trwa ładowanie…
Podobnie rzecz się ma z masą FW jak zauważyłeś. Chyba większość ma jakieś obejście tego problemu. Ja największy kontakt mam z Kohaną i tam jest to podobnie rozwiązane jak tu: Database_Expression (gałąź 2.X) lub DB::expr( gałąź 3.x)
Pytałem o zdanie kolegów siedzących we frameworkach innych niż symfony – przyznali zgodnie, że nie mieli jeszcze takiego problemu. Najwyraźniej tylko ja zajmuję się szukaniem dziury w całym.
Dzięki za podesłanie rozwiązania dla Kohany, może ktoś trafi tutaj i przy okazji mu się przyda. ;]
Linkdump #47: WordPress, Jaśnie Panie!
Nie tyle szukaniem dziury w całym, co faktem, że część doświadczonych osób świadomie rezygnuje z ORM czy query builderów na rzecz jawnego wpisywania zapytań. Taka Kohana także oferuje wszystkie te rozwiązania. Jeśli wiem, że wystarczy mi ORM – użyję go. Do większości zastosowań także query builder jest bardzo szybki. Ale jeśli mam jakieś pokiereszowane zapytanie, gdzie ani ORM nie da rady, ani query builder nie będzie wyglądał zbyt zgrabnie, to wolę użyć metody query w Kohanie. Zapytanie podaję wtedy „z palca”.
To jest jasne, że korzystamy z ORMa do momentu, kiedy pozwala on na osiągnięcie celu. Jeśli widzę, że będę musiał obchodzić połowę mechanizmów Doctrine’a, żeby dotrzeć do jakiegoś interesującego mnie datasetu, to nawet się nie będę zastanawiał, tylko od razu wklepię zapytanie SQL do metody executeSql(). ;]
Linkdump #47: WordPress, Jaśnie Panie!
Rzucę Ci funkcje do pobierania listy wyników w wyszukiwarce jednego z naszych serwisów w Kohanie 3.0.
Kolega „trochę” zaszalał pisząc to wszystko w query builderze ;)
public function get_list($args) { if( ! is_array($args)) return FALSE; if( ! array_key_exists('limit', $args)) { $args['limit'] = 10; } if( ! array_key_exists('page', $args) OR $args['page'] == 0) { $args['page'] = 1; } $args['offset'] = ($args['page'] - 1) * $args['limit']; $cur_url = explode('/', utf8::str_ireplace('/'.$args['page'], '', Request::instance()->uri())); $cur_url = $cur_url[0].(isset($cur_url[1]) ? '/'.$cur_url[1] : ''); $subquery = DB::select(DB::expr('COUNT(*)')) ->from('boardrooms') ->where('id_object','=',DB::expr('o.id')) ->compile($this->_db); $main_category = DB::select(DB::expr('ot.title')) ->from(array('objects_object_types','oot')) ->join(array('object_types','ot'), 'LEFT') ->on('oot.id_type', '=', 'ot.id') ->where('oot.id_object','=',DB::expr('o.id')) ->where('ot.weight','=','main') ->compile($this->_db); $positioning = DB::select('id_item', 'position', 'active') ->from('positioning') ->where('site','=','e-konferencje') ->and_where('type','=','object') ->and_where('target','=','url') ->and_where('value','=', $cur_url) ->and_where('active','=','1') ->compile($this->_db); $data = DB::select(DB::expr('SQL_CALC_FOUND_ROWS `o`.`id`'), 'o.name', 'o.guests', 'o.photo', 'o.full_presentation', 'o.street', 'o.postcode', 'c.state', 'p.position', 'o.description_short', array(DB::expr('('.$subquery.')'), 'boardrooms'), array('c.name', 'city'), array('cs.name', 'city_second'), array(DB::expr('('.$main_category.')'), 'category') ) ->from(array('objects','o')) ->join(array(DB::expr('('.$positioning.')'), 'p'), 'LEFT') ->on('o.id', '=', 'p.id_item') ->join(array('cities','c'), 'LEFT') ->on('o.id_city_main', '=', 'c.id') ->join(array('cities','cs'), 'LEFT') ->on('o.id_city_second', '=', 'cs.id') ->join(array('objects_object_types','oot'), 'LEFT') ->on('o.id', '=', 'oot.id_object') ->join(array('object_types','ot'), 'LEFT') ->on('oot.id_type', '=', 'ot.id') ->where('o.status','=',1); if(Arr::get($args, 'slowa', FALSE)) { $search = is_array($args['slowa']) ? implode(' ', $args['slowa']) : $args['slowa']; $data->and_where_open() ->where('o.name','LIKE','%'.$search.'%') ->or_where('o.description_short','LIKE','%'.$search.'%') ->or_where('o.description_long','LIKE','%'.$search.'%') ->and_where_close(); } if(Arr::get($args, 'miasto', FALSE)) { $data->and_where_open() ->and_where('c.name', '=', $args['miasto']) ->or_where('cs.name', '=', $args['miasto']) ->and_where_close(); } if(Arr::get($args, 'wojewodztwo', FALSE)) { $data->and_where_open() ->and_where('c.state', '=', Kohana::config('misc.states.'.$args['wojewodztwo'])) ->or_where('cs.state', '=', Kohana::config('misc.states.'.$args['wojewodztwo'])) ->and_where_close(); } if(Arr::get($args, 'miejsc', FALSE)) { $data->and_where('o.guests', '>=',$args['miejsc']); } if(Arr::get($args, 'standard-min', FALSE)) { $data->and_where('o.standard', '>=',$args['standard-min']); } if(Arr::get($args, 'standard-max', FALSE)) { $data->and_where('o.standard', 'having('boardrooms', '>=',$args['sal']); } if(Arr::get($args, 'hotel', FALSE)) { $data->and_where('ot.slug', '=','hotel'); } if(Arr::get($args, 'typ', FALSE)) { $data->and_where('ot.slug', '=',$args['typ']); } if(Arr::get($args, 'region', FALSE)) { $data->and_where('ot.slug', '=',$args['region']); } if(Arr::get($args, 'skala', FALSE)) { $data->and_where('ot.slug', '=',$args['skala']); } if(Arr::get($args, 'list', FALSE)) { $data->and_where('o.id', 'IN',DB::expr('('.$args['list'].')')); } $data = $data->order_by(DB::expr('ISNULL(`p`.`position`)'), 'ASC') ->order_by('p.position', 'ASC') ->order_by('o.full_presentation', 'DESC') ->order_by('o.id', 'ASC') ->group_by('o.id') ->limit($args['limit']) ->offset($args['offset'])->execute(); if (count($data) > 0) { $result['data'] = $data->as_array(); $cnt = DB::select(DB::expr('FOUND_ROWS() AS cnt'))->execute()->current('cnt'); $result = array_merge($result, $cnt); return $result; } return FALSE; }Kurczę, ale komentarz. ;] Wygląda to ciekawie, aczkolwiek widzę, że Kohana mocno wzoruje się na Doctrine 1.x. Poza tym, nie lubię skrótowych nazw metod i zmiennych, np. nazwa metody DB::expr() powinna zostać zmieniona na DB::expression, żeby była bardziej czytelna. Poza tym spokojnie, ja też czasem musiałem pisać takie tasiemce w symfony, także wiem o co chodzi. ;]
BTW. Co zawiera w sobie klasa Arr? To jest jakiś kontener danych przychodzących do skryptu, czy może coś innego?
Linkdump #47: WordPress, Jaśnie Panie!
Arr to helper. Typowa klasa ze statycznymi metodami coś tam robiącymi. W tym wypadku wykonujący różne operacje na tablicach.
Ok, dzięki za informacje. Nie zmienia to faktu, że faktycznie ten kod jest mocno zakombinowany, ale to chyba cena za możliwości frameworków. ;]
Linkdump #47: WordPress, Jaśnie Panie!
Ogólnie samo zapytanie, które z tego mogło by powstać, nawet pisane ręcznie to byłaby istna masakra. A całość to taki mętlik z prostego powodu… System linków na stronie to jedna wielka wyszukiwarka. Skacząc więc po odnośnikach nie tyle wywołujesz różne kontrolery, co przechodzisz w większości przypadków przez system wyszukiwarki, która wypluwa odpowiednio sklasyfikowane i znalezione dane. Tablica $args zawiera bowiem już podzielony na pary klucz-wartość url. Im dłuższy url, tym bardziej zamotane zapytanie w wyniku. Dłuższy znaczy mający więcej tych par. Rozwiązanie fajne, bo można w url zamieniać jak się chce kolejność par, mieszać je, a i tak znajdzie co trzeba. Tyle że canonicale itp. muszą być w użyciu, by nie zwalić SEO (duplicate content). Jednak potencjalna ilość zaindeksowanych stron to istny raj dla robotów :)
Domyślam się, że już przy kilku czynnikach wyszukujących, kombinacja bez powtórzeń z tego zbioru daje imponujący wynik. Trik z dołączaniem metod andWhere() według przekazanych parametrów już znam, szczerze mówiąc nie widzę za bardzo lepszego rozwiązania, poza jakimś automatycznym mechanizmem, który coś takiego dorzuci do zapytania na podstawie mapowania parametr => pole w bazie danych.
BTW. Można wiedzieć, co to za serwis, czy jest to tajemnica służbowa? ;]
PHP: Dzielenie stringa według separatora z wyłączeniem znaku ucieczki.
Pingback: (My)SQL: Aktualizowanie pola typu TIMESTAMP. « Tomasz Kowalczyk
Czołówka w google pod frazą „szkolenia24″. Zobaczysz kilka, sam sie zorientujesz który ;) Nie każdy ma rozbudowaną wyszukiwarkę.
Dzięki, przejrzę w wolnej chwili. ;]
Linkdump #49: Symfonia symfony 1.x / Symfony2.