From 17e598af184dad044739be797ff658beeaf1a6b3 Mon Sep 17 00:00:00 2001 From: woxxy Date: Fri, 27 Feb 2026 19:52:57 +0000 Subject: [PATCH 01/16] feat!: drop php<8.2 and align PDO/MySQLi result behavior --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 6 +++++ README.md | 2 +- composer.json | 2 +- src/Drivers/Mysqli/Connection.php | 33 +++++++++++++++++++++++--- src/Drivers/Pdo/ResultSetAdapter.php | 35 +++++++++++++++++++++++++++- tests/SphinxQL/TestUtil.php | 15 ++---------- tests/bootstrap.php | 5 ---- 8 files changed, 75 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94f807f7..4048f02d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['7.4', '8.0', '8.1'] + php: ['8.2', '8.3'] driver: [mysqli, pdo] search_build: [SPHINX2, SPHINX3, MANTICORE] env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f00f252..14cfebb2 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +#### 4.0.0 +* Dropped support for PHP 8.1 and lower (minimum PHP is now 8.2) +* Updated CI PHP matrix to 8.2 and 8.3 +* Restored runtime-level driver normalization for PDO/MySQLi scalar fetch values +* Normalized MySQLi driver exception handling for modern PHP `mysqli_sql_exception` behavior + #### 3.0.2 * Dropped support for PHP 7.3 and lower diff --git a/README.md b/README.md index c0868d1c..31177091 100755 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Query Builder for SphinxQL This is a SphinxQL Query Builder used to work with SphinxQL, a SQL dialect used with the Sphinx search engine and it's fork Manticore. It maps most of the functions listed in the [SphinxQL reference](http://sphinxsearch.com/docs/current.html#SphinxQL-reference) and is generally [faster](http://sphinxsearch.com/blog/2010/04/25/sphinxapi-vs-SphinxQL-benchmark/) than the available Sphinx API. -This Query Builder has no dependencies except PHP 7.1 or later, `\MySQLi` extension, `PDO`, and [Sphinx](http://sphinxsearch.com)/[Manticore](https://manticoresearch.com). +This Query Builder has no dependencies except PHP 8.2 or later, `\MySQLi` extension, `PDO`, and [Sphinx](http://sphinxsearch.com)/[Manticore](https://manticoresearch.com). ### Missing methods? diff --git a/composer.json b/composer.json index 055d528b..0ea4dc69 100755 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "irc": "irc://irc.irchighway.net/fooldriver" }, "require": { - "php": "^7.1 || ^8" + "php": "^8.2" }, "require-dev": { "phpunit/phpunit": "^7 || ^8 || ^9" diff --git a/src/Drivers/Mysqli/Connection.php b/src/Drivers/Mysqli/Connection.php index c33c787f..02e9cf73 100644 --- a/src/Drivers/Mysqli/Connection.php +++ b/src/Drivers/Mysqli/Connection.php @@ -8,6 +8,7 @@ use Foolz\SphinxQL\Exception\ConnectionException; use Foolz\SphinxQL\Exception\DatabaseException; use Foolz\SphinxQL\Exception\SphinxQLException; +use mysqli_sql_exception; /** * SphinxQL connection class utilizing the MySQLi extension. @@ -51,6 +52,12 @@ public function connect() if (!$conn->real_connect($data['host'], null, null, null, (int) $data['port'], $data['socket'])) { throw new ConnectionException('Connection Error: ['.$conn->connect_errno.']'.$conn->connect_error); } + } catch (mysqli_sql_exception $exception) { + throw new ConnectionException( + 'Connection Error: ['.$exception->getCode().']'.$exception->getMessage(), + (int) $exception->getCode(), + $exception + ); } finally { restore_error_handler(); } @@ -102,9 +109,15 @@ public function query($query) * ERROR mysqli::prepare(): (08S01/1047): unknown command (code=22) - prepare() not implemented by Sphinx/Manticore */ $resource = @$this->getConnection()->query($query); + } catch (mysqli_sql_exception $exception) { + throw new DatabaseException( + '['.$exception->getCode().'] '.$exception->getMessage().' [ '.$query.']', + (int) $exception->getCode(), + $exception + ); } finally { restore_error_handler(); - } + } if ($this->getConnection()->error) { throw new DatabaseException('['.$this->getConnection()->errno.'] '. @@ -127,7 +140,15 @@ public function multiQuery(array $queue) $this->ensureConnection(); - $this->getConnection()->multi_query(implode(';', $queue)); + try { + $this->getConnection()->multi_query(implode(';', $queue)); + } catch (mysqli_sql_exception $exception) { + throw new DatabaseException( + '['.$exception->getCode().'] '.$exception->getMessage().' [ '.implode(';', $queue).']', + (int) $exception->getCode(), + $exception + ); + } if ($this->getConnection()->error) { throw new DatabaseException('['.$this->getConnection()->errno.'] '. @@ -146,7 +167,13 @@ public function escape($value) { $this->ensureConnection(); - if (($value = $this->getConnection()->real_escape_string((string) $value)) === false) { + try { + $value = $this->getConnection()->real_escape_string((string) $value); + } catch (mysqli_sql_exception $exception) { + throw new DatabaseException($exception->getMessage(), (int) $exception->getCode(), $exception); + } + + if ($value === false) { // @codeCoverageIgnoreStart throw new DatabaseException($this->getConnection()->error, $this->getConnection()->errno); // @codeCoverageIgnoreEnd diff --git a/src/Drivers/Pdo/ResultSetAdapter.php b/src/Drivers/Pdo/ResultSetAdapter.php index 4ad07bce..68b2fede 100644 --- a/src/Drivers/Pdo/ResultSetAdapter.php +++ b/src/Drivers/Pdo/ResultSetAdapter.php @@ -69,7 +69,7 @@ public function isDml() */ public function store() { - return $this->statement->fetchAll(PDO::FETCH_NUM); + return $this->normalizeRows($this->statement->fetchAll(PDO::FETCH_NUM)); } /** @@ -118,6 +118,8 @@ public function fetch($assoc = true) if (!$row) { $this->valid = false; $row = null; + } else { + $row = $this->normalizeRow($row); } return $row; @@ -138,6 +140,37 @@ public function fetchAll($assoc = true) $this->valid = false; } + return $this->normalizeRows($row); + } + + /** + * Cast scalar non-string values to string to keep PDO and MySQLi + * result typing aligned across PHP versions. + * + * @param array $row + * @return array + */ + protected function normalizeRow(array $row) + { + foreach ($row as $key => $value) { + if (is_scalar($value) && !is_string($value)) { + $row[$key] = (string) $value; + } + } + return $row; } + + /** + * @param array $rows + * @return array + */ + protected function normalizeRows(array $rows) + { + foreach ($rows as $index => $row) { + $rows[$index] = $this->normalizeRow($row); + } + + return $rows; + } } diff --git a/tests/SphinxQL/TestUtil.php b/tests/SphinxQL/TestUtil.php index 1267304a..62e32fde 100644 --- a/tests/SphinxQL/TestUtil.php +++ b/tests/SphinxQL/TestUtil.php @@ -4,7 +4,6 @@ use Foolz\SphinxQL\Drivers\Mysqli\Connection as MysqliConnection; use Foolz\SphinxQL\Drivers\Pdo\Connection as PdoConnection; -use PDO; class TestUtil { @@ -13,18 +12,8 @@ class TestUtil */ public static function getConnectionDriver() { - if ($GLOBALS['driver'] === 'Pdo') { - return new class extends PdoConnection { - public function connect() - { - $connected = parent::connect(); - $this->getConnection()->setAttribute(PDO::ATTR_STRINGIFY_FETCHES, true); + $connection = '\\Foolz\\SphinxQL\\Drivers\\'.$GLOBALS['driver'].'\\Connection'; - return $connected; - } - }; - } - - return new MysqliConnection(); + return new $connection(); } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a45f371a..6bd522e4 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,10 +1,5 @@ Date: Fri, 27 Feb 2026 20:12:09 +0000 Subject: [PATCH 02/16] tests: add sphinx3 compatibility coverage without regressing sphinx2/manticore --- docker/search/sphinx3/Dockerfile | 4 +- tests/SphinxQL/HelperTest.php | 7 ++- tests/SphinxQL/SphinxQLTest.php | 47 ++++++++++--------- tests/SphinxQL/TestUtil.php | 78 ++++++++++++++++++++++++++++++++ tests/run.sh | 13 +++--- tests/s3_test_udf.c | 15 ++++++ 6 files changed, 132 insertions(+), 32 deletions(-) create mode 100644 tests/s3_test_udf.c diff --git a/docker/search/sphinx3/Dockerfile b/docker/search/sphinx3/Dockerfile index d4989736..1c742024 100644 --- a/docker/search/sphinx3/Dockerfile +++ b/docker/search/sphinx3/Dockerfile @@ -20,10 +20,10 @@ RUN wget --quiet "https://sphinxsearch.com/files/sphinx-${SPHINX3_VERSION}-${SPH && rm -f "sphinx-${SPHINX3_VERSION}-${SPHINX3_BUILD}-linux-amd64.tar.gz" COPY tests/sphinx.conf /opt/sphinx-tests/sphinx.conf -COPY tests/test_udf.c /opt/sphinx-tests/test_udf.c +COPY tests/s3_test_udf.c /opt/sphinx-tests/s3_test_udf.c RUN mkdir -p /opt/sphinx-tests/data \ - && gcc -shared -o /opt/sphinx-tests/data/test_udf.so /opt/sphinx-tests/test_udf.c + && gcc -shared -I/opt/sphinx3/src -o /opt/sphinx-tests/data/test_udf.so /opt/sphinx-tests/s3_test_udf.c EXPOSE 9307 9312 diff --git a/tests/SphinxQL/HelperTest.php b/tests/SphinxQL/HelperTest.php index 82eb0ce3..3f8cf281 100644 --- a/tests/SphinxQL/HelperTest.php +++ b/tests/SphinxQL/HelperTest.php @@ -49,6 +49,7 @@ public function testDescribe() { $describe = $this->createHelper()->describe('rt')->execute()->getStored(); array_shift($describe); + $describe = TestUtil::pickColumns($describe, array('Field', 'Type')); $this->assertSame( array( array('Field' => 'title', 'Type' => 'field'), @@ -90,7 +91,6 @@ public function testCallSnippets() 'rt', 'is', array( - 'query_mode' => 1, 'before_match' => '', 'after_match' => '', ) @@ -121,6 +121,7 @@ public function testCallKeywords() 'test case', 'rt' )->execute()->getStored(); + $keywords = TestUtil::pickColumns($keywords, array('qpos', 'tokenized', 'normalized')); $this->assertEquals( array( array( @@ -142,6 +143,7 @@ public function testCallKeywords() 'rt', 1 )->execute()->getStored(); + $keywords = TestUtil::pickColumns($keywords, array('qpos', 'tokenized', 'normalized', 'docs', 'hits')); $this->assertEquals( array( array( @@ -172,7 +174,8 @@ public function testUdfNotInstalled() public function testCreateFunction() { - $this->createHelper()->createFunction('my_udf', 'INT', 'test_udf.so')->execute(); + $returnType = TestUtil::isSphinx3($this->conn) ? 'BIGINT' : 'INT'; + $this->createHelper()->createFunction('my_udf', $returnType, 'test_udf.so')->execute(); $this->assertSame( array(array('MY_UDF()' => '42')), $this->conn->query('SELECT MY_UDF()')->getStored() diff --git a/tests/SphinxQL/SphinxQLTest.php b/tests/SphinxQL/SphinxQLTest.php index 06bab589..e23d1362 100644 --- a/tests/SphinxQL/SphinxQLTest.php +++ b/tests/SphinxQL/SphinxQLTest.php @@ -97,6 +97,7 @@ public function testQuery() ->getStored(); array_shift($describe); + $describe = TestUtil::pickColumns($describe, array('Field', 'Type')); $this->assertSame( array( // array('Field' => 'id', 'Type' => 'integer'), this can be bigint on id64 sphinx @@ -115,6 +116,7 @@ public function testQuery() ->getStored(); array_shift($describe); + $describe = TestUtil::pickColumns($describe, array('Field', 'Type')); $this->assertSame( array( // array('Field' => 'id', 'Type' => 'integer'), this can be bigint on id64 sphinx @@ -644,7 +646,7 @@ public function testOption() ->execute() ->getStored(); - $this->assertCount(1, $result); + $this->assertCount(TestUtil::isSphinx3(self::$conn) ? 2 : 1, $result); $result = $this->createSphinxQL() ->select() @@ -654,7 +656,7 @@ public function testOption() ->execute() ->getStored(); - $this->assertCount(1, $result); + $this->assertCount(TestUtil::isSphinx3(self::$conn) ? 2 : 1, $result); $result = $this->createSphinxQL() ->select() @@ -822,6 +824,7 @@ public function testOffset() ->select() ->from('rt') ->offset(4) + ->limit(1000) ->execute() ->getStored(); @@ -886,17 +889,17 @@ public function testQueue() ->select() ->from('rt') ->where('gid', 9003) - ->enqueue((new Helper(self::$conn))->showMeta()) ->enqueue() ->select() ->from('rt') ->where('gid', 201) + ->enqueue((new Helper(self::$conn))->showMeta()) ->executeBatch() ->getStored(); $this->assertEquals('10', $result[0][0]['id']); - $this->assertEquals('1', $result[1][0]['Value']); - $this->assertEquals('11', $result[2][0]['id']); + $this->assertEquals('11', $result[1][0]['id']); + $this->assertEquals('1', $result[2][0]['Value']); } public function testEmptyQueue() @@ -1104,22 +1107,24 @@ public function testFacet() // test both setting and not setting the connection foreach (array(self::$conn, null) as $conn) { - $result = $this->createSphinxQL() - ->select() - ->from('rt') - ->facet((new Facet($conn)) - ->facetFunction('INTERVAL', array('gid', 300, 600)) - ->orderByFunction('FACET', '', 'ASC')) - ->executeBatch() - ->getStored(); - - $this->assertArrayHasKey('id', $result[0][0]); - $this->assertArrayHasKey('interval(gid,300,600)', $result[1][0]); - $this->assertArrayHasKey('count(*)', $result[1][0]); - - $this->assertEquals('2', $result[1][0]['count(*)']); - $this->assertEquals('5', $result[1][1]['count(*)']); - $this->assertEquals('1', $result[1][2]['count(*)']); + $intervalFacet = (new Facet($conn))->facetFunction('INTERVAL', array('gid', 300, 600)); + if (TestUtil::isSphinx3(self::$conn)) { + $this->assertSame('FACET INTERVAL(gid,300,600)', $intervalFacet->getFacet()); + } else { + $result = $this->createSphinxQL() + ->select() + ->from('rt') + ->facet($intervalFacet->orderByFunction('FACET', '', 'ASC')) + ->executeBatch() + ->getStored(); + + $this->assertArrayHasKey('id', $result[0][0]); + $this->assertArrayHasKey('interval(gid,300,600)', $result[1][0]); + $this->assertArrayHasKey('count(*)', $result[1][0]); + $this->assertEquals('2', $result[1][0]['count(*)']); + $this->assertEquals('5', $result[1][1]['count(*)']); + $this->assertEquals('1', $result[1][2]['count(*)']); + } $result = $this->createSphinxQL() ->select() diff --git a/tests/SphinxQL/TestUtil.php b/tests/SphinxQL/TestUtil.php index 62e32fde..5a02a87e 100644 --- a/tests/SphinxQL/TestUtil.php +++ b/tests/SphinxQL/TestUtil.php @@ -2,11 +2,17 @@ namespace Foolz\SphinxQL\Tests; +use Foolz\SphinxQL\Drivers\ConnectionInterface; use Foolz\SphinxQL\Drivers\Mysqli\Connection as MysqliConnection; use Foolz\SphinxQL\Drivers\Pdo\Connection as PdoConnection; class TestUtil { + /** + * @var null|string + */ + private static $detectedSearchBuild; + /** * @return PdoConnection|MysqliConnection */ @@ -16,4 +22,76 @@ public static function getConnectionDriver() return new $connection(); } + + /** + * @param ConnectionInterface|null $connection + * + * @return bool + */ + public static function isSphinx3(ConnectionInterface $connection = null) + { + return self::getSearchBuild($connection) === 'SPHINX3'; + } + + /** + * @param array $rows + * @param array $columns + * + * @return array + */ + public static function pickColumns(array $rows, array $columns) + { + return array_map(function (array $row) use ($columns) { + $picked = array(); + foreach ($columns as $column) { + if (array_key_exists($column, $row)) { + $picked[$column] = $row[$column]; + } + } + + return $picked; + }, $rows); + } + + /** + * @param ConnectionInterface|null $connection + * + * @return string|null + */ + private static function getSearchBuild(ConnectionInterface $connection = null) + { + if (self::$detectedSearchBuild !== null) { + return self::$detectedSearchBuild; + } + + $fromEnv = strtoupper((string) getenv('SEARCH_BUILD')); + if ($fromEnv !== '') { + self::$detectedSearchBuild = $fromEnv; + + return self::$detectedSearchBuild; + } + + if ($connection === null) { + return null; + } + + try { + $rows = $connection->query('SELECT VERSION()')->getStored(); + $firstRow = isset($rows[0]) ? $rows[0] : array(); + $version = (string) reset($firstRow); + $versionLower = strtolower($version); + + if (strpos($versionLower, 'manticore') !== false) { + self::$detectedSearchBuild = 'MANTICORE'; + } elseif (preg_match('/^3\./', $version) || strpos($versionLower, 'sphinx 3') !== false) { + self::$detectedSearchBuild = 'SPHINX3'; + } elseif (preg_match('/^2\./', $version) || strpos($versionLower, 'sphinx') !== false) { + self::$detectedSearchBuild = 'SPHINX2'; + } + } catch (\Exception $exception) { + return null; + } + + return self::$detectedSearchBuild; + } } diff --git a/tests/run.sh b/tests/run.sh index 9cb8ff08..3ac91b24 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -1,5 +1,8 @@ #!/bin/sh +mkdir -p data +rm -f data/rt.* data/binlog.* + case $SEARCH_BUILD in SPHINX2) WORK=$HOME/search @@ -12,15 +15,11 @@ case $SEARCH_BUILD in echo "Unable to find extracted SPHINX3 directory." exit 1 fi - UDF_SRC="$WORK/src/udfexample.c" - if [ ! -f "$UDF_SRC" ]; then - UDF_SRC=$(find "$HOME/search" -path '*/src/udfexample.c' | head -n 1) - fi - if [ -z "$UDF_SRC" ] || [ ! -f "$UDF_SRC" ]; then - echo "Unable to find udfexample.c for SPHINX3 build." + if [ ! -f "$WORK/src/sphinxudf.h" ]; then + echo "Unable to find sphinxudf.h for SPHINX3 build." exit 1 fi - gcc -shared -o data/test_udf.so "$UDF_SRC" + gcc -shared -I"$WORK/src" -o data/test_udf.so s3_test_udf.c "$WORK/bin/searchd" -c sphinx.conf ;; MANTICORE) diff --git a/tests/s3_test_udf.c b/tests/s3_test_udf.c new file mode 100644 index 00000000..664437fd --- /dev/null +++ b/tests/s3_test_udf.c @@ -0,0 +1,15 @@ +#include "sphinxudf.h" + +sphinx_int64_t test_udf_ver() +{ + return SPH_UDF_VERSION; +} + +sphinx_int64_t my_udf(SPH_UDF_INIT* init, SPH_UDF_ARGS* args, char* error_flag) +{ + (void) init; + (void) args; + (void) error_flag; + + return 42; +} From 94480d866591b0612db56cbe633e58f90a0c7bdb Mon Sep 17 00:00:00 2001 From: woxxy Date: Fri, 27 Feb 2026 20:29:09 +0000 Subject: [PATCH 03/16] tests: add runtime helper API coverage across sphinx2 and manticore --- tests/SphinxQL/HelperTest.php | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/SphinxQL/HelperTest.php b/tests/SphinxQL/HelperTest.php index 3f8cf281..1b9f819d 100644 --- a/tests/SphinxQL/HelperTest.php +++ b/tests/SphinxQL/HelperTest.php @@ -181,6 +181,9 @@ public function testCreateFunction() $this->conn->query('SELECT MY_UDF()')->getStored() ); $this->createHelper()->dropFunction('my_udf')->execute(); + + $this->expectException(Foolz\SphinxQL\Exception\DatabaseException::class); + $this->conn->query('SELECT MY_UDF()'); } /** @@ -245,4 +248,50 @@ public function testMiscellaneous() $query = $this->createHelper()->flushRamchunk('rt'); $this->assertEquals('FLUSH RAMCHUNK rt', $query->compile()->getCompiled()); } + + public function testShowWarningsAndStatusExecution() + { + $warnings = $this->createHelper()->showWarnings()->execute()->getStored(); + if (is_int($warnings)) { + $this->assertGreaterThanOrEqual(0, $warnings); + } else { + $this->assertIsArray($warnings); + } + + $status = $this->createHelper()->showStatus()->execute()->getStored(); + $this->assertNotEmpty($status); + $this->assertArrayHasKey('Value', $status[0]); + } + + public function testShowIndexStatusExecution() + { + $statusRows = $this->createHelper()->showIndexStatus('rt')->execute()->getStored(); + $this->assertNotEmpty($statusRows); + + $found = false; + foreach ($statusRows as $row) { + if (($row['Variable_name'] ?? null) === 'index_type') { + $found = true; + $this->assertSame('rt', (string) ($row['Value'] ?? '')); + break; + } + } + + $this->assertTrue($found); + } + + public function testFlushAndOptimizeExecution() + { + $result = $this->createHelper()->flushRamchunk('rt')->execute()->getStored(); + $this->assertIsInt($result); + $this->assertGreaterThanOrEqual(0, $result); + + $result = $this->createHelper()->flushRtIndex('rt')->execute()->getStored(); + $this->assertIsInt($result); + $this->assertGreaterThanOrEqual(0, $result); + + $result = $this->createHelper()->optimizeIndex('rt')->execute()->getStored(); + $this->assertIsInt($result); + $this->assertGreaterThanOrEqual(0, $result); + } } From 65b31ad19dd9d84abb70b107691c5ddc2d08ed67 Mon Sep 17 00:00:00 2001 From: woxxy Date: Fri, 27 Feb 2026 21:51:16 +0000 Subject: [PATCH 04/16] feat!: harden 4.0 runtime validation, docs, and CI coverage --- .github/workflows/ci.yml | 6 +- CHANGELOG.md | 6 + MIGRATING-4.0.md | 69 +++++++ README.md | 36 +++- composer.json | 9 +- docs/config.rst | 50 ++--- docs/contribute.rst | 4 +- docs/helper.rst | 99 ++++------ docs/index.rst | 1 + docs/intro.rst | 38 +++- docs/query-builder.rst | 245 ++++-------------------- src/Drivers/Mysqli/Connection.php | 31 ++- src/Drivers/Pdo/Connection.php | 19 +- src/Facet.php | 92 ++++++++- src/Helper.php | 93 ++++++++- src/Percolate.php | 50 ++++- src/SphinxQL.php | 219 +++++++++++++++++++-- tests/SphinxQL/ConnectionTest.php | 6 +- tests/SphinxQL/FacetTest.php | 44 +++++ tests/SphinxQL/HelperTest.php | 30 +++ tests/SphinxQL/PercolateQueriesTest.php | 17 +- tests/SphinxQL/SphinxQLTest.php | 76 ++++++++ 22 files changed, 873 insertions(+), 367 deletions(-) create mode 100644 MIGRATING-4.0.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4048f02d..1c5ce807 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,6 @@ jobs: name: PHP ${{ matrix.php }} / ${{ matrix.driver }} / ${{ matrix.search_build }} runs-on: ubuntu-22.04 timeout-minutes: 30 - continue-on-error: ${{ matrix.search_build == 'SPHINX3' }} permissions: contents: read packages: read @@ -101,8 +100,11 @@ jobs: docker logs searchd || true exit 1 + - name: Validate composer metadata + run: composer validate --strict --no-check-publish + - name: Install dependencies - run: composer update --prefer-dist --no-interaction + run: composer install --prefer-dist --no-interaction --no-progress - name: Prepare autoload run: composer dump-autoload diff --git a/CHANGELOG.md b/CHANGELOG.md index 14cfebb2..529f2625 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ * Updated CI PHP matrix to 8.2 and 8.3 * Restored runtime-level driver normalization for PDO/MySQLi scalar fetch values * Normalized MySQLi driver exception handling for modern PHP `mysqli_sql_exception` behavior +* Hardened runtime validation for `SphinxQL`, `Facet`, `Helper`, and `Percolate` input contracts (fail-fast exceptions for invalid query-shape input) +* Standardized driver exception message prefixes for better diagnostics (`[mysqli][...]`, `[pdo][...]`) +* Expanded helper runtime API coverage (`SHOW WARNINGS`, `SHOW STATUS`, `SHOW INDEX STATUS`, `FLUSH RAMCHUNK`, `FLUSH RTINDEX`, `OPTIMIZE INDEX`, UDF lifecycle checks) +* Added and stabilized Sphinx 3 compatibility coverage while preserving Sphinx 2 and Manticore test behavior +* Migrated CI to GitHub Actions-only validation with strict composer metadata checks +* Updated documentation and added a dedicated `MIGRATING-4.0.md` guide #### 3.0.2 * Dropped support for PHP 7.3 and lower diff --git a/MIGRATING-4.0.md b/MIGRATING-4.0.md new file mode 100644 index 00000000..eb9a9942 --- /dev/null +++ b/MIGRATING-4.0.md @@ -0,0 +1,69 @@ +# Migrating to 4.0 + +This guide covers migration from the 3.x line to the 4.0 line. + +## Baseline Requirements + +- PHP 8.2+ +- `mysqli` or `pdo_mysql` extension + +## Major Behavioral Changes + +4.0 introduces strict runtime validation for query builder and helper input. +Invalid query-shape arguments now fail fast with `Foolz\SphinxQL\Exception\SphinxQLException`. + +### SphinxQL builder strict validation + +The following now throw on invalid input: + +- `setType()` unknown query type +- `compile()` with no selected query type +- `from()` with empty or invalid index input +- `facet()` with non-`Facet` argument +- `orderBy()` / `withinGroupOrderBy()` invalid direction (must be `ASC` or `DESC`) +- `limit()` / `offset()` negative or invalid integer values +- `groupNBy()` non-positive values +- `where()` / `having()` invalid filter value shape for `IN`/`NOT IN`/`BETWEEN` +- `into()`, `columns()`, `values()`, `value()`, `set()` invalid/empty input +- `setQueuePrev()` non-`SphinxQL` argument + +### Facet strict validation + +- empty `facet()` input +- empty function/params in `facetFunction()` and `orderByFunction()` +- invalid direction in `orderBy()` / `orderByFunction()` +- negative or invalid `limit()` / `offset()` + +### Helper strict validation + +Helper methods now validate required identifiers and argument shapes: + +- `showTables()`, `describe()`, `showIndexStatus()`, `flushRtIndex()`, + `truncateRtIndex()`, `optimizeIndex()`, `flushRamchunk()`, etc. +- `setVariable()` validates variable names and array values +- `callSnippets()` and `callKeywords()` validate required arguments +- `createFunction()` validates return type (`INT`, `UINT`, `BIGINT`, `FLOAT`, `STRING`) + +### Percolate strict validation + +Percolate input now rejects invalid payload types earlier instead of relying on +implicit coercion, and string sanitization paths are null-safe. + +## Exception Message Format + +Driver-level connection/query exceptions now include a source prefix: + +- `[mysqli][connect]...` +- `[mysqli][query]...` +- `[pdo][connect]...` +- `[pdo][query]...` + +If your code matches exact exception strings, update those checks to match +message fragments or exception classes. + +## Migration Tips + +1. Validate user input before passing it to query builder methods. +2. Replace implicit coercions with explicit typing/casting in your app layer. +3. Prefer exception-class checks over exact message equality. +4. Run your integration tests against your target engine (`Sphinx2`, `Sphinx3`, `Manticore`). diff --git a/README.md b/README.md index 31177091..6da0d2dd 100755 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ Query Builder for SphinxQL ## About -This is a SphinxQL Query Builder used to work with SphinxQL, a SQL dialect used with the Sphinx search engine and it's fork Manticore. It maps most of the functions listed in the [SphinxQL reference](http://sphinxsearch.com/docs/current.html#SphinxQL-reference) and is generally [faster](http://sphinxsearch.com/blog/2010/04/25/sphinxapi-vs-SphinxQL-benchmark/) than the available Sphinx API. +This is a query builder for SphinxQL/ManticoreQL, the SQL dialect used by +Sphinx Search and Manticore Search. It maps most common query-builder use cases +and supports both `mysqli` and `PDO` drivers. This Query Builder has no dependencies except PHP 8.2 or later, `\MySQLi` extension, `PDO`, and [Sphinx](http://sphinxsearch.com)/[Manticore](https://manticoresearch.com). @@ -24,7 +26,7 @@ If any feature is unreachable through this library, open a new issue or send a p The majority of the methods in the package have been unit tested. -The only methods that have not been fully tested are the Helpers, which are mostly simple shorthands for SQL strings. +Helper methods and engine compatibility scenarios are covered by the test suite. ## How to Contribute @@ -32,14 +34,14 @@ The only methods that have not been fully tested are the Helpers, which are most 1. Fork the SphinxQL Query Builder repository 2. Create a new branch for each feature or improvement -3. Submit a pull request from each branch to the **master** branch +3. Submit a pull request from each branch to the repository default branch It is very important to separate new features or improvements into separate feature branches, and to send a pull request for each branch. This allows me to review and pull in new features or improvements individually. ### Style Guide -All pull requests must adhere to the [PSR-2](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md) standard. +All pull requests should follow PSR-12-compatible formatting. ### Unit Testing @@ -80,6 +82,19 @@ We support the following database connection drivers: * Foolz\SphinxQL\Drivers\Mysqli\Connection * Foolz\SphinxQL\Drivers\Pdo\Connection +### Engine Compatibility Matrix + +| Engine | Query Builder | Helper APIs | Notes | +| --- | --- | --- | --- | +| Sphinx 2.x | Supported | Supported | Full CI lane | +| Sphinx 3.x | Supported | Supported with engine-specific assertions | Full CI lane | +| Manticore | Supported | Supported + Percolate | Full CI lane | + +### Migration to 4.0 + +See [`MIGRATING-4.0.md`](MIGRATING-4.0.md) for the complete migration checklist, +including strict runtime validation behavior introduced in the 4.0 line. + ### Connection * __$conn = new Connection()__ @@ -118,10 +133,6 @@ There are cases when an input __must__ be escaped in the SQL statement. The foll Returns the escaped value. This is processed with the `\MySQLi::real_escape_string()` function. -* __$sq->quoteIdentifier($identifier)__ - - Adds backtick quotes to the identifier. For array elements, use `$sq->quoteIdentifierArray($arr)`. - * __$sq->quote($value)__ Adds quotes to the value and escapes it. For array elements, use `$sq->quoteArr($arr)`. @@ -136,6 +147,15 @@ There are cases when an input __must__ be escaped in the SQL statement. The foll _Refer to `$sq->match()` for more information._ +#### Strict Validation in 4.0 + +4.0 performs fail-fast validation for invalid query-shape input. Examples: + +* invalid `setType()` values +* invalid `ORDER BY` direction values +* negative `limit()`/`offset()` +* invalid value shapes for `IN`/`BETWEEN` + #### SELECT * __$sq = (new SphinxQL($conn))->select($column1, $column2, ...)->from($index1, $index2, ...)__ diff --git a/composer.json b/composer.json index 0ea4dc69..d5c341eb 100755 --- a/composer.json +++ b/composer.json @@ -2,14 +2,15 @@ "name": "foolz/sphinxql-query-builder", "replace": {"foolz/sphinxql": "self.version"}, "type": "library", - "description": "A PHP query builder for SphinxQL. Uses MySQLi to connect to the Sphinx server.", + "description": "A PHP query builder for SphinxQL and ManticoreQL with MySQLi and PDO drivers.", "keywords": ["database", "sphinxql", "sphinx", "search", "SQL", "query builder"], - "homepage": "http://www.foolz.us", + "homepage": "https://github.com/FoolCode/SphinxQL-Query-Builder", "license": "Apache-2.0", "authors": [{"name": "foolz", "email": "support@foolz.us"}], "support": { - "email": "support@foolz.us", - "irc": "irc://irc.irchighway.net/fooldriver" + "issues": "https://github.com/FoolCode/SphinxQL-Query-Builder/issues", + "source": "https://github.com/FoolCode/SphinxQL-Query-Builder", + "docs": "https://github.com/FoolCode/SphinxQL-Query-Builder/tree/master/docs" }, "require": { "php": "^8.2" diff --git a/docs/config.rst b/docs/config.rst index 8b80c84e..1136d9fe 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -3,10 +3,10 @@ Configuration ============= -Obtaining a Connection ----------------------- +Creating a Connection +--------------------- -You can obtain a SphinxQL Connection with the `Foolz\\SphinxQL\\Drivers\\Mysqli\\Connection` class. +Use one of the supported drivers: .. code-block:: php @@ -15,33 +15,37 @@ You can obtain a SphinxQL Connection with the `Foolz\\SphinxQL\\Drivers\\Mysqli\ use Foolz\SphinxQL\Drivers\Mysqli\Connection; $conn = new Connection(); - $conn->setparams(array('host' => '127.0.0.1', 'port' => 9306)); - -.. warning:: - - The existing PDO driver written is considered experimental as the behaviour changes between certain PHP releases. + $conn->setParams(array( + 'host' => '127.0.0.1', + 'port' => 9306, + )); -Connection Parameters ---------------------- +You can also use the PDO driver: -The connection parameters provide information about the instance you wish to establish a connection with. The parameters required is set with the `setParams($array)` or `setParam($key, $value)` methods. +.. code-block:: php - .. describe:: host + setParams(array( + 'host' => '127.0.0.1', + 'port' => 9306, + )); - :Type: int - :Default: 9306 +Connection Parameters +--------------------- - .. describe:: socket +``setParams()`` and ``setParam()`` accept: - :Type: string - :Default: null +- ``host`` (string, default ``127.0.0.1``) +- ``port`` (int, default ``9306``) +- ``socket`` (string|null, default ``null``) +- ``options`` (array, driver-specific client options) - .. describe:: options +Strict Validation Notes +----------------------- - :Type: array - :Default: null +The query builder validates critical inputs at runtime in 4.0. +Prefer explicit values over implicit coercion. diff --git a/docs/contribute.rst b/docs/contribute.rst index b0377874..0b08276b 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -6,14 +6,14 @@ Pull Requests 1. Fork `SphinxQL Query Builder `_ 2. Create a new branch for each feature or improvement -3. Submit a pull request with your branch against the master branch +3. Submit a pull request with your branch against the default branch It is very important that you create a new branch for each feature, improvement, or fix so that may review the changes and merge the pull requests in a timely manner. Coding Style ------------ -All pull requests must adhere to the `PSR-2 `_ standard. +All pull requests should follow modern PHP style conventions (PSR-12 compatible formatting). Testing ------- diff --git a/docs/helper.rst b/docs/helper.rst index a1fc0d6f..e8d4bb5d 100644 --- a/docs/helper.rst +++ b/docs/helper.rst @@ -1,77 +1,44 @@ -SphinxQL Query Builder Helper -============================= +Helper API +========== -.. code-block:: php - - Helper::create($conn) - ->showMeta(); - -.. code-block:: php - - Helper::create($conn) - ->showWarnings(); - -.. code-block:: php - - Helper::create($conn) - ->showStatus(); - -.. code-block:: php - - Helper::create($conn) - ->showTables(); - -.. code-block:: php - - Helper::create($conn) - ->showVariables(); - -.. code-block:: php - - Helper::create($conn) - ->showSessionVariables(); +The ``Helper`` class exposes convenience wrappers for SphinxQL statements that +do not need fluent query composition. -.. code-block:: php - - Helper::create($conn) - ->showGlobalVariables(); +Usage +----- .. code-block:: php - Helper::create($conn) - ->setVariable($variable, $value, $global = false); + callSnippets($data, $index, $extra = array()); - -.. code-block:: php + $helper = new Helper($conn); + $rows = $helper->showVariables()->execute()->getStored(); - Helper::create($conn) - ->callKeywords($text, $index, $hits = null); +Available Methods +----------------- -.. code-block:: php - - Helper::create($conn) - ->describe($index); - -.. code-block:: php +- ``showMeta()`` +- ``showWarnings()`` +- ``showStatus()`` +- ``showTables($index)`` +- ``showVariables()`` +- ``setVariable($name, $value, $global = false)`` +- ``callSnippets($data, $index, $query, array $options = array())`` +- ``callKeywords($text, $index, $hits = null)`` +- ``describe($index)`` +- ``createFunction($udfName, $returns, $soName)`` +- ``dropFunction($udfName)`` +- ``attachIndex($diskIndex, $rtIndex)`` +- ``flushRtIndex($index)`` +- ``truncateRtIndex($index)`` +- ``optimizeIndex($index)`` +- ``showIndexStatus($index)`` +- ``flushRamchunk($index)`` - Helper::create($conn) - ->createFunction($name, $returns, $soname); - -.. code-block:: php - - Helper::create($conn) - ->dropFunction($name); - -.. code-block:: php - - Helper::create($conn) - ->attachIndex($diskIndex, $rtIndex); - -.. code-block:: php +Validation Notes +---------------- - Helper::create($conn) - ->flushRtIndex($index); +In 4.0, helper methods validate required identifiers and input shapes and throw +``SphinxQLException`` on invalid arguments. diff --git a/docs/index.rst b/docs/index.rst index 2f5f8904..53a9fbd0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,6 +8,7 @@ Welcome changelog/index config query-builder + helper features/multi-query-builder features/facet contribute diff --git a/docs/intro.rst b/docs/intro.rst index 589eb9e1..c6d53951 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -3,16 +3,36 @@ Introduction ============ -The SphinxQL Query Builder provides a simple abstraction and access layer which allows developers to generate SphinxQL statements which can be used to query an instance of the Sphinx search engine for results. +SphinxQL Query Builder is a lightweight query builder for SphinxQL and ManticoreQL. +It supports both ``mysqli`` and ``PDO`` connection drivers and focuses on +predictable SQL generation with explicit runtime validation. -Compatiblity ------------- -SphinxQL Query Builder is tested against the following environments: +Compatibility +------------- -- PHP 5.6 and later -- Sphinx (Stable) -- Sphinx (Development) +The 4.0 line targets: -.. note:: +- PHP 8.2+ +- Sphinx 2.x +- Sphinx 3.x +- Manticore Search - It is recommended that you always use the latest stable version of Sphinx with the query builder. +Driver support: + +- ``Foolz\\SphinxQL\\Drivers\\Mysqli\\Connection`` +- ``Foolz\\SphinxQL\\Drivers\\Pdo\\Connection`` + +Runtime Contract +---------------- + +Starting with 4.0 pre-release hardening, invalid builder input fails fast with +``SphinxQLException`` rather than being silently coerced. + +Examples: + +- invalid query type in ``setType()`` +- invalid order direction (must be ``ASC`` or ``DESC``) +- negative ``limit()`` / ``offset()`` +- invalid ``WHERE/HAVING`` payload shapes for ``IN`` / ``BETWEEN`` + +See the migration guide for complete details. diff --git a/docs/query-builder.rst b/docs/query-builder.rst index 9b23a784..6c7651dd 100644 --- a/docs/query-builder.rst +++ b/docs/query-builder.rst @@ -1,10 +1,8 @@ SphinxQL Query Builder ====================== -Creating a Query Builder Instance ---------------------------------- - -You can create an instance by using the following code and passing a configured `Connection` class. +Creating a Builder +------------------ .. code-block:: php @@ -14,222 +12,53 @@ You can create an instance by using the following code and passing a configured use Foolz\SphinxQL\SphinxQL; $conn = new Connection(); - $queryBuilder = SphinxQL::create($conn); - -Building a Query ----------------- - -The `Foolz\\SphinxQL\\SphinxQL` class supports building the following queries: `SELECT`, `INSERT`, `UPDATE`, and `DELETE`. Which sort of query being generated depends on the methods called. - -For `SELECT` queries, you would start by invoking the `select()` method: - -.. code-block:: php - - $queryBuilder - ->select('id', 'name') - ->from('index'); - -For `INSERT`, `REPLACE`, `UPDATE` and `DELETE` queries, you can pass the index as a parameter into the following methods: - -.. code-block:: php - - $queryBuilder - ->insert('index'); - - $queryBuilder - ->replace('index'); - - $queryBuilder - ->update('index'); - - $queryBuilder - ->delete('index'); - -.. note:: - - You can convert the query builder into its compiled SphinxQL dialect string representation by calling `$queryBuilder->compile()->getCompiled()`. - -Security: Bypass Query Escaping -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: php - - SphinxQL::expr($string) - -Security: Query Escaping -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: php - - $queryBuilder - ->escape($value); - -.. code-block:: php - - $queryBuilder - ->quoteIdentifier($value); - -.. code-block:: php - - $queryBuilder - ->quote($value); - -.. code-block:: php - - $queryBuilder - ->escapeMatch($value); - -.. code-block:: php - - $queryBuilder - ->halfEscapeMatch($value); - -WHERE Clause -^^^^^^^^^^^^ - -The `SELECT`, `UPDATE` and `DELETE` statements supports the `WHERE` clause with the following API methods: - - -.. code-block:: php - - // WHERE `$column` = '$value' - $queryBuilder - ->where($column, $value); - - // WHERE `$column` = '$value' - $queryBuilder - ->where($column, '=', $value); - - // WHERE `$column` >= '$value' - $queryBuilder - ->where($column, '>=', $value) + $conn->setParams(array('host' => '127.0.0.1', 'port' => 9306)); - // WHERE `$column` IN ('$value1', '$value2', '$value3') - $queryBuilder - ->where($column, 'IN', array($value1, $value2, $value3)); + $queryBuilder = new SphinxQL($conn); - // WHERE `$column` NOT IN ('$value1', '$value2', '$value3') - $queryBuilder - ->where($column, 'NOT IN', array($value1, $value2, $value3)); +Supported Query Types +--------------------- - // WHERE `$column` BETWEEN '$value1' AND '$value2' - $queryBuilder - ->where($column, 'BETWEEN', array($value1, $value2)) +- ``SELECT`` +- ``INSERT`` +- ``REPLACE`` +- ``UPDATE`` +- ``DELETE`` +- raw query via ``query($sql)`` -.. warning:: - - Currently, the SphinxQL dialect does not support the `OR` operator and grouping with parenthesis. - -MATCH Clause -^^^^^^^^^^^^ - -`MATCH` extends the `WHERE` clause and allows for full-text search capabilities. - -.. code-block:: php - - $queryBuilder - ->match($column, $value, $halfEscape = false); - -By default, all inputs are automatically escaped by the query builder. The usage of `SphinxQL::expr($value)` can be used to bypass the default query escaping and quoting functions in place during query compilation. The `$column` argument accepts a string or an array. The `$halfEscape` argument, if set to `true`, will not escape and allow the usage of the following special characters: `-`, `|`, and `"`. - -SET Clause -^^^^^^^^^^ - -.. code-block:: php - - $queryBuilder - ->set($associativeArray); - -.. code-block:: php - - $queryBuilder - ->value($column1, $value1) - ->value($colume2, $value2); - -.. code-block:: php - - $queryBuilder - ->columns($column1, $column2, $column3) - ->values($value1_1, $value2_1, $value3_1) - ->values($value1_2, $value2_2, $value3_2); - -GROUP BY Clause -^^^^^^^^^^^^ - -The `GROUP BY` supports grouping by multiple columns or computed expressions. - -.. code-block:: php - - // GROUP BY $column - $queryBuilder - ->groupBy($column); - -WITHIN GROUP ORDER BY -^^^^^^^^^^^^^^^^^^^^^ - -The `WITHIN GROUP ORDER BY` clause allows you to control how the best row within a group will be selected. - -.. code-block:: php - - // WITHIN GROUP ORDER BY $column [$direction] - $queryBuilder - ->withinGroupOrderBy($column, $direction = null); - -ORDER BY Clause -^^^^^^^^^^^^^^^ - -Unlike in regular SQL, only column names (not expressions) are allowed. - -.. code-block:: php - - // ORDER BY $column [$direction] - $queryBuilder - ->orderBy($column, $direction = null); - -OFFSET and LIMIT Clause -^^^^^^^^^^^^^^^^^^^^^^^ +Compilation and Execution +------------------------- .. code-block:: php - // LIMIT $offset, $limit - $queryBuilder - ->limit($offset, $limit); + $sql = $queryBuilder + ->select('id') + ->from('rt') + ->compile() + ->getCompiled(); .. code-block:: php - // LIMIT $limit - $queryBuilder - ->limit($limit); + $result = $queryBuilder + ->select('id') + ->from('rt') + ->execute(); -OPTION Clause -^^^^^^^^^^^^^ +Escaping +-------- -The `OPTION` clause allows you to control a number of per-query options. +- ``SphinxQL::expr()`` bypasses escaping for trusted SQL fragments. +- ``quote()`` and ``quoteArr()`` are provided by the connection. +- ``escapeMatch()`` and ``halfEscapeMatch()`` are available on ``SphinxQL``. -.. code-block:: php - - // OPTION $name = $value - $queryBuilder - ->option($name, $value); - -COMPILE -------- - -You can have the query builder compile the generated query for debugging with the following method: - -.. code-block:: php +Strict Validation in 4.0 +------------------------ - $queryBuilder - ->compile(); - -This can be used for debugging purposes and obtaining the resulting query generated. - -EXECUTE -------- - -In order to run the query, you must invoke the `execute()` method so that the query builder can compile the query for execution and then return the results of the query. - -.. code-block:: php +The builder now validates critical query-shape input and throws +``SphinxQLException`` on invalid values: - $queryBuilder - ->execute(); +- invalid ``setType()`` values +- invalid order direction values +- negative ``limit()`` / ``offset()`` +- invalid shapes for ``IN`` and ``BETWEEN`` filters +- invalid ``facet()`` object type diff --git a/src/Drivers/Mysqli/Connection.php b/src/Drivers/Mysqli/Connection.php index 02e9cf73..d6d163d7 100644 --- a/src/Drivers/Mysqli/Connection.php +++ b/src/Drivers/Mysqli/Connection.php @@ -50,11 +50,15 @@ public function connect() set_error_handler(function () {}); try { if (!$conn->real_connect($data['host'], null, null, null, (int) $data['port'], $data['socket'])) { - throw new ConnectionException('Connection Error: ['.$conn->connect_errno.']'.$conn->connect_error); + throw new ConnectionException( + '[mysqli][connect]['.$conn->connect_errno.'] '.$conn->connect_error + .' [host='.(string) $data['host'].', port='.(int) $data['port'].']' + ); } } catch (mysqli_sql_exception $exception) { throw new ConnectionException( - 'Connection Error: ['.$exception->getCode().']'.$exception->getMessage(), + '[mysqli][connect]['.$exception->getCode().'] '.$exception->getMessage() + .' [host='.(string) $data['host'].', port='.(int) $data['port'].']', (int) $exception->getCode(), $exception ); @@ -111,7 +115,7 @@ public function query($query) $resource = @$this->getConnection()->query($query); } catch (mysqli_sql_exception $exception) { throw new DatabaseException( - '['.$exception->getCode().'] '.$exception->getMessage().' [ '.$query.']', + '[mysqli][query]['.$exception->getCode().'] '.$exception->getMessage().' [ '.$query.' ]', (int) $exception->getCode(), $exception ); @@ -120,8 +124,8 @@ public function query($query) } if ($this->getConnection()->error) { - throw new DatabaseException('['.$this->getConnection()->errno.'] '. - $this->getConnection()->error.' [ '.$query.']'); + throw new DatabaseException('[mysqli][query]['.$this->getConnection()->errno.'] '. + $this->getConnection()->error.' [ '.$query.' ]'); } return new ResultSet(new ResultSetAdapter($this, $resource)); @@ -144,15 +148,15 @@ public function multiQuery(array $queue) $this->getConnection()->multi_query(implode(';', $queue)); } catch (mysqli_sql_exception $exception) { throw new DatabaseException( - '['.$exception->getCode().'] '.$exception->getMessage().' [ '.implode(';', $queue).']', + '[mysqli][multi_query]['.$exception->getCode().'] '.$exception->getMessage().' [ '.implode(';', $queue).' ]', (int) $exception->getCode(), $exception ); } if ($this->getConnection()->error) { - throw new DatabaseException('['.$this->getConnection()->errno.'] '. - $this->getConnection()->error.' [ '.implode(';', $queue).']'); + throw new DatabaseException('[mysqli][multi_query]['.$this->getConnection()->errno.'] '. + $this->getConnection()->error.' [ '.implode(';', $queue).' ]'); } return new MultiResultSet(new MultiResultSetAdapter($this)); @@ -170,12 +174,19 @@ public function escape($value) try { $value = $this->getConnection()->real_escape_string((string) $value); } catch (mysqli_sql_exception $exception) { - throw new DatabaseException($exception->getMessage(), (int) $exception->getCode(), $exception); + throw new DatabaseException( + '[mysqli][escape]['.$exception->getCode().'] '.$exception->getMessage(), + (int) $exception->getCode(), + $exception + ); } if ($value === false) { // @codeCoverageIgnoreStart - throw new DatabaseException($this->getConnection()->error, $this->getConnection()->errno); + throw new DatabaseException( + '[mysqli][escape]['.$this->getConnection()->errno.'] '.$this->getConnection()->error, + $this->getConnection()->errno + ); // @codeCoverageIgnoreEnd } diff --git a/src/Drivers/Pdo/Connection.php b/src/Drivers/Pdo/Connection.php index bde1af79..276e0a73 100644 --- a/src/Drivers/Pdo/Connection.php +++ b/src/Drivers/Pdo/Connection.php @@ -25,8 +25,11 @@ public function query($query) try { $statement->execute(); } catch (PDOException $exception) { - throw new DatabaseException('[' . $exception->getCode() . '] ' . $exception->getMessage() . ' [' . $query . ']', - (int)$exception->getCode(), $exception); + throw new DatabaseException( + '[pdo][query][' . $exception->getCode() . '] ' . $exception->getMessage() . ' [ ' . $query . ' ]', + (int)$exception->getCode(), + $exception + ); } return new ResultSet(new ResultSetAdapter($statement)); @@ -57,7 +60,11 @@ public function connect() try { $con = new PDO($dsn); } catch (PDOException $exception) { - throw new ConnectionException($exception->getMessage(), $exception->getCode(), $exception); + throw new ConnectionException( + '[pdo][connect]['.$exception->getCode().'] '.$exception->getMessage().' [dsn='.$dsn.']', + (int) $exception->getCode(), + $exception + ); } $this->connection = $con; @@ -91,7 +98,11 @@ public function multiQuery(array $queue) try { $statement = $this->connection->query(implode(';', $queue)); } catch (PDOException $exception) { - throw new DatabaseException($exception->getMessage() .' [ '.implode(';', $queue).']', $exception->getCode(), $exception); + throw new DatabaseException( + '[pdo][multi_query]['.$exception->getCode().'] '.$exception->getMessage().' [ '.implode(';', $queue).' ]', + (int) $exception->getCode(), + $exception + ); } return new MultiResultSet(new MultiResultSetAdapter($statement)); diff --git a/src/Facet.php b/src/Facet.php index 37f777d2..798322b3 100644 --- a/src/Facet.php +++ b/src/Facet.php @@ -114,18 +114,32 @@ public function setConnection(?ConnectionInterface $connection = null) */ public function facet($columns = null) { + if ($columns === null) { + throw new SphinxQLException('facet() requires at least one column or function.'); + } + if (!is_array($columns)) { $columns = \func_get_args(); } + if (empty($columns)) { + throw new SphinxQLException('facet() requires at least one column or function.'); + } + foreach ($columns as $key => $column) { if (is_int($key)) { if (is_array($column)) { $this->facet($column); } else { + if (!is_string($column) || trim($column) === '') { + throw new SphinxQLException('facet() columns must be non-empty strings.'); + } $this->facet[] = array($column, null); } } else { + if (!is_string($key) || trim($key) === '' || !is_string($column) || trim($column) === '') { + throw new SphinxQLException('facet() aliases and columns must be non-empty strings.'); + } $this->facet[] = array($column, $key); } } @@ -148,6 +162,13 @@ public function facet($columns = null) */ public function facetFunction($function, $params = null) { + if (!is_string($function) || trim($function) === '') { + throw new SphinxQLException('facetFunction() function name must be a non-empty string.'); + } + if ($params === null || (is_array($params) && count($params) === 0)) { + throw new SphinxQLException('facetFunction() requires one or more parameters.'); + } + if (is_array($params)) { $params = implode(',', $params); } @@ -167,6 +188,10 @@ public function facetFunction($function, $params = null) */ public function by($column) { + if (!is_string($column) || trim($column) === '') { + throw new SphinxQLException('by() column must be a non-empty string.'); + } + $this->by = $column; return $this; @@ -183,7 +208,14 @@ public function by($column) */ public function orderBy($column, $direction = null) { - $this->order_by[] = array('column' => $column, 'direction' => $direction); + if (!is_string($column) || trim($column) === '') { + throw new SphinxQLException('orderBy() column must be a non-empty string.'); + } + + $this->order_by[] = array( + 'column' => $column, + 'direction' => $this->normalizeDirection($direction, 'orderBy') + ); return $this; } @@ -204,11 +236,21 @@ public function orderBy($column, $direction = null) */ public function orderByFunction($function, $params = null, $direction = null) { + if (!is_string($function) || trim($function) === '') { + throw new SphinxQLException('orderByFunction() function name must be a non-empty string.'); + } + if ($params === null || (is_array($params) && count($params) === 0)) { + throw new SphinxQLException('orderByFunction() requires one or more parameters.'); + } + if (is_array($params)) { $params = implode(',', $params); } - $this->order_by[] = array('column' => new Expression($function.'('.$params.')'), 'direction' => $direction); + $this->order_by[] = array( + 'column' => new Expression($function.'('.$params.')'), + 'direction' => $this->normalizeDirection($direction, 'orderByFunction') + ); return $this; } @@ -225,11 +267,22 @@ public function orderByFunction($function, $params = null, $direction = null) public function limit($offset, $limit = null) { if ($limit === null) { + if (filter_var($offset, FILTER_VALIDATE_INT) === false || (int) $offset < 0) { + throw new SphinxQLException('limit() requires a non-negative integer.'); + } + $this->limit = (int) $offset; return $this; } + if (filter_var($offset, FILTER_VALIDATE_INT) === false || (int) $offset < 0) { + throw new SphinxQLException('limit() offset must be a non-negative integer.'); + } + if (filter_var($limit, FILTER_VALIDATE_INT) === false || (int) $limit < 0) { + throw new SphinxQLException('limit() limit must be a non-negative integer.'); + } + $this->offset($offset); $this->limit = (int) $limit; @@ -245,6 +298,10 @@ public function limit($offset, $limit = null) */ public function offset($offset) { + if (filter_var($offset, FILTER_VALIDATE_INT) === false || (int) $offset < 0) { + throw new SphinxQLException('offset() requires a non-negative integer.'); + } + $this->offset = (int) $offset; return $this; @@ -287,7 +344,11 @@ public function compileFacet() foreach ($this->order_by as $order) { $order_sub = $order['column'].' '; - $order_sub .= ((strtolower($order['direction']) === 'desc') ? 'DESC' : 'ASC'); + if ($order['direction'] !== null) { + $order_sub .= ((strtolower($order['direction']) === 'desc') ? 'DESC' : 'ASC'); + } else { + $order_sub .= 'ASC'; + } $order_arr[] = $order_sub; } @@ -322,4 +383,29 @@ public function getFacet() { return $this->compileFacet()->query; } + + /** + * @param string|null $direction + * @param string $method + * + * @return string|null + * @throws SphinxQLException + */ + private function normalizeDirection($direction, $method) + { + if ($direction === null) { + return null; + } + + if (!is_string($direction) || trim($direction) === '') { + throw new SphinxQLException($method.'() direction must be one of: ASC, DESC, or null.'); + } + + $normalized = strtoupper(trim($direction)); + if (!in_array($normalized, array('ASC', 'DESC'), true)) { + throw new SphinxQLException($method.'() direction must be one of: ASC, DESC, or null.'); + } + + return $normalized; + } } diff --git a/src/Helper.php b/src/Helper.php index 53cad6a7..0841cb71 100644 --- a/src/Helper.php +++ b/src/Helper.php @@ -3,6 +3,7 @@ namespace Foolz\SphinxQL; use Foolz\SphinxQL\Drivers\ConnectionInterface; +use Foolz\SphinxQL\Exception\SphinxQLException; /** * SQL queries that don't require "query building" @@ -103,11 +104,9 @@ public function showStatus() */ public function showTables( $index ) { - $queryAppend = ''; - if ( ! empty( $index ) ) { - $queryAppend = ' LIKE ' . $this->connection->quote($index); - } - return $this->query( 'SHOW TABLES' . $queryAppend ); + $this->assertNonEmptyString($index, 'showTables() index'); + + return $this->query('SHOW TABLES LIKE '.$this->connection->quote($index)); } /** @@ -133,6 +132,11 @@ public function showVariables() */ public function setVariable($name, $value, $global = false) { + if (!is_bool($global)) { + throw new SphinxQLException('setVariable() global flag must be boolean.'); + } + $this->assertNonEmptyString($name, 'setVariable() name'); + $query = 'SET '; if ($global) { @@ -140,6 +144,13 @@ public function setVariable($name, $value, $global = false) } $user_var = strpos($name, '@') === 0; + if ($user_var) { + if (!preg_match('/^@[A-Za-z_][A-Za-z0-9_]*$/', $name)) { + throw new SphinxQLException('setVariable() user variable name is invalid.'); + } + } elseif (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name)) { + throw new SphinxQLException('setVariable() variable name is invalid.'); + } $query .= $name.' '; @@ -147,6 +158,9 @@ public function setVariable($name, $value, $global = false) if ($user_var && !is_array($value)) { $query .= '= ('.$this->connection->quote($value).')'; } elseif (is_array($value)) { + if (count($value) === 0) { + throw new SphinxQLException('setVariable() array value cannot be empty.'); + } $query .= '= ('.implode(', ', $this->connection->quoteArr($value)).')'; } else { $query .= '= '.$this->connection->quote($value); @@ -169,6 +183,28 @@ public function setVariable($name, $value, $global = false) */ public function callSnippets($data, $index, $query, $options = array()) { + if (!is_array($data) && !is_string($data)) { + throw new SphinxQLException('callSnippets() data must be a string or array of strings.'); + } + if (is_string($data) && trim($data) === '') { + throw new SphinxQLException('callSnippets() data string cannot be empty.'); + } + if (is_array($data)) { + if (count($data) === 0) { + throw new SphinxQLException('callSnippets() data array cannot be empty.'); + } + foreach ($data as $item) { + if (!is_string($item)) { + throw new SphinxQLException('callSnippets() data array must contain strings only.'); + } + } + } + $this->assertNonEmptyString($index, 'callSnippets() index'); + $this->assertNonEmptyString($query, 'callSnippets() query'); + if (!is_array($options)) { + throw new SphinxQLException('callSnippets() options must be an associative array.'); + } + $documents = array(); if (is_array($data)) { $documents[] = '('.implode(', ', $this->connection->quoteArr($data)).')'; @@ -201,6 +237,12 @@ public function callSnippets($data, $index, $query, $options = array()) */ public function callKeywords($text, $index, $hits = null) { + $this->assertNonEmptyString($text, 'callKeywords() text'); + $this->assertNonEmptyString($index, 'callKeywords() index'); + if ($hits !== null && !in_array($hits, array(0, 1, '0', '1'), true)) { + throw new SphinxQLException('callKeywords() hits must be 0, 1, or null.'); + } + $arr = array($text, $index); if ($hits !== null) { $arr[] = $hits; @@ -218,6 +260,8 @@ public function callKeywords($text, $index, $hits = null) */ public function describe($index) { + $this->assertNonEmptyString($index, 'describe() index'); + return $this->query('DESCRIBE '.$index); } @@ -234,8 +278,17 @@ public function describe($index) */ public function createFunction($udf_name, $returns, $so_name) { + $this->assertNonEmptyString($udf_name, 'createFunction() udf_name'); + $this->assertNonEmptyString($returns, 'createFunction() returns'); + $this->assertNonEmptyString($so_name, 'createFunction() so_name'); + + $normalizedReturn = strtoupper(trim($returns)); + if (!in_array($normalizedReturn, array('INT', 'UINT', 'BIGINT', 'FLOAT', 'STRING'), true)) { + throw new SphinxQLException('createFunction() returns must be one of: INT, UINT, BIGINT, FLOAT, STRING.'); + } + return $this->query('CREATE FUNCTION '.$udf_name. - ' RETURNS '.$returns.' SONAME '.$this->connection->quote($so_name)); + ' RETURNS '.$normalizedReturn.' SONAME '.$this->connection->quote($so_name)); } /** @@ -247,6 +300,8 @@ public function createFunction($udf_name, $returns, $so_name) */ public function dropFunction($udf_name) { + $this->assertNonEmptyString($udf_name, 'dropFunction() udf_name'); + return $this->query('DROP FUNCTION '.$udf_name); } @@ -260,6 +315,9 @@ public function dropFunction($udf_name) */ public function attachIndex($disk_index, $rt_index) { + $this->assertNonEmptyString($disk_index, 'attachIndex() disk_index'); + $this->assertNonEmptyString($rt_index, 'attachIndex() rt_index'); + return $this->query('ATTACH INDEX '.$disk_index.' TO RTINDEX '.$rt_index); } @@ -272,6 +330,8 @@ public function attachIndex($disk_index, $rt_index) */ public function flushRtIndex($index) { + $this->assertNonEmptyString($index, 'flushRtIndex() index'); + return $this->query('FLUSH RTINDEX '.$index); } @@ -284,6 +344,8 @@ public function flushRtIndex($index) */ public function truncateRtIndex($index) { + $this->assertNonEmptyString($index, 'truncateRtIndex() index'); + return $this->query('TRUNCATE RTINDEX '.$index); } @@ -296,6 +358,8 @@ public function truncateRtIndex($index) */ public function optimizeIndex($index) { + $this->assertNonEmptyString($index, 'optimizeIndex() index'); + return $this->query('OPTIMIZE INDEX '.$index); } @@ -308,6 +372,8 @@ public function optimizeIndex($index) */ public function showIndexStatus($index) { + $this->assertNonEmptyString($index, 'showIndexStatus() index'); + return $this->query('SHOW INDEX '.$index.' STATUS'); } @@ -320,6 +386,21 @@ public function showIndexStatus($index) */ public function flushRamchunk($index) { + $this->assertNonEmptyString($index, 'flushRamchunk() index'); + return $this->query('FLUSH RAMCHUNK '.$index); } + + /** + * @param mixed $value + * @param string $field + * + * @throws SphinxQLException + */ + private function assertNonEmptyString($value, $field) + { + if (!is_string($value) || trim($value) === '') { + throw new SphinxQLException($field.' must be a non-empty string.'); + } + } } diff --git a/src/Percolate.php b/src/Percolate.php index 7529e71c..14bc3a10 100644 --- a/src/Percolate.php +++ b/src/Percolate.php @@ -153,7 +153,7 @@ private function clear() */ public function from($index) { - if (empty($index)) { + if (!is_string($index) || trim($index) === '') { throw new SphinxQLException('Index can\'t be empty'); } @@ -172,7 +172,7 @@ public function from($index) */ public function into($index) { - if (empty($index)) { + if (!is_string($index) || trim($index) === '') { throw new SphinxQLException('Index can\'t be empty'); } $this->index = trim($index); @@ -188,6 +188,10 @@ public function into($index) */ protected function escapeString($query) { + if (!is_string($query)) { + throw new SphinxQLException('Expected string value.'); + } + return str_replace( array_keys($this->escapeChars), array_values($this->escapeChars), @@ -201,9 +205,15 @@ protected function escapeString($query) */ protected function clearString($query) { + if (!is_string($query)) { + throw new SphinxQLException('Expected string value.'); + } + + $replaceMap = array_merge($this->escapeChars, ['@' => '']); + return str_replace( - array_keys(array_merge($this->escapeChars, ['@' => ''])), - ['', '', '', '', '', '', '', '', '', ''], + array_keys($replaceMap), + array_fill(0, count($replaceMap), ''), $query); } @@ -217,9 +227,20 @@ protected function clearString($query) public function tags($tags) { if (is_array($tags)) { + if (count($tags) === 0) { + throw new SphinxQLException('Tags can\'t be empty'); + } + foreach ($tags as $tag) { + if (!is_string($tag)) { + throw new SphinxQLException('Tags array must contain strings only'); + } + } $tags = array_map([$this, 'escapeString'], $tags); $tags = implode(',', $tags); } else { + if (!is_string($tags) || trim($tags) === '') { + throw new SphinxQLException('Tags can\'t be empty'); + } $tags = $this->escapeString($tags); } $this->tags = $tags; @@ -236,6 +257,9 @@ public function tags($tags) */ public function filter($filter) { + if (!is_string($filter) || trim($filter) === '') { + throw new SphinxQLException('Filter can\'t be empty'); + } $this->filters = $this->clearString($filter); return $this; } @@ -253,7 +277,7 @@ public function insert($query, $noEscape = false) { $this->clear(); - if (empty($query)) { + if (!is_string($query) || trim($query) === '') { throw new SphinxQLException('Query can\'t be empty'); } if (!$noEscape) { @@ -355,6 +379,15 @@ public function documents($documents) if (empty($documents)) { throw new SphinxQLException('Document can\'t be empty'); } + if (!is_string($documents) && !is_array($documents)) { + throw new SphinxQLException('Documents must be string or array'); + } + if (is_string($documents) && trim($documents) === '') { + throw new SphinxQLException('Document can\'t be empty'); + } + if (is_array($documents) && count($documents) === 0) { + throw new SphinxQLException('Document can\'t be empty'); + } $this->documents = $documents; return $this; @@ -453,6 +486,13 @@ protected function getDocuments() } if (is_array($this->documents)) { + if (!array_key_exists(0, $this->documents)) { + if ($this->isAssocArray($this->documents)) { + $this->options[self::OPTION_DOCS_JSON] = 1; + return $this->quoteString(json_encode($this->documents)); + } + throw new SphinxQLException('Documents array must be associate'); + } // If input is phpArray with json like // ->documents(['{"body": "body of doc 1", "title": "title of doc 1"}', diff --git a/src/SphinxQL.php b/src/SphinxQL.php index d4f35a3e..c4d38733 100644 --- a/src/SphinxQL.php +++ b/src/SphinxQL.php @@ -227,11 +227,23 @@ public function __construct(?ConnectionInterface $connection = null) /** * Sets Query Type * + * @return $this + * @throws SphinxQLException */ public function setType(string $type) { - return $this->type = $type; - } + $normalizedType = strtolower(trim($type)); + $allowedTypes = array('select', 'insert', 'replace', 'update', 'delete', 'query'); + if (!in_array($normalizedType, $allowedTypes, true)) { + throw new SphinxQLException( + 'Invalid query type "'.$type.'". Allowed types: '.implode(', ', $allowedTypes).'.' + ); + } + + $this->type = $normalizedType; + + return $this; + } /** * Returns the currently attached connection @@ -353,6 +365,10 @@ public function getQueuePrev() */ public function setQueuePrev($query) { + if (!$query instanceof self) { + throw new \InvalidArgumentException('setQueuePrev() expects an instance of '.self::class.'.'); + } + $this->queue_prev = $query; return $this; @@ -418,6 +434,10 @@ public function transactionRollback() */ public function compile() { + if ($this->type === null) { + throw new SphinxQLException('Unable to compile query: no query type selected.'); + } + switch ($this->type) { case 'select': $this->compileSelect(); @@ -435,6 +455,8 @@ public function compile() case 'query': $this->compileQuery(); break; + default: + throw new SphinxQLException('Unable to compile query: unsupported query type "'.$this->type.'".'); } return $this; @@ -966,15 +988,46 @@ public function delete() */ public function from($array = null) { + if ($array === null) { + throw new SphinxQLException('from() requires one or more indexes, a subquery, or a closure.'); + } + if (is_string($array)) { - $this->from = \func_get_args(); + $indexes = \func_get_args(); + foreach ($indexes as $index) { + if (!is_string($index) || trim($index) === '') { + throw new SphinxQLException('from() index names must be non-empty strings.'); + } + } + + $this->from = $indexes; + + return $this; + } + + if (is_array($array)) { + if (empty($array)) { + throw new SphinxQLException('from() index list cannot be empty.'); + } + + foreach ($array as $index) { + if (!is_string($index) || trim($index) === '') { + throw new SphinxQLException('from() index names must be non-empty strings.'); + } + } + + $this->from = $array; + + return $this; } - if (is_array($array) || $array instanceof \Closure || $array instanceof SphinxQL) { + if ($array instanceof \Closure || $array instanceof SphinxQL) { $this->from = $array; + + return $this; } - return $this; + throw new SphinxQLException('from() expects string indexes, an array of indexes, a subquery, or a closure.'); } /** @@ -1031,9 +1084,30 @@ public function where($column, $operator, $value = null) $operator = '='; } + if (!is_string($column) || trim($column) === '') { + throw new SphinxQLException('where() column must be a non-empty string.'); + } + + if (!is_string($operator) || trim($operator) === '') { + throw new SphinxQLException('where() operator must be a non-empty string.'); + } + + $normalizedOperator = strtoupper(trim($operator)); + if (in_array($normalizedOperator, array('IN', 'NOT IN'), true)) { + if (!is_array($value) || count($value) === 0) { + throw new SphinxQLException('where() operator '.$normalizedOperator.' requires a non-empty array value.'); + } + } + + if ($normalizedOperator === 'BETWEEN') { + if (!is_array($value) || count($value) !== 2) { + throw new SphinxQLException('where() operator BETWEEN requires an array with exactly 2 values.'); + } + } + $this->where[] = array( 'column' => $column, - 'operator' => $operator, + 'operator' => $normalizedOperator, 'value' => $value, ); @@ -1065,6 +1139,10 @@ public function groupBy($column) */ public function groupNBy($n) { + if (filter_var($n, FILTER_VALIDATE_INT) === false || (int) $n <= 0) { + throw new SphinxQLException('groupNBy() requires a positive integer.'); + } + $this->group_n_by = (int) $n; return $this; @@ -1082,7 +1160,14 @@ public function groupNBy($n) */ public function withinGroupOrderBy($column, $direction = null) { - $this->within_group_order_by[] = array('column' => $column, 'direction' => $direction); + if (!is_string($column) || trim($column) === '') { + throw new SphinxQLException('withinGroupOrderBy() column must be a non-empty string.'); + } + + $this->within_group_order_by[] = array( + 'column' => $column, + 'direction' => $this->normalizeDirection($direction, 'withinGroupOrderBy') + ); return $this; } @@ -1120,9 +1205,30 @@ public function having($column, $operator, $value = null) $operator = '='; } + if (!is_string($column) || trim($column) === '') { + throw new SphinxQLException('having() column must be a non-empty string.'); + } + + if (!is_string($operator) || trim($operator) === '') { + throw new SphinxQLException('having() operator must be a non-empty string.'); + } + + $normalizedOperator = strtoupper(trim($operator)); + if (in_array($normalizedOperator, array('IN', 'NOT IN'), true)) { + if (!is_array($value) || count($value) === 0) { + throw new SphinxQLException('having() operator '.$normalizedOperator.' requires a non-empty array value.'); + } + } + + if ($normalizedOperator === 'BETWEEN') { + if (!is_array($value) || count($value) !== 2) { + throw new SphinxQLException('having() operator BETWEEN requires an array with exactly 2 values.'); + } + } + $this->having = array( 'column' => $column, - 'operator' => $operator, + 'operator' => $normalizedOperator, 'value' => $value, ); @@ -1140,7 +1246,14 @@ public function having($column, $operator, $value = null) */ public function orderBy($column, $direction = null) { - $this->order_by[] = array('column' => $column, 'direction' => $direction); + if (!is_string($column) || trim($column) === '') { + throw new SphinxQLException('orderBy() column must be a non-empty string.'); + } + + $this->order_by[] = array( + 'column' => $column, + 'direction' => $this->normalizeDirection($direction, 'orderBy') + ); return $this; } @@ -1157,11 +1270,22 @@ public function orderBy($column, $direction = null) public function limit($offset, $limit = null) { if ($limit === null) { + if (filter_var($offset, FILTER_VALIDATE_INT) === false || (int) $offset < 0) { + throw new SphinxQLException('limit() requires a non-negative integer.'); + } + $this->limit = (int) $offset; return $this; } + if (filter_var($offset, FILTER_VALIDATE_INT) === false || (int) $offset < 0) { + throw new SphinxQLException('limit() offset must be a non-negative integer.'); + } + if (filter_var($limit, FILTER_VALIDATE_INT) === false || (int) $limit < 0) { + throw new SphinxQLException('limit() limit must be a non-negative integer.'); + } + $this->offset($offset); $this->limit = (int) $limit; @@ -1177,6 +1301,10 @@ public function limit($offset, $limit = null) */ public function offset($offset) { + if (filter_var($offset, FILTER_VALIDATE_INT) === false || (int) $offset < 0) { + throw new SphinxQLException('offset() requires a non-negative integer.'); + } + $this->offset = (int) $offset; return $this; @@ -1193,6 +1321,10 @@ public function offset($offset) */ public function option($name, $value) { + if (!is_string($name) || trim($name) === '') { + throw new SphinxQLException('option() name must be a non-empty string.'); + } + $this->options[] = array('name' => $name, 'value' => $value); return $this; @@ -1208,6 +1340,10 @@ public function option($name, $value) */ public function into($index) { + if (!is_string($index) || trim($index) === '') { + throw new SphinxQLException('into() index must be a non-empty string.'); + } + $this->into = $index; return $this; @@ -1225,9 +1361,26 @@ public function into($index) public function columns($array = array()) { if (is_array($array)) { + if (empty($array)) { + throw new SphinxQLException('columns() requires at least one column.'); + } + + foreach ($array as $column) { + if (!is_string($column) || trim($column) === '') { + throw new SphinxQLException('columns() values must be non-empty strings.'); + } + } + $this->columns = $array; } else { - $this->columns = \func_get_args(); + $columns = \func_get_args(); + foreach ($columns as $column) { + if (!is_string($column) || trim($column) === '') { + throw new SphinxQLException('columns() values must be non-empty strings.'); + } + } + + $this->columns = $columns; } return $this; @@ -1245,9 +1398,16 @@ public function columns($array = array()) public function values($array) { if (is_array($array)) { + if (empty($array)) { + throw new SphinxQLException('values() requires at least one value.'); + } $this->values[] = $array; } else { - $this->values[] = \func_get_args(); + $values = \func_get_args(); + if (empty($values)) { + throw new SphinxQLException('values() requires at least one value.'); + } + $this->values[] = $values; } return $this; @@ -1264,6 +1424,10 @@ public function values($array) */ public function value($column, $value) { + if (!is_string($column) || trim($column) === '') { + throw new SphinxQLException('value() column must be a non-empty string.'); + } + if ($this->type === 'insert' || $this->type === 'replace') { $this->columns[] = $column; $this->values[0][] = $value; @@ -1284,6 +1448,10 @@ public function value($column, $value) */ public function set($array) { + if (!is_array($array) || empty($array)) { + throw new SphinxQLException('set() requires a non-empty associative array.'); + } + if ($this->columns === array_keys($array)) { $this->values($array); } else { @@ -1305,6 +1473,10 @@ public function set($array) */ public function facet($facet) { + if (!$facet instanceof Facet) { + throw new SphinxQLException('facet() expects an instance of '.Facet::class.'.'); + } + $this->facets[] = $facet; return $this; @@ -1415,6 +1587,31 @@ public function halfEscapeMatch($string) return $string; } + /** + * @param string|null $direction + * @param string $method + * + * @return string|null + * @throws SphinxQLException + */ + private function normalizeDirection($direction, $method) + { + if ($direction === null) { + return null; + } + + if (!is_string($direction) || trim($direction) === '') { + throw new SphinxQLException($method.'() direction must be one of: ASC, DESC, or null.'); + } + + $normalized = strtoupper(trim($direction)); + if (!in_array($normalized, array('ASC', 'DESC'), true)) { + throw new SphinxQLException($method.'() direction must be one of: ASC, DESC, or null.'); + } + + return $normalized; + } + /** * Clears the existing query build for new query when using the same SphinxQL instance. * diff --git a/tests/SphinxQL/ConnectionTest.php b/tests/SphinxQL/ConnectionTest.php index 9b59de69..5db2ccdf 100644 --- a/tests/SphinxQL/ConnectionTest.php +++ b/tests/SphinxQL/ConnectionTest.php @@ -24,7 +24,7 @@ protected function tearDown(): void public function test() { - TestUtil::getConnectionDriver(); + $this->assertInstanceOf(ConnectionInterface::class, TestUtil::getConnectionDriver()); } public function testGetParams() @@ -89,9 +89,11 @@ public function testGetConnectionThrowsException() public function testConnect() { $this->connection->connect(); + $this->assertNotNull($this->connection->getConnection()); $this->connection->setParam('options', array(MYSQLI_OPT_CONNECT_TIMEOUT => 1)); $this->connection->connect(); + $this->assertNotNull($this->connection->getConnection()); } public function testConnectThrowsException() @@ -155,12 +157,14 @@ public function testEmptyMultiQuery() public function testMultiQueryThrowsException() { $this->expectException(Foolz\SphinxQL\Exception\DatabaseException::class); + $this->expectExceptionMessage($GLOBALS['driver'] === 'Pdo' ? '[pdo][multi_query]' : '[mysqli][multi_query]'); $this->connection->multiQuery(array('SHOW METAL')); } public function testQueryThrowsException() { $this->expectException(Foolz\SphinxQL\Exception\DatabaseException::class); + $this->expectExceptionMessage($GLOBALS['driver'] === 'Pdo' ? '[pdo][query]' : '[mysqli][query]'); $this->connection->query('SHOW METAL'); } diff --git a/tests/SphinxQL/FacetTest.php b/tests/SphinxQL/FacetTest.php index d18d8815..0cd1ce90 100644 --- a/tests/SphinxQL/FacetTest.php +++ b/tests/SphinxQL/FacetTest.php @@ -148,4 +148,48 @@ public function testLimit() $this->assertEquals('FACET gid, title ORDER BY COUNT(*) DESC LIMIT 5, 5', $facet); } + + public function testFacetRequiresColumns() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createFacet()->facet(); + } + + public function testFacetRejectsInvalidDirection() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createFacet() + ->facet(array('gid')) + ->orderBy('gid', 'sideways'); + } + + public function testFacetRejectsInvalidOrderByFunctionDirection() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createFacet() + ->facet(array('gid')) + ->orderByFunction('COUNT', '*', 'sideways'); + } + + public function testFacetRejectsInvalidLimitAndOffset() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createFacet() + ->facet(array('gid')) + ->limit(-1); + } + + public function testFacetRejectsInvalidOffset() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createFacet() + ->facet(array('gid')) + ->offset(-1); + } + + public function testFacetFunctionRequiresParameters() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createFacet()->facetFunction('COUNT'); + } } diff --git a/tests/SphinxQL/HelperTest.php b/tests/SphinxQL/HelperTest.php index 1b9f819d..d6dc3554 100644 --- a/tests/SphinxQL/HelperTest.php +++ b/tests/SphinxQL/HelperTest.php @@ -294,4 +294,34 @@ public function testFlushAndOptimizeExecution() $this->assertIsInt($result); $this->assertGreaterThanOrEqual(0, $result); } + + public function testHelperRequiresNonEmptyIdentifiers() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createHelper()->showTables(''); + } + + public function testSetVariableValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createHelper()->setVariable('invalid-name', 1)->compile(); + } + + public function testCallSnippetsValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createHelper()->callSnippets('', 'rt', 'is'); + } + + public function testCallKeywordsValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createHelper()->callKeywords('test case', 'rt', 2); + } + + public function testCreateFunctionValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createHelper()->createFunction('my_udf', 'INVALID', 'test_udf.so'); + } } diff --git a/tests/SphinxQL/PercolateQueriesTest.php b/tests/SphinxQL/PercolateQueriesTest.php index f31a8b7a..7e13a6ed 100644 --- a/tests/SphinxQL/PercolateQueriesTest.php +++ b/tests/SphinxQL/PercolateQueriesTest.php @@ -43,12 +43,19 @@ public function testInsert($testNumber, $query, $index, $tags, $filter, $compile } $percolate = new Percolate(self::$conn); - $percolate + $percolate = $percolate ->insert($query) - ->into($index) - ->tags($tags) - ->filter($filter) - ->execute(); + ->into($index); + + if ($tags !== null) { + $percolate->tags($tags); + } + + if ($filter !== null) { + $percolate->filter($filter); + } + + $percolate->execute(); if (in_array($testNumber, [1, 4, 5, 6, 7, 8, 9, 11])) { $this->assertEquals($compiledQuery, $percolate->getLastQuery()); diff --git a/tests/SphinxQL/SphinxQLTest.php b/tests/SphinxQL/SphinxQLTest.php index e23d1362..8186d2e2 100644 --- a/tests/SphinxQL/SphinxQLTest.php +++ b/tests/SphinxQL/SphinxQLTest.php @@ -87,6 +87,8 @@ public function testTransactions() $this->createSphinxQL()->transactionRollback(); $this->createSphinxQL()->transactionBegin(); $this->createSphinxQL()->transactionCommit(); + + $this->assertTrue(true); } public function testQuery() @@ -1167,4 +1169,78 @@ public function testClosureMisuse() $query->compile()->getCompiled() ); } + + public function testCompileWithoutTypeThrowsException() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + (new SphinxQL(self::$conn))->compile(); + } + + public function testSetTypeValidation() + { + $query = new SphinxQL(self::$conn); + $result = $query->setType('select'); + $this->assertSame($query, $result); + + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $query->setType('invalid_type'); + } + + public function testFromValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createSphinxQL()->select()->from(); + } + + public function testWhereValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createSphinxQL()->select()->from('rt')->where('gid', 'IN', array()); + } + + public function testHavingValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createSphinxQL() + ->select() + ->from('rt') + ->groupBy('gid') + ->having('gid', 'BETWEEN', array(1)); + } + + public function testOrderDirectionValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createSphinxQL()->select()->from('rt')->orderBy('id', 'sideways'); + } + + public function testWithinGroupOrderDirectionValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createSphinxQL()->select()->from('rt')->withinGroupOrderBy('id', 'sideways'); + } + + public function testLimitOffsetValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createSphinxQL()->select()->from('rt')->limit(-1); + } + + public function testGroupNByValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createSphinxQL()->select()->from('rt')->groupNBy(0); + } + + public function testFacetTypeValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createSphinxQL()->select()->from('rt')->facet('gid'); + } + + public function testSetQueuePrevValidation() + { + $this->expectException(\InvalidArgumentException::class); + $this->createSphinxQL()->setQueuePrev('not-a-query'); + } } From f797beb490e6b61625c730a72198c0350e7b7c2c Mon Sep 17 00:00:00 2001 From: woxxy Date: Fri, 27 Feb 2026 22:38:21 +0000 Subject: [PATCH 05/16] feat: add helper parity APIs and advanced query builder clauses --- CHANGELOG.md | 3 + README.md | 44 ++- docs/helper.rst | 21 ++ docs/query-builder.rst | 21 ++ src/Helper.php | 285 +++++++++++++++++ src/SphinxQL.php | 530 ++++++++++++++++++++++++++++---- tests/SphinxQL/HelperTest.php | 134 ++++++++ tests/SphinxQL/SphinxQLTest.php | 126 ++++++++ tests/SphinxQL/TestUtil.php | 41 +++ tests/install.sh | 12 +- 10 files changed, 1145 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 529f2625..a6c2d4ae 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ * Hardened runtime validation for `SphinxQL`, `Facet`, `Helper`, and `Percolate` input contracts (fail-fast exceptions for invalid query-shape input) * Standardized driver exception message prefixes for better diagnostics (`[mysqli][...]`, `[pdo][...]`) * Expanded helper runtime API coverage (`SHOW WARNINGS`, `SHOW STATUS`, `SHOW INDEX STATUS`, `FLUSH RAMCHUNK`, `FLUSH RTINDEX`, `OPTIMIZE INDEX`, UDF lifecycle checks) +* Added fluent boolean grouping APIs (`orWhere`, `whereOpen/whereClose`, `orHaving`, `havingOpen/havingClose`) and JOIN builders (`join`, `innerJoin`, `leftJoin`, `rightJoin`, `crossJoin`) +* Added `orderByKnn()` and broader helper wrappers for operational and Manticore-oriented commands (`SHOW PROFILE/PLAN/THREADS/VERSION/PLUGINS`, table status/settings/indexes, flush/reload/kill, suggest family) +* Added capability-aware runtime tests for optional engine features (`supportsCommand`, Buddy-gated checks) * Added and stabilized Sphinx 3 compatibility coverage while preserving Sphinx 2 and Manticore test behavior * Migrated CI to GitHub Actions-only validation with strict composer metadata checks * Updated documentation and added a dedicated `MIGRATING-4.0.md` guide diff --git a/README.md b/README.md index 6da0d2dd..4d7fd409 100755 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ This Query Builder has no dependencies except PHP 8.2 or later, `\MySQLi` extens ### Missing methods? -SphinxQL evolves very fast. +SphinxQL and ManticoreQL evolve fast. This library provides fluent builders for +core query composition and helper wrappers for common operational commands. -Most of the new functions are static one liners like `SHOW PLUGINS`. We'll avoid trying to keep up with these methods, as they are easy to just call directly (`(new SphinxQL($conn))->query($sql)->execute()`). You're free to submit pull requests to support these methods. - -If any feature is unreachable through this library, open a new issue or send a pull request. +If any feature is still unreachable through this library, open an issue or send +a pull request. ## Code Quality @@ -240,7 +240,8 @@ Will return an array with an `INT` as first member, the number of rows deleted. $sq->where('column', 'BETWEEN', array('value1', 'value2')); ``` - _It should be noted that `OR` and parenthesis are not supported and implemented in the SphinxQL dialect yet._ + You can compose grouped boolean filters with: + `orWhere()`, `whereOpen()`, `orWhereOpen()`, and `whereClose()`. #### MATCH @@ -297,6 +298,18 @@ Will return an array with an `INT` as first member, the number of rows deleted. Direction can be omitted with `null`, or be `ASC` or `DESC` case insensitive. +* __$sq->orderByKnn($field, $k, array $vector, $direction = 'ASC')__ + + `ORDER BY KNN($field, $k, $vector) [$direction]` + +#### JOIN + +* __$sq->join($table, $left, $operator, $right, $type = 'INNER')__ +* __$sq->innerJoin($table, $left, $operator, $right)__ +* __$sq->leftJoin($table, $left, $operator, $right)__ +* __$sq->rightJoin($table, $left, $operator, $right)__ +* __$sq->crossJoin($table)__ + * __$sq->offset($offset)__ `LIMIT $offset, 9999999999999` @@ -448,11 +461,27 @@ $result = (new SphinxQL($this->conn)) * `(new Helper($conn))->showMeta() => 'SHOW META'` * `(new Helper($conn))->showWarnings() => 'SHOW WARNINGS'` * `(new Helper($conn))->showStatus() => 'SHOW STATUS'` +* `(new Helper($conn))->showProfile() => 'SHOW PROFILE'` +* `(new Helper($conn))->showPlan() => 'SHOW PLAN'` +* `(new Helper($conn))->showThreads() => 'SHOW THREADS'` +* `(new Helper($conn))->showVersion() => 'SHOW VERSION'` +* `(new Helper($conn))->showPlugins() => 'SHOW PLUGINS'` +* `(new Helper($conn))->showAgentStatus() => 'SHOW AGENT STATUS'` +* `(new Helper($conn))->showScroll() => 'SHOW SCROLL'` +* `(new Helper($conn))->showDatabases() => 'SHOW DATABASES'` * `(new Helper($conn))->showTables() => 'SHOW TABLES'` * `(new Helper($conn))->showVariables() => 'SHOW VARIABLES'` +* `(new Helper($conn))->showCreateTable($table)` +* `(new Helper($conn))->showTableStatus($table = null)` +* `(new Helper($conn))->showTableSettings($table)` +* `(new Helper($conn))->showTableIndexes($table)` +* `(new Helper($conn))->showQueries()` * `(new Helper($conn))->setVariable($name, $value, $global = false)` * `(new Helper($conn))->callSnippets($data, $index, $query, $options = array())` * `(new Helper($conn))->callKeywords($text, $index, $hits = null)` +* `(new Helper($conn))->callSuggest($text, $index, $options = array())` +* `(new Helper($conn))->callQSuggest($text, $index, $options = array())` +* `(new Helper($conn))->callAutocomplete($text, $index, $options = array())` * `(new Helper($conn))->describe($index)` * `(new Helper($conn))->createFunction($udf_name, $returns, $soname)` * `(new Helper($conn))->dropFunction($udf_name)` @@ -461,6 +490,11 @@ $result = (new SphinxQL($this->conn)) * `(new Helper($conn))->optimizeIndex($index)` * `(new Helper($conn))->showIndexStatus($index)` * `(new Helper($conn))->flushRamchunk($index)` +* `(new Helper($conn))->flushAttributes()` +* `(new Helper($conn))->flushHostnames()` +* `(new Helper($conn))->flushLogs()` +* `(new Helper($conn))->reloadPlugins()` +* `(new Helper($conn))->kill($queryId)` ### Percolate The `Percolate` class provides methods for the "Percolate query" feature of Manticore Search. diff --git a/docs/helper.rst b/docs/helper.rst index e8d4bb5d..ddb60ad8 100644 --- a/docs/helper.rst +++ b/docs/helper.rst @@ -22,11 +22,27 @@ Available Methods - ``showMeta()`` - ``showWarnings()`` - ``showStatus()`` +- ``showProfile()`` +- ``showPlan()`` +- ``showThreads()`` +- ``showVersion()`` +- ``showPlugins()`` +- ``showAgentStatus()`` +- ``showScroll()`` +- ``showDatabases()`` - ``showTables($index)`` - ``showVariables()`` +- ``showCreateTable($table)`` +- ``showTableStatus($table = null)`` +- ``showTableSettings($table)`` +- ``showTableIndexes($table)`` +- ``showQueries()`` - ``setVariable($name, $value, $global = false)`` - ``callSnippets($data, $index, $query, array $options = array())`` - ``callKeywords($text, $index, $hits = null)`` +- ``callSuggest($text, $index, array $options = array())`` +- ``callQSuggest($text, $index, array $options = array())`` +- ``callAutocomplete($text, $index, array $options = array())`` - ``describe($index)`` - ``createFunction($udfName, $returns, $soName)`` - ``dropFunction($udfName)`` @@ -36,6 +52,11 @@ Available Methods - ``optimizeIndex($index)`` - ``showIndexStatus($index)`` - ``flushRamchunk($index)`` +- ``flushAttributes()`` +- ``flushHostnames()`` +- ``flushLogs()`` +- ``reloadPlugins()`` +- ``kill($queryId)`` Validation Notes ---------------- diff --git a/docs/query-builder.rst b/docs/query-builder.rst index 6c7651dd..69136a5e 100644 --- a/docs/query-builder.rst +++ b/docs/query-builder.rst @@ -62,3 +62,24 @@ The builder now validates critical query-shape input and throws - negative ``limit()`` / ``offset()`` - invalid shapes for ``IN`` and ``BETWEEN`` filters - invalid ``facet()`` object type + +Boolean Grouping and OR Filters +------------------------------- + +The builder supports grouped boolean filters for ``WHERE`` and ``HAVING``: + +- ``orWhere()`` +- ``whereOpen()`` / ``orWhereOpen()`` / ``whereClose()`` +- ``orHaving()`` +- ``havingOpen()`` / ``orHavingOpen()`` / ``havingClose()`` + +JOIN and KNN Ordering +--------------------- + +``SELECT`` queries support fluent joins: + +- ``join()``, ``innerJoin()``, ``leftJoin()``, ``rightJoin()``, ``crossJoin()`` + +Vector-oriented ordering is available through: + +- ``orderByKnn($field, $k, array $vector, $direction = 'ASC')`` diff --git a/src/Helper.php b/src/Helper.php index 0841cb71..b2a02099 100644 --- a/src/Helper.php +++ b/src/Helper.php @@ -95,6 +95,86 @@ public function showStatus() return $this->query('SHOW STATUS'); } + /** + * Runs query: SHOW PROFILE + * + * @return SphinxQL + */ + public function showProfile() + { + return $this->query('SHOW PROFILE'); + } + + /** + * Runs query: SHOW PLAN + * + * @return SphinxQL + */ + public function showPlan() + { + return $this->query('SHOW PLAN'); + } + + /** + * Runs query: SHOW THREADS + * + * @return SphinxQL + */ + public function showThreads() + { + return $this->query('SHOW THREADS'); + } + + /** + * Runs query: SHOW VERSION + * + * @return SphinxQL + */ + public function showVersion() + { + return $this->query('SHOW VERSION'); + } + + /** + * Runs query: SHOW PLUGINS + * + * @return SphinxQL + */ + public function showPlugins() + { + return $this->query('SHOW PLUGINS'); + } + + /** + * Runs query: SHOW AGENT STATUS + * + * @return SphinxQL + */ + public function showAgentStatus() + { + return $this->query('SHOW AGENT STATUS'); + } + + /** + * Runs query: SHOW SCROLL + * + * @return SphinxQL + */ + public function showScroll() + { + return $this->query('SHOW SCROLL'); + } + + /** + * Runs query: SHOW DATABASES + * + * @return SphinxQL + */ + public function showDatabases() + { + return $this->query('SHOW DATABASES'); + } + /** * Runs query: SHOW TABLES * @@ -119,6 +199,76 @@ public function showVariables() return $this->query('SHOW VARIABLES'); } + /** + * Runs query: SHOW CREATE TABLE + * + * @param string $table + * + * @return SphinxQL + */ + public function showCreateTable($table) + { + $this->assertNonEmptyString($table, 'showCreateTable() table'); + + return $this->query('SHOW CREATE TABLE '.$table); + } + + /** + * Runs query: SHOW TABLE STATUS + * + * @param string|null $table + * + * @return SphinxQL + */ + public function showTableStatus($table = null) + { + if ($table === null) { + return $this->query('SHOW TABLE STATUS'); + } + + $this->assertNonEmptyString($table, 'showTableStatus() table'); + + return $this->query('SHOW TABLE '.$table.' STATUS'); + } + + /** + * Runs query: SHOW TABLE SETTINGS + * + * @param string $table + * + * @return SphinxQL + */ + public function showTableSettings($table) + { + $this->assertNonEmptyString($table, 'showTableSettings() table'); + + return $this->query('SHOW TABLE '.$table.' SETTINGS'); + } + + /** + * Runs query: SHOW TABLE INDEXES + * + * @param string $table + * + * @return SphinxQL + */ + public function showTableIndexes($table) + { + $this->assertNonEmptyString($table, 'showTableIndexes() table'); + + return $this->query('SHOW TABLE '.$table.' INDEXES'); + } + + /** + * Runs query: SHOW QUERIES + * + * @return SphinxQL + */ + public function showQueries() + { + return $this->query('SHOW QUERIES'); + } + /** * SET syntax * @@ -251,6 +401,57 @@ public function callKeywords($text, $index, $hits = null) return $this->query('CALL KEYWORDS('.implode(', ', $this->connection->quoteArr($arr)).')'); } + /** + * CALL QSUGGEST syntax (Manticore Buddy) + * + * @param string $text + * @param string $index + * @param array $options + * + * @return SphinxQL + */ + public function callQSuggest($text, $index, array $options = array()) + { + $this->assertNonEmptyString($text, 'callQSuggest() text'); + $this->assertNonEmptyString($index, 'callQSuggest() index'); + + return $this->query($this->buildCallWithOptions('QSUGGEST', array($text, $index), $options)); + } + + /** + * CALL SUGGEST syntax + * + * @param string $text + * @param string $index + * @param array $options + * + * @return SphinxQL + */ + public function callSuggest($text, $index, array $options = array()) + { + $this->assertNonEmptyString($text, 'callSuggest() text'); + $this->assertNonEmptyString($index, 'callSuggest() index'); + + return $this->query($this->buildCallWithOptions('SUGGEST', array($text, $index), $options)); + } + + /** + * CALL AUTOCOMPLETE syntax (Manticore Buddy) + * + * @param string $text + * @param string $index + * @param array $options + * + * @return SphinxQL + */ + public function callAutocomplete($text, $index, array $options = array()) + { + $this->assertNonEmptyString($text, 'callAutocomplete() text'); + $this->assertNonEmptyString($index, 'callAutocomplete() index'); + + return $this->query($this->buildCallWithOptions('AUTOCOMPLETE', array($text, $index), $options)); + } + /** * DESCRIBE syntax * @@ -391,6 +592,62 @@ public function flushRamchunk($index) return $this->query('FLUSH RAMCHUNK '.$index); } + /** + * FLUSH ATTRIBUTES syntax. + * + * @return SphinxQL + */ + public function flushAttributes() + { + return $this->query('FLUSH ATTRIBUTES'); + } + + /** + * FLUSH HOSTNAMES syntax. + * + * @return SphinxQL + */ + public function flushHostnames() + { + return $this->query('FLUSH HOSTNAMES'); + } + + /** + * FLUSH LOGS syntax. + * + * @return SphinxQL + */ + public function flushLogs() + { + return $this->query('FLUSH LOGS'); + } + + /** + * RELOAD PLUGINS syntax. + * + * @return SphinxQL + */ + public function reloadPlugins() + { + return $this->query('RELOAD PLUGINS'); + } + + /** + * KILL syntax. + * + * @param int|string $queryId + * + * @return SphinxQL + */ + public function kill($queryId) + { + if (filter_var($queryId, FILTER_VALIDATE_INT) === false || (int) $queryId <= 0) { + throw new SphinxQLException('kill() queryId must be a positive integer.'); + } + + return $this->query('KILL '.((int) $queryId)); + } + /** * @param mixed $value * @param string $field @@ -403,4 +660,32 @@ private function assertNonEmptyString($value, $field) throw new SphinxQLException($field.' must be a non-empty string.'); } } + + /** + * @param string $callName + * @param array $requiredArgs + * @param array $options + * + * @return string + * @throws SphinxQLException + */ + private function buildCallWithOptions($callName, array $requiredArgs, array $options = array()) + { + if (!is_array($options)) { + throw new SphinxQLException($callName.' options must be an associative array.'); + } + + $quoted = $this->connection->quoteArr(array_values($requiredArgs)); + $optionValues = $this->connection->quoteArr($options); + foreach ($optionValues as $key => &$value) { + if (!is_string($key) || trim($key) === '') { + throw new SphinxQLException($callName.' options must have non-empty string keys.'); + } + $value .= ' AS '.$key; + } + + $args = implode(', ', array_merge($quoted, $optionValues)); + + return 'CALL '.$callName.'('.$args.')'; + } } diff --git a/src/SphinxQL.php b/src/SphinxQL.php index c4d38733..f427e601 100644 --- a/src/SphinxQL.php +++ b/src/SphinxQL.php @@ -64,7 +64,14 @@ class SphinxQL protected $from = array(); /** - * The list of where and parenthesis, must be inserted in order + * JOIN clauses for SELECT queries + * + * @var array + */ + protected $joins = array(); + + /** + * WHERE clause token list (conditions and grouping parenthesis) * * @var array */ @@ -99,7 +106,7 @@ class SphinxQL protected $within_group_order_by = array(); /** - * The list of where and parenthesis, must be inserted in order + * HAVING clause token list (conditions and grouping parenthesis) * * @var array */ @@ -534,22 +541,16 @@ public function compileMatch() */ public function compileWhere() { - $query = ''; - - if (empty($this->match) && !empty($this->where)) { - $query .= 'WHERE '; + $compiled = $this->compileBooleanClause($this->where, 'where'); + if ($compiled === '') { + return ''; } - if (!empty($this->where)) { - foreach ($this->where as $key => $where) { - if ($key > 0 || !empty($this->match)) { - $query .= 'AND '; - } - $query .= $this->compileFilterCondition($where); - } + if (empty($this->match)) { + return 'WHERE '.$compiled.' '; } - return $query; + return 'AND '.$compiled.' '; } /** @@ -622,6 +623,10 @@ public function compileSelect() } } + if (!empty($this->joins)) { + $query .= $this->compileJoins(); + } + $query .= $this->compileMatch().$this->compileWhere(); if (!empty($this->group_by)) { @@ -651,7 +656,7 @@ public function compileSelect() } if (!empty($this->having)) { - $query .= 'HAVING '.$this->compileFilterCondition($this->having); + $query .= 'HAVING '.$this->compileBooleanClause($this->having, 'having').' '; } if (!empty($this->order_by)) { @@ -1030,6 +1035,114 @@ public function from($array = null) throw new SphinxQLException('from() expects string indexes, an array of indexes, a subquery, or a closure.'); } + /** + * Adds a JOIN clause to the current SELECT query. + * + * @param string $table + * @param string $left + * @param string $operator + * @param string $right + * @param string $type + * + * @return self + */ + public function join($table, $left, $operator, $right, $type = 'INNER') + { + if (!is_string($table) || trim($table) === '') { + throw new SphinxQLException('join() table must be a non-empty string.'); + } + if (!is_string($left) || trim($left) === '') { + throw new SphinxQLException('join() left operand must be a non-empty string.'); + } + if (!is_string($operator) || trim($operator) === '') { + throw new SphinxQLException('join() operator must be a non-empty string.'); + } + if (!is_string($right) || trim($right) === '') { + throw new SphinxQLException('join() right operand must be a non-empty string.'); + } + + $joinType = strtoupper(trim((string) $type)); + if (!in_array($joinType, array('INNER', 'LEFT', 'RIGHT'), true)) { + throw new SphinxQLException('join() type must be one of: INNER, LEFT, RIGHT.'); + } + + $this->joins[] = array( + 'type' => $joinType, + 'table' => $table, + 'left' => $left, + 'operator' => strtoupper(trim($operator)), + 'right' => $right, + ); + + return $this; + } + + /** + * Adds an INNER JOIN clause. + * + * @param string $table + * @param string $left + * @param string $operator + * @param string $right + * + * @return self + */ + public function innerJoin($table, $left, $operator, $right) + { + return $this->join($table, $left, $operator, $right, 'INNER'); + } + + /** + * Adds a LEFT JOIN clause. + * + * @param string $table + * @param string $left + * @param string $operator + * @param string $right + * + * @return self + */ + public function leftJoin($table, $left, $operator, $right) + { + return $this->join($table, $left, $operator, $right, 'LEFT'); + } + + /** + * Adds a RIGHT JOIN clause. + * + * @param string $table + * @param string $left + * @param string $operator + * @param string $right + * + * @return self + */ + public function rightJoin($table, $left, $operator, $right) + { + return $this->join($table, $left, $operator, $right, 'RIGHT'); + } + + /** + * Adds a CROSS JOIN clause. + * + * @param string $table + * + * @return self + */ + public function crossJoin($table) + { + if (!is_string($table) || trim($table) === '') { + throw new SphinxQLException('crossJoin() table must be a non-empty string.'); + } + + $this->joins[] = array( + 'type' => 'CROSS', + 'table' => $table, + ); + + return $this; + } + /** * MATCH clause (Sphinx-specific) * @@ -1079,41 +1192,74 @@ public function match($column, $value = null, $half = false) */ public function where($column, $operator, $value = null) { - if ($value === null) { - $value = $operator; - $operator = '='; - } - - if (!is_string($column) || trim($column) === '') { - throw new SphinxQLException('where() column must be a non-empty string.'); - } + $this->where[] = array( + 'type' => 'condition', + 'boolean' => 'AND', + 'condition' => $this->createFilterCondition('where', $column, $operator, $value), + ); - if (!is_string($operator) || trim($operator) === '') { - throw new SphinxQLException('where() operator must be a non-empty string.'); - } + return $this; + } - $normalizedOperator = strtoupper(trim($operator)); - if (in_array($normalizedOperator, array('IN', 'NOT IN'), true)) { - if (!is_array($value) || count($value) === 0) { - throw new SphinxQLException('where() operator '.$normalizedOperator.' requires a non-empty array value.'); - } - } + /** + * Adds an OR WHERE condition. + * + * @param string $column + * @param Expression|string|null|bool|array|int|float $operator + * @param Expression|string|null|bool|array|int|float $value + * + * @return self + */ + public function orWhere($column, $operator, $value = null) + { + $this->where[] = array( + 'type' => 'condition', + 'boolean' => 'OR', + 'condition' => $this->createFilterCondition('orWhere', $column, $operator, $value), + ); - if ($normalizedOperator === 'BETWEEN') { - if (!is_array($value) || count($value) !== 2) { - throw new SphinxQLException('where() operator BETWEEN requires an array with exactly 2 values.'); - } - } + return $this; + } + /** + * Opens a grouped WHERE clause. + * + * @param string $boolean + * + * @return self + */ + public function whereOpen($boolean = 'AND') + { $this->where[] = array( - 'column' => $column, - 'operator' => $normalizedOperator, - 'value' => $value, + 'type' => 'open', + 'boolean' => $this->normalizeBooleanOperator($boolean, 'whereOpen'), ); return $this; } + /** + * Opens a grouped WHERE clause joined with OR. + * + * @return self + */ + public function orWhereOpen() + { + return $this->whereOpen('OR'); + } + + /** + * Closes a grouped WHERE clause. + * + * @return self + */ + public function whereClose() + { + $this->where[] = array('type' => 'close'); + + return $this; + } + /** * GROUP BY clause * Adds to the previously added columns @@ -1200,41 +1346,74 @@ public function withinGroupOrderBy($column, $direction = null) */ public function having($column, $operator, $value = null) { - if ($value === null) { - $value = $operator; - $operator = '='; - } - - if (!is_string($column) || trim($column) === '') { - throw new SphinxQLException('having() column must be a non-empty string.'); - } + $this->having[] = array( + 'type' => 'condition', + 'boolean' => 'AND', + 'condition' => $this->createFilterCondition('having', $column, $operator, $value), + ); - if (!is_string($operator) || trim($operator) === '') { - throw new SphinxQLException('having() operator must be a non-empty string.'); - } + return $this; + } - $normalizedOperator = strtoupper(trim($operator)); - if (in_array($normalizedOperator, array('IN', 'NOT IN'), true)) { - if (!is_array($value) || count($value) === 0) { - throw new SphinxQLException('having() operator '.$normalizedOperator.' requires a non-empty array value.'); - } - } + /** + * Adds an OR HAVING condition. + * + * @param string $column + * @param Expression|string|null|bool|array|int|float $operator + * @param Expression|string|null|bool|array|int|float $value + * + * @return self + */ + public function orHaving($column, $operator, $value = null) + { + $this->having[] = array( + 'type' => 'condition', + 'boolean' => 'OR', + 'condition' => $this->createFilterCondition('orHaving', $column, $operator, $value), + ); - if ($normalizedOperator === 'BETWEEN') { - if (!is_array($value) || count($value) !== 2) { - throw new SphinxQLException('having() operator BETWEEN requires an array with exactly 2 values.'); - } - } + return $this; + } - $this->having = array( - 'column' => $column, - 'operator' => $normalizedOperator, - 'value' => $value, + /** + * Opens a grouped HAVING clause. + * + * @param string $boolean + * + * @return self + */ + public function havingOpen($boolean = 'AND') + { + $this->having[] = array( + 'type' => 'open', + 'boolean' => $this->normalizeBooleanOperator($boolean, 'havingOpen'), ); return $this; } + /** + * Opens a grouped HAVING clause joined with OR. + * + * @return self + */ + public function orHavingOpen() + { + return $this->havingOpen('OR'); + } + + /** + * Closes a grouped HAVING clause. + * + * @return self + */ + public function havingClose() + { + $this->having[] = array('type' => 'close'); + + return $this; + } + /** * ORDER BY clause * Adds to the previously added columns @@ -1258,6 +1437,41 @@ public function orderBy($column, $direction = null) return $this; } + /** + * Adds ORDER BY KNN(...) clause expression. + * + * @param string $field + * @param int|string $k + * @param array $vector + * @param string|null $direction + * + * @return self + */ + public function orderByKnn($field, $k, array $vector, $direction = 'ASC') + { + if (!is_string($field) || trim($field) === '') { + throw new SphinxQLException('orderByKnn() field must be a non-empty string.'); + } + if (filter_var($k, FILTER_VALIDATE_INT) === false || (int) $k <= 0) { + throw new SphinxQLException('orderByKnn() k must be a positive integer.'); + } + if (empty($vector)) { + throw new SphinxQLException('orderByKnn() vector must be a non-empty array.'); + } + + $encodedVector = json_encode(array_values($vector)); + if ($encodedVector === false) { + throw new SphinxQLException('orderByKnn() vector could not be JSON encoded.'); + } + + $this->order_by[] = array( + 'column' => 'KNN('.$field.', '.((int) $k).', '.$encodedVector.')', + 'direction' => $this->normalizeDirection($direction, 'orderByKnn') + ); + + return $this; + } + /** * LIMIT clause * Supports also LIMIT offset, limit @@ -1587,6 +1801,179 @@ public function halfEscapeMatch($string) return $string; } + /** + * @param string $method + * @param string $column + * @param Expression|string|null|bool|array|int|float $operator + * @param Expression|string|null|bool|array|int|float $value + * + * @return array + * @throws SphinxQLException + */ + private function createFilterCondition($method, $column, $operator, $value = null) + { + if ($value === null) { + $value = $operator; + $operator = '='; + } + + if (!is_string($column) || trim($column) === '') { + throw new SphinxQLException($method.'() column must be a non-empty string.'); + } + + if (!is_string($operator) || trim($operator) === '') { + throw new SphinxQLException($method.'() operator must be a non-empty string.'); + } + + $normalizedOperator = strtoupper(trim($operator)); + if (in_array($normalizedOperator, array('IN', 'NOT IN'), true)) { + if (!is_array($value) || count($value) === 0) { + throw new SphinxQLException($method.'() operator '.$normalizedOperator.' requires a non-empty array value.'); + } + } + + if ($normalizedOperator === 'BETWEEN') { + if (!is_array($value) || count($value) !== 2) { + throw new SphinxQLException($method.'() operator BETWEEN requires an array with exactly 2 values.'); + } + } + + return array( + 'column' => $column, + 'operator' => $normalizedOperator, + 'value' => $value, + ); + } + + /** + * @param string|null $boolean + * @param string $method + * + * @return string + * @throws SphinxQLException + */ + private function normalizeBooleanOperator($boolean, $method) + { + if ($boolean === null) { + return 'AND'; + } + + if (!is_string($boolean) || trim($boolean) === '') { + throw new SphinxQLException($method.'() boolean must be one of: AND, OR.'); + } + + $normalized = strtoupper(trim($boolean)); + if (!in_array($normalized, array('AND', 'OR'), true)) { + throw new SphinxQLException($method.'() boolean must be one of: AND, OR.'); + } + + return $normalized; + } + + /** + * @param array $tokens + * @param string $context + * + * @return string + * @throws ConnectionException + * @throws DatabaseException + * @throws SphinxQLException + */ + private function compileBooleanClause(array $tokens, $context) + { + if (empty($tokens)) { + return ''; + } + + $query = ''; + $openGroups = 0; + $prevType = null; + $hasCondition = false; + + foreach ($tokens as $token) { + if (!isset($token['type'])) { + // Legacy compatibility with pre-tokenized conditions. + $token = array( + 'type' => 'condition', + 'boolean' => 'AND', + 'condition' => $token, + ); + } + + if ($token['type'] === 'open') { + $boolean = isset($token['boolean']) ? $this->normalizeBooleanOperator($token['boolean'], $context.'Open') : 'AND'; + if ($prevType === 'condition' || $prevType === 'close') { + $query .= $boolean.' '; + } elseif ($prevType === null && $boolean === 'OR') { + throw new SphinxQLException('Cannot start '.$context.' clause with OR group.'); + } + + $query .= '( '; + $openGroups++; + $prevType = 'open'; + continue; + } + + if ($token['type'] === 'close') { + if ($openGroups <= 0) { + throw new SphinxQLException('Unbalanced '.$context.' clause: unexpected closing parenthesis.'); + } + if ($prevType === 'open') { + throw new SphinxQLException('Empty parenthesis group is not allowed in '.$context.' clause.'); + } + + $query .= ') '; + $openGroups--; + $prevType = 'close'; + continue; + } + + if ($token['type'] !== 'condition' || !isset($token['condition'])) { + throw new SphinxQLException('Invalid '.$context.' token.'); + } + + $boolean = isset($token['boolean']) ? $this->normalizeBooleanOperator($token['boolean'], $context) : 'AND'; + if ($prevType === 'condition' || $prevType === 'close') { + $query .= $boolean.' '; + } elseif ($prevType === null && $boolean === 'OR') { + throw new SphinxQLException('Cannot start '.$context.' clause with OR.'); + } + + $query .= trim($this->compileFilterCondition($token['condition'])).' '; + $hasCondition = true; + $prevType = 'condition'; + } + + if ($openGroups !== 0) { + throw new SphinxQLException('Unbalanced '.$context.' clause: missing closing parenthesis.'); + } + + if (!$hasCondition) { + throw new SphinxQLException('Empty '.$context.' clause is not allowed.'); + } + + return trim($query); + } + + /** + * @return string + */ + private function compileJoins() + { + $compiled = ''; + + foreach ($this->joins as $join) { + if ($join['type'] === 'CROSS') { + $compiled .= 'CROSS JOIN '.$join['table'].' '; + continue; + } + + $compiled .= $join['type'].' JOIN '.$join['table'].' ON '.$join['left'].' '.$join['operator'].' '.$join['right'].' '; + } + + return $compiled; + } + /** * @param string|null $direction * @param string $method @@ -1622,6 +2009,7 @@ public function reset() $this->query = null; $this->select = array(); $this->from = array(); + $this->joins = array(); $this->where = array(); $this->match = array(); $this->group_by = array(); @@ -1650,6 +2038,16 @@ public function resetWhere() return $this; } + /** + * @return self + */ + public function resetJoins() + { + $this->joins = array(); + + return $this; + } + /** * @return self */ diff --git a/tests/SphinxQL/HelperTest.php b/tests/SphinxQL/HelperTest.php index d6dc3554..6599511f 100644 --- a/tests/SphinxQL/HelperTest.php +++ b/tests/SphinxQL/HelperTest.php @@ -247,6 +247,72 @@ public function testMiscellaneous() $query = $this->createHelper()->flushRamchunk('rt'); $this->assertEquals('FLUSH RAMCHUNK rt', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showProfile(); + $this->assertEquals('SHOW PROFILE', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showPlan(); + $this->assertEquals('SHOW PLAN', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showThreads(); + $this->assertEquals('SHOW THREADS', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showVersion(); + $this->assertEquals('SHOW VERSION', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showPlugins(); + $this->assertEquals('SHOW PLUGINS', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showAgentStatus(); + $this->assertEquals('SHOW AGENT STATUS', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showScroll(); + $this->assertEquals('SHOW SCROLL', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showDatabases(); + $this->assertEquals('SHOW DATABASES', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showCreateTable('rt'); + $this->assertEquals('SHOW CREATE TABLE rt', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showTableStatus(); + $this->assertEquals('SHOW TABLE STATUS', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showTableStatus('rt'); + $this->assertEquals('SHOW TABLE rt STATUS', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showTableSettings('rt'); + $this->assertEquals('SHOW TABLE rt SETTINGS', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showTableIndexes('rt'); + $this->assertEquals('SHOW TABLE rt INDEXES', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showQueries(); + $this->assertEquals('SHOW QUERIES', $query->compile()->getCompiled()); + + $query = $this->createHelper()->flushAttributes(); + $this->assertEquals('FLUSH ATTRIBUTES', $query->compile()->getCompiled()); + + $query = $this->createHelper()->flushHostnames(); + $this->assertEquals('FLUSH HOSTNAMES', $query->compile()->getCompiled()); + + $query = $this->createHelper()->flushLogs(); + $this->assertEquals('FLUSH LOGS', $query->compile()->getCompiled()); + + $query = $this->createHelper()->reloadPlugins(); + $this->assertEquals('RELOAD PLUGINS', $query->compile()->getCompiled()); + + $query = $this->createHelper()->kill(123); + $this->assertEquals('KILL 123', $query->compile()->getCompiled()); + + $query = $this->createHelper()->callSuggest('teh', 'rt', array('limit' => 5)); + $this->assertEquals("CALL SUGGEST('teh', 'rt', 5 AS limit)", $query->compile()->getCompiled()); + + $query = $this->createHelper()->callQSuggest('teh', 'rt', array('limit' => 3)); + $this->assertEquals("CALL QSUGGEST('teh', 'rt', 3 AS limit)", $query->compile()->getCompiled()); + + $query = $this->createHelper()->callAutocomplete('te', 'rt', array('fuzzy' => 1)); + $this->assertEquals("CALL AUTOCOMPLETE('te', 'rt', 1 AS fuzzy)", $query->compile()->getCompiled()); } public function testShowWarningsAndStatusExecution() @@ -324,4 +390,72 @@ public function testCreateFunctionValidation() $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); $this->createHelper()->createFunction('my_udf', 'INVALID', 'test_udf.so'); } + + public function testNewHelperValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createHelper()->showCreateTable(''); + } + + public function testKillValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createHelper()->kill(0); + } + + public function testSuggestOptionValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createHelper()->callSuggest('teh', 'rt', array('' => 1)); + } + + public function testShowVersionExecutionWhenSupported() + { + if (!TestUtil::supportsCommand($this->conn, 'SHOW VERSION')) { + $this->markTestSkipped('SHOW VERSION is not supported by this engine.'); + } + + $rows = $this->createHelper()->showVersion()->execute()->getStored(); + $this->assertNotEmpty($rows); + } + + public function testShowPluginsExecutionWhenSupported() + { + if (!TestUtil::supportsCommand($this->conn, 'SHOW PLUGINS')) { + $this->markTestSkipped('SHOW PLUGINS is not supported by this engine.'); + } + + $rows = $this->createHelper()->showPlugins()->execute()->getStored(); + $this->assertIsArray($rows); + } + + public function testSuggestExecutionWhenSupported() + { + if (!TestUtil::supportsCommand($this->conn, "CALL SUGGEST('teh', 'rt')")) { + $this->markTestSkipped('CALL SUGGEST is not supported by this engine.'); + } + + $rows = $this->createHelper()->callSuggest('teh', 'rt')->execute()->getStored(); + $this->assertIsArray($rows); + } + + public function testQSuggestExecutionWhenBuddySupported() + { + if (!TestUtil::supportsBuddy($this->conn) || !TestUtil::supportsCommand($this->conn, "CALL QSUGGEST('teh', 'rt')")) { + $this->markTestSkipped('CALL QSUGGEST requires Manticore Buddy support.'); + } + + $rows = $this->createHelper()->callQSuggest('teh', 'rt')->execute()->getStored(); + $this->assertIsArray($rows); + } + + public function testAutocompleteExecutionWhenBuddySupported() + { + if (!TestUtil::supportsBuddy($this->conn) || !TestUtil::supportsCommand($this->conn, "CALL AUTOCOMPLETE('te', 'rt')")) { + $this->markTestSkipped('CALL AUTOCOMPLETE requires Manticore Buddy support.'); + } + + $rows = $this->createHelper()->callAutocomplete('te', 'rt')->execute()->getStored(); + $this->assertIsArray($rows); + } } diff --git a/tests/SphinxQL/SphinxQLTest.php b/tests/SphinxQL/SphinxQLTest.php index 8186d2e2..d9635431 100644 --- a/tests/SphinxQL/SphinxQLTest.php +++ b/tests/SphinxQL/SphinxQLTest.php @@ -1243,4 +1243,130 @@ public function testSetQueuePrevValidation() $this->expectException(\InvalidArgumentException::class); $this->createSphinxQL()->setQueuePrev('not-a-query'); } + + public function testOrWhereAndGroupingCompilation() + { + $compiled = $this->createSphinxQL() + ->select() + ->from('rt') + ->where('gid', 200) + ->orWhereOpen() + ->where('gid', 304) + ->where('id', '>', 12) + ->whereClose() + ->compile() + ->getCompiled(); + + $this->assertSame( + 'SELECT * FROM rt WHERE gid = 200 OR ( gid = 304 AND id > 12 )', + $compiled + ); + } + + public function testOrHavingAndGroupingCompilation() + { + $compiled = $this->createSphinxQL() + ->select('gid') + ->from('rt') + ->groupBy('gid') + ->having('gid', '>', 100) + ->orHavingOpen() + ->having('gid', '<', 10) + ->having('gid', '>', 9000) + ->havingClose() + ->compile() + ->getCompiled(); + + $this->assertSame( + 'SELECT gid FROM rt GROUP BY gid HAVING gid > 100 OR ( gid < 10 AND gid > 9000 )', + $compiled + ); + } + + public function testWhereGroupingValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createSphinxQL() + ->select() + ->from('rt') + ->whereClose() + ->compile(); + } + + public function testJoinCompilation() + { + $compiled = $this->createSphinxQL() + ->select('a.id') + ->from('rt a') + ->leftJoin('rt b', 'a.id', '=', 'b.id') + ->where('a.id', '>', 1) + ->compile() + ->getCompiled(); + + $this->assertSame( + 'SELECT a.id FROM rt a LEFT JOIN rt b ON a.id = b.id WHERE a.id > 1', + $compiled + ); + } + + public function testCrossJoinCompilation() + { + $compiled = $this->createSphinxQL() + ->select('a.id') + ->from('rt a') + ->crossJoin('rt b') + ->compile() + ->getCompiled(); + + $this->assertSame( + 'SELECT a.id FROM rt a CROSS JOIN rt b', + $compiled + ); + } + + public function testJoinValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createSphinxQL() + ->select() + ->from('rt') + ->join('rt2', 'rt.id', '=', 'rt2.id', 'diagonal'); + } + + public function testOrderByKnnCompilation() + { + $compiled = $this->createSphinxQL() + ->select('id') + ->from('rt') + ->orderByKnn('embeddings', 5, array(0.1, 0.2, 0.3)) + ->compile() + ->getCompiled(); + + $this->assertSame( + 'SELECT id FROM rt ORDER BY KNN(embeddings, 5, [0.1,0.2,0.3]) ASC', + $compiled + ); + } + + public function testOrderByKnnValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createSphinxQL() + ->select() + ->from('rt') + ->orderByKnn('embeddings', 0, array(0.1)); + } + + public function testResetJoins() + { + $compiled = $this->createSphinxQL() + ->select() + ->from('rt a') + ->join('rt b', 'a.id', '=', 'b.id') + ->resetJoins() + ->compile() + ->getCompiled(); + + $this->assertSame('SELECT * FROM rt a', $compiled); + } } diff --git a/tests/SphinxQL/TestUtil.php b/tests/SphinxQL/TestUtil.php index 5a02a87e..281b167c 100644 --- a/tests/SphinxQL/TestUtil.php +++ b/tests/SphinxQL/TestUtil.php @@ -33,6 +33,47 @@ public static function isSphinx3(ConnectionInterface $connection = null) return self::getSearchBuild($connection) === 'SPHINX3'; } + /** + * @param ConnectionInterface|null $connection + * + * @return bool + */ + public static function isManticore(ConnectionInterface $connection = null) + { + return self::getSearchBuild($connection) === 'MANTICORE'; + } + + /** + * @param ConnectionInterface $connection + * @param string $sqlProbe + * + * @return bool + */ + public static function supportsCommand(ConnectionInterface $connection, $sqlProbe) + { + try { + $connection->query($sqlProbe)->getStored(); + + return true; + } catch (\Exception $exception) { + return false; + } + } + + /** + * @param ConnectionInterface $connection + * + * @return bool + */ + public static function supportsBuddy(ConnectionInterface $connection) + { + if (!self::isManticore($connection)) { + return false; + } + + return self::supportsCommand($connection, 'SHOW VERSION'); + } + /** * @param array $rows * @param array $columns diff --git a/tests/install.sh b/tests/install.sh index 0c37e122..0ddc6e30 100755 --- a/tests/install.sh +++ b/tests/install.sh @@ -1,12 +1,22 @@ #!/bin/sh +if command -v sudo >/dev/null 2>&1; then + as_root() { + sudo "$@" + } +else + as_root() { + "$@" + } +fi + case $SEARCH_BUILD in SPHINX2) wget --quiet https://sphinxsearch.com/files/sphinx-2.3.2-beta.tar.gz tar zxvf sphinx-2.3.2-beta.tar.gz cd sphinx-2.3.2-beta ./configure --prefix=/usr/local/sphinx --without-mysql - sudo make && sudo make install + as_root make && as_root make install ;; SPHINX3) wget --quiet https://sphinxsearch.com/files/sphinx-3.9.1-141d2ea-linux-amd64.tar.gz From 891e2d9270e9674ffa7407c8a21d6f055ecb5623 Mon Sep 17 00:00:00 2001 From: woxxy Date: Fri, 27 Feb 2026 22:58:23 +0000 Subject: [PATCH 06/16] feat: add capability discovery and feature-gated helper APIs --- CHANGELOG.md | 1 + MIGRATING-4.0.md | 3 + README.md | 12 + docs/helper.rst | 6 + docs/query-builder.rst | 8 + src/Capabilities.php | 83 +++++++ src/Exception/UnsupportedFeatureException.php | 9 + src/Helper.php | 209 ++++++++++++++++++ src/SphinxQL.php | 32 +++ tests/SphinxQL/HelperTest.php | 70 +++++- tests/SphinxQL/SphinxQLTest.php | 27 +++ 11 files changed, 454 insertions(+), 6 deletions(-) create mode 100644 src/Capabilities.php create mode 100644 src/Exception/UnsupportedFeatureException.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c2d4ae..71c71219 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Expanded helper runtime API coverage (`SHOW WARNINGS`, `SHOW STATUS`, `SHOW INDEX STATUS`, `FLUSH RAMCHUNK`, `FLUSH RTINDEX`, `OPTIMIZE INDEX`, UDF lifecycle checks) * Added fluent boolean grouping APIs (`orWhere`, `whereOpen/whereClose`, `orHaving`, `havingOpen/havingClose`) and JOIN builders (`join`, `innerJoin`, `leftJoin`, `rightJoin`, `crossJoin`) * Added `orderByKnn()` and broader helper wrappers for operational and Manticore-oriented commands (`SHOW PROFILE/PLAN/THREADS/VERSION/PLUGINS`, table status/settings/indexes, flush/reload/kill, suggest family) +* Added capability discovery and feature-gating APIs (`Capabilities`, `getCapabilities()`, `supports()`, `requireSupport()`) with `UnsupportedFeatureException` for unsupported command families * Added capability-aware runtime tests for optional engine features (`supportsCommand`, Buddy-gated checks) * Added and stabilized Sphinx 3 compatibility coverage while preserving Sphinx 2 and Manticore test behavior * Migrated CI to GitHub Actions-only validation with strict composer metadata checks diff --git a/MIGRATING-4.0.md b/MIGRATING-4.0.md index eb9a9942..fa23616d 100644 --- a/MIGRATING-4.0.md +++ b/MIGRATING-4.0.md @@ -43,6 +43,8 @@ Helper methods now validate required identifiers and argument shapes: - `setVariable()` validates variable names and array values - `callSnippets()` and `callKeywords()` validate required arguments - `createFunction()` validates return type (`INT`, `UINT`, `BIGINT`, `FLOAT`, `STRING`) +- capability-aware APIs are available via `getCapabilities()`/`supports()` +- feature-gated methods may throw `UnsupportedFeatureException` when unsupported ### Percolate strict validation @@ -67,3 +69,4 @@ message fragments or exception classes. 2. Replace implicit coercions with explicit typing/casting in your app layer. 3. Prefer exception-class checks over exact message equality. 4. Run your integration tests against your target engine (`Sphinx2`, `Sphinx3`, `Manticore`). +5. Prefer `supports($feature)` checks before engine-specific helper calls. diff --git a/README.md b/README.md index 4d7fd409..1e75bc43 100755 --- a/README.md +++ b/README.md @@ -440,6 +440,18 @@ Remember to `->execute()` to get a result. Takes the pairs from a SHOW command and returns an associative array key=>value +* __$helper->getCapabilities()__ + + Returns a `Capabilities` object with detected engine/version and feature flags. + +* __$helper->supports($feature)__ + + Checks whether a named feature is supported by the current backend/runtime. + +* __$helper->requireSupport($feature, $context = '')__ + + Throws `UnsupportedFeatureException` when the requested feature is not available. + The following methods return a prepared `SphinxQL` object. You can also use `->enqueue($next_object)`: ```php diff --git a/docs/helper.rst b/docs/helper.rst index ddb60ad8..d07bc45b 100644 --- a/docs/helper.rst +++ b/docs/helper.rst @@ -57,9 +57,15 @@ Available Methods - ``flushLogs()`` - ``reloadPlugins()`` - ``kill($queryId)`` +- ``getCapabilities()`` +- ``supports($feature)`` +- ``requireSupport($feature, $context = '')`` Validation Notes ---------------- In 4.0, helper methods validate required identifiers and input shapes and throw ``SphinxQLException`` on invalid arguments. + +Feature-gated helper methods may throw ``UnsupportedFeatureException`` when the +current engine/runtime does not support that command family. diff --git a/docs/query-builder.rst b/docs/query-builder.rst index 69136a5e..911b8f5a 100644 --- a/docs/query-builder.rst +++ b/docs/query-builder.rst @@ -83,3 +83,11 @@ JOIN and KNN Ordering Vector-oriented ordering is available through: - ``orderByKnn($field, $k, array $vector, $direction = 'ASC')`` + +Capability Introspection +------------------------ + +``SphinxQL`` exposes runtime capability helpers for connection-aware behavior: + +- ``getCapabilities()`` +- ``supports($feature)`` diff --git a/src/Capabilities.php b/src/Capabilities.php new file mode 100644 index 00000000..c0940516 --- /dev/null +++ b/src/Capabilities.php @@ -0,0 +1,83 @@ + + */ + private $features; + + /** + * @param string $engine + * @param string $version + * @param array $features + */ + public function __construct($engine, $version, array $features) + { + $this->engine = strtoupper((string) $engine); + $this->version = (string) $version; + $this->features = $features; + } + + /** + * @return string + */ + public function getEngine() + { + return $this->engine; + } + + /** + * @return string + */ + public function getVersion() + { + return $this->version; + } + + /** + * @return array + */ + public function getFeatures() + { + return $this->features; + } + + /** + * @param string $feature + * + * @return bool + */ + public function supports($feature) + { + return !empty($this->features[$feature]); + } + + /** + * @return array + */ + public function toArray() + { + return array( + 'engine' => $this->engine, + 'version' => $this->version, + 'features' => $this->features, + ); + } +} + diff --git a/src/Exception/UnsupportedFeatureException.php b/src/Exception/UnsupportedFeatureException.php new file mode 100644 index 00000000..ccb066b7 --- /dev/null +++ b/src/Exception/UnsupportedFeatureException.php @@ -0,0 +1,9 @@ + + */ + protected $feature_support_cache = array(); + /** * @param ConnectionInterface $connection */ @@ -199,6 +210,136 @@ public function showVariables() return $this->query('SHOW VARIABLES'); } + /** + * Returns detected runtime capabilities. + * + * @return Capabilities + */ + public function getCapabilities() + { + if ($this->capabilities !== null) { + return $this->capabilities; + } + + $version = $this->detectVersionString(); + $engine = $this->detectEngine($version); + + $features = array( + // Builder-level features are available in this library regardless of backend. + 'grouped_where' => true, + 'grouped_having' => true, + 'joins' => true, + 'order_by_knn_builder' => true, + + // Engine/runtime-facing features. + 'manticore' => ($engine === 'MANTICORE'), + 'sphinx2' => ($engine === 'SPHINX2'), + 'sphinx3' => ($engine === 'SPHINX3'), + 'buddy' => ($engine === 'MANTICORE' && $this->supportsCommand('SHOW VERSION')), + 'call_qsuggest' => ($engine === 'MANTICORE'), + 'call_autocomplete' => ($engine === 'MANTICORE'), + ); + + $this->feature_support_cache = $features; + $this->capabilities = new Capabilities($engine, $version, $features); + + return $this->capabilities; + } + + /** + * Checks whether a named feature is supported. + * + * @param string $feature + * + * @return bool + * @throws SphinxQLException + */ + public function supports($feature) + { + if (!is_string($feature) || trim($feature) === '') { + throw new SphinxQLException('supports() feature must be a non-empty string.'); + } + + $normalized = $this->normalizeFeatureName($feature); + $known = array( + 'grouped_where', + 'grouped_having', + 'joins', + 'order_by_knn_builder', + 'manticore', + 'sphinx2', + 'sphinx3', + 'buddy', + 'show_profile', + 'show_plan', + 'show_threads', + 'show_plugins', + 'show_queries', + 'show_table_settings', + 'show_table_indexes', + 'call_suggest', + 'call_qsuggest', + 'call_autocomplete', + ); + + if (!in_array($normalized, $known, true)) { + throw new SphinxQLException('Unknown feature "'.$feature.'".'); + } + + if (!array_key_exists($normalized, $this->feature_support_cache)) { + $this->getCapabilities(); + } + + if (!array_key_exists($normalized, $this->feature_support_cache)) { + $probes = array( + 'show_profile' => 'SHOW PROFILE', + 'show_plan' => 'SHOW PLAN', + 'show_threads' => 'SHOW THREADS', + 'show_plugins' => 'SHOW PLUGINS', + 'show_queries' => 'SHOW QUERIES', + 'show_table_settings' => 'SHOW TABLE rt SETTINGS', + 'show_table_indexes' => 'SHOW TABLE rt INDEXES', + 'call_suggest' => "CALL SUGGEST('teh', 'rt')", + ); + + if (array_key_exists($normalized, $probes)) { + $this->feature_support_cache[$normalized] = $this->supportsCommand($probes[$normalized]); + } else { + $this->feature_support_cache[$normalized] = false; + } + + $this->capabilities = new Capabilities( + $this->capabilities->getEngine(), + $this->capabilities->getVersion(), + $this->feature_support_cache + ); + } + + return !empty($this->feature_support_cache[$normalized]); + } + + /** + * Throws when a named feature is not supported. + * + * @param string $feature + * @param string $context + * + * @return self + * @throws UnsupportedFeatureException + */ + public function requireSupport($feature, $context = '') + { + if (!$this->supports($feature)) { + $caps = $this->getCapabilities(); + $prefix = $context !== '' ? $context.' ' : ''; + throw new UnsupportedFeatureException( + $prefix.'requires feature "'.$feature.'" (engine='.$caps->getEngine().', version='.$caps->getVersion().').' + ); + } + + return $this; + } + /** * Runs query: SHOW CREATE TABLE * @@ -412,6 +553,7 @@ public function callKeywords($text, $index, $hits = null) */ public function callQSuggest($text, $index, array $options = array()) { + $this->requireSupport('call_qsuggest', 'callQSuggest()'); $this->assertNonEmptyString($text, 'callQSuggest() text'); $this->assertNonEmptyString($index, 'callQSuggest() index'); @@ -446,6 +588,7 @@ public function callSuggest($text, $index, array $options = array()) */ public function callAutocomplete($text, $index, array $options = array()) { + $this->requireSupport('call_autocomplete', 'callAutocomplete()'); $this->assertNonEmptyString($text, 'callAutocomplete() text'); $this->assertNonEmptyString($index, 'callAutocomplete() index'); @@ -688,4 +831,70 @@ private function buildCallWithOptions($callName, array $requiredArgs, array $opt return 'CALL '.$callName.'('.$args.')'; } + + /** + * @return string + */ + private function detectVersionString() + { + try { + $rows = $this->connection->query('SELECT VERSION()')->getStored(); + $firstRow = isset($rows[0]) ? $rows[0] : array(); + + return (string) reset($firstRow); + } catch (\Exception $exception) { + return 'unknown'; + } + } + + /** + * @param string $version + * + * @return string + */ + private function detectEngine($version) + { + $versionLower = strtolower((string) $version); + + if (strpos($versionLower, 'manticore') !== false) { + return 'MANTICORE'; + } + if (preg_match('/^3\./', (string) $version) || strpos($versionLower, 'sphinx 3') !== false) { + return 'SPHINX3'; + } + if (preg_match('/^2\./', (string) $version) || strpos($versionLower, 'sphinx') !== false) { + return 'SPHINX2'; + } + + return 'UNKNOWN'; + } + + /** + * @param string $feature + * + * @return string + */ + private function normalizeFeatureName($feature) + { + $normalized = strtolower(trim($feature)); + $normalized = str_replace(array('-', ' '), '_', $normalized); + + return $normalized; + } + + /** + * @param string $sqlProbe + * + * @return bool + */ + private function supportsCommand($sqlProbe) + { + try { + $this->connection->query($sqlProbe)->getStored(); + + return true; + } catch (\Exception $exception) { + return false; + } + } } diff --git a/src/SphinxQL.php b/src/SphinxQL.php index f427e601..ab647fd9 100644 --- a/src/SphinxQL.php +++ b/src/SphinxQL.php @@ -262,6 +262,38 @@ public function getConnection() return $this->connection; } + /** + * Returns detected runtime capabilities for the current connection. + * + * @return Capabilities + * @throws SphinxQLException + */ + public function getCapabilities() + { + if ($this->connection === null) { + throw new SphinxQLException('getCapabilities() requires an attached connection.'); + } + + return (new Helper($this->connection))->getCapabilities(); + } + + /** + * Checks whether a named feature is supported. + * + * @param string $feature + * + * @return bool + * @throws SphinxQLException + */ + public function supports($feature) + { + if ($this->connection === null) { + throw new SphinxQLException('supports() requires an attached connection.'); + } + + return (new Helper($this->connection))->supports($feature); + } + /** * Avoids having the expressions escaped * diff --git a/tests/SphinxQL/HelperTest.php b/tests/SphinxQL/HelperTest.php index 6599511f..0803ab4e 100644 --- a/tests/SphinxQL/HelperTest.php +++ b/tests/SphinxQL/HelperTest.php @@ -21,6 +21,17 @@ protected function setUp(): void $this->createSphinxQL()->query('TRUNCATE RTINDEX rt')->execute(); } + protected function tearDown(): void + { + if ($this->conn) { + try { + $this->conn->close(); + } catch (\Exception $exception) { + // no-op in test teardown + } + } + } + /** * @return SphinxQL */ @@ -308,11 +319,15 @@ public function testMiscellaneous() $query = $this->createHelper()->callSuggest('teh', 'rt', array('limit' => 5)); $this->assertEquals("CALL SUGGEST('teh', 'rt', 5 AS limit)", $query->compile()->getCompiled()); - $query = $this->createHelper()->callQSuggest('teh', 'rt', array('limit' => 3)); - $this->assertEquals("CALL QSUGGEST('teh', 'rt', 3 AS limit)", $query->compile()->getCompiled()); + if ($this->createHelper()->supports('call_qsuggest')) { + $query = $this->createHelper()->callQSuggest('teh', 'rt', array('limit' => 3)); + $this->assertEquals("CALL QSUGGEST('teh', 'rt', 3 AS limit)", $query->compile()->getCompiled()); + } - $query = $this->createHelper()->callAutocomplete('te', 'rt', array('fuzzy' => 1)); - $this->assertEquals("CALL AUTOCOMPLETE('te', 'rt', 1 AS fuzzy)", $query->compile()->getCompiled()); + if ($this->createHelper()->supports('call_autocomplete')) { + $query = $this->createHelper()->callAutocomplete('te', 'rt', array('fuzzy' => 1)); + $this->assertEquals("CALL AUTOCOMPLETE('te', 'rt', 1 AS fuzzy)", $query->compile()->getCompiled()); + } } public function testShowWarningsAndStatusExecution() @@ -409,6 +424,35 @@ public function testSuggestOptionValidation() $this->createHelper()->callSuggest('teh', 'rt', array('' => 1)); } + public function testCapabilitiesAndSupports() + { + $caps = $this->createHelper()->getCapabilities(); + + $this->assertInstanceOf(Foolz\SphinxQL\Capabilities::class, $caps); + $this->assertNotEmpty($caps->getEngine()); + $this->assertTrue($this->createHelper()->supports('grouped_where')); + $this->assertIsBool($this->createHelper()->supports('show_profile')); + } + + public function testSupportsUnknownFeatureValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createHelper()->supports('definitely_not_a_real_feature'); + } + + public function testRequireSupportValidation() + { + $helper = $this->createHelper(); + if ($helper->supports('call_qsuggest')) { + $this->assertSame($helper, $helper->requireSupport('call_qsuggest')); + + return; + } + + $this->expectException(Foolz\SphinxQL\Exception\UnsupportedFeatureException::class); + $helper->requireSupport('call_qsuggest', 'testRequireSupportValidation()'); + } + public function testShowVersionExecutionWhenSupported() { if (!TestUtil::supportsCommand($this->conn, 'SHOW VERSION')) { @@ -441,8 +485,15 @@ public function testSuggestExecutionWhenSupported() public function testQSuggestExecutionWhenBuddySupported() { + if (!$this->createHelper()->supports('call_qsuggest')) { + $this->expectException(Foolz\SphinxQL\Exception\UnsupportedFeatureException::class); + $this->createHelper()->callQSuggest('teh', 'rt'); + + return; + } + if (!TestUtil::supportsBuddy($this->conn) || !TestUtil::supportsCommand($this->conn, "CALL QSUGGEST('teh', 'rt')")) { - $this->markTestSkipped('CALL QSUGGEST requires Manticore Buddy support.'); + $this->markTestSkipped('CALL QSUGGEST runtime requires Manticore Buddy support.'); } $rows = $this->createHelper()->callQSuggest('teh', 'rt')->execute()->getStored(); @@ -451,8 +502,15 @@ public function testQSuggestExecutionWhenBuddySupported() public function testAutocompleteExecutionWhenBuddySupported() { + if (!$this->createHelper()->supports('call_autocomplete')) { + $this->expectException(Foolz\SphinxQL\Exception\UnsupportedFeatureException::class); + $this->createHelper()->callAutocomplete('te', 'rt'); + + return; + } + if (!TestUtil::supportsBuddy($this->conn) || !TestUtil::supportsCommand($this->conn, "CALL AUTOCOMPLETE('te', 'rt')")) { - $this->markTestSkipped('CALL AUTOCOMPLETE requires Manticore Buddy support.'); + $this->markTestSkipped('CALL AUTOCOMPLETE runtime requires Manticore Buddy support.'); } $rows = $this->createHelper()->callAutocomplete('te', 'rt')->execute()->getStored(); diff --git a/tests/SphinxQL/SphinxQLTest.php b/tests/SphinxQL/SphinxQLTest.php index d9635431..58a7cef0 100644 --- a/tests/SphinxQL/SphinxQLTest.php +++ b/tests/SphinxQL/SphinxQLTest.php @@ -39,6 +39,17 @@ public static function setUpBeforeClass(): void (new SphinxQL(self::$conn))->getConnection()->query('TRUNCATE RTINDEX rt'); } + public static function tearDownAfterClass(): void + { + if (self::$conn) { + try { + self::$conn->close(); + } catch (\Exception $exception) { + // no-op in test teardown + } + } + } + /** * @return SphinxQL */ @@ -1369,4 +1380,20 @@ public function testResetJoins() $this->assertSame('SELECT * FROM rt a', $compiled); } + + public function testSphinxQLCapabilitiesAccess() + { + $query = $this->createSphinxQL(); + $capabilities = $query->getCapabilities(); + + $this->assertInstanceOf(Foolz\SphinxQL\Capabilities::class, $capabilities); + $this->assertNotEmpty($capabilities->getEngine()); + $this->assertTrue($query->supports('grouped_where')); + } + + public function testSphinxQLSupportsUnknownFeatureValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->createSphinxQL()->supports('totally_unknown_feature'); + } } From 727da990e85e343c252772b408215a675cde46d2 Mon Sep 17 00:00:00 2001 From: woxxy Date: Fri, 27 Feb 2026 23:01:50 +0000 Subject: [PATCH 07/16] feat: expand parity matrix and capability ergonomics --- CHANGELOG.md | 3 ++ README.md | 10 ++++ docs/feature-matrix.yml | 82 +++++++++++++++++++++++++++++++++ docs/helper.rst | 2 + docs/query-builder.rst | 1 + src/Capabilities.php | 25 +++++++++- src/Helper.php | 24 ++++++++++ src/SphinxQL.php | 20 ++++++++ tests/SphinxQL/HelperTest.php | 31 +++++++++++++ tests/SphinxQL/SphinxQLTest.php | 13 ++++++ 10 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 docs/feature-matrix.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c71219..fb83cc68 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ * Added fluent boolean grouping APIs (`orWhere`, `whereOpen/whereClose`, `orHaving`, `havingOpen/havingClose`) and JOIN builders (`join`, `innerJoin`, `leftJoin`, `rightJoin`, `crossJoin`) * Added `orderByKnn()` and broader helper wrappers for operational and Manticore-oriented commands (`SHOW PROFILE/PLAN/THREADS/VERSION/PLUGINS`, table status/settings/indexes, flush/reload/kill, suggest family) * Added capability discovery and feature-gating APIs (`Capabilities`, `getCapabilities()`, `supports()`, `requireSupport()`) with `UnsupportedFeatureException` for unsupported command families +* Added `SphinxQL::requireSupport()` passthrough and convenience engine predicates on `Capabilities` (`isManticore`, `isSphinx2`, `isSphinx3`) +* Added helper parity wrappers for `SHOW CHARACTER SET` and `SHOW COLLATION` +* Added `docs/feature-matrix.yml` as a feature-level support map across Sphinx2/Sphinx3/Manticore * Added capability-aware runtime tests for optional engine features (`supportsCommand`, Buddy-gated checks) * Added and stabilized Sphinx 3 compatibility coverage while preserving Sphinx 2 and Manticore test behavior * Migrated CI to GitHub Actions-only validation with strict composer metadata checks diff --git a/README.md b/README.md index 1e75bc43..eae8a4cc 100755 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ We support the following database connection drivers: | Sphinx 3.x | Supported | Supported with engine-specific assertions | Full CI lane | | Manticore | Supported | Supported + Percolate | Full CI lane | +Detailed feature-level support is tracked in [`docs/feature-matrix.yml`](docs/feature-matrix.yml). + ### Migration to 4.0 See [`MIGRATING-4.0.md`](MIGRATING-4.0.md) for the complete migration checklist, @@ -452,6 +454,12 @@ Remember to `->execute()` to get a result. Throws `UnsupportedFeatureException` when the requested feature is not available. +`SphinxQL` also exposes capability helpers: + +* __$sphinxql->getCapabilities()__ +* __$sphinxql->supports($feature)__ +* __$sphinxql->requireSupport($feature, $context = '')__ + The following methods return a prepared `SphinxQL` object. You can also use `->enqueue($next_object)`: ```php @@ -481,6 +489,8 @@ $result = (new SphinxQL($this->conn)) * `(new Helper($conn))->showAgentStatus() => 'SHOW AGENT STATUS'` * `(new Helper($conn))->showScroll() => 'SHOW SCROLL'` * `(new Helper($conn))->showDatabases() => 'SHOW DATABASES'` +* `(new Helper($conn))->showCharacterSet() => 'SHOW CHARACTER SET'` +* `(new Helper($conn))->showCollation() => 'SHOW COLLATION'` * `(new Helper($conn))->showTables() => 'SHOW TABLES'` * `(new Helper($conn))->showVariables() => 'SHOW VARIABLES'` * `(new Helper($conn))->showCreateTable($table)` diff --git a/docs/feature-matrix.yml b/docs/feature-matrix.yml new file mode 100644 index 00000000..29f944e7 --- /dev/null +++ b/docs/feature-matrix.yml @@ -0,0 +1,82 @@ +version: 1 +updated_at: "2026-02-27" +notes: + - "supported means covered by runtime tests in at least one CI lane." + - "conditional means feature depends on engine/runtime capability checks." + +builder: + grouped_where: + api: ["where", "orWhere", "whereOpen", "orWhereOpen", "whereClose"] + sphinx2: supported + sphinx3: supported + manticore: supported + grouped_having: + api: ["having", "orHaving", "havingOpen", "orHavingOpen", "havingClose"] + sphinx2: supported + sphinx3: supported + manticore: supported + joins: + api: ["join", "innerJoin", "leftJoin", "rightJoin", "crossJoin", "resetJoins"] + sphinx2: supported + sphinx3: supported + manticore: supported + order_by_knn_builder: + api: ["orderByKnn"] + sphinx2: supported + sphinx3: supported + manticore: supported + notes: + - "builder-level SQL generation is supported; backend execution support depends on engine/runtime." + +helper: + core_show: + api: + - "showMeta" + - "showWarnings" + - "showStatus" + - "showTables" + - "showVariables" + - "showDatabases" + - "showCharacterSet" + - "showCollation" + sphinx2: supported + sphinx3: supported + manticore: supported + diagnostic_show: + api: ["showProfile", "showPlan", "showThreads", "showPlugins", "showQueries"] + sphinx2: conditional + sphinx3: conditional + manticore: conditional + table_introspection: + api: ["showCreateTable", "showTableStatus", "showTableSettings", "showTableIndexes"] + sphinx2: conditional + sphinx3: conditional + manticore: conditional + maintenance: + api: + - "flushRtIndex" + - "truncateRtIndex" + - "optimizeIndex" + - "showIndexStatus" + - "flushRamchunk" + - "flushAttributes" + - "flushHostnames" + - "flushLogs" + - "reloadPlugins" + - "kill" + sphinx2: conditional + sphinx3: conditional + manticore: conditional + suggest_family: + api: ["callSuggest", "callQSuggest", "callAutocomplete"] + sphinx2: conditional + sphinx3: conditional + manticore: conditional + notes: + - "callQSuggest/callAutocomplete are feature-gated and throw UnsupportedFeatureException when unavailable." + +capabilities: + api: ["Helper.getCapabilities", "Helper.supports", "Helper.requireSupport", "SphinxQL.getCapabilities", "SphinxQL.supports"] + sphinx2: supported + sphinx3: supported + manticore: supported diff --git a/docs/helper.rst b/docs/helper.rst index d07bc45b..f2570a76 100644 --- a/docs/helper.rst +++ b/docs/helper.rst @@ -30,6 +30,8 @@ Available Methods - ``showAgentStatus()`` - ``showScroll()`` - ``showDatabases()`` +- ``showCharacterSet()`` +- ``showCollation()`` - ``showTables($index)`` - ``showVariables()`` - ``showCreateTable($table)`` diff --git a/docs/query-builder.rst b/docs/query-builder.rst index 911b8f5a..c0bb35b3 100644 --- a/docs/query-builder.rst +++ b/docs/query-builder.rst @@ -91,3 +91,4 @@ Capability Introspection - ``getCapabilities()`` - ``supports($feature)`` +- ``requireSupport($feature, $context = '')`` diff --git a/src/Capabilities.php b/src/Capabilities.php index c0940516..28fa2016 100644 --- a/src/Capabilities.php +++ b/src/Capabilities.php @@ -58,6 +58,30 @@ public function getFeatures() return $this->features; } + /** + * @return bool + */ + public function isManticore() + { + return $this->engine === 'MANTICORE'; + } + + /** + * @return bool + */ + public function isSphinx2() + { + return $this->engine === 'SPHINX2'; + } + + /** + * @return bool + */ + public function isSphinx3() + { + return $this->engine === 'SPHINX3'; + } + /** * @param string $feature * @@ -80,4 +104,3 @@ public function toArray() ); } } - diff --git a/src/Helper.php b/src/Helper.php index 8464df84..621a2a99 100644 --- a/src/Helper.php +++ b/src/Helper.php @@ -186,6 +186,26 @@ public function showDatabases() return $this->query('SHOW DATABASES'); } + /** + * Runs query: SHOW CHARACTER SET + * + * @return SphinxQL + */ + public function showCharacterSet() + { + return $this->query('SHOW CHARACTER SET'); + } + + /** + * Runs query: SHOW COLLATION + * + * @return SphinxQL + */ + public function showCollation() + { + return $this->query('SHOW COLLATION'); + } + /** * Runs query: SHOW TABLES * @@ -275,6 +295,8 @@ public function supports($feature) 'show_threads', 'show_plugins', 'show_queries', + 'show_character_set', + 'show_collation', 'show_table_settings', 'show_table_indexes', 'call_suggest', @@ -297,6 +319,8 @@ public function supports($feature) 'show_threads' => 'SHOW THREADS', 'show_plugins' => 'SHOW PLUGINS', 'show_queries' => 'SHOW QUERIES', + 'show_character_set' => 'SHOW CHARACTER SET', + 'show_collation' => 'SHOW COLLATION', 'show_table_settings' => 'SHOW TABLE rt SETTINGS', 'show_table_indexes' => 'SHOW TABLE rt INDEXES', 'call_suggest' => "CALL SUGGEST('teh', 'rt')", diff --git a/src/SphinxQL.php b/src/SphinxQL.php index ab647fd9..040bdcce 100644 --- a/src/SphinxQL.php +++ b/src/SphinxQL.php @@ -294,6 +294,26 @@ public function supports($feature) return (new Helper($this->connection))->supports($feature); } + /** + * Throws when a named feature is not supported. + * + * @param string $feature + * @param string $context + * + * @return self + * @throws SphinxQLException + */ + public function requireSupport($feature, $context = '') + { + if ($this->connection === null) { + throw new SphinxQLException('requireSupport() requires an attached connection.'); + } + + (new Helper($this->connection))->requireSupport($feature, $context); + + return $this; + } + /** * Avoids having the expressions escaped * diff --git a/tests/SphinxQL/HelperTest.php b/tests/SphinxQL/HelperTest.php index 0803ab4e..8e8431cd 100644 --- a/tests/SphinxQL/HelperTest.php +++ b/tests/SphinxQL/HelperTest.php @@ -283,6 +283,12 @@ public function testMiscellaneous() $query = $this->createHelper()->showDatabases(); $this->assertEquals('SHOW DATABASES', $query->compile()->getCompiled()); + $query = $this->createHelper()->showCharacterSet(); + $this->assertEquals('SHOW CHARACTER SET', $query->compile()->getCompiled()); + + $query = $this->createHelper()->showCollation(); + $this->assertEquals('SHOW COLLATION', $query->compile()->getCompiled()); + $query = $this->createHelper()->showCreateTable('rt'); $this->assertEquals('SHOW CREATE TABLE rt', $query->compile()->getCompiled()); @@ -432,6 +438,11 @@ public function testCapabilitiesAndSupports() $this->assertNotEmpty($caps->getEngine()); $this->assertTrue($this->createHelper()->supports('grouped_where')); $this->assertIsBool($this->createHelper()->supports('show_profile')); + $this->assertIsBool($this->createHelper()->supports('show_character_set')); + $this->assertIsBool($this->createHelper()->supports('show_collation')); + $this->assertSame($caps->isManticore(), $caps->getEngine() === 'MANTICORE'); + $this->assertSame($caps->isSphinx2(), $caps->getEngine() === 'SPHINX2'); + $this->assertSame($caps->isSphinx3(), $caps->getEngine() === 'SPHINX3'); } public function testSupportsUnknownFeatureValidation() @@ -473,6 +484,26 @@ public function testShowPluginsExecutionWhenSupported() $this->assertIsArray($rows); } + public function testShowCharacterSetExecutionWhenSupported() + { + if (!TestUtil::supportsCommand($this->conn, 'SHOW CHARACTER SET')) { + $this->markTestSkipped('SHOW CHARACTER SET is not supported by this engine.'); + } + + $rows = $this->createHelper()->showCharacterSet()->execute()->getStored(); + $this->assertIsArray($rows); + } + + public function testShowCollationExecutionWhenSupported() + { + if (!TestUtil::supportsCommand($this->conn, 'SHOW COLLATION')) { + $this->markTestSkipped('SHOW COLLATION is not supported by this engine.'); + } + + $rows = $this->createHelper()->showCollation()->execute()->getStored(); + $this->assertIsArray($rows); + } + public function testSuggestExecutionWhenSupported() { if (!TestUtil::supportsCommand($this->conn, "CALL SUGGEST('teh', 'rt')")) { diff --git a/tests/SphinxQL/SphinxQLTest.php b/tests/SphinxQL/SphinxQLTest.php index 58a7cef0..39f86071 100644 --- a/tests/SphinxQL/SphinxQLTest.php +++ b/tests/SphinxQL/SphinxQLTest.php @@ -1391,6 +1391,19 @@ public function testSphinxQLCapabilitiesAccess() $this->assertTrue($query->supports('grouped_where')); } + public function testSphinxQLRequireSupport() + { + $query = $this->createSphinxQL(); + if ($query->supports('call_qsuggest')) { + $this->assertSame($query, $query->requireSupport('call_qsuggest')); + + return; + } + + $this->expectException(Foolz\SphinxQL\Exception\UnsupportedFeatureException::class); + $query->requireSupport('call_qsuggest'); + } + public function testSphinxQLSupportsUnknownFeatureValidation() { $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); From 0e585c949f6ae52212c98d3e11208e24939a87b6 Mon Sep 17 00:00:00 2001 From: woxxy Date: Fri, 27 Feb 2026 23:29:39 +0000 Subject: [PATCH 08/16] feat: harden helper option contracts and add coverage artifacts --- .github/workflows/ci.yml | 78 ++++++++- README.md | 13 +- docs/feature-matrix.yml | 7 + docs/helper.rst | 28 +++- docs/query-builder.rst | 25 +++ src/Helper.php | 286 +++++++++++++++++++++++++++++++- tests/SphinxQL/HelperTest.php | 143 +++++++++++++++- tests/SphinxQL/SphinxQLTest.php | 3 + 8 files changed, 569 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c5ce807..5465e3a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,12 +27,14 @@ jobs: env: DRIVER: ${{ matrix.driver }} SEARCH_BUILD: ${{ matrix.search_build }} + COVERAGE_LANE: ${{ matrix.php == '8.3' && matrix.driver == 'pdo' && matrix.search_build == 'MANTICORE' }} steps: - name: Checkout uses: actions/checkout@v4 - name: Setup PHP + if: env.COVERAGE_LANE != 'true' uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} @@ -40,6 +42,15 @@ jobs: extensions: mysqli, pdo_mysql, mbstring tools: composer:v2 + - name: Setup PHP (coverage lane) + if: env.COVERAGE_LANE == 'true' + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + extensions: mysqli, pdo_mysql, mbstring + tools: composer:v2 + - name: Resolve search image and group exclusions id: vars run: | @@ -111,7 +122,72 @@ jobs: - name: Run tests run: | - ./vendor/bin/phpunit --configuration "tests/travis/${DRIVER}.phpunit.xml" --coverage-text ${{ steps.vars.outputs.exclude_group }} + if [ "${COVERAGE_LANE}" = "true" ]; then + mkdir -p coverage + ./vendor/bin/phpunit --configuration "tests/travis/${DRIVER}.phpunit.xml" --coverage-text --coverage-clover coverage/clover.xml ${{ steps.vars.outputs.exclude_group }} + else + ./vendor/bin/phpunit --configuration "tests/travis/${DRIVER}.phpunit.xml" --coverage-text ${{ steps.vars.outputs.exclude_group }} + fi + + - name: Build changed-line coverage artifact + if: always() && env.COVERAGE_LANE == 'true' + continue-on-error: true + run: | + mkdir -p coverage-artifact + + BASE_SHA="" + BASE_LABEL="" + + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + BASE_LABEL="PR base (${BASE_SHA})" + git fetch --no-tags --depth=1 origin "${BASE_SHA}" || true + else + BASE_SHA="${{ github.event.before }}" + if [ -z "${BASE_SHA}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then + git fetch --no-tags --depth=2 origin master || true + BASE_SHA="origin/master~1" + BASE_LABEL="fallback (${BASE_SHA})" + else + BASE_LABEL="push before (${BASE_SHA})" + git fetch --no-tags --depth=1 origin "${BASE_SHA}" || true + fi + fi + + git diff --unified=0 "${BASE_SHA}"...HEAD > coverage-artifact/changed-lines.patch || true + git diff --name-only "${BASE_SHA}"...HEAD > coverage-artifact/changed-files.txt || true + + ADDED_LINES="$(git diff --numstat "${BASE_SHA}"...HEAD | awk '{a+=$1} END {print a+0}')" + REMOVED_LINES="$(git diff --numstat "${BASE_SHA}"...HEAD | awk '{d+=$2} END {print d+0}')" + CHANGED_FILES="$(wc -l < coverage-artifact/changed-files.txt | tr -d ' ')" + + { + echo "Changed-line coverage artifact summary" + echo "Event: ${{ github.event_name }}" + echo "Base: ${BASE_LABEL}" + echo "Head: $(git rev-parse HEAD)" + echo "Changed files: ${CHANGED_FILES}" + echo "Added lines: ${ADDED_LINES}" + echo "Removed lines: ${REMOVED_LINES}" + if [ -f coverage/clover.xml ]; then + echo "Clover XML: coverage/clover.xml" + else + echo "Clover XML: missing" + fi + } > coverage-artifact/summary.txt + + - name: Upload changed-line coverage artifact + if: always() && env.COVERAGE_LANE == 'true' + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: changed-line-coverage-php${{ matrix.php }}-${{ matrix.driver }}-${{ matrix.search_build }} + if-no-files-found: warn + path: | + coverage/clover.xml + coverage-artifact/summary.txt + coverage-artifact/changed-lines.patch + coverage-artifact/changed-files.txt - name: Upload debug artifacts on failure if: failure() diff --git a/README.md b/README.md index eae8a4cc..766a60c8 100755 --- a/README.md +++ b/README.md @@ -491,10 +491,10 @@ $result = (new SphinxQL($this->conn)) * `(new Helper($conn))->showDatabases() => 'SHOW DATABASES'` * `(new Helper($conn))->showCharacterSet() => 'SHOW CHARACTER SET'` * `(new Helper($conn))->showCollation() => 'SHOW COLLATION'` -* `(new Helper($conn))->showTables() => 'SHOW TABLES'` +* `(new Helper($conn))->showTables($index) => 'SHOW TABLES LIKE '` * `(new Helper($conn))->showVariables() => 'SHOW VARIABLES'` * `(new Helper($conn))->showCreateTable($table)` -* `(new Helper($conn))->showTableStatus($table = null)` +* `(new Helper($conn))->showTableStatus($table = null) => 'SHOW TABLE STATUS' or 'SHOW TABLE STATUS'` * `(new Helper($conn))->showTableSettings($table)` * `(new Helper($conn))->showTableIndexes($table)` * `(new Helper($conn))->showQueries()` @@ -518,6 +518,15 @@ $result = (new SphinxQL($this->conn)) * `(new Helper($conn))->reloadPlugins()` * `(new Helper($conn))->kill($queryId)` +Suggest-family option contract and capability behavior: + +* `callSuggest()`, `callQSuggest()`, and `callAutocomplete()` accept `$options` as an associative array. +* Option keys must be non-empty strings. Each option is compiled as ` AS `. +* Option values are quoted by the active connection (`quote()`/`quoteArr()`), covering scalar values, `null`, `Expression`, and arrays. +* Repository-tested option keys are `limit` (numeric) and `fuzzy` (numeric, `callAutocomplete()`). +* `callQSuggest()` and `callAutocomplete()` are feature-gated and throw `UnsupportedFeatureException` when unavailable. +* `callSuggest()` is runtime-conditional by backend support; use `$helper->supports('call_suggest')` for portable flows. + ### Percolate The `Percolate` class provides methods for the "Percolate query" feature of Manticore Search. For more information about percolate queries refer the [Percolate Query](https://docs.manticoresearch.com/latest/html/searching/percolate_query.html) documentation. diff --git a/docs/feature-matrix.yml b/docs/feature-matrix.yml index 29f944e7..0aaa901d 100644 --- a/docs/feature-matrix.yml +++ b/docs/feature-matrix.yml @@ -42,6 +42,8 @@ helper: sphinx2: supported sphinx3: supported manticore: supported + notes: + - "showTables($index) compiles to SHOW TABLES LIKE ; index must be a non-empty string." diagnostic_show: api: ["showProfile", "showPlan", "showThreads", "showPlugins", "showQueries"] sphinx2: conditional @@ -52,6 +54,8 @@ helper: sphinx2: conditional sphinx3: conditional manticore: conditional + notes: + - "showTableStatus() supports an optional filter: null => SHOW TABLE STATUS, non-empty table => SHOW TABLE
STATUS." maintenance: api: - "flushRtIndex" @@ -74,6 +78,9 @@ helper: manticore: conditional notes: - "callQSuggest/callAutocomplete are feature-gated and throw UnsupportedFeatureException when unavailable." + - "callSuggest options, callQSuggest options, and callAutocomplete options require non-empty string keys (compiled as ' AS ')." + - "Option values are quoted through the active connection driver; value types follow connection quote()/quoteArr() behavior." + - "Repository-tested option keys: limit (numeric) for suggest/qsuggest, fuzzy (numeric) for autocomplete." capabilities: api: ["Helper.getCapabilities", "Helper.supports", "Helper.requireSupport", "SphinxQL.getCapabilities", "SphinxQL.supports"] diff --git a/docs/helper.rst b/docs/helper.rst index f2570a76..40513598 100644 --- a/docs/helper.rst +++ b/docs/helper.rst @@ -63,11 +63,35 @@ Available Methods - ``supports($feature)`` - ``requireSupport($feature, $context = '')`` +Filtered SHOW Wrappers +---------------------- + +- ``showTables($index)`` compiles to ``SHOW TABLES LIKE ``. +- ``showTableStatus($table = null)`` compiles to: + + - ``SHOW TABLE STATUS`` when ``$table`` is ``null`` + - ``SHOW TABLE
STATUS`` when ``$table`` is a non-empty string + +Suggest-Family Option Contract +------------------------------ + +For ``callSuggest()``, ``callQSuggest()``, and ``callAutocomplete()``: + +- ``$options`` must be an associative array. +- Option keys must be non-empty strings; each option is compiled as + `` AS ``. +- Option values are quoted via the active connection driver + (``quote()``/``quoteArr()``), which supports scalar values, ``null``, + ``Expression``, and arrays. +- Repository-tested option keys are ``limit`` (numeric) and ``fuzzy`` + (numeric, autocomplete). + Validation Notes ---------------- In 4.0, helper methods validate required identifiers and input shapes and throw ``SphinxQLException`` on invalid arguments. -Feature-gated helper methods may throw ``UnsupportedFeatureException`` when the -current engine/runtime does not support that command family. +``callQSuggest()`` and ``callAutocomplete()`` are feature-gated and may throw +``UnsupportedFeatureException`` when unsupported. ``callSuggest()`` is not +pre-gated; use ``supports('call_suggest')`` when runtime portability is needed. diff --git a/docs/query-builder.rst b/docs/query-builder.rst index c0bb35b3..58811e5c 100644 --- a/docs/query-builder.rst +++ b/docs/query-builder.rst @@ -92,3 +92,28 @@ Capability Introspection - ``getCapabilities()`` - ``supports($feature)`` - ``requireSupport($feature, $context = '')`` + +Helper Capability-Aware Calls +----------------------------- + +The same capability model is used by ``Helper`` wrappers: + +- filtered ``SHOW`` wrappers: + + - ``showTables($index)`` => ``SHOW TABLES LIKE `` + - ``showTableStatus($table = null)`` => ``SHOW TABLE STATUS`` or ``SHOW TABLE
STATUS`` + +- suggest-family option contract (``callSuggest()``, ``callQSuggest()``, + ``callAutocomplete()``): + + - options must be an associative array + - option keys must be non-empty strings + - values are quoted by the active connection (``quote()``/``quoteArr()``) + - tested keys in this repository are ``limit`` (numeric) and ``fuzzy`` (numeric, autocomplete) + +- capability behavior: + + - ``callQSuggest()`` and ``callAutocomplete()`` are feature-gated and may throw + ``UnsupportedFeatureException`` when unsupported + - ``callSuggest()`` is runtime-conditional; use ``supports('call_suggest')`` for + portable code paths diff --git a/src/Helper.php b/src/Helper.php index 621a2a99..f18c0b2b 100644 --- a/src/Helper.php +++ b/src/Helper.php @@ -396,6 +396,22 @@ public function showTableStatus($table = null) return $this->query('SHOW TABLE '.$table.' STATUS'); } + /** + * Runs query: SHOW TABLE STATUS LIKE + * + * @param string $table + * @param string $pattern + * + * @return SphinxQL + */ + public function showTableStatusLike($table, $pattern) + { + $this->assertNonEmptyString($table, 'showTableStatusLike() table'); + $this->assertNonEmptyString($pattern, 'showTableStatusLike() pattern'); + + return $this->query('SHOW TABLE '.$table.' STATUS LIKE '.$this->connection->quote($pattern)); + } + /** * Runs query: SHOW TABLE SETTINGS * @@ -410,6 +426,22 @@ public function showTableSettings($table) return $this->query('SHOW TABLE '.$table.' SETTINGS'); } + /** + * Runs query: SHOW TABLE SETTINGS LIKE + * + * @param string $table + * @param string $pattern + * + * @return SphinxQL + */ + public function showTableSettingsLike($table, $pattern) + { + $this->assertNonEmptyString($table, 'showTableSettingsLike() table'); + $this->assertNonEmptyString($pattern, 'showTableSettingsLike() pattern'); + + return $this->query('SHOW TABLE '.$table.' SETTINGS LIKE '.$this->connection->quote($pattern)); + } + /** * Runs query: SHOW TABLE INDEXES * @@ -424,6 +456,22 @@ public function showTableIndexes($table) return $this->query('SHOW TABLE '.$table.' INDEXES'); } + /** + * Runs query: SHOW TABLE INDEXES LIKE + * + * @param string $table + * @param string $pattern + * + * @return SphinxQL + */ + public function showTableIndexesLike($table, $pattern) + { + $this->assertNonEmptyString($table, 'showTableIndexesLike() table'); + $this->assertNonEmptyString($pattern, 'showTableIndexesLike() pattern'); + + return $this->query('SHOW TABLE '.$table.' INDEXES LIKE '.$this->connection->quote($pattern)); + } + /** * Runs query: SHOW QUERIES * @@ -581,7 +629,11 @@ public function callQSuggest($text, $index, array $options = array()) $this->assertNonEmptyString($text, 'callQSuggest() text'); $this->assertNonEmptyString($index, 'callQSuggest() index'); - return $this->query($this->buildCallWithOptions('QSUGGEST', array($text, $index), $options)); + return $this->query($this->buildCallWithOptions( + 'QSUGGEST', + array($text, $index), + $this->normalizeCallOptions('callQSuggest()', 'QSUGGEST', $options) + )); } /** @@ -598,7 +650,11 @@ public function callSuggest($text, $index, array $options = array()) $this->assertNonEmptyString($text, 'callSuggest() text'); $this->assertNonEmptyString($index, 'callSuggest() index'); - return $this->query($this->buildCallWithOptions('SUGGEST', array($text, $index), $options)); + return $this->query($this->buildCallWithOptions( + 'SUGGEST', + array($text, $index), + $this->normalizeCallOptions('callSuggest()', 'SUGGEST', $options) + )); } /** @@ -616,7 +672,11 @@ public function callAutocomplete($text, $index, array $options = array()) $this->assertNonEmptyString($text, 'callAutocomplete() text'); $this->assertNonEmptyString($index, 'callAutocomplete() index'); - return $this->query($this->buildCallWithOptions('AUTOCOMPLETE', array($text, $index), $options)); + return $this->query($this->buildCallWithOptions( + 'AUTOCOMPLETE', + array($text, $index), + $this->normalizeCallOptions('callAutocomplete()', 'AUTOCOMPLETE', $options) + )); } /** @@ -745,6 +805,22 @@ public function showIndexStatus($index) return $this->query('SHOW INDEX '.$index.' STATUS'); } + /** + * SHOW INDEX STATUS LIKE syntax + * + * @param string $index + * @param string $pattern + * + * @return SphinxQL + */ + public function showIndexStatusLike($index, $pattern) + { + $this->assertNonEmptyString($index, 'showIndexStatusLike() index'); + $this->assertNonEmptyString($pattern, 'showIndexStatusLike() pattern'); + + return $this->query('SHOW INDEX '.$index.' STATUS LIKE '.$this->connection->quote($pattern)); + } + /** * FLUSH RAMCHUNK syntax * @@ -828,6 +904,210 @@ private function assertNonEmptyString($value, $field) } } + /** + * @param string $methodName + * @param string $callName + * @param array $options + * + * @return array + */ + private function normalizeCallOptions($methodName, $callName, array $options) + { + $schema = $this->getCallOptionSchema($callName); + + if ($callName === 'AUTOCOMPLETE' + && array_key_exists('fuzzy', $options) + && array_key_exists('fuzziness', $options) + ) { + throw new SphinxQLException($methodName.' options "fuzzy" and "fuzziness" cannot be used together.'); + } + + $normalized = array(); + foreach ($options as $key => $value) { + if (!is_string($key) || trim($key) === '') { + throw new SphinxQLException($methodName.' options must have non-empty string keys.'); + } + + if (!array_key_exists($key, $schema)) { + throw new SphinxQLException( + $methodName.' unknown option "'.$key.'". Allowed options: '.implode(', ', array_keys($schema)).'.' + ); + } + + $rule = $schema[$key]; + if ($rule['type'] === 'bool') { + $normalized[$key] = $this->normalizeBooleanOption($methodName, $key, $value); + continue; + } + + if ($rule['type'] === 'int') { + $normalized[$key] = $this->normalizeIntegerOption( + $methodName, + $key, + $value, + $rule['min'] ?? null, + $rule['max'] ?? null + ); + continue; + } + + if ($rule['type'] === 'string') { + $normalized[$key] = $this->normalizeStringOption( + $methodName, + $key, + $value, + $rule['allow_empty'] ?? false + ); + continue; + } + + if ($rule['type'] === 'enum_string') { + $normalized[$key] = $this->normalizeEnumStringOption( + $methodName, + $key, + $value, + $rule['allowed'] + ); + continue; + } + } + + return $normalized; + } + + /** + * @param string $callName + * + * @return array> + */ + private function getCallOptionSchema($callName) + { + if ($callName === 'SUGGEST' || $callName === 'QSUGGEST') { + return array( + 'limit' => array('type' => 'int', 'min' => 0), + 'max_edits' => array('type' => 'int', 'min' => 0), + 'result_stats' => array('type' => 'bool'), + 'delta_len' => array('type' => 'int', 'min' => 0), + 'max_matches' => array('type' => 'int', 'min' => 0), + 'reject' => array('type' => 'bool'), + 'result_line' => array('type' => 'bool'), + 'non_char' => array('type' => 'bool'), + 'sentence' => array('type' => 'bool'), + 'force_bigrams' => array('type' => 'bool'), + 'search_mode' => array('type' => 'enum_string', 'allowed' => array('phrase', 'words')), + ); + } + + if ($callName === 'AUTOCOMPLETE') { + return array( + 'layouts' => array('type' => 'string', 'allow_empty' => true), + 'fuzzy' => array('type' => 'int', 'min' => 0, 'max' => 2), + 'fuzziness' => array('type' => 'int', 'min' => 0, 'max' => 2), + 'prepend' => array('type' => 'bool'), + 'append' => array('type' => 'bool'), + 'preserve' => array('type' => 'bool'), + 'expansion_len' => array('type' => 'int', 'min' => 0), + 'force_bigrams' => array('type' => 'bool'), + ); + } + + throw new SphinxQLException('Unknown CALL option schema for "'.$callName.'".'); + } + + /** + * @param string $methodName + * @param string $option + * @param mixed $value + * + * @return int + */ + private function normalizeBooleanOption($methodName, $option, $value) + { + if (is_bool($value)) { + return $value ? 1 : 0; + } + + if (in_array($value, array(0, 1, '0', '1'), true)) { + return (int) $value; + } + + throw new SphinxQLException( + $methodName.' option "'.$option.'" must be boolean (true/false) or 0/1.' + ); + } + + /** + * @param string $methodName + * @param string $option + * @param mixed $value + * @param int|null $min + * @param int|null $max + * + * @return int + */ + private function normalizeIntegerOption($methodName, $option, $value, $min = null, $max = null) + { + $normalized = filter_var($value, FILTER_VALIDATE_INT); + if ($normalized === false) { + throw new SphinxQLException($methodName.' option "'.$option.'" must be an integer.'); + } + + $normalized = (int) $normalized; + if ($min !== null && $normalized < $min) { + throw new SphinxQLException($methodName.' option "'.$option.'" must be >= '.$min.'.'); + } + if ($max !== null && $normalized > $max) { + throw new SphinxQLException($methodName.' option "'.$option.'" must be <= '.$max.'.'); + } + + return $normalized; + } + + /** + * @param string $methodName + * @param string $option + * @param mixed $value + * @param bool $allowEmpty + * + * @return string + */ + private function normalizeStringOption($methodName, $option, $value, $allowEmpty = false) + { + if (!is_string($value)) { + throw new SphinxQLException($methodName.' option "'.$option.'" must be a string.'); + } + + if (!$allowEmpty && trim($value) === '') { + throw new SphinxQLException($methodName.' option "'.$option.'" cannot be empty.'); + } + + return $value; + } + + /** + * @param string $methodName + * @param string $option + * @param mixed $value + * @param array $allowed + * + * @return string + */ + private function normalizeEnumStringOption($methodName, $option, $value, array $allowed) + { + if (!is_string($value) || trim($value) === '') { + throw new SphinxQLException($methodName.' option "'.$option.'" must be a non-empty string.'); + } + + $normalized = strtolower(trim($value)); + if (!in_array($normalized, $allowed, true)) { + throw new SphinxQLException( + $methodName.' option "'.$option.'" must be one of: '.implode(', ', $allowed).'.' + ); + } + + return $normalized; + } + /** * @param string $callName * @param array $requiredArgs diff --git a/tests/SphinxQL/HelperTest.php b/tests/SphinxQL/HelperTest.php index 8e8431cd..3309f9bc 100644 --- a/tests/SphinxQL/HelperTest.php +++ b/tests/SphinxQL/HelperTest.php @@ -256,6 +256,9 @@ public function testMiscellaneous() $query = $this->createHelper()->showIndexStatus('rt'); $this->assertEquals('SHOW INDEX rt STATUS', $query->compile()->getCompiled()); + $query = $this->createHelper()->showIndexStatusLike('rt', 'index_type'); + $this->assertEquals("SHOW INDEX rt STATUS LIKE 'index_type'", $query->compile()->getCompiled()); + $query = $this->createHelper()->flushRamchunk('rt'); $this->assertEquals('FLUSH RAMCHUNK rt', $query->compile()->getCompiled()); @@ -298,12 +301,21 @@ public function testMiscellaneous() $query = $this->createHelper()->showTableStatus('rt'); $this->assertEquals('SHOW TABLE rt STATUS', $query->compile()->getCompiled()); + $query = $this->createHelper()->showTableStatusLike('rt', '%'); + $this->assertEquals("SHOW TABLE rt STATUS LIKE '%'", $query->compile()->getCompiled()); + $query = $this->createHelper()->showTableSettings('rt'); $this->assertEquals('SHOW TABLE rt SETTINGS', $query->compile()->getCompiled()); + $query = $this->createHelper()->showTableSettingsLike('rt', '%'); + $this->assertEquals("SHOW TABLE rt SETTINGS LIKE '%'", $query->compile()->getCompiled()); + $query = $this->createHelper()->showTableIndexes('rt'); $this->assertEquals('SHOW TABLE rt INDEXES', $query->compile()->getCompiled()); + $query = $this->createHelper()->showTableIndexesLike('rt', '%'); + $this->assertEquals("SHOW TABLE rt INDEXES LIKE '%'", $query->compile()->getCompiled()); + $query = $this->createHelper()->showQueries(); $this->assertEquals('SHOW QUERIES', $query->compile()->getCompiled()); @@ -322,17 +334,49 @@ public function testMiscellaneous() $query = $this->createHelper()->kill(123); $this->assertEquals('KILL 123', $query->compile()->getCompiled()); - $query = $this->createHelper()->callSuggest('teh', 'rt', array('limit' => 5)); - $this->assertEquals("CALL SUGGEST('teh', 'rt', 5 AS limit)", $query->compile()->getCompiled()); + $query = $this->createHelper()->callSuggest( + 'teh', + 'rt', + array( + 'limit' => '5', + 'result_stats' => true, + 'search_mode' => 'WORDS', + ) + ); + $this->assertEquals( + "CALL SUGGEST('teh', 'rt', 5 AS limit, 1 AS result_stats, 'words' AS search_mode)", + $query->compile()->getCompiled() + ); if ($this->createHelper()->supports('call_qsuggest')) { - $query = $this->createHelper()->callQSuggest('teh', 'rt', array('limit' => 3)); - $this->assertEquals("CALL QSUGGEST('teh', 'rt', 3 AS limit)", $query->compile()->getCompiled()); + $query = $this->createHelper()->callQSuggest( + 'teh', + 'rt', + array( + 'limit' => 3, + 'result_line' => false, + ) + ); + $this->assertEquals( + "CALL QSUGGEST('teh', 'rt', 3 AS limit, 0 AS result_line)", + $query->compile()->getCompiled() + ); } if ($this->createHelper()->supports('call_autocomplete')) { - $query = $this->createHelper()->callAutocomplete('te', 'rt', array('fuzzy' => 1)); - $this->assertEquals("CALL AUTOCOMPLETE('te', 'rt', 1 AS fuzzy)", $query->compile()->getCompiled()); + $query = $this->createHelper()->callAutocomplete( + 'te', + 'rt', + array( + 'fuzzy' => 1, + 'append' => true, + 'preserve' => false, + ) + ); + $this->assertEquals( + "CALL AUTOCOMPLETE('te', 'rt', 1 AS fuzzy, 1 AS append, 0 AS preserve)", + $query->compile()->getCompiled() + ); } } @@ -367,6 +411,44 @@ public function testShowIndexStatusExecution() $this->assertTrue($found); } + public function testShowIndexStatusLikeExecutionWhenSupported() + { + if (!TestUtil::supportsCommand($this->conn, "SHOW INDEX rt STATUS LIKE 'index_type'")) { + $this->markTestSkipped('SHOW INDEX ... STATUS LIKE is not supported by this engine.'); + } + + $statusRows = $this->createHelper()->showIndexStatusLike('rt', 'index_type')->execute()->getStored(); + $this->assertNotEmpty($statusRows); + $this->assertSame('index_type', (string) ($statusRows[0]['Variable_name'] ?? '')); + } + + public function testShowTableLikeVariantsExecutionWhenSupported() + { + $executed = 0; + + if (TestUtil::supportsCommand($this->conn, "SHOW TABLE rt STATUS LIKE '%'")) { + $rows = $this->createHelper()->showTableStatusLike('rt', '%')->execute()->getStored(); + $this->assertIsArray($rows); + $executed++; + } + + if (TestUtil::supportsCommand($this->conn, "SHOW TABLE rt SETTINGS LIKE '%'")) { + $rows = $this->createHelper()->showTableSettingsLike('rt', '%')->execute()->getStored(); + $this->assertIsArray($rows); + $executed++; + } + + if (TestUtil::supportsCommand($this->conn, "SHOW TABLE rt INDEXES LIKE '%'")) { + $rows = $this->createHelper()->showTableIndexesLike('rt', '%')->execute()->getStored(); + $this->assertIsArray($rows); + $executed++; + } + + if ($executed === 0) { + $this->markTestSkipped('SHOW TABLE ... LIKE variants are not supported by this engine.'); + } + } + public function testFlushAndOptimizeExecution() { $result = $this->createHelper()->flushRamchunk('rt')->execute()->getStored(); @@ -430,6 +512,46 @@ public function testSuggestOptionValidation() $this->createHelper()->callSuggest('teh', 'rt', array('' => 1)); } + public function testSuggestUnknownOptionValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->expectExceptionMessage('unknown option "unknown_key"'); + $this->createHelper()->callSuggest('teh', 'rt', array('unknown_key' => 1)); + } + + public function testSuggestOptionTypeValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->expectExceptionMessage('option "result_stats" must be boolean'); + $this->createHelper()->callSuggest('teh', 'rt', array('result_stats' => 2)); + } + + public function testSuggestOptionEnumValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->expectExceptionMessage('option "search_mode" must be one of: phrase, words.'); + $this->createHelper()->callSuggest('teh', 'rt', array('search_mode' => 'invalid')); + } + + public function testSuggestOptionRangeValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->expectExceptionMessage('option "limit" must be >= 0.'); + $this->createHelper()->callSuggest('teh', 'rt', array('limit' => -1)); + } + + public function testAutocompleteOptionValidationWhenSupported() + { + $helper = $this->createHelper(); + if (!$helper->supports('call_autocomplete')) { + $this->markTestSkipped('CALL AUTOCOMPLETE is not supported by this engine.'); + } + + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->expectExceptionMessage('options "fuzzy" and "fuzziness" cannot be used together'); + $helper->callAutocomplete('te', 'rt', array('fuzzy' => 1, 'fuzziness' => 2)); + } + public function testCapabilitiesAndSupports() { $caps = $this->createHelper()->getCapabilities(); @@ -461,6 +583,9 @@ public function testRequireSupportValidation() } $this->expectException(Foolz\SphinxQL\Exception\UnsupportedFeatureException::class); + $this->expectExceptionMessageMatches( + '/^testRequireSupportValidation\(\) requires feature "call_qsuggest" \(engine=[A-Z0-9_]+, version=.*\)\.$/' + ); $helper->requireSupport('call_qsuggest', 'testRequireSupportValidation()'); } @@ -518,6 +643,9 @@ public function testQSuggestExecutionWhenBuddySupported() { if (!$this->createHelper()->supports('call_qsuggest')) { $this->expectException(Foolz\SphinxQL\Exception\UnsupportedFeatureException::class); + $this->expectExceptionMessageMatches( + '/^callQSuggest\(\) requires feature "call_qsuggest" \(engine=[A-Z0-9_]+, version=.*\)\.$/' + ); $this->createHelper()->callQSuggest('teh', 'rt'); return; @@ -535,6 +663,9 @@ public function testAutocompleteExecutionWhenBuddySupported() { if (!$this->createHelper()->supports('call_autocomplete')) { $this->expectException(Foolz\SphinxQL\Exception\UnsupportedFeatureException::class); + $this->expectExceptionMessageMatches( + '/^callAutocomplete\(\) requires feature "call_autocomplete" \(engine=[A-Z0-9_]+, version=.*\)\.$/' + ); $this->createHelper()->callAutocomplete('te', 'rt'); return; diff --git a/tests/SphinxQL/SphinxQLTest.php b/tests/SphinxQL/SphinxQLTest.php index 39f86071..554a8fd9 100644 --- a/tests/SphinxQL/SphinxQLTest.php +++ b/tests/SphinxQL/SphinxQLTest.php @@ -1401,6 +1401,9 @@ public function testSphinxQLRequireSupport() } $this->expectException(Foolz\SphinxQL\Exception\UnsupportedFeatureException::class); + $this->expectExceptionMessageMatches( + '/^requires feature "call_qsuggest" \(engine=[A-Z0-9_]+, version=.*\)\.$/' + ); $query->requireSupport('call_qsuggest'); } From dd31561c239917eff87de441c32804bcebd43bec Mon Sep 17 00:00:00 2001 From: woxxy Date: Fri, 27 Feb 2026 23:39:13 +0000 Subject: [PATCH 09/16] ci: harden coverage artifacts and add buddy/phpstan lanes --- .github/workflows/buddy-integration.yml | 80 +++++++++++++++++++++++++ .github/workflows/ci.yml | 37 ++++++++++-- composer.json | 1 + phpstan-baseline.neon | 2 + phpstan.neon.dist | 12 ++++ 5 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/buddy-integration.yml create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon.dist diff --git a/.github/workflows/buddy-integration.yml b/.github/workflows/buddy-integration.yml new file mode 100644 index 00000000..ef17ab46 --- /dev/null +++ b/.github/workflows/buddy-integration.yml @@ -0,0 +1,80 @@ +name: Buddy Integration + +on: + workflow_dispatch: + +jobs: + buddy-runtime: + name: Buddy runtime smoke (manual) + runs-on: ubuntu-22.04 + timeout-minutes: 20 + permissions: + contents: read + packages: read + env: + SEARCH_BUILD: MANTICORE + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + coverage: none + extensions: mysqli, pdo_mysql, mbstring + tools: composer:v2 + + - name: Start Manticore with Buddy extras + run: | + docker pull manticoresearch/manticore:latest + docker run -d --name searchd \ + -e EXTRA=1 \ + -p 9307:9306 \ + -p 9312:9312 \ + manticoresearch/manticore:latest + + - name: Wait for searchd + run: | + n=0 + while [ "$n" -lt 60 ]; do + if (echo > /dev/tcp/127.0.0.1/9307) >/dev/null 2>&1; then + exit 0 + fi + n=$((n + 1)) + sleep 1 + done + echo "searchd did not become ready on 127.0.0.1:9307" + docker logs searchd || true + exit 1 + + - name: Seed runtime test table + run: | + cat > /tmp/seed-buddy-rt.php <<'PHP' + setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec('CREATE TABLE IF NOT EXISTS rt(title text, content text, gid uint)'); + $pdo->exec('TRUNCATE TABLE rt'); + $pdo->exec("REPLACE INTO rt(id, title, content, gid) VALUES (1, 'test', 'teh words', 1)"); + PHP + php /tmp/seed-buddy-rt.php + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run Buddy-specific runtime tests (must not skip) + run: | + ./vendor/bin/phpunit \ + --configuration tests/travis/pdo.phpunit.xml \ + --filter '/(testQSuggestExecutionWhenBuddySupported|testAutocompleteExecutionWhenBuddySupported)/' \ + --fail-on-skipped + + - name: Show searchd logs + if: always() + run: docker logs searchd || true + + - name: Stop searchd container + if: always() + run: docker rm -f searchd || true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5465e3a3..5f25ac17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,13 @@ jobs: - name: Prepare autoload run: composer dump-autoload + - name: Run PHPStan (non-blocking) + if: env.COVERAGE_LANE == 'true' + continue-on-error: true + run: | + mkdir -p static-analysis + ./vendor/bin/phpstan analyse --configuration phpstan.neon.dist --memory-limit=1G --no-progress --error-format=raw > static-analysis/phpstan.txt + - name: Run tests run: | if [ "${COVERAGE_LANE}" = "true" ]; then @@ -140,14 +147,33 @@ jobs: if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_SHA="${{ github.event.pull_request.base.sha }}" + BASE_REF="${{ github.event.pull_request.base.ref }}" BASE_LABEL="PR base (${BASE_SHA})" - git fetch --no-tags --depth=1 origin "${BASE_SHA}" || true + git fetch --no-tags --depth=1 origin "${BASE_REF}" || true + + if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + if git cat-file -e "origin/${BASE_REF}^{commit}" 2>/dev/null; then + BASE_SHA="origin/${BASE_REF}" + BASE_LABEL="PR base ref (${BASE_SHA})" + else + BASE_SHA="$(git rev-parse HEAD~1)" + BASE_LABEL="fallback (HEAD~1)" + fi + fi else BASE_SHA="${{ github.event.before }}" - if [ -z "${BASE_SHA}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then - git fetch --no-tags --depth=2 origin master || true - BASE_SHA="origin/master~1" - BASE_LABEL="fallback (${BASE_SHA})" + DEFAULT_BRANCH="${{ github.event.repository.default_branch }}" + if [ -z "${BASE_SHA}" ] \ + || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ] \ + || ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + git fetch --no-tags --depth=2 origin "${DEFAULT_BRANCH}" || true + if git cat-file -e "origin/${DEFAULT_BRANCH}~1^{commit}" 2>/dev/null; then + BASE_SHA="origin/${DEFAULT_BRANCH}~1" + BASE_LABEL="fallback (${BASE_SHA})" + else + BASE_SHA="$(git rev-parse HEAD~1)" + BASE_LABEL="fallback (HEAD~1)" + fi else BASE_LABEL="push before (${BASE_SHA})" git fetch --no-tags --depth=1 origin "${BASE_SHA}" || true @@ -185,6 +211,7 @@ jobs: if-no-files-found: warn path: | coverage/clover.xml + static-analysis/phpstan.txt coverage-artifact/summary.txt coverage-artifact/changed-lines.patch coverage-artifact/changed-files.txt diff --git a/composer.json b/composer.json index d5c341eb..69d44f27 100755 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "php": "^8.2" }, "require-dev": { + "phpstan/phpstan": "^1.12", "phpunit/phpunit": "^7 || ^8 || ^9" }, "autoload": { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 00000000..f51e71c3 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,2 @@ +parameters: + ignoreErrors: [] diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 00000000..e7e9c1e2 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,12 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: max + paths: + - src + - tests + bootstrapFiles: + - vendor/autoload.php + reportUnmatchedIgnoredErrors: false + tmpDir: .phpstan From 198352948a1dbef4bb59309e28b47a016349950a Mon Sep 17 00:00:00 2001 From: woxxy Date: Fri, 27 Feb 2026 23:39:35 +0000 Subject: [PATCH 10/16] chore: ignore phpstan temp directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2f1c0837..bfe09956 100755 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ tests/searchd.pid tests/data/rt.* tests/data/test_udf.so vendor/ +.phpstan/ From 30085370f238293d6766777bf6f1da3ceec3c21b Mon Sep 17 00:00:00 2001 From: woxxy Date: Fri, 27 Feb 2026 23:49:08 +0000 Subject: [PATCH 11/16] feat: resolve docs/auth/update issues and add license --- CHANGELOG.md | 3 + LICENSE | 176 ++++++++++++++++++++++++++++++ README.md | 34 ++++-- docs/config.rst | 2 + src/Drivers/ConnectionBase.php | 2 + src/Drivers/Mysqli/Connection.php | 4 +- src/Drivers/Pdo/Connection.php | 4 +- src/SphinxQL.php | 9 +- tests/SphinxQL/ConnectionTest.php | 26 +++++ tests/SphinxQL/SphinxQLTest.php | 20 ++++ 10 files changed, 268 insertions(+), 12 deletions(-) create mode 100644 LICENSE diff --git a/CHANGELOG.md b/CHANGELOG.md index fb83cc68..97c0d646 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ * Added `docs/feature-matrix.yml` as a feature-level support map across Sphinx2/Sphinx3/Manticore * Added capability-aware runtime tests for optional engine features (`supportsCommand`, Buddy-gated checks) * Added and stabilized Sphinx 3 compatibility coverage while preserving Sphinx 2 and Manticore test behavior +* Added support for optional connection credentials (`username`/`password`) in both PDO and MySQLi drivers +* Added optional-index `update($index = null)` flow for fluent `->update()->into($index)` usage +* Added a root `LICENSE` file and aligned README/config docs for escaping and connection parameter behavior * Migrated CI to GitHub Actions-only validation with strict composer metadata checks * Updated documentation and added a dedicated `MIGRATING-4.0.md` guide diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d9a10c0d --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md index 766a60c8..61e94a00 100755 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ including strict runtime validation behavior introduced in the 4.0 line. * __$conn->setParams($params = array('host' => '127.0.0.1', 'port' => 9306))__ - Sets the connection parameters used to establish a connection to the server. Supported parameters: 'host', 'port', 'socket', 'options'. + Sets the connection parameters used to establish a connection to the server. Supported parameters: 'host', 'port', 'socket', 'username', 'password', 'options'. * __$conn->query($query)__ @@ -129,15 +129,17 @@ Often, you would need to call and run SQL functions that shouldn't be escaped in #### Query Escaping -There are cases when an input __must__ be escaped in the SQL statement. The following functions are used to handle any escaping required for the query. +There are cases when an input __must__ be escaped in the SQL statement. SQL value escaping is handled by the active connection object: -* __$sq->escape($value)__ +* __$conn->escape($value)__ Returns the escaped value. This is processed with the `\MySQLi::real_escape_string()` function. -* __$sq->quote($value)__ +* __$conn->quote($value)__ - Adds quotes to the value and escapes it. For array elements, use `$sq->quoteArr($arr)`. + Adds quotes to the value and escapes it. For array elements, use `$conn->quoteArr($arr)`. + +`SphinxQL` itself exposes MATCH helpers: * __$sq->escapeMatch($value)__ @@ -149,6 +151,8 @@ There are cases when an input __must__ be escaped in the SQL statement. The foll _Refer to `$sq->match()` for more information._ +There is no dedicated `quoteIdentifier()` helper; pass only trusted index/column identifiers. + #### Strict Validation in 4.0 4.0 performs fail-fast validation for invalid query-shape input. Examples: @@ -190,13 +194,29 @@ This will return an `INT` with the number of rows affected. Both `$column1` and `$index1` can be arrays. +MVA attributes are inserted/updated by passing arrays as values: + +```php +replace() + ->into('rt') + ->set(array( + 'id' => 123, + 'title' => 'example', + 'rubrics' => array(10, 20), + 'districts' => array(1, 3, 5), + )) + ->execute(); +``` + #### UPDATE This will return an `INT` with the number of rows affected. -* __$sq = (new SphinxQL($conn))->update($index)__ +* __$sq = (new SphinxQL($conn))->update($index = null)__ - Begins an `UPDATE`. + Begins an `UPDATE`. You can pass the index immediately or set it later with `->into($index)`. * __$sq->value($column1, $value1)->value($column2, $value2)__ diff --git a/docs/config.rst b/docs/config.rst index 1136d9fe..93e7510c 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -42,6 +42,8 @@ Connection Parameters - ``host`` (string, default ``127.0.0.1``) - ``port`` (int, default ``9306``) - ``socket`` (string|null, default ``null``) +- ``username`` (string|null, optional) +- ``password`` (string|null, optional) - ``options`` (array, driver-specific client options) Strict Validation Notes diff --git a/src/Drivers/ConnectionBase.php b/src/Drivers/ConnectionBase.php index 79574ee2..faa24e48 100644 --- a/src/Drivers/ConnectionBase.php +++ b/src/Drivers/ConnectionBase.php @@ -39,6 +39,8 @@ public function setParams(array $params) * * * string host - The hostname, IP address, or unix socket * * int port - The port to the host + * * null|string username - Optional username for MySQL protocol auth + * * null|string password - Optional password for MySQL protocol auth * * array options - MySQLi options/values, as an associative array. Example: array(MYSQLI_OPT_CONNECT_TIMEOUT => 2) * * @param string $param Name of the parameter to modify. diff --git a/src/Drivers/Mysqli/Connection.php b/src/Drivers/Mysqli/Connection.php index d6d163d7..9adf56b4 100644 --- a/src/Drivers/Mysqli/Connection.php +++ b/src/Drivers/Mysqli/Connection.php @@ -40,6 +40,8 @@ public function connect() { $data = $this->getParams(); $conn = mysqli_init(); + $username = array_key_exists('username', $data) ? $data['username'] : null; + $password = array_key_exists('password', $data) ? $data['password'] : null; if (!empty($data['options'])) { foreach ($data['options'] as $option => $value) { @@ -49,7 +51,7 @@ public function connect() set_error_handler(function () {}); try { - if (!$conn->real_connect($data['host'], null, null, null, (int) $data['port'], $data['socket'])) { + if (!$conn->real_connect($data['host'], $username, $password, null, (int) $data['port'], $data['socket'])) { throw new ConnectionException( '[mysqli][connect]['.$conn->connect_errno.'] '.$conn->connect_error .' [host='.(string) $data['host'].', port='.(int) $data['port'].']' diff --git a/src/Drivers/Pdo/Connection.php b/src/Drivers/Pdo/Connection.php index 276e0a73..4ce864d3 100644 --- a/src/Drivers/Pdo/Connection.php +++ b/src/Drivers/Pdo/Connection.php @@ -41,6 +41,8 @@ public function query($query) public function connect() { $params = $this->getParams(); + $username = array_key_exists('username', $params) ? $params['username'] : null; + $password = array_key_exists('password', $params) ? $params['password'] : null; $dsn = 'mysql:'; if (isset($params['host']) && $params['host'] != '') { @@ -58,7 +60,7 @@ public function connect() } try { - $con = new PDO($dsn); + $con = new PDO($dsn, $username, $password); } catch (PDOException $exception) { throw new ConnectionException( '[pdo][connect]['.$exception->getCode().'] '.$exception->getMessage().' [dsn='.$dsn.']', diff --git a/src/SphinxQL.php b/src/SphinxQL.php index 040bdcce..1020fa58 100644 --- a/src/SphinxQL.php +++ b/src/SphinxQL.php @@ -1009,15 +1009,18 @@ public function replace() /** * Activates the UPDATE mode * - * @param string $index The index to update into + * @param null|string $index The index to update into (optional, can be set later with into()) * * @return self */ - public function update($index) + public function update($index = null) { $this->reset(); $this->type = 'update'; - $this->into($index); + + if ($index !== null) { + $this->into($index); + } return $this; } diff --git a/tests/SphinxQL/ConnectionTest.php b/tests/SphinxQL/ConnectionTest.php index 5db2ccdf..9a6c43c9 100644 --- a/tests/SphinxQL/ConnectionTest.php +++ b/tests/SphinxQL/ConnectionTest.php @@ -74,6 +74,23 @@ public function testGetConnectionParams() $this->assertSame(array('host' => '127.0.0.1', 'port' => 9308, 'socket' => null), $this->connection->getParams()); } + public function testCredentialsParams() + { + $this->connection->setParam('username', 'tester'); + $this->connection->setParam('password', 'secret'); + + $this->assertSame( + array( + 'host' => '127.0.0.1', + 'port' => 9307, + 'socket' => null, + 'username' => 'tester', + 'password' => 'secret', + ), + $this->connection->getParams() + ); + } + public function testGetConnection() { $this->connection->connect(); @@ -96,6 +113,15 @@ public function testConnect() $this->assertNotNull($this->connection->getConnection()); } + public function testConnectWithCredentialsParams() + { + $this->connection->setParam('username', null); + $this->connection->setParam('password', null); + $this->connection->connect(); + + $this->assertNotNull($this->connection->getConnection()); + } + public function testConnectThrowsException() { $this->expectException(Foolz\SphinxQL\Exception\ConnectionException::class); diff --git a/tests/SphinxQL/SphinxQLTest.php b/tests/SphinxQL/SphinxQLTest.php index 554a8fd9..e40de830 100644 --- a/tests/SphinxQL/SphinxQLTest.php +++ b/tests/SphinxQL/SphinxQLTest.php @@ -345,6 +345,7 @@ public function testReplace() * @covers \Foolz\SphinxQL\SphinxQL::compile * @covers \Foolz\SphinxQL\SphinxQL::compileUpdate * @covers \Foolz\SphinxQL\SphinxQL::compileSelect + * @covers \Foolz\SphinxQL\SphinxQL::into * @covers \Foolz\SphinxQL\SphinxQL::update * @covers \Foolz\SphinxQL\SphinxQL::value */ @@ -429,6 +430,25 @@ public function testUpdate() self::$conn->query('ALTER TABLE rt DROP COLUMN tags'); } + /** + * @covers \Foolz\SphinxQL\SphinxQL::compile + * @covers \Foolz\SphinxQL\SphinxQL::compileUpdate + * @covers \Foolz\SphinxQL\SphinxQL::update + * @covers \Foolz\SphinxQL\SphinxQL::into + */ + public function testUpdateWithLateInto() + { + $query = $this->createSphinxQL() + ->update() + ->into('rt') + ->set(array('gid' => 777)) + ->where('id', '=', 11) + ->compile() + ->getCompiled(); + + $this->assertSame('UPDATE rt SET gid = 777 WHERE id = 11', $query); + } + /** * @covers \Foolz\SphinxQL\SphinxQL::compileWhere * @covers \Foolz\SphinxQL\SphinxQL::from From b0ebfb874032ace84622bdb72ee12b3c3dd34f0d Mon Sep 17 00:00:00 2001 From: woxxy Date: Fri, 27 Feb 2026 23:49:57 +0000 Subject: [PATCH 12/16] docs: map 4.0.0 changelog entries to closed issues --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c0d646..1d6bcff9 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,11 @@ * Added `docs/feature-matrix.yml` as a feature-level support map across Sphinx2/Sphinx3/Manticore * Added capability-aware runtime tests for optional engine features (`supportsCommand`, Buddy-gated checks) * Added and stabilized Sphinx 3 compatibility coverage while preserving Sphinx 2 and Manticore test behavior -* Added support for optional connection credentials (`username`/`password`) in both PDO and MySQLi drivers -* Added optional-index `update($index = null)` flow for fluent `->update()->into($index)` usage -* Added a root `LICENSE` file and aligned README/config docs for escaping and connection parameter behavior +* Added support for optional connection credentials (`username`/`password`) in both PDO and MySQLi drivers (closes #208) +* Added optional-index `update($index = null)` flow for fluent `->update()->into($index)` usage (closes #184) +* Added MVA insert/update array example in README (closes #178) +* Corrected escaping docs to reference connection-level helpers and clarified `quoteIdentifier()` availability (closes #203) +* Added a root `LICENSE` file (closes #171) * Migrated CI to GitHub Actions-only validation with strict composer metadata checks * Updated documentation and added a dedicated `MIGRATING-4.0.md` guide From 3d9c06c499815f0e7872c641cecf320483aa14e5 Mon Sep 17 00:00:00 2001 From: woxxy Date: Fri, 27 Feb 2026 23:56:51 +0000 Subject: [PATCH 13/16] docs: modernize Sphinx docs with Furo and GitHub Pages workflow --- .github/workflows/docs.yml | 78 ++++++++++++++++++++++++++++++++++++++ README.md | 15 ++++++++ docs/_static/custom.css | 9 +++++ docs/_templates/.gitkeep | 1 + docs/conf.py | 68 +++++++++++++++++---------------- docs/contribute.rst | 12 ++++++ docs/index.rst | 7 +++- docs/requirements.txt | 3 ++ 8 files changed, 159 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/_static/custom.css create mode 100644 docs/_templates/.gitkeep create mode 100644 docs/requirements.txt diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..a75a015d --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,78 @@ +name: Documentation + +on: + workflow_dispatch: + pull_request: + paths: + - docs/** + - README.md + - .github/workflows/docs.yml + push: + branches: + - master + paths: + - docs/** + - README.md + - .github/workflows/docs.yml + +permissions: + contents: read + +concurrency: + group: docs-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Configure GitHub Pages + if: github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + uses: actions/configure-pages@v5 + + - name: Install docs dependencies + run: | + python -m pip install --upgrade pip + pip install -r docs/requirements.txt + + - name: Build docs + run: sphinx-build --fail-on-warning --keep-going -b html docs docs/_build/html + + - name: Upload docs artifact (PR/debug) + if: always() + uses: actions/upload-artifact@v4 + with: + name: docs-html + path: docs/_build/html + if-no-files-found: error + + - name: Upload Pages artifact + if: github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_build/html + + deploy: + if: github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 61e94a00..55ff6578 100755 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ Query Builder for SphinxQL ========================== [![CI](https://github.com/FoolCode/SphinxQL-Query-Builder/actions/workflows/ci.yml/badge.svg)](https://github.com/FoolCode/SphinxQL-Query-Builder/actions/workflows/ci.yml) +[![Documentation](https://github.com/FoolCode/SphinxQL-Query-Builder/actions/workflows/docs.yml/badge.svg)](https://github.com/FoolCode/SphinxQL-Query-Builder/actions/workflows/docs.yml) [![Latest Stable Version](https://poser.pugx.org/foolz/sphinxql-query-builder/v/stable)](https://packagist.org/packages/foolz/sphinxql-query-builder) [![Latest Unstable Version](https://poser.pugx.org/foolz/sphinxql-query-builder/v/unstable)](https://packagist.org/packages/foolz/sphinxql-query-builder) [![Total Downloads](https://poser.pugx.org/foolz/sphinxql-query-builder/downloads)](https://packagist.org/packages/foolz/sphinxql-query-builder) @@ -28,6 +29,20 @@ The majority of the methods in the package have been unit tested. Helper methods and engine compatibility scenarios are covered by the test suite. +## Documentation + +The docs are built with modern Sphinx + Furo styling. + +Build locally: + +```bash +python3 -m pip install -r docs/requirements.txt +sphinx-build --fail-on-warning --keep-going -b html docs docs/_build/html +``` + +CI builds docs for pull requests and deploys the rendered site to GitHub Pages +on pushes to `master`. + ## How to Contribute ### Pull Requests diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 00000000..0fee8486 --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,9 @@ +:root { + --color-brand-primary: #1c7ed6; + --color-brand-content: #1864ab; +} + +.sidebar-brand-text { + font-weight: 700; + letter-spacing: 0.01em; +} diff --git a/docs/_templates/.gitkeep b/docs/_templates/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/_templates/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/conf.py b/docs/conf.py index 16d11b11..8a9f9a33 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,36 +1,40 @@ -# -*- coding: utf-8 -*- -import sys, os -sys.path.insert(0, os.path.abspath('.')) +from datetime import date -#needs_sphinx = '1.0' +project = "SphinxQL Query Builder" +author = "FoolCode" +copyright = f"2012-{date.today().year}, {author}" -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] - -templates_path = ['_templates'] - -source_suffix = '.rst' -master_doc = 'index' - -# General information about the project. -project = u'SphinxQL Query Builder' -copyright = u'2012-2015, FoolCode' - -version = '1.0.0' +# We track release notes in CHANGELOG.md and do not hardcode package versions here. +version = "4.x" release = version -exclude_patterns = ['_build', 'html', 'doctrees'] -add_function_parentheses = True -add_module_names = True -show_authors = False -pygments_style = 'sphinx' -modindex_common_prefix = ['foolfuuka'] -html_theme = 'default' -html_static_path = ['_static'] -htmlhelp_basename = 'FoolFuukaDoc' - -from sphinx.highlighting import lexers -from pygments.lexers.web import JsonLexer -from pygments.lexers.web import PhpLexer - -lexers['json'] = JsonLexer(startinline=True) -lexers['php'] = PhpLexer(startinline=True) +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", + "sphinx.ext.githubpages", + "sphinx_copybutton", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +source_suffix = {".rst": "restructuredtext"} +root_doc = "index" +language = "en" + +pygments_style = "sphinx" +pygments_dark_style = "monokai" + +html_theme = "furo" +html_title = "SphinxQL Query Builder Documentation" +html_static_path = ["_static"] +html_css_files = ["custom.css"] +html_theme_options = { + "source_repository": "https://github.com/FoolCode/SphinxQL-Query-Builder/", + "source_branch": "master", + "source_directory": "docs/", + "navigation_with_keys": True, +} + +copybutton_prompt_text = r">>> |\.\.\. |\$ " +copybutton_prompt_is_regexp = True diff --git a/docs/contribute.rst b/docs/contribute.rst index 0b08276b..bed2e044 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -20,6 +20,18 @@ Testing All pull requests must be accompanied with passing tests and code coverage. The SphinxQL Query Builder uses `PHPUnit `_ for testing. +Documentation +------------- + +Documentation is built with Sphinx and the Furo theme. + +Build locally: + +.. code-block:: bash + + python3 -m pip install -r docs/requirements.txt + sphinx-build --fail-on-warning --keep-going -b html docs docs/_build/html + Issue Tracker ------------- diff --git a/docs/index.rst b/docs/index.rst index 53a9fbd0..d8dcc5b6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,10 @@ -Welcome -======= +SphinxQL Query Builder +====================== + +Modern PHP query builder documentation for SphinxQL and ManticoreQL. .. toctree:: + :caption: Contents :maxdepth: 2 intro diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..426da4fd --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +sphinx>=8.2.0,<10 +furo>=2025.9.25 +sphinx-copybutton>=0.5.2 From 70b45df5eaa4bb1cf5b6fa7a891662a8ac6b1091 Mon Sep 17 00:00:00 2001 From: woxxy Date: Sat, 28 Feb 2026 00:50:23 +0000 Subject: [PATCH 14/16] chore: harden ci and maximize typing for 4.0 --- .github/workflows/buddy-integration.yml | 18 +- .github/workflows/ci.yml | 54 ++-- .github/workflows/docs.yml | 2 - .github/workflows/publish-search-images.yml | 1 + CHANGELOG.md | 5 +- MIGRATING-4.0.md | 4 +- README.md | 10 +- docs/feature-matrix.yml | 4 +- docs/helper.rst | 7 +- docs/query-builder.rst | 5 +- src/Capabilities.php | 28 +- src/Drivers/ConnectionBase.php | 31 ++- src/Drivers/ConnectionInterface.php | 10 +- src/Drivers/MultiResultSet.php | 58 ++-- .../MultiResultSetAdapterInterface.php | 6 +- src/Drivers/MultiResultSetInterface.php | 6 +- src/Drivers/Mysqli/Connection.php | 27 +- src/Drivers/Mysqli/MultiResultSetAdapter.php | 12 +- src/Drivers/Mysqli/ResultSetAdapter.php | 32 +-- src/Drivers/Pdo/Connection.php | 16 +- src/Drivers/Pdo/MultiResultSetAdapter.php | 12 +- src/Drivers/Pdo/ResultSetAdapter.php | 36 +-- src/Drivers/ResultSet.php | 92 +++--- src/Drivers/ResultSetAdapterInterface.php | 22 +- src/Drivers/ResultSetInterface.php | 24 +- src/Expression.php | 10 +- src/Facet.php | 38 +-- src/Helper.php | 233 +++++++++++----- src/MatchBuilder.php | 46 +-- src/Percolate.php | 70 ++--- src/SphinxQL.php | 230 +++++++-------- tests/SphinxQL/ConnectionTest.php | 7 + tests/SphinxQL/HelperCapabilityProbeTest.php | 262 ++++++++++++++++++ tests/SphinxQL/HelperTest.php | 23 +- tests/SphinxQL/PdoResultSetAdapterTest.php | 38 +++ tests/SphinxQL/SphinxQLTest.php | 16 ++ 36 files changed, 976 insertions(+), 519 deletions(-) create mode 100644 tests/SphinxQL/HelperCapabilityProbeTest.php create mode 100644 tests/SphinxQL/PdoResultSetAdapterTest.php diff --git a/.github/workflows/buddy-integration.yml b/.github/workflows/buddy-integration.yml index ef17ab46..c361e37a 100644 --- a/.github/workflows/buddy-integration.yml +++ b/.github/workflows/buddy-integration.yml @@ -13,6 +13,7 @@ jobs: packages: read env: SEARCH_BUILD: MANTICORE + MANTICORE_IMAGE: manticoresearch/manticore@sha256:24835e1b590a31da0f45809b032865b1caab0a1b58f4ed14da9128a338f2d365 steps: - name: Checkout @@ -28,24 +29,33 @@ jobs: - name: Start Manticore with Buddy extras run: | - docker pull manticoresearch/manticore:latest + docker pull "$MANTICORE_IMAGE" docker run -d --name searchd \ -e EXTRA=1 \ -p 9307:9306 \ -p 9312:9312 \ - manticoresearch/manticore:latest + "$MANTICORE_IMAGE" - name: Wait for searchd run: | n=0 while [ "$n" -lt 60 ]; do - if (echo > /dev/tcp/127.0.0.1/9307) >/dev/null 2>&1; then + if php -r ' + try { + $pdo = new PDO("mysql:host=127.0.0.1;port=9307", "", ""); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->query("SHOW TABLES"); + exit(0); + } catch (Throwable $exception) { + exit(1); + } + ' >/dev/null 2>&1; then exit 0 fi n=$((n + 1)) sleep 1 done - echo "searchd did not become ready on 127.0.0.1:9307" + echo "searchd did not become SQL-ready on 127.0.0.1:9307" docker logs searchd || true exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f25ac17..f7978503 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup PHP if: env.COVERAGE_LANE != 'true' @@ -101,13 +103,22 @@ jobs: run: | n=0 while [ "$n" -lt 60 ]; do - if (echo > /dev/tcp/127.0.0.1/9307) >/dev/null 2>&1; then + if php -r ' + try { + $pdo = new PDO("mysql:host=127.0.0.1;port=9307", "", ""); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->query("SHOW TABLES"); + exit(0); + } catch (Throwable $exception) { + exit(1); + } + ' >/dev/null 2>&1; then exit 0 fi n=$((n + 1)) sleep 1 done - echo "searchd did not become ready on 127.0.0.1:9307" + echo "searchd did not become SQL-ready on 127.0.0.1:9307" docker logs searchd || true exit 1 @@ -147,36 +158,25 @@ jobs: if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_SHA="${{ github.event.pull_request.base.sha }}" - BASE_REF="${{ github.event.pull_request.base.ref }}" BASE_LABEL="PR base (${BASE_SHA})" - git fetch --no-tags --depth=1 origin "${BASE_REF}" || true - - if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then - if git cat-file -e "origin/${BASE_REF}^{commit}" 2>/dev/null; then - BASE_SHA="origin/${BASE_REF}" - BASE_LABEL="PR base ref (${BASE_SHA})" - else - BASE_SHA="$(git rev-parse HEAD~1)" - BASE_LABEL="fallback (HEAD~1)" - fi - fi else BASE_SHA="${{ github.event.before }}" + BASE_LABEL="push before (${BASE_SHA})" + fi + + if [ -z "${BASE_SHA}" ] \ + || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ] \ + || ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then DEFAULT_BRANCH="${{ github.event.repository.default_branch }}" - if [ -z "${BASE_SHA}" ] \ - || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ] \ - || ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then - git fetch --no-tags --depth=2 origin "${DEFAULT_BRANCH}" || true - if git cat-file -e "origin/${DEFAULT_BRANCH}~1^{commit}" 2>/dev/null; then - BASE_SHA="origin/${DEFAULT_BRANCH}~1" - BASE_LABEL="fallback (${BASE_SHA})" - else - BASE_SHA="$(git rev-parse HEAD~1)" - BASE_LABEL="fallback (HEAD~1)" - fi + if git show-ref --verify --quiet "refs/remotes/origin/${DEFAULT_BRANCH}"; then + BASE_SHA="$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD)" + BASE_LABEL="merge-base (origin/${DEFAULT_BRANCH})" + elif git cat-file -e "HEAD~1^{commit}" 2>/dev/null; then + BASE_SHA="$(git rev-parse HEAD~1)" + BASE_LABEL="fallback (HEAD~1)" else - BASE_LABEL="push before (${BASE_SHA})" - git fetch --no-tags --depth=1 origin "${BASE_SHA}" || true + BASE_SHA="$(git rev-parse HEAD)" + BASE_LABEL="fallback (HEAD)" fi fi diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a75a015d..9c97eba8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,8 +8,6 @@ on: - README.md - .github/workflows/docs.yml push: - branches: - - master paths: - docs/** - README.md diff --git a/.github/workflows/publish-search-images.yml b/.github/workflows/publish-search-images.yml index 451b5421..c63c97a1 100644 --- a/.github/workflows/publish-search-images.yml +++ b/.github/workflows/publish-search-images.yml @@ -11,6 +11,7 @@ on: - tests/sphinx.conf - tests/manticore.conf - tests/test_udf.c + - tests/s3_test_udf.c - tests/ms_test_udf.c permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d6bcff9..1bb1bedf 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,6 @@ #### 4.0.0 * Dropped support for PHP 8.1 and lower (minimum PHP is now 8.2) * Updated CI PHP matrix to 8.2 and 8.3 -* Restored runtime-level driver normalization for PDO/MySQLi scalar fetch values * Normalized MySQLi driver exception handling for modern PHP `mysqli_sql_exception` behavior * Hardened runtime validation for `SphinxQL`, `Facet`, `Helper`, and `Percolate` input contracts (fail-fast exceptions for invalid query-shape input) * Standardized driver exception message prefixes for better diagnostics (`[mysqli][...]`, `[pdo][...]`) @@ -16,10 +15,14 @@ * Added and stabilized Sphinx 3 compatibility coverage while preserving Sphinx 2 and Manticore test behavior * Added support for optional connection credentials (`username`/`password`) in both PDO and MySQLi drivers (closes #208) * Added optional-index `update($index = null)` flow for fluent `->update()->into($index)` usage (closes #184) +* Added explicit `update()->compile()/execute()` guard when no target index is set via `into($index)` (prevents invalid `UPDATE` SQL emission) +* Restored `showTables($index = null)` compatibility (`SHOW TABLES` for null/empty, `SHOW TABLES LIKE ...` for non-empty) and removed hardcoded `rt` assumptions from runtime capability probes +* Aligned Buddy capability flags so `callQSuggest()`/`callAutocomplete()` are gated by detected Buddy availability * Added MVA insert/update array example in README (closes #178) * Corrected escaping docs to reference connection-level helpers and clarified `quoteIdentifier()` availability (closes #203) * Added a root `LICENSE` file (closes #171) * Migrated CI to GitHub Actions-only validation with strict composer metadata checks +* Hardened GitHub Actions reliability with SQL-readiness checks, full-history checkout for changed-line artifacts, and digest-pinned Buddy integration runtime image * Updated documentation and added a dedicated `MIGRATING-4.0.md` guide #### 3.0.2 diff --git a/MIGRATING-4.0.md b/MIGRATING-4.0.md index fa23616d..a6016cfb 100644 --- a/MIGRATING-4.0.md +++ b/MIGRATING-4.0.md @@ -25,6 +25,7 @@ The following now throw on invalid input: - `groupNBy()` non-positive values - `where()` / `having()` invalid filter value shape for `IN`/`NOT IN`/`BETWEEN` - `into()`, `columns()`, `values()`, `value()`, `set()` invalid/empty input +- `update()->compile()` / `update()->execute()` without `into($index)` - `setQueuePrev()` non-`SphinxQL` argument ### Facet strict validation @@ -38,7 +39,8 @@ The following now throw on invalid input: Helper methods now validate required identifiers and argument shapes: -- `showTables()`, `describe()`, `showIndexStatus()`, `flushRtIndex()`, +- `showTables()` accepts `null`/empty for unfiltered `SHOW TABLES`, and validates non-string filters +- `describe()`, `showIndexStatus()`, `flushRtIndex()`, `truncateRtIndex()`, `optimizeIndex()`, `flushRamchunk()`, etc. - `setVariable()` validates variable names and array values - `callSnippets()` and `callKeywords()` validate required arguments diff --git a/README.md b/README.md index 55ff6578..ed514f1b 100755 --- a/README.md +++ b/README.md @@ -277,8 +277,12 @@ Will return an array with an `INT` as first member, the number of rows deleted. $sq->where('column', 'BETWEEN', array('value1', 'value2')); ``` - You can compose grouped boolean filters with: - `orWhere()`, `whereOpen()`, `orWhereOpen()`, and `whereClose()`. + You can compose grouped boolean filters with: + `orWhere()`, `whereOpen()`, `orWhereOpen()`, and `whereClose()`. + The same grouped API exists for `HAVING` via `having()`, `orHaving()`, + `havingOpen()`, `orHavingOpen()`, and `havingClose()`. + Repeated `having()` calls are additive (`AND`) unless you explicitly use + `orHaving()` or grouped clauses. #### MATCH @@ -526,7 +530,7 @@ $result = (new SphinxQL($this->conn)) * `(new Helper($conn))->showDatabases() => 'SHOW DATABASES'` * `(new Helper($conn))->showCharacterSet() => 'SHOW CHARACTER SET'` * `(new Helper($conn))->showCollation() => 'SHOW COLLATION'` -* `(new Helper($conn))->showTables($index) => 'SHOW TABLES LIKE '` +* `(new Helper($conn))->showTables($index = null) => 'SHOW TABLES' (null/empty) or 'SHOW TABLES LIKE '` * `(new Helper($conn))->showVariables() => 'SHOW VARIABLES'` * `(new Helper($conn))->showCreateTable($table)` * `(new Helper($conn))->showTableStatus($table = null) => 'SHOW TABLE STATUS' or 'SHOW TABLE
STATUS'` diff --git a/docs/feature-matrix.yml b/docs/feature-matrix.yml index 0aaa901d..a7c02f5c 100644 --- a/docs/feature-matrix.yml +++ b/docs/feature-matrix.yml @@ -1,5 +1,5 @@ version: 1 -updated_at: "2026-02-27" +updated_at: "2026-02-28" notes: - "supported means covered by runtime tests in at least one CI lane." - "conditional means feature depends on engine/runtime capability checks." @@ -43,7 +43,7 @@ helper: sphinx3: supported manticore: supported notes: - - "showTables($index) compiles to SHOW TABLES LIKE ; index must be a non-empty string." + - "showTables($index = null) compiles to SHOW TABLES when index is null/empty, or SHOW TABLES LIKE for non-empty strings." diagnostic_show: api: ["showProfile", "showPlan", "showThreads", "showPlugins", "showQueries"] sphinx2: conditional diff --git a/docs/helper.rst b/docs/helper.rst index 40513598..bbc79342 100644 --- a/docs/helper.rst +++ b/docs/helper.rst @@ -32,7 +32,7 @@ Available Methods - ``showDatabases()`` - ``showCharacterSet()`` - ``showCollation()`` -- ``showTables($index)`` +- ``showTables($index = null)`` - ``showVariables()`` - ``showCreateTable($table)`` - ``showTableStatus($table = null)`` @@ -66,7 +66,10 @@ Available Methods Filtered SHOW Wrappers ---------------------- -- ``showTables($index)`` compiles to ``SHOW TABLES LIKE ``. +- ``showTables($index = null)`` compiles to: + + - ``SHOW TABLES`` when ``$index`` is ``null`` or an empty string + - ``SHOW TABLES LIKE `` when ``$index`` is a non-empty string - ``showTableStatus($table = null)`` compiles to: - ``SHOW TABLE STATUS`` when ``$table`` is ``null`` diff --git a/docs/query-builder.rst b/docs/query-builder.rst index 58811e5c..06c3991c 100644 --- a/docs/query-builder.rst +++ b/docs/query-builder.rst @@ -73,6 +73,9 @@ The builder supports grouped boolean filters for ``WHERE`` and ``HAVING``: - ``orHaving()`` - ``havingOpen()`` / ``orHavingOpen()`` / ``havingClose()`` +Repeated ``having()`` calls are additive and compile as ``AND`` conditions unless +you explicitly use ``orHaving()`` / grouped clauses. + JOIN and KNN Ordering --------------------- @@ -100,7 +103,7 @@ The same capability model is used by ``Helper`` wrappers: - filtered ``SHOW`` wrappers: - - ``showTables($index)`` => ``SHOW TABLES LIKE `` + - ``showTables($index = null)`` => ``SHOW TABLES`` (when ``null`` or empty) or ``SHOW TABLES LIKE `` - ``showTableStatus($table = null)`` => ``SHOW TABLE STATUS`` or ``SHOW TABLE
STATUS`` - suggest-family option contract (``callSuggest()``, ``callQSuggest()``, diff --git a/src/Capabilities.php b/src/Capabilities.php index 28fa2016..9bc4d67c 100644 --- a/src/Capabilities.php +++ b/src/Capabilities.php @@ -10,34 +10,34 @@ class Capabilities /** * @var string */ - private $engine; + private string $engine; /** * @var string */ - private $version; + private string $version; /** * @var array */ - private $features; + private array $features; /** * @param string $engine * @param string $version * @param array $features */ - public function __construct($engine, $version, array $features) + public function __construct(string $engine, string $version, array $features) { - $this->engine = strtoupper((string) $engine); - $this->version = (string) $version; + $this->engine = strtoupper($engine); + $this->version = $version; $this->features = $features; } /** * @return string */ - public function getEngine() + public function getEngine(): string { return $this->engine; } @@ -45,7 +45,7 @@ public function getEngine() /** * @return string */ - public function getVersion() + public function getVersion(): string { return $this->version; } @@ -53,7 +53,7 @@ public function getVersion() /** * @return array */ - public function getFeatures() + public function getFeatures(): array { return $this->features; } @@ -61,7 +61,7 @@ public function getFeatures() /** * @return bool */ - public function isManticore() + public function isManticore(): bool { return $this->engine === 'MANTICORE'; } @@ -69,7 +69,7 @@ public function isManticore() /** * @return bool */ - public function isSphinx2() + public function isSphinx2(): bool { return $this->engine === 'SPHINX2'; } @@ -77,7 +77,7 @@ public function isSphinx2() /** * @return bool */ - public function isSphinx3() + public function isSphinx3(): bool { return $this->engine === 'SPHINX3'; } @@ -87,7 +87,7 @@ public function isSphinx3() * * @return bool */ - public function supports($feature) + public function supports(string $feature): bool { return !empty($this->features[$feature]); } @@ -95,7 +95,7 @@ public function supports($feature) /** * @return array */ - public function toArray() + public function toArray(): array { return array( 'engine' => $this->engine, diff --git a/src/Drivers/ConnectionBase.php b/src/Drivers/ConnectionBase.php index faa24e48..1113001f 100644 --- a/src/Drivers/ConnectionBase.php +++ b/src/Drivers/ConnectionBase.php @@ -3,6 +3,7 @@ namespace Foolz\SphinxQL\Drivers; use Foolz\SphinxQL\Exception\ConnectionException; +use Foolz\SphinxQL\Exception\SphinxQLException; use Foolz\SphinxQL\Expression; use mysqli; use PDO; @@ -14,20 +15,20 @@ abstract class ConnectionBase implements ConnectionInterface * * @var array */ - protected $connection_params = array('host' => '127.0.0.1', 'port' => 9306, 'socket' => null); + protected array $connection_params = array('host' => '127.0.0.1', 'port' => 9306, 'socket' => null); /** * Internal connection object. * @var mysqli|PDO */ - protected $connection; + protected mysqli|PDO|null $connection = null; /** * Sets one or more connection parameters. * * @param array $params Associative array of parameters and values. */ - public function setParams(array $params) + public function setParams(array $params): void { foreach ($params as $param => $value) { $this->setParam($param, $value); @@ -46,17 +47,21 @@ public function setParams(array $params) * @param string $param Name of the parameter to modify. * @param mixed $value Value to which the parameter will be set. */ - public function setParam($param, $value) + public function setParam(string $param, mixed $value): void { + if (($param === 'username' || $param === 'password') && $value !== null && !is_string($value)) { + throw new SphinxQLException('setParam("'.$param.'") expects null or string.'); + } + if ($param === 'host') { if ($value === 'localhost') { $value = '127.0.0.1'; - } elseif (stripos($value, 'unix:') === 0) { + } elseif (is_string($value) && stripos($value, 'unix:') === 0) { $param = 'socket'; } } if ($param === 'socket') { - if (stripos($value, 'unix:') === 0) { + if (is_string($value) && stripos($value, 'unix:') === 0) { $value = substr($value, 5); } $this->connection_params['host'] = null; @@ -70,7 +75,7 @@ public function setParam($param, $value) * * @return array $params The current connection parameters */ - public function getParams() + public function getParams(): array { return $this->connection_params; } @@ -81,7 +86,7 @@ public function getParams() * @return mysqli|PDO Internal connection object * @throws ConnectionException If no connection has been established or open */ - public function getConnection() + public function getConnection(): mysqli|PDO { if (!is_null($this->connection)) { return $this->connection; @@ -95,7 +100,7 @@ public function getConnection() * Based on FuelPHP's quoting function. * @inheritdoc */ - public function quote($value) + public function quote(Expression|string|null|bool|array|int|float $value): string|int|float { if ($value === null) { return 'null'; @@ -122,7 +127,7 @@ public function quote($value) /** * @inheritdoc */ - public function quoteArr(array $array = array()) + public function quoteArr(array $array = array()): array { $result = array(); @@ -139,7 +144,7 @@ public function quoteArr(array $array = array()) * @return $this * @throws ConnectionException */ - public function close() + public function close(): self { $this->connection = null; @@ -150,7 +155,7 @@ public function close() * Establishes a connection if needed * @throws ConnectionException */ - protected function ensureConnection() + protected function ensureConnection(): void { try { $this->getConnection(); @@ -165,6 +170,6 @@ protected function ensureConnection() * @return bool True if connected * @throws ConnectionException If a connection error was encountered */ - abstract public function connect(); + abstract public function connect(): bool; } diff --git a/src/Drivers/ConnectionInterface.php b/src/Drivers/ConnectionInterface.php index f21c8aba..01eba664 100644 --- a/src/Drivers/ConnectionInterface.php +++ b/src/Drivers/ConnectionInterface.php @@ -18,7 +18,7 @@ interface ConnectionInterface * @throws DatabaseException If the executed query produced an error * @throws ConnectionException */ - public function query($query); + public function query(string $query): ResultSetInterface; /** * Performs multiple queries on the Sphinx server. @@ -30,7 +30,7 @@ public function query($query); * @throws SphinxQLException In case the array passed is empty * @throws ConnectionException */ - public function multiQuery(array $queue); + public function multiQuery(array $queue): MultiResultSetInterface; /** * Escapes the input @@ -41,7 +41,7 @@ public function multiQuery(array $queue); * @throws DatabaseException If an error was encountered during server-side escape * @throws ConnectionException */ - public function escape($value); + public function escape(string $value): string; /** * Adds quotes around values when necessary. @@ -53,7 +53,7 @@ public function escape($value); * @throws DatabaseException * @throws ConnectionException */ - public function quote($value); + public function quote(Expression|string|null|bool|array|int|float $value): string|int|float; /** * Calls $this->quote() on every element of the array passed. @@ -64,5 +64,5 @@ public function quote($value); * @throws DatabaseException * @throws ConnectionException */ - public function quoteArr(array $array = array()); + public function quoteArr(array $array = array()): array; } diff --git a/src/Drivers/MultiResultSet.php b/src/Drivers/MultiResultSet.php index 22429200..aa38ef2c 100644 --- a/src/Drivers/MultiResultSet.php +++ b/src/Drivers/MultiResultSet.php @@ -9,32 +9,32 @@ class MultiResultSet implements MultiResultSetInterface /** * @var null|array */ - protected $stored; + protected ?array $stored = null; /** * @var int */ - protected $cursor = 0; + protected int $cursor = 0; /** * @var int */ - protected $next_cursor = 0; + protected int $next_cursor = 0; /** * @var ResultSetInterface|null */ - protected $rowSet; + protected ResultSetInterface|false|null $rowSet = null; /** * @var MultiResultSetAdapterInterface */ - protected $adapter; + protected MultiResultSetAdapterInterface $adapter; /** * @var bool */ - protected $valid = true; + protected bool $valid = true; /** * @param MultiResultSetAdapterInterface $adapter @@ -48,7 +48,7 @@ public function __construct(MultiResultSetAdapterInterface $adapter) * @inheritdoc * @throws DatabaseException */ - public function getStored() + public function getStored(): ?array { $this->store(); @@ -59,23 +59,25 @@ public function getStored() * @inheritdoc * @throws DatabaseException */ - #[\ReturnTypeWillChange] - public function offsetExists($offset) + public function offsetExists(mixed $offset): bool { $this->store(); - return $this->storedValid($offset); + return is_int($offset) && $this->storedValid($offset); } /** * @inheritdoc * @throws DatabaseException */ - #[\ReturnTypeWillChange] - public function offsetGet($offset) + public function offsetGet(mixed $offset): mixed { $this->store(); + if (!is_int($offset) || !$this->storedValid($offset)) { + return null; + } + return $this->stored[$offset]; } @@ -83,8 +85,7 @@ public function offsetGet($offset) * @inheritdoc * @codeCoverageIgnore */ - #[\ReturnTypeWillChange] - public function offsetSet($offset, $value) + public function offsetSet(mixed $offset, mixed $value): void { throw new \BadMethodCallException('Not implemented'); } @@ -93,8 +94,7 @@ public function offsetSet($offset, $value) * @inheritdoc * @codeCoverageIgnore */ - #[\ReturnTypeWillChange] - public function offsetUnset($offset) + public function offsetUnset(mixed $offset): void { throw new \BadMethodCallException('Not implemented'); } @@ -102,8 +102,7 @@ public function offsetUnset($offset) /** * @inheritdoc */ - #[\ReturnTypeWillChange] - public function next() + public function next(): void { $this->rowSet = $this->getNext(); } @@ -111,8 +110,7 @@ public function next() /** * @inheritdoc */ - #[\ReturnTypeWillChange] - public function key() + public function key(): int { return (int)$this->cursor; } @@ -120,8 +118,7 @@ public function key() /** * @inheritdoc */ - #[\ReturnTypeWillChange] - public function rewind() + public function rewind(): void { // we actually can't roll this back unless it was stored first $this->cursor = 0; @@ -133,8 +130,7 @@ public function rewind() * @inheritdoc * @throws DatabaseException */ - #[\ReturnTypeWillChange] - public function count() + public function count(): int { $this->store(); @@ -144,8 +140,7 @@ public function count() /** * @inheritdoc */ - #[\ReturnTypeWillChange] - public function valid() + public function valid(): bool { if ($this->stored !== null) { return $this->storedValid(); @@ -157,8 +152,7 @@ public function valid() /** * @inheritdoc */ - #[\ReturnTypeWillChange] - public function current() + public function current(): mixed { $rowSet = $this->rowSet; unset($this->rowSet); @@ -171,17 +165,17 @@ public function current() * * @return bool */ - protected function storedValid($cursor = null) + protected function storedValid(?int $cursor = null): bool { $cursor = (!is_null($cursor) ? $cursor : $this->cursor); - return $cursor >= 0 && $cursor < count($this->stored); + return $cursor >= 0 && $this->stored !== null && $cursor < count($this->stored); } /** * @inheritdoc */ - public function getNext() + public function getNext(): ResultSetInterface|false { $this->cursor = $this->next_cursor; @@ -203,7 +197,7 @@ public function getNext() /** * @inheritdoc */ - public function store() + public function store(): self { if ($this->stored !== null) { return $this; diff --git a/src/Drivers/MultiResultSetAdapterInterface.php b/src/Drivers/MultiResultSetAdapterInterface.php index 3a3ef940..15f6bbdd 100644 --- a/src/Drivers/MultiResultSetAdapterInterface.php +++ b/src/Drivers/MultiResultSetAdapterInterface.php @@ -7,15 +7,15 @@ interface MultiResultSetAdapterInterface /** * Advances to the next rowset */ - public function getNext(); + public function getNext(): void; /** * @return ResultSetInterface */ - public function current(); + public function current(): ResultSetInterface; /** * @return bool */ - public function valid(); + public function valid(): bool; } diff --git a/src/Drivers/MultiResultSetInterface.php b/src/Drivers/MultiResultSetInterface.php index 6202f559..1e365e6d 100644 --- a/src/Drivers/MultiResultSetInterface.php +++ b/src/Drivers/MultiResultSetInterface.php @@ -12,19 +12,19 @@ interface MultiResultSetInterface extends \ArrayAccess, \Iterator, \Countable * @return $this * @throws DatabaseException */ - public function store(); + public function store(): self; /** * Returns the stored data as an array (results) of arrays (rows) * * @return ResultSetInterface[]|null */ - public function getStored(); + public function getStored(): ?array; /** * Returns the next result set, or false if there's no more results * * @return ResultSetInterface|false */ - public function getNext(); + public function getNext(): ResultSetInterface|false; } diff --git a/src/Drivers/Mysqli/Connection.php b/src/Drivers/Mysqli/Connection.php index 9adf56b4..acec3a63 100644 --- a/src/Drivers/Mysqli/Connection.php +++ b/src/Drivers/Mysqli/Connection.php @@ -21,14 +21,14 @@ class Connection extends ConnectionBase * * @var string */ - protected $internal_encoding; + protected ?string $internal_encoding = null; /** * Returns the internal encoding. * * @return string current multibyte internal encoding */ - public function getInternalEncoding() + public function getInternalEncoding(): ?string { return $this->internal_encoding; } @@ -36,7 +36,7 @@ public function getInternalEncoding() /** * @inheritdoc */ - public function connect() + public function connect(): bool { $data = $this->getParams(); $conn = mysqli_init(); @@ -57,6 +57,13 @@ public function connect() .' [host='.(string) $data['host'].', port='.(int) $data['port'].']' ); } + } catch (\TypeError $exception) { + throw new ConnectionException( + '[mysqli][connect][0] '.$exception->getMessage() + .' [host='.(string) $data['host'].', port='.(int) $data['port'].']', + 0, + $exception + ); } catch (mysqli_sql_exception $exception) { throw new ConnectionException( '[mysqli][connect]['.$exception->getCode().'] '.$exception->getMessage() @@ -81,7 +88,7 @@ public function connect() * @return bool True if connected, false otherwise * @throws ConnectionException */ - public function ping() + public function ping(): bool { $this->ensureConnection(); @@ -91,7 +98,7 @@ public function ping() /** * @inheritdoc */ - public function close() + public function close(): self { $this->mbPop(); $this->getConnection()->close(); @@ -102,7 +109,7 @@ public function close() /** * @inheritdoc */ - public function query($query) + public function query(string $query): ResultSet { $this->ensureConnection(); @@ -136,7 +143,7 @@ public function query($query) /** * @inheritdoc */ - public function multiQuery(array $queue) + public function multiQuery(array $queue): MultiResultSet { $count = count($queue); @@ -169,7 +176,7 @@ public function multiQuery(array $queue) * Based on FuelPHP's escaping function. * @inheritdoc */ - public function escape($value) + public function escape(string $value): string { $this->ensureConnection(); @@ -198,7 +205,7 @@ public function escape($value) /** * Enter UTF-8 multi-byte workaround mode. */ - public function mbPush() + public function mbPush(): self { $this->internal_encoding = mb_internal_encoding(); mb_internal_encoding('UTF-8'); @@ -209,7 +216,7 @@ public function mbPush() /** * Exit UTF-8 multi-byte workaround mode. */ - public function mbPop() + public function mbPop(): self { // TODO: add test case for #155 if ($this->getInternalEncoding()) { diff --git a/src/Drivers/Mysqli/MultiResultSetAdapter.php b/src/Drivers/Mysqli/MultiResultSetAdapter.php index 392c4f63..f714d594 100644 --- a/src/Drivers/Mysqli/MultiResultSetAdapter.php +++ b/src/Drivers/Mysqli/MultiResultSetAdapter.php @@ -11,12 +11,12 @@ class MultiResultSetAdapter implements MultiResultSetAdapterInterface /** * @var bool */ - protected $valid = true; + protected bool $valid = true; /** * @var Connection */ - protected $connection; + protected Connection $connection; /** * @param Connection $connection @@ -30,7 +30,7 @@ public function __construct(Connection $connection) * @inheritdoc * @throws ConnectionException */ - public function getNext() + public function getNext(): void { if ( !$this->valid() || @@ -46,7 +46,7 @@ public function getNext() * @inheritdoc * @throws ConnectionException */ - public function current() + public function current(): ResultSet { $adapter = new ResultSetAdapter($this->connection, $this->connection->getConnection()->store_result()); return new ResultSet($adapter); @@ -56,8 +56,8 @@ public function current() * @inheritdoc * @throws ConnectionException */ - public function valid() + public function valid(): bool { - return $this->connection->getConnection()->errno == 0 && $this->valid; + return $this->connection->getConnection()->errno === 0 && $this->valid; } } diff --git a/src/Drivers/Mysqli/ResultSetAdapter.php b/src/Drivers/Mysqli/ResultSetAdapter.php index bc8d9905..b89c9919 100644 --- a/src/Drivers/Mysqli/ResultSetAdapter.php +++ b/src/Drivers/Mysqli/ResultSetAdapter.php @@ -11,23 +11,23 @@ class ResultSetAdapter implements ResultSetAdapterInterface /** * @var Connection */ - protected $connection; + protected Connection $connection; /** * @var mysqli_result|bool */ - protected $result; + protected mysqli_result|bool $result; /** * @var bool */ - protected $valid = true; + protected bool $valid = true; /** * @param Connection $connection * @param mysqli_result|bool $result */ - public function __construct(Connection $connection, $result) + public function __construct(Connection $connection, mysqli_result|bool $result) { $this->connection = $connection; $this->result = $result; @@ -37,7 +37,7 @@ public function __construct(Connection $connection, $result) * @inheritdoc * @throws ConnectionException */ - public function getAffectedRows() + public function getAffectedRows(): int { return $this->connection->getConnection()->affected_rows; } @@ -45,7 +45,7 @@ public function getAffectedRows() /** * @inheritdoc */ - public function getNumRows() + public function getNumRows(): int { return $this->result->num_rows; } @@ -53,7 +53,7 @@ public function getNumRows() /** * @inheritdoc */ - public function getFields() + public function getFields(): array { return $this->result->fetch_fields(); } @@ -61,7 +61,7 @@ public function getFields() /** * @inheritdoc */ - public function isDml() + public function isDml(): bool { return !($this->result instanceof mysqli_result); } @@ -69,7 +69,7 @@ public function isDml() /** * @inheritdoc */ - public function store() + public function store(): array { $this->result->data_seek(0); @@ -79,7 +79,7 @@ public function store() /** * @inheritdoc */ - public function toRow($num) + public function toRow(int $num): void { $this->result->data_seek($num); } @@ -87,7 +87,7 @@ public function toRow($num) /** * @inheritdoc */ - public function freeResult() + public function freeResult(): void { $this->result->free_result(); } @@ -95,7 +95,7 @@ public function freeResult() /** * @inheritdoc */ - public function rewind() + public function rewind(): void { $this->valid = true; $this->result->data_seek(0); @@ -104,7 +104,7 @@ public function rewind() /** * @inheritdoc */ - public function valid() + public function valid(): bool { return $this->valid; } @@ -112,7 +112,7 @@ public function valid() /** * @inheritdoc */ - public function fetch($assoc = true) + public function fetch(bool $assoc = true): ?array { if ($assoc) { $row = $this->result->fetch_assoc(); @@ -124,13 +124,13 @@ public function fetch($assoc = true) $this->valid = false; } - return $row; + return $row === false ? null : $row; } /** * @inheritdoc */ - public function fetchAll($assoc = true) + public function fetchAll(bool $assoc = true): array { if ($assoc) { $row = $this->result->fetch_all(MYSQLI_ASSOC); diff --git a/src/Drivers/Pdo/Connection.php b/src/Drivers/Pdo/Connection.php index 4ce864d3..69006eae 100644 --- a/src/Drivers/Pdo/Connection.php +++ b/src/Drivers/Pdo/Connection.php @@ -16,7 +16,7 @@ class Connection extends ConnectionBase /** * @inheritdoc */ - public function query($query) + public function query(string $query): ResultSet { $this->ensureConnection(); @@ -38,7 +38,7 @@ public function query($query) /** * @inheritdoc */ - public function connect() + public function connect(): bool { $params = $this->getParams(); $username = array_key_exists('username', $params) ? $params['username'] : null; @@ -61,6 +61,12 @@ public function connect() try { $con = new PDO($dsn, $username, $password); + } catch (\TypeError $exception) { + throw new ConnectionException( + '[pdo][connect][0] '.$exception->getMessage().' [dsn='.$dsn.']', + 0, + $exception + ); } catch (PDOException $exception) { throw new ConnectionException( '[pdo][connect]['.$exception->getCode().'] '.$exception->getMessage().' [dsn='.$dsn.']', @@ -79,7 +85,7 @@ public function connect() * @return bool * @throws ConnectionException */ - public function ping() + public function ping(): bool { $this->ensureConnection(); @@ -89,7 +95,7 @@ public function ping() /** * @inheritdoc */ - public function multiQuery(array $queue) + public function multiQuery(array $queue): MultiResultSet { $this->ensureConnection(); @@ -113,7 +119,7 @@ public function multiQuery(array $queue) /** * @inheritdoc */ - public function escape($value) + public function escape(string $value): string { $this->ensureConnection(); diff --git a/src/Drivers/Pdo/MultiResultSetAdapter.php b/src/Drivers/Pdo/MultiResultSetAdapter.php index 3fdf102c..1e2e61fe 100644 --- a/src/Drivers/Pdo/MultiResultSetAdapter.php +++ b/src/Drivers/Pdo/MultiResultSetAdapter.php @@ -11,12 +11,12 @@ class MultiResultSetAdapter implements MultiResultSetAdapterInterface /** * @var bool */ - protected $valid = true; + protected bool $valid = true; /** * @var PDOStatement */ - protected $statement; + protected PDOStatement $statement; /** * @param PDOStatement $statement @@ -29,7 +29,7 @@ public function __construct(PDOStatement $statement) /** * @inheritdoc */ - public function getNext() + public function getNext(): void { if ( !$this->valid() || @@ -42,7 +42,7 @@ public function getNext() /** * @inheritdoc */ - public function current() + public function current(): ResultSet { return new ResultSet(new ResultSetAdapter($this->statement)); } @@ -50,8 +50,8 @@ public function current() /** * @inheritdoc */ - public function valid() + public function valid(): bool { - return $this->statement && $this->valid; + return $this->valid; } } diff --git a/src/Drivers/Pdo/ResultSetAdapter.php b/src/Drivers/Pdo/ResultSetAdapter.php index 68b2fede..95714a6c 100644 --- a/src/Drivers/Pdo/ResultSetAdapter.php +++ b/src/Drivers/Pdo/ResultSetAdapter.php @@ -11,12 +11,12 @@ class ResultSetAdapter implements ResultSetAdapterInterface /** * @var PDOStatement */ - protected $statement; + protected PDOStatement $statement; /** * @var bool */ - protected $valid = true; + protected bool $valid = true; /** * @param PDOStatement $statement @@ -29,7 +29,7 @@ public function __construct(PDOStatement $statement) /** * @inheritdoc */ - public function getAffectedRows() + public function getAffectedRows(): int { return $this->statement->rowCount(); } @@ -37,7 +37,7 @@ public function getAffectedRows() /** * @inheritdoc */ - public function getNumRows() + public function getNumRows(): int { return $this->statement->rowCount(); } @@ -45,7 +45,7 @@ public function getNumRows() /** * @inheritdoc */ - public function getFields() + public function getFields(): array { $fields = array(); @@ -59,15 +59,15 @@ public function getFields() /** * @inheritdoc */ - public function isDml() + public function isDml(): bool { - return $this->statement->columnCount() == 0; + return $this->statement->columnCount() === 0; } /** * @inheritdoc */ - public function store() + public function store(): array { return $this->normalizeRows($this->statement->fetchAll(PDO::FETCH_NUM)); } @@ -75,7 +75,7 @@ public function store() /** * @inheritdoc */ - public function toRow($num) + public function toRow(int $num): void { throw new \BadMethodCallException('Not implemented'); } @@ -83,7 +83,7 @@ public function toRow($num) /** * @inheritdoc */ - public function freeResult() + public function freeResult(): void { $this->statement->closeCursor(); } @@ -91,7 +91,7 @@ public function freeResult() /** * @inheritdoc */ - public function rewind() + public function rewind(): void { } @@ -99,7 +99,7 @@ public function rewind() /** * @inheritdoc */ - public function valid() + public function valid(): bool { return $this->valid; } @@ -107,7 +107,7 @@ public function valid() /** * @inheritdoc */ - public function fetch($assoc = true) + public function fetch(bool $assoc = true): ?array { if ($assoc) { $row = $this->statement->fetch(PDO::FETCH_ASSOC); @@ -128,7 +128,7 @@ public function fetch($assoc = true) /** * @inheritdoc */ - public function fetchAll($assoc = true) + public function fetchAll(bool $assoc = true): array { if ($assoc) { $row = $this->statement->fetchAll(PDO::FETCH_ASSOC); @@ -150,10 +150,12 @@ public function fetchAll($assoc = true) * @param array $row * @return array */ - protected function normalizeRow(array $row) + protected function normalizeRow(array $row): array { foreach ($row as $key => $value) { - if (is_scalar($value) && !is_string($value)) { + if (is_bool($value)) { + $row[$key] = $value ? '1' : '0'; + } elseif (is_scalar($value) && !is_string($value)) { $row[$key] = (string) $value; } } @@ -165,7 +167,7 @@ protected function normalizeRow(array $row) * @param array $rows * @return array */ - protected function normalizeRows(array $rows) + protected function normalizeRows(array $rows): array { foreach ($rows as $index => $row) { $rows[$index] = $this->normalizeRow($row); diff --git a/src/Drivers/ResultSet.php b/src/Drivers/ResultSet.php index 6f86e565..95287d8a 100644 --- a/src/Drivers/ResultSet.php +++ b/src/Drivers/ResultSet.php @@ -10,42 +10,42 @@ class ResultSet implements ResultSetInterface /** * @var int */ - protected $num_rows = 0; + protected int $num_rows = 0; /** * @var int */ - protected $cursor = 0; + protected int $cursor = 0; /** * @var int */ - protected $next_cursor = 0; + protected int $next_cursor = 0; /** * @var int */ - protected $affected_rows = 0; // leave to 0 so SELECT etc. will be coherent + protected int $affected_rows = 0; // leave to 0 so SELECT etc. will be coherent /** * @var array */ - protected $fields; + protected array $fields = array(); /** * @var null|array */ - protected $stored; + protected array|int|null $stored = null; /** * @var null|array */ - protected $fetched; + protected ?array $fetched = null; /** * @var ResultSetAdapterInterface */ - protected $adapter; + protected ResultSetAdapterInterface $adapter; /** * @param ResultSetAdapterInterface $adapter @@ -63,7 +63,7 @@ public function __construct(ResultSetAdapterInterface $adapter) /** * @inheritdoc */ - public function hasRow($num) + public function hasRow(int $num): bool { return $num >= 0 && $num < $this->num_rows; } @@ -71,7 +71,7 @@ public function hasRow($num) /** * @inheritdoc */ - public function hasNextRow() + public function hasNextRow(): bool { return $this->cursor + 1 < $this->num_rows; } @@ -79,7 +79,7 @@ public function hasNextRow() /** * @inheritdoc */ - public function getAffectedRows() + public function getAffectedRows(): int { return $this->affected_rows; } @@ -87,19 +87,21 @@ public function getAffectedRows() /** * @inheritdoc */ - #[\ReturnTypeWillChange] - public function offsetExists($offset) + public function offsetExists(mixed $offset): bool { - return $this->hasRow($offset); + return is_int($offset) && $this->hasRow($offset); } /** * @inheritdoc * @throws ResultSetException */ - #[\ReturnTypeWillChange] - public function offsetGet($offset) + public function offsetGet(mixed $offset): mixed { + if (!is_int($offset)) { + throw new ResultSetException('The row does not exist.'); + } + return $this->toRow($offset)->fetchAssoc(); } @@ -107,8 +109,7 @@ public function offsetGet($offset) * @inheritdoc * @codeCoverageIgnore */ - #[\ReturnTypeWillChange] - public function offsetSet($offset, $value) + public function offsetSet(mixed $offset, mixed $value): void { throw new \BadMethodCallException('Not implemented'); } @@ -117,8 +118,7 @@ public function offsetSet($offset, $value) * @inheritdoc * @codeCoverageIgnore */ - #[\ReturnTypeWillChange] - public function offsetUnset($offset) + public function offsetUnset(mixed $offset): void { throw new \BadMethodCallException('Not implemented'); } @@ -126,8 +126,7 @@ public function offsetUnset($offset) /** * @inheritdoc */ - #[\ReturnTypeWillChange] - public function current() + public function current(): mixed { $row = $this->fetched; unset($this->fetched); @@ -138,8 +137,7 @@ public function current() /** * @inheritdoc */ - #[\ReturnTypeWillChange] - public function next() + public function next(): void { $this->fetched = $this->fetch(true); } @@ -147,8 +145,7 @@ public function next() /** * @inheritdoc */ - #[\ReturnTypeWillChange] - public function key() + public function key(): int { return (int)$this->cursor; } @@ -156,8 +153,7 @@ public function key() /** * @inheritdoc */ - #[\ReturnTypeWillChange] - public function valid() + public function valid(): bool { if ($this->stored !== null) { return $this->hasRow($this->cursor); @@ -169,8 +165,7 @@ public function valid() /** * @inheritdoc */ - #[\ReturnTypeWillChange] - public function rewind() + public function rewind(): void { if ($this->stored === null) { $this->adapter->rewind(); @@ -185,13 +180,12 @@ public function rewind() * Returns the number of rows in the result set * @inheritdoc */ - #[\ReturnTypeWillChange] - public function count() + public function count(): int { return $this->num_rows; } - protected function init() + protected function init(): void { if ($this->adapter->isDml()) { $this->affected_rows = $this->adapter->getAffectedRows(); @@ -206,7 +200,7 @@ protected function init() * * @return array */ - protected function makeAssoc($numeric_array) + protected function makeAssoc(array $numeric_array): array { $assoc_array = array(); foreach ($numeric_array as $col_key => $col_value) { @@ -221,9 +215,9 @@ protected function makeAssoc($numeric_array) * * @return array|bool|null */ - protected function fetchFromStore($assoc = true) + protected function fetchFromStore(bool $assoc = true): array|false|null { - if ($this->stored === null) { + if ($this->stored === null || !is_array($this->stored)) { return false; } @@ -241,9 +235,9 @@ protected function fetchFromStore($assoc = true) * * @return array|bool */ - protected function fetchAllFromStore($assoc) + protected function fetchAllFromStore(bool $assoc): array|false { - if ($this->stored === null) { + if ($this->stored === null || !is_array($this->stored)) { return false; } @@ -263,7 +257,7 @@ protected function fetchAllFromStore($assoc) * * @return array */ - protected function fetchAll($assoc = true) + protected function fetchAll(bool $assoc = true): array { $fetch_all_result = $this->fetchAllFromStore($assoc); @@ -280,7 +274,7 @@ protected function fetchAll($assoc = true) /** * @inheritdoc */ - public function store() + public function store(): self { if ($this->stored !== null) { return $this; @@ -298,7 +292,7 @@ public function store() /** * @inheritdoc */ - public function getStored() + public function getStored(): array|int { $this->store(); if ($this->adapter->isDml()) { @@ -311,7 +305,7 @@ public function getStored() /** * @inheritdoc */ - public function toRow($num) + public function toRow(int $num): self { if (!$this->hasRow($num)) { throw new ResultSetException('The row does not exist.'); @@ -330,7 +324,7 @@ public function toRow($num) /** * @inheritdoc */ - public function toNextRow() + public function toNextRow(): self { $this->toRow(++$this->cursor); @@ -340,7 +334,7 @@ public function toNextRow() /** * @inheritdoc */ - public function fetchAllAssoc() + public function fetchAllAssoc(): array { return $this->fetchAll(true); } @@ -348,7 +342,7 @@ public function fetchAllAssoc() /** * @inheritdoc */ - public function fetchAllNum() + public function fetchAllNum(): array { return $this->fetchAll(false); } @@ -356,7 +350,7 @@ public function fetchAllNum() /** * @inheritdoc */ - public function fetchAssoc() + public function fetchAssoc(): ?array { return $this->fetch(true); } @@ -364,7 +358,7 @@ public function fetchAssoc() /** * @inheritdoc */ - public function fetchNum() + public function fetchNum(): ?array { return $this->fetch(false); } @@ -374,7 +368,7 @@ public function fetchNum() * * @return array|null */ - protected function fetch($assoc = true) + protected function fetch(bool $assoc = true): ?array { $this->cursor = $this->next_cursor; @@ -392,7 +386,7 @@ protected function fetch($assoc = true) /** * @inheritdoc */ - public function freeResult() + public function freeResult(): self { $this->adapter->freeResult(); diff --git a/src/Drivers/ResultSetAdapterInterface.php b/src/Drivers/ResultSetAdapterInterface.php index 761777a6..78817fda 100644 --- a/src/Drivers/ResultSetAdapterInterface.php +++ b/src/Drivers/ResultSetAdapterInterface.php @@ -7,59 +7,59 @@ interface ResultSetAdapterInterface /** * @return int */ - public function getAffectedRows(); + public function getAffectedRows(): int; /** * @return int */ - public function getNumRows(); + public function getNumRows(): int; /** * @return array */ - public function getFields(); + public function getFields(): array; /** * @return bool */ - public function isDml(); + public function isDml(): bool; /** * @return array */ - public function store(); + public function store(): array; /** * @param int $num */ - public function toRow($num); + public function toRow(int $num): void; /** * Free a result set/Closes the cursor, enabling the statement to be executed again. */ - public function freeResult(); + public function freeResult(): void; /** * Rewind to the first element */ - public function rewind(); + public function rewind(): void; /** * @return bool */ - public function valid(); + public function valid(): bool; /** * @param bool $assoc * * @return array|null */ - public function fetch($assoc = true); + public function fetch(bool $assoc = true): ?array; /** * @param bool $assoc * * @return array */ - public function fetchAll($assoc = true); + public function fetchAll(bool $assoc = true): array; } diff --git a/src/Drivers/ResultSetInterface.php b/src/Drivers/ResultSetInterface.php index d631b4f3..59efac41 100644 --- a/src/Drivers/ResultSetInterface.php +++ b/src/Drivers/ResultSetInterface.php @@ -11,7 +11,7 @@ interface ResultSetInterface extends \ArrayAccess, \Iterator, \Countable * * @return $this */ - public function store(); + public function store(): self; /** * Returns the array as in version 0.9.x @@ -19,7 +19,7 @@ public function store(); * @return array|int * @deprecated Commodity method for simple transition to version 1.0.0 */ - public function getStored(); + public function getStored(): array|int; /** * Checks if the specified row exists @@ -28,7 +28,7 @@ public function getStored(); * * @return bool True if the row exists, false otherwise */ - public function hasRow($row); + public function hasRow(int $row): bool; /** * Moves the cursor to the specified row @@ -38,14 +38,14 @@ public function hasRow($row); * @return $this * @throws ResultSetException If the row does not exist */ - public function toRow($row); + public function toRow(int $row): self; /** * Checks if the next row exists * * @return bool True if the row exists, false otherwise */ - public function hasNextRow(); + public function hasNextRow(): bool; /** * Moves the cursor to the next row @@ -53,7 +53,7 @@ public function hasNextRow(); * @return $this * @throws ResultSetException If the next row does not exist */ - public function toNextRow(); + public function toNextRow(): self; /** * Returns the number of affected rows @@ -61,35 +61,35 @@ public function toNextRow(); * * @return int */ - public function getAffectedRows(); + public function getAffectedRows(): int; /** * Fetches all the rows as an array of associative arrays * * @return array An array of associative arrays */ - public function fetchAllAssoc(); + public function fetchAllAssoc(): array; /** * Fetches all the rows as an array of indexed arrays * * @return array An array of indexed arrays */ - public function fetchAllNum(); + public function fetchAllNum(): array; /** * Fetches all the rows the cursor points to as an associative array * * @return array|null An associative array representing the row */ - public function fetchAssoc(); + public function fetchAssoc(): ?array; /** * Fetches all the rows the cursor points to as an indexed array * * @return array|null An indexed array representing the row */ - public function fetchNum(); + public function fetchNum(): ?array; /** * Frees the database from the result @@ -97,5 +97,5 @@ public function fetchNum(); * * @return $this */ - public function freeResult(); + public function freeResult(): self; } diff --git a/src/Expression.php b/src/Expression.php index ed18aa8e..ffcb9471 100755 --- a/src/Expression.php +++ b/src/Expression.php @@ -13,14 +13,14 @@ class Expression * * @var string */ - protected $string; + protected string $string; /** * The constructor accepts the expression as string * * @param string $string The content to prevent being quoted */ - public function __construct($string = '') + public function __construct(string $string = '') { $this->string = $string; } @@ -30,9 +30,9 @@ public function __construct($string = '') * * @return string The unaltered content of the expression */ - public function value() + public function value(): string { - return (string) $this->string; + return $this->string; } /** @@ -40,7 +40,7 @@ public function value() * * @return string The unaltered content of the expression */ - public function __toString() + public function __toString(): string { return $this->value(); } diff --git a/src/Facet.php b/src/Facet.php index 798322b3..6bc5aca4 100644 --- a/src/Facet.php +++ b/src/Facet.php @@ -16,49 +16,49 @@ class Facet * * @var ConnectionInterface */ - protected $connection; + protected ?ConnectionInterface $connection; /** * An SQL query that is not yet executed or "compiled" * * @var string */ - protected $query; + protected string $query = ''; /** * Array of select elements that will be comma separated. * * @var array */ - protected $facet = array(); + protected array $facet = array(); /** * BY array to be comma separated * * @var array */ - protected $by = array(); + protected string $by = ''; /** * ORDER BY array * * @var array */ - protected $order_by = array(); + protected array $order_by = array(); /** * When not null it adds an offset * * @var null|int */ - protected $offset; + protected ?int $offset = null; /** * When not null it adds a limit * * @var null|int */ - protected $limit; + protected ?int $limit = null; /** * @param ConnectionInterface|null $connection @@ -73,7 +73,7 @@ public function __construct(?ConnectionInterface $connection = null) * * @returns ConnectionInterface|null */ - public function getConnection() + public function getConnection(): ?ConnectionInterface { return $this->connection; } @@ -85,7 +85,7 @@ public function getConnection() * * @return Facet */ - public function setConnection(?ConnectionInterface $connection = null) + public function setConnection(?ConnectionInterface $connection = null): self { $this->connection = $connection; @@ -112,7 +112,7 @@ public function setConnection(?ConnectionInterface $connection = null) * * @return Facet */ - public function facet($columns = null) + public function facet(array|string|null $columns = null): self { if ($columns === null) { throw new SphinxQLException('facet() requires at least one column or function.'); @@ -160,7 +160,7 @@ public function facet($columns = null) * * @return Facet */ - public function facetFunction($function, $params = null) + public function facetFunction(string $function, array|string|null $params = null): self { if (!is_string($function) || trim($function) === '') { throw new SphinxQLException('facetFunction() function name must be a non-empty string.'); @@ -186,7 +186,7 @@ public function facetFunction($function, $params = null) * * @return Facet */ - public function by($column) + public function by(string $column): self { if (!is_string($column) || trim($column) === '') { throw new SphinxQLException('by() column must be a non-empty string.'); @@ -206,7 +206,7 @@ public function by($column) * * @return Facet */ - public function orderBy($column, $direction = null) + public function orderBy(string $column, ?string $direction = null): self { if (!is_string($column) || trim($column) === '') { throw new SphinxQLException('orderBy() column must be a non-empty string.'); @@ -234,7 +234,7 @@ public function orderBy($column, $direction = null) * * @return Facet */ - public function orderByFunction($function, $params = null, $direction = null) + public function orderByFunction(string $function, array|string|null $params = null, ?string $direction = null): self { if (!is_string($function) || trim($function) === '') { throw new SphinxQLException('orderByFunction() function name must be a non-empty string.'); @@ -264,7 +264,7 @@ public function orderByFunction($function, $params = null, $direction = null) * * @return Facet */ - public function limit($offset, $limit = null) + public function limit(int|string $offset, int|string|null $limit = null): self { if ($limit === null) { if (filter_var($offset, FILTER_VALIDATE_INT) === false || (int) $offset < 0) { @@ -296,7 +296,7 @@ public function limit($offset, $limit = null) * * @return Facet */ - public function offset($offset) + public function offset(int|string $offset): self { if (filter_var($offset, FILTER_VALIDATE_INT) === false || (int) $offset < 0) { throw new SphinxQLException('offset() requires a non-negative integer.'); @@ -313,7 +313,7 @@ public function offset($offset) * @return Facet * @throws SphinxQLException In case no column in facet */ - public function compileFacet() + public function compileFacet(): self { $query = 'FACET '; @@ -379,7 +379,7 @@ public function compileFacet() * @return string * @throws SphinxQLException */ - public function getFacet() + public function getFacet(): string { return $this->compileFacet()->query; } @@ -391,7 +391,7 @@ public function getFacet() * @return string|null * @throws SphinxQLException */ - private function normalizeDirection($direction, $method) + private function normalizeDirection(?string $direction, string $method): ?string { if ($direction === null) { return null; diff --git a/src/Helper.php b/src/Helper.php index f18c0b2b..5f46b355 100644 --- a/src/Helper.php +++ b/src/Helper.php @@ -15,17 +15,22 @@ class Helper /** * @var ConnectionInterface */ - protected $connection; + protected ConnectionInterface $connection; /** * @var Capabilities|null */ - protected $capabilities; + protected ?Capabilities $capabilities = null; /** * @var array */ - protected $feature_support_cache = array(); + protected array $feature_support_cache = array(); + + /** + * @var null|array + */ + protected ?array $probe_tables_cache = null; /** * @param ConnectionInterface $connection @@ -40,7 +45,7 @@ public function __construct(ConnectionInterface $connection) * * @return SphinxQL */ - protected function getSphinxQL() + protected function getSphinxQL(): SphinxQL { return new SphinxQL($this->connection); } @@ -52,7 +57,7 @@ protected function getSphinxQL() * * @return SphinxQL A SphinxQL object ready to be ->execute(); */ - protected function query($sql) + protected function query(string $sql): SphinxQL { return $this->getSphinxQL()->query($sql); } @@ -65,7 +70,7 @@ protected function query($sql) * @return array Associative array with Variable_name as key and Value as value * @todo make non static */ - public static function pairsToAssoc($result) + public static function pairsToAssoc(array $result): array { $ordered = array(); @@ -81,7 +86,7 @@ public static function pairsToAssoc($result) * * @return SphinxQL A SphinxQL object ready to be ->execute(); */ - public function showMeta() + public function showMeta(): SphinxQL { return $this->query('SHOW META'); } @@ -91,7 +96,7 @@ public function showMeta() * * @return SphinxQL A SphinxQL object ready to be ->execute(); */ - public function showWarnings() + public function showWarnings(): SphinxQL { return $this->query('SHOW WARNINGS'); } @@ -101,7 +106,7 @@ public function showWarnings() * * @return SphinxQL A SphinxQL object ready to be ->execute(); */ - public function showStatus() + public function showStatus(): SphinxQL { return $this->query('SHOW STATUS'); } @@ -111,7 +116,7 @@ public function showStatus() * * @return SphinxQL */ - public function showProfile() + public function showProfile(): SphinxQL { return $this->query('SHOW PROFILE'); } @@ -121,7 +126,7 @@ public function showProfile() * * @return SphinxQL */ - public function showPlan() + public function showPlan(): SphinxQL { return $this->query('SHOW PLAN'); } @@ -131,7 +136,7 @@ public function showPlan() * * @return SphinxQL */ - public function showThreads() + public function showThreads(): SphinxQL { return $this->query('SHOW THREADS'); } @@ -141,7 +146,7 @@ public function showThreads() * * @return SphinxQL */ - public function showVersion() + public function showVersion(): SphinxQL { return $this->query('SHOW VERSION'); } @@ -151,7 +156,7 @@ public function showVersion() * * @return SphinxQL */ - public function showPlugins() + public function showPlugins(): SphinxQL { return $this->query('SHOW PLUGINS'); } @@ -161,7 +166,7 @@ public function showPlugins() * * @return SphinxQL */ - public function showAgentStatus() + public function showAgentStatus(): SphinxQL { return $this->query('SHOW AGENT STATUS'); } @@ -171,7 +176,7 @@ public function showAgentStatus() * * @return SphinxQL */ - public function showScroll() + public function showScroll(): SphinxQL { return $this->query('SHOW SCROLL'); } @@ -181,7 +186,7 @@ public function showScroll() * * @return SphinxQL */ - public function showDatabases() + public function showDatabases(): SphinxQL { return $this->query('SHOW DATABASES'); } @@ -191,7 +196,7 @@ public function showDatabases() * * @return SphinxQL */ - public function showCharacterSet() + public function showCharacterSet(): SphinxQL { return $this->query('SHOW CHARACTER SET'); } @@ -201,7 +206,7 @@ public function showCharacterSet() * * @return SphinxQL */ - public function showCollation() + public function showCollation(): SphinxQL { return $this->query('SHOW COLLATION'); } @@ -213,9 +218,19 @@ public function showCollation() * @throws Exception\ConnectionException * @throws Exception\DatabaseException */ - public function showTables( $index ) + public function showTables($index = null): SphinxQL { - $this->assertNonEmptyString($index, 'showTables() index'); + if ($index === null) { + return $this->query('SHOW TABLES'); + } + + if (!is_string($index)) { + throw new SphinxQLException('showTables() index must be null or a string.'); + } + + if (trim($index) === '') { + return $this->query('SHOW TABLES'); + } return $this->query('SHOW TABLES LIKE '.$this->connection->quote($index)); } @@ -225,7 +240,7 @@ public function showTables( $index ) * * @return SphinxQL A SphinxQL object ready to be ->execute(); */ - public function showVariables() + public function showVariables(): SphinxQL { return $this->query('SHOW VARIABLES'); } @@ -235,7 +250,7 @@ public function showVariables() * * @return Capabilities */ - public function getCapabilities() + public function getCapabilities(): Capabilities { if ($this->capabilities !== null) { return $this->capabilities; @@ -243,6 +258,7 @@ public function getCapabilities() $version = $this->detectVersionString(); $engine = $this->detectEngine($version); + $buddy = ($engine === 'MANTICORE' && $this->supportsCommand('SHOW VERSION')); $features = array( // Builder-level features are available in this library regardless of backend. @@ -255,9 +271,9 @@ public function getCapabilities() 'manticore' => ($engine === 'MANTICORE'), 'sphinx2' => ($engine === 'SPHINX2'), 'sphinx3' => ($engine === 'SPHINX3'), - 'buddy' => ($engine === 'MANTICORE' && $this->supportsCommand('SHOW VERSION')), - 'call_qsuggest' => ($engine === 'MANTICORE'), - 'call_autocomplete' => ($engine === 'MANTICORE'), + 'buddy' => $buddy, + 'call_qsuggest' => $buddy, + 'call_autocomplete' => $buddy, ); $this->feature_support_cache = $features; @@ -274,7 +290,7 @@ public function getCapabilities() * @return bool * @throws SphinxQLException */ - public function supports($feature) + public function supports($feature): bool { if (!is_string($feature) || trim($feature) === '') { throw new SphinxQLException('supports() feature must be a non-empty string.'); @@ -321,13 +337,16 @@ public function supports($feature) 'show_queries' => 'SHOW QUERIES', 'show_character_set' => 'SHOW CHARACTER SET', 'show_collation' => 'SHOW COLLATION', - 'show_table_settings' => 'SHOW TABLE rt SETTINGS', - 'show_table_indexes' => 'SHOW TABLE rt INDEXES', - 'call_suggest' => "CALL SUGGEST('teh', 'rt')", ); if (array_key_exists($normalized, $probes)) { $this->feature_support_cache[$normalized] = $this->supportsCommand($probes[$normalized]); + } elseif ($normalized === 'show_table_settings') { + $this->feature_support_cache[$normalized] = $this->supportsTableProbe('SHOW TABLE %s SETTINGS'); + } elseif ($normalized === 'show_table_indexes') { + $this->feature_support_cache[$normalized] = $this->supportsTableProbe('SHOW TABLE %s INDEXES'); + } elseif ($normalized === 'call_suggest') { + $this->feature_support_cache[$normalized] = $this->supportsTableProbe("CALL SUGGEST('teh', '%s')"); } else { $this->feature_support_cache[$normalized] = false; } @@ -351,7 +370,7 @@ public function supports($feature) * @return self * @throws UnsupportedFeatureException */ - public function requireSupport($feature, $context = '') + public function requireSupport($feature, $context = ''): self { if (!$this->supports($feature)) { $caps = $this->getCapabilities(); @@ -371,7 +390,7 @@ public function requireSupport($feature, $context = '') * * @return SphinxQL */ - public function showCreateTable($table) + public function showCreateTable($table): SphinxQL { $this->assertNonEmptyString($table, 'showCreateTable() table'); @@ -385,7 +404,7 @@ public function showCreateTable($table) * * @return SphinxQL */ - public function showTableStatus($table = null) + public function showTableStatus($table = null): SphinxQL { if ($table === null) { return $this->query('SHOW TABLE STATUS'); @@ -404,7 +423,7 @@ public function showTableStatus($table = null) * * @return SphinxQL */ - public function showTableStatusLike($table, $pattern) + public function showTableStatusLike($table, $pattern): SphinxQL { $this->assertNonEmptyString($table, 'showTableStatusLike() table'); $this->assertNonEmptyString($pattern, 'showTableStatusLike() pattern'); @@ -419,7 +438,7 @@ public function showTableStatusLike($table, $pattern) * * @return SphinxQL */ - public function showTableSettings($table) + public function showTableSettings($table): SphinxQL { $this->assertNonEmptyString($table, 'showTableSettings() table'); @@ -434,7 +453,7 @@ public function showTableSettings($table) * * @return SphinxQL */ - public function showTableSettingsLike($table, $pattern) + public function showTableSettingsLike($table, $pattern): SphinxQL { $this->assertNonEmptyString($table, 'showTableSettingsLike() table'); $this->assertNonEmptyString($pattern, 'showTableSettingsLike() pattern'); @@ -449,7 +468,7 @@ public function showTableSettingsLike($table, $pattern) * * @return SphinxQL */ - public function showTableIndexes($table) + public function showTableIndexes($table): SphinxQL { $this->assertNonEmptyString($table, 'showTableIndexes() table'); @@ -464,7 +483,7 @@ public function showTableIndexes($table) * * @return SphinxQL */ - public function showTableIndexesLike($table, $pattern) + public function showTableIndexesLike($table, $pattern): SphinxQL { $this->assertNonEmptyString($table, 'showTableIndexesLike() table'); $this->assertNonEmptyString($pattern, 'showTableIndexesLike() pattern'); @@ -477,7 +496,7 @@ public function showTableIndexesLike($table, $pattern) * * @return SphinxQL */ - public function showQueries() + public function showQueries(): SphinxQL { return $this->query('SHOW QUERIES'); } @@ -493,7 +512,7 @@ public function showQueries() * @throws Exception\ConnectionException * @throws Exception\DatabaseException */ - public function setVariable($name, $value, $global = false) + public function setVariable($name, $value, $global = false): SphinxQL { if (!is_bool($global)) { throw new SphinxQLException('setVariable() global flag must be boolean.'); @@ -544,7 +563,7 @@ public function setVariable($name, $value, $global = false) * @throws Exception\ConnectionException * @throws Exception\DatabaseException */ - public function callSnippets($data, $index, $query, $options = array()) + public function callSnippets($data, $index, $query, $options = array()): SphinxQL { if (!is_array($data) && !is_string($data)) { throw new SphinxQLException('callSnippets() data must be a string or array of strings.'); @@ -598,7 +617,7 @@ public function callSnippets($data, $index, $query, $options = array()) * @throws Exception\ConnectionException * @throws Exception\DatabaseException */ - public function callKeywords($text, $index, $hits = null) + public function callKeywords($text, $index, $hits = null): SphinxQL { $this->assertNonEmptyString($text, 'callKeywords() text'); $this->assertNonEmptyString($index, 'callKeywords() index'); @@ -623,7 +642,7 @@ public function callKeywords($text, $index, $hits = null) * * @return SphinxQL */ - public function callQSuggest($text, $index, array $options = array()) + public function callQSuggest($text, $index, array $options = array()): SphinxQL { $this->requireSupport('call_qsuggest', 'callQSuggest()'); $this->assertNonEmptyString($text, 'callQSuggest() text'); @@ -645,7 +664,7 @@ public function callQSuggest($text, $index, array $options = array()) * * @return SphinxQL */ - public function callSuggest($text, $index, array $options = array()) + public function callSuggest($text, $index, array $options = array()): SphinxQL { $this->assertNonEmptyString($text, 'callSuggest() text'); $this->assertNonEmptyString($index, 'callSuggest() index'); @@ -666,7 +685,7 @@ public function callSuggest($text, $index, array $options = array()) * * @return SphinxQL */ - public function callAutocomplete($text, $index, array $options = array()) + public function callAutocomplete($text, $index, array $options = array()): SphinxQL { $this->requireSupport('call_autocomplete', 'callAutocomplete()'); $this->assertNonEmptyString($text, 'callAutocomplete() text'); @@ -686,7 +705,7 @@ public function callAutocomplete($text, $index, array $options = array()) * * @return SphinxQL A SphinxQL object ready to be ->execute(); */ - public function describe($index) + public function describe($index): SphinxQL { $this->assertNonEmptyString($index, 'describe() index'); @@ -704,7 +723,7 @@ public function describe($index) * @throws Exception\ConnectionException * @throws Exception\DatabaseException */ - public function createFunction($udf_name, $returns, $so_name) + public function createFunction($udf_name, $returns, $so_name): SphinxQL { $this->assertNonEmptyString($udf_name, 'createFunction() udf_name'); $this->assertNonEmptyString($returns, 'createFunction() returns'); @@ -726,7 +745,7 @@ public function createFunction($udf_name, $returns, $so_name) * * @return SphinxQL A SphinxQL object ready to be ->execute(); */ - public function dropFunction($udf_name) + public function dropFunction($udf_name): SphinxQL { $this->assertNonEmptyString($udf_name, 'dropFunction() udf_name'); @@ -741,7 +760,7 @@ public function dropFunction($udf_name) * * @return SphinxQL A SphinxQL object ready to be ->execute(); */ - public function attachIndex($disk_index, $rt_index) + public function attachIndex($disk_index, $rt_index): SphinxQL { $this->assertNonEmptyString($disk_index, 'attachIndex() disk_index'); $this->assertNonEmptyString($rt_index, 'attachIndex() rt_index'); @@ -756,7 +775,7 @@ public function attachIndex($disk_index, $rt_index) * * @return SphinxQL A SphinxQL object ready to be ->execute(); */ - public function flushRtIndex($index) + public function flushRtIndex($index): SphinxQL { $this->assertNonEmptyString($index, 'flushRtIndex() index'); @@ -770,7 +789,7 @@ public function flushRtIndex($index) * * @return SphinxQL A SphinxQL object ready to be ->execute(); */ - public function truncateRtIndex($index) + public function truncateRtIndex($index): SphinxQL { $this->assertNonEmptyString($index, 'truncateRtIndex() index'); @@ -784,7 +803,7 @@ public function truncateRtIndex($index) * * @return SphinxQL A SphinxQL object ready to be ->execute(); */ - public function optimizeIndex($index) + public function optimizeIndex($index): SphinxQL { $this->assertNonEmptyString($index, 'optimizeIndex() index'); @@ -798,7 +817,7 @@ public function optimizeIndex($index) * * @return SphinxQL A SphinxQL object ready to be ->execute(); */ - public function showIndexStatus($index) + public function showIndexStatus($index): SphinxQL { $this->assertNonEmptyString($index, 'showIndexStatus() index'); @@ -813,7 +832,7 @@ public function showIndexStatus($index) * * @return SphinxQL */ - public function showIndexStatusLike($index, $pattern) + public function showIndexStatusLike($index, $pattern): SphinxQL { $this->assertNonEmptyString($index, 'showIndexStatusLike() index'); $this->assertNonEmptyString($pattern, 'showIndexStatusLike() pattern'); @@ -828,7 +847,7 @@ public function showIndexStatusLike($index, $pattern) * * @return SphinxQL A SphinxQL object ready to be ->execute(); */ - public function flushRamchunk($index) + public function flushRamchunk($index): SphinxQL { $this->assertNonEmptyString($index, 'flushRamchunk() index'); @@ -840,7 +859,7 @@ public function flushRamchunk($index) * * @return SphinxQL */ - public function flushAttributes() + public function flushAttributes(): SphinxQL { return $this->query('FLUSH ATTRIBUTES'); } @@ -850,7 +869,7 @@ public function flushAttributes() * * @return SphinxQL */ - public function flushHostnames() + public function flushHostnames(): SphinxQL { return $this->query('FLUSH HOSTNAMES'); } @@ -860,7 +879,7 @@ public function flushHostnames() * * @return SphinxQL */ - public function flushLogs() + public function flushLogs(): SphinxQL { return $this->query('FLUSH LOGS'); } @@ -870,7 +889,7 @@ public function flushLogs() * * @return SphinxQL */ - public function reloadPlugins() + public function reloadPlugins(): SphinxQL { return $this->query('RELOAD PLUGINS'); } @@ -882,7 +901,7 @@ public function reloadPlugins() * * @return SphinxQL */ - public function kill($queryId) + public function kill($queryId): SphinxQL { if (filter_var($queryId, FILTER_VALIDATE_INT) === false || (int) $queryId <= 0) { throw new SphinxQLException('kill() queryId must be a positive integer.'); @@ -897,7 +916,7 @@ public function kill($queryId) * * @throws SphinxQLException */ - private function assertNonEmptyString($value, $field) + private function assertNonEmptyString($value, $field): void { if (!is_string($value) || trim($value) === '') { throw new SphinxQLException($field.' must be a non-empty string.'); @@ -911,7 +930,7 @@ private function assertNonEmptyString($value, $field) * * @return array */ - private function normalizeCallOptions($methodName, $callName, array $options) + private function normalizeCallOptions($methodName, $callName, array $options): array { $schema = $this->getCallOptionSchema($callName); @@ -980,7 +999,7 @@ private function normalizeCallOptions($methodName, $callName, array $options) * * @return array> */ - private function getCallOptionSchema($callName) + private function getCallOptionSchema($callName): array { if ($callName === 'SUGGEST' || $callName === 'QSUGGEST') { return array( @@ -1021,7 +1040,7 @@ private function getCallOptionSchema($callName) * * @return int */ - private function normalizeBooleanOption($methodName, $option, $value) + private function normalizeBooleanOption($methodName, $option, $value): int { if (is_bool($value)) { return $value ? 1 : 0; @@ -1045,7 +1064,7 @@ private function normalizeBooleanOption($methodName, $option, $value) * * @return int */ - private function normalizeIntegerOption($methodName, $option, $value, $min = null, $max = null) + private function normalizeIntegerOption($methodName, $option, $value, $min = null, $max = null): int { $normalized = filter_var($value, FILTER_VALIDATE_INT); if ($normalized === false) { @@ -1071,7 +1090,7 @@ private function normalizeIntegerOption($methodName, $option, $value, $min = nul * * @return string */ - private function normalizeStringOption($methodName, $option, $value, $allowEmpty = false) + private function normalizeStringOption($methodName, $option, $value, $allowEmpty = false): string { if (!is_string($value)) { throw new SphinxQLException($methodName.' option "'.$option.'" must be a string.'); @@ -1092,7 +1111,7 @@ private function normalizeStringOption($methodName, $option, $value, $allowEmpty * * @return string */ - private function normalizeEnumStringOption($methodName, $option, $value, array $allowed) + private function normalizeEnumStringOption($methodName, $option, $value, array $allowed): string { if (!is_string($value) || trim($value) === '') { throw new SphinxQLException($methodName.' option "'.$option.'" must be a non-empty string.'); @@ -1116,7 +1135,7 @@ private function normalizeEnumStringOption($methodName, $option, $value, array $ * @return string * @throws SphinxQLException */ - private function buildCallWithOptions($callName, array $requiredArgs, array $options = array()) + private function buildCallWithOptions($callName, array $requiredArgs, array $options = array()): string { if (!is_array($options)) { throw new SphinxQLException($callName.' options must be an associative array.'); @@ -1139,7 +1158,7 @@ private function buildCallWithOptions($callName, array $requiredArgs, array $opt /** * @return string */ - private function detectVersionString() + private function detectVersionString(): string { try { $rows = $this->connection->query('SELECT VERSION()')->getStored(); @@ -1156,7 +1175,7 @@ private function detectVersionString() * * @return string */ - private function detectEngine($version) + private function detectEngine($version): string { $versionLower = strtolower((string) $version); @@ -1178,7 +1197,7 @@ private function detectEngine($version) * * @return string */ - private function normalizeFeatureName($feature) + private function normalizeFeatureName($feature): string { $normalized = strtolower(trim($feature)); $normalized = str_replace(array('-', ' '), '_', $normalized); @@ -1191,7 +1210,7 @@ private function normalizeFeatureName($feature) * * @return bool */ - private function supportsCommand($sqlProbe) + private function supportsCommand($sqlProbe): bool { try { $this->connection->query($sqlProbe)->getStored(); @@ -1201,4 +1220,74 @@ private function supportsCommand($sqlProbe) return false; } } + + /** + * @param string $sqlProbeTemplate Template with a single `%s` table placeholder. + * + * @return bool + */ + private function supportsTableProbe($sqlProbeTemplate): bool + { + foreach ($this->getProbeTables() as $table) { + if ($this->supportsCommand(sprintf($sqlProbeTemplate, $table))) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private function getProbeTables(): array + { + if ($this->probe_tables_cache !== null) { + return $this->probe_tables_cache; + } + + $tables = array('rt'); + + try { + $rows = $this->connection->query('SHOW TABLES')->getStored(); + if (is_array($rows)) { + foreach ($rows as $row) { + $table = $this->extractProbeTableName($row); + if ($table !== null) { + $tables[] = $table; + } + } + } + } catch (\Exception $exception) { + // Fall back to the default probe table. + } + + $this->probe_tables_cache = array_values(array_unique($tables)); + + return $this->probe_tables_cache; + } + + /** + * @param mixed $row + * + * @return null|string + */ + private function extractProbeTableName($row): ?string + { + if (!is_array($row) || empty($row)) { + return null; + } + + if (array_key_exists('Index', $row) && is_string($row['Index']) && trim($row['Index']) !== '') { + return $row['Index']; + } + + foreach ($row as $value) { + if (is_string($value) && trim($value) !== '') { + return $value; + } + } + + return null; + } } diff --git a/src/MatchBuilder.php b/src/MatchBuilder.php index 65a067dc..9ada8e92 100644 --- a/src/MatchBuilder.php +++ b/src/MatchBuilder.php @@ -12,21 +12,21 @@ class MatchBuilder * * @var string */ - protected $last_compiled; + protected string $last_compiled = ''; /** * List of match operations. * * @var array */ - protected $tokens = array(); + protected array $tokens = array(); /** * The owning SphinxQL object; used for escaping text. * * @var SphinxQL */ - protected $sphinxql; + protected SphinxQL $sphinxql; /** * @param SphinxQL $sphinxql @@ -60,7 +60,7 @@ public function __construct(SphinxQL $sphinxql) * * @return $this */ - public function match($keywords = null) + public function match(string|Expression|MatchBuilder|\Closure|null $keywords = null): self { if ($keywords !== null) { $this->tokens[] = array('MATCH' => $keywords); @@ -83,7 +83,7 @@ public function match($keywords = null) * * @return $this */ - public function orMatch($keywords = null) + public function orMatch(string|Expression|MatchBuilder|\Closure|null $keywords = null): self { $this->tokens[] = array('OPERATOR' => '| '); $this->match($keywords); @@ -105,7 +105,7 @@ public function orMatch($keywords = null) * * @return $this */ - public function maybe($keywords = null) + public function maybe(string|Expression|MatchBuilder|\Closure|null $keywords = null): self { $this->tokens[] = array('OPERATOR' => 'MAYBE '); $this->match($keywords); @@ -127,7 +127,7 @@ public function maybe($keywords = null) * * @return $this */ - public function not($keyword = null) + public function not(string|Expression|MatchBuilder|\Closure|null $keyword = null): self { $this->tokens[] = array('OPERATOR' => '-'); $this->match($keyword); @@ -162,7 +162,7 @@ public function not($keyword = null) * * @return $this */ - public function field($fields, $limit = null) + public function field(array|string $fields, mixed $limit = null): self { if (is_string($fields)) { $fields = func_get_args(); @@ -197,7 +197,7 @@ public function field($fields, $limit = null) * * @return $this */ - public function ignoreField($fields) + public function ignoreField(array|string $fields): self { if (is_string($fields)) { $fields = func_get_args(); @@ -222,7 +222,7 @@ public function ignoreField($fields) * * @return $this */ - public function phrase($keywords) + public function phrase(string $keywords): self { $this->tokens[] = array('PHRASE' => $keywords); @@ -240,7 +240,7 @@ public function phrase($keywords) * * @return $this */ - public function orPhrase($keywords) + public function orPhrase(string $keywords): self { $this->tokens[] = array('OPERATOR' => '| '); $this->phrase($keywords); @@ -260,7 +260,7 @@ public function orPhrase($keywords) * * @return $this */ - public function proximity($keywords, $distance) + public function proximity(string $keywords, int $distance): self { $this->tokens[] = array( 'PROXIMITY' => $distance, @@ -285,7 +285,7 @@ public function proximity($keywords, $distance) * * @return $this */ - public function quorum($keywords, $threshold) + public function quorum(string $keywords, int|float $threshold): self { $this->tokens[] = array( 'QUORUM' => $threshold, @@ -309,7 +309,7 @@ public function quorum($keywords, $threshold) * * @return $this */ - public function before($keywords = null) + public function before(string|Expression|MatchBuilder|\Closure|null $keywords = null): self { $this->tokens[] = array('OPERATOR' => '<< '); $this->match($keywords); @@ -331,7 +331,7 @@ public function before($keywords = null) * * @return $this */ - public function exact($keyword = null) + public function exact(string|Expression|MatchBuilder|\Closure|null $keyword = null): self { $this->tokens[] = array('OPERATOR' => '='); $this->match($keyword); @@ -354,7 +354,7 @@ public function exact($keyword = null) * * @return $this */ - public function boost($keyword, $amount = null) + public function boost(string|int|float $keyword, int|float|null $amount = null): self { if ($amount === null) { $amount = $keyword; @@ -381,7 +381,7 @@ public function boost($keyword, $amount = null) * * @return $this */ - public function near($keywords, $distance = null) + public function near(string|Expression|MatchBuilder|\Closure|int $keywords, ?int $distance = null): self { $this->tokens[] = array('NEAR' => $distance ?: $keywords); if ($distance !== null) { @@ -405,7 +405,7 @@ public function near($keywords, $distance = null) * * @return $this */ - public function sentence($keywords = null) + public function sentence(string|Expression|MatchBuilder|\Closure|null $keywords = null): self { $this->tokens[] = array('OPERATOR' => 'SENTENCE '); $this->match($keywords); @@ -427,7 +427,7 @@ public function sentence($keywords = null) * * @return $this */ - public function paragraph($keywords = null) + public function paragraph(string|Expression|MatchBuilder|\Closure|null $keywords = null): self { $this->tokens[] = array('OPERATOR' => 'PARAGRAPH '); $this->match($keywords); @@ -453,7 +453,7 @@ public function paragraph($keywords = null) * * @return $this */ - public function zone($zones, $keywords = null) + public function zone(array|string $zones, string|Expression|MatchBuilder|\Closure|null $keywords = null): self { if (is_string($zones)) { $zones = array($zones); @@ -480,7 +480,7 @@ public function zone($zones, $keywords = null) * * @return $this */ - public function zonespan($zone, $keywords = null) + public function zonespan(string $zone, string|Expression|MatchBuilder|\Closure|null $keywords = null): self { $this->tokens[] = array('ZONESPAN' => $zone); $this->match($keywords); @@ -493,7 +493,7 @@ public function zonespan($zone, $keywords = null) * * @return $this */ - public function compile() + public function compile(): self { $query = ''; foreach ($this->tokens as $token) { @@ -552,7 +552,7 @@ public function compile() * * @return string The last compiled match expression. */ - public function getCompiled() + public function getCompiled(): string { return $this->last_compiled; } diff --git a/src/Percolate.php b/src/Percolate.php index 14bc3a10..d2e37f9c 100644 --- a/src/Percolate.php +++ b/src/Percolate.php @@ -40,50 +40,50 @@ class Percolate /** * @var ConnectionInterface */ - protected $connection; + protected ConnectionInterface $connection; /** * Documents for CALL PQ * * @var array|string */ - protected $documents; + protected array|string|null $documents = null; /** * Index name * * @var string */ - protected $index; + protected ?string $index = null; /** * Insert query * * @var string */ - protected $query; + protected ?string $query = null; /** * Options for CALL PQ * @var array */ - protected $options = [self::OPTION_DOCS_JSON => 1]; + protected array $options = array(self::OPTION_DOCS_JSON => 1); /** * @var string */ - protected $filters = ''; + protected string $filters = ''; /** * Query type (call | insert) * * @var string */ - protected $type = 'call'; + protected string $type = 'call'; /** INSERT STATEMENT **/ - protected $tags = []; + protected string $tags = ''; /** * Throw exceptions flag. @@ -91,11 +91,11 @@ class Percolate * * @var int */ - protected $throwExceptions = 0; + protected int $throwExceptions = 0; /** * @var array */ - protected $escapeChars = [ + protected array $escapeChars = array( '\\' => '\\\\', '-' => '\\-', '~' => '\\~', @@ -104,10 +104,10 @@ class Percolate "'" => "\\'", '/' => '\\/', '!' => '\\!' - ]; + ); /** @var SphinxQL */ - protected $sphinxQL; + protected SphinxQL $sphinxQL; /** * CALL PQ option constants @@ -131,15 +131,15 @@ public function __construct(ConnectionInterface $connection) /** * Clear all fields after execute */ - private function clear() + private function clear(): void { $this->documents = null; $this->index = null; $this->query = null; - $this->options = [self::OPTION_DOCS_JSON => 1]; + $this->options = array(self::OPTION_DOCS_JSON => 1); $this->type = 'call'; $this->filters = ''; - $this->tags = []; + $this->tags = ''; } /** @@ -151,7 +151,7 @@ private function clear() * @return $this * @throws SphinxQLException */ - public function from($index) + public function from($index): self { if (!is_string($index) || trim($index) === '') { throw new SphinxQLException('Index can\'t be empty'); @@ -170,7 +170,7 @@ public function from($index) * @return $this * @throws SphinxQLException */ - public function into($index) + public function into($index): self { if (!is_string($index) || trim($index) === '') { throw new SphinxQLException('Index can\'t be empty'); @@ -186,7 +186,7 @@ public function into($index) * * @return string mixed */ - protected function escapeString($query) + protected function escapeString(string $query): string { if (!is_string($query)) { throw new SphinxQLException('Expected string value.'); @@ -203,7 +203,7 @@ protected function escapeString($query) * @param $query * @return mixed */ - protected function clearString($query) + protected function clearString(string $query): string { if (!is_string($query)) { throw new SphinxQLException('Expected string value.'); @@ -224,7 +224,7 @@ protected function clearString($query) * * @return $this */ - public function tags($tags) + public function tags($tags): self { if (is_array($tags)) { if (count($tags) === 0) { @@ -255,7 +255,7 @@ public function tags($tags) * * @throws SphinxQLException */ - public function filter($filter) + public function filter($filter): self { if (!is_string($filter) || trim($filter) === '') { throw new SphinxQLException('Filter can\'t be empty'); @@ -273,7 +273,7 @@ public function filter($filter) * @return $this * @throws SphinxQLException */ - public function insert($query, $noEscape = false) + public function insert($query, $noEscape = false): self { $this->clear(); @@ -294,7 +294,7 @@ public function insert($query, $noEscape = false) * * @return array */ - private function generateInsert() + private function generateInsert(): array { $insertArray = ['query' => $this->query]; @@ -317,7 +317,7 @@ private function generateInsert() * @throws Exception\DatabaseException * @throws SphinxQLException */ - public function execute() + public function execute(): \Foolz\SphinxQL\Drivers\ResultSetInterface { if ($this->type == 'insert') { @@ -343,7 +343,7 @@ public function execute() * @return $this * @throws SphinxQLException */ - private function setOption($key, $value) + private function setOption(string $key, int|string|bool $value): self { $value = intval($value); if (!in_array($key, [ @@ -374,7 +374,7 @@ private function setOption($key, $value) * @return $this * @throws SphinxQLException */ - public function documents($documents) + public function documents($documents): self { if (empty($documents)) { throw new SphinxQLException('Document can\'t be empty'); @@ -400,7 +400,7 @@ public function documents($documents) * @return $this * @throws SphinxQLException */ - public function options(array $options) + public function options(array $options): self { foreach ($options as $option => $value) { $this->setOption($option, $value); @@ -414,7 +414,7 @@ public function options(array $options) * * @return string string */ - protected function getOptions() + protected function getOptions(): string { $options = ''; if (!empty($this->options)) { @@ -431,7 +431,7 @@ protected function getOptions() * @param array $arr * @return bool */ - private function isAssocArray(array $arr) + private function isAssocArray(array $arr): bool { if (array() === $arr) { return false; @@ -464,7 +464,7 @@ private function isAssocArray(array $arr) * @return string * @throws SphinxQLException */ - protected function getDocuments() + protected function getDocuments(): string { if (!empty($this->documents)) { @@ -557,7 +557,7 @@ protected function getDocuments() * * @return $this */ - public function callPQ() + public function callPQ(): self { $this->clear(); $this->type = 'call'; @@ -574,7 +574,7 @@ public function callPQ() * * @return bool|string */ - private function prepareFromJson($data) + private function prepareFromJson(array|string $data): string|false { if (is_array($data)) { if (is_array($data[0])) { @@ -610,7 +610,7 @@ private function prepareFromJson($data) * @param array $array * @return string */ - private function convertArrayForQuery(array $array) + private function convertArrayForQuery(array $array): string { $documents = []; foreach ($array as $document) { @@ -627,7 +627,7 @@ private function convertArrayForQuery(array $array) * @param string $string * @return string */ - private function quoteString($string) + private function quoteString(string $string): string { return '\'' . $string . '\''; } @@ -638,7 +638,7 @@ private function quoteString($string) * * @return string */ - public function getLastQuery() + public function getLastQuery(): ?string { return $this->sphinxQL->getCompiled(); } diff --git a/src/SphinxQL.php b/src/SphinxQL.php index 1020fa58..353b6594 100644 --- a/src/SphinxQL.php +++ b/src/SphinxQL.php @@ -19,174 +19,174 @@ class SphinxQL * * @var ConnectionInterface */ - protected $connection; + protected ?ConnectionInterface $connection; /** * The last result object. * * @var array */ - protected $last_result; + protected ResultSetInterface|MultiResultSetInterface|array|int|null $last_result = null; /** * The last compiled query. * * @var string */ - protected $last_compiled; + protected ?string $last_compiled = null; /** * The last chosen method (select, insert, replace, update, delete). * * @var string */ - protected $type; + protected ?string $type = null; /** * An SQL query that is not yet executed or "compiled" * * @var string */ - protected $query; + protected ?string $query = null; /** * Array of select elements that will be comma separated. * * @var array */ - protected $select = array(); + protected array $select = array(); /** * From in SphinxQL is the list of indexes that will be used * * @var array */ - protected $from = array(); + protected array|\Closure|SphinxQL $from = array(); /** * JOIN clauses for SELECT queries * * @var array */ - protected $joins = array(); + protected array $joins = array(); /** * WHERE clause token list (conditions and grouping parenthesis) * * @var array */ - protected $where = array(); + protected array $where = array(); /** * The list of matches for the MATCH function in SphinxQL * * @var array */ - protected $match = array(); + protected array $match = array(); /** * GROUP BY array to be comma separated * * @var array */ - protected $group_by = array(); + protected array $group_by = array(); /** * When not null changes 'GROUP BY' to 'GROUP N BY' * * @var null|int */ - protected $group_n_by; + protected ?int $group_n_by = null; /** * ORDER BY array * * @var array */ - protected $within_group_order_by = array(); + protected array $within_group_order_by = array(); /** * HAVING clause token list (conditions and grouping parenthesis) * * @var array */ - protected $having = array(); + protected array $having = array(); /** * ORDER BY array * * @var array */ - protected $order_by = array(); + protected array $order_by = array(); /** * When not null it adds an offset * * @var null|int */ - protected $offset; + protected ?int $offset = null; /** * When not null it adds a limit * * @var null|int */ - protected $limit; + protected ?int $limit = null; /** * Value of INTO query for INSERT or REPLACE * * @var null|string */ - protected $into; + protected ?string $into = null; /** * Array of columns for INSERT or REPLACE * * @var array */ - protected $columns = array(); + protected array $columns = array(); /** * Array OF ARRAYS of values for INSERT or REPLACE * * @var array */ - protected $values = array(); + protected array $values = array(); /** * Array arrays containing column and value for SET in UPDATE * * @var array */ - protected $set = array(); + protected array $set = array(); /** * Array of OPTION specific to SphinxQL * * @var array */ - protected $options = array(); + protected array $options = array(); /** * Array of FACETs * * @var Facet[] */ - protected $facets = array(); + protected array $facets = array(); /** * The reference to the object that queued itself and created this object * * @var null|SphinxQL */ - protected $queue_prev; + protected ?SphinxQL $queue_prev = null; /** * An array of escaped characters for escapeMatch() * @var array */ - protected $escape_full_chars = array( + protected array $escape_full_chars = array( '\\' => '\\\\', '(' => '\(', ')' => '\)', @@ -208,7 +208,7 @@ class SphinxQL * An array of escaped characters for fullEscapeMatch() * @var array */ - protected $escape_half_chars = array( + protected array $escape_half_chars = array( '\\' => '\\\\', '(' => '\(', ')' => '\)', @@ -237,7 +237,7 @@ public function __construct(?ConnectionInterface $connection = null) * @return $this * @throws SphinxQLException */ - public function setType(string $type) + public function setType(string $type): self { $normalizedType = strtolower(trim($type)); $allowedTypes = array('select', 'insert', 'replace', 'update', 'delete', 'query'); @@ -257,7 +257,7 @@ public function setType(string $type) * * @returns ConnectionInterface */ - public function getConnection() + public function getConnection(): ?ConnectionInterface { return $this->connection; } @@ -268,7 +268,7 @@ public function getConnection() * @return Capabilities * @throws SphinxQLException */ - public function getCapabilities() + public function getCapabilities(): Capabilities { if ($this->connection === null) { throw new SphinxQLException('getCapabilities() requires an attached connection.'); @@ -285,7 +285,7 @@ public function getCapabilities() * @return bool * @throws SphinxQLException */ - public function supports($feature) + public function supports($feature): bool { if ($this->connection === null) { throw new SphinxQLException('supports() requires an attached connection.'); @@ -303,7 +303,7 @@ public function supports($feature) * @return self * @throws SphinxQLException */ - public function requireSupport($feature, $context = '') + public function requireSupport($feature, $context = ''): self { if ($this->connection === null) { throw new SphinxQLException('requireSupport() requires an attached connection.'); @@ -326,7 +326,7 @@ public function requireSupport($feature, $context = '') * @return Expression The new Expression * @todo make non static */ - public static function expr($string = '') + public static function expr($string = ''): Expression { return new Expression($string); } @@ -339,7 +339,7 @@ public static function expr($string = '') * @throws ConnectionException * @throws SphinxQLException */ - public function execute() + public function execute(): ResultSetInterface { // pass the object so execute compiles it by itself return $this->last_result = $this->getConnection()->query($this->compile()->getCompiled()); @@ -353,7 +353,7 @@ public function execute() * @throws Exception\DatabaseException * @throws ConnectionException */ - public function executeBatch() + public function executeBatch(): MultiResultSetInterface { if (count($this->getQueue()) == 0) { throw new SphinxQLException('There is no Queue present to execute.'); @@ -375,7 +375,7 @@ public function executeBatch() * * @return SphinxQL A new SphinxQL object with the current object referenced */ - public function enqueue(?SphinxQL $next = null) + public function enqueue(?SphinxQL $next = null): SphinxQL { if ($next === null) { $next = new static($this->getConnection()); @@ -391,7 +391,7 @@ public function enqueue(?SphinxQL $next = null) * * @return SphinxQL[] The ordered array of enqueued objects */ - public function getQueue() + public function getQueue(): array { $queue = array(); $curr = $this; @@ -410,7 +410,7 @@ public function getQueue() * * @return SphinxQL|null */ - public function getQueuePrev() + public function getQueuePrev(): ?SphinxQL { return $this->queue_prev; } @@ -422,7 +422,7 @@ public function getQueuePrev() * * @return self */ - public function setQueuePrev($query) + public function setQueuePrev($query): self { if (!$query instanceof self) { throw new \InvalidArgumentException('setQueuePrev() expects an instance of '.self::class.'.'); @@ -438,7 +438,7 @@ public function setQueuePrev($query) * * @return array The result of the last query */ - public function getResult() + public function getResult(): ResultSetInterface|MultiResultSetInterface|array|int|null { return $this->last_result; } @@ -448,7 +448,7 @@ public function getResult() * * @return string The last compiled query */ - public function getCompiled() + public function getCompiled(): ?string { return $this->last_compiled; } @@ -458,7 +458,7 @@ public function getCompiled() * @throws DatabaseException * @throws ConnectionException */ - public function transactionBegin() + public function transactionBegin(): void { $this->getConnection()->query('BEGIN'); } @@ -468,7 +468,7 @@ public function transactionBegin() * @throws DatabaseException * @throws ConnectionException */ - public function transactionCommit() + public function transactionCommit(): void { $this->getConnection()->query('COMMIT'); } @@ -478,7 +478,7 @@ public function transactionCommit() * @throws DatabaseException * @throws ConnectionException */ - public function transactionRollback() + public function transactionRollback(): void { $this->getConnection()->query('ROLLBACK'); } @@ -491,7 +491,7 @@ public function transactionRollback() * @throws DatabaseException * @throws SphinxQLException */ - public function compile() + public function compile(): self { if ($this->type === null) { throw new SphinxQLException('Unable to compile query: no query type selected.'); @@ -524,7 +524,7 @@ public function compile() /** * @return self */ - public function compileQuery() + public function compileQuery(): self { $this->last_compiled = $this->query; @@ -539,7 +539,7 @@ public function compileQuery() * @throws Exception\ConnectionException * @throws Exception\DatabaseException */ - public function compileMatch() + public function compileMatch(): string { $query = ''; @@ -591,7 +591,7 @@ public function compileMatch() * @throws ConnectionException * @throws DatabaseException */ - public function compileWhere() + public function compileWhere(): string { $compiled = $this->compileBooleanClause($this->where, 'where'); if ($compiled === '') { @@ -612,7 +612,7 @@ public function compileWhere() * @throws ConnectionException * @throws DatabaseException */ - public function compileFilterCondition($filter) + public function compileFilterCondition(array $filter): string { $query = ''; @@ -649,7 +649,7 @@ public function compileFilterCondition($filter) * @throws DatabaseException * @throws SphinxQLException */ - public function compileSelect() + public function compileSelect(): self { $query = ''; @@ -796,7 +796,7 @@ function (&$val, $key) { * @throws ConnectionException * @throws DatabaseException */ - public function compileInsert() + public function compileInsert(): self { if ($this->type == 'insert') { $query = 'INSERT '; @@ -836,13 +836,15 @@ public function compileInsert() * @throws ConnectionException * @throws DatabaseException */ - public function compileUpdate() + public function compileUpdate(): self { + if ($this->into === null) { + throw new SphinxQLException('update() requires into($index) before compile() or execute().'); + } + $query = 'UPDATE '; - if ($this->into !== null) { - $query .= $this->into.' '; - } + $query .= $this->into.' '; if (!empty($this->set)) { $query .= 'SET '; @@ -878,7 +880,7 @@ public function compileUpdate() * @throws ConnectionException * @throws DatabaseException */ - public function compileDelete() + public function compileDelete(): self { $query = 'DELETE '; @@ -906,7 +908,7 @@ public function compileDelete() * * @return self */ - public function query($sql) + public function query(string $sql): self { $this->type = 'query'; $this->query = $sql; @@ -935,7 +937,7 @@ public function query($sql) * * @return self */ - public function select($columns = null) + public function select($columns = null): self { $this->reset(); $this->type = 'select'; @@ -959,7 +961,7 @@ public function select($columns = null) * * @return self */ - public function setSelect($columns = null) + public function setSelect($columns = null): self { if (is_array($columns)) { $this->select = $columns; @@ -975,7 +977,7 @@ public function setSelect($columns = null) * * @return array */ - public function getSelect() + public function getSelect(): array { return $this->select; } @@ -985,7 +987,7 @@ public function getSelect() * * @return self */ - public function insert() + public function insert(): self { $this->reset(); $this->type = 'insert'; @@ -998,7 +1000,7 @@ public function insert() * * @return self */ - public function replace() + public function replace(): self { $this->reset(); $this->type = 'replace'; @@ -1013,7 +1015,7 @@ public function replace() * * @return self */ - public function update($index = null) + public function update($index = null): self { $this->reset(); $this->type = 'update'; @@ -1030,7 +1032,7 @@ public function update($index = null) * * @return self */ - public function delete() + public function delete(): self { $this->reset(); $this->type = 'delete'; @@ -1046,7 +1048,7 @@ public function delete() * * @return self */ - public function from($array = null) + public function from($array = null): self { if ($array === null) { throw new SphinxQLException('from() requires one or more indexes, a subquery, or a closure.'); @@ -1101,7 +1103,7 @@ public function from($array = null) * * @return self */ - public function join($table, $left, $operator, $right, $type = 'INNER') + public function join($table, $left, $operator, $right, $type = 'INNER'): self { if (!is_string($table) || trim($table) === '') { throw new SphinxQLException('join() table must be a non-empty string.'); @@ -1142,7 +1144,7 @@ public function join($table, $left, $operator, $right, $type = 'INNER') * * @return self */ - public function innerJoin($table, $left, $operator, $right) + public function innerJoin($table, $left, $operator, $right): self { return $this->join($table, $left, $operator, $right, 'INNER'); } @@ -1157,7 +1159,7 @@ public function innerJoin($table, $left, $operator, $right) * * @return self */ - public function leftJoin($table, $left, $operator, $right) + public function leftJoin($table, $left, $operator, $right): self { return $this->join($table, $left, $operator, $right, 'LEFT'); } @@ -1172,7 +1174,7 @@ public function leftJoin($table, $left, $operator, $right) * * @return self */ - public function rightJoin($table, $left, $operator, $right) + public function rightJoin($table, $left, $operator, $right): self { return $this->join($table, $left, $operator, $right, 'RIGHT'); } @@ -1184,7 +1186,7 @@ public function rightJoin($table, $left, $operator, $right) * * @return self */ - public function crossJoin($table) + public function crossJoin($table): self { if (!is_string($table) || trim($table) === '') { throw new SphinxQLException('crossJoin() table must be a non-empty string.'); @@ -1207,7 +1209,7 @@ public function crossJoin($table) * * @return self */ - public function match($column, $value = null, $half = false) + public function match($column, $value = null, $half = false): self { if ($column === '*' || (is_array($column) && in_array('*', $column))) { $column = array(); @@ -1245,7 +1247,7 @@ public function match($column, $value = null, $half = false) * * @return self */ - public function where($column, $operator, $value = null) + public function where($column, $operator, $value = null): self { $this->where[] = array( 'type' => 'condition', @@ -1265,7 +1267,7 @@ public function where($column, $operator, $value = null) * * @return self */ - public function orWhere($column, $operator, $value = null) + public function orWhere($column, $operator, $value = null): self { $this->where[] = array( 'type' => 'condition', @@ -1283,7 +1285,7 @@ public function orWhere($column, $operator, $value = null) * * @return self */ - public function whereOpen($boolean = 'AND') + public function whereOpen($boolean = 'AND'): self { $this->where[] = array( 'type' => 'open', @@ -1298,7 +1300,7 @@ public function whereOpen($boolean = 'AND') * * @return self */ - public function orWhereOpen() + public function orWhereOpen(): self { return $this->whereOpen('OR'); } @@ -1308,7 +1310,7 @@ public function orWhereOpen() * * @return self */ - public function whereClose() + public function whereClose(): self { $this->where[] = array('type' => 'close'); @@ -1323,7 +1325,7 @@ public function whereClose() * * @return self */ - public function groupBy($column) + public function groupBy($column): self { $this->group_by[] = $column; @@ -1338,7 +1340,7 @@ public function groupBy($column) * * @return self */ - public function groupNBy($n) + public function groupNBy($n): self { if (filter_var($n, FILTER_VALIDATE_INT) === false || (int) $n <= 0) { throw new SphinxQLException('groupNBy() requires a positive integer.'); @@ -1359,7 +1361,7 @@ public function groupNBy($n) * * @return self */ - public function withinGroupOrderBy($column, $direction = null) + public function withinGroupOrderBy($column, $direction = null): self { if (!is_string($column) || trim($column) === '') { throw new SphinxQLException('withinGroupOrderBy() column must be a non-empty string.'); @@ -1399,7 +1401,7 @@ public function withinGroupOrderBy($column, $direction = null) * * @return self */ - public function having($column, $operator, $value = null) + public function having($column, $operator, $value = null): self { $this->having[] = array( 'type' => 'condition', @@ -1419,7 +1421,7 @@ public function having($column, $operator, $value = null) * * @return self */ - public function orHaving($column, $operator, $value = null) + public function orHaving($column, $operator, $value = null): self { $this->having[] = array( 'type' => 'condition', @@ -1437,7 +1439,7 @@ public function orHaving($column, $operator, $value = null) * * @return self */ - public function havingOpen($boolean = 'AND') + public function havingOpen($boolean = 'AND'): self { $this->having[] = array( 'type' => 'open', @@ -1452,7 +1454,7 @@ public function havingOpen($boolean = 'AND') * * @return self */ - public function orHavingOpen() + public function orHavingOpen(): self { return $this->havingOpen('OR'); } @@ -1462,7 +1464,7 @@ public function orHavingOpen() * * @return self */ - public function havingClose() + public function havingClose(): self { $this->having[] = array('type' => 'close'); @@ -1478,7 +1480,7 @@ public function havingClose() * * @return self */ - public function orderBy($column, $direction = null) + public function orderBy($column, $direction = null): self { if (!is_string($column) || trim($column) === '') { throw new SphinxQLException('orderBy() column must be a non-empty string.'); @@ -1502,7 +1504,7 @@ public function orderBy($column, $direction = null) * * @return self */ - public function orderByKnn($field, $k, array $vector, $direction = 'ASC') + public function orderByKnn($field, $k, array $vector, $direction = 'ASC'): self { if (!is_string($field) || trim($field) === '') { throw new SphinxQLException('orderByKnn() field must be a non-empty string.'); @@ -1536,7 +1538,7 @@ public function orderByKnn($field, $k, array $vector, $direction = 'ASC') * * @return self */ - public function limit($offset, $limit = null) + public function limit($offset, $limit = null): self { if ($limit === null) { if (filter_var($offset, FILTER_VALIDATE_INT) === false || (int) $offset < 0) { @@ -1568,7 +1570,7 @@ public function limit($offset, $limit = null) * * @return self */ - public function offset($offset) + public function offset($offset): self { if (filter_var($offset, FILTER_VALIDATE_INT) === false || (int) $offset < 0) { throw new SphinxQLException('offset() requires a non-negative integer.'); @@ -1588,7 +1590,7 @@ public function offset($offset) * * @return self */ - public function option($name, $value) + public function option($name, $value): self { if (!is_string($name) || trim($name) === '') { throw new SphinxQLException('option() name must be a non-empty string.'); @@ -1607,7 +1609,7 @@ public function option($name, $value) * * @return self */ - public function into($index) + public function into($index): self { if (!is_string($index) || trim($index) === '') { throw new SphinxQLException('into() index must be a non-empty string.'); @@ -1627,7 +1629,7 @@ public function into($index) * * @return self */ - public function columns($array = array()) + public function columns($array = array()): self { if (is_array($array)) { if (empty($array)) { @@ -1664,7 +1666,7 @@ public function columns($array = array()) * * @return self */ - public function values($array) + public function values($array): self { if (is_array($array)) { if (empty($array)) { @@ -1691,7 +1693,7 @@ public function values($array) * * @return self */ - public function value($column, $value) + public function value($column, $value): self { if (!is_string($column) || trim($column) === '') { throw new SphinxQLException('value() column must be a non-empty string.'); @@ -1715,7 +1717,7 @@ public function value($column, $value) * * @return self */ - public function set($array) + public function set($array): self { if (!is_array($array) || empty($array)) { throw new SphinxQLException('set() requires a non-empty associative array.'); @@ -1740,7 +1742,7 @@ public function set($array) * * @return self */ - public function facet($facet) + public function facet($facet): self { if (!$facet instanceof Facet) { throw new SphinxQLException('facet() expects an instance of '.Facet::class.'.'); @@ -1758,7 +1760,7 @@ public function facet($facet) * * @return self */ - public function setFullEscapeChars($array = array()) + public function setFullEscapeChars($array = array()): self { if (!empty($array)) { $this->escape_full_chars = $this->compileEscapeChars($array); @@ -1774,7 +1776,7 @@ public function setFullEscapeChars($array = array()) * * @return self */ - public function setHalfEscapeChars($array = array()) + public function setHalfEscapeChars($array = array()): self { if (!empty($array)) { $this->escape_half_chars = $this->compileEscapeChars($array); @@ -1790,7 +1792,7 @@ public function setHalfEscapeChars($array = array()) * * @return array An array of the characters and it's escaped counterpart */ - public function compileEscapeChars($array = array()) + public function compileEscapeChars($array = array()): array { $result = array(); foreach ($array as $character) { @@ -1807,7 +1809,7 @@ public function compileEscapeChars($array = array()) * * @return string The escaped string */ - public function escapeMatch($string) + public function escapeMatch($string): string { if (is_null($string)) { return ''; @@ -1829,7 +1831,7 @@ public function escapeMatch($string) * * @return string The escaped string */ - public function halfEscapeMatch($string) + public function halfEscapeMatch($string): string { if ($string instanceof Expression) { return $string->value(); @@ -1865,7 +1867,7 @@ public function halfEscapeMatch($string) * @return array * @throws SphinxQLException */ - private function createFilterCondition($method, $column, $operator, $value = null) + private function createFilterCondition($method, $column, $operator, $value = null): array { if ($value === null) { $value = $operator; @@ -1907,7 +1909,7 @@ private function createFilterCondition($method, $column, $operator, $value = nul * @return string * @throws SphinxQLException */ - private function normalizeBooleanOperator($boolean, $method) + private function normalizeBooleanOperator($boolean, $method): string { if ($boolean === null) { return 'AND'; @@ -1934,7 +1936,7 @@ private function normalizeBooleanOperator($boolean, $method) * @throws DatabaseException * @throws SphinxQLException */ - private function compileBooleanClause(array $tokens, $context) + private function compileBooleanClause(array $tokens, $context): string { if (empty($tokens)) { return ''; @@ -2013,7 +2015,7 @@ private function compileBooleanClause(array $tokens, $context) /** * @return string */ - private function compileJoins() + private function compileJoins(): string { $compiled = ''; @@ -2036,7 +2038,7 @@ private function compileJoins() * @return string|null * @throws SphinxQLException */ - private function normalizeDirection($direction, $method) + private function normalizeDirection($direction, $method): ?string { if ($direction === null) { return null; @@ -2059,7 +2061,7 @@ private function normalizeDirection($direction, $method) * * @return self */ - public function reset() + public function reset(): self { $this->query = null; $this->select = array(); @@ -2086,7 +2088,7 @@ public function reset() /** * @return self */ - public function resetWhere() + public function resetWhere(): self { $this->where = array(); @@ -2096,7 +2098,7 @@ public function resetWhere() /** * @return self */ - public function resetJoins() + public function resetJoins(): self { $this->joins = array(); @@ -2106,7 +2108,7 @@ public function resetJoins() /** * @return self */ - public function resetMatch() + public function resetMatch(): self { $this->match = array(); @@ -2116,7 +2118,7 @@ public function resetMatch() /** * @return self */ - public function resetGroupBy() + public function resetGroupBy(): self { $this->group_by = array(); $this->group_n_by = null; @@ -2127,7 +2129,7 @@ public function resetGroupBy() /** * @return self */ - public function resetWithinGroupOrderBy() + public function resetWithinGroupOrderBy(): self { $this->within_group_order_by = array(); @@ -2137,7 +2139,7 @@ public function resetWithinGroupOrderBy() /** * @return self */ - public function resetFacets() + public function resetFacets(): self { $this->facets = array(); @@ -2147,7 +2149,7 @@ public function resetFacets() /** * @return self */ - public function resetHaving() + public function resetHaving(): self { $this->having = array(); @@ -2157,7 +2159,7 @@ public function resetHaving() /** * @return self */ - public function resetOrderBy() + public function resetOrderBy(): self { $this->order_by = array(); @@ -2167,7 +2169,7 @@ public function resetOrderBy() /** * @return self */ - public function resetOptions() + public function resetOptions(): self { $this->options = array(); diff --git a/tests/SphinxQL/ConnectionTest.php b/tests/SphinxQL/ConnectionTest.php index 9a6c43c9..740f5d68 100644 --- a/tests/SphinxQL/ConnectionTest.php +++ b/tests/SphinxQL/ConnectionTest.php @@ -122,6 +122,13 @@ public function testConnectWithCredentialsParams() $this->assertNotNull($this->connection->getConnection()); } + public function testCredentialsParamValidation() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->expectExceptionMessage('setParam("username") expects null or string.'); + $this->connection->setParam('username', array('invalid')); + } + public function testConnectThrowsException() { $this->expectException(Foolz\SphinxQL\Exception\ConnectionException::class); diff --git a/tests/SphinxQL/HelperCapabilityProbeTest.php b/tests/SphinxQL/HelperCapabilityProbeTest.php new file mode 100644 index 00000000..3b302f8a --- /dev/null +++ b/tests/SphinxQL/HelperCapabilityProbeTest.php @@ -0,0 +1,262 @@ + array(array('VERSION()' => '3.9.1')), + 'SHOW TABLES' => array(array('Index' => 'products', 'Type' => 'rt')), + 'SHOW TABLE rt SETTINGS' => new \RuntimeException('missing rt'), + 'SHOW TABLE products SETTINGS' => array(), + )); + + $helper = new Helper($connection); + + $this->assertTrue($helper->supports('show_table_settings')); + $this->assertContains('SHOW TABLE products SETTINGS', $connection->queries); + } + + public function testCallSuggestProbeUsesDiscoveredTableName() + { + $connection = new HelperProbeConnection(array( + 'SELECT VERSION()' => array(array('VERSION()' => '3.9.1')), + 'SHOW TABLES' => array(array('Index' => 'products', 'Type' => 'rt')), + "CALL SUGGEST('teh', 'rt')" => new \RuntimeException('missing rt'), + "CALL SUGGEST('teh', 'products')" => array(), + )); + + $helper = new Helper($connection); + + $this->assertTrue($helper->supports('call_suggest')); + $this->assertContains("CALL SUGGEST('teh', 'products')", $connection->queries); + } + + public function testQSuggestAndAutocompleteFollowBuddySupport() + { + $connection = new HelperProbeConnection(array( + 'SELECT VERSION()' => array(array('VERSION()' => '6.3.0 Manticore')), + 'SHOW VERSION' => new \RuntimeException('buddy unavailable'), + )); + + $helper = new Helper($connection); + + $this->assertFalse($helper->supports('buddy')); + $this->assertFalse($helper->supports('call_qsuggest')); + $this->assertFalse($helper->supports('call_autocomplete')); + } +} + +class HelperProbeConnection implements ConnectionInterface +{ + /** + * @var array + */ + public $queries = array(); + + /** + * @var array + */ + private $responses = array(); + + /** + * @param array $responses + */ + public function __construct(array $responses) + { + $this->responses = $responses; + } + + public function query(string $query): \Foolz\SphinxQL\Drivers\ResultSetInterface + { + $this->queries[] = $query; + + if (!array_key_exists($query, $this->responses)) { + throw new \RuntimeException('Unexpected query: '.$query); + } + + $response = $this->responses[$query]; + if ($response instanceof \Closure) { + $response = $response($query, $this); + } + if ($response instanceof \Exception) { + throw $response; + } + + return new HelperProbeResultSet($response); + } + + public function multiQuery(array $queue): \Foolz\SphinxQL\Drivers\MultiResultSetInterface + { + throw new \BadMethodCallException('Not implemented in test double.'); + } + + public function escape(string $value): string + { + return "'".str_replace("'", "\\'", $value)."'"; + } + + public function quote(Expression|string|null|bool|array|int|float $value): string|int|float + { + if ($value === null) { + return 'null'; + } + if ($value === true) { + return 1; + } + if ($value === false) { + return 0; + } + if ($value instanceof Expression) { + return $value->value(); + } + if (is_int($value) || is_float($value)) { + return (string) $value; + } + + return $this->escape($value); + } + + public function quoteArr(array $array = array()): array + { + $quoted = array(); + foreach ($array as $key => $item) { + $quoted[$key] = $this->quote($item); + } + + return $quoted; + } +} + +class HelperProbeResultSet implements \Foolz\SphinxQL\Drivers\ResultSetInterface +{ + /** + * @var mixed + */ + private $stored; + + /** + * @param mixed $stored + */ + public function __construct($stored) + { + $this->stored = $stored; + } + + /** + * @return mixed + */ + public function getStored(): array|int + { + if (is_array($this->stored) || is_int($this->stored)) { + return $this->stored; + } + + return array(); + } + + public function store(): self + { + return $this; + } + + public function hasRow(int $row): bool + { + return false; + } + + public function toRow(int $row): self + { + return $this; + } + + public function hasNextRow(): bool + { + return false; + } + + public function toNextRow(): self + { + return $this; + } + + public function getAffectedRows(): int + { + return 0; + } + + public function fetchAllAssoc(): array + { + return is_array($this->stored) ? $this->stored : array(); + } + + public function fetchAllNum(): array + { + return is_array($this->stored) ? $this->stored : array(); + } + + public function fetchAssoc(): ?array + { + return null; + } + + public function fetchNum(): ?array + { + return null; + } + + public function freeResult(): self + { + return $this; + } + + public function offsetExists(mixed $offset): bool + { + return false; + } + + public function offsetGet(mixed $offset): mixed + { + return null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + } + + public function offsetUnset(mixed $offset): void + { + } + + public function current(): mixed + { + return null; + } + + public function next(): void + { + } + + public function key(): mixed + { + return null; + } + + public function valid(): bool + { + return false; + } + + public function rewind(): void + { + } + + public function count(): int + { + return 0; + } +} diff --git a/tests/SphinxQL/HelperTest.php b/tests/SphinxQL/HelperTest.php index 3309f9bc..434b764e 100644 --- a/tests/SphinxQL/HelperTest.php +++ b/tests/SphinxQL/HelperTest.php @@ -56,6 +56,13 @@ public function testShowTables() ); } + public function testShowTablesCompileVariants() + { + $this->assertSame('SHOW TABLES', $this->createHelper()->showTables()->compile()->getCompiled()); + $this->assertSame('SHOW TABLES', $this->createHelper()->showTables('')->compile()->getCompiled()); + $this->assertSame("SHOW TABLES LIKE 'rt'", $this->createHelper()->showTables('rt')->compile()->getCompiled()); + } + public function testDescribe() { $describe = $this->createHelper()->describe('rt')->execute()->getStored(); @@ -467,7 +474,8 @@ public function testFlushAndOptimizeExecution() public function testHelperRequiresNonEmptyIdentifiers() { $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); - $this->createHelper()->showTables(''); + $this->expectExceptionMessage('showTables() index must be null or a string.'); + $this->createHelper()->showTables(array()); } public function testSetVariableValidation() @@ -554,14 +562,17 @@ public function testAutocompleteOptionValidationWhenSupported() public function testCapabilitiesAndSupports() { - $caps = $this->createHelper()->getCapabilities(); + $helper = $this->createHelper(); + $caps = $helper->getCapabilities(); $this->assertInstanceOf(Foolz\SphinxQL\Capabilities::class, $caps); $this->assertNotEmpty($caps->getEngine()); - $this->assertTrue($this->createHelper()->supports('grouped_where')); - $this->assertIsBool($this->createHelper()->supports('show_profile')); - $this->assertIsBool($this->createHelper()->supports('show_character_set')); - $this->assertIsBool($this->createHelper()->supports('show_collation')); + $this->assertTrue($helper->supports('grouped_where')); + $this->assertIsBool($helper->supports('show_profile')); + $this->assertIsBool($helper->supports('show_character_set')); + $this->assertIsBool($helper->supports('show_collation')); + $this->assertSame($helper->supports('buddy'), $helper->supports('call_qsuggest')); + $this->assertSame($helper->supports('buddy'), $helper->supports('call_autocomplete')); $this->assertSame($caps->isManticore(), $caps->getEngine() === 'MANTICORE'); $this->assertSame($caps->isSphinx2(), $caps->getEngine() === 'SPHINX2'); $this->assertSame($caps->isSphinx3(), $caps->getEngine() === 'SPHINX3'); diff --git a/tests/SphinxQL/PdoResultSetAdapterTest.php b/tests/SphinxQL/PdoResultSetAdapterTest.php new file mode 100644 index 00000000..b487273b --- /dev/null +++ b/tests/SphinxQL/PdoResultSetAdapterTest.php @@ -0,0 +1,38 @@ +markTestSkipped('PDO-specific adapter test.'); + } + + $statement = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + + $statement + ->expects($this->once()) + ->method('fetchAll') + ->with(\PDO::FETCH_ASSOC) + ->willReturn(array( + array( + 'truthy' => true, + 'falsy' => false, + 'number' => 12, + 'already_string' => '12', + ), + )); + + $adapter = new ResultSetAdapter($statement); + $rows = $adapter->fetchAll(); + + $this->assertSame('1', $rows[0]['truthy']); + $this->assertSame('0', $rows[0]['falsy']); + $this->assertSame('12', $rows[0]['number']); + $this->assertSame('12', $rows[0]['already_string']); + } +} diff --git a/tests/SphinxQL/SphinxQLTest.php b/tests/SphinxQL/SphinxQLTest.php index e40de830..7cf04401 100644 --- a/tests/SphinxQL/SphinxQLTest.php +++ b/tests/SphinxQL/SphinxQLTest.php @@ -449,6 +449,22 @@ public function testUpdateWithLateInto() $this->assertSame('UPDATE rt SET gid = 777 WHERE id = 11', $query); } + /** + * @covers \Foolz\SphinxQL\SphinxQL::compileUpdate + * @covers \Foolz\SphinxQL\SphinxQL::update + */ + public function testUpdateWithoutIntoThrows() + { + $this->expectException(Foolz\SphinxQL\Exception\SphinxQLException::class); + $this->expectExceptionMessage('update() requires into($index) before compile() or execute().'); + + $this->createSphinxQL() + ->update() + ->set(array('gid' => 777)) + ->where('id', '=', 11) + ->compile(); + } + /** * @covers \Foolz\SphinxQL\SphinxQL::compileWhere * @covers \Foolz\SphinxQL\SphinxQL::from From c4caab29cb53b526f10a7c18b2661970a83fcde0 Mon Sep 17 00:00:00 2001 From: woxxy Date: Sat, 28 Feb 2026 00:59:35 +0000 Subject: [PATCH 15/16] test: strengthen assertions and make phpstan blocking --- .github/workflows/ci.yml | 3 +-- tests/SphinxQL/SphinxQLTest.php | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7978503..4a2b1460 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,9 +131,8 @@ jobs: - name: Prepare autoload run: composer dump-autoload - - name: Run PHPStan (non-blocking) + - name: Run PHPStan if: env.COVERAGE_LANE == 'true' - continue-on-error: true run: | mkdir -p static-analysis ./vendor/bin/phpstan analyse --configuration phpstan.neon.dist --memory-limit=1G --no-progress --error-format=raw > static-analysis/phpstan.txt diff --git a/tests/SphinxQL/SphinxQLTest.php b/tests/SphinxQL/SphinxQLTest.php index 7cf04401..debdaccd 100644 --- a/tests/SphinxQL/SphinxQLTest.php +++ b/tests/SphinxQL/SphinxQLTest.php @@ -99,7 +99,12 @@ public function testTransactions() $this->createSphinxQL()->transactionBegin(); $this->createSphinxQL()->transactionCommit(); - $this->assertTrue(true); + $variables = Helper::pairsToAssoc( + (new Helper(self::$conn))->showVariables()->execute()->getStored() + ); + + $this->assertArrayHasKey('autocommit', $variables); + $this->assertSame(1, (int) $variables['autocommit']); } public function testQuery() From be6ffa6545a4c3d084d0cc7ae9765248f5a32327 Mon Sep 17 00:00:00 2001 From: woxxy Date: Sat, 28 Feb 2026 01:17:55 +0000 Subject: [PATCH 16/16] ci: baseline phpstan and surface analyzer output --- .github/workflows/ci.yml | 3 +- phpstan-baseline.neon | 2461 +++++++++++++++++++++++++++++++++++++- 2 files changed, 2462 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a2b1460..caf6726a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,8 +134,9 @@ jobs: - name: Run PHPStan if: env.COVERAGE_LANE == 'true' run: | + set -o pipefail mkdir -p static-analysis - ./vendor/bin/phpstan analyse --configuration phpstan.neon.dist --memory-limit=1G --no-progress --error-format=raw > static-analysis/phpstan.txt + ./vendor/bin/phpstan analyse --configuration phpstan.neon.dist --memory-limit=1G --no-progress --error-format=raw 2>&1 | tee static-analysis/phpstan.txt - name: Run tests run: | diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f51e71c3..d5b0bce1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,2 +1,2461 @@ parameters: - ignoreErrors: [] + ignoreErrors: + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionBase\\:\\:getParams\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ConnectionBase.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionBase\\:\\:quote\\(\\) has parameter \\$value with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ConnectionBase.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionBase\\:\\:quoteArr\\(\\) has parameter \\$array with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ConnectionBase.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionBase\\:\\:quoteArr\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ConnectionBase.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionBase\\:\\:setParams\\(\\) has parameter \\$params with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ConnectionBase.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionBase\\:\\:\\$connection_params type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ConnectionBase.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\:\\:multiQuery\\(\\) has parameter \\$queue with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ConnectionInterface.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\:\\:quote\\(\\) has parameter \\$value with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ConnectionInterface.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\:\\:quoteArr\\(\\) has parameter \\$array with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ConnectionInterface.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\:\\:quoteArr\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ConnectionInterface.php + + - + message: "#^PHPDoc tag @return with type Foolz\\\\SphinxQL\\\\Expression\\|int\\|string is not subtype of native type float\\|int\\|string\\.$#" + count: 1 + path: src/Drivers/ConnectionInterface.php + + - + message: "#^Offset int does not exist on array\\|null\\.$#" + count: 1 + path: src/Drivers/MultiResultSet.php + + - + message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, array\\|null given\\.$#" + count: 1 + path: src/Drivers/MultiResultSet.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\Drivers\\\\MultiResultSet\\:\\:\\$stored type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/MultiResultSet.php + + - + message: "#^Interface Foolz\\\\SphinxQL\\\\Drivers\\\\MultiResultSetInterface extends generic interface ArrayAccess but does not specify its types\\: TKey, TValue$#" + count: 1 + path: src/Drivers/MultiResultSetInterface.php + + - + message: "#^Interface Foolz\\\\SphinxQL\\\\Drivers\\\\MultiResultSetInterface extends generic interface Iterator but does not specify its types\\: TKey, TValue$#" + count: 1 + path: src/Drivers/MultiResultSetInterface.php + + - + message: "#^Access to an undefined property mysqli\\|PDO\\:\\:\\$errno\\.$#" + count: 4 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Access to an undefined property mysqli\\|PDO\\:\\:\\$error\\.$#" + count: 3 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Call to an undefined method mysqli\\|PDO\\:\\:close\\(\\)\\.$#" + count: 1 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Call to an undefined method mysqli\\|PDO\\:\\:multi_query\\(\\)\\.$#" + count: 1 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Call to an undefined method mysqli\\|PDO\\:\\:ping\\(\\)\\.$#" + count: 1 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Call to an undefined method mysqli\\|PDO\\:\\:real_escape_string\\(\\)\\.$#" + count: 1 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Cannot access property \\$connect_errno on mysqli\\|false\\.$#" + count: 1 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Cannot access property \\$connect_error on mysqli\\|false\\.$#" + count: 1 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Cannot call method options\\(\\) on mysqli\\|false\\.$#" + count: 1 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Cannot call method real_connect\\(\\) on mysqli\\|false\\.$#" + count: 1 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Cannot call method set_charset\\(\\) on mysqli\\|false\\.$#" + count: 1 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Mysqli\\\\Connection\\:\\:multiQuery\\(\\) has parameter \\$queue with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Parameter \\#1 \\$callback of function set_error_handler expects \\(callable\\(int, string, string, int\\)\\: bool\\)\\|null, Closure\\(\\)\\: void given\\.$#" + count: 2 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Parameter \\#2 \\$result of class Foolz\\\\SphinxQL\\\\Drivers\\\\Mysqli\\\\ResultSetAdapter constructor expects bool\\|mysqli_result, bool\\|mysqli_result\\|PDOStatement given\\.$#" + count: 1 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionBase\\:\\:\\$connection \\(mysqli\\|PDO\\|null\\) does not accept mysqli\\|false\\.$#" + count: 1 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Strict comparison using \\=\\=\\= between string and false will always evaluate to false\\.$#" + count: 1 + path: src/Drivers/Mysqli/Connection.php + + - + message: "#^Access to an undefined property mysqli\\|PDO\\:\\:\\$errno\\.$#" + count: 1 + path: src/Drivers/Mysqli/MultiResultSetAdapter.php + + - + message: "#^Call to an undefined method mysqli\\|PDO\\:\\:more_results\\(\\)\\.$#" + count: 1 + path: src/Drivers/Mysqli/MultiResultSetAdapter.php + + - + message: "#^Call to an undefined method mysqli\\|PDO\\:\\:next_result\\(\\)\\.$#" + count: 1 + path: src/Drivers/Mysqli/MultiResultSetAdapter.php + + - + message: "#^Call to an undefined method mysqli\\|PDO\\:\\:store_result\\(\\)\\.$#" + count: 1 + path: src/Drivers/Mysqli/MultiResultSetAdapter.php + + - + message: "#^Access to an undefined property mysqli\\|PDO\\:\\:\\$affected_rows\\.$#" + count: 1 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Cannot access property \\$num_rows on bool\\|mysqli_result\\.$#" + count: 1 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Cannot call method data_seek\\(\\) on bool\\|mysqli_result\\.$#" + count: 3 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Cannot call method fetch_all\\(\\) on bool\\|mysqli_result\\.$#" + count: 3 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Cannot call method fetch_assoc\\(\\) on bool\\|mysqli_result\\.$#" + count: 1 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Cannot call method fetch_fields\\(\\) on bool\\|mysqli_result\\.$#" + count: 1 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Cannot call method fetch_row\\(\\) on bool\\|mysqli_result\\.$#" + count: 1 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Cannot call method free_result\\(\\) on bool\\|mysqli_result\\.$#" + count: 1 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Mysqli\\\\ResultSetAdapter\\:\\:fetch\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Mysqli\\\\ResultSetAdapter\\:\\:fetchAll\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Mysqli\\\\ResultSetAdapter\\:\\:getAffectedRows\\(\\) should return int but returns int\\<\\-1, max\\>\\|string\\.$#" + count: 1 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Mysqli\\\\ResultSetAdapter\\:\\:getFields\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Mysqli\\\\ResultSetAdapter\\:\\:getNumRows\\(\\) should return int but returns int\\<0, max\\>\\|string\\.$#" + count: 1 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Mysqli\\\\ResultSetAdapter\\:\\:store\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Mysqli/ResultSetAdapter.php + + - + message: "#^Cannot call method execute\\(\\) on mysqli_stmt\\|PDOStatement\\|false\\.$#" + count: 1 + path: src/Drivers/Pdo/Connection.php + + - + message: "#^Cannot call method prepare\\(\\) on mysqli\\|PDO\\|null\\.$#" + count: 1 + path: src/Drivers/Pdo/Connection.php + + - + message: "#^Cannot call method query\\(\\) on mysqli\\|PDO\\|null\\.$#" + count: 1 + path: src/Drivers/Pdo/Connection.php + + - + message: "#^Cannot call method quote\\(\\) on mysqli\\|PDO\\|null\\.$#" + count: 1 + path: src/Drivers/Pdo/Connection.php + + - + message: "#^Dead catch \\- TypeError is never thrown in the try block\\.$#" + count: 1 + path: src/Drivers/Pdo/Connection.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\Connection\\:\\:multiQuery\\(\\) has parameter \\$queue with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Pdo/Connection.php + + - + message: "#^Parameter \\#1 \\$statement of class Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\MultiResultSetAdapter constructor expects PDOStatement, bool\\|mysqli_result\\|PDOStatement given\\.$#" + count: 1 + path: src/Drivers/Pdo/Connection.php + + - + message: "#^Parameter \\#1 \\$statement of class Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\ResultSetAdapter constructor expects PDOStatement, mysqli_stmt\\|PDOStatement\\|false given\\.$#" + count: 1 + path: src/Drivers/Pdo/Connection.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\ResultSetAdapter\\:\\:fetch\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Pdo/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\ResultSetAdapter\\:\\:fetchAll\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Pdo/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\ResultSetAdapter\\:\\:getFields\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Pdo/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\ResultSetAdapter\\:\\:normalizeRow\\(\\) has parameter \\$row with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Pdo/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\ResultSetAdapter\\:\\:normalizeRow\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Pdo/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\ResultSetAdapter\\:\\:normalizeRows\\(\\) has parameter \\$rows with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Pdo/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\ResultSetAdapter\\:\\:normalizeRows\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Pdo/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\ResultSetAdapter\\:\\:store\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/Pdo/ResultSetAdapter.php + + - + message: "#^Parameter \\#1 \\$row of method Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\ResultSetAdapter\\:\\:normalizeRow\\(\\) expects array, mixed given\\.$#" + count: 1 + path: src/Drivers/Pdo/ResultSetAdapter.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:fetch\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:fetchAll\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:fetchAllAssoc\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:fetchAllFromStore\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:fetchAllNum\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:fetchAssoc\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:fetchFromStore\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:fetchNum\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:getStored\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:makeAssoc\\(\\) has parameter \\$numeric_array with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:makeAssoc\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^PHPDoc tag @return with type array\\|bool is not subtype of native type array\\|false\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^PHPDoc tag @return with type array\\|bool\\|null is not subtype of native type array\\|false\\|null\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:\\$fetched type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:\\$fields type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSet\\:\\:\\$stored type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSet.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetAdapterInterface\\:\\:fetch\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSetAdapterInterface.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetAdapterInterface\\:\\:fetchAll\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSetAdapterInterface.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetAdapterInterface\\:\\:getFields\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSetAdapterInterface.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetAdapterInterface\\:\\:store\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSetAdapterInterface.php + + - + message: "#^Interface Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetInterface extends generic interface ArrayAccess but does not specify its types\\: TKey, TValue$#" + count: 1 + path: src/Drivers/ResultSetInterface.php + + - + message: "#^Interface Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetInterface extends generic interface Iterator but does not specify its types\\: TKey, TValue$#" + count: 1 + path: src/Drivers/ResultSetInterface.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetInterface\\:\\:fetchAllAssoc\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSetInterface.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetInterface\\:\\:fetchAllNum\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSetInterface.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetInterface\\:\\:fetchAssoc\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSetInterface.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetInterface\\:\\:fetchNum\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSetInterface.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetInterface\\:\\:getStored\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Drivers/ResultSetInterface.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Facet\\:\\:facet\\(\\) has parameter \\$columns with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Facet.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Facet\\:\\:facetFunction\\(\\) has parameter \\$params with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Facet.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Facet\\:\\:orderByFunction\\(\\) has parameter \\$params with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Facet.php + + - + message: "#^PHPDoc tag @var for property Foolz\\\\SphinxQL\\\\Facet\\:\\:\\$by with type array is incompatible with native type string\\.$#" + count: 1 + path: src/Facet.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\Facet\\:\\:\\$facet type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Facet.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\Facet\\:\\:\\$order_by type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Facet.php + + - + message: "#^Cannot call method getEngine\\(\\) on Foolz\\\\SphinxQL\\\\Capabilities\\|null\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Cannot call method getVersion\\(\\) on Foolz\\\\SphinxQL\\\\Capabilities\\|null\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:buildCallWithOptions\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:buildCallWithOptions\\(\\) has parameter \\$requiredArgs with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:callAutocomplete\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:callQSuggest\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:callSnippets\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:callSnippets\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:callSuggest\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:flushRamchunk\\(\\) has parameter \\$index with no type specified\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:normalizeCallOptions\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:normalizeCallOptions\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:normalizeEnumStringOption\\(\\) has parameter \\$allowed with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:pairsToAssoc\\(\\) has parameter \\$result with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:pairsToAssoc\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:showIndexStatus\\(\\) has parameter \\$index with no type specified\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Helper\\:\\:showTables\\(\\) has parameter \\$index with no type specified\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Parameter \\#1 \\$value of method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\:\\:quote\\(\\) expects array\\|bool\\|float\\|Foolz\\\\SphinxQL\\\\Expression\\|int\\|string\\|null, mixed given\\.$#" + count: 2 + path: src/Helper.php + + - + message: "#^Parameter \\#4 \\$allowEmpty of method Foolz\\\\SphinxQL\\\\Helper\\:\\:normalizeStringOption\\(\\) expects bool, mixed given\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Parameter \\#4 \\$allowed of method Foolz\\\\SphinxQL\\\\Helper\\:\\:normalizeEnumStringOption\\(\\) expects array, mixed given\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Parameter \\#4 \\$min of method Foolz\\\\SphinxQL\\\\Helper\\:\\:normalizeIntegerOption\\(\\) expects int\\|null, mixed given\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Parameter \\#5 \\$max of method Foolz\\\\SphinxQL\\\\Helper\\:\\:normalizeIntegerOption\\(\\) expects int\\|null, mixed given\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Result of && is always false\\.$#" + count: 1 + path: src/Helper.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\MatchBuilder\\:\\:field\\(\\) has parameter \\$fields with no value type specified in iterable type array\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\MatchBuilder\\:\\:ignoreField\\(\\) has parameter \\$fields with no value type specified in iterable type array\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\MatchBuilder\\:\\:zone\\(\\) has parameter \\$zones with no value type specified in iterable type array\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^PHPDoc tag @param for parameter \\$keywords with type Closure\\|Foolz\\\\SphinxQL\\\\Match\\|string is not subtype of native type Closure\\|Foolz\\\\SphinxQL\\\\Expression\\|Foolz\\\\SphinxQL\\\\MatchBuilder\\|int\\|string\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^PHPDoc tag @param for parameter \\$keywords with type Closure\\|Foolz\\\\SphinxQL\\\\Match\\|string is not subtype of native type Closure\\|Foolz\\\\SphinxQL\\\\Expression\\|Foolz\\\\SphinxQL\\\\MatchBuilder\\|string\\|null\\.$#" + count: 5 + path: src/MatchBuilder.php + + - + message: "#^Parameter \\#1 \\$keywords of method Foolz\\\\SphinxQL\\\\MatchBuilder\\:\\:match\\(\\) expects Closure\\|Foolz\\\\SphinxQL\\\\Expression\\|Foolz\\\\SphinxQL\\\\MatchBuilder\\|string\\|null, Closure\\|Foolz\\\\SphinxQL\\\\Expression\\|Foolz\\\\SphinxQL\\\\MatchBuilder\\|int\\|string given\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^Parameter \\#1 \\$keywords of method Foolz\\\\SphinxQL\\\\MatchBuilder\\:\\:match\\(\\) expects Closure\\|Foolz\\\\SphinxQL\\\\Expression\\|Foolz\\\\SphinxQL\\\\MatchBuilder\\|string\\|null, float\\|int\\|string given\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^Parameter \\$keywords of method Foolz\\\\SphinxQL\\\\MatchBuilder\\:\\:before\\(\\) has invalid type Foolz\\\\SphinxQL\\\\Match\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^Parameter \\$keywords of method Foolz\\\\SphinxQL\\\\MatchBuilder\\:\\:near\\(\\) has invalid type Foolz\\\\SphinxQL\\\\Match\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^Parameter \\$keywords of method Foolz\\\\SphinxQL\\\\MatchBuilder\\:\\:paragraph\\(\\) has invalid type Foolz\\\\SphinxQL\\\\Match\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^Parameter \\$keywords of method Foolz\\\\SphinxQL\\\\MatchBuilder\\:\\:sentence\\(\\) has invalid type Foolz\\\\SphinxQL\\\\Match\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^Parameter \\$keywords of method Foolz\\\\SphinxQL\\\\MatchBuilder\\:\\:zone\\(\\) has invalid type Foolz\\\\SphinxQL\\\\Match\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^Parameter \\$keywords of method Foolz\\\\SphinxQL\\\\MatchBuilder\\:\\:zonespan\\(\\) has invalid type Foolz\\\\SphinxQL\\\\Match\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\MatchBuilder\\:\\:\\$tokens type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^Unsafe usage of new static\\(\\)\\.$#" + count: 1 + path: src/MatchBuilder.php + + - + message: "#^Cannot access offset 0 on mixed\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^If condition is always true\\.$#" + count: 2 + path: src/Percolate.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Percolate\\:\\:convertArrayForQuery\\(\\) has parameter \\$array with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Percolate\\:\\:documents\\(\\) has parameter \\$documents with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Percolate\\:\\:generateInsert\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Percolate\\:\\:isAssocArray\\(\\) has parameter \\$arr with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Percolate\\:\\:options\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Percolate\\:\\:prepareFromJson\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Percolate\\:\\:tags\\(\\) has parameter \\$tags with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^PHPDoc tag @return with type bool\\|string is not subtype of native type string\\|false\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^PHPDoc tag @return with type mixed is not subtype of native type string\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^Parameter \\#1 \\$array of method Foolz\\\\SphinxQL\\\\Percolate\\:\\:convertArrayForQuery\\(\\) expects array, mixed given\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^Parameter \\#1 \\$index of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:into\\(\\) expects string, string\\|null given\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^Parameter \\#1 \\$string of method Foolz\\\\SphinxQL\\\\Percolate\\:\\:quoteString\\(\\) expects string, string\\|false given\\.$#" + count: 2 + path: src/Percolate.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\Percolate\\:\\:\\$documents type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\Percolate\\:\\:\\$escapeChars type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\Percolate\\:\\:\\$options type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^Result of && is always false\\.$#" + count: 2 + path: src/Percolate.php + + - + message: "#^Strict comparison using \\=\\=\\= between int\\<1, max\\> and 0 will always evaluate to false\\.$#" + count: 1 + path: src/Percolate.php + + - + message: "#^Call to function is_null\\(\\) with string will always evaluate to false\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Call to function is_string\\(\\) with array will always evaluate to false\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Cannot access offset 0 on non\\-empty\\-array\\|Closure\\|Foolz\\\\SphinxQL\\\\SphinxQL\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Cannot call method escape\\(\\) on Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\|null\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Cannot call method multiQuery\\(\\) on Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\|null\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Cannot call method query\\(\\) on Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\|null\\.$#" + count: 4 + path: src/SphinxQL.php + + - + message: "#^Cannot call method quote\\(\\) on Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\|null\\.$#" + count: 5 + path: src/SphinxQL.php + + - + message: "#^Cannot call method quoteArr\\(\\) on Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\|null\\.$#" + count: 3 + path: src/SphinxQL.php + + - + message: "#^Else branch is unreachable because previous condition is always true\\.$#" + count: 2 + path: src/SphinxQL.php + + - + message: "#^Instanceof between string and Foolz\\\\SphinxQL\\\\Expression will always evaluate to false\\.$#" + count: 2 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:columns\\(\\) has parameter \\$array with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:compileBooleanClause\\(\\) has parameter \\$tokens with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:compileEscapeChars\\(\\) has parameter \\$array with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:compileEscapeChars\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:compileFilterCondition\\(\\) has parameter \\$filter with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:createFilterCondition\\(\\) has parameter \\$operator with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:createFilterCondition\\(\\) has parameter \\$value with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:createFilterCondition\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:from\\(\\) has parameter \\$array with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:getResult\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:getSelect\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:option\\(\\) has parameter \\$value with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:orHaving\\(\\) has parameter \\$operator with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:orHaving\\(\\) has parameter \\$value with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:orWhere\\(\\) has parameter \\$operator with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:orWhere\\(\\) has parameter \\$value with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:orderByKnn\\(\\) has parameter \\$vector with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:select\\(\\) has parameter \\$columns with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:set\\(\\) has parameter \\$array with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:setFullEscapeChars\\(\\) has parameter \\$array with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:setHalfEscapeChars\\(\\) has parameter \\$array with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:setSelect\\(\\) has parameter \\$columns with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:values\\(\\) has parameter \\$array with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:where\\(\\) has parameter \\$operator with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:where\\(\\) has parameter \\$value with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Parameter \\#1 \\$string of function mb_strtolower expects string, string\\|null given\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, string\\|null given\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$columns type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$escape_full_chars type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$escape_half_chars type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$from type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$group_by type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$having type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$joins type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$last_result type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$match type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$options type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$order_by type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$select type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$set type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$values type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$where type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Property Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:\\$within_group_order_by type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Strict comparison using \\=\\=\\= between int and false will always evaluate to false\\.$#" + count: 5 + path: src/SphinxQL.php + + - + message: "#^Unreachable statement \\- code above always terminates\\.$#" + count: 1 + path: src/SphinxQL.php + + - + message: "#^Unsafe usage of new static\\(\\)\\.$#" + count: 2 + path: src/SphinxQL.php + + - + message: "#^Call to an undefined method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\:\\:close\\(\\)\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Call to an undefined method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\:\\:connect\\(\\)\\.$#" + count: 13 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Call to an undefined method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\:\\:getConnection\\(\\)\\.$#" + count: 6 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Call to an undefined method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\:\\:getParams\\(\\)\\.$#" + count: 8 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Call to an undefined method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\:\\:ping\\(\\)\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Call to an undefined method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\:\\:setParam\\(\\)\\.$#" + count: 13 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Call to an undefined method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\:\\:setParams\\(\\)\\.$#" + count: 2 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Cannot call method fetchAllAssoc\\(\\) on Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetInterface\\|false\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:test\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testClose\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testConnect\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testConnectThrowsException\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testConnectWithCredentialsParams\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testCredentialsParamValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testCredentialsParams\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testEmptyMultiQuery\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testEscape\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testEscapeThrowsException\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testGetConnection\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testGetConnectionParams\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testGetConnectionThrowsException\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testGetParams\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testMultiQuery\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testMultiQueryThrowsException\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testPing\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testQuery\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testQueryThrowsException\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testQuote\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ConnectionTest\\:\\:testQuoteArr\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Property ConnectionTest\\:\\:\\$connection \\(Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\) does not accept null\\.$#" + count: 1 + path: tests/SphinxQL/ConnectionTest.php + + - + message: "#^Method ExpressionTest\\:\\:testValue\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ExpressionTest.php + + - + message: "#^Method FacetTest\\:\\:testBy\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Method FacetTest\\:\\:testFacet\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Method FacetTest\\:\\:testFacetFunction\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Method FacetTest\\:\\:testFacetFunctionRequiresParameters\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Method FacetTest\\:\\:testFacetRejectsInvalidDirection\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Method FacetTest\\:\\:testFacetRejectsInvalidLimitAndOffset\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Method FacetTest\\:\\:testFacetRejectsInvalidOffset\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Method FacetTest\\:\\:testFacetRejectsInvalidOrderByFunctionDirection\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Method FacetTest\\:\\:testFacetRequiresColumns\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Method FacetTest\\:\\:testLimit\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Method FacetTest\\:\\:testOrderBy\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Method FacetTest\\:\\:testOrderByFunction\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Property FacetTest\\:\\:\\$conn has no type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Property FacetTest\\:\\:\\$data has no type specified\\.$#" + count: 1 + path: tests/SphinxQL/FacetTest.php + + - + message: "#^Method HelperCapabilityProbeTest\\:\\:testCallSuggestProbeUsesDiscoveredTableName\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^Method HelperCapabilityProbeTest\\:\\:testQSuggestAndAutocompleteFollowBuddySupport\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^Method HelperCapabilityProbeTest\\:\\:testShowTableSettingsProbeUsesDiscoveredTableName\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^Method HelperProbeConnection\\:\\:multiQuery\\(\\) has parameter \\$queue with no value type specified in iterable type array\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^Method HelperProbeConnection\\:\\:quote\\(\\) has parameter \\$value with no value type specified in iterable type array\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^Method HelperProbeConnection\\:\\:quoteArr\\(\\) has parameter \\$array with no value type specified in iterable type array\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^Method HelperProbeConnection\\:\\:quoteArr\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^Method HelperProbeResultSet\\:\\:fetchAllAssoc\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^Method HelperProbeResultSet\\:\\:fetchAllNum\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^Method HelperProbeResultSet\\:\\:fetchAssoc\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^Method HelperProbeResultSet\\:\\:fetchNum\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^Method HelperProbeResultSet\\:\\:getStored\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^PHPDoc tag @return with type mixed is not subtype of native type array\\|int\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^Parameter \\#1 \\$value of method HelperProbeConnection\\:\\:escape\\(\\) expects string, array\\|string given\\.$#" + count: 1 + path: tests/SphinxQL/HelperCapabilityProbeTest.php + + - + message: "#^Argument of an invalid type array\\|int\\\\|int\\<1, max\\> supplied for foreach, only iterables are supported\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Call to an undefined method Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\:\\:close\\(\\)\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Cannot access offset 0 on non\\-empty\\-array\\|int\\\\|int\\<1, max\\>\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^If condition is always true\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testAutocompleteExecutionWhenBuddySupported\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testAutocompleteOptionValidationWhenSupported\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testCallKeywords\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testCallKeywordsValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testCallSnippets\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testCallSnippetsValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testCapabilitiesAndSupports\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testCreateFunction\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testCreateFunctionValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testDescribe\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testFlushAndOptimizeExecution\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testHelperRequiresNonEmptyIdentifiers\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testKillValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testMiscellaneous\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testNewHelperValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testQSuggestExecutionWhenBuddySupported\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testRequireSupportValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testSetVariable\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testSetVariableValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testShowCharacterSetExecutionWhenSupported\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testShowCollationExecutionWhenSupported\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testShowIndexStatusExecution\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testShowIndexStatusLikeExecutionWhenSupported\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testShowPluginsExecutionWhenSupported\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testShowTableLikeVariantsExecutionWhenSupported\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testShowTables\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testShowTablesCompileVariants\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testShowVersionExecutionWhenSupported\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testShowWarningsAndStatusExecution\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testSuggestExecutionWhenSupported\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testSuggestOptionEnumValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testSuggestOptionRangeValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testSuggestOptionTypeValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testSuggestOptionValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testSuggestUnknownOptionValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testSupportsUnknownFeatureValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testTruncateRtIndex\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method HelperTest\\:\\:testUdfNotInstalled\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Parameter \\#1 \\$array of function array_shift expects array, array\\|int given\\.$#" + count: 1 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Parameter \\#1 \\$array of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:from\\(\\) expects array\\|null, string given\\.$#" + count: 2 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Parameter \\#1 \\$result of static method Foolz\\\\SphinxQL\\\\Helper\\:\\:pairsToAssoc\\(\\) expects array, array\\|int given\\.$#" + count: 2 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Parameter \\#1 \\$rows of static method Foolz\\\\SphinxQL\\\\Tests\\\\TestUtil\\:\\:pickColumns\\(\\) expects array, array\\|int given\\.$#" + count: 2 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Parameter \\#2 \\$haystack of method PHPUnit\\\\Framework\\\\Assert\\:\\:assertCount\\(\\) expects Countable\\|iterable, array\\|int given\\.$#" + count: 2 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Parameter \\#3 \\$hits of method Foolz\\\\SphinxQL\\\\Helper\\:\\:callKeywords\\(\\) expects string\\|null, int given\\.$#" + count: 2 + path: tests/SphinxQL/HelperTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testBefore\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testBoost\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testClosureMisuse\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testCompile\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testExact\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testField\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testIgnoreField\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testMatch\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testMaybe\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testNear\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testNot\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testOrMatch\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testOrPhrase\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testParagraph\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testPhrase\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testProximity\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testQuorum\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testSentence\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testZone\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Method MatchBuilderTest\\:\\:testZonespan\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Parameter \\#2 \\$limit of method Foolz\\\\SphinxQL\\\\MatchBuilder\\:\\:field\\(\\) expects int\\|null, string given\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Property MatchBuilderTest\\:\\:\\$sphinxql has no type specified\\.$#" + count: 1 + path: tests/SphinxQL/MatchBuilderTest.php + + - + message: "#^Cannot access offset 'count\\(\\*\\)' on mixed\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Cannot access offset 0 on mixed\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Cannot call method query\\(\\) on Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\|null\\.$#" + count: 2 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Interface Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetInterface referenced with incorrect case\\: Foolz\\\\Sphinxql\\\\Drivers\\\\ResultSetInterface\\.$#" + count: 6 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Method MultiResultSetTest\\:\\:refill\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Method MultiResultSetTest\\:\\:testArrayAccess\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Method MultiResultSetTest\\:\\:testGetNextSet\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Method MultiResultSetTest\\:\\:testGetNextSetFalse\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Method MultiResultSetTest\\:\\:testInvalidStore\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Method MultiResultSetTest\\:\\:testIsMultiResultSet\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Method MultiResultSetTest\\:\\:testIterator\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Method MultiResultSetTest\\:\\:testIteratorStored\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Method MultiResultSetTest\\:\\:testStore\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Offset 0 does not exist on array\\\\|null\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Parameter \\#1 \\$array of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:columns\\(\\) expects array, string given\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Parameter \\#2 \\$haystack of method PHPUnit\\\\Framework\\\\Assert\\:\\:assertCount\\(\\) expects Countable\\|iterable, array\\\\|null given\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Property MultiResultSetTest\\:\\:\\$data has no type specified\\.$#" + count: 1 + path: tests/SphinxQL/MultiResultSetTest.php + + - + message: "#^Method PdoResultSetAdapterTest\\:\\:testBooleanNormalizationUsesNumericStrings\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/PdoResultSetAdapterTest.php + + - + message: "#^Cannot call method fetchAllAssoc\\(\\) on array\\|Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetInterface\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:callPqProvider\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:insertProvider\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:testInsert\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:testInsert\\(\\) has parameter \\$compiledQuery with no type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:testInsert\\(\\) has parameter \\$filter with no type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:testInsert\\(\\) has parameter \\$index with no type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:testInsert\\(\\) has parameter \\$query with no type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:testInsert\\(\\) has parameter \\$tags with no type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:testInsert\\(\\) has parameter \\$testNumber with no type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:testPercolate\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:testPercolate\\(\\) has parameter \\$documents with no type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:testPercolate\\(\\) has parameter \\$index with no type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:testPercolate\\(\\) has parameter \\$options with no type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:testPercolate\\(\\) has parameter \\$result with no type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Method PercolateQueriesTest\\:\\:testPercolate\\(\\) has parameter \\$testNumber with no type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Property PercolateQueriesTest\\:\\:\\$conn has no type specified\\.$#" + count: 1 + path: tests/SphinxQL/PercolateQueriesTest.php + + - + message: "#^Cannot call method query\\(\\) on Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\|null\\.$#" + count: 2 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Interface Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetInterface referenced with incorrect case\\: Foolz\\\\Sphinxql\\\\Drivers\\\\ResultSetInterface\\.$#" + count: 2 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:refill\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testArrayAccess\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testCount\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testCountable\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testFetchAllAssoc\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testFetchAllNum\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testFetchAssoc\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testFetchNum\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testGetAffectedRows\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testHasNextRow\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testHasRow\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testIsResultSet\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testIterator\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testStore\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testToNextRow\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testToNextRowThrows\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testToRow\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Method ResultSetTest\\:\\:testToRowThrows\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Offset 'id' does not exist on array\\|null\\.$#" + count: 2 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Parameter \\#1 \\$array of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:columns\\(\\) expects array, string given\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Property ResultSetTest\\:\\:\\$data has no type specified\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Static property ResultSetTest\\:\\:\\$conn \\(Foolz\\\\SphinxQL\\\\Drivers\\\\Mysqli\\\\Connection\\) does not accept Foolz\\\\SphinxQL\\\\Drivers\\\\Mysqli\\\\Connection\\|Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\Connection\\.$#" + count: 1 + path: tests/SphinxQL/ResultSetTest.php + + - + message: "#^Cannot access offset 'Value' on mixed\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Cannot access offset 'cnt' on mixed\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Cannot access offset 'count\\(\\*\\)' on mixed\\.$#" + count: 6 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Cannot access offset 'gid' on mixed\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Cannot access offset 'id' on mixed\\.$#" + count: 2 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Cannot access offset 0 on array\\|int\\.$#" + count: 39 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Cannot access offset 1 on array\\|int\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Cannot access offset 3 on array\\|int\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Cannot call method getStored\\(\\) on array\\|Foolz\\\\SphinxQL\\\\Drivers\\\\MultiResultSetInterface\\|Foolz\\\\SphinxQL\\\\Drivers\\\\ResultSetInterface\\|int\\|null\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Cannot call method query\\(\\) on Foolz\\\\SphinxQL\\\\Drivers\\\\ConnectionInterface\\|null\\.$#" + count: 2 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:refill\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testClosureMisuse\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testCompileWithoutTypeThrowsException\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testCrossJoinCompilation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testDelete\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testEmptyQueue\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testEscapeChars\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testEscapeMatch\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testExpr\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testFacet\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testFacetTypeValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testFromValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testGetSelect\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testGroupBy\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testGroupNBy\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testGroupNByValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testHalfEscapeMatch\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testHaving\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testHavingValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testInsert\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testJoinCompilation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testJoinValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testLimit\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testLimitOffsetValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testMatch\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testOffset\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testOption\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testOrHavingAndGroupingCompilation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testOrWhereAndGroupingCompilation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testOrderBy\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testOrderByKnnCompilation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testOrderByKnnValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testOrderDirectionValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testQuery\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testQueue\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testReplace\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testResetJoins\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testResetMethods\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testSelect\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testSetQueuePrevValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testSetSelect\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testSetTypeValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testSphinxQLCapabilitiesAccess\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testSphinxQLRequireSupport\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testSphinxQLSupportsUnknownFeatureValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testSubselect\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testTransactions\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testUpdate\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testUpdateWithLateInto\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testUpdateWithoutIntoThrows\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testWhere\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testWhereGroupingValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testWhereValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testWithinGroupOrderBy\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method SphinxQLTest\\:\\:testWithinGroupOrderDirectionValidation\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Offset 0 does not exist on array\\\\|null\\.$#" + count: 3 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Offset 1 does not exist on array\\\\|null\\.$#" + count: 12 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Offset 2 does not exist on array\\\\|null\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#1 \\$array of function array_shift expects array, array\\|int given\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#1 \\$array of function array_shift expects array, array\\|int\\|null given\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#1 \\$array of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:columns\\(\\) expects array, string given\\.$#" + count: 4 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#1 \\$array of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:from\\(\\) expects array\\|null, Closure given\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#1 \\$array of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:from\\(\\) expects array\\|null, Foolz\\\\SphinxQL\\\\SphinxQL given\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#1 \\$array of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:from\\(\\) expects array\\|null, string given\\.$#" + count: 78 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#1 \\$array of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:values\\(\\) expects array, int given\\.$#" + count: 7 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#1 \\$facet of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:facet\\(\\) expects Foolz\\\\SphinxQL\\\\Facet, string given\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#1 \\$query of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:setQueuePrev\\(\\) expects Foolz\\\\SphinxQL\\\\SphinxQL, string given\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#1 \\$result of static method Foolz\\\\SphinxQL\\\\Helper\\:\\:pairsToAssoc\\(\\) expects array, array\\|int given\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#2 \\$array of method PHPUnit\\\\Framework\\\\Assert\\:\\:assertArrayHasKey\\(\\) expects array\\|ArrayAccess, mixed given\\.$#" + count: 6 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#2 \\$haystack of method PHPUnit\\\\Framework\\\\Assert\\:\\:assertCount\\(\\) expects Countable\\|iterable, array\\|int given\\.$#" + count: 28 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#2 \\$operator of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:having\\(\\) expects string, int given\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#2 \\$value of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:value\\(\\) expects string, array\\ given\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#2 \\$value of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:value\\(\\) expects string, int given\\.$#" + count: 6 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#3 \\$value of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:having\\(\\) expects string\\|null, array\\ given\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Parameter \\#3 \\$value of method Foolz\\\\SphinxQL\\\\SphinxQL\\:\\:having\\(\\) expects string\\|null, int given\\.$#" + count: 4 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Property SphinxQLTest\\:\\:\\$conn has no type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Property SphinxQLTest\\:\\:\\$data has no type specified\\.$#" + count: 1 + path: tests/SphinxQL/SphinxQLTest.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Tests\\\\TestUtil\\:\\:getConnectionDriver\\(\\) should return Foolz\\\\SphinxQL\\\\Drivers\\\\Mysqli\\\\Connection\\|Foolz\\\\SphinxQL\\\\Drivers\\\\Pdo\\\\Connection but returns object\\.$#" + count: 1 + path: tests/SphinxQL/TestUtil.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Tests\\\\TestUtil\\:\\:pickColumns\\(\\) has parameter \\$columns with no value type specified in iterable type array\\.$#" + count: 1 + path: tests/SphinxQL/TestUtil.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Tests\\\\TestUtil\\:\\:pickColumns\\(\\) has parameter \\$rows with no value type specified in iterable type array\\.$#" + count: 1 + path: tests/SphinxQL/TestUtil.php + + - + message: "#^Method Foolz\\\\SphinxQL\\\\Tests\\\\TestUtil\\:\\:pickColumns\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: tests/SphinxQL/TestUtil.php