- FormulaParser의 eval() 2곳 제거 (executeSimpleMath, evaluateCondition) - FormulaEvaluatorService의 eval() 1곳 제거 (calculateExpression) - Shunting-yard 알고리즘 기반 SafeMathEvaluator 신규 추가 - 사칙연산, 비교연산, 단항 마이너스, 괄호, 나머지 연산 지원
311 lines
9.0 KiB
PHP
311 lines
9.0 KiB
PHP
<?php
|
|
|
|
namespace App\Helpers;
|
|
|
|
use InvalidArgumentException;
|
|
|
|
/**
|
|
* eval() 없이 산술 수식을 안전하게 계산하는 평가기
|
|
*
|
|
* Shunting-yard 알고리즘으로 중위 표기법 → 후위 표기법(RPN) 변환 후 계산
|
|
* 지원: 숫자, +, -, *, /, %, (, ), 단항 마이너스
|
|
*/
|
|
class SafeMathEvaluator
|
|
{
|
|
private const OPERATORS = ['+', '-', '*', '/', '%'];
|
|
|
|
private const PRECEDENCE = [
|
|
'+' => 2,
|
|
'-' => 2,
|
|
'*' => 3,
|
|
'/' => 3,
|
|
'%' => 3,
|
|
'UNARY_MINUS' => 4,
|
|
];
|
|
|
|
/**
|
|
* 산술 수식을 계산하여 float 반환
|
|
*/
|
|
public static function calculate(string $expression): float
|
|
{
|
|
$expression = trim($expression);
|
|
|
|
if ($expression === '') {
|
|
return 0;
|
|
}
|
|
|
|
$tokens = self::tokenize($expression);
|
|
|
|
if (empty($tokens)) {
|
|
return 0;
|
|
}
|
|
|
|
$rpn = self::toRPN($tokens);
|
|
|
|
return self::evaluateRPN($rpn);
|
|
}
|
|
|
|
/**
|
|
* 비교식을 평가하여 bool 반환
|
|
* 예: "3000 <= 6000", "100 == 100", "5 > 3 && 2 < 4"
|
|
*/
|
|
public static function compare(string $expression): bool
|
|
{
|
|
$expression = trim($expression);
|
|
|
|
// && 논리 AND 처리
|
|
if (str_contains($expression, '&&')) {
|
|
$parts = explode('&&', $expression);
|
|
|
|
foreach ($parts as $part) {
|
|
if (! self::compare(trim($part))) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// || 논리 OR 처리
|
|
if (str_contains($expression, '||')) {
|
|
$parts = explode('||', $expression);
|
|
|
|
foreach ($parts as $part) {
|
|
if (self::compare(trim($part))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// 비교 연산자 추출 (2문자 먼저 검사)
|
|
$operators = ['>=', '<=', '!=', '==', '>', '<'];
|
|
|
|
foreach ($operators as $op) {
|
|
$pos = strpos($expression, $op);
|
|
if ($pos !== false) {
|
|
$left = self::calculate(substr($expression, 0, $pos));
|
|
$right = self::calculate(substr($expression, $pos + strlen($op)));
|
|
|
|
return match ($op) {
|
|
'>=' => $left >= $right,
|
|
'<=' => $left <= $right,
|
|
'!=' => $left != $right,
|
|
'==' => $left == $right,
|
|
'>' => $left > $right,
|
|
'<' => $left < $right,
|
|
};
|
|
}
|
|
}
|
|
|
|
// 비교 연산자가 없으면 수치를 boolean으로 평가
|
|
return (bool) self::calculate($expression);
|
|
}
|
|
|
|
/**
|
|
* 수식 문자열을 토큰 배열로 분리
|
|
*/
|
|
private static function tokenize(string $expression): array
|
|
{
|
|
$tokens = [];
|
|
$len = strlen($expression);
|
|
$i = 0;
|
|
|
|
while ($i < $len) {
|
|
$char = $expression[$i];
|
|
|
|
// 공백 건너뛰기
|
|
if ($char === ' ' || $char === "\t") {
|
|
$i++;
|
|
|
|
continue;
|
|
}
|
|
|
|
// 숫자 (정수, 소수)
|
|
if (is_numeric($char) || ($char === '.' && $i + 1 < $len && is_numeric($expression[$i + 1]))) {
|
|
$num = '';
|
|
while ($i < $len && (is_numeric($expression[$i]) || $expression[$i] === '.')) {
|
|
$num .= $expression[$i];
|
|
$i++;
|
|
}
|
|
$tokens[] = ['type' => 'number', 'value' => (float) $num];
|
|
|
|
continue;
|
|
}
|
|
|
|
// 괄호
|
|
if ($char === '(') {
|
|
$tokens[] = ['type' => 'lparen'];
|
|
$i++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($char === ')') {
|
|
$tokens[] = ['type' => 'rparen'];
|
|
$i++;
|
|
|
|
continue;
|
|
}
|
|
|
|
// 연산자
|
|
if (in_array($char, self::OPERATORS)) {
|
|
// 단항 마이너스 판별: 맨 앞이거나, 앞이 연산자 또는 여는 괄호인 경우
|
|
if ($char === '-') {
|
|
$isUnary = empty($tokens)
|
|
|| $tokens[count($tokens) - 1]['type'] === 'operator'
|
|
|| $tokens[count($tokens) - 1]['type'] === 'lparen';
|
|
|
|
if ($isUnary) {
|
|
$tokens[] = ['type' => 'operator', 'value' => 'UNARY_MINUS'];
|
|
$i++;
|
|
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$tokens[] = ['type' => 'operator', 'value' => $char];
|
|
$i++;
|
|
|
|
continue;
|
|
}
|
|
|
|
throw new InvalidArgumentException("허용되지 않는 문자: '{$char}' (위치 {$i})");
|
|
}
|
|
|
|
return $tokens;
|
|
}
|
|
|
|
/**
|
|
* 중위 표기법 토큰 → 후위 표기법(RPN) 변환 (Shunting-yard)
|
|
*/
|
|
private static function toRPN(array $tokens): array
|
|
{
|
|
$output = [];
|
|
$operatorStack = [];
|
|
|
|
foreach ($tokens as $token) {
|
|
if ($token['type'] === 'number') {
|
|
$output[] = $token;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($token['type'] === 'operator') {
|
|
$op = $token['value'];
|
|
$prec = self::PRECEDENCE[$op] ?? 0;
|
|
|
|
while (! empty($operatorStack)) {
|
|
$top = end($operatorStack);
|
|
if ($top['type'] === 'lparen') {
|
|
break;
|
|
}
|
|
$topPrec = self::PRECEDENCE[$top['value']] ?? 0;
|
|
|
|
// 단항 연산자는 오른쪽 결합
|
|
if ($op === 'UNARY_MINUS') {
|
|
if ($topPrec > $prec) {
|
|
$output[] = array_pop($operatorStack);
|
|
} else {
|
|
break;
|
|
}
|
|
} else {
|
|
// 이항 연산자는 왼쪽 결합 (같은 우선순위면 먼저 pop)
|
|
if ($topPrec >= $prec) {
|
|
$output[] = array_pop($operatorStack);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$operatorStack[] = $token;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($token['type'] === 'lparen') {
|
|
$operatorStack[] = $token;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($token['type'] === 'rparen') {
|
|
while (! empty($operatorStack) && end($operatorStack)['type'] !== 'lparen') {
|
|
$output[] = array_pop($operatorStack);
|
|
}
|
|
if (empty($operatorStack)) {
|
|
throw new InvalidArgumentException('괄호 불일치: 여는 괄호 없음');
|
|
}
|
|
array_pop($operatorStack); // 여는 괄호 제거
|
|
|
|
continue;
|
|
}
|
|
}
|
|
|
|
while (! empty($operatorStack)) {
|
|
$top = array_pop($operatorStack);
|
|
if ($top['type'] === 'lparen') {
|
|
throw new InvalidArgumentException('괄호 불일치: 닫는 괄호 없음');
|
|
}
|
|
$output[] = $top;
|
|
}
|
|
|
|
return $output;
|
|
}
|
|
|
|
/**
|
|
* 후위 표기법(RPN) 계산
|
|
*/
|
|
private static function evaluateRPN(array $rpn): float
|
|
{
|
|
$stack = [];
|
|
|
|
foreach ($rpn as $token) {
|
|
if ($token['type'] === 'number') {
|
|
$stack[] = $token['value'];
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($token['type'] === 'operator') {
|
|
$op = $token['value'];
|
|
|
|
// 단항 마이너스
|
|
if ($op === 'UNARY_MINUS') {
|
|
if (empty($stack)) {
|
|
throw new InvalidArgumentException('수식 오류: 단항 마이너스 피연산자 없음');
|
|
}
|
|
$stack[] = -array_pop($stack);
|
|
|
|
continue;
|
|
}
|
|
|
|
// 이항 연산자
|
|
if (count($stack) < 2) {
|
|
throw new InvalidArgumentException('수식 오류: 피연산자 부족');
|
|
}
|
|
|
|
$right = array_pop($stack);
|
|
$left = array_pop($stack);
|
|
|
|
$stack[] = match ($op) {
|
|
'+' => $left + $right,
|
|
'-' => $left - $right,
|
|
'*' => $left * $right,
|
|
'/' => $right != 0 ? $left / $right : throw new InvalidArgumentException('0으로 나눌 수 없음'),
|
|
'%' => $right != 0 ? fmod($left, $right) : throw new InvalidArgumentException('0으로 나눌 수 없음'),
|
|
default => throw new InvalidArgumentException("알 수 없는 연산자: {$op}"),
|
|
};
|
|
}
|
|
}
|
|
|
|
if (count($stack) !== 1) {
|
|
throw new InvalidArgumentException('수식 오류: 결과가 하나가 아님');
|
|
}
|
|
|
|
return (float) $stack[0];
|
|
}
|
|
}
|