🆙 Add cms i using 🆙

This commit is contained in:
2025-11-25 22:42:56 +01:00
parent 94704e0925
commit d44196149e
35591 changed files with 3601123 additions and 0 deletions
@@ -0,0 +1,27 @@
<?php
namespace Filament\QueryBuilder\Constraints;
use Filament\QueryBuilder\Constraints\BooleanConstraint\Operators\IsTrueOperator;
use Filament\QueryBuilder\Constraints\Operators\IsFilledOperator;
use Filament\QueryBuilder\View\QueryBuilderIconAlias;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
class BooleanConstraint extends Constraint
{
use Concerns\CanBeNullable;
protected function setUp(): void
{
parent::setUp();
$this->icon(FilamentIcon::resolve(QueryBuilderIconAlias::CONSTRAINTS_BOOLEAN) ?? Heroicon::CheckCircle);
$this->operators([
IsTrueOperator::class,
IsFilledOperator::make()
->visible(fn (): bool => $this->isNullable()),
]);
}
}
@@ -0,0 +1,45 @@
<?php
namespace Filament\QueryBuilder\Constraints\BooleanConstraint\Operators;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class IsTrueOperator extends Operator
{
public function getName(): string
{
return 'isTrue';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.boolean.is_true.label.inverse' :
'filament-query-builder::query-builder.operators.boolean.is_true.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.boolean.is_true.summary.inverse' :
'filament-query-builder::query-builder.operators.boolean.is_true.summary.direct',
['attribute' => $this->getConstraint()->getAttributeLabel()],
);
}
/**
* @template TModel of Model
*
* @param Builder<TModel> $query
* @return Builder<TModel>
*/
public function apply(Builder $query, string $qualifiedColumn): Builder
{
return $query->where($qualifiedColumn, ! $this->isInverse());
}
}
@@ -0,0 +1,22 @@
<?php
namespace Filament\QueryBuilder\Constraints\Concerns;
use Closure;
trait CanBeNullable
{
protected bool | Closure $isNullable = false;
public function nullable(bool | Closure $condition = true): static
{
$this->isNullable = $condition;
return $this;
}
public function isNullable(): bool
{
return (bool) $this->evaluate($this->isNullable);
}
}
@@ -0,0 +1,37 @@
<?php
namespace Filament\QueryBuilder\Constraints\Concerns;
use Closure;
trait HasLabel
{
protected string | Closure | null $label = null;
protected bool $shouldTranslateLabel = false;
public function label(string | Closure | null $label): static
{
$this->label = $label;
return $this;
}
public function translateLabel(bool $shouldTranslateLabel = true): static
{
$this->shouldTranslateLabel = $shouldTranslateLabel;
return $this;
}
public function getLabel(): string
{
$label = $this->evaluate($this->label) ?? (string) str($this->getName())
->before('.')
->kebab()
->replace(['-', '_'], ' ')
->ucfirst();
return $this->shouldTranslateLabel ? __($label) : $label;
}
}
@@ -0,0 +1,20 @@
<?php
namespace Filament\QueryBuilder\Constraints\Concerns;
trait HasName
{
protected string $name;
public function name(string $name): static
{
$this->name = $name;
return $this;
}
public function getName(): string
{
return $this->name;
}
}
@@ -0,0 +1,79 @@
<?php
namespace Filament\QueryBuilder\Constraints\Concerns;
use Filament\QueryBuilder\Constraints\Operators\Operator;
trait HasOperators
{
/** @var array<Operator> */
protected array $operators = [];
/**
* @param array<class-string<Operator> | Operator> $operators
*/
public function unshiftOperators(array $operators): static
{
foreach ($operators as $operator) {
if (is_string($operator)) {
$operator = $operator::make();
}
$operatorName = $operator->getName();
if (array_key_exists($operatorName, $this->operators)) {
unset($this->operators[$operatorName]);
}
$this->operators = [
$operatorName => $operator,
...$this->operators,
];
}
return $this;
}
/**
* @param array<class-string<Operator> | Operator> $operators
*/
public function operators(array $operators): static
{
$this->operators = [];
$this->pushOperators($operators);
return $this;
}
/**
* @param array<class-string<Operator> | Operator> $operators
*/
public function pushOperators(array $operators): static
{
foreach ($operators as $operator) {
if (is_string($operator)) {
$operator = $operator::make();
}
$this->operators[$operator->getName()] = $operator;
}
return $this;
}
/**
* @return array<Operator>
*/
public function getOperators(): array
{
return array_filter(
$this->operators,
fn (Operator $operator): bool => $operator->isVisible(),
);
}
public function getOperator(string $name): ?Operator
{
return $this->getOperators()[$name] ?? null;
}
}
@@ -0,0 +1,309 @@
<?php
namespace Filament\QueryBuilder\Constraints;
use Closure;
use Filament\Actions\Action;
use Filament\Forms\Components\Builder;
use Filament\Forms\Components\Builder\Block;
use Filament\Forms\Components\Select;
use Filament\Forms\View\FormsIconAlias;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Flex;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Support\Components\Component;
use Filament\Support\Concerns\HasIcon;
use Filament\Support\Enums\Size;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Illuminate\Database\Eloquent\Model;
use LogicException;
class Constraint extends Component
{
use Concerns\HasLabel;
use Concerns\HasName;
use Concerns\HasOperators;
use HasIcon;
public const OPERATOR_SELECT_NAME = 'operator';
protected string $evaluationIdentifier = 'constraint';
protected string | Closure | null $attribute = null;
protected string | Closure | null $attributeLabel = null;
protected string | Closure | null $relationship = null;
protected ?Closure $modifyRelationshipQueryUsing = null;
/**
* @var array<string, mixed> | null
*/
protected ?array $settings = null;
protected ?bool $isInverse = null;
/**
* @var ?class-string<Model>
*/
protected ?string $model = null;
final public function __construct(string $name)
{
$this->name($name);
}
public static function make(?string $name = null): static
{
$constraintClass = static::class;
$name ??= static::getDefaultName();
if (blank($name)) {
throw new LogicException("Constraint of class [$constraintClass] must have a unique name, passed to the [make()] method.");
}
$static = app($constraintClass, ['name' => $name]);
$static->configure();
return $static;
}
public static function getDefaultName(): ?string
{
return null;
}
public function getBuilderBlock(): Block
{
return Block::make($this->getName())
->label($this->getLabel())
->icon($this->getIcon())
->schema(function (): array {
$operatorSelectOptions = $this->getOperatorSelectOptions();
return [
Flex::make(function (Flex $component) use ($operatorSelectOptions): array {
/** @var Builder $builder */
$builder = $component->getContainer()->getParentComponent()->getContainer()->getParentComponent();
return [
Grid::make(['@md' => 2, '@xl' => 3, '!@sm' => 2, '!@md' => 3])
->schema([
Select::make(static::OPERATOR_SELECT_NAME)
->label($this->getLabel())
->options($operatorSelectOptions)
->default(array_key_first($operatorSelectOptions))
->live()
->afterStateUpdated(fn (Select $component, Get $get) => $component
->getContainer()
->getComponent('settings', withHidden: true)
->getChildSchema()
->fill($get('settings'))),
Group::make(function (Get $get): array {
$operator = $get(static::OPERATOR_SELECT_NAME);
if (blank($operator)) {
return [];
}
[$operatorName] = $this->parseOperatorString($operator);
$operator = $this->getOperator($operatorName);
if (! $operator) {
return [];
}
$operator->constraint($this);
try {
return $operator->getFormSchema();
} finally {
$operator->constraint(null);
}
})
->dense()
->statePath('settings')
->key('settings')
->columnSpan(['@xl' => 2, '!@md' => 2])
->columns(['@xl' => 2, '!@md' => 2])
->visible(fn (Group $component): bool => filled($component->getChildSchema()->getComponents())),
])
->dense(),
Actions::make([
Action::make($cloneActionName = $builder->getCloneActionName())
->label(__('filament-forms::components.builder.actions.clone.label'))
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_BUILDER_ACTIONS_CLONE) ?? Heroicon::Square2Stack)
->color('gray')
->iconButton()
->size(Size::Small)
->action($builder->getAction($cloneActionName)->arguments(['item' => (string) str($component->getContainer()->getStatePath(isAbsolute: false))->beforeLast('.data')])->getLivewireClickHandler()),
Action::make($deleteActionName = $builder->getDeleteActionName())
->label(__('filament-forms::components.builder.actions.delete.label'))
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_BUILDER_ACTIONS_DELETE) ?? Heroicon::Trash)
->color('danger')
->iconButton()
->size(Size::Small)
->action($builder->getAction($deleteActionName)->arguments(['item' => (string) str($component->getContainer()->getStatePath(isAbsolute: false))->beforeLast('.data')])->getLivewireClickHandler()),
])->grow(false),
];
})->gridContainer(),
];
});
}
/**
* @return array<string, string>
*/
public function getOperatorSelectOptions(): array
{
$options = [];
foreach ($this->getOperators() as $operatorName => $operator) {
$operator->constraint($this);
$options[$operatorName] = $operator->inverse(false)->getLabel();
$options["{$operatorName}.inverse"] = $operator->inverse()->getLabel();
$operator->constraint(null);
$operator->inverse(null);
}
return $options;
}
/**
* @return array{string, bool}
*/
public function parseOperatorString(string $operator): array
{
if (str($operator)->endsWith('.inverse')) {
return [(string) str($operator)->beforeLast('.'), true];
}
return [$operator, false];
}
public function attribute(string | Closure | null $name): static
{
$this->attribute = $name;
return $this;
}
public function attributeLabel(string | Closure | null $label): static
{
$this->attributeLabel = $label;
return $this;
}
public function modifyRelationshipQueryUsing(?Closure $modifyQueryUsing): static
{
$this->modifyRelationshipQueryUsing = $modifyQueryUsing;
return $this;
}
public function relationship(string $name, string $titleAttribute, ?Closure $modifyQueryUsing = null): static
{
$this->attribute("{$name}.{$titleAttribute}");
$this->modifyRelationshipQueryUsing($modifyQueryUsing);
return $this;
}
/**
* @param ?class-string<Model> $model
*/
public function model(?string $model): static
{
$this->model = $model;
return $this;
}
/**
* @return ?class-string<Model>
*/
public function getModel(): ?string
{
return $this->model;
}
public function getAttribute(): string
{
return $this->evaluate($this->attribute) ?? $this->getName();
}
public function getAttributeLabel(): string
{
return $this->evaluate($this->attributeLabel) ?? $this->getLabel();
}
public function queriesRelationships(): bool
{
return str($this->getAttribute())->contains('.');
}
public function getRelationshipName(): string
{
return (string) str($this->getAttribute())->beforeLast('.');
}
public function getAttributeForQuery(): string
{
return (string) str($this->getAttribute())->afterLast('.');
}
public function getModifyRelationshipQueryUsing(): ?Closure
{
return $this->modifyRelationshipQueryUsing;
}
/**
* @param array<string, mixed> | null $settings
*/
public function settings(?array $settings): static
{
$this->settings = $settings;
return $this;
}
public function inverse(?bool $condition = true): static
{
$this->isInverse = $condition;
return $this;
}
/**
* @return array<string, mixed> | null
*/
public function getSettings(): ?array
{
return $this->settings;
}
public function isInverse(): ?bool
{
return $this->isInverse;
}
protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array
{
return match ($parameterName) {
'isInverse' => [$this->isInverse()],
'settings' => [$this->getSettings()],
default => parent::resolveDefaultClosureDependencyForEvaluationByName($parameterName),
};
}
}
@@ -0,0 +1,35 @@
<?php
namespace Filament\QueryBuilder\Constraints;
use Filament\QueryBuilder\Constraints\DateConstraint\Operators\IsAfterOperator;
use Filament\QueryBuilder\Constraints\DateConstraint\Operators\IsBeforeOperator;
use Filament\QueryBuilder\Constraints\DateConstraint\Operators\IsDateOperator;
use Filament\QueryBuilder\Constraints\DateConstraint\Operators\IsMonthOperator;
use Filament\QueryBuilder\Constraints\DateConstraint\Operators\IsYearOperator;
use Filament\QueryBuilder\Constraints\Operators\IsFilledOperator;
use Filament\QueryBuilder\View\QueryBuilderIconAlias;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
class DateConstraint extends Constraint
{
use Concerns\CanBeNullable;
protected function setUp(): void
{
parent::setUp();
$this->icon(FilamentIcon::resolve(QueryBuilderIconAlias::CONSTRAINTS_DATE) ?? Heroicon::Calendar);
$this->operators([
IsAfterOperator::class,
IsBeforeOperator::class,
IsDateOperator::class,
IsMonthOperator::class,
IsYearOperator::class,
IsFilledOperator::make()
->visible(fn (): bool => $this->isNullable()),
]);
}
}
@@ -0,0 +1,58 @@
<?php
namespace Filament\QueryBuilder\Constraints\DateConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\DatePicker;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class IsAfterOperator extends Operator
{
public function getName(): string
{
return 'isAfter';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.date.is_after.label.inverse' :
'filament-query-builder::query-builder.operators.date.is_after.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.date.is_after.summary.inverse' :
'filament-query-builder::query-builder.operators.date.is_after.summary.direct',
[
'attribute' => $this->getConstraint()->getAttributeLabel(),
'date' => Carbon::parse($this->getSettings()['date'])->toFormattedDateString(),
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
DatePicker::make('date')
->label(__('filament-query-builder::query-builder.operators.date.form.date.label'))
->required(),
];
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
return $query->whereDate($qualifiedColumn, $this->isInverse() ? '<' : '>=', $this->getSettings()['date']);
}
}
@@ -0,0 +1,58 @@
<?php
namespace Filament\QueryBuilder\Constraints\DateConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\DatePicker;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class IsBeforeOperator extends Operator
{
public function getName(): string
{
return 'isBefore';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.date.is_before.label.inverse' :
'filament-query-builder::query-builder.operators.date.is_before.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.date.is_before.summary.inverse' :
'filament-query-builder::query-builder.operators.date.is_before.summary.direct',
[
'attribute' => $this->getConstraint()->getAttributeLabel(),
'date' => Carbon::parse($this->getSettings()['date'])->toFormattedDateString(),
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
DatePicker::make('date')
->label(__('filament-query-builder::query-builder.operators.date.form.date.label'))
->required(),
];
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
return $query->whereDate($qualifiedColumn, $this->isInverse() ? '>' : '<=', $this->getSettings()['date']);
}
}
@@ -0,0 +1,58 @@
<?php
namespace Filament\QueryBuilder\Constraints\DateConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\DatePicker;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class IsDateOperator extends Operator
{
public function getName(): string
{
return 'isDate';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.date.is_date.label.inverse' :
'filament-query-builder::query-builder.operators.date.is_date.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.date.is_date.summary.inverse' :
'filament-query-builder::query-builder.operators.date.is_date.summary.direct',
[
'attribute' => $this->getConstraint()->getAttributeLabel(),
'date' => Carbon::parse($this->getSettings()['date'])->toFormattedDateString(),
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
DatePicker::make('date')
->label(__('filament-query-builder::query-builder.operators.date.form.date.label'))
->required(),
];
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
return $query->whereDate($qualifiedColumn, $this->isInverse() ? '!=' : '=', $this->getSettings()['date']);
}
}
@@ -0,0 +1,70 @@
<?php
namespace Filament\QueryBuilder\Constraints\DateConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\Select;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Eloquent\Builder;
class IsMonthOperator extends Operator
{
public function getName(): string
{
return 'isMonth';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.date.is_month.label.inverse' :
'filament-query-builder::query-builder.operators.date.is_month.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.date.is_month.summary.inverse' :
'filament-query-builder::query-builder.operators.date.is_month.summary.direct',
[
'attribute' => $this->getConstraint()->getAttributeLabel(),
'month' => $this->getMonths()[$this->getSettings()['month']] ?? null,
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
Select::make('month')
->label(__('filament-query-builder::query-builder.operators.date.form.month.label'))
->options($this->getMonths())
->required(),
];
}
/**
* @return array<string>
*/
protected function getMonths(): array
{
return collect(range(1, 12))
->mapWithKeys(fn (int $month): array => [
$month => now()->setMonth($month)->setDay(1)->getTranslatedMonthName(),
])
->all();
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
return $query->whereMonth($qualifiedColumn, $this->isInverse() ? '!=' : '=', $this->getSettings()['month']);
}
}
@@ -0,0 +1,58 @@
<?php
namespace Filament\QueryBuilder\Constraints\DateConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\TextInput;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Eloquent\Builder;
class IsYearOperator extends Operator
{
public function getName(): string
{
return 'isYear';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.date.is_year.label.inverse' :
'filament-query-builder::query-builder.operators.date.is_year.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.date.is_year.summary.inverse' :
'filament-query-builder::query-builder.operators.date.is_year.summary.direct',
[
'attribute' => $this->getConstraint()->getAttributeLabel(),
'year' => $this->getSettings()['year'],
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
TextInput::make('year')
->label(__('filament-query-builder::query-builder.operators.date.form.year.label'))
->integer()
->required(),
];
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
return $query->whereYear($qualifiedColumn, $this->isInverse() ? '!=' : '=', $this->getSettings()['year']);
}
}
@@ -0,0 +1,63 @@
<?php
namespace Filament\QueryBuilder\Constraints;
use Closure;
use Filament\QueryBuilder\Constraints\NumberConstraint\Operators\EqualsOperator;
use Filament\QueryBuilder\Constraints\NumberConstraint\Operators\IsMaxOperator;
use Filament\QueryBuilder\Constraints\NumberConstraint\Operators\IsMinOperator;
use Filament\QueryBuilder\Constraints\Operators\IsFilledOperator;
use Filament\QueryBuilder\View\QueryBuilderIconAlias;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
class NumberConstraint extends Constraint
{
use Concerns\CanBeNullable;
/**
* @var array<string, string>
*/
protected array $existingAggregateAliases = [];
protected bool | Closure $isInteger = false;
protected function setUp(): void
{
parent::setUp();
$this->icon(FilamentIcon::resolve(QueryBuilderIconAlias::CONSTRAINTS_NUMBER) ?? Heroicon::Variable);
$this->operators([
IsMinOperator::class,
IsMaxOperator::class,
EqualsOperator::class,
IsFilledOperator::make()
->visible(fn (): bool => $this->isNullable()),
]);
}
public function integer(bool | Closure $condition = true): static
{
$this->isInteger = $condition;
return $this;
}
public function isInteger(): bool
{
return (bool) $this->evaluate($this->isInteger);
}
public function reportAggregateAlias(string $alias): static
{
$this->existingAggregateAliases[$alias] = $alias;
return $this;
}
public function isExistingAggregateAlias(string $alias): bool
{
return array_key_exists($alias, $this->existingAggregateAliases);
}
}
@@ -0,0 +1,143 @@
<?php
namespace Filament\QueryBuilder\Constraints\NumberConstraint\Operators\Concerns;
use Filament\Forms\Components\Select;
use Filament\QueryBuilder\Constraints\NumberConstraint;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use LogicException;
trait CanAggregateRelationships
{
public static function getAggregateSelectName(): string
{
return 'aggregate';
}
public static function getAggregateAverageKey(): string
{
return 'avg';
}
public static function getAggregateMaxKey(): string
{
return 'max';
}
public static function getAggregateMinKey(): string
{
return 'min';
}
public static function getAggregateSumKey(): string
{
return 'sum';
}
public function queriesRelationshipsUsingSubSelect(): bool
{
return parent::queriesRelationshipsUsingSubSelect() && blank($this->getSettings()[static::getAggregateSelectName()]);
}
protected function getNumericCastType(Builder $query): string
{
/** @var Connection $databaseConnection */
$databaseConnection = $query->getConnection();
$driver = $databaseConnection->getDriverName();
return match ($driver) {
'sqlite' => 'real',
'pgsql' => 'numeric',
default => 'decimal(10,2)',
};
}
protected function applyAggregateComparison(Builder $query, string $operator, float $value): Builder
{
$relationshipName = $this->getConstraint()->getRelationshipName();
$attributeForQuery = $this->getConstraint()->getAttributeForQuery();
$aggregate = $this->getAggregate();
/** @var Relation $relationship */
$relationship = $query->getModel()->{$relationshipName}();
$relatedModel = $relationship->getModel();
$attributeForQuery = $relatedModel->qualifyColumn($attributeForQuery);
$castType = $this->getNumericCastType($query);
if ($relationship instanceof BelongsToMany) {
$pivotTable = $relationship->getTable();
$foreignPivotKey = $relationship->getQualifiedForeignPivotKeyName();
$relatedPivotKey = $relationship->getQualifiedRelatedPivotKeyName();
$parentKey = $relationship->getQualifiedParentKeyName();
$relatedKey = $relationship->getQualifiedRelatedKeyName();
$subQuery = $relatedModel->query()
->selectRaw("cast({$aggregate}({$attributeForQuery}) as {$castType})")
->join($pivotTable, $relatedKey, '=', $relatedPivotKey)
->whereColumn($foreignPivotKey, $parentKey);
return $query->whereRaw("({$subQuery->toSql()}) {$operator} ?", [...$subQuery->getBindings(), $value]);
}
if ($relationship instanceof HasOneOrMany) {
$foreignKeyName = $relationship->getQualifiedForeignKeyName();
$parentKeyName = $relationship->getQualifiedParentKeyName();
$subQuery = $relatedModel->query()
->selectRaw("cast({$aggregate}({$attributeForQuery}) as {$castType})")
->whereColumn($foreignKeyName, $parentKeyName);
return $query->whereRaw("({$subQuery->toSql()}) {$operator} ?", [...$subQuery->getBindings(), $value]);
}
throw new LogicException('Relationship type [' . get_class($relationship) . '] is not supported for aggregate queries.');
}
protected function getAggregateSelect(): Select
{
return Select::make(static::getAggregateSelectName())
->label(__('filament-query-builder::query-builder.operators.number.form.aggregate.label'))
->options([
static::getAggregateSumKey() => __('filament-query-builder::query-builder.operators.number.aggregates.sum.label'),
static::getAggregateAverageKey() => __('filament-query-builder::query-builder.operators.number.aggregates.average.label'),
static::getAggregateMinKey() => __('filament-query-builder::query-builder.operators.number.aggregates.max.label'),
static::getAggregateMaxKey() => __('filament-query-builder::query-builder.operators.number.aggregates.min.label'),
])
->visible($this->getConstraint()->queriesRelationships());
}
protected function getAggregate(): ?string
{
return $this->getSettings()[static::getAggregateSelectName()] ?? null;
}
protected function getAttributeLabel(): string
{
$attributeLabel = $this->getConstraint()->getAttributeLabel();
return __(match ($this->getAggregate()) {
static::getAggregateAverageKey() => 'filament-query-builder::query-builder.operators.number.aggregates.average.summary',
static::getAggregateMaxKey() => 'filament-query-builder::query-builder.operators.number.aggregates.max.summary',
static::getAggregateMinKey() => 'filament-query-builder::query-builder.operators.number.aggregates.min.summary',
static::getAggregateSumKey() => 'filament-query-builder::query-builder.operators.number.aggregates.sum.summary',
default => $attributeLabel,
}, ['attribute' => $attributeLabel]);
}
public function getConstraint(): ?NumberConstraint
{
$constraint = parent::getConstraint();
if (! ($constraint instanceof NumberConstraint)) {
throw new LogicException('Constraint must be an instance of [' . NumberConstraint::class . '].');
}
return $constraint;
}
}
@@ -0,0 +1,70 @@
<?php
namespace Filament\QueryBuilder\Constraints\NumberConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\TextInput;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Number;
class EqualsOperator extends Operator
{
use Concerns\CanAggregateRelationships;
public function getName(): string
{
return 'equals';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.number.equals.label.inverse' :
'filament-query-builder::query-builder.operators.number.equals.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.number.equals.summary.inverse' :
'filament-query-builder::query-builder.operators.number.equals.summary.direct',
[
'attribute' => $this->getAttributeLabel(),
'number' => Number::format($this->getSettings()['number']),
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
TextInput::make('number')
->label(__('filament-query-builder::query-builder.operators.number.form.number.label'))
->numeric()
->integer($this->getConstraint()->isInteger())
->required(),
$this->getAggregateSelect(),
];
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
if (filled($this->getAggregate())) {
$operator = $this->isInverse() ? '!=' : '=';
$value = floatval($this->getSettings()['number']);
return $this->applyAggregateComparison($query, $operator, $value);
}
return $query->where($qualifiedColumn, $this->isInverse() ? '!=' : '=', floatval($this->getSettings()['number']));
}
}
@@ -0,0 +1,70 @@
<?php
namespace Filament\QueryBuilder\Constraints\NumberConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\TextInput;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Number;
class IsMaxOperator extends Operator
{
use Concerns\CanAggregateRelationships;
public function getName(): string
{
return 'isMax';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.number.is_max.label.inverse' :
'filament-query-builder::query-builder.operators.number.is_max.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.number.is_max.summary.inverse' :
'filament-query-builder::query-builder.operators.number.is_max.summary.direct',
[
'attribute' => $this->getAttributeLabel(),
'number' => Number::format($this->getSettings()['number']),
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
TextInput::make('number')
->label(__('filament-query-builder::query-builder.operators.number.form.number.label'))
->numeric()
->integer($this->getConstraint()->isInteger())
->required(),
$this->getAggregateSelect(),
];
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
if (filled($this->getAggregate())) {
$operator = $this->isInverse() ? '>' : '<=';
$value = floatval($this->getSettings()['number']);
return $this->applyAggregateComparison($query, $operator, $value);
}
return $query->where($qualifiedColumn, $this->isInverse() ? '>' : '<=', floatval($this->getSettings()['number']));
}
}
@@ -0,0 +1,70 @@
<?php
namespace Filament\QueryBuilder\Constraints\NumberConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\TextInput;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Number;
class IsMinOperator extends Operator
{
use Concerns\CanAggregateRelationships;
public function getName(): string
{
return 'isMin';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.number.is_min.label.inverse' :
'filament-query-builder::query-builder.operators.number.is_min.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.number.is_min.summary.inverse' :
'filament-query-builder::query-builder.operators.number.is_min.summary.direct',
[
'attribute' => $this->getAttributeLabel(),
'number' => Number::format($this->getSettings()['number']),
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
TextInput::make('number')
->label(__('filament-query-builder::query-builder.operators.number.form.number.label'))
->numeric()
->integer($this->getConstraint()->isInteger())
->required(),
$this->getAggregateSelect(),
];
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
if (filled($this->getAggregate())) {
$operator = $this->isInverse() ? '<' : '>=';
$value = floatval($this->getSettings()['number']);
return $this->applyAggregateComparison($query, $operator, $value);
}
return $query->where($qualifiedColumn, $this->isInverse() ? '<' : '>=', floatval($this->getSettings()['number']));
}
}
@@ -0,0 +1,40 @@
<?php
namespace Filament\QueryBuilder\Constraints\Operators\Concerns;
use Closure;
trait CanBeHidden
{
protected bool | Closure $isHidden = false;
protected bool | Closure $isVisible = true;
public function hidden(bool | Closure $condition = true): static
{
$this->isHidden = $condition;
return $this;
}
public function visible(bool | Closure $condition = true): static
{
$this->isVisible = $condition;
return $this;
}
public function isHidden(): bool
{
if ($this->evaluate($this->isHidden)) {
return true;
}
return ! $this->evaluate($this->isVisible);
}
public function isVisible(): bool
{
return ! $this->isHidden();
}
}
@@ -0,0 +1,59 @@
<?php
namespace Filament\QueryBuilder\Constraints\Operators;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Expression;
use Illuminate\Support\Str;
class IsFilledOperator extends Operator
{
public function getName(): string
{
return 'isFilled';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.is_filled.label.inverse' :
'filament-query-builder::query-builder.operators.is_filled.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.is_filled.summary.inverse' :
'filament-query-builder::query-builder.operators.is_filled.summary.direct',
['attribute' => $this->getConstraint()->getAttributeLabel()],
);
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
$qualifiedStringColumn = $qualifiedColumn;
/** @var Connection $databaseConnection */
$databaseConnection = $query->getConnection();
$isPostgres = $databaseConnection->getDriverName() === 'pgsql';
if ((Str::lower($qualifiedColumn) !== $qualifiedColumn) && $isPostgres) {
$qualifiedColumn = (string) str($qualifiedColumn)->wrap('"');
}
if ($isPostgres) {
$qualifiedStringColumn = new Expression("{$qualifiedColumn}::text");
}
return $query->where(
fn (Builder $query) => $query
->{$this->isInverse() ? 'whereNull' : 'whereNotNull'}($qualifiedColumn)
->{$this->isInverse() ? 'orWhere' : 'whereNot'}($qualifiedStringColumn, ''),
);
}
}
@@ -0,0 +1,249 @@
<?php
namespace Filament\QueryBuilder\Constraints\Operators;
use Closure;
use Filament\QueryBuilder\Constraints\Constraint;
use Filament\QueryBuilder\Constraints\Operators\Concerns\CanBeHidden;
use Filament\Support\Components\Component;
use Illuminate\Database\Eloquent\Builder;
class Operator extends Component
{
use CanBeHidden;
protected string $evaluationIdentifier = 'operator';
protected ?Constraint $constraint = null;
/**
* @var array<string, mixed> | null
*/
protected ?array $settings = null;
protected ?bool $isInverse = null;
protected ?string $name = null;
protected string | Closure | null $label = null;
protected string | Closure | null $summary = null;
protected ?Closure $modifyQueryUsing = null;
protected ?Closure $modifyBaseQueryUsing = null;
final public function __construct(?string $name = null)
{
$this->name($name);
}
public static function make(?string $name = null): static
{
$static = app(static::class, ['name' => $name]);
$static->configure();
return $static;
}
public function name(?string $name): static
{
$this->name = $name;
return $this;
}
public function label(string | Closure | null $label): static
{
$this->label = $label;
return $this;
}
public function summary(string | Closure | null $summary): static
{
$this->summary = $summary;
return $this;
}
public function query(?Closure $callback): static
{
$this->modifyQueryUsing($callback);
return $this;
}
public function baseQuery(?Closure $callback): static
{
$this->modifyBaseQueryUsing($callback);
return $this;
}
public function modifyQueryUsing(?Closure $callback): static
{
$this->modifyQueryUsing = $callback;
return $this;
}
public function modifyBaseQueryUsing(?Closure $callback): static
{
$this->modifyBaseQueryUsing = $callback;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function getLabel(): string
{
return $this->evaluate($this->label) ?? (string) str($this->getName())
->before('.')
->kebab()
->replace(['-', '_'], ' ')
->ucfirst();
}
public function getSummary(): string
{
return $this->evaluate($this->summary);
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
return $query;
}
public function applyToBaseQuery(Builder $query): Builder
{
if ($this->hasBaseQueryModificationCallback()) {
return $this->evaluate($this->modifyBaseQueryUsing, [
'query' => $query,
]) ?? $query;
}
if ($this->queriesRelationshipsUsingSubSelect()) {
return $query->whereHas(
$this->getConstraint()->getRelationshipName(),
function (Builder $query): Builder {
$modifyRelationshipQueryUsing = $this->getConstraint()->getModifyRelationshipQueryUsing();
if ($modifyRelationshipQueryUsing) {
$query = $this->evaluate($modifyRelationshipQueryUsing, [
'query' => $query,
]) ?? $query;
}
$qualifiedColumn = $query->qualifyColumn($this->getConstraint()->getAttributeForQuery());
if ($this->hasQueryModificationCallback()) {
return $this->evaluate($this->modifyQueryUsing, [
'column' => $qualifiedColumn,
'qualifiedColumn' => $qualifiedColumn,
'query' => $query,
]) ?? $query;
}
return $this->apply($query, $qualifiedColumn);
},
);
}
$qualifiedColumn = $query->qualifyColumn($this->getConstraint()->getAttributeForQuery());
if ($this->hasQueryModificationCallback()) {
return $this->evaluate($this->modifyQueryUsing, [
'column' => $qualifiedColumn,
'qualifiedColumn' => $qualifiedColumn,
'query' => $query,
]) ?? $query;
}
return $this->apply($query, $qualifiedColumn);
}
public function applyToBaseFilterQuery(Builder $query): Builder
{
return $query;
}
/**
* @return array<\Filament\Schemas\Components\Component>
*/
public function getFormSchema(): array
{
return [];
}
public function constraint(?Constraint $constraint): static
{
$this->constraint = $constraint;
return $this;
}
/**
* @param array<string, mixed> | null $settings
*/
public function settings(?array $settings): static
{
$this->settings = $settings;
return $this;
}
public function inverse(?bool $condition = true): static
{
$this->isInverse = $condition;
return $this;
}
public function getConstraint(): ?Constraint
{
return $this->constraint;
}
/**
* @return array<string, mixed> | null
*/
public function getSettings(): ?array
{
return $this->settings;
}
public function isInverse(): ?bool
{
return $this->isInverse;
}
public function queriesRelationshipsUsingSubSelect(): bool
{
return $this->getConstraint()->queriesRelationships();
}
protected function hasQueryModificationCallback(): bool
{
return $this->modifyQueryUsing instanceof Closure;
}
protected function hasBaseQueryModificationCallback(): bool
{
return $this->modifyBaseQueryUsing instanceof Closure;
}
protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array
{
return match ($parameterName) {
'constraint' => [$this->getConstraint()],
'isInverse' => [$this->isInverse()],
'settings' => [$this->getSettings()],
default => parent::resolveDefaultClosureDependencyForEvaluationByName($parameterName),
};
}
}
@@ -0,0 +1,69 @@
<?php
namespace Filament\QueryBuilder\Constraints;
use Closure;
use Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators\EqualsOperator;
use Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators\HasMaxOperator;
use Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators\HasMinOperator;
use Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators\IsEmptyOperator;
use Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators\IsRelatedToOperator;
use Filament\QueryBuilder\View\QueryBuilderIconAlias;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
class RelationshipConstraint extends Constraint
{
protected bool | Closure $isMultiple = false;
protected bool | Closure | null $canBeEmpty = false;
protected function setUp(): void
{
parent::setUp();
$this->icon(FilamentIcon::resolve(QueryBuilderIconAlias::CONSTRAINTS_RELATIONSHIP) ?? Heroicon::ArrowsPointingOut);
$this->operators([
HasMinOperator::make()
->visible(fn (): bool => $this->isMultiple()),
HasMaxOperator::make()
->visible(fn (): bool => $this->isMultiple()),
EqualsOperator::make()
->visible(fn (): bool => $this->isMultiple()),
IsEmptyOperator::make()
->visible(fn (): bool => $this->canBeEmpty()),
]);
}
public function selectable(IsRelatedToOperator $operator): static
{
$this->unshiftOperators([$operator]);
return $this;
}
public function multiple(bool | Closure $condition = true): static
{
$this->isMultiple = $condition;
return $this;
}
public function isMultiple(): bool
{
return (bool) $this->evaluate($this->isMultiple);
}
public function emptyable(bool | Closure | null $condition = true): static
{
$this->canBeEmpty = $condition;
return $this;
}
public function canBeEmpty(): bool
{
return $this->evaluate($this->canBeEmpty) ?? $this->isMultiple();
}
}
@@ -0,0 +1,59 @@
<?php
namespace Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\TextInput;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Eloquent\Builder;
class EqualsOperator extends Operator
{
public function getName(): string
{
return 'equals';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.relationship.equals.label.inverse' :
'filament-query-builder::query-builder.operators.relationship.equals.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.relationship.equals.summary.inverse' :
'filament-query-builder::query-builder.operators.relationship.equals.summary.direct',
[
'relationship' => $this->getConstraint()->getAttributeLabel(),
'count' => $this->getSettings()['count'],
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
TextInput::make('count')
->label(__('filament-query-builder::query-builder.operators.relationship.form.count.label'))
->numeric()
->required()
->minValue(1),
];
}
public function applyToBaseQuery(Builder $query): Builder
{
return $query->has($this->getConstraint()->getRelationshipName(), $this->isInverse() ? '!=' : '=', intval($this->getSettings()['count']));
}
}
@@ -0,0 +1,59 @@
<?php
namespace Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\TextInput;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Eloquent\Builder;
class HasMaxOperator extends Operator
{
public function getName(): string
{
return 'hasMax';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.relationship.has_max.label.inverse' :
'filament-query-builder::query-builder.operators.relationship.has_max.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.relationship.has_max.summary.inverse' :
'filament-query-builder::query-builder.operators.relationship.has_max.summary.direct',
[
'relationship' => $this->getConstraint()->getAttributeLabel(),
'count' => $this->getSettings()['count'],
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
TextInput::make('count')
->label(__('filament-query-builder::query-builder.operators.relationship.form.count.label'))
->numeric()
->required()
->minValue(1),
];
}
public function applyToBaseQuery(Builder $query): Builder
{
return $query->has($this->getConstraint()->getRelationshipName(), $this->isInverse() ? '>' : '<=', intval($this->getSettings()['count']));
}
}
@@ -0,0 +1,59 @@
<?php
namespace Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\TextInput;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Eloquent\Builder;
class HasMinOperator extends Operator
{
public function getName(): string
{
return 'hasMin';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.relationship.has_min.label.inverse' :
'filament-query-builder::query-builder.operators.relationship.has_min.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.relationship.has_min.summary.inverse' :
'filament-query-builder::query-builder.operators.relationship.has_min.summary.direct',
[
'relationship' => $this->getConstraint()->getAttributeLabel(),
'count' => $this->getSettings()['count'],
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
TextInput::make('count')
->label(__('filament-query-builder::query-builder.operators.relationship.form.count.label'))
->numeric()
->required()
->minValue(1),
];
}
public function applyToBaseQuery(Builder $query): Builder
{
return $query->has($this->getConstraint()->getRelationshipName(), $this->isInverse() ? '<' : '>=', intval($this->getSettings()['count']));
}
}
@@ -0,0 +1,38 @@
<?php
namespace Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Illuminate\Database\Eloquent\Builder;
class IsEmptyOperator extends Operator
{
public function getName(): string
{
return 'isEmpty';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.relationship.is_empty.label.inverse' :
'filament-query-builder::query-builder.operators.relationship.is_empty.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.relationship.is_empty.summary.inverse' :
'filament-query-builder::query-builder.operators.relationship.is_empty.summary.direct',
['relationship' => $this->getConstraint()->getAttributeLabel()],
);
}
public function applyToBaseQuery(Builder $query): Builder
{
return $query->{$this->isInverse() ? 'has' : 'doesntHave'}($this->getConstraint()->getRelationshipName());
}
}
@@ -0,0 +1,345 @@
<?php
namespace Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators;
use Closure;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\Select;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\QueryBuilder\Constraints\RelationshipConstraint;
use Filament\Schemas\Components\Component;
use Filament\Support\Services\RelationshipJoiner;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Arr;
use LogicException;
use Znck\Eloquent\Relations\BelongsToThrough;
class IsRelatedToOperator extends Operator
{
protected string | Closure | null $titleAttribute = null;
protected ?Closure $modifyRelationshipQueryUsing = null;
protected bool | Closure $isPreloaded = false;
protected bool | Closure $isMultiple = false;
protected bool | Closure $isNative = true;
protected bool | Closure $isStatic = false;
protected bool | Closure $isSearchable = false;
protected int | Closure $optionsLimit = 50;
protected bool | Closure | null $isSearchForcedCaseInsensitive = null;
protected ?Closure $getOptionLabelUsing = null;
protected ?Closure $getOptionLabelsUsing = null;
protected ?Closure $getSearchResultsUsing = null;
protected ?Closure $getOptionLabelFromRecordUsing = null;
public function getName(): string
{
return 'isRelatedTo';
}
public function getLabel(): string
{
return __(
$this->getConstraint()->isMultiple() ?
(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.relationship.is_related_to.label.multiple.inverse' :
'filament-query-builder::query-builder.operators.relationship.is_related_to.label.multiple.direct'
) :
(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.relationship.is_related_to.label.single.inverse' :
'filament-query-builder::query-builder.operators.relationship.is_related_to.label.single.direct'
),
);
}
public function getSummary(): string
{
$constraint = $this->getConstraint();
$values = Arr::wrap($this->getSettings()[$constraint->isMultiple() ? 'values' : 'value']);
$relationshipQuery = $this->getRelationshipQuery();
$values = $relationshipQuery
->when(
$this->getRelationship() instanceof BelongsToThrough,
fn (Builder $query) => $query->distinct(),
)
->whereKey($values)
->pluck($relationshipQuery->qualifyColumn($this->getTitleAttribute()))
->join(glue: __('filament-query-builder::query-builder.operators.relationship.is_related_to.summary.values_glue.0'), finalGlue: __('filament-query-builder::query-builder.operators.relationship.is_related_to.summary.values_glue.final'));
return __(
$this->getConstraint()->isMultiple() ?
(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.relationship.is_related_to.summary.multiple.inverse' :
'filament-query-builder::query-builder.operators.relationship.is_related_to.summary.multiple.direct'
) :
(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.relationship.is_related_to.summary.single.inverse' :
'filament-query-builder::query-builder.operators.relationship.is_related_to.summary.single.direct'
),
[
'relationship' => $constraint->getAttributeLabel(),
'values' => $values,
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
$constraint = $this->getConstraint();
$field = Select::make($constraint->isMultiple() ? 'values' : 'value')
->label(__($constraint->isMultiple() ? 'filament-query-builder::query-builder.operators.relationship.is_related_to.form.values.label' : 'filament-query-builder::query-builder.operators.relationship.is_related_to.form.value.label'))
->multiple($this->isMultiple())
->searchable($this->isSearchable())
->preload($this->isPreloaded())
->native($this->isNative())
->optionsLimit($this->getOptionsLimit())
->required()
->model($constraint->getModel())
->relationship(
$constraint->getRelationshipName(),
$this->getTitleAttribute(),
$this->modifyRelationshipQueryUsing,
)
->forceSearchCaseInsensitive($this->isSearchForcedCaseInsensitive())
->columnSpanFull();
if ($this->getOptionLabelUsing) {
$field->getOptionLabelUsing($this->getOptionLabelUsing);
}
if ($this->getOptionLabelsUsing) {
$field->getOptionLabelsUsing($this->getOptionLabelsUsing);
}
if ($this->getOptionLabelFromRecordUsing) {
$field->getOptionLabelFromRecordUsing($this->getOptionLabelFromRecordUsing);
}
if ($this->getSearchResultsUsing) {
$field->getSearchResultsUsing($this->getSearchResultsUsing);
}
return [$field];
}
public function titleAttribute(string | Closure | null $attribute): static
{
$this->titleAttribute = $attribute;
return $this;
}
public function modifyRelationshipQueryUsing(?Closure $modifyQueryUsing = null): static
{
$this->modifyRelationshipQueryUsing = $modifyQueryUsing;
return $this;
}
public function getOptionLabelUsing(?Closure $callback): static
{
$this->getOptionLabelUsing = $callback;
return $this;
}
public function getOptionLabelsUsing(?Closure $callback): static
{
$this->getOptionLabelsUsing = $callback;
return $this;
}
public function getSearchResultsUsing(?Closure $callback): static
{
$this->getSearchResultsUsing = $callback;
return $this;
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
$constraint = $this->getConstraint();
$value = $this->getSettings()[$constraint->isMultiple() ? 'values' : 'value'];
return $query->{$this->isInverse() ? 'whereDoesntHave' : 'whereHas'}(
$constraint->getRelationshipName(),
fn (Builder $query) => $query->whereKey($value),
);
}
public function getConstraint(): ?RelationshipConstraint
{
$constraint = parent::getConstraint();
if (! $constraint) {
return null;
}
if (! ($constraint instanceof RelationshipConstraint)) {
throw new LogicException('Is operator can only be used with relationship constraints.');
}
return $constraint;
}
public function forceSearchCaseInsensitive(bool | Closure | null $condition = true): static
{
$this->isSearchForcedCaseInsensitive = $condition;
return $this;
}
public function isSearchForcedCaseInsensitive(): ?bool
{
return $this->evaluate($this->isSearchForcedCaseInsensitive);
}
public function getModifyRelationshipQueryUsing(): ?Closure
{
return $this->modifyRelationshipQueryUsing;
}
public function getRelationship(): Relation | Builder
{
$constraint = $this->getConstraint();
$record = app($constraint->getModel());
$relationship = null;
foreach (explode('.', $constraint->getRelationshipName()) as $nestedRelationshipName) {
if ($record->hasAttribute($nestedRelationshipName)) {
$relationship = null;
break;
}
if (! $record->isRelation($nestedRelationshipName)) {
$relationship = null;
break;
}
$relationship = $record->{$nestedRelationshipName}();
$record = $relationship->getRelated();
}
return $relationship;
}
public function getRelationshipQuery(): ?Builder
{
$relationship = Relation::noConstraints(fn () => $this->getRelationship());
$relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship);
if ($this->getModifyRelationshipQueryUsing()) {
$relationshipQuery = $this->evaluate($this->modifyRelationshipQueryUsing, [
'query' => $relationshipQuery,
]) ?? $relationshipQuery;
}
if (empty($relationshipQuery->getQuery()->orders)) {
$relationshipQuery->orderBy($relationshipQuery->qualifyColumn($this->getTitleAttribute()));
}
return $relationshipQuery;
}
public function getTitleAttribute(): ?string
{
return $this->evaluate($this->titleAttribute);
}
public function multiple(bool | Closure $condition = true): static
{
$this->isMultiple = $condition;
return $this;
}
public function isMultiple(): bool
{
return (bool) $this->evaluate($this->isMultiple);
}
public function searchable(bool | Closure $condition = true): static
{
$this->isSearchable = $condition;
return $this;
}
public function isSearchable(): bool
{
return (bool) $this->evaluate($this->isSearchable);
}
public function optionsLimit(int | Closure $limit): static
{
$this->optionsLimit = $limit;
return $this;
}
public function getOptionsLimit(): int
{
return $this->evaluate($this->optionsLimit);
}
public function native(bool | Closure $condition = true): static
{
$this->isNative = $condition;
return $this;
}
public function isNative(): bool
{
return (bool) $this->evaluate($this->isNative);
}
public function getOptionLabelFromRecordUsing(?Closure $callback): static
{
$this->getOptionLabelFromRecordUsing = $callback;
return $this;
}
public function preload(bool | Closure $condition = true): static
{
$this->isPreloaded = $condition;
return $this;
}
public function isPreloaded(): bool
{
return (bool) $this->evaluate($this->isPreloaded);
}
}
@@ -0,0 +1,191 @@
<?php
namespace Filament\QueryBuilder\Constraints;
use Closure;
use Filament\QueryBuilder\Constraints\Operators\IsFilledOperator;
use Filament\QueryBuilder\Constraints\SelectConstraint\Operators\IsOperator;
use Filament\QueryBuilder\View\QueryBuilderIconAlias;
use Filament\Support\Contracts\HasLabel as LabelInterface;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Arrayable;
use UnitEnum;
class SelectConstraint extends Constraint
{
use Concerns\CanBeNullable;
protected bool | Closure $isMultiple = false;
protected bool | Closure $isNative = true;
protected bool | Closure $isSearchable = false;
protected int | Closure $optionsLimit = 50;
protected ?Closure $getOptionLabelFromRecordUsing = null;
/**
* @var array<string | array<string>> | Arrayable | class-string | Closure | null
*/
protected array | Arrayable | string | Closure | null $options = null;
protected ?Closure $getOptionLabelUsing = null;
protected ?Closure $getOptionLabelsUsing = null;
protected ?Closure $getSearchResultsUsing = null;
protected function setUp(): void
{
parent::setUp();
$this->icon(FilamentIcon::resolve(QueryBuilderIconAlias::CONSTRAINTS_SELECT) ?? Heroicon::ChevronUpDown);
$this->operators([
IsOperator::class,
IsFilledOperator::make()
->visible(fn (): bool => $this->isNullable()),
]);
}
public function multiple(bool | Closure $condition = true): static
{
$this->isMultiple = $condition;
return $this;
}
public function searchable(bool | Closure $condition = true): static
{
$this->isSearchable = $condition;
return $this;
}
public function isMultiple(): bool
{
return (bool) $this->evaluate($this->isMultiple);
}
public function isSearchable(): bool
{
return (bool) $this->evaluate($this->isSearchable);
}
public function optionsLimit(int | Closure $limit): static
{
$this->optionsLimit = $limit;
return $this;
}
public function getOptionsLimit(): int
{
return $this->evaluate($this->optionsLimit);
}
public function native(bool | Closure $condition = true): static
{
$this->isNative = $condition;
return $this;
}
public function isNative(): bool
{
return (bool) $this->evaluate($this->isNative);
}
public function getOptionLabelFromRecordUsing(?Closure $callback): static
{
$this->getOptionLabelFromRecordUsing = $callback;
return $this;
}
public function getOptionLabelFromRecordUsingCallback(): ?Closure
{
return $this->getOptionLabelFromRecordUsing;
}
/**
* @param array<string | array<string>> | Arrayable | class-string | Closure | null $options
*/
public function options(array | Arrayable | string | Closure | null $options): static
{
$this->options = $options;
return $this;
}
/**
* @return array<string | array<string>>
*/
public function getOptions(): array
{
$options = $this->evaluate($this->options);
if (
is_string($options) &&
enum_exists($enum = $options)
) {
if (is_a($enum, LabelInterface::class, allow_string: true)) {
return array_reduce($enum::cases(), function (array $carry, LabelInterface & UnitEnum $case): array {
$carry[$case->value ?? $case->name] = $case->getLabel() ?? $case->name;
return $carry;
}, []);
}
return array_reduce($enum::cases(), function (array $carry, UnitEnum $case): array {
$carry[$case->value ?? $case->name] = $case->name;
return $carry;
}, []);
}
if ($options instanceof Arrayable) {
$options = $options->toArray();
}
return $options ?? [];
}
public function getOptionLabelUsing(?Closure $callback): static
{
$this->getOptionLabelUsing = $callback;
return $this;
}
public function getOptionLabelsUsing(?Closure $callback): static
{
$this->getOptionLabelsUsing = $callback;
return $this;
}
public function getSearchResultsUsing(?Closure $callback): static
{
$this->getSearchResultsUsing = $callback;
return $this;
}
public function getOptionLabelUsingCallback(): ?Closure
{
return $this->getOptionLabelUsing;
}
public function getOptionLabelsUsingCallback(): ?Closure
{
return $this->getOptionLabelsUsing;
}
public function getSearchResultsUsingCallback(): ?Closure
{
return $this->getSearchResultsUsing;
}
}
@@ -0,0 +1,122 @@
<?php
namespace Filament\QueryBuilder\Constraints\SelectConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\Select;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\QueryBuilder\Constraints\SelectConstraint;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use LogicException;
class IsOperator extends Operator
{
public function getName(): string
{
return 'is';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.select.is.label.inverse' :
'filament-query-builder::query-builder.operators.select.is.label.direct',
);
}
public function getSummary(): string
{
$constraint = $this->getConstraint();
$settings = $this->getSettings();
if ($constraint->isMultiple()) {
$getLabels = $constraint->getOptionLabelsUsingCallback();
$valuesKey = 'values';
} else {
$getLabels = $constraint->getOptionLabelUsingCallback();
$valuesKey = 'value';
}
$labels = $getLabels ?
Arr::wrap($this->evaluate($getLabels, [$valuesKey => $settings[$valuesKey]])) :
Arr::only($constraint->getOptions(), $settings[$valuesKey]);
$joinedValues = Arr::join(
$labels,
glue: __('filament-query-builder::query-builder.operators.select.is.summary.values_glue.0'),
finalGlue: __('filament-query-builder::query-builder.operators.select.is.summary.values_glue.final'),
);
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.select.is.summary.inverse' :
'filament-query-builder::query-builder.operators.select.is.summary.direct',
[
'attribute' => $constraint->getAttributeLabel(),
'values' => $joinedValues,
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
$constraint = $this->getConstraint();
$field = Select::make($constraint->isMultiple() ? 'values' : 'value')
->label(__($constraint->isMultiple() ? 'filament-query-builder::query-builder.operators.select.is.form.values.label' : 'filament-query-builder::query-builder.operators.select.is.form.value.label'))
->options($constraint->getOptions())
->multiple($constraint->isMultiple())
->searchable($constraint->isSearchable())
->native($constraint->isNative())
->optionsLimit($constraint->getOptionsLimit())
->required()
->columnSpanFull();
if ($getOptionLabelUsing = $constraint->getOptionLabelUsingCallback()) {
$field->getOptionLabelUsing($getOptionLabelUsing);
}
if ($getOptionLabelsUsing = $constraint->getOptionLabelsUsingCallback()) {
$field->getOptionLabelsUsing($getOptionLabelsUsing);
}
if ($getOptionLabelFromRecordUsing = $constraint->getOptionLabelFromRecordUsingCallback()) {
$field->getOptionLabelFromRecordUsing($getOptionLabelFromRecordUsing);
}
if ($getSearchResultsUsing = $constraint->getSearchResultsUsingCallback()) {
$field->getSearchResultsUsing($getSearchResultsUsing);
}
return [$field];
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
$value = $this->getSettings()[$this->getConstraint()->isMultiple() ? 'values' : 'value'];
if (is_array($value)) {
return $query->{$this->isInverse() ? 'whereNotIn' : 'whereIn'}($qualifiedColumn, $value);
}
return $query->{$this->isInverse() ? 'whereNot' : 'where'}($qualifiedColumn, $value);
}
public function getConstraint(): ?SelectConstraint
{
$constraint = parent::getConstraint();
if (! ($constraint instanceof SelectConstraint)) {
throw new LogicException('Is operator can only be used with select constraints.');
}
return $constraint;
}
}
@@ -0,0 +1,33 @@
<?php
namespace Filament\QueryBuilder\Constraints;
use Filament\QueryBuilder\Constraints\Operators\IsFilledOperator;
use Filament\QueryBuilder\Constraints\TextConstraint\Operators\ContainsOperator;
use Filament\QueryBuilder\Constraints\TextConstraint\Operators\EndsWithOperator;
use Filament\QueryBuilder\Constraints\TextConstraint\Operators\EqualsOperator;
use Filament\QueryBuilder\Constraints\TextConstraint\Operators\StartsWithOperator;
use Filament\QueryBuilder\View\QueryBuilderIconAlias;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
class TextConstraint extends Constraint
{
use Concerns\CanBeNullable;
protected function setUp(): void
{
parent::setUp();
$this->icon(FilamentIcon::resolve(QueryBuilderIconAlias::CONSTRAINTS_TEXT) ?? Heroicon::Language);
$this->operators([
ContainsOperator::class,
StartsWithOperator::class,
EndsWithOperator::class,
EqualsOperator::class,
IsFilledOperator::make()
->visible(fn (): bool => $this->isNullable()),
]);
}
}
@@ -0,0 +1,92 @@
<?php
namespace Filament\QueryBuilder\Constraints\TextConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\TextInput;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Expression;
use Illuminate\Support\Str;
class ContainsOperator extends Operator
{
public function getName(): string
{
return 'contains';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.text.contains.label.inverse' :
'filament-query-builder::query-builder.operators.text.contains.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.text.contains.summary.inverse' :
'filament-query-builder::query-builder.operators.text.contains.summary.direct',
[
'attribute' => $this->getConstraint()->getAttributeLabel(),
'text' => $this->getSettings()['text'],
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
TextInput::make('text')
->label(__('filament-query-builder::query-builder.operators.text.form.text.label'))
->required()
->columnSpanFull(),
];
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
$text = trim($this->getSettings()['text']);
/** @var Connection $databaseConnection */
$databaseConnection = $query->getConnection();
$isPostgres = $databaseConnection->getDriverName() === 'pgsql';
if ($isPostgres) {
$parts = explode('.', $qualifiedColumn);
if (count($parts) === 3) {
[$schema, $table, $column] = $parts;
$table = "{$schema}.{$table}";
} else {
[$table, $column] = $parts;
}
if (Str::lower($table) !== $table) {
$table = collect(explode('.', $table))
->map(fn (string $segment): string => "\"{$segment}\"")
->implode('.');
}
if (Str::lower($column) !== $column) {
$column = "\"{$column}\"";
}
$qualifiedColumn = new Expression("lower({$table}.{$column}::text)");
$text = Str::lower($text);
}
return $query->{$this->isInverse() ? 'whereNot' : 'where'}($qualifiedColumn, 'like', "%{$text}%");
}
}
@@ -0,0 +1,92 @@
<?php
namespace Filament\QueryBuilder\Constraints\TextConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\TextInput;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Expression;
use Illuminate\Support\Str;
class EndsWithOperator extends Operator
{
public function getName(): string
{
return 'endsWith';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.text.ends_with.label.inverse' :
'filament-query-builder::query-builder.operators.text.ends_with.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.text.ends_with.summary.inverse' :
'filament-query-builder::query-builder.operators.text.ends_with.summary.direct',
[
'attribute' => $this->getConstraint()->getAttributeLabel(),
'text' => $this->getSettings()['text'],
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
TextInput::make('text')
->label(__('filament-query-builder::query-builder.operators.text.form.text.label'))
->required()
->columnSpanFull(),
];
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
$text = trim($this->getSettings()['text']);
/** @var Connection $databaseConnection */
$databaseConnection = $query->getConnection();
$isPostgres = $databaseConnection->getDriverName() === 'pgsql';
if ($isPostgres) {
$parts = explode('.', $qualifiedColumn);
if (count($parts) === 3) {
[$schema, $table, $column] = $parts;
$table = "{$schema}.{$table}";
} else {
[$table, $column] = $parts;
}
if (Str::lower($table) !== $table) {
$table = collect(explode('.', $table))
->map(fn (string $segment): string => "\"{$segment}\"")
->implode('.');
}
if (Str::lower($column) !== $column) {
$column = "\"{$column}\"";
}
$qualifiedColumn = new Expression("lower({$table}.{$column}::text)");
$text = Str::lower($text);
}
return $query->{$this->isInverse() ? 'whereNot' : 'where'}($qualifiedColumn, 'like', "%{$text}");
}
}
@@ -0,0 +1,92 @@
<?php
namespace Filament\QueryBuilder\Constraints\TextConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\TextInput;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Expression;
use Illuminate\Support\Str;
class EqualsOperator extends Operator
{
public function getName(): string
{
return 'equals';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.text.equals.label.inverse' :
'filament-query-builder::query-builder.operators.text.equals.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.text.equals.summary.inverse' :
'filament-query-builder::query-builder.operators.text.equals.summary.direct',
[
'attribute' => $this->getConstraint()->getAttributeLabel(),
'text' => $this->getSettings()['text'],
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
TextInput::make('text')
->label(__('filament-query-builder::query-builder.operators.text.form.text.label'))
->required()
->columnSpanFull(),
];
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
$text = trim($this->getSettings()['text']);
/** @var Connection $databaseConnection */
$databaseConnection = $query->getConnection();
$isPostgres = $databaseConnection->getDriverName() === 'pgsql';
if ($isPostgres) {
$parts = explode('.', $qualifiedColumn);
if (count($parts) === 3) {
[$schema, $table, $column] = $parts;
$table = "{$schema}.{$table}";
} else {
[$table, $column] = $parts;
}
if (Str::lower($table) !== $table) {
$table = collect(explode('.', $table))
->map(fn (string $segment): string => "\"{$segment}\"")
->implode('.');
}
if (Str::lower($column) !== $column) {
$column = "\"{$column}\"";
}
$qualifiedColumn = new Expression("lower({$table}.{$column}::text)");
$text = Str::lower($text);
}
return $query->{$this->isInverse() ? 'whereNot' : 'where'}($qualifiedColumn, 'like', $text);
}
}
@@ -0,0 +1,92 @@
<?php
namespace Filament\QueryBuilder\Constraints\TextConstraint\Operators;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\TextInput;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\Schemas\Components\Component;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Expression;
use Illuminate\Support\Str;
class StartsWithOperator extends Operator
{
public function getName(): string
{
return 'startsWith';
}
public function getLabel(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.text.starts_with.label.inverse' :
'filament-query-builder::query-builder.operators.text.starts_with.label.direct',
);
}
public function getSummary(): string
{
return __(
$this->isInverse() ?
'filament-query-builder::query-builder.operators.text.starts_with.summary.inverse' :
'filament-query-builder::query-builder.operators.text.starts_with.summary.direct',
[
'attribute' => $this->getConstraint()->getAttributeLabel(),
'text' => $this->getSettings()['text'],
],
);
}
/**
* @return array<Component | Action | ActionGroup>
*/
public function getFormSchema(): array
{
return [
TextInput::make('text')
->label(__('filament-query-builder::query-builder.operators.text.form.text.label'))
->required()
->columnSpanFull(),
];
}
public function apply(Builder $query, string $qualifiedColumn): Builder
{
$text = trim($this->getSettings()['text']);
/** @var Connection $databaseConnection */
$databaseConnection = $query->getConnection();
$isPostgres = $databaseConnection->getDriverName() === 'pgsql';
if ($isPostgres) {
$parts = explode('.', $qualifiedColumn);
if (count($parts) === 3) {
[$schema, $table, $column] = $parts;
$table = "{$schema}.{$table}";
} else {
[$table, $column] = $parts;
}
if (Str::lower($table) !== $table) {
$table = collect(explode('.', $table))
->map(fn (string $segment): string => "\"{$segment}\"")
->implode('.');
}
if (Str::lower($column) !== $column) {
$column = "\"{$column}\"";
}
$qualifiedColumn = new Expression("lower({$table}.{$column}::text)");
$text = Str::lower($text);
}
return $query->{$this->isInverse() ? 'whereNot' : 'where'}($qualifiedColumn, 'like', "{$text}%");
}
}
@@ -0,0 +1,158 @@
<?php
namespace Filament\QueryBuilder\Forms\Components;
use Filament\Actions\Action;
use Filament\Forms\Components\Builder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\View\FormsIconAlias;
use Filament\QueryBuilder\Constraints\Constraint;
use Filament\QueryBuilder\View\QueryBuilderIconAlias;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Flex;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Support\Enums\Alignment;
use Filament\Support\Enums\Size;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Str;
class RuleBuilder extends Builder
{
public const OR_BLOCK_NAME = 'or';
public const OR_BLOCK_GROUPS_REPEATER_NAME = 'groups';
/** @var array<Constraint> */
protected array $constraints = [];
protected function setUp(): void
{
parent::setUp();
$this
->label(__('filament-query-builder::query-builder.form.rules.label'))
->blocks(function (): array {
return [
...array_map(
fn (Constraint $constraint): Builder\Block => $constraint->getBuilderBlock(),
$this->getConstraints(),
),
Builder\Block::make(static::OR_BLOCK_NAME)
->label(__('filament-query-builder::query-builder.form.or_groups.block.label'))
->icon(FilamentIcon::resolve(QueryBuilderIconAlias::OR_GROUP_BLOCK) ?? Heroicon::Slash)
->schema([
Flex::make(function (Flex $component): array {
/** @var Builder $builder */
$builder = $component->getContainer()->getParentComponent()->getContainer()->getParentComponent();
return [
Repeater::make(static::OR_BLOCK_GROUPS_REPEATER_NAME)
->label(__('filament-query-builder::query-builder.form.or_groups.label'))
->schema([
Flex::make([
static::make('rules')
->hiddenLabel()
->partiallyRenderAfterActionsCalled($builder->shouldPartiallyRenderAfterActionsCalled())
->constraints($this->getConstraints())
->blockPickerColumns($this->getBlockPickerColumns())
->blockPickerWidth($this->getBlockPickerWidth()),
Actions::make(function (Actions $component): array {
/** @var Repeater $repeater */
$repeater = $component->getContainer()->getParentComponent()->getContainer()->getParentComponent();
return [
Action::make($deleteActionName = $repeater->getDeleteActionName())
->label(__('filament-forms::components.repeater.actions.delete.label'))
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_REPEATER_ACTIONS_DELETE) ?? Heroicon::Trash)
->color('danger')
->iconButton()
->size(Size::Small)
->action($repeater->getAction($deleteActionName)->arguments(['item' => (string) str($component->getContainer()->getParentComponent()->getContainer()->getStatePath(isAbsolute: false))->beforeLast('.data')])->getLivewireClickHandler())
->visible(fn (Get $get): bool => blank($get('rules')) && (count($repeater->getRawState()) > 2)),
];
})->grow(false),
])->verticallyAlignCenter(),
])
->addAction(fn (Action $action, Repeater $component) => $action
->label(__('filament-query-builder::query-builder.actions.add_rule_group.label'))
->icon(FilamentIcon::resolve(QueryBuilderIconAlias::OR_GROUP_ADD_GROUP_ACTION) ?? Heroicon::Plus)
->hidden(fn (): bool => filled(array_filter($component->getRawState(), fn (array $itemState): bool => blank($itemState['rules'])))))
->addActionAlignment(Alignment::End)
->labelBetweenItems(__('filament-query-builder::query-builder.item_separators.or'))
->itemHeaders(false)
->defaultItems(2)
->minItems(2)
->cloneable()
->hiddenLabel()
->generateUuidUsing(fn (): string => Str::random(4))
->partiallyRenderAfterActionsCalled($builder->shouldPartiallyRenderAfterActionsCalled()),
Actions::make([
Action::make($cloneActionName = $builder->getCloneActionName())
->label(__('filament-forms::components.builder.actions.clone.label'))
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_BUILDER_ACTIONS_CLONE) ?? Heroicon::Square2Stack)
->color('gray')
->iconButton()
->size(Size::Small)
->action($builder->getAction($cloneActionName)->arguments(['item' => (string) str($component->getContainer()->getStatePath(isAbsolute: false))->beforeLast('.data')])->getLivewireClickHandler()),
Action::make($deleteActionName = $builder->getDeleteActionName())
->label(__('filament-forms::components.builder.actions.delete.label'))
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_BUILDER_ACTIONS_DELETE) ?? Heroicon::Trash)
->color('danger')
->iconButton()
->size(Size::Small)
->action($builder->getAction($deleteActionName)->arguments(['item' => (string) str($component->getContainer()->getStatePath(isAbsolute: false))->beforeLast('.data')])->getLivewireClickHandler()),
])->grow(false),
];
}),
]),
];
})
->addAction(fn (Action $action) => $action
->label(__('filament-query-builder::query-builder.actions.add_rule.label'))
->icon(FilamentIcon::resolve(QueryBuilderIconAlias::ADD_RULE_ACTION) ?? Heroicon::Plus))
->addBetweenAction(fn (Action $action) => $action->hidden())
->addActionAlignment(Alignment::Start)
->labelBetweenItems(__('filament-query-builder::query-builder.item_separators.and'))
->blockHeaders(false)
->cloneable()
->generateUuidUsing(fn (): string => Str::random(4))
->partiallyRenderAfterActionsCalled(false)
->extraAttributes(['class' => 'fi-fo-builder-not-contained']);
}
/**
* @param array<Constraint> $constraints
*/
public function constraints(array $constraints): static
{
foreach ($constraints as $constraint) {
$this->constraints[$constraint->getName()] = $constraint;
}
return $this;
}
/**
* @return array<Constraint>
*/
public function getConstraints(): array
{
return $this->evaluate($this->constraints);
}
public function getConstraint(string $name): ?Constraint
{
return $this->getConstraints()[$name] ?? null;
}
/**
* @param array<string, ?int> | int | null $columns
*/
public function constraintPickerColumns(array | int | null $columns = 2): static
{
$this->blockPickerColumns($columns);
return $this;
}
}
@@ -0,0 +1,153 @@
<?php
namespace Filament\QueryBuilder\Models\Scopes;
use Closure;
use Filament\QueryBuilder\Constraints\Constraint;
use Filament\QueryBuilder\Constraints\Operators\Operator;
use Filament\QueryBuilder\Forms\Components\RuleBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
class QueryBuilderScope
{
/**
* @param array<string, mixed> $rules
* @param array<Constraint> $constraints
*/
public function __construct(protected array $rules, protected array $constraints)
{
$this->constraints = Arr::mapWithKeys(
$this->constraints,
fn (Constraint $constraint) => [$constraint->getName() => $constraint],
);
}
/**
* @param array<string, mixed> $rules
* @param array<Constraint> $constraints
*/
public static function make(array $rules, array $constraints): static
{
return app(static::class, ['rules' => $rules, 'constraints' => $constraints]);
}
public function __invoke(Builder $query): Builder
{
$this->applyToBaseQuery($query);
$query->where(fn (Builder $query) => $this->applyToQuery($query));
return $query;
}
/**
* @param array<string, mixed> | null $rules
*/
public function applyToBaseQuery(Builder $query, ?array $rules = null): Builder
{
$rules ??= $this->rules;
foreach ($rules as $rule) {
if ($rule['type'] === RuleBuilder::OR_BLOCK_NAME) {
foreach ($rule['data'][RuleBuilder::OR_BLOCK_GROUPS_REPEATER_NAME] as $orGroup) {
$this->applyToBaseQuery(
$query,
$orGroup['rules'],
);
}
continue;
}
$this->tapOperatorFromRule(
$rule,
fn (Operator $operator) => $operator->applyToBaseFilterQuery($query),
);
}
return $query;
}
/**
* @param array<string, mixed> | null $rules
*/
public function applyToQuery(Builder $query, ?array $rules = null): Builder
{
$rules ??= $this->rules;
foreach ($rules as $rule) {
if ($rule['type'] === RuleBuilder::OR_BLOCK_NAME) {
$query->where(function (Builder $query) use ($rule): void {
$isFirst = true;
foreach ($rule['data'][RuleBuilder::OR_BLOCK_GROUPS_REPEATER_NAME] as $orGroup) {
$query->{$isFirst ? 'where' : 'orWhere'}(function (Builder $query) use ($orGroup): void {
$this->applyToQuery(
$query,
$orGroup['rules'],
);
});
$isFirst = false;
}
});
continue;
}
$this->tapOperatorFromRule(
$rule,
fn (Operator $operator) => $operator->applyToBaseQuery($query),
);
}
return $query;
}
/**
* @param array<string, mixed> $rule
*/
protected function tapOperatorFromRule(array $rule, Closure $callback): void
{
$constraint = $this->constraints[$rule['type']] ?? null;
if (! $constraint) {
return;
}
$operator = $rule['data'][$constraint::OPERATOR_SELECT_NAME];
if (blank($operator)) {
return;
}
[$operatorName, $isInverseOperator] = $constraint->parseOperatorString($operator);
$operator = $constraint->getOperator($operatorName);
if (! $operator) {
return;
}
$constraint
->settings($rule['data']['settings'] ?? [])
->inverse($isInverseOperator);
$operator
->constraint($constraint)
->settings($rule['data']['settings'] ?? [])
->inverse($isInverseOperator);
$callback($operator);
$constraint
->settings(null)
->inverse(null);
$operator
->constraint(null)
->settings(null)
->inverse(null);
}
}
@@ -0,0 +1,13 @@
<?php
namespace Filament\QueryBuilder;
use Illuminate\Support\ServiceProvider;
class QueryBuilderServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'filament-query-builder');
}
}
@@ -0,0 +1,24 @@
<?php
namespace Filament\QueryBuilder\View;
class QueryBuilderIconAlias
{
const ADD_RULE_ACTION = 'query-builder::add-rule-action';
const CONSTRAINTS_BOOLEAN = 'query-builder::constraints.boolean';
const CONSTRAINTS_DATE = 'query-builder::constraints.date';
const CONSTRAINTS_NUMBER = 'query-builder::constraints.number';
const CONSTRAINTS_RELATIONSHIP = 'query-builder::constraints.relationship';
const CONSTRAINTS_SELECT = 'query-builder::constraints.select';
const CONSTRAINTS_TEXT = 'query-builder::constraints.text';
const OR_GROUP_BLOCK = 'query-builder::or-group.block';
const OR_GROUP_ADD_GROUP_ACTION = 'query-builder::or-group.add-group-action';
}