RE:NODE

PDO prepared statements და შეცდომების დამუშავება PHP-ში

ავტორი RE:NODE-ის გუნდი
19 წუთის საკითხავი


PDO-ს ბაგების უმეტესობა დაკავშირებას არ ეხება. ისინი prepare()-სა და დაბრუნებულ მწკრივს შორის ხდება: placeholder-ების მასივში ერთი ზედმეტი გასაღები, ბულეანი, რომელიც MySQL-ში ცარიელი სტრიქონის სახით მიდის, ციკლში გამოძახებული bindParam(), რომელიც ყველა მწკრივს ბოლო მნიშვნელობას აბამს, სტრიქონების შეერთებით აწყობილი IN () სია, ერთი ბრძანებით დაგვიანებით წაკითხული lastInsertId() და catch ბლოკი, რომელიც არასწორ შეცდომის კოდს ადარებს. თითოეულს მოკლე და ზუსტი წესი აქვს. ეს პოსტი სწორედ ამ წესების ცნობარია: როგორ ემთხვევა placeholder-ები მნიშვნელობებს, როგორ ენიჭება მნიშვნელობებს ტიპი, როგორ მოდის შეცდომები და რას ნიშნავს თითოეული გავრცელებული SQLSTATE, რომელი fetch რეჟიმი რომელ ფორმის მონაცემებს ერგება და როგორ იქცევა სინამდვილეში ტრანზაქციები, ჩასმული ID-ები და მწკრივების რაოდენობა MySQL-ზე.

პოსტი ვარაუდობს, რომ მუშა კავშირი უკვე გაქვს. DSN, charset=utf8mb4, სამი სასარგებლო პარამეტრი, localhost-ის socket-ის მახე და TLS განხილულია პოსტში PHP PDO და MySQL. აქ მოყვანილ მაგალითებში $pdo ისეა შექმნილი, როგორც იქ წერია: ERRMODE_EXCEPTION, ნაგულისხმევი fetch რეჟიმი FETCH_ASSOC და ATTR_EMULATE_PREPARES, რომელიც false-ზეა დაყენებული.

პოზიციური თუ დასახელებული placeholder-ები#

PDO placeholder-ის ორ სტილს იღებს. პოზიციური placeholder უბრალო ?-ია და მნიშვნელობებს რიგითობით ემთხვევა. დასახელებული placeholder არის ორწერტილი და სახელი, :email, და მნიშვნელობებს სახელით ემთხვევა. ორივე ერთსა და იმავე prepared statement-ს აწარმოებს. არჩევანი იმაზეა დამოკიდებული, რა იკითხება უკეთ და რის სისწორის შენარჩუნებაა უფრო ადვილი, როცა მოთხოვნა იზრდება.

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]);

პოზიციური სტილი ორ-სამ მნიშვნელობამდე კარგია. მეტი რომ გახდება, დასახელებული placeholder-ები სჯობს, რადგან პოზიციური მოთხოვნის შუაში ახალი პირობის ჩამატება ყველა მომდევნო მნიშვნელობას ჩუმად ერთით ცვლის - და თუ ტიპები შეთავსებადია, MySQL არასწორ მოთხოვნას უპრობლემოდ შეასრულებს.

წესები, რომლებზეც ხალხი ბორძიკობს:

  • ერთ statement-ში ორ სტილს ვერ შეურევ. WHERE a = ? AND b = :b ვარდება შეცდომით SQLSTATE[HY093]: Invalid parameter number: mixed named and positional parameters.
  • ორწერტილი მასივის გასაღებში არასავალდებულოა. ['email' => $x] და [':email' => $x] ორივე მუშაობს. აირჩიე ერთი და მისდიე, რადგან კოდბაზა, სადაც ორივეა, ძიებას ართულებს.
  • რაოდენობა ზუსტად უნდა ემთხვეოდეს. გასაღები, რომელსაც მოთხოვნა არ იყენებს, არ იგნორირდება - ეს არის HY093: Invalid parameter number: number of bound variables does not match number of tokens. ეს იჭერს მათ, ვინც მთელ $_POST მასივს ან ხელახლა გამოყენებულ პარამეტრების მასივს აწვდის მოთხოვნას, რომელსაც მხოლოდ ნაწილი სჭირდება.
  • პოზიციური მასივები სიები უნდა იყოს. array_filter()-ის ან unset()-ის შემდეგ გასაღებები აღარ არის 0, 1, 2, და execute() მათ ვეღარ ათანხმებს. ჯერ მასივი array_values()-ში გაატარე.
  • დასახელებული placeholder შეიძლება ერთხელ გამოჩნდეს, როცა prepare-ები native-ია. WHERE title LIKE :q OR body LIKE :q ემულაციაში მუშაობს, native რეჟიმში კი ვარდება HY093-ით. გამოიყენე :q1 და :q2 და მნიშვნელობა ორჯერ გადაეცი.
  • placeholder მხოლოდ მნიშვნელობას ინახავს. ცხრილის სახელები, სვეტის სახელები, ASC/DESC და SQL-ის საკვანძო სიტყვები ვერ მიება; ისინი შენს კოდში არსებული დაშვებული სიიდან მოდის.

placeholder ბრჭყალებში ჩასმული სტრიქონის შიგნითაც ვერ იქნება. LIKE '%:q%' არის ლიტერალური სტრიქონი, რომელიც შეიცავს სიმბოლოებს :q, და არა placeholder. პატერნი PHP-ში ააგე და მთლიანად მიაბი: LIKE :q და '%' . $term . '%'. თუ საძიებო ტერმინში შეიძლება იყოს % ან _ და მათ პირდაპირი მნიშვნელობით უნდა დაემთხვეს, ჯერ გაატარე addcslashes($term, '%_\\')-ში, რადგან ორივე wildcard-ია LIKE-ის შიგნით და placeholder ამას არ ცვლის.

bindValue, bindParam და მნიშვნელობების ტიპები#

execute($array) მოკლე ფორმაა. ის ყველა მნიშვნელობას PDO::PARAM_STR-ად აბამს, გარდა null-ისა, რომელიც SQL NULL-ად იგზავნება. MySQL სტრიქონს რიცხვად გარდაქმნის, როცა სვეტს ან შედარებას ეს სჭირდება, ამიტომ მოთხოვნების უმეტესობისთვის ეს სწორედ ის არის, რაც გჭირდება. გრძელი ფორმა მნიშვნელობების სათითაოდ მიბმაა, და ტიპსაც ასე აკონტროლებ.

მიბმის ორი მეთოდი არსებობს და ისინი სხვადასხვანაირად იქცევა:

მეთოდირას აბამსროდის იკითხება მნიშვნელობა
bindValue()მნიშვნელობის ასლსგამოძახებისას
bindParam()ცვლადის მითითებასროცა execute() სრულდება

bindParam() იმიტომ არსებობს, რომ ერთი prepared statement განმეორებით შესრულდეს, სანამ ცვლადს გაშვებებს შორის ცვლი. სწორედ ეს მითითებითი ქცევაა მისი კლასიკური ბაგიც:

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);}

პირველ ციკლში თითოეული bindParam() გამოძახება ერთსა და იმავე ცვლადს, $value-ს, აბამს, რომელშიც execute()-ის გაშვების მომენტში ბოლო იტერაციის დატოვებული მნიშვნელობაა. გამოიყენე bindValue(), თუ მითითება განზრახ არ გჭირდება.

ტიპის მუდმივები, რომლებსაც MySQL-ზე მნიშვნელობა აქვს:

მუდმივარისთვის
PDO::PARAM_STRსტრიქონები, ათწილადები, თარიღები - ნაგულისხმევი
PDO::PARAM_INTმთელი რიცხვები, განსაკუთრებით LIMIT და OFFSET
PDO::PARAM_NULLაშკარა NULL
PDO::PARAM_BOOLბულეანები - მაგრამ იხილე ქვემოთ
PDO::PARAM_LOBდიდი ბინარული მონაცემები, stream რესურსის ჩათვლით

პოზიციური placeholder-ები სათითაოდ მიბმისას 1-დან გადანომრილია: $stmt->bindValue(1, $id, PDO::PARAM_INT). დასახელებულებს სახელი მიეთითება, ორწერტილით ან მის გარეშე.

ბულეანები, NULL და ფული

ტიპებთან დაკავშირებული გაკვირვებების უმეტესობას სამი მნიშვნელობა იწვევს.

PHP-ის false, რომელიც execute()-ით გადაეცემა, სტრიქონად გარდაიქმნება, და false სტრიქონის სახით არის ''. MySQL 8.0-სა და 8.4-ში ნაგულისხმევი sql_mode შეიცავს STRICT_TRANS_TABLES-ს, ამიტომ ''-ის ჩასმა TINYINT სვეტში ვარდება შეცდომით 1366, Incorrect integer value: '' for column 'is_active'. strict რეჟიმის გარეშე იგივე ჩასმა გაფრთხილებით წარმატებულია და 0 ინახება, რაც ბაგს მალავს მანამ, სანამ ვინმე strict რეჟიმს ჩართავს. ბულეანები მიბმამდე მთელ რიცხვად გადაიყვანე - (int) $isActive - და პრობლემა ორივე რეჟიმში ქრება.

NULL სწორად მიება, მაგრამ შედარებაში ისე არ იქცევა, როგორც ელოდებიან. WHERE deleted_at = ? null მნიშვნელობით არც ერთ მწკრივს არ ემთხვევა, რადგან SQL-ში NULL = NULL ჭეშმარიტი არ არის. იმ მნიშვნელობის დასაემთხვევად, რომელიც შეიძლება NULL იყოს, გამოიყენე MySQL-ის null-უსაფრთხო ოპერატორი: WHERE deleted_at <=> ?. ის ორ NULL-ს ტოლად თვლის და დანარჩენში =-ივით იქცევა.

