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

Bug 4069 - query returns Result even when query result is FALSE #4176

Merged
merged 10 commits into from
Feb 26, 2021

Conversation

sneakyimp
Copy link
Contributor

Initial commit fixing wrong return value in Database/BaseConnection:query. When underlying resultID if false, this function should return false as described in #4069. This is a preliminary (and trivial) correction. In order for this change to be run, the db must be configured with DBDebug=false (e.g., in production mode).

NOTE that this code change is based on the CI 3 behavior. This may seem like a fundamental change but I truly believe that returning a Result object when the query failed was a mistake because these cast to true. I have been unable to detect any change in the behavior of the phpunit tests from this one change, so it's my guess that the phpunit tests do not ever run with DBDebug=false. I've constructed a phpunit test, BadQueryTest which seems to work on my machine, but I have added this line to my .env file:

database.tests.DBDebug = false

I suspect my BadQueryTest might have trouble with the automate tests that will run, and would ask help (@kenjis ? @michalsn ?) in massaging things here to achieve the right test and make sure this isn't a breaking change. I've only made this change to try and make sure CI4 behaves as the documentation says it will. There are no changes required to the user docs. On the contrary, there is probably still more work to be done to the code to make sure it behaves as the documentation says it does. In particular, the code will not currently return true under any circumstances.

Description
I changed one line (line 680) to return false instead of a Result object. This conforms both to CI3 behavior but also to the behavior described in the CI4 documentation. I added a BadQueryTest which probably needs work.

Checklist:

  • Securely signed commits
  • Component(s) with PHPdocs
  • Unit testing, with >80% coverage
  • User guide updated
  • Conforms to style guide

@sneakyimp
Copy link
Contributor Author

sneakyimp commented Jan 29, 2021

Looking at the test errors, it would appear that some of the tests expect a ResultInterface object to be returned when, according to the docs, no such object is specified -- and, given that the docs on the db->query functon say write queries return a boolean, we should be expecting a boolean:

The query() function returns a database result object when “read” type queries are run which you can use to show your results. When “write” type queries are run it simply returns TRUE or FALSE depending on success or failure.

The errors from my last commit:

There were 7 errors:

1) CodeIgniter\Models\DeleteModelTest::testDeleteFail
ErrorException: Trying to get property 'resultID' of non-object

/home/runner/work/CodeIgniter4/CodeIgniter4/tests/system/Models/DeleteModelTest.php:30

2) CodeIgniter\Models\DeleteModelTest::testDeleteWithSoftDeleteFail
ErrorException: Trying to get property 'resultID' of non-object

/home/runner/work/CodeIgniter4/CodeIgniter4/system/Database/BaseBuilder.php:2474
/home/runner/work/CodeIgniter4/CodeIgniter4/system/Model.php:418
/home/runner/work/CodeIgniter4/CodeIgniter4/system/BaseModel.php:995
/home/runner/work/CodeIgniter4/CodeIgniter4/tests/system/Models/DeleteModelTest.php:59

3) CodeIgniter\Models\InsertModelTest::testInsertResultFail
ErrorException: Trying to get property 'resultID' of non-object

/home/runner/work/CodeIgniter4/CodeIgniter4/system/Model.php:281
/home/runner/work/CodeIgniter4/CodeIgniter4/system/BaseModel.php:751
/home/runner/work/CodeIgniter4/CodeIgniter4/system/Model.php:705
/home/runner/work/CodeIgniter4/CodeIgniter4/tests/system/Models/InsertModelTest.php:138

4) CodeIgniter\Models\SaveModelTest::testSaveNewRecordArrayFail
ErrorException: Trying to get property 'resultID' of non-object

/home/runner/work/CodeIgniter4/CodeIgniter4/system/Model.php:281
/home/runner/work/CodeIgniter4/CodeIgniter4/system/BaseModel.php:751
/home/runner/work/CodeIgniter4/CodeIgniter4/system/Model.php:705
/home/runner/work/CodeIgniter4/CodeIgniter4/system/BaseModel.php:667
/home/runner/work/CodeIgniter4/CodeIgniter4/tests/system/Models/SaveModelTest.php:54

5) CodeIgniter\Models\SaveModelTest::testSaveUpdateRecordArrayFail
ErrorException: Trying to get property 'resultID' of non-object

/home/runner/work/CodeIgniter4/CodeIgniter4/system/Database/BaseBuilder.php:2474
/home/runner/work/CodeIgniter4/CodeIgniter4/system/Model.php:353
/home/runner/work/CodeIgniter4/CodeIgniter4/system/BaseModel.php:887
/home/runner/work/CodeIgniter4/CodeIgniter4/system/Model.php:737
/home/runner/work/CodeIgniter4/CodeIgniter4/system/BaseModel.php:663
/home/runner/work/CodeIgniter4/CodeIgniter4/tests/system/Models/SaveModelTest.php:84

6) CodeIgniter\Models\UpdateModelTest::testUpdateResultFail
ErrorException: Trying to get property 'resultID' of non-object

/home/runner/work/CodeIgniter4/CodeIgniter4/system/Database/BaseBuilder.php:2474
/home/runner/work/CodeIgniter4/CodeIgniter4/system/Model.php:353
/home/runner/work/CodeIgniter4/CodeIgniter4/system/BaseModel.php:887
/home/runner/work/CodeIgniter4/CodeIgniter4/system/Model.php:737
/home/runner/work/CodeIgniter4/CodeIgniter4/tests/system/Models/UpdateModelTest.php:100

7) CodeIgniter\Database\Live\BadQueryTest::testBadQuery
ErrorException: pg_query(): Query failed: ERROR:  relation "table_does_not_exist" does not exist
LINE 1: SELECT * FROM table_does_not_exist
                      ^

/home/runner/work/CodeIgniter4/CodeIgniter4/system/Database/Postgre/Connection.php:170
/home/runner/work/CodeIgniter4/CodeIgniter4/system/Database/BaseConnection.php:715
/home/runner/work/CodeIgniter4/CodeIgniter4/system/Database/BaseConnection.php:643
/home/runner/work/CodeIgniter4/CodeIgniter4/tests/system/Database/Live/BadQueryTest.php:18

For example, in DeleteModelTest, we see this:

	public function testDeleteFail(): void
	{
		$this->setPrivateProperty($this->db, 'DBDebug', false);
		$this->createModel(JobModel::class);
		$this->seeInDatabase('job', ['name' => 'Developer']);

		$result = $this->model->where('name123', 'Developer')->delete();
		$this->assertFalse($result->resultID);
		$this->seeInDatabase('job', ['name' => 'Developer']);
	}

Neither model docs nor the query builder docs give any details about the return values of their respective delete methods. We can see by reading the source that the Model delete method utilizes either the builder::update or builder::delete functions. We can see from the update and delete source code that those functions return the result of db->query. As mentioned above, the db->query docs should be returning boolean values.

I would point out that the testDeleteWithSoftDeleteFail test doesn't expect a result object, it expects a boolean. This boolean is currently extracted in the BaseBuilder::update function where the code anticipates a ResultInterface object even though the query is not a read-type query. This behavior contradicts the behavior of the query function specified in the documentation.

I haven't troubled yet to examine the other test cases. I am hoping to get some feedback from the project bosses before I charge too far down this rabbit hole. Clearly, my pull request isn't ready yet. I'd like to further modify the query method to reflect the behavior specified in the documentation: a ResultInterface object for "read" type queries, a boolean for "write" type queries. We'd also need to locate and fix any code that anticipates a ResultInterface object for a "read" type query.

@kenjis
Copy link
Member

kenjis commented Jan 29, 2021

--- a/tests/system/Database/Live/BadQueryTest.php
+++ b/tests/system/Database/Live/BadQueryTest.php
@@ -15,6 +15,7 @@ class BadQueryTest extends CIDatabaseTestCase
    {
        // this throws an exception in this testing environment, but in production it'll return FALSE
        // perhaps check $this->db->DBDebug for different test?
+       $this->setPrivateProperty($this->db, 'DBDebug', false);
        $query = $this->db->query('SELECT * FROM table_does_not_exist');
        $this->assertEquals(false, $query);
    }
$ vendor/bin/phpunit tests/system/Database/Live/BadQueryTest.php --no-coverage
PHPUnit 8.5.4 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 138 ms, Memory: 12.00 MB

OK (1 test, 1 assertion)

@sneakyimp
Copy link
Contributor Author

Thanks @paulbalandan for requesting a review from @michalsn

This commit is clearly not ready -- aside from the nearly trivial change to BaseConnection::query, some classes and tests that use this function will need to be examined and the function should probably be further altered to return true for successful write-type queries. This is not an outrageous amount of work, but a requires a fair effort. I'd like to know how folks feel about this change I'm hoping to make. I think it's necessary because

  1. The CI4 documentation, presumably published with the 4.0 release, says that's how the function should work, and developers of CI4 projects have likely already written code expecting boolean results for write-type queries.
  2. This is how the CI3 query function behaves, so migrations from CI3 to CI4 will be easier (which is my primary concern).

It might be a bit of a chore to return an actual boolean true value for all successful write-type queries because if differences in behavior between the various DBMS query functions. We'd probably have to do regex on the query being run to determine whether or not the query is a write-type query or read-type query. For everyone's reference i've constructed two queries to run on the test db:

-- bad query
UPDATE table_does_not_exist SET name='Developers' WHERE id=1
-- good query
UPDATE db_job SET name='Developer' WHERE id=1

The results of the various native DBMS functions are as follows:

function bad good
mysqli_query false true
pg_query false resource of type pgsql result
SQLite3::query false true
sqlsrv_query false resource of type SQL Server Statement

I was thinking of mimicking the old CI3 function which returns TRUE for successful write-type queries (using a function to detect them) or which can be forced through an optional parameter to return a result object.

Any comments welcome.

@kenjis
Copy link
Member

kenjis commented Feb 5, 2021

I think CI3's behavior is reasonable.
https://codeigniter.com/userguide3/database/db_driver_reference.html#CI_DB_driver::query

The CI4 User Guide says like the following, and no contradictions with it.

The query() function returns a database result object when “read” type queries are run which you can use to show your results. When “write” type queries are run it simply returns TRUE or FALSE depending on success or failure.
https://codeigniter4.github.io/CodeIgniter4/database/queries.html#regular-queries

@michalsn
Copy link
Member

michalsn commented Feb 5, 2021

Thanks, @sneakyimp for your research on the subject! Sorry that I'm answering so late, but I haven't had time to properly sit and read about this problem earlier.

I guess we can do something like this:

if ($query->isWriteType())
{
    return false;
}

return new $resultClass($this->connID, $this->resultID);

and handle the scenario similarly when the query is successful.

I wouldn't worry about BC, since we're fixing a bug here.

@sneakyimp
Copy link
Contributor Author

To start, I was hoping to define one centralized isWriteType function but the existing CI4 codebase has a bit of a code redundancy issue. It defines isWriteType in three different places, none of which is accessible to the other:

The SQLite3 and Query classes use an identical regex. The SQLSRV is almost identical but adds MERGE queries as well like the old CI3 function. I've taken the liberty of adding a static Query::sqlIsWriteType() method to the base Query class -- it seems logical to associate such functionality with this class. I have made all other isWriteType functions refer to this one function.

It looks like there's a phpunit test method defined in tests/system/Database/BaseQueryTest, testIsWriteType but I can't seem to find anywhere that this function might actually be called. My knowledge of how the phpunit tests work is somewhat limited, but the definition of that function is the only time any PHP source contains the string testIsWriteType.

I don't think we need to check isWriteType() if the underlying DBMS function has returned false. CI3 query function does not do so, and I can't imagine what we should be returning if a read-type query has resulted in false from the underlying DBMS function. Please correct me if I'm overlooking something.

I'm looking through the source code for references to resultID to determine which should change and am not entirely sure in some cases:

Mock/MockConnection
I'm guessing this class is intended to mimic the behavior of an actual db connection so I've add a check to see if the sql is writable and, if so, return a boolean type. Please advise.

I made a couple of changes to the unit tests impacted by the changes. I've also tried to improve the return types specified for the various insert/delete/query functions to reflect the actual possibilities.

@sneakyimp
Copy link
Contributor Author

sneakyimp commented Feb 9, 2021

So it looks like the badQueryTest I've added needs some massage. This behaves very differently depending on whether DBDebug is on or off or which DBMS I'm running. With DBDebug = true, we get a variety of different exceptions thrown when we run this code:

$query = $this->db->query('SELECT * FROM table_does_not_exist');
engine exception class mesg
mysqli mysqli_sql_exception Table 'ci4_test.table_does_not_exist' doesn't exist
postgres ErrorException pg_query(): Query failed: ERROR: relation "table_does_not_exist" does not exist
sqlite3 ErrorException SQLite3::query(): Unable to prepare statement: 1, no such table: table_does_not_exist
sqlsrv Exception [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]Invalid object name 'table_does_not_exist'.

If DBDebug is false (i.e., ENVIRONMENT is production) then all 4 DBMS options return a $query value of false with no exception thrown. I'm not really sure how to go about constructing this test. Any advice would be most welcome.

@sneakyimp
Copy link
Contributor Author

sneakyimp commented Feb 9, 2021

I have modified my BadQueryTest in an attempt to make it check for the DBDebug setting and, if it is true, just to expect a generic exception:

	public function testBadQuery()
	{
		if ($this->db->DBDebug) {
			// expect an exception, class and message varies by DBMS
			$this->expectException(\Exception::class);
			$query = $this->db->query('SELECT * FROM table_does_not_exist');
		} else {
			// this throws an exception in this testing environment, but in production it'll return FALSE
			// perhaps check $this->db->DBDebug for different test?
			$query = $this->db->query('SELECT * FROM table_does_not_exist');
			$this->assertEquals(false, $query);
		}
	}

I wonder if the code should maybe have a try/catch block somewhere to normalize the exception thrown into a single DatabaseException type? Please advise.

@sneakyimp
Copy link
Contributor Author

The commit I just pushed is resulting in a Rector failure. It looks like Rector is removing a single line? I am fairly mystified at the change it's making, which doesn't seem consistent with the rest of the file -- why would it delete that one blank line when there are so many other identical ones? Also, I've tried running Rector on the system folder locally but it hangs on test 231 of 1148:

$ vendor/bin/rector process tests
Rector 0.8.56
Config file: rector.php

  231/1148 [▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░]  20%

@kenjis
Copy link
Member

kenjis commented Feb 10, 2021

Thank you for your investigation.

It seems CI4 is not properly designed for exceptions.
I think database exceptions should be standardized regardless of the driver.

@sneakyimp
Copy link
Contributor Author

It seems CI4 is not properly designed for exceptions.
I think database exceptions should be standardized regardless of the driver.

Should we perhaps start a separate issue/bug report for the lack of exception standardization? I don't imagine it would be too difficult to roll that change in with the ones I've already made here.

Also, @kenjis, could you suggest any way to deal with the Rector test that is failing? It's not clear to me what change it wants to make -- if it's just trying to delete a single blank line, that's pretty confusing because there are many other blank lines just like that one already in the file which do not get changed. Also, I'm curious why I cannot get Rector to complete locally. It freezes when I try to run it and will not complete.

@kenjis
Copy link
Member

kenjis commented Feb 10, 2021

$ git fetch codeigniter
$ git checkout develop
$ git merge codeigniter/develop
$ composer update
$ vendor/bin/rector process tests
Rector 0.9.21
Config file: rector.php

  235/1164 [▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░]  20%

The progress rate doesn't move from there.

@kenjis
Copy link
Member

kenjis commented Feb 10, 2021

The command was not the same in GitHub Actions.

$ vendor/bin/rector process --dry-run
Rector 0.9.21
Config file: rector.php

 1500/1500 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

                                                                                             
 [OK] Rector is done!                                                                        

No problem.

@sneakyimp Why don't you rebase this branch?

@sneakyimp
Copy link
Contributor Author

@kenjis I'm confused by your posts. I hope you might clarify some things for me:

The command was not the same in GitHub Actions.

$ vendor/bin/rector process --dry-run
Rector 0.9.21
Config file: rector.php

 1500/1500 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

                                                                                             
 [OK] Rector is done!                                                                        
  1. the --dry-run flag just tells rector to output the changes rather than altering files. Using this flag in my working directory still hangs.
  2. My version of rector is 0.8.56. I've tried composer update and composer upgrade but neither seem to alter the rector version.

@sneakyimp Why don't you rebase this branch?

  1. As I mentioned before, I am not very familiar with rebasing. Why would that help? Also, is it strictly necessary? I deleted my prior fork and only just created this fork 13 days ago.

Any additional detail would be much appreciated.

@kenjis
Copy link
Member

kenjis commented Feb 11, 2021

@sneakyimp Okay.

Your base CI4 is very old. So your rector's version is old even you run composer update.

Author: Abdul Malik Ikhsan <samsonasik@gmail.com>
Date:   Tue Jan 5 02:10:45 2021 +0700

    [Static Analysis] Use Rector 0.9 for 4.1 branch with php version feature 7.3

diff --git a/composer.json b/composer.json
index 9516cd390..a845905ef 100644
--- a/composer.json
+++ b/composer.json
@@ -21,7 +21,7 @@
        "phpstan/phpstan": "^0.12",
        "phpunit/phpunit": "^9.1",
        "predis/predis": "^1.1",
