Last active
August 29, 2015 13:56
-
-
Save mattparker/9217271 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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