Skip to content

FOUR-16161: Add support for raw query conditions to advanced filters #6876

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 18, 2024
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
83 changes: 47 additions & 36 deletions ProcessMaker/Filters/Filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,30 @@

namespace ProcessMaker\Filters;

use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use ProcessMaker\Models\ProcessRequestToken;
use ProcessMaker\Models\User;
use ProcessMaker\Query\BaseField;
use ProcessMaker\Query\Expression;
use ProcessMaker\Traits\InteractsWithRawFilter;

class Filter
{
const TYPE_PARTICIPANTS = 'Participants';
use InteractsWithRawFilter;

const TYPE_PARTICIPANTS_FULLNAME = 'ParticipantsFullName';
public const TYPE_PARTICIPANTS = 'Participants';

const TYPE_ASSIGNEES_FULLNAME = 'AssigneesFullName';
public const TYPE_PARTICIPANTS_FULLNAME = 'ParticipantsFullName';

const TYPE_STATUS = 'Status';
public const TYPE_ASSIGNEES_FULLNAME = 'AssigneesFullName';

const TYPE_FIELD = 'Field';
public const TYPE_STATUS = 'Status';

const TYPE_PROCESS = 'Process';
public const TYPE_FIELD = 'Field';

const TYPE_RELATIONSHIP = 'Relationship';
public const TYPE_PROCESS = 'Process';

public const TYPE_RELATIONSHIP = 'Relationship';

public string|null $subjectValue;

Expand All @@ -49,7 +51,18 @@ class Filter
'starts_with',
];

public static function filter(Builder $query, string|array $filterDefinitions)
public function __construct($definition)
{
$this->subjectType = $definition['subject']['type'];
$this->subjectValue = Arr::get($definition, 'subject.value');
$this->operator = $definition['operator'];
$this->value = $definition['value'];
$this->or = Arr::get($definition, 'or', []);

$this->detectRawValue();
}

public static function filter(Builder $query, string|array $filterDefinitions): void
{
if (is_string($filterDefinitions)) {
$filterDefinitions = json_decode($filterDefinitions, true);
Expand All @@ -66,27 +79,16 @@ public static function filter(Builder $query, string|array $filterDefinitions)
});
}

public function __construct($definition)
{
$this->subjectType = $definition['subject']['type'];
$this->subjectValue = Arr::get($definition, 'subject.value');
$this->operator = $definition['operator'];
$this->value = $definition['value'];
$this->or = Arr::get($definition, 'or', []);
}

public function addToQuery(Builder $query)
public function addToQuery(Builder $query): void
{
if (!empty($this->or)) {
$query->where(function ($query) {
$this->apply($query);
});
$query->where(fn ($query) => $this->apply($query));
} else {
$this->apply($query);
}
}

private function apply($query)
private function apply($query): void
{
if ($valueAliasMethod = $this->valueAliasMethod()) {
$this->valueAliasAdapter($valueAliasMethod, $query);
Expand Down Expand Up @@ -137,16 +139,20 @@ private function applyQueryBuilderMethod($query)
* @param [type] $query
* @return void
*/
private function manuallyAddJsonWhere($query)
private function manuallyAddJsonWhere($query): void
{
$parts = explode('.', $this->subjectValue);

array_shift($parts);

$selector = implode('"."', $parts);
$operator = $this->operator();
$value = $this->value();

if (!is_numeric($value)) {
$value = \DB::connection()->getPdo()->quote($value);
$value = DB::connection()->getPdo()->quote($value);
}

$query->whereRaw("json_unquote(json_extract(`data`, '$.\"{$selector}\"')) {$operator} {$value}");
}

Expand Down Expand Up @@ -212,12 +218,12 @@ private function subject()
return $this->subjectValue;
}

private function relationshipSubjectTypeParts()
private function relationshipSubjectTypeParts(): array
{
return explode('.', $this->subjectValue);
}

private function value()
public function value()
{
if ($this->operator === 'contains') {
return '%' . $this->value . '%';
Expand All @@ -227,6 +233,10 @@ private function value()
return $this->value . '%';
}

if ($this->filteringWithRawValue()) {
return $this->getRawValue();
}

return $this->value;
}

Expand Down Expand Up @@ -258,16 +268,16 @@ private function valueAliasMethod()
return $method;
}

private function valueAliasAdapter(string $method, Builder $query)
private function valueAliasAdapter(string $method, Builder $query): void
{
$operator = $this->operator();

if ($operator === 'in') {
$operator = '=';
}
$values = (array) $this->value();

$values = (array) $this->value();
$expression = (object) ['operator' => $operator];

$model = $query->getModel();

if ($method === 'valueAliasParticipant') {
Expand All @@ -292,27 +302,28 @@ private function convertUserIdsToUsernames($values)
}, $values);
}

private function filterByProcessId(Builder $query)
private function filterByProcessId(Builder $query): void
{
if ($query->getModel() instanceof ProcessRequestToken) {
$query->whereIn('process_request_id', function ($query) {
$query->select('id')->from('process_requests')
->whereIn('process_id', (array) $this->value());
$query->select('id')
->from('process_requests')
->whereIn('process_id', (array) $this->value());
});
} else {
$this->applyQueryBuilderMethod($query);
}
}

private function filterByRelationship(Builder $query)
private function filterByRelationship(Builder $query): void
{
$relationshipName = $this->relationshipSubjectTypeParts()[0];
$query->whereHas($relationshipName, function ($rel) {
$this->applyQueryBuilderMethod($rel);
});
}

private function filterByRequestData(Builder $query)
private function filterByRequestData(Builder $query): void
{
$query->whereHas('processRequest', function ($rel) {
$this->applyQueryBuilderMethod($rel);
Expand Down
119 changes: 119 additions & 0 deletions ProcessMaker/Traits/InteractsWithRawFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

namespace ProcessMaker\Traits;

use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Illuminate\Contracts\Database\Query\Expression;

trait InteractsWithRawFilter
{
private bool $usesRawValue = false;

/**
* Operators allowed to be used with raw()
*
* @var array
*/
private array $validRawFilterOperators = ['=', '!=', '>', '<', '>=', '<='];

/**
* Unwrap the raw() and retrieve the string value passed
*
* @return \Illuminate\Contracts\Database\Query\Expression
*/
public function getRawValue(): Expression
{
// Get the string equivalent of the raw() filter value
$value = $this->containsRawValue($this->getValue()) ? $this->getValue() : '';

// Remove the actual row( and ) from the string
$unwrappedRawValue = $this->unwrapRawValue($value);

// Wrap it in a DB expression and return it
return DB::raw($unwrappedRawValue);
}

/**
* Determine if the value is using the raw() function
*
* @param string $value
*
* @return bool
*/
public function containsRawValue(string $value): bool
{
return Str::contains($value, 'raw(')
&& Str::endsWith($value, ')');
}

/**
* Sets related properties
*
* @return void
*/
protected function detectRawValue(): void
{
$value = $this->getValue();

// Sometimes, the value is an array, which likely means
// this filter is set to the use the "between" operator
$value = is_string($value) ? $value : '';

// Detect if this particular filter includes a raw() value
$this->usesRawValue = $this->containsRawValue($value);

// If so, validate it is being used with a compatible operator
if ($this->usesRawValue) {
$this->validateOperator();
}
}

/**
* Remove the initial "row(" and the final ")" to unwrap the filter value
*
* @param string $value
*
* @return string
*/
protected function unwrapRawValue(string $value): string
{
$stripped = Str::after($value, 'raw(');

return Str::beforeLast($stripped, ')');
}

/**
* Get the string value of the filter
*
* @return array|string
*/
protected function getValue(): mixed
{
return $this->value ?? '';
}

/**
* Returns true when this particular filter instance is using a raw() query filter
*
* @return bool
*/
protected function filteringWithRawValue(): bool
{
return $this->usesRawValue === true;
}

/**
* Validate the operator for this raw() filter
*
* @return bool
*/
private function validateOperator(): void
{
$allowed = $this->validRawFilterOperators;

if (!in_array($this->operator(), $allowed, true)) {
abort(422, 'Invalid operator: Only '.implode(', ', $allowed). ' are allowed.');
}
}
}
16 changes: 16 additions & 0 deletions tests/unit/ProcessMaker/FilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ public function testFormData()
);
}

public function testRawValue()
{
$sql = $this->filter([
[
'subject' => ['type' => 'Field', 'value' => 'due_at'],
'operator' => '>',
'value' => 'raw(NOW())',
],
], ProcessRequestToken::class);

$this->assertEquals(
'select * from `process_request_tokens` where (`due_at` > NOW())',
$sql
);
}

public function testCompareDataInteger()
{
$filter = [
Expand Down
Loading