Last active
October 7, 2022 21:49
-
-
Save olleharstedt/88752595d8abb0ff7ba7197d26b3d15b to your computer and use it in GitHub Desktop.
EDSL in PHP
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 | |
/** | |
* EDSL = embedded domain-specific language | |
* The domain in this example is "side-effects", anything writing/reading to io, database, file, etc | |
* | |
* Using this EDSL, you neither need DI nor mocking to make the code testable. You can dry-run the particular | |
* parts needed of the "effectful" logic. | |
* | |
* Inspired by tagless-final pattern from FP. https://okmij.org/ftp/tagless-final/course/optimizations.html#primer | |
* Kind of related pattern is event sourcing: https://eventsauce.io/docs/ | |
* | |
* Usage example at bottom of file. | |
*/ | |
/** | |
* Everything that implements Node can be evaluated by an node evaluator. | |
* Nodes carry only data, no behaviour. Behaviour is implemented by the different | |
* evaluators, so that the behaviour can be switched or mocked in testing. | |
*/ | |
interface Node | |
{ | |
} | |
class Save implements Node | |
{ | |
public $model; | |
public function __construct($model) { | |
$this->model = $model; | |
} | |
} | |
class PushToStack implements Node | |
{ | |
public $stack; | |
public $thing; | |
public function __construct($stack, $thing) | |
{ | |
$this->stack = $stack; | |
$this->thing = $thing; | |
} | |
} | |
class If_ implements Node | |
{ | |
public $if; | |
public $then; | |
public function __construct(Node $if) | |
{ | |
$this->if = $if; | |
} | |
public function setThen(Node $if) | |
{ | |
$this->then = $if; | |
} | |
} | |
// Wrap constructor in function to lessen boilerplate in client code. | |
function save($model) | |
{ | |
return new Save($model); | |
} | |
// Wrap constructor in function to lessen boilerplate in client code. | |
function pushToStack($stack, $thing) | |
{ | |
return new PushToStack($stack, $thing); | |
} | |
/** | |
* The state class is the node builder used by client code. | |
*/ | |
class St | |
{ | |
public $queue = []; | |
public function if($if) | |
{ | |
$this->queue[] = new If_($if); | |
return $this; | |
} | |
public function then($if) | |
{ | |
$i = count($this->queue) - 1; | |
if ($this->queue[$i] instanceof If_) { | |
$this->queue[$i]->setThen($if); | |
} else { | |
throw new InvalidArgumentException('then must come after if'); | |
} | |
} | |
public function run($ev) | |
{ | |
foreach ($this->queue as $node) { | |
$ev->evalNode($node); | |
} | |
} | |
} | |
// Dummy user | |
class User | |
{ | |
public $id; | |
public function save() | |
{ | |
throw new Exception("Database problem :("); | |
} | |
} | |
// Dummy request | |
class Request | |
{ | |
public function getParam($name, $default) | |
{ | |
return 1; | |
} | |
} | |
class NodeEvaluator | |
{ | |
/** | |
* @return mixed | |
*/ | |
public function evalNode(Node $node) | |
{ | |
switch (get_class($node)) { | |
case "If_": | |
if ($this->evalNode($node->if)) { | |
$this->evalNode($node->then); | |
} elseif (!empty($node->else)) { | |
$this->evalNode($node->else); | |
} | |
break; | |
case "Save": | |
return $node->model->save(); | |
break; | |
case "PushToStack": | |
$node->stack->push($node->thing); | |
break; | |
} | |
} | |
} | |
class DryRunEvaluator | |
{ | |
public $log = []; | |
/** | |
* @return mixed | |
*/ | |
public function evalNode($node) | |
{ | |
switch (get_class($node)) { | |
case "If_": | |
$this->log[] = "Evaluating if"; | |
if ($this->evalNode($node->if)) { | |
$this->log[] = "Evaluating then"; | |
$this->evalNode($node->then); | |
} elseif (!empty($node->else)) { | |
$this->log[] = "Evaluating else"; | |
$this->evalNode($node->else); | |
} | |
break; | |
case "Save": | |
$this->log[] = "Save model"; | |
return true; | |
break; | |
case "PushToStack": | |
$this->log[] = "Push thing to stack: " . $node->thing; | |
break; | |
} | |
} | |
} | |
// --- Application code --- | |
// This is the function before side-effects are factored out | |
function originalCreateDummyUsers(Request $request): array | |
{ | |
$times = $request->getParam('times', 5); | |
$dummyUsers = new SplStack(); | |
for (; $times > 0; $times--) { | |
// Need factory to mock this line | |
$user = new User(); | |
$user->username = 'John Doe'; | |
// We want to avoid database interaction during testing. | |
if($user->save()) { | |
$dummyUsers->push($user->username); | |
} | |
} | |
return [ | |
'success' => true, | |
'dummyUsers' => $dummyUsers | |
]; | |
} | |
// Same function using the side-effect EDSL | |
function createDummyUsers(Request $request, St $st): array | |
{ | |
$times = $request->getParam('times', 5); | |
$dummyUsers = new SplStack(); | |
for (; $times > 0; $times--) { | |
$user = new User(); | |
$user->username = 'John Doe'; | |
// Using the EDSL | |
$st | |
->if(save($user)) | |
->then(pushToStack($dummyUsers, $user->username)); | |
} | |
return [ | |
'success' => true, | |
'dummyUsers' => $dummyUsers | |
]; | |
} | |
$st = new St(); | |
createDummyUsers(new Request(), $st); | |
$dry = new DryRunEvaluator(); | |
$st->run($dry); | |
echo implode("\n", $dry->log) . PHP_EOL; |
The evaluator can be modified to return a result from the AST run. Maybe with a ReturnNode?
$result = null;
$st
->if(fileExists($file))
->then(set($result, fileGetContents($file)))
->else(set($result, 'DEFAULT'));
// Evaluate the AST in $st, which will store result in $result
(new Evaluator())->run($st->getAst());
$result = strtoupper($result);
Or
function getupperText(string $file, St $st)
{
$result = 'DEFAULT';
$st
->if(fileExists($file))
->then(set($result, fileGetContents($file)))
();
return strtoupper($result);
}
https://blog.ploeh.dk/2016/09/26/decoupling-decisions-from-effects/
https://degoes.net/articles/modern-fp
saveFile :: Path -> Bytes -> IO Unit
saveFile p f = do
log ("Saving file" ++ show (name p) ++ " to " ++ show (parentDir p))
r <- httpPost ("cloudfiles.fooservice.com/" ++ (show p)) f
if (httpOK r) then log ("Successfully saved file " ++ show p)
else let msg = "Failed to save file " ++ show p
in log msg *> throwException (error msg)
function saveFile(string $path, $bytes, St $st): void
{
$failMsg = "Failed";
$st->trace("Saving file");
$st
->if(curlPost("cloudfiles.service.com" . $path, $bytes))
->then(trace("Successfully saved file"))
->else([
trace($failedMsg),
throw_($failedMsg)
]);
}
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
https://old.reddit.com/r/programming/comments/t3okjw/why_you_should_defer_side_effects_until_the_last/