"double", 'dbTimeoutLock' => "double", 'dbTimeoutConnect' => "double", 'dbSQLite3Timeout' => "double", ]; protected $types = []; /** Creates a new configuration object * @param string $import_file Optional file to read configuration data from * @see self::importFile() */ public function __construct(string $import_file = "") { $this->types = $this->propertyDiscover(); foreach (array_keys($this->types) as $prop) { $this->$prop = $this->propertyImport($prop, $this->$prop); } if ($import_file !== "") { $this->importFile($import_file); } } /** Layers configuration data from a file into an existing object * * The file must be a PHP script which returns an array with keys that match the properties of the Conf class. Malformed files will throw an exception; unknown keys are silently accepted. Files may be imported in succession, though this is not currently used. * @param string $file Full path and file name for the file to import */ public function importFile(string $file): self { if (!file_exists($file)) { throw new Conf\Exception("fileMissing", $file); } elseif (!is_readable($file)) { throw new Conf\Exception("fileUnreadable", $file); } try { ob_start(); $arr = (@include $file); } catch (\Throwable $e) { $arr = null; } finally { ob_end_clean(); } if (!is_array($arr)) { throw new Conf\Exception("fileCorrupt", $file); } return $this->importData($arr, $file); } /** Layers configuration data from an associative array into an existing object * * The input array must have keys that match the properties of the Conf class; unknown keys are silently accepted. Arrays may be imported in succession, though this is not currently used. * @param mixed[] $arr Array of configuration parameters to export */ public function import(array $arr): self { $file = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 1)[0]['file'] ?? ""; return $this->importData($arr, $file); } /** Layers configuration data from an associative array into an existing object */ protected function importData(array $arr, string $file): self { foreach ($arr as $key => $value) { $this->$key = $this->propertyImport($key, $value, $file); } return $this; } /** Outputs configuration settings, either non-default ones or all, as an associative array * @param bool $full Whether to output all configuration options rather than only changed ones */ public function export(bool $full = false): array { $conf = new \ReflectionObject($this); $ref = (new \ReflectionClass($this))->getDefaultProperties(); $out = []; foreach ($conf->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) { $name = $prop->name; $value = $prop->getValue($this); if ($prop->isDefault()) { $default = $ref[$name]; // if the property is a known property (rather than one added by a hypothetical plug-in) // we convert intervals to strings and then export anything which doesn't match the default value $value = $this->propertyExport($name, $value); if ((is_scalar($value) || is_null($value)) && ($full || $value !== $ref[$name])) { $out[$name] = $value; } } elseif (is_scalar($value) || is_null($value)) { // otherwise export the property only if it is scalar $out[$name] = $value; } } return $out; } /** Outputs configuration settings, either non-default ones or all, to a file in a format suitable for later import * @param string $file Full path and file name for the file to import to; the containing directory must already exist * @param bool $full Whether to output all configuration options rather than only changed ones */ public function exportFile(string $file, bool $full = false): bool { $arr = $this->export($full); $conf = new \ReflectionObject($this); $out = " $value) { $match = null; $doc = $comment = ""; // retrieve the property's docblock, if it exists try { $doc = (new \ReflectionProperty(self::class, $prop))->getDocComment(); // parse the docblock to extract the property description if (preg_match("<@var\s+\S+\s+(.+?)(?:\s*\*/)?\s*$>m", $doc, $match)) { $comment = $match[1]; } } catch (\ReflectionException $e) { } // append the docblock description if there is one, or an empty comment otherwise $out .= " // ".$comment.PHP_EOL; // append the property and an export of its value to the output $out .= " ".var_export($prop, true)." => ".var_export($value, true).",".PHP_EOL; } $out .= "];".PHP_EOL; // write the configuration representation to the requested file if (!@file_put_contents($file, $out)) { // if it fails throw an exception $err = file_exists($file) ? "fileUnwritable" : "fileUncreatable"; throw new Conf\Exception($err, $file); } return true; } /** Caches information about configuration properties for later access */ protected function propertyDiscover(): array { $out = []; $rc = new \ReflectionClass($this); foreach ($rc->getProperties(\ReflectionProperty::IS_PUBLIC) as $p) { if (preg_match("/@var\s+((?:int(eger)?|float|bool(ean)?|string|\\\\DateInterval)(?:\|null)?)[^\[]/", $p->getDocComment(), $match)) { $match = explode("|", $match[1]); $nullable = (sizeof($match) > 1); $type = [ 'string' => Value::T_STRING | Value::M_STRICT, 'integer' => Value::T_INT | Value::M_STRICT, 'boolean' => Value::T_BOOL | Value::M_STRICT, 'float' => Value::T_FLOAT | Value::M_STRICT, '\\DateInterval' => Value::T_INTERVAL | Value::M_LOOSE, ][$match[0]]; if ($nullable) { $type |= Value::M_NULL; } } else { // catch-all for custom properties $type = Value::T_MIXED; // @codeCoverageIgnore } $out[$p->name] = ['name' => $match[0], 'const' => $type]; } return $out; } protected function propertyImport(string $key, $value, string $file = "") { $typeName = $this->types[$key]['name'] ?? "mixed"; $typeConst = $this->types[$key]['const'] ?? Value::T_MIXED; $nullable = (int) (bool) ($typeConst & Value::M_NULL); try { if ($typeName === "\\DateInterval") { // date intervals have special handling: if the existing value (ultimately, the default value) // is an integer or float, the new value should be imported as numeric. If the new value is a string // it is first converted to an interval and then converted to the numeric type if necessary $mode = $nullable ? Value::M_STRICT | Value::M_NULL : Value::M_STRICT; if (is_string($value)) { $value = Value::normalize($value, Value::T_INTERVAL | $mode); } switch (self::EXPECTED_TYPES[$key] ?? gettype($this->$key)) { case "integer": // no properties are currently typed as integers return Value::normalize($value, Value::T_INT | $mode); // @codeCoverageIgnore case "double": return Value::normalize($value, Value::T_FLOAT | $mode); case "string": case "object": return $value; default: // this should never occur throw new Conf\Exception("ambiguousDefault", ['param' => $key]); // @codeCoverageIgnore } } $value = Value::normalize($value, $typeConst); switch ($key) { case "dbDriver": $driver = $driver ?? Database::DRIVER_NAMES[strtolower($value)] ?? $value; $interface = $interface ?? Db\Driver::class; // no break case "userDriver": $driver = $driver ?? User::DRIVER_NAMES[strtolower($value)] ?? $value; $interface = $interface ?? User\Driver::class; // no break case "serviceDriver": $driver = $driver ?? Service::DRIVER_NAMES[strtolower($value)] ?? $value; $interface = $interface ?? Service\Driver::class; if (!is_subclass_of($driver, $interface)) { throw new Conf\Exception("semanticMismatch", ['param' => $key, 'file' => $file]); } return $driver; } return $value; } catch (ExceptionType $e) { $type = $this->types[$key]['const'] & ~(Value::M_STRICT | Value::M_DROP | Value::M_NULL | Value::M_ARRAY); throw new Conf\Exception("typeMismatch", ['param' => $key, 'type' => Value::TYPE_NAMES[$type], 'file' => $file, 'nullable' => $nullable]); } } protected function propertyExport(string $key, $value) { $value = ($value instanceof \DateInterval) ? Value::normalize($value, Value::T_STRING) : $value; switch ($key) { case "dbDriver": return array_flip(Database::DRIVER_NAMES)[$value] ?? $value; case "userDriver": return array_flip(User::DRIVER_NAMES)[$value] ?? $value; case "serviceDriver": return array_flip(Service::DRIVER_NAMES)[$value] ?? $value; default: return $value; } } }