Skip to content

Commit

Permalink
types
Browse files Browse the repository at this point in the history
  • Loading branch information
markhuot committed Oct 25, 2023
1 parent be5fe44 commit fcff117
Show file tree
Hide file tree
Showing 6 changed files with 211 additions and 68 deletions.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
},
"files": [
"src/helpers/event.php",
"src/helpers/data.php"
"src/helpers/data.php",
"src/helpers/base.php"
]
},
"authors": [
Expand Down
98 changes: 98 additions & 0 deletions src/actions/MakeModelFromArray.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

namespace markhuot\keystone\actions;

use craft\base\Model;
use markhuot\keystone\db\ActiveRecord;
use yii\base\ModelEvent;

class MakeModelFromArray
{
/**
* @template T
*
* @param class-string<T> $className
* @return T
*/
public function handle(string $className, array $data, $validate = true, $errorOnMissing = false, $createOnMissing = true): mixed
{
if (is_subclass_of($className, ActiveRecord::class)) {
$primaryKey = $className::primaryKey();
if (! is_array($primaryKey)) {
$primaryKey = [$primaryKey];
}
$condition = array_flip($primaryKey);
foreach ($condition as $key => &$value) {
$value = $data[$key];
}
$condition = array_filter($condition);

if (count($condition)) {
$model = $className::findOne($condition);
}
}

if (empty($model) && $createOnMissing) {
$model = new $className;
}

if (empty($model) && $errorOnMissing) {
throw new \RuntimeException('Could not find a matching '.$className);
}

if (empty($model)) {
return null;
}

$reflect = new \ReflectionClass($model);

foreach ($data as $key => &$value) {
if ($reflect->hasProperty($key)) {
$property = $reflect->getProperty($key);
$type = $property->getType();

if (class_exists($type)) {
$value = (new static)
->handle(
className: $type->getName(),
data: $value,
validate: true,
errorOnMissing: false,
createOnMissing: false,
);
}
}
}

$reflect = new \ReflectionClass($model);
foreach ($reflect->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
if (! $property->getType()?->allowsNull() && ($data[$property->getName()] ?? null) === null) {
$model->addError($property->getName(), $property->getName().' can not be null');
unset($data[$property->getName()]);
}
}

$model->load($data, '');

$catch = function (ModelEvent $event) {
$reflect = new \ReflectionClass($event->sender);
foreach ($reflect->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
if (! $property->isInitialized($event->sender) && ! $property->getType()?->allowsNull()) {
$event->sender->addError($property->getName(), $property->getName().' is required');
}
}
};

$model->on(Model::EVENT_BEFORE_VALIDATE, $catch);

if ($validate && ! $model->validate()) {
if ($errorOnMissing) {
throw new \RuntimeException('oh no!');
}
}

$model->off(Model::EVENT_BEFORE_VALIDATE, $catch);

return $model;
}
}
43 changes: 10 additions & 33 deletions src/behaviors/BodyParamObjectBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@
use craft\helpers\App;
use craft\web\Request;
use craft\web\Response;
use markhuot\keystone\db\ActiveRecord;
use markhuot\keystone\actions\MakeModelFromArray;
use yii\base\Behavior;
use yii\web\BadRequestHttpException;
use yii\web\NotFoundHttpException;

use function markhuot\openai\helpers\throw_if;
use function markhuot\keystone\helpers\base\throw_if;

/**
* @property Request $owner;
Expand All @@ -32,49 +31,27 @@ public function getQueryParamString(string $name, string $defaultValue = ''): st
* @param class-string<T> $class
* @return T
*/
public function getBodyParamObject(string $class, string $formName = '', $validate = true)
public function getBodyParamObject(string $class, string $formName = '')
{
if (! $this->owner->getIsPost()) {
throw new BadRequestHttpException('Post request required');
}

$bodyParams = $this->owner->getBodyParams();

if (is_subclass_of($class, ActiveRecord::class)) {
// $keyField = (new $class)->tableSchema->primaryKey[0] ?? null;
$primaryKey = $class::primaryKey();
if (! is_array($primaryKey)) {
$primaryKey = [$primaryKey];
}
$condition = array_flip($primaryKey);
foreach ($condition as $key => &$value) {
$value = $bodyParams[$key];
}
$condition = array_filter($condition);

if (count($condition)) {
$model = $class::findOne($condition);
if (! $model) {
throw new NotFoundHttpException('Could not find '.$class.' with key(s) '.json_encode($condition));
}
} else {
throw new NotFoundHttpException('Empty condition when searching '.$class);
}
} else {
$model = new $class;
}
// Get the post data
$data = $this->owner->getBodyParams();

// Yii doesn't support nested form names so manually pull out
// the right data using Laravel's data_get() and then drop the
// form name from the Yii call
if (! empty($formName)) {
$bodyParams = data_get($bodyParams, $formName);
$formName = '';
$data = data_get($data, $formName);
}

$model->load($bodyParams, $formName);
// Create our model
$model = (new MakeModelFromArray)->handle($class, $data);

if ($validate && ! $model->validate()) {
// Validate the model
if ($model->hasErrors()) {
if (App::env('YII_ENV_TEST')) {
// This should be cleaned up. Craft really should allow me to throw an
// exception that can be a redirect. Then Pest would handle all of this for me and I wouldn't have
Expand Down
48 changes: 48 additions & 0 deletions src/helpers/base.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace markhuot\keystone\helpers\base;

use Craft;

function app(): \craft\web\Application|\craft\console\Application
{
return Craft::$app;
}

/**
* @template T
*
* @phpstan-assert !true $condition
*
* @param T $condition
* @return T
*/
function throw_if(mixed $condition, \Exception|string $message): void
{
if ($condition) {
if (is_object($message) && $message instanceof \Exception) {
throw $message;
} else {
throw new \RuntimeException($message);
}
}
}

/**
* @template T
*
* @phpstan-assert true $condition
*
* @param T $condition
* @return T
*/
function throw_unless(mixed $condition, \Exception|string $message): void
{
if (! $condition) {
if (is_object($message) && $message instanceof \Exception) {
throw $message;
} else {
throw new \RuntimeException($message);
}
}
}
59 changes: 25 additions & 34 deletions src/models/http/MoveComponentRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

use Craft;
use craft\base\ElementInterface;
use craft\base\FieldInterface;
use craft\base\Model;
use markhuot\keystone\models\Component;
use yii\db\ActiveRecordInterface;

use function markhuot\keystone\helpers\base\app;
use function markhuot\keystone\helpers\base\throw_if;

class MoveComponentRequest extends Model
{
Expand All @@ -18,11 +21,17 @@ class MoveComponentRequest extends Model

public ?string $slot = null;

public function safeAttributes()
/**
* @return array<string>
*/
public function safeAttributes(): array
{
return [...parent::safeAttributes(), 'slot'];
}

/**
* @return array<mixed>
*/
public function rules(): array
{
return [
Expand All @@ -32,42 +41,24 @@ public function rules(): array
];
}

public function getTargetElement()
public function getTargetElement(): ElementInterface
{
return Craft::$app->getElements()->getElementById($this->target->elementId);
// We ignore the next line for phpstan because Craft types on the second argument
// which is looking for an element type class-string. But we want our components
// to work on _any_ element type so we don't want to pass anything as the second
// argument. Because of that phpstan can't reason about the template.
// @phpstan-ignore-next-line
$element = app()->getElements()->getElementById($this->target->elementId);
throw_if($element === null, 'Could not find an element with the ID '.$this->target->elementId);

return $element;
}

public function getTargetField()
public function getTargetField(): FieldInterface
{
return Craft::$app->getFields()->getFieldById($this->target->fieldId);
}

public function setAttributes($values, $safeOnly = true): void
{
$reflect = new \ReflectionClass($this);

foreach ($reflect->getProperties() as $property) {
$type = $property->getType()->getName();

$isActiveRecord = class_exists($type) && class_implements($type, ActiveRecordInterface::class);
$isElementInterface = $type === ElementInterface::class;
if (! $isActiveRecord && ! $isElementInterface) {
continue;
}

$condition = $values[$property->name];

if (! is_array($condition)) {
$condition = [(method_exists($type, 'primaryKey') ? $type::primaryKey() : 'id') => $condition];
}

if ((new \ReflectionClass($type))->implementsInterface(ElementInterface::class)) {
$values[$property->name] = Craft::$app->elements->getElementById($condition['id']);
} else {
$values[$property->name] = $type::findOne($condition);
}
}
$field = app()->getFields()->getFieldById($this->target->fieldId);
throw_if($field === null, 'Could not find a field with the ID '.$this->target->fieldId);

parent::setAttributes($values, $safeOnly);
return $field;
}
}
28 changes: 28 additions & 0 deletions tests/MoveComponentsTest.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<?php

use markhuot\keystone\actions\MakeModelFromArray;
use markhuot\keystone\actions\MoveComponent;
use markhuot\keystone\models\Component;
use markhuot\keystone\models\http\MoveComponentRequest;

beforeEach(function () {
$this->components = collect([
Expand All @@ -13,6 +15,32 @@
]);
});

it('parses post data', function () {
[$source, $target] = Component::factory()->count(2)->create();
$data = new MoveComponentRequest();
$data->load([
'source' => ['id' => $source->id, 'fieldId' => $source->fieldId, 'elementId' => $source->elementId],
'target' => ['id' => $target->id, 'fieldId' => $target->fieldId, 'elementId' => $target->elementId],
], '');

expect($data)
->errors->toBeEmpty()
->source->getQueryCondition()->toEqualCanonicalizing($source->getQueryCondition())
->target->getQueryCondition()->toEqualCanonicalizing($target->getQueryCondition());
});

it('errors on bad post data', function () {
[$source, $target] = Component::factory()->count(2)->create();
$data = (new MakeModelFromArray())->make(MoveComponentRequest::class, [
'source' => ['id' => $source->id, 'fieldId' => $source->fieldId, 'elementId' => $source->elementId],
'target' => ['id' => 'foo', 'fieldId' => $target->fieldId, 'elementId' => $target->elementId],
]);

expect($data->errors)
->target->not->toBeNull()
->position->not->toBeNull();
})->only();

it('moves components', function () {
$components = collect([
Component::factory()->create(['sortOrder' => 0]),
Expand Down

0 comments on commit fcff117

Please sign in to comment.