Skip to content

Instantly share code, notes, and snippets.

@mattparker
Last active August 29, 2015 13:56
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mattparker/9217271 to your computer and use it in GitHub Desktop.
Save mattparker/9217271 to your computer and use it in GitHub Desktop.
<?php
// Domain expert says:
// Tenant can construct their own 'premium customer specification' from a list of ingredients
// Tenant chooses 3 orders, one in the last month:
$spec1 = new CustomerWith3OrdersIsPremium();
$spec2 = new CustomerWithRecentOrderIsPremium();
$allSpecs = new CompositeCustomerSpecification();
$allSpecs->add($spec1)->add($spec2);
// Is this a premium customer?
$customer1 = new AParticularCustomer();
$isPremium = $allSpecs->isSatisfiedBy($customer1);
// Can I have a list of all premium customers?
$sqlFactory = new CustomerConditionFactory();
$sqlConditions = $allSpecs->asSql($sqlFactory);
$sqlCompiler = new SqlQueryCompiler($sqlConditions);
$sql = $sqlCompiler->compile();
// SELECT customer.* FROM customer WHERE customer.order_count >= 3 AND customer.date_of_last_order >= '2014-01-25'
// or another time
// SELECT customer.* FROM customer INNER JOIN order ON customer.id = order.customerid GROUP BY customer.id
// HAVING COUNT(order.id) >= 3 AND MAX(order.order_date) >= '2014-01-25'
/*
A Specification tells us whether a customer meets some criteria. We want it to be able to tell
us for a particular Customer instance, but also to be able to express that same criteria in a way
that we can use to query the database.
Specification exposes methods isSatisfiedBy() and asSql().
We also want to be able to combine Specifications arbitrarily.
I'm using a CompositeSpecification to gather these together, which itself exposes the same
Specification interface - isSatisfiedBy() and asSql().
The asSql call factory methods on an SqlConditionFactory. The SqlConditions that are returned
by the factory methods know just enough about the SQL without exposing it to the Specification
objects, but in such a way that they can be combined - they are not a complete SQL statement
as they are (although they can be compiled into one).
These SqlConditions are then consumed by the SqlConditionCompiler, which knows a bit more
about how to join tables if necessary, and combines the SqlConditions into a single
SQL statement.
I've cheated and skipped the details of implementation of the compilation (especially OR/nested statements).
That's largely because I wouldn't actually want to be generating raw SQL strings. But doing
anything more at this point is besides the point.
*/
final class CustomerWith3OrdersIsPremium implements CustomerSpecification {
/**
* @return SqlCondition
*/
public function asSql (CustomerConditionFactory $factory) {
$comparison = new SqlComparison(Comparison::GREATER_OR_EQUAL);
$value = new IntValue(3);
return $factory->hasOrderCount($comparison, $value);
}
/**
* @return bool
*/
public function isSatisfiedBy (Customer $customer) {
// do something
return ($customer->howManyOrdersHaveYouMade() >= 3);
}
}
/**
* The composite holds a series of Specifications
*/
interface CompositeSpecification {
/**
* @return $this
*/
public function add (Specification $spec);
}
/**
* Holding a series of CustomerSpecification s
*
*/
class CompositeCustomerSpecification implements CompositeSpecification, CustomerSpecification {
/**
* @return array
*/
public function asSql (SqlConditionFactory $factory) {
$conditions = array();
foreach ($this->specifications as $spec) {
$conditions[] = $spec->asSql($factory);
}
return $conditions;
}
/**
* @return bool
*/
public function isSatisfiedBy (Customer $customer) {
foreach ($this->specifications as $spec) {
if ($spec->isSatisfieldBy($customer) === false) {
return false;
}
}
return true;
}
}
/**
* Converts a bunch of conditions into an SQL statement
*/
class SqlConditionCompiler {
public function __construct (array $conditions = array()) {
foreach ($conditions as $condition) {
$this->addCondition($condition);
}
}
public function addCondition (Condition $condition) {
//...
}
/**
* @return string
*/
public function compile () {
$this->gatherRequirements();
$sql = 'SELECT ';
$sql .= $this->writeColumns();
$sql .= $this->writeFrom();
$sql .= $this->writeWhere();
//...
return $sql;
}
// implementation ...
}
/**
* An individual SQL condition
*/
class SqlCondition {
protected $requiredTables = [];
protected $columns = [];
protected $where = [];
protected $having = [];
protected $group = [];
public function __construct ($tableName) {
$this->requiredTables[] = $tableName;
}
public function from ($tableName) {
}
public function where ($clause) {
}
// etc...
}
class CustomerConditionFactory {
protected $table = 'customer';
/**
* @return SqlCondition
*/
public function hasOrderCount (SqlComparison $comparison, IntValue $value) {
$where = $this->table . '.order_count '
. $comparison->toString() . ' ' . $value->toString();
$condition = new SqlCondition($this->table);
$condition->where($where);
return $condition;
}
}
// Misc value objects. These aren't important at all.
interface Comparison {
const EQUAL = 1;
const GREATER_OR_EQUAL = 2;
const LESS_OR_EQUAL = 3;
public function toString();
}
final class SqlComparison implements Comparison {
private $strings = array(
1 => '=',
2 => '>=',
3 => '<='
);
public function __construct ($type) {
$this->type = $type;
}
public function toString () {
return $this->strings[$this->type];
}
}
final class IntValue {
public function __construct ($value) {
if (!is_int($value)) {
throw new InvalidArgumentException("IntValue needs an integer");
}
$this->value = $value;
}
public function toString () {
return $this->value;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment