Query2
Query2 je minimalistický databázový layer pro MySQL v PHP. Je podobný třeba dibi nebo Zend_Db, hlavní odlišností je asi to, že se nepokouší o databázovou abstrakci - je pevně svázán s MySQL, což má své výhody i nevýhody.
Přehled
- Malá, jednoduchá a snadno upravitelná knihovna - celé Query2 má asi 20KB, kód je čitelný a dostatečně komentovaný
- Pro srovnání, kód Dibi má 250KB, cca 12krát tolik.
- Flexibilní a mocný - Query2 umožňuje psát značně méně kódu
- Dělá jednu věc, zato pořádně - tou je "kompozice" dotazu
- Neumí managovat databázové spojení, logování a podobné okrajové věci
- Umí používat speciality MySQL
- Jako třeba INSERT INTO ... ON DUPLICATE KEY UPDATE
- Neumí pojmenované parametry - v MySQL je ostatně jejich přínos diskutabilní
- licencován pod New BSD license
Download
Aktuální stabilní verze je 1.1.3, k dispozici ke stáhnutí zde. SVN repozitář je na Google Code k nalezení zde.
Změny v jednotlivých verzích jsou popsány v ChangeLogu.
Požadavky
PHP >= 5. Otestováno na PHP 5.0.5, 5.2.11 a 5.3.1. Query2 je možné použít pouze s kódováními kompatibilními s ASCII, např. UTF-8, ISO-8859-X/latinX, CP-XXXX atp. Mezi kódování nekompatibilní s ASCII patří např. UTF-16/UCS-2 nebo UTF-32/UCS-4.
Manuál
Připojení k databázi
PHP (plain)
// Vytvoříme instanci objektu a zároveň se připojíme k databázi $q = new Query2("localhost", "root", "", "my_database"); // Připojit se k databázi můžeme ale nezávisle na instanciaci objektu $q = new Query2(); // pouze vytvoříme instanci objektu $q->connect("localhost", "root", "", "my_database"); // connect() má stejné parametry jako konstruktor
Základní dotazy
PHP (plain)
echo $q->query("SELECT id FROM users WHERE login = %s AND password = %s", // SQL kód můžeme mixovat s parametry, následující dotaz dělá úplně to samé, co předchozí "AND password = %s", $_POST["login"])->fetchOne();
Co přesně kód dělá? Metoda query() zparsuje vstupní argumenty a spustí dotaz. Předali jsme jí 3 parametry (v prvním případě). Parser hledá znaky procenta, ty značí, že následující znak/řetězec má speciální význam (říkáme jim modifikátory). %s znamená, že další parametr bude řetězec, který se má na místo %s substituovat, ale v escapované podobě. Nemusíme se proto starat o spouštění addslashes() nebo mysql_real_escape_string().
Metoda query() vrací tři možné věci:
- False - dotaz neuspěl
- Instanci objektu Query2Result v případě, kdy dotaz do databáze vrací nějaká data (hlavně SELECT, DESCRIBE atp.)
- To je náš případ, metodou fetchOne() objektu třídy Query2Result si vyžádáme první sloupec prvního výsledku
- Instanci objektu Query2 v ostatních případech (tedy $this)
- Typicky pro INSERT, UPDATE, DELETE atp.
Seznam modifikátorů
Modifikátory pracující s řetězci se často vyskytují ve dvojicích - modifikátor %a (malé písmeno) parametr escapuje, %A parametr neescapuje. I neescapovaný řetězec je ale vždy obklopen jednoduchými uvozovkami.
- %i (od slova "integer") - celé číslo
- %f ("float") - reálné číslo
- %s a %S ("string") - řetězec
- %t ("table") - jméno tabulky/sloupce/view/indexu atp., obklopuje hodnotu znakem `
- Je vhodný např. na nastavitelné řazení tabulky ala $q->query("SELECT ... ORDER BY %t", $radici_sloupec)...
- V tomto případě se používá jiný druh escapování - escapuje se znak `, " a ' se nechávají být.
- Příklad: table.column => `table`.`column`
- %x a %X - pouze vloží argument, volitelně escapuje - neobklopuje ' ani `. Vhodné na vkládání částí nebo celých dotazů v čistém SQL
- liší se od 1.0, dnešní %X odpovídá %q z 1.0. Obdoba %x v 1.0 neexistovala.
- %in a %IN - přijímá pole hodnot a vytvoří něco jako IN ('A', 'B', 'C') - operátor IN se v SQL neuvádí, např. "WHERE born %in", array(1987, 1990)
- V případě, že pole v argumentu je prázdné, vloží se místo IN() hodnota FALSE. IN() je chybný zápis, IN(NULL) zase nefunguje pro NOT IN
- NOT IN(...) se zapisuje očekávatelně: "WHERE born NOT %in", array(...)
- %a a %A ("aktualizovat") - z asociativního pole v argumentu vytvoří řetězec vhodný pro UPDATE
- %v a %V ("vložit") - to samé jako %a a %A, ale pro INSERT
- Pokud v argumentu není asociativní pole, ale pole asociativních polí, vytvoří se multi insert
- Pokud vkládáte velké množství řádků, pak je "multi insert" řádově rychlejší než X samostatných INSERTů (ale pozor na maximální velikost packetů!)
- %va a %VA - vytvoří řetězec vhodný pro INSERT ... ON DUPLICATE KEY, je to ale trochu složitější a rozeberu to zvlášť níže
Speciálním případem je PHP hodnota null, která se bez ohledu na modifikátor vždy převede na NULL v SQL.
Příklady
PHP (plain)
// Vytvoří dotaz SELECT 'SQL \'injection', 'SQL 'injection' $q->query("SELECT %s, %S", "SQL 'injection", "SQL 'injection"); // Chceme najít všechny loginy končící na písmeno s (pouze demonstrativní příklad) // Vytvoří SELECT * FROM users WHERE login LIKE '%s' $q->query("SELECT * FROM users WHERE %q", "login LIKE '%s'"); // Vytvoří dotaz UPDATE users SET name = 'Lil\' John', password = '47bce5c74f589f4867dbd57e9ca9f808' WHERE id = 25 "name" => "Lil' John", "password" => "47bce5c74f589f4867dbd57e9ca9f808" ), 25); // Vytvoří dotaz SELECT * FROM users WHERE login IN ('Eva', 'Filip', 'Jakub') ORDER BY `lo'gi``n` $q->query("SELECT * FROM users WHERE login %in ORDER BY %t", array("Eva", "Filip", "Jakub"), "lo'gi`n"); // Vytvoří dotaz INSERT INTO users (name, password) VALUES ('Lil\' John', '47bce5c74f589f4867dbd57e9ca9f808') "name" => "Lil' John", "password" => "47bce5c74f589f4867dbd57e9ca9f808" ));
Poslední dva příklady dávají nápovědu jak řešit častý problém: pokud řádek v tabulce existuje, potřebujeme jej aktualizovat, pokud ne, tak vytvořit:
PHP (plain)
"name" => "Eva", "password" => "47bce5c74f589f4867dbd57e9ca9f808" ); if($id) $q->query("UPDATE users SET %a WHERE id = %i", $arr, $id); else $id = $q->query("INSERT INTO users %v", $arr)->lastInsertId();
Tento problém lze vyřešit elegantně i bez Query2, a to pomocí méně známé MySQL konstrukce INSERT INTO users SET ..., která data přijímá ve stejné podobě jako UPDATE.
Jde to ale ještě elegantněji:
INSERT ... ON DUPLICATE KEY UPDATE
Zopakujme si, co vlatně tato velmi užitečná konstrukce dělá: Nejprve se snaží vložit zadaný řádek, pokud ovšem nemůže kvůli konfliktu indexů (a to nejen primárního, ale i ostatních unikátních), pak se provede update řádku (a to jen vyspecifikované hodnoty).
Zápis v SQL je ale zbytečně dlouhý a možná i matoucí.
Query2 umožňuje dvě možná použití této konstrukce, která se liší formátem argumentu:
Jednoduchá
Snaží se vložit řádek, pokud neuspěje, aktualizuje všechny sloupce řádku uvedené v argumentu. Argumentem je klasické asociativní pole jméno => hodnota.
PHP (plain)
// Vytvoří dotaz INSERT INTO users (name, password) VALUES ('Eva', '47bce5c74f589f4867dbd57e9ca9f808') // ON DUPLICATE KEY UPDATE name = VALUES(name), password = VALUES(password) "name" => "Eva", "password" => "47bce5c74f589f4867dbd57e9ca9f808" ));
Složitá
Toto použití je složitější, ale poskytuje více možností. Argumentem je pole, ovšem s maximálně třemi položkami:
- data - klasické asociativní pole s jmény sloupců a hodnotami (tedy stejné jako argument z jednoduché varianty), povinné
- update - pole s jmény sloupců, které se mají updatovat v případě konfliktu indexů, volitelné
- pokud není definované, předpokládají se všechny sloupce
- auto_increment - specifikuje sloupec s auto inkrementem, volitelné
PHP (plain)
// Vytvoří dotaz INSERT INTO users (id, name, password) VALUES (5, 'Eva', '47bce5c74f589f4867dbd57e9ca9f808') // ON DUPLICATE KEY UPDATE password = VALUES(password), id = LAST_INSERT_ID(id) $id = $q->query("INSERT INTO users %va", "id" => $id "name" => "Eva", "password" => "47bce5c74f589f4867dbd57e9ca9f808" ), "auto_increment" => "id" )->lastInsertId();
Na co ten "auto_increment" parametr je? Vraťme se opět k našemu problému vložení řádku, pokud neexistuje a aktualizace pokud existuje. V případě, že se řádek vloží, budete pravděpodobně chtít znát ID právě vloženého řádku. V případě, že ale řádek existuje a proběhne jen update, databázové LAST_INSERT_ID logicky zůstane na předchozí hodnotě, protože ke vložení nového řádku nedošlo. Problém ale je, že nedokážeme zjistit, jestli došlo k INSERT nebo UPDATE.
Na to se nám hodí ta magická konstrukce na konci "id = LAST_INSERT_ID(id)". V případě, že dojde k UPDATE, nastaví databázové LAST_INSERT_ID na hodnotu sloupce "id" (resp. sloupce s příznakem AUTO_INCREMENT) právě aktualizovaného řádku. Takto vám metoda lastInsertId() vždy vrátí ID řádku, ať už byl aktualizovaný nebo právě vložený.
Pokud se vám zdá, že je ta syntaxe divná, nejste sami :-)
SQL konstrukce v %a, %v a %av
Představme si, že chceme do Query2 převést tento dotaz:
SQL (plain)
INSERT INTO users_login (id_user, last_login) VALUES (1, NOW())
Modifikátor %v ani %V použít nemůžeme, protože oba by NOW() obalili jednoduchými uvozovkami. Musíme to řešit proto takovou specialitou:
PHP (plain)
"id_user" => 1, "last_login" => new Query2Statement("NOW()") ));
Query2Statement je primitivní třída vytvořená čistě pro tento úkol. V konstruktoru se zadává hodnota, která se má substituovat bez escapování a bez oblokopení uvozovkami. Upozorňuji na to, že tuto speciální konstrukce je nutné (a taky možné) použít jen uvnitř argumentů k %a/%A, %v/%V. Jinak totiž není problém napsat:
PHP (plain)
$q->query("INSERT INTO users_login (id_user, last_login) VALUES (%i, NOW())", $id_user);
Fetchování dat
Zatím jsem rozebíral pouze "kompozici" dotazů, teď se dostanu k "fetchování" dat z výsledku dotazu. Máme několik metod, většina je celkem standardní. Pojmenování je stejné jako u Zend_Db.
- fetchRow() - vrátí řádek v asociovaném poli, je možné volat opakovaně v cyklu, stejně jako mysql_fetch_row()
- fetchAll() - vrátí celý výsledek v dvourozměrném poli
- fetchOne() - vrátí hodnotu prvního sloupce prvního řádku výsledku. Volitelný parametr může obsahovat jméno sloupce, který se má vrátit místo prvního
- fetchCol() - vrací pole obsahující první sloupce všech řádků výsledku
- fetchPairs() - vrátí výsledek v asociativním poli složený z prvních dvou sloupců
- fetchAssoc($col, ...) - vrátí celý výsledek asociovaný podle zadaného sloupce. Pokud je zadáno více sloupců, asociuje se víceúrovňově. Pro každou hodnotu asociovaného sloupce se vytvoří pole, počítá se tím pádem s duplikátními hodnotami.
Příklady
PHP (plain)
$result = $q->query("SELECT id, name, password FROM users"); "id" => 1, "name" => "Alice", "password" => "A52E094A3580FC32" ) "id" => 1, "name" => "Alice", "password" => "A52E094A3580FC32" ), "id" => 2, "name" => "Markéta", "password" => "E87E094A3580FC32" ) ); echo $result->fetchOne(); 1 1 => "Alice", 2 => "Markéta" ); $result = $q->query("SELECT id, country, city, name FROM users"); "id" => 1, "country" => "CR" "city" => "Praha", "name" => "Alice" ), "id" => 2, "country" => "CR" "city" => "Praha", "name" => "Markéta" ) ), "id" => 3, "country" => "CR" "city" => "Brno", "name" => "Eva" ) ) ), "id" => 4, "country" => "SR" "city" => "Bratislava", "name" => "Martina" ) ) ) );
Iterator a countable interface
S výsledky je možné pracovat i jako s obyčejným polem:
PHP (plain)
$result = $q->query("SELECT * FROM users"); foreach($result as $row) echo $row["name"], "<br />\n"; // Result se dá "rewindnout", jde tedy procházet výsledek opakovaně foreach($result as $row) echo $row["surname"], "<br />\n";
Query2Builder
SQL je jazyk poměrně blízký lidskému jazyku - to má své výhody, ale také jednu nevýhodu - dotaz se kódem blbě modifikuje. Vytvořme si modelovou situaci. V administraci blogovacího systému máme seznam blogpostů, ten je možné řadit podle libovolného sloupce, stránkovat, filtrovat podle data publikace a autora. Možných variant dotazu je docela dost a kód, který tento dotaz generuje je zbytečně dlouhý a nepřehledný. Query2Builder se snaží tento problém usnadnit:
PHP (plain)
// Objekt Query2Builder získáváme z objektu Query2 metodou builder() $builder = $q->builder(); $builder->select("blog.id")->select("blog.name, blog.id_autor")->from("blog"); // je možné více způsobů zápisu if(isset($_COOKIE["filter_autor"]) // v Query2Builderu je možné úplně normálně používat modifikátory $builder->from("JOIN autor USING(id_autor)")->whereAnd("autor.name LIKE %s", $_COOKIE["filter_autor"]); $builder->whereAnd("blog.datum = %s", $_COOKIE["filter_datum"]); // je možné používat modifikátory $builder->orderBy($_COOKIE["orderby"]); $builder->limit(($page - 1) * $perpage, $perpage); // stránkování $blogposty = $q->query($builder)->fetchAll(); // do metody query() zadáme jako argument instanci Query2Builder // použijeme trochu modifikovaný dotaz pro získání celkového počtu řádků (užitečné na zobrazení stránkování) // BTW, v MySQL jde udělat i bez spouštění druhého dotazu $celkem_pocet = $q->query($builder->clearSelect()->select("COUNT(*)")->clearLimit())->fetchOne();
Zanořené WHERE a HAVING
PHP (plain)
$or = $q->builder()->whereOr("skladem = 1")->whereOr("ocekavane_naskladneni < NOW() + INTERVAL 1 MONTH"); $builder = $q->builder()->select("*")->from("knihy")->whereAnd("v_prodeji = 1")->whereAnd($or); // composeQuery() pouze vytvoří SQL dotaz, ale nespouští jej echo $q->composeQuery($builder); // => SELECT * FROM knihy WHERE v_prodeji = 1 AND (skladem = 1 OR ocekavane_naskladneni < NOW() + INTERVAL 1 MONTH)
Jedna instance Query2Builderu dokáže vytvořit pouze jednu úroveň pro WHERE. Na druhou stranu, ale také akceptuje jako argument další instanci Query2Builderu, kterou bere jako zanořenou podmínku.
Metody pro HAVING se chovají analogicky.
Jiné dotazy než SELECTy
Query2Builder byl vytvořen hlavně pro SELECTy, s trochou snahy ho lze ale využít pro libovolné dotazy, viz pár například:
PHP (plain)
$where = $q->builder()->whereAnd("active = 1")->whereAnd("id_category = %i", $id_category); $q->query("UPDATE book SET %a", $cols_to_update, $where); // => UPDATE book SET availability = 1, ... WHERE active = 1 AND id_category = 456
Využíváme té vlastnosti, že Quer2Builder do generovaného dotazu vkládá pouze neprázdné položky. Pokud jsme tedy nezadali žádný sloupect do select() nebo tabulku do from(), ve výsledném dotazu vůbec klíčová slova SELECT a FROM nebudou (nebude se tedy jednat o správně zkonstruovaný dotaz, ale můžeme jej použít jako část celku).
Obsluha chyb
Obsluha chyb je velmi jednoduchá, při každé se vyhodí výjimka Query2Exception. Vyhozená výjimka má tyto atributy:
- code - číselný kód chyby:
- 100 - Can't connect to database server.
- 101 - Can't select database.
- 200 - Wrong argument list/order to query()
- Nastává, v případech: $q->query("INSERT INTO users %v"); (tedy zapomenutí parametru)
- 201 - Wrong modifier to query() function.
- 300 - Chyba při vykonávání dotazu
- error - textový popis chyby, v případě 300 se jedná o mysql_error()
- sql - u 300 dotaz, který chybu způsobil
Extras
Logování
Objektu lze předat callback, který bude volán po vykonání dotazu s údaji o délce provedení, úspěchu dotazu (callback se volá před vyhozením výjimky).
PHP (plain)
function logger($success, $query, $time) { echo "Podařilo se dotaz vykonat: ($success ? "ANO" : "NE"), ", dotaz zabral $time milisekund. Obsah dotazu: $query"; } $q->setLogCallback("logger"); $callback = $q->getLogCallback(); // v případe, když bychom ho potřebovali zpět
Pomocné metody
- composeQuery() - akceptuje stejné parametry jako query() a vrací vygenerovaný dotaz v čistém SQL
- pquery() - vygeneruje dotaz, vytiskne ho na výstup, ale dotaz také spustí. Vhodné na rychlé hledání chyb, protože jediné, co potřebujete pro výpis dotazu na výstup je přidání jediného písmenka (query() => pquery()), jinak totiž všechno funguje úplně stejně.
Statické metody
V Dibi najdete zdánlivě praktické statické metody dibi::connect(), dibi::query() a možná i další. V Query2 žádné takové nenajdete a to jednoduše proto, že jsou z principu velmi špatné. Proč jsou statické metody tak špatné zjistíte (např.), když podědíte třídu dibi a budete ji chtít použít v existujícím projektu místo dibi.
Unit testy
V repozitáři je k dispozici soubor s PHPUnit testy. Přestože je pokrytí kódu prakticky stoprocentní, je stále co zlepšovat. Může se hodit na věci, které z manuálu nejsou úplně jasné.
Autor
Autorem Query2 je Adam Živnéř, adam.zivner@gmail.com . Úvodní fázi vývoje zasponzorovala firma Továrna.cz.