ფული და სხვა ზუსტი ათწილადები სტრიქონის სახით მიაბი, და არა float-ად. '19.99' DECIMAL(10,2) სვეტამდე ზუსტად აღწევს; 19.99 PHP float-ის სახით ორობითი მიახლოებაა, რომელსაც MySQL შემდეგ ამრგვალებს. იგივე ეხება უკან დაბრუნებასაც: DECIMAL სვეტები native prepare-ების დროსაც სტრიქონად ბრუნდება, განზრახ, რათა მნიშვნელობა float-ში გადაყვანამ არ დააზიანოს.

IN() სიები სწორად#

მასივს ერთ placeholder-ზე ვერ მიაბამ. WHERE id IN (?) მასივით [1, 2, 3] არ მუშაობს, ხოლო მნიშვნელობების SQL სტრიქონში implode-ი prepare-ის აზრს აქარწყლებს. გამოსავალია ერთი placeholder თითო მნიშვნელობაზე, მასივიდან გენერირებული.

პოზიციური placeholder-ებით:

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));

როცა იმავე მოთხოვნაში სხვა პირობებიცაა, პოზიციური placeholder-ები გაიძულებს მასივები ზუსტად იმ რიგით გააერთიანო, როგორც ? ნიშნები ჩანს, და ეს მყიფეა. დასახელებული placeholder-ები კითხვადობას ინარჩუნებს:

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);

გენერირებული სახელები ციკლის მთვლელიდან მოდის და არასოდეს შეყვანიდან, ამიტომ მომხმარებლისგან მიღებული არაფერი ხვდება SQL ტექსტში.

ძალიან გრძელი სიები

ორი ზღვარი მოქმედებს. MySQL-ის პროტოკოლი ერთ prepared statement-ში მაქსიმუმ 65,535 placeholder-ს იძლევა; ამაზე მეტზე prepare() ვარდება შეცდომით 1390, Prepared statement contains too many placeholders. ამაზე ბევრად ადრე ათიათასობით მნიშვნელობიანი მოთხოვნა ანალიზსა და დაგეგმვაში ნელი ხდება, და მთელი პაკეტი max_allowed_packet-ში უნდა ეტეოდეს (MySQL 8-ში ნაგულისხმევად 64 MB). პრაქტიკაში სია 500-1,000 მნიშვნელობიან ნაწილებად დაჭერი და მოთხოვნა თითო ნაწილზე ერთხელ გაუშვი:

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());}

MySQL 8-ზე ალტერნატივაა სია ერთ JSON სტრიქონად გაგზავნო და სერვერზე JSON_TABLE-ით გაშალო, რასაც სიის სიგრძის მიუხედავად მხოლოდ ერთი placeholder სჭირდება:

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

მას json_encode(array_values($ids)) მიაბი. statement-ის ტექსტი არასოდეს იცვლება, რაც გვეხმარება, როცა ერთი და იგივე მოთხოვნა სხვადასხვა სიგრძის სიებით გადის. მძიმეებით გამოყოფილ სტრიქონთან FIND_IN_SET(id, ?) ერიდე: ის მუშაობს, მაგრამ ინდექსს ვერ იყენებს და მთელ ცხრილს სკანირებს.

რას ცვლის native prepare-ები შეცდომებში#

ATTR_EMULATE_PREPARES => false რეკომენდებული პარამეტრია, და კავშირის პოსტი განმარტავს, რატომ: რეალური სერვერული statement-ები, native მთელი და float ტიპები და შეცდომები, რომლებიც ადრე მოდის. ის ასევე ცვლის, სად და როგორ ჩნდება შეცდომები, და ეს შეცდომების დამუშავებისას მნიშვნელოვანია.

  • სინტაქსის შეცდომები `prepare()`-ზე მოდის. native prepare-ებით prepare() SQL-ს MySQL-ს უგზავნის, რომელიც მას აანალიზებს და შეცდომას დაუყოვნებლივ აცხადებს SQLSTATE 42000-ით, შეცდომა 1064. ემულაციისას prepare() მხოლოდ ლოკალურად ამუშავებს placeholder-ებს და იგივე შეცდომა execute()-ზე ჩნდება. თუ შენი try ბლოკი მხოლოდ execute()-ს ფარავს, native რეჟიმში გამონაკლისი მის გარეთ გადადის.
  • უცნობი ცხრილები და სვეტები `prepare()`-ზეც მოდის - 42S02 და 42S22 - იმავე მიზეზით.
  • ერთი statement თითო prepare-ზე. native prepared statement ზუსტად ერთ SQL statement-ს ინახავს. prepare('UPDATE a SET x = 1; UPDATE b SET y = 2') სინტაქსის შეცდომაა. ემულაციისას MySQL დრაივერი ნაგულისხმევად რამდენიმე statement-ს იღებს, რაც მოსახერხებელია მიგრაციის სკრიპტებისთვის და საჩუქარია ყველასთვის, ვინც ინექციის წერტილს იპოვის. კავშირის პარამეტრებში PDO::MYSQL_ATTR_MULTI_STATEMENTS => false ამას ემულაციის ჩართვისასაც კეტავს (PHP 8.4-სა და უფრო ახალში მუდმივა არის Pdo\Mysql::ATTR_MULTI_STATEMENTS).
  • statement-ები სერვერის რესურსებია. თითოეული native prepare სერვერზე statement-ს ქმნის, რომელიც PDOStatement ობიექტის განადგურებამდე ცოცხლობს. MySQL ყველა კავშირის ჯამს max_prepared_stmt_count-ით ზღუდავს, ნაგულისხმევად 16,382. ხანგრძლივად მომუშავე worker-მა, რომელიც ციკლში prepare()-ს იძახებს და ყველა statement ობიექტს მასივში ინახავს, შეიძლება შეცდომა 1461 მიიღოს, Can't create more than max_prepared_stmt_count statements. prepare ციკლის გარეთ ერთხელ გააკეთე და execute - მრავალჯერ, ან ობიექტები დაუშვი, რომ scope-იდან გავიდნენ.

შეცდომის რეჟიმები და რას ატარებს PDOException#

PDO-ს სამი შეცდომის რეჟიმი აქვს, რომლებიც PDO::ATTR_ERRMODE-ით ირთვება:

რეჟიმიშეცდომისას
ERRMODE_SILENTაბრუნებს false-ს; errorInfo()-ს შენ ამოწმებ
ERRMODE_WARNINGაგდებს E_WARNING-ს და აბრუნებს false-ს
ERRMODE_EXCEPTIONაგდებს PDOException-ს - ნაგულისხმევია PHP 8.0-დან

კონსტრუქტორი გამონაკლისია: new PDO(...) წარუმატებელ კავშირზე PDOException-ს აგდებს შეცდომის რეჟიმის მიუხედავად, რადგან არ არსებობს ობიექტი, რომელზეც errorInfo() გამოიძახებოდა. კონსტრუქციის შემდეგ ყველაფერი რეჟიმს მისდევს.

silent რეჟიმი PDO-ს ყველაზე ძვირადღირებული ბაგების წყაროა, რადგან წარუმატებელი execute() false-ს აბრუნებს, კოდი გრძელდება და შედეგი შეცდომა კი არა, გამოტოვებული შეკვეთის სტრიქონია. silent რეჟიმში შეცდომები ორ ადგილას ინახება: წარუმატებელი prepare() PDO ობიექტზე ფიქსირდება ($pdo->errorInfo()), წარუმატებელი execute() კი statement-ზე ($stmt->errorInfo()). კოდი, რომელიც არასწორს ამოწმებს, 00000-ს ხედავს - წარმატებას. თუ silent რეჟიმის კოდი გერგო, გამონაკლისებზე გადართვა ხშირად ყველაზე კარგი ერთი გამოსწორებაა.

PDOException შეცდომას სამი ფორმით ატარებს:

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() ხუთსიმბოლოიან SQLSTATE-ს სტრიქონად აბრუნებს, ამიტომ PDOException იმ იშვიათ გამონაკლისებს შორისაა, სადაც getCode() მთელი რიცხვი არ არის; მკაცრი შედარება 23000-თან რიცხვის სახით არასოდეს დაემთხვევა. errorInfo არის მასივი: SQLSTATE, დრაივერის შეცდომის ნომერი და დრაივერის შეტყობინება. წარუმატებელი კავშირებისას გამოიყენე შეტყობინება და getCode(), რომელიც MySQL-ის ნომერს ინახავს, მაგალითად 1045 ან 2002; errorInfo-ს შევსებას იქ ნუ დაეყრდნობი.

შეძლებისდაგვარად MySQL-ის ნომრით განშტოვდი. SQLSTATE კატეგორიაა და ზოგი კატეგორია გადატვირთულია: 23000 მოიცავს დუბლიკატებს, foreign key-ის შეცდომებს და NOT NULL სვეტში NULL-ს, ხოლო HY000 ათეულობით ერთმანეთთან დაუკავშირებელ შეცდომას.

დაიჭირე ვიწროდ, დაალოგე სრულად

PDOException დაიჭირე მხოლოდ იქ, სადაც მასთან კონკრეტულს რამეს შეძლებ - დუბლიკატი email ფორმის შეცდომად აქციო, deadlock-ი ხელახლა სცადო - დანარჩენი კი თავიდან გადააგდე. მოულოდნელი მონაცემთა ბაზის შეცდომები აპლიკაციის თავში ერთ დამმუშავებელს მიაღწიოს, რომელიც სრულ შეტყობინებას ლოგავს და სტუმარს ზოგად გვერდს აჩვენებს. შეტყობინებაში შეიძლება იყოს host-ის სახელები, მომხმარებლის სახელები, მოთხოვნისა და მონაცემების ფრაგმენტები, ამიტომ მისი ადგილი ლოგია და არასოდეს - პასუხი. მონაცემთა ბაზის უსაფრთხოების ჩეკლისტი განმარტავს, კიდევ რა არ უნდა გაჟონოს.