-       "rector/rector": "^0.8",
+       "rector/rector": "^0.9",
        "squizlabs/php_codesniffer": "^3.3"
    },
    "suggest": {

So rebasing helps you. I'm not sure what the rector error is. But it was the past error.
No problem in the current CI4.

And you are not familiar with git. I would show you how to do it.

@kenjis
Copy link
Member

kenjis commented Feb 11, 2021

Clone your repos:

git clone git@github.com:sneakyimp/CodeIgniter4.git
cd CodeIgniter4/

Add remote branch codeigniter:

git remote add codeigniter git://github.com/codeigniter4/CodeIgniter4.git

Fetch the latest CI4 (remote branch):

git fetch codeigniter

Checkout your PR branch:

git checkout bugs/issue-4069

Rebase your branch:

git rebase codeigniter/develop

@sneakyimp
Copy link
Contributor Author

Thank you!

Your base CI4 is very old. So your rector's version is old even you run composer update.

I guess 13 days is pretty old. I see that the latest commit to the develop branch is a mere 8 hours ago.

And you are not familiar with git. I would show you how to do it.

You've been quite helpful. Let me read up on my own and I think I can figure it out.

@sneakyimp
Copy link
Contributor Author

@kenjis I have rebased as you so kindly suggested. Sadly, I cannot run composer update because someone changed the composer.json file to require PHP 7.3 or greater. This is problematic for anyone running Ubuntu 18.04 (which will receive maintenance updates until 2023). Its default php 7 installation is 7.2. There is no apt option for php 7.3, 7.4 or 8.

$ ./composer.phar update
Loading composer repositories with package information
Updating dependencies
Your requirements could not be resolved to an installable set of packages.

  Problem 1
    - Root composer.json requires php ^7.3||^8.0 but your php version (7.2.24) does not satisfy that requirement.
  Problem 2
    - phpunit/phpunit[9.1.0, ..., 9.2.6] require php ^7.3 -> your php version (7.2.24) does not satisfy that requirement.
    - phpunit/phpunit[9.3.0, ..., 9.3.8] require php ^7.3 || ^8.0 -> your php version (7.2.24) does not satisfy that requirement.
    - phpunit/phpunit[9.3.9, ..., 9.5.2] require php >=7.3 -> your php version (7.2.24) does not satisfy that requirement.
    - Root composer.json requires phpunit/phpunit ^9.1 -> satisfiable by phpunit/phpunit[9.1.0, ..., 9.5.2].

Use the option --with-all-dependencies (-W) to allow upgrades, downgrades and removals for packages currently locked to specific versions.

I think it's a mistake to remove support for php 7.2.

@kenjis
Copy link
Member

kenjis commented Feb 11, 2021

Unfortunately CI 4.1 requires PHP 7.3. And it was decided in the past.

commit 22e34b190f24ff21735f6dcf7bb2671181234165
Author: MGatner <mgatner@icloud.com>
Date:   Fri Oct 30 18:13:27 2020 +0000

    Bump PHP req, PHPUnit version

diff --git a/admin/framework/composer.json b/admin/framework/composer.json
index df3468dba..e5e88487c 100644
--- a/admin/framework/composer.json
+++ b/admin/framework/composer.json
@@ -5,7 +5,7 @@
    "homepage": "https://codeigniter.com",
    "license": "MIT",
    "require": {
-       "php": "^7.2 || ^8.0",
+       "php": "^7.3||^8.0",
        "ext-curl": "*",
        "ext-intl": "*",
        "ext-json": "*",
@@ -18,7 +18,7 @@
        "codeigniter4/codeigniter4-standard": "^1.0",
        "fakerphp/faker": "^1.9",
        "mikey179/vfsstream": "^1.6",
-       "phpunit/phpunit": "^8.5",
+       "phpunit/phpunit": "^9.1",
        "predis/predis": "^1.1",
        "squizlabs/php_codesniffer": "^3.3"
    },

@kenjis
Copy link
Member

kenjis commented Feb 11, 2021

@sneakyimp Can you use ppa:ondrej/php?
https://launchpad.net/~ondrej/+archive/ubuntu/php

@sneakyimp
Copy link
Contributor Author

@kenjis thank you very much for the kind suggestion. I'm always reluctant to use non-official repos for my workstation. I ended up upgrading to Ubuntu 20, which has an apt package for PHP 7.4. I still think it's a poor decision to abandon PHP 7.2. Ubuntu 18.04 LTS, which only offers PHP 7.2 as an apt package will provide maintenance updates for two more years and extended security maintenance until 2028.

I have rebased as advised and committed the latest changes. That was a lot of work just so rector could remove a single blank line. I still don't understand why rector would take issue with that one empty line when there are at least a dozen others just like it.

Before:

	public function getVersion(): string;

	//--------------------------------------------------------------------

	/**
	 * Orchestrates a query against the database. Queries must use
	 * Database\Statement objects to store the query and build it.
	 * This method works with the cache.
	 *
	 * Should automatically handle different connections for read/write
	 * queries if needed.
	 *
	 * @param string $sql
	 * @param mixed  ...$binds
	 *
	 * @return BaseResult|Query|boolean
	 */
	public function query(string $sql, $binds = null);

after:

	public function getVersion(): string;

	//--------------------------------------------------------------------
	/**
	 * Orchestrates a query against the database. Queries must use
	 * Database\Statement objects to store the query and build it.
	 * This method works with the cache.
	 *
	 * Should automatically handle different connections for read/write
	 * queries if needed.
	 *
	 * @param string $sql
	 * @param mixed  ...$binds
	 *
	 * @return BaseResult|Query|boolean
	 */
	public function query(string $sql, $binds = null);

@kenjis
Copy link
Member

kenjis commented Feb 12, 2021

It seems your branch is not to be expected status, because it has 168 commits and Files changed 195.

You have the unneeded merge commit: 0cf7b27
I'm not sure, but it makes the PR too big?

@sneakyimp
Copy link
Contributor Author

It seems your branch is not to be expected status, because it has 168 commits and Files changed 195.

You have the unneeded merge commit: 0cf7b27
I'm not sure, but it makes the PR too big?

I tried rebasing as you suggested -- although I didn't re-clone the repo because I already had it checked out. I haven't the foggiest idea why this PR is so big.

@kenjis
Copy link
Member

kenjis commented Feb 12, 2021

@sneakyimp Do you understand the difference between merge and rebase?
If not, see https://stackoverflow.com/questions/16666089/whats-the-difference-between-git-merge-and-git-rebase

In this situation, you don't have to use merge at all.

After all, you need to fix this PR commits. I would explain it.

@kenjis
Copy link
Member

kenjis commented Feb 12, 2021

It seems you need the commits 3e55588, 094d003, f599492, d9f568d.

$ git log -b bugs/issue-4069 --oneline
d9f568d8e (HEAD -> bugs/issue-4069, sneakyimp/bugs/issue-4069) tiny change made by record...remove of one line in ConnectionInterface
0cf7b2772 Merge branch 'bugs/issue-4069' of https://github.com/sneakyimp/CodeIgniter4 into bugs/issue-4069
f5994929a improved BadQueryTest, simplified return value in BaseUtils::optimizeTable
094d0032a changes to make query() function return boolean for write-type queries. centralize isWriteType functionality in one place. Change tests impacted by this change
3e5558885 Initial commit fixing wrong return value in Database/BaseConnection::query. When underlying resultID if false, this should return false. Added BadQueryTest to tests.
86402521e (origin/develop, origin/HEAD, codeigniter/develop, develop) Merge pull request #4262 from kenjis/fix-github/workflows/test-phpunit

@sneakyimp
Copy link
Contributor Author

@sneakyimp Do you understand the difference between merge and rebase?
If not, see https://stackoverflow.com/questions/16666089/whats-the-difference-between-git-merge-and-git-rebase

I am dimly aware of the difference, having read the documentation here. I confess I didn't understand why you suggested that I rebase. I did not understand why i should re-clone a repo that I already had locally, so I attempted to rebase my local repo.

In this situation, you don't have to use merge at all.

I did not use any merge command. I tried to follow your example above except I chose to operate on my local repository instead of cloning again. I forget the actual sequence of commands, but the merge happened automatically at one point. To be clear, I did not manually type any merge commands at all.

After all, you need to fix this PR commits. I would explain it.

I will certainly need help to sort this out. As I mentioned before, I am not familiar with rebasing.

@kenjis
Copy link
Member

kenjis commented Feb 12, 2021

I did not understand why i should re-clone a repo that I already had locally

It seems I wrote it wrong. You don't have to re-clone a repo at all.

I just showed all the flow of the process. You have to clone a repo only once.

@kenjis
Copy link
Member

kenjis commented Feb 12, 2021

Update your develop branch:

$ git fetch codeigniter
$ git checkout develop
$ git merge codeigniter/develop

Create new branch from the latest develop:

$ git checkout -b new-bugs/issue-4069

Cherry-pick needed commits:

$ git cherry-pick 3e55588 094d003 f599492 d9f568d

Back up the original PR branch:

$ git checkout bugs/issue-4069 
$ git checkout -b bk-bugs/issue-4069

Remove the original PR branch:

$ git branch -D bugs/issue-4069 

Rename the new branch:

$ git checkout new-bugs/issue-4069 
$ git branch -M bugs/issue-4069 

Force push the new branch:

$ git push --force-with-lease origin bugs/issue-4069

…C sp_rename into SQLSRV\Connection, tweaked WriteTypeQueryTest
@sneakyimp
Copy link
Contributor Author

@kenjis, I have made the changes as you suggested -- avoiding any static methods. I had a closer look and the MockConnection, because it extends the BaseConnection class, inherits its isWriteType method which alleviated my concerns about that method being available wherever needed.

I have added MERGE type queries to the BaseConnection::isWriteType function even though it's apparently not supported by MySQLi because this is what CI3 did.

I added an isWriteType() declaration to ConnectionInterface because the automated tests were complaining about return types in the Query class.

I added a new unit test, Database/Live/WriteTypeQueryTest where I have made an initial attempt at unit tests. This test could probably be enhanced, and it overlaps with Database/BaseQueryTest.php somewhat, but it does test a couple of DBMS-specific isWriteType functions and it tests actual queries generated by the query builder.

In running the unit tests locally, I noticed that it seems like the code assumes the tables/data structures don't exist before the test runs but then doesn't eliminate the tables when tests are complete, so you get some tests failing if you run these tests repeatedly. SQLITE/AlterTableTest::testModifyColumnSuccess should probably destroy and recreate the janky table to make this test repeatable. testModifyColumnSuccess and other such functions also should probably reset the table to make this test repeatable.

@kenjis
Copy link
Member

kenjis commented Feb 24, 2021

SQLITE/AlterTableTest::testModifyColumnSuccess should probably destroy and recreate the janky table to make this test repeatable. testModifyColumnSuccess and other such functions also should probably reset the table to make this test repeatable.

I sent PR: #4334

@codeigniter4 codeigniter4 deleted a comment from sneakyimp Feb 24, 2021
@codeigniter4 codeigniter4 deleted a comment from sneakyimp Feb 24, 2021
Comment on lines 16 to 25
if ($this->db->DBDebug) {
// expect an exception, class and message varies by DBMS
$this->expectException(\Exception::class);
$query = $this->db->query('SELECT * FROM table_does_not_exist');
} else {
// this throws an exception in this testing environment, but in production it'll return FALSE
// perhaps check $this->db->DBDebug for different test?
$query = $this->db->query('SELECT * FROM table_does_not_exist');
$this->assertEquals(false, $query);
}
Copy link
Member

Choose a reason for hiding this comment

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

In test execution, the if condition is true or false, not both.
You should test both cases, $this->db->DBDebug is true and false.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I still don't understand. If $this->db-DBDEbug casts as true, code execution starts at line 17 -- we must expect the exception before executing the query. Otherwise code execution goes to line 23, and we must run the query before we assert that its result is false.

Copy link
Member

Choose a reason for hiding this comment

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

When we run the test case, we really test either of below:

  • $this->expectException(\Exception::class);
  • $this->assertEquals(false, $query);

But we should test both cases.
In other words, we should do:

  1. set $this->db->DBDebug true and test
  2. set $this->db->DBDebug false and test

Copy link
Member

Choose a reason for hiding this comment

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

Please create separate test cases for DBDebug option enabled and disabled.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Aha! I think I understand now -- sorry.

I should remove the if statement and instead explicitly set DBDebug=true and test that and then set it to false and run that test also so that the test performs both tests every time. I was under the mistaken impression that the tests that ran would try it both ways.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have had some success if I make $origDebug a static variable because it then persists between different instances of DBUtilsTest:

	protected static $origDebug;

	/**
	 * This test must run first to store the inital debug value before we tinker with it below
	 */
	public function testFirst() {
		$this::$origDebug = $this->getPrivateProperty($this->db, 'DBDebug');
		
		$this->assertIsBool($this::$origDebug);
	}

and then if I can be sure these tests will always run in the order they are defined in the PHP file, this should avoid changing DBDebug from whatever default it may have except for the scope of these tests:

	public function testUtilsOptimizeTableFalseOptimizeDatabaseDebugTrue()
	{
		$util = (new Database())->loadUtils($this->db);
		$this->setPrivateProperty($util, 'optimizeTable', false);
		
		// set debug to true -- WARNING this change will persist!
		$this->setPrivateProperty($this->db, 'DBDebug', true);
		
		$this->expectException(DatabaseException::class);
		$this->expectExceptionMessage('Unsupported feature of the database platform you are using.');
		$util->optimizeDatabase();
		
		// this point in code execution will never be reached
	}

	public function testUtilsOptimizeTableFalseOptimizeDatabaseDebugFalse()
	{
		$util = (new Database())->loadUtils($this->db);
		$this->setPrivateProperty($util, 'optimizeTable', false);

		// set debug to false -- WARNING this change will persist!
		$this->setPrivateProperty($this->db, 'DBDebug', false);
		
		$result = $util->optimizeDatabase();
		$this->assertFalse($result);
		
		// restore original value grabbed from testFirst -- WARNING this change will persist!
		$this->setPrivateProperty($this->db, 'DBDebug', $this::$origDebug);
	}

Copy link
Member

Choose a reason for hiding this comment

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

I.e., the unit tests start with whatever DBDebug default (default value, from Config, from .env, etc) and if you change DBDebug in one unit test method, that changed value will still be in effect for the following test.

Yes. Ideally, all the state before a test run should be completely identical.

But currently, CI4 tests have a lot of hidden dependencies, and it will be difficult to control them perfectly,
so the best approach is to save the DBDebug value before execution and restore it.

Copy link
Member

Choose a reason for hiding this comment

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

HOWEVER it would appear that phpunit instantiates a new instance of DbUtilsTest for each and every test method it contains so $this->origDebug will be null inside every other test function inside it even while any changes to $this->db->DBDebug persist between tests.

PHPUnit resets properties in a test case, because the state before each test should be identical
and ideally a test should not depend on another test.

If you want to keep a value between test methods, you can use static properties or Test Dependencies
https://phpunit.readthedocs.io/en/9.5/writing-tests-for-phpunit.html#test-dependencies

Copy link
Contributor Author

Choose a reason for hiding this comment

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

PHPUnit resets properties in a test case, because the state before each test should be identical
and ideally a test should not depend on another test.

I did test whether a change to the DBDebug value in one test method would persist when PHPUnit moves on to the next test and it did ⚠️⚠️⚠️.

I tried adding a class property that was non-static, public $origDebug and I set this value in a test method but, to my surprise, it was NULL in every subsequent test method in that file. This is what made me think it was creating a new instance of the test class for each method. I added a constructor function and this was called repeatedly for a single test file -- presumably once for each test. I surmised from these experiments that the db object would retain any changes made to DBDebug until all the subsequent tests had run. PHP passes objects by reference, so if you modify them inside a function, the changes can persist:

class foo {
  public $prop = "default";
}

function myFunc($v) {
  $v->prop = "inside";
  echo $v->prop . "\n";
}

$v = new foo;
echo $v->prop . "\n";

myFunc($v);

echo $v->prop . "\n";

output:

default
inside
inside

If you want to keep a value between test methods, you can use static properties or Test Dependencies
https://phpunit.readthedocs.io/en/9.5/writing-tests-for-phpunit.html#test-dependencies

I did indeed end up using a public static $origDebug var to hold the original/default value for DBDebug, and this seems to work, but I would point out that this assumes the tests will be executed in the order they are defined in the file.

Copy link
Member

Choose a reason for hiding this comment

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

I did test whether a change to the DBDebug value in one test method would persist when PHPUnit moves on to the next test and it did ⚠️⚠️⚠️.

$this->db comes from the static method (it was moved to trait recently):

CodeIgniter4/DatabaseTestTrait.php at develop · codeigniter4/CodeIgniter4

if ($this->db === null)
{
$this->db = Database::connect($this->DBGroup);
$this->db->initialize();
}

this assumes the tests will be executed in the order they are defined in the file.

If I remember correctly, phpunit runs tests in the order.

tests/system/Database/Live/DbUtilsTest.php Outdated Show resolved Hide resolved
@kenjis
Copy link
Member

kenjis commented Feb 24, 2021

@sneakyimp I deleted the previous comments and I re-wrote them.

@sneakyimp
Copy link
Contributor Author

This is another issue, because CI3 does not have prepared statement functionality.

When we use prepared statements, we prepare and execute a query.
https://codeigniter4.github.io/CodeIgniter4/database/queries.html#executing-the-query

When a prepared query fails, what value should be returned?
The current implementation always returns a Result object.

Thank you for digging this deeply into the code, and thank you for your very kind help. I would not be able to contribute without your guidance.

I have not looked into the prepared statement functionality. Would it be acceptable to create a separate issue/bug report for that? I very badly need to get back to work on my CI3 to CI4 site migration which has been stymied by these bugs I've been working on. I could probably look at it after March 1st.

@kenjis
Copy link
Member

kenjis commented Feb 24, 2021

Yeah, I think prepared statement issue is another separate issue/bug.

To be honest, I'm tired of this big PR.
I hope it will be merged as soon as possible.

Copy link
Member

@michalsn michalsn left a comment

Choose a reason for hiding this comment

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

Thanks, both of you @sneakyimp and @kenjis for taking your time and working on this.

Besides small tweaks in tests, it looks like it's almost ready.

The last thing we need is a changelog update and migration instructions.

Please make an entry here: https://github.com/codeigniter4/CodeIgniter4/blob/develop/user_guide_src/source/changelogs/v4.1.2.rst under Bugs fixed - when you will be referring to the fixed return status.

We need to make the migration instructions when it comes to the changed ConnectionInterface, so please copy this file: https://github.com/codeigniter4/CodeIgniter4/blob/develop/user_guide_src/source/installation/upgrade_410.rst and based on it create upgrade_412.rst and add instructions for adding a new method.

Comment on lines 16 to 25
if ($this->db->DBDebug) {
// expect an exception, class and message varies by DBMS
$this->expectException(\Exception::class);
$query = $this->db->query('SELECT * FROM table_does_not_exist');
} else {
// this throws an exception in this testing environment, but in production it'll return FALSE
// perhaps check $this->db->DBDebug for different test?
$query = $this->db->query('SELECT * FROM table_does_not_exist');
$this->assertEquals(false, $query);
}
Copy link
Member

Choose a reason for hiding this comment

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

Please create separate test cases for DBDebug option enabled and disabled.

tests/system/Database/Live/DbUtilsTest.php Outdated Show resolved Hide resolved
system/Database/ConnectionInterface.php Show resolved Hide resolved
Copy link
Member

@michalsn michalsn left a comment

Choose a reason for hiding this comment

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

Looks good. Thanks!

@michalsn michalsn merged commit 0d45be6 into codeigniter4:develop Feb 26, 2021
@michalsn
Copy link
Member

@sneakyimp Thank you for this.

@sneakyimp
Copy link
Contributor Author

@sneakyimp Thank you for this.

I'm happy to contribute, and feel this is important. I think it was probably overlooked because the phpunit tests get run with DBDebug=true ?? We should probably consider a test run with that set to false because that's what it will be in production mode, right?

Also, I hope you guys will review & accept PR #4307 . @paulbalandan took a look but I haven't heard anything in five days. I think autorouting is a very serious consideration for code organization, to reduce complicated routes files, for seo optimization, and for security to prevent malicious code execution issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants