1
\$\begingroup\$

Purpose:

I want to accept the value of a checkbox or database field and make a Value Object out of it. The input could be any of y, Y, n, N, Yes, No, yes, no, 1, 0, true, false, or NULL.

In the case of NULL (from the DB), I want to be able to distinguish between 'no' and 'no choice has been made', so __toString() reports it as an empty string.

<?php declare(strict_types=1);
// namespace ValueObject;  // implementing later

/**
 * Boolean Value Object
 *
 * Accepts common descriptions of boolean value.
 * NULL is a special case: 
 * 
 *   if allowed (via constructor),
 *     `isTrue()` => FALSE
 *     `equals(NULL) => TRUE
 *     `__toString` => ''
 * 
 *   but by default,
 *     NULL throws InvalidArgumentException.
 * 
 * Allows a different formatting for saving to database.
 */
class Boolean
{
    const TRUTHY = ['T','TRUE','Y','YES','1'];
    const FALSEY = ['F','FALSE','N','NO','0'];

    const DB_TRUE  = 'Y';
    const DB_FALSE = 'N';

    const HUMAN_READABLE_TRUE  = 'Yes';
    const HUMAN_READABLE_FALSE = 'No';
    const HUMAN_READABLE_NULL  = '';

    private ?bool $val;

    public function __construct($bool, ?bool $allowNull = FALSE) 
    {
        $this->val = NULL;
        $this->assignifBoolean($bool);
        $this->assignIfTruthy((string)$bool);
        $this->assignIfFalsey((string)$bool);

        if($bool && is_null($this->val)) {
            throw new \InvalidArgumentException('Not a recognized boolean value');
        }

        if(!$allowNull && is_null($bool)) {
            throw new \InvalidArgumentException('Null value for boolean not allowed');
        }

    }

    private function assignifBoolean($bool) : void
    {
        if(is_bool($bool)) {
            $this->val = $bool;
        }
    }

    private function assignIfTruthy(string $bool) : void
    {
        if(in_array(strtoupper($bool), self::TRUTHY)) {
            $this->val = TRUE;
        }
    }

    /**
     * Assign FALSE only if in the list of falsey values.
     *
     * This does NOT assign FALSE TO NULL!
     *
     * @param  string $bool [description]
     * @return [type]       [description]
     */
    private function assignIfFalsey(string $bool) : void
    {
        if(in_array(strtoupper($bool), self::FALSEY)) {
            $this->val = FALSE;
        }
    }

    public function equals($val) : bool
    {
        return $val === $this->val;
    }

    public function isTrue(): bool
    {
        return $this->val === TRUE;
    }

    public function __toString() : string
    {
        if(!is_null($this->val)) {
            return $this->val ? self::HUMAN_READABLE_TRUE : self::HUMAN_READABLE_FALSE;
        }
        return self::HUMAN_READABLE_NULL;
    }

    public function toDB() : ?string
    {
        if(!is_null($this->val)) {
            return $this->val ? self::DB_TRUE : self::DB_FALSE;
        }
        return NULL;
    }

}


// SAMPLE TESTING
$testValues = [
    'y' => TRUE,
    'Y' => TRUE,
    'n' => FALSE,
    'N' => FALSE,
    'Yes' => TRUE,
    'No' => FALSE,
    'True' => TRUE,
    'False' => FALSE,
    'T' => TRUE,
    'F' => FALSE,
    1 => TRUE,
    '1' => TRUE,
    0 => FALSE,
    '0' => FALSE,
    'NULL' => NULL
];

foreach($testValues as $k => $v) {
    try {
        $name = $k;
        if($k == 'NULL') { $k = NULL; }
        $tf = new Boolean($k, TRUE);
        // $tf = new Boolean($k);
        print "$name ($tf) ";
        print $tf->isTrue() ? ' is true ' : ' is false ';
        if(!$tf->equals($v)) {
            throw new Exception("$name generated Wrong Value\n");
        }
        print "\n";

    } catch(Exception $e) {
        print $e->getMessage();
    }
}

