diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index a6ad250..705018f 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -1,8 +1,6 @@
name: "PHQL CI"
on:
- schedule:
- - cron: '0 2 * * *' # Daily at 02:00 runs only on default branch
push:
paths-ignore:
- '**.md'
@@ -17,6 +15,50 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
jobs:
+ # PHP CodeSniffer inspection
+ phpcs:
+ name: "Validate code style"
+
+ permissions:
+ contents: read
+
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: true
+ matrix:
+ php:
+ - '8.1'
+ - '8.2'
+ - '8.3'
+ - '8.4'
+ - '8.5'
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: "Setup PHP"
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: ${{ env.EXTENSIONS }}
+ ini-values: apc.enable_cli=on, session.save_path=/tmp
+ tools: pecl
+ env:
+ COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PHALCON_PATH: ext
+
+ - name: "Install development dependencies with Composer"
+ uses: "ramsey/composer-install@v3"
+ with:
+ composer-options: "--prefer-dist"
+
+ - name: "PHPCS"
+ run: composer cs
+
+ - name: "PHPStan"
+ run: composer analyze
+
tests:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
@@ -60,6 +102,7 @@ jobs:
coverage:
name: Code Coverage
+ needs: tests
runs-on: ubuntu-latest
permissions:
@@ -71,7 +114,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '8.3'
+ php-version: '8.5'
extensions: mbstring
coverage: xdebug
tools: composer:v2
@@ -79,12 +122,12 @@ jobs:
COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
- uses: ramsey/composer-install@v3
+ uses: ramsey/composer-install@v4
with:
composer-options: "--prefer-dist"
- name: Run tests with coverage
- run: vendor/bin/phpunit --coverage-clover coverage.xml
+ run: vendor/bin/phpunit -c phpunit.xml --coverage-clover coverage.xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
diff --git a/.gitignore b/.gitignore
index 3256564..e2f5912 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,5 +7,6 @@ phql.log
# PHPUnit
.phpunit.result.cache/
+tests/_output/
coverage/
coverage.xml
diff --git a/composer.json b/composer.json
index 839847d..8c19b0a 100644
--- a/composer.json
+++ b/composer.json
@@ -1,15 +1,26 @@
{
"name": "phalcon/phql",
"description": "Phalcon Query Language (PHQL)",
+ "type": "library",
"keywords": [
"php",
"framework",
+ "phalcon",
"query",
- "sql"
+ "sql",
+ "phql"
],
+ "homepage": "https://phalcon.io",
"license": "MIT",
+ "authors": [
+ {
+ "name": "Phalcon Team",
+ "email": "team@phalcon.io",
+ "homepage": "https://phalcon.io"
+ }
+ ],
"require": {
- "php": ">=8.1",
+ "php": ">=8.1 <9.0",
"ext-mbstring": "*"
},
"suggest": {
@@ -17,7 +28,10 @@
},
"require-dev": {
"phpstan/phpstan": "^2.0",
- "phpunit/phpunit": "^10.5"
+ "phpunit/phpunit": "^10.5",
+ "pds/skeleton": "^1.0",
+ "pds/composer-script-names": "^1.0",
+ "squizlabs/php_codesniffer": "^4.0"
},
"autoload": {
"psr-4": {
@@ -29,12 +43,20 @@
},
"autoload-dev": {
"psr-4": {
- "Phalcon\\Phql\\Tests\\": "tests/",
- "Phalcon\\Phql\\Tests\\Unit\\": "tests/unit/"
+ "Phalcon\\Phql\\Tests\\": "tests/"
}
},
+ "minimum-stability": "stable",
+ "prefer-stable": true,
"scripts": {
+ "cs": "vendor/bin/phpcs --standard=phpcs.xml",
+ "cs-fix": "vendor/bin/phpcbf --standard=phpcs.xml",
+ "analyze": "vendor/bin/phpstan analyse -c phpstan.neon --memory-limit 1024M",
"test": "vendor/bin/phpunit -c phpunit.xml --fail-on-all-issues",
"test-coverage": "vendor/bin/phpunit -c phpunit.xml --coverage-html tests/_output/coverage/"
+ },
+ "support": {
+ "issues": "https://github.com/phalcon/phql/issues",
+ "source": "https://github.com/phalcon/phql"
}
}
diff --git a/composer.lock b/composer.lock
index 6a56b5f..8545330 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "3449d9f432f7ba89aa4794432fe52259",
+ "content-hash": "e76f5860ff8d0931244d183985f3a478",
"packages": [],
"packages-dev": [
{
@@ -125,6 +125,68 @@
},
"time": "2025-12-06T11:56:16+00:00"
},
+ {
+ "name": "pds/composer-script-names",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-pds/composer-script-names.git",
+ "reference": "e6a78aaeaee7cef82a995718ab2f5d0445ef8073"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-pds/composer-script-names/zipball/e6a78aaeaee7cef82a995718ab2f5d0445ef8073",
+ "reference": "e6a78aaeaee7cef82a995718ab2f5d0445ef8073",
+ "shasum": ""
+ },
+ "type": "standard",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "CC-BY-SA-4.0"
+ ],
+ "description": "Standard for Composer script names.",
+ "homepage": "https://github.com/php-pds/composer-script-names",
+ "support": {
+ "issues": "https://github.com/php-pds/composer-script-names/issues",
+ "source": "https://github.com/php-pds/composer-script-names/tree/1.0.0"
+ },
+ "time": "2023-04-06T13:42:16+00:00"
+ },
+ {
+ "name": "pds/skeleton",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-pds/skeleton.git",
+ "reference": "95e476e5d629eadacbd721c5a9553e537514a231"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-pds/skeleton/zipball/95e476e5d629eadacbd721c5a9553e537514a231",
+ "reference": "95e476e5d629eadacbd721c5a9553e537514a231",
+ "shasum": ""
+ },
+ "bin": [
+ "bin/pds-skeleton"
+ ],
+ "type": "standard",
+ "autoload": {
+ "psr-4": {
+ "Pds\\Skeleton\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "CC-BY-SA-4.0"
+ ],
+ "description": "Standard for PHP package skeletons.",
+ "homepage": "https://github.com/php-pds/skeleton",
+ "support": {
+ "issues": "https://github.com/php-pds/skeleton/issues",
+ "source": "https://github.com/php-pds/skeleton/tree/1.x"
+ },
+ "time": "2017-01-25T23:30:41+00:00"
+ },
{
"name": "phar-io/manifest",
"version": "2.0.4",
@@ -1679,6 +1741,85 @@
],
"time": "2023-02-07T11:34:05+00:00"
},
+ {
+ "name": "squizlabs/php_codesniffer",
+ "version": "4.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
+ "reference": "0525c73950de35ded110cffafb9892946d7771b5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5",
+ "reference": "0525c73950de35ded110cffafb9892946d7771b5",
+ "shasum": ""
+ },
+ "require": {
+ "ext-simplexml": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": ">=7.2.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31"
+ },
+ "bin": [
+ "bin/phpcbf",
+ "bin/phpcs"
+ ],
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Greg Sherwood",
+ "role": "Former lead"
+ },
+ {
+ "name": "Juliette Reinders Folmer",
+ "role": "Current lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
+ }
+ ],
+ "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "keywords": [
+ "phpcs",
+ "standards",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
+ "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
+ "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-11-10T16:43:36+00:00"
+ },
{
"name": "theseer/tokenizer",
"version": "1.3.1",
@@ -1733,10 +1874,10 @@
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
- "prefer-stable": false,
+ "prefer-stable": true,
"prefer-lowest": false,
"platform": {
- "php": ">=8.1",
+ "php": ">=8.1 <9.0",
"ext-mbstring": "*"
},
"platform-dev": {},
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000..8828afe
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,12 @@
+
+
+ Phalcon Coding Standards
+
+
+
+
+
+
+ src
+ tests/unit
+
diff --git a/phpunit.xml b/phpunit.xml
index c217093..614d18c 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -15,7 +15,9 @@
src
- resources
+
+ resources/files/parser.php
+
diff --git a/resources/docker/develop/Dockerfile b/resources/docker/develop/Dockerfile
index 5a68a02..9578c2b 100644
--- a/resources/docker/develop/Dockerfile
+++ b/resources/docker/develop/Dockerfile
@@ -44,7 +44,7 @@ RUN groupadd -g "${GID}" "${GROUP}" \
&& rm -rf /tmp/* /var/tmp/* \
&& find /var/cache/apt/archives /var/lib/apt/lists -not -name lock -type f -delete \
&& find /var/cache -type f -delete \
- && find /var/log -type f -delete \
+ && find /var/log -type f -delete
COPY resources/docker/develop/.bashrc /home/${USER}/.bashrc
COPY resources/docker/develop/extra.ini /usr/local/etc/php/conf.d/
diff --git a/resources/files/parser.php b/resources/files/parser.php
index bac010d..172f571 100644
--- a/resources/files/parser.php
+++ b/resources/files/parser.php
@@ -31,9 +31,9 @@
class phql_Parser
{
- protected array $output = [];
+ protected mixed $output = [];
- public function getOutput(): array
+ public function getOutput(): mixed
{
return $this->output;
}
@@ -3913,7 +3913,7 @@ private function yy_reduce(int $yyruleno): void
*/
case 0:
$yygotominor = $this->yystack[$this->yyidx + 0]->minor;
- //ZVAL_ZVAL($status->ret, $this->yystack[$this->yyidx + 0]->minor, 1, 1);
+ $this->status->setAst($this->yystack[$this->yyidx + 0]->minor);
break;
case 1:
case 2:
@@ -3971,9 +3971,13 @@ private function yy_reduce(int $yyruleno): void
$this->yy_destructor(30, $this->yystack[$this->yyidx + 0]->minor);
break;
case 9:
- case 20:
- case 27:
- case 38:
+ // distinct_all ::= (no DISTINCT or ALL keyword) - no distinct
+ $yygotominor = null;
+ break;
+ case 135:
+ // distinct_or_null ::= (no DISTINCT keyword in function call)
+ $yygotominor = null;
+ break;
case 69:
case 71:
case 78:
@@ -3981,10 +3985,18 @@ private function yy_reduce(int $yyruleno): void
case 85:
case 89:
case 91:
- case 135:
case 137:
+ // Empty optional clause (where, limit, order, group, having, for_update, etc.)
+ // Matches cphalcon ZVAL_UNDEF — must be null so phql_ret_*_statement skips adding the key
+ $yygotominor = null;
+ break;
+ case 20:
+ case 27:
$yygotominor = [];
break;
+ case 38:
+ $yygotominor = null;
+ break;
case 10:
case 17:
case 41:
@@ -4010,13 +4022,13 @@ private function yy_reduce(int $yyruleno): void
break;
case 12:
case 140:
- phql_ret_column_item($yygotominor, Opcode::PHQL_T_STARALL);
+ phql_ret_column_item($yygotominor, Opcode::STARALL->value);
$this->yy_destructor(18, $this->yystack[$this->yyidx + 0]->minor);
break;
case 13:
phql_ret_column_item(
$yygotominor,
- Opcode::PHQL_T_DOMAINALL,
+ Opcode::DOMAINALL->value,
null,
$this->yystack[$this->yyidx + -2]->minor,
);
@@ -4026,7 +4038,7 @@ private function yy_reduce(int $yyruleno): void
case 14:
phql_ret_column_item(
$yygotominor,
- Opcode::PHQL_T_EXPR,
+ Opcode::EXPR->value,
$this->yystack[$this->yyidx + -2]->minor,
null,
$this->yystack[$this->yyidx + 0]->minor
@@ -4036,7 +4048,7 @@ private function yy_reduce(int $yyruleno): void
case 15:
phql_ret_column_item(
$yygotominor,
- Opcode::PHQL_T_EXPR,
+ Opcode::EXPR->value,
$this->yystack[$this->yyidx + -1]->minor,
null,
$this->yystack[$this->yyidx + 0]->minor
@@ -4045,7 +4057,7 @@ private function yy_reduce(int $yyruleno): void
case 16:
phql_ret_column_item(
$yygotominor,
- Opcode::PHQL_T_EXPR,
+ Opcode::EXPR->value,
$this->yystack[$this->yyidx + 0]->minor,
);
break;
@@ -4077,50 +4089,50 @@ private function yy_reduce(int $yyruleno): void
phql_ret_qualified_name($yygotominor, null, null, $this->yystack[$this->yyidx + 0]->minor);
break;
case 28:
- $yygotominor = Opcode::PHQL_T_INNERJOIN;
+ $yygotominor = Opcode::INNERJOIN->value;
$this->yy_destructor(34, $this->yystack[$this->yyidx + -1]->minor);
$this->yy_destructor(35, $this->yystack[$this->yyidx + 0]->minor);
break;
case 29:
- $yygotominor = Opcode::PHQL_T_CROSSJOIN;
+ $yygotominor = Opcode::CROSSJOIN->value;
$this->yy_destructor(36, $this->yystack[$this->yyidx + -1]->minor);
$this->yy_destructor(35, $this->yystack[$this->yyidx + 0]->minor);
break;
case 30:
- $yygotominor = Opcode::PHQL_T_LEFTJOIN;
+ $yygotominor = Opcode::LEFTJOIN->value;
$this->yy_destructor(37, $this->yystack[$this->yyidx + -2]->minor);
$this->yy_destructor(38, $this->yystack[$this->yyidx + -1]->minor);
$this->yy_destructor(35, $this->yystack[$this->yyidx + 0]->minor);
break;
case 31:
- $yygotominor = Opcode::PHQL_T_LEFTJOIN;
+ $yygotominor = Opcode::LEFTJOIN->value;
$this->yy_destructor(37, $this->yystack[$this->yyidx + -1]->minor);
$this->yy_destructor(35, $this->yystack[$this->yyidx + 0]->minor);
break;
case 32:
- $yygotominor = Opcode::PHQL_T_RIGHTJOIN;
+ $yygotominor = Opcode::RIGHTJOIN->value;
$this->yy_destructor(39, $this->yystack[$this->yyidx + -2]->minor);
$this->yy_destructor(38, $this->yystack[$this->yyidx + -1]->minor);
$this->yy_destructor(35, $this->yystack[$this->yyidx + 0]->minor);
break;
case 33:
- $yygotominor = Opcode::PHQL_T_RIGHTJOIN;
+ $yygotominor = Opcode::RIGHTJOIN->value;
$this->yy_destructor(39, $this->yystack[$this->yyidx + -1]->minor);
$this->yy_destructor(35, $this->yystack[$this->yyidx + 0]->minor);
break;
case 34:
- $yygotominor = Opcode::PHQL_T_FULLJOIN;
+ $yygotominor = Opcode::FULLJOIN->value;
$this->yy_destructor(40, $this->yystack[$this->yyidx + -2]->minor);
$this->yy_destructor(38, $this->yystack[$this->yyidx + -1]->minor);
$this->yy_destructor(35, $this->yystack[$this->yyidx + 0]->minor);
break;
case 35:
- $yygotominor = Opcode::PHQL_T_FULLJOIN;
+ $yygotominor = Opcode::FULLJOIN->value;
$this->yy_destructor(40, $this->yystack[$this->yyidx + -1]->minor);
$this->yy_destructor(35, $this->yystack[$this->yyidx + 0]->minor);
break;
case 36:
- $yygotominor = Opcode::PHQL_T_INNERJOIN;
+ $yygotominor = Opcode::INNERJOIN->value;
$this->yy_destructor(35, $this->yystack[$this->yyidx + 0]->minor);
break;
case 37:
@@ -4286,11 +4298,11 @@ private function yy_reduce(int $yyruleno): void
phql_ret_order_item($yygotominor, $this->yystack[$this->yyidx + 0]->minor);
break;
case 75:
- phql_ret_order_item($yygotominor, $this->yystack[$this->yyidx + -1]->minor, Opcode::PHQL_T_ASC);
+ phql_ret_order_item($yygotominor, $this->yystack[$this->yyidx + -1]->minor, Opcode::ASC->value);
$this->yy_destructor(54, $this->yystack[$this->yyidx + 0]->minor);
break;
case 76:
- phql_ret_order_item($yygotominor, $this->yystack[$this->yyidx + -1]->minor, Opcode::PHQL_T_DESC);
+ phql_ret_order_item($yygotominor, $this->yystack[$this->yyidx + -1]->minor, Opcode::DESC->value);
$this->yy_destructor(55, $this->yystack[$this->yyidx + 0]->minor);
break;
case 77:
@@ -4334,7 +4346,7 @@ private function yy_reduce(int $yyruleno): void
case 150:
phql_ret_literal_zval(
$yygotominor,
- Opcode::PHQL_T_INTEGER,
+ Opcode::INTEGER->value,
$this->yystack[$this->yyidx + 0]->minor
);
break;
@@ -4342,7 +4354,7 @@ private function yy_reduce(int $yyruleno): void
case 151:
phql_ret_literal_zval(
$yygotominor,
- Opcode::PHQL_T_HINTEGER,
+ Opcode::HINTEGER->value,
$this->yystack[$this->yyidx + 0]->minor
);
break;
@@ -4350,7 +4362,7 @@ private function yy_reduce(int $yyruleno): void
case 157:
phql_ret_placeholder_zval(
$yygotominor,
- Opcode::PHQL_T_NPLACEHOLDER,
+ Opcode::NPLACEHOLDER->value,
$this->yystack[$this->yyidx + 0]->minor
);
break;
@@ -4358,7 +4370,7 @@ private function yy_reduce(int $yyruleno): void
case 158:
phql_ret_placeholder_zval(
$yygotominor,
- Opcode::PHQL_T_SPLACEHOLDER,
+ Opcode::SPLACEHOLDER->value,
$this->yystack[$this->yyidx + 0]->minor
);
break;
@@ -4366,18 +4378,18 @@ private function yy_reduce(int $yyruleno): void
case 159:
phql_ret_placeholder_zval(
$yygotominor,
- Opcode::PHQL_T_BPLACEHOLDER,
+ Opcode::BPLACEHOLDER->value,
$this->yystack[$this->yyidx + 0]->minor
);
break;
case 97:
- phql_ret_expr($yygotominor, Opcode::PHQL_T_MINUS, null, $this->yystack[$this->yyidx + 0]->minor);
+ phql_ret_expr($yygotominor, Opcode::MINUS->value, null, $this->yystack[$this->yyidx + 0]->minor);
$this->yy_destructor(21, $this->yystack[$this->yyidx + -1]->minor);
break;
case 98:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_SUB,
+ Opcode::SUB->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4386,7 +4398,7 @@ private function yy_reduce(int $yyruleno): void
case 99:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_ADD,
+ Opcode::ADD->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4395,7 +4407,7 @@ private function yy_reduce(int $yyruleno): void
case 100:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_MUL,
+ Opcode::MUL->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4404,7 +4416,7 @@ private function yy_reduce(int $yyruleno): void
case 101:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_DIV,
+ Opcode::DIV->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4413,7 +4425,7 @@ private function yy_reduce(int $yyruleno): void
case 102:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_MOD,
+ Opcode::MOD->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4422,7 +4434,7 @@ private function yy_reduce(int $yyruleno): void
case 103:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_AND,
+ Opcode::AND->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4431,7 +4443,7 @@ private function yy_reduce(int $yyruleno): void
case 104:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_OR,
+ Opcode::OR->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4440,7 +4452,7 @@ private function yy_reduce(int $yyruleno): void
case 105:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_BITWISE_AND,
+ Opcode::BITWISE_AND->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4449,7 +4461,7 @@ private function yy_reduce(int $yyruleno): void
case 106:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_BITWISE_OR,
+ Opcode::BITWISE_OR->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4458,7 +4470,7 @@ private function yy_reduce(int $yyruleno): void
case 107:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_BITWISE_XOR,
+ Opcode::BITWISE_XOR->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4467,7 +4479,7 @@ private function yy_reduce(int $yyruleno): void
case 108:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_EQUALS,
+ Opcode::EQUALS->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4476,7 +4488,7 @@ private function yy_reduce(int $yyruleno): void
case 109:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_NOTEQUALS,
+ Opcode::NOTEQUALS->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4485,7 +4497,7 @@ private function yy_reduce(int $yyruleno): void
case 110:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_LESS,
+ Opcode::LESS->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4494,7 +4506,7 @@ private function yy_reduce(int $yyruleno): void
case 111:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_GREATER,
+ Opcode::GREATER->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4503,7 +4515,7 @@ private function yy_reduce(int $yyruleno): void
case 112:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_GREATEREQUAL,
+ Opcode::GREATEREQUAL->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4512,7 +4524,7 @@ private function yy_reduce(int $yyruleno): void
case 113:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_LESSEQUAL,
+ Opcode::LESSEQUAL->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4521,7 +4533,7 @@ private function yy_reduce(int $yyruleno): void
case 114:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_LIKE,
+ Opcode::LIKE->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4530,7 +4542,7 @@ private function yy_reduce(int $yyruleno): void
case 115:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_NLIKE,
+ Opcode::NLIKE->value,
$this->yystack[$this->yyidx + -3]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4540,7 +4552,7 @@ private function yy_reduce(int $yyruleno): void
case 116:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_ILIKE,
+ Opcode::ILIKE->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4549,7 +4561,7 @@ private function yy_reduce(int $yyruleno): void
case 117:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_NILIKE,
+ Opcode::NILIKE->value,
$this->yystack[$this->yyidx + -3]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4560,7 +4572,7 @@ private function yy_reduce(int $yyruleno): void
case 121:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_IN,
+ Opcode::IN->value,
$this->yystack[$this->yyidx + -4]->minor,
$this->yystack[$this->yyidx + -1]->minor
);
@@ -4572,7 +4584,7 @@ private function yy_reduce(int $yyruleno): void
case 122:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_NOTIN,
+ Opcode::NOTIN->value,
$this->yystack[$this->yyidx + -5]->minor,
$this->yystack[$this->yyidx + -1]->minor
);
@@ -4584,7 +4596,7 @@ private function yy_reduce(int $yyruleno): void
case 120:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_SUBQUERY,
+ Opcode::SUBQUERY->value,
$this->yystack[$this->yyidx + -1]->minor,
null
);
@@ -4592,7 +4604,7 @@ private function yy_reduce(int $yyruleno): void
$this->yy_destructor(46, $this->yystack[$this->yyidx + 0]->minor);
break;
case 123:
- phql_ret_expr($yygotominor, Opcode::PHQL_T_EXISTS, null, $this->yystack[$this->yyidx + -1]->minor);
+ phql_ret_expr($yygotominor, Opcode::EXISTS->value, null, $this->yystack[$this->yyidx + -1]->minor);
$this->yy_destructor(66, $this->yystack[$this->yyidx + -3]->minor);
$this->yy_destructor(45, $this->yystack[$this->yyidx + -2]->minor);
$this->yy_destructor(46, $this->yystack[$this->yyidx + 0]->minor);
@@ -4600,7 +4612,7 @@ private function yy_reduce(int $yyruleno): void
case 124:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_AGAINST,
+ Opcode::AGAINST->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4611,7 +4623,7 @@ private function yy_reduce(int $yyruleno): void
phql_ret_raw_qualified_name($qualified, $this->yystack[$this->yyidx + -1]->minor);
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_CAST,
+ Opcode::CAST->value,
$this->yystack[$this->yyidx + -3]->minor,
$qualified
);
@@ -4625,7 +4637,7 @@ private function yy_reduce(int $yyruleno): void
phql_ret_raw_qualified_name($qualified, $this->yystack[$this->yyidx + -1]->minor, null);
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_CONVERT,
+ Opcode::CONVERT->value,
$this->yystack[$this->yyidx + -3]->minor,
$qualified
);
@@ -4637,7 +4649,7 @@ private function yy_reduce(int $yyruleno): void
case 127:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_CASE,
+ Opcode::CASE->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + -1]->minor
);
@@ -4647,7 +4659,7 @@ private function yy_reduce(int $yyruleno): void
case 130:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_WHEN,
+ Opcode::WHEN->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4655,7 +4667,7 @@ private function yy_reduce(int $yyruleno): void
$this->yy_destructor(73, $this->yystack[$this->yyidx + -1]->minor);
break;
case 131:
- phql_ret_expr($yygotominor, Opcode::PHQL_T_ELSE, $this->yystack[$this->yyidx + 0]->minor, null);
+ phql_ret_expr($yygotominor, Opcode::ELSE->value, $this->yystack[$this->yyidx + 0]->minor, null);
$this->yy_destructor(74, $this->yystack[$this->yyidx + -1]->minor);
break;
case 133:
@@ -4669,18 +4681,19 @@ private function yy_reduce(int $yyruleno): void
$this->yy_destructor(46, $this->yystack[$this->yyidx + 0]->minor);
break;
case 134:
- $yygotominor = 0;
+ // distinct_or_null ::= DISTINCT (DISTINCT keyword present in function call)
+ $yygotominor = true;
$this->yy_destructor(29, $this->yystack[$this->yyidx + 0]->minor);
break;
case 142:
- phql_ret_expr($yygotominor, Opcode::PHQL_T_ISNULL, $this->yystack[$this->yyidx + -2]->minor, null);
+ phql_ret_expr($yygotominor, Opcode::ISNULL->value, $this->yystack[$this->yyidx + -2]->minor, null);
$this->yy_destructor(22, $this->yystack[$this->yyidx + -1]->minor);
$this->yy_destructor(75, $this->yystack[$this->yyidx + 0]->minor);
break;
case 143:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_ISNOTNULL,
+ Opcode::ISNOTNULL->value,
$this->yystack[$this->yyidx + -3]->minor,
null
);
@@ -4691,7 +4704,7 @@ private function yy_reduce(int $yyruleno): void
case 144:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_BETWEEN,
+ Opcode::BETWEEN->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4700,20 +4713,20 @@ private function yy_reduce(int $yyruleno): void
case 145:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_BETWEEN_NOT,
+ Opcode::BETWEEN_NOT->value,
$this->yystack[$this->yyidx + -2]->minor,
$this->yystack[$this->yyidx + 0]->minor
);
$this->yy_destructor(3, $this->yystack[$this->yyidx + -1]->minor);
break;
case 146:
- phql_ret_expr($yygotominor, Opcode::PHQL_T_NOT, null, $this->yystack[$this->yyidx + 0]->minor);
+ phql_ret_expr($yygotominor, Opcode::NOT->value, null, $this->yystack[$this->yyidx + 0]->minor);
$this->yy_destructor(24, $this->yystack[$this->yyidx + -1]->minor);
break;
case 147:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_BITWISE_NOT,
+ Opcode::BITWISE_NOT->value,
null,
$this->yystack[$this->yyidx + 0]->minor
);
@@ -4722,7 +4735,7 @@ private function yy_reduce(int $yyruleno): void
case 148:
phql_ret_expr(
$yygotominor,
- Opcode::PHQL_T_ENCLOSED,
+ Opcode::ENCLOSED->value,
$this->yystack[$this->yyidx + -1]->minor,
null
);
@@ -4730,19 +4743,19 @@ private function yy_reduce(int $yyruleno): void
$this->yy_destructor(46, $this->yystack[$this->yyidx + 0]->minor);
break;
case 152:
- phql_ret_literal_zval($yygotominor, Opcode::PHQL_T_STRING, $this->yystack[$this->yyidx + 0]->minor);
+ phql_ret_literal_zval($yygotominor, Opcode::STRING->value, $this->yystack[$this->yyidx + 0]->minor);
break;
case 153:
- phql_ret_literal_zval($yygotominor, Opcode::PHQL_T_DOUBLE, $this->yystack[$this->yyidx + 0]->minor);
+ phql_ret_literal_zval($yygotominor, Opcode::DOUBLE->value, $this->yystack[$this->yyidx + 0]->minor);
break;
case 154:
- phql_ret_literal_zval($yygotominor, Opcode::PHQL_T_NULL);
+ phql_ret_literal_zval($yygotominor, Opcode::NULL->value);
$this->yy_destructor(75, $this->yystack[$this->yyidx + 0]->minor);
break;
case 155:
#line 920 "c/parser.php.lemon"
{
- phql_ret_literal_zval($yygotominor, Opcode::PHQL_T_TRUE, null);
+ phql_ret_literal_zval($yygotominor, Opcode::TRUE->value, null);
$this->yy_destructor(78, $this->yystack[$this->yyidx + 0]->minor);
}
#line 2086 "c/parser.php.php"
@@ -4750,7 +4763,7 @@ private function yy_reduce(int $yyruleno): void
case 156:
#line 924 "c/parser.php.lemon"
{
- phql_ret_literal_zval($yygotominor, Opcode::PHQL_T_FALSE, null);
+ phql_ret_literal_zval($yygotominor, Opcode::FALSE->value, null);
$this->yy_destructor(79, $this->yystack[$this->yyidx + 0]->minor);
}
#line 2094 "c/parser.php.php"
@@ -4848,8 +4861,8 @@ private function yy_syntax_error()
if ($this->status->getState()->getStartLength()) {
if ($active_token) {
- if (in_array($active_token->getOpcode(), $tokens)) {
- $token_name = array_search($active_token->getOpcode(), $tokens);
+ if (in_array($active_token->value, $tokens)) {
+ $token_name = array_search($active_token->value, $tokens);
}
}
@@ -4858,11 +4871,11 @@ private function yy_syntax_error()
}
if ($near_length > 0) {
- if ($this->status->getToken()->getValue()) {
+ if ($this->status->getToken()->value) {
$this->status->setSyntaxError(sprintf(
"Syntax error, unexpected token %s(%s), near to '%s', when parsing: %s",
$token_name,
- $this->status->getToken()->getValue(),
+ $this->status->getToken()->value,
$this->status->getState()->getStart(),
$this->status->getState()->getRawBuffer(),
));
@@ -4875,12 +4888,12 @@ private function yy_syntax_error()
));
}
} else {
- if ($active_token != Opcode::PHQL_T_IGNORE) {
- if ($this->status->getToken()->getValue()) {
+ if ($active_token !== Opcode::IGNORE) {
+ if ($this->status->getToken()->value) {
$this->status->setSyntaxError(sprintf(
"Syntax error, unexpected token %s(%s), at the end of query, when parsing: %s",
$token_name,
- $this->status->getToken()->getValue(),
+ $this->status->getToken()->value,
$this->status->getState()->getRawBuffer(),
));
} else {
@@ -4924,20 +4937,21 @@ class phql_yyStackEntry
function phql_ret_insert_statement(&$ret, $Q, $F, $V): void
{
$ret = [
- "type" => Opcode::PHQL_T_INSERT,
+ "type" => Opcode::INSERT->value,
"qualifiedName" => $Q,
- "values" => $V,
];
if ($F !== null) {
$ret["fields"] = $F;
}
+
+ $ret["values"] = $V;
}
function phql_ret_select_statement(&$ret, $S, $W, $O, $G, $H, $L, $F): void
{
$ret = [
- "type" => Opcode::PHQL_T_SELECT,
+ "type" => Opcode::SELECT->value,
"select" => $S,
];
@@ -4995,7 +5009,7 @@ function phql_ret_literal_zval(&$ret, int $type, ?Token $T = null): array
{
$ret = ['type' => $type];
if ($T !== null) {
- $ret['value'] = $T->getValue();
+ $ret['value'] = $T->value;
}
return $ret;
@@ -5040,11 +5054,11 @@ function phql_ret_column_item(
}
if ($identifierColumn !== null) {
- $ret['column'] = $identifierColumn->getValue();
+ $ret['column'] = $identifierColumn->value;
}
if ($alias !== null) {
- $ret['alias'] = $alias->getValue();
+ $ret['alias'] = $alias->value;
}
return $ret;
@@ -5083,25 +5097,25 @@ function phql_ret_qualified_name(
): void
{
$ret = [
- 'type' => Opcode::PHQL_T_QUALIFIED,
+ 'type' => Opcode::QUALIFIED->value,
];
if ($nsAlias !== null) {
- $ret['ns-alias'] = $nsAlias->getValue();
+ $ret['ns-alias'] = $nsAlias->value;
}
/* if (B) phql_add_assoc_stringl(..., "domain", ...) */
if ($domain !== null) {
- $ret['domain'] = $domain->getValue();
+ $ret['domain'] = $domain->value;
}
- $ret['name'] = $name->getValue();
+ $ret['name'] = $name->value;
}
function phql_ret_update_statement(array &$ret, $update, $where = null, $limit = null): void
{
$ret = [];
- $ret['type'] = defined('PHQL_T_UPDATE') ? PHQL_T_UPDATE : 0;
+ $ret['type'] = Opcode::UPDATE->value;
$ret['update'] = $update;
if ($where !== null) {
@@ -5130,7 +5144,7 @@ function phql_ret_update_item(array &$ret, $column, $expr): void
function phql_ret_delete_statement(array &$ret, $delete, $where = null, $limit = null): void
{
$ret = [];
- $ret['type'] = defined('PHQL_T_DELETE') ? PHQL_T_DELETE : 0;
+ $ret['type'] = Opcode::DELETE->value;
$ret['delete'] = $delete;
if ($where !== null) {
@@ -5156,7 +5170,7 @@ function phql_ret_assoc_name(array &$ret, $qualifiedName, $alias = null, $with =
];
if ($alias !== null) {
- $ret['alias'] = $alias instanceof Token ? $alias->getValue() : $alias;
+ $ret['alias'] = $alias instanceof Token ? $alias->value : $alias;
}
if ($with !== null) {
@@ -5188,29 +5202,29 @@ function phql_ret_placeholder_zval(array &$ret, int $type, ?Token $value = null)
{
$ret = [];
$ret['type'] = $type;
- $ret['value'] = $value->getValue() ?? null;
+ $ret['value'] = $value->value ?? null;
}
-function phql_ret_raw_qualified_name(array &$ret, string $tokenA, ?string $tokenB = null): void
+function phql_ret_raw_qualified_name(array &$ret, Token $tokenA, ?Token $tokenB = null): void
{
$ret = [];
- $ret['type'] = defined('PHQL_T_RAW_QUALIFIED') ? PHQL_T_RAW_QUALIFIED : 0;
+ $ret['type'] = Opcode::RAW_QUALIFIED->value;
if ($tokenB !== null) {
/* Two-part qualified name: domain + name */
- $ret['domain'] = $tokenA; // equivalent to phql_add_assoc_stringl(..., "domain", A->token, ...)
- $ret['name'] = $tokenB; // equivalent to phql_add_assoc_stringl(..., "name", B->token, ...)
+ $ret['domain'] = $tokenA->value;
+ $ret['name'] = $tokenB->value;
} else {
/* Single-part name */
- $ret['name'] = $tokenA;
+ $ret['name'] = $tokenA->value;
}
}
function phql_ret_func_call(array &$ret, $name, $arguments = null, $distinct = null): void
{
$ret = [];
- $ret['type'] = defined('PHQL_T_FCALL') ? PHQL_T_FCALL : 0;
- $ret['name'] = $name instanceof Token ? $name->getValue() : $name;
+ $ret['type'] = Opcode::FCALL->value;
+ $ret['name'] = $name instanceof Token ? $name->value : $name;
if ($arguments !== null) {
$ret['arguments'] = $arguments;
diff --git a/src/Enum.php b/src/Enum.php
deleted file mode 100644
index 5b14eb0..0000000
--- a/src/Enum.php
+++ /dev/null
@@ -1,138 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE.txt
- * file that was distributed with this source code.
- */
-
-declare(strict_types=1);
-
-namespace Phalcon\Phql;
-
-class Enum
-{
- /**
- * PHQL
- */
- public const PHQL_SCANNER_RETCODE_EOF = -1;
- public const PHQL_SCANNER_RETCODE_ERR = -2;
- public const PHQL_SCANNER_RETCODE_IMPOSSIBLE = -3;
- public const PHQL_T_ADD = '+';
-
- /* Literals & Identifiers */
- public const PHQL_T_AGAINST = 276;
- public const PHQL_T_ALL = 338;
- public const PHQL_T_AND = 266;
- public const PHQL_T_AS = 305;
- public const PHQL_T_ASC = 327;
-
- /* Operators */
- public const PHQL_T_BETWEEN = 331;
- public const PHQL_T_BETWEEN_NOT = 332;
- public const PHQL_T_BITWISE_AND = '&';
- public const PHQL_T_BITWISE_NOT = '~';
- public const PHQL_T_BITWISE_OR = '|';
- public const PHQL_T_BITWISE_XOR = '^';
- public const PHQL_T_BPLACEHOLDER = 277;
- public const PHQL_T_BY = 311;
- public const PHQL_T_CASE = 409;
- public const PHQL_T_CAST = 333;
- public const PHQL_T_COLON = ':';
- public const PHQL_T_COMMA = 269;
- public const PHQL_T_CONVERT = 336;
- public const PHQL_T_CROSS = 324;
- public const PHQL_T_CROSSJOIN = 363;
- public const PHQL_T_DELETE = 303;
- public const PHQL_T_DESC = 328;
- public const PHQL_T_DISTINCT = 330;
- public const PHQL_T_DIV = '/';
- public const PHQL_T_DOMAINALL = 353;
- public const PHQL_T_DOT = '.';
- public const PHQL_T_DOUBLE = 259;
- public const PHQL_T_ELSE = 411;
- public const PHQL_T_ENCLOSED = 356;
- public const PHQL_T_END = 412;
- public const PHQL_T_EQUALS = '=';
- public const PHQL_T_EXISTS = 408;
- public const PHQL_T_EXPR = 354;
- public const PHQL_T_FALSE = 335;
- /** Special Tokens */
- public const PHQL_T_FCALL = 350;
- public const PHQL_T_FOR = 339;
- public const PHQL_T_FROM = 304;
- public const PHQL_T_FULL = 325;
- public const PHQL_T_FULLJOIN = 364;
- public const PHQL_T_GREATER = '>';
- public const PHQL_T_GREATEREQUAL = 272;
- public const PHQL_T_GROUP = 313;
- public const PHQL_T_HAVING = 314;
- public const PHQL_T_HINTEGER = 414;
- public const PHQL_T_IDENTIFIER = 265;
- public const PHQL_T_IGNORE = 257;
- public const PHQL_T_ILIKE = 275;
- public const PHQL_T_IN = 315;
- public const PHQL_T_INNER = 317;
- public const PHQL_T_INNERJOIN = 360;
- public const PHQL_T_INSERT = 306;
- public const PHQL_T_INTEGER = 258;
- public const PHQL_T_INTO = 307;
- public const PHQL_T_IS = 321;
- public const PHQL_T_ISNOTNULL = 366;
- public const PHQL_T_ISNULL = 365;
- public const PHQL_T_JOIN = 318;
- public const PHQL_T_LEFT = 319;
- public const PHQL_T_LEFTJOIN = 361;
- public const PHQL_T_LESS = '<';
- public const PHQL_T_LESSEQUAL = 271;
- public const PHQL_T_LIKE = 268;
- public const PHQL_T_LIMIT = 312;
- public const PHQL_T_MINUS = 367;
- public const PHQL_T_MOD = '%';
- public const PHQL_T_MUL = '*';
- public const PHQL_T_NILIKE = 357;
- public const PHQL_T_NLIKE = 351;
- public const PHQL_T_NOT = '!';
- public const PHQL_T_NOTEQUALS = 270;
- public const PHQL_T_NOTIN = 323;
- /** Placeholders */
- public const PHQL_T_NPLACEHOLDER = 273;
- public const PHQL_T_NULL = 322;
- public const PHQL_T_OFFSET = 329;
- public const PHQL_T_ON = 316;
- public const PHQL_T_OR = 267;
- public const PHQL_T_ORDER = 310;
- public const PHQL_T_OUTER = 326;
- public const PHQL_T_PARENTHESES_CLOSE = ')';
- public const PHQL_T_PARENTHESES_OPEN = '(';
- public const PHQL_T_QUALIFIED = 355;
- public const PHQL_T_RAW_QUALIFIED = 358;
- public const PHQL_T_RIGHT = 320;
- public const PHQL_T_RIGHTJOIN = 362;
- public const PHQL_T_SELECT = 309;
- public const PHQL_T_SET = 301;
- public const PHQL_T_SPLACEHOLDER = 274;
- public const PHQL_T_STARALL = 352;
- public const PHQL_T_STRING = 260;
- public const PHQL_T_SUB = '-';
- public const PHQL_T_SUBQUERY = 407;
- public const PHQL_T_THEN = 413;
- public const PHQL_T_TRUE = 334;
- public const PHQL_T_TS_AND = 403;
- public const PHQL_T_TS_CONTAINS_ANOTHER = 405;
- public const PHQL_T_TS_CONTAINS_IN = 406;
- /** Postgresql Text Search Operators */
- public const PHQL_T_TS_MATCHES = 401;
- public const PHQL_T_TS_NEGATE = 404;
- public const PHQL_T_TS_OR = 402;
- /** Reserved words */
- public const PHQL_T_UPDATE = 300;
- public const PHQL_T_USING = 337;
- public const PHQL_T_VALUES = 308;
- public const PHQL_T_WHEN = 410;
- public const PHQL_T_WHERE = 302;
- public const PHQL_T_WITH = 415;
-}
diff --git a/src/Parser.php b/src/Parser.php
index 233e752..70f173c 100644
--- a/src/Parser.php
+++ b/src/Parser.php
@@ -4,423 +4,226 @@
namespace Phalcon\Phql;
+use Phalcon\Phql\Parser\Status;
use Phalcon\Phql\Scanner\Opcode;
use Phalcon\Phql\Scanner\Scanner;
+use Phalcon\Phql\Scanner\ScannerStatus;
use Phalcon\Phql\Scanner\State;
use Phalcon\Phql\Scanner\Token;
-use RuntimeException;
-use stdClass;
/**
* Orchestrates the PHQL lexer and parser, equivalent to
* phql_internal_parse_phql() in base.c.
*/
-class Parser
+final class Parser
{
- private bool $enableLiterals;
-
- public function __construct(bool $enableLiterals = true)
- {
- $this->enableLiterals = $enableLiterals;
- }
+ private bool $enableLiterals = true;
/**
* Parse a PHQL string and return the AST array.
*
- * @throws RuntimeException on syntax or scanner error
+ * @return array
+ * @throws Exception on syntax or scanner error
*/
public function parse(string $phql): array
{
if ($phql === '') {
- throw new RuntimeException('PHQL statement cannot be NULL');
+ throw new Exception('PHQL statement cannot be NULL');
}
- $state = new State($phql);
- $token = new Token();
- $scanner = new Scanner($state, $token);
-
- $parserObject = new \phql_Parser();
-
- // Status object mirrors phql_parser_status in C
- $status = new stdClass();
- $status->status = \phql_Parser::PHQL_PARSING_OK;
- $status->scanner_state = $state;
- $status->ret = null;
- $status->syntax_error = null;
- $status->token = $token;
- $status->enable_literals = $this->enableLiterals;
- $status->phql = $phql;
- $status->phql_length = mb_strlen($phql);
-
- $parserObject->status = $status;
-
- $errorMsg = null;
- $failed = false;
-
- while (($scannerStatus = $scanner->scanForToken()) >= 0) {
- // Equivalent to: state->start_length = (phql + phql_length - state->start)
- $state->startLength = $status->phql_length - $state->getCursor();
-
- $state->activeToken = $token->opcode;
-
- switch ($token->opcode) {
- case Opcode::PHQL_T_IGNORE:
- break;
-
- case Opcode::PHQL_T_ADD:
- $parserObject->phql_(\phql_Parser::PHQL_PLUS);
- break;
- case Opcode::PHQL_T_SUB:
- $parserObject->phql_(\phql_Parser::PHQL_MINUS);
- break;
- case Opcode::PHQL_T_MUL:
- $parserObject->phql_(\phql_Parser::PHQL_TIMES);
- break;
- case Opcode::PHQL_T_DIV:
- $parserObject->phql_(\phql_Parser::PHQL_DIVIDE);
- break;
- case Opcode::PHQL_T_MOD:
- $parserObject->phql_(\phql_Parser::PHQL_MOD);
- break;
- case Opcode::PHQL_T_AND:
- $parserObject->phql_(\phql_Parser::PHQL_AND);
- break;
- case Opcode::PHQL_T_OR:
- $parserObject->phql_(\phql_Parser::PHQL_OR);
- break;
- case Opcode::PHQL_T_EQUALS:
- $parserObject->phql_(\phql_Parser::PHQL_EQUALS);
- break;
- case Opcode::PHQL_T_NOTEQUALS:
- $parserObject->phql_(\phql_Parser::PHQL_NOTEQUALS);
- break;
- case Opcode::PHQL_T_LESS:
- $parserObject->phql_(\phql_Parser::PHQL_LESS);
- break;
- case Opcode::PHQL_T_GREATER:
- $parserObject->phql_(\phql_Parser::PHQL_GREATER);
- break;
- case Opcode::PHQL_T_GREATEREQUAL:
- $parserObject->phql_(\phql_Parser::PHQL_GREATEREQUAL);
- break;
- case Opcode::PHQL_T_LESSEQUAL:
- $parserObject->phql_(\phql_Parser::PHQL_LESSEQUAL);
- break;
-
- case Opcode::PHQL_T_IDENTIFIER:
- $parserObject->phql_(\phql_Parser::PHQL_IDENTIFIER, $this->makeParserToken($token));
- break;
-
- case Opcode::PHQL_T_DOT:
- $parserObject->phql_(\phql_Parser::PHQL_DOT);
- break;
- case Opcode::PHQL_T_COMMA:
- $parserObject->phql_(\phql_Parser::PHQL_COMMA);
- break;
-
- case Opcode::PHQL_T_PARENTHESES_OPEN:
- $parserObject->phql_(\phql_Parser::PHQL_PARENTHESES_OPEN);
- break;
- case Opcode::PHQL_T_PARENTHESES_CLOSE:
- $parserObject->phql_(\phql_Parser::PHQL_PARENTHESES_CLOSE);
- break;
-
- case Opcode::PHQL_T_LIKE:
- $parserObject->phql_(\phql_Parser::PHQL_LIKE);
- break;
- case Opcode::PHQL_T_ILIKE:
- $parserObject->phql_(\phql_Parser::PHQL_ILIKE);
- break;
- case Opcode::PHQL_T_NOT:
- $parserObject->phql_(\phql_Parser::PHQL_NOT);
- break;
- case Opcode::PHQL_T_BITWISE_AND:
- $parserObject->phql_(\phql_Parser::PHQL_BITWISE_AND);
- break;
- case Opcode::PHQL_T_BITWISE_OR:
- $parserObject->phql_(\phql_Parser::PHQL_BITWISE_OR);
- break;
- case Opcode::PHQL_T_BITWISE_NOT:
- $parserObject->phql_(\phql_Parser::PHQL_BITWISE_NOT);
- break;
- case Opcode::PHQL_T_BITWISE_XOR:
- $parserObject->phql_(\phql_Parser::PHQL_BITWISE_XOR);
- break;
- case Opcode::PHQL_T_AGAINST:
- $parserObject->phql_(\phql_Parser::PHQL_AGAINST);
- break;
- case Opcode::PHQL_T_CASE:
- $parserObject->phql_(\phql_Parser::PHQL_CASE);
- break;
- case Opcode::PHQL_T_WHEN:
- $parserObject->phql_(\phql_Parser::PHQL_WHEN);
- break;
- case Opcode::PHQL_T_THEN:
- $parserObject->phql_(\phql_Parser::PHQL_THEN);
- break;
- case Opcode::PHQL_T_END:
- $parserObject->phql_(\phql_Parser::PHQL_END);
- break;
- case Opcode::PHQL_T_ELSE:
- $parserObject->phql_(\phql_Parser::PHQL_ELSE);
- break;
- case Opcode::PHQL_T_FOR:
- $parserObject->phql_(\phql_Parser::PHQL_FOR);
- break;
- case Opcode::PHQL_T_WITH:
- $parserObject->phql_(\phql_Parser::PHQL_WITH);
- break;
-
- case Opcode::PHQL_T_INTEGER:
- if ($this->enableLiterals) {
- $parserObject->phql_(\phql_Parser::PHQL_INTEGER, $this->makeParserToken($token));
- } else {
- $errorMsg = 'Literals are disabled in PHQL statements';
- $status->status = \phql_Parser::PHQL_PARSING_FAILED;
- }
- break;
- case Opcode::PHQL_T_DOUBLE:
- if ($this->enableLiterals) {
- $parserObject->phql_(\phql_Parser::PHQL_DOUBLE, $this->makeParserToken($token));
- } else {
- $errorMsg = 'Literals are disabled in PHQL statements';
- $status->status = \phql_Parser::PHQL_PARSING_FAILED;
- }
- break;
- case Opcode::PHQL_T_STRING:
- if ($this->enableLiterals) {
- $parserObject->phql_(\phql_Parser::PHQL_STRING, $this->makeParserToken($token));
- } else {
- $errorMsg = 'Literals are disabled in PHQL statements';
- $status->status = \phql_Parser::PHQL_PARSING_FAILED;
- }
- break;
- case Opcode::PHQL_T_TRUE:
- if ($this->enableLiterals) {
- $parserObject->phql_(\phql_Parser::PHQL_TRUE);
- } else {
- $errorMsg = 'Literals are disabled in PHQL statements';
- $status->status = \phql_Parser::PHQL_PARSING_FAILED;
- }
- break;
- case Opcode::PHQL_T_FALSE:
- if ($this->enableLiterals) {
- $parserObject->phql_(\phql_Parser::PHQL_FALSE);
- } else {
- $errorMsg = 'Literals are disabled in PHQL statements';
- $status->status = \phql_Parser::PHQL_PARSING_FAILED;
- }
- break;
- case Opcode::PHQL_T_HINTEGER:
- if ($this->enableLiterals) {
- $parserObject->phql_(\phql_Parser::PHQL_HINTEGER, $this->makeParserToken($token));
- } else {
- $errorMsg = 'Literals are disabled in PHQL statements';
- $status->status = \phql_Parser::PHQL_PARSING_FAILED;
- }
- break;
-
- case Opcode::PHQL_T_NPLACEHOLDER:
- $parserObject->phql_(\phql_Parser::PHQL_NPLACEHOLDER, $this->makeParserToken($token));
- break;
- case Opcode::PHQL_T_SPLACEHOLDER:
- $parserObject->phql_(\phql_Parser::PHQL_SPLACEHOLDER, $this->makeParserToken($token));
- break;
- case Opcode::PHQL_T_BPLACEHOLDER:
- $parserObject->phql_(\phql_Parser::PHQL_BPLACEHOLDER, $this->makeParserToken($token));
- break;
-
- case Opcode::PHQL_T_FROM:
- $parserObject->phql_(\phql_Parser::PHQL_FROM);
- break;
- case Opcode::PHQL_T_UPDATE:
- $parserObject->phql_(\phql_Parser::PHQL_UPDATE);
- break;
- case Opcode::PHQL_T_SET:
- $parserObject->phql_(\phql_Parser::PHQL_SET);
- break;
- case Opcode::PHQL_T_WHERE:
- $parserObject->phql_(\phql_Parser::PHQL_WHERE);
- break;
- case Opcode::PHQL_T_DELETE:
- $parserObject->phql_(\phql_Parser::PHQL_DELETE);
- break;
- case Opcode::PHQL_T_INSERT:
- $parserObject->phql_(\phql_Parser::PHQL_INSERT);
- break;
- case Opcode::PHQL_T_INTO:
- $parserObject->phql_(\phql_Parser::PHQL_INTO);
- break;
- case Opcode::PHQL_T_VALUES:
- $parserObject->phql_(\phql_Parser::PHQL_VALUES);
- break;
- case Opcode::PHQL_T_SELECT:
- $parserObject->phql_(\phql_Parser::PHQL_SELECT);
- break;
- case Opcode::PHQL_T_AS:
- $parserObject->phql_(\phql_Parser::PHQL_AS);
- break;
- case Opcode::PHQL_T_ORDER:
- $parserObject->phql_(\phql_Parser::PHQL_ORDER);
- break;
- case Opcode::PHQL_T_BY:
- $parserObject->phql_(\phql_Parser::PHQL_BY);
- break;
- case Opcode::PHQL_T_LIMIT:
- $parserObject->phql_(\phql_Parser::PHQL_LIMIT);
- break;
- case Opcode::PHQL_T_OFFSET:
- $parserObject->phql_(\phql_Parser::PHQL_OFFSET);
- break;
- case Opcode::PHQL_T_GROUP:
- $parserObject->phql_(\phql_Parser::PHQL_GROUP);
- break;
- case Opcode::PHQL_T_HAVING:
- $parserObject->phql_(\phql_Parser::PHQL_HAVING);
- break;
- case Opcode::PHQL_T_ASC:
- $parserObject->phql_(\phql_Parser::PHQL_ASC);
- break;
- case Opcode::PHQL_T_DESC:
- $parserObject->phql_(\phql_Parser::PHQL_DESC);
- break;
- case Opcode::PHQL_T_IN:
- $parserObject->phql_(\phql_Parser::PHQL_IN);
- break;
- case Opcode::PHQL_T_ON:
- $parserObject->phql_(\phql_Parser::PHQL_ON);
- break;
- case Opcode::PHQL_T_INNER:
- $parserObject->phql_(\phql_Parser::PHQL_INNER);
- break;
- case Opcode::PHQL_T_JOIN:
- $parserObject->phql_(\phql_Parser::PHQL_JOIN);
- break;
- case Opcode::PHQL_T_LEFT:
- $parserObject->phql_(\phql_Parser::PHQL_LEFT);
- break;
- case Opcode::PHQL_T_RIGHT:
- $parserObject->phql_(\phql_Parser::PHQL_RIGHT);
- break;
- case Opcode::PHQL_T_CROSS:
- $parserObject->phql_(\phql_Parser::PHQL_CROSS);
- break;
- case Opcode::PHQL_T_FULL:
- $parserObject->phql_(\phql_Parser::PHQL_FULL);
- break;
- case Opcode::PHQL_T_OUTER:
- $parserObject->phql_(\phql_Parser::PHQL_OUTER);
- break;
- case Opcode::PHQL_T_IS:
- $parserObject->phql_(\phql_Parser::PHQL_IS);
- break;
- case Opcode::PHQL_T_NULL:
- $parserObject->phql_(\phql_Parser::PHQL_NULL);
- break;
- case Opcode::PHQL_T_BETWEEN:
- $parserObject->phql_(\phql_Parser::PHQL_BETWEEN);
- break;
- case Opcode::PHQL_T_BETWEEN_NOT:
- $parserObject->phql_(\phql_Parser::PHQL_BETWEEN_NOT);
- break;
- case Opcode::PHQL_T_DISTINCT:
- $parserObject->phql_(\phql_Parser::PHQL_DISTINCT);
- break;
- case Opcode::PHQL_T_ALL:
- $parserObject->phql_(\phql_Parser::PHQL_ALL);
- break;
- case Opcode::PHQL_T_CAST:
- $parserObject->phql_(\phql_Parser::PHQL_CAST);
- break;
- case Opcode::PHQL_T_CONVERT:
- $parserObject->phql_(\phql_Parser::PHQL_CONVERT);
- break;
- case Opcode::PHQL_T_USING:
- $parserObject->phql_(\phql_Parser::PHQL_USING);
- break;
- case Opcode::PHQL_T_EXISTS:
- $parserObject->phql_(\phql_Parser::PHQL_EXISTS);
- break;
-
- default:
- $status->status = \phql_Parser::PHQL_PARSING_FAILED;
- $errorMsg = sprintf('Scanner: Unknown opcode %d', $token->opcode);
- break;
- }
-
- if ($status->status !== \phql_Parser::PHQL_PARSING_OK) {
- $failed = true;
+ $state = new State($phql);
+ $scanner = new Scanner($state);
+ $token = $scanner->getToken();
+ $status = new Status($state);
+ $parserObject = new \phql_Parser($status);
+
+ $status->setToken($token);
+ $status->setEnableLiterals($this->enableLiterals);
+
+ $errorMessage = null;
+ $parseFailed = false;
+
+ while (($scannerStatus = $scanner->scanForToken()) === ScannerStatus::OK) {
+ $state->setStartLength(mb_strlen($phql) - $state->getCursor());
+ $state->setActiveToken($scanner->getToken()->opcode);
+
+ match ($scanner->getToken()->opcode) {
+ Opcode::IGNORE => null,
+
+ Opcode::ADD => $parserObject->phql_(\phql_Parser::PHQL_PLUS),
+ Opcode::SUB => $parserObject->phql_(\phql_Parser::PHQL_MINUS),
+ Opcode::MUL => $parserObject->phql_(\phql_Parser::PHQL_TIMES),
+ Opcode::DIV => $parserObject->phql_(\phql_Parser::PHQL_DIVIDE),
+ Opcode::MOD => $parserObject->phql_(\phql_Parser::PHQL_MOD),
+ Opcode::AND => $parserObject->phql_(\phql_Parser::PHQL_AND),
+ Opcode::OR => $parserObject->phql_(\phql_Parser::PHQL_OR),
+ Opcode::EQUALS => $parserObject->phql_(\phql_Parser::PHQL_EQUALS),
+ Opcode::NOTEQUALS => $parserObject->phql_(\phql_Parser::PHQL_NOTEQUALS),
+ Opcode::LESS => $parserObject->phql_(\phql_Parser::PHQL_LESS),
+ Opcode::GREATER => $parserObject->phql_(\phql_Parser::PHQL_GREATER),
+ Opcode::GREATEREQUAL => $parserObject->phql_(\phql_Parser::PHQL_GREATEREQUAL),
+ Opcode::LESSEQUAL => $parserObject->phql_(\phql_Parser::PHQL_LESSEQUAL),
+ Opcode::DOT => $parserObject->phql_(\phql_Parser::PHQL_DOT),
+ Opcode::COMMA => $parserObject->phql_(\phql_Parser::PHQL_COMMA),
+ Opcode::PARENTHESES_OPEN => $parserObject->phql_(\phql_Parser::PHQL_PARENTHESES_OPEN),
+ Opcode::PARENTHESES_CLOSE => $parserObject->phql_(\phql_Parser::PHQL_PARENTHESES_CLOSE),
+ Opcode::LIKE => $parserObject->phql_(\phql_Parser::PHQL_LIKE),
+ Opcode::ILIKE => $parserObject->phql_(\phql_Parser::PHQL_ILIKE),
+ Opcode::NOT => $parserObject->phql_(\phql_Parser::PHQL_NOT),
+ Opcode::BITWISE_AND => $parserObject->phql_(\phql_Parser::PHQL_BITWISE_AND),
+ Opcode::BITWISE_OR => $parserObject->phql_(\phql_Parser::PHQL_BITWISE_OR),
+ Opcode::BITWISE_NOT => $parserObject->phql_(\phql_Parser::PHQL_BITWISE_NOT),
+ Opcode::BITWISE_XOR => $parserObject->phql_(\phql_Parser::PHQL_BITWISE_XOR),
+ Opcode::AGAINST => $parserObject->phql_(\phql_Parser::PHQL_AGAINST),
+ Opcode::CASE => $parserObject->phql_(\phql_Parser::PHQL_CASE),
+ Opcode::WHEN => $parserObject->phql_(\phql_Parser::PHQL_WHEN),
+ Opcode::THEN => $parserObject->phql_(\phql_Parser::PHQL_THEN),
+ Opcode::END => $parserObject->phql_(\phql_Parser::PHQL_END),
+ Opcode::ELSE => $parserObject->phql_(\phql_Parser::PHQL_ELSE),
+ Opcode::FOR => $parserObject->phql_(\phql_Parser::PHQL_FOR),
+ Opcode::WITH => $parserObject->phql_(\phql_Parser::PHQL_WITH),
+ Opcode::FROM => $parserObject->phql_(\phql_Parser::PHQL_FROM),
+ Opcode::UPDATE => $parserObject->phql_(\phql_Parser::PHQL_UPDATE),
+ Opcode::SET => $parserObject->phql_(\phql_Parser::PHQL_SET),
+ Opcode::WHERE => $parserObject->phql_(\phql_Parser::PHQL_WHERE),
+ Opcode::DELETE => $parserObject->phql_(\phql_Parser::PHQL_DELETE),
+ Opcode::INSERT => $parserObject->phql_(\phql_Parser::PHQL_INSERT),
+ Opcode::INTO => $parserObject->phql_(\phql_Parser::PHQL_INTO),
+ Opcode::VALUES => $parserObject->phql_(\phql_Parser::PHQL_VALUES),
+ Opcode::SELECT => $parserObject->phql_(\phql_Parser::PHQL_SELECT),
+ Opcode::AS => $parserObject->phql_(\phql_Parser::PHQL_AS),
+ Opcode::ORDER => $parserObject->phql_(\phql_Parser::PHQL_ORDER),
+ Opcode::BY => $parserObject->phql_(\phql_Parser::PHQL_BY),
+ Opcode::LIMIT => $parserObject->phql_(\phql_Parser::PHQL_LIMIT),
+ Opcode::OFFSET => $parserObject->phql_(\phql_Parser::PHQL_OFFSET),
+ Opcode::GROUP => $parserObject->phql_(\phql_Parser::PHQL_GROUP),
+ Opcode::HAVING => $parserObject->phql_(\phql_Parser::PHQL_HAVING),
+ Opcode::ASC => $parserObject->phql_(\phql_Parser::PHQL_ASC),
+ Opcode::DESC => $parserObject->phql_(\phql_Parser::PHQL_DESC),
+ Opcode::IN => $parserObject->phql_(\phql_Parser::PHQL_IN),
+ Opcode::ON => $parserObject->phql_(\phql_Parser::PHQL_ON),
+ Opcode::INNER => $parserObject->phql_(\phql_Parser::PHQL_INNER),
+ Opcode::JOIN => $parserObject->phql_(\phql_Parser::PHQL_JOIN),
+ Opcode::LEFT => $parserObject->phql_(\phql_Parser::PHQL_LEFT),
+ Opcode::RIGHT => $parserObject->phql_(\phql_Parser::PHQL_RIGHT),
+ Opcode::CROSS => $parserObject->phql_(\phql_Parser::PHQL_CROSS),
+ Opcode::FULL => $parserObject->phql_(\phql_Parser::PHQL_FULL),
+ Opcode::OUTER => $parserObject->phql_(\phql_Parser::PHQL_OUTER),
+ Opcode::IS => $parserObject->phql_(\phql_Parser::PHQL_IS),
+ Opcode::NULL => $parserObject->phql_(\phql_Parser::PHQL_NULL),
+ Opcode::BETWEEN => $parserObject->phql_(\phql_Parser::PHQL_BETWEEN),
+ Opcode::BETWEEN_NOT => $parserObject->phql_(\phql_Parser::PHQL_BETWEEN_NOT),
+ Opcode::DISTINCT => $parserObject->phql_(\phql_Parser::PHQL_DISTINCT),
+ Opcode::ALL => $parserObject->phql_(\phql_Parser::PHQL_ALL),
+ Opcode::CAST => $parserObject->phql_(\phql_Parser::PHQL_CAST),
+ Opcode::CONVERT => $parserObject->phql_(\phql_Parser::PHQL_CONVERT),
+ Opcode::USING => $parserObject->phql_(\phql_Parser::PHQL_USING),
+ Opcode::EXISTS => $parserObject->phql_(\phql_Parser::PHQL_EXISTS),
+
+ Opcode::IDENTIFIER => $parserObject->phql_(
+ \phql_Parser::PHQL_IDENTIFIER,
+ $this->makeParserToken($scanner->getToken())
+ ),
+ Opcode::NPLACEHOLDER => $parserObject->phql_(
+ \phql_Parser::PHQL_NPLACEHOLDER,
+ $this->makeParserToken($scanner->getToken())
+ ),
+ Opcode::SPLACEHOLDER => $parserObject->phql_(
+ \phql_Parser::PHQL_SPLACEHOLDER,
+ $this->makeParserToken($scanner->getToken())
+ ),
+ Opcode::BPLACEHOLDER => $parserObject->phql_(
+ \phql_Parser::PHQL_BPLACEHOLDER,
+ $this->makeParserToken($scanner->getToken())
+ ),
+
+ Opcode::INTEGER => $this->enableLiterals
+ ? $parserObject->phql_(\phql_Parser::PHQL_INTEGER, $this->makeParserToken($scanner->getToken()))
+ : $this->handleLiteralsDisabled($status),
+ Opcode::DOUBLE => $this->enableLiterals
+ ? $parserObject->phql_(\phql_Parser::PHQL_DOUBLE, $this->makeParserToken($scanner->getToken()))
+ : $this->handleLiteralsDisabled($status),
+ Opcode::STRING => $this->enableLiterals
+ ? $parserObject->phql_(\phql_Parser::PHQL_STRING, $this->makeParserToken($scanner->getToken()))
+ : $this->handleLiteralsDisabled($status),
+ Opcode::HINTEGER => $this->enableLiterals
+ ? $parserObject->phql_(\phql_Parser::PHQL_HINTEGER, $this->makeParserToken($scanner->getToken()))
+ : $this->handleLiteralsDisabled($status),
+ Opcode::TRUE => $this->enableLiterals
+ ? $parserObject->phql_(\phql_Parser::PHQL_TRUE)
+ : $this->handleLiteralsDisabled($status),
+ Opcode::FALSE => $this->enableLiterals
+ ? $parserObject->phql_(\phql_Parser::PHQL_FALSE)
+ : $this->handleLiteralsDisabled($status),
+
+ default => $this->handleUnknownOpcode($scanner->getToken()->opcode, $status),
+ };
+
+ if ($status->getStatus() !== Status::PHQL_PARSING_OK) {
+ $parseFailed = true;
break;
}
}
- if (!$failed) {
+ if (!$parseFailed) {
if (
- $scannerStatus === Scanner::PHQL_SCANNER_RETCODE_ERR
- || $scannerStatus === Scanner::PHQL_SCANNER_RETCODE_IMPOSSIBLE
+ $scannerStatus === ScannerStatus::ERR
+ || $scannerStatus === ScannerStatus::IMPOSSIBLE
) {
- if ($errorMsg === null) {
- $errorMsg = $this->buildScannerErrorMsg($status, $phql);
- }
- $failed = true;
+ $errorMessage = $this->buildScannerErrorMessage($status, $phql);
+ $parseFailed = true;
} else {
- // Signal EOF to the parser
$parserObject->phql_(0);
}
}
- $state->activeToken = 0;
+ $state->setActiveToken(null);
- if ($status->status !== \phql_Parser::PHQL_PARSING_OK) {
- $failed = true;
- if ($status->syntax_error !== null && $errorMsg === null) {
- $errorMsg = $status->syntax_error;
+ if ($status->getStatus() !== Status::PHQL_PARSING_OK) {
+ $parseFailed = true;
+ if ($status->getSyntaxError() !== null && $errorMessage === null) {
+ $errorMessage = $status->getSyntaxError();
}
}
- if ($failed) {
- throw new RuntimeException($errorMsg ?? 'Unknown PHQL parsing error');
+ if ($parseFailed) {
+ throw new Exception($errorMessage ?? 'Unknown PHQL parsing error');
}
- if (!is_array($status->ret)) {
- throw new RuntimeException('PHQL parsing produced no result');
+ /** @var array|null $ast */
+ $ast = $status->getAst();
+ if (!is_array($ast)) {
+ throw new Exception('PHQL parsing produced no result'); // @codeCoverageIgnore
}
- return $status->ret;
+ return $ast;
}
- /**
- * Wrap a scanner Token into the lightweight token object the parser expects.
- * Mirrors phql_parse_with_token() in base.c.
- */
- private function makeParserToken(Token $token): stdClass
+ public function setEnableLiterals(bool $enable): static
{
- $pt = new stdClass();
- $pt->opcode = $token->opcode;
- $pt->token = $token->value;
- $pt->token_len = $token->len;
- $pt->free_flag = 1;
+ $this->enableLiterals = $enable;
- return $pt;
+ return $this;
}
/**
* Mirrors phql_scanner_error_msg() in base.c.
*/
- private function buildScannerErrorMsg(stdClass $status, string $phql): string
+ private function buildScannerErrorMessage(Status $status, string $phql): string
{
- $state = $status->scanner_state;
+ $state = $status->getState();
+ $phqlLength = mb_strlen($phql);
- if ($state->getStart() !== null && $state->startLength > 0) {
+ if ($state->getStart() !== null && $state->getStartLength() > 0) {
$startStr = substr($phql, $state->getCursor());
- if ($state->startLength > 16) {
+ if ($state->getStartLength() > 16) {
$errorPart = substr($startStr, 0, 16);
return sprintf(
"Scanning error before '%s...' when parsing: %s (%d)",
$errorPart,
$phql,
- $status->phql_length
+ $phqlLength
);
}
@@ -428,10 +231,39 @@ private function buildScannerErrorMsg(stdClass $status, string $phql): string
"Scanning error before '%s' when parsing: %s (%d)",
$startStr,
$phql,
- $status->phql_length
+ $phqlLength
);
}
- return 'Scanning error near to EOF';
+ return 'Scanning error near to EOF'; // @codeCoverageIgnore
+ }
+
+ private function handleLiteralsDisabled(Status $status): void
+ {
+ $status->setSyntaxError('Literals are disabled in PHQL statements');
+ $status->setStatus(Status::PHQL_PARSING_FAILED);
+ }
+
+ private function handleUnknownOpcode(?Opcode $opcode, Status $status): void
+ {
+ $status->setStatus(Status::PHQL_PARSING_FAILED);
+
+ throw new Exception(
+ sprintf('Scanner: Unknown opcode %d', $opcode->value ?? 0)
+ );
+ }
+
+ /**
+ * Snapshot the current scanner token into a new Token instance so the
+ * parser stack holds stable values (the scanner reuses its token object).
+ * Mirrors phql_parse_with_token() in base.c.
+ */
+ private function makeParserToken(Token $token): Token
+ {
+ return new Token(
+ $token->opcode,
+ $token->value,
+ $token->length,
+ );
}
}
diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php
deleted file mode 100644
index 1e3add2..0000000
--- a/src/Parser/Parser.php
+++ /dev/null
@@ -1,414 +0,0 @@
-debug) {
- $debug = fopen($this->debugFile, 'w+');
- }
-
- $codeLength = strlen($phql);
- $parserState = new State($phql);
- $parserStatus = new Status($parserState);
- $parserStatus->setEnableLiterals(true);
- $scanner = new Scanner($parserStatus->getState());
-
- $parser = new phql_Parser($parserStatus);
- $parser->phql_Trace($debug);
-
- $state = $parserStatus->getState();
- while (0 <= $scannerStatus = $scanner->scanForToken()) {
- $this->token = $scanner->getToken();
- $parserStatus->setToken($this->token);
- $state->setStartLength($codeLength - $state->getCursor());
-
- $opcode = $this->token->getOpcode();
- $state->setActiveToken($this->token);
-
- switch ($opcode) {
- case Opcode::PHQL_T_IGNORE:
- break;
-
- case Opcode::PHQL_T_ADD:
- $parser->phql_(phql_Parser::PHQL_PLUS);
- break;
-
- case Opcode::PHQL_T_SUB:
- $parser->phql_(phql_Parser::PHQL_MINUS);
- break;
-
- case Opcode::PHQL_T_MUL:
- $parser->phql_(phql_Parser::PHQL_TIMES);
- break;
-
- case Opcode::PHQL_T_DIV:
- $parser->phql_(phql_Parser::PHQL_DIVIDE);
- break;
-
- case Opcode::PHQL_T_MOD:
- $parser->phql_(phql_Parser::PHQL_MOD);
- break;
-
- case Opcode::PHQL_T_AND:
- $parser->phql_(phql_Parser::PHQL_AND);
- break;
-
- case Opcode::PHQL_T_OR:
- $parser->phql_(phql_Parser::PHQL_OR);
- break;
- case Opcode::PHQL_T_EQUALS:
- $parser->phql_(phql_Parser::PHQL_EQUALS);
- break;
- case Opcode::PHQL_T_NOTEQUALS:
- $parser->phql_(phql_Parser::PHQL_NOTEQUALS);
- break;
- case Opcode::PHQL_T_LESS:
- $parser->phql_(phql_Parser::PHQL_LESS);
- break;
- case Opcode::PHQL_T_GREATER:
- $parser->phql_(phql_Parser::PHQL_GREATER);
- break;
- case Opcode::PHQL_T_GREATEREQUAL:
- $parser->phql_(phql_Parser::PHQL_GREATEREQUAL);
- break;
- case Opcode::PHQL_T_LESSEQUAL:
- $parser->phql_(phql_Parser::PHQL_LESSEQUAL);
- break;
- case Opcode::PHQL_T_IDENTIFIER:
- $this->phqlParseWithToken($parser, Opcode::PHQL_T_IDENTIFIER, phql_Parser::PHQL_IDENTIFIER);
- break;
-
- case Opcode::PHQL_T_DOT:
- $parser->phql_(phql_Parser::PHQL_DOT);
- break;
- case Opcode::PHQL_T_COMMA:
- $parser->phql_(phql_Parser::PHQL_COMMA);
- break;
-
- case Opcode::PHQL_T_PARENTHESES_OPEN:
- $parser->phql_(phql_Parser::PHQL_PARENTHESES_OPEN);
- break;
- case Opcode::PHQL_T_PARENTHESES_CLOSE:
- $parser->phql_(phql_Parser::PHQL_PARENTHESES_CLOSE);
- break;
-
- case Opcode::PHQL_T_LIKE:
- $parser->phql_(phql_Parser::PHQL_LIKE);
- break;
- case Opcode::PHQL_T_ILIKE:
- $parser->phql_(phql_Parser::PHQL_ILIKE);
- break;
- case Opcode::PHQL_T_NOT:
- $parser->phql_(phql_Parser::PHQL_NOT);
- break;
- case Opcode::PHQL_T_BITWISE_AND:
- $parser->phql_(phql_Parser::PHQL_BITWISE_AND);
- break;
- case Opcode::PHQL_T_BITWISE_OR:
- $parser->phql_(phql_Parser::PHQL_BITWISE_OR);
- break;
- case Opcode::PHQL_T_BITWISE_NOT:
- $parser->phql_(phql_Parser::PHQL_BITWISE_NOT);
- break;
- case Opcode::PHQL_T_BITWISE_XOR:
- $parser->phql_(phql_Parser::PHQL_BITWISE_XOR);
- break;
- case Opcode::PHQL_T_AGAINST:
- $parser->phql_(phql_Parser::PHQL_AGAINST);
- break;
- case Opcode::PHQL_T_CASE:
- $parser->phql_(phql_Parser::PHQL_CASE);
- break;
- case Opcode::PHQL_T_WHEN:
- $parser->phql_(phql_Parser::PHQL_WHEN);
- break;
- case Opcode::PHQL_T_THEN:
- $parser->phql_(phql_Parser::PHQL_THEN);
- break;
- case Opcode::PHQL_T_END:
- $parser->phql_(phql_Parser::PHQL_END);
- break;
- case Opcode::PHQL_T_ELSE:
- $parser->phql_(phql_Parser::PHQL_ELSE);
- break;
- case Opcode::PHQL_T_FOR:
- $parser->phql_(phql_Parser::PHQL_FOR);
- break;
- case Opcode::PHQL_T_WITH:
- $parser->phql_(phql_Parser::PHQL_WITH);
- break;
-
- case Opcode::PHQL_T_INTEGER:
- if ($parserStatus->getEnableLiterals()) {
- $this->phqlParseWithToken($parser, Opcode::PHQL_T_INTEGER, phql_Parser::PHQL_INTEGER);
- } else {
- $parserStatus->setSyntaxError("Literals are disabled in PHQL statements");
- $parserStatus->setStatus(Status::PHQL_PARSING_FAILED);
- }
- break;
- case Opcode::PHQL_T_DOUBLE:
- if ($parserStatus->getEnableLiterals()) {
- $this->phqlParseWithToken($parser, Opcode::PHQL_T_DOUBLE, phql_Parser::PHQL_DOUBLE);
- } else {
- $parserStatus->setSyntaxError("Literals are disabled in PHQL statements");
- $parserStatus->setStatus(Status::PHQL_PARSING_FAILED);
- }
- break;
- case Opcode::PHQL_T_STRING:
- if ($parserStatus->getEnableLiterals()) {
- $this->phqlParseWithToken($parser, Opcode::PHQL_T_STRING, phql_Parser::PHQL_STRING);
- } else {
- $parserStatus->setSyntaxError("Literals are disabled in PHQL statements");
- $parserStatus->setStatus(Status::PHQL_PARSING_FAILED);
- }
- break;
- case Opcode::PHQL_T_TRUE:
- if ($parserStatus->getEnableLiterals()) {
- $parser->phql_(phql_Parser::PHQL_TRUE);
- } else {
- $parserStatus->setSyntaxError("Literals are disabled in PHQL statements");
- $parserStatus->setStatus(Status::PHQL_PARSING_FAILED);
- }
- break;
- case Opcode::PHQL_T_FALSE:
- if ($parserStatus->getEnableLiterals()) {
- $parser->phql_(phql_Parser::PHQL_FALSE);
- } else {
- $parserStatus->setSyntaxError("Literals are disabled in PHQL statements");
- $parserStatus->setStatus(Status::PHQL_PARSING_FAILED);
- }
- break;
- case Opcode::PHQL_T_HINTEGER:
- if ($parserStatus->getEnableLiterals()) {
- $this->phqlParseWithToken($parser, Opcode::PHQL_T_HINTEGER, phql_Parser::PHQL_HINTEGER);
- } else {
- $parserStatus->setSyntaxError("Integers are disabled in PHQL statements");
- $parserStatus->setStatus(Status::PHQL_PARSING_FAILED);
- }
- break;
-
- case Opcode::PHQL_T_NPLACEHOLDER:
- $this->phqlParseWithToken($parser, Opcode::PHQL_T_NPLACEHOLDER, phql_Parser::PHQL_NPLACEHOLDER);
- break;
- case Opcode::PHQL_T_SPLACEHOLDER:
- $this->phqlParseWithToken($parser, Opcode::PHQL_T_SPLACEHOLDER, phql_Parser::PHQL_SPLACEHOLDER);
- break;
- case Opcode::PHQL_T_BPLACEHOLDER:
- $this->phqlParseWithToken($parser, Opcode::PHQL_T_BPLACEHOLDER, phql_Parser::PHQL_BPLACEHOLDER);
- break;
-
- case Opcode::PHQL_T_FROM:
- $parser->phql_(phql_Parser::PHQL_FROM);
- break;
- case Opcode::PHQL_T_UPDATE:
- $parser->phql_(phql_Parser::PHQL_UPDATE);
- break;
- case Opcode::PHQL_T_SET:
- $parser->phql_(phql_Parser::PHQL_SET);
- break;
- case Opcode::PHQL_T_WHERE:
- $parser->phql_(phql_Parser::PHQL_WHERE);
- break;
- case Opcode::PHQL_T_DELETE:
- $parser->phql_(phql_Parser::PHQL_DELETE);
- break;
- case Opcode::PHQL_T_INSERT:
- $parser->phql_(phql_Parser::PHQL_INSERT);
- break;
- case Opcode::PHQL_T_INTO:
- $parser->phql_(phql_Parser::PHQL_INTO);
- break;
- case Opcode::PHQL_T_VALUES:
- $parser->phql_(phql_Parser::PHQL_VALUES);
- break;
- case Opcode::PHQL_T_SELECT:
- $parser->phql_(phql_Parser::PHQL_SELECT);
- break;
- case Opcode::PHQL_T_AS:
- $parser->phql_(phql_Parser::PHQL_AS);
- break;
- case Opcode::PHQL_T_ORDER:
- $parser->phql_(phql_Parser::PHQL_ORDER);
- break;
- case Opcode::PHQL_T_BY:
- $parser->phql_(phql_Parser::PHQL_BY);
- break;
- case Opcode::PHQL_T_LIMIT:
- $parser->phql_(phql_Parser::PHQL_LIMIT);
- break;
- case Opcode::PHQL_T_OFFSET:
- $parser->phql_(phql_Parser::PHQL_OFFSET);
- break;
- case Opcode::PHQL_T_GROUP:
- $parser->phql_(phql_Parser::PHQL_GROUP);
- break;
- case Opcode::PHQL_T_HAVING:
- $parser->phql_(phql_Parser::PHQL_HAVING);
- break;
- case Opcode::PHQL_T_ASC:
- $parser->phql_(phql_Parser::PHQL_ASC);
- break;
- case Opcode::PHQL_T_DESC:
- $parser->phql_(phql_Parser::PHQL_DESC);
- break;
- case Opcode::PHQL_T_IN:
- $parser->phql_(phql_Parser::PHQL_IN);
- break;
- case Opcode::PHQL_T_ON:
- $parser->phql_(phql_Parser::PHQL_ON);
- break;
- case Opcode::PHQL_T_INNER:
- $parser->phql_(phql_Parser::PHQL_INNER);
- break;
- case Opcode::PHQL_T_JOIN:
- $parser->phql_(phql_Parser::PHQL_JOIN);
- break;
- case Opcode::PHQL_T_LEFT:
- $parser->phql_(phql_Parser::PHQL_LEFT);
- break;
- case Opcode::PHQL_T_RIGHT:
- $parser->phql_(phql_Parser::PHQL_RIGHT);
- break;
- case Opcode::PHQL_T_CROSS:
- $parser->phql_(phql_Parser::PHQL_CROSS);
- break;
- case Opcode::PHQL_T_FULL:
- $parser->phql_(phql_Parser::PHQL_FULL);
- break;
- case Opcode::PHQL_T_OUTER:
- $parser->phql_(phql_Parser::PHQL_OUTER);
- break;
- case Opcode::PHQL_T_IS:
- $parser->phql_(phql_Parser::PHQL_IS);
- break;
- case Opcode::PHQL_T_NULL:
- $parser->phql_(phql_Parser::PHQL_NULL);
- break;
- case Opcode::PHQL_T_BETWEEN:
- $parser->phql_(phql_Parser::PHQL_BETWEEN);
- break;
- case Opcode::PHQL_T_BETWEEN_NOT:
- $parser->phql_(phql_Parser::PHQL_BETWEEN_NOT);
- break;
- case Opcode::PHQL_T_DISTINCT:
- $parser->phql_(phql_Parser::PHQL_DISTINCT);
- break;
- case Opcode::PHQL_T_ALL:
- $parser->phql_(phql_Parser::PHQL_ALL);
- break;
- case Opcode::PHQL_T_CAST:
- $parser->phql_(phql_Parser::PHQL_CAST);
- break;
- case Opcode::PHQL_T_CONVERT:
- $parser->phql_(phql_Parser::PHQL_CONVERT);
- break;
- case Opcode::PHQL_T_USING:
- $parser->phql_(phql_Parser::PHQL_USING);
- break;
- case Opcode::PHQL_T_EXISTS:
- $parser->phql_(phql_Parser::PHQL_EXISTS);
- break;
-
- default:
- $parserStatus->setStatus(Status::PHQL_PARSING_FAILED);
- $parserStatus->setSyntaxError("Scanner: Unknown opcode %d" . $opcode);
- break;
- }
-
- if ($parserStatus->getStatus() === Status::PHQL_PARSING_FAILED) {
- break;
- }
-
- $state->setEnd($state->getStart());
- }
-
- if ($scannerStatus === Scanner::PHQL_SCANNER_RETCODE_ERR || $scannerStatus === Scanner::PHQL_SCANNER_RETCODE_IMPOSSIBLE) {
- throw new Exception($parserStatus->getSyntaxError());
- } elseif ($scannerStatus === Scanner::PHQL_SCANNER_RETCODE_EOF) {
- $parser->phql_(0);
- }
-
- /**
- * Set a unique id for the parsed ast
- * /
- * if (phalcon_globals_ptr->orm.cache_level >= 1) {
- * if (Z_TYPE_P(&parser_status->ret) == IS_ARRAY) {
- * add_assoc_long(&parser_status->ret, "id", phalcon_globals_ptr->orm.unique_cache_id++);
- * }
- * }
- *
- * ZVAL_ZVAL(*result, &parser_status->ret, 1, 1);
- *
- * /**
- * Store the parsed definition in the cache
- * /
- * if (cache_level >= 0) {
- *
- * if (!phalcon_globals_ptr->orm.parser_cache) {
- * ALLOC_HASHTABLE(phalcon_globals_ptr->orm.parser_cache);
- * zend_hash_init(phalcon_globals_ptr->orm.parser_cache, 0, NULL, ZVAL_PTR_DTOR, 0);
- * }
- *
- * Z_TRY_ADDREF_P(*result);
- *
- * zend_hash_index_update(
- * phalcon_globals_ptr->orm.parser_cache,
- * phql_key,
- * result
- * );
- * }
- *
- * }
- * }
- * }
- */
-
- $state->setStartLength(0);
- $state->setActiveToken(0);
-
- if ($parserStatus->getStatus() !== Status::PHQL_PARSING_OK) {
- throw new Exception($parserStatus->getSyntaxError());
- }
-
- return $parser->getOutput();
- }
-
- private function phqlParseWithToken(
- phql_Parser $parser,
- int $opcode,
- int $parserCode,
- ): void {
- $newToken = new Token();
- $newToken->setOpcode($opcode);
- $newToken->setValue($this->token->getValue());
-
- $this->token = $newToken;
-
- $parser->phql_($parserCode, $newToken);
- }
-}
diff --git a/src/Parser/Status.php b/src/Parser/Status.php
index a2f1c09..12fae80 100644
--- a/src/Parser/Status.php
+++ b/src/Parser/Status.php
@@ -12,33 +12,32 @@ class Status
public const PHQL_PARSING_FAILED = 0;
public const PHQL_PARSING_OK = 1;
- protected ?string $syntaxError = null;
-
- protected ?Token $token = null;
-
- protected bool $enableLiterals = false;
+ /** @var array|null $ast */
+ private array|null $ast = null;
+ private bool $enableLiterals = false;
+ private ?string $syntaxError = null;
+ private ?Token $token = null;
public function __construct(
- protected State $scannerState,
- protected int $status = self::PHQL_PARSING_OK,
+ private readonly State $scannerState,
+ private int $status = self::PHQL_PARSING_OK,
) {
}
- public function getState(): State
+ /** @return array|null */
+ public function getAst(): array|null
{
- return $this->scannerState;
+ return $this->ast;
}
- public function setEnableLiterals(bool $enable): self
+ public function getEnableLiterals(): bool
{
- $this->enableLiterals = $enable;
-
- return $this;
+ return $this->enableLiterals;
}
- public function getEnableLiterals(): bool
+ public function getState(): State
{
- return $this->enableLiterals;
+ return $this->scannerState;
}
public function getStatus(): int
@@ -56,25 +55,39 @@ public function getToken(): ?Token
return $this->token;
}
- public function setStatus(int $status): self
+ /** @param array $ast */
+ public function setAst(array $ast): static
+ {
+ $this->ast = $ast;
+
+ return $this;
+ }
+
+ public function setEnableLiterals(bool $enable): static
+ {
+ $this->enableLiterals = $enable;
+
+ return $this;
+ }
+
+ public function setStatus(int $status): static
{
- //throw new \Exception('Test');
$this->status = $status;
return $this;
}
- public function setSyntaxError(string $syntaxError): self
+ public function setSyntaxError(string $syntaxError): static
{
$this->syntaxError = $syntaxError;
return $this;
}
- public function setToken(Token $token): self
+ public function setToken(Token $token): static
{
$this->token = $token;
return $this;
}
-}
\ No newline at end of file
+}
diff --git a/src/Scanner/Opcode.php b/src/Scanner/Opcode.php
index 648b666..140f618 100644
--- a/src/Scanner/Opcode.php
+++ b/src/Scanner/Opcode.php
@@ -4,120 +4,139 @@
namespace Phalcon\Phql\Scanner;
-class Opcode
+enum Opcode: int
{
- public const PHQL_T_ADD = '+';
+ case ADD = 43;
+ case AGAINST = 276;
+ case ALL = 338;
+ case AND = 266;
+ case AS = 305;
+ case ASC = 327;
+ case BETWEEN = 331;
+ case BETWEEN_NOT = 332;
+ case BITWISE_AND = 38;
+ case BITWISE_NOT = 126;
+ case BITWISE_OR = 124;
+ case BITWISE_XOR = 94;
+ case BPLACEHOLDER = 277;
+ case BY = 311;
+ case CASE = 409;
+ case CAST = 333;
+ case COLON = 58;
+ case COMMA = 269;
+ case CONVERT = 336;
+ case CROSS = 324;
+ case CROSSJOIN = 363;
+ case DELETE = 303;
+ case DESC = 328;
+ case DISTINCT = 330;
+ case DIV = 47;
+ case DOMAINALL = 353;
+ case DOT = 46;
+ case DOUBLE = 259;
+ case ELSE = 411;
+ case ENCLOSED = 356;
+ case END = 412;
+ case EQUALS = 61;
+ case EXISTS = 408;
+ case EXPR = 354;
+ case FALSE = 335;
+ case FCALL = 350;
+ case FOR = 339;
+ case FROM = 304;
+ case FULL = 325;
+ case FULLJOIN = 364;
+ case GREATER = 62;
+ case GREATEREQUAL = 272;
+ case GROUP = 313;
+ case HAVING = 314;
+ case HINTEGER = 414;
+ case IDENTIFIER = 265;
+ case IGNORE = 257;
+ case ILIKE = 275;
+ case IN = 315;
+ case INNER = 317;
+ case INNERJOIN = 360;
+ case INSERT = 306;
+ case INTEGER = 258;
+ case INTO = 307;
+ case IS = 321;
+ case ISNOTNULL = 366;
+ case ISNULL = 365;
+ case JOIN = 318;
+ case LEFT = 319;
+ case LEFTJOIN = 361;
+ case LESS = 60;
+ case LESSEQUAL = 271;
+ case LIKE = 268;
+ case LIMIT = 312;
+ case MINUS = 367;
+ case MOD = 37;
+ case MUL = 42;
+ case NILIKE = 357;
+ case NLIKE = 351;
+ case NOT = 33;
+ case NOTEQUALS = 270;
+ case NOTIN = 323;
+ case NPLACEHOLDER = 273;
+ case NULL = 322;
+ case OFFSET = 329;
+ case ON = 316;
+ case OR = 267;
+ case ORDER = 310;
+ case OUTER = 326;
+ case PARENTHESES_CLOSE = 41;
+ case PARENTHESES_OPEN = 40;
+ case QUALIFIED = 355;
+ case RAW_QUALIFIED = 358;
+ case RIGHT = 320;
+ case RIGHTJOIN = 362;
+ case SELECT = 309;
+ case SET = 301;
+ case SPLACEHOLDER = 274;
+ case STARALL = 352;
+ case STRING = 260;
+ case SUB = 45;
+ case SUBQUERY = 407;
+ case THEN = 413;
+ case TRUE = 334;
+ case TS_AND = 403;
+ case TS_CONTAINS_ANOTHER = 405;
+ case TS_CONTAINS_IN = 406;
+ case TS_MATCHES = 401;
+ case TS_NEGATE = 404;
+ case TS_OR = 402;
+ case UPDATE = 300;
+ case USING = 337;
+ case VALUES = 308;
+ case WHEN = 410;
+ case WHERE = 302;
+ case WITH = 415;
- /* Literals & Identifiers */
- public const PHQL_T_AGAINST = 276;
- public const PHQL_T_ALL = 338;
- public const PHQL_T_AND = 266;
- public const PHQL_T_AS = 305;
- public const PHQL_T_ASC = 327;
-
- /* Operators */
- public const PHQL_T_BETWEEN = 331;
- public const PHQL_T_BETWEEN_NOT = 332;
- public const PHQL_T_BITWISE_AND = '&';
- public const PHQL_T_BITWISE_NOT = '~';
- public const PHQL_T_BITWISE_OR = '|';
- public const PHQL_T_BITWISE_XOR = '^';
- public const PHQL_T_BPLACEHOLDER = 277;
- public const PHQL_T_BY = 311;
- public const PHQL_T_CASE = 409;
- public const PHQL_T_CAST = 333;
- public const PHQL_T_COLON = ':';
- public const PHQL_T_COMMA = 269;
- public const PHQL_T_CONVERT = 336;
- public const PHQL_T_CROSS = 324;
- public const PHQL_T_CROSSJOIN = 363;
- public const PHQL_T_DELETE = 303;
- public const PHQL_T_DESC = 328;
- public const PHQL_T_DISTINCT = 330;
- public const PHQL_T_DIV = '/';
- public const PHQL_T_DOMAINALL = 353;
- public const PHQL_T_DOT = '.';
- public const PHQL_T_DOUBLE = 259;
- public const PHQL_T_ELSE = 411;
- public const PHQL_T_ENCLOSED = 356;
- public const PHQL_T_END = 412;
- public const PHQL_T_EQUALS = '=';
- public const PHQL_T_EXISTS = 408;
- public const PHQL_T_EXPR = 354;
- public const PHQL_T_FALSE = 335;
- /** Special Tokens */
- public const PHQL_T_FCALL = 350;
- public const PHQL_T_FOR = 339;
- public const PHQL_T_FROM = 304;
- public const PHQL_T_FULL = 325;
- public const PHQL_T_FULLJOIN = 364;
- public const PHQL_T_GREATER = '>';
- public const PHQL_T_GREATEREQUAL = 272;
- public const PHQL_T_GROUP = 313;
- public const PHQL_T_HAVING = 314;
- public const PHQL_T_HINTEGER = 414;
- public const PHQL_T_IDENTIFIER = 265;
- public const PHQL_T_IGNORE = 257;
- public const PHQL_T_ILIKE = 275;
- public const PHQL_T_IN = 315;
- public const PHQL_T_INNER = 317;
- public const PHQL_T_INNERJOIN = 360;
- public const PHQL_T_INSERT = 306;
- public const PHQL_T_INTEGER = 258;
- public const PHQL_T_INTO = 307;
- public const PHQL_T_IS = 321;
- public const PHQL_T_ISNOTNULL = 366;
- public const PHQL_T_ISNULL = 365;
- public const PHQL_T_JOIN = 318;
- public const PHQL_T_LEFT = 319;
- public const PHQL_T_LEFTJOIN = 361;
- public const PHQL_T_LESS = '<';
- public const PHQL_T_LESSEQUAL = 271;
- public const PHQL_T_LIKE = 268;
- public const PHQL_T_LIMIT = 312;
- public const PHQL_T_MINUS = 367;
- public const PHQL_T_MOD = '%';
- public const PHQL_T_MUL = '*';
- public const PHQL_T_NILIKE = 357;
- public const PHQL_T_NLIKE = 351;
- public const PHQL_T_NOT = '!';
- public const PHQL_T_NOTEQUALS = 270;
- public const PHQL_T_NOTIN = 323;
- /** Placeholders */
- public const PHQL_T_NPLACEHOLDER = 273;
- public const PHQL_T_NULL = 322;
- public const PHQL_T_OFFSET = 329;
- public const PHQL_T_ON = 316;
- public const PHQL_T_OR = 267;
- public const PHQL_T_ORDER = 310;
- public const PHQL_T_OUTER = 326;
- public const PHQL_T_PARENTHESES_CLOSE = ')';
- public const PHQL_T_PARENTHESES_OPEN = '(';
- public const PHQL_T_QUALIFIED = 355;
- public const PHQL_T_RAW_QUALIFIED = 358;
- public const PHQL_T_RIGHT = 320;
- public const PHQL_T_RIGHTJOIN = 362;
- public const PHQL_T_SELECT = 309;
- public const PHQL_T_SET = 301;
- public const PHQL_T_SPLACEHOLDER = 274;
- public const PHQL_T_STARALL = 352;
- public const PHQL_T_STRING = 260;
- public const PHQL_T_SUB = '-';
- public const PHQL_T_SUBQUERY = 407;
- public const PHQL_T_THEN = 413;
- public const PHQL_T_TRUE = 334;
- public const PHQL_T_TS_AND = 403;
- public const PHQL_T_TS_CONTAINS_ANOTHER = 405;
- public const PHQL_T_TS_CONTAINS_IN = 406;
- /** Postgresql Text Search Operators */
- public const PHQL_T_TS_MATCHES = 401;
- public const PHQL_T_TS_NEGATE = 404;
- public const PHQL_T_TS_OR = 402;
- /** Reserved words */
- public const PHQL_T_UPDATE = 300;
- public const PHQL_T_USING = 337;
- public const PHQL_T_VALUES = 308;
- public const PHQL_T_WHEN = 410;
- public const PHQL_T_WHERE = 302;
- public const PHQL_T_WITH = 415;
+ public function label(): string
+ {
+ return match ($this) {
+ self::ADD => '+',
+ self::BITWISE_AND => '&',
+ self::BITWISE_NOT => '~',
+ self::BITWISE_OR => '|',
+ self::BITWISE_XOR => '^',
+ self::COLON => ':',
+ self::DIV => '/',
+ self::DOT => '.',
+ self::EQUALS => '=',
+ self::GREATER => '>',
+ self::GREATEREQUAL => '>=',
+ self::LESS => '<',
+ self::LESSEQUAL => '<=',
+ self::MOD => '%',
+ self::MUL => '*',
+ self::NOT => '!',
+ self::NOTEQUALS => '<>',
+ self::PARENTHESES_CLOSE => ')',
+ self::PARENTHESES_OPEN => '(',
+ self::SUB => '-',
+ default => $this->name,
+ };
+ }
}
diff --git a/src/Scanner/Scanner.php b/src/Scanner/Scanner.php
index 6d30d85..0584986 100644
--- a/src/Scanner/Scanner.php
+++ b/src/Scanner/Scanner.php
@@ -4,18 +4,16 @@
namespace Phalcon\Phql\Scanner;
+use Exception;
+use Phalcon\Phql\Scanner\ScannerStatus;
use Phalcon\Phql\Scanner\State;
use Phalcon\Phql\Scanner\Token;
class Scanner
{
- public const PHQL_SCANNER_RETCODE_EOF = -1;
- public const PHQL_SCANNER_RETCODE_ERR = -2;
- public const PHQL_SCANNER_RETCODE_IMPOSSIBLE = -3;
-
private Token $token;
- public function __construct(private State $state)
+ public function __construct(private readonly State $state)
{
$this->token = new Token();
}
@@ -25,26 +23,23 @@ public function getToken(): Token
return $this->token;
}
- public function scanForToken(): int
+ public function scanForToken(): ScannerStatus
{
$yyinput = $this->state->getRawBuffer();
$yycursor = $this->state->getCursor();
if ($yycursor >= $this->state->getBufferLength()) {
- return self::PHQL_SCANNER_RETCODE_EOF;
+ return ScannerStatus::EOF;
}
- $q = $yycursor;
- $token = $this->token;
- $token->value = null;
- $token->opcode = null;
- $token->len = 0;
- $status = self::PHQL_SCANNER_RETCODE_IMPOSSIBLE;
+ $q = $yycursor;
+ $yymarker = $yycursor;
+ $status = ScannerStatus::IMPOSSIBLE;
- while (self::PHQL_SCANNER_RETCODE_IMPOSSIBLE == $status) {
+ while (ScannerStatus::IMPOSSIBLE === $status) {
if ($yycursor >= $this->state->getBufferLength()) {
- return self::PHQL_SCANNER_RETCODE_EOF;
+ return ScannerStatus::EOF;
}
$yych = 0;
$yyaccept = 0;
@@ -55,7 +50,7 @@ public function scanForToken(): int
$yych = $yyinput[$yycursor];
$yycursor += 1;
switch ($yych) {
- case 0x00:
+ case "\x00":
$yystate = 1;
break 2;
case "\t":
@@ -251,17 +246,15 @@ public function scanForToken(): int
break 2;
}
case 1:
-
- $status = self::PHQL_SCANNER_RETCODE_EOF;
- break;
+ $status = ScannerStatus::EOF;
+ break 2;
case 2:
$yystate = 3;
- break 2;
- case 3:
-
- $status = self::PHQL_SCANNER_RETCODE_ERR;
break;
+ case 3:
+ $status = ScannerStatus::ERR;
+ break 2;
case 4:
$yych = $yyinput[$yycursor];
@@ -278,10 +271,9 @@ public function scanForToken(): int
break 2;
}
case 5:
-
- $token->opcode = Opcode::PHQL_T_IGNORE;
+ $this->token = new Token(Opcode::IGNORE);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 6:
$yych = $yyinput[$yycursor];
@@ -299,26 +291,24 @@ public function scanForToken(): int
break 2;
}
case 7:
-
- $token->opcode = Opcode::PHQL_T_NOT;
+ $this->token = new Token(Opcode::NOT);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 8:
$yyaccept = 0;
$yymarker = $yycursor;
$yych = $yyinput[$yycursor];
- if ($yych <= 0x00) {
+ if ($yych === "\x00") {
$yystate = 3;
- break 2;
+ break;
}
$yystate = 68;
- break 2;
+ break;
case 9:
-
- $token->opcode = Opcode::PHQL_T_MOD;
+ $this->token = new Token(Opcode::MOD);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 10:
$yych = $yyinput[$yycursor];
@@ -332,56 +322,49 @@ public function scanForToken(): int
break 2;
}
case 11:
-
- $token->opcode = Opcode::PHQL_T_BITWISE_AND;
+ $this->token = new Token(Opcode::BITWISE_AND);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 12:
$yyaccept = 0;
$yymarker = $yycursor;
$yych = $yyinput[$yycursor];
- if ($yych <= 0x00) {
+ if ($yych === "\x00") {
$yystate = 3;
- break 2;
+ break;
}
$yystate = 74;
- break 2;
+ break;
case 13:
-
- $token->opcode = Opcode::PHQL_T_PARENTHESES_OPEN;
+ $this->token = new Token(Opcode::PARENTHESES_OPEN);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 14:
-
- $token->opcode = Opcode::PHQL_T_PARENTHESES_CLOSE;
+ $this->token = new Token(Opcode::PARENTHESES_CLOSE);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 15:
-
- $token->opcode = Opcode::PHQL_T_MUL;
+ $this->token = new Token(Opcode::MUL);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 16:
-
- $token->opcode = Opcode::PHQL_T_ADD;
+ $this->token = new Token(Opcode::ADD);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 17:
-
- $token->opcode = Opcode::PHQL_T_COMMA;
+ $this->token = new Token(Opcode::COMMA);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 18:
-
- $token->opcode = Opcode::PHQL_T_SUB;
+ $this->token = new Token(Opcode::SUB);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 19:
$yych = $yyinput[$yycursor];
@@ -404,16 +387,14 @@ public function scanForToken(): int
break 2;
}
case 20:
-
- $token->opcode = Opcode::PHQL_T_DOT;
+ $this->token = new Token(Opcode::DOT);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 21:
-
- $token->opcode = Opcode::PHQL_T_DIV;
+ $this->token = new Token(Opcode::DIV);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 22:
$yych = $yyinput[$yycursor];
@@ -456,13 +437,10 @@ public function scanForToken(): int
break 2;
}
case 23:
-
- $token->opcode = Opcode::PHQL_T_INTEGER;
- $token->value = substr($yyinput, $q, $yycursor - $q);
- $token->len = $yycursor - $q;
+ $this->token = new Token(Opcode::INTEGER, substr($yyinput, $q, $yycursor - $q), $yycursor - $q);
$q = $yycursor;
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 24:
$yyaccept = 1;
@@ -541,10 +519,9 @@ public function scanForToken(): int
break 2;
}
case 25:
-
- $token->opcode = Opcode::PHQL_T_COLON;
+ $this->token = new Token(Opcode::COLON);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 26:
$yych = $yyinput[$yycursor];
@@ -562,16 +539,14 @@ public function scanForToken(): int
break 2;
}
case 27:
-
- $token->opcode = Opcode::PHQL_T_LESS;
+ $this->token = new Token(Opcode::LESS);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 28:
-
- $token->opcode = Opcode::PHQL_T_EQUALS;
+ $this->token = new Token(Opcode::EQUALS);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 29:
$yych = $yyinput[$yycursor];
@@ -585,10 +560,9 @@ public function scanForToken(): int
break 2;
}
case 30:
-
- $token->opcode = Opcode::PHQL_T_GREATER;
+ $this->token = new Token(Opcode::GREATER);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 31:
$yych = $yyinput[$yycursor];
@@ -653,15 +627,14 @@ public function scanForToken(): int
break 2;
}
case 34:
-
- $token->value = substr($yyinput, $q, $yycursor - $q);
- $token->len = $yycursor - $q;
- if ($token->len > 2 && str_starts_with($token->value, "0x")) {
- $token->opcode = Opcode::PHQL_T_HINTEGER;
+ $tokenValue = substr($yyinput, $q, $yycursor - $q);
+ $tokenLen = $yycursor - $q;
+ if ($tokenLen > 2 && str_starts_with($tokenValue, "0x")) {
+ $this->token = new Token(Opcode::HINTEGER, $tokenValue, $tokenLen);
} else {
$alpha = 0;
- for ($i = 0; $i < $token->len; $i++) {
- $ch = $token->value[$i];
+ for ($i = 0; $i < $tokenLen; $i++) {
+ $ch = $tokenValue[$i];
if (!(($ch >= '0') && ($ch <= '9'))) {
$alpha = 1;
break;
@@ -669,15 +642,15 @@ public function scanForToken(): int
}
if ($alpha) {
- $token->opcode = Opcode::PHQL_T_IDENTIFIER;
+ $this->token = new Token(Opcode::IDENTIFIER, $tokenValue, $tokenLen);
} else {
- $token->opcode = Opcode::PHQL_T_INTEGER;
+ $this->token = new Token(Opcode::INTEGER, $tokenValue, $tokenLen);
}
}
$q = $yycursor;
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 35:
$yych = $yyinput[$yycursor];
@@ -800,23 +773,27 @@ public function scanForToken(): int
break 2;
}
case 41:
-
- $token->opcode = Opcode::PHQL_T_IDENTIFIER;
- if (($yycursor - $q) > 1) {
- if ($yyinput[$q] === '\\') {
- $token->value = substr($yyinput, $q + 1, $yycursor - $q - 1);
- $token->len = $yycursor - $q - 1;
+ // Use $yymarker (PPMARKER in C re2c) as token start.
+ // Keyword save-point states (e.g. NOT at state 176) overwrite $yymarker,
+ // so identifiers like "Notes" yield the post-keyword suffix ("es").
+ $tokenValue = null;
+ $tokenLen = 0;
+ if (($yycursor - $yymarker) > 1) {
+ if ($yyinput[$yymarker] === '\\') {
+ $tokenValue = substr($yyinput, $yymarker + 1, $yycursor - $yymarker - 1);
+ $tokenLen = $yycursor - $yymarker - 1;
} else {
- $token->value = substr($yyinput, $q, $yycursor - $q);
- $token->len = $yycursor - $q;
+ $tokenValue = substr($yyinput, $yymarker, $yycursor - $yymarker);
+ $tokenLen = $yycursor - $yymarker;
}
} else {
- $token->value = substr($yyinput, $q, $yycursor - $q);
- $token->len = $yycursor - $q;
+ $tokenValue = substr($yyinput, $yymarker, $yycursor - $yymarker);
+ $tokenLen = $yycursor - $yymarker;
}
$q = $yycursor;
$this->state->setCursor($yycursor);
- return 0;
+ $this->token = new Token(Opcode::IDENTIFIER, $tokenValue, $tokenLen);
+ return ScannerStatus::OK;
case 42:
$yych = $yyinput[$yycursor];
@@ -1094,15 +1071,15 @@ public function scanForToken(): int
$yymarker = $yycursor;
$yych = $yyinput[$yycursor];
switch ($yych) {
- case 0x00:
- case 0x01:
- case 0x02:
- case 0x03:
- case 0x04:
- case 0x05:
- case 0x06:
- case 0x07:
- case 0x08:
+ case "\x00":
+ case "\x01":
+ case "\x02":
+ case "\x03":
+ case "\x04":
+ case "\x05":
+ case "\x06":
+ case "\x07":
+ case "\x08":
case "\t":
case "\n":
case "\v":
@@ -1198,10 +1175,9 @@ public function scanForToken(): int
break 2;
}
case 58:
-
- $token->opcode = Opcode::PHQL_T_BITWISE_XOR;
+ $this->token = new Token(Opcode::BITWISE_XOR);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 59:
$yych = $yyinput[$yycursor];
@@ -1372,35 +1348,31 @@ public function scanForToken(): int
break 2;
}
case 63:
-
- $token->opcode = Opcode::PHQL_T_BITWISE_OR;
+ $this->token = new Token(Opcode::BITWISE_OR);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 64:
-
- $token->opcode = Opcode::PHQL_T_BITWISE_NOT;
+ $this->token = new Token(Opcode::BITWISE_NOT);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 65:
-
- $token->opcode = Opcode::PHQL_T_TS_NEGATE;
+ $this->token = new Token(Opcode::TS_NEGATE);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 66:
-
- $token->opcode = Opcode::PHQL_T_NOTEQUALS;
+ $this->token = new Token(Opcode::NOTEQUALS);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 67:
$yych = $yyinput[$yycursor];
// fall through
case 68:
switch ($yych) {
- case 0x00:
+ case "\x00":
$yystate = 69;
break 2;
case '"':
@@ -1433,13 +1405,16 @@ public function scanForToken(): int
break 2;
}
case 70:
-
- $token->opcode = Opcode::PHQL_T_STRING;
- $token->value = substr($yyinput, $q, $yycursor - $q - 1);
- $token->len = $yycursor - $q - 1;
+ // $yymarker points to position after the opening quote (set in state 8/12)
+ // $yycursor is past the closing quote; subtract 1 to exclude it
+ $this->token = new Token(
+ Opcode::STRING,
+ substr($yyinput, $yymarker, $yycursor - $yymarker - 1),
+ $yycursor - $yymarker - 1
+ );
$q = $yycursor;
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 71:
$yych = $yyinput[$yycursor];
@@ -1453,17 +1428,16 @@ public function scanForToken(): int
break 2;
}
case 72:
-
- $token->opcode = Opcode::PHQL_T_TS_AND;
+ $this->token = new Token(Opcode::TS_AND);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 73:
$yych = $yyinput[$yycursor];
// fall through
case 74:
switch ($yych) {
- case 0x00:
+ case "\x00":
$yystate = 69;
break 2;
case '\'':
@@ -1511,13 +1485,10 @@ public function scanForToken(): int
break 2;
}
case 77:
-
- $token->opcode = Opcode::PHQL_T_DOUBLE;
- $token->value = substr($yyinput, $q, $yycursor - $q);
- $token->len = $yycursor - $q;
+ $this->token = new Token(Opcode::DOUBLE, substr($yyinput, $q, $yycursor - $q), $yycursor - $q);
$q = $yycursor;
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 78:
$yych = $yyinput[$yycursor];
@@ -1631,17 +1602,17 @@ public function scanForToken(): int
break 2;
}
case 80:
- $token->opcode = Opcode::PHQL_T_LESSEQUAL;
+ $this->token = new Token(Opcode::LESSEQUAL);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 81:
- $token->opcode = Opcode::PHQL_T_NOTEQUALS;
+ $this->token = new Token(Opcode::NOTEQUALS);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 82:
- $token->opcode = Opcode::PHQL_T_GREATEREQUAL;
+ $this->token = new Token(Opcode::GREATEREQUAL);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 83:
$yych = $yyinput[$yycursor];
switch ($yych) {
@@ -1663,20 +1634,22 @@ public function scanForToken(): int
break 2;
}
case 84:
- $token->opcode = Opcode::PHQL_T_NPLACEHOLDER;
- $token->value = substr($yyinput, $q, $yycursor - $q);
- $token->len = $yycursor - $q;
+ $this->token = new Token(
+ Opcode::NPLACEHOLDER,
+ substr($yyinput, $q, $yycursor - $q),
+ $yycursor - $q
+ );
$q = $yycursor;
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 85:
- $token->opcode = Opcode::PHQL_T_TS_CONTAINS_ANOTHER;
+ $this->token = new Token(Opcode::TS_CONTAINS_ANOTHER);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 86:
- $token->opcode = Opcode::PHQL_T_TS_MATCHES;
+ $this->token = new Token(Opcode::TS_MATCHES);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 87:
$yych = $yyinput[$yycursor];
switch ($yych) {
@@ -1792,10 +1765,9 @@ public function scanForToken(): int
break 2;
}
case 91:
-
- $token->opcode = Opcode::PHQL_T_AS;
+ $this->token = new Token(Opcode::AS);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 92:
$yych = $yyinput[$yycursor];
switch ($yych) {
@@ -1884,10 +1856,9 @@ public function scanForToken(): int
break 2;
}
case 94:
-
- $token->opcode = Opcode::PHQL_T_BY;
+ $this->token = new Token(Opcode::BY);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 95:
$yych = $yyinput[$yycursor];
switch ($yych) {
@@ -2170,10 +2141,9 @@ public function scanForToken(): int
break 2;
}
case 112:
-
- $token->opcode = Opcode::PHQL_T_IN;
+ $this->token = new Token(Opcode::IN);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 113:
$yych = $yyinput[$yycursor];
@@ -2251,10 +2221,9 @@ public function scanForToken(): int
break 2;
}
case 114:
-
- $token->opcode = Opcode::PHQL_T_IS;
+ $this->token = new Token(Opcode::IS);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 115:
$yych = $yyinput[$yycursor];
@@ -2409,10 +2378,9 @@ public function scanForToken(): int
break 2;
}
case 122:
-
- $token->opcode = Opcode::PHQL_T_ON;
+ $this->token = new Token(Opcode::ON);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 123:
$yych = $yyinput[$yycursor];
@@ -2493,10 +2461,9 @@ public function scanForToken(): int
break 2;
}
case 124:
-
- $token->opcode = Opcode::PHQL_T_OR;
+ $this->token = new Token(Opcode::OR);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 125:
$yych = $yyinput[$yycursor];
@@ -2628,15 +2595,15 @@ public function scanForToken(): int
// fall through
case 136:
switch ($yych) {
- case 0x00:
- case 0x01:
- case 0x02:
- case 0x03:
- case 0x04:
- case 0x05:
- case 0x06:
- case 0x07:
- case 0x08:
+ case "\x00":
+ case "\x01":
+ case "\x02":
+ case "\x03":
+ case "\x04":
+ case "\x05":
+ case "\x06":
+ case "\x07":
+ case "\x08":
case "\t":
case "\n":
case "\v":
@@ -2680,15 +2647,15 @@ public function scanForToken(): int
case 137:
$yych = $yyinput[$yycursor];
switch ($yych) {
- case 0x00:
- case 0x01:
- case 0x02:
- case 0x03:
- case 0x04:
- case 0x05:
- case 0x06:
- case 0x07:
- case 0x08:
+ case "\x00":
+ case "\x01":
+ case "\x02":
+ case "\x03":
+ case "\x04":
+ case "\x05":
+ case "\x06":
+ case "\x07":
+ case "\x08":
case "\t":
case "\n":
case "\v":
@@ -2729,15 +2696,26 @@ public function scanForToken(): int
break 2;
}
case 138:
- // fall through
+ // Bracket-enclosed identifier: [name] or [First Name]
+ // Use $yymarker (set by state 56 after '[', updated by state 193 after '\]')
+ // so that escaped-bracket sequences correctly yield the post-escape substring.
+ $this->token = new Token(
+ Opcode::IDENTIFIER,
+ substr($yyinput, $yymarker, $yycursor - $yymarker - 1),
+ $yycursor - $yymarker - 1
+ );
+ $q = $yycursor;
+ $this->state->setCursor($yycursor);
+ return ScannerStatus::OK;
case 139:
-
- $token->opcode = Opcode::PHQL_T_IDENTIFIER;
- $token->value = substr($yyinput, $q, $yycursor - $q);
- $token->len = $yycursor - $q;
+ $this->token = new Token(
+ Opcode::IDENTIFIER,
+ substr($yyinput, $q, $yycursor - $q),
+ $yycursor - $q
+ );
$q = $yycursor;
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 140:
$yych = $yyinput[$yycursor];
@@ -2819,19 +2797,20 @@ public function scanForToken(): int
break 2;
}
case 141:
-
- $token->opcode = Opcode::PHQL_T_TS_OR;
+ $this->token = new Token(Opcode::TS_OR);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 142:
-
- $token->opcode = Opcode::PHQL_T_SPLACEHOLDER;
- $token->value = substr($yyinput, $q, $yycursor - $q - 1);
- $token->len = $yycursor - $q - 1;
+ // Strip leading ':' — Query.php prepends ':' when building the placeholder
+ $this->token = new Token(
+ Opcode::SPLACEHOLDER,
+ substr($yyinput, $q + 1, $yycursor - $q - 2),
+ $yycursor - $q - 2
+ );
$q = $yycursor;
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 143:
$yych = $yyinput[$yycursor];
@@ -2921,10 +2900,9 @@ public function scanForToken(): int
break 2;
}
case 145:
-
- $token->opcode = Opcode::PHQL_T_ALL;
+ $this->token = new Token(Opcode::ALL);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 146:
$yych = $yyinput[$yycursor];
@@ -3002,10 +2980,9 @@ public function scanForToken(): int
break 2;
}
case 147:
-
- $token->opcode = Opcode::PHQL_T_AND;
+ $this->token = new Token(Opcode::AND);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 148:
$yych = $yyinput[$yycursor];
@@ -3083,10 +3060,9 @@ public function scanForToken(): int
break 2;
}
case 149:
-
- $token->opcode = Opcode::PHQL_T_ASC;
+ $this->token = new Token(Opcode::ASC);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 150:
$yych = $yyinput[$yycursor];
@@ -3265,10 +3241,9 @@ public function scanForToken(): int
break 2;
}
case 159:
-
- $token->opcode = Opcode::PHQL_T_END;
+ $this->token = new Token(Opcode::END);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 160:
$yych = $yyinput[$yycursor];
@@ -3370,10 +3345,9 @@ public function scanForToken(): int
break 2;
}
case 163:
-
- $token->opcode = Opcode::PHQL_T_FOR;
+ $this->token = new Token(Opcode::FOR);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 164:
$yych = $yyinput[$yycursor];
@@ -3601,10 +3575,9 @@ public function scanForToken(): int
break 2;
}
case 177:
-
- $token->opcode = Opcode::PHQL_T_NOT;
+ $this->token = new Token(Opcode::NOT);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 178:
$yych = $yyinput[$yycursor];
@@ -3754,10 +3727,9 @@ public function scanForToken(): int
break 2;
}
case 185:
-
- $token->opcode = Opcode::PHQL_T_SET;
+ $this->token = new Token(Opcode::SET);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 186:
$yych = $yyinput[$yycursor];
@@ -3853,15 +3825,15 @@ public function scanForToken(): int
$yymarker = $yycursor;
$yych = $yyinput[$yycursor];
switch ($yych) {
- case 0x00:
- case 0x01:
- case 0x02:
- case 0x03:
- case 0x04:
- case 0x05:
- case 0x06:
- case 0x07:
- case 0x08:
+ case "\x00":
+ case "\x01":
+ case "\x02":
+ case "\x03":
+ case "\x04":
+ case "\x05":
+ case "\x06":
+ case "\x07":
+ case "\x08":
case "\t":
case "\n":
case "\v":
@@ -3903,13 +3875,15 @@ public function scanForToken(): int
break 2;
}
case 194:
-
- $token->opcode = Opcode::PHQL_T_BPLACEHOLDER;
- $token->value = substr($yyinput, $q, $yycursor - $q - 1);
- $token->len = $yycursor - $q - 1;
+ // Strip leading ':' — Query.php handles the ':' prefix separately
+ $this->token = new Token(
+ Opcode::BPLACEHOLDER,
+ substr($yyinput, $q + 1, $yycursor - $q - 2),
+ $yycursor - $q - 2
+ );
$q = $yycursor;
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 195:
$yych = $yyinput[$yycursor];
@@ -4011,10 +3985,9 @@ public function scanForToken(): int
break 2;
}
case 198:
-
- $token->opcode = Opcode::PHQL_T_CASE;
+ $this->token = new Token(Opcode::CASE);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 199:
$yych = $yyinput[$yycursor];
@@ -4092,10 +4065,9 @@ public function scanForToken(): int
break 2;
}
case 200:
-
- $token->opcode = Opcode::PHQL_T_CAST;
+ $this->token = new Token(Opcode::CAST);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 201:
$yych = $yyinput[$yycursor];
@@ -4209,10 +4181,9 @@ public function scanForToken(): int
break 2;
}
case 205:
-
- $token->opcode = Opcode::PHQL_T_DESC;
+ $this->token = new Token(Opcode::DESC);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 206:
$yych = $yyinput[$yycursor];
@@ -4302,10 +4273,9 @@ public function scanForToken(): int
break 2;
}
case 208:
-
- $token->opcode = Opcode::PHQL_T_ELSE;
+ $this->token = new Token(Opcode::ELSE);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 209:
$yych = $yyinput[$yycursor];
@@ -4407,10 +4377,9 @@ public function scanForToken(): int
break 2;
}
case 212:
-
- $token->opcode = Opcode::PHQL_T_FROM;
+ $this->token = new Token(Opcode::FROM);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 213:
$yych = $yyinput[$yycursor];
@@ -4488,10 +4457,9 @@ public function scanForToken(): int
break 2;
}
case 214:
-
- $token->opcode = Opcode::PHQL_T_FULL;
+ $this->token = new Token(Opcode::FULL);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 215:
$yych = $yyinput[$yycursor];
@@ -4629,10 +4597,9 @@ public function scanForToken(): int
break 2;
}
case 221:
-
- $token->opcode = Opcode::PHQL_T_INTO;
+ $this->token = new Token(Opcode::INTO);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 222:
$yych = $yyinput[$yycursor];
@@ -4710,10 +4677,9 @@ public function scanForToken(): int
break 2;
}
case 223:
-
- $token->opcode = Opcode::PHQL_T_JOIN;
+ $this->token = new Token(Opcode::JOIN);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 224:
$yych = $yyinput[$yycursor];
@@ -4791,10 +4757,9 @@ public function scanForToken(): int
break 2;
}
case 225:
-
- $token->opcode = Opcode::PHQL_T_LEFT;
+ $this->token = new Token(Opcode::LEFT);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 226:
$yych = $yyinput[$yycursor];
@@ -4872,10 +4837,9 @@ public function scanForToken(): int
break 2;
}
case 227:
-
- $token->opcode = Opcode::PHQL_T_LIKE;
+ $this->token = new Token(Opcode::LIKE);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 228:
$yych = $yyinput[$yycursor];
@@ -4977,10 +4941,9 @@ public function scanForToken(): int
break 2;
}
case 231:
-
- $token->opcode = Opcode::PHQL_T_NULL;
+ $this->token = new Token(Opcode::NULL);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 232:
$yych = $yyinput[$yycursor];
@@ -5118,10 +5081,9 @@ public function scanForToken(): int
break 2;
}
case 238:
-
- $token->opcode = Opcode::PHQL_T_THEN;
+ $this->token = new Token(Opcode::THEN);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 239:
$yych = $yyinput[$yycursor];
@@ -5199,10 +5161,9 @@ public function scanForToken(): int
break 2;
}
case 240:
-
- $token->opcode = Opcode::PHQL_T_TRUE;
+ $this->token = new Token(Opcode::TRUE);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 241:
$yych = $yyinput[$yycursor];
@@ -5316,10 +5277,9 @@ public function scanForToken(): int
break 2;
}
case 245:
-
- $token->opcode = Opcode::PHQL_T_WHEN;
+ $this->token = new Token(Opcode::WHEN);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 246:
$yych = $yyinput[$yycursor];
@@ -5409,10 +5369,9 @@ public function scanForToken(): int
break 2;
}
case 248:
-
- $token->opcode = Opcode::PHQL_T_WITH;
+ $this->token = new Token(Opcode::WITH);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 249:
$yych = $yyinput[$yycursor];
@@ -5526,10 +5485,9 @@ public function scanForToken(): int
break 2;
}
case 253:
-
- $token->opcode = Opcode::PHQL_T_CROSS;
+ $this->token = new Token(Opcode::CROSS);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 254:
$yych = $yyinput[$yycursor];
@@ -5643,10 +5601,9 @@ public function scanForToken(): int
break 2;
}
case 258:
-
- $token->opcode = Opcode::PHQL_T_FALSE;
+ $this->token = new Token(Opcode::FALSE);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 259:
$yych = $yyinput[$yycursor];
@@ -5724,10 +5681,9 @@ public function scanForToken(): int
break 2;
}
case 260:
-
- $token->opcode = Opcode::PHQL_T_GROUP;
+ $this->token = new Token(Opcode::GROUP);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 261:
$yych = $yyinput[$yycursor];
@@ -5817,10 +5773,9 @@ public function scanForToken(): int
break 2;
}
case 263:
-
- $token->opcode = Opcode::PHQL_T_ILIKE;
+ $this->token = new Token(Opcode::ILIKE);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 264:
$yych = $yyinput[$yycursor];
@@ -5898,10 +5853,9 @@ public function scanForToken(): int
break 2;
}
case 265:
-
- $token->opcode = Opcode::PHQL_T_INNER;
+ $this->token = new Token(Opcode::INNER);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 266:
$yych = $yyinput[$yycursor];
@@ -5991,10 +5945,9 @@ public function scanForToken(): int
break 2;
}
case 268:
-
- $token->opcode = Opcode::PHQL_T_LIMIT;
+ $this->token = new Token(Opcode::LIMIT);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 269:
$yych = $yyinput[$yycursor];
@@ -6096,10 +6049,9 @@ public function scanForToken(): int
break 2;
}
case 272:
-
- $token->opcode = Opcode::PHQL_T_ORDER;
+ $this->token = new Token(Opcode::ORDER);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 273:
$yych = $yyinput[$yycursor];
@@ -6177,10 +6129,9 @@ public function scanForToken(): int
break 2;
}
case 274:
-
- $token->opcode = Opcode::PHQL_T_OUTER;
+ $this->token = new Token(Opcode::OUTER);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 275:
$yych = $yyinput[$yycursor];
@@ -6258,10 +6209,9 @@ public function scanForToken(): int
break 2;
}
case 276:
-
- $token->opcode = Opcode::PHQL_T_RIGHT;
+ $this->token = new Token(Opcode::RIGHT);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 277:
$yych = $yyinput[$yycursor];
@@ -6363,10 +6313,9 @@ public function scanForToken(): int
break 2;
}
case 280:
-
- $token->opcode = Opcode::PHQL_T_USING;
+ $this->token = new Token(Opcode::USING);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 281:
$yych = $yyinput[$yycursor];
@@ -6456,10 +6405,9 @@ public function scanForToken(): int
break 2;
}
case 283:
-
- $token->opcode = Opcode::PHQL_T_WHERE;
+ $this->token = new Token(Opcode::WHERE);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 284:
$yych = $yyinput[$yycursor];
@@ -6573,10 +6521,9 @@ public function scanForToken(): int
break 2;
}
case 288:
-
- $token->opcode = Opcode::PHQL_T_DELETE;
+ $this->token = new Token(Opcode::DELETE);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 289:
$yych = $yyinput[$yycursor];
@@ -6666,10 +6613,9 @@ public function scanForToken(): int
break 2;
}
case 291:
-
- $token->opcode = Opcode::PHQL_T_EXISTS;
+ $this->token = new Token(Opcode::EXISTS);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 292:
$yych = $yyinput[$yycursor];
@@ -6747,10 +6693,9 @@ public function scanForToken(): int
break 2;
}
case 293:
-
- $token->opcode = Opcode::PHQL_T_HAVING;
+ $this->token = new Token(Opcode::HAVING);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 294:
$yych = $yyinput[$yycursor];
@@ -6828,10 +6773,9 @@ public function scanForToken(): int
break 2;
}
case 295:
-
- $token->opcode = Opcode::PHQL_T_INSERT;
+ $this->token = new Token(Opcode::INSERT);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 296:
$yych = $yyinput[$yycursor];
@@ -6921,10 +6865,9 @@ public function scanForToken(): int
break 2;
}
case 298:
-
- $token->opcode = Opcode::PHQL_T_OFFSET;
+ $this->token = new Token(Opcode::OFFSET);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 299:
$yych = $yyinput[$yycursor];
@@ -7002,10 +6945,9 @@ public function scanForToken(): int
break 2;
}
case 300:
-
- $token->opcode = Opcode::PHQL_T_SELECT;
+ $this->token = new Token(Opcode::SELECT);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 301:
$yych = $yyinput[$yycursor];
@@ -7083,10 +7025,9 @@ public function scanForToken(): int
break 2;
}
case 302:
-
- $token->opcode = Opcode::PHQL_T_UPDATE;
+ $this->token = new Token(Opcode::UPDATE);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 303:
$yych = $yyinput[$yycursor];
@@ -7164,10 +7105,9 @@ public function scanForToken(): int
break 2;
}
case 304:
-
- $token->opcode = Opcode::PHQL_T_VALUES;
+ $this->token = new Token(Opcode::VALUES);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 305:
$yych = $yyinput[$yycursor];
@@ -7245,10 +7185,9 @@ public function scanForToken(): int
break 2;
}
case 306:
-
- $token->opcode = Opcode::PHQL_T_AGAINST;
+ $this->token = new Token(Opcode::AGAINST);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 307:
$yych = $yyinput[$yycursor];
@@ -7326,10 +7265,9 @@ public function scanForToken(): int
break 2;
}
case 308:
-
- $token->opcode = Opcode::PHQL_T_BETWEEN;
+ $this->token = new Token(Opcode::BETWEEN);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 309:
$yych = $yyinput[$yycursor];
@@ -7407,10 +7345,9 @@ public function scanForToken(): int
break 2;
}
case 310:
-
- $token->opcode = Opcode::PHQL_T_CONVERT;
+ $this->token = new Token(Opcode::CONVERT);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 311:
$yych = $yyinput[$yycursor];
@@ -7512,10 +7449,9 @@ public function scanForToken(): int
break 2;
}
case 314:
-
- $token->opcode = Opcode::PHQL_T_DISTINCT;
+ $this->token = new Token(Opcode::DISTINCT);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
case 315:
$yych = $yyinput[$yycursor];
@@ -7554,10 +7490,9 @@ public function scanForToken(): int
break 2;
}
case 318:
-
- $token->opcode = Opcode::PHQL_T_BETWEEN_NOT;
+ $this->token = new Token(Opcode::BETWEEN_NOT);
$this->state->setCursor($yycursor);
- return 0;
+ return ScannerStatus::OK;
default:
throw new Exception("internal lexer error");
diff --git a/src/Scanner/ScannerStatus.php b/src/Scanner/ScannerStatus.php
new file mode 100644
index 0000000..74b06f7
--- /dev/null
+++ b/src/Scanner/ScannerStatus.php
@@ -0,0 +1,13 @@
+bufferLength = strlen($buffer);
- $this->rawBuffer = $buffer . "\0"; // null terminator for look-ahead safety in scanner
+ $this->rawBuffer = $buffer . "\0";
$this->startLength = mb_strlen($buffer);
+
if ($this->startLength > 0) {
- $this->setStart($buffer[0]);
- $this->setEnd($buffer[0]);
+ $this->start = $buffer[0];
}
}
- public function getActiveToken(): mixed
+ public function getActiveToken(): ?Opcode
{
return $this->activeToken;
}
@@ -36,14 +35,14 @@ public function getBufferLength(): int
return $this->bufferLength;
}
- public function getRawBuffer(): string
+ public function getCursor(): int
{
- return $this->rawBuffer;
+ return $this->cursor;
}
- public function getCursor(): int
+ public function getRawBuffer(): string
{
- return $this->cursor;
+ return $this->rawBuffer;
}
public function getStart(): ?string
@@ -56,47 +55,33 @@ public function getStartLength(): int
return $this->startLength;
}
- public function incrementStart(int $value = 1): self
+ public function incrementStart(int $value = 1): static
{
$this->cursor += $value;
- $this->setStart($this->rawBuffer[$this->cursor] ?? null);
+ $this->start = $this->rawBuffer[$this->cursor] ?? null;
return $this;
}
- public function setCursor(int $cursor): self
+ public function setActiveToken(?Opcode $activeToken): static
{
- $this->cursor = $cursor;
- $this->setStart($this->rawBuffer[$this->cursor] ?? null);
-
- return $this;
- }
-
- public function setEnd(?string $end): self
- {
- $this->end = $end;
+ $this->activeToken = $activeToken;
return $this;
}
- public function setStart(?string $start): self
+ public function setCursor(int $cursor): static
{
- $this->start = $start;
+ $this->cursor = $cursor;
+ $this->start = $this->rawBuffer[$this->cursor] ?? null;
return $this;
}
- public function setStartLength(int $startLength): self
+ public function setStartLength(int $startLength): static
{
$this->startLength = $startLength;
return $this;
}
-
- public function setActiveToken(mixed $activeToken): self
- {
- $this->activeToken = $activeToken;
-
- return $this;
- }
}
diff --git a/src/Scanner/Token.php b/src/Scanner/Token.php
index ce8d1dd..f8ac0bf 100644
--- a/src/Scanner/Token.php
+++ b/src/Scanner/Token.php
@@ -4,49 +4,12 @@
namespace Phalcon\Phql\Scanner;
-class Token
+final class Token
{
- protected int $length = 0;
- public int $len = 0;
- public mixed $opcode = null;
- public mixed $value = null;
-
- public function getLength(): int
- {
- return $this->length;
- }
-
- public function getOpcode(): mixed
- {
- return $this->opcode;
- }
-
- public function getValue(): mixed
- {
- return $this->value;
- }
-
- public function setLength(int $length): self
- {
- $this->length = $length;
-
- return $this;
- }
-
- public function setOpcode(mixed $opcode): self
- {
- $this->opcode = $opcode;
-
- return $this;
- }
-
- public function setValue(mixed $value): self
- {
- $this->value = $value;
- if (!empty($value)) {
- $this->setLength(mb_strlen($value));
- }
-
- return $this;
+ public function __construct(
+ public readonly ?Opcode $opcode = null,
+ public readonly ?string $value = null,
+ public readonly int $length = 0,
+ ) {
}
}
diff --git a/src/Tokens.php b/src/Tokens.php
deleted file mode 100644
index ea5fbbd..0000000
--- a/src/Tokens.php
+++ /dev/null
@@ -1,54 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE.txt
- * file that was distributed with this source code.
- */
-
-declare(strict_types=1);
-
-namespace Phalcon\Phql;
-
-class Tokens
-{
- public static array $names = [
- 'INTEGER' => Enum::PHQL_T_INTEGER,
- 'DOUBLE' => Enum::PHQL_T_DOUBLE,
- 'STRING' => Enum::PHQL_T_STRING,
- 'IDENTIFIER' => Enum::PHQL_T_IDENTIFIER,
- 'MINUS' => Enum::PHQL_T_MINUS,
- '+' => Enum::PHQL_T_ADD,
- '-' => Enum::PHQL_T_SUB,
- '*' => Enum::PHQL_T_MUL,
- '/' => Enum::PHQL_T_DIV,
- '%%' => Enum::PHQL_T_MOD,
- '!' => Enum::PHQL_T_NOT,
- //'~' => Enum::PHQL_T_CONCAT,
- 'AND' => Enum::PHQL_T_AND,
- 'OR' => Enum::PHQL_T_OR,
- 'DOT' => Enum::PHQL_T_DOT,
- 'COMMA' => Enum::PHQL_T_COMMA,
- 'EQUALS' => Enum::PHQL_T_EQUALS,
- 'NOT EQUALS' => Enum::PHQL_T_NOTEQUALS,
- //'IDENTICAL' => Enum::PHQL_T_IDENTICAL,
- //'NOT IDENTICAL' => Enum::PHQL_T_NOTIDENTICAL,
- 'NOT' => Enum::PHQL_T_NOT,
- //'RANGE' => Enum::PHQL_T_RANGE,
- 'COLON' => Enum::PHQL_T_COLON,
- //'QUESTION MARK' => Enum::PHQL_T_QUESTION,
- '<' => Enum::PHQL_T_LESS,
- '<=' => Enum::PHQL_T_LESSEQUAL,
- '>' => Enum::PHQL_T_GREATER,
- '>=' => Enum::PHQL_T_GREATEREQUAL,
- '(' => Enum::PHQL_T_PARENTHESES_OPEN,
- ')' => Enum::PHQL_T_PARENTHESES_CLOSE,
- //'[' => Enum::PHQL_T_SBRACKET_OPEN,
- //']' => Enum::PHQL_T_SBRACKET_CLOSE,
- //'{' => Enum::PHQL_T_CBRACKET_OPEN,
- //'}' => Enum::PHQL_T_CBRACKET_CLOSE,
- ];
-}
diff --git a/tests-old/001.phpt b/tests-old/001.phpt
deleted file mode 100644
index d6d8eff..0000000
--- a/tests-old/001.phpt
+++ /dev/null
@@ -1,55 +0,0 @@
---TEST--
-phql_parse_phql - Select with limit
---SKIPIF--
-
---FILE--
-
---EXPECT--
-array(4) {
- ["type"]=>
- int(309)
- ["select"]=>
- array(2) {
- ["columns"]=>
- array(1) {
- [0]=>
- array(2) {
- ["type"]=>
- int(353)
- ["column"]=>
- string(1) "r"
- }
- }
- ["tables"]=>
- array(2) {
- ["qualifiedName"]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(6) "Robots"
- }
- ["alias"]=>
- string(1) "r"
- }
- }
- ["limit"]=>
- array(1) {
- ["number"]=>
- array(2) {
- ["type"]=>
- int(258)
- ["value"]=>
- string(2) "10"
- }
- }
- ["id"]=>
- int(3)
-}
diff --git a/tests-old/002.phpt b/tests-old/002.phpt
deleted file mode 100644
index b70403c..0000000
--- a/tests-old/002.phpt
+++ /dev/null
@@ -1,81 +0,0 @@
---TEST--
-phql_parse_phql - Select with betwen
---SKIPIF--
-
---FILE--
-
---EXPECT--
-array(4) {
- ["type"]=>
- int(309)
- ["select"]=>
- array(2) {
- ["columns"]=>
- array(1) {
- [0]=>
- array(2) {
- ["type"]=>
- int(354)
- ["column"]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(11) "column_name"
- }
- }
- }
- ["tables"]=>
- array(1) {
- ["qualifiedName"]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(10) "table_name"
- }
- }
- }
- ["where"]=>
- array(3) {
- ["type"]=>
- int(331)
- ["left"]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(11) "column_name"
- }
- ["right"]=>
- array(3) {
- ["type"]=>
- int(266)
- ["left"]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(6) "value1"
- }
- ["right"]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(6) "value2"
- }
- }
- }
- ["id"]=>
- int(3)
-}
diff --git a/tests-old/003.phpt b/tests-old/003.phpt
deleted file mode 100644
index 8e8a57d..0000000
--- a/tests-old/003.phpt
+++ /dev/null
@@ -1,63 +0,0 @@
---TEST--
-phql_parse_phql - Using FQCN for source model
---SKIPIF--
-
---FILE--
-
---EXPECT--
-array(3) {
- ["type"]=>
- int(309)
- ["select"]=>
- array(2) {
- ["columns"]=>
- array(1) {
- [0]=>
- array(3) {
- ["type"]=>
- int(354)
- ["column"]=>
- array(3) {
- ["type"]=>
- int(350)
- ["name"]=>
- string(3) "AVG"
- ["arguments"]=>
- array(1) {
- [0]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(9) "inv_total"
- }
- }
- }
- ["alias"]=>
- string(7) "average"
- }
- }
- ["tables"]=>
- array(1) {
- ["qualifiedName"]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(28) "Phalcon\Tests\Models\Invoices"
- }
- }
- }
- ["id"]=>
- int(3)
-}
diff --git a/tests-old/bug14253.phpt b/tests-old/bug14253.phpt
deleted file mode 100644
index 4e72c3c..0000000
--- a/tests-old/bug14253.phpt
+++ /dev/null
@@ -1,105 +0,0 @@
---TEST--
-phql_parse_phql - Select with betwen
---SKIPIF--
-
---FILE--
-
---EXPECT--
-array(4) {
- ["type"]=>
- int(309)
- ["select"]=>
- array(2) {
- ["columns"]=>
- array(3) {
- [0]=>
- array(2) {
- ["type"]=>
- int(354)
- ["column"]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(2) "Id"
- }
- }
- [1]=>
- array(2) {
- ["type"]=>
- int(354)
- ["column"]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(11) "ProductName"
- }
- }
- [2]=>
- array(2) {
- ["type"]=>
- int(354)
- ["column"]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(9) "UnitPrice"
- }
- }
- }
- ["tables"]=>
- array(1) {
- ["qualifiedName"]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(7) "Product"
- }
- }
- }
- ["where"]=>
- array(3) {
- ["type"]=>
- int(332)
- ["left"]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(9) "UnitPrice"
- }
- ["right"]=>
- array(3) {
- ["type"]=>
- int(266)
- ["left"]=>
- array(2) {
- ["type"]=>
- int(258)
- ["value"]=>
- string(1) "5"
- }
- ["right"]=>
- array(2) {
- ["type"]=>
- int(258)
- ["value"]=>
- string(3) "100"
- }
- }
- }
- ["id"]=>
- int(3)
-}
diff --git a/tests-old/bug14535.phpt b/tests-old/bug14535.phpt
deleted file mode 100644
index 6923c97..0000000
--- a/tests-old/bug14535.phpt
+++ /dev/null
@@ -1,72 +0,0 @@
---TEST--
-phql_parse_phql - Using spaces in column alias
---SKIPIF--
-
---FILE--
-
---EXPECT--
-array(3) {
- ["type"]=>
- int(309)
- ["select"]=>
- array(2) {
- ["columns"]=>
- array(2) {
- [0]=>
- array(3) {
- ["type"]=>
- int(354)
- ["column"]=>
- array(3) {
- ["type"]=>
- int(355)
- ["domain"]=>
- string(6) "People"
- ["name"]=>
- string(9) "firstName"
- }
- ["alias"]=>
- string(10) "First Name"
- }
- [1]=>
- array(3) {
- ["type"]=>
- int(354)
- ["column"]=>
- array(3) {
- ["type"]=>
- int(355)
- ["domain"]=>
- string(6) "People"
- ["name"]=>
- string(8) "lastName"
- }
- ["alias"]=>
- string(9) "Last Name"
- }
- }
- ["tables"]=>
- array(1) {
- ["qualifiedName"]=>
- array(2) {
- ["type"]=>
- int(355)
- ["name"]=>
- string(6) "People"
- }
- }
- }
- ["id"]=>
- int(3)
-}
diff --git a/tests/unit/Parser/PhqlParserTest.php b/tests/unit/Parser/PhqlParserTest.php
deleted file mode 100644
index b7b479f..0000000
--- a/tests/unit/Parser/PhqlParserTest.php
+++ /dev/null
@@ -1,193 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE.txt
- * file that was distributed with this source code.
- */
-
-declare(strict_types=1);
-
-namespace Phalcon\Phql\Tests\Unit\Parser;
-
-use Phalcon\Phql\Parser\Parser;
-use Phalcon\Phql\Tests\AbstractUnitTestCase;
-
-final class PhqlParserTest extends AbstractUnitTestCase
-{
- private Parser $parser;
-
- protected function setUp(): void
- {
- $this->parser = new Parser();
- }
-
- /**
- * Test: Select with limit
- * Original: tests-old/001.phpt
- */
- public function testSelectWithLimit(): void
- {
- $phql = 'SELECT r.* FROM Robots r LIMIT 10';
- $result = $this->parser->parse($phql);
-
- $this->assertIsArray($result);
- $this->assertEquals(309, $result['type']);
- $this->assertArrayHasKey('select', $result);
- $this->assertArrayHasKey('limit', $result);
-
- $this->assertArrayHasKey('columns', $result['select']);
- $this->assertIsArray($result['select']['columns']);
- $this->assertCount(1, $result['select']['columns']);
-
- $this->assertEquals(353, $result['select']['columns'][0]['type']);
- $this->assertEquals('r', $result['select']['columns'][0]['column']);
-
- $this->assertArrayHasKey('tables', $result['select']);
- $this->assertEquals('Robots', $result['select']['tables']['qualifiedName']['name']);
- $this->assertEquals('r', $result['select']['tables']['alias']);
-
- $this->assertEquals('10', $result['limit']['number']['value']);
- }
-
- /**
- * Test: Select with BETWEEN
- * Original: tests-old/002.phpt
- */
- public function testSelectWithBetween(): void
- {
- $phql = <<parser->parse($phql);
-
- $this->assertIsArray($result);
- $this->assertEquals(309, $result['type']);
- $this->assertArrayHasKey('select', $result);
- $this->assertArrayHasKey('where', $result);
-
- $this->assertEquals('column_name', $result['select']['columns'][0]['column']['name']);
- $this->assertEquals('table_name', $result['select']['tables']['qualifiedName']['name']);
- $this->assertEquals('column_name', $result['where']['left']['name']);
- $this->assertEquals('value1', $result['where']['right']['left']['name']);
- $this->assertEquals('value2', $result['where']['right']['right']['name']);
- }
-
- /**
- * Test: Using FQCN for source model
- * Original: tests-old/003.phpt
- */
- public function testUsingFQCNForSourceModel(): void
- {
- $phql = <<parser->parse($phql);
-
- $this->assertIsArray($result);
- $this->assertEquals(309, $result['type']);
- $this->assertArrayHasKey('select', $result);
-
- $this->assertEquals('AVG', $result['select']['columns'][0]['column']['name']);
- $this->assertEquals('inv_total', $result['select']['columns'][0]['column']['arguments'][0]['name']);
- $this->assertEquals('average', $result['select']['columns'][0]['alias']);
-
- $this->assertEquals('[Phalcon\\Tests\\Models\\Invoices]', $result['select']['tables']['qualifiedName']['name']);
- }
-
- /**
- * Test: Select with NOT BETWEEN
- * Original: tests-old/bug14253.phpt
- */
- public function testSelectWithNotBetween(): void
- {
- $phql = <<parser->parse($phql);
-
- $this->assertIsArray($result);
- $this->assertEquals(309, $result['type']);
- $this->assertArrayHasKey('select', $result);
- $this->assertArrayHasKey('where', $result);
-
- $this->assertCount(3, $result['select']['columns']);
- $this->assertEquals('Id', $result['select']['columns'][0]['column']['name']);
- $this->assertEquals('ProductName', $result['select']['columns'][1]['column']['name']);
- $this->assertEquals('UnitPrice', $result['select']['columns'][2]['column']['name']);
- $this->assertEquals('Product', $result['select']['tables']['qualifiedName']['name']);
-
- $this->assertEquals(332, $result['where']['type']); // NOT BETWEEN type
- $this->assertEquals('UnitPrice', $result['where']['left']['name']);
- $this->assertEquals('5', $result['where']['right']['left']['value']);
- $this->assertEquals('100', $result['where']['right']['right']['value']);
- }
-
- /**
- * Test: Using spaces in column alias
- * Original: tests-old/bug14535.phpt
- */
- public function testUsingSpacesInColumnAlias(): void
- {
- $phql = <<parser->parse($phql);
-
- $this->assertIsArray($result);
- $this->assertEquals(309, $result['type']);
- $this->assertArrayHasKey('select', $result);
-
- $this->assertCount(2, $result['select']['columns']);
- $this->assertEquals('People', $result['select']['columns'][0]['column']['domain']);
- $this->assertEquals('firstName', $result['select']['columns'][0]['column']['name']);
- $this->assertEquals('[First Name]', $result['select']['columns'][0]['alias']);
-
- $this->assertEquals('People', $result['select']['columns'][1]['column']['domain']);
- $this->assertEquals('lastName', $result['select']['columns'][1]['column']['name']);
- $this->assertEquals('[Last Name]', $result['select']['columns'][1]['alias']);
-
- $this->assertEquals('People', $result['select']['tables']['qualifiedName']['name']);
- }
-
- /**
- * Test: Delete with WHERE conditions using AND/OR
- */
- public function testDeleteWithWhereAndOr(): void
- {
- $phql = "DELETE FROM co_invoices "
- . "WHERE inv_total > :test: "
- . "AND inv_cst_id = 2 "
- . "OR inv_status_flag = 3 ";
-
- $parser = new Parser(true);
- $result = $parser->parse($phql);
-
- $this->assertIsArray($result);
- $this->assertArrayHasKey('type', $result);
- $this->assertArrayHasKey('delete', $result);
-
- $this->assertArrayHasKey('tables', $result['delete']);
- $this->assertEquals('co_invoices', $result['delete']['tables']['qualifiedName']['name']);
-
- $this->assertArrayHasKey('where', $result);
- }
-}
\ No newline at end of file
diff --git a/tests/unit/Parser/StatusTest.php b/tests/unit/Parser/StatusTest.php
new file mode 100644
index 0000000..64663e7
--- /dev/null
+++ b/tests/unit/Parser/StatusTest.php
@@ -0,0 +1,82 @@
+assertSame(Status::PHQL_PARSING_OK, $status->getStatus());
+ $this->assertNull($status->getAst());
+ $this->assertNull($status->getSyntaxError());
+ $this->assertNull($status->getToken());
+ $this->assertFalse($status->getEnableLiterals());
+ }
+
+ public function testEnableLiterals(): void
+ {
+ $state = new State('SELECT');
+ $status = new Status($state);
+
+ $status->setEnableLiterals(true);
+
+ $this->assertTrue($status->getEnableLiterals());
+ }
+
+ public function testGetState(): void
+ {
+ $state = new State('SELECT');
+ $status = new Status($state);
+
+ $this->assertSame($state, $status->getState());
+ }
+
+ public function testNoGetRetMethod(): void
+ {
+ $state = new State('SELECT');
+ $status = new Status($state);
+
+ $this->assertFalse(method_exists($status, 'getRet'));
+ $this->assertFalse(method_exists($status, 'setRet'));
+ }
+
+ public function testSetAndGetAst(): void
+ {
+ $state = new State('SELECT');
+ $status = new Status($state);
+ $ast = ['type' => 309, 'select' => []];
+
+ $status->setAst($ast);
+
+ $this->assertSame($ast, $status->getAst());
+ }
+
+ public function testSetStatus(): void
+ {
+ $state = new State('SELECT');
+ $status = new Status($state);
+
+ $status->setStatus(Status::PHQL_PARSING_FAILED);
+
+ $this->assertSame(Status::PHQL_PARSING_FAILED, $status->getStatus());
+ }
+
+ public function testSetSyntaxError(): void
+ {
+ $state = new State('SELECT');
+ $status = new Status($state);
+
+ $status->setSyntaxError('Unexpected token');
+
+ $this->assertSame('Unexpected token', $status->getSyntaxError());
+ }
+}
diff --git a/tests/unit/ParserTest.php b/tests/unit/ParserTest.php
new file mode 100644
index 0000000..279160a
--- /dev/null
+++ b/tests/unit/ParserTest.php
@@ -0,0 +1,135 @@
+expectException(Exception::class);
+ $this->expectExceptionMessage('Literals are disabled in PHQL statements');
+
+ (new Parser())->setEnableLiterals(false)->parse('SELECT 1.5 FROM Invoices');
+ }
+
+ public function testLiteralsDisabledBlocksFalse(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Literals are disabled in PHQL statements');
+
+ (new Parser())->setEnableLiterals(false)->parse('SELECT FALSE FROM Invoices');
+ }
+
+ public function testLiteralsDisabledBlocksHinteger(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Literals are disabled in PHQL statements');
+
+ (new Parser())->setEnableLiterals(false)->parse('SELECT 0xFF FROM Invoices');
+ }
+
+ public function testLiteralsDisabledBlocksInteger(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Literals are disabled in PHQL statements');
+
+ (new Parser())->setEnableLiterals(false)->parse('SELECT 1 FROM Invoices');
+ }
+
+ public function testLiteralsDisabledBlocksString(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Literals are disabled in PHQL statements');
+
+ (new Parser())->setEnableLiterals(false)->parse("SELECT 'hello' FROM Invoices");
+ }
+
+ public function testLiteralsDisabledBlocksTrue(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Literals are disabled in PHQL statements');
+
+ (new Parser())->setEnableLiterals(false)->parse('SELECT TRUE FROM Invoices');
+ }
+
+ public function testLiteralsEnabledByDefault(): void
+ {
+ // Integer literal should parse without error when literals are enabled (default)
+ $result = (new Parser())->parse('SELECT 1 FROM Invoices');
+ $this->assertIsArray($result);
+ }
+
+ public function testParseEmptyStringThrows(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('PHQL statement cannot be NULL');
+
+ (new Parser())->parse('');
+ }
+
+ public function testParseSimpleSelect(): void
+ {
+ $result = (new Parser())->parse('SELECT * FROM Invoices');
+
+ $this->assertIsArray($result);
+ $this->assertSame(Opcode::SELECT->value, $result['type']);
+ $this->assertArrayHasKey('select', $result);
+ }
+
+ public function testScannerErrorLongMessage(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessageMatches('/Scanning error before/');
+ $this->expectExceptionMessageMatches('/\.\.\./');
+
+ (new Parser())->parse('#' . str_repeat('x', 20));
+ }
+
+ public function testScannerErrorShortMessage(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessageMatches('/Scanning error before/');
+
+ (new Parser())->parse('#');
+ }
+
+ public function testSetEnableLiteralsChaining(): void
+ {
+ // Should not throw — fluent chaining works
+ $result = (new Parser())->setEnableLiterals(true)->parse('SELECT * FROM Invoices');
+ $this->assertIsArray($result);
+ }
+
+ public function testSetEnableLiteralsReturnsSelf(): void
+ {
+ $parser = new Parser();
+ $result = $parser->setEnableLiterals(false);
+
+ $this->assertSame($parser, $result);
+ }
+
+ public function testThrowsPhqlException(): void
+ {
+ try {
+ (new Parser())->parse('');
+ } catch (\Throwable $e) {
+ $this->assertInstanceOf(Exception::class, $e);
+ $this->assertNotInstanceOf(\RuntimeException::class, $e);
+ }
+ }
+
+ public function testUnknownOpcodeThrows(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessageMatches('/Unknown opcode/');
+
+ (new Parser())->parse('SELECT : FROM Invoices');
+ }
+}
diff --git a/tests/unit/Phql/Delete/CombinationTest.php b/tests/unit/Phql/Delete/CombinationTest.php
new file mode 100644
index 0000000..14a8664
--- /dev/null
+++ b/tests/unit/Phql/Delete/CombinationTest.php
@@ -0,0 +1,369 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Delete;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class CombinationTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlDelete(): void
+ {
+ $source = "DELETE FROM Invoices";
+ $expected = [
+ 'type' => Opcode::DELETE->value,
+ 'delete' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlDeleteAliasWhereEqZero(): void
+ {
+ $source = "DELETE FROM Invoices AS i WHERE i.inv_status_flag = 0";
+ $expected = [
+ 'type' => Opcode::DELETE->value,
+ 'delete' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlDeleteLimit(): void
+ {
+ $source = "DELETE FROM Invoices LIMIT 10";
+ $expected = [
+ 'type' => Opcode::DELETE->value,
+ 'delete' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '10',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlDeleteWhereEqNum(): void
+ {
+ $source = "DELETE FROM Invoices WHERE inv_id = 1";
+ $expected = [
+ 'type' => Opcode::DELETE->value,
+ 'delete' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlDeleteWhereEqPlaceholder(): void
+ {
+ $source = "DELETE FROM Invoices WHERE inv_id = :id:";
+ $expected = [
+ 'type' => Opcode::DELETE->value,
+ 'delete' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'id',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlDeleteWhereEqPlaceholderNumeric(): void
+ {
+ $source = "DELETE FROM Invoices WHERE inv_id = ?0";
+ $expected = [
+ 'type' => Opcode::DELETE->value,
+ 'delete' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::NPLACEHOLDER->value,
+ 'value' => '?0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlDeleteWhereEqZero(): void
+ {
+ $source = "DELETE FROM Invoices WHERE inv_status_flag = 0";
+ $expected = [
+ 'type' => Opcode::DELETE->value,
+ 'delete' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlDeleteWhereIn(): void
+ {
+ $source = "DELETE FROM Invoices WHERE inv_cst_id IN (1, 2, 3)";
+ $expected = [
+ 'type' => Opcode::DELETE->value,
+ 'delete' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::IN->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 0 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 1 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '2',
+ ],
+ 2 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '3',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlDeleteWhereIsNull(): void
+ {
+ $source = "DELETE FROM Invoices WHERE inv_created_at IS NULL";
+ $expected = [
+ 'type' => Opcode::DELETE->value,
+ 'delete' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::ISNULL->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_created_at',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlDeleteWhereLtNumLimit(): void
+ {
+ $source = "DELETE FROM Invoices WHERE inv_total < 0 LIMIT 3";
+ $expected = [
+ 'type' => Opcode::DELETE->value,
+ 'delete' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::LESS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '3',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Insert/CombinationTest.php b/tests/unit/Phql/Insert/CombinationTest.php
new file mode 100644
index 0000000..565885a
--- /dev/null
+++ b/tests/unit/Phql/Insert/CombinationTest.php
@@ -0,0 +1,506 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Insert;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class CombinationTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqInsertFields(): void
+ {
+ $source = "INSERT INTO Invoices " . "(inv_cst_id, inv_status_flag, inv_title, inv_total, inv_created_at) " .
+ "VALUES (1, 0, 'Test Invoice', 150.50, '2025-01-01 00:00:00')";
+ $expected = [
+ 'type' => Opcode::INSERT->value,
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'fields' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 1 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 2 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 3 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 4 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_created_at',
+ ],
+ ],
+ 'values' => [
+ 0 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 1 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ 2 => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'Test Invoice',
+ ],
+ 3 => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '150.50',
+ ],
+ 4 => [
+ 'type' => Opcode::STRING->value,
+ 'value' => '2025-01-01 00:00:00',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlInsert(): void
+ {
+ $source = "INSERT INTO Invoices " . "VALUES (1, 1, 1, 'Test Invoice', 100.00, '2025-01-01 00:00:00')";
+ $expected = [
+ 'type' => Opcode::INSERT->value,
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'values' => [
+ 0 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 1 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 2 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 3 => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'Test Invoice',
+ ],
+ 4 => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '100.00',
+ ],
+ 5 => [
+ 'type' => Opcode::STRING->value,
+ 'value' => '2025-01-01 00:00:00',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlInsertArithmeticInValues(): void
+ {
+ $source = "INSERT INTO Invoices (inv_total) VALUES (100 + 50)";
+ $expected = [
+ 'type' => Opcode::INSERT->value,
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'fields' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ 'values' => [
+ 0 => [
+ 'type' => Opcode::ADD->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '100',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '50',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlInsertFieldsNull(): void
+ {
+ $source = "INSERT INTO Invoices " . "(inv_title, inv_total) " . "VALUES ('Null Test', NULL)";
+ $expected = [
+ 'type' => Opcode::INSERT->value,
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'fields' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 1 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ 'values' => [
+ 0 => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'Null Test',
+ ],
+ 1 => [
+ 'type' => Opcode::NULL->value,
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlInsertFieldsPartial(): void
+ {
+ $source = "INSERT INTO Invoices (inv_title, inv_total) " . "VALUES ('Invoice A', 200.00)";
+ $expected = [
+ 'type' => Opcode::INSERT->value,
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'fields' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 1 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ 'values' => [
+ 0 => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'Invoice A',
+ ],
+ 1 => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '200.00',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlInsertFieldsPlaceholders(): void
+ {
+ $source = "INSERT INTO Invoices " . "(inv_cst_id, inv_status_flag, inv_title, inv_total) " .
+ "VALUES (:cstId:, :status:, :title:, :total:)";
+ $expected = [
+ 'type' => Opcode::INSERT->value,
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'fields' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 1 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 2 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 3 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ 'values' => [
+ 0 => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'cstId',
+ ],
+ 1 => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'status',
+ ],
+ 2 => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'title',
+ ],
+ 3 => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'total',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlInsertFieldsPlaceholdersBrackets(): void
+ {
+ $source = "INSERT INTO Invoices " . "(inv_id, inv_cst_id, inv_status_flag, inv_title, inv_total) " .
+ "VALUES ({id}, {cstId}, {status}, {title}, {total})";
+ $expected = [
+ 'type' => Opcode::INSERT->value,
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'fields' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 1 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 2 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 3 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 4 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ 'values' => [
+ 0 => [
+ 'type' => Opcode::BPLACEHOLDER->value,
+ 'value' => 'id',
+ ],
+ 1 => [
+ 'type' => Opcode::BPLACEHOLDER->value,
+ 'value' => 'cstId',
+ ],
+ 2 => [
+ 'type' => Opcode::BPLACEHOLDER->value,
+ 'value' => 'status',
+ ],
+ 3 => [
+ 'type' => Opcode::BPLACEHOLDER->value,
+ 'value' => 'title',
+ ],
+ 4 => [
+ 'type' => Opcode::BPLACEHOLDER->value,
+ 'value' => 'total',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlInsertFieldsPlaceholdersNum(): void
+ {
+ $source = "INSERT INTO Invoices " . "(inv_cst_id, inv_title, inv_total) " . "VALUES (?0, ?1, ?2)";
+ $expected = [
+ 'type' => Opcode::INSERT->value,
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'fields' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 1 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 2 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ 'values' => [
+ 0 => [
+ 'type' => Opcode::NPLACEHOLDER->value,
+ 'value' => '?0',
+ ],
+ 1 => [
+ 'type' => Opcode::NPLACEHOLDER->value,
+ 'value' => '?1',
+ ],
+ 2 => [
+ 'type' => Opcode::NPLACEHOLDER->value,
+ 'value' => '?2',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlInsertFieldsTrue(): void
+ {
+ $source = "INSERT INTO Invoices (inv_title, inv_status_flag) VALUES ('New Invoice', TRUE)";
+ $expected = [
+ 'type' => Opcode::INSERT->value,
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'fields' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 1 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ 'values' => [
+ 0 => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'New Invoice',
+ ],
+ 1 => [
+ 'type' => Opcode::TRUE->value,
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlInsertFuncInValues(): void
+ {
+ $source = "INSERT INTO Invoices (inv_title, inv_total) "
+ . "VALUES (UPPER('test invoice'), 100.00)";
+ $expected = [
+ 'type' => Opcode::INSERT->value,
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'fields' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 1 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ 'values' => [
+ 0 => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'UPPER',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'test invoice',
+ ],
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '100.00',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/AggregateTest.php b/tests/unit/Phql/Select/AggregateTest.php
new file mode 100644
index 0000000..4909c30
--- /dev/null
+++ b/tests/unit/Phql/Select/AggregateTest.php
@@ -0,0 +1,504 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class AggregateTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectAvgField(): void
+ {
+ $source = "SELECT AVG(inv_total) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'AVG',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @issue 3
+ * @since 2026-04-11
+ */
+ public function testMvcModelQueryPhqlSelectAvgFieldAliasFqcn(): void
+ {
+ $source = "SELECT AVG(inv_total) AS average "
+ . "FROM [Phalcon\\Tests\\Models\\Invoices]";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'AVG',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ 'alias' => 'average',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Phalcon\\Tests\\Models\\Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCount(): void
+ {
+ $source = "SELECT COUNT(*) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCountDistinctField(): void
+ {
+ $source = "SELECT COUNT(DISTINCT inv_cst_id) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ ],
+ 'distinct' => true,
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCountField(): void
+ {
+ $source = "SELECT COUNT(inv_id) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCountSumAvgMinMax(): void
+ {
+ $source = "SELECT COUNT(*), SUM(inv_total), AVG(inv_total), MIN(inv_total), MAX(inv_total) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'SUM',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ ],
+ 2 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'AVG',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ ],
+ 3 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'MIN',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ ],
+ 4 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'MAX',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectMaxDate(): void
+ {
+ $source = "SELECT MAX(inv_created_at) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'MAX',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_created_at',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectMaxField(): void
+ {
+ $source = "SELECT MAX(inv_total) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'MAX',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectMinDate(): void
+ {
+ $source = "SELECT MIN(inv_created_at) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'MIN',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_created_at',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectMinField(): void
+ {
+ $source = "SELECT MIN(inv_total) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'MIN',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectSumField(): void
+ {
+ $source = "SELECT SUM(inv_total) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'SUM',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/BasicTest.php b/tests/unit/Phql/Select/BasicTest.php
new file mode 100644
index 0000000..811160f
--- /dev/null
+++ b/tests/unit/Phql/Select/BasicTest.php
@@ -0,0 +1,335 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class BasicTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectAll(): void
+ {
+ $source = "SELECT * FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectAllAliased(): void
+ {
+ $source = "SELECT i.* FROM Invoices i";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::DOMAINALL->value,
+ 'column' => 'i',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectAllTable(): void
+ {
+ $source = "SELECT Invoices.* FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::DOMAINALL->value,
+ 'column' => 'Invoices',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectIdStringFloat(): void
+ {
+ $source = "SELECT inv_id, inv_title, inv_total FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ ],
+ 2 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectInt(): void
+ {
+ $source = "SELECT inv_id FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectIntAliased(): void
+ {
+ $source = "SELECT i.inv_id FROM Invoices i";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectIntAliasedAs(): void
+ {
+ $source = "SELECT i.inv_id FROM Invoices AS i";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectIntAliasedTable(): void
+ {
+ $source = "SELECT Invoices.inv_id FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'Invoices',
+ 'name' => 'inv_id',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectIntString(): void
+ {
+ $source = "SELECT inv_id, inv_title FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/BetweenTest.php b/tests/unit/Phql/Select/BetweenTest.php
new file mode 100644
index 0000000..3ab1275
--- /dev/null
+++ b/tests/unit/Phql/Select/BetweenTest.php
@@ -0,0 +1,338 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class BetweenTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBetweenFloat(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE inv_total BETWEEN 10.00 AND 500.00";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::BETWEEN->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '10.00',
+ ],
+ 'right' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '500.00',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @issue 2
+ * @since 2026-04-11
+ */
+ public function testMvcModelQueryPhqlSelectBetweenIdentifiers(): void
+ {
+ $source = "SELECT column_name "
+ . "FROM table_name "
+ . "WHERE column_name BETWEEN value1 AND value2";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'column_name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'table_name',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::BETWEEN->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'column_name',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'value1',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'value2',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBetweenInt(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE inv_id BETWEEN 1 AND 100";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::BETWEEN->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '100',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectNotBetweenFloat(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE inv_total NOT BETWEEN 10.00 AND 500.00";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::BETWEEN_NOT->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '10.00',
+ ],
+ 'right' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '500.00',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectNotBetweenInt(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE inv_id NOT BETWEEN 1 AND 100";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::BETWEEN_NOT->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '100',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-11
+ */
+ public function testMvcModelQueryPhqlSelectNotBetweenIntMultipleColumns(): void
+ {
+ $source = "SELECT Id, ProductName, UnitPrice "
+ . "FROM Product "
+ . "WHERE UnitPrice NOT BETWEEN 5 AND 100";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'ProductName',
+ ],
+ ],
+ 2 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'UnitPrice',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Product',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::BETWEEN_NOT->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'UnitPrice',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '5',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '100',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/BitwiseTest.php b/tests/unit/Phql/Select/BitwiseTest.php
new file mode 100644
index 0000000..9c3eede
--- /dev/null
+++ b/tests/unit/Phql/Select/BitwiseTest.php
@@ -0,0 +1,247 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class BitwiseTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBitwiseAnd(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE inv_status_flag & 1 = 1";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::BITWISE_AND->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBitwiseInField(): void
+ {
+ $source = "SELECT inv_status_flag & 3 AS masked "
+ . "FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::BITWISE_AND->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '3',
+ ],
+ ],
+ 'alias' => 'masked',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBitwiseNotField(): void
+ {
+ $source = "SELECT ~inv_status_flag "
+ . "FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::BITWISE_NOT->value,
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBitwiseOr(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE inv_status_flag | 2 = 3";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::BITWISE_OR->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '2',
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '3',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBitwiseXor(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE inv_status_flag ^ 1 = 0";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::BITWISE_XOR->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/BracketsNameTest.php b/tests/unit/Phql/Select/BracketsNameTest.php
new file mode 100644
index 0000000..7691378
--- /dev/null
+++ b/tests/unit/Phql/Select/BracketsNameTest.php
@@ -0,0 +1,105 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class BracketsNameTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBrackets(): void
+ {
+ $source = "SELECT [inv_id], [inv_title] FROM [Invoices]";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBracketsWhereNum(): void
+ {
+ $source = "SELECT [inv_id] FROM Invoices WHERE [inv_status_flag] = 1";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/BracketsWithEscapedNameTest.php b/tests/unit/Phql/Select/BracketsWithEscapedNameTest.php
new file mode 100644
index 0000000..109383f
--- /dev/null
+++ b/tests/unit/Phql/Select/BracketsWithEscapedNameTest.php
@@ -0,0 +1,61 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class BracketsWithEscapedNameTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBracketsEscapedNames(): void
+ {
+ $source = "SELECT [col\[0\]], [col\[1\]] FROM Items";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => '',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => '',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Items',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/BracketsWithSpaceNameTest.php b/tests/unit/Phql/Select/BracketsWithSpaceNameTest.php
new file mode 100644
index 0000000..10a7913
--- /dev/null
+++ b/tests/unit/Phql/Select/BracketsWithSpaceNameTest.php
@@ -0,0 +1,277 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class BracketsWithSpaceNameTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-11
+ */
+ public function testMvcModelQueryPhqlSelectBracketsColumnAliasSpaced(): void
+ {
+ $source = "SELECT People.firstName AS [First Name], "
+ . "People.lastName AS [Last Name] "
+ . "FROM People";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'People',
+ 'name' => 'firstName',
+ ],
+ 'alias' => 'First Name',
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'People',
+ 'name' => 'lastName',
+ ],
+ 'alias' => 'Last Name',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'People',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBracketsFieldSpaces(): void
+ {
+ $source = "SELECT [First Name], [Last Name] FROM Contacts";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'First Name',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Last Name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Contacts',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBracketsFieldSpacesAlias(): void
+ {
+ $source = "SELECT c.[First Name], c.[Last Name] " . "FROM Contacts AS c";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'First Name',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'Last Name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Contacts',
+ ],
+ 'alias' => 'c',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBracketsFieldSpacesAliasWhereString(): void
+ {
+ $source = "SELECT c.[First Name] " . "FROM Contacts AS c " . "WHERE c.[Last Name] = 'Smith'";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'First Name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Contacts',
+ ],
+ 'alias' => 'c',
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'Last Name',
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'Smith',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBracketsFieldSpacesOrderBy(): void
+ {
+ $source = "SELECT [First Name] AS firstName, [Last Name] AS lastName "
+ . "FROM Contacts "
+ . "ORDER BY [Last Name] ASC";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'First Name',
+ ],
+ 'alias' => 'firstName',
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Last Name',
+ ],
+ 'alias' => 'lastName',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Contacts',
+ ],
+ ],
+ ],
+ 'orderBy' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Last Name',
+ ],
+ 'sort' => 327,
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectBracketsTableSpaces(): void
+ {
+ $source = "SELECT * FROM [My Table]";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'My Table',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/CaseTest.php b/tests/unit/Phql/Select/CaseTest.php
new file mode 100644
index 0000000..a4efbbe
--- /dev/null
+++ b/tests/unit/Phql/Select/CaseTest.php
@@ -0,0 +1,228 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class CaseTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlSelectCaseSimple(): void
+ {
+ $source = "SELECT CASE inv_status_flag "
+ . "WHEN 0 THEN 'pending' "
+ . "WHEN 1 THEN 'paid' "
+ . "ELSE 'unknown' END "
+ . "FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::CASE->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 0 => [
+ 'type' => Opcode::WHEN->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'pending',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::WHEN->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'paid',
+ ],
+ ],
+ 2 => [
+ 'type' => Opcode::ELSE->value,
+ 'left' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'unknown',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlSelectCaseSimpleAlias(): void
+ {
+ $source = "SELECT CASE inv_status_flag "
+ . "WHEN 0 THEN 'pending' "
+ . "WHEN 1 THEN 'paid' "
+ . "ELSE 'unknown' END AS status "
+ . "FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::CASE->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 0 => [
+ 'type' => Opcode::WHEN->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'pending',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::WHEN->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'paid',
+ ],
+ ],
+ 2 => [
+ 'type' => Opcode::ELSE->value,
+ 'left' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'unknown',
+ ],
+ ],
+ ],
+ ],
+ 'alias' => 'status',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlSelectCaseSimpleInWhere(): void
+ {
+ $source = "SELECT * FROM Invoices "
+ . "WHERE CASE inv_status_flag WHEN 1 THEN 1 ELSE 0 END = 1";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::CASE->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 0 => [
+ 'type' => Opcode::WHEN->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::ELSE->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/CastConvertTest.php b/tests/unit/Phql/Select/CastConvertTest.php
new file mode 100644
index 0000000..80deede
--- /dev/null
+++ b/tests/unit/Phql/Select/CastConvertTest.php
@@ -0,0 +1,141 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class CastConvertTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCastInt(): void
+ {
+ $source = "SELECT CAST(inv_total AS INTEGER) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::CAST->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::RAW_QUALIFIED->value,
+ 'name' => 'INTEGER',
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCastVarchar(): void
+ {
+ $source = "SELECT CAST(inv_id AS VARCHAR) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::CAST->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::RAW_QUALIFIED->value,
+ 'name' => 'VARCHAR',
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectConvertUtf8(): void
+ {
+ $source = "SELECT CONVERT(inv_title USING utf8) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::CONVERT->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 'right' => [
+ 'type' => Opcode::RAW_QUALIFIED->value,
+ 'name' => 'utf8',
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/ColumnAliasesTest.php b/tests/unit/Phql/Select/ColumnAliasesTest.php
new file mode 100644
index 0000000..4348f13
--- /dev/null
+++ b/tests/unit/Phql/Select/ColumnAliasesTest.php
@@ -0,0 +1,142 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class ColumnAliasesTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectAliasInt(): void
+ {
+ $source = "SELECT inv_id AS id FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'alias' => 'id',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectAliasStringFloat(): void
+ {
+ $source = "SELECT inv_title AS title, inv_total AS total FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 'alias' => 'title',
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'alias' => 'total',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectAliasTableAlias(): void
+ {
+ $source = "SELECT i.inv_id AS id, i.inv_title title FROM Invoices AS i";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ 'alias' => 'id',
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_title',
+ ],
+ 'alias' => 'title',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/ComplexTest.php b/tests/unit/Phql/Select/ComplexTest.php
new file mode 100644
index 0000000..2fe62d5
--- /dev/null
+++ b/tests/unit/Phql/Select/ComplexTest.php
@@ -0,0 +1,306 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class ComplexTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectAllWhereAndInAndBetweenOrderByLimitOffset(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE inv_cst_id = :cstId: "
+ . "AND inv_status_flag IN (0, 1) "
+ . "AND inv_total BETWEEN :min: AND :max: "
+ . "ORDER BY inv_created_at DESC "
+ . "LIMIT :limit: "
+ . "OFFSET :offset:";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::BETWEEN->value,
+ 'left' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'cstId',
+ ],
+ 'right' => [
+ 'type' => Opcode::IN->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 0 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ 1 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'min',
+ ],
+ 'right' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'max',
+ ],
+ ],
+ ],
+ 'orderBy' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_created_at',
+ ],
+ 'sort' => 328,
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'limit',
+ ],
+ 'offset' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'offset',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectSumFieldWhereGroupByHavingOrderByLimit(): void
+ {
+ $source = "SELECT i.inv_id, i.inv_title, SUM(i.inv_total) AS total "
+ . "FROM Invoices AS i "
+ . "WHERE i.inv_status_flag = 1 "
+ . "GROUP BY i.inv_cst_id "
+ . "HAVING SUM(i.inv_total) > 500 "
+ . "ORDER BY total DESC "
+ . "LIMIT 10";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_title',
+ ],
+ ],
+ 2 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'SUM',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ 'alias' => 'total',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ 'orderBy' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'total',
+ ],
+ 'sort' => 328,
+ ],
+ 'groupBy' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ 'having' => [
+ 'type' => Opcode::GREATER->value,
+ 'left' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'SUM',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '500',
+ ],
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '10',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCountFieldWhereGroupByOrderBy(): void
+ {
+ $source = "SELECT COUNT(*) AS cnt, inv_status_flag "
+ . "FROM Invoices "
+ . "WHERE inv_created_at IS NOT NULL "
+ . "GROUP BY inv_status_flag "
+ . "ORDER BY cnt DESC";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ ],
+ 'alias' => 'cnt',
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::ISNOTNULL->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_created_at',
+ ],
+ ],
+ 'orderBy' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'cnt',
+ ],
+ 'sort' => 328,
+ ],
+ 'groupBy' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/DistinctTest.php b/tests/unit/Phql/Select/DistinctTest.php
new file mode 100644
index 0000000..f9e643d
--- /dev/null
+++ b/tests/unit/Phql/Select/DistinctTest.php
@@ -0,0 +1,133 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class DistinctTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectAll(): void
+ {
+ $source = "SELECT ALL inv_status_flag "
+ . "FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'distinct' => 0,
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectDistinct(): void
+ {
+ $source = "SELECT DISTINCT inv_status_flag "
+ . "FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'distinct' => 1,
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectDistinctInt(): void
+ {
+ $source = "SELECT DISTINCT inv_cst_id, inv_status_flag "
+ . "FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'distinct' => 1,
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/ForUpdateTest.php b/tests/unit/Phql/Select/ForUpdateTest.php
new file mode 100644
index 0000000..5415e3e
--- /dev/null
+++ b/tests/unit/Phql/Select/ForUpdateTest.php
@@ -0,0 +1,97 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class ForUpdateTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectForUpdate(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices FOR UPDATE";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'forUpdate' => true,
+
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectForUpdateWhere(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE inv_id = 1 FOR UPDATE";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ 'forUpdate' => true,
+
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/FromTest.php b/tests/unit/Phql/Select/FromTest.php
new file mode 100644
index 0000000..c8d7636
--- /dev/null
+++ b/tests/unit/Phql/Select/FromTest.php
@@ -0,0 +1,176 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class FromTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectFromAliases(): void
+ {
+ $source = "SELECT i.inv_id, c.name "
+ . "FROM Invoices AS i, Customers AS c "
+ . "WHERE i.inv_cst_id = c.id";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 0 => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ 1 => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ 'alias' => 'c',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'id',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlSelectFromMultipleTables(): void
+ {
+ $source = "SELECT * FROM Invoices, Customers";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 0 => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 1 => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlSelectFromMultipleTablesWhere(): void
+ {
+ $source = "SELECT * FROM Invoices, Customers "
+ . "WHERE Invoices.inv_cst_id = Customers.id";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 0 => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 1 => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'Invoices',
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'Customers',
+ 'name' => 'id',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/GroupByTest.php b/tests/unit/Phql/Select/GroupByTest.php
new file mode 100644
index 0000000..428a2be
--- /dev/null
+++ b/tests/unit/Phql/Select/GroupByTest.php
@@ -0,0 +1,188 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class GroupByTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectGroupBy(): void
+ {
+ $source = "SELECT inv_status_flag, COUNT(*) "
+ . "FROM Invoices "
+ . "GROUP BY inv_status_flag";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'groupBy' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectGroupByCountField(): void
+ {
+ $source = "SELECT inv_cst_id, inv_status_flag, COUNT(*) "
+ . "FROM Invoices " .
+ "GROUP BY inv_cst_id, inv_status_flag";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ 2 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'groupBy' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 1 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectGroupBySumField(): void
+ {
+ $source = "SELECT inv_cst_id, SUM(inv_total) "
+ . "FROM Invoices "
+ . "GROUP BY inv_cst_id";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'SUM',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'groupBy' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/HavingTest.php b/tests/unit/Phql/Select/HavingTest.php
new file mode 100644
index 0000000..d8b1367
--- /dev/null
+++ b/tests/unit/Phql/Select/HavingTest.php
@@ -0,0 +1,225 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class HavingTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectHavingCountAll(): void
+ {
+ $source = "SELECT inv_status_flag, COUNT(*) AS cnt "
+ . "FROM Invoices "
+ . "GROUP BY inv_status_flag "
+ . "HAVING COUNT(*) > 5";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ ],
+ 'alias' => 'cnt',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'groupBy' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'having' => [
+ 'type' => Opcode::GREATER->value,
+ 'left' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '5',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectHavingCountField(): void
+ {
+ $source = "SELECT inv_cst_id, COUNT(*) AS cnt "
+ . "FROM Invoices "
+ . "GROUP BY inv_cst_id "
+ . "HAVING cnt > 10";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ ],
+ 'alias' => 'cnt',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'groupBy' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 'having' => [
+ 'type' => Opcode::GREATER->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'cnt',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '10',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectHavingSum(): void
+ {
+ $source = "SELECT inv_cst_id, SUM(inv_total) AS total "
+ . "FROM Invoices "
+ . "GROUP BY inv_cst_id "
+ . "HAVING SUM(inv_total) > 1000";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'SUM',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ 'alias' => 'total',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'groupBy' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 'having' => [
+ 'type' => Opcode::GREATER->value,
+ 'left' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'SUM',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1000',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/JoinTest.php b/tests/unit/Phql/Select/JoinTest.php
new file mode 100644
index 0000000..247398f
--- /dev/null
+++ b/tests/unit/Phql/Select/JoinTest.php
@@ -0,0 +1,820 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class JoinTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCrossJoin(): void
+ {
+ $source = "SELECT i.inv_id, c.name "
+ . "FROM Invoices AS i "
+ . "CROSS JOIN Customers AS c";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ 'joins' => [
+ 'type' => Opcode::CROSSJOIN->value,
+ 'qualified' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ 'alias' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'c',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectFullJoin(): void
+ {
+ $source = "SELECT i.inv_id, c.name "
+ . "FROM Invoices AS i "
+ . "FULL JOIN Customers AS c ON i.inv_cst_id = c.id";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ 'joins' => [
+ 'type' => Opcode::FULLJOIN->value,
+ 'qualified' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ 'alias' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'c',
+ ],
+ 'conditions' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'id',
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectFullOuterJoin(): void
+ {
+ $source = "SELECT i.inv_id, c.name "
+ . "FROM Invoices AS i "
+ . "FULL OUTER JOIN Customers AS c ON i.inv_cst_id = c.id";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ 'joins' => [
+ 'type' => Opcode::FULLJOIN->value,
+ 'qualified' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ 'alias' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'c',
+ ],
+ 'conditions' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'id',
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectInnerJoin(): void
+ {
+ $source = "SELECT i.inv_id, c.name "
+ . "FROM Invoices AS i "
+ . "INNER JOIN Customers AS c ON i.inv_cst_id = c.id";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ 'joins' => [
+ 'type' => Opcode::INNERJOIN->value,
+ 'qualified' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ 'alias' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'c',
+ ],
+ 'conditions' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'id',
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlSelectInnerJoinOnComplexCondition(): void
+ {
+ $source = "SELECT i.inv_id, c.name FROM Invoices AS i "
+ . "INNER JOIN Customers AS c "
+ . "ON (i.inv_cst_id = c.id AND i.inv_status_flag = 1)";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ 'joins' => [
+ 'type' => Opcode::INNERJOIN->value,
+ 'qualified' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ 'alias' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'c',
+ ],
+ 'conditions' => [
+ 'type' => Opcode::ENCLOSED->value,
+ 'left' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectInnerLeftJoin(): void
+ {
+ $source = "SELECT i.inv_id, c.name, p.description "
+ . "FROM Invoices AS i "
+ . "INNER JOIN Customers AS c ON i.inv_cst_id = c.id "
+ . "LEFT JOIN Products AS p ON i.inv_id = p.inv_id";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'name',
+ ],
+ ],
+ 2 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'p',
+ 'name' => 'description',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ 'joins' => [
+ 0 => [
+ 'type' => Opcode::INNERJOIN->value,
+ 'qualified' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ 'alias' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'c',
+ ],
+ 'conditions' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'id',
+ ],
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::LEFTJOIN->value,
+ 'qualified' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Products',
+ ],
+ 'alias' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'p',
+ ],
+ 'conditions' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'p',
+ 'name' => 'inv_id',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectJoin(): void
+ {
+ $source = "SELECT i.inv_id, c.name "
+ . "FROM Invoices AS i "
+ . "JOIN Customers AS c ON i.inv_cst_id = c.id";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ 'joins' => [
+ 'type' => Opcode::INNERJOIN->value,
+ 'qualified' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ 'alias' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'c',
+ ],
+ 'conditions' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'id',
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLeftJoin(): void
+ {
+ $source = "SELECT i.inv_id, c.name "
+ . "FROM Invoices AS i "
+ . "LEFT JOIN Customers AS c ON i.inv_cst_id = c.id";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ 'joins' => [
+ 'type' => Opcode::LEFTJOIN->value,
+ 'qualified' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ 'alias' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'c',
+ ],
+ 'conditions' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'id',
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLeftOuterJoin(): void
+ {
+ $source = "SELECT i.inv_id, c.name "
+ . "FROM Invoices AS i "
+ . "LEFT OUTER JOIN Customers AS c ON i.inv_cst_id = c.id";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ 'joins' => [
+ 'type' => Opcode::LEFTJOIN->value,
+ 'qualified' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ 'alias' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'c',
+ ],
+ 'conditions' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'id',
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectRightJoin(): void
+ {
+ $source = "SELECT i.inv_id, c.name "
+ . "FROM Invoices AS i "
+ . "RIGHT JOIN Customers AS c ON i.inv_cst_id = c.id";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ 'joins' => [
+ 'type' => Opcode::RIGHTJOIN->value,
+ 'qualified' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ 'alias' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'c',
+ ],
+ 'conditions' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'id',
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectRightOuterJoin(): void
+ {
+ $source = "SELECT i.inv_id, c.name "
+ . "FROM Invoices AS i "
+ . "RIGHT OUTER JOIN Customers AS c ON i.inv_cst_id = c.id";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'name',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ 'joins' => [
+ 'type' => Opcode::RIGHTJOIN->value,
+ 'qualified' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ 'alias' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'c',
+ ],
+ 'conditions' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'c',
+ 'name' => 'id',
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/KeywordCollisionsNameTest.php b/tests/unit/Phql/Select/KeywordCollisionsNameTest.php
new file mode 100644
index 0000000..837f73c
--- /dev/null
+++ b/tests/unit/Phql/Select/KeywordCollisionsNameTest.php
@@ -0,0 +1,420 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class KeywordCollisionsNameTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCollisionNames(): void
+ {
+ $source = "SELECT [Order], [Group] FROM Items";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Order',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Group',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Items',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCollisionNamesDeleteUpdate(): void
+ {
+ $source = "SELECT [Delete], [Update] FROM AuditLog";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Delete',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Update',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'AuditLog',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCollisionNamesOrderBy(): void
+ {
+ $source = "SELECT [Order], [Desc] FROM Items ORDER BY [Order] ASC";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Order',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Desc',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Items',
+ ],
+ ],
+ ],
+ 'orderBy' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Order',
+ ],
+ 'sort' => 327,
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCollisionNamesValuesSet(): void
+ {
+ $source = "SELECT [Values], [Set] FROM Config";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Values',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Set',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Config',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCollisionNamesWhere(): void
+ {
+ $source = "SELECT * FROM Items WHERE [Select] = 1";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Items',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Select',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCollisionNamesWhereInt(): void
+ {
+ $source = "SELECT * FROM Items WHERE [In] = 1";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Items',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'In',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCollisionNamesWhereIsNotNull(): void
+ {
+ $source = "SELECT * FROM Items WHERE [From] IS NOT NULL";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Items',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::ISNOTNULL->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'From',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCollisionNamesWhereIsNull(): void
+ {
+ $source = "SELECT * FROM Items WHERE [Null] IS NULL";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Items',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::ISNULL->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Null',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCollisionNamesWhereString(): void
+ {
+ $source = "SELECT * FROM Items WHERE [Where] = 'test'";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Items',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Where',
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'test',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCollisionNamesWhereZero(): void
+ {
+ $source = "SELECT * FROM Items WHERE [Not] = 0";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Items',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Not',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/KeywordPrefixNameTest.php b/tests/unit/Phql/Select/KeywordPrefixNameTest.php
new file mode 100644
index 0000000..75d6c44
--- /dev/null
+++ b/tests/unit/Phql/Select/KeywordPrefixNameTest.php
@@ -0,0 +1,295 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class KeywordPrefixNameTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectPrefixGroups(): void
+ {
+ $source = "SELECT Groups FROM Settings";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Groups',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Settings',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectPrefixGroupsBrackets(): void
+ {
+ $source = "SELECT [Groups] FROM Settings";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Groups',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Settings',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectPrefixNotes(): void
+ {
+ $source = "SELECT Notes FROM Contacts";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'es',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Contacts',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectPrefixNotesBrackets(): void
+ {
+ $source = "SELECT [Notes] FROM Contacts";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Notes',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Contacts',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectPrefixOrders(): void
+ {
+ $source = "SELECT Orders FROM Customers";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Orders',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectPrefixOrdersBrackets(): void
+ {
+ $source = "SELECT [Orders] FROM Customers";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Orders',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectPrefixWhereNotesIsNotNull(): void
+ {
+ $source = "SELECT * FROM Contacts WHERE [Notes] IS NOT NULL";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Contacts',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::ISNOTNULL->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Notes',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectPrefixWhereNotesLike(): void
+ {
+ $source = "SELECT * FROM Contacts WHERE [Notes] LIKE '%important%'";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Contacts',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::LIKE->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Notes',
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => '%important%',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/LikeTest.php b/tests/unit/Phql/Select/LikeTest.php
new file mode 100644
index 0000000..d00bf78
--- /dev/null
+++ b/tests/unit/Phql/Select/LikeTest.php
@@ -0,0 +1,181 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class LikeTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectIlike(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_title ILIKE '%invoice%'";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::ILIKE->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => '%invoice%',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLike(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_title LIKE '%test%'";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::LIKE->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => '%test%',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectNotIlike(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_title NOT ILIKE '%draft%'";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::NILIKE->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => '%draft%',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectNotLike(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_title NOT LIKE '%draft%'";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::NLIKE->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => '%draft%',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/LimitTest.php b/tests/unit/Phql/Select/LimitTest.php
new file mode 100644
index 0000000..4ecf95c
--- /dev/null
+++ b/tests/unit/Phql/Select/LimitTest.php
@@ -0,0 +1,377 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class LimitTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLimit(): void
+ {
+ $source = "SELECT * FROM Invoices LIMIT 10";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '10',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @issue 1
+ * @since 2026-04-11
+ */
+ public function testMvcModelQueryPhqlSelectLimitAliasedDomainAll(): void
+ {
+ $source = "SELECT r.* FROM Robots r LIMIT 10";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::DOMAINALL->value,
+ 'column' => 'r',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Robots',
+ ],
+ 'alias' => 'r',
+ ],
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '10',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLimitBoth(): void
+ {
+ $source = "SELECT * FROM Invoices LIMIT 20, 10";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '10',
+ ],
+ 'offset' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '20',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlSelectLimitBracePlaceholder(): void
+ {
+ $source = "SELECT * FROM Invoices LIMIT {limit}";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::BPLACEHOLDER->value,
+ 'value' => 'limit',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlSelectLimitBracePlaceholderOffset(): void
+ {
+ $source = "SELECT * FROM Invoices LIMIT {limit} OFFSET {offset}";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::BPLACEHOLDER->value,
+ 'value' => 'limit',
+ ],
+ 'offset' => [
+ 'type' => Opcode::BPLACEHOLDER->value,
+ 'value' => 'offset',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLimitOffset(): void
+ {
+ $source = "SELECT * FROM Invoices LIMIT 10 OFFSET 20";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '10',
+ ],
+ 'offset' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '20',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLimitOffsetPlaceholders(): void
+ {
+ $source = "SELECT * FROM Invoices LIMIT :limit: OFFSET :offset:";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'limit',
+ ],
+ 'offset' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'offset',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLimitPlaceholderNum(): void
+ {
+ $source = "SELECT * FROM Invoices LIMIT ?0";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::NPLACEHOLDER->value,
+ 'value' => '?0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereOrderLimit(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE inv_status_flag = 1 "
+ . "ORDER BY inv_id DESC "
+ . "LIMIT 5";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ 'orderBy' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'sort' => 328,
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '5',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/LiteralsTest.php b/tests/unit/Phql/Select/LiteralsTest.php
new file mode 100644
index 0000000..3a968fe
--- /dev/null
+++ b/tests/unit/Phql/Select/LiteralsTest.php
@@ -0,0 +1,291 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class LiteralsTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLiteralsFalse(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_status_flag = FALSE";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::FALSE->value,
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLiteralsFloat(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_total = 100.5";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '100.5',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLiteralsFloatNoLeft(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_total = .5";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '.5',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLiteralsFloatNoRight(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_total = 100.";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '100.',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLiteralsNull(): void
+ {
+ $source = "SELECT NULL FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::NULL->value,
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLiteralsTrue(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_status_flag = TRUE";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::TRUE->value,
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLiteralsWhereHex(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_id = 0xFF";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::HINTEGER->value,
+ 'value' => '0xFF',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/MatchAgainstTest.php b/tests/unit/Phql/Select/MatchAgainstTest.php
new file mode 100644
index 0000000..360b2c4
--- /dev/null
+++ b/tests/unit/Phql/Select/MatchAgainstTest.php
@@ -0,0 +1,110 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class MatchAgainstTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectAgainst(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_title AGAINST 'invoice'";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::AGAINST->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'invoice',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectMatchAgainst(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE MATCH(inv_title) AGAINST ('invoice')";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::AGAINST->value,
+ 'left' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'MATCH',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::ENCLOSED->value,
+ 'left' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'invoice',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/NullTest.php b/tests/unit/Phql/Select/NullTest.php
new file mode 100644
index 0000000..1130d02
--- /dev/null
+++ b/tests/unit/Phql/Select/NullTest.php
@@ -0,0 +1,93 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class NullTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereIsNotNull(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_title IS NOT NULL";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::ISNOTNULL->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereIsNull(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_title IS NULL";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::ISNULL->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/OperatorsTest.php b/tests/unit/Phql/Select/OperatorsTest.php
new file mode 100644
index 0000000..75727de
--- /dev/null
+++ b/tests/unit/Phql/Select/OperatorsTest.php
@@ -0,0 +1,327 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class OperatorsTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectAddition(): void
+ {
+ $source = "SELECT inv_total + 10 FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::ADD->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '10',
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectDivision(): void
+ {
+ $source = "SELECT inv_total / 2 FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::DIV->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '2',
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectModulo(): void
+ {
+ $source = "SELECT inv_total % 3 FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::MOD->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '3',
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectMultiplication(): void
+ {
+ $source = "SELECT inv_total * 1.1 FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::MUL->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '1.1',
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectMultiplicationAliasAs(): void
+ {
+ $source = "SELECT inv_id, inv_total * 1.1 AS total_with_tax FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::MUL->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '1.1',
+ ],
+ ],
+ 'alias' => 'total_with_tax',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectSelectAdditionMultiplicationAliasAs(): void
+ {
+ $source = "SELECT inv_id, (inv_total + 5) * 2 AS adjusted FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::MUL->value,
+ 'left' => [
+ 'type' => Opcode::ENCLOSED->value,
+ 'left' => [
+ 'type' => Opcode::ADD->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '5',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '2',
+ ],
+ ],
+ 'alias' => 'adjusted',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectSubtraction(): void
+ {
+ $source = "SELECT inv_total - 5 FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::SUB->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '5',
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/OrderByTest.php b/tests/unit/Phql/Select/OrderByTest.php
new file mode 100644
index 0000000..0953261
--- /dev/null
+++ b/tests/unit/Phql/Select/OrderByTest.php
@@ -0,0 +1,288 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class OrderByTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlSelectOrderByAggregate(): void
+ {
+ $source = "SELECT inv_cst_id, COUNT(*) FROM Invoices "
+ . "GROUP BY inv_cst_id ORDER BY COUNT(*) DESC";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'orderBy' => [
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ ],
+ 'sort' => 328,
+ ],
+ 'groupBy' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectOrderByDateDescIntAsc(): void
+ {
+ $source = "SELECT * FROM Invoices ORDER BY inv_created_at DESC, inv_id ASC";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'orderBy' => [
+ 0 => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_created_at',
+ ],
+ 'sort' => 328,
+ ],
+ 1 => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'sort' => 327,
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectOrderByFloatDescStringAscIntAsc(): void
+ {
+ $source = "SELECT * FROM Invoices "
+ . "ORDER BY inv_total DESC, inv_title ASC, inv_id ASC";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'orderBy' => [
+ 0 => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'sort' => 328,
+ ],
+ 1 => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 'sort' => 327,
+ ],
+ 2 => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'sort' => 327,
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectOrderByInt(): void
+ {
+ $source = "SELECT * FROM Invoices ORDER BY inv_id";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'orderBy' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectOrderByIntAsc(): void
+ {
+ $source = "SELECT * FROM Invoices ORDER BY inv_id ASC";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'orderBy' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'sort' => 327,
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectOrderByIntDesc(): void
+ {
+ $source = "SELECT * FROM Invoices ORDER BY inv_id DESC";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'orderBy' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'sort' => 328,
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/QualifiedNamesTest.php b/tests/unit/Phql/Select/QualifiedNamesTest.php
new file mode 100644
index 0000000..b0deabc
--- /dev/null
+++ b/tests/unit/Phql/Select/QualifiedNamesTest.php
@@ -0,0 +1,125 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class QualifiedNamesTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectModelNamespace(): void
+ {
+ $source = "SELECT * FROM App\Models\Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'App\\Models\\Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectModelNamespaceAliasAs(): void
+ {
+ $source = "SELECT i.inv_id FROM App\Models\Invoices AS i";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'App\\Models\\Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectModelNamespaceWhereInt(): void
+ {
+ $source = "SELECT * FROM App\Models\Invoices WHERE inv_id = 1";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'App\\Models\\Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/ScalarTest.php b/tests/unit/Phql/Select/ScalarTest.php
new file mode 100644
index 0000000..267d2f3
--- /dev/null
+++ b/tests/unit/Phql/Select/ScalarTest.php
@@ -0,0 +1,521 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class ScalarTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectAbs(): void
+ {
+ $source = "SELECT ABS(inv_total) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'ABS',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectCoalesce(): void
+ {
+ $source = "SELECT COALESCE(inv_title, 'N/A') FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COALESCE',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 1 => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'N/A',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectConcat(): void
+ {
+ $source = "SELECT CONCAT(inv_title, ' - paid') FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'CONCAT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 1 => [
+ 'type' => Opcode::STRING->value,
+ 'value' => ' - paid',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectIfnull(): void
+ {
+ $source = "SELECT IFNULL(inv_title, 'N/A') FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'IFNULL',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 1 => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'N/A',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLength(): void
+ {
+ $source = "SELECT LENGTH(inv_title) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'LENGTH',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectLower(): void
+ {
+ $source = "SELECT LOWER(inv_title) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'LOWER',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectMonthCount(): void
+ {
+ $source = "SELECT MONTH(inv_created_at), COUNT(*) FROM Invoices GROUP BY MONTH(inv_created_at)";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'MONTH',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_created_at',
+ ],
+ ],
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'groupBy' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'MONTH',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_created_at',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectNow(): void
+ {
+ $source = "SELECT NOW() FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'NOW',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectRound(): void
+ {
+ $source = "SELECT ROUND(inv_total, 2) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'ROUND',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 1 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '2',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectTrim(): void
+ {
+ $source = "SELECT TRIM(inv_title) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'TRIM',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectUpper(): void
+ {
+ $source = "SELECT UPPER(inv_title) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'UPPER',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectYear(): void
+ {
+ $source = "SELECT YEAR(inv_created_at) FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'YEAR',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_created_at',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/SubqueriesTest.php b/tests/unit/Phql/Select/SubqueriesTest.php
new file mode 100644
index 0000000..7724f34
--- /dev/null
+++ b/tests/unit/Phql/Select/SubqueriesTest.php
@@ -0,0 +1,475 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class SubqueriesTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectFieldSubquery(): void
+ {
+ $source = "SELECT i.inv_id, "
+ . "(SELECT COUNT(*) "
+ . "FROM Invoices "
+ . "WHERE inv_cst_id = i.inv_cst_id) AS cst_count "
+ . "FROM Invoices i";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_id',
+ ],
+ ],
+ 1 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::SUBQUERY->value,
+ 'left' => [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'COUNT',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ ],
+ ],
+ ],
+ 'alias' => 'cst_count',
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereEqualsSubquery(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE inv_total = (SELECT MAX(inv_total) FROM Invoices)";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::SUBQUERY->value,
+ 'left' => [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'MAX',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereExistsSubquery(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE EXISTS "
+ . "(SELECT id FROM Customers WHERE id = Invoices.inv_cst_id)";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EXISTS->value,
+ 'right' => [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'id',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'id',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'Invoices',
+ 'name' => 'inv_cst_id',
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlSelectWhereInNestedSubquery(): void
+ {
+ $source = "SELECT * FROM Invoices "
+ . "WHERE inv_cst_id IN "
+ . "(SELECT id FROM Customers "
+ . "WHERE id IN (SELECT cst_id FROM Orders WHERE status = 1))";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::IN->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'id',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::IN->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'id',
+ ],
+ 'right' => [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'cst_id',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Orders',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'status',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereInSubquery(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE inv_cst_id IN "
+ . "(SELECT id FROM Customers WHERE status = 1)";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::IN->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'id',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'status',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereNotInSubquery(): void
+ {
+ $source = "SELECT * "
+ . "FROM Invoices "
+ . "WHERE inv_cst_id NOT IN "
+ . "(SELECT id FROM Customers WHERE status = 0)";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::NOTIN->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'id',
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Customers',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'status',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/UnaryMinusTest.php b/tests/unit/Phql/Select/UnaryMinusTest.php
new file mode 100644
index 0000000..f08fb3b
--- /dev/null
+++ b/tests/unit/Phql/Select/UnaryMinusTest.php
@@ -0,0 +1,100 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class UnaryMinusTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectUnaryMinusField(): void
+ {
+ $source = "SELECT -inv_total FROM Invoices";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::EXPR->value,
+ 'column' => [
+ 'type' => Opcode::MINUS->value,
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectUnaryMinusWhere(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_total > -1";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::GREATER->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::MINUS->value,
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/WhereInTest.php b/tests/unit/Phql/Select/WhereInTest.php
new file mode 100644
index 0000000..80cd736
--- /dev/null
+++ b/tests/unit/Phql/Select/WhereInTest.php
@@ -0,0 +1,117 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class WhereInTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereIn(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_status_flag IN (0, 1, 2)";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::IN->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 0 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ 1 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 2 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '2',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereNotIn(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_status_flag NOT IN (0, 1)";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::NOTIN->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 0 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ 1 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/WhereLogicalTest.php b/tests/unit/Phql/Select/WhereLogicalTest.php
new file mode 100644
index 0000000..34f6da5
--- /dev/null
+++ b/tests/unit/Phql/Select/WhereLogicalTest.php
@@ -0,0 +1,311 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class WhereLogicalTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereAnd(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_status_flag = 1 AND inv_total > 0";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::GREATER->value,
+ 'left' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereAndAnd(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_cst_id = 1 AND inv_status_flag = 1 AND inv_total > 0";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::GREATER->value,
+ 'left' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereNot(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE NOT inv_status_flag = 0";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::NOT->value,
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereOr(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_status_flag = 0 OR inv_status_flag = 1";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::OR->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereParenthesesOrAnd(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE (inv_status_flag = 1 OR inv_status_flag = 2) AND inv_total > 0";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::GREATER->value,
+ 'left' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::ENCLOSED->value,
+ 'left' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::OR->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '2',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/WherePlaceholdersTest.php b/tests/unit/Phql/Select/WherePlaceholdersTest.php
new file mode 100644
index 0000000..577d7ff
--- /dev/null
+++ b/tests/unit/Phql/Select/WherePlaceholdersTest.php
@@ -0,0 +1,303 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class WherePlaceholdersTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWherePlaceholderBrackets(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_id = {id}";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::BPLACEHOLDER->value,
+ 'value' => 'id',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWherePlaceholderBracketsAnd(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_cst_id = {custId} AND inv_total > {minTotal}";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::GREATER->value,
+ 'left' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::BPLACEHOLDER->value,
+ 'value' => 'custId',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::BPLACEHOLDER->value,
+ 'value' => 'minTotal',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWherePlaceholderNum(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_id = ?0";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::NPLACEHOLDER->value,
+ 'value' => '?0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWherePlaceholderNumAnd(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_id = ?1 AND inv_status_flag = ?2";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::NPLACEHOLDER->value,
+ 'value' => '?1',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::NPLACEHOLDER->value,
+ 'value' => '?2',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWherePlaceholderString(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_title = :title:";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 'right' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'title',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWherePlaceholderStringAnd(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_cst_id = :custId: AND inv_status_flag = :status:";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'custId',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'status',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/WhereTest.php b/tests/unit/Phql/Select/WhereTest.php
new file mode 100644
index 0000000..5d7686c
--- /dev/null
+++ b/tests/unit/Phql/Select/WhereTest.php
@@ -0,0 +1,433 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class WhereTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereEqInt(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_id = 1";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereEqString(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_title = 'test invoice'";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'test invoice',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlSelectWhereFuncLeft(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE UPPER(inv_title) = 'TEST INVOICE'";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'UPPER',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'TEST INVOICE',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlSelectWhereFuncLeftPlaceholder(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE UPPER(inv_title) = :title:";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'UPPER',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'title',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereGtFloat(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_total > 100.00";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::GREATER->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '100.00',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereGteFloat(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_total >= 100.00";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::GREATEREQUAL->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '100.00',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereLtFloat(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_total < 100.00";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::LESS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '100.00',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereLteFloat(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_total <= 100.00";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::LESSEQUAL->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '100.00',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereNeqInt(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_id != 1";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::NOTEQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWhereNotInt(): void
+ {
+ $source = "SELECT * FROM Invoices WHERE inv_id <> 1";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::NOTEQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Select/WithTest.php b/tests/unit/Phql/Select/WithTest.php
new file mode 100644
index 0000000..c559d75
--- /dev/null
+++ b/tests/unit/Phql/Select/WithTest.php
@@ -0,0 +1,95 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Select;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class WithTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWithCache(): void
+ {
+ $source = "SELECT * FROM Invoices AS i WITH cache";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ 'with' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'cache',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlSelectWithCacheShared(): void
+ {
+ $source = "SELECT * FROM Invoices AS i WITH (cache, shared)";
+ $expected = [
+ 'type' => Opcode::SELECT->value,
+ 'select' => [
+ 'columns' => [
+ 0 => [
+ 'type' => Opcode::STARALL->value,
+ ],
+ ],
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ 'with' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'cache',
+ ],
+ 1 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'shared',
+ ],
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Phql/Update/CombinationTest.php b/tests/unit/Phql/Update/CombinationTest.php
new file mode 100644
index 0000000..818fecd
--- /dev/null
+++ b/tests/unit/Phql/Update/CombinationTest.php
@@ -0,0 +1,667 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Phalcon\Phql\Tests\Unit\Phql\Update;
+
+use Phalcon\Phql\Parser;
+use Phalcon\Phql\Scanner\Opcode;
+use Phalcon\Phql\Tests\AbstractUnitTestCase;
+
+final class CombinationTest extends AbstractUnitTestCase
+{
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlUpdate(): void
+ {
+ $source = "UPDATE Invoices " . "SET inv_status_flag = 1";
+ $expected = [
+ 'type' => Opcode::UPDATE->value,
+ 'update' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 'values' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'expr' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlUpdateAliasNumWhereNum(): void
+ {
+ $source = "UPDATE Invoices AS i " . "SET i.inv_status_flag = 1 " . "WHERE i.inv_cst_id = 1";
+ $expected = [
+ 'type' => Opcode::UPDATE->value,
+ 'update' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ 'alias' => 'i',
+ ],
+ 'values' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_status_flag',
+ ],
+ 'expr' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'domain' => 'i',
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlUpdateCalculatedWhereNumZero(): void
+ {
+ $source = "UPDATE Invoices " . "SET inv_total = inv_total * 1.1 " . "WHERE inv_status_flag = 0";
+ $expected = [
+ 'type' => Opcode::UPDATE->value,
+ 'update' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 'values' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'expr' => [
+ 'type' => Opcode::MUL->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'right' => [
+ 'type' => Opcode::DOUBLE->value,
+ 'value' => '1.1',
+ ],
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlUpdateNullWhereNum(): void
+ {
+ $source = "UPDATE Invoices " . "SET inv_total = NULL " . "WHERE inv_status_flag = 0";
+ $expected = [
+ 'type' => Opcode::UPDATE->value,
+ 'update' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 'values' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'expr' => [
+ 'type' => Opcode::NULL->value,
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlUpdateNumLimit(): void
+ {
+ $source = "UPDATE Invoices " . "SET inv_status_flag = 0 " . "LIMIT 10";
+ $expected = [
+ 'type' => Opcode::UPDATE->value,
+ 'update' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 'values' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'expr' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ],
+ 'limit' => [
+ 'number' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '10',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlUpdateNumPlaceholderWhereNumPlaceholder(): void
+ {
+ $source = "UPDATE Invoices " . "SET inv_status_flag = ?0, inv_total = ?1 " . "WHERE inv_id = ?2";
+ $expected = [
+ 'type' => Opcode::UPDATE->value,
+ 'update' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 'values' => [
+ 0 => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'expr' => [
+ 'type' => Opcode::NPLACEHOLDER->value,
+ 'value' => '?0',
+ ],
+ ],
+ 1 => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ 'expr' => [
+ 'type' => Opcode::NPLACEHOLDER->value,
+ 'value' => '?1',
+ ],
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::NPLACEHOLDER->value,
+ 'value' => '?2',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlUpdateNumStringWhereEqNum(): void
+ {
+ $source = "UPDATE Invoices " . "SET inv_status_flag = 1, inv_title = 'Updated' " . "WHERE inv_id = 1";
+ $expected = [
+ 'type' => Opcode::UPDATE->value,
+ 'update' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 'values' => [
+ 0 => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'expr' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ 1 => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 'expr' => [
+ 'type' => Opcode::STRING->value,
+ 'value' => 'Updated',
+ ],
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlUpdateNumWhereEqNum(): void
+ {
+ $source = "UPDATE Invoices SET inv_status_flag = 1 WHERE inv_id = 1";
+ $expected = [
+ 'type' => Opcode::UPDATE->value,
+ 'update' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 'values' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'expr' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlUpdateNumWhereIn(): void
+ {
+ $source = "UPDATE Invoices " . "SET inv_status_flag = 1 " . "WHERE inv_id IN (1, 2, 3)";
+ $expected = [
+ 'type' => Opcode::UPDATE->value,
+ 'update' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 'values' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'expr' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::IN->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 0 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 1 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '2',
+ ],
+ 2 => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '3',
+ ],
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlUpdatePlaceholderWherePlaceholder(): void
+ {
+ $source = "UPDATE Invoices " . "SET inv_title = :title: " . "WHERE inv_id = :id:";
+ $expected = [
+ 'type' => Opcode::UPDATE->value,
+ 'update' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 'values' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 'expr' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'title',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::SPLACEHOLDER->value,
+ 'value' => 'id',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlUpdateTrueWhereNum(): void
+ {
+ $source = "UPDATE Invoices " . "SET inv_status_flag = TRUE " . "WHERE inv_id = 1";
+ $expected = [
+ 'type' => Opcode::UPDATE->value,
+ 'update' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 'values' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'expr' => [
+ 'type' => Opcode::TRUE->value,
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-09
+ */
+ public function testMvcModelQueryPhqlUpdateUpperWhereNum(): void
+ {
+ $source = "UPDATE Invoices " . "SET inv_title = UPPER(inv_title) " . "WHERE inv_status_flag = 1";
+ $expected = [
+ 'type' => Opcode::UPDATE->value,
+ 'update' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 'values' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ 'expr' => [
+ 'type' => Opcode::FCALL->value,
+ 'name' => 'UPPER',
+ 'arguments' => [
+ 0 => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_title',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+
+ /**
+ * @return void
+ *
+ * @author Phalcon Team
+ * @since 2026-04-10
+ */
+ public function testMvcModelQueryPhqlUpdateWhereMultipleAndConditions(): void
+ {
+ $source = "UPDATE Invoices SET inv_status_flag = 1 "
+ . "WHERE inv_cst_id = 1 AND inv_total > 100 AND inv_status_flag = 0";
+ $expected = [
+ 'type' => Opcode::UPDATE->value,
+ 'update' => [
+ 'tables' => [
+ 'qualifiedName' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'Invoices',
+ ],
+ ],
+ 'values' => [
+ 'column' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ 'expr' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ ],
+ ],
+ 'where' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::GREATER->value,
+ 'left' => [
+ 'type' => Opcode::EQUALS->value,
+ 'left' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_cst_id',
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '1',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_total',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::AND->value,
+ 'left' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '100',
+ ],
+ 'right' => [
+ 'type' => Opcode::QUALIFIED->value,
+ 'name' => 'inv_status_flag',
+ ],
+ ],
+ ],
+ 'right' => [
+ 'type' => Opcode::INTEGER->value,
+ 'value' => '0',
+ ],
+ ],
+ ];
+ $actual = (new Parser())->parse($source);
+ $this->assertSame($expected, $actual);
+ }
+}
diff --git a/tests/unit/Scanner/OpcodeTest.php b/tests/unit/Scanner/OpcodeTest.php
new file mode 100644
index 0000000..1ad3ab7
--- /dev/null
+++ b/tests/unit/Scanner/OpcodeTest.php
@@ -0,0 +1,96 @@
+assertSame(Opcode::SELECT, Opcode::from(309));
+ $this->assertSame(Opcode::IDENTIFIER, Opcode::from(265));
+ }
+
+ public function testKeyOpcodeValues(): void
+ {
+ $this->assertSame(43, Opcode::ADD->value);
+ $this->assertSame(257, Opcode::IGNORE->value);
+ $this->assertSame(258, Opcode::INTEGER->value);
+ $this->assertSame(259, Opcode::DOUBLE->value);
+ $this->assertSame(260, Opcode::STRING->value);
+ $this->assertSame(265, Opcode::IDENTIFIER->value);
+ $this->assertSame(300, Opcode::UPDATE->value);
+ $this->assertSame(302, Opcode::WHERE->value);
+ $this->assertSame(303, Opcode::DELETE->value);
+ $this->assertSame(304, Opcode::FROM->value);
+ $this->assertSame(306, Opcode::INSERT->value);
+ $this->assertSame(309, Opcode::SELECT->value);
+ $this->assertSame(322, Opcode::NULL->value);
+ $this->assertSame(334, Opcode::TRUE->value);
+ $this->assertSame(335, Opcode::FALSE->value);
+ $this->assertSame(350, Opcode::FCALL->value);
+ $this->assertSame(352, Opcode::STARALL->value);
+ $this->assertSame(353, Opcode::DOMAINALL->value);
+ $this->assertSame(354, Opcode::EXPR->value);
+ $this->assertSame(355, Opcode::QUALIFIED->value);
+ }
+
+ public function testLabelFallbackToName(): void
+ {
+ $this->assertSame('SELECT', Opcode::SELECT->label());
+ $this->assertSame('FROM', Opcode::FROM->label());
+ $this->assertSame('WHERE', Opcode::WHERE->label());
+ $this->assertSame('IDENTIFIER', Opcode::IDENTIFIER->label());
+ $this->assertSame('INTEGER', Opcode::INTEGER->label());
+ }
+
+ public function testLabelOperators(): void
+ {
+ $this->assertSame('+', Opcode::ADD->label());
+ $this->assertSame('-', Opcode::SUB->label());
+ $this->assertSame('*', Opcode::MUL->label());
+ $this->assertSame('/', Opcode::DIV->label());
+ $this->assertSame('%', Opcode::MOD->label());
+ $this->assertSame('=', Opcode::EQUALS->label());
+ $this->assertSame('<', Opcode::LESS->label());
+ $this->assertSame('>', Opcode::GREATER->label());
+ $this->assertSame('<=', Opcode::LESSEQUAL->label());
+ $this->assertSame('>=', Opcode::GREATEREQUAL->label());
+ $this->assertSame('!', Opcode::NOT->label());
+ $this->assertSame('<>', Opcode::NOTEQUALS->label());
+ $this->assertSame('&', Opcode::BITWISE_AND->label());
+ $this->assertSame('|', Opcode::BITWISE_OR->label());
+ $this->assertSame('~', Opcode::BITWISE_NOT->label());
+ $this->assertSame('^', Opcode::BITWISE_XOR->label());
+ $this->assertSame(':', Opcode::COLON->label());
+ $this->assertSame('.', Opcode::DOT->label());
+ $this->assertSame('(', Opcode::PARENTHESES_OPEN->label());
+ $this->assertSame(')', Opcode::PARENTHESES_CLOSE->label());
+ }
+
+ public function testSingleCharOpcodeValues(): void
+ {
+ $this->assertSame(43, Opcode::ADD->value); // ord('+')
+ $this->assertSame(45, Opcode::SUB->value); // ord('-')
+ $this->assertSame(42, Opcode::MUL->value); // ord('*')
+ $this->assertSame(47, Opcode::DIV->value); // ord('/')
+ $this->assertSame(37, Opcode::MOD->value); // ord('%')
+ $this->assertSame(61, Opcode::EQUALS->value); // ord('=')
+ $this->assertSame(60, Opcode::LESS->value); // ord('<')
+ $this->assertSame(62, Opcode::GREATER->value); // ord('>')
+ $this->assertSame(33, Opcode::NOT->value); // ord('!')
+ $this->assertSame(46, Opcode::DOT->value); // ord('.')
+ $this->assertSame(58, Opcode::COLON->value); // ord(':')
+ $this->assertSame(40, Opcode::PARENTHESES_OPEN->value); // ord('(')
+ $this->assertSame(41, Opcode::PARENTHESES_CLOSE->value); // ord(')')
+ }
+
+ public function testTryFromUnknown(): void
+ {
+ $this->assertNull(Opcode::tryFrom(9999));
+ }
+}
diff --git a/tests/unit/Scanner/ScannerStatusTest.php b/tests/unit/Scanner/ScannerStatusTest.php
new file mode 100644
index 0000000..39c20a1
--- /dev/null
+++ b/tests/unit/Scanner/ScannerStatusTest.php
@@ -0,0 +1,32 @@
+assertSame(-1, ScannerStatus::EOF->value);
+ $this->assertSame(-2, ScannerStatus::ERR->value);
+ $this->assertSame(-3, ScannerStatus::IMPOSSIBLE->value);
+ $this->assertSame(0, ScannerStatus::OK->value);
+ }
+
+ public function testFromInt(): void
+ {
+ $this->assertSame(ScannerStatus::EOF, ScannerStatus::from(-1));
+ $this->assertSame(ScannerStatus::ERR, ScannerStatus::from(-2));
+ $this->assertSame(ScannerStatus::IMPOSSIBLE, ScannerStatus::from(-3));
+ $this->assertSame(ScannerStatus::OK, ScannerStatus::from(0));
+ }
+
+ public function testTryFromUnknown(): void
+ {
+ $this->assertNull(ScannerStatus::tryFrom(99));
+ }
+}
diff --git a/tests/unit/Scanner/ScannerTest.php b/tests/unit/Scanner/ScannerTest.php
new file mode 100644
index 0000000..ea18a80
--- /dev/null
+++ b/tests/unit/Scanner/ScannerTest.php
@@ -0,0 +1,250 @@
+scanAll('& && | || ~ ^ !! @@ @>');
+ $this->assertSame([
+ Opcode::BITWISE_AND,
+ Opcode::TS_AND,
+ Opcode::BITWISE_OR,
+ Opcode::TS_OR,
+ Opcode::BITWISE_NOT,
+ Opcode::BITWISE_XOR,
+ Opcode::TS_NEGATE,
+ Opcode::TS_MATCHES,
+ Opcode::TS_CONTAINS_ANOTHER,
+ ], $opcodes);
+ }
+
+ public function testBracketPlaceholder(): void
+ {
+ $state = new State('{id}');
+ $scanner = new Scanner($state);
+
+ $scanner->scanForToken();
+ $token = $scanner->getToken();
+
+ $this->assertSame(Opcode::BPLACEHOLDER, $token->opcode);
+ $this->assertSame('id', $token->value);
+ }
+
+ public function testComparisonOperators(): void
+ {
+ $opcodes = $this->scanAll('= != ! < <= > >=');
+ $this->assertSame([
+ Opcode::EQUALS,
+ Opcode::NOTEQUALS,
+ Opcode::NOT,
+ Opcode::LESS,
+ Opcode::LESSEQUAL,
+ Opcode::GREATER,
+ Opcode::GREATEREQUAL,
+ ], $opcodes);
+ }
+
+ public function testDoubleLiteral(): void
+ {
+ $state = new State('3.14');
+ $scanner = new Scanner($state);
+
+ $scanner->scanForToken();
+ $token = $scanner->getToken();
+
+ $this->assertSame(Opcode::DOUBLE, $token->opcode);
+ $this->assertSame('3.14', $token->value);
+ }
+
+ public function testDoubleQuotedString(): void
+ {
+ $state = new State('"world"');
+ $scanner = new Scanner($state);
+
+ $scanner->scanForToken();
+ $token = $scanner->getToken();
+
+ $this->assertSame(Opcode::STRING, $token->opcode);
+ $this->assertSame('world', $token->value);
+ }
+
+ public function testEmptyInputReturnsEof(): void
+ {
+ $state = new State('');
+ $scanner = new Scanner($state);
+
+ $this->assertSame(ScannerStatus::EOF, $scanner->scanForToken());
+ }
+
+ public function testFromKeyword(): void
+ {
+ $opcodes = $this->scanAll('FROM');
+ $this->assertSame([Opcode::FROM], $opcodes);
+ }
+
+ public function testHexIntegerLiteral(): void
+ {
+ $state = new State('0xFF');
+ $scanner = new Scanner($state);
+
+ $scanner->scanForToken();
+ $token = $scanner->getToken();
+
+ $this->assertSame(Opcode::HINTEGER, $token->opcode);
+ }
+
+ public function testIdentifier(): void
+ {
+ $state = new State('Invoices');
+ $scanner = new Scanner($state);
+
+ $scanner->scanForToken();
+ $token = $scanner->getToken();
+
+ $this->assertSame(Opcode::IDENTIFIER, $token->opcode);
+ $this->assertSame('Invoices', $token->value);
+ }
+
+ public function testIntegerLiteral(): void
+ {
+ $state = new State('42');
+ $scanner = new Scanner($state);
+
+ $scanner->scanForToken();
+ $token = $scanner->getToken();
+
+ $this->assertSame(Opcode::INTEGER, $token->opcode);
+ $this->assertSame('42', $token->value);
+ }
+
+ public function testNamedPlaceholder(): void
+ {
+ $state = new State(':id:');
+ $scanner = new Scanner($state);
+
+ $scanner->scanForToken();
+ $token = $scanner->getToken();
+
+ $this->assertSame(Opcode::SPLACEHOLDER, $token->opcode);
+ $this->assertSame('id', $token->value);
+ }
+
+ public function testNoLegacyRetcodeConstants(): void
+ {
+ $this->assertFalse(defined('Phalcon\Phql\Scanner\Scanner::PHQL_SCANNER_RETCODE_EOF'));
+ $this->assertFalse(defined('Phalcon\Phql\Scanner\Scanner::PHQL_SCANNER_RETCODE_ERR'));
+ $this->assertFalse(defined('Phalcon\Phql\Scanner\Scanner::PHQL_SCANNER_RETCODE_IMPOSSIBLE'));
+ }
+
+ public function testNumericPlaceholder(): void
+ {
+ $state = new State('?0');
+ $scanner = new Scanner($state);
+
+ $scanner->scanForToken();
+ $token = $scanner->getToken();
+
+ $this->assertSame(Opcode::NPLACEHOLDER, $token->opcode);
+ }
+
+ public function testOperators(): void
+ {
+ $opcodes = $this->scanAll('+ - * / %');
+ $this->assertSame([
+ Opcode::ADD,
+ Opcode::SUB,
+ Opcode::MUL,
+ Opcode::DIV,
+ Opcode::MOD,
+ ], $opcodes);
+ }
+
+ public function testReturnTypeIsScannerStatus(): void
+ {
+ $state = new State('SELECT');
+ $scanner = new Scanner($state);
+ $result = $scanner->scanForToken();
+
+ $this->assertInstanceOf(ScannerStatus::class, $result);
+ }
+
+ public function testSelectFromSequence(): void
+ {
+ $opcodes = $this->scanAll('SELECT * FROM Invoices');
+ $this->assertSame([
+ Opcode::SELECT,
+ Opcode::MUL,
+ Opcode::FROM,
+ Opcode::IDENTIFIER,
+ ], $opcodes);
+ }
+
+ public function testSelectKeyword(): void
+ {
+ $opcodes = $this->scanAll('SELECT');
+ $this->assertSame([Opcode::SELECT], $opcodes);
+ }
+
+ public function testSingleQuotedString(): void
+ {
+ $state = new State("'hello'");
+ $scanner = new Scanner($state);
+
+ $scanner->scanForToken();
+ $token = $scanner->getToken();
+
+ $this->assertSame(Opcode::STRING, $token->opcode);
+ $this->assertSame('hello', $token->value);
+ }
+
+ public function testUnknownCharacterReturnsErr(): void
+ {
+ $state = new State('#');
+ $scanner = new Scanner($state);
+ $result = $scanner->scanForToken();
+
+ // '#' is not a valid PHQL token — scanner returns ERR
+ $this->assertSame(ScannerStatus::ERR, $result);
+ }
+
+ public function testWhereKeyword(): void
+ {
+ $opcodes = $this->scanAll('WHERE');
+ $this->assertSame([Opcode::WHERE], $opcodes);
+ }
+
+ public function testWhitespaceProducesIgnore(): void
+ {
+ $state = new State(' ');
+ $scanner = new Scanner($state);
+
+ $scanner->scanForToken();
+ $this->assertSame(Opcode::IGNORE, $scanner->getToken()->opcode);
+ }
+
+ private function scanAll(string $input): array
+ {
+ $state = new State($input);
+ $scanner = new Scanner($state);
+ $opcodes = [];
+
+ while (($scanner->scanForToken()) === ScannerStatus::OK) {
+ $token = $scanner->getToken();
+ if ($token->opcode !== Opcode::IGNORE) {
+ $opcodes[] = $token->opcode;
+ }
+ }
+
+ return $opcodes;
+ }
+}
diff --git a/tests/unit/Scanner/StateTest.php b/tests/unit/Scanner/StateTest.php
new file mode 100644
index 0000000..47310c2
--- /dev/null
+++ b/tests/unit/Scanner/StateTest.php
@@ -0,0 +1,73 @@
+setActiveToken(Opcode::SELECT);
+ $state->setActiveToken(null);
+
+ $this->assertNull($state->getActiveToken());
+ }
+
+ public function testConstruction(): void
+ {
+ $state = new State('SELECT');
+
+ $this->assertSame(6, $state->getBufferLength());
+ $this->assertSame(6, $state->getStartLength());
+ $this->assertSame(0, $state->getCursor());
+ $this->assertSame('S', $state->getStart());
+ $this->assertNull($state->getActiveToken());
+ }
+
+ public function testEmptyBuffer(): void
+ {
+ $state = new State('');
+
+ $this->assertSame(0, $state->getBufferLength());
+ $this->assertNull($state->getStart());
+ }
+
+ public function testIncrementStart(): void
+ {
+ $state = new State('SELECT');
+ $state->incrementStart(1);
+
+ $this->assertSame(1, $state->getCursor());
+ $this->assertSame('E', $state->getStart());
+ }
+
+ public function testNoSetEndMethod(): void
+ {
+ $state = new State('SELECT');
+ /** @phpstan-ignore function.impossibleType */
+ $this->assertFalse(method_exists($state, 'setEnd'));
+ }
+
+ public function testSetActiveToken(): void
+ {
+ $state = new State('SELECT');
+ $state->setActiveToken(Opcode::SELECT);
+
+ $this->assertSame(Opcode::SELECT, $state->getActiveToken());
+ }
+
+ public function testSetCursor(): void
+ {
+ $state = new State('SELECT');
+ $state->setCursor(3);
+
+ $this->assertSame(3, $state->getCursor());
+ $this->assertSame('E', $state->getStart());
+ }
+}
diff --git a/tests/unit/Scanner/TokenTest.php b/tests/unit/Scanner/TokenTest.php
new file mode 100644
index 0000000..eba7a82
--- /dev/null
+++ b/tests/unit/Scanner/TokenTest.php
@@ -0,0 +1,48 @@
+assertSame(Opcode::SELECT, $token->opcode);
+ $this->assertNull($token->value);
+ $this->assertSame(6, $token->length);
+ }
+
+ public function testConstructionWithValue(): void
+ {
+ $token = new Token(Opcode::IDENTIFIER, 'Invoices', 8);
+
+ $this->assertSame(Opcode::IDENTIFIER, $token->opcode);
+ $this->assertSame('Invoices', $token->value);
+ $this->assertSame(8, $token->length);
+ }
+
+ public function testDefaultConstruction(): void
+ {
+ $token = new Token();
+
+ $this->assertNull($token->opcode);
+ $this->assertNull($token->value);
+ $this->assertSame(0, $token->length);
+ }
+
+ public function testIsReadonly(): void
+ {
+ $token = new Token(Opcode::SELECT, null, 6);
+
+ $this->expectException(\Error::class);
+ // @phpstan-ignore-next-line
+ $token->opcode = Opcode::FROM;
+ }
+}