SQLSTATE და MySQL შეცდომების ცნობარი#

შეცდომები, რომლებსაც რეალურად მოთხოვნებისგან შეხვდები, კავშირებისგან განსხვავებით (ისინი კავშირის პოსტშია). ჯერ შეზღუდვებისა და მონაცემების შეცდომები:

SQLSTATE / MySQLშეტყობინება იწყებაჩვეულებრივი მიზეზი
23000 / 1062Duplicate entryუნიკალური გასაღების შეჯახება
23000 / 1452Cannot add or update a child rowforeign key-ის სამიზნე არ არსებობს
23000 / 1451Cannot delete or update a parent rowმწკრივებს მაინც მიუთითებენ მასზე
23000 / 1048Column 'x' cannot be nullNULL NOT NULL-ში
22001 / 1406Data too long for columnსტრიქონი სვეტზე გრძელია
22003 / 1264Out of range valueრიცხვი ტიპისთვის ძალიან დიდია
22007 / 1292Incorrect datetime valueთარიღის არასწორი ფორმატი, ან ''
HY000 / 1366Incorrect integer value'' PHP-ის false-დან, ან არასწორი ტექსტი

შემდეგ statement-ისა და შესრულების შეცდომები:

SQLSTATE / MySQLშეტყობინება იწყებაჩვეულებრივი მიზეზი
42000 / 1064You have an error in your SQL syntaxშეცდომა წერაში, ან დაცული სიტყვა სახელად
42S02 / 1146Table ... doesn't existარასწორი ბაზა ან გამოტოვებული მიგრაცია
42S22 / 1054Unknown columnშეცდომა წერაში, ან deploy-ის შემდეგ დამატებული სვეტი
21S01 / 1136Column count doesn't matchINSERT-ის სვეტები და მნიშვნელობები განსხვავდება
HY093Invalid parameter numberplaceholder-ები და მნიშვნელობები არ ემთხვევა
40001 / 1213Deadlock foundმთელი ტრანზაქცია გაუქმდა - სცადე ხელახლა
HY000 / 1205Lock wait timeout exceededმწკრივის ბლოკს 50 წმ ელოდა
HY000 / 2014Cannot execute queries while other unbuffered queries are activeღია კურსორი

ამათგან სამს ერთი-ორი სიტყვა სჭირდება. HY093-ს MySQL-ის ნომერი არ აქვს, რადგან მას PDO ბადებს, სანამ რამე სერვერამდე მივა; ეს ყოველთვის placeholder-ებსა და მიწოდებულ მნიშვნელობებს შორის შეუსაბამობაა, და შემთხვევები placeholder-ების ზემოთა თავშია ჩამოთვლილი. 22001 და 22003 მხოლოდ strict რეჟიმში ჩნდება - STRICT_TRANS_TABLES-ის გარეშე MySQL სტრიქონს ამოკლებს ან რიცხვს ზღვარზე აჩერებს, ინახავს და გაფრთხილებას აგდებს, რომელსაც არავინ კითხულობს. ხოლო 1064 მოთხოვნაზე, რომელიც სწორად გამოიყურება, ხშირად ნიშნავს სვეტს სახელით order, key, rank ან groups: ყველა ისინი დაცული სიტყვებია და backtick-ები სჭირდება.

1205-ის 50 წამი innodb_lock_wait_timeout-ის ნაგულისხმევი მნიშვნელობაა. deadlock-ები და ბლოკის მოლოდინი განხილულია ხელახალი ცდის wrapper-ით პოსტში MySQL-ის ტრანზაქციები, ბლოკირება და deadlock-ები.

fetch რეჟიმები FETCH_ASSOC-ის მიღმა#

FETCH_ASSOC ნაგულისხმევად უმეტეს კოდს ერგება. დანარჩენი რეჟიმები ციკლს გიზოგავს ყოველ ჯერზე, როცა საჭირო ფორმა "მწკრივების სია" არ არის. გადაეცი ისინი fetch()-ს ან fetchAll()-ს, ან statement-ზე დააყენე $stmt->setFetchMode()-ით.

რეჟიმიდაბრუნებული ფორმა
FETCH_NUMმწკრივები ინდექსებით 0, 1, 2
FETCH_OBJმწკრივები stdClass ობიექტებად
FETCH_COLUMNპირველი სვეტის ბრტყელი სია
FETCH_KEY_PAIR[first => second] ორი სვეტიდან
FETCH_UNIQUEმწკრივები პირველი სვეტის მიხედვით ინდექსირებული
FETCH_GROUPმწკრივების სიები პირველი სვეტის მიხედვით დაჯგუფებული
FETCH_CLASSმწკრივები კლასში ჩატვირთული

