FormValidator.php

<?php

namespace Phad;


/**
 *
 *
 * @todo write proper, simple unit tests for validations
 * @todo integrate https://github.com/rlanvin/php-form, possibly, for better validation
 */
class FormValidator {

    /**
     * I don't think this does anything. I really don't know, though.
     */
    public $throw_submission_error = false;
    /**
     * Set true to allow extra fields in the submission that are not defined in the form.
     */
    public bool $allow_extra_fields = false;
    public $failedSubmitColumns = [];
    protected $props = [];
    /**
     *  Set `FormValidator->validators['key'] = function($property_value, $property_settings, &$errors)`. Also set `<div validate="key">` to match `->validators['key']`. Default validation will be ignored in this case.
     *  
     * @feature(Form Property Validation) Custom validation for properties on a form by specifying `validate='key'` in the html and `->validators['key'] = function(...){}` in php
     *
     * @deprecated in favor of $attribute_validators, because `$validators` causes default prop tests not to be run. 
     */
    public $validators = [];

    /**
     * User-added attribute validators. 
     *
     * array<string attribute_name, callable $callable> key/value array of `attribute_name => $callable`s.
     */
    protected array $attribute_validators = [];

    /** 
     *
     * @param $properties, like ['propName'=>['tagName'=>'textarea', 'attribute'=>'value'], 'prop2'=>[...]];
     */
    public function __construct(array $properties=[]){
        $this->props = $properties;
    }


    /**
     * Add an html attribute validator. Can overwrite built-in attribute validators or user-added attribute validators.
     *
     * Define `some-attribute="some_spec"` on an html node and call `addAttributeValidator('some-attribute', $callable)`.
     *
     * @param $attribute_name a string attribute name, which you'll declare on HTML property nodes
     * @param $callable A function/method/callable defined like `function(string $attribute_name, string $attribute_value, mixed $user_input_value)`. Does not actually have to be `Callable` type.
     * 
     * @return true if this overwrites an existing user-defined attribute validator. False otherwise. Also returns false if it overwrites a built-in attribute validator.
     */
    public function addAttributeValidator(string $attribute_name, mixed $callable): bool {
        $was_set = isset($this->attribute_validators[$attribute_name]);
        $this->attribute_validators[$attribute_name] = $callable;

        return $was_set;
    }

    /**
     *
     * @param $data like `['key'=>'value', 'key2'=>value]`, as you'd get from `$_POST`
     * @param &$failed_columns will be filled with an array of columns that failed to validate
     * @return true/false whether there are errors or not
     */
    public function validate(array $data, ?array &$errors=[], ?array &$failed_columns=[]): bool {
        if ($errors==null)$errors = [];
        if ($failed_columns==null)$failed_columns = [];

        $remaining = $data;
        $failed_columns = [];
        foreach ($this->props as $propName=>$settings){
            unset($remaining[$propName]);
            $propValue = $data[$propName] ?? null;

            // make `required` into a boolean value
            if (!isset($settings['required']) || $settings['required'] == 'false'){
                $settings['required'] = false;
            } else $settings['required'] = true;

            // skip validation if prop is empty & not required
            if (($propValue===null||$propValue==='')&&$settings['required']===false){
                continue;
            }

            // run a validator if one is set
            if (isset($settings['validate'])){
                $validator = $settings['validate'];
                if (!isset($this->validators[$validator])){
                    throw new \Exception("Validator '$validator' does not exist.");
                }
                $error_count = count($errors);
                $didPass = $this->validators[$validator]($propValue, $settings, $errors);
                if (!$didPass){
                    $failed_columns[$propName]['failed_value'] = $propValue;
                    $failed_columns[$propName]['validator'] = $validator;
                    if (count($errors)==$error_count){
                        $errors[] = "'$propName' failed validating with '$validator'";
                    }
                }
            } else {
                // run default validation
                foreach ($settings as $name=>$spec){
                    $parts = explode('-', $name);
                    $uc_parts = array_map('ucfirst', $parts);
                    $method_name = implode('',$uc_parts);

                    $method = 'validateProp'.$method_name;
                    //$method = 'validateProp'.ucfirst($name);
                    $out_value = $propValue;
                    $doesPass = false;
                    // @TODO if the validator doesn't exist, we should probably throw an exception, not blindly approve the value.
                    if (isset($this->attribute_validators[$name])){
                        $callable = $this->attribute_validators[$name];
                        $doesPass = $callable($name, $spec, $out_value);
                    }
                    else if (method_exists($this,$method))$doesPass = $this->$method($spec, $out_value);
                    else $doesPass = true;
                    //$doesPass = !method_exists($this,$method) || $this->$method($spec, $out_value);
                    if (!$doesPass){
                        if (is_bool($spec))$spec = $spec ? 'true' : 'false';
                        if (is_array($spec))$spec = print_r($spec,true);
                        $errors[] = ['msg'=>"'$propName' failed validation for '$name:$spec'"];
                        $failed_columns[$propName]['failed_value'] = $propValue;
                        $failed_columns[$propName][$name] = $spec;
                    } 
                    // else {
                        // $data[$propName] = $out_value;
                    // }
                }
            }
        }

        // var_dump($errors);
        if (count($errors)>0){
            return false;
        } else if (($c=count($failed_columns))>0){
            $errors[] = ['msg'=>"$c fields failed validation. Cause unkown."];
            return false;
        }

        if (!$this->allow_extra_fields&&($c=count($remaining))>0){
            $fields_str = implode(', ', array_keys($remaining));
            $errors[] = ['msg'=>"$c unexpected fields were submitted. They were: ".$fields_str];
            return false;
        }
        return true;
    }

