276 lines
8.4 KiB
PHP
276 lines
8.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace FecaMailshots\Domain;
|
|
|
|
final class DslParser
|
|
{
|
|
/** @var list<DslToken> */
|
|
private array $tokens = [];
|
|
private int $pos = 0;
|
|
|
|
/**
|
|
* @return array{sources:list<string>, where:array<int, mixed>}
|
|
*/
|
|
public function parse(string $input): array
|
|
{
|
|
$lexer = new DslLexer();
|
|
$this->tokens = $lexer->lex($input);
|
|
$this->pos = 0;
|
|
|
|
$sources = $this->parseSourceExpr();
|
|
$where = [];
|
|
|
|
if ($this->matchKw('where')) {
|
|
$where = $this->parsePredicateExpr();
|
|
}
|
|
|
|
$this->expect('EOF');
|
|
|
|
return [
|
|
'sources' => $sources,
|
|
'where' => $where,
|
|
];
|
|
}
|
|
|
|
/** @return list<string> */
|
|
private function parseSourceExpr(): array
|
|
{
|
|
$sources = [];
|
|
$sources[] = $this->parseSourceRef();
|
|
|
|
while ($this->matchKw('and')) {
|
|
$sources[] = $this->parseSourceRef();
|
|
}
|
|
|
|
return array_values(array_unique($sources));
|
|
}
|
|
|
|
private function parseSourceRef(): string
|
|
{
|
|
$left = $this->expect('IDENT')->value;
|
|
if ($this->match('.')) {
|
|
$right = $this->expect('IDENT')->value;
|
|
return $left . '.' . $right;
|
|
}
|
|
return strtolower($left);
|
|
}
|
|
|
|
/** @return array<int, mixed> */
|
|
private function parsePredicateExpr(): array
|
|
{
|
|
$predicates = [];
|
|
$predicates[] = $this->parsePredicateTerm();
|
|
|
|
while ($this->matchKw('and')) {
|
|
$predicates[] = $this->parsePredicateTerm();
|
|
}
|
|
|
|
return $predicates;
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function parsePredicateTerm(): array
|
|
{
|
|
$negated = $this->matchKw('not');
|
|
|
|
if ($this->match('(')) {
|
|
$inner = $this->parsePredicateExpr();
|
|
$this->expect(')');
|
|
return ['type' => 'group', 'not' => $negated, 'items' => $inner];
|
|
}
|
|
|
|
$ident = $this->expect('IDENT')->value;
|
|
|
|
if ($this->isBareFilter($ident)) {
|
|
return ['type' => 'filter', 'not' => $negated, 'name' => strtolower($ident), 'args' => []];
|
|
}
|
|
|
|
if ($this->match('(')) {
|
|
$args = [];
|
|
if (!$this->match(')')) {
|
|
$args = $this->parseValueList();
|
|
$this->expect(')');
|
|
}
|
|
return ['type' => 'filter', 'not' => $negated, 'name' => strtolower($ident), 'args' => $args];
|
|
}
|
|
|
|
$lhs = $this->parseFieldRefFromIdent($ident);
|
|
|
|
if ($this->matchKw('in')) {
|
|
$this->expect('(');
|
|
if ($this->peek()->type === 'IDENT' && $this->peek(1)->type === '.') {
|
|
$rhs = $this->parseFieldRef();
|
|
$this->expect(')');
|
|
return ['type' => 'set_ref', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $rhs, 'op' => 'in'];
|
|
}
|
|
|
|
$rhsValues = $this->parseValueList();
|
|
$this->expect(')');
|
|
return ['type' => 'set_values', 'not' => $negated, 'lhs' => $lhs, 'values' => $rhsValues, 'op' => 'in'];
|
|
}
|
|
|
|
if ($this->matchKw('not')) {
|
|
$this->expectKw('in');
|
|
$this->expect('(');
|
|
if ($this->peek()->type === 'IDENT' && $this->peek(1)->type === '.') {
|
|
$rhs = $this->parseFieldRef();
|
|
$this->expect(')');
|
|
return ['type' => 'set_ref', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $rhs, 'op' => 'not in'];
|
|
}
|
|
|
|
$rhsValues = $this->parseValueList();
|
|
$this->expect(')');
|
|
return ['type' => 'set_values', 'not' => $negated, 'lhs' => $lhs, 'values' => $rhsValues, 'op' => 'not in'];
|
|
}
|
|
|
|
if ($this->matchKw('blank')) {
|
|
return ['type' => 'blank', 'not' => $negated, 'lhs' => $lhs];
|
|
}
|
|
|
|
$opToken = $this->peek();
|
|
$op = '';
|
|
if ($opToken->type === 'OP') {
|
|
$op = $this->next()->value;
|
|
} elseif ($opToken->type === 'KW' && in_array($opToken->value, ['contains', 'starts-with', 'ends-with'], true)) {
|
|
$op = $this->next()->value;
|
|
} else {
|
|
throw new AppError('dsl_parse', 'Expected comparison operator', ['offset' => $opToken->offset]);
|
|
}
|
|
|
|
if ($this->peek()->type === 'IDENT' && $this->peek(1)->type === '.') {
|
|
$rhsField = $this->parseFieldRef();
|
|
return ['type' => 'comparison_field', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $rhsField, 'op' => $op];
|
|
}
|
|
|
|
$value = $this->parseValue();
|
|
return ['type' => 'comparison_value', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $value, 'op' => $op];
|
|
}
|
|
|
|
/** @return array{source:string, field:string} */
|
|
private function parseFieldRefFromIdent(string $ident): array
|
|
{
|
|
$this->expect('.');
|
|
$field = $this->expect('IDENT')->value;
|
|
if ($this->match('.')) {
|
|
$ident .= '.' . $field;
|
|
$field = $this->expect('IDENT')->value;
|
|
} else {
|
|
$ident = strtolower($ident);
|
|
}
|
|
return ['source' => $ident, 'field' => $field];
|
|
}
|
|
|
|
/** @return array{source:string, field:string} */
|
|
private function parseFieldRef(): array
|
|
{
|
|
$src = $this->expect('IDENT')->value;
|
|
$this->expect('.');
|
|
$field = $this->expect('IDENT')->value;
|
|
if ($this->match('.')) {
|
|
$src .= '.' . $field;
|
|
$field = $this->expect('IDENT')->value;
|
|
} else {
|
|
$src = strtolower($src);
|
|
}
|
|
return ['source' => $src, 'field' => $field];
|
|
}
|
|
|
|
/** @return list<mixed> */
|
|
private function parseValueList(): array
|
|
{
|
|
$values = [$this->parseValue()];
|
|
while ($this->match(',')) {
|
|
$values[] = $this->parseValue();
|
|
}
|
|
return $values;
|
|
}
|
|
|
|
/** @return mixed */
|
|
private function parseValue()
|
|
{
|
|
$token = $this->next();
|
|
if ($token->type === 'STRING') {
|
|
return $token->value;
|
|
}
|
|
if ($token->type === 'NUMBER') {
|
|
return (int) $token->value;
|
|
}
|
|
if ($token->type === 'KW' && ($token->value === 'true' || $token->value === 'false')) {
|
|
return $token->value === 'true';
|
|
}
|
|
if ($token->type === 'KW' && $token->value === 'blank') {
|
|
return ['type' => 'blank_literal'];
|
|
}
|
|
|
|
throw new AppError('dsl_parse', 'Expected literal value', ['offset' => $token->offset]);
|
|
}
|
|
|
|
private function match(string $type): bool
|
|
{
|
|
if ($this->peek()->type === $type) {
|
|
$this->pos++;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private function matchKw(string $value): bool
|
|
{
|
|
$token = $this->peek();
|
|
if ($token->type === 'KW' && $token->value === $value) {
|
|
$this->pos++;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private function expect(string $type): DslToken
|
|
{
|
|
$token = $this->next();
|
|
if ($token->type !== $type) {
|
|
throw new AppError('dsl_parse', 'Unexpected token', ['expected' => $type, 'actual' => $token->type, 'offset' => $token->offset]);
|
|
}
|
|
return $token;
|
|
}
|
|
|
|
private function expectKw(string $value): DslToken
|
|
{
|
|
$token = $this->next();
|
|
if ($token->type !== 'KW' || $token->value !== $value) {
|
|
throw new AppError('dsl_parse', 'Expected keyword', ['expected' => $value, 'actual' => $token->value, 'offset' => $token->offset]);
|
|
}
|
|
return $token;
|
|
}
|
|
|
|
private function next(): DslToken
|
|
{
|
|
$token = $this->tokens[$this->pos] ?? new DslToken('EOF', '', 0);
|
|
$this->pos++;
|
|
return $token;
|
|
}
|
|
|
|
private function peek(int $offset = 0): DslToken
|
|
{
|
|
return $this->tokens[$this->pos + $offset] ?? new DslToken('EOF', '', 0);
|
|
}
|
|
|
|
private function isBareFilter(string $ident): bool
|
|
{
|
|
$filters = [
|
|
'selected-renewal',
|
|
'pending-renewal',
|
|
'selected',
|
|
'primary-contact',
|
|
'fen1-contact',
|
|
'pending-invoice',
|
|
'selected-invoice',
|
|
'member-or-affiliate-or-parish-council',
|
|
];
|
|
$next = $this->peek();
|
|
$blockedNext = ($next->type === '.') || ($next->type === 'OP') || ($next->type === 'KW' && in_array($next->value, ['in', 'not', 'contains', 'starts-with', 'ends-with'], true));
|
|
return in_array(strtolower($ident), $filters, true) && !$blockedNext;
|
|
}
|
|
}
|