Questions

  • Does this object know too much (i.e., does toDB() violate SRP)?
  • Formatting output in the Value Object makes sense to me; it seems like something the object would know about itself. But is it common practice?
  • The method isTrue() is just a convenient way to write equals(TRUE). Is it bad practice?

Any other comments/critique welcome

\$\endgroup\$
3
  • 1
    \$\begingroup\$ Why is $allowNull allowed to be passed in as null? Shouldn't those exceptions be thrown at the start of the constructor? Why are the assignifTruthy() and assignifFalsey() methods unconditionally called even if assignifBoolean() deemed the input as a boolean? \$\endgroup\$ May 31, 2023 at 4:35
  • 1
    \$\begingroup\$ Do you want to handle if a string "null" or "NULL" is passed in? Might you want to convert an empty string to null (a lack of decision/value)? \$\endgroup\$ May 31, 2023 at 4:45
  • \$\begingroup\$ @mickmackusa Good catch on ?bool $allowNull = FALSE. That's a bug just waiting to happen. Also appreciate the suggestion of adding 'null', 'NULL', and ''. \$\endgroup\$
    – Tim Morton
    May 31, 2023 at 13:32

2 Answers 2

4
\$\begingroup\$

I find it odd to prohibit null values within the instantiation. Validation is a concern while reading values from the user or, although less probable, while loading values from the database rather than while instantiating a class. A first improvement would be to implement a Boolean validator to be run when validation runs.

The following is just a scratched idea:

With previous mentions adding a public static factory method and several private static factory methods Boolean could be instanced using a plain and clear domain specific language (DSL):

$from_true = Boolean::from("true");
$from_false = Boolean::from("false");
$from_yes = Boolean::from("yes");
$from_no = Boolean::from("no");
$from_1 = Boolean::from(1);
$from_0 = Boolean::from(0);
$from_y = Boolean::from("y");
$from_n = Boolean::from("n");
$from_t = Boolean::from("t");
$from_f = Boolean::from("f");

The following is just a depict implementation that before use should be subject for improvements:

class Boolean
{

    private $value;

    private function __construct($value)
    {
        $this->value = $value;
    }

    private static function trueFactory() : Boolean {
        return new Boolean(true);
    }

    private static function falseFactory() : Boolean {
        return new Boolean(false);
    }

    public static function from($value) : Boolean {

       return is_string($value) ? Boolean::fromString($value) : (is_int($value) ? Boolean::fromInt($value) : (is_bool($value) ? Boolean::fromBool($value): Boolean::fromUnknownType($value)));
    }

    private static function fromUnknownType($value) : Boolean
    {
        // log a message about unknown type. includes null
        return new Boolean($value);
    }

    private static function fromString(?string $value) : Boolean
    {
        $factories = ["t" => function() { return Boolean::trueFactory(); }
                     , "true" => function() { return Boolean::trueFactory(); }
                     , "y" => function() { return Boolean::trueFactory(); }
                     , "yes" => function() { return Boolean::trueFactory(); }
                     , "1" => function() { return Boolean::trueFactory(); }
                     , "f" => function() { return Boolean::falseFactory(); }
                     , "false" => function() { return Boolean::falseFactory(); }
                     , "n" => function() { return Boolean::falseFactory(); }
                     , "no" => function() { return Boolean::falseFactory(); }
                     , "0" => function() { return Boolean::falseFactory(); }];

        return $factories[$value]();
    }

    private static function fromInt(int $value) : Boolean
    {
        return $value == 0 ? Boolean::falseFactory() : Boolean::trueFactory();
    }

    private static function fromBool(bool $value) : Boolean
    {
        return $value ? Boolean::trueFactory() : Boolean::falseFactory();
    }

    public function toDB() : ?string
    {
        if ( is_null($this->value) ) { return null; }

        return $this->value ? 'Y' : 'N';
    }

    public function equals($value) : bool
    {
        return $value === $this->value;
    }

    public function isTrue() : bool
    {
        return $this->value === TRUE;
    }

