Multiple implementations of the same back-end application. The aim is to provide quick, side-by-side comparisons of different technologies (languages, frameworks, libraries) while preserving consistent business logic across all implementations.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

5.8 KiB

Specification Pattern in PHP for AutoStore

The Specification pattern is a way to encapsulate business rules or query criteria in a single place. In AutoStore, this pattern helps us define conditions like "find expired items" without duplicating logic across the codebase.

Why Do We Need This?

Imagine checking if an item is expired in multiple places:

// In a repository
$expiredItems = array_filter($items, function($item) {
    return $item->getExpirationDate() <= new \DateTime();
});

// In a service
if ($item->getExpirationDate() <= new \DateTime()) {
    $this->sendOrderNotification($item);
}

// In SQL query
$stmt = $pdo->prepare("SELECT * FROM items WHERE expirationDate <= :expirationDate");
$stmt->execute(['expirationDate' => (new DateTime())->format('Y-m-d H:i:s')]);
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);

This creates problems:

  • Duplication: The same rule appears multiple times
  • Maintenance: If the rule changes, you need to update it everywhere
  • Database queries: In-memory filtering doesn't translate to SQL WHERE clauses

How This Implementation Works

The implementation has two main classes:

  1. Specification: Evaluates conditions against objects
  2. Spec: Helper class with constants and methods to build specifications

Basic Structure

A specification can be:

  • A simple comparison: [field, operator, value] (e.g., ['expirationDate', '<=', $today])
  • A logical group: AND, OR, or NOT containing other specifications

Simple Example: Finding Expired Items

// Create a specification for expired items
$expiredSpec = new Specification(
    Spec::lte('expirationDate', new \DateTimeImmutable())
);

// Use it to check if an item is expired
if ($expiredSpec->match($item)) {
    echo "This item is expired!";
}

// Or filter an array of items
$expiredItems = array_filter($items, function($item) use ($expiredSpec) {
    return $expiredSpec->match($item);
});

Creating Complex Conditions

You can combine conditions using and, or, and not:

// Find active items that are expiring in the next 7 days
$aboutToExpireSpec = new Specification(
    Spec::and([
        Spec::eq('status', 'active'),
        Spec::and([
            Spec::gte('expirationDate', new \DateTimeImmutable()),
            Spec::lte('expirationDate', new \DateTimeImmutable('+7 days'))
        ])
    ])
);

Available Operators

Comparison Operators

Method Description Example
Spec::eq(field, value) Equals Spec::eq('name', 'Milk')
Spec::neq(field, value) Not equals Spec::neq('status', 'deleted')
Spec::gt(field, value) Greater than Spec::gt('quantity', 10)
Spec::gte(field, value) Greater than or equal Spec::gte('price', 5.99)
Spec::lt(field, value) Less than Spec::lt('weight', 2.5)
Spec::lte(field, value) Less than or equal Spec::lte('expirationDate', $today)
Spec::in(field, [values]) Value is in list Spec::in('category', ['dairy', 'meat'])
Spec::nin(field, [values]) Value is not in list Spec::nin('status', ['deleted', 'archived'])

Logical Operators

Method Description Example
Spec::and([specs]) All conditions must be true Spec::and([$spec1, $spec2])
Spec::or([specs]) At least one condition must be true Spec::or([$spec1, $spec2])
Spec::not(spec) Negates the condition Spec::not($spec1)

Best Practice: Domain-Specific Specifications

For important business rules, create dedicated classes:

// src/Domain/Specifications/ItemExpirationSpec.php
class ItemExpirationSpec
{
    public function isExpired(Item $item, DateTimeImmutable $currentTime): bool
    {
        return $this->getSpec($currentTime)->match($item);
    }

    public function getSpec(DateTimeImmutable $currentTime): Specification
    {
        return new Specification(
            Spec::lte('expirationDate', $currentTime->format('Y-m-d H:i:s'))
        );
    }
}

Using Specifications with Repositories

For In-Memory Repositories

    public function findWhere(Specification $specification): array
    {
        $result = [];
        foreach ($this->items as $item) {
            if ($specification->match($item)) {
                $result[] = $item;
            }
        }
        return $result;
    }

For SQL Repositories

The SQL renderer in the example converts specifications to WHERE clauses:

public function findWhere(Specification $specification): array
{
    $params = [];
    $sqlRenderer = new SqlRenderer();
    $whereClause = $sqlRenderer->render($spec->getSpec(), $params);

    $sql = "SELECT * FROM items WHERE $whereClause";
    $stmt = $this->pdo->prepare($sql);
    $stmt->execute($params);

    return $this->mapToItems($stmt->fetchAll(\PDO::FETCH_ASSOC));
}

Example: SQL Renderer Usage

The commented example in the code shows how to convert a specification to SQL:

$spec = Spec::and([
    Spec::eq('status', 'active'),
    Spec::or([
        Spec::gt('score', 80),
        Spec::in('role', ['admin', 'moderator'])
    ]),
    Spec::not(Spec::eq('deleted', true))
]);

$params = [];
$sqlRenderer = new SqlRenderer();
$whereClause = $sqlRenderer->render($spec, $params);

// Results in SQL like:
// (status = ? AND ((score > ?) OR (role IN (?,?))) AND NOT (deleted = ?))
// with params: ['active', 80, 'admin', 'moderator', true]

Benefits for AutoStore

  1. Consistent business rules: Item expiration logic is defined once
  2. Clean code: No duplicated conditions across repositories, services, etc.
  3. Efficiency: Can be used both for in-memory filtering and SQL queries
  4. Flexibility: If business rules change (e.g., items expire 3 days after their date), you only update one place