Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- feat(router): implement Router panel with Current Route and Rules sections.
- fix(toolbar): follow debug tags through adapter query URLs and enforce complete JavaScript mutation coverage.
- feat(panel): add `UserRbacRow` typed view-model so adapters render RBAC role and permission rows from a single normalized shape.
- feat: add shared UI parity contracts, Asset composition, and cross-adapter acceptance documentation.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,20 @@ the toolbar data contract, and framework-neutral PHP templates composed with the
does not register assets, render responses, inject toolbar markup, or depend on Yii2, Yii3, an application container, a
view implementation, or a framework request lifecycle.

Shared adapter UI contracts include `PHPForge\Debug\Data\FilterEngine`, `FilterPrefix`, `PageSize`, and `QueryInput`,
plus `PHPForge\Debug\Panel\PanelRenderContext`. Adapters provide a
`PHPForge\Debug\Routing\DebugUrlGeneratorInterface` implementation so portable panel renderers can build history,
panel, and action links without importing a framework URL manager.

Adapters collect framework data, convert it into immutable snapshots, expose toolbar data endpoints, define and
publish assets through their framework, and render the shared templates with their framework view component. They also
own toolbar response injection. Routes, controllers or actions, URL generation, panel metadata, and framework-specific
panel views remain in each adapter. Yii adapters resolve the packaged frontend at
`@vendor/php-forge/debug-core/resources/assets` and configure their own alias for `resources/views`.

The visual and behavioral synchronization contract for the Yii adapters is documented in the
[Yii Debug UI parity baseline](docs/ui-parity-baseline.md).

Current adapters:

- `yii2-extensions/debug`
Expand Down
172 changes: 172 additions & 0 deletions src/Data/FilterEngine.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Data;

use PHPForge\Debug\Helper\Dump;

use function array_filter;
use function array_key_exists;
use function array_values;
use function get_object_vars;
use function is_float;
use function is_int;
use function is_numeric;
use function is_object;
use function is_scalar;
use function is_string;
use function mb_stripos;
use function mb_strtolower;
use function preg_match;

/**
* Filters typed rows against exact, partial, and leading numeric comparison conditions.
*/
final class FilterEngine
{
private const string CHARSET = 'UTF-8';

/**
* @var list<
* array{attribute: string, operator: '>'|'<'|'>=', value: float}
* |array{attribute: string, operator: 'contains'|'same', value: string}
* >
*/
private array $conditions = [];

/**
* Registers an exact, partial, or leading numeric comparison condition.
*
* Empty and non-scalar raw values register nothing, so unfiltered attributes can be passed through unconditionally.
*
* @param string $attribute Row attribute (public property or array key) the condition applies to.
* @param mixed $rawValue Raw filter value as read from the request; scalars are compared as strings.
* @param bool $partial Whether a non-numeric value matches as a case-insensitive substring instead of whole-value.
*/
public function addCondition(string $attribute, mixed $rawValue, bool $partial = false): void
{
$value = is_scalar($rawValue) ? (string) $rawValue : '';

if ($value === '') {
return;
}

if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/D', $value, $matches) === 1) {

Check warning on line 55 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "PregMatchRemoveFlags": @@ @@ return; } - if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/D', $value, $matches) === 1) { + if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/', $value, $matches) === 1) { $this->conditions[] = [ 'attribute' => $attribute, 'operator' => $matches[1],

Check warning on line 55 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "PregMatchRemoveDollar": @@ @@ return; } - if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/D', $value, $matches) === 1) { + if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*/D', $value, $matches) === 1) { $this->conditions[] = [ 'attribute' => $attribute, 'operator' => $matches[1],

Check warning on line 55 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "PregMatchRemoveCaret": @@ @@ return; } - if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/D', $value, $matches) === 1) { + if (preg_match('/\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/D', $value, $matches) === 1) { $this->conditions[] = [ 'attribute' => $attribute, 'operator' => $matches[1],

Check warning on line 55 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "PregMatchRemoveFlags": @@ @@ return; } - if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/D', $value, $matches) === 1) { + if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/', $value, $matches) === 1) { $this->conditions[] = [ 'attribute' => $attribute, 'operator' => $matches[1],

Check warning on line 55 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "PregMatchRemoveDollar": @@ @@ return; } - if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/D', $value, $matches) === 1) { + if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*/D', $value, $matches) === 1) { $this->conditions[] = [ 'attribute' => $attribute, 'operator' => $matches[1],

Check warning on line 55 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "PregMatchRemoveCaret": @@ @@ return; } - if (preg_match('/^\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/D', $value, $matches) === 1) { + if (preg_match('/\s*([<>])\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*$/D', $value, $matches) === 1) { $this->conditions[] = [ 'attribute' => $attribute, 'operator' => $matches[1],
$this->conditions[] = [
'attribute' => $attribute,
'operator' => $matches[1],
'value' => (float) $matches[2],
];

return;
}

$this->conditions[] = [
'attribute' => $attribute,
'operator' => $partial ? 'contains' : 'same',
'value' => $value,
];
}

/**
* Registers a numeric greater-than-or-equal condition.
*
* @param string $attribute Row attribute (public property or array key) the condition applies to.
* @param float $value Inclusive lower bound the attribute value must reach.
*/
public function addMinimumCondition(string $attribute, float $value): void
{
$this->conditions[] = ['attribute' => $attribute, 'operator' => '>=', 'value' => $value];
}

/**
* Applies all registered conditions and resets the engine for the next run.
*
* @template TRow of array<string, mixed>|object
*
* @param array<int, TRow> $rows Rows to filter; typed row objects or string-keyed arrays.
*
* @return list<TRow> Rows matching every registered condition, reindexed.
*/
public function filter(array $rows): array
{
$filtered = array_values(array_filter($rows, $this->matches(...)));

$this->conditions = [];

return $filtered;
}

/**
* @param array<string, mixed>|object $row Typed row object or string-keyed array.
*/
private function matches(array|object $row): bool
{
foreach ($this->conditions as $condition) {
$attribute = $condition['attribute'];

$values = is_object($row) ? get_object_vars($row) : $row;

if (!array_key_exists($attribute, $values)) {
return false;
}

$candidate = $values[$attribute];
$operator = $condition['operator'];

if ($operator === '>' || $operator === '<' || $operator === '>=') {
$expected = $condition['value'];

if (
!is_float($expected)
|| !is_int($candidate) && !is_float($candidate) && !is_string($candidate)
|| !is_numeric($candidate)
) {
return false;
}

$candidate = (float) $candidate;

Check warning on line 129 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "CastFloat": @@ @@ return false; } - $candidate = (float) $candidate; + $candidate = $candidate; $matched = match ($operator) { '>' => $candidate > $expected,

Check warning on line 129 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "CastFloat": @@ @@ return false; } - $candidate = (float) $candidate; + $candidate = $candidate; $matched = match ($operator) { '>' => $candidate > $expected,

$matched = match ($operator) {

Check warning on line 131 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "MatchArmRemoval": @@ @@ $candidate = (float) $candidate; $matched = match ($operator) { - '>' => $candidate > $expected, '<' => $candidate < $expected, default => $candidate >= $expected, };

Check warning on line 131 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "MatchArmRemoval": @@ @@ $candidate = (float) $candidate; $matched = match ($operator) { - '>' => $candidate > $expected, '<' => $candidate < $expected, default => $candidate >= $expected, };
'>' => $candidate > $expected,

Check warning on line 132 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "GreaterThan": @@ @@ $candidate = (float) $candidate; $matched = match ($operator) { - '>' => $candidate > $expected, + '>' => $candidate >= $expected, '<' => $candidate < $expected, default => $candidate >= $expected, };

Check warning on line 132 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "GreaterThan": @@ @@ $candidate = (float) $candidate; $matched = match ($operator) { - '>' => $candidate > $expected, + '>' => $candidate >= $expected, '<' => $candidate < $expected, default => $candidate >= $expected, };
'<' => $candidate < $expected,

Check warning on line 133 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "LessThan": @@ @@ $matched = match ($operator) { '>' => $candidate > $expected, - '<' => $candidate < $expected, + '<' => $candidate <= $expected, default => $candidate >= $expected, };

Check warning on line 133 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "LessThan": @@ @@ $matched = match ($operator) { '>' => $candidate > $expected, - '<' => $candidate < $expected, + '<' => $candidate <= $expected, default => $candidate >= $expected, };
default => $candidate >= $expected,
};

if (!$matched) {
return false;
}

continue;

Check warning on line 141 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "Continue_": @@ @@ return false; } - continue; + break; } $candidate = is_scalar($candidate)

Check warning on line 141 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "Continue_": @@ @@ return false; } - continue; + break; } $candidate = is_scalar($candidate)
}

$candidate = is_scalar($candidate)
? (string) $candidate
: Dump::export($candidate);

$expected = $condition['value'];

if (!is_string($expected)) {
return false;
}

if ($operator === 'contains') {
if (mb_stripos($candidate, $expected, 0, self::CHARSET) === false) {

Check warning on line 155 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "MBString": @@ @@ } if ($operator === 'contains') { - if (mb_stripos($candidate, $expected, 0, self::CHARSET) === false) { + if (stripos($candidate, $expected, 0) === false) { return false; }

Check warning on line 155 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "MBString": @@ @@ } if ($operator === 'contains') { - if (mb_stripos($candidate, $expected, 0, self::CHARSET) === false) { + if (stripos($candidate, $expected, 0) === false) { return false; }
return false;
}

continue;

Check warning on line 159 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "Continue_": @@ @@ return false; } - continue; + break; } if (

Check warning on line 159 in src/Data/FilterEngine.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "Continue_": @@ @@ return false; } - continue; + break; } if (
}

if (
mb_strtolower($candidate, self::CHARSET)
!== mb_strtolower($expected, self::CHARSET)
) {
return false;
}
}

return true;
}
}
33 changes: 33 additions & 0 deletions src/Data/FilterPrefix.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Data;

/**
* Freezes the `Prefix[attribute]` query-parameter vocabulary shared by every debug-panel filter form.
*/
final class FilterPrefix
{
public const string ASSET = 'Asset';

public const string DB = 'Db';

public const string DEBUG = 'Debug';

public const string EVENT = 'Event';

public const string LOG = 'Log';

public const string MAIL = 'Mail';

public const string PROFILE = 'Profile';

public const string QUEUE = 'Queue';

public const string ROUTER = 'Router';

public const string TIMELINE = 'Timeline';

public const string USER = 'User';
}
108 changes: 108 additions & 0 deletions src/Data/PageSize.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Data;

use UIAwesome\Html\Form\{Option, Select};
use UIAwesome\Html\Phrasing\{Label, Span};

use function is_numeric;
use function min;
use function strcasecmp;

/**
* Resolves the `per-page` grid page size and renders the shared page-size selector.
*/
final class PageSize
{
/**
* Default page size applied when no `per-page` parameter is supplied or the value is invalid.
*/
public const int DEFAULT = 50;

/**
* Hard cap on the number of rows per page.
*/
public const int MAX = 1000;

/**
* Selector options in display order; the literal `all` disables pagination.
*/
public const array OPTIONS = ['10', '25', '50', '100', 'all'];

/**
* Returns the `per-page` selector state, canonicalizing `all` and falling back to the default.
*
* @param string|null $raw Raw `per-page` query-parameter value, or `null` when absent.
* @param int $default Page size used when no value is supplied.
*/
public static function current(string|null $raw, int $default = self::DEFAULT): string
{
if ($raw === null) {
return (string) $default;
}

return strcasecmp($raw, 'all') === 0 ? 'all' : $raw;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Resolves the raw `per-page` value into an effective page size.
*
* @param string|null $raw Raw `per-page` query-parameter value, or `null` when absent.
* @param positive-int $default Page size used when no value is supplied or the value is invalid.
*
* @return positive-int|null Effective page size capped at {@see MAX}, or `null` when `all` disables pagination.
*/
public static function resolve(string|null $raw, int $default = self::DEFAULT): int|null
{
if ($raw !== null && strcasecmp($raw, 'all') === 0) {
return null;
}

$size = $raw !== null && is_numeric($raw) ? (int) $raw : $default;

if ($size <= 0) {
$size = $default;
}

return min($size, self::MAX);
}

/**
* Renders the inline page-size selector shown in the grid summary header.
*
* Usage example:
* ```php
* $html = \PHPForge\Debug\Data\PageSize::selectorHtml('50');
* ```
*
* @param string $current Currently selected raw value (one of {@see OPTIONS} for a highlighted option).
*/
public static function selectorHtml(string $current): string
{
$select = Select::tag()
->addDataAttribute('yii-debug-pagesize', true)
->class('yii-debug-grid-pagesize-select')
->name('per-page');

foreach (self::OPTIONS as $row) {
$select = $select->option(
Option::tag()
->value($row)
->content($row === 'all' ? 'All' : $row)
->selected($row === $current),
);
}

return Label::tag()
->class('yii-debug-grid-pagesize')
->html(
Span::tag()
->class('yii-debug-grid-pagesize-label')
->content('Rows'),
$select,
)
->render();
}
}
Loading
Loading