PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Interpreter w PHP – własna gramatyka reguł rabatowych, parser, drzewo wyrażeń

by Henryk Tews / wtorek, 17 marca 2026 / Opublikowano w PHP, Wzorce projektowe

Interpreter to wzorzec GoF który pozwala zdefiniować gramatykę dla prostego języka i zbudować interpreter który przetwarza wyrażenia w tym języku. Brzmi akademicko, ale ma bardzo konkretne zastosowania: reguły rabatowe konfigurowane przez operatorów bez zmian kodu, wyrażenia filtrujące produkty, warunki widoczności komponentów w CMS. Buduję od zera mini-interpreter reguł promocyjnych w PHP.

Kiedy Interpreter ma sens?

Interpreter jest uzasadniony gdy:

  • Masz reguły które zmieniają się często i muszą być konfigurowalne przez nie-developerów
  • Reguły można wyrazić w prostej gramatyce (wyrażenia logiczne, matematyczne)
  • Złożoność gramatyki jest mała – duże języki lepiej obsłuży ANTLR lub dedykowany parser

Gramatyka reguł rabatowych

# Przykłady reguł które chcemy parsować i wykonywać:
# "order.total > 500 AND customer.group = 'vip'"
# "product.category IN ['shoes', 'bags'] OR product.brand = 'Nike'"
# "NOT order.has_coupon AND order.total >= 200"

# Gramatyka (uproszczony EBNF):
# expression  = term (("AND" | "OR") term)*
# term        = "NOT" term | comparison | "(" expression ")"
# comparison  = identifier operator value
# identifier  = word ("." word)*
# operator    = "=" | "!=" | ">" | ">=" | "<" | "<=" | "IN" | "NOT IN"
# value       = number | string | "[" value ("," value)* "]"

Abstract Expression Interface

<?php

declare(strict_types=1);

// Context - dane na których interpretujemy wyrażenie
class RuleContext
{
    private array $data;

    public function __construct(array $data)
    {
        $this->data = $data;
    }

    public function resolve(string $path): mixed
    {
        $parts  = explode('.', $path);
        $current = $this->data;

        foreach ($parts as $part) {
            if (!isset($current[$part])) {
                return null;
            }
            $current = $current[$part];
        }

        return $current;
    }
}

// Abstract Expression - interfejs dla wszystkich wyrażeń
interface ExpressionInterface
{
    public function interpret(RuleContext $context): bool;
    public function __toString(): string;
}

Terminal Expressions – liście drzewa

<?php

declare(strict_types=1);

// Wyrażenie porównania: "order.total > 500"
final class ComparisonExpression implements ExpressionInterface
{
    public function __construct(
        private readonly string $identifier,   // "order.total"
        private readonly string $operator,     // ">"
        private readonly mixed  $value         // 500
    ) {}

    public function interpret(RuleContext $context): bool
    {
        $left = $context->resolve($this->identifier);

        return match($this->operator) {
            '='       => $left == $this->value,
            '!='      => $left != $this->value,
            '>'       => $left > $this->value,
            '>='      => $left >= $this->value,
            '<'       => $left < $this->value,
            '<='      => $left <= $this->value,
            'IN'      => is_array($this->value) && in_array($left, $this->value, true),
            'NOT IN'  => is_array($this->value) && !in_array($left, $this->value, true),
            default   => throw new \InvalidArgumentException("Unknown operator: {$this->operator}"),
        };
    }

    public function __toString(): string
    {
        $val = is_array($this->value) ? '[' . implode(', ', $this->value) . ']' : $this->value;
        return "{$this->identifier} {$this->operator} {$val}";
    }
}

Non-Terminal Expressions – kompozyty

<?php

declare(strict_types=1);

// AND - oba warunki muszą być spełnione
final class AndExpression implements ExpressionInterface
{
    public function __construct(
        private readonly ExpressionInterface $left,
        private readonly ExpressionInterface $right
    ) {}

    public function interpret(RuleContext $context): bool
    {
        // Short-circuit: jeśli left = false, nie obliczaj right
        return $this->left->interpret($context)
            && $this->right->interpret($context);
    }

    public function __toString(): string
    {
        return "({$this->left} AND {$this->right})";
    }
}

// OR - przynajmniej jeden warunek
final class OrExpression implements ExpressionInterface
{
    public function __construct(
        private readonly ExpressionInterface $left,
        private readonly ExpressionInterface $right
    ) {}

