Skip to content

Commit 9d1593e

Browse files
committed
Facet query processor
1 parent e109181 commit 9d1593e

File tree

6 files changed

+326
-0
lines changed

6 files changed

+326
-0
lines changed

src/FacetQueryBuilder.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@
22

33
namespace Ensi\LaravelElasticQuerySpecification;
44

5+
use Ensi\LaravelElasticQuery\Aggregating\AggregationsQuery;
6+
use Ensi\LaravelElasticQuery\Contracts\AggregationsBuilder;
7+
use Ensi\LaravelElasticQuerySpecification\Processors\FacetCompositeProcessor;
8+
use Ensi\LaravelElasticQuerySpecification\Processors\FacetConstraintProcessor;
9+
use Ensi\LaravelElasticQuerySpecification\Processors\FacetQueryProcessor;
510
use Ensi\LaravelElasticQuerySpecification\Processors\FacetRequestProcessor;
611
use Ensi\LaravelElasticQuerySpecification\Processors\FilterProcessor;
712
use Generator;
813

14+
/**
15+
* @mixin AggregationsQuery
16+
* @extends BaseQueryBuilder<AggregationsQuery>
17+
*/
918
class FacetQueryBuilder extends BaseQueryBuilder
1019
{
1120
/**
@@ -15,5 +24,11 @@ protected function processors(): Generator
1524
{
1625
yield new FilterProcessor($this->parameters->filters());
1726
yield new FacetRequestProcessor($this->parameters->facets());
27+
yield new FacetConstraintProcessor($this->query);
28+
yield new FacetCompositeProcessor(
29+
$this->query,
30+
$this->parameters->facets(),
31+
fn (string $facet, AggregationsBuilder $builder) => new FacetQueryProcessor($builder, $facet)
32+
);
1833
}
1934
}

src/Faceting/AllowedFacet.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,20 @@ public function attachFilter(AllowedFilter $filter): void
6262
$this->filters[] = $filter;
6363
}
6464

65+
public function disableFilters(): void
66+
{
67+
foreach ($this->filters() as $filter) {
68+
$filter->disable();
69+
}
70+
}
71+
72+
public function enableFilters(): void
73+
{
74+
foreach ($this->filters() as $filter) {
75+
$filter->enable();
76+
}
77+
}
78+
6579
public function aggregate(): ?AllowedAggregate
6680
{
6781
return $this->aggregate;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
namespace Ensi\LaravelElasticQuerySpecification\Processors;
4+
5+
use Closure;
6+
use Ensi\LaravelElasticQuery\Aggregating\AggregationsQuery;
7+
use Ensi\LaravelElasticQuery\Contracts\AggregationsBuilder;
8+
use Ensi\LaravelElasticQuerySpecification\Specification\Specification;
9+
use Ensi\LaravelElasticQuerySpecification\Specification\Visitor;
10+
use Illuminate\Support\Collection;
11+
12+
class FacetCompositeProcessor implements Visitor
13+
{
14+
private Collection $callbacks;
15+
16+
public function __construct(
17+
private AggregationsQuery $query,
18+
private Collection $facets,
19+
private Closure $facetProcessorFactory
20+
) {
21+
$this->callbacks = new Collection();
22+
}
23+
24+
public function visitRoot(Specification $specification): void
25+
{
26+
if ($specification->hasActiveFacet()) {
27+
$this->callbacks->push(fn (Visitor $visitor) => $visitor->visitRoot($specification));
28+
}
29+
}
30+
31+
public function visitNested(string $field, Specification $specification): void
32+
{
33+
if ($specification->hasActiveFacet()) {
34+
$this->callbacks->push(fn (Visitor $visitor) => $visitor->visitNested($field, $specification));
35+
}
36+
}
37+
38+
public function done(): void
39+
{
40+
if ($this->callbacks->isEmpty()) {
41+
return;
42+
}
43+
44+
foreach ($this->facets as $facet) {
45+
$this->query->composite(
46+
fn (AggregationsBuilder $builder) => $this->processFacet($facet, $builder)
47+
);
48+
}
49+
}
50+
51+
private function processFacet(string $facet, AggregationsBuilder $builder): void
52+
{
53+
$processor = $this->createFacetProcessor($facet, $builder);
54+
55+
$this->callbacks->each(fn (callable $callback) => $callback($processor));
56+
$processor->done();
57+
}
58+
59+
private function createFacetProcessor(string $facet, AggregationsBuilder $builder): Visitor
60+
{
61+
return ($this->facetProcessorFactory)($facet, $builder);
62+
}
63+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
namespace Ensi\LaravelElasticQuerySpecification\Processors;
4+
5+
use Ensi\LaravelElasticQuery\Contracts\AggregationsBuilder;
6+
use Ensi\LaravelElasticQuerySpecification\Contracts\Constraint;
7+
use Ensi\LaravelElasticQuerySpecification\Faceting\AllowedFacet;
8+
use Ensi\LaravelElasticQuerySpecification\Filtering\AllowedFilter;
9+
use Ensi\LaravelElasticQuerySpecification\Specification\Specification;
10+
use Ensi\LaravelElasticQuerySpecification\Specification\Visitor;
11+
12+
class FacetQueryProcessor implements Visitor
13+
{
14+
public function __construct(private AggregationsBuilder $builder, private string $facetName)
15+
{
16+
}
17+
18+
public function visitRoot(Specification $specification): void
19+
{
20+
$facets = $specification->facets()->filter(fn (AllowedFacet $facet) => $facet->isActive());
21+
$current = $facets->first(fn (AllowedFacet $facet) => $facet->name() === $this->facetName);
22+
23+
$current?->disableFilters();
24+
25+
$facets->flatMap(fn (AllowedFacet $facet) => $facet->filters())
26+
->each(fn (AllowedFilter $filter) => $filter($this->builder));
27+
28+
if ($current !== null) {
29+
$current->aggregate()($this->builder);
30+
}
31+
32+
$current?->enableFilters();
33+
}
34+
35+
public function visitNested(string $field, Specification $specification): void
36+
{
37+
$current = $specification->facets()
38+
->filter(fn (AllowedFacet $facet) => $facet->isActive())
39+
->first(fn (AllowedFacet $facet) => $facet->name() === $this->facetName);
40+
41+
$current?->disableFilters();
42+
43+
$this->builder->nested($field, function (AggregationsBuilder $builder) use ($specification, $current) {
44+
$specification->constraints()
45+
->each(fn (Constraint $constraint) => $constraint($builder));
46+
47+
if ($current !== null) {
48+
$current->aggregate()($builder);
49+
}
50+
});
51+
52+
$current?->enableFilters();
53+
}
54+
55+
public function done(): void
56+
{
57+
}
58+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
use Ensi\LaravelElasticQuery\Aggregating\AggregationsQuery;
4+
use Ensi\LaravelElasticQuerySpecification\Faceting\AllowedFacet;
5+
use Ensi\LaravelElasticQuerySpecification\Processors\FacetCompositeProcessor;
6+
use Ensi\LaravelElasticQuerySpecification\Specification\Specification;
7+
use Ensi\LaravelElasticQuerySpecification\Specification\Visitor;
8+
use Illuminate\Support\Collection;
9+
10+
uses()->group('unit');
11+
12+
test('no active facets', function () {
13+
$visitor = Mockery::mock(Visitor::class);
14+
$visitor->expects('visitRoot')->never();
15+
$visitor->expects('visitNested')->never();
16+
17+
$processor = new FacetCompositeProcessor(
18+
Mockery::mock(AggregationsQuery::class),
19+
new Collection(),
20+
fn () => $visitor
21+
);
22+
23+
$processor->visitRoot(Specification::new());
24+
$processor->visitNested('foo', Specification::new());
25+
$processor->done();
26+
});
27+
28+
test('root specification contains active facet', function () {
29+
$query = Mockery::mock(AggregationsQuery::class);
30+
$query->allows('composite')
31+
->andReturnUsing(function (Closure $callback) use ($query) {
32+
$callback($query);
33+
34+
return $query;
35+
});
36+
37+
$visitor = Mockery::mock(Visitor::class);
38+
$visitor->expects('visitRoot')->once();
39+
$visitor->expects('done')->once();
40+
41+
$processor = new FacetCompositeProcessor(
42+
$query,
43+
new Collection(['foo']),
44+
fn () => $visitor
45+
);
46+
47+
$facet = AllowedFacet::minmax('foo');
48+
$facet->enable();
49+
50+
$processor->visitRoot(Specification::new()->allowedFacets([$facet]));
51+
$processor->done();
52+
});
53+
54+
test('nested specification contains active facet', function () {
55+
$query = Mockery::mock(AggregationsQuery::class);
56+
$query->allows('composite')
57+
->andReturnUsing(function (Closure $callback) use ($query) {
58+
$callback($query);
59+
60+
return $query;
61+
});
62+
63+
$visitor = Mockery::mock(Visitor::class);
64+
$visitor->expects('visitNested')->once();
65+
$visitor->expects('done')->once();
66+
67+
$processor = new FacetCompositeProcessor(
68+
$query,
69+
new Collection(['foo']),
70+
fn () => $visitor
71+
);
72+
73+
$facet = AllowedFacet::minmax('foo');
74+
$facet->enable();
75+
76+
$processor->visitNested('bar', Specification::new()->allowedFacets([$facet]));
77+
$processor->done();
78+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
use Ensi\LaravelElasticQuery\Contracts\AggregationsBuilder;
4+
use Ensi\LaravelElasticQuerySpecification\Faceting\AllowedFacet;
5+
use Ensi\LaravelElasticQuerySpecification\Filtering\AllowedFilter;
6+
use Ensi\LaravelElasticQuerySpecification\Processors\FacetQueryProcessor;
7+
use Ensi\LaravelElasticQuerySpecification\Specification\Specification;
8+
use Ensi\LaravelElasticQuerySpecification\Tests\Unit\Processors\FluentProcessor;
9+
10+
uses()->group('unit');
11+
12+
test('process root specification with current facet', function () {
13+
$facet = AllowedFacet::terms('foo');
14+
$facet->attachFilter(AllowedFilter::exact('foo')->default(10));
15+
$facet->enable();
16+
17+
$spec = Specification::new()->allowedFacets([$facet]);
18+
19+
$query = Mockery::mock(AggregationsBuilder::class);
20+
$query->expects('terms')->once();
21+
$query->expects('where')->never();
22+
23+
FluentProcessor::new(FacetQueryProcessor::class, $query, 'foo')
24+
->visitRoot($spec)
25+
->done();
26+
});
27+
28+
test('process root specification without current facet', function () {
29+
$facet = AllowedFacet::terms('foo');
30+
$facet->attachFilter(AllowedFilter::exact('foo')->default(10));
31+
$facet->enable();
32+
33+
$spec = Specification::new()->allowedFacets([$facet]);
34+
35+
$query = Mockery::mock(AggregationsBuilder::class);
36+
$query->expects('terms')->never();
37+
$query->expects('where')->with('foo', 10)->once();
38+
39+
FluentProcessor::new(FacetQueryProcessor::class, $query, 'bar')
40+
->visitRoot($spec)
41+
->done();
42+
});
43+
44+
test('process nested specification with current facet', function () {
45+
$filter = AllowedFilter::exact('foo')->default(10);
46+
$facet = AllowedFacet::terms('foo');
47+
$facet->attachFilter($filter);
48+
$facet->enable();
49+
50+
$spec = Specification::new()
51+
->allowedFacets([$facet])
52+
->allowedFilters([$filter]);
53+
54+
$query = Mockery::mock(AggregationsBuilder::class);
55+
$query->allows('nested')
56+
->with('field', any())
57+
->andReturnUsing(function ($field, callable $callback) use ($query) {
58+
$callback($query);
59+
60+
return $query;
61+
});
62+
63+
$query->expects('terms')->once();
64+
$query->expects('where')->never();
65+
66+
FluentProcessor::new(FacetQueryProcessor::class, $query, 'foo')
67+
->visitNested('field', $spec)
68+
->done();
69+
});
70+
71+
test('process nested specification without current facet', function () {
72+
$filter = AllowedFilter::exact('foo')->default(10);
73+
$facet = AllowedFacet::terms('foo');
74+
$facet->attachFilter($filter);
75+
$facet->enable();
76+
77+
$spec = Specification::new()
78+
->allowedFacets([$facet])
79+
->allowedFilters([$filter])
80+
->where('bar', 50);
81+
82+
$query = Mockery::mock(AggregationsBuilder::class);
83+
$query->allows('nested')
84+
->with('field', any())
85+
->andReturnUsing(function ($field, callable $callback) use ($query) {
86+
$callback($query);
87+
88+
return $query;
89+
});
90+
91+
$query->expects('terms')->never();
92+
$query->expects('where')->with('foo', 10)->once();
93+
$query->expects('where')->with('bar', 50)->once();
94+
95+
FluentProcessor::new(FacetQueryProcessor::class, $query, 'bar')
96+
->visitNested('field', $spec)
97+
->done();
98+
});

0 commit comments

Comments
 (0)