Skip to content

Fix last insert ID consistency accross drivers#2648

Closed
deeky666 wants to merge 12 commits intodoctrine:masterfrom
deeky666:fix-last-insert-id-consistency
Closed

Fix last insert ID consistency accross drivers#2648
deeky666 wants to merge 12 commits intodoctrine:masterfrom
deeky666:fix-last-insert-id-consistency

Conversation

@deeky666
Copy link
Copy Markdown
Member

@deeky666 deeky666 commented Feb 8, 2017

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:

  • MySQL drivers reset the last insert ID to 0 after each non-insert query
  • mysqli and sqlanywhere return int instead of string
  • sqlsrv returns null instead of 0 if no INSERT was been executed, yet
  • ibm_db2 returns `` instead of 0 if no INSERT was been executed, yet
  • sqlsrv only records the last insert ID on prepared statements (not on exec) because of custom implementation via object
  • pdo_pgsql resets the last insert ID to 0 after dropping the particular table the last insert ID was retrieved from
  • oci8 always returns false instead of 0 (not supported on Oracle)
  • Surely others I forgot right now...

Looking at our interface for lastInsertId() drivers always have to return string, 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 0 in this case. Otherwise MySQL behaves different when it comes to keeping the last insert ID for the connection (resets to 0 while the others don't).

Two exceptions that cannot be made consistent:

  • SQLite uses a different concept when it comes to reusing rolled back insert IDs (see tests)
  • pdo_pgsql returns 4294967295 if 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.

return $stmt->fetchColumn();
}

return $this->lastInsertId->getId();
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is an empty string a valid generated identifier? I think this conditional should be checking for '0' !== $lastInsertId

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also introduces the issue in scenarios where sequences actually contain '0' as valid insert ID at some point.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are just edge cases that should really not be considered, IMO, but it's good to comment on them, at least.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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)?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@Ocramius Ocramius added this to the 2.6 milestone Feb 8, 2017
@Ocramius
Copy link
Copy Markdown
Member

Ocramius commented Feb 8, 2017

While the fix is perfectly valid, and our API documents the behavior correctly, people may (!!!) currently rely on the last insert id being an int.

Therefore, this will go only to 2.6 due to regression risks in consumer code.

@Ocramius
Copy link
Copy Markdown
Member

Ocramius commented Feb 8, 2017

I restarted the CI run, but postgresql + PHP nightly seem to have some issues with fetching the lastval

@deeky666 deeky666 changed the title Fix last insert ID consistency accross drivers [WIP] Fix last insert ID consistency accross drivers Feb 8, 2017
@deeky666
Copy link
Copy Markdown
Member Author

deeky666 commented Feb 9, 2017

@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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

final? I really suggest that userland does never extend this...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

$this->_bindedValues = array_fill(1, $paramCount, null);
}

$this->lastInsertId = $lastInsertId;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably create an instance, if none is given

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's necessary here, sadly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

return '0';
}

$sql = 'SELECT ' . $name . '.CURRVAL FROM DUAL';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$sql variable is redundant

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

{
/**
* @var string
*/
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not reuse the LastInsertId object that we created?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see what you mean. Yeah true I can do that.

*
* @return bool
*/
protected function supportsTrackingLastInsertId()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could also be a constant, btw.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another (better) approach is to make trackLastInsertId empty in subclasses not supporting it. Thoughts?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YES! The last suggestion sounds perfect. I will see if that works.

*/
private $lastInsertIdCallNested = false;

/**
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't keep commented code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if ($this->connection) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 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');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I said, this in fact is what would normally be in lastInsertId()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks OK then.

@deeky666 deeky666 force-pushed the fix-last-insert-id-consistency branch from e2f2eb7 to 761b218 Compare February 9, 2017 20:18
$lastInsertId = $this->fetchLastInsertId();

// Reactivate exception mode.
$this->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
Copy link
Copy Markdown
Member Author

@deeky666 deeky666 Feb 9, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ocramius should we rather evaluate and set the previous error mode here? because in userland you could potentially change our default error mode.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Scratch that - using `PDO#getAttribute()` is simple/easy here - anything against using it?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can fetchLastInsertId ever fail? If so, this block should be in a finally {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done both (restoring + finally)

* Protected constructor.
*
* @param PDOConnection|null $connection
*
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ocramius no decision done here so far. What do you think?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ocramius do we need to keep the constant for BC?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

// 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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably apply a string cast, since we are not using PHP7's type hints.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer needed, as we can rely on PHP 7.1 now.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need: handled via addition of a type hint

$this->_bindedValues = array_fill(1, $paramCount, null);
}

$this->lastInsertId = $lastInsertId;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's necessary here, sadly.

$lastInsertId = $this->fetchLastInsertId();

// Reactivate exception mode.
$this->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can fetchLastInsertId ever fail? If so, this block should be in a finally {}

}

/**
* Tracks the last insert ID at the current state.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method should be annotated with @internal

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* Protected constructor.
*
* @param PDOConnection|null $connection
*
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conditional seems wrong: you only track if there's no connection?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks OK then.

morozov added a commit to morozov/dbal that referenced this pull request Mar 31, 2018
morozov added a commit to morozov/dbal that referenced this pull request Mar 31, 2018
morozov added a commit to morozov/dbal that referenced this pull request Mar 31, 2018
morozov added a commit to morozov/dbal that referenced this pull request Mar 31, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 2, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 2, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 2, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 2, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 2, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 2, 2018
morozov added a commit to morozov/dbal that referenced this pull request Apr 2, 2018
morozov added a commit to morozov/dbal that referenced this pull request Apr 2, 2018
morozov added a commit to morozov/dbal that referenced this pull request Apr 2, 2018
morozov added a commit to morozov/dbal that referenced this pull request Apr 2, 2018
morozov added a commit to morozov/dbal that referenced this pull request Apr 2, 2018
morozov added a commit to morozov/dbal that referenced this pull request Apr 2, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 3, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 3, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 3, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 3, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 3, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 3, 2018
morozov added a commit to morozov/dbal that referenced this pull request Apr 3, 2018
morozov added a commit to morozov/dbal that referenced this pull request Apr 3, 2018
morozov added a commit to morozov/dbal that referenced this pull request Apr 3, 2018
morozov added a commit to morozov/dbal that referenced this pull request Apr 3, 2018
morozov added a commit to morozov/dbal that referenced this pull request Apr 3, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 3, 2018
morozov pushed a commit to morozov/dbal that referenced this pull request Apr 3, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants