Każdy wielbiciel klasy PDO, który używał jej z bazą MSSQL za pośrednictwem sterownika FreeTDS zapewne doświadczył problemu z metodą lastInsertId. Problem ów polega na tym, iż metoda ta nie zwraca oczekiwanej wartości podczas dodawania rekordu do tabeli.
Jak sobie z tym problemem poradzić? Można oczywiście spróbować zmienić sterownik, ale to nie zawsze będzie możliwe. Można również zmodyfikować zapytanie do bazy, które zarówno doda nam potrzebny rekord jak i zwróci od razu interesującą nas wartość identyfikatora. Rozwiązanie takie ma jednak wady ‑przede wszystkim nie zawsze chcemy (lub nie możemy) modyfikować zapytania. Ponad to w przypadku stosowania „gotowych” bibliotek taka modyfikacja mogłaby budzić obawy, że przy najbliższej aktualizacji musielibyśmy pamiętać o wprowadzeniu niezbędnych zmian.
Czy można sobie jednak poradzić z problemem w taki sposób aby ewentualna ingerencja w kod była minimalna? Wg mnie można i w niniejszym wpisie chciałbym przedstawić na to swoją propozycję.
Do osiągnięcia celu potrzebujemy rozwiązać dwa problemy:
- pobrać w jakiś sposób wartość interesującego nas identyfikatora,
- nakłonić naszą klasę PDO aby poprzez metodę lastInsertId zwróciła nam oczekiwaną wartość.
W przypadku pierwszego problemu posłużymy się mechanizmami dostarczanymi przez samą bazę danych. Szybkie przeszukanie internetu podpowie nam, że w języku T‑SQL można posłużyć się w tym celu trzema konstrukcjami:
- wartość @@IDENTITY,
- funkcja SCOPE_IDENTITY(),
- funkcja IDENT_CURRENT(‘nazwa tabeli’).
Czym one się między siebie różnią? Wartość @@IDENTITY zwraca nam ostatnio utworzony identyfikator. Trzeba jednak być tutaj świadomym dosłowności tego opisu – jeśli zapytanie insertujące wykona jakiś trigger, który doda rekord do innej tabeli, to element ten zwróci dosłownie wartość ostatnio utworzonego identyfikatora (niekoniecznie tego, na którym nam zależy). Funkcja SCOPE_IDENTITY() natomiast jest już o wiele ciekawsza – zwraca ona wartość ostatnio utworzonego indeksu w ramach tego samego scope (czyli mówiąc prościej – naszego zapytania). Jeśli nasze polecenie spowoduje wywołanie np. triggera to funkcja ta nie będzie się interesować owymi „dodatkowymi” indeksami. Co do funkcji IDENT_CURRENT(‘nazwa tabeli’) to tutaj możemy pobrać wartość identyfikatora utworzonego w konkretnie wskazanej tabeli.
Na którą metodę pobierania wartości identyfikatora powinniśmy się zdecydować? Wg mnie oczywistym jest, że wartość @@IDENTITY powinniśmy sobie odpuścić. Pozostaje nam zatem wybrać jedną ze wspomnianych funkcji. Może będzie to dla kogoś zaskoczeniem, ale osobiście uważam, że łatwiejsza w wykorzystaniu będzie funkcja SCOPE_IDENTITY(). Nie znaczy to, że druga funkcja jest gorsza – tego nie mówię. Chodzi mi tylko o to, że w przypadku pierwszej z nich implementacja jest moim zdaniem o wiele łatwiejsza. Dlaczego – o tym powiem w dalszej części wpisu.
Aby „dobrać się” do wartości interesującego nas identyfikatora musimy postarać się o wykonanie w tym samym „scope” np. następującego zapytania
select SCOPE_IDENTITY() as 'identity';
Zapewne ktoś się zastanawia jak niby dodać coś do zapytania skoro założyliśmy sobie, że nie będziemy wprowadzać zmian do zapytań? Aby odpowiedzieć na to pytanie pokażę najpierw w jaki sposób poradzić sobie ze wspomnianym wcześniej drugim problemem – czyli jak nakłonić naszą klasę PDO do zwracania poprawnej wartości poprzez metodę lastInsertId. Oczywistym jest, iż nie mamy wpływu na samą definicję klasy PDO, możemy jednak wprowadzić do niej zmiany poprzez jej rozszerzenie. Dzięki temu zaimplementujemy własną metodę lastInsertId oraz otrzymamy możliwość wpłynięcia na treść samego zapytania SQL. Pisanie naszego rozszerzenia możemy zacząć od następującego kodu
class BetterPDO extends \PDO { public function prepare($statement, $driver_options = null) { } public function lastInsertId($name = null) { } }
Metodę prepare możemy wykorzystać od „przechwycenia” zapytania i uzupełnienia go o kod SQL pobierający wartość identyfikatora. Ciało naszej metody mogłoby wyglądać następująco
public function prepare($statement, $driver_options = null) { if (preg_match('/^insert/i', $statement)) { $statement = "{$statement}; select SCOPE_IDENTITY() AS 'Identity';"; } $stmt = parent::prepare($statement, is_array($driver_options) ? $driver_options : []); return $stmt; }
Działanie powyższego kodu jest dosyć proste. Napierw sprawdzamy czy zapytanie zaczyna się od słowa „insert”. Jeśli tak to na wszelki wypadek zamykamy nasze zapytanie znakiem „;” (jeśli zapytanie było już zamknięte to nic nie szkodzi – dla serwera dodatkowy znak „;” nie jest błędem) i dodajemy nasze dodatkowe zapytanie. Działanie takie opiera się oczywiście na założeniu, że nasze „oryginalne” zapytanie to czyste polecenie insertujące (czyli takie gdzie nie spodziewamy się zwracania żadnej kolekcji wierszy). Przy okazji warto również zwrócić uwagę dlaczego wykorzystana funkcja SCOPE_IDENTITY jest wg mnie lepsza od IDENT_CURRENT – w przypadku tej drugiej musielibyśmy wydobyć z oryginalnego zapytania nazwę tabeli, co może nie być już takie wygodne jak wykorzystane przez nas rozwiązanie 🙂
Jak wiadomo zapytanie SQL nie jest wykonywane przez samą klasę PDO ale przez obiekt PDOStatement zwracany przez metodę prepare. Ponieważ metoda pobierająca wartość identyfikatora jest jednak wykonywana na obiekcie klasy PDO dlatego dobrze byłoby zapisać sobie w nim wskaźnik do tworzonego obiektu PDOStatement. Wg mnie warto będzie jeszcze zapamiętać sobie, że „wzbogaciliśmy nasze zapytanie”. W efekcie dodajmy do naszej klasy dwa atrybuty
protected $stmt; protected $withIdentitySelect;
Naszą metodę prepare możemy teraz zmodyfikować do następującej postaci
public function prepare($statement, $driver_options = null) { $this->$withIdentitySelect = false; if (preg_match('/^insert/i', $statement)) { $statement = "{$statement}; select SCOPE_IDENTITY() AS 'Identity';"; $this->$withIdentitySelect = true; } $this->stmt = parent::prepare($statement, is_array($driver_options) ? $driver_options : []); return $this->stmt; }
Analogicznie możemy postąpić gdybyśmy chcieli nadpisać metodę query. W takiej sytuacji moglibyśmy zaimplementować ją jako
public function query($statement) { $this->$withIdentitySelect = false; if (preg_match('/^insert/i', $statement)) { $statement = "{$statement}; select SCOPE_IDENTITY() AS 'Identity';"; $this->$withIdentitySelect = true; } $this->stmt = parent::query($statement); return $this->stmt; }
Skoro mamy już zapamiętany wskaźnik do obiektu PDOStatement to możemy napisać teraz ciało naszej nowej metody lastInsertId:
public function lastInsertId($name = null) { $lastIndex = 0; if (($this->$withIdentitySelect) && ($this->stmt->columnCount() > 0)) { $lastIndex = $this->stmt->fetchColumn(0); $this->stmt->closeCursor(); } return $lastIndex; }
Jak widać rozwiązanie bazuje na prostym odczytaniu wyniku zwróconego przez nasze rozszerzone zapytanie. Oczywiście rozwiązanie to działa przy założeniu, że nikt nie wpadnie na pomysł aby przy zapytaniu insertującym próbować „fetchować” gdzieś po drodze zwracane dane 🙂
Jak teraz zaimplementować nasze rozwiązanie? Wg mnie najprostrzą metodą będzie wskazanie naszej klasy BetterPDO jako „use” z aliasem „PDO” w skryptach tworzących nowe obiekty PDO. Dzięki temu nie będziemy musieli modyfikować niczego w samym skrypcie za wyjątkiem dodania np. takiego kodu na jego początku
use BetterPDO as PDO;
Oczywiście jeśli w skrypcie wykorzystywane jest pełne odwołanie się do klasy \PDO wtedy może okazać się konieczne zastąpienie nazwy klasy wprost na naszą wszędzie go to konieczne.
Na zakończenie zbierzmy sobie jeszcze cały kod w jednym miejscu aby można go było łatwo pobrać i wypróbować 🙂
class BetterPDO extends \PDO { protected $stmt; protected $withIdentitySelect; public function prepare($statement, $driver_options = null) { $this->$withIdentitySelect = false; if (preg_match('/^insert/i', $statement)) { $statement = "{$statement}; select SCOPE_IDENTITY() AS 'Identity';"; $this->$withIdentitySelect = true; } $this->stmt = parent::prepare($statement, is_array($driver_options) ? $driver_options : []); return $this->stmt; } public function query($statement) { $this->$withIdentitySelect = false; if (preg_match('/^insert/i', $statement)) { $statement = "{$statement}; select SCOPE_IDENTITY() AS 'Identity';"; $this->$withIdentitySelect = true; } $this->stmt = parent::query($statement); return $this->stmt; } public function lastInsertId($name = null) { $lastIndex = 0; if (($this->$withIdentitySelect) && ($this->stmt->columnCount() > 0)) { $lastIndex = $this->stmt->fetchColumn(0); $this->stmt->closeCursor(); } return $lastIndex; } }