Files
ave-cms/system/App/Content/Requests/RequestExecutorComparator.php
T
2026-07-27 12:58:44 +03:00

293 lines
10 KiB
PHP

<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file system/App/Content/Requests/RequestExecutorComparator.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
namespace App\Content\Requests;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Content\ContentTables;
use App\Frontend\RequestRenderer;
use DB;
/** Runs Legacy and Native side by side and reports differences without changing public output. */
class RequestExecutorComparator
{
const VERIFY_LIMIT = 5000;
public function compare($requestId, $limit = 20)
{
$requestId = (int) $requestId;
$plan = NativeRequestPlanCompiler::compile($requestId);
return $this->comparePlan($requestId, $plan, $limit);
}
/** Compare Legacy with an explicit candidate plan without persisting it. */
public function comparePlan($requestId, array $plan, $limit = 20)
{
$requestId = (int) $requestId;
$limit = max(self::VERIFY_LIMIT, max(1, (int) $limit));
if (empty($plan['eligible'])) {
return array(
'status' => 'unsupported',
'matched' => false,
'reasons' => isset($plan['reasons']) ? $plan['reasons'] : array(),
'plan' => $plan,
);
}
$contexts = self::runtimeContexts($plan);
$results = array();
$verification = array();
foreach ($contexts as $context) {
$result = $this->compareContext($requestId, $plan, $limit, $context['input']);
$results[] = $result;
$verification[] = array(
'label' => $context['label'],
'keys' => array_keys($context['input']),
'status' => $result['status'],
'legacy_total' => isset($result['legacy']['total']) ? (int) $result['legacy']['total'] : null,
'native_total' => isset($result['native']['total']) ? (int) $result['native']['total'] : null,
'legacy_ids_hash' => isset($result['legacy']['ids']) ? self::idsHash($result['legacy']['ids']) : '',
'native_ids_hash' => isset($result['native']['ids']) ? self::idsHash($result['native']['ids']) : '',
);
}
$selected = $results[0];
foreach ($results as $result) {
if (in_array($result['status'], array('mismatch', 'order_diff', 'incomplete'), true)) {
$selected = $result;
break;
}
}
$allMatched = true;
foreach ($results as $result) {
$allMatched = $allMatched && !empty($result['matched']);
}
$selected['matched'] = $allMatched;
$selected['runtime_verification'] = $verification;
return $selected;
}
protected static function idsHash(array $ids)
{
return hash('sha256', implode(',', array_map('intval', $ids)));
}
protected function compareContext($requestId, array $plan, $limit, array $runtimeInput)
{
$previousRequest = $_REQUEST;
$_REQUEST = $runtimeInput;
try {
$legacy = (new RequestRenderer())->render($requestId, array(
'RETURN_DATA' => 1,
'LIMIT' => $limit,
'START' => 0,
'EXECUTOR' => 'legacy',
));
$native = (new NativeRequestExecutor())->execute($requestId, array(
'LIMIT' => $limit,
'START' => 0,
'RUNTIME_INPUT' => $runtimeInput,
), $plan);
} finally {
$_REQUEST = $previousRequest;
}
$legacyIds = self::ids(isset($legacy['rows']) ? $legacy['rows'] : array());
$nativeIds = self::ids(isset($native['rows']) ? $native['rows'] : array());
$missing = array_values(array_diff($legacyIds, $nativeIds));
$extra = array_values(array_diff($nativeIds, $legacyIds));
$totalsMatch = (int) $legacy['total'] === (int) $native['total'];
$orderMatch = $legacyIds === $nativeIds;
$setsMatch = !$missing && !$extra;
$complete = max((int) $legacy['total'], (int) $native['total']) <= self::VERIFY_LIMIT;
$deterministic = !empty($plan['deterministic_order']);
$orderDiagnostics = !$orderMatch && $setsMatch
? RequestOrderDiagnostics::explain($legacyIds, $nativeIds, $plan)
: array();
$status = !$complete
? 'incomplete'
: ($totalsMatch && $orderMatch
? ($deterministic ? 'matched' : 'unstable')
: ($totalsMatch && $setsMatch ? 'order_diff' : 'mismatch'));
return array(
'status' => $status,
'matched' => $complete && $deterministic && $totalsMatch && $orderMatch,
'complete' => $complete,
'deterministic_order' => $deterministic,
'checked' => min(self::VERIFY_LIMIT, max(count($legacyIds), count($nativeIds))),
'totals_match' => $totalsMatch,
'order_match' => $orderMatch,
'legacy' => array(
'total' => (int) $legacy['total'],
'ids' => $legacyIds,
'query_time' => (float) $legacy['query_time'],
'sql' => (string) $legacy['sql'],
),
'native' => array(
'total' => (int) $native['total'],
'ids' => $nativeIds,
'query_time' => (float) $native['query_time'],
'sql' => (string) $native['sql'],
),
'missing_ids' => $missing,
'extra_ids' => $extra,
'order_diagnostics' => $orderDiagnostics,
'reasons' => self::reasons($status, $orderDiagnostics),
'plan' => $plan,
);
}
protected static function runtimeContexts(array $plan)
{
$contexts = array(array('label' => 'Без входных значений', 'input' => array()));
$all = array();
foreach (isset($plan['runtime_sources']) ? $plan['runtime_sources'] : array() as $key => $source) {
$value = self::runtimeProbe($plan, (string) $key, $source);
$input = array((string) $key => $value);
$contexts[] = array('label' => (string) $key, 'input' => $input);
$all[(string) $key] = $value;
}
if (count($all) > 1) {
$contexts[] = array('label' => 'Все входные значения', 'input' => $all);
}
return $contexts;
}
protected static function runtimeProbe(array $plan, $key, array $source)
{
$condition = self::runtimeCondition($plan, $key);
$cast = isset($source['cast']) ? (string) $source['cast'] : '';
$shape = isset($source['shape']) ? (string) $source['shape'] : 'scalar';
if (!$condition || $cast === 'constant' || $shape === 'presence') {
if (isset($source['source'])) { return RequestConditionValue::probe($source); }
return RequestRuntimeValue::probe($source);
}
$fieldId = (int) $condition['field_id'];
$rows = DB::query(
'SELECT rf.field_value,rf.field_number_value FROM %b rf'
. ' INNER JOIN %b d ON d.Id=rf.document_id'
. ' WHERE rf.rubric_field_id=%i AND d.rubric_id=%i AND d.document_deleted=%s'
. ' AND d.document_status=%s ORDER BY rf.document_id ASC LIMIT 100',
ContentTables::table('document_fields'),
ContentTables::table('documents'),
$fieldId,
(int) $plan['rubric_id'],
'0',
'1'
)->getAll();
foreach ($rows ?: array() as $row) {
if ($cast === 'integer' || $cast === 'decimal') {
$value = is_numeric($row['field_number_value'])
? (string) $row['field_number_value']
: (string) $row['field_value'];
if (is_numeric($value)) {
$value = $cast === 'integer' ? (string) (int) $value : (string) (float) $value;
if ($shape === 'range') { return array('min' => $value, 'max' => $value); }
if ($shape === 'list') { return array($value); }
return $value;
}
}
$pipe = isset($source['transform']) && $source['transform'] === 'pipe_member'
? true
: strpos($cast, 'pipe_') === 0;
$value = self::textProbe((string) $row['field_value'], $pipe);
if ($value !== '') {
if ($shape === 'range') { return array('min' => $value, 'max' => $value); }
if ($shape === 'list') { return array($value); }
return $value;
}
}
if (isset($source['source'])) { return RequestConditionValue::probe($source); }
return RequestRuntimeValue::probe($source);
}
protected static function runtimeCondition(array $plan, $key)
{
foreach (isset($plan['groups']) ? $plan['groups'] : array() as $group) {
foreach (isset($group['conditions']) ? $group['conditions'] : array() as $condition) {
if (isset($condition['value_source']['key']) && $condition['value_source']['key'] === $key) {
return $condition;
}
}
}
return null;
}
protected static function textProbe($value, $pipe)
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
if ($pipe && preg_match('/\|([^|\r\n]{1,100})\|/', $value, $match) === 1) {
return trim((string) $match[1]);
}
if ($pipe) {
return '';
}
return function_exists('mb_substr') ? mb_substr($value, 0, 64, 'UTF-8') : substr($value, 0, 64);
}
protected static function reasons($status, array $orderDiagnostics)
{
if ($status === 'unstable') {
return array('Состав совпадает, но сортировка не заканчивается уникальным ID. Запрос оставлен в Legacy, чтобы порядок на сайте не менялся.');
}
if ($status === 'order_diff') {
return !empty($orderDiagnostics['keys_equal'])
? array('Первая перестановка произошла при одинаковом значении сортировки. Нужен финальный ID.')
: array('Порядок различается на разных значениях сортировки. Проверьте настройки и Native-компилятор.');
}
return array();
}
public static function resultFingerprint(array $result)
{
$total = isset($result['total']) ? (int) $result['total'] : 0;
$ids = isset($result['ids']) && is_array($result['ids']) ? array_values($result['ids']) : array();
return hash('sha256', json_encode(array($total, $ids), JSON_UNESCAPED_SLASHES));
}
protected static function ids(array $rows)
{
$result = array();
foreach ($rows as $row) {
$id = is_object($row) && isset($row->Id)
? (int) $row->Id
: (is_array($row) && isset($row['Id']) ? (int) $row['Id'] : 0);
if ($id > 0) {
$result[] = $id;
}
}
return $result;
}
}