Repository: pheature-flags/pheature-flags (php)

Issue:
[Toggle Model] Create a Segment type that finds a match in a collection of values
**Epic:** https://github.com/pheature-flags/pheature-flags/issues/2 Toggle Model
**Package:** https://github.com/pheature-flags/toggle-model 

**Description:**

Create a new segment type implementing the Segment interface. It should match when the given value is present in a segment's collection of values.

Pull request description:
[#118] [Toggle Model] Create a Segment type that finds a match in a collection of values
It closes #118. 

Patch:
diff --git a/packages/toggle-model/src/InCollectionMatchingSegment.php b/packages/toggle-model/src/InCollectionMatchingSegment.php
new file mode 100644
index 0000000..e4e8904
--- /dev/null
+++ b/packages/toggle-model/src/InCollectionMatchingSegment.php
@@ -0,0 +1,84 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Pheature\Model\Toggle;
+
+use Pheature\Core\Toggle\Read\Segment;
+
+class InCollectionMatchingSegment implements Segment
+{
+    public const NAME = 'in_collection_matching_segment';
+    private string $id;
+    /** @var array<string, mixed> */
+    private array $criteria;
+
+    /**
+     * InCollectionMatchingSegment constructor.
+     *
+     * @param string $id
+     * @param array<string, mixed> $criteria
+     */
+    public function __construct(string $id, array $criteria)
+    {
+        $this->id = $id;
+        $this->criteria = $criteria;
+    }
+
+    public function id(): string
+    {
+        return $this->id;
+    }
+
+    public function criteria(): array
+    {
+        return $this->criteria;
+    }
+
+    public function match(array $payload): bool
+    {
+        if (empty($this->criteria)) {
+            return false;
+        }
+
+        /** @var mixed $criterionValue */
+        foreach ($this->criteria as $field => $criterionValue) {
+            if (!array_key_exists($field, $payload)) {
+                return false;
+            }
+
+            if (!$this->isAMatch($payload[$field], $criterionValue)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * @param mixed $payloadValue
+     * @param mixed $criterionValue
+     * @return bool
+     */
+    private function isAMatch($payloadValue, $criterionValue): bool
+    {
+        if (is_array($criterionValue)) {
+            return in_array($payloadValue, $criterionValue, true);
+        }
+
+        return $payloadValue === $criterionValue;
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'id' => $this->id,
+            'criteria' => $this->criteria,
+        ];
+    }
+
+    public function jsonSerialize()
+    {
+        return $this->toArray();
+    }
+}
diff --git a/packages/toggle-model/src/SegmentFactory.php b/packages/toggle-model/src/SegmentFactory.php
index c790903..3d98c08 100644
--- a/packages/toggle-model/src/SegmentFactory.php
+++ b/packages/toggle-model/src/SegmentFactory.php
@@ -5,12 +5,12 @@ declare(strict_types=1);
 namespace Pheature\Model\Toggle;
 
 use Pheature\Core\Toggle\Exception\InvalidSegmentTypeGiven;
-use Pheature\Core\Toggle\Read\Segment as ISegment;
+use Pheature\Core\Toggle\Read\Segment;
 use Pheature\Core\Toggle\Read\SegmentFactory as ISegmentFactory;
 
 final class SegmentFactory implements ISegmentFactory
 {
-    public function create(string $segmentId, string $segmentType, array $criteria): ISegment
+    public function create(string $segmentId, string $segmentType, array $criteria): Segment
     {
         if (StrictMatchingSegment::NAME === $segmentType) {
             return new StrictMatchingSegment($segmentId, $criteria);
@@ -18,6 +18,9 @@ final class SegmentFactory implements ISegmentFactory
         if (IdentitySegment::NAME === $segmentType) {
             return new IdentitySegment($segmentId, $criteria);
         }
+        if (InCollectionMatchingSegment::NAME === $segmentType) {
+            return new InCollectionMatchingSegment($segmentId, $criteria);
+        }
 
         throw InvalidSegmentTypeGiven::withType($segmentType);
     }
@@ -27,6 +30,7 @@ final class SegmentFactory implements ISegmentFactory
         return [
             StrictMatchingSegment::NAME,
             IdentitySegment::NAME,
+            InCollectionMatchingSegment::NAME,
         ];
     }
 }
diff --git a/packages/toggle-model/src/StrictMatchingSegment.php b/packages/toggle-model/src/StrictMatchingSegment.php
index 8fe760f..565cecd 100644
--- a/packages/toggle-model/src/StrictMatchingSegment.php
+++ b/packages/toggle-model/src/StrictMatchingSegment.php
@@ -4,9 +4,9 @@ declare(strict_types=1);
 
 namespace Pheature\Model\Toggle;
 
-use Pheature\Core\Toggle\Read\Segment as ISegment;
+use Pheature\Core\Toggle\Read\Segment;
 
-final class StrictMatchingSegment implements ISegment
+final class StrictMatchingSegment implements Segment
 {
     public const NAME = 'strict_matching_segment';
     private string $id;


Test patch:
diff --git a/packages/toggle-model/test/InCollectionMatchingSegmentTest.php b/packages/toggle-model/test/InCollectionMatchingSegmentTest.php
new file mode 100644
index 0000000..d7317a1
--- /dev/null
+++ b/packages/toggle-model/test/InCollectionMatchingSegmentTest.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Pheature\Test\Model\Toggle;
+
+use Generator;
+use Pheature\Model\Toggle\InCollectionMatchingSegment;
+use PHPUnit\Framework\TestCase;
+
+class InCollectionMatchingSegmentTest extends TestCase
+{
+    private const SEGMENT_ID = 'a_segment_id';
+
+    /** @dataProvider nonMatchingPayloads */
+    public function testItShouldNotMatch(array $criteria, array $payload): void
+    {
+        $segment = new InCollectionMatchingSegment(self::SEGMENT_ID, $criteria);
+
+        self::assertFalse($segment->match($payload));
+    }
+
+    /** @dataProvider matchingPayloads */
+    public function testItShouldMatch(array $criteria, array $payload): void
+    {
+        $segment = new InCollectionMatchingSegment(self::SEGMENT_ID, $criteria);
+
+        self::assertTrue($segment->match($payload));
+    }
+
+    public function nonMatchingPayloads(): Generator
+    {
+        yield 'no criteria present' => [
+            'criteria' => [],
+            'payload' => [
+                'location' => 'girona',
+            ]
+        ];
+
+        yield 'strict value comparison' => [
+            'criteria' => [
+                'location' => 'barcelona',
+            ],
+            'payload' => [
+                'location' => 'girona',
+            ]
+        ];
+
+        yield 'possible values comparison' => [
+            'criteria' => [
+                'location' => ['barcelona', 'girona'],
+            ],
+            'payload' => [
+                'location' => 'bilbao',
+            ]
+        ];
+
+        yield 'multiple fields comparison' => [
+            'criteria' => [
+                'location' => ['barcelona', 'girona'],
+                'top_buyers' => true,
+            ],
+            'payload' => [
+                'location' => 'girona',
+                'top_buyers' => false,
+            ]
+        ];
+
+        yield 'multiple fields with missing field comparison' => [
+            'criteria' => [
+                'location' => ['barcelona', 'girona'],
+                'top_buyers' => true,
+            ],
+            'payload' => [
+                'location' => 'girona',
+            ]
+        ];
+    }
+
+    public function matchingPayloads(): Generator
+    {
+        yield 'strict value comparison' => [
+            'criteria' => [
+                'location' => 'barcelona',
+            ],
+            'payload' => [
+                'location' => 'barcelona',
+            ]
+        ];
+
+        yield 'possible values comparison' => [
+            'criteria' => [
+                'location' => ['barcelona', 'girona'],
+            ],
+            'payload' => [
+                'location' => 'girona',
+            ]
+        ];
+
+        yield 'multiple fields comparison' => [
+            'criteria' => [
+                'location' => ['barcelona', 'girona'],
+                'top_buyers' => true,
+            ],
+            'payload' => [
+                'location' => 'girona',
+                'top_buyers' => true,
+            ]
+        ];
+    }
+}
diff --git a/packages/toggle-model/test/SegmentFactoryTest.php b/packages/toggle-model/test/SegmentFactoryTest.php
index 096a417..29b8dfb 100644
--- a/packages/toggle-model/test/SegmentFactoryTest.php
+++ b/packages/toggle-model/test/SegmentFactoryTest.php
@@ -6,6 +6,7 @@ namespace Pheature\Test\Model\Toggle;
 
 use Pheature\Core\Toggle\Exception\InvalidSegmentTypeGiven;
 use Pheature\Model\Toggle\IdentitySegment;
+use Pheature\Model\Toggle\InCollectionMatchingSegment;
 use Pheature\Model\Toggle\StrictMatchingSegment;
 use Pheature\Model\Toggle\SegmentFactory;
 use PHPUnit\Framework\TestCase;
@@ -15,6 +16,7 @@ final class SegmentFactoryTest extends TestCase
     private const SEGMENT_ID = 'some_segment';
     private const STRICT_MATCHING_SEGMENT = 'strict_matching_segment';
     private const IDENTITY_SEGMENT = 'identity_segment';
+    private const IN_COLLECTION_MATCHING_SEGMENT = 'in_collection_matching_segment';
     private const CRITERIA = [
         'some' => 'criteria',
         'with' => [
@@ -29,6 +31,7 @@ final class SegmentFactoryTest extends TestCase
         self::assertSame([
             self::STRICT_MATCHING_SEGMENT,
             self::IDENTITY_SEGMENT,
+            self::IN_COLLECTION_MATCHING_SEGMENT,
         ], $factory->types());
     }
 
@@ -55,4 +58,12 @@ final class SegmentFactoryTest extends TestCase
         $segment = $factory->create(self::SEGMENT_ID, self::IDENTITY_SEGMENT, self::CRITERIA);
         self::assertInstanceOf(IdentitySegment::class, $segment);
     }
+
+    public function testItShouldCreateInstancesOfInCollectionMatchingSegment(): void
+    {
+        $factory = new SegmentFactory();
+
+        $segment = $factory->create(self::SEGMENT_ID, self::IN_COLLECTION_MATCHING_SEGMENT, self::CRITERIA);
+        self::assertInstanceOf(InCollectionMatchingSegment::class, $segment);
+    }
 }