    public function interpret(RuleContext $context): bool
    {
        return $this->left->interpret($context)
            || $this->right->interpret($context);
    }

    public function __toString(): string
    {
        return "({$this->left} OR {$this->right})";
    }
}

// NOT - negacja
final class NotExpression implements ExpressionInterface
{
    public function __construct(
        private readonly ExpressionInterface $expression
    ) {}

    public function interpret(RuleContext $context): bool
    {
        return !$this->expression->interpret($context);
    }

    public function __toString(): string
    {
        return "(NOT {$this->expression})";
    }
}

Parser – buduje drzewo wyrażeń z tekstu

<?php

declare(strict_types=1);

// Prosty parser rekurencyjnego zstępowania
class RuleParser
{
    private array $tokens = [];
    private int $position = 0;

    public function parse(string $rule): ExpressionInterface
    {
        $this->tokens   = $this->tokenize($rule);
        $this->position = 0;

        return $this->parseExpression();
    }

    // expression = term (("AND" | "OR") term)*
    private function parseExpression(): ExpressionInterface
    {
        $left = $this->parseTerm();

        while ($this->peek() === 'AND' || $this->peek() === 'OR') {
            $operator = $this->consume();

            $right = $this->parseTerm();
            $left  = $operator === 'AND'
                ? new AndExpression($left, $right)
                : new OrExpression($left, $right);
        }

        return $left;
    }

    // term = "NOT" term | "(" expression ")" | comparison
    private function parseTerm(): ExpressionInterface
    {
        if ($this->peek() === 'NOT') {
            $this->consume();
            return new NotExpression($this->parseTerm());
        }

        if ($this->peek() === '(') {
            $this->consume(); // (
            $expr = $this->parseExpression();
            $this->consume(); // )
            return $expr;
        }

        return $this->parseComparison();
    }

    // comparison = identifier operator value
    private function parseComparison(): ExpressionInterface
    {
        $identifier = $this->consume(); // np. "order.total"
        $operator   = $this->consume(); // np. ">"

        // Obsługa "NOT IN"
        if ($operator === 'NOT' && $this->peek() === 'IN') {
            $this->consume();
            $operator = 'NOT IN';
        }

        $value = $this->parseValue();

        return new ComparisonExpression($identifier, $operator, $value);
    }

    private function parseValue(): mixed
    {
        $token = $this->consume();

        // Lista wartości: ['shoes', 'bags']
        if ($token === '[') {
            $values = [];
            while ($this->peek() !== ']') {
                $values[] = $this->parseScalar($this->consume());
                if ($this->peek() === ',') {
                    $this->consume();
                }
            }
            $this->consume(); // ]
            return $values;
        }

        return $this->parseScalar($token);
    }

    private function parseScalar(string $token): mixed
    {
        // String w cudzysłowiu
        if (str_starts_with($token, "'") && str_ends_with($token, "'")) {
            return trim($token, "'");
        }

        // Liczba
        if (is_numeric($token)) {
            return str_contains($token, '.') ? (float) $token : (int) $token;
        }

        // Boolean
        if ($token === 'true')  return true;
        if ($token === 'false') return false;
        if ($token === 'null')  return null;

        return $token;
    }

    private function tokenize(string $rule): array
    {
        // Prosta tokenizacja przez regex
        preg_match_all(
            "/\w+\.\w+|\bNOT\b|\bAND\b|\bOR\b|\bIN\b|[><=!]+|'[^']*'|\[|\]|[(),]|\d+\.?\d*/",
            $rule,
            $matches
        );

        return $matches[0];
    }

    private function peek(): ?string
    {
        return $this->tokens[$this->position] ?? null;
    }

    private function consume(): string
    {
        $token = $this->tokens[$this->position]
            ?? throw new \RuntimeException("Unexpected end of rule");

        $this->position++;
        return $token;
    }
}

Użycie w praktyce – reguły rabatowe

<?php

$parser = new RuleParser();

// Reguła z bazy danych / panelu admina
$ruleText = "order.total >= 500 AND customer.group IN ['vip', 'wholesale'] AND NOT order.has_coupon";

$expression = $parser->parse($ruleText);

