RE:NODE

PDO prepared statements a práce s chybami v PHP

Text: Tým RE:NODE
19 min čtení


Většina chyb v PDO se netýká připojení. Týkají se prostoru mezi prepare() a řádkem, který dostaneš zpátky: pole placeholderů s jedním klíčem navíc, boolean, který do MySQL dorazí jako prázdný řetězec, bindParam() ve smyčce, který naváže všechny řádky na poslední hodnotu, seznam IN () poskládaný spojováním řetězců, lastInsertId() přečtené o jeden příkaz později a blok catch, který porovnává špatný kód chyby. Pro každou z nich platí krátké a přesné pravidlo. Tento článek je jejich přehled: jak se párují placeholdery, jak se typují hodnoty, jak se hlásí chyby a co znamená který běžný SQLSTATE, který fetch mód použít pro jaký tvar dat a jak se transakce, ID vložených řádků a počty řádků doopravdy chovají v MySQL.

Předpokládá funkční připojení. DSN, charset=utf8mb4, tři volby, které stojí za nastavení, past se socketem u localhost i TLS najdeš v článku PHP PDO a MySQL. Příklady tady používají $pdo vytvořené tímto způsobem, s ERRMODE_EXCEPTION, FETCH_ASSOC jako výchozím fetch módem a ATTR_EMULATE_PREPARES nastaveným na false.

Poziční, nebo pojmenované placeholdery#

PDO přijímá dva styly placeholderů. Poziční placeholdery jsou holé ? a hodnoty se k nim přiřazují podle pořadí. Pojmenované placeholdery jsou dvojtečka a jméno, například :email, a přiřazují se podle jména. Oba styly vytvoří stejný prepared statement; volba je o tom, co se lépe čte a co se snáz udrží správně, když dotaz roste.

php
// Positional: values in the order the ? appear$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ? AND status = ?');$stmt->execute([$email, 'active']);// Named: keys match names, order does not matter$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email AND status = :status');$stmt->execute(['status' => 'active', 'email' => $email]);

Poziční styl je v pořádku pro dvě nebo tři hodnoty. Při větším počtu vyhrávají pojmenované placeholdery, protože vložení nové podmínky doprostřed pozičního dotazu tiše posune všechny pozdější hodnoty o jednu - a pokud jsou typy náhodou kompatibilní, MySQL spustí špatný dotaz bez jediné stížnosti.

Pravidla, na kterých se lidé zadrhávají:

  • Oba styly nelze v jednom příkazu míchat. WHERE a = ? AND b = :b selže s SQLSTATE[HY093]: Invalid parameter number: mixed named and positional parameters.
  • Dvojtečka v klíči pole je nepovinná. ['email' => $x] i [':email' => $x] fungují. Vyber si jednu variantu a drž se jí, protože kódová základna, která používá obě, se hůř prohledává.
  • Počet musí sedět přesně. Klíč, který dotaz nepoužívá, se neignoruje - skončí jako HY093: Invalid parameter number: number of bound variables does not match number of tokens. To chytá lidi, kteří předají celé pole $_POST nebo znovu použité pole parametrů dotazu, který potřebuje jen jeho část.
  • Poziční pole musí být seznamy. Po array_filter() nebo unset() už klíče nejsou 0, 1, 2 a execute() je nedokáže spárovat. Pole nejdřív obal do array_values().
  • Pojmenovaný placeholder se smí objevit jen jednou, když jsou prepares nativní. WHERE title LIKE :q OR body LIKE :q funguje při emulaci a nativně selže s HY093. Použij :q1 a :q2 a hodnotu předej dvakrát.
  • Placeholdery drží jen hodnoty. Názvy tabulek, názvy sloupců, ASC/DESC a klíčová slova SQL nelze navázat; berou se z povoleného seznamu ve tvém kódu.

Placeholder také nemůže stát uvnitř uvozeného řetězce. LIKE '%:q%' je literální řetězec obsahující znaky :q, ne placeholder. Vzor si sestav v PHP a naváž celý: LIKE :q s hodnotou '%' . $term . '%'. Pokud hledaný výraz může obsahovat % nebo _ a mají se shodovat doslova, nejdřív je escapuj pomocí addcslashes($term, '%_\\'), protože oba znaky jsou uvnitř LIKE zástupné a placeholder na tom nic nemění.

bindValue, bindParam a typy hodnot#

execute($array) je zkrácená forma. Naváže každou hodnotu jako PDO::PARAM_STR, kromě null, který se pošle jako SQL NULL. MySQL převádí řetězce na čísla, kdykoli to sloupec nebo porovnání potřebuje, takže pro většinu dotazů je to přesně správně. Delší forma je navazování hodnot po jedné, a právě tak ovládáš typ.

Existují dvě metody navázání a chovají se různě:

MetodaNavážeHodnota se čte
bindValue()Kopii hodnotyKdyž ji zavoláš
bindParam()Referenci na proměnnouKdyž se spustí execute()

bindParam() existuje proto, aby se jeden prepared statement dal spouštět opakovaně, zatímco mezi běhy měníš proměnnou. Chování přes referenci je zároveň jeho klasická past:

php
// Wrong: every placeholder ends up bound to the last $valueforeach ($params as $key => $value) {    $stmt->bindParam($key, $value);}// Right: bindValue copies the value nowforeach ($params as $key => $value) {    $stmt->bindValue($key, $value);}

V první smyčce každé volání bindParam() navazuje stejnou proměnnou, $value, která v okamžiku spuštění execute() obsahuje to, co v ní nechala poslední iterace. Používej bindValue(), pokud výslovně nechceš referenci.

Konstanty typů, na kterých v MySQL záleží:

KonstantaPoužití
PDO::PARAM_STRŘetězce, desetinná čísla, data - výchozí
PDO::PARAM_INTCelá čísla, zvlášť LIMIT a OFFSET
PDO::PARAM_NULLExplicitní NULL
PDO::PARAM_BOOLBooleany - ale viz níže
PDO::PARAM_LOBVelká binární data včetně resource streamu

Poziční placeholdery se při jednotlivém navazování číslují od 1: $stmt->bindValue(1, $id, PDO::PARAM_INT). Pojmenované berou jméno, s dvojtečkou i bez ní.

Booleany, NULL a peníze

Většinu překvapení s typy způsobují tři hodnoty.

PHP false předané přes execute() se převede na řetězec a false jako řetězec je ''. Výchozí sql_mode v MySQL 8.0 a 8.4 obsahuje STRICT_TRANS_TABLES, takže vložení '' do sloupce TINYINT selže s chybou 1366, Incorrect integer value: '' for column 'is_active'. Bez striktního režimu stejný insert projde s varováním a uloží 0, což chybu skryje do chvíle, než někdo striktní režim zapne. Před navázáním přetypuj booleany na integery - (int) $isActive - a problém zmizí v obou režimech.

NULL se naváže správně, ale neporovnává se tak, jak lidé čekají. WHERE deleted_at = ? s hodnotou null nenajde vůbec žádné řádky, protože v SQL NULL = NULL neplatí. Pro shodu s hodnotou, která může být NULL, použij null-safe operátor MySQL: WHERE deleted_at <=> ?. Dva NULL bere jako shodné a jinak se chová jako =.

Peníze a další přesná desetinná čísla navazuj jako řetězce, ne jako floaty. '19.99' dorazí do sloupce DECIMAL(10,2) přesně; 19.99 jako PHP float je binární aproximace, kterou MySQL teprve zaokrouhlí. Totéž platí na cestě zpátky: sloupce DECIMAL se vracejí jako řetězce i s nativními prepares, záměrně, aby se hodnota nepoškodila převodem na float.

Seznamy IN() správně#

Pole nelze navázat na jediný placeholder. WHERE id IN (?) s [1, 2, 3] nefunguje a slepení hodnot do SQL řetězce ruší smysl prepare. Řešením je jeden placeholder na hodnotu, vygenerovaný z pole.

S pozičními placeholdery:

php
if ($ids === []) {    return [];  // IN () is a syntax error; an empty list matches nothing anyway}$marks = implode(',', array_fill(0, count($ids), '?'));$stmt = $pdo->prepare("SELECT id, name FROM products WHERE id IN ($marks)");$stmt->execute(array_values($ids));

Když má dotaz další podmínky, poziční placeholdery tě nutí slučovat pole přesně v pořadí, v jakém se objevují značky ?, což je křehké. Pojmenované placeholdery zůstávají čitelné:

php
$params = ['status' => 'active'];$names = [];foreach (array_values($ids) as $i => $id) {    $names[] = ":id$i";    $params["id$i"] = (int) $id;}$sql = 'SELECT id, name FROM products WHERE status = :status AND id IN ('     . implode(', ', $names) . ')';$stmt = $pdo->prepare($sql);$stmt->execute($params);

Vygenerovaná jména pocházejí z čítače smyčky, nikdy ze vstupu, takže se do textu SQL nedostane nic od uživatele.

Velmi dlouhé seznamy

Platí dva stropy. Protokol MySQL povoluje nejvýš 65 535 placeholderů v jednom prepared statementu; nad tuto hranici prepare() selže s chybou 1390, Prepared statement contains too many placeholders. Dávno předtím se dotaz s desítkami tisíc hodnot stane pomalým na parsování a plánování a celý paket se musí vejít do max_allowed_packet (v MySQL 8 výchozích 64 MB). V praxi seznam rozděl na dávky po 500 až 1 000 hodnotách a pro každou dávku spusť dotaz zvlášť:

php
$rows = [];foreach (array_chunk($ids, 1000) as $chunk) {    $marks = implode(',', array_fill(0, count($chunk), '?'));    $stmt = $pdo->prepare("SELECT id, name FROM products WHERE id IN ($marks)");    $stmt->execute($chunk);    array_push($rows, ...$stmt->fetchAll());}

Alternativou v MySQL 8 je poslat seznam jako jeden řetězec JSON a na serveru ho rozbalit pomocí JSON_TABLE, což vyžaduje jediný placeholder bez ohledu na délku seznamu:

sql
SELECT p.id, p.nameFROM products pJOIN JSON_TABLE(?, '$[*]' COLUMNS (id INT PATH '$')) AS j ON j.id = p.id;

Naváž na něj json_encode(array_values($ids)). Text příkazu se nikdy nemění, což pomáhá, když stejný dotaz běží se seznamy různé délky. Vyhni se FIND_IN_SET(id, ?) s řetězcem odděleným čárkami: funguje, ale nemůže použít index a prochází celou tabulku.

Co nativní prepares mění na chybách#

ATTR_EMULATE_PREPARES => false je doporučené nastavení a článek o připojení vysvětluje proč: skutečné příkazy na straně serveru, nativní typy integer a float a chyby, které přicházejí brzy. Mění také to, kde a jak se chyby objevují, což pro práci s chybami záleží.

  • Syntaktické chyby přicházejí při `prepare()`. S nativními prepares prepare() pošle SQL do MySQL, které ho zparsuje a překlep okamžitě odmítne s SQLSTATE 42000, chyba 1064. Při emulaci prepare() jen lokálně zparsuje placeholdery a stejný překlep se objeví až při execute(). Pokud tvůj blok try obaluje jen execute(), nativní režim přesune výjimku mimo něj.
  • Neznámé tabulky a sloupce přicházejí také při `prepare()` - 42S02 a 42S22 - ze stejného důvodu.
  • Jeden příkaz na jeden prepare. Nativní prepared statement drží přesně jeden příkaz SQL. prepare('UPDATE a SET x = 1; UPDATE b SET y = 2') je syntaktická chyba. Při emulaci ovladač MySQL ve výchozím stavu přijímá více příkazů, což je pohodlné pro migrační skripty a dar pro každého, kdo najde místo pro injection. Nastavení PDO::MYSQL_ATTR_MULTI_STATEMENTS => false v možnostech připojení to uzavře i při zapnuté emulaci (v PHP 8.4 a novějším je konstanta Pdo\Mysql::ATTR_MULTI_STATEMENTS).
  • Statementy jsou prostředek serveru. Každý nativní prepare vytvoří na serveru příkaz, který žije, dokud se nezničí objekt PDOStatement. MySQL omezuje celkový počet přes všechna připojení hodnotou max_prepared_stmt_count, výchozí 16 382. Dlouho běžící worker, který volá prepare() ve smyčce a drží každý objekt statementu v poli, může narazit na chybu 1461, Can't create more than max_prepared_stmt_count statements. Připrav příkaz jednou mimo smyčku a spouštěj ho opakovaně, nebo nech objekty zaniknout.

Režimy chyb a co nese PDOException#

PDO má tři režimy chyb, nastavují se přes PDO::ATTR_ERRMODE:

RežimPři chybě
ERRMODE_SILENTVrátí false; errorInfo() kontroluješ ty
ERRMODE_WARNINGVyvolá E_WARNING a vrátí false
ERRMODE_EXCEPTIONVyhodí PDOException - výchozí od PHP 8.0

Výjimkou je konstruktor: new PDO(...) vyhodí PDOException při nezdařeném připojení bez ohledu na režim chyb, protože není objekt, na kterém by šlo zavolat errorInfo(). Vše po konstrukci se řídí režimem.

Tichý režim je zdrojem nejdražších chyb v PDO, protože neúspěšný execute() vrátí false, kód pokračuje a výsledkem je chybějící řádek objednávky místo chyby. V tichém režimu také chyby žijí na dvou místech: neúspěšný prepare() se zaznamená na objektu PDO ($pdo->errorInfo()) a neúspěšný execute() na statementu ($stmt->errorInfo()). Kód, který kontroluje špatné místo, vidí 00000 - úspěch. Pokud zdědíš kód v tichém režimu, přepnutí na výjimky je obvykle nejlepší jednotlivá oprava, jakou máš k dispozici.

PDOException nese chybu ve třech podobách:

php
try {    $stmt->execute($params);} catch (PDOException $e) {    $sqlstate = $e->getCode();          // '23000' - a string, not an int    $driverCode = $e->errorInfo[1] ?? null;  // 1062 - the MySQL error number    $driverMsg = $e->errorInfo[2] ?? null;   // "Duplicate entry 'a@b.c' for key ..."}

getCode() vrací pětiznakový SQLSTATE jako řetězec, a proto je PDOException jednou z mála výjimek, u kterých getCode() není integer; striktní porovnání s 23000 jako číslem nikdy nesedí. errorInfo je pole se SQLSTATE, číslem chyby ovladače a zprávou ovladače. U neúspěšných připojení používej zprávu a getCode(), který drží číslo MySQL, například 1045 nebo 2002; nespoléhej na to, že je tam errorInfo vyplněné.

Větvi podle čísla MySQL, kde to jde. SQLSTATE je kategorie a některé kategorie jsou přeplněné: 23000 pokrývá duplicity, selhání cizích klíčů i NULL ve sloupci NOT NULL a HY000 pokrývá desítky nesouvisejících chyb.

Chytej úzce, loguj úplně

PDOException chytej jen tam, kde s ní umíš udělat něco konkrétního - proměnit duplicitní e-mail na chybu formuláře, zopakovat deadlock - a vše ostatní vyhoď dál. Nech neočekávané databázové chyby dojít k jedinému handleru na vrcholu aplikace, který zaloguje celou zprávu a návštěvníkovi ukáže obecnou stránku. Zpráva může obsahovat názvy hostů, uživatelská jména a útržky dotazu i dat, takže patří do logu, nikdy do odpovědi. Bezpečnostní checklist databáze pokrývá zbytek toho, co by neměl uniknout.

Referenční přehled SQLSTATE a chyb MySQL#

Chyby, které opravdu potkáš u dotazů, na rozdíl od připojení (ty jsou v článku o připojení). Nejdřív chyby omezení a dat:

SQLSTATE / MySQLZpráva začínáObvyklá příčina
23000 / 1062Duplicate entryKolize unikátního klíče
23000 / 1452Cannot add or update a child rowChybí cíl cizího klíče
23000 / 1451Cannot delete or update a parent rowŘádky na něj stále odkazují
23000 / 1048Column 'x' cannot be nullNULL do NOT NULL
22001 / 1406Data too long for columnŘetězec delší než sloupec
22003 / 1264Out of range valueČíslo moc velké pro typ
22007 / 1292Incorrect datetime valueŠpatný formát data, nebo ''
HY000 / 1366Incorrect integer value'' z PHP false, nebo špatný text

Potom chyby příkazů a běhu:

SQLSTATE / MySQLZpráva začínáObvyklá příčina
42000 / 1064You have an error in your SQL syntaxPřeklep, nebo rezervované slovo jako název
42S02 / 1146Table ... doesn't existŠpatná databáze nebo chybějící migrace
42S22 / 1054Unknown columnPřeklep, nebo sloupec přidaný po deployi
21S01 / 1136Column count doesn't matchSloupce a hodnoty v INSERT se liší
HY093Invalid parameter numberPlaceholdery a hodnoty nesedí
40001 / 1213Deadlock foundCelá transakce vrácena zpět - zopakuj
HY000 / 1205Lock wait timeout exceededČekání 50 s na zámek řádku
HY000 / 2014Cannot execute queries while other unbuffered queries are activeOtevřený kurzor

Tři z nich si zaslouží slovo. HY093 nemá číslo MySQL, protože ho vyvolá PDO dřív, než cokoli dorazí na server; je to vždy nesoulad mezi placeholdery a dodanými hodnotami a případy jsou vypsané v sekci o placeholderech výše. 22001 a 22003 se objevují jen ve striktním režimu - bez STRICT_TRANS_TABLES MySQL řetězec zkrátí nebo číslo ořízne, uloží ho a vydá varování, které nikdo nečte. A 1064 u dotazu, který vypadá správně, bývá sloupec jménem order, key, rank nebo groups, což jsou všechno rezervovaná slova, která potřebují zpětné apostrofy.

Těch 50 sekund u 1205 je výchozí innodb_lock_wait_timeout. Deadlocky a čekání na zámky jsou i s retry wrapperem popsané v článku Transakce, zamykání a deadlocky v MySQL.

Fetch módy kromě FETCH_ASSOC#

FETCH_ASSOC jako výchozí vyhovuje většině kódu. Ostatní módy ušetří smyčku pokaždé, když tvar, který potřebuješ, není „seznam řádků“. Předávej je do fetch() nebo fetchAll(), nebo je nastav na statementu pomocí $stmt->setFetchMode().

MódVrácený tvar
FETCH_NUMŘádky indexované 0, 1, 2
FETCH_OBJŘádky jako objekty stdClass
FETCH_COLUMNPlochý seznam prvního sloupce
FETCH_KEY_PAIR[first => second] ze dvou sloupců
FETCH_UNIQUEŘádky s klíčem podle prvního sloupce
FETCH_GROUPSeznamy řádků seskupené podle prvního sloupce
FETCH_CLASSŘádky naplněné do třídy

FETCH_UNIQUE je ten, který lidé přehlížejí. SELECT id, name, price FROM products s fetchAll(PDO::FETCH_UNIQUE) vrátí [17 => ['name' => ..., 'price' => ...], ...], s klíčem podle ID, což je přesně vyhledávací tabulka, kterou bys jinak skládal přes foreach. Pokud první sloupec není unikátní, pozdější řádky přepíšou dřívější bez varování.

FETCH_GROUP lze kombinovat s FETCH_COLUMN: SELECT category, name FROM products s fetchAll(PDO::FETCH_GROUP | PDO::FETCH_COLUMN) vrátí ['tools' => ['saw', 'drill'], 'paint' => [...]].

Naplnění tříd

FETCH_CLASS vytvoří objekt pro každý řádek a přiřadí sloupce vlastnostem stejného jména:

php
final class Product{    public int $id;    public string $name;    public string $price;    public function __construct() { /* runs after properties are set */ }}$stmt = $pdo->query('SELECT id, name, price FROM products');$products = $stmt->fetchAll(PDO::FETCH_CLASS, Product::class);

Dvě chování lidi překvapují. Ve výchozím stavu PDO nastaví vlastnosti před voláním konstruktoru, takže konstruktor, který přiřazuje výchozí hodnoty, přepíše data, která dostal. Přidej PDO::FETCH_PROPS_LATE (FETCH_CLASS | FETCH_PROPS_LATE), aby se konstruktor spustil první. A sloupec bez odpovídající vlastnosti se přesto přiřadí, jako dynamická vlastnost, což od PHP 8.2 vyvolá deprecation notice, pokud třída dynamické vlastnosti nepovoluje. Vybírej jen sloupce, které třída deklaruje, nebo je přejmenuj aliasem.

Typované vlastnosti spolupracují s typy z fetche: při nativních prepares přijde sloupec INT jako int a hodí se do vlastnosti int; DECIMAL přijde jako řetězec, a proto je $price výše deklarované jako string.

Prázdné výsledky

fetch() a fetchColumn() vracejí false, když už nejsou další řádky; fetchAll() vrací prázdné pole. Díky tomu je if ($row = $stmt->fetch()) správná kontrola existence a fetchColumn() na dotazu s COUNT(*) je bezpečné - počet vždy vrátí jeden řádek. Pozor na fetchColumn() u sloupce, který může oprávněně obsahovat hodnoty podobné false: 0 nebo '0' se vrátí samo za sebe, ale kód, který výsledek testuje přes if (!$value), to bere jako „žádný řádek“.

Transakce, savepointy a opakování#

Základní vzor - beginTransaction(), práce, commit() a rollBack() v catch - je v článku o připojení. Chyby vznikají na okrajích.

PDO transakce nezanořuje. Zavolání beginTransaction(), zatímco jedna běží, vyhodí There is already an active transaction. Kód, který zevnitř jedné transakce volá pomocnou funkci otevírající vlastní transakci, potřebuje buď wrapper sledující hloubku, nebo savepointy, které MySQL podporuje obyčejným SQL:

php
function transactional(PDO $pdo, callable $work): mixed{    static $depth = 0;    $outer = $depth === 0;    $outer ? $pdo->beginTransaction() : $pdo->exec("SAVEPOINT sp$depth");    $depth++;    try {        $result = $work($pdo);        $depth--;        $outer ? $pdo->commit() : $pdo->exec("RELEASE SAVEPOINT sp$depth");        return $result;    } catch (Throwable $e) {        $depth--;        if ($outer) {            if ($pdo->inTransaction()) {                $pdo->rollBack();            }        } else {            $pdo->exec("ROLLBACK TO SAVEPOINT sp$depth");        }        throw $e;    }}

Vnitřní selhání vrací zpět jen k vlastnímu savepointu; o tom, zda bude pokračovat, rozhoduje vnější transakce. Stejně funguje DB::transaction() v Laravelu, proto tam zanořování nevyhazuje chybu.

Věci, které ukončí transakci bez ptaní:

  • DDL. CREATE, ALTER, DROP a TRUNCATE v MySQL commitují implicitně. Změny před nimi jsou potvrzené a nelze je vrátit zpět a v PHP 8 pozdější commit() vyhodí There is no active transaction.
  • Deadlock. Chyba 1213 vrátí na serveru zpět celou transakci. Opakovat poslední příkaz je špatně; opakuj celou jednotku od beginTransaction().
  • Ztracené připojení. Chyba 2006 nebo 2013 uprostřed transakce znamená, že server provedl rollback. Objekt PDO už není použitelný.

Lock wait timeouty (1205) jsou zvláštní případ: ve výchozím stavu vrátí zpět jen příkaz, který vypršel, a transakci nechají otevřenou. Před opakováním proveď rollback explicitně, jinak commitneš polovinu práce.

Drž transakce krátké a pomalé věci z nich vynech. Transakce, která drží zámky řádků, zatímco volá platební API nebo posílá e-mail, nutí čekat všechny ostatní požadavky na tytéž řádky - a následují wait timeouty.

Okrajové případy lastInsertId a rowCount#

$pdo->lastInsertId() vrací hodnotu AUTO_INCREMENT vygenerovanou posledním příkazem na tomto připojení, jako řetězec. Je to per připojení, takže souběžné požadavky nikdy nevidí ID těch druhých. Okrajové případy:

  • Čti ho okamžitě. Pozdější příkaz, který negeneruje ID, může hodnotu vynulovat na 0. Zavolej ho hned po INSERT, před jakýmkoli dalším dotazem na tomto připojení.
  • Vícerádkové inserty vracejí první ID. INSERT INTO tags (name) VALUES (?), (?), (?) vrátí ID prvního řádku. Neodvozuj ostatní přičtením 1 a 2; podle innodb_autoinc_lock_mode a souběžných insertů to není zaručené. Pokud potřebuješ všechna ID, vkládej řádek po řádku uvnitř transakce, nebo si je přečti zpět podle unikátního klíče.
  • Tabulky bez `AUTO_INCREMENT` - klíče UUID, přirozené klíče - vracejí '0'. Vygeneruj klíč v PHP a už ho máš.
  • `INSERT IGNORE`, který řádek přeskočil, negeneruje žádné ID. Zkontroluj rowCount(), abys věděl, zda se vůbec něco vložilo.

INSERT ... ON DUPLICATE KEY UPDATE vyžaduje péči u obojího. MySQL hlásí rowCount() jako 1 pro nový řádek, 2 pro aktualizovaný existující řádek a 0 pro existující řádek, který už ty hodnoty měl. A když řádek už existoval, lastInsertId() neukáže jeho ID - pokud nepoužiješ zdokumentovaný trik MySQL s předáním ID přes LAST_INSERT_ID(expr):

sql
INSERT INTO users (email, name) VALUES (:email, :name)ON DUPLICATE KEY UPDATE name = VALUES(name), id = LAST_INSERT_ID(id);

Teď lastInsertId() vrací ID řádku v obou případech. MySQL 8.0.20 označilo VALUES(col) v této klauzuli za zastaralé ve prospěch aliasu řádku (VALUES (...) AS new ... UPDATE name = new.name); VALUES() stále funguje i v 8.4, ale vyvolá varování, takže nový kód by měl používat formu s aliasem.

U UPDATE počítá rowCount() změněné řádky, ne shodné, takže aktualizace řádku na jeho současné hodnoty vrátí 0. Nastavení PDO::MYSQL_ATTR_FOUND_ROWS => true při připojení to přepne na shodné řádky. U SELECT rowCount() nepoužívej vůbec: manuál PHP varuje, že jeho výsledek u SELECT není přenositelný. Počítej přes COUNT(*), nebo spočítej pole, které jsi načetl.

Kde to běží na RE:NODE#

Nic z výše uvedeného nezávisí na hostingu, ale několik faktů pomůže, pokud to tady provozuješ. MySQL na hostingu databází je MySQL 8.4 LTS, takže platí JSON_TABLE, forma ON DUPLICATE KEY UPDATE s aliasem řádku i výchozí hodnoty 8.x popsané v tomto článku; root heslo, aplikační databáze a aplikační uživatel se vygenerují při vytvoření serveru. Řada Laravel / PHP aplikací běží na PHP 8.3, 8.4 nebo 8.5 na FrankenPHP, což je důležité kvůli názvům konstant Pdo\Mysql, a plány aplikací a webů zahrnují sloty databází vytvářené v panelu s vygenerovaným hostem, uživatelem a heslem. „Open in phpMyAdmin“ u slotu je nejrychlejší způsob, jak spustit SHOW CREATE TABLE, když potřebuješ vidět, který sloupec ještě není utf8mb4 nebo na který klíč se odkazuje chyba 1062.

FAQ#

Dělají prepared statements dotaz úplně bezpečný proti SQL injection?

Dělají bezpečné hodnoty. Cokoli zřetězené do textu SQL - název sloupce pro řazení, název tabulky, klíčové slovo směru, LIMIT poskládaný z řetězce - je mimo ochranu. Namapuj to na pevný povolený seznam v kódu. Druhá mezera jsou uložená data použitá později: hodnota bezpečně vložená přes placeholder je stále nedůvěryhodná, když ji čteš zpět, a musí znovu projít placeholderem.

Vyplatí se připravovat statement, který spustím jen jednou?

Ano, kvůli bezpečnosti. Cena nativního prepare je jedna cesta navíc na server, což je u blízké databáze hluboko pod milisekundou. Kód, který přepíná mezi query() a prepare() podle toho, zda je v dotazu hodnota, se nakonec splete; připravovat všechno, co přijímá vstup, to rozhodování odstraní.

Proč execute() vrací true, ale nic se nezmění?

Buď WHERE nenašlo žádné řádky - rowCount() bude 0 - nebo práce proběhla uvnitř transakce, která nikdy nebyla commitnutá. S vypnutým autocommitem, nebo po beginTransaction() bez commit(), se změny vrátí zpět, když se připojení na konci požadavku zavře. Při ladění zkontroluj na konci skriptu $pdo->inTransaction().

Mohu znovu použít jeden PDOStatement s jinými hodnotami?

Ano, je to zamýšlené použití. Připrav jednou, pak volej execute() s novým polem, kolikrát chceš; s nativními prepares server parsuje SQL jen jednou. Pokud předchozí SELECT na statementu nechal nepřečtené řádky, před dalším spuštěním zavolej closeCursor().

Jak zalogovat SQL s doplněnými hodnotami?

PDO při nativních prepares finální řetězec dotazu nevytvoří, protože server dostává SQL a hodnoty zvlášť. $stmt->debugDumpParams() vypíše SQL a každý navázaný parametr, což obvykle stačí. Pro úplný záznam toho, co server spustil, použij obecný log dotazů MySQL na vývojovém stroji, nebo v produkci slow query log pro dotazy, na kterých záleží.