feat: add filters to mainpage

This commit is contained in:
2025-10-03 00:26:13 +03:00
parent 023acee68f
commit 1e2a9bc705
168 changed files with 5367 additions and 662 deletions

View File

@@ -30,6 +30,8 @@
"require-dev": {
"roave/security-advisories": "dev-latest",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^9.6"
"phpunit/phpunit": "^9.6",
"doctrine/sql-formatter": "^1.3",
"mockery/mockery": "^1.6"
}
}

View File

@@ -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": "7800f8b8834540a6bcbca3336bef7949",
"content-hash": "e8ed2d3d0e11eac86a27bb2972b115cd",
"packages": [
{
"name": "graham-campbell/result-type",
@@ -1757,6 +1757,195 @@
],
"time": "2022-12-30T00:15:36+00:00"
},
{
"name": "doctrine/sql-formatter",
"version": "1.3.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/sql-formatter.git",
"reference": "3447381095d32a171fe3a58323749f44dbb5ac7d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/3447381095d32a171fe3a58323749f44dbb5ac7d",
"reference": "3447381095d32a171fe3a58323749f44dbb5ac7d",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"doctrine/coding-standard": "^9.0",
"phpstan/phpstan": "^1.0",
"phpunit/phpunit": "^8.5 || ^9.6",
"vimeo/psalm": "^4.11"
},
"bin": [
"bin/sql-formatter"
],
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\SqlFormatter\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jeremy Dorn",
"email": "jeremy@jeremydorn.com",
"homepage": "https://jeremydorn.com/"
}
],
"description": "a PHP SQL highlighting library",
"homepage": "https://github.com/doctrine/sql-formatter/",
"keywords": [
"highlight",
"sql"
],
"support": {
"issues": "https://github.com/doctrine/sql-formatter/issues",
"source": "https://github.com/doctrine/sql-formatter/tree/1.3.0"
},
"time": "2024-05-06T21:49:18+00:00"
},
{
"name": "hamcrest/hamcrest-php",
"version": "v2.1.1",
"source": {
"type": "git",
"url": "https://github.com/hamcrest/hamcrest-php.git",
"reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
"reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"replace": {
"cordoval/hamcrest-php": "*",
"davedevelopment/hamcrest-php": "*",
"kodova/hamcrest-php": "*"
},
"require-dev": {
"phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0",
"phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.1-dev"
}
},
"autoload": {
"classmap": [
"hamcrest"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "This is the PHP port of Hamcrest Matchers",
"keywords": [
"test"
],
"support": {
"issues": "https://github.com/hamcrest/hamcrest-php/issues",
"source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1"
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "mockery/mockery",
"version": "1.6.12",
"source": {
"type": "git",
"url": "https://github.com/mockery/mockery.git",
"reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699",
"reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699",
"shasum": ""
},
"require": {
"hamcrest/hamcrest-php": "^2.0.1",
"lib-pcre": ">=7.0",
"php": ">=7.3"
},
"conflict": {
"phpunit/phpunit": "<8.0"
},
"require-dev": {
"phpunit/phpunit": "^8.5 || ^9.6.17",
"symplify/easy-coding-standard": "^12.1.14"
},
"type": "library",
"autoload": {
"files": [
"library/helpers.php",
"library/Mockery.php"
],
"psr-4": {
"Mockery\\": "library/Mockery"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Pádraic Brady",
"email": "padraic.brady@gmail.com",
"homepage": "https://github.com/padraic",
"role": "Author"
},
{
"name": "Dave Marshall",
"email": "dave.marshall@atstsolutions.co.uk",
"homepage": "https://davedevelopment.co.uk",
"role": "Developer"
},
{
"name": "Nathanael Esayeas",
"email": "nathanael.esayeas@protonmail.com",
"homepage": "https://github.com/ghostwriter",
"role": "Lead Developer"
}
],
"description": "Mockery is a simple yet flexible PHP mock object framework",
"homepage": "https://github.com/mockery/mockery",
"keywords": [
"BDD",
"TDD",
"library",
"mock",
"mock objects",
"mockery",
"stub",
"test",
"test double",
"testing"
],
"support": {
"docs": "https://docs.mockery.io/",
"issues": "https://github.com/mockery/mockery/issues",
"rss": "https://github.com/mockery/mockery/releases.atom",
"security": "https://github.com/mockery/mockery/security/advisories",
"source": "https://github.com/mockery/mockery"
},
"time": "2024-05-16T03:13:13+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.13.4",

View File

@@ -0,0 +1,40 @@
<?php
namespace Openguru\OpenCartFramework\CriteriaBuilder;
use InvalidArgumentException;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class CriteriaBuilder
{
private RulesRegistry $rulesRegistry;
public function __construct(RulesRegistry $rulesRegistry)
{
$this->rulesRegistry = $rulesRegistry;
}
public function apply(Builder $queryBuilder, array $query): void
{
$operand = $query['operand'] ?? 'AND';
$rules = $query['rules'] ?? [];
foreach ($rules as $ruleId => $rule) {
if (! $this->rulesRegistry->has($ruleId)) {
throw new InvalidArgumentException('Invalid rule: ' . $ruleId);
}
$className = $this->rulesRegistry->get($ruleId);
$criteria = [];
foreach ($rule['criteria'] as $name => $item) {
$criteria[$name] = new Criterion($item['type'], $item['params']);
}
/** @var BaseRule $ruleClass */
$ruleClass = new $className($ruleId, $criteria);
$ruleClass->apply($queryBuilder, $operand);
}
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Openguru\OpenCartFramework\CriteriaBuilder;
class Criterion
{
public string $type;
public ?array $params;
public function __construct(string $type, ?array $params)
{
$this->type = $type;
$this->params = $params;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Openguru\OpenCartFramework\CriteriaBuilder\Exceptions;
use RuntimeException;
class CriteriaBuilderException extends RuntimeException
{
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Openguru\OpenCartFramework\CriteriaBuilder;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use ReflectionClass;
use ReflectionProperty;
class RuleSerializer
{
public function toArray(BaseRule $rule): array
{
return $this->extractProperties($rule);
}
public function extractProperties(object $object): array
{
$reflection = new ReflectionClass($object);
$properties = $reflection->getProperties(ReflectionProperty::IS_PUBLIC);
$data = [];
foreach ($properties as $property) {
$propertyName = $property->getName();
$propertyValue = $property->getValue($object);
if (is_object($propertyValue)) {
$data[$propertyName] = $this->extractProperties($propertyValue);
} elseif (is_array($propertyValue)) {
$data[$propertyName] = $this->extractArray($propertyValue);
} else {
$data[$propertyName] = $propertyValue;
}
}
if ($reflection->hasMethod('metaAttributes')) {
$data['__meta'] = $reflection->getMethod('metaAttributes')->invoke($object);
}
return $data;
}
private function extractArray(array $array): array
{
$data = [];
foreach ($array as $key => $value) {
if (is_object($value)) {
$data[$key] = $this->extractProperties($value);
} elseif (is_array($value)) {
$data[$key] = $this->extractArray($value);
} else {
$data[$key] = $value;
}
}
return $data;
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace Openguru\OpenCartFramework\CriteriaBuilder\Rules;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
abstract class BaseRule
{
public const CRITERIA_OPTION_STRING = 'string';
public const CRITERIA_OPTION_NUMBER = 'number';
public const CRITERIA_OPTION_BOOLEAN = 'boolean';
public const CRITERIA_OPTION_PRODUCT_CATEGORIES = 'product_categories';
public const CRITERIA_OPTION_PRODUCT_MANUFACTURER = 'product_manufacturer';
public const CRITERIA_OPTION_PRODUCT_ATTRIBUTE = 'product_attribute';
public const CRITERIA_OPTION_PRODUCT_MODEL = 'product_model';
public const CRITERIA_OPTION_PRODUCT_FOR_MAIN_PAGE = 'product_for_main_page';
public const CRITERIA_OPTION_PRODUCT_OPTION = 'product_option';
public const CRITERIA_OPERATOR_CONTAINS = 'contains';
public const CRITERIA_OPERATOR_NOT_CONTAINS = 'not_contains';
public const CRITERIA_OPERATOR_GREATER_OR_EQUAL = 'greater_or_equals';
public const CRITERIA_OPERATOR_EQUALS = 'equals';
protected static $numberCompareOperators = [
'between' => 'BETWEEN',
'equals' => '=',
'greater' => '>',
'greater_or_equals' => '>=',
'less' => '<',
'less_or_equals' => '<=',
'not_equals' => '<>',
];
protected static $stringCompareOperators = [
'contains' => 'LIKE',
'not_contains' => 'NOT LIKE',
'equals' => '=',
'not_equals' => '<>',
'is_empty' => 'is_empty',
'is_not_empty' => 'is_not_empty',
];
/**
* @var string
*/
public $id;
/**
* @var array<Criterion>
*/
public $criteria;
public function __construct(
string $id,
array $criteria
) {
$this->id = $id;
$this->criteria = $criteria;
}
abstract public static function initWithDefaults(): BaseRule;
public function metaAttributes(): array
{
return [
'group' => 'other',
];
}
abstract public function apply(Builder $builder, $operand);
public function criterionStringCondition(
Builder $builder,
Criterion $criterion,
string $field,
string $operand,
string $joinAlias = ''
): void {
$operator = static::$stringCompareOperators[$criterion->params['operator']];
$keyword = $criterion->params['keyword'];
if ($operator === 'is_empty') {
if ($joinAlias) {
$builder->whereNested(function (Builder $query) use ($field, $joinAlias) {
$query->whereRaw("TRIM($field) = '' OR {$joinAlias}.product_id IS NULL");
});
} else {
$builder->whereRaw("TRIM($field) = ''");
}
return;
}
if ($operator === 'is_not_empty') {
$builder->whereRaw("TRIM($field) <> ''");
return;
}
if ($operator === 'LIKE' || $operator === 'NOT LIKE') {
$keyword = "%$keyword%";
}
$builder->where($field, $operator, $keyword, $operand);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Openguru\OpenCartFramework\CriteriaBuilder;
use InvalidArgumentException;
use Openguru\OpenCartFramework\Support\Utils;
class RulesRegistry
{
private array $items = [];
public function getRuleId(string $class): string
{
$segments = explode('\\', $class);
$lastSegment = end($segments);
if (! $lastSegment) {
throw new InvalidArgumentException("Class {$class} does not exist");
}
return 'RULE_' . implode('_', array_map('mb_strtoupper', Utils::ucsplit($lastSegment)));
}
public function has(string $ruleId): bool
{
return array_key_exists($ruleId, $this->items);
}
/**
* @param string|array $ruleId
* @param string|null $class
* @return void
*/
public function register($ruleId, string $class = null): void
{
if (is_array($ruleId)) {
foreach ($ruleId as $key => $value) {
$this->register($key, $value);
}
return;
}
if ($this->has($ruleId)) {
throw new InvalidArgumentException("Rule '$ruleId' is already registered");
}
$this->items[$ruleId] = $class;
}
public function get(string $ruleId)
{
if (! $this->has($ruleId)) {
throw new InvalidArgumentException("Rule '$ruleId' is not registered");
}
return $this->items[$ruleId];
}
public function getItems(): array
{
return $this->items;
}
}

View File

@@ -176,7 +176,13 @@ class Builder
return $this;
}
public function whereBetween(string $column, array $values, $boolean = 'and'): Builder
/**
* @param RawExpression|string $column
* @param array $values
* @param string $boolean
* @return $this
*/
public function whereBetween($column, array $values, string $boolean = 'and'): Builder
{
if (count($values) !== 2) {
throw new InvalidArgumentException('Invalid number of values provided.');

View File

@@ -102,9 +102,18 @@ abstract class Grammar
return $prefix . implode(' ', $compiledConditions);
}
private function getRawValue($value): string
{
if ($value instanceof RawExpression) {
return $value->getValue();
}
return $value;
}
public function whereBasic($condition): string
{
return $condition['column'] . ' ' . $condition['operator'] . ' ?';
return $this->getRawValue($condition['column']) . ' ' . $condition['operator'] . ' ?';
}
public function whereRaw($condition): string
@@ -114,12 +123,12 @@ abstract class Grammar
public function whereNull($condition): string
{
return $condition['column'] . ' IS NULL';
return $this->getRawValue($condition['column']) . ' IS NULL';
}
public function whereNotNull($condition): string
{
return $condition['column'] . ' IS NOT NULL';
return $this->getRawValue($condition['column']) . ' IS NOT NULL';
}
public function whereNested($condition): string
@@ -129,7 +138,7 @@ abstract class Grammar
public function whereBetween($condition): string
{
return $condition['column'] . ' BETWEEN ? AND ?';
return $this->getRawValue($condition['column']) . ' BETWEEN ? AND ?';
}
public function compileOrders(Builder $builder, array $orders): string
@@ -183,6 +192,6 @@ abstract class Grammar
$inValues = str_repeat('?, ', count($condition['value']) - 1) . '?';
$inOperator = $condition['operator'];
return $condition['column'] . " $inOperator (" . $inValues . ')';
return $this->getRawValue($condition['column']) . " $inOperator (" . $inValues . ')';
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\FacetSearch\Filters;
use InvalidArgumentException;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
class ProductAttribute extends BaseRule
{
public const NAME = 'RULE_PRODUCT_ATTRIBUTE';
public static function initWithDefaults(): BaseRule
{
return new static(static::NAME, [
'product_attribute' => new Criterion(static::CRITERIA_OPTION_PRODUCT_ATTRIBUTE, [
'attribute_id' => null,
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'keyword' => '',
'language_id' => config('language_id'),
]),
]);
}
public function apply(Builder $builder, $operand): void
{
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_ATTRIBUTE) {
$facetHash = md5(serialize($criterion));
$joinAlias = 'product_attributes_facet_' . $facetHash;
if ($builder->hasJoinAlias($joinAlias)) {
return;
}
$operator = static::$stringCompareOperators[$criterion->params['operator']];
$languageId = $criterion->params['language_id'] ?? null;
if (! $languageId) {
throw new InvalidArgumentException('language_id is required for the product attribute filter');
}
$builder->leftJoin(
db_table('product_attribute') . " AS $joinAlias",
function (JoinClause $join) use ($criterion, $joinAlias, $operator, $languageId) {
$join->on('products.product_id', '=', "$joinAlias.product_id")
->where("$joinAlias.attribute_id", '=', $criterion->params['attribute_id'])
->where("$joinAlias.language_id", '=', $languageId);
if ($operator !== 'is_empty' && $operator !== 'is_not_empty') {
$this->criterionStringCondition(
$join,
$criterion,
"$joinAlias.text",
'and'
);
}
}
);
if ($operator === 'is_empty') {
$builder->whereNull("$joinAlias.product_id", $operand);
} else {
$builder->whereNotNull("$joinAlias.product_id", $operand);
}
}
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\FacetSearch\Filters;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
class ProductCategories extends BaseRule
{
public const NAME = 'RULE_PRODUCT_CATEGORIES';
public static function initWithDefaults(): BaseRule
{
return new static(static::NAME, [
'product_category_ids' => new Criterion(static::CRITERIA_OPTION_PRODUCT_CATEGORIES, [
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'value' => [],
])
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_CATEGORIES) {
$uniqHash = md5(serialize($criterion));
$joinAlias = 'product_category_' . $uniqHash;
if ($builder->hasJoinAlias($joinAlias)) {
return;
}
$operator = $criterion->params['operator'];
$categoryIds = $criterion->params['value'];
$builder->join(
db_table('product_to_category') . " AS $joinAlias",
function (JoinClause $join) use ($joinAlias, $categoryIds) {
$join->on('products.product_id', '=', "$joinAlias.product_id");
if ($categoryIds) {
$join->whereIn("$joinAlias.category_id", $categoryIds);
}
},
'left'
);
if ($operator === 'contains' && ! $categoryIds) {
$builder->whereNull("$joinAlias.product_id", $operand);
} elseif ($operator === 'not_contains' && ! $categoryIds) {
$builder->whereNotNull("$joinAlias.product_id", $operand);
} elseif ($operator === 'contains') {
$builder->whereNotNull("$joinAlias.product_id", $operand);
} else {
$builder->whereNull("$joinAlias.product_id", $operand);
}
}
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\FacetSearch\Filters;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class ProductForMainPage extends BaseRule
{
public const NAME = 'RULE_PRODUCT_FOR_MAIN_PAGE';
public static function initWithDefaults(): BaseRule
{
return new static(static::NAME, [
'product_for_main_page' => new Criterion(static::CRITERIA_OPTION_PRODUCT_MODEL, [
'operator' => static::CRITERIA_OPERATOR_EQUALS,
'value' => false,
]),
]);
}
public function apply(Builder $builder, $operand): void
{
$criterion = $this->criteria[static::CRITERIA_OPTION_PRODUCT_FOR_MAIN_PAGE] ?? false;
if (! $criterion || $criterion->params['value'] === false) {
return;
}
$featuredProducts = config('featured_products', []);
$mainpageProducts = config('mainpage_products');
if ($mainpageProducts === 'featured' && $featuredProducts) {
$builder->whereIn('products.product_id', $featuredProducts);
return;
}
if ($mainpageProducts === 'latest') {
$builder->orders = [];
$builder->orderBy('products.date_modified', 'DESC');
return;
}
if ($mainpageProducts === 'most_viewed') {
$builder->orders = [];
$builder->orderBy('products.viewed', 'DESC');
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\FacetSearch\Filters;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class ProductManufacturer extends BaseRule
{
public const NAME = 'RULE_PRODUCT_MANUFACTURER';
public static function initWithDefaults(): BaseRule
{
return new static(static::NAME, [
'product_manufacturer_ids' => new Criterion(static::CRITERIA_OPTION_PRODUCT_MANUFACTURER, [
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'value' => [],
])
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_MANUFACTURER) {
$operator = $criterion->params['operator'];
$ids = $criterion->params['value'];
if ($ids) {
$builder->whereIn(
'products.manufacturer_id',
$ids,
$operator === static::CRITERIA_OPERATOR_NOT_CONTAINS
);
} else {
$builder->where(
'products.manufacturer_id',
'=',
0,
$operator === static::CRITERIA_OPERATOR_NOT_CONTAINS
);
}
}
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\FacetSearch\Filters;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class ProductModel extends BaseRule
{
public const NAME = 'RULE_PRODUCT_MODEL';
public static function initWithDefaults(): BaseRule
{
return new static(static::NAME, [
'product_model' => new Criterion(static::CRITERIA_OPTION_PRODUCT_MODEL, [
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'value' => [],
])
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_MODEL) {
$operator = $criterion->params['operator'];
$models = $criterion->params['value'] ?? [];
if ($models) {
$builder->whereIn(
'products.model',
$models,
$operator === static::CRITERIA_OPERATOR_NOT_CONTAINS
);
} else {
$builder->whereRaw('TRUE = FALSE');
}
}
}
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\FacetSearch\Filters;
use InvalidArgumentException;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
use Openguru\OpenCartFramework\Support\Arr;
use RuntimeException;
class ProductPrice extends BaseRule
{
public const NAME = 'RULE_PRODUCT_PRICE';
public static function initWithDefaults(): BaseRule
{
return new static(static::NAME, [
'product_price' => new Criterion(static::CRITERIA_OPTION_NUMBER, [
'operator' => static::CRITERIA_OPERATOR_GREATER_OR_EQUAL,
'value' => [
'from' => 0,
'to' => null,
],
]),
'include_discounts' => new Criterion(static::CRITERIA_OPTION_BOOLEAN, [
'value' => true,
]),
'include_specials' => new Criterion(static::CRITERIA_OPTION_BOOLEAN, [
'value' => true,
]),
]);
}
/**
* @return void
*/
public function apply(Builder $builder, $operand)
{
$includeSpecials = Arr::get($this->criteria, 'include_specials.value', true);
$includeDiscounts = Arr::get($this->criteria, 'include_discounts.value', false);
/** @var Criterion|null $productPriceCriterion */
$productPriceCriterion = $this->criteria['product_price'] ?? null;
if (! $productPriceCriterion) {
throw new RuntimeException('Invalid product price rule format. Criterion is not found. Check filter JSON.');
}
if (! isset(static::$numberCompareOperators[$productPriceCriterion->params['operator']])) {
throw new InvalidArgumentException('Invalid operator: ' . $productPriceCriterion->params['operator']);
}
$column = 'products.price';
if ($includeSpecials) {
$specialsFacetHash = md5(serialize($productPriceCriterion) . 'specials');
$joinAlias = 'product_specials_' . $specialsFacetHash;
if ($builder->hasJoinAlias($joinAlias)) {
return;
}
$customerGroupId = config('oc_customer_group_id', 1);
$builder->join(
db_table('product_special') . " AS $joinAlias",
function (JoinClause $join) use ($joinAlias, $customerGroupId) {
$join
->on('products.product_id', '=', "$joinAlias.product_id")
->where("$joinAlias.customer_group_id", '=', $customerGroupId)
->whereRaw("
($joinAlias.date_start = '0000-00-00' OR $joinAlias.date_start < NOW())
AND ($joinAlias.date_end = '0000-00-00' OR $joinAlias.date_end > NOW())
")
->orderBy("$joinAlias.priority", 'ASC')
->orderBy('products.price', 'ASC')
->limit(1);
},
'left'
);
$column = new RawExpression("COALESCE($joinAlias.price, products.price)");
}
$numberOperator = static::$numberCompareOperators[$productPriceCriterion->params['operator']];
$value = $this->prepareValue(
$numberOperator,
$productPriceCriterion->params['value']
);
if ($numberOperator === 'BETWEEN') {
[$min, $max] = $value; // $min = левая, $max = правая граница
// если обе границы не указаны — фильтр игнорируем
if ($min === null && $max === null) {
return;
}
// если только правая граница — "меньше или равно"
if ($min === null && $max !== null) {
$builder->where($column, '<=', $max, $operand);
return;
}
// если только левая граница — "больше или равно"
if ($min !== null && $max === null) {
$builder->where($column, '>=', $min, $operand);
return;
}
// левая и правая граница равны
if ($min !== null && $max !== null && $min === $max) {
$builder->where($column, '=', $min, $operand);
return;
}
// если обе границы есть — классический between (min ≤ x ≤ max)
if ($min !== null && $max !== null) {
$builder->whereBetween($column, [$min, $max], $operand);
}
} else {
$builder->where($column, $numberOperator, $value[0], $operand);
}
}
private function prepareValue($numberOperator, array $value): array
{
$from = null;
$to = null;
if (is_numeric($value['from'])) {
$from = (int) $value['from'];
}
if (is_numeric($value['to'])) {
$to = (int) $value['to'];
}
return [$from, $to];
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\FacetSearch\Filters;
use InvalidArgumentException;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Exceptions\CriteriaBuilderException;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use RuntimeException;
class ProductQuantity extends BaseRule
{
public const NAME = 'RULE_PRODUCT_QUANTITY';
public static function initWithDefaults(): BaseRule
{
return new static(static::NAME, [
'product_quantity' => new Criterion(static::CRITERIA_OPTION_NUMBER, [
'operator' => static::CRITERIA_OPERATOR_GREATER_OR_EQUAL,
'value' => [0, null],
]),
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion|null $productQuantityCriterion */
$productQuantityCriterion = $this->criteria['product_quantity'] ?? null;
if (! $productQuantityCriterion) {
throw new RuntimeException('Product Quantity rule criterion is not found.');
}
$column = 'products.quantity';
if (! isset(static::$numberCompareOperators[$productQuantityCriterion->params['operator']])) {
throw new InvalidArgumentException('Invalid operator: ' . $productQuantityCriterion->params['operator']);
}
$numberOperator = static::$numberCompareOperators[$productQuantityCriterion->params['operator']];
$value = $this->prepareValue(
$numberOperator,
$productQuantityCriterion->params['value'],
);
if ($numberOperator === 'BETWEEN') {
$builder->whereBetween($column, $value, $operand);
} else {
$builder->where($column, $numberOperator, $value[0], $operand);
}
}
private function prepareValue($numberOperator, array $value): array
{
if (
(isset($value[0]) && ! is_numeric($value[0]))
|| ($numberOperator === 'BETWEEN' && ! $value[1])
) {
throw new CriteriaBuilderException('Value is required.');
}
return array_map('intval', $value);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\FacetSearch\Filters;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class ProductStatus extends BaseRule
{
public const NAME = 'RULE_PRODUCT_STATUS';
public static function initWithDefaults(): BaseRule
{
return new static(static::NAME, [
'product_status' => new Criterion(static::CRITERIA_OPTION_BOOLEAN, [
'operator' => static::CRITERIA_OPERATOR_EQUALS,
'value' => true,
]),
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_BOOLEAN) {
$value = $criterion->params['value'];
$builder->where('products.status', '=', $value, $operand);
}
}
}
}

View File

@@ -29,24 +29,15 @@ class ProductsHandler
public function index(Request $request): JsonResponse
{
$page = (int) $request->get('page', 1);
$perPage = min((int) $request->get('perPage', 6), 15);
$categoryId = (int) $request->get('categoryId', 0);
$search = trim($request->get('search', ''));
$forMainPage = filter_var($request->get('forMainPage', false), FILTER_VALIDATE_BOOLEAN);
$featuredProducts = $this->settings->get('featured_products');
$mainpageProducts = $this->settings->get('mainpage_products');
$page = (int) $request->json('page', 1);
$perPage = min((int) $request->json('perPage', 6), 15);
$search = trim($request->json('search', ''));
$filters = $request->json('filters');
$languageId = $this->settings->get('language_id');
$response = $this->productsService->getProductsResponse(
compact(
'page',
'perPage',
'categoryId',
'search',
'forMainPage',
'featuredProducts',
'mainpageProducts'
)
compact('page', 'perPage', 'search', 'filters'),
$languageId,
);
return new JsonResponse($response);

View File

@@ -3,9 +3,18 @@
namespace App\ServiceProviders;
use App\Exceptions\CustomExceptionHandler;
use App\FacetSearch\Filters\ProductAttribute;
use App\FacetSearch\Filters\ProductCategories;
use App\FacetSearch\Filters\ProductForMainPage;
use App\FacetSearch\Filters\ProductManufacturer;
use App\FacetSearch\Filters\ProductModel;
use App\FacetSearch\Filters\ProductPrice;
use App\FacetSearch\Filters\ProductQuantity;
use App\FacetSearch\Filters\ProductStatus;
use App\Telegram\LinkCommand;
use Openguru\OpenCartFramework\Container\ServiceProvider;
use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface;
use Openguru\OpenCartFramework\CriteriaBuilder\RulesRegistry;
use Openguru\OpenCartFramework\Telegram\Commands\ChatIdCommand;
use Openguru\OpenCartFramework\Telegram\TelegramCommandsRegistry;
@@ -18,6 +27,7 @@ class AppServiceProvider extends ServiceProvider
});
$this->registerTelegramCommands();
$this->registerFacetFilters();
}
private function registerTelegramCommands(): void
@@ -31,4 +41,23 @@ class AppServiceProvider extends ServiceProvider
$registry->addCommand('id', ChatIdCommand::class, 'Возвращает ChatID текущего чата.');
$registry->addCommand('link', LinkCommand::class, 'Генератор Telegram сообщений с кнопкой');
}
private function registerFacetFilters(): void
{
$this->container->singleton(RulesRegistry::class, function () {
return new RulesRegistry();
});
$registry = $this->container->get(RulesRegistry::class);
$registry->register([
ProductAttribute::NAME => ProductAttribute::class,
ProductCategories::NAME => ProductCategories::class,
ProductManufacturer::NAME => ProductManufacturer::class,
ProductModel::NAME => ProductModel::class,
ProductPrice::NAME => ProductPrice::class,
ProductQuantity::NAME => ProductQuantity::class,
ProductStatus::NAME => ProductStatus::class,
ProductForMainPage::NAME => ProductForMainPage::class,
]);
}
}

View File

@@ -6,6 +6,7 @@ use Cart\Currency;
use Cart\Tax;
use Exception;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\CriteriaBuilder\CriteriaBuilder;
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
use Openguru\OpenCartFramework\ImageTool\ImageToolInterface;
use Openguru\OpenCartFramework\Logger\LoggerInterface;
@@ -25,6 +26,7 @@ class ProductsService
private ImageToolInterface $ocImageTool;
private OcRegistryDecorator $oc;
private LoggerInterface $logger;
private CriteriaBuilder $criteriaBuilder;
public function __construct(
Builder $queryBuilder,
@@ -33,7 +35,8 @@ class ProductsService
Settings $settings,
ImageToolInterface $ocImageTool,
OcRegistryDecorator $registry,
LoggerInterface $logger
LoggerInterface $logger,
CriteriaBuilder $criteriaBuilder
) {
$this->queryBuilder = $queryBuilder;
$this->currency = $currency;
@@ -42,32 +45,19 @@ class ProductsService
$this->ocImageTool = $ocImageTool;
$this->oc = $registry;
$this->logger = $logger;
$this->criteriaBuilder = $criteriaBuilder;
}
public function getProductsResponse(array $params): array
public function getProductsResponse(array $params, int $languageId): array
{
$page = $params['page'];
$perPage = $params['perPage'];
$categoryId = $params['categoryId'];
$search = $params['search'];
$forMainPage = $params['forMainPage'];
$featuredProducts = $params['featuredProducts'];
$mainpageProducts = $params['mainpageProducts'];
$status = $params['status'] ?? 1;
$languageId = 1;
$categoryName = '';
$imageWidth = 300;
$imageHeight = 300;
$maxPages = $params['maxPages'] ?? 10;
if ($categoryId) {
$categoryName = $this->queryBuilder->newQuery()
->select(['name'])
->from(db_table('category_description'), 'category')
->where('language_id', '=', $languageId)
->where('category_id', '=', $categoryId)
->value('name');
}
$maxPages = $params['maxPages'] ?? 50;
$filters = $params['filters'] ?? [];
$customerGroupId = (int) $this->oc->config->get('config_customer_group_id');
$specialPriceSql = "(SELECT price
@@ -97,34 +87,21 @@ class ProductsService
->where('product_description.language_id', '=', $languageId);
}
)
->where('products.status', '=', $status)
->where('products.status', '=', 1)
->whereRaw('products.date_available < NOW()')
->when($categoryId !== 0, function (Builder $query) use ($categoryId) {
$query->join(
db_table('product_to_category') . ' AS product_to_category',
function (JoinClause $join) use ($categoryId) {
$join->on('product_to_category.product_id', '=', 'products.product_id')
->where('product_to_category.category_id', '=', $categoryId);
}
);
})
->when(
$forMainPage && $mainpageProducts === 'featured' && $featuredProducts,
function (Builder $query) use ($featuredProducts) {
$query->whereIn('products.product_id', $featuredProducts);
}
)
->when($search, function (Builder $query) use ($search) {
$query->where('product_description.name', 'LIKE', '%' . $search . '%');
});
$this->criteriaBuilder->apply($productsQuery, $filters);
$total = $productsQuery->count();
$lastPage = min(PaginationHelper::calculateLastPage($total, $perPage), $maxPages);
$hasMore = $page + 1 <= $lastPage;
$products = $productsQuery
->forPage($page, $perPage)
->orderBy($mainpageProducts === 'latest' ? 'date_modified' : 'viewed', 'DESC')
->orderBy('date_modified', 'DESC')
->get();
$productIds = Arr::pluck($products, 'product_id');
@@ -150,6 +127,13 @@ class ProductsService
];
}
$debug = [];
if (env('APP_DEBUG')) {
$debug = [
'sql' => $productsQuery->toRawSql(),
];
}
return [
'data' => array_map(function ($product) use ($productsImagesMap, $imageWidth, $imageHeight) {
$allImages = [];
@@ -204,6 +188,7 @@ class ProductsService
'meta' => [
'currentCategoryName' => $categoryName,
'hasMore' => $hasMore,
'debug' => $debug,
]
];
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Tests\Helpers;
use Doctrine\SqlFormatter\NullHighlighter;
use Doctrine\SqlFormatter\SqlFormatter;
use Mockery;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use PDO;
trait DatabaseHelpers
{
public function dropAllTables(): void
{
/** @var ConnectionInterface $database */
$database = $this->app->get(ConnectionInterface::class);
$databaseName = getenv('DB_DATABASE');
$sql = <<<SQL
SELECT TABLE_NAME
FROM information_schema.tables
WHERE table_schema = ?
SQL;
$tables = $database->select($sql, [$databaseName]);
$tables = array_column($tables, 'TABLE_NAME');
foreach ($tables as $table) {
$database->dropTable($table);
}
}
public function getPdoMock()
{
$pdoMock = Mockery::spy(PDO::class);
$pdoMock->shouldReceive('quote')
->andReturnUsing(function ($value) {
return "'$value'";
});
return $pdoMock;
}
public function formatSql(string $query): string
{
return (new SqlFormatter(new NullHighlighter()))->format($query);
}
public function createRandomTable(): string
{
$randomTableName = 'tbl_random_' . uniqid('', true);
$randomTableName = str_replace('.', '_', $randomTableName);
/** @var ConnectionInterface $database */
$database = $this->app->get(ConnectionInterface::class);
$database->statement("CREATE TABLE $randomTableName (id INT AUTO_INCREMENT PRIMARY KEY)");
return $randomTableName;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Tests\Helpers\ExampleClasses;
class ExampleDatabaseConnection
{
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Tests\Helpers\ExampleClasses;
class ExampleEmailWithConfig
{
public array $config;
public int $rateLimit;
public function __construct(array $config, int $rateLimit) {
$this->config = $config;
$this->rateLimit = $rateLimit;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Tests\Helpers\ExampleClasses;
class ExamplePersonRepository
{
/**
* @var ExampleDatabaseConnection
*/
public ExampleDatabaseConnection $connection;
public function __construct(ExampleDatabaseConnection $connection)
{
$this->connection = $connection;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Tests\Helpers\ExampleClasses;
class ExamplePersonService
{
/**
* @var ExamplePersonRepository
*/
public ExamplePersonRepository $personRepository;
/**
* @var ExampleSmsGateway
*/
public ExampleSmsGateway $smsGateway;
public function __construct(
ExamplePersonRepository $personRepository,
ExampleSmsGateway $smsGateway
) {
$this->personRepository = $personRepository;
$this->smsGateway = $smsGateway;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Tests\Helpers\ExampleClasses;
class ExampleSmsGateway
{
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Tests\Helpers\ExampleClasses;
class ExampleUserService
{
/**
* @var ExampleEmailWithConfig
*/
private ExampleEmailWithConfig $email;
public function __construct(ExampleEmailWithConfig $email)
{
$this->email = $email;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Tests\Helpers\ExampleClasses;
class FilterDTO
{
public string $field;
public string $operator;
public $value;
public function __construct(string $field, string $operator, $value)
{
$this->field = $field;
$this->operator = $operator;
$this->value = $value;
}
/**
* @return mixed
*/
public function getValue()
{
return $this->value;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Tests\Helpers\ExampleClasses;
use Openguru\OpenCartFramework\Http\JsonResponse;
use stdClass;
class TestClassWithMethod
{
public function testMethod(stdClass $dependency): JsonResponse
{
return new JsonResponse([]);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace Tests\Helpers;
class OpencartUrl
{
}

View File

@@ -2,18 +2,69 @@
namespace Tests;
use App\ApplicationFactory;
use Mockery;
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Container\Container;
use PHPUnit\Framework\TestCase as BaseTestCase;
use Tests\Helpers\DatabaseHelpers;
class TestCase extends \PHPUnit\Framework\TestCase
class TestCase extends BaseTestCase
{
protected Application $app;
use DatabaseHelpers;
protected $app;
protected function setUp(): void
{
parent::setUp();
$config = [];
$this->app = $this->bootstrapApplication();
}
$this->app = new Application($config);
public static function basePath(): string
{
return __DIR__;
}
public static function fixturesPath(): string
{
return static::basePath() . DIRECTORY_SEPARATOR . 'fixtures';
}
private function bootstrapApplication(): Application
{
$app = ApplicationFactory::create([
'db' => [
'prefix' => 'oc_',
],
]);
$app->boot();
$this->registerDependenciesForTests($app);
return $app;
}
/**
* @template T
* @param class-string<T> $factoryClass
* @param array $params
* @return T
*/
public function factory(string $factoryClass, array $params = [])
{
return $this->app->get($factoryClass);
}
private function registerDependenciesForTests(Container $container): void
{
$url = Mockery::mock('Url');
$url->shouldReceive('link')->andReturn('http://localhost');
$container->bind(\Url::class, function () use ($url) {
return $url;
});
}
}

View File

@@ -0,0 +1,194 @@
<?php
namespace Tests\Unit;
use InvalidArgumentException;
use Openguru\OpenCartFramework\Support\Arr;
use PHPUnit\Framework\TestCase;
class ArrTest extends TestCase
{
public function testKeyByField(): void
{
$data = [
['id' => 1, 'name' => 'Item 1'],
['id' => 2, 'name' => 'Item 2'],
];
$result = Arr::keyByField($data, 'id');
$this->assertArrayHasKey(1, $result);
$this->assertArrayHasKey(2, $result);
$this->assertEquals('Item 1', $result[1]['name']);
$this->assertEquals('Item 2', $result[2]['name']);
$this->expectException(InvalidArgumentException::class);
Arr::keyByField($data, 'nonexistent');
}
public function testGroupByKey(): void
{
$data = [
['category' => 'A', 'value' => 10],
['category' => 'A', 'value' => 20],
['category' => 'B', 'value' => 30],
];
$result = Arr::groupByKey($data, 'category', 'value');
$this->assertEquals(['A' => [10, 20], 'B' => [30]], $result);
}
public function testGet(): void
{
$data = ['key' => 'value', 'nested' => ['key' => 'nestedValue']];
$this->assertEquals('value', Arr::get($data, 'key'));
$this->assertEquals('nestedValue', Arr::get($data, 'nested.key'));
$this->assertNull(Arr::get($data, 'nonexistent'));
$this->assertEquals('default', Arr::get($data, 'nonexistent', 'default'));
}
public function testSet(): void
{
$data = [];
Arr::set($data, 'key', 'value');
$this->assertEquals('value', $data['key']);
Arr::set($data, 'nested.key', 'nestedValue');
$this->assertEquals('nestedValue', $data['nested']['key']);
}
public function testUnset(): void
{
$data = ['key' => 'value', 'nested' => ['key' => 'nestedValue']];
Arr::unset($data, 'key');
$this->assertArrayNotHasKey('key', $data);
Arr::unset($data, 'nested.key');
$this->assertArrayNotHasKey('key', $data['nested']);
}
public function testFind(): void
{
$data = [
['id' => 1, 'name' => 'Item 1'],
['id' => 2, 'name' => 'Item 2'],
];
$result = Arr::find($data, function ($item) {
return $item['id'] === 2;
});
$this->assertEquals(['id' => 2, 'name' => 'Item 2'], $result);
$result = Arr::find($data, function ($item) {
return $item['id'] === 3;
});
$this->assertNull($result);
}
public function testMergeArraysFlat(): void
{
$base = ['key1' => 'value1', 'key2' => 'value2'];
$override = ['key2' => 'new_value2', 'key3' => 'value3'];
$expected = [
'key1' => 'value1',
'key2' => 'new_value2',
'key3' => 'value3',
];
$this->assertSame($expected, Arr::mergeArraysRecursively($base, $override));
}
public function testMergeArraysNested(): void
{
$base = [
'key1' => [
'subkey1' => 'value1',
'subkey2' => 'value2',
],
'key2' => 'value3',
];
$override = [
'key1' => [
'subkey2' => 'new_value2',
'subkey3' => 'value4',
],
'key2' => 'new_value3',
];
$expected = [
'key1' => [
'subkey1' => 'value1',
'subkey2' => 'new_value2',
'subkey3' => 'value4',
],
'key2' => 'new_value3',
];
$this->assertSame($expected, Arr::mergeArraysRecursively($base, $override));
}
public function testMergeArraysOverrideWithNonArray(): void
{
$base = ['key1' => ['subkey1' => 'value1']];
$override = ['key1' => 'new_value1'];
$expected = ['key1' => 'new_value1'];
$this->assertSame($expected, Arr::mergeArraysRecursively($base, $override));
}
public function testMergeArraysEmptyBase(): void
{
$base = [];
$override = ['key1' => 'value1'];
$expected = ['key1' => 'value1'];
$this->assertSame($expected, Arr::mergeArraysRecursively($base, $override));
}
public function testMergeArraysEmptyOverride(): void
{
$base = ['key1' => 'value1'];
$override = [];
$expected = ['key1' => 'value1'];
$this->assertSame($expected, Arr::mergeArraysRecursively($base, $override));
}
public function testMergeArraysWithDeepNesting(): void
{
$base = [
'key1' => [
'subkey1' => [
'subsubkey1' => 'value1',
],
],
];
$override = [
'key1' => [
'subkey1' => [
'subsubkey1' => 'new_value1',
'subsubkey2' => 'value2',
],
],
];
$expected = [
'key1' => [
'subkey1' => [
'subsubkey1' => 'new_value1',
'subsubkey2' => 'value2',
],
],
];
$this->assertSame($expected, Arr::mergeArraysRecursively($base, $override));
}
}

View File

@@ -0,0 +1,347 @@
<?php
namespace Tests\Unit;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\Connections\MySqlConnection;
use Openguru\OpenCartFramework\QueryBuilder\Grammars\MySqlGrammar;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
use Tests\TestCase;
class BuilderTest extends TestCase
{
protected $builder;
protected function setUp(): void
{
parent::setUp();
$connection = new MySqlConnection($this->getPdoMock());
$this->builder = new Builder($connection, new MySqlGrammar());
}
public function testSelect(): void
{
$sql = $this->builder
->select([
'foo',
'boo' => 'bar',
])
->from('some_table')
->toSql();
$this->assertEquals(/** @lang text */ 'SELECT foo, boo AS bar FROM some_table', $sql);
}
public function testSelectRawExpression(): void
{
$sql = $this->builder
->select([
'foo',
new RawExpression('IF (foobar IS NULL, TRUE, FALSE) AS alias'),
])
->from('some_table')
->toSql();
$this->assertEquals(
/** @lang text */ 'SELECT foo, IF (foobar IS NULL, TRUE, FALSE) AS alias FROM some_table',
$sql
);
}
public function testFrom(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table AS alias',
$this->builder
->from('some_table', 'alias')
->toSql()
);
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table',
$this->builder
->from('some_table')
->toSql()
);
}
public function testWhereNotNull(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table WHERE foo IS NOT NULL',
$this->builder
->from('some_table')
->whereNotNull('foo')
->toSql()
);
}
public function testWhereNull(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table WHERE foo IS NULL',
$this->builder
->from('some_table')
->whereNull('foo')
->toSql()
);
}
public function testWhere(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table WHERE foo = ?',
$this->builder
->from('some_table')
->where('foo', '=', 'bar')
->toSql()
);
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table WHERE foo LIKE ?',
$this->builder
->newQuery()
->from('some_table')
->where('foo', 'LIKE', '%bar%')
->toSql()
);
}
public function testOrWhere(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM t1 WHERE foo = ? OR bar = ?',
$this->builder
->from('t1')
->where('foo', '=', 'bar')
->orWhere('bar', '=', 'boo')
->toSql()
);
}
public function testLimit(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table LIMIT 10',
$this->builder
->from('some_table')
->limit(10)
->toSql()
);
}
public function testOffset(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table OFFSET 10',
$this->builder
->from('some_table')
->offset(10)
->toSql()
);
}
public function testForPage(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table LIMIT 20 OFFSET 20',
$this->builder
->from('some_table')
->forPage(2, 20)
->toSql()
);
}
public function testOrderBy(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table ORDER BY foo ASC, bar DESC',
$this->builder
->from('some_table')
->orderBy('foo')
->orderBy('bar', 'DESC')
->toSql()
);
}
public function testJoin(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM t1 INNER JOIN t2 ON t1.key = t2.key AND t1.foo IS NOT NULL OR t2.bar <> ?',
$this->builder
->from('t1')
->join('t2', function (JoinClause $join) {
$join->on('t1.key', '=', 't2.key')
->whereNotNull('t1.foo')
->where('t2.bar', '<>', 'value', 'or');
})
->toSql()
);
}
public function testLeftJoin(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM t1 LEFT JOIN t2 ON t1.key = t2.key',
$this->builder
->from('t1')
->join('t2', function (JoinClause $join) {
$join->on('t1.key', '=', 't2.key');
}, 'left')
->toSql()
);
$this->assertEquals(
/** @lang text */ 'SELECT * FROM t1 LEFT JOIN t2 ON t1.key = t2.key',
$this->builder->newQuery()
->from('t1')
->leftJoin('t2', function (JoinClause $join) {
$join->on('t1.key', '=', 't2.key');
})
->toSql()
);
}
public function testWhereNested(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM t1 WHERE (t1.foo = ? AND t2.bar = ?) OR (t1.foo = ? AND t2.bar = ?)',
$this->builder
->from('t1')
->whereNested(function (Builder $builder) {
$builder->where('t1.foo', '=', 'bar')
->where('t2.bar', '=', 'foo');
})
->whereNested(function (Builder $builder) {
$builder->where('t1.foo', '=', 'foo')
->where('t2.bar', '=', 'bar');
}, 'or')
->toSql()
);
}
public function testWhereNestedEmpty(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM t1 WHERE ()',
$this->builder
->from('t1')
->whereNested(function (Builder $builder) {
})
->toSql()
);
}
public function testWhereBetween(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table WHERE foo BETWEEN 100 AND 200',
$this->builder
->from('some_table')
->whereBetween('foo', [100, 200])
->toRawSql()
);
}
public function testWhereBetweenWithOperand(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table WHERE foo BETWEEN 100 AND 200 OR bar BETWEEN 10 AND 20',
$this->builder
->from('some_table')
->whereBetween('foo', [100, 200])
->whereBetween('bar', [10, 20], 'or')
->toRawSql()
);
}
public function testWhereIn(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table WHERE foo IN (1, 2, 3)',
$this->builder
->from('some_table')
->whereIn('foo', [1, 2, 3])
->toRawSql()
);
}
public function testWhereNotIn(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table WHERE foo NOT IN (1, 2, 3)',
$this->builder
->from('some_table')
->whereIn('foo', [1, 2, 3], true)
->toRawSql()
);
$this->assertEquals(
/** @lang text */ 'SELECT * FROM some_table WHERE foo NOT IN (1, 2, 3)',
$this->builder
->newQuery()
->from('some_table')
->whereNotIn('foo', [1, 2, 3])
->toRawSql()
);
}
public function testDistinct(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT DISTINCT id FROM some_table',
$this->builder
->select(['id'])
->distinct()
->from('some_table')
->toRawSql()
);
}
public function testWhenConditionTrue(): void
{
$this->assertEquals(
/** @lang text */ "SELECT id FROM some_table WHERE foo = 'bar'",
$this->builder
->select(['id'])
->from('some_table')
->when(true, function (Builder $query) {
$query->where('foo', '=', 'bar');
})
->toRawSql()
);
}
public function testWhenConditionFalseWithDefault(): void
{
$this->assertEquals(
/** @lang text */ "SELECT id FROM some_table WHERE foo <> 'bar'",
$this->builder
->select(['id'])
->from('some_table')
->when(false, function (Builder $query) {
$query->where('foo', '=', 'bar');
}, function (Builder $query) {
$query->where('foo', '<>', 'bar');
})
->toRawSql()
);
}
public function testWhenConditionFalseWithoutDefault(): void
{
$this->assertEquals(
/** @lang text */ "SELECT id FROM some_table",
$this->builder
->select(['id'])
->from('some_table')
->when(false, function (Builder $query) {
$query->where('foo', '=', 'bar');
})
->toRawSql()
);
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace Tests\Unit;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Exceptions\ContainerDependencyResolutionException;
use Openguru\OpenCartFramework\Http\JsonResponse;
use stdClass;
use Tests\Helpers\ExampleClasses\TestClassWithMethod;
use Tests\TestCase;
use Tests\Helpers\ExampleClasses\ExampleDatabaseConnection;
use Tests\Helpers\ExampleClasses\ExampleSmsGateway;
use Tests\Helpers\ExampleClasses\ExamplePersonRepository;
use Tests\Helpers\ExampleClasses\ExamplePersonService;
use Tests\Helpers\ExampleClasses\ExampleEmailWithConfig;
class ContainerTest extends TestCase
{
private $config = [
'key' => 'value',
];
private $container;
protected function setUp(): void
{
parent::setUp();
$this->container = new Container($this->config);
}
public function testGetConfigValue()
{
$this->assertEquals('value', $this->container->getConfigValue('key'));
$this->assertIsArray($this->container->getConfigValue());
}
public function testBind(): void
{
$this->container->bind('abstract', function () {});
$this->assertTrue($this->container->has('abstract'));
}
public function testResolve(): void
{
$this->container->bind(ExampleDatabaseConnection::class, function () {
return new ExampleDatabaseConnection();
});
$concrete1 = $this->container->get(ExampleDatabaseConnection::class);
$concrete2 = $this->container->get(ExampleDatabaseConnection::class);
$this->assertInstanceOf(ExampleDatabaseConnection::class, $concrete1);
$this->assertInstanceOf(ExampleDatabaseConnection::class, $concrete2);
$this->assertNotSame($concrete1, $concrete2);
}
public function testSingleton(): void
{
$this->container->singleton(ExampleDatabaseConnection::class, function () {
return new ExampleDatabaseConnection();
});
$concrete1 = $this->container->get(ExampleDatabaseConnection::class);
$concrete2 = $this->container->get(ExampleDatabaseConnection::class);
$this->assertInstanceOf(ExampleDatabaseConnection::class, $concrete1);
$this->assertInstanceOf(ExampleDatabaseConnection::class, $concrete2);
$this->assertSame($concrete1, $concrete2);
}
public function testDeepResolve(): void
{
$container = new Container([]);
$container->bind(ExampleSmsGateway::class, function () { return new ExampleSmsGateway(); });
$container->bind(ExampleDatabaseConnection::class, function () { return new ExampleDatabaseConnection(); });
$container->bind(ExamplePersonRepository::class, function (Container $container) {
return new ExamplePersonRepository(
$container->get(ExampleDatabaseConnection::class),
);
});
$container->bind(ExamplePersonService::class, function (Container $container) {
return new ExamplePersonService(
$container->get(ExamplePersonRepository::class),
$container->get(ExampleSmsGateway::class),
);
});
$personService = $container->get(ExamplePersonService::class);
$this->assertInstanceOf(ExamplePersonService::class, $personService);
$this->assertInstanceOf(ExamplePersonRepository::class, $personService->personRepository);
$this->assertInstanceOf(ExampleSmsGateway::class, $personService->smsGateway);
}
public function testAutoResolve(): void
{
$personService = $this->container->get(ExamplePersonService::class);
$this->assertInstanceOf(ExamplePersonService::class, $personService);
$this->assertInstanceOf(ExamplePersonRepository::class, $personService->personRepository);
$this->assertInstanceOf(ExampleSmsGateway::class, $personService->smsGateway);
}
public function testAutoResolveFailed(): void
{
$this->expectException(ContainerDependencyResolutionException::class);
$response = $this->container->get(ExampleEmailWithConfig::class);
$this->assertInstanceOf(ExampleEmailWithConfig::class, $response);
}
public function testAutoResolveWithCustomParams(): void
{
$container = new Container([]);
$container->bind(
ExampleEmailWithConfig::class,
function () {
return new ExampleEmailWithConfig(['foo' => 'bar'], 10);
}
);
/** @var ExampleEmailWithConfig $emailWithConfig */
$emailWithConfig = $container->get(ExampleEmailWithConfig::class);
$this->assertInstanceOf(ExampleEmailWithConfig::class, $emailWithConfig);
$this->assertEquals(['foo' => 'bar'], $emailWithConfig->config);
$this->assertEquals(10, $emailWithConfig->rateLimit);
}
public function testCallMethodWithDependencies(): void
{
$this->container->bind(stdClass::class, function () {
return new stdClass();
});
$response = $this->container->call(TestClassWithMethod::class, 'testMethod');
$this->assertInstanceOf(JsonResponse::class, $response);
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace Tests\Unit;
use App\FacetSearch\Filters\ProductAttribute;
use App\FacetSearch\Filters\ProductCategories;
use App\FacetSearch\Filters\ProductForMainPage;
use App\FacetSearch\Filters\ProductManufacturer;
use App\FacetSearch\Filters\ProductModel;
use App\FacetSearch\Filters\ProductPrice;
use App\FacetSearch\Filters\ProductQuantity;
use App\FacetSearch\Filters\ProductStatus;
use DirectoryIterator;
use InvalidArgumentException;
use JsonException;
use Mockery;
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\CriteriaBuilder\CriteriaBuilder;
use Openguru\OpenCartFramework\CriteriaBuilder\RulesRegistry;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\QueryBuilder\Connections\MySqlConnection;
use Openguru\OpenCartFramework\QueryBuilder\Grammars\MySqlGrammar;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\Support\Arr;
use Tests\TestCase;
class CriteriaBuilderTest extends TestCase
{
protected function tearDown(): void
{
parent::tearDown();
Mockery::close();
}
/** @dataProvider CriteriaBuilderFixturesDataProvider */
public function testRule($folder): void
{
$this->expectNotToPerformAssertions();
$input = $this->jsonFileToArray($folder . DIRECTORY_SEPARATOR . 'input.json');
$outputFilename = $folder . '/output.sql';
$settingsFilename = $folder . DIRECTORY_SEPARATOR . 'settings_override.json';
$settingsOverride = [];
if (file_exists($settingsFilename)) {
$settingsOverride = $this->jsonFileToArray($settingsFilename);
}
$baseSettings = [
'db' => [
'prefix' => 'oc_',
],
];
$config = Arr::mergeArraysRecursively($baseSettings, $settingsOverride);
$application = new Application($config);
$mysqlConnection = new MySqlConnection($this->getPdoMock());
$application->boot();
$e = config('mainpage_products');
$application->bind(ConnectionInterface::class, function () use ($mysqlConnection) {
return $mysqlConnection;
});
$application->bind(Builder::class, function (Container $container) {
return new Builder(
$container->get(ConnectionInterface::class),
$container->get(MySqlGrammar::class),
);
});
$application->singleton(RulesRegistry::class, function () {
return new RulesRegistry();
});
$application->singleton(Settings::class, function () {
return new Settings();
});
/** @var RulesRegistry $rulesRegistry */
$rulesRegistry = $application->get(RulesRegistry::class);
$rulesRegistry->register(ProductPrice::NAME, ProductPrice::class);
$rulesRegistry->register(ProductStatus::NAME, ProductStatus::class);
$rulesRegistry->register(ProductModel::NAME, ProductModel::class);
$rulesRegistry->register(ProductCategories::NAME, ProductCategories::class);
$rulesRegistry->register(ProductManufacturer::NAME, ProductManufacturer::class);
$rulesRegistry->register(ProductQuantity::NAME, ProductQuantity::class);
$rulesRegistry->register(ProductAttribute::NAME, ProductAttribute::class);
$rulesRegistry->register(ProductForMainPage::NAME, ProductForMainPage::class);
$this->builder = $application->get(Builder::class);
$this->criteriaBuilder = $application->get(CriteriaBuilder::class);
$this->baseQuery = $this->builder->newQuery()
->select([
'products.product_id' => 'product_id',
'products.image' => 'image',
'product_description.name' => 'name',
'products.model' => 'model',
'products.price' => 'price',
'products.quantity' => 'quantity',
'products.status' => 'status',
'products.noindex' => 'noindex'
])
->from('oc_product', 'products')
->join(
'oc_product_description AS product_description',
function (JoinClause $join) {
return $join->on('products.product_id', '=', 'product_description.product_id')
->where('product_description.language_id', '=', 1);
}
);
$this->criteriaBuilder->apply($this->baseQuery, $input);
$actual = $this->formatSql($this->baseQuery->toRawSql());
if (! file_exists($outputFilename)) {
file_put_contents($outputFilename, $actual);
$this->markTestSkipped('Regenerated fixtures. Rerun the test.');
}
if (file_get_contents($outputFilename) !== $actual) {
file_put_contents($outputFilename, $actual);
$this->markTestIncomplete('SQL changed. Please check it and regenerate fixtures.');
}
}
public static function CriteriaBuilderFixturesDataProvider(): array
{
return self::findDirectories(static::fixturesPath() . '/criteria_builder');
}
private static function findDirectories($rootFolder, $currentLevel = 1, $maxLevel = 2): array
{
$directories = [];
if ($currentLevel > $maxLevel) {
return $directories;
}
$iterator = new DirectoryIterator($rootFolder);
foreach ($iterator as $fileInfo) {
if ($fileInfo->isDot()) {
continue;
}
if ($fileInfo->isDir()) {
if ($currentLevel === 2) {
$parts = array_reverse(explode('/', $fileInfo->getPathname()));
$key = $parts[1] . '_' . $parts[0];
$directories[$key] = [
$fileInfo->getPathname(),
];
}
$subDirectories = self::findDirectories($fileInfo->getPathname(), $currentLevel + 1, $maxLevel);
$directories = array_merge($directories, $subDirectories);
}
}
return $directories;
}
/**
* @throws JsonException
*/
private function jsonFileToArray(string $path): array
{
if (! file_exists($path)) {
throw new InvalidArgumentException('Input file not found: ' . $path);
}
return json_decode(
file_get_contents($path),
true,
512,
JSON_THROW_ON_ERROR
);
}
}

View File

@@ -0,0 +1,176 @@
<?php
namespace Tests\Unit;
use ArrayIterator;
use InvalidArgumentException;
use Openguru\OpenCartFramework\Collection\Collection;
use stdClass;
use Tests\Helpers\ExampleClasses\FilterDTO;
use Tests\TestCase;
class GenericCollectionTest extends TestCase
{
public function testCanAddItemsToCollection(): void
{
$collection = new Collection([], FilterDTO::class);
$filter = new FilterDTO('price', '>', 100);
$collection->add($filter);
$this->assertCount(1, $collection);
$this->assertSame($filter, $collection->all()[0]);
}
public function testThrowsExceptionWhenAddingInvalidItem(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Item must be an instance of');
$collection = new Collection([], FilterDTO::class);
$collection->add(new stdClass()); // Invalid item
}
public function testCanFindItemByProperty(): void
{
$filter1 = new FilterDTO('price', '>', 100);
$filter2 = new FilterDTO('stock', '<', 50);
$collection = new Collection([$filter1, $filter2], FilterDTO::class);
$result = $collection->findByProperty('field', 'stock');
$this->assertNotNull($result);
$this->assertSame($filter2, $result);
}
public function testReturnsNullWhenItemNotFound(): void
{
$filter = new FilterDTO('price', '>', 100);
$collection = new Collection([$filter], FilterDTO::class);
$result = $collection->findByProperty('field', 'nonexistent');
$this->assertNull($result);
}
public function testCanCheckIfItemWithSpecificPropertyAndValueExists(): void
{
$filter = new FilterDTO('price', '>', 100);
$collection = new Collection([$filter], FilterDTO::class);
$exists = $collection->hasValue('field', 'price');
$notExists = $collection->hasValue('field', 'nonexistent');
$this->assertTrue($exists);
$this->assertFalse($notExists);
}
public function testImplementsCountable(): void
{
$filter1 = new FilterDTO('price', '>', 100);
$filter2 = new FilterDTO('stock', '<', 50);
$collection = new Collection([$filter1, $filter2], FilterDTO::class);
$this->assertCount(2, $collection);
}
public function testImplementsIteratorAggregate(): void
{
$filter1 = new FilterDTO('price', '>', 100);
$filter2 = new FilterDTO('stock', '<', 50);
$collection = new Collection([$filter1, $filter2], FilterDTO::class);
$iterator = $collection->getIterator();
$this->assertInstanceOf(ArrayIterator::class, $iterator);
$this->assertCount(2, $iterator);
}
public function testImplementsArrayAccess(): void
{
$filter1 = new FilterDTO('price', '>', 100);
$filter2 = new FilterDTO('stock', '<', 50);
$collection = new Collection([], FilterDTO::class);
// Adding items
$collection[] = $filter1;
$collection[] = $filter2;
$this->assertCount(2, $collection);
$this->assertSame($filter1, $collection[0]);
$this->assertSame($filter2, $collection[1]);
// Modifying an item
$collection[0] = $filter2;
$this->assertSame($filter2, $collection[0]);
// Checking existence
$this->assertTrue(isset($collection[0]));
$this->assertFalse(isset($collection[5]));
// Removing an item
unset($collection[0]);
$this->assertFalse(isset($collection[0]));
}
public function testArrayAccessThrowsExceptionForInvalidType(): void
{
$collection = new Collection([], FilterDTO::class);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Item must be an instance of');
$collection[] = new stdClass(); // Invalid item
}
public function testConstructorValidatesItems(): void
{
$this->expectException(InvalidArgumentException::class);
$invalidItems = [new stdClass(), new FilterDTO('price', '>', 100)];
new Collection($invalidItems, FilterDTO::class);
}
public function testCanRetrieveAllItems(): void
{
$filter1 = new FilterDTO('price', '>', 100);
$filter2 = new FilterDTO('stock', '<', 50);
$collection = new Collection([$filter1, $filter2], FilterDTO::class);
$allItems = $collection->all();
$this->assertCount(2, $allItems);
$this->assertSame($filter1, $allItems[0]);
$this->assertSame($filter2, $allItems[1]);
}
public function testCanGetValueByProperty(): void
{
$filter1 = new FilterDTO('price', '>', 100);
$filter2 = new FilterDTO('category', '=', 'electronics');
$collection = new Collection([$filter1, $filter2], FilterDTO::class);
$value = $collection->getValueByProperty('field', 'price');
$this->assertSame(100, $value);
$value = $collection->getValueByProperty('field', 'category');
$this->assertSame('electronics', $value);
}
public function testReturnsNullIfPropertyNotFound(): void
{
$filter = new FilterDTO('price', '>', 100);
$collection = new Collection([$filter], FilterDTO::class);
$value = $collection->getValueByProperty('field', 'nonexistent');
$this->assertNull($value);
}
public function testReturnsNullIfNoItemsInCollection(): void
{
$collection = new Collection([], FilterDTO::class);
$value = $collection->getValueByProperty('field', 'price');
$this->assertNull($value);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Tests\Unit;
use InvalidArgumentException;
use Tests\TestCase;
class HelpersTest extends TestCase
{
public function testDbTable(): void
{
$this->assertEquals('oc_some_table', db_table('some_table'));
}
public function testDbColumnWithTableAndColumn(): void
{
$column = 'users.email';
$expected = 'oc_users.email';
$this->assertEquals($expected, db_column($column));
}
public function testDbColumnWithoutTable(): void
{
$column = 'email';
$expected = 'email';
$this->assertEquals($expected, db_column($column));
}
public function testDbColumnWithEmptyTable(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid column reference: ');
db_column('.email');
}
public function testDbColumnWithEmptyColumn(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid column reference: ');
db_column('users.');
}
public function testDbColumnWithEmptyTableAndColumn(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid column reference: ');
db_column('.');
}
}

View File

@@ -0,0 +1,222 @@
<?php
namespace Tests\Unit;
use Mockery as m;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\Grammars\MySqlGrammar;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Tests\TestCase;
class MySqlGrammarTest extends TestCase
{
private $grammar;
public function setUp(): void
{
parent::setUp();
$this->grammar = new MySqlGrammar();
}
public function tearDown(): void
{
parent::tearDown();
m::close();
}
public function testCompileFrom(): void
{
$this->assertEquals(
'FROM foobar AS alias',
$this->grammar->compileFrom(m::mock(Builder::class), ['table' => 'foobar', 'as' => 'alias'])
);
$this->assertEquals(
'FROM foobar',
$this->grammar->compileFrom(m::mock(Builder::class), ['table' => 'foobar', 'as' => null])
);
}
public function testCompileLimit(): void
{
$this->assertEquals('LIMIT 10', $this->grammar->compileLimit(m::mock(Builder::class), 10));
}
public function testCompileOffset(): void
{
$this->assertEquals('OFFSET 10', $this->grammar->compileOffset(m::mock(Builder::class), 10));
}
public function testCompileColumns(): void
{
$this->assertEquals('SELECT *', $this->grammar->compileColumns(m::mock(Builder::class), []));
$this->assertEquals('SELECT foo, bar', $this->grammar->compileColumns(m::mock(Builder::class), [
['column' => 'foo', 'as' => null],
['column' => 'bar', 'as' => null],
]));
$this->assertEquals('SELECT foo AS bar, xxx AS yyy', $this->grammar->compileColumns(
m::mock(Builder::class), [
['column' => 'foo', 'as' => 'bar'],
['column' => 'xxx', 'as' => 'yyy'],
]));
}
public function testCompileWheres(): void
{
$this->assertEquals('', $this->grammar->compileWheres(m::mock(Builder::class), []));
$this->assertEquals('WHERE foo IS NOT NULL', $this->grammar->compileWheres(m::mock(Builder::class), [
[
'type' => 'NotNull',
'column' => 'foo',
'boolean' => 'and',
]
]));
$this->assertEquals('WHERE foo IS NULL', $this->grammar->compileWheres(m::mock(Builder::class), [
[
'type' => 'Null',
'column' => 'foo',
'boolean' => 'and',
]
]));
$this->assertEquals('WHERE foo = ? OR bar = ? AND xxx IS NULL', $this->grammar->compileWheres(
m::mock(Builder::class), [
[
'type' => 'Basic',
'column' => 'foo',
'operator' => '=',
'value' => 'val',
'boolean' => 'and',
],
[
'type' => 'Basic',
'column' => 'bar',
'operator' => '=',
'value' => 'val',
'boolean' => 'or',
],
[
'type' => 'Null',
'column' => 'xxx',
'boolean' => 'and',
]
]));
}
public function testCompileOrders(): void
{
$this->assertEquals('ORDER BY foo DESC, bar ASC', $this->grammar->compileOrders(
m::mock(Builder::class), [
['column' => 'foo', 'direction' => 'DESC'],
['column' => 'bar', 'direction' => 'ASC'],
]));
}
public function testWhereNotNull(): void
{
$this->assertEquals('foo IS NOT NULL', $this->grammar->whereNotNull([
'type' => 'NotNull',
'column' => 'foo',
'boolean' => 'and',
]));
}
public function testWhereNull(): void
{
$this->assertEquals('foo IS NULL', $this->grammar->whereNull([
'type' => 'Null',
'column' => 'foo',
'boolean' => 'and',
]));
}
public function testWhereBasic(): void
{
$this->assertEquals('foo = ?', $this->grammar->whereBasic([
'type' => 'Basic',
'column' => 'foo',
'operator' => '=',
'value' => 'val',
'boolean' => 'and',
]));
}
public function testConcatCompiled(): void
{
$this->assertEquals(
/** @lang text */ 'SELECT * FROM table WHERE foo = ? LIMIT 10',
$this->grammar->concatCompiled([
'wheres' => 'WHERE foo = ?',
'limit' => 'LIMIT 10',
'columns' => 'SELECT *',
'from' => 'FROM table',
])
);
}
public function testCompileJoins(): void
{
$joinClause = m::mock(JoinClause::class);
$joinClause->table = 'table AS t';
$joinClause->type = 'inner';
$joinClause->first = 't.id';
$joinClause->operator = '=';
$joinClause->second = 'parent.id';
$joinClause->wheres = [
[
'type' => 'Basic',
'column' => 't.foo',
'operator' => '=',
'value' => 'bar',
'boolean' => 'and',
]
];
$builder = m::mock(Builder::class);
$this->assertEquals(
'INNER JOIN table AS t ON t.id = parent.id AND t.foo = ?',
$this->grammar->compileJoins(
$builder,
[
$joinClause,
]
)
);
}
public function testCompileWhereIn(): void
{
$this->assertEquals('foo IN (?, ?, ?, ?)', $this->grammar->whereIn([
'type' => 'In',
'column' => 'foo',
'operator' => 'IN',
'value' => [1, 2, 3, 4],
'boolean' => 'and',
]));
}
public function testCompileWhereNotIn(): void
{
$this->assertEquals('foo NOT IN (?, ?, ?, ?)', $this->grammar->whereIn([
'type' => 'In',
'column' => 'foo',
'operator' => 'NOT IN',
'value' => [1, 2, 3, 4],
'boolean' => 'and',
]));
}
public function testCompileDistinctColumns(): void
{
$mock = m::mock(Builder::class);
$mock->distinct = true;
$this->assertEquals('SELECT DISTINCT foo, bar', $this->grammar->compileColumns($mock, [
['column' => 'foo', 'as' => null],
['column' => 'bar', 'as' => null],
]));
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Tests\Unit;
use App\FacetSearch\Filters\ProductModel;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\RuleSerializer;
use Tests\TestCase;
class RuleSerializerTest extends TestCase
{
public function testSerialize(): void
{
$rulesSerializer = new RuleSerializer();
$productName = new ProductModel(
ProductModel::NAME,
[
'product_model' => new Criterion('string', [
'operator' => 'contains',
'keyword' => 'foobar',
])
]
);
$expected = [
'id' => 'RULE_PRODUCT_MODEL',
'criteria' => [
'product_model' =>
[
'type' => 'string',
'params' => [
'operator' => 'contains',
'keyword' => 'foobar',
]
]
],
'__meta' => [
'group' => 'other',
],
];
$actual = $rulesSerializer->toArray($productName);
$this->assertEquals($expected, $actual);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace Tests\Unit;
use Openguru\OpenCartFramework\Config\Settings;
use Tests\TestCase;
class SettingsTest extends TestCase
{
private $settings;
protected function setUp(): void
{
$this->settings = new Settings([
'app_name' => 'MyApp',
'debug_mode' => true,
'default_language' => 'en',
'database' => [
'host' => 'localhost',
'port' => 3306,
],
]);
}
public function testGet(): void
{
$this->assertEquals('MyApp', $this->settings->get('app_name'));
$this->assertTrue($this->settings->get('debug_mode'));
$this->assertEquals('en', $this->settings->get('default_language'));
$this->assertEquals('localhost', $this->settings->get('database.host'));
}
public function testGetDefault(): void
{
$this->assertNull($this->settings->get('non_existent_key'));
$this->assertEquals('default_value', $this->settings->get('non_existent_key', 'default_value'));
$this->assertEquals('default_host', $this->settings->get('database.non_existent', 'default_host'));
}
public function testSet(): void
{
$this->settings->set('app_name', 'NewApp');
$this->assertEquals('NewApp', $this->settings->get('app_name'));
$this->settings->set('new_setting', 'new_value');
$this->assertEquals('new_value', $this->settings->get('new_setting'));
$this->settings->set('database.host', '127.0.0.1');
$this->assertEquals('127.0.0.1', $this->settings->get('database.host'));
}
public function testHas(): void
{
$this->assertTrue($this->settings->has('app_name'));
$this->assertTrue($this->settings->has('debug_mode'));
$this->assertFalse($this->settings->has('non_existent_key'));
$this->assertTrue($this->settings->has('database.host'));
$this->assertFalse($this->settings->has('database.non_existent'));
}
public function testRemove(): void
{
$this->settings->remove('debug_mode');
$this->assertFalse($this->settings->has('debug_mode'));
$this->settings->remove('database.host');
$this->assertFalse($this->settings->has('database.host'));
}
public function testGetAll(): void
{
$expected = [
'app_name' => 'MyApp',
'debug_mode' => true,
'default_language' => 'en',
'database' => [
'host' => 'localhost',
'port' => 3306,
],
];
$this->assertEquals($expected, $this->settings->getAll());
}
public function testSetAll(): void
{
$newSettings = [
'app_name' => 'NewApp',
'theme' => 'dark',
];
$this->settings->setAll($newSettings);
$this->assertEquals($newSettings, $this->settings->getAll());
}
public function testDotNotationGetAndSet(): void
{
$this->settings->set('database.username', 'root');
$this->assertEquals('root', $this->settings->get('database.username'));
$this->settings->set('app.env', 'production');
$this->assertEquals('production', $this->settings->get('app.env'));
}
public function testDotNotationHas(): void
{
$this->settings->set('cache.enabled', true);
$this->assertTrue($this->settings->has('cache.enabled'));
$this->assertFalse($this->settings->has('cache.non_existent'));
}
public function testDotNotationRemove(): void
{
$this->settings->set('logging.level', 'debug');
$this->assertTrue($this->settings->has('logging.level'));
$this->settings->remove('logging.level');
$this->assertFalse($this->settings->has('logging.level'));
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Tests\Unit;
use Openguru\OpenCartFramework\Translator\Translator;
use Tests\TestCase;
class TranslatorTest extends TestCase
{
public function testBasicTranslate(): void
{
$translator = new Translator('ru', ['foo' => 'bar']);
$this->assertEquals('bar', $translator->translate('foo'));
}
public function testTranslateWithParams(): void
{
$translator = new Translator('ru', [
'example' => '{placeholder_1} foo {placeholder_2} bar {placeholder_3}',
]);
$placeholders = ['placeholder_1' => 'a', 'placeholder_2' => 'b', 'placeholder_3' => 'c'];
$expected = 'a foo b bar c';
$this->assertEquals(
$expected,
$translator->translate('example', $placeholders)
);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Tests\Validator;
namespace Tests\Unit\Validator;
use Openguru\OpenCartFramework\Validator\ErrorBag;
use Tests\TestCase;

View File

@@ -1,6 +1,6 @@
<?php
namespace Tests\Validator;
namespace Tests\Unit\Validator;
use Openguru\OpenCartFramework\Validator\ValidationRuleNotFoundException;
use Openguru\OpenCartFramework\Validator\ValidationRules\Required;

View File

@@ -0,0 +1,18 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_ATTRIBUTE": {
"criteria": {
"product_attribute": {
"type": "product_attribute",
"params": {
"attribute_id": 3,
"operator": "contains",
"keyword": "test",
"language_id": 1
}
}
}
}
}
}

View File

@@ -0,0 +1,19 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1
LEFT JOIN oc_product_attribute AS product_attributes_facet_69f524aec48a647c231b774b5b3684f5 ON products.product_id = product_attributes_facet_69f524aec48a647c231b774b5b3684f5.product_id
AND product_attributes_facet_69f524aec48a647c231b774b5b3684f5.attribute_id = 3
AND product_attributes_facet_69f524aec48a647c231b774b5b3684f5.language_id = 1
AND product_attributes_facet_69f524aec48a647c231b774b5b3684f5.text LIKE '%test%'
WHERE
product_attributes_facet_69f524aec48a647c231b774b5b3684f5.product_id IS NOT NULL

View File

@@ -0,0 +1,21 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_CATEGORIES": {
"criteria": {
"product_category_ids": {
"type": "product_categories",
"params": {
"operator": "contains",
"value": [
58
]
}
}
},
"__meta": {
"group": "other"
}
}
}
}

View File

@@ -0,0 +1,17 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1
LEFT JOIN oc_product_to_category AS product_category_599af8cd341315a88d056caf99121e59 ON products.product_id = product_category_599af8cd341315a88d056caf99121e59.product_id
AND product_category_599af8cd341315a88d056caf99121e59.category_id IN (58)
WHERE
product_category_599af8cd341315a88d056caf99121e59.product_id IS NOT NULL

View File

@@ -0,0 +1,21 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_CATEGORIES": {
"criteria": {
"product_category_ids": {
"type": "product_categories",
"params": {
"operator": "not_contains",
"value": [
58
]
}
}
},
"__meta": {
"group": "other"
}
}
}
}

View File

@@ -0,0 +1,17 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1
LEFT JOIN oc_product_to_category AS product_category_ac1646c96882395073737c8da0822479 ON products.product_id = product_category_ac1646c96882395073737c8da0822479.product_id
AND product_category_ac1646c96882395073737c8da0822479.category_id IN (58)
WHERE
product_category_ac1646c96882395073737c8da0822479.product_id IS NULL

View File

@@ -0,0 +1,16 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_FOR_MAIN_PAGE": {
"criteria": {
"product_for_main_page": {
"type": "boolean",
"params": {
"operator": "equals",
"value": true
}
}
}
}
}
}

View File

@@ -0,0 +1,15 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1
WHERE
products.product_id IN (1, 2, 3)

View File

@@ -0,0 +1,4 @@
{
"mainpage_products": "featured",
"featured_products": [1, 2, 3]
}

View File

@@ -0,0 +1,16 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_FOR_MAIN_PAGE": {
"criteria": {
"product_for_main_page": {
"type": "boolean",
"params": {
"operator": "equals",
"value": true
}
}
}
}
}
}

View File

@@ -0,0 +1,13 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1

View File

@@ -0,0 +1,4 @@
{
"mainpage_products": "featured",
"featured_products": []
}

View File

@@ -0,0 +1,16 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_FOR_MAIN_PAGE": {
"criteria": {
"product_for_main_page": {
"type": "boolean",
"params": {
"operator": "equals",
"value": true
}
}
}
}
}
}

View File

@@ -0,0 +1,15 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1
ORDER BY
products.date_modified DESC

View File

@@ -0,0 +1,16 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_FOR_MAIN_PAGE": {
"criteria": {
"product_for_main_page": {
"type": "boolean",
"params": {
"operator": "equals",
"value": true
}
}
}
}
}
}

View File

@@ -0,0 +1,15 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1
ORDER BY
products.viewed DESC

View File

@@ -0,0 +1,3 @@
{
"mainpage_products": "most_viewed"
}

View File

@@ -0,0 +1,22 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_MANUFACTURER": {
"criteria": {
"product_manufacturer_ids": {
"type": "product_manufacturer",
"params": {
"operator": "contains",
"value": [
8,
9
]
}
}
},
"__meta": {
"group": "other"
}
}
}
}

View File

@@ -0,0 +1,15 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1
WHERE
products.manufacturer_id IN (8, 9)

View File

@@ -0,0 +1,22 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_MANUFACTURER": {
"criteria": {
"product_manufacturer_ids": {
"type": "product_manufacturer",
"params": {
"operator": "not_contains",
"value": [
8,
9
]
}
}
},
"__meta": {
"group": "other"
}
}
}
}

View File

@@ -0,0 +1,15 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1
WHERE
products.manufacturer_id NOT IN (8, 9)

View File

@@ -0,0 +1,22 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_MODEL": {
"criteria": {
"product_model": {
"type": "product_model",
"params": {
"operator": "contains",
"value": [
"model1",
"model2"
]
}
}
},
"__meta": {
"group": "other"
}
}
}
}

View File

@@ -0,0 +1,15 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1
WHERE
products.model IN ('model1', 'model2')

View File

@@ -0,0 +1,19 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_MODEL": {
"criteria": {
"product_model": {
"type": "product_model",
"params": {
"operator": "equals",
"keyword": "model1"
}
}
},
"__meta": {
"group": "other"
}
}
}
}

View File

@@ -0,0 +1,15 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1
WHERE
TRUE = FALSE

View File

@@ -0,0 +1,19 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_MODEL": {
"criteria": {
"product_model": {
"type": "string",
"params": {
"operator": "is_empty",
"keyword": "product"
}
}
},
"__meta": {
"group": "other"
}
}
}
}

View File

@@ -0,0 +1,13 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1

View File

@@ -0,0 +1,19 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_MODEL": {
"criteria": {
"product_model": {
"type": "string",
"params": {
"operator": "is_not_empty",
"keyword": "product"
}
}
},
"__meta": {
"group": "other"
}
}
}
}

View File

@@ -0,0 +1,13 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1

View File

@@ -0,0 +1,19 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_MODEL": {
"criteria": {
"product_model": {
"type": "string",
"params": {
"operator": "not_contains",
"keyword": "product"
}
}
},
"__meta": {
"group": "other"
}
}
}
}

View File

@@ -0,0 +1,13 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1

View File

@@ -0,0 +1,19 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_MODEL": {
"criteria": {
"product_model": {
"type": "string",
"params": {
"operator": "not_equals",
"keyword": "product"
}
}
},
"__meta": {
"group": "other"
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More