FETCH_UNIQUE ის რეჟიმია, რომელსაც ხალხი ამჩნევს ნაკლებად. SELECT id, name, price FROM products fetchAll(PDO::FETCH_UNIQUE)-ით აბრუნებს [17 => ['name' => ..., 'price' => ...], ...]-ს, ID-ით ინდექსირებულს, ანუ ზუსტად იმ საძიებო ცხრილს, რომელსაც სხვაგვარად foreach-ით ააგებდი. თუ პირველი სვეტი უნიკალური არ არის, შემდეგი მწკრივები წინას გაფრთხილების გარეშე გადაწერს.

FETCH_GROUP შეიძლება FETCH_COLUMN-თან გაერთიანდეს: SELECT category, name FROM products fetchAll(PDO::FETCH_GROUP | PDO::FETCH_COLUMN)-ით აბრუნებს ['tools' => ['saw', 'drill'], 'paint' => [...]]-ს.

კლასების ჩატვირთვა

FETCH_CLASS თითო მწკრივზე ობიექტს ქმნის და სვეტებს იმავე სახელის თვისებებს ანიჭებს:

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);

ორი ქცევა ბევრს აკვირვებს. ნაგულისხმევად PDO თვისებებს კონსტრუქტორის გამოძახებამდე ადგენს, ამიტომ კონსტრუქტორი, რომელიც ნაგულისხმევ მნიშვნელობებს ანიჭებს, მიღებულ მონაცემებს გადაწერს. დაამატე PDO::FETCH_PROPS_LATE (FETCH_CLASS | FETCH_PROPS_LATE), რომ კონსტრუქტორი ჯერ გაეშვას. და სვეტი, რომელსაც შესაბამისი თვისება არ აქვს, მაინც ენიჭება, დინამიკურ თვისებად, რაც PHP 8.2-დან deprecation შეტყობინებას იწვევს, თუ კლასი დინამიკურ თვისებებს არ უშვებს. აირჩიე მხოლოდ ის სვეტები, რომლებსაც კლასი აცხადებს, ან alias-ებით დაუმთხვიე.

ტიპიზებული თვისებები fetch-ის ტიპებთან ურთიერთქმედებს: native prepare-ების დროს INT სვეტი int-ად მოდის და int თვისებაში ეტევა; DECIMAL სტრიქონად მოდის, ამიტომაა $price ზემოთ string-ად გამოცხადებული.

ცარიელი შედეგები

fetch() და fetchColumn() აბრუნებს false-ს, როცა მწკრივები აღარ არის; fetchAll() აბრუნებს ცარიელ მასივს. ამიტომ if ($row = $stmt->fetch()) არსებობის სწორი შემოწმებაა, და ამიტომ fetchColumn() COUNT(*) მოთხოვნაზე უსაფრთხოა - დათვლა ყოველთვის ერთ მწკრივს აბრუნებს. ფრთხილად იყავი fetchColumn()-თან სვეტზე, რომელსაც კანონიერად შეიძლება false-ის მსგავსი მნიშვნელობები ჰქონდეს: 0 ან '0' თავისთავად ბრუნდება, მაგრამ კოდი, რომელიც შედეგს if (!$value)-ით ამოწმებს, მას "მწკრივი არ არის"-დ აღიქვამს.

ტრანზაქციები, savepoint-ები და ხელახალი ცდები#

ძირითადი ნიმუში - beginTransaction(), სამუშაო, commit() და rollBack() catch-ში - კავშირის პოსტშია. კოდი კიდეებზე ტყდება.

PDO ტრანზაქციებს არ აბუდებს. beginTransaction()-ის გამოძახება აქტიური ტრანზაქციის დროს აგდებს There is already an active transaction-ს. კოდს, რომელიც სხვა ტრანზაქციის შიგნიდან ისეთ დამხმარეს იძახებს, რომელიც საკუთარ ტრანზაქციას ხსნის, სჭირდება ან wrapper, რომელიც სიღრმეს თვალყურს ადევნებს, ან savepoint-ები, რომლებსაც MySQL უბრალო 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;    }}

შიდა წარუმატებლობა მხოლოდ თავის savepoint-მდე იქცევა უკან; გარე ტრანზაქცია წყვეტს, გაგრძელდეს თუ არა. Laravel-ის DB::transaction() ასევე მუშაობს, ამიტომ მასში ბუდე შეცდომას არ აგდებს.

რამეები, რომლებიც ტრანზაქციას უკითხავად ამთავრებს:

  • DDL. CREATE, ALTER, DROP და TRUNCATE MySQL-ში იმპლიციტურად commit-დება. მათამდე შეტანილი ცვლილებები დაფიქსირებულია და უკან ვეღარ დაბრუნდება, ხოლო PHP 8-ზე შემდგომი commit() აგდებს There is no active transaction-ს.
  • Deadlock. შეცდომა 1213 სერვერზე მთელ ტრანზაქციას აუქმებს. ბოლო statement-ის ხელახლა ცდა არასწორია; ხელახლა სცადე მთელი ერთეული beginTransaction()-იდან.
  • დაკარგული კავშირი. შეცდომა 2006 ან 2013 ტრანზაქციის შუაში ნიშნავს, რომ სერვერმა უკან დააბრუნა. PDO ობიექტი აღარ გამოდგება.

