diff --git a/.plans/open/issue-200-n-plus-one-queries-member-list.md b/.plans/done/issue-200-n-plus-one-queries-member-list.md similarity index 100% rename from .plans/open/issue-200-n-plus-one-queries-member-list.md rename to .plans/done/issue-200-n-plus-one-queries-member-list.md diff --git a/composer-setup.php b/composer-setup.php new file mode 100644 index 0000000..53b32bc --- /dev/null +++ b/composer-setup.php @@ -0,0 +1,1788 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +setupEnvironment(); +process(is_array($argv) ? $argv : array()); + +/** + * Initializes various values + * + * @throws RuntimeException If uopz extension prevents exit calls + */ +function setupEnvironment() +{ + ini_set('display_errors', 1); + + if (extension_loaded('uopz') && !(ini_get('uopz.disable') || ini_get('uopz.exit'))) { + // uopz works at opcode level and disables exit calls + if (function_exists('uopz_allow_exit')) { + @uopz_allow_exit(true); + } else { + throw new RuntimeException('The uopz extension ignores exit calls and breaks this installer.'); + } + } + + $installer = 'ComposerInstaller'; + + if (defined('PHP_WINDOWS_VERSION_MAJOR')) { + if ($version = getenv('COMPOSERSETUP')) { + $installer = sprintf('Composer-Setup.exe/%s', $version); + } + } + + define('COMPOSER_INSTALLER', $installer); +} + +/** + * Processes the installer + */ +function process($argv) +{ + // Determine ANSI output from --ansi and --no-ansi flags + setUseAnsi($argv); + + $help = in_array('--help', $argv) || in_array('-h', $argv); + if ($help) { + displayHelp(); + exit(0); + } + + $check = in_array('--check', $argv); + $force = in_array('--force', $argv); + $quiet = in_array('--quiet', $argv); + $channel = 'stable'; + if (in_array('--snapshot', $argv)) { + $channel = 'snapshot'; + } elseif (in_array('--preview', $argv)) { + $channel = 'preview'; + } elseif (in_array('--1', $argv)) { + $channel = '1'; + } elseif (in_array('--2', $argv)) { + $channel = '2'; + } elseif (in_array('--2.2', $argv)) { + $channel = '2.2'; + } + $disableTls = in_array('--disable-tls', $argv); + $installDir = getOptValue('--install-dir', $argv, false); + $version = getOptValue('--version', $argv, false); + $filename = getOptValue('--filename', $argv, 'composer.phar'); + $cafile = getOptValue('--cafile', $argv, false); + + if (!checkParams($installDir, $version, $cafile)) { + exit(1); + } + + $ok = checkPlatform($warnings, $quiet, $disableTls, true); + + if ($check) { + // Only show warnings if we haven't output any errors + if ($ok) { + showWarnings($warnings); + showSecurityWarning($disableTls); + } + exit($ok ? 0 : 1); + } + + if ($ok || $force) { + if ($channel === '1' && !$quiet) { + out('Warning: You forced the install of Composer 1.x via --1, but Composer 2.x is the latest stable version. Updating to it via composer self-update --stable is recommended.', 'error'); + } + + $installer = new Installer($quiet, $disableTls, $cafile); + if ($installer->run($version, $installDir, $filename, $channel)) { + showWarnings($warnings); + showSecurityWarning($disableTls); + exit(0); + } + } + + exit(1); +} + +/** + * Displays the help + */ +function displayHelp() +{ + echo << $value) { + $next = $key + 1; + if (0 === strpos($value, $opt)) { + if ($optLength === strlen($value) && isset($argv[$next])) { + return trim($argv[$next]); + } else { + return trim(substr($value, $optLength + 1)); + } + } + } + + return $default; +} + +/** + * Checks that user-supplied params are valid + * + * @param mixed $installDir The required istallation directory + * @param mixed $version The required composer version to install + * @param mixed $cafile Certificate Authority file + * + * @return bool True if the supplied params are okay + */ +function checkParams($installDir, $version, $cafile) +{ + $result = true; + + if (false !== $installDir && !is_dir($installDir)) { + out("The defined install dir ({$installDir}) does not exist.", 'info'); + $result = false; + } + + if (false !== $version && 1 !== preg_match('/^\d+\.\d+\.\d+(\-(alpha|beta|RC)\d*)*$/', $version)) { + out("The defined install version ({$version}) does not match release pattern.", 'info'); + $result = false; + } + + if (false !== $cafile && (!file_exists($cafile) || !is_readable($cafile))) { + out("The defined Certificate Authority (CA) cert file ({$cafile}) does not exist or is not readable.", 'info'); + $result = false; + } + return $result; +} + +/** + * Checks the platform for possible issues running Composer + * + * Errors are written to the output, warnings are saved for later display. + * + * @param array $warnings Populated by method, to be shown later + * @param bool $quiet Quiet mode + * @param bool $disableTls Bypass tls + * @param bool $install If we are installing, rather than diagnosing + * + * @return bool True if there are no errors + */ +function checkPlatform(&$warnings, $quiet, $disableTls, $install) +{ + getPlatformIssues($errors, $warnings, $install); + + // Make openssl warning an error if tls has not been specifically disabled + if (isset($warnings['openssl']) && !$disableTls) { + $errors['openssl'] = $warnings['openssl']; + unset($warnings['openssl']); + } + + if (!empty($errors)) { + // Composer-Setup.exe uses "Some settings" to flag platform errors + out('Some settings on your machine make Composer unable to work properly.', 'error'); + out('Make sure that you fix the issues listed below and run this script again:', 'error'); + outputIssues($errors); + return false; + } + + if (empty($warnings) && !$quiet) { + out('All settings correct for using Composer', 'success'); + } + return true; +} + +/** + * Checks platform configuration for common incompatibility issues + * + * @param array $errors Populated by method + * @param array $warnings Populated by method + * @param bool $install If we are installing, rather than diagnosing + * + * @return bool If any errors or warnings have been found + */ +function getPlatformIssues(&$errors, &$warnings, $install) +{ + $errors = array(); + $warnings = array(); + + $iniMessage = PHP_EOL.getIniMessage(); + $iniMessage .= PHP_EOL.'If you can not modify the ini file, you can also run `php -d option=value` to modify ini values on the fly. You can use -d multiple times.'; + + if (ini_get('detect_unicode')) { + $errors['unicode'] = array( + 'The detect_unicode setting must be disabled.', + 'Add the following to the end of your `php.ini`:', + ' detect_unicode = Off', + $iniMessage + ); + } + + if (extension_loaded('suhosin')) { + $suhosin = ini_get('suhosin.executor.include.whitelist'); + $suhosinBlacklist = ini_get('suhosin.executor.include.blacklist'); + if (false === stripos($suhosin, 'phar') && (!$suhosinBlacklist || false !== stripos($suhosinBlacklist, 'phar'))) { + $errors['suhosin'] = array( + 'The suhosin.executor.include.whitelist setting is incorrect.', + 'Add the following to the end of your `php.ini` or suhosin.ini (Example path [for Debian]: /etc/php5/cli/conf.d/suhosin.ini):', + ' suhosin.executor.include.whitelist = phar '.$suhosin, + $iniMessage + ); + } + } + + if (!function_exists('json_decode')) { + $errors['json'] = array( + 'The json extension is missing.', + 'Install it or recompile php without --disable-json' + ); + } + + if (!extension_loaded('Phar')) { + $errors['phar'] = array( + 'The phar extension is missing.', + 'Install it or recompile php without --disable-phar' + ); + } + + if (!extension_loaded('filter')) { + $errors['filter'] = array( + 'The filter extension is missing.', + 'Install it or recompile php without --disable-filter' + ); + } + + if (!extension_loaded('hash')) { + $errors['hash'] = array( + 'The hash extension is missing.', + 'Install it or recompile php without --disable-hash' + ); + } + + if (!extension_loaded('iconv') && !extension_loaded('mbstring')) { + $errors['iconv_mbstring'] = array( + 'The iconv OR mbstring extension is required and both are missing.', + 'Install either of them or recompile php without --disable-iconv' + ); + } + + if (!ini_get('allow_url_fopen')) { + $errors['allow_url_fopen'] = array( + 'The allow_url_fopen setting is incorrect.', + 'Add the following to the end of your `php.ini`:', + ' allow_url_fopen = On', + $iniMessage + ); + } + + if (extension_loaded('ionCube Loader') && ioncube_loader_iversion() < 40009) { + $ioncube = ioncube_loader_version(); + $errors['ioncube'] = array( + 'Your ionCube Loader extension ('.$ioncube.') is incompatible with Phar files.', + 'Upgrade to ionCube 4.0.9 or higher or remove this line (path may be different) from your `php.ini` to disable it:', + ' zend_extension = /usr/lib/php5/20090626+lfs/ioncube_loader_lin_5.3.so', + $iniMessage + ); + } + + if (version_compare(PHP_VERSION, '5.3.2', '<')) { + $errors['php'] = array( + 'Your PHP ('.PHP_VERSION.') is too old, you must upgrade to PHP 5.3.2 or higher.' + ); + } + + if (version_compare(PHP_VERSION, '5.3.4', '<')) { + $warnings['php'] = array( + 'Your PHP ('.PHP_VERSION.') is quite old, upgrading to PHP 5.3.4 or higher is recommended.', + 'Composer works with 5.3.2+ for most people, but there might be edge case issues.' + ); + } + + if (!extension_loaded('openssl')) { + $warnings['openssl'] = array( + 'The openssl extension is missing, which means that secure HTTPS transfers are impossible.', + 'If possible you should enable it or recompile php with --with-openssl' + ); + } + + if (extension_loaded('openssl') && OPENSSL_VERSION_NUMBER < 0x1000100f) { + // Attempt to parse version number out, fallback to whole string value. + $opensslVersion = trim(strstr(OPENSSL_VERSION_TEXT, ' ')); + $opensslVersion = substr($opensslVersion, 0, strpos($opensslVersion, ' ')); + $opensslVersion = $opensslVersion ? $opensslVersion : OPENSSL_VERSION_TEXT; + + $warnings['openssl_version'] = array( + 'The OpenSSL library ('.$opensslVersion.') used by PHP does not support TLSv1.2 or TLSv1.1.', + 'If possible you should upgrade OpenSSL to version 1.0.1 or above.' + ); + } + + if (!defined('HHVM_VERSION') && !extension_loaded('apcu') && ini_get('apc.enable_cli')) { + $warnings['apc_cli'] = array( + 'The apc.enable_cli setting is incorrect.', + 'Add the following to the end of your `php.ini`:', + ' apc.enable_cli = Off', + $iniMessage + ); + } + + if (!$install && extension_loaded('xdebug')) { + $warnings['xdebug_loaded'] = array( + 'The xdebug extension is loaded, this can slow down Composer a little.', + 'Disabling it when using Composer is recommended.' + ); + + if (ini_get('xdebug.profiler_enabled')) { + $warnings['xdebug_profile'] = array( + 'The xdebug.profiler_enabled setting is enabled, this can slow down Composer a lot.', + 'Add the following to the end of your `php.ini` to disable it:', + ' xdebug.profiler_enabled = 0', + $iniMessage + ); + } + } + + if (!extension_loaded('zlib')) { + $warnings['zlib'] = array( + 'The zlib extension is not loaded, this can slow down Composer a lot.', + 'If possible, install it or recompile php with --with-zlib', + $iniMessage + ); + } + + if (defined('PHP_WINDOWS_VERSION_BUILD') + && (version_compare(PHP_VERSION, '7.2.23', '<') + || (version_compare(PHP_VERSION, '7.3.0', '>=') + && version_compare(PHP_VERSION, '7.3.10', '<')))) { + $warnings['onedrive'] = array( + 'The Windows OneDrive folder is not supported on PHP versions below 7.2.23 and 7.3.10.', + 'Upgrade your PHP ('.PHP_VERSION.') to use this location with Composer.' + ); + } + + if (extension_loaded('uopz') && !(ini_get('uopz.disable') || ini_get('uopz.exit'))) { + $warnings['uopz'] = array( + 'The uopz extension ignores exit calls and may not work with all Composer commands.', + 'Disabling it when using Composer is recommended.' + ); + } + + ob_start(); + phpinfo(INFO_GENERAL); + $phpinfo = (string) ob_get_clean(); + if (preg_match('{Configure Command(?: *| *=> *)(.*?)(?:|$)}m', $phpinfo, $match)) { + $configure = $match[1]; + + if (false !== strpos($configure, '--enable-sigchild')) { + $warnings['sigchild'] = array( + 'PHP was compiled with --enable-sigchild which can cause issues on some platforms.', + 'Recompile it without this flag if possible, see also:', + ' https://bugs.php.net/bug.php?id=22999' + ); + } + + if (false !== strpos($configure, '--with-curlwrappers')) { + $warnings['curlwrappers'] = array( + 'PHP was compiled with --with-curlwrappers which will cause issues with HTTP authentication and GitHub.', + 'Recompile it without this flag if possible' + ); + } + } + + // Stringify the message arrays + foreach ($errors as $key => $value) { + $errors[$key] = PHP_EOL.implode(PHP_EOL, $value); + } + + foreach ($warnings as $key => $value) { + $warnings[$key] = PHP_EOL.implode(PHP_EOL, $value); + } + + return !empty($errors) || !empty($warnings); +} + + +/** + * Outputs an array of issues + * + * @param array $issues + */ +function outputIssues($issues) +{ + foreach ($issues as $issue) { + out($issue, 'info'); + } + out(''); +} + +/** + * Outputs any warnings found + * + * @param array $warnings + */ +function showWarnings($warnings) +{ + if (!empty($warnings)) { + out('Some settings on your machine may cause stability issues with Composer.', 'error'); + out('If you encounter issues, try to change the following:', 'error'); + outputIssues($warnings); + } +} + +/** + * Outputs an end of process warning if tls has been bypassed + * + * @param bool $disableTls Bypass tls + */ +function showSecurityWarning($disableTls) +{ + if ($disableTls) { + out('You have instructed the Installer not to enforce SSL/TLS security on remote HTTPS requests.', 'info'); + out('This will leave all downloads during installation vulnerable to Man-In-The-Middle (MITM) attacks', 'info'); + } +} + +/** + * colorize output + */ +function out($text, $color = null, $newLine = true) +{ + $styles = array( + 'success' => "\033[0;32m%s\033[0m", + 'error' => "\033[31;31m%s\033[0m", + 'info' => "\033[33;33m%s\033[0m" + ); + + $format = '%s'; + + if (is_string($color) && isset($styles[$color]) && USE_ANSI) { + $format = $styles[$color]; + } + + if ($newLine) { + $format .= PHP_EOL; + } + + printf($format, $text); +} + +/** + * Returns the system-dependent Composer home location, which may not exist + * + * @return string + */ +function getHomeDir() +{ + $home = getenv('COMPOSER_HOME'); + if ($home) { + return $home; + } + + $userDir = getUserDir(); + + if (defined('PHP_WINDOWS_VERSION_MAJOR')) { + return $userDir.'/Composer'; + } + + $dirs = array(); + + if (useXdg()) { + // XDG Base Directory Specifications + $xdgConfig = getenv('XDG_CONFIG_HOME'); + if (!$xdgConfig) { + $xdgConfig = $userDir . '/.config'; + } + + $dirs[] = $xdgConfig . '/composer'; + } + + $dirs[] = $userDir . '/.composer'; + + // select first dir which exists of: $XDG_CONFIG_HOME/composer or ~/.composer + foreach ($dirs as $dir) { + if (is_dir($dir)) { + return $dir; + } + } + + // if none exists, we default to first defined one (XDG one if system uses it, or ~/.composer otherwise) + return $dirs[0]; +} + +/** + * Returns the location of the user directory from the environment + * @throws RuntimeException If the environment value does not exists + * + * @return string + */ +function getUserDir() +{ + $userEnv = defined('PHP_WINDOWS_VERSION_MAJOR') ? 'APPDATA' : 'HOME'; + $userDir = getenv($userEnv); + + if (!$userDir) { + throw new RuntimeException('The '.$userEnv.' or COMPOSER_HOME environment variable must be set for composer to run correctly'); + } + + return rtrim(strtr($userDir, '\\', '/'), '/'); +} + +/** + * @return bool + */ +function useXdg() +{ + foreach (array_keys($_SERVER) as $key) { + if (strpos((string) $key, 'XDG_') === 0) { + return true; + } + } + + if (is_dir('/etc/xdg')) { + return true; + } + + return false; +} + +function validateCaFile($contents) +{ + // assume the CA is valid if php is vulnerable to + // https://www.sektioneins.de/advisories/advisory-012013-php-openssl_x509_parse-memory-corruption-vulnerability.html + if ( + PHP_VERSION_ID <= 50327 + || (PHP_VERSION_ID >= 50400 && PHP_VERSION_ID < 50422) + || (PHP_VERSION_ID >= 50500 && PHP_VERSION_ID < 50506) + ) { + return !empty($contents); + } + + return (bool) openssl_x509_parse($contents); +} + +/** + * Returns php.ini location information + * + * @return string + */ +function getIniMessage() +{ + $paths = array((string) php_ini_loaded_file()); + $scanned = php_ini_scanned_files(); + + if ($scanned !== false) { + $paths = array_merge($paths, array_map('trim', explode(',', $scanned))); + } + + // We will have at least one value, which may be empty + if ($paths[0] === '') { + array_shift($paths); + } + + $ini = array_shift($paths); + + if ($ini === null) { + return 'A php.ini file does not exist. You will have to create one.'; + } + + if (count($paths) > 1) { + return 'Your command-line PHP is using multiple ini files. Run `php --ini` to show them.'; + } + + return 'The php.ini used by your command-line PHP is: '.$ini; +} + +class Installer +{ + private $quiet; + private $disableTls; + private $cafile; + private $displayPath; + private $target; + private $tmpFile; + private $tmpCafile; + private $baseUrl; + private $algo; + private $errHandler; + private $httpClient; + private $pubKeys = array(); + private $installs = array(); + + /** + * Constructor - must not do anything that throws an exception + * + * @param bool $quiet Quiet mode + * @param bool $disableTls Bypass tls + * @param mixed $cafile Path to CA bundle, or false + */ + public function __construct($quiet, $disableTls, $caFile) + { + if (($this->quiet = $quiet)) { + ob_start(); + } + $this->disableTls = $disableTls; + $this->cafile = $caFile; + $this->errHandler = new ErrorHandler(); + } + + /** + * Runs the installer + * + * @param mixed $version Specific version to install, or false + * @param mixed $installDir Specific installation directory, or false + * @param string $filename Specific filename to save to, or composer.phar + * @param string $channel Specific version channel to use + * @throws Exception If anything other than a RuntimeException is caught + * + * @return bool If the installation succeeded + */ + public function run($version, $installDir, $filename, $channel) + { + try { + $this->initTargets($installDir, $filename); + $this->initTls(); + $this->httpClient = new HttpClient($this->disableTls, $this->cafile); + $result = $this->install($version, $channel); + + // in case --1 or --2 is passed, we leave the default channel for next self-update to stable + if (1 === preg_match('{^\d+$}D', $channel)) { + $channel = 'stable'; + } + + if ($result && $channel !== 'stable' && !$version && defined('PHP_BINARY')) { + $null = (defined('PHP_WINDOWS_VERSION_MAJOR') ? 'NUL' : '/dev/null'); + @exec(escapeshellarg(PHP_BINARY) .' '.escapeshellarg($this->target).' self-update --'.$channel.' --set-channel-only -q > '.$null.' 2> '.$null, $output); + } + } catch (Exception $e) { + $result = false; + } + + // Always clean up + $this->cleanUp($result); + + if (isset($e)) { + // Rethrow anything that is not a RuntimeException + if (!$e instanceof RuntimeException) { + throw $e; + } + out($e->getMessage(), 'error'); + } + return $result; + } + + /** + * Initialization methods to set the required filenames and composer url + * + * @param mixed $installDir Specific installation directory, or false + * @param string $filename Specific filename to save to, or composer.phar + * @throws RuntimeException If the installation directory is not writable + */ + protected function initTargets($installDir, $filename) + { + $this->displayPath = ($installDir ? rtrim($installDir, '/').'/' : '').$filename; + $installDir = $installDir ? realpath($installDir) : getcwd(); + + if (!is_writeable($installDir)) { + throw new RuntimeException('The installation directory "'.$installDir.'" is not writable'); + } + + $this->target = $installDir.DIRECTORY_SEPARATOR.$filename; + $this->tmpFile = $installDir.DIRECTORY_SEPARATOR.basename($this->target, '.phar').'-temp.phar'; + + $uriScheme = $this->disableTls ? 'http' : 'https'; + $this->baseUrl = $uriScheme.'://getcomposer.org'; + } + + /** + * A wrapper around methods to check tls and write public keys + * @throws RuntimeException If SHA384 is not supported + */ + protected function initTls() + { + if ($this->disableTls) { + return; + } + + if (!in_array('sha384', array_map('strtolower', openssl_get_md_methods()))) { + throw new RuntimeException('SHA384 is not supported by your openssl extension'); + } + + $this->algo = defined('OPENSSL_ALGO_SHA384') ? OPENSSL_ALGO_SHA384 : 'SHA384'; + $home = $this->getComposerHome(); + + $this->pubKeys = array( + 'dev' => $this->installKey(self::getPKDev(), $home, 'keys.dev.pub'), + 'tags' => $this->installKey(self::getPKTags(), $home, 'keys.tags.pub') + ); + + if (empty($this->cafile) && !HttpClient::getSystemCaRootBundlePath()) { + $this->cafile = $this->tmpCafile = $this->installKey(HttpClient::getPackagedCaFile(), $home, 'cacert-temp.pem'); + } + } + + /** + * Returns the Composer home directory, creating it if required + * @throws RuntimeException If the directory cannot be created + * + * @return string + */ + protected function getComposerHome() + { + $home = getHomeDir(); + + if (!is_dir($home)) { + $this->errHandler->start(); + + if (!mkdir($home, 0777, true)) { + throw new RuntimeException(sprintf( + 'Unable to create Composer home directory "%s": %s', + $home, + $this->errHandler->message + )); + } + $this->installs[] = $home; + $this->errHandler->stop(); + } + return $home; + } + + /** + * Writes public key data to disc + * + * @param string $data The public key(s) in pem format + * @param string $path The directory to write to + * @param string $filename The name of the file + * @throws RuntimeException If the file cannot be written + * + * @return string The path to the saved data + */ + protected function installKey($data, $path, $filename) + { + $this->errHandler->start(); + + $target = $path.DIRECTORY_SEPARATOR.$filename; + $installed = file_exists($target); + $write = file_put_contents($target, $data, LOCK_EX); + @chmod($target, 0644); + + $this->errHandler->stop(); + + if (!$write) { + throw new RuntimeException(sprintf('Unable to write %s to: %s', $filename, $path)); + } + + if (!$installed) { + $this->installs[] = $target; + } + + return $target; + } + + /** + * The main install function + * + * @param mixed $version Specific version to install, or false + * @param string $channel Version channel to use + * + * @return bool If the installation succeeded + */ + protected function install($version, $channel) + { + $retries = 3; + $result = false; + $infoMsg = 'Downloading...'; + $infoType = 'info'; + + while ($retries--) { + if (!$this->quiet) { + out($infoMsg, $infoType); + $infoMsg = 'Retrying...'; + $infoType = 'error'; + } + + if (!$this->getVersion($channel, $version, $url, $error)) { + out($error, 'error'); + continue; + } + + if (!$this->downloadToTmp($url, $signature, $error)) { + out($error, 'error'); + continue; + } + + if (!$this->verifyAndSave($version, $signature, $error)) { + out($error, 'error'); + continue; + } + + $result = true; + break; + } + + if (!$this->quiet) { + if ($result) { + out(PHP_EOL."Composer (version {$version}) successfully installed to: {$this->target}", 'success'); + out("Use it: php {$this->displayPath}", 'info'); + out(''); + } else { + out('The download failed repeatedly, aborting.', 'error'); + } + } + return $result; + } + + /** + * Sets the version url, downloading version data if required + * + * @param string $channel Version channel to use + * @param false|string $version Version to install, or set by method + * @param null|string $url The versioned url, set by method + * @param null|string $error Set by method on failure + * + * @return bool If the operation succeeded + */ + protected function getVersion($channel, &$version, &$url, &$error) + { + $error = ''; + + if ($version) { + if (empty($url)) { + $url = $this->baseUrl."/download/{$version}/composer.phar"; + } + return true; + } + + $this->errHandler->start(); + + if ($this->downloadVersionData($data, $error)) { + $this->parseVersionData($data, $channel, $version, $url); + } + + $this->errHandler->stop(); + return empty($error); + } + + /** + * Downloads and json-decodes version data + * + * @param null|array $data Downloaded version data, set by method + * @param null|string $error Set by method on failure + * + * @return bool If the operation succeeded + */ + protected function downloadVersionData(&$data, &$error) + { + $url = $this->baseUrl.'/versions'; + $errFmt = 'The "%s" file could not be %s: %s'; + + if (!$json = $this->httpClient->get($url)) { + $error = sprintf($errFmt, $url, 'downloaded', $this->errHandler->message); + return false; + } + + if (!$data = json_decode($json, true)) { + $error = sprintf($errFmt, $url, 'json-decoded', $this->getJsonError()); + return false; + } + return true; + } + + /** + * A wrapper around the methods needed to download and save the phar + * + * @param string $url The versioned download url + * @param null|string $signature Set by method on successful download + * @param null|string $error Set by method on failure + * + * @return bool If the operation succeeded + */ + protected function downloadToTmp($url, &$signature, &$error) + { + $error = ''; + $errFmt = 'The "%s" file could not be downloaded: %s'; + $sigUrl = $url.'.sig'; + $this->errHandler->start(); + + if (!$fh = fopen($this->tmpFile, 'w')) { + $error = sprintf('Could not create file "%s": %s', $this->tmpFile, $this->errHandler->message); + + } elseif (!$this->getSignature($sigUrl, $signature)) { + $error = sprintf($errFmt, $sigUrl, $this->errHandler->message); + + } elseif (!fwrite($fh, $this->httpClient->get($url))) { + $error = sprintf($errFmt, $url, $this->errHandler->message); + } + + if (is_resource($fh)) { + fclose($fh); + } + $this->errHandler->stop(); + return empty($error); + } + + /** + * Verifies the downloaded file and saves it to the target location + * + * @param string $version The composer version downloaded + * @param string $signature The digital signature to check + * @param null|string $error Set by method on failure + * + * @return bool If the operation succeeded + */ + protected function verifyAndSave($version, $signature, &$error) + { + $error = ''; + + if (!$this->validatePhar($this->tmpFile, $pharError)) { + $error = 'The download is corrupt: '.$pharError; + + } elseif (!$this->verifySignature($version, $signature, $this->tmpFile)) { + $error = 'Signature mismatch, could not verify the phar file integrity'; + + } else { + $this->errHandler->start(); + + if (!rename($this->tmpFile, $this->target)) { + $error = sprintf('Could not write to file "%s": %s', $this->target, $this->errHandler->message); + } + chmod($this->target, 0755); + $this->errHandler->stop(); + } + + return empty($error); + } + + /** + * Parses an array of version data to match the required channel + * + * @param array $data Downloaded version data + * @param mixed $channel Version channel to use + * @param false|string $version Set by method + * @param mixed $url The versioned url, set by method + */ + protected function parseVersionData(array $data, $channel, &$version, &$url) + { + foreach ($data[$channel] as $candidate) { + if ($candidate['min-php'] <= PHP_VERSION_ID) { + $version = $candidate['version']; + $url = $this->baseUrl.$candidate['path']; + break; + } + } + + if (!$version) { + $error = sprintf( + 'None of the %d %s version(s) of Composer matches your PHP version (%s / ID: %d)', + count($data[$channel]), + $channel, + PHP_VERSION, + PHP_VERSION_ID + ); + throw new RuntimeException($error); + } + } + + /** + * Downloads the digital signature of required phar file + * + * @param string $url The signature url + * @param null|string $signature Set by method on success + * + * @return bool If the download succeeded + */ + protected function getSignature($url, &$signature) + { + if (!$result = $this->disableTls) { + $signature = $this->httpClient->get($url); + + if ($signature) { + $signature = json_decode($signature, true); + $signature = base64_decode($signature['sha384']); + $result = true; + } + } + + return $result; + } + + /** + * Verifies the signature of the downloaded phar + * + * @param string $version The composer versione + * @param string $signature The downloaded digital signature + * @param string $file The temp phar file + * + * @return bool If the operation succeeded + */ + protected function verifySignature($version, $signature, $file) + { + if (!$result = $this->disableTls) { + $path = preg_match('{^[0-9a-f]{40}$}', $version) ? $this->pubKeys['dev'] : $this->pubKeys['tags']; + $pubkeyid = openssl_pkey_get_public('file://'.$path); + + $result = 1 === openssl_verify( + file_get_contents($file), + $signature, + $pubkeyid, + $this->algo + ); + + // PHP 8 automatically frees the key instance and deprecates the function + if (PHP_VERSION_ID < 80000) { + openssl_free_key($pubkeyid); + } + } + + return $result; + } + + /** + * Validates the downloaded phar file + * + * @param string $pharFile The temp phar file + * @param null|string $error Set by method on failure + * + * @return bool If the operation succeeded + */ + protected function validatePhar($pharFile, &$error) + { + if (ini_get('phar.readonly')) { + return true; + } + + try { + // Test the phar validity + $phar = new Phar($pharFile); + // Free the variable to unlock the file + unset($phar); + $result = true; + + } catch (Exception $e) { + if (!$e instanceof UnexpectedValueException && !$e instanceof PharException) { + throw $e; + } + $error = $e->getMessage(); + $result = false; + } + return $result; + } + + /** + * Returns a string representation of the last json error + * + * @return string The error string or code + */ + protected function getJsonError() + { + if (function_exists('json_last_error_msg')) { + return json_last_error_msg(); + } else { + return 'json_last_error = '.json_last_error(); + } + } + + /** + * Cleans up resources at the end of the installation + * + * @param bool $result If the installation succeeded + */ + protected function cleanUp($result) + { + if ($this->quiet) { + // Ensure output buffers are emptied + $errors = explode(PHP_EOL, (string) ob_get_clean()); + } + + if (!$result) { + // Output buffered errors + if ($this->quiet) { + $this->outputErrors($errors); + } + // Clean up stuff we created + $this->uninstall(); + } elseif ($this->tmpCafile !== null) { + @unlink($this->tmpCafile); + } + } + + /** + * Outputs unique errors when in quiet mode + * + */ + protected function outputErrors(array $errors) + { + $shown = array(); + + foreach ($errors as $error) { + if ($error && !in_array($error, $shown)) { + out($error, 'error'); + $shown[] = $error; + } + } + } + + /** + * Uninstalls newly-created files and directories on failure + * + */ + protected function uninstall() + { + foreach (array_reverse($this->installs) as $target) { + if (is_file($target)) { + @unlink($target); + } elseif (is_dir($target)) { + @rmdir($target); + } + } + + if ($this->tmpFile !== null && file_exists($this->tmpFile)) { + @unlink($this->tmpFile); + } + } + + public static function getPKDev() + { + return <<message) { + $this->message .= PHP_EOL; + } + $this->message .= preg_replace('{^file_get_contents\(.*?\): }', '', $msg); + } + + /** + * Starts error-handling if not already active + * + * Any message is cleared + */ + public function start() + { + if (!$this->active) { + set_error_handler(array($this, 'handleError')); + $this->active = true; + } + $this->message = ''; + } + + /** + * Stops error-handling if active + * + * Any message is preserved until the next call to start() + */ + public function stop() + { + if ($this->active) { + restore_error_handler(); + $this->active = false; + } + } +} + +class NoProxyPattern +{ + private $composerInNoProxy = false; + private $rulePorts = array(); + + public function __construct($pattern) + { + $rules = preg_split('{[\s,]+}', $pattern, null, PREG_SPLIT_NO_EMPTY); + + if ($matches = preg_grep('{getcomposer\.org(?::\d+)?}i', $rules)) { + $this->composerInNoProxy = true; + + foreach ($matches as $match) { + if (strpos($match, ':') !== false) { + list(, $port) = explode(':', $match); + $this->rulePorts[] = (int) $port; + } + } + } + } + + /** + * Returns true if NO_PROXY contains getcomposer.org + * + * @param string $url http(s)://getcomposer.org + * + * @return bool + */ + public function test($url) + { + if (!$this->composerInNoProxy) { + return false; + } + + if (empty($this->rulePorts)) { + return true; + } + + if (strpos($url, 'http://') === 0) { + $port = 80; + } else { + $port = 443; + } + + return in_array($port, $this->rulePorts); + } +} + +class HttpClient { + + /** @var null|string */ + private static $caPath; + + private $options = array('http' => array()); + private $disableTls = false; + + public function __construct($disableTls = false, $cafile = false) + { + $this->disableTls = $disableTls; + if ($this->disableTls === false) { + if (!empty($cafile) && !is_dir($cafile)) { + if (!is_readable($cafile) || !validateCaFile(file_get_contents($cafile))) { + throw new RuntimeException('The configured cafile (' .$cafile. ') was not valid or could not be read.'); + } + } + $options = $this->getTlsStreamContextDefaults($cafile); + $this->options = array_replace_recursive($this->options, $options); + } + } + + public function get($url) + { + if (function_exists('http_clear_last_response_headers')) { + $http_response_header = http_clear_last_response_headers(); + } + + $context = $this->getStreamContext($url); + $result = file_get_contents($url, false, $context); + + if ($result && extension_loaded('zlib')) { + if (function_exists('http_get_last_response_headers')) { + $http_response_header = http_get_last_response_headers(); + } + $headers = $http_response_header; + $decode = false; + foreach ($headers as $header) { + if (preg_match('{^content-encoding: *gzip *$}i', $header)) { + $decode = true; + continue; + } elseif (preg_match('{^HTTP/}i', $header)) { + $decode = false; + } + } + + if ($decode) { + if (version_compare(PHP_VERSION, '5.4.0', '>=')) { + $result = zlib_decode($result); + } else { + // work around issue with gzuncompress & co that do not work with all gzip checksums + $result = file_get_contents('compress.zlib://data:application/octet-stream;base64,'.base64_encode($result)); + } + + if (!$result) { + throw new RuntimeException('Failed to decode zlib stream'); + } + } + } + + return $result; + } + + protected function getStreamContext($url) + { + if ($this->disableTls === false) { + if (PHP_VERSION_ID < 50600) { + $this->options['ssl']['SNI_server_name'] = parse_url($url, PHP_URL_HOST); + } + } + // Keeping the above mostly isolated from the code copied from Composer. + return $this->getMergedStreamContext($url); + } + + protected function getTlsStreamContextDefaults($cafile) + { + $ciphers = implode(':', array( + 'ECDHE-RSA-AES128-GCM-SHA256', + 'ECDHE-ECDSA-AES128-GCM-SHA256', + 'ECDHE-RSA-AES256-GCM-SHA384', + 'ECDHE-ECDSA-AES256-GCM-SHA384', + 'DHE-RSA-AES128-GCM-SHA256', + 'DHE-DSS-AES128-GCM-SHA256', + 'kEDH+AESGCM', + 'ECDHE-RSA-AES128-SHA256', + 'ECDHE-ECDSA-AES128-SHA256', + 'ECDHE-RSA-AES128-SHA', + 'ECDHE-ECDSA-AES128-SHA', + 'ECDHE-RSA-AES256-SHA384', + 'ECDHE-ECDSA-AES256-SHA384', + 'ECDHE-RSA-AES256-SHA', + 'ECDHE-ECDSA-AES256-SHA', + 'DHE-RSA-AES128-SHA256', + 'DHE-RSA-AES128-SHA', + 'DHE-DSS-AES128-SHA256', + 'DHE-RSA-AES256-SHA256', + 'DHE-DSS-AES256-SHA', + 'DHE-RSA-AES256-SHA', + 'AES128-GCM-SHA256', + 'AES256-GCM-SHA384', + 'AES128-SHA256', + 'AES256-SHA256', + 'AES128-SHA', + 'AES256-SHA', + 'AES', + 'CAMELLIA', + 'DES-CBC3-SHA', + '!aNULL', + '!eNULL', + '!EXPORT', + '!DES', + '!RC4', + '!MD5', + '!PSK', + '!aECDH', + '!EDH-DSS-DES-CBC3-SHA', + '!EDH-RSA-DES-CBC3-SHA', + '!KRB5-DES-CBC3-SHA', + )); + + /** + * CN_match and SNI_server_name are only known once a URL is passed. + * They will be set in the getOptionsForUrl() method which receives a URL. + * + * cafile or capath can be overridden by passing in those options to constructor. + */ + $options = array( + 'ssl' => array( + 'ciphers' => $ciphers, + 'verify_peer' => true, + 'verify_depth' => 7, + 'SNI_enabled' => true, + ) + ); + + /** + * Attempt to find a local cafile or throw an exception. + * The user may go download one if this occurs. + */ + if (!$cafile) { + $cafile = self::getSystemCaRootBundlePath(); + } + if (is_dir($cafile)) { + $options['ssl']['capath'] = $cafile; + } elseif ($cafile) { + $options['ssl']['cafile'] = $cafile; + } else { + throw new RuntimeException('A valid cafile could not be located automatically.'); + } + + /** + * Disable TLS compression to prevent CRIME attacks where supported. + */ + if (version_compare(PHP_VERSION, '5.4.13') >= 0) { + $options['ssl']['disable_compression'] = true; + } + + return $options; + } + + /** + * function copied from Composer\Util\StreamContextFactory::initOptions + * + * Any changes should be applied there as well, or backported here. + * + * @param string $url URL the context is to be used for + * @return resource Default context + * @throws \RuntimeException if https proxy required and OpenSSL uninstalled + */ + protected function getMergedStreamContext($url) + { + $options = $this->options; + + // Handle HTTP_PROXY/http_proxy on CLI only for security reasons + if ((PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') && (!empty($_SERVER['HTTP_PROXY']) || !empty($_SERVER['http_proxy']))) { + $proxy = parse_url(!empty($_SERVER['http_proxy']) ? $_SERVER['http_proxy'] : $_SERVER['HTTP_PROXY']); + } + + // Prefer CGI_HTTP_PROXY if available + if (!empty($_SERVER['CGI_HTTP_PROXY'])) { + $proxy = parse_url($_SERVER['CGI_HTTP_PROXY']); + } + + // Override with HTTPS proxy if present and URL is https + if (preg_match('{^https://}i', $url) && (!empty($_SERVER['HTTPS_PROXY']) || !empty($_SERVER['https_proxy']))) { + $proxy = parse_url(!empty($_SERVER['https_proxy']) ? $_SERVER['https_proxy'] : $_SERVER['HTTPS_PROXY']); + } + + // Remove proxy if URL matches no_proxy directive + if (!empty($_SERVER['NO_PROXY']) || !empty($_SERVER['no_proxy']) && parse_url($url, PHP_URL_HOST)) { + $pattern = new NoProxyPattern(!empty($_SERVER['no_proxy']) ? $_SERVER['no_proxy'] : $_SERVER['NO_PROXY']); + if ($pattern->test($url)) { + unset($proxy); + } + } + + if (!empty($proxy)) { + $proxyURL = isset($proxy['scheme']) ? $proxy['scheme'] . '://' : ''; + $proxyURL .= isset($proxy['host']) ? $proxy['host'] : ''; + + if (isset($proxy['port'])) { + $proxyURL .= ":" . $proxy['port']; + } elseif (strpos($proxyURL, 'http://') === 0) { + $proxyURL .= ":80"; + } elseif (strpos($proxyURL, 'https://') === 0) { + $proxyURL .= ":443"; + } + + // check for a secure proxy + if (strpos($proxyURL, 'https://') === 0) { + if (!extension_loaded('openssl')) { + throw new RuntimeException('You must enable the openssl extension to use a secure proxy.'); + } + if (strpos($url, 'https://') === 0) { + throw new RuntimeException('PHP does not support https requests through a secure proxy.'); + } + } + + // http(s):// is not supported in proxy + $proxyURL = str_replace(array('http://', 'https://'), array('tcp://', 'ssl://'), $proxyURL); + + $options['http'] = array( + 'proxy' => $proxyURL, + ); + + // add request_fulluri for http requests + if ('http' === parse_url($url, PHP_URL_SCHEME)) { + $options['http']['request_fulluri'] = true; + } + + // handle proxy auth if present + if (isset($proxy['user'])) { + $auth = rawurldecode($proxy['user']); + if (isset($proxy['pass'])) { + $auth .= ':' . rawurldecode($proxy['pass']); + } + $auth = base64_encode($auth); + + $options['http']['header'] = "Proxy-Authorization: Basic {$auth}\r\n"; + } + } + + if (isset($options['http']['header'])) { + $options['http']['header'] .= "Connection: close\r\n"; + } else { + $options['http']['header'] = "Connection: close\r\n"; + } + if (extension_loaded('zlib')) { + $options['http']['header'] .= "Accept-Encoding: gzip\r\n"; + } + $options['http']['header'] .= "User-Agent: ".COMPOSER_INSTALLER."\r\n"; + $options['http']['protocol_version'] = 1.1; + $options['http']['timeout'] = 600; + + return stream_context_create($options); + } + + /** + * This method was adapted from Sslurp. + * https://github.com/EvanDotPro/Sslurp + * + * (c) Evan Coury + * + * For the full copyright and license information, please see below: + * + * Copyright (c) 2013, Evan Coury + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + public static function getSystemCaRootBundlePath() + { + if (self::$caPath !== null) { + return self::$caPath; + } + + // If SSL_CERT_FILE env variable points to a valid certificate/bundle, use that. + // This mimics how OpenSSL uses the SSL_CERT_FILE env variable. + $envCertFile = getenv('SSL_CERT_FILE'); + if ($envCertFile && is_readable($envCertFile) && validateCaFile(file_get_contents($envCertFile))) { + return self::$caPath = $envCertFile; + } + + // If SSL_CERT_DIR env variable points to a valid certificate/bundle, use that. + // This mimics how OpenSSL uses the SSL_CERT_FILE env variable. + $envCertDir = getenv('SSL_CERT_DIR'); + if ($envCertDir && is_dir($envCertDir) && is_readable($envCertDir)) { + return self::$caPath = $envCertDir; + } + + $configured = ini_get('openssl.cafile'); + if ($configured && strlen($configured) > 0 && is_readable($configured) && validateCaFile(file_get_contents($configured))) { + return self::$caPath = $configured; + } + + $configured = ini_get('openssl.capath'); + if ($configured && is_dir($configured) && is_readable($configured)) { + return self::$caPath = $configured; + } + + $caBundlePaths = array( + '/etc/pki/tls/certs/ca-bundle.crt', // Fedora, RHEL, CentOS (ca-certificates package) + '/etc/ssl/certs/ca-certificates.crt', // Debian, Ubuntu, Gentoo, Arch Linux (ca-certificates package) + '/etc/ssl/ca-bundle.pem', // SUSE, openSUSE (ca-certificates package) + '/usr/local/share/certs/ca-root-nss.crt', // FreeBSD (ca_root_nss_package) + '/usr/ssl/certs/ca-bundle.crt', // Cygwin + '/opt/local/share/curl/curl-ca-bundle.crt', // OS X macports, curl-ca-bundle package + '/usr/local/share/curl/curl-ca-bundle.crt', // Default cURL CA bunde path (without --with-ca-bundle option) + '/usr/share/ssl/certs/ca-bundle.crt', // Really old RedHat? + '/etc/ssl/cert.pem', // OpenBSD + '/usr/local/etc/ssl/cert.pem', // FreeBSD 10.x + '/usr/local/etc/openssl/cert.pem', // OS X homebrew, openssl package + '/usr/local/etc/openssl@1.1/cert.pem', // OS X homebrew, openssl@1.1 package + '/opt/homebrew/etc/openssl@3/cert.pem', // macOS silicon homebrew, openssl@3 package + '/opt/homebrew/etc/openssl@1.1/cert.pem', // macOS silicon homebrew, openssl@1.1 package + ); + + foreach ($caBundlePaths as $caBundle) { + if (@is_readable($caBundle) && validateCaFile(file_get_contents($caBundle))) { + return self::$caPath = $caBundle; + } + } + + foreach ($caBundlePaths as $caBundle) { + $caBundle = dirname($caBundle); + if (is_dir($caBundle) && glob($caBundle.'/*')) { + return self::$caPath = $caBundle; + } + } + + return self::$caPath = false; + } + + public static function getPackagedCaFile() + { + return <<findEntities($qb); } + /** + * Full-text search across members, addresses, and notes with all + * sub-entities in a single query. + * + * Searches: vorname, nachname, notizen, zusatz_notizen, and joined + * address fields. Returns members with nested addresses, phones, and + * emails. + * + * Part of Issue #33. + * + * @return array Flat member arrays with nested sub-entities + * @throws Exception + */ + public function fullTextSearchWithRelations(string $query, int $limit = 20): array { + $qb = $this->db->getQueryBuilder(); + $searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%'; + + $qb->select('m.*') + ->from($this->getTableName(), 'm'); + + $this->addJoinClauses($qb); + + $qb->where($qb->expr()->isNull('m.deleted_at')) + ->andWhere( + $qb->expr()->orX( + $qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)), + $qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern)), + $qb->expr()->iLike('m.notizen', $qb->createNamedParameter($searchPattern)), + $qb->expr()->iLike('m.zusatz_notizen', $qb->createNamedParameter($searchPattern)), + $qb->expr()->iLike('a.strasse', $qb->createNamedParameter($searchPattern)), + $qb->expr()->iLike('a.plz', $qb->createNamedParameter($searchPattern)), + $qb->expr()->iLike('a.ort', $qb->createNamedParameter($searchPattern)) + ) + ) + ->orderBy('m.nachname', 'ASC') + ->addOrderBy('m.vorname', 'ASC') + ->setMaxResults($limit); + + return $this->fetchWithRelations($qb); + } + /** * Find a member by exact Vorname, Nachname, and Geburtsdatum. * Used for duplicate detection during import. @@ -330,4 +371,348 @@ class MemberMapper extends QBMapper { return $count; } + + // ── Joined fetch methods (N+1 avoidance) ──────────────────────── + + /** + * Find all members with their addresses, phones, and emails in a + * single query using LEFT JOINs. + * + * Returns an array of associative arrays, each representing one + * member with nested 'addresses', 'phones', and 'emails' arrays. + * + * This replaces the N+1 pattern (1 query for members + 3 queries + * per member for sub-entities) with a single query regardless of + * the number of members. + * + * @param int|null $limit Pagination limit + * @param int|null $offset Pagination offset + * @return array Flat member arrays with nested sub-entities + * @throws Exception + */ + public function findAllWithRelations(?int $limit = null, ?int $offset = null): array { + return $this->fetchWithRelations( + $this->buildBaseQuery(false, $limit, $offset) + ); + } + + /** + * Find members by family ID with all sub-entities in a single query. + * + * @param int $familyId Family ID + * @return array Flat member arrays with nested sub-entities + * @throws Exception + */ + public function findByFamilyWithRelations(int $familyId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName(), 'm'); + + $this->addJoinClauses($qb); + + $qb->where($qb->expr()->eq('m.family_id', $qb->createNamedParameter($familyId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNull('m.deleted_at')) + ->orderBy('m.nachname', 'ASC') + ->addOrderBy('m.vorname', 'ASC'); + + return $this->fetchWithRelations($qb); + } + + /** + * Find members by status with all sub-entities in a single query. + * + * @param string $status Status to filter by + * @return array Flat member arrays with nested sub-entities + * @throws Exception + */ + public function findByStatusWithRelations(string $status): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName(), 'm'); + + $this->addJoinClauses($qb); + + $qb->where($qb->expr()->eq('m.status', $qb->createNamedParameter($status))) + ->orderBy('m.nachname', 'ASC') + ->addOrderBy('m.vorname', 'ASC'); + + return $this->fetchWithRelations($qb); + } + + /** + * Search members by name with all sub-entities in a single query. + * + * @param string $query Search query (searches Vorname and Nachname) + * @return array Flat member arrays with nested sub-entities + * @throws Exception + */ + public function searchWithRelations(string $query): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName(), 'm'); + + $this->addJoinClauses($qb); + + $searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%'; + $qb->where($qb->expr()->isNull('m.deleted_at')) + ->andWhere( + $qb->expr()->orX( + $qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)), + $qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern)) + ) + ) + ->orderBy('m.nachname', 'ASC') + ->addOrderBy('m.vorname', 'ASC'); + + return $this->fetchWithRelations($qb); + } + + /** + * Find members with a birthday in the given month with all + * sub-entities in a single query. + * + * @param int $month Month (1-12) + * @return array Flat member arrays with nested sub-entities + * @throws Exception + */ + public function findByBirthdayMonthWithRelations(int $month): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName(), 'm'); + + $this->addJoinClauses($qb); + + $monthExpr = PlatformHelper::getMonthExpression($this->db, 'm.geburtsdatum'); + $qb->where($qb->expr()->isNull('m.deleted_at')) + ->andWhere( + $qb->expr()->eq( + $qb->createFunction($monthExpr), + $qb->createNamedParameter($month, IQueryBuilder::PARAM_INT) + ) + ) + ->orderBy('m.geburtsdatum', 'ASC'); + + return $this->fetchWithRelations($qb); + } + + /** + * Find members who have unpaid fee records for a given year with + * all sub-entities in a single query. + * + * @param int $year Year to filter by + * @return array Flat member arrays with nested sub-entities + * @throws Exception + */ + public function findWithUnpaidFeesWithRelations(int $year): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('m.*') + ->from($this->getTableName(), 'm') + ->innerJoin('m', 'mv_fee_records', 'f', $qb->expr()->eq('m.id', 'f.member_id')); + + $this->addJoinClauses($qb); + + $qb->where($qb->expr()->isNull('m.deleted_at')) + ->andWhere($qb->expr()->eq('f.year', $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('f.paid', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->orderBy('m.nachname', 'ASC') + ->addOrderBy('m.vorname', 'ASC'); + + return $this->fetchWithRelations($qb); + } + + /** + * Find members matching a combination of filters with all + * sub-entities in a single query. + * + * @param string|null $status Filter by status (aktiv/inaktiv) + * @param string|null $rolle Filter by rolle (mitglied/erziehungsberechtigter) + * @param bool $birthdayThisMonth Only members with birthday in current month + * @param bool $unpaidFees Only members with unpaid fee records for current year + * @return array Flat member arrays with nested sub-entities + * @throws Exception + */ + public function findFilteredWithRelations( + ?string $status = null, + ?string $rolle = null, + bool $birthdayThisMonth = false, + bool $unpaidFees = false + ): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('m.*') + ->from($this->getTableName(), 'm'); + + $this->addJoinClauses($qb); + + $qb->where($qb->expr()->isNull('m.deleted_at')); + + if ($unpaidFees) { + $year = (int)(new \DateTime())->format('Y'); + $qb->innerJoin('m', 'mv_fee_records', 'f', $qb->expr()->eq('m.id', 'f.member_id')) + ->andWhere($qb->expr()->eq('f.year', $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('f.paid', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))); + } + + if ($status !== null) { + $qb->andWhere($qb->expr()->eq('m.status', $qb->createNamedParameter($status))); + } + + if ($rolle !== null) { + $qb->andWhere($qb->expr()->eq('m.rolle', $qb->createNamedParameter($rolle))); + } + + if ($birthdayThisMonth) { + $currentMonth = (int)(new \DateTime())->format('m'); + $monthExpr = PlatformHelper::getMonthExpression($this->db, 'm.geburtsdatum'); + $qb->andWhere( + $qb->expr()->eq( + $qb->createFunction($monthExpr), + $qb->createNamedParameter($currentMonth, IQueryBuilder::PARAM_INT) + ) + ); + } + + $qb->orderBy('m.nachname', 'ASC') + ->addOrderBy('m.vorname', 'ASC'); + + return $this->fetchWithRelations($qb); + } + + /** + * Build the base query for findAllWithRelations. + */ + private function buildBaseQuery( + bool $includeDeleted, + ?int $limit, + ?int $offset + ): \OCP\DB\QueryBuilder\IQueryBuilder { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName(), 'm'); + + $this->addJoinClauses($qb); + + if (!$includeDeleted) { + $qb->where($qb->expr()->isNull('m.deleted_at')); + } + + $qb->orderBy('m.nachname', 'ASC') + ->addOrderBy('m.vorname', 'ASC'); + + if ($limit !== null) { + $qb->setMaxResults($limit); + } + if ($offset !== null) { + $qb->setFirstResult($offset); + } + + return $qb; + } + + /** + * Add the LEFT JOIN clauses for addresses, phones, and emails + * to the given query builder. + */ + private function addJoinClauses(\OCP\DB\QueryBuilder\IQueryBuilder $qb): void { + $qb->leftJoin('m', 'mv_addresses', 'a', $qb->expr()->eq('m.id', 'a.member_id')) + ->leftJoin('m', 'mv_phones', 'p', $qb->expr()->eq('m.id', 'p.member_id')) + ->leftJoin('m', 'mv_emails', 'e', $qb->expr()->eq('m.id', 'e.member_id')); + } + + /** + * Execute a query with joined sub-entities and parse the flat + * result set into nested member arrays. + * + * The query must SELECT all columns from the members table plus + * columns from mv_addresses, mv_phones, and mv_emails. Column + * name collision is handled by prefixing joined columns with + * aliases (a.*, p.*, e.*) which Doctrine maps as + * a_strasse, p_number_e164, etc. + * + * @throws Exception + */ + private function fetchWithRelations(\OCP\DB\QueryBuilder\IQueryBuilder $qb): array { + $result = $qb->executeQuery(); + + /** @var array $grouped */ + $grouped = []; + + while ($row = $result->fetch()) { + $id = (int)$row['id']; + if (!isset($grouped[$id])) { + // Build the base member record from the member columns. + $grouped[$id] = [ + 'id' => $id, + 'vorname' => $row['vorname'] ?? '', + 'nachname' => $row['nachname'] ?? '', + 'geburtsdatum' => $row['geburtsdatum'] ?? null, + 'geschlecht' => $row['geschlecht'] ?? null, + 'rolle' => $row['rolle'] ?? 'mitglied', + 'stufeId' => isset($row['stufe_id']) ? (int)$row['stufe_id'] : null, + 'eintritt' => $row['eintritt'] ?? '', + 'austritt' => $row['austritt'] ?? null, + 'status' => $row['status'] ?? 'aktiv', + 'allergienEncrypted' => $row['allergien_encrypted'] ?? null, + 'notizen' => $row['notizen'] ?? null, + 'zusatzNotizen' => $row['zusatz_notizen'] ?? null, + 'kvTyp' => $row['kv_typ'] ?? null, + 'kvName' => $row['kv_name'] ?? null, + 'familyId' => isset($row['family_id']) ? (int)$row['family_id'] : null, + 'frozenFeeRate' => $row['frozen_fee_rate'] ?? null, + 'calendarEventUri' => $row['calendar_event_uri'] ?? null, + 'contactVcardUri' => $row['contact_vcard_uri'] ?? null, + 'createdAt' => $row['created_at'] ?? '', + 'updatedAt' => $row['updated_at'] ?? '', + 'deletedAt' => $row['deleted_at'] ?? null, + 'einwilligungDatum' => $row['einwilligung_datum'] ?? null, + 'juleicaNummer' => $row['juleica_nummer'] ?? null, + 'juleicaAblaufdatum' => $row['juleica_ablaufdatum'] ?? null, + 'addresses' => [], + 'phones' => [], + 'emails' => [], + ]; + } + + // Parse sub-entities from the joined columns. + // A row with NULLs for all address columns means no address + // is joined for this row — we must not create an empty Address. + $addrId = $row['a_id'] ?? null; + if ($addrId !== null && $addrId !== '') { + $grouped[$id]['addresses'][] = [ + 'id' => (int)$addrId, + 'memberId' => (int)$row['a_member_id'], + 'label' => $row['a_label'] ?? null, + 'strasse' => $row['a_strasse'] ?? '', + 'plz' => $row['a_plz'] ?? '', + 'ort' => $row['a_ort'] ?? '', + 'land' => $row['a_land'] ?? 'Deutschland', + 'isPrimary' => (bool)$row['a_is_primary'], + ]; + } + + $phoneId = $row['p_id'] ?? null; + if ($phoneId !== null && $phoneId !== '') { + $grouped[$id]['phones'][] = [ + 'id' => (int)$phoneId, + 'memberId' => (int)$row['p_member_id'], + 'label' => $row['p_label'] ?? null, + 'numberE164' => $row['p_number_e164'] ?? '', + ]; + } + + $emailId = $row['e_id'] ?? null; + if ($emailId !== null && $emailId !== '') { + $grouped[$id]['emails'][] = [ + 'id' => (int)$emailId, + 'memberId' => (int)$row['e_member_id'], + 'label' => $row['e_label'] ?? null, + 'email' => $row['e_email'] ?? '', + ]; + } + } + + $result->closeCursor(); + + // Re-index to a sequential 0-based array while preserving order. + return array_values($grouped); + } } diff --git a/lib/Service/MemberService.php b/lib/Service/MemberService.php index d536cf6..895d8f8 100644 --- a/lib/Service/MemberService.php +++ b/lib/Service/MemberService.php @@ -186,77 +186,56 @@ class MemberService { /** * Find all members with optional pagination. * + * Uses a single query with LEFT JOINs to avoid the N+1 problem. + * * @return array[] * @throws Exception */ public function findAll(?int $limit = null, ?int $offset = null): array { - $members = $this->memberMapper->findAll($limit, $offset); - return array_map(function (Member $member) { - $id = $member->getId(); - $addresses = $this->addressMapper->findByMemberId($id); - $phones = $this->phoneMapper->findByMemberId($id); - $emails = $this->emailMapper->findByMemberId($id); - return $this->buildMemberResponse($member, $addresses, $phones, $emails); - }, $members); + return $this->memberMapper->findAllWithRelations($limit, $offset); } /** * Find members by family ID. * + * Uses a single query with LEFT JOINs to avoid the N+1 problem. + * * @return array[] * @throws Exception */ public function findByFamily(int $familyId): array { - $members = $this->memberMapper->findByFamily($familyId); - return array_map(function (Member $member) { - return $this->buildMemberResponse( - $member, - $this->addressMapper->findByMemberId($member->getId()), - $this->phoneMapper->findByMemberId($member->getId()), - $this->emailMapper->findByMemberId($member->getId()) - ); - }, $members); + return $this->memberMapper->findByFamilyWithRelations($familyId); } /** * Find members by status. * + * Uses a single query with LEFT JOINs to avoid the N+1 problem. + * * @return array[] * @throws Exception */ public function findByStatus(string $status): array { - $members = $this->memberMapper->findByStatus($status); - return array_map(function (Member $member) { - return $this->buildMemberResponse( - $member, - $this->addressMapper->findByMemberId($member->getId()), - $this->phoneMapper->findByMemberId($member->getId()), - $this->emailMapper->findByMemberId($member->getId()) - ); - }, $members); + return $this->memberMapper->findByStatusWithRelations($status); } /** * Search members by name. * + * Uses a single query with LEFT JOINs to avoid the N+1 problem. + * * @return array[] * @throws Exception */ public function search(string $query): array { - $members = $this->memberMapper->search($query); - return array_map(function (Member $member) { - return $this->buildMemberResponse( - $member, - $this->addressMapper->findByMemberId($member->getId()), - $this->phoneMapper->findByMemberId($member->getId()), - $this->emailMapper->findByMemberId($member->getId()) - ); - }, $members); + return $this->memberMapper->searchWithRelations($query); } /** * Find members with a birthday in the current month. * + * Uses a single query with LEFT JOINs to avoid the N+1 problem. + * * Part of Issue #34. * * @return array[] @@ -264,20 +243,14 @@ class MemberService { */ public function findByBirthdayThisMonth(): array { $currentMonth = (int)(new DateTime())->format('m'); - $members = $this->memberMapper->findByBirthdayMonth($currentMonth); - return array_map(function (Member $member) { - return $this->buildMemberResponse( - $member, - $this->addressMapper->findByMemberId($member->getId()), - $this->phoneMapper->findByMemberId($member->getId()), - $this->emailMapper->findByMemberId($member->getId()) - ); - }, $members); + return $this->memberMapper->findByBirthdayMonthWithRelations($currentMonth); } /** * Find members with unpaid fee records for the current year. * + * Uses a single query with LEFT JOINs to avoid the N+1 problem. + * * Part of Issue #34. * * @return array[] @@ -285,20 +258,14 @@ class MemberService { */ public function findWithUnpaidFees(): array { $currentYear = (int)(new DateTime())->format('Y'); - $members = $this->memberMapper->findWithUnpaidFees($currentYear); - return array_map(function (Member $member) { - return $this->buildMemberResponse( - $member, - $this->addressMapper->findByMemberId($member->getId()), - $this->phoneMapper->findByMemberId($member->getId()), - $this->emailMapper->findByMemberId($member->getId()) - ); - }, $members); + return $this->memberMapper->findWithUnpaidFeesWithRelations($currentYear); } /** * Find members matching a combination of filters (additive/AND logic). * + * Uses a single query with LEFT JOINs to avoid the N+1 problem. + * * @return array[] * @throws Exception */ @@ -308,43 +275,88 @@ class MemberService { bool $birthdayThisMonth = false, bool $unpaidFees = false ): array { - $members = $this->memberMapper->findFiltered($status, $rolle, $birthdayThisMonth, $unpaidFees); - return array_map(function (Member $member) { - return $this->buildMemberResponse( - $member, - $this->addressMapper->findByMemberId($member->getId()), - $this->phoneMapper->findByMemberId($member->getId()), - $this->emailMapper->findByMemberId($member->getId()) - ); - }, $members); + return $this->memberMapper->findFilteredWithRelations($status, $rolle, $birthdayThisMonth, $unpaidFees); } /** * Full-text search across member names, addresses, and notes. * Returns results with match context for display in search dropdown. * + * Uses a single query with LEFT JOINs to avoid the N+1 problem. + * * Part of Issue #33. * * @return array[] Members with sub-entities and matchContext field * @throws Exception */ public function fullTextSearch(string $query, int $limit = 20): array { - $members = $this->memberMapper->fullTextSearch($query, $limit); + $rawResults = $this->memberMapper->fullTextSearchWithRelations($query, $limit); $lowQuery = mb_strtolower($query); - return array_map(function (Member $member) use ($lowQuery) { - $id = $member->getId(); - $addresses = $this->addressMapper->findByMemberId($id); - $phones = $this->phoneMapper->findByMemberId($id); - $emails = $this->emailMapper->findByMemberId($id); - - $result = $this->buildMemberResponse($member, $addresses, $phones, $emails); + return array_map(function (array $row) use ($lowQuery): array { + // buildMatchContext expects an Address[] — the raw result + // stores addresses as arrays. Build minimal Address objects + // so the existing logic works without rewriting the method. + $addresses = array_map( + fn(array $a): Address => $this->arrayToAddress($a), + $row['addresses'] ?? [] + ); // Determine match context — which field matched - $result['matchContext'] = $this->buildMatchContext($member, $addresses, $lowQuery); + $row['matchContext'] = $this->buildMatchContext( + $this->arrayToMember($row), + $addresses, + $lowQuery + ); - return $result; - }, $members); + return $row; + }, $rawResults); + } + + /** + * Convert an associative array (from fetchWithRelations) to a Member entity. + */ + private function arrayToMember(array $row): Member { + $m = new Member(); + $m->setId($row['id']); + $m->setVorname($row['vorname']); + $m->setNachname($row['nachname']); + $m->setGeburtsdatum($row['geburtsdatum']); + $m->setGeschlecht($row['geschlecht']); + $m->setRolle($row['rolle']); + $m->setStufeId($row['stufeId']); + $m->setEintritt($row['eintritt']); + $m->setAustritt($row['austritt']); + $m->setStatus($row['status']); + $m->setAllergienEncrypted($row['allergienEncrypted']); + $m->setNotizen($row['notizen']); + $m->setZusatzNotizen($row['zusatzNotizen']); + $m->setKvTyp($row['kvTyp']); + $m->setKvName($row['kvName']); + $m->setFamilyId($row['familyId']); + $m->setFrozenFeeRate($row['frozenFeeRate']); + $m->setCalendarEventUri($row['calendarEventUri']); + $m->setContactVcardUri($row['contactVcardUri']); + $m->setEinwilligungDatum($row['einwilligungDatum']); + $m->setJuleicaNummer($row['juleicaNummer']); + $m->setJuleicaAblaufdatum($row['juleicaAblaufdatum']); + return $m; + } + + /** + * Convert an associative array (from fetchWithRelations) to an Address entity. + */ + private function arrayToAddress(array $row): Address { + $a = new Address(); + $a->setId($row['id']); + $a->setMemberId($row['memberId']); + $a->setLabel($row['label']); + $a->setStrasse($row['strasse']); + $a->setPlz($row['plz']); + $a->setOrt($row['ort']); + $a->setLand($row['land']); + $a->setIsPrimary($row['isPrimary']); + return $a; } /** diff --git a/tests/Unit/MemberServiceTest.php b/tests/Unit/MemberServiceTest.php index 97c9341..968eb9c 100644 --- a/tests/Unit/MemberServiceTest.php +++ b/tests/Unit/MemberServiceTest.php @@ -186,10 +186,10 @@ class MemberServiceTest extends TestCase { $member1 = $this->createMember(1, 'Max'); $member2 = $this->createMember(2, 'Anna', 'Schmidt'); - $this->memberMapper->method('findAll')->willReturn([$member1, $member2]); - $this->addressMapper->method('findByMemberId')->willReturn([]); - $this->phoneMapper->method('findByMemberId')->willReturn([]); - $this->emailMapper->method('findByMemberId')->willReturn([]); + $this->memberMapper->method('findAllWithRelations')->willReturn([ + $member1->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []], + $member2->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []], + ]); $result = $this->service->findAll(); @@ -200,7 +200,7 @@ class MemberServiceTest extends TestCase { public function testFindAllWithPagination(): void { $this->memberMapper->expects($this->once()) - ->method('findAll') + ->method('findAllWithRelations') ->with(10, 5) ->willReturn([]); @@ -208,7 +208,7 @@ class MemberServiceTest extends TestCase { } public function testFindAllReturnsEmptyArray(): void { - $this->memberMapper->method('findAll')->willReturn([]); + $this->memberMapper->method('findAllWithRelations')->willReturn([]); $result = $this->service->findAll(); $this->assertSame([], $result); @@ -225,9 +225,6 @@ class MemberServiceTest extends TestCase { $m->setId(1); return $m; }); - $this->addressMapper->method('findByMemberId')->willReturn([]); - $this->phoneMapper->method('findByMemberId')->willReturn([]); - $this->emailMapper->method('findByMemberId')->willReturn([]); $this->auditService->expects($this->once())->method('logCreate'); @@ -900,10 +897,9 @@ class MemberServiceTest extends TestCase { public function testSearchReturnsMatchingMembers(): void { $member = $this->createMember(1); - $this->memberMapper->method('search')->with('Max')->willReturn([$member]); - $this->addressMapper->method('findByMemberId')->willReturn([]); - $this->phoneMapper->method('findByMemberId')->willReturn([]); - $this->emailMapper->method('findByMemberId')->willReturn([]); + $this->memberMapper->method('searchWithRelations')->with('Max')->willReturn([ + $member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []], + ]); $result = $this->service->search('Max'); @@ -912,7 +908,7 @@ class MemberServiceTest extends TestCase { } public function testSearchReturnsEmptyForNoMatch(): void { - $this->memberMapper->method('search')->willReturn([]); + $this->memberMapper->method('searchWithRelations')->willReturn([]); $result = $this->service->search('Nonexistent'); $this->assertSame([], $result); @@ -922,14 +918,15 @@ class MemberServiceTest extends TestCase { public function testFullTextSearchReturnsResultsWithMatchContext(): void { $member = $this->createMember(1, 'Max', 'Mustermann'); - $address = $this->createAddress(1, 1); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]], + 'phones' => [], + 'emails' => [], + ]; - $this->memberMapper->method('fullTextSearch') + $this->memberMapper->method('fullTextSearchWithRelations') ->with('Max', 20) - ->willReturn([$member]); - $this->addressMapper->method('findByMemberId')->willReturn([$address]); - $this->phoneMapper->method('findByMemberId')->willReturn([]); - $this->emailMapper->method('findByMemberId')->willReturn([]); + ->willReturn([$memberData]); $result = $this->service->fullTextSearch('Max'); @@ -940,13 +937,14 @@ class MemberServiceTest extends TestCase { public function testFullTextSearchMatchesAddress(): void { $member = $this->createMember(1, 'Max', 'Mustermann'); - $address = $this->createAddress(1, 1); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstrasse 1', 'plz' => '12345', 'ort' => 'Musterstadt', 'land' => 'Deutschland', 'isPrimary' => true]], + 'phones' => [], + 'emails' => [], + ]; - $this->memberMapper->method('fullTextSearch') - ->willReturn([$member]); - $this->addressMapper->method('findByMemberId')->willReturn([$address]); - $this->phoneMapper->method('findByMemberId')->willReturn([]); - $this->emailMapper->method('findByMemberId')->willReturn([]); + $this->memberMapper->method('fullTextSearchWithRelations') + ->willReturn([$memberData]); $result = $this->service->fullTextSearch('Musterstrasse'); @@ -957,11 +955,13 @@ class MemberServiceTest extends TestCase { public function testFullTextSearchMatchesNotizen(): void { $member = $this->createMember(1, 'Max', 'Mustermann'); $member->setNotizen('Hat einen besonderen Wunsch fuer Training'); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [], + 'phones' => [], + 'emails' => [], + ]; - $this->memberMapper->method('fullTextSearch')->willReturn([$member]); - $this->addressMapper->method('findByMemberId')->willReturn([]); - $this->phoneMapper->method('findByMemberId')->willReturn([]); - $this->emailMapper->method('findByMemberId')->willReturn([]); + $this->memberMapper->method('fullTextSearchWithRelations')->willReturn([$memberData]); $result = $this->service->fullTextSearch('Training'); @@ -972,11 +972,13 @@ class MemberServiceTest extends TestCase { public function testFullTextSearchMatchesZusatzNotizen(): void { $member = $this->createMember(1, 'Max', 'Mustermann'); $member->setZusatzNotizen('Zusaetzliche Information'); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [], + 'phones' => [], + 'emails' => [], + ]; - $this->memberMapper->method('fullTextSearch')->willReturn([$member]); - $this->addressMapper->method('findByMemberId')->willReturn([]); - $this->phoneMapper->method('findByMemberId')->willReturn([]); - $this->emailMapper->method('findByMemberId')->willReturn([]); + $this->memberMapper->method('fullTextSearchWithRelations')->willReturn([$memberData]); // Search for something not in name or notizen, but in zusatzNotizen $result = $this->service->fullTextSearch('zusaetzliche'); @@ -991,7 +993,7 @@ class MemberServiceTest extends TestCase { $currentMonth = (int)(new \DateTime())->format('m'); $this->memberMapper->expects($this->once()) - ->method('findByBirthdayMonth') + ->method('findByBirthdayMonthWithRelations') ->with($currentMonth) ->willReturn([]); @@ -1000,10 +1002,9 @@ class MemberServiceTest extends TestCase { public function testFindByBirthdayThisMonthReturnsMembers(): void { $member = $this->createMember(1); - $this->memberMapper->method('findByBirthdayMonth')->willReturn([$member]); - $this->addressMapper->method('findByMemberId')->willReturn([]); - $this->phoneMapper->method('findByMemberId')->willReturn([]); - $this->emailMapper->method('findByMemberId')->willReturn([]); + $this->memberMapper->method('findByBirthdayMonthWithRelations')->willReturn([ + $member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []], + ]); $result = $this->service->findByBirthdayThisMonth(); @@ -1016,7 +1017,7 @@ class MemberServiceTest extends TestCase { $currentYear = (int)(new \DateTime())->format('Y'); $this->memberMapper->expects($this->once()) - ->method('findWithUnpaidFees') + ->method('findWithUnpaidFeesWithRelations') ->with($currentYear) ->willReturn([]); @@ -1025,10 +1026,9 @@ class MemberServiceTest extends TestCase { public function testFindWithUnpaidFeesReturnsMembers(): void { $member = $this->createMember(1); - $this->memberMapper->method('findWithUnpaidFees')->willReturn([$member]); - $this->addressMapper->method('findByMemberId')->willReturn([]); - $this->phoneMapper->method('findByMemberId')->willReturn([]); - $this->emailMapper->method('findByMemberId')->willReturn([]); + $this->memberMapper->method('findWithUnpaidFeesWithRelations')->willReturn([ + $member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []], + ]); $result = $this->service->findWithUnpaidFees(); @@ -1039,7 +1039,7 @@ class MemberServiceTest extends TestCase { public function testFindFilteredDelegatesToMapper(): void { $this->memberMapper->expects($this->once()) - ->method('findFiltered') + ->method('findFilteredWithRelations') ->with('aktiv', 'mitglied', true, false) ->willReturn([]); @@ -1048,7 +1048,7 @@ class MemberServiceTest extends TestCase { public function testFindFilteredNoFilters(): void { $this->memberMapper->expects($this->once()) - ->method('findFiltered') + ->method('findFilteredWithRelations') ->with(null, null, false, false) ->willReturn([]); @@ -1058,12 +1058,13 @@ class MemberServiceTest extends TestCase { public function testFindFilteredReturnsWithSubEntities(): void { $member = $this->createMember(1); - $address = $this->createAddress(1, 1); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]], + 'phones' => [], + 'emails' => [], + ]; - $this->memberMapper->method('findFiltered')->willReturn([$member]); - $this->addressMapper->method('findByMemberId')->with(1)->willReturn([$address]); - $this->phoneMapper->method('findByMemberId')->with(1)->willReturn([]); - $this->emailMapper->method('findByMemberId')->with(1)->willReturn([]); + $this->memberMapper->method('findFilteredWithRelations')->willReturn([$memberData]); $result = $this->service->findFiltered('aktiv'); @@ -1075,12 +1076,9 @@ class MemberServiceTest extends TestCase { public function testFindFilteredByRolleOnly(): void { $member = $this->createMember(1, rolle: 'erziehungsberechtigter'); - $this->memberMapper->method('findFiltered') + $this->memberMapper->method('findFilteredWithRelations') ->with(null, 'erziehungsberechtigter', false, false) - ->willReturn([$member]); - $this->addressMapper->method('findByMemberId')->willReturn([]); - $this->phoneMapper->method('findByMemberId')->willReturn([]); - $this->emailMapper->method('findByMemberId')->willReturn([]); + ->willReturn([$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []]]); $result = $this->service->findFiltered(null, 'erziehungsberechtigter'); @@ -1090,7 +1088,7 @@ class MemberServiceTest extends TestCase { public function testFindFilteredCombinedAllParams(): void { $this->memberMapper->expects($this->once()) - ->method('findFiltered') + ->method('findFilteredWithRelations') ->with('inaktiv', 'mitglied', true, true) ->willReturn([]); @@ -1252,10 +1250,9 @@ class MemberServiceTest extends TestCase { public function testFindByFamilyReturnsMembers(): void { $member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv', 'mitglied', 5); - $this->memberMapper->method('findByFamily')->with(5)->willReturn([$member]); - $this->addressMapper->method('findByMemberId')->willReturn([]); - $this->phoneMapper->method('findByMemberId')->willReturn([]); - $this->emailMapper->method('findByMemberId')->willReturn([]); + $this->memberMapper->method('findByFamilyWithRelations')->with(5)->willReturn([ + $member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []], + ]); $result = $this->service->findByFamily(5); @@ -1268,10 +1265,9 @@ class MemberServiceTest extends TestCase { public function testFindByStatusReturnsMembers(): void { $member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv'); - $this->memberMapper->method('findByStatus')->with('aktiv')->willReturn([$member]); - $this->addressMapper->method('findByMemberId')->willReturn([]); - $this->phoneMapper->method('findByMemberId')->willReturn([]); - $this->emailMapper->method('findByMemberId')->willReturn([]); + $this->memberMapper->method('findByStatusWithRelations')->with('aktiv')->willReturn([ + $member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []], + ]); $result = $this->service->findByStatus('aktiv'); @@ -1313,4 +1309,220 @@ class MemberServiceTest extends TestCase { $this->assertSame(1, $this->service->countArchived()); } + + // ── Joined methods: findAllWithRelations ────────────────────── + + public function testFindAllWithRelationsReturnsMemberWithNestedSubEntities(): void { + $member = $this->createMember(1, 'Max', 'Mustermann'); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [ + ['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true], + ['id' => 2, 'memberId' => 1, 'label' => 'Arbeit', 'strasse' => 'Büroweg 5', 'plz' => '12346', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => false], + ], + 'phones' => [ + ['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678'], + ], + 'emails' => [ + ['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com'], + ], + ]; + + $this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]); + + $result = $this->service->findAll(); + + $this->assertCount(1, $result); + $this->assertSame('Max', $result[0]['vorname']); + $this->assertCount(2, $result[0]['addresses']); + $this->assertCount(1, $result[0]['phones']); + $this->assertCount(1, $result[0]['emails']); + $this->assertSame('Hauptstr. 1', $result[0]['addresses'][0]['strasse']); + $this->assertSame('+4917612345678', $result[0]['phones'][0]['numberE164']); + $this->assertSame('max@example.com', $result[0]['emails'][0]['email']); + } + + public function testFindAllWithRelationsHandlesMemberWithoutSubEntities(): void { + $member = $this->createMember(1); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [], + 'phones' => [], + 'emails' => [], + ]; + + $this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]); + + $result = $this->service->findAll(); + + $this->assertCount(1, $result); + $this->assertCount(0, $result[0]['addresses']); + $this->assertCount(0, $result[0]['phones']); + $this->assertCount(0, $result[0]['emails']); + } + + // ── Joined methods: searchWithRelations ─────────────────────── + + public function testSearchWithRelationsReturnsMemberWithSubEntities(): void { + $member = $this->createMember(1, 'Max', 'Mustermann'); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]], + 'phones' => [], + 'emails' => [], + ]; + + $this->memberMapper->method('searchWithRelations')->with('Max')->willReturn([$memberData]); + + $result = $this->service->search('Max'); + + $this->assertCount(1, $result); + $this->assertSame('Max', $result[0]['vorname']); + $this->assertCount(1, $result[0]['addresses']); + } + + // ── Joined methods: findByFamilyWithRelations ───────────────── + + public function testFindByFamilyWithRelationsReturnsMemberWithSubEntities(): void { + $member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv', 'mitglied', 5); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]], + 'phones' => [['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678']], + 'emails' => [['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com']], + ]; + + $this->memberMapper->method('findByFamilyWithRelations')->with(5)->willReturn([$memberData]); + + $result = $this->service->findByFamily(5); + + $this->assertCount(1, $result); + $this->assertSame('Max', $result[0]['vorname']); + $this->assertCount(1, $result[0]['addresses']); + $this->assertCount(1, $result[0]['phones']); + $this->assertCount(1, $result[0]['emails']); + } + + // ── Joined methods: findByStatusWithRelations ───────────────── + + public function testFindByStatusWithRelationsReturnsMemberWithSubEntities(): void { + $member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv'); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [], + 'phones' => [], + 'emails' => [], + ]; + + $this->memberMapper->method('findByStatusWithRelations')->with('aktiv')->willReturn([$memberData]); + + $result = $this->service->findByStatus('aktiv'); + + $this->assertCount(1, $result); + $this->assertSame('Max', $result[0]['vorname']); + } + + // ── Joined methods: findByBirthdayMonthWithRelations ────────── + + public function testFindByBirthdayMonthWithRelationsReturnsMemberWithSubEntities(): void { + $currentMonth = (int)(new \DateTime())->format('m'); + $member = $this->createMember(1, 'Max', 'Mustermann', '2010-06-15', '2020-01-01'); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [], + 'phones' => [], + 'emails' => [], + ]; + + $this->memberMapper->expects($this->once()) + ->method('findByBirthdayMonthWithRelations') + ->with($currentMonth) + ->willReturn([$memberData]); + + $result = $this->service->findByBirthdayThisMonth(); + + $this->assertCount(1, $result); + $this->assertSame('Max', $result[0]['vorname']); + } + + // ── Joined methods: findWithUnpaidFeesWithRelations ─────────── + + public function testFindWithUnpaidFeesWithRelationsReturnsMemberWithSubEntities(): void { + $member = $this->createMember(1, 'Max', 'Mustermann'); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [], + 'phones' => [], + 'emails' => [], + ]; + + $this->memberMapper->method('findWithUnpaidFeesWithRelations')->willReturn([$memberData]); + + $result = $this->service->findWithUnpaidFees(); + + $this->assertCount(1, $result); + $this->assertSame('Max', $result[0]['vorname']); + } + + // ── Joined methods: findFilteredWithRelations ───────────────── + + public function testFindFilteredWithRelationsReturnsMemberWithSubEntities(): void { + $member = $this->createMember(1, 'Max', 'Mustermann'); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]], + 'phones' => [], + 'emails' => [], + ]; + + $this->memberMapper->method('findFilteredWithRelations') + ->with('aktiv', 'mitglied', false, false) + ->willReturn([$memberData]); + + $result = $this->service->findFiltered('aktiv', 'mitglied'); + + $this->assertCount(1, $result); + $this->assertSame('Max', $result[0]['vorname']); + $this->assertCount(1, $result[0]['addresses']); + } + + // ── Joined methods: fullTextSearchWithRelations ─────────────── + + public function testFullTextSearchWithRelationsReturnsResultsWithMatchContext(): void { + $member = $this->createMember(1, 'Max', 'Mustermann'); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstrasse 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]], + 'phones' => [], + 'emails' => [], + ]; + + $this->memberMapper->method('fullTextSearchWithRelations') + ->with('Musterstrasse', 20) + ->willReturn([$memberData]); + + $result = $this->service->fullTextSearch('Musterstrasse'); + + $this->assertCount(1, $result); + $this->assertArrayHasKey('matchContext', $result[0]); + $this->assertStringContainsString('Adresse:', $result[0]['matchContext']); + $this->assertStringContainsString('Musterstrasse 1', $result[0]['matchContext']); + } + + // ── Backward compatibility: findAll shape ───────────────────── + + public function testFindAllReturnsSameShapeAsBefore(): void { + // The new method returns the same shape as the old one: + // an array of member arrays with addresses/phones/emails sub-arrays + $member = $this->createMember(1, 'Max'); + $memberData = $member->jsonSerialize() + [ + 'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]], + 'phones' => [['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678']], + 'emails' => [['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com']], + ]; + + $this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]); + + $result = $this->service->findAll(); + + // Verify the shape matches what the frontend expects + $this->assertIsArray($result[0]); + $this->assertArrayHasKey('id', $result[0]); + $this->assertArrayHasKey('vorname', $result[0]); + $this->assertArrayHasKey('nachname', $result[0]); + $this->assertArrayHasKey('addresses', $result[0]); + $this->assertArrayHasKey('phones', $result[0]); + $this->assertArrayHasKey('emails', $result[0]); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 922e26f..747b713 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -19,4 +19,30 @@ if (!class_exists('OCA\DAV\CalDAV\CalDavBackend')) { eval('namespace OCA\DAV\CalDAV; class CalDavBackend {}'); } -require_once __DIR__ . '/../vendor/autoload.php'; +// Try the app's vendor autoload first (full local dev). +// Fall back to Nextcloud's root autoloader which has PSR packages. +$appAutoload = __DIR__ . '/../vendor/autoload.php'; +$ncAutoload = '/var/www/html/lib/composer/autoload.php'; + +if (file_exists($appAutoload)) { + require_once $appAutoload; +} elseif (file_exists($ncAutoload)) { + require_once $ncAutoload; +} + +// Register the app's lib directory for PSR-4 autoloading. +// This ensures OCA\Mitgliederverwaltung\* classes are loaded regardless +// of which vendor/autoload.php was used (full local dev vs. container). +spl_autoload_register(function ($class) { + $prefix = 'OCA\\Mitgliederverwaltung\\'; + $baseDir = __DIR__ . '/../lib/'; + $len = strlen($prefix); + if (strncmp($prefix, $class, $len) !== 0) { + return; + } + $relativeClass = substr($class, $len); + $file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php'; + if (file_exists($file)) { + require $file; + } +});