Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prepared statements become unusable after calling closeCursor() on IBM DB2, Oracle and MS SQL Server. #2546

Merged
merged 11 commits into from
Feb 4, 2017

Conversation

morozov
Copy link
Member

@morozov morozov commented Oct 30, 2016

The adapters above free their statement resources as part of closeCursor(), so the statement cannot be reused after that. The rest of adapters (mysqli, sasql and PDO) keep their statement resources in place during closing cursor and allow reuse after.

The fact that the statement is closed during closeCursor() contradicts the purpose of the method:

Closes the cursor, enabling the statement to be executed again.

@deeky666
Copy link
Member

@morozov the patch looks legit. What is the matter with mysqli? Why is it not working? Can we get it behaving correctly somehow?

@morozov
Copy link
Member Author

morozov commented Jan 14, 2017

@deeky666 the test will fail on mysqli until #2536 is resolved. Basically, the mysqli driver currently cannot reuse statements for a different reason. That pull request is also waiting for your input.

@morozov
Copy link
Member Author

morozov commented Jan 14, 2017

@deeky666 please see the update.

$value = $stmt->fetchColumn();
} catch (\Exception $e) {
// some adapters trigger PHP error or throw adapter-specific exception in case of fetching
// from a closed cursor, which still proves that it has been closed
Copy link
Member

Choose a reason for hiding this comment

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

This is an inconsistency then. Maybe we should fix that too. The interface states that false is returned in that case. Having the drivers behave consistently finally would be great. Can you investigate? Thanks!

Copy link
Member Author

@morozov morozov Jan 14, 2017

Choose a reason for hiding this comment

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

The current state is:

  1. pdo_sqlite returns false,
  2. mysqli throws Doctrine\DBAL\Driver\Mysqli\MysqliException "Commands out of sync; you can't run this command now",
  3. oci8 returns false,
  4. sqlsrv returns false,
  5. ibm_db2 triggers warning "db2_fetch_array(): Fetch Failure".

Looks like MySQL and DB2 consider this situation logically exceptional, as the cursor is not just empty but is explicitly closed. Maybe so should we in the rest of the drivers.

Copy link
Member

Choose a reason for hiding this comment

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

@morozov it is indeed an exceptional case but we should stick to our interfaces as those are the contracts we provide. In the 3.0 we will rework the interfaces to throw meaningful exceptions as returning false is not a good behaviour in exceptional cases. But for now we should stick to returning false I think. @Ocramius thoughts?

Copy link
Member Author

Choose a reason for hiding this comment

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

@deeky666 if I understand correctly, there's no clear expectation of how cursors should behave after being closed given the current implementation. The problem with returning false is that it's hard to identify why exactly fetch failed on the application end. "Fetch Failure" and "Commands out of sync;" are too generic and may mean some other exception which we don't want to silently convert to false.

Another question is, do we really have to fix consistency as part of this ticket. It's already big enough and judging by the number resolves ~5 different issues (the number of test cases).

Copy link
Member

Choose a reason for hiding this comment

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

@morozov you are completely right. I feel bad about returning false too but unfortunately we have made up that contract when designing the API.

I'm also aware that this PR is growing big already but in the end it is about fixing the close cursor behaviour across all the drivers. Unfortunately it's quite a rabbit hole but it feels wrong to leave some parts buggy when trying to fix a general problem (which this test case exposes). I am ok with a rather big change here if we get it fixed consistently.

Also relying on exceptions from some drivers but not from others in this scenario is not an issue for us because it is undefined behaviour (as not being defined in the interface).

Copy link
Member Author

Choose a reason for hiding this comment

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

@deeky666 I see your point. How should we implement handling these warnings/exceptions? Just do string/error code comparison? Or maybe manually track calls to closeCursor() in the drivers in question and return false without calling the actual fetch() to meet the contract.

Copy link
Member

@deeky666 deeky666 Jan 16, 2017

Choose a reason for hiding this comment

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

@morozov We could use a flag $result for those problematic drivers, which is set in execute as soon as we have a result and have fetch() methods check for that flag and return false early. closeCursor will then reset the flag. Does that make sense or am I missing something here?

Copy link
Member Author

Choose a reason for hiding this comment

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

@deeky666 I was thinking about the opposite: having an $isCursorClosed flag which is set in closeCursor(), reset in execute() and is checked before calling the underlying fetch() implementation. Here, we only want to manually handle the case when the cursor is closed and then fetched. Not the cases when for example the statement is constructed but not yet executed (which would also be covered with your approach). In general, both are about the same. What would you suggest?

Copy link
Member

Choose a reason for hiding this comment

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

@morozov I don't have a strong opinion about either of both options. I am just concerned about what the actual behaviour should be when fetching without having executed the query before. Because IMO the whole API defined in Doctrine\DBAL\Driver\ResultStatement is in theory only available after executing the prepared statement. I think of it as if one would return a Result object (which is in fact a cursor) form the execute() method. So to say an encapsulation of the result cursor. I think APIs like JDBC have similar constucts. So in this case the $result flag would make more sense to me as it is explicitly set when having a result rather than having as $isCursorClosed which does not have a meaning as long as we the prepared statment is not executed. I think I saw something like the $result flag in some driver already...

$stmt->execute();

$id = $stmt->fetchColumn();
$this->assertNotEmpty($id);
Copy link
Member

Choose a reason for hiding this comment

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

I don't think we need to assert here again as it already is covered by the test before. So simply executing and immediately closing should be enough.

$table->addColumn('id', 'integer');
$sm->createTable($table);
$this->_conn->insert('stmt_test_no_results', array('id' => 1));
$this->_conn->insert('stmt_test_no_results', array('id' => 2));
Copy link
Member

Choose a reason for hiding this comment

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

We only need one row, so please remove this one.

@morozov morozov force-pushed the close-cursor-frees-statement branch from 923c29d to c9b86c2 Compare January 14, 2017 23:52
@morozov
Copy link
Member Author

morozov commented Jan 17, 2017

@deeky666 please see the update.

@morozov
Copy link
Member Author

morozov commented Jan 23, 2017

@deeky666, ping

Copy link
Member

@deeky666 deeky666 left a comment

Choose a reason for hiding this comment

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

We also need to change sasql_stmt_free_result to sasql_stmt_reset in SQLAnywhereStatement::closeCursor() as reusing the prepared statement won't work otherwise.

Furthermore I would suggest that we introduce more tests for edge case scenarios:

  • fetch*()
  • closeCursor()
  • closeCursor() -> fetch*()
  • execute() -> closeCursor() -> fetch*()

What do you think? Anything else that comes to mind?

$sm = $this->_conn->getSchemaManager();
$table = new Table('stmt_test_longer_results');
$table->addColumn('param', 'string');
$table->addColumn('value', 'text');
Copy link
Member

Choose a reason for hiding this comment

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

Using value errors on DB2 as it is a reserved word. Using val for example works though.


$sm = $this->_conn->getSchemaManager();
$table = new Table('stmt_test_long_blob');
$table->addColumn('data', 'blob', array(
Copy link
Member

Choose a reason for hiding this comment

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

Using data errors on DB2 as it is a reserved word, we need to use something else here.

// @link http://php.net/manual/en/pdostatement.closecursor.php
// @link https://github.com/php/php-src/blob/php-7.0.11/ext/pdo/pdo_stmt.c#L2075
// deliberately do not consider multiple result sets, since doctrine/dbal doesn't support them
while (oci_fetch($this->_sth));
Copy link
Member

Choose a reason for hiding this comment

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

We need to work with $result here too, otherwise calling closeCursor() or one of the fetch*() methods before execute() errors:

ORA-24374: define not done before fetch or execute and fetch

PDO returns true for closeCursor() and false for fetch*() methods. We should try to be consistent here.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

@@ -150,9 +150,13 @@ public function bindParam($column, &$variable, $type = null, $length = null)
*/
public function closeCursor()
{
if ($this->stmt) {
Copy link
Member

Choose a reason for hiding this comment

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

If $stmt is not initialized, we need to return true here. This happens if execute() has not been called before. Otherwise sqlsrv_fetch() will error.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

@@ -150,9 +150,13 @@ public function bindParam($column, &$variable, $type = null, $length = null)
*/
public function closeCursor()
{
if ($this->stmt) {
Copy link
Member

Choose a reason for hiding this comment

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

Also reusing prepared statements does currently not work as the binding by reference does not work. We need to modify bindParam() to this:

public function bindParam($column, &$variable, $type = null, $length = null)
{
    if (!is_numeric($column)) {
        throw new SQLSrvException("sqlsrv does not support named parameters to queries, use question mark (?) placeholders instead.");
    }

    $this->values[$column] = $variable;

    if ($type === \PDO::PARAM_LOB) {
        $this->params[$column-1] = array(&$this->values[$column], SQLSRV_PARAM_IN, SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY), SQLSRV_SQLTYPE_VARBINARY('max'));
    } else {
        $this->params[$column-1] =& $this->values[$column];
    }
}

Also introduces a new private property $values that keeps track of parameter references. Otherwise using different parameters won't work and the driver will always use those from the first binding.

Copy link
Member Author

Choose a reason for hiding this comment

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

@deeky666 is it about the case like:

public function testReuseStatementWithParameterBoundByReference()
{
    $this->_conn->insert('stmt_test', array('id' => 1));
    $this->_conn->insert('stmt_test', array('id' => 2));

    $stmt = $this->_conn->prepare('SELECT id FROM stmt_test WHERE id = ?');
    $stmt->bindParam(1, $id);

    $id = 1;
    $stmt->execute();
    $this->assertEquals(1, $stmt->fetchColumn());

    $id = 2;
    $stmt->execute();
    $this->assertEquals(2, $stmt->fetchColumn());
}

Then it should be $this->values[$column] =& $variable, same as in MysqliStatement::bindParam(). In any case, DB2 is unhappy about this case because the DBAL does both db2_bind_param() and passes the values of $this->_bindParam to db2_execute():

db2_execute(): Binding Error 2

Copy link
Member

Choose a reason for hiding this comment

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

@morozov basically it is about that yes. But the test case I used was this:

1) Doctrine\Tests\DBAL\Functional\StatementTest::testReuseStatementAfterClosingCursor
Failed asserting that 1 matches expected 2.

/php/srv/tests/Doctrine/Tests/DBAL/Functional/StatementTest.php:151

But also we are talking about SQL Server here, not DB2.

Copy link
Member Author

Choose a reason for hiding this comment

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

@deeky666 with your fix testReuseStatementAfterClosingCursor() passes, thank you. Not sure if this is relevant to this issue (I wish it wasn't), but testReuseStatementWithParameterBoundByReference() above still doesn't pass on SQL Server and IBM DB2. It's made up and I don't know if it should pass in the first place (however it does on other DBs).

The difference between the two is that the first binds param by value, and the second binds them by reference.

Copy link
Member Author

Choose a reason for hiding this comment

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

UPD: I've modified your solution to make testReuseStatementWithParameterBoundByReference() also pass.

Copy link
Member

Choose a reason for hiding this comment

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

@morozov I now figured out the issue with DB2 binding and it is quite nasty -.-. The binding error seems to be related due to $this->_bindParam having referenced variable values so the fix that worked for me is to change the portion in execute() to:

        if ($params === null) {
            ksort($this->_bindParam);

            $params = array();

            foreach ($this->_bindParam as $column => $value) {
                $params[] = $value;
            }
        }

This fixes the binding error but in fact we cannot get db_bind_param() to work at all because it is not actually binding immediately at call time. Instead when calling db2_execute(), the function looks up variables by the names passed to db2_bind_param lazily. And even worse, it expects thos variables to be declared in global scope (WTF). In theory we could create global variables on binding with global keyword but that does not seem to work properly either as I am getting db2_execute(): Binding Error 1 then. So I would suggest changing the code as shown above, because real parameter binding does not work atm anyways.

@morozov morozov force-pushed the close-cursor-frees-statement branch from 89bc749 to ec8c742 Compare January 25, 2017 01:55
@morozov
Copy link
Member Author

morozov commented Jan 25, 2017

@deeky666 thank you for the detailed review.

@morozov morozov force-pushed the close-cursor-frees-statement branch from ec8c742 to bc62b08 Compare January 25, 2017 04:46
@deeky666
Copy link
Member

@morozov we are getting closer :) Awesome test cases btw!

…LongBlob()` on pdo_sqlsrv

The test is partially skipped due to:

1. Fetching blob as binary string from pdo_sqlsrv requires using `PDOStatement::bindColumn()` and `PDOStatement::fetch(PDO::FETCH_BIND)` which are not supported by the DBAL.
2. Setting encoding on the connection level is not supported: https://msdn.microsoft.com/en-us/library/ff628164(v=sql.105).aspx
…StatementWithParameterBoundByReference()` on ibm_db2
@morozov morozov force-pushed the close-cursor-frees-statement branch from 2050ed7 to dab3ae1 Compare February 2, 2017 19:56
@Ocramius
Copy link
Member

Ocramius commented Feb 2, 2017

Random comment, but it has to be said: @morozov thanks for the patience, impressive work!

@morozov
Copy link
Member Author

morozov commented Feb 2, 2017

@Ocramius my pleasure. I want to say the same to you guys but I'll wait until this one gets merged :-)

Copy link
Member

@deeky666 deeky666 left a comment

Choose a reason for hiding this comment

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

simply: AWESOME!

@deeky666
Copy link
Member

deeky666 commented Feb 2, 2017

LGTM! I'll let have @Ocramius have a second look on that and then we can merge. 🎉

@morozov
Copy link
Member Author

morozov commented Feb 2, 2017

@deeky666 does it make sense to introduce a libmysql label as it's also an environment attribute (often not taken into account) which the DBAL behavior may depend on?

@deeky666
Copy link
Member

deeky666 commented Feb 3, 2017

@morozov good point. Done. Added libmysql and mysqlnd labels.

@Ocramius Ocramius merged commit 3f8c746 into doctrine:master Feb 4, 2017
@Ocramius
Copy link
Member

Ocramius commented Feb 4, 2017

Awesome work! Some minor CS problems, but I'm going to close an eye and fix them post-merge.

Thanks @morozov and @deeky666!

Ocramius added a commit that referenced this pull request Feb 4, 2017
@Ocramius
Copy link
Member

Ocramius commented Feb 4, 2017

Backported to 2.5 in b713ba7

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.

3 participants