ბლოკის მოლოდინის ვადის გასვლა (1205) უცნაურია: ნაგულისხმევად ის უკან მხოლოდ იმ statement-ს აბრუნებს, რომლის დროც ამოიწურა, ტრანზაქცია კი ღია რჩება. ხელახლა ცდამდე აშკარად გააკეთე rollback, თორემ სამუშაოს ნახევარს დააფიქსირებ.

ტრანზაქციები მოკლე გაამწესე და ნელი რამეები მათ გარეთ დატოვე. ტრანზაქცია, რომელიც მწკრივების ბლოკებს ინახავს და ამასობაში გადახდის API-ს იძახებს ან email-ს აგზავნის, ყველა სხვა მოთხოვნას ამ მწკრივებზე ალოდინებს - და მოლოდინის ვადის გასვლაც მოჰყვება.

lastInsertId და rowCount-ის განსაკუთრებული შემთხვევები#

$pdo->lastInsertId() აბრუნებს ამ კავშირზე ბოლო statement-ის მიერ გენერირებულ AUTO_INCREMENT მნიშვნელობას, სტრიქონის სახით. ის თითო კავშირზეა, ამიტომ პარალელური მოთხოვნები ერთმანეთის ID-ებს არასოდეს ხედავს. განსაკუთრებული შემთხვევები:

  • წაიკითხე დაუყოვნებლივ. შემდგომმა statement-მა, რომელიც ID-ს არ აგენერირებს, მნიშვნელობა შეიძლება 0-ზე დააბრუნოს. გამოიძახე INSERT-ის უშუალოდ შემდეგ, ამ კავშირზე სხვა მოთხოვნამდე.
  • მრავალმწკრივიანი ჩასმა პირველ ID-ს აბრუნებს. INSERT INTO tags (name) VALUES (?), (?), (?) პირველი მწკრივის ID-ს აბრუნებს. დანარჩენებს 1-ისა და 2-ის მიმატებით ნუ გამოთვლი; innodb_autoinc_lock_mode-სა და პარალელურ ჩასმებზე დამოკიდებულებით ეს დაშვება გარანტირებული არ არის. თუ ყველა ID გჭირდება, ჩასვი მწკრივ-მწკრივ ტრანზაქციის შიგნით, ან უკან წაიკითხე უნიკალური გასაღებით.
  • ცხრილები `AUTO_INCREMENT`-ის გარეშე - UUID გასაღებები, ბუნებრივი გასაღებები - აბრუნებს '0'-ს. გასაღები PHP-ში შექმენი და ის უკვე გაქვს.
  • `INSERT IGNORE`, რომელმაც მწკრივი გამოტოვა, ID-ს არ აგენერირებს. rowCount() შეამოწმე, რომ გაიგო, ჩაისვა თუ არა რამე.

INSERT ... ON DUPLICATE KEY UPDATE ორივესთვის სიფრთხილეს ითხოვს. MySQL rowCount()-ს აცხადებს 1-ად ახალი მწკრივისთვის, 2-ად განახლებული არსებული მწკრივისთვის და 0-ად არსებული მწკრივისთვის, რომელსაც უკვე იგივე მნიშვნელობები ჰქონდა. და როცა მწკრივი უკვე არსებობდა, lastInsertId() მის ID-ს არ გაძლევს - თუ არ გამოიყენებ MySQL-ის დოკუმენტირებულ ხრიკს, ID-ს 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);

ახლა lastInsertId() მწკრივის ID-ს ორივე შემთხვევაში აბრუნებს. MySQL 8.0.20-მა ამ clause-ში VALUES(col) deprecated გამოაცხადა მწკრივის alias-ის სასარგებლოდ (VALUES (...) AS new ... UPDATE name = new.name); VALUES() 8.4-შიც მუშაობს, მაგრამ გაფრთხილებას აგდებს, ამიტომ ახალმა კოდმა alias-ის ფორმა უნდა გამოიყენოს.

UPDATE-ზე rowCount() ცვლილ მწკრივებს ითვლის და არა დამთხვეულებს, ამიტომ მწკრივის მიმდინარე მნიშვნელობებზე განახლება 0-ს აბრუნებს. PDO::MYSQL_ATTR_FOUND_ROWS => true კავშირისას დაყენებით ის დამთხვეულ მწკრივებზე გადადის. SELECT-ზე rowCount() საერთოდ ნუ გამოიყენებ: PHP-ის სახელმძღვანელო აფრთხილებს, რომ SELECT-ზე მისი შედეგი პორტაბელური არ არის. დათვალე COUNT(*)-ით, ან დათვალე მიღებული მასივი.

სად მუშაობს ეს RE:NODE-ზე#

ზემოთქმულიდან არაფერი ჰოსტზეა დამოკიდებული, მაგრამ რამდენიმე ფაქტი გამოგადგება, თუ აქ გაუშვებ. მონაცემთა ბაზის ჰოსტინგზე MySQL არის MySQL 8.4 LTS, ამიტომ JSON_TABLE, ON DUPLICATE KEY UPDATE-ის მწკრივის alias-ის ფორმა და ამ პოსტში აღწერილი 8.x-ის ნაგულისხმევი მნიშვნელობები ყველა მოქმედებს; root პაროლი, აპლიკაციის ბაზა და აპლიკაციის მომხმარებელი სერვერის შექმნისას გენერირდება. Laravel / PHP აპლიკაციების ხაზი PHP 8.3, 8.4 ან 8.5-ს უშვებს FrankenPHP-ზე, რაც Pdo\Mysql მუდმივების სახელებისთვის მნიშვნელოვანია, ხოლო აპლიკაციისა და ვებ გეგმები მოიცავს მონაცემთა ბაზის სლოტებს, რომლებიც პანელში იქმნება გენერირებული host-ით, მომხმარებლითა და პაროლით. სლოტზე "Open in phpMyAdmin" SHOW CREATE TABLE-ის გასაშვებად ყველაზე სწრაფი გზაა, როცა უნდა ნახო, რომელი სვეტი არ არის ჯერ კიდევ utf8mb4 ან რომელ გასაღებს ეხება 1062.

FAQ#

იცავს თუ არა prepared statement-ები მოთხოვნას SQL ინექციისგან სრულად?

ისინი მნიშვნელობებს იცავს. ყველაფერი, რაც SQL ტექსტს ერთვის - დახარისხების სვეტის სახელი, ცხრილის სახელი, მიმართულების საკვანძო სიტყვა, სტრიქონიდან აგებული LIMIT - დაცვის გარეთაა. ისინი კოდში ფიქსირებული დაშვებული სიით დააკავშირე. მეორე ხვრელია შენახული მონაცემები, რომლებიც მოგვიანებით ხელახლა გამოიყენება: placeholder-ით უსაფრთხოდ ჩასმული მნიშვნელობა უკან წაკითხვისას მაინც არასანდოა და ისევ placeholder-ში უნდა გაიაროს.

ღირს თუ არა statement-ის prepare, რომელსაც მხოლოდ ერთხელ გავუშვებ?

დიახ, უსაფრთხოებისთვის. native prepare-ის ფასი სერვერთან ერთი დამატებითი წრეა, რაც ახლო მონაცემთა ბაზაზე მილიწამზე ბევრად ნაკლებია. კოდი, რომელიც query()-სა და prepare()-ს შორის იმის მიხედვით გადადის, მონაწილეობს თუ არა მნიშვნელობა, საბოლოოდ შეცდება; ყველაფრის prepare, რაც შეყვანას იღებს, ამ გადაწყვეტილებას აქრობს.

რატომ აბრუნებს execute() true-ს, მაგრამ არაფერი იცვლება?

ან WHERE არც ერთ მწკრივს არ დაემთხვა - rowCount() იქნება 0 - ან სამუშაო ტრანზაქციაში შესრულდა, რომელიც არასოდეს დაფიქსირებულა. გამორთული autocommit-ით, ან beginTransaction()-ის შემდეგ commit()-ის გარეშე, ცვლილებები უკან ბრუნდება, როცა კავშირი მოთხოვნის ბოლოს იხურება. გამართვისას სკრიპტის ბოლოს შეამოწმე $pdo->inTransaction().

შემიძლია ერთი PDOStatement სხვადასხვა მნიშვნელობით ხელახლა გამოვიყენო?

დიახ, და ეს განზრახ გამოყენებაა. ერთხელ prepare გააკეთე, შემდეგ execute() ახალი მასივით იმდენჯერ გამოიძახე, რამდენჯერაც გინდა; native prepare-ებით სერვერი SQL-ს მხოლოდ ერთხელ აანალიზებს. თუ statement-ზე წინა SELECT-მა წაუკითხავი მწკრივები დატოვა, ხელახლა შესრულებამდე გამოიძახე closeCursor().

როგორ დავალოგო SQL შევსებული მნიშვნელობებით?

native prepare-ებით PDO საბოლოო მოთხოვნის სტრიქონს არ აწარმოებს, რადგან სერვერი SQL-სა და მნიშვნელობებს ცალ-ცალკე იღებს. $stmt->debugDumpParams() ბეჭდავს SQL-ს და თითოეულ მიბმულ პარამეტრს, რაც ჩვეულებრივ საკმარისია. იმისთვის, რომ სრულად ნახო, რა გაუშვა სერვერმა, development-ის მანქანაზე გამოიყენე MySQL-ის general query log, ან production-ში - ნელი მოთხოვნების ლოგი იმ მოთხოვნებისთვის, რომლებიც მნიშვნელოვანია.