Skip to content

Commit

Permalink
Added support for customizing accessor method names using #[Format] a…
Browse files Browse the repository at this point in the history
…ttribute.

* Added support for customizing accessor method names using #[Format] attribute.
* Cleaned up Accessible trait and moved most of the code into ClassConf.
* Added missing copyright banners.
* Add @api tags to appropriate classes.
* Update documentation.
  • Loading branch information
marguskaidja authored Nov 7, 2022
1 parent ba22991 commit 295c166
Show file tree
Hide file tree
Showing 16 changed files with 767 additions and 313 deletions.
72 changes: 65 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Current library can create automatic accessors (e.g. _getters_ and _setters_) fo
* direct assignment syntax:
* `$value = $foo->property`
* `$foo->property = 'value'`
* method syntax:
* method syntax (**customizable format**):
* `$value = $foo->get('property')`
* `$value = $foo->property()`
* `$foo->property('value')`
Expand All @@ -20,14 +20,14 @@ Current library can create automatic accessors (e.g. _getters_ and _setters_) fo
* Accessors can be configured _per_ property or for all class at once.
* Inheritance and override support. E.g. set default behaviour for whole class and make exceptions for specific properties.
* No variables, functions nor methods will be polluted into user classes or global namespace (except necessary `__get()`/`__set()`/`__isset()`/`__unset()`/`__call()`).
* [_PHPDoc_](https://docs.phpdoc.org/3.0/guide/references/phpdoc/tags/property.html) tags `@property`, `@property-read` and `@property-write` are also supported and can be used instead of Attributes on basic cases.
* [_PHPDoc_](https://docs.phpdoc.org/3.0/guide/references/phpdoc/tags/property.html) tags `@property`, `@property-read` and `@property-write` can be used instead of basic Attributes.
* _Weak_ **immutability** support backed by _wither_ methods.
* **Mutator** support for _setters_.

## Requirements

PHP >= 8.0
~~~~

## Installation

Install with composer:
Expand Down Expand Up @@ -69,6 +69,7 @@ echo $a->getFoo(); // Outputs "foo"
echo $a->getBar(); // Outputs "bar"
echo $a->getBaz(); // Outputs "baz"
```

This has boilerplate code just to make 3 properties readable. In case there are tens of properties things could get quite tedious.

By using `Accessible` trait this class can be rewritten:
Expand Down Expand Up @@ -284,6 +285,7 @@ Notes:
* Immutable properties can be still changed inside the owner object.
* To prevent ambiguity, immutable properties must be changed using method `with` instead of `set`. Using `set` for immutable properties results in exception and vice versa.
* Unsetting immutable properties is not possible and results in exception.
* When `#[Immutable]` is defined on a class then it must be done on top of hierarchy and not in somewhere between. This is to enforce consistency throughout all of the inheritance. This effectively prohibits situations when there's mutable parent but in derived class the same inherited properties can be turned suddenly immutable.

### Mutator

Expand Down Expand Up @@ -398,11 +400,11 @@ unset($a->bar); // Results in Exception
```

Notes:
* since `Unset` is reserved word, `Delete` was chosen for attribute name instead.
* since `Unset` is reserved word, `Delete` is used as Attribute name instead.

### Configuration inheritance

Inheritance is quite straightforward. Attributes in parent class are inherited by children and can be overwritten (except for `Immutable`):
Inheritance is quite straightforward. Attributes in parent class are inherited by children and can be overwritten (except `#[Immutable]` and `#[Format]`):

```php
use margusk\Accessors\Attr\{
Expand Down Expand Up @@ -464,6 +466,61 @@ echo $a->FOO; // Outputs "FOO"
echo $a->Foo; // Results in Exception because property "Foo" doesn't exist
```

### Customizing format of accessor method names
Usually the names for accessor methods are just straightforward _camel-case_'d names like `get<property>`, `set<property>` and so on. But if necessary, it can be customized.

Customization can be achieved using `#[Format(class-string<FormatContract>)]` attribute with first parameter specifying class name. This class is responsible for parsing out accessor methods names.

Following example turns _camel-case_ methods into _snake-case_:

```php
use margusk\Accessors\Attr\{
Get, Set, Format
};
use margusk\Accessors\Format\Method;
use margusk\Accessors\Format\Standard;
use margusk\Accessors\Accessible;

class SnakeCaseFmt extends Standard
{
public function matchCalled(string $method): ?Method
{
if (
preg_match(
'/^(' . implode('|', Method::TYPES) . ')_(.*)/i',
strtolower($method),
$matches
)
) {
$methodName = $matches[1];
$propertyName = $matches[2];

return new Method(
Method::TYPES[$methodName],
$propertyName
);
}

return null;
}
}

#[Get,Set,Format(SnakeCaseFmt::class)]
class A
{
use Accessible;

protected string $foo = "foo";
}

$a = new A();
echo $a->set_foo("new foo")->get_foo(); // Outputs "new foo"
echo $a->setFoo("new foo"); // Results in Exception

```
Notes:
* `#[Format(...)]` can be defined only for a class and on top of hierarchy. This is to enforce consistency throughout all of the inheritance tree. This effectively prohibits situations when there's one syntax in parent but in derived classes the syntax suddenly changes.

### IDE autocompletion

Having accessors with _magic methods_ can bring the disadvantages of losing somewhat of IDE autocompletion and make static code analyzers grope in the dark.
Expand Down Expand Up @@ -512,9 +569,10 @@ Since `@property[<-read>|<-write>]` tags act also in exposing properties, you ge
* if `string` type is used then it must contain regular function name or syntax `$this->someMutatorMethod` implies instance method.
* use `array` type for specifying static class method.
* and use `null` to discard any previously set mutator.
* `#[Immutable]`: turns an property or whole class immutable. Once the attribute is added, it can't be disabled later.
* `#[Immutable]`: turns an property or whole class immutable. When used on a class, then it must be defined on top of hierarchy. Once defined, it can't be disabled later.
* `#[Format(class-string<FormatContract>)]`: allows customization of accessor method names.

### Properties can be accessed with following syntaxes:
### Accessor methods:

#### Reading properties:
* `$value = $obj->foo;`
Expand Down
226 changes: 11 additions & 215 deletions src/Accessible.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,9 @@

namespace margusk\Accessors;

use margusk\Accessors\Exception\BadMethodCallException;
use margusk\Accessors\Exception\InvalidArgumentException;
use ReflectionException;

use function array_shift;
use function count;
use function current;
use function in_array;
use function is_array;
use function is_string;
use function strlen;
use function strtolower;
use function substr;

/** @api */
trait Accessible
{
/**
Expand All @@ -37,185 +26,8 @@ trait Accessible
*/
public function __call(string $method, array $args): mixed
{
$classConf = ClassConf::factory(static::class);

$lcaseMethod = strtolower($method);

// Try to extract accessor method from magic method name
if (
!in_array(($accessorMethod = substr($lcaseMethod, 0, 3)), ['get', 'set'], true)
&& 'with' !== ($accessorMethod = substr($lcaseMethod, 0, 4))
&& !in_array(($accessorMethod = substr($lcaseMethod, 0, 5)), ['unset', 'isset'], true)
) {
$accessorMethod = null;
}

$nArgs = count($args);
$propertyName = substr($method, strlen((string)$accessorMethod));
$propertyValue = null;
$propertiesList = [];
$accessorMethodIsSetOrWith = in_array($accessorMethod, ['set', 'with'], true);

// Check if the call is multi-property accessor, that is if first
// argument is array and accessor method is set at this point and is "set", "with" or "unset"
if (
'' === $propertyName
&& $nArgs > 0
&& is_array(current($args))
&& ($accessorMethodIsSetOrWith || 'unset' === $accessorMethod)
) {
if ($nArgs > 1) {
throw InvalidArgumentException::dueMultiPropertyAccessorCanHaveExactlyOneArgument(
static::class,
$method
);
}

/** @var mixed[] $propertiesList */
$propertiesList = array_shift($args);

// Check if whole method name is property name like
// $obj->somePropertyName('somevalue')
} elseif (
null === $accessorMethod
&& null !== $classConf->properties()->findConf($propertyName, true)
) {
// If there are zero arguments, then interpret the call as Getter
// If there are arguments, then it's Setter
if ($nArgs > 0) {
$accessorMethodIsSetOrWith = true;
$accessorMethod = 'set';
} else {
$accessorMethod = 'get';
}
}

// Accessor method must be resolved at this point, or we fail
if (null === $accessorMethod) {
throw BadMethodCallException::dueUnknownAccessorMethod(static::class, $method);
}

$propertyNameCI = false;

// If accessorProperties are not set at this point (thus not specified using array
// as first parameter to set or with), then extract them as separate arguments to current method
if (0 === count($propertiesList)) {
if ('' === $propertyName) {
if (!count($args)) {
throw InvalidArgumentException::dueMethodIsMissingPropertyNameArgument(static::class, $method);
}

$propertyName = array_shift($args);

if (!is_string($propertyName)) {
throw InvalidArgumentException::duePropertyNameArgumentMustBeString(
static::class,
$method,
count($args) + 1
);
}
/** @var string $propertyName */
} else {
// If we arrive here, then property name was specified partially or fully in method name and
// in this case we always interpret it as case-insensitive
$propertyNameCI = true;
}

if ($accessorMethodIsSetOrWith) {
if (!count($args)) {
throw InvalidArgumentException::dueMethodIsMissingPropertyValueArgument(
static::class,
$method,
$nArgs + 1
);
}

$propertyValue = array_shift($args);
}

// Fail if there are more arguments specified than we are willing to process
if (count($args)) {
throw InvalidArgumentException::dueMethodHasMoreArgumentsThanExpected(
static::class,
$method,
$nArgs - count($args)
);
}

if ($accessorMethodIsSetOrWith) {
$propertiesList[$propertyName] = $propertyValue;
} else {
$propertiesList[] = $propertyName;
}
}

$result = $this;

// Call Set or With
if ($accessorMethodIsSetOrWith) {
if ('with' === $accessorMethod) {
$result = clone $result;
}

$accessorImpl = $classConf->getSetter();

foreach ($propertiesList as $propertyName => $propertyValue) {
if (!is_string($propertyName)) {
throw InvalidArgumentException::dueMultiPropertyArrayContainsNonStringProperty(
static::class,
$method,
$propertyName
);
}

$propertyConf = $classConf->properties()->findConf($propertyName, $propertyNameCI);
$immutable = ($propertyConf?->isImmutable()) ?? false;

// Check if mutable/immutable property was called using correct method:
// - mutable properties must be accessed using "set"
// - immutable properties must be accessed using "with"
if (
($immutable === true && 'set' === $accessorMethod)
|| ($immutable === false && 'with' === $accessorMethod)
) {
if ($immutable) {
throw BadMethodCallException::dueImmutablePropertiesMustBeCalledUsingWith(
static::class,
$propertyName
);
} else {
throw BadMethodCallException::dueMutablePropertiesMustBeCalledUsingSet(
static::class,
$propertyName
);
}
}

$result = $accessorImpl($result, $accessorMethod, $propertyName, $propertyValue, $propertyConf);
}
} else {
/** @var 'get'|'isset'|'unset' $accessorMethod */
$accessorImpl = match ($accessorMethod) {
'get' => $classConf->getGetter(),
'isset' => $classConf->getIsSetter(),
'unset' => $classConf->getUnSetter()
};

foreach ($propertiesList as $propertyName) {
if (!is_string($propertyName)) {
throw InvalidArgumentException::dueMultiPropertyArrayContainsNonStringProperty(
static::class,
$method,
$propertyName
);
}

$propertyConf = $classConf->properties()->findConf($propertyName, $propertyNameCI);
$result = $accessorImpl($result, $propertyName, $propertyConf);
}
}

return $result;
return ClassConf::factory(static::class)
->handleMagicCall($this, $method, $args);
}

/**
Expand All @@ -226,10 +38,8 @@ public function __call(string $method, array $args): mixed
*/
public function __get(string $propertyName): mixed
{
$classConf = ClassConf::factory(static::class);
$propertyConf = $classConf->properties()->findConf($propertyName);

return ($classConf->getGetter())($this, $propertyName, $propertyConf);
return ClassConf::factory(static::class)
->handleMagicGet($this, $propertyName);
}

/**
Expand All @@ -241,18 +51,8 @@ public function __get(string $propertyName): mixed
*/
public function __set(string $propertyName, mixed $propertyValue): void
{
$classConf = ClassConf::factory(static::class);
$propertyConf = $classConf->properties()->findConf($propertyName);
$immutable = $propertyConf?->isImmutable();

if ($immutable) {
throw BadMethodCallException::dueImmutablePropertiesCantBeSetUsingAssignmentOperator(
static::class,
$propertyName
);
}

($classConf->getSetter())($this, 'set', $propertyName, $propertyValue, $propertyConf);
ClassConf::factory(static::class)
->handleMagicSet($this, $propertyName, $propertyValue);
}

/**
Expand All @@ -263,10 +63,8 @@ public function __set(string $propertyName, mixed $propertyValue): void
*/
public function __isset(string $propertyName): bool
{
$classConf = ClassConf::factory(static::class);
$propertyConf = $classConf->properties()->findConf($propertyName);

return ($classConf->getIsSetter())($this, $propertyName, $propertyConf);
return ClassConf::factory(static::class)
->handleMagicIsset($this, $propertyName);
}

/**
Expand All @@ -277,9 +75,7 @@ public function __isset(string $propertyName): bool
*/
public function __unset(string $propertyName): void
{
$classConf = ClassConf::factory(static::class);
$propertyConf = $classConf->properties()->findConf($propertyName);

($classConf->getUnSetter())($this, $propertyName, $propertyConf);
ClassConf::factory(static::class)
->handleMagicUnset($this, $propertyName);
}
}
Loading

0 comments on commit 295c166

Please sign in to comment.