// Kontekst dla konkretnego zamówienia
$context = new RuleContext([
    'order' => [
        'total'      => 650.0,
        'has_coupon' => false,
        'item_count' => 5,
    ],
    'customer' => [
        'group'          => 'vip',
        'order_count'    => 12,
        'registration_days' => 365,
    ],
]);

$qualifies = $expression->interpret($context);
echo "Kwalifikuje do rabatu: " . ($qualifies ? 'TAK' : 'NIE') . "\n"; // TAK

// Debug - wypisz drzewo wyrażeń
echo $expression . "\n";
// ((order.total >= 500 AND customer.group IN [vip, wholesale]) AND (NOT (order.has_coupon = true)))

// Inny kontekst – nie kwalifikuje
$context2 = new RuleContext([
    'order' => ['total' => 300.0, 'has_coupon' => true],
    'customer' => ['group' => 'retail'],
]);

echo "Kwalifikuje: " . ($expression->interpret($context2) ? 'TAK' : 'NIE') . "\n"; // NIE

Integracja z Magento 2 – reguły w bazie danych

<?php

declare(strict_types=1);

// Serwis który ładuje reguły z bazy i ewaluuje dla zamówienia
class OrderDiscountRuleEngine
{
    public function __construct(
        private RuleParser $parser,
        private \Magento\Framework\App\ResourceConnection $resourceConnection
    ) {}

    public function getApplicableDiscounts(\Magento\Sales\Api\Data\OrderInterface $order): array
    {
        $rules    = $this->loadActiveRules();
        $context  = $this->buildContext($order);
        $discounts = [];

        foreach ($rules as $rule) {
            try {
                $expression = $this->parser->parse($rule['condition']);
                if ($expression->interpret($context)) {
                    $discounts[] = [
                        'rule_id'   => $rule['rule_id'],
                        'label'     => $rule['label'],
                        'discount'  => (float) $rule['discount_percent'],
                    ];
                }
            } catch (\Exception $e) {
                // Loguj błąd parsowania reguły, ale nie przerwaj checkout
                $this->logger->error("Invalid rule condition: {$rule['rule_id']}", [
                    'condition' => $rule['condition'],
                    'error'     => $e->getMessage(),
                ]);
            }
        }

        return $discounts;
    }

    private function buildContext(\Magento\Sales\Api\Data\OrderInterface $order): RuleContext
    {
        return new RuleContext([
            'order' => [
                'total'       => $order->getGrandTotal(),
                'item_count'  => count($order->getItems()),
                'has_coupon'  => !empty($order->getCouponCode()),
                'currency'    => $order->getOrderCurrencyCode(),
            ],
            'customer' => [
                'group'       => $order->getCustomerGroupId(),
                'is_logged_in' => $order->getCustomerId() !== null,
            ],
        ]);
    }

    private function loadActiveRules(): array
    {
        $select = $this->resourceConnection->getConnection()->select()
            ->from($this->resourceConnection->getTableName('vendor_discount_rules'))
            ->where('is_active = ?', 1)
            ->order('priority ASC');

        return $this->resourceConnection->getConnection()->fetchAll($select);
    }
}

Podsumowanie

Wzorzec Interpreter daje coś rzadkiego: możliwość dodawania nowych reguł biznesowych przez konfigurację, bez deploymentu kodu. Operator może wpisać order.total > 1000 AND customer.group = 'premium' w panelu admina i reguła działa natychmiast. Parser zbudowany metodą rekurencyjnego zstępowania obsługuje arbitralnie zagnieżdżone wyrażenia. Kluczowe ograniczenie: gramatyka musi być prosta. Gdy potrzebujesz pełnego języka programowania – lepiej sięgnąć po Twig Sandbox albo dedykowany DSL.

About Henryk Tews

Co możesz przeczytać następne

WooCommerce vs Magento 2 – architektura, hooks vs DI, kiedy który
Wzorzec Decorator w PHP – kompozycja zamiast dziedziczenia, przykład z cache repository
Iterator i Generator – leniwe przetwarzanie, yield, IteratorAggregate, benchmark pamięci
  • Publikacje
  • O autorze
  • Kontakt

© 2026 Created by

GÓRA
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Zawsze aktywne
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Zarządzaj opcjami
  • Zarządzaj serwisami
  • Zarządzaj {vendor_count} dostawcami
  • Przeczytaj więcej o tych celach
Zobacz preferencje
  • {title}
  • {title}
  • {title}