    public function validatePropTagname($tagName, $propValue){
        if ($tagName=='input')return true;
        if ($tagName=='textarea'&&is_string($propValue))return true;
        if ($tagName=='select')return true;

        return false;
    }
    public function validatePropRequired(bool $isRequired, $propValue){
        if (!$isRequired)return true;
        if ($propValue!==null&&strlen($propValue)>0)return true;

        return false;
    }

    public function validatePropType(string $type, $value){
        if ($type=='text'&&is_string($value))return true;
        else if ($type=="number"&&is_numeric($value))return true;
        else if ($type=="backend")return true;
        else if ($type=='hidden')return true;
        else if ($type=='date'||$type=='datetime-local'){
            if (trim($value??'')=='')return true;
            // var_dump($value);
            // exit;
            // if
            $date = date_create($value);
            if ($date===false)return false;
            return true;
        } else if ($type=='time'){
            $split = explode(':',$value);
            $hour = (int)$split[0];
            $minute = (int)$split[1];
            // var_dump($split);
            // var_dump($hour);
            // var_dump($minute);
            // var_dump((int)'09');
            // exit;
            if ( !(is_numeric($split[0])&&((int)$split[0])<24) )return false;
            if ( !(is_numeric($split[1])&&((int)$split[1])<60) )return false;
            return true;
        } else if ($type=='url'){
            return filter_var($value,FILTER_VALIDATE_URL);
        } else if ($type=='phone'){
            // $value = preg_replace('/[^0-9]/','', $value);
            if ((
                    strlen($value)==10
                    ||strlen($value)==11&&$value[0]=='1'
                )
                &&is_numeric($value)
            ){
                return true;
            }
            return false;
        } else if ($type=='email'){
            return filter_var($value, FILTER_VALIDATE_EMAIL);
        } else if ($type=='checkbox'){
            if ($value=='on'||$value=='1'){
                // $value = 1;
                return true;
            }// else $value = 0;
        } else if ($type=='radio'){
            return true;
        }

        return false;
    }

    public function validatePropMaxlength(int $maxLen, $propValue){
        if (strlen($propValue??'')<=$maxLen)return true;
        return false;
    }

    public function validatePropMinLength(int $minLen, $propValue){
        if (strlen($propValue??'')>=$minLen)return true;
        return false;
    }

    public function validatePropOptions(array $options, $value){
        return in_array($value, $options);
    }

    /**
     * Validate the php data type. Declare `p-type="array"` or another type.
     *
     * @param $type can be array, string, number, or int (@TODO others)
     * @param $value the value to validate
     *
     * @return if the value is the given type. For type-checks not yet implemented, return false
     */
    public function validatePropPType(string $type, mixed $value): bool {
        switch ($type){
            case "array":
                return is_array($value);
            case "string":
                return is_string($value);
            case "number":
                return is_numeric($value);
            case "int": 
                // form submissions are always strings (right?), so we can't use `is_int()`
                $intval = (int)$value;
                $intstring = (string)$intval;
                //var_dump($value);
                //var_dump($intval);
                //var_dump($intstring);
                if ($intstring===$value)return true;
                return false;
        }


        return false;
    }

    /**
     * Validate that user input does not contain any html
     *
     * @param $attribute_value "false" to disable this validation test. All other values are ignored and enable the test.
     * @param $user_input_value the value submitted by the user.
     *
     * @return if the user input value is identical to `strip_tags($user_input_value)`, return true. Otherwise false.
     */
    public function validatePropPNohtml(string $attribute_value, mixed $user_input_value){
        if ($attribute_value==='false')return true;

        if (!is_string($user_input_value) || $user_input_value === strip_tags($user_input_value)) return true;

        return false;
    } 

    /**
     * Validate that user input is numerically greater than the target value. User value MUST be numeric (uses p-type=number validation)
     *
     * @param $attribute_value "false" to disable this validation test. All other values are ignored and enable the test.
     * @param $user_input_value the value submitted by the user.
     *
     * @return if the user input value is identical to `strip_tags($user_input_value)`, return true. Otherwise false.
     */
    public function validatePropPGreaterthan(string $target_value, mixed $user_input_value){

        if (!$this->validatePropPType('number', $user_input_value))return false;

        if ($user_input_value > $target_value) return true;

        return false;
    } 
}