    public function __toString() : string
    {
        return is_null($this->value) ? "NULL" : ($this->value ? "true" : "false");
    }
}
\$\endgroup\$
1
  • \$\begingroup\$ One of the main things about a value object is that they are self-validating, so I'm not following what you mean in the first paragraph. This should throw an exception immediately if it is instantiated with an invalid value. Perhaps the confusion is that the object is also translating acceptable values into boolean or null (for database insertion) or human-readable for displaying to the user. Maybe trying to do too much... But thank you for the suggestion about factory methods (aka named constructors). That solves the irritation of having a lengthy __construct() method! \$\endgroup\$
    – Tim Morton
    Sep 11, 2023 at 21:35
3
\$\begingroup\$

What I understand about your class, it would take $bool as an input then parse it to boolean value. So it has 2 responsibilities here: parse string to boolean and represent a boolean value.

Also, if the responsibility is that, Boolean is not a good name here. Boolean should have responsibility of representing boolean value: True/False. I would break the Boolean into 2 classes. One is Boolean with function isTrue() and the another is TruthyString with function toBoolean(): Boolean

Another one is I think having too much code in constructor is not really good idea. Constructor should take the data to create objects only as the responsibility of the constructor function. Having complex codes in constructor would be a sign of poor design.

Lastly, I'm not a fan of null so I'd rather throw error when I encounter a NULL value. And let the caller handle it as an edge case.

Here's my idea:

class TruthyString represent a string that could have meaning of true or false

class TruthyString {

 const TRUTHY = ['T','TRUE','Y','YES','1'];
 const FALSEY = ['F','FALSE','N','NO','0'];

 public function __construct(private string $input) {}

 public function toBoolean(): Boolean {
  if (in_array($this->input, self::TRUTHY))
   return new Boolean(true)
  else if (in_array($this, self::FALSEY))
   return new Boolean(false)
  else 
   throw new Exception("Input does not match any boolean string")
}

class Boolean

class Boolean {

 const DB_TRUE  = 'Y';
 const DB_FALSE = 'N';

 const HUMAN_READABLE_TRUE  = 'Yes';
 const HUMAN_READABLE_FALSE = 'No';

 public function __constructor(private bool $value) {}

 public function toDbString(): string {
  return $this->value ? self::DB_TRUE : self::DB_FALSE;
 }

 public function toString(): string {
  return $this->value ? self::HUMAN_READABLE_TRUE : self::HUMAN_READABLE_FALSE;
 }
}

On client side:

var $boolean: Boolean;
if is_bool($input) {
 $boolean = new Boolean($input)
} else if (is_string($input)) {
 $truthyString = new TruthyString($input)
 $boolean = $truthyString->toBoolean()
} else {
 throw Exception("Not valid data type")
}

If you think the toBoolean() function would need to do computation every time we provoke that function. The class could have a variable as a state to hold value if needed. It only do computation once and save it inside. Return saved instance for the following calls. However, I guess it would most likely not be called more than one time.

For client side, if you use it in once place, leave it there. You it in more than one place, you can create another class BooleanFactory to produce.

\$\endgroup\$
2
  • \$\begingroup\$ The most under-used command is "class", as you have just demonstrated about my code. I also agree "Boolean" was a poor choice of name; I was really looking for tri-state, but this example still is very helpful. One thing I don't understand though, is that while TruthyString meets the Value Object requirement to be immutable, it only throws an exception when you try to work with its value instead of when it is created. While I also dislike lengthy constructors, it seems necessary for Value Object constructors to at least pass off validation to other methods. Maybe I'm missing something? \$\endgroup\$
    – Tim Morton
    Sep 11, 2023 at 19:19
  • \$\begingroup\$ Regard the issue with validation in constructors, it would be the principle I follow that I would keep the constructors only for creating objects. Giving birth to objects should not considered to be an error. Rather, objects would throw exceptions because you are expecting something that the objects could not do. You can imagine objects as human in the world of software. \$\endgroup\$
    – Kevin
    Sep 18, 2023 at 3:48

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.