Fix last insert ID consistency accross drivers#2648
Fix last insert ID consistency accross drivers#2648deeky666 wants to merge 12 commits intodoctrine:masterfrom
Conversation
| return $stmt->fetchColumn(); | ||
| } | ||
|
|
||
| return $this->lastInsertId->getId(); |
There was a problem hiding this comment.
I wanted to remove the custom object altogether but unfortunately the property is protected on the connection so I kept it here for BC.
|
|
||
| // The last insert ID is reset to "0" in certain situations by some implementations, | ||
| // therefore we keep the previously set insert ID locally. | ||
| if ($lastInsertId) { |
There was a problem hiding this comment.
Is an empty string a valid generated identifier? I think this conditional should be checking for '0' !== $lastInsertId
There was a problem hiding this comment.
Also introduces the issue in scenarios where sequences actually contain '0' as valid insert ID at some point.
There was a problem hiding this comment.
These are just edge cases that should really not be considered, IMO, but it's good to comment on them, at least.
There was a problem hiding this comment.
@Ocramius this PR does not touch sequence ID retrieval (which requires passing $name). So this is all about regular "autoincrement" via identity columns. I am not aware of the possibility to have an identity column generate an empty string or 0 (they start at 1, don't they)?
There was a problem hiding this comment.
Yes, sequences are indeed not affected, but we still need to consider scenarios where something like an AUTO_INCREMENT started at -10, and then at some point reaches 0. We can't handle that, but we have to at least specify that to users (we only support positive insert identifiers, starting from 1, while all, other scenarios lead to edge cases)
|
While the fix is perfectly valid, and our API documents the behavior correctly, people may (!!!) currently rely on the last insert id being an Therefore, this will go only to |
|
I restarted the CI run, but postgresql + PHP nightly seem to have some issues with fetching the |
|
@Ocramius could you do me the favour and have a look at the current approach (no nitpicking, it's not polished yet) and tell me what you think about it? Concerns, optimizations, different ideas welcome. The only related failing test ist this one which is expected (see remarks in the code). Don't know how to fix this. I guess we need to skip the test? |
| * | ||
| * @author Steve Müller <st.mueller@dzh-online.de> | ||
| */ | ||
| class LastInsertId |
There was a problem hiding this comment.
final? I really suggest that userland does never extend this...
| $this->_bindedValues = array_fill(1, $paramCount, null); | ||
| } | ||
|
|
||
| $this->lastInsertId = $lastInsertId; |
There was a problem hiding this comment.
Should probably create an instance, if none is given
There was a problem hiding this comment.
That won't help as we need the reference in the connection. I just wanted to keep BC here, but not sure if that is necessary.
There was a problem hiding this comment.
Yes, that's necessary here, sadly.
| return '0'; | ||
| } | ||
|
|
||
| $sql = 'SELECT ' . $name . '.CURRVAL FROM DUAL'; |
| { | ||
| /** | ||
| * @var string | ||
| */ |
There was a problem hiding this comment.
Why not reuse the LastInsertId object that we created?
There was a problem hiding this comment.
Because I need to know how to retrieve the last insert ID in the statement object? Simply passing the LastInsertId container won't help in PDOStatement::execute()
There was a problem hiding this comment.
Ah I see what you mean. Yeah true I can do that.
| * | ||
| * @return bool | ||
| */ | ||
| protected function supportsTrackingLastInsertId() |
There was a problem hiding this comment.
This can most probably be a constant or a protected property. Having it as a method seems excessive, and can lead to (although minor) overhead
There was a problem hiding this comment.
Another (better) approach is to make trackLastInsertId empty in subclasses not supporting it. Thoughts?
There was a problem hiding this comment.
YES! The last suggestion sounds perfect. I will see if that works.
| */ | ||
| private $lastInsertIdCallNested = false; | ||
|
|
||
| /** |
There was a problem hiding this comment.
Please don't keep commented code
There was a problem hiding this comment.
I left it in here to demonstrate you what the problem with postgres is. I will of course remove it in the final version ;)
| try { | ||
| return parent::execute($params); | ||
| $result = parent::execute($params); | ||
|
|
There was a problem hiding this comment.
👍 BTW I am still not using either style consistently. Do you have any reason why to prefer one over the other?
| return sqlsrv_rows_affected($stmt); | ||
| $affectedRowCount = sqlsrv_rows_affected($stmt); | ||
|
|
||
| $stmt = sqlsrv_query($this->conn, 'SELECT @@IDENTITY'); |
There was a problem hiding this comment.
This seems excessive, as it duplicates queries to the DB. Since we can just SELECT @@IDENTITY anyway, do we need to update the last insert ID?
There was a problem hiding this comment.
This is what I was talking about before showing off this approach ("performance impact"). If you have a look at how the different PDO drivers implement lastInsertId() you will see that each of them actually trigger a query. pdo_sqlsrv uses the exact same, pdo_pgsql uses SELECT LASTVAL(), pdo_mysql uses SELECT LAST_INSERT_ID(). However I am not sure but I believe those are session related information calls which do not go through the server at all. Would need to investigate this.
There was a problem hiding this comment.
And yes we need to update the ID in exec(), query() and prepare()->execute() as those are the methods you can use to execute queries on each driver.
There was a problem hiding this comment.
However I am not sure but I believe those are session related information calls which do not go through the server at all. Would need to investigate this.
This might cause an uproar of DBAs if they actually hit the backend. If only some exotic drivers (cough sqlsrv cough) actually hit the backend, then we should be quite OK.
There was a problem hiding this comment.
Since this is an SQLSrv-only problem, this comment can be ignored.
IMO: their problem for relying on SQLSrv.
| return; | ||
| } | ||
|
|
||
| $statement = sqlsrv_query($this->conn, 'SELECT @@IDENTITY'); |
There was a problem hiding this comment.
As I said, this in fact is what would normally be in lastInsertId()
e2f2eb7 to
761b218
Compare
| $lastInsertId = $this->fetchLastInsertId(); | ||
|
|
||
| // Reactivate exception mode. | ||
| $this->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); |
There was a problem hiding this comment.
@Ocramius should we rather evaluate and set the previous error mode here? because in userland you could potentially change our default error mode.
There was a problem hiding this comment.
Scratch that - using `PDO#getAttribute()` is simple/easy here - anything against using it?
There was a problem hiding this comment.
Can fetchLastInsertId ever fail? If so, this block should be in a finally {}
There was a problem hiding this comment.
Done both (restoring + finally)
| * Protected constructor. | ||
| * | ||
| * @param PDOConnection|null $connection | ||
| * |
There was a problem hiding this comment.
@Ocramius no decision done here so far. What do you think?
There was a problem hiding this comment.
We have to maintain BC in my opinion, otherwise we may break existing manual DIC calls.
| $this->sql = $sql; | ||
|
|
||
| if (stripos($sql, 'INSERT INTO ') === 0) { | ||
| $this->sql .= self::LAST_INSERT_ID_SQL; |
There was a problem hiding this comment.
@Ocramius do we need to keep the constant for BC?
There was a problem hiding this comment.
The constant has to stay, but should be deprecated if we don't use it. This is where private constants will be vital once we switch to PHP 7.1
| // The last insert ID is reset to "0" in certain situations by some implementations, | ||
| // therefore we keep the previously set insert ID locally. | ||
| if ('0' !== $value) { | ||
| $this->value = $value; |
There was a problem hiding this comment.
Should probably apply a string cast, since we are not using PHP7's type hints.
There was a problem hiding this comment.
No longer needed, as we can rely on PHP 7.1 now.
There was a problem hiding this comment.
No need: handled via addition of a type hint
| $this->_bindedValues = array_fill(1, $paramCount, null); | ||
| } | ||
|
|
||
| $this->lastInsertId = $lastInsertId; |
There was a problem hiding this comment.
Yes, that's necessary here, sadly.
| $lastInsertId = $this->fetchLastInsertId(); | ||
|
|
||
| // Reactivate exception mode. | ||
| $this->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); |
There was a problem hiding this comment.
Scratch that - using `PDO#getAttribute()` is simple/easy here - anything against using it?
| $lastInsertId = $this->fetchLastInsertId(); | ||
|
|
||
| // Reactivate exception mode. | ||
| $this->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); |
There was a problem hiding this comment.
Can fetchLastInsertId ever fail? If so, this block should be in a finally {}
| } | ||
|
|
||
| /** | ||
| * Tracks the last insert ID at the current state. |
There was a problem hiding this comment.
This method should be annotated with @internal
| * Protected constructor. | ||
| * | ||
| * @param PDOConnection|null $connection | ||
| * |
There was a problem hiding this comment.
We have to maintain BC in my opinion, otherwise we may break existing manual DIC calls.
| try { | ||
| return parent::execute($params); | ||
| $result = parent::execute($params); | ||
|
|
There was a problem hiding this comment.
This conditional seems wrong: you only track if there's no connection?
There was a problem hiding this comment.
This comment is invalid - the connection passed in as constructor is a design decision, and nullability is just a side-effect of BC compliance. This code should never be skipped inside DBAL, but it could be skipped if the class is used directly in userland.
| return sqlsrv_rows_affected($stmt); | ||
| $affectedRowCount = sqlsrv_rows_affected($stmt); | ||
|
|
||
| $stmt = sqlsrv_query($this->conn, 'SELECT @@IDENTITY'); |
There was a problem hiding this comment.
However I am not sure but I believe those are session related information calls which do not go through the server at all. Would need to investigate this.
This might cause an uproar of DBAs if they actually hit the backend. If only some exotic drivers (cough sqlsrv cough) actually hit the backend, then we should be quite OK.
| $this->sql = $sql; | ||
|
|
||
| if (stripos($sql, 'INSERT INTO ') === 0) { | ||
| $this->sql .= self::LAST_INSERT_ID_SQL; |
There was a problem hiding this comment.
The constant has to stay, but should be deprecated if we don't use it. This is where private constants will be vital once we switch to PHP 7.1
| return; | ||
| } | ||
|
|
||
| $statement = sqlsrv_query($this->conn, 'SELECT @@IDENTITY'); |
… with a hardcoded value
…2 handles per test)
… with a hardcoded value
…2 handles per test)
This one came while trying to fix the last remaining testsuite failure on
sqlsrv.Retrieving the last insert ID for non-sequences is highly unreliable accross the driver implementations (as also stated in the PDO docs). This PR makes all drivers behave the same (which support this feature) with two exceptions (shown later on).
To demonstrate some inconsistency examples:
0after each non-insert querymysqliandsqlanywherereturnintinstead ofstringsqlsrvreturnsnullinstead of0if no INSERT was been executed, yetibm_db2returns `` instead of0if no INSERT was been executed, yetsqlsrvonly records the last insert ID on prepared statements (not on exec) because of custom implementation via objectpdo_pgsqlresets the last insert ID to0after dropping the particular table the last insert ID was retrieved fromoci8always returnsfalseinstead of0(not supported on Oracle)Looking at our interface for
lastInsertId()drivers always have to returnstring, no word about exceptions or what to return if not supported or no ID available.To stick to the contract and finding a reasonable common denominator, I chose MySQL as a base for returning "I don't know the value". MySQL returns
0in this case. Otherwise MySQL behaves different when it comes to keeping the last insert ID for the connection (resets to0while the others don't).Two exceptions that cannot be made consistent:
pdo_pgsqlreturns4294967295if no insert ID is available yet (weird thing, don't know what PHP intending here)I tried to cover the most important situations in the tests, hope I did not forget anything.