diff --git a/VERSION b/VERSION index 7a25b4b..88f81de 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2410.42042 +0.2410.42227 diff --git a/src/Config/Config.php b/src/Config/Config.php new file mode 100644 index 0000000..d6874eb --- /dev/null +++ b/src/Config/Config.php @@ -0,0 +1,192 @@ + ...$prefix Parts of the desired. + * @return Config A scoped configuration instance. + */ + public function scopeTo(string ...$prefix): Config; + + /** + * Gets the separator character used for scoping. + * + * @return string Separator character. + */ + public function getSeparator(): string; + + /** + * Checks if configurations contains given names. + * An empty $names list will always return true. + * + * @param string|string[] $names Name or names to check for. + * @return bool Whether all given names are present or not. + */ + public function hasValues(string|array $names): bool; + + /** + * Removes values with given names from the configuration, if writable. + * + * @param string|string[] $names Name or names to remove. + * @throws RuntimeException If the configuration is read only. + */ + public function removeValues(string|array $names): void; + + /** + * Lists all value informations from this configuration. + * + * @param int $range Amount of items to take, 0 for all. Must be a positive integer. + * @param int $offset Amount of items to skip. Must be a positive integer. Has no effect if $range is 0. + * @throws InvalidArgumentException If $range or $offset are negative. + * @return ConfigValueInfo[] Configuration value infos. + */ + public function getAllValueInfos(int $range = 0, int $offset = 0): array; + + /** + * Gets value informations for one or more items. + * + * @param string|string[] $names Name or names to retrieve. + * @return ConfigValueInfo[] Array with value informations. + */ + public function getValueInfos(string|array $names): array; + + /** + * Gets value information for a single item. + * + * @param string $name Name or names to retrieve. + * @return ?ConfigValueInfo Value information, or null if not present. + */ + public function getValueInfo(string $name): ?ConfigValueInfo; + + /** + * Gets multiple values of varying types at once. + * + * The format of a $specs entry can be one of the following: + * If the entry is a string: + * - The name of a value: 'value_name' + * - The name of a value followed by a requested type, separated by a colon: 'value_name:s', 'value_name:i', 'value_name:a' + * If the entry is an array, all except [0] are optional: + * - [0] follows to same format as the above described string + * - [1] is the default value to fall back on, MUST be the same type as the one specified in [0]. + * - [2] is an alternative key for the output array. + * + * Available types are: + * :s - string + * :a - array + * :i - integer + * :b - boolean + * :f - float + * :d - float + * + * @param array $specs Specification of what items to grab. + * @throws InvalidArgumentException If $specs is malformed. + * @return array An associative array containing the retrieved values. + */ + public function getValues(array $specs): array; + + /** + * Gets a single string value. + * + * @param string $name Name of the value to fetch. + * @param string $default Default value to fall back on if the value is not present. + * @return string Configuration value for $name. + */ + public function getString(string $name, string $default = ''): string; + + /** + * Gets a single integer value. + * + * @param string $name Name of the value to fetch. + * @param int $default Default value to fall back on if the value is not present. + * @return int Configuration value for $name. + */ + public function getInteger(string $name, int $default = 0): int; + + /** + * Gets a single floating point value. + * + * @param string $name Name of the value to fetch. + * @param float $default Default value to fall back on if the value is not present. + * @return float Configuration value for $name. + */ + public function getFloat(string $name, float $default = 0): float; + + /** + * Gets a single boolean value. + * + * @param string $name Name of the value to fetch. + * @param bool $default Default value to fall back on if the value is not present. + * @return bool Configuration value for $name. + */ + public function getBoolean(string $name, bool $default = false): bool; + + /** + * Gets an array value. + * + * @param string $name Name of the value to fetch. + * @param mixed[] $default Default value to fall back on if the value is not present. + * @return mixed[] Configuration value for $name. + */ + public function getArray(string $name, array $default = []): array; + + /** + * Sets multiple values at once using an associative array. + * + * @param array $values Values to save. + * @throws InvalidArgumentException If $values is malformed. + * @throws RuntimeException If the configuration is read only. + */ + public function setValues(array $values): void; + + /** + * Sets a single string value. + * + * @param string $name Name of the value to save. + * @param string $value Value to save. + */ + public function setString(string $name, string $value): void; + + /** + * Sets a single integer value. + * + * @param string $name Name of the value to save. + * @param int $value Value to save. + */ + public function setInteger(string $name, int $value): void; + + /** + * Sets a single floating point value. + * + * @param string $name Name of the value to save. + * @param float $value Value to save. + */ + public function setFloat(string $name, float $value): void; + + /** + * Sets a single boolean value. + * + * @param string $name Name of the value to save. + * @param bool $value Value to save. + */ + public function setBoolean(string $name, bool $value): void; + + /** + * Sets an array value. + * + * @param string $name Name of the value to save. + * @param mixed[] $value Value to save. + */ + public function setArray(string $name, array $value): void; +} diff --git a/src/Config/ConfigValueInfo.php b/src/Config/ConfigValueInfo.php new file mode 100644 index 0000000..0e7f5e3 --- /dev/null +++ b/src/Config/ConfigValueInfo.php @@ -0,0 +1,110 @@ + */ + private array $values = []; + + /** @var string[] */ + private array $extraFieldNames; + + /** @var mixed[] */ + private array $extraFieldValues; + + /** + * @param DbConnection $dbConn + * @param string $tableName + * @param string $nameField + * @param string $valueField + * @param array $extraFields + */ + public function __construct( + DbConnection $dbConn, + private string $tableName, + private string $nameField = 'config_name', + private string $valueField = 'config_value', + array $extraFields = [], + ) { + $this->cache = new DbStatementCache($dbConn); + $this->extraFieldNames = array_keys($extraFields); + $this->extraFieldValues = array_values($extraFields); + } + + public static function validateName(string $name): bool { + $parts = explode('.', $name); + foreach($parts as $part) { + if($part === '' || trim($part) !== $part) + return false; + + if(preg_match('#^([a-z][a-zA-Z0-9_]+)$#', $part) !== 1) + return false; + } + + return true; + } + + /** + * Resets value cache. + */ + public function reset(): void { + $this->values = []; + } + + /** + * Unloads specifics items from the local cache. + * + * @param string|string[] $names Names of values to unload. + */ + public function unload(string|array $names): void { + if(empty($names)) + return; + if(is_string($names)) + $names = [$names]; + + foreach($names as $name) + unset($this->values[$name]); + } + + public function getSeparator(): string { + return '.'; + } + + public function scopeTo(string ...$prefix): Config { + return new ScopedConfig($this, $prefix); + } + + public function hasValues(string|array $names): bool { + if(empty($names)) + return true; + if(is_string($names)) + $names = [$names]; + + $cachedNames = array_keys($this->values); + $names = array_diff($names, $cachedNames); + + if(!empty($names)) { + // array_diff preserves keys, the for() later would fuck up without it + $names = array_values($names); + $nameCount = count($names); + + $query = sprintf( + 'SELECT COUNT(*) FROM %s WHERE %s IN (%s)', + $this->tableName, $this->nameField, + DbTools::prepareListString($nameCount) + ); + foreach($this->extraFieldNames as $extraFieldName) + $query .= sprintf(' AND %s = ?', $extraFieldName); + + $stmt = $this->cache->get($query); + + $args = 0; + foreach($names as $name) + $stmt->addParameter(++$args, $name); + foreach($this->extraFieldValues as $extraFieldValue) + $stmt->addParameter(++$args, $extraFieldValue); + + $stmt->execute(); + $result = $stmt->getResult(); + if($result->next()) + return $result->getInteger(0) >= $nameCount; + } + + return true; + } + + public function removeValues(string|array $names): void { + if(empty($names)) + return; + if(is_string($names)) + $names = [$names]; + + foreach($names as $name) + unset($this->values[$name]); + + $nameCount = count($names); + $query = sprintf( + 'DELETE FROM %s WHERE %s IN (%s)', + $this->tableName, $this->nameField, + DbTools::prepareListString($nameCount) + ); + foreach($this->extraFieldNames as $extraFieldName) + $query .= sprintf(' AND %s = ?', $extraFieldName); + + $stmt = $this->cache->get($query); + + $args = 0; + foreach($names as $name) + $stmt->addParameter(++$args, $name); + foreach($this->extraFieldValues as $extraFieldValue) + $stmt->addParameter(++$args, $extraFieldValue); + + $stmt->execute(); + } + + public function getAllValueInfos(int $range = 0, int $offset = 0): array { + $this->reset(); + $infos = []; + + $hasRange = $range !== 0; + + $query = sprintf('SELECT %s, %s FROM %s', $this->nameField, $this->valueField, $this->tableName); + foreach($this->extraFieldNames as $i => $extraFieldName) + $query .= sprintf(' %s %s = ?', $i < 1 ? 'WHERE' : 'AND', $extraFieldName); + if($hasRange) { + if($range < 0) + throw new InvalidArgumentException('$range must be a positive integer'); + if($offset < 0) + throw new InvalidArgumentException('$offset must be greater than zero if a range is specified'); + + $query .= ' LIMIT ? OFFSET ?'; + } + + $stmt = $this->cache->get($query); + + $args = 0; + foreach($this->extraFieldValues as $extraFieldValue) + $stmt->addParameter(++$args, $extraFieldValue); + if($hasRange) { + $stmt->addParameter(++$args, $range); + $stmt->addParameter(++$args, $offset); + } + + $stmt->execute(); + + $result = $stmt->getResult(); + + while($result->next()) { + $name = $result->getString(0); + $infos[] = $this->values[$name] = new DbConfigValueInfo($result); + } + + return $infos; + } + + public function getValueInfos(string|array $names): array { + if(empty($names)) + return []; + if(is_string($names)) + $names = [$names]; + + $infos = []; + $skip = []; + + foreach($names as $name) + if(array_key_exists($name, $this->values)) { + $infos[] = $this->values[$name]; + $skip[] = $name; + } + + $names = array_diff($names, $skip); + + if(!empty($names)) { + // array_diff preserves keys, the for() later would fuck up without it + $names = array_values($names); + $nameCount = count($names); + + $query = sprintf( + 'SELECT %s, %s FROM %s WHERE %s IN (%s)', + $this->nameField, $this->valueField, $this->tableName, $this->nameField, + DbTools::prepareListString($nameCount) + ); + foreach($this->extraFieldNames as $extraFieldName) + $query .= sprintf(' AND %s = ?', $extraFieldName); + + $stmt = $this->cache->get($query); + + $args = 0; + foreach($names as $name) + $stmt->addParameter(++$args, $name); + foreach($this->extraFieldValues as $extraFieldValue) + $stmt->addParameter(++$args, $extraFieldValue); + + $stmt->execute(); + + $result = $stmt->getResult(); + while($result->next()) { + $name = $result->getString(0); + $infos[] = $this->values[$name] = new DbConfigValueInfo($result); + } + } + + return $infos; + } + + public function setValues(array $values): void { + if(empty($values)) + return; + + $fields = [$this->nameField, $this->valueField]; + if(count($this->extraFieldNames) > 0) + $fields = array_merge($fields, $this->extraFieldNames); + $fieldCount = count($fields); + $fields = implode(', ', $fields); + + $stmt = $this->cache->get(sprintf( + 'INSERT INTO %s (%s) VALUES (%s)', + $this->tableName, $fields, + DbTools::prepareListString($fieldCount) + )); + + foreach($values as $name => $value) { + if(!self::validateName($name)) + throw new InvalidArgumentException('invalid name encountered in $values'); + + if(is_array($value)) { + foreach($value as $entry) + if(!is_scalar($entry)) + throw new InvalidArgumentException('an array value in $values contains a non-scalar type'); + } elseif(!is_scalar($value)) + throw new InvalidArgumentException('invalid value type encountered in $values'); + + $this->removeValues($name); + + $args = 0; + $stmt->addParameter(++$args, $name); + $stmt->addParameter(++$args, serialize($value)); + foreach($this->extraFieldValues as $extraFieldValue) + $stmt->addParameter(++$args, $extraFieldValue); + + $stmt->execute(); + } + } +} diff --git a/src/Config/Db/DbConfigValueInfo.php b/src/Config/Db/DbConfigValueInfo.php new file mode 100644 index 0000000..380bebc --- /dev/null +++ b/src/Config/Db/DbConfigValueInfo.php @@ -0,0 +1,94 @@ +name = $result->getString(0); + $this->value = $result->getString(1); + } + + public function getName(): string { + return $this->name; + } + + public function getType(): string { + return match($this->value[0]) { + 'b' => 'bool', + 'a' => 'array', + 'd' => 'float', + 'i' => 'int', + 's' => 'string', + default => 'unknown', + }; + } + + public function isBoolean(): bool { return $this->value[0] === 'b'; } + public function isArray(): bool { return $this->value[0] === 'a'; } + public function isFloat(): bool { return $this->value[0] === 'd'; } + public function isInteger(): bool { return $this->value[0] === 'i'; } + public function isString(): bool { return $this->value[0] === 's'; } + + public function getValue(): mixed { + return unserialize($this->value); + } + + public function getString(): string { + $value = $this->getValue(); + if(!is_string($value)) + throw new UnexpectedValueException('Value is not a string.'); + return $value; + } + + public function getInteger(): int { + $value = $this->getValue(); + if(!is_int($value)) + throw new UnexpectedValueException('Value is not an integer.'); + return $value; + } + + public function getFloat(): float { + $value = $this->getValue(); + if(!is_float($value)) + throw new UnexpectedValueException('Value is not a floating point number.'); + return $value; + } + + public function getBoolean(): bool { + $value = $this->getValue(); + if(!is_bool($value)) + throw new UnexpectedValueException('Value is not a boolean.'); + return $value; + } + + public function getArray(): array { + $value = $this->getValue(); + if(!is_array($value)) + throw new UnexpectedValueException('Value is not an array.'); + return $value; + } + + public function __toString(): string { + $value = $this->getValue(); + if(is_array($value)) + return implode(', ', $value); + + return (string)$value; // @phpstan-ignore-line dude trust me + } +} diff --git a/src/Config/Fs/FsConfig.php b/src/Config/Fs/FsConfig.php new file mode 100644 index 0000000..483d4d3 --- /dev/null +++ b/src/Config/Fs/FsConfig.php @@ -0,0 +1,144 @@ + $values + */ + public function __construct(private array $values) {} + + public function getSeparator(): string { + return ':'; + } + + public function scopeTo(string ...$prefix): Config { + return new ScopedConfig($this, $prefix); + } + + public function hasValues(string|array $names): bool { + if(is_string($names)) + return array_key_exists($names, $this->values); + + foreach($names as $name) + if(!array_key_exists($name, $this->values)) + return false; + + return true; + } + + public function getAllValueInfos(int $range = 0, int $offset = 0): array { + if($range === 0) + return array_values($this->values); + + if($range < 0) + throw new InvalidArgumentException('$range must be a positive integer'); + if($offset < 0) + throw new InvalidArgumentException('$offset must be greater than zero if a range is specified'); + + return array_slice($this->values, $offset, $range); + } + + public function getValueInfos(string|array $names): array { + if(is_string($names)) + return array_key_exists($names, $this->values) ? [$this->values[$names]] : []; + + $infos = []; + + foreach($names as $name) + if(array_key_exists($name, $this->values)) + $infos[] = $this->values[$name]; + + return $infos; + } + + /** + * Creates an instance of FsConfig from an array of lines. + * + * @param string[] $lines Config lines. + * @return FsConfig + */ + public static function fromLines(array $lines): self { + $values = []; + + foreach($lines as $line) { + $line = trim($line); + if($line === '' || $line[0] === '#' || $line[0] === ';') + continue; + + $info = new FsConfigValueInfo(...explode(' ', $line, 2)); + $values[$info->getName()] = $info; + } + + return new FsConfig($values); + } + + /** + * Creates an instance of FsConfig from a string. + * + * @param string $lines Config lines. + * @param non-empty-string $newLine Line separator character. + * @return FsConfig + */ + public static function fromString(string $lines, string $newLine = "\n"): self { + return self::fromLines(explode($newLine, $lines)); + } + + /** + * Creates an instance of FsConfig from a file. + * + * @param string $path Config file path. + * @throws InvalidArgumentException If $path does not exist or could not be opened. + * @return FsConfig + */ + public static function fromFile(string $path): self { + if(!is_file($path)) + throw new InvalidArgumentException('$path does not exist'); + + $handle = fopen($path, 'rb'); + if($handle === false) + throw new RuntimeException('could not open a file handle from $path'); + + try { + return self::fromStream($handle); + } finally { + fclose($handle); + } + } + + /** + * Creates an instance of FsConfig from a readable stream. + * + * @param resource $stream Config file stream. + * @throws InvalidArgumentException If $stream is not a . + * @return FsConfig + */ + public static function fromStream(mixed $stream): self { + if(!is_resource($stream)) + throw new InvalidArgumentException('$stream must be a resource'); + + $values = []; + while(($line = fgets($stream)) !== false) { + $line = trim($line); + if($line === '' || $line[0] === '#' || $line[0] === ';') + continue; + + $info = new FsConfigValueInfo(...explode(' ', $line, 2)); + $values[$info->getName()] = $info; + } + + return new FsConfig($values); + } +} diff --git a/src/Config/Fs/FsConfigValueInfo.php b/src/Config/Fs/FsConfigValueInfo.php new file mode 100644 index 0000000..371bb3e --- /dev/null +++ b/src/Config/Fs/FsConfigValueInfo.php @@ -0,0 +1,83 @@ +name = $name; + $this->value = trim($value); + } + + public function getName(): string { + return $this->name; + } + + public function getType(): string { + // SharpChat config format is just all strings and casts on demand + return 'string'; + } + + public function isString(): bool { + return true; + } + + public function isInteger(): bool { + return true; + } + + public function isFloat(): bool { + return true; + } + + public function isBoolean(): bool { + return true; + } + + public function isArray(): bool { + return true; + } + + public function getValue(): mixed { + return $this->value; + } + + public function getString(): string { + return $this->value; + } + + public function getInteger(): int { + return (int)$this->value; + } + + public function getFloat(): float { + return (float)$this->value; + } + + public function getBoolean(): bool { + return $this->value !== '0' + && strcasecmp($this->value, 'false') !== 0; + } + + public function getArray(): array { + return explode(' ', $this->value); + } + + public function __toString(): string { + return $this->value; + } +} diff --git a/src/Config/GetValueInfoTrait.php b/src/Config/GetValueInfoTrait.php new file mode 100644 index 0000000..3147335 --- /dev/null +++ b/src/Config/GetValueInfoTrait.php @@ -0,0 +1,41 @@ +getValueInfos($name); + return empty($infos) ? null : $infos[0]; + } + + public function getString(string $name, string $default = ''): string { + $valueInfo = $this->getValueInfo($name); + return $valueInfo?->isString() ? $valueInfo->getString() : $default; + } + + public function getInteger(string $name, int $default = 0): int { + $valueInfo = $this->getValueInfo($name); + return $valueInfo?->isInteger() ? $valueInfo->getInteger() : $default; + } + + public function getFloat(string $name, float $default = 0): float { + $valueInfo = $this->getValueInfo($name); + return $valueInfo?->isFloat() ? $valueInfo->getFloat() : $default; + } + + public function getBoolean(string $name, bool $default = false): bool { + $valueInfo = $this->getValueInfo($name); + return $valueInfo?->isBoolean() ? $valueInfo->getBoolean() : $default; + } + + public function getArray(string $name, array $default = []): array { + $valueInfo = $this->getValueInfo($name); + return $valueInfo?->isArray() ? $valueInfo->getArray() : $default; + } +} diff --git a/src/Config/GetValuesTrait.php b/src/Config/GetValuesTrait.php new file mode 100644 index 0000000..23937dd --- /dev/null +++ b/src/Config/GetValuesTrait.php @@ -0,0 +1,97 @@ + $specs + * @throws InvalidArgumentException If $specs contains an invalid entry. + * @return array + */ + public function getValues(array $specs): array { + $names = []; + $evald = []; + + foreach($specs as $key => $spec) { + if(is_string($spec)) { + $name = $spec; + $default = null; + $alias = null; + } elseif(is_array($spec) && !empty($spec)) { + $name = $spec[0]; + $default = $spec[1] ?? null; + $alias = $spec[2] ?? null; + } else + throw new InvalidArgumentException('$specs array contains an invalid entry'); + + $nameLength = strlen($name); + if($nameLength > 3 && ($colon = strrpos($name, ':')) === $nameLength - 2) { + $type = substr($name, $colon + 1, 1); + $name = substr($name, 0, $colon); + } else $type = ''; + + $names[] = $name; + $evald[$key] = [ + 'name' => $name, + 'type' => $type, + 'default' => $default, + 'alias' => $alias, + ]; + } + + $infos = $this->getValueInfos($names); + $results = []; + + foreach($evald as $spec) { + foreach($infos as $infoTest) + if($infoTest->getName() === $spec['name']) { + $info = $infoTest; + break; + } + + $resultName = $spec['alias'] ?? $spec['name']; + + if(!isset($info)) { + $defaultValue = $spec['default'] ?? null; + if($spec['type'] !== '') + settype($defaultValue, match($spec['type']) { + 's' => 'string', + 'a' => 'array', + 'i' => 'int', + 'b' => 'bool', + 'f' => 'float', + 'd' => 'double', + default => throw new InvalidArgumentException(sprintf('invalid type letter encountered: "%s"', $spec['type'])), + }); + + $results[$resultName] = $defaultValue; + continue; + } + + $results[$resultName] = match($spec['type']) { + 's' => $info->getString(), + 'a' => $info->getArray(), + 'i' => $info->getInteger(), + 'b' => $info->getBoolean(), + 'f' => $info->getFloat(), + 'd' => $info->getFloat(), + '' => $info->getValue(), + default => throw new InvalidArgumentException('unknown type encountered in $specs'), + }; + + unset($info); + } + + return $results; + } +} diff --git a/src/Config/ImmutableConfigTrait.php b/src/Config/ImmutableConfigTrait.php new file mode 100644 index 0000000..ac1e0f6 --- /dev/null +++ b/src/Config/ImmutableConfigTrait.php @@ -0,0 +1,41 @@ +setValues([$name => $value]); + } + + public function setInteger(string $name, int $value): void { + $this->setValues([$name => $value]); + } + + public function setFloat(string $name, float $value): void { + $this->setValues([$name => $value]); + } + + public function setBoolean(string $name, bool $value): void { + $this->setValues([$name => $value]); + } + + public function setArray(string $name, array $value): void { + $this->setValues([$name => $value]); + } +} diff --git a/src/Config/Null/NullConfig.php b/src/Config/Null/NullConfig.php new file mode 100644 index 0000000..4f4dea8 --- /dev/null +++ b/src/Config/Null/NullConfig.php @@ -0,0 +1,69 @@ + */ + private array $prefixRaw; + private int $prefixLength; + + /** @param string[] $prefixRaw */ + public function __construct(Config $config, array $prefixRaw) { + if(empty($prefixRaw)) + throw new InvalidArgumentException('$prefix may not be empty'); + + $scopeChar = $config->getSeparator(); + $prefix = implode($scopeChar, $prefixRaw) . $scopeChar; + + $this->config = $config; + $this->prefix = $prefix; + $this->prefixRaw = $prefixRaw; + $this->prefixLength = strlen($prefix); + } + + /** + * @param string|string[] $names + * @return string[] + */ + private function prefixNames(string|array $names): array { + if(is_string($names)) + return [$this->prefix . $names]; + + foreach($names as $key => $name) + $names[$key] = $this->prefix . $name; + + return $names; + } + + private function prefixName(string $name): string { + return $this->prefix . $name; + } + + public function scopeTo(string ...$prefix): Config { + return $this->config->scopeTo(...array_merge($this->prefixRaw, $prefix)); + } + + public function getSeparator(): string { + return $this->config->getSeparator(); + } + + public function hasValues(string|array $names): bool { + return $this->config->hasValues($this->prefixNames($names)); + } + + public function removeValues(string|array $names): void { + $this->config->removeValues($this->prefixNames($names)); + } + + public function getAllValueInfos(int $range = 0, int $offset = 0): array { + $infos = $this->config->getAllValueInfos($range, $offset); + foreach($infos as $key => $info) + $infos[$key] = new ScopedConfigValueInfo($info, $this->prefixLength); + return $infos; + } + + public function getValueInfos(string|array $names): array { + $infos = $this->config->getValueInfos($this->prefixNames($names)); + foreach($infos as $key => $info) + $infos[$key] = new ScopedConfigValueInfo($info, $this->prefixLength); + return $infos; + } + + public function getValueInfo(string $name): ?ConfigValueInfo { + $info = $this->config->getValueInfo($this->prefixName($name)); + if($info !== null) + $info = new ScopedConfigValueInfo($info, $this->prefixLength); + return $info; + } + + public function getValues(array $specs): array { + foreach($specs as $key => $spec) { + if(is_string($spec)) + $specs[$key] = $this->prefixName($spec); + elseif(is_array($spec) && !empty($spec)) + $specs[$key][0] = $this->prefixName($spec[0]); + else + throw new InvalidArgumentException('$specs array contains an invalid entry'); + } + + $results = []; + foreach($this->config->getValues($specs) as $name => $result) + // prefix removal should probably be done with a whitelist of sorts + $results[str_starts_with($name, $this->prefix) ? substr($name, $this->prefixLength) : $name] = $result; + + return $results; + } + + public function getString(string $name, string $default = ''): string { + return $this->config->getString($this->prefixName($name), $default); + } + + public function getInteger(string $name, int $default = 0): int { + return $this->config->getInteger($this->prefixName($name), $default); + } + + public function getFloat(string $name, float $default = 0): float { + return $this->config->getFloat($this->prefixName($name), $default); + } + + public function getBoolean(string $name, bool $default = false): bool { + return $this->config->getBoolean($this->prefixName($name), $default); + } + + public function getArray(string $name, array $default = []): array { + return $this->config->getArray($this->prefixName($name), $default); + } + + public function setValues(array $values): void { + if(empty($values)) + return; + + $prefixed = []; + foreach($values as $name => $value) + $prefixed[$this->prefixName($name)] = $value; + $this->config->setValues($values); + } + + public function setString(string $name, string $value): void { + $this->config->setString($this->prefixName($name), $value); + } + + public function setInteger(string $name, int $value): void { + $this->config->setInteger($this->prefixName($name), $value); + } + + public function setFloat(string $name, float $value): void { + $this->config->setFloat($this->prefixName($name), $value); + } + + public function setBoolean(string $name, bool $value): void { + $this->config->setBoolean($this->prefixName($name), $value); + } + + public function setArray(string $name, array $value): void { + $this->config->setArray($this->prefixName($name), $value); + } +} diff --git a/src/Config/ScopedConfigValueInfo.php b/src/Config/ScopedConfigValueInfo.php new file mode 100644 index 0000000..68763b6 --- /dev/null +++ b/src/Config/ScopedConfigValueInfo.php @@ -0,0 +1,85 @@ +info->getName(), $this->prefixLength); + } + + /** + * Gets the real name of the configuration value, without removing the prefix. + * + * @return string Unprefixed configuration value. + */ + public function getRealName(): string { + return $this->info->getName(); + } + + public function getType(): string { + return $this->info->getType(); + } + + public function isString(): bool { + return $this->info->isString(); + } + + public function isInteger(): bool { + return $this->info->isInteger(); + } + + public function isFloat(): bool { + return $this->info->isFloat(); + } + + public function isBoolean(): bool { + return $this->info->isBoolean(); + } + + public function isArray(): bool { + return $this->info->isArray(); + } + + public function getValue(): mixed { + return $this->info->getValue(); + } + + public function getString(): string { + return $this->info->getString(); + } + + public function getInteger(): int { + return $this->info->getInteger(); + } + + public function getFloat(): float { + return $this->info->getFloat(); + } + + public function getBoolean(): bool { + return $this->info->getBoolean(); + } + + public function getArray(): array { + return $this->info->getArray(); + } + + public function __toString(): string { + return (string)$this->info; + } +} diff --git a/tests/DbConfigTest.php b/tests/DbConfigTest.php new file mode 100644 index 0000000..3545395 --- /dev/null +++ b/tests/DbConfigTest.php @@ -0,0 +1,182 @@ + 'b:1;', + 'private.enable' => 'b:0;', + 'private.msg' => 's:71:"Things are happening. Check back later for something new... eventually.";', + 'private.perm.cat' => 's:4:"user";', + 'private.perm.val' => 'i:1;', + 'site.desc' => 's:38:"The internet\'s last convenience store.";', + 'site.ext_logo' => 's:51:"https://static.flash.moe/images/flashii-logo-v3.png";', + 'site.name' => 's:5:"Edgii";', + 'site.social.bsky' => 's:36:"https://bsky.app/profile/flashii.net";', + 'site.url' => 's:18:"https://edgii.net/";', + 'test.array' => 'a:5:{i:0;i:1234;i:1;d:56.789;i:2;s:6:"Mewow!";i:3;b:1;i:4;s:4:"jeff";}', + 'test.bool' => 'b:1;', + 'test.float' => 'd:9876.4321;', + 'test.int' => 'i:243230;', + ]; + + private const USER_VALUES = [ + 'mobile.left_handed' => ['b:0;', 'b:1;'], + 'profile.allow_indexing' => ['b:1;', 'b:0;'], + ]; + + protected function setUp(): void { + $this->dbConn = DbTools::create('sqlite::memory:'); + $this->dbConn->execute('CREATE TABLE skh_config (config_name TEXT NOT NULL COLLATE NOCASE, config_value BLOB NOT NULL, PRIMARY KEY (config_name))'); + $this->dbConn->execute('CREATE TABLE skh_user_settings (user_id INTEGER NOT NULL, setting_name TEXT NOT NULL COLLATE NOCASE, setting_value BLOB NOT NULL, PRIMARY KEY (user_id, setting_name))'); + + $stmt = $this->dbConn->prepare('INSERT INTO skh_config (config_name, config_value) VALUES (?, ?)'); + foreach(self::VALUES as $name => $value) { + $stmt->addParameter(1, $name); + $stmt->addParameter(2, $value); + $stmt->execute(); + } + + $stmt = $this->dbConn->prepare('INSERT INTO skh_user_settings (user_id, setting_name, setting_value) VALUES (?, ?, ?)'); + for($i = 1; $i <= 10; ++$i) + foreach(self::USER_VALUES as $name => $value) { + $stmt->addParameter(1, $i); + $stmt->addParameter(2, $name); + $stmt->addParameter(3, $value[$i % 2]); + $stmt->execute(); + } + + $this->config = new DbConfig($this->dbConn, 'skh_config'); + } + + public function testScoping(): void { + $this->assertEquals('user', $this->config->getString('private.perm.cat')); + $this->assertEquals('Edgii', $this->config->getString('site.name')); + + $scoped = $this->config->scopeTo('private', 'perm'); + $this->assertEquals('user', $scoped->getString('cat')); + } + + public function testHasValues(): void { + // hasValues should always return true when the list is empty + $this->assertTrue($this->config->hasValues([])); + $this->assertFalse($this->config->hasValues('meow')); + $this->assertTrue($this->config->hasValues('site.desc')); + $this->assertTrue($this->config->hasValues(['site.ext_logo', 'site.url'])); + $this->assertFalse($this->config->hasValues(['site.ext_logo', 'site.url', 'site.gun'])); + } + + public function testGetAllValueInfos(): void { + $all = $this->config->getAllValueInfos(); + $expected = array_keys(self::VALUES); + $values = []; + + foreach($all as $info) + $values[] = $info->getName(); + + $this->assertEquals($expected, $values); + + $subset = $this->config->getAllValueInfos(2, 3); + $expected = [ + 'private.perm.cat', + 'private.perm.val', + ]; + $values = []; + + foreach($subset as $info) + $values[] = $info->getName(); + + $this->assertEquals($expected, $values); + } + + public function testGetValues(): void { + $this->assertNull($this->config->getValueInfo('doesnotexist')); + + $scoped = $this->config->scopeTo('private', 'perm'); + + $expected = ['private.perm.cat' => 'user', 'private.perm.val' => 1]; + $values = []; + $valueInfos = $scoped->getValueInfos(['cat', 'val', 'poop']); + foreach($valueInfos as $valueInfo) + $values[$valueInfo->getRealName()] = $valueInfo->getValue(); + + $this->assertEquals($expected, $values); + + $scoped = $this->config->scopeTo('site')->scopeTo('social'); + + $expected = [ + 'bsky' => 'https://bsky.app/profile/flashii.net', + 'bsky_show' => true, + 'twitter' => '', + 'twitterShow' => false, + ]; + $values = $scoped->getValues([ + 'bsky', + ['bsky_show:b', true], + 'twitter:s', + ['twitter_show:b', false, 'twitterShow'], + ]); + + $this->assertEquals($expected, $values); + + $this->assertEquals('', $this->config->getString('none.string')); + $this->assertEquals('test', $this->config->getString('none.string', 'test')); + $this->assertEquals('https://edgii.net/', $this->config->getString('site.url')); + $this->assertEquals(0, $this->config->getInteger('site.url')); + + $this->assertEquals(0, $this->config->getInteger('none.int')); + $this->assertEquals(10, $this->config->getInteger('none.int', 10)); + $this->assertEquals(243230, $this->config->getInteger('test.int')); + $this->assertEquals('', $this->config->getString('test.int')); + + $this->assertEquals(0, $this->config->getFloat('none.float')); + $this->assertEquals(0.1, $this->config->getFloat('none.float', 0.1)); + $this->assertEquals(9876.4321, $this->config->getFloat('test.float')); + $this->assertEmpty($this->config->getArray('test.float')); + + $this->assertEquals(false, $this->config->getBoolean('none.bool')); + $this->assertEquals(true, $this->config->getBoolean('none.bool', true)); + $this->assertEquals(true, $this->config->getBoolean('test.bool')); + $this->assertEquals(false, $this->config->getBoolean('private.msg')); + $this->assertEquals(0, $this->config->getFloat('test.bool')); + + $this->assertEmpty($this->config->getArray('none.array')); + $this->assertEquals(['de', 'het', 'een'], $this->config->getArray('none.array', ['de', 'het', 'een'])); + $this->assertEquals([1234, 56.789, 'Mewow!', true, 'jeff'], $this->config->getArray('test.array')); + $this->assertEquals(false, $this->config->getBoolean('test.array')); + } + + public function testNameValidation(): void { + $this->assertTrue(DbConfig::validateName('th1s.iS.vAL1d')); + $this->assertFalse(DbConfig::validateName('')); + $this->assertFalse(DbConfig::validateName('this..is.not.valid')); + $this->assertFalse(DbConfig::validateName('this..is.not.valid')); + $this->assertFalse(DbConfig::validateName('First.may.Not.be.uppercase')); + } + + public function testUserSettings(): void { + for($i = 1; $i <= 10; ++$i) { + $config = new DbConfig($this->dbConn, 'skh_user_settings', 'setting_name', 'setting_value', ['user_id' => $i]); + + foreach(self::USER_VALUES as $name => $value) + $this->assertEquals(unserialize($value[$i % 2]), $config->getBoolean($name)); + } + } +} diff --git a/tests/FsConfigTest.php b/tests/FsConfigTest.php new file mode 100644 index 0000000..94b3dd4 --- /dev/null +++ b/tests/FsConfigTest.php @@ -0,0 +1,191 @@ +expectException(\RuntimeException::class); + FsConfig::fromString('test value')->removeValues('test'); + } + + public function testImmutableSetString(): void { + $this->expectException(\RuntimeException::class); + FsConfig::fromString('test value')->setString('test', 'the'); + } + + public function testImmutableSetInteger(): void { + $this->expectException(\RuntimeException::class); + FsConfig::fromString('test 1234')->setInteger('test', 5678); + } + + public function testImmutableSetFloat(): void { + $this->expectException(\RuntimeException::class); + FsConfig::fromString('test 56.78')->setFloat('test', 12.34); + } + + public function testImmutableSetBoolean(): void { + $this->expectException(\RuntimeException::class); + FsConfig::fromString('test true')->setBoolean('test', false); + } + + public function testImmutableSetArray(): void { + $this->expectException(\RuntimeException::class); + FsConfig::fromString('test words words words')->setArray('test', ['meow', 'meow', 'meow']); + } + + public function testImmutableSetValues(): void { + $this->expectException(\RuntimeException::class); + FsConfig::fromString('')->setValues([ + 'stringval' => 'the', + 'intval' => 1234, + 'floatval' => 56.78, + 'boolval' => true, + 'arrval' => ['meow'], + ]); + } + + public function testScoping(): void { + $config = FsConfig::fromLines([ + 'test Inaccessible', + 'scoped:test Accessible', + ]); + + $this->assertEquals('Inaccessible', $config->getString('test')); + $this->assertEquals('Accessible', $config->getString('scoped:test')); + + $scoped = $config->scopeTo('scoped'); + $this->assertEquals('Accessible', $scoped->getString('test')); + } + + public function testHasValues(): void { + $config = FsConfig::fromLines([ + 'test 123', + 'scoped:test true', + 'scoped:meow meow', + ]); + + // hasValues should always return true when the list is empty + $this->assertTrue($config->hasValues([])); + $this->assertFalse($config->hasValues('meow')); + $this->assertTrue($config->hasValues('test')); + $this->assertFalse($config->hasValues(['test', 'meow'])); + $this->assertTrue($config->hasValues(['scoped:test', 'scoped:meow'])); + } + + public function testGetAllValueInfos(): void { + $config = FsConfig::fromFile(__DIR__ . '/sharpchat.cfg'); + + $all = $config->getAllValueInfos(); + $expected = [ + 'chat:port', + 'chat:msgMaxLength', + 'chat:floodKickLength', + 'chat:channels', + 'chat:channels:lounge:name', + 'chat:channels:lounge:autoJoin', + 'chat:channels:prog:name', + 'chat:channels:games:name', + 'chat:channels:splat:name', + 'chat:channels:passwd:name', + 'chat:channels:passwd:password', + 'chat:channels:staff:name', + 'chat:channels:staff:minRank', + 'msz:secret', + 'msz:url', + 'mariadb:host', + 'mariadb:user', + 'mariadb:pass', + 'mariadb:db', + ]; + $values = []; + + foreach($all as $info) + $values[] = $info->getName(); + + $this->assertEquals($expected, $values); + + $subset = $config->getAllValueInfos(3, 6); + $expected = [ + 'chat:channels:prog:name', + 'chat:channels:games:name', + 'chat:channels:splat:name', + ]; + $values = []; + + foreach($subset as $info) + $values[] = $info->getName(); + + $this->assertEquals($expected, $values); + } + + public function testGetValues(): void { + $config = FsConfig::fromFile(__DIR__ . '/sharpchat.cfg'); + + $this->assertNull($config->getValueInfo('doesnotexist')); + + $scoped = $config->scopeTo('chat')->scopeTo('channels', 'passwd'); + + $expected = ['chat:channels:passwd:name' => 'Password', 'chat:channels:passwd:password' => 'meow']; + $values = []; + $valueInfos = $scoped->getValueInfos(['name', 'password', 'minRank']); + foreach($valueInfos as $valueInfo) + $values[$valueInfo->getRealName()] = $valueInfo->getValue(); + + $this->assertEquals($expected, $values); + + $scoped = $config->scopeTo('chat', 'channels', 'lounge'); + + $expected = [ + 'name' => 'Lounge', + 'auto_join' => true, + 'minRank' => 0, + ]; + $values = $scoped->getValues([ + 'name', + ['autoJoin:b', false, 'auto_join'], + 'minRank:i', + ]); + + $this->assertEquals($expected, $values); + + $this->assertEquals('', $config->getString('msz:url2')); + $this->assertEquals('test', $config->getString('msz:url2', 'test')); + $this->assertEquals('https://flashii.net/_sockchat', $config->getString('msz:url')); + + $this->assertEquals(0, $config->getInteger('chat:connMaxCount')); + $this->assertEquals(10, $config->getInteger('chat:connMaxCount', 10)); + $this->assertEquals(30, $config->getInteger('chat:floodKickLength')); + $this->assertEquals('30', $config->getString('chat:floodKickLength')); + + $this->assertEquals(0, $config->getFloat('boat')); + $this->assertEquals(0.1, $config->getFloat('boat', 0.1)); + $this->assertEquals(192.168, $config->getFloat('mariadb:host')); + $this->assertEquals('192.168.0.123', $config->getString('mariadb:host')); + + $this->assertEquals(false, $config->getBoolean('nonexist')); + $this->assertEquals(true, $config->getBoolean('nonexist', true)); + $this->assertEquals(true, $config->getBoolean('chat:channels:lounge:autoJoin')); + $this->assertEquals('true', $config->getString('chat:channels:lounge:autoJoin')); + $this->assertEquals(true, $config->getBoolean('mariadb:db')); + + $this->assertEmpty($config->getArray('nonexist')); + $this->assertEquals(['de', 'het', 'een'], $config->getArray('nonexist', ['de', 'het', 'een'])); + $this->assertEquals(['lounge', 'prog', 'games', 'splat', 'passwd', 'staff'], $config->getArray('chat:channels')); + $this->assertEquals('lounge prog games splat passwd staff', $config->getString('chat:channels')); + $this->assertEquals(['fake', 'secret', 'meow'], $config->getArray('msz:secret')); + $this->assertEquals('fake secret meow', $config->getString('msz:secret')); + } +} diff --git a/tests/NullConfigTest.php b/tests/NullConfigTest.php new file mode 100644 index 0000000..a478d63 --- /dev/null +++ b/tests/NullConfigTest.php @@ -0,0 +1,81 @@ +removeValues('test'); + $config->setString('stringval', 'the'); + $config->setInteger('intval', 1234); + $config->setFloat('floatval', 56.78); + $config->setBoolean('boolval', true); + $config->setArray('arrval', ['meow']); + $config->setValues([ + 'stringval' => 'the', + 'intval' => 1234, + 'floatval' => 56.78, + 'boolval' => true, + 'arrval' => ['meow'], + ]); + + // NullConfig currently returns itself when scoping + // might change this depending on whether the scope prefix will be exposed or not + $scoped = $config->scopeTo('scoped'); + $this->assertEquals($config, $scoped); + + // hasValues should always return true when the list is empty + $this->assertTrue($config->hasValues([])); + $this->assertFalse($config->hasValues('anything')); + $this->assertFalse($config->hasValues(['manything1', 'manything2'])); + + $this->assertEmpty($config->getAllValueInfos()); + $this->assertEmpty($config->getValueInfos('the')); + + $expected = [ + 'test_no_type' => null, + 'test_yes_type' => false, + 'test_no_type_yes_default' => 1234, + 'test_yes_type_yes_default' => 56.78, + 'aliased' => null, + ]; + $values = $config->getValues([ + 'test_no_type', + 'test_yes_type:b', + ['test_no_type_yes_default', 1234], + ['test_yes_type_yes_default:d', 56.78], + ['test_no_default_yes_alias', null, 'aliased'], + ]); + + $this->assertEqualsCanonicalizing($expected, $values); + + $this->assertNull($config->getValueInfo('value')); + + $this->assertEquals('', $config->getString('string')); + $this->assertEquals('default', $config->getString('string', 'default')); + + $this->assertEquals(0, $config->getInteger('int')); + $this->assertEquals(960, $config->getInteger('int', 960)); + + $this->assertEquals(0, $config->getFloat('float')); + $this->assertEquals(67.7, $config->getFloat('float', 67.7)); + + $this->assertFalse($config->getBoolean('bool')); + $this->assertTrue($config->getBoolean('bool', true)); + + $this->assertEmpty($config->getArray('arr')); + $this->assertEqualsCanonicalizing(['de', 'het', 'een'], $config->getArray('the', ['de', 'het', 'een'])); + } +} diff --git a/tests/sharpchat.cfg b/tests/sharpchat.cfg new file mode 100644 index 0000000..4659405 --- /dev/null +++ b/tests/sharpchat.cfg @@ -0,0 +1,36 @@ +# and ; can be used at the start of a line for comments. + +# General Configuration +chat:port 6770 +chat:msgMaxLength 5000 +;chat:connMaxCount 5 +chat:floodKickLength 30 + +# Channels +chat:channels lounge prog games splat passwd staff + +# Lounge channel settings +chat:channels:lounge:name Lounge +chat:channels:lounge:autoJoin true + +chat:channels:prog:name Programming +chat:channels:games:name Games +chat:channels:splat:name Splatoon + +# Passworded channel +chat:channels:passwd:name Password +chat:channels:passwd:password meow + +# Staff channel settings +chat:channels:staff:name Staff +chat:channels:staff:minRank 5 + +# Misuzu integration settings +msz:secret fake secret meow +msz:url https://flashii.net/_sockchat + +# MariaDB configuration +mariadb:host 192.168.0.123 +mariadb:user chat +mariadb:pass nyaa